Fork me on GitHub

Java ThreadLocal源码分析

ThreadLocal简介

  这个类在 java.lang包下
  ThreadLocal在Spring中发挥着巨大的作用,在管理Request作用域中的Bean、事务管理、任务调度、AOP等模块都出现了它的身影。Hibernate的session管理也有用到

看看JDK中的源码是怎么写的:
This class provides thread-local variables.
These variables differ from their normal counterparts in that each thread
that accesses one (via its {@code get} or {@code set} method) has its own,
independently initialized copy of the variable.
{@code ThreadLocal} instances are typically private static fields in classes
that wish to associate state with a thread (e.g., a user ID or Transaction ID).
  ThreadLocal类用来提供线程内部的局部变量。这种变量在多线程环境下访问(通过get或set方法访问)时能保证各个线程里的变量相对独立于其他线程内的变量。ThreadLocal实例通常来说都是private static类型的,用于关联线程和线程的上下文。

  可以总结为一句话:ThreadLocal的作用是提供线程内的局部变量,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或者组件之间一些公共变量的传递的复杂度。

应用实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class TestThreadLocal {
private static final ThreadLocal<Integer> value = new ThreadLocal<Integer>() {
@Override
protected Integer initialValue() {
return 0;
}
};
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
new Thread(new MyThread(i)).start();
}
}
static class MyThread implements Runnable {
private int index;
public MyThread(int index) {
this.index = index;
}
public void run() {
System.out.println("线程" + index + "的初始value:" + value.get());
for (int i = 0; i < 10; i++) {
value.set(value.get() + i);
}
System.out.println("线程" + index + "的累加value:" + value.get());
}
}
}

线程0的初始value:0
线程4的初始value:0
线程0的累加value:45
线程2的初始value:0
线程3的初始value:0
线程1的初始value:0
线程3的累加value:45
线程2的累加value:45
线程4的累加value:45
线程1的累加value:45
  可以看到,各个线程的value值是相互独立的,本线程的累加操作不会影响到其他线程的值,真正达到了线程内部隔离的效果。


深入解析ThreadLocal类

  最早期的ThreadLocal设计:每个ThreadLocal类创建一个Map,然后用线程的ID作为Map的key,实例对象作为Map的value,这样就能达到各个线程的值隔离的效果。
  ThreadLocal的设计思路:每个Thread维护一个ThreadLocalMap映射表,这个映射表的key是ThreadLocal实例本身,value是真正需要存储的Object。
优势:
  这样设计之后每个Map的Entry数量变小了:之前是Thread的数量,现在是ThreadLocal的数量,能提高很大的性能
  当Thread销毁之后对应的ThreadLocalMap也就随之销毁了,能减少内存使用量。

为什么不直接用线程id来作为ThreadLocalMap的key?
  这一点很容易理解,因为直接用线程id来作为ThreadLocalMap的key,无法区分放入ThreadLocalMap中的多个value。比如我们放入了两个字符串,你如何知道我要取出来的是哪一个字符串呢?
  而使用ThreadLocal作为key就不一样了,由于每一个ThreadLocal对象都可以由threadLocalHashCode属性唯一区分或者说每一个ThreadLocal对象都可以由这个对象的名字唯一区分(int i = key.threadLocalHashCode & (len-1);)

get()

  看一下具体ThreadLocal是如何实现, 主要是看get()方法

1
2
3
4
5
6
7
8
9
10
11
12
public T get() {
// 取得当前线程
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
// 注意:这里用的是this
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null)
return (T)e.value;
}
return setInitialValue();
}

1.getMap分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 是调用当期线程t,返回当前线程t中的一个成员变量threadLocals。
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
// java.lang.Thread类下, 实际上就是一个ThreadLocalMap
ThreadLocal.ThreadLocalMap threadLocals = null;
static class ThreadLocalMap {
// 继承了WeakReference,并且使用ThreadLocal作为键值。
static class Entry extends WeakReference<ThreadLocal> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal k, Object v) {
super(k);
value = v;
}
}
}

2.setInitialValue()方法分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private T setInitialValue() {
T value = initialValue(); // 该方法下方有分析
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
// 因为get的参数是this, 所以set的key也得是this
map.set(this, value);
else
createMap(t, value);
return value;
}
protected T initialValue() {
return null;
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}

  至此,可能大部分朋友已经明白了ThreadLocal是如何为每个线程创建变量的副本的:
  首先,在每个线程Thread内部有一个ThreadLocal.ThreadLocalMap类型的成员变量threadLocals,这个threadLocals就是用来存储实际的变量副本的,键值为当前ThreadLocal变量,value为变量副本(即T类型的变量)。
  初始时,在Thread里面,threadLocals为空,当通过ThreadLocal变量调用get()方法或者set()方法,就会对Thread类中的threadLocals进行初始化,并且以当前ThreadLocal变量为键值,以ThreadLocal要保存的副本变量为value,存到threadLocals。
  然后在当前线程里面,如果要使用副本变量,就可以通过get方法在threadLocals里面查找。

