之前在看java guideThreadLocal的相关内容时,看的云里雾里,当时只知道和ThreadLocal底层使用的ThreadLocalMapEntry的弱引用有关,甚至天真的以为ThreadLocal内存泄漏的原因是弱引用(面试被问到也这么答了/(ㄒoㄒ)/~~😓),今天认真看了一下ThreadLocalMap的源码,才发现并不是这样,而且网上很多错误回答也的确误导了当时的我,究其原因还是自己对于知识的掌握太过心切,欲速则不达,下面就来好好复盘一下这个问题

ThreadLocal用法

首先要知道Thread 类中有一个 threadLocals 和 一个 inheritableThreadLocals 变量,它们都是 ThreadLocalMap 类型的变量,可以把 ThreadLocalMap 理解为ThreadLocal 类实现的定制化的 HashMap:

1
2
3
//Thread类中有两个ThreadLocalMap类型的变量
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

默认情况下这两个变量都是 null,只有当前线程调用 ThreadLocal 类的 setget方法时才创建它们,即当前线程调用这两个方法的时候,实际上调用的是静态内部类ThreadLocalMap中对应的 get()set()方法

这篇文章重点在于ThreadLocal的内存泄漏问题,关于ThreadLocal的定义与使用,就简单的回顾一下,先来看一个声明了多个ThreadLocal变量的例子(借助ChatGPT):

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
31
32
public class ThreadLocalExample {
private static ThreadLocal<String> threadLocal1 = new ThreadLocal<>();
private static ThreadLocal<Integer> threadLocal2 = new ThreadLocal<>();

public static void main(String[] args) {
threadLocal1.set("Hello");
threadLocal2.set(42);

Thread thread1 = new Thread(() -> {
threadLocal1.set("Thread 1");
threadLocal2.set(10);

System.out.println("Thread 1 - ThreadLocal1: " + threadLocal1.get());
System.out.println("Thread 1 - ThreadLocal2: " + threadLocal2.get());
});

Thread thread2 = new Thread(() -> {
threadLocal1.set("Thread 2");
threadLocal2.set(20);

System.out.println("Thread 2 - ThreadLocal1: " + threadLocal1.get());
System.out.println("Thread 2 - ThreadLocal2: " + threadLocal2.get());
});

thread1.start();
thread2.start();

System.out.println("Main Thread - ThreadLocal1: " + threadLocal1.get());
System.out.println("Main Thread - ThreadLocal2: " + threadLocal2.get());
}
}

在这个例子中,我们声明了两个ThreadLocal变量(属于类):threadLocal1threadLocal2,每个ThreadLocal变量都与当前线程的ThreadLocalMap实例相关联

首先,我们来分析主线程的ThreadLocalMap情况:

1
2
3
Main Thread:
ThreadLocal1 -> "Hello"
ThreadLocal2 -> 42

主线程的ThreadLocalMap中存储了两个键值对,分别是ThreadLocal1 -> "Hello"ThreadLocal2 -> 42。这些值是在main方法中通过threadLocal1.set("Hello")threadLocal2.set(42)设置的

接下来,我们分析thread1线程的ThreadLocalMap情况:

1
2
3
Thread 1:
ThreadLocal1 -> "Thread 1"
ThreadLocal2 -> 10

thread1线程的ThreadLocalMap中存储了两个键值对,分别是ThreadLocal1 -> "Thread 1"ThreadLocal2 -> 10。这些值是在thread1的线程体内通过threadLocal1.set("Thread 1")threadLocal2.set(10)设置的

最后,我们分析thread2线程的ThreadLocalMap情况:

1
2
3
4
Thread 2:
ThreadLocal1 -> "Thread 2"
ThreadLocal2 -> 20

thread2线程的ThreadLocalMap中也存储了两个键值对,分别是ThreadLocal1 -> "Thread 2"ThreadLocal2 -> 20。这些值是在thread2的线程体内通过threadLocal1.set("Thread 2")threadLocal2.set(20)设置的。

由上面可以看出,每个线程独立地管理其自己的ThreadLocalMap,因此每个线程对ThreadLocal变量的更改不会影响其他线程的ThreadLocal变量,这样可以确保在多线程环境下,每个线程都具有独立的上下文信息

Java 引用

Java数据类型只有两种:基本类型和引用类型,例如:

1
Object obj=new Object();

obj就是引用类型,指向堆中的实例对象

而Java为了避免出现OutOfMemoryError错误,会执行Garbage Collection来不定时回收无任何对象引用的对象占据的内存空间,JVM如何找到需要回收的对象,方式有两种:

  • 引用计数法:每个对象有一个引用计数属性,新增一个对该对象的引用时计数器加1,引用释放时计数减1,计数为0时则可以进行垃圾回收
  • 可达性分析法:从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的,那么虚拟机就判断是可回收对象

JDK 1.2版本开始,对象的引用被划分为4种级别,从而使程序能更加灵活地控制对象的生命周期。这4种级别由高到低依次为:强引用软引用弱引用虚引用

  1. 强引用 ** 是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器**绝不会回收它。如下:
1
Object strongReference = new Object();

