ThreadLocal原理解读

前言

多线程环境中经常会发生因为资源竞争而出现的线程安全问题,通常情况下我们使用加锁的方式解决这类问题。但是除了控制资源的访问外,我们还可以增加资源来保证所有对象的线程安全。例如一个班级30个人填写个人信息表,如果只有一支笔大家都会哄抢,谁个填不完。但是从另一个角度出发,一支笔的成本也不大,人手一支笔的话,很快所有人都能填完表格。

线程安全案例

时间类在我们日常开发中是经常会用到的,虽然JDK为我们封装了很强大的SimpleDateFormat类,但是如果稍不留心的话就有可能导致很大的问题,我们先来看一个简单的例子:

public class 没有ThreadLocal的场景 {
    
    

    private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static class ParseDate implements Runnable {
    
    

        private int i;

        public ParseDate(int i) {
    
    
            this.i = i;
        }

        @Override
        public void run() {
    
    
            try {
    
    
                Date t = sdf.parse("2019-07-16 20:40:" + i % 60);
                System.out.println(Thread.currentThread().getName() + " : " + t);
            } catch (Exception e) {
    
    
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
    
    
        ExecutorService es = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 100; i++) {
    
    
            es.execute(new ParseDate(i));
        }
    }

}

这个例子很简单,就是创建了一个线程池,使用SimpleDateFormat对象实例来解析字符串的日志。执行后却发现却抛异常了如下:

java.lang.NumberFormatException: For input string: ""
	at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)

这个异常的本质其实就是SimpleDateFormat.parse()方法并不是线程安全的,在多个线程中共享此变量必然会出现这个错误。

面对线程安全的问题,一种万金油的解决方式就是加锁,当然这里依然可以选择加锁,我们再sdf.parse()前后加锁,当然可以达到我们的目的。或者说SimpleDateFormat既然会有线程安全问题,我们干脆不设置成静态的而是每个线程运行的时候去new,这样也是可以的。但是这样的解决方式并不优雅,如果对每个方法调用的时候我们都要去重新的new SimpleDateFormat(),当我们的日期格式不想要yyyy-MM-dd HH:mm:ss想换成yyyy-MM-dd,我们需要对所有的方法内部进行替换,没有静态变量来的方便。所以这时候就需要借助ThreadLocal来帮我们实现。

ThreadLocal简单使用

ThreadLocal字面上翻译是线程局部变量,它用于存放当前线程的一些变量,既然只有当前线程可以访问,那么自然就是线程安全的了。上述代码我们使用ThreadLocal进行如下改造:

public class 使用ThreadLocal {
    
    

    private static final ThreadLocal<SimpleDateFormat> tl = new ThreadLocal<SimpleDateFormat>();

    public static class ParseDate implements Runnable {
    
    

        int i = 0;

        public ParseDate(int i) {
    
    
            this.i = i;
        }

        @Override
        public void run() {
    
    
            try {
    
    
                if (tl.get() == null) {
    
    
                    tl.set(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
                }
                Date t = tl.get().parse("2019-07-16 20:40:" + i % 60);
                System.out.println(Thread.currentThread().getName() + " : " + t);
            } catch (Exception e) {
    
    
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
    
    
        ExecutorService es = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 100; i++) {
    
    
            es.execute(new ParseDate(i));
        }
    }

}

执行程序后发现错误解决了,从这里就可以看出使用了ThreadLocal后每个线程独享一份SimpleDateFormat对象,避免了在方法内频繁的创建对象,同时也避免了多线程的竞争。

ThreadLocal实现原理

ThreadLocal到底是如何做到每个线程独享对变量的独享的?首先ThreadLocal这个对象是存放于ThreadLocalMap中的,而ThreadLocalMap是定于在代表线程对象Thread内中的:

public class Thread implements Runnable {
    
    
    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;
}

ThreadLocalMap结构

ThreadLocalMap是一个自定义的Map,用于存放线程ThreadLocal变量。它的操作仅限于在ThreadLocal类中,不能对外暴露。我们来看一下ThreadLocalMap的结构。

public class Thread implements Runnable {
    
    

  static class ThreadLocalMap {
    
    

    static class Entry extends WeakReference<ThreadLocal<?>> {
    
    
      	// 与当前ThreadLocal相关的对象
        Object value;
        Entry(ThreadLocal<?> k, Object v) {
    
    
            super(k);
            value = v;
        }
    }
    
    // 初始容量
    private static final int INITIAL_CAPACITY = 16;
		
    // 存放信息的数组
    private Entry[] table;
		
    // 当前容器大小
    private int size = 0;
		
    // 当容量到达阈值就会进行扩容
    private int threshold; // Default to 0
		
    // 设置阈值threshold为数组长度的2/3
    private void setThreshold(int len) {
    
    
      threshold = len * 2 / 3;
    }
  