initialValue()方法

  该函数在调用get函数的时候会第一次调用,但是如果一开始就调用了set函数,则该函数不会被调用。
  通常该函数只会被调用一次,除非手动调用了remove函数之后又调用get函数,这种情况下,get函数中还是会调用initialValue函数。
  该函数是protected类型的,很显然是建议在子类重载该函数的,所以通常该函数都会以匿名内部类的形式被重载,以指定初始值,比如:

1
2
3
4
5
6
7
8
public class TestThreadLocal {
private static final ThreadLocal<Integer> value = new ThreadLocal<Integer>() {
@Override
protected Integer initialValue() {
return Integer.valueOf(1);
}
};
}

ThreadLocalMap及本身如何避免内存泄漏

  ThreadLocalMap是使用ThreadLocal的弱引用作为Key的
  ThreadLocalMap和WeakHashMap实现有点类似,也是利用了WeakReference来和GC建立关联,因为ThreadLocal对象被线程对象引用,如果一个线程的生命周期比较长,那可能会出现内存泄露的问题,ThreadLocalMap借助弱引用巧妙的解决了这个问题,源码如下所示:

1
2
3
4
5
6
7
8
9
10
11
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
//这里的Entry并不是一个链表,如果出现hash碰撞,会放到数组的下一个位置
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
}

  下图是本文介绍到的一些对象之间的引用关系图,实线表示强引用,虚线表示弱引用:

  然后网上就传言,ThreadLocal会引发内存泄露,他们的理由是这样的:如上图,ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用引用他,那么系统gc的时候,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:ThreadLocal Ref -> Thread -> ThreaLocalMap -> Entry -> value永远无法回收,造成内存泄露。
  实际是不会,该类已经处理了。

  从源码可以看到ThreadLocalMap的Entry继承自WeakReference, Entry中的key是一个弱引用,也就是说线程类持有了ThreadLocal对象的一个弱引用,当ThreadLocal对象没有其他强引用关联时,在GC时会把ThreadLocal对象回收掉,但是这里仅仅回收ThreadLocal对象,Entry对象和Value并没有回收,ThreadLocalMap里就可能存在很多Key为null的Entry,
  所以需要在调用map.getEntry()时对key为null的对象进行处理,源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private Entry getEntry(ThreadLocal<?> key) {
// set方法 int i = key.threadLocalHashCode & (len-1);
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
// 当前位置没有找到,可能Hash碰撞了
return getEntryAfterMiss(key, i, e);
}
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
while (e != null) {
ThreadLocal<?> k = e.get();
if (k == key)
// 找到了直接返回
return e;
if (k == null)
// 检测到有个ThreadLocal对象被回收了,这个时候去清理后面所有Key为null的Entry
expungeStaleEntry(i);
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}

  从源码中可以看到ThreadLocalMap清理掉key为Null的Entry的时机是根据ThreadLocal对象的hashCode去获取entry时发生了hash碰撞,并且下一个entry的key为null时才去清理,因为ThreadLocalMap的Entry和普通Map的不太一样,一般的Map是一个链表数组,而它的数组每个元素就是一个Entry,如果出现Hash碰撞了就放到数组的下一个位置,因此如果get的时候发现没有碰撞可以认为当前Map中的元素还不多,一旦检测到碰撞了并且下一个entry的key被回收了,就调用expungeStaleEntry()来释放ThreadLocal为null的那些entry,避免了内存泄露。