如果强引用对象不使用时,需要弱化从而使GC能够回收,显式地设置strongReference指向为null,则JVM认为该对象不存在引用,这时就可以回收这个对象:

1
strongReference = null;

如果在一个方法的内部有一个强引用,这个引用保存在Java中,而真正的实例(Object)保存在Java中。 当这个方法运行完成后,就会退出方法栈,则引用对象的引用数0,这个对象会被回收

1
2
3
4
public void test() {
Object strongReference = new Object();
// 省略其他操作
}

但是如果这个strongReference全局变量时,就需要在不用这个对象时赋值为null,因为强引用不会被垃圾回收!

  1. 软引用 如果一个对象只具有软引用,则内存空间充足时,垃圾回收器不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用,对比一下强引用和软引用,软引用需要借助SoftReference
1
2
3
4
5
6
// 强引用
String strongReference = new String("abc");
// 软引用
String str = new String("abc");
SoftReference<String> softReference = new SoftReference<String>(str);

软引用可以和一个引用队列(ReferenceQueue)联合使用。如果软引用所引用对象被垃圾回收JAVA虚拟机就会把这个软引用加入到与之关联的引用队列

1
2
3
4
5
6
7
8
9
10
11
12
ReferenceQueue<String> referenceQueue = new ReferenceQueue<>();//引用队列
String str = new String("abc");
SoftReference<String> softReference = new SoftReference<>(str, referenceQueue);
str = null;
// Notify GC
/*
下面调用System.gc()方法只是起通知作用,JVM什么时候扫描回收对象是JVM自己的状态决定的,即使扫描到软引用对象也不一定会回收它,只有内存不够的时候才会回收
*/
System.gc();
System.out.println(softReference.get()); // abc
Reference<? extends String> reference = referenceQueue.poll();//弹出队列元素
System.out.println(reference); //null

即当内存不足时,JVM首先将软引用中的对象引用置为null,然后通知垃圾回收器进行回收,且垃圾收集线程会在JVM抛出OutOfMemoryError之前回收软引用对象,而且虚拟机会尽可能优先回收长时间闲置不用软引用对象(引入引用队列的原因)

  1. 弱引用 弱引用软引用的区别在于:只具有弱引用的对象拥有更短暂生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存
1
2
String str = new String("abc");
WeakReference<String> weakReference = new WeakReference<>(str);

弱引用同样会关联一个引用队列,如果弱引用所引用的对象垃圾回收Java虚拟机就会把这个弱引用加入到与之关联的引用队列中,WeakReference对象的生命周期基本由垃圾回收器决定,一旦垃圾回收线程发现了具有弱引用的对象,在下一次GC过程中就会对其进行回收,本文所要讨论的ThreadLocalMapEntryKey就是弱引用:

内存泄漏

不再会被使用的对象或者变量占用的内存不能被有效垃圾回收,就是内存泄露

1
2
private static ThreadLocal<String> threadLocal1 = new ThreadLocal<>();
threadLocal1.set("Hello");

观察以上代码:ThreadLocalMap在保存时,key—threadlocal1是一个弱引用,指向真正的堆中ThreadLocal实例,而value—"hello"为当前线程变量的副本,是强引用。key被设计成WeakReference弱引用,这就导致了一个问题:Entry中的value强引用、而key是弱引用,在JVM发生GCkey会被回收,而value由于是强引用不会,这样一来,ThreadLocalMap 中就会出现 keynullEntry,只有当前Thread线程退出以后,value的强引用链条才会断掉。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就产生了内存泄露

TheadLocal中内存泄漏是指TheadLocalMap中的Entry中的keynull,而value不为null。因为key为null导致value一直访问不到,而根据可达性分析,始终有threadRef->currentThread->threadLocalMap->entry->valueRef->valueMemory,导致垃圾回收在进行可达性分析时,value可达从而不会被回收掉,但是该value永远不能被访问到,这样就存在了内存泄漏

ThreadLocalMap 的实现中已经考虑了这种情况,在调用 set()get()remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后最好手动调用remove()方法,ThreadLocal在remove()的时候,会调用弱引用中的clear()方法:

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
//ThreaLocal中的remove方法
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null) {
m.remove(this);
}
}

//ThreaLocalMap中定义的remove方法
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null; //这个for循环其实就是为了找到key为null的entry
e = tab[i = nextIndex(i, len)]) {
if (e.refersTo(key)) {
e.clear(); //如果key为null,而对应的entry还未被回收,调用Reference中的clear()方法
expungeStaleEntry(i);
return;
}
}
}

//Reference中的clear方法
public void clear() {
clear0();
}
//native方法
private native void clear0();

可以看出来:

Thread类中包含变量ThreadLocalMap,因此ThreadLocalMapThread的生命周期是一样长,如果没有手动删除对应key,都会导致内存泄漏

但是使用弱引用可以多一层保障:ThreadLocal实例对应的key采用弱引用不会发生内存泄漏,对应的value在下一次ThreadLocalMap调用set(),get(),remove()的时候会经过内部判断从而被清除

因此,ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果当前线程没有消亡,且没有手动删除对应keynullentry就会导致内存泄漏,而不是因为key是弱引用!