Fork me on GitHub

ThreadLocal

前言

ThreadLocal是一个本地线程副本变量存储工具类。主要用于将一个线程和该线程存放的副本对象做一个映射(Map),各个线程之间的变量互不干扰,在高并发场景下,可以实现无状态的调用,特别适用于各个线程依赖不同的变量值完成操作的场景。

正文

我们根据代码了解下ThreadLocal

在了解ThreadLocal之前,我们先带着几个问题:

  1. ThreadLocal的每个线程的私有变量保存在哪里?
  2. 大家说的关于ThreadLocal使用不当会发生内存泄露又是怎么回事?
  3. ThreadLocal弱引用导致内存泄露又是怎么回事?为什么要使用弱引用?
  4. 对于可能出现的内存泄露,ThreadLocal本身有哪些优化?我们编码时应该如何避免内存泄露?
  5. ThreadLocal的应用场景?

首先我们先来看下ThreadLocal的几个重要方法。

ThreadLocal的主要方法

ThreadLocal有三个重要方法,如下:

方法说明
public T get()该方法用于获取线程本地变量副本
public void set(T value)该方法用于设置线程本地变量副本
public void remove()该方法用于移除线程本地变量副本

三个方法的相关源码:

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
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}

由上面get()方法的源码可以看到,本地变量副本是由一个叫ThreadLocalMap的对象维护的,我们看一下getMap(t)方法。

1
2
3
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
1
2
3
4
5
6
7
8
9
public class Thread implements Runnable {
//.........部分代码略

/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;

//.........部分代码略
}

可以看到在Thread类里维护着一个ThreadLocalMap,该线程的本地变量副本就会存到这儿。

再来看下这个变量赋予初始值的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}

当我们通过set方法设置本地变量副本时,如果ThreadLocalMapnull,就会调用createMap将初始值放入。

而对于get方法,如果ThreadLocalMapnull,就会调用setInitialValue方法,最终调用createMap方法,此时初始值为null

我们继续看下ThreadLocalMap的相关源码。

ThreadLocalMap

ThreadLocalMap的主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;

Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
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的value来维护变量副本的,其key为ThreadLocal本身。

而且Entry的key为弱引用(WeakReference)

关于Java引用

Java中的引用按照引用强度不同分为四种,从强到弱依次为:强引用、软引用、弱引用和虚引用。

引用的强度,代表了对内存占用的能力大小,具体体现在GC的时候,会不会被回收,什么时候被回收。

强引用

我们一般很少提及它,但它无处不在。其实我们创建一个对象便是强引用,如StringBuffer buffer = new StringBuffer();

HotSpot JVM目前的垃圾回收算法一般默认是可达性算法,即在每一轮GC的时候,选定一些对象作为GC ROOT,然后以它们为根发散遍历,遍历完成之后,如果一个对象不被任何GC ROOT引用,那么它就是不可达对象,则在接下来的GC过程中很可能会被回收。

如果我们在垃圾回收时还有对buffer的引用,那么它便不会被垃圾回收器回收。

软引用

软引用是用来描述一些还有用但是并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收返回之后进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。JDK1.2之后提供了SoftReference来实现软引用。

相对于强引用,软引用在内存充足时可能不会被回收,在内存不够时会被回收。

弱引用

弱引用也是用来描述非必须的对象的,但它的强度更弱,被弱引用关联的对象只能生存到下一次GC发生之前,也就是说下一次GC就会被回收。JDK1.2之后,提供了WeakReference来实现弱引用。

虚引用

虚引用也成为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间造成影响,也无法通过虚引用来取得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是在这个对象被GC时收到一个系统通知。JDK1.2之后提供了PhantomReference来实现虚引用。

ThreadLocal内存模型

由上面内容,下面ThreadLocal的内存模型也是比较好理解的。

upload successful

  • 线程运行时,我们定义的ThreadLocal对象被初始化,存储在Heap,同时线程运行的栈区保存了指向该实例的引用,也就是图中的ThreadLocalRef

  • ThreadLocalset/get被调用时,虚拟机会根据当前线程的引用也就是CurrentThreadRef找到其对应在堆区的实例,然后查看其对用的ThreadLocalMap实例是否被创建,如果没有,则创建并初始化。

  • Map实例化之后,也就拿到了该ThreadLocalMap的句柄,然后如果将当前ThreadLocal对象作为key,进行存取操作。

  • 图中的虚线,表示keyThreadLocal实例的引用是个弱引用。

内存泄露分析

根据上面内容,我们可以知道 ThreadLocal是被ThreadLocalMap以弱引用的方式关联着,因此如果ThreadLocal没有被ThreadLocalMap以外的对象引用,则在下一次GC的时候,ThreadLocal实例就会被回收,那么此时ThreadLocalMap里的一组Entry的K就是null了,因此在没有额外操作的情况下,此处的V便不会被外部访问到,而且只要Thread实例一直存在,Thread实例就强引用着ThreadLocalMap,因此ThreadLocalMap就不会被回收,那么这里K为null的V就一直占用着内存。