    // ThreadLocalMap的构造器,可以看出key是经过ThreadLocal内部一个变量threadLocalHashCode
    // 计算而来的一个索引位置,稍后详解
    ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    
    
        table = new Entry[INITIAL_CAPACITY];
        int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
        table[i] = new Entry(firstKey, firstValue);
        size = 1;
        setThreshold(INITIAL_CAPACITY);
    }
}

从上述信息我们大概可以清楚了,当创建一个ThreadLocalMap时,实际上内部是构建了一个Entry类型的数组,Entry是类似Map的Key-Value结构的,Key是根据当前ThreadLocal计算来了一个hashCode,Value就是要保存的线程变量的副本(如上文中的SimpleDateFormat)。key初始化大小为16,阈值threshold为数组长度的2/3,Entry类型为,有一个弱引用指向ThreadLocal对象。

所以每个Thread内部都维护这一个类似Map(虽然不是,但是可以简单的认为是HashMap),当我们创建一个ThreadLocal后,实际上是把当前的ThreadLocal信息存放到Thread内部所维护的ThreadLocalMap中。ThreadLocalMap是对当前线程中所有的方法都开放的,所以当就做到了每个线程共享,接下来进行详细分析。

ThreadLocal详解

  • 首先看一下set()方法的源码:
public void set(T value) {
    
    
		Thread t = Thread.currentThread();
  	// 获取当前线程的ThreadLocalMap
		ThreadLocalMap map = getMap(t);
		if (map != null)
      	// 如果Map不为null,就把当前ThreadLocal和Value添加到Map中
				map.set(this, value);
		else
      	// 如果当前Map为null,就创建一个ThreadLocalMap保存到当前线程内部
			  createMap(t, value);
}

void createMap(Thread t, T firstValue) {
    
    
	  t.threadLocals = new ThreadLocalMap(this, firstValue);
}

ThreadLocalMap getMap(Thread t) {
    
    
  	return t.threadLocals;
}

其实源码也非常简单,无非就是获取ThreadLocalMap对象,如果这个对象不存在就创建ThreadLocalMap,如果存在就将ThreadLocalMap写入Map。其中,Key为ThreadLocal当前对象,Value就是我们需要的值。

再上文中我们清楚了ThreadLocalMap其实就是一个Entry类型的数组,任何Map都要解决的的就是哈希冲突。其中int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);的i是ThreadLocal存放在ThreadLocalMap中的索引位置,然后threadLocalHashCode的具体细节:


private final int threadLocalHashCode = nextHashCode();

private static int nextHashCode() {
    
    
    return nextHashCode.getAndAdd(HASH_INCREMENT);
}

public final int getAndAdd(int delta) {
    
    
  	return unsafe.getAndAddInt(this, valueOffset, delta);
}

也就是说,每一个ThreadLocal都会根据nextHashCode生成一个int值,作为哈希值。然后根据这个哈希值和数组的长度len-1(因为len的长度总是2的倍数,减一的话就可以保证低N位都是1)进行求和,从而获取哈希值的低N位,从而获取再数组中的索引位置。

如何解决哈希冲突

我们熟悉的HashMap发生哈希冲突我们都很熟悉了,通过链表或者红黑树进行解决,而ThreadLocalMap它本身就是一个很简单Entry数组,并不像HashMap具有那么复杂的数据结构,那么ThreadLocalMap是如何解决的呢?

private void set(ThreadLocal<?> key, Object value) {
    
    

    ThreadLocal.ThreadLocalMap.Entry[] tab = table;
    int len = tab.length;
  
  	// 求索引位置
    int i = key.threadLocalHashCode & (len-1);
		
  	// 如果要存放的i位置有数据,就说明发生了哈希冲突
    for (ThreadLocal.ThreadLocalMap.Entry e = tab[i]; 
         e != null;
         e = tab[i = nextIndex(i, len)]) {
    
    
        ThreadLocal<?> k = e.get();
				
        // 如果是同一个ThreadLocal对象,就直接覆盖
        if (k == key) {
    
    
            e.value = value;
            return;
        }
				
        // 如果key为null,则替换它的位置
        if (k == null) {
    
    
            replaceStaleEntry(key, value, i);
            return;
        }
      
        // 否则就nextIndex(i, len),去找下一个位置
    }

    tab[i] = new ThreadLocal.ThreadLocalMap.Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

如果发生哈希冲突主要就是判断当前位置是否可以替换,如果不可以替换就往后移动一位,继续判断。

private static int nextIndex(int i, int len) {
    
    
    return ((i + 1 < len) ? i + 1 : 0);
}
  • 接下来我们看get()方法
public T get() {
    
    
    Thread t = Thread.currentThread();
    // 获取当前线程的ThreadLocalMap
    ThreadLocal.ThreadLocalMap map = getMap(t);
    if (map != null) {
    
    
      	// 从Map中获取当Entry
        ThreadLocal.ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
    
    
            // 如果不为null,则返回value
            T result = (T)e.value;
            return result;
        }
    }
    // Map不存在或者找不到value值,则调用setInitialValue,进行初始化
    return setInitialValue();
}

private T setInitialValue() {
    
    
    // 获取初始化值,当然我们通过覆盖initialValue()方法可以设置自己想要的值
    T value = initialValue();
    Thread t = Thread.currentThread();
    // 获取当前线程的ThreadLocalMap
    ThreadLocal.ThreadLocalMap map = getMap(t);
    if (map != null)
        // 如果不为null,则设置值
        map.set(this, value);
    else
        // 如果当前Map为null,就创建一个ThreadLocalMap保存到当前线程内部
        createMap(t, value);
    return value;
}

protected T initialValue() {
    
    
    return null;
}

总结

每个线程内部都有一个ThreadLocalMap的Map,当线程需要添加ThreadLocal对象时,都是保存到代表每个线程私有变量的ThreadLocalMap中,所以线程与线程间不会互相干扰。如下图:
在这里插入图片描述

ThreadLocal内存泄露问题

再了解了ThreadLocal的内部实现后,我们就会发现那些TheadLocal变量是维护在Thread类内部的,这也意味着只要线程不退出的话,对象的引用就一直存在。

ThreadLocal内也有一个常见的坑,如果使用不当的话就会发生内存泄漏,来看一个例子。

public class ThreadLocal内存溢出 {
    
    