整理一下ThreadLocalMap的getEntry函数的流程:
  1.首先从ThreadLocal的直接索引位置(通过ThreadLocal.threadLocalHashCode & (len-1)运算得到)获取Entry e,如果e不为null并且key相同则返回e;
  2.如果e为null或者key不一致则向下一个位置查询,
    如果下一个位置的key和当前需要查询的key相等,则返回对应的Entry,
    否则,如果key值为null,则擦除该位置的Entry,否则继续向下一个位置查询.
  在这个过程中遇到的key为null的Entry都会被擦除,那么Entry内的value也就没有强引用链,自然会被回收。仔细研究代码可以发现,set操作也有类似的思想,将key为null的这些Entry都删除,防止内存泄露。
  但是光这样还是不够的,上面的设计思路依赖一个前提条件:要调用ThreadLocalMap的genEntry函数或者set函数。这当然是不可能任何情况都成立的,所以很多情况下需要使用者手动调用ThreadLocal的remove函数,手动删除不再需要的ThreadLocal,防止内存泄露。
  所以JDK建议将ThreadLocal变量定义成private static的,这样的话ThreadLocal的生命周期就更长,由于一直存在ThreadLocal的强引用,所以ThreadLocal也就不会被回收,也就能保证任何时候都能根据ThreadLocal的弱引用访问到Entry的value值,然后remove它,防止内存泄露。

ThreadLocal 内存自动回收

1.在 ThreadLocal 层面的内存回收:
  当线程死亡时,那么所有的保存在的线程局部变量就会被回收,其实这里是指线程Thread对象中的 ThreadLocal.ThreadLocalMap threadLocals 会被回收,这是显然的。
2.ThreadLocalMap 层面的内存回收:
  如果线程可以活很长的时间,并且该线程保存的线程局部变量有很多(也就是 Entry 对象很多),那么就涉及到在线程的生命期内如何回收 ThreadLocalMap 的内存了,不然的话,Entry对象越多,那么ThreadLocalMap 就会越来越大,占用的内存就会越来越多,所以对于已经不需要了的线程局部变量,就应该清理掉其对应的Entry对象。
  使用的方式是,Entry对象的key是WeakReference 的包装,当线程拥有的局部变量超过了容量的2/3(没有扩大容量时是16 * 2/3 = 10个),会涉及到ThreadLocalMap中Entry的回收,我们可以看到 ThreadLocalMap.set方法中有下面的代码:

1
2
3
// cleanSomeSlots 就是进行回收内存:将key值设置为null,可以回收该 Entry 对象
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();


ThreadLocal内存泄漏

  在定位JVM性能问题时可能会遇到内存泄露导致JVM OutOfMemory的情况。
  在使用Tomcat容器时如果设置了reloadable=”true”这个参数,在频繁热部署应用时也有可能会遇到内存溢出的情况。Tomcat的热部署原理是检测到WEB-INF/classes或者WEB-INF/lib目录下的文件发生了变更后会把应用先停止然后再启动,由于Tomcat默认给每个应用分配一个WebAppClassLoader,热替换的原理就是创建一个新的ClassLoader来加载类,由于JVM中一个类的唯一性由它的class文件和它的类加载器来决定,因此重新加载类可以达到热替换的目的。当热部署的次数比较多会导致JVM加载的类比较多,如果之前的类由于某种原因(比如内存泄露)没有及时卸载就可能导致永久代或者MetaSpace的OutOfMemory

  上文介绍了ThreadLocal的原理,每个线程有个ThreadLocalMap,如果线程的生命周期比较长可能会导致ThreadLocalMap里的Entry没法被回收,那ThreadLocal的那个对象就一直被线程持有强引用,由于实例对象会持有Class对象的引用,Class对象又会持有加载它的ClassLoader的引用,这样就会导致Class无法被卸载了,当加载的类足够多时就可能出现永久代或者MetaSpace的内存溢出,如果该类有大对象,比如有比较大的字节数组,会导致Java堆区的内存溢出。

在类使用完之后,满足下面的情形,会被卸载(方法区的回收):
  1.该类在堆中的所有实例都已被回收,即在堆中不存在该类的实例对象。
  2.加载该类的classLoader已经被回收。
  3.该类对应的Class对象没有任何地方可以被引用,通过反射访问不到该Class对象。
如果类满足卸载条件,JVM就在GC的时候,对类进行卸载,即在方法区清除类的信息。


synchronized 与 ThreadLocal的区别:

多线程安全性解决方案
  进行同步控制synchronized 效率降低 并发变同步(串行), 占用内存小,但速度慢
  使用ThreadLocal 本地线程 每个线程一个变量副本(各不相干),占用内存较大,但速度快


参考文章:
https://www.cnblogs.com/digdeep/p/4510875.html
https://www.ezlippi.com/blog/2017/12/threadlocal.html
https://www.ezlippi.com/blog/2017/12/java-memory-leak-example.html
https://www.zhihu.com/question/23089780
https://www.cnblogs.com/xzwblog/p/7227509.html
http://blog.51cto.com/lavasoft/51926

-----------------本文结束,感谢您的阅读-----------------