因此发生内存泄露的条件是:

  • ThreadLocal没有被外部强引用;
  • ThreadLocal实例被回收;
  • 但是Thread实例一直存活,一直强引用着ThreadLocalMap,也就是说ThreadLocalMap也不会被GC。

也就是说,如果ThreadLocal使用不当,是有可能发生内存泄露的。

我们这里说的内存泄露,指的是开发者使用不当造成的,而非ThreadLocal本身的问题。

一个典型的例子就是线程池,如果我们在线程池的task里实例化了ThreadLocal对象,线程使用完后,回归线程池,但是本身并不会结束,但是task任务结束了,对ThreadLocal的强引用结束了,这时候在ThreadLocalMap中的value没有被任何清理机制有效清理。

我们可以模拟这种内存泄露情况,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ThreadLocalTest {
//创建一个定长线程池
private static final ExecutorService executorService = Executors.newFixedThreadPool(1);
public static void main(String[] args) throws Exception{
int a= 1;
do {
final int k = a;
executorService.submit(()->{
System.out.println("线程ID:" + Thread.currentThread().getId());
ThreadLocal<Integer> t = new ThreadLocal<>();
t.set(k);
System.out.println("Value:"+ t.get());
});
a+=1;
TimeUnit.SECONDS.sleep(1);
// MemoryMXBean mem = ManagementFactory.getMemoryMXBean();
// System.out.println("USED RAM:" + mem.getHeapMemoryUsage().getUsed() / 1024 / 1024 + "MB");
}while (a < 1000);
}
}

如上,我们模拟了一个长度为1的定长线程池(为了简化),这个线程池只有一个线程,我们在task里创建了ThreadLocal对象,当task结束后,实际Thread是还存活的。

我们通过debug模式,执行若干次,可以看到ThreadLocalMap里那些无用的value,如下图:

upload successful

这实际上就发生了内存泄露问题。

其实,我们调用ThreadLocal里提供的remove方法,变会完全解决这个问题。

upload successful

如上图,我们如果使用完后添加remove方法删除变量副本,可以看到无论运行多少次,也不会出现内存泄露问题。

不要觉得这个内存泄露条件自己不会碰到,实际上无论Http,数据库连接等都有线程池的概念,我们每一段代码如果使用ThreadLocal都可能成为task那段的一部分,使用不好就可能出现内存泄露问题。

因此在日常编码中一定要养成良好的编码习惯。

ThreadLocal的优化

如果上面那个内存泄露的例子我们多运行一段时间,跟着debug,会发现ThreadLocalMap并不会一直增长的。

如下图,可以看到一些无用数据会自动消失。

upload successful

这是因为ThreadLocal本身的优化,在ThreadLocalMapgetEntry方法里,我们可以看到如下方法getEntryAfterMiss

1
2
3
4
5
6
7
8
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
return getEntryAfterMiss(key, i, e);
}

是的,这个方法就是找不到Entry的处理方法,该方法代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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)
expungeStaleEntry(i);
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}

这个方法一个关键部分就是k == null时调用expungeStaleEntry方法,用来删除旧的Entry,代码如下:

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
33
34
35
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;

// expunge entry at staleSlot
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;

// Rehash until we encounter null
Entry e;
int i;
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;

// Unlike Knuth 6.4 Algorithm R, we must scan until
// null because multiple entries could have been stale.
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}

主要逻辑如下:

  1. 清理当前脏entry,即将其value引用置为null,并且将table[staleSlot]也置为nullvalue置为null后该value域变为不可达,在下一次gc的时候就会被回收掉,同时table[staleSlot]null后以便于存放新的entry;

  2. 从当前staleSlot位置向后环形(nextIndex)继续搜索,直到遇到哈希桶(tab[i])为null的时候退出;

  3. 若在搜索过程再次遇到脏entry,继续将其清除。

除了该方法外,我们在set方法里可以看到对脏entry的处理,如下:

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
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);

for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();

if (k == key) {
e.value = value;
return;
}

if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}

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

在该方法中针对脏entry做了这样的处理:

  1. 如果当前table[i]!=null的话说明hash冲突就需要向后环形查找,若在查找过程中遇到脏entry就通过replaceStaleEntry进行处理;

  2. 如果当前table[i]==null的话说明新的entry可以直接插入,但是插入后会调用cleanSomeSlots方法检测并清除脏entry

我们先来看下replaceStaleEntry方法。

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
private void replaceStaleEntry(ThreadLocal<?> key, Object value,int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;

// Back up to check for prior stale entry in current run.
// We clean out whole runs at a time to avoid continual
// incremental rehashing due to garbage collector freeing
// up refs in bunches (i.e., whenever the collector runs).
int slotToExpunge = staleSlot;
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len))
if (e.get() == null)
slotToExpunge = i;

// Find either the key or trailing null slot of run, whichever
// occurs first
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();