    public static void main(String[] args) {
    
    

        new Thread(() -> {
    
    

            for (int i = 0; i < 1000; i++) {
    
    
                TestClass t = new TestClass(i);
                t.printId();
                t = null;
                // 如果换成这一句就不会发生问题了
                //t.threadLocal.remove();
            }

        }).start();


    }

    static class TestClass{
    
    
       private int id;
       private int[] arr;
       private ThreadLocal<TestClass> threadLocal;

       TestClass(int id){
    
    
            this.id = id;
            arr = new int[1000000];
            threadLocal = new ThreadLocal<>();
            threadLocal.set(this);
       }

       public void printId(){
    
    
             System.out.println(threadLocal.get().id);
       }
   }

}

调用t = null后,虽然无法再通过t访问内存地址,但是当前线程依然存活,可以通过thread指向的内存地址访问到Thread对象从而访问ThreadLocalMap对象,访问到value指向的内存空间,访问arr指向的内存空间,从而导致java垃圾回收并不会回收int[1000000]这一片空间,久而久之不被回收的空间越来越大,最后抛出java.lang.OutOfMemoryError: Java heap space。

如果我们稍加改进将t = null;换成t.threadLocal.remove();就可以完美的解决问题呢,首先来看看t.threadLocal.remove()干了些什么:

public void remove() {
    
    
    ThreadLocal.ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        m.remove(this);
}

private void remove(ThreadLocal<?> key) {
    
    
    // 获取线程中的Entry数组
    ThreadLocal.ThreadLocalMap.Entry[] tab = table;
    int len = tab.length;
    // 计算当前ThreadLocal变量存放索引位置
    int i = key.threadLocalHashCode & (len-1);
    for (ThreadLocal.ThreadLocalMap.Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
    
    
        // 如果找到就进行清楚
        if (e.get() == key) {
    
    
            e.clear();
            expungeStaleEntry(i);
            return;
        }
    }
}

看完之后就恍然大悟,调用remove方法后就讲referent和value都被设置为null,这样就造成了内存不大可达,java垃圾回收就会回收这片内存,从而就不会导致内存泄漏。

Entry为什么要是WeakReference类型

ThreadLocalMap的实现使用了弱引用。弱引用是比强引用弱的多的引用。只要强引用存在,垃圾收集器就永远不会收集掉被引用的对象。当内存空间不足的时候,Java垃圾回收程序发现对象有一个强引用,宁愿抛出OutofMemory错误,也不会去回收一个强引用的内存空间。软引用是万不得已的时候才抛弃,而弱引用是当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被软引用关联的对象。ThreadLocalMap的内部由一系列的Entry结构,没一个Entry都是WeakReference类型的

static class Entry extends WeakReference<ThreadLocal<?>> {
    
    
  	// 与当前ThreadLocal相关的对象
    Object value;
    Entry(ThreadLocal<?> k, Object v) {
    
    
        super(k);
        value = v;
    }
}

这里的Key是ThreadLocal实例,作为软引用来使用。所以,虽然ThreadLocal作为Map的Key,但是实际上,它并没有真正持有ThreadLocal的引用。而当ThreadLocal的外部强引用被回收时,ThreadLocalMap中的Key就会变成null。当系统进行ThreadlocalMap清理时,就会将这些垃圾数据进行回收。

猜你喜欢

转载自blog.csdn.net/qq_25448409/article/details/96780095