// If we find key, then we need to swap it
// with the stale entry to maintain hash table order.
// The newly stale slot, or any other stale slot
// encountered above it, can then be sent to expungeStaleEntry
// to remove or rehash all of the other entries in run.
if (k == key) {
e.value = value;

tab[i] = tab[staleSlot];
tab[staleSlot] = e;

// Start expunge at preceding stale entry if it exists
if (slotToExpunge == staleSlot)
slotToExpunge = i;
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}

// If we didn't find stale entry on backward scan, the
// first stale entry seen while scanning for key is the
// first still present in the run.
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}

// If key not found, put new entry in stale slot
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);

// If there are any other stale entries in run, expunge them
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

这部分代码通过PreIndex方法实现往前环形搜索脏entry的功能,初始时slotToExpungestaleSlot相同,若在搜索过程中发现了脏entry,则更新slotToExpunge为当前索引i。另外,说明replaceStaleEntry并不仅仅局限于处理当前已知的脏entry,它认为在出现脏entry的相邻位置也有很大概率出现脏entry,所以为了一次处理到位,就需要向前环形搜索,找到前面的脏entry

我们再来看下cleanSomeSlots方法的调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private boolean cleanSomeSlots(int i, int n) {
boolean removed = false;
Entry[] tab = table;
int len = tab.length;
do {
i = nextIndex(i, len);
Entry e = tab[i];
if (e != null && e.get() == null) {
n = len;
removed = true;
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0);
return removed;
}

该方法用来清除一些脏entry,其扫描次数通过n来控制,可以看到n >>>= 1表示每次n除以2进行减小范围搜索,当遇到脏entry时,n = len,就会扩大搜索范围。如果在整个搜索过程没遇到脏entry的话,搜索结束,采用这种方式的主要是用于时间效率上的平衡。

为什么使用弱引用?

根据上面的内容,我们来分析下为什么ThreadLocal要使用弱引用。

假设ThreadLocal使用的是强引用,在业务代码中执行threadLocalInstance==null操作,以清理掉ThreadLocal实例的目的,但是因为ThreadLocalMapEntry强引用ThreadLocal,因此在gc的时候进行可达性分析,ThreadLocal依然可达,对ThreadLocal并不会进行垃圾回收,这样就无法真正达到业务逻辑的目的,出现逻辑错误。

假设Entry弱引用ThreadLocal,尽管会出现内存泄漏的问题,但是在ThreadLocal的生命周期里(set,get,remove)里,都会针对keynull的脏Entry进行处理。

从以上的分析可以看出,使用弱引用的话在ThreadLocal生命周期里会尽可能的保证不出现内存泄漏的问题,达到安全的状态。而且只要我们规范代码,就可以避免内存泄露问题。

线程退出

Thread源码里,我们可以看到exit方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void exit() {
if (group != null) {
group.threadTerminated(this);
group = null;
}
/* Aggressively null out all reference fields: see bug 4006245 */
target = null;
/* Speed the release of some of these resources */
threadLocals = null;
inheritableThreadLocals = null;
inheritedAccessControlContext = null;
blocker = null;
uncaughtExceptionHandler = null;
}

可以看到线程退出后,threadLocals变为null,也就意味着GC可以将ThreadLocalMap进行垃圾回收。

ThreadLocal应用场景

ThreadLocal在一些开源框架下有着广泛应用。

  1. Spring的事务管理

    在Spring事务管理相关类TransactionAspectSupport代码中,我们可以找到这段代码.

    1
    2
    3
    4
    //...部分代码略
    private static final ThreadLocal<TransactionInfo> transactionInfoHolder =
    new NamedThreadLocal<TransactionInfo>("Current aspect-driven transaction");
    //...部分代码略

    其目的就是用来存储当前事务相关信息。

  2. Logback中的使用

    LogbackLogbackMDCAdapter相关代码中,也有ThreadLocal的使用。

    1
    2
    3
    4
    //...部分代码略
    final ThreadLocal<Map<String, String>> copyOnThreadLocal = new ThreadLocal<Map<String, String>>();
    final ThreadLocal<Integer> lastOperation = new ThreadLocal<Integer>();
    //...部分代码略
  3. tomcat相关代码中,org.apache.catalina.core.ApplicationContext

    1
    2
    3
    4
    5
    6
    //...部分代码略
    /**
    * Thread local data used during request dispatch.
    */
    private final ThreadLocal<DispatchData> dispatchData = new ThreadLocal<>();
    //...部分代码略
  4. 如果要配置多数据源,我们可以使用ThreadLocal来进行数据源key的切换管理。

    可以看下这篇文章SpringBoot多数据源配置

结语

我们对Threadlocal进行了详细介绍,除了了解它的主要原理,解决项目中遇到的一些问题外,更要使用好它,每次使用完Threadlocal,应调用remove方法清除数据。

参考资料

  1. 一篇文章,从源码深入详解ThreadLocal内存泄漏问题
  2. ThreadLocal小结-到底会不会引起内存泄露



-------------文章结束啦 ~\(≧▽≦)/~ 感谢您的阅读-------------

SakuraTears wechat
扫一扫关注我的公众号
您的支持就是我创作的动力!
0%