必威体育Betway必威体育官网
当前位置:首页 > IT技术

我和Java ThreadLocal的故事

时间:2019-06-07 05:45:15来源:IT技术作者:seo实验室小编阅读:84次「手机版」
 

偷吃禁果

摘要

Threadlocaljava中的一个类,在Java 1.2版本加入,它的作者是Josh Bloch和Doug Lea。这篇文章会详细深入的分析ThreadLocal(基于JDK 1.8)。


ThreadLocal介绍

Java文档中对ThreadLocal的介绍如下图所示:

在这里插入图片描述图中大概意思:该类为线程提供局部变量,每个线程会维护一个该变量独立的副本,直到线程结束。

这段话不太好理解,太抽象,下面通过一些示例,来直接的感受ThreadLocal。

示例1

public class ThreadLocalDemo {
    public static void main(String[] args) throws InterruptedException {
        ThreadLocal<integer> threadLocal = new ThreadLocal<>();
        threadLocal.set(11);

        // 新建一个线程Thread-1,对threadLocal变量进行操作
        new Thread("Thread-1") {
            @Override
            public void run() {
                threadLocal.set(22);
                System.out.println("Thread Name: " + Thread.currentThread().getName() + " threadLocal = " + threadLocal.get());
            }
        }.start();

        Thread.sleep(100);  // 阻塞100ms,让线程Thread-1先输出
        System.out.println("Thread Name: " + Thread.currentThread().getName() + " threadLocal = " + threadLocal.get());
    }
}

输出结果如下图所示:

在这里插入图片描述

虽然在线程Thread-1中,设置threadLocal为22,但主线程中的threadLocal值仍然为11,并没有收到影响。

示例2

修改示例1的例子,再添加一个线程来修改threadLocal的值,代码如下所示:

public class ThreadLocalDemo {
    public static void main(String[] args) throws InterruptedException {
        ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
        threadLocal.set(11);

        // 新建一个线程Thread-1,对threadLocal变量进行操作
        new Thread("Thread-1") {
            @Override
            public void run() {
                threadLocal.set(22);
                System.out.println("Thread Name: " + Thread.currentThread().getName() + " threadLocal = " + threadLocal.get());
            }
        }.start();

        // 新建一个线程Thread-2,对threadLocal变量进行操作
        new Thread("Thread-2") {
            @Override
            public void run() {
                threadLocal.set(33);
                System.out.println("Thread Name: " + Thread.currentThread().getName() + " threadLocal = " + threadLocal.get());
            }
        }.start();

        Thread.sleep(100);  // 阻塞100ms,让线程Thread-1、Thread-2先输出
        System.out.println("Thread Name: " + Thread.currentThread().getName() + " threadLocal = " + threadLocal.get());
    }
}

输出结果如下图所示:

在这里插入图片描述

虽然在Thread-2中修改了threadLocal的值,同样未影响主线程中threadLocal的值。

现在,再回过来看ThreadLocal的介绍:该类为线程提供局部变量,每个线程会维护一个该变量独立的副本,直到线程结束。

也就是说,如果一个变量为ThreadLocal对象,并在每一个线程中对ThreadLocal变量用set方法赋值,那么每一个线程都会在堆中新建一个对象与之对应,所以在线程中对ThreadLocal变量的操作,只是操作与之对应的对象,不会影响其他线程(如下左图)。

而普通变量,在不同的线程中,对应的依然是堆中同一个对象(如下右图)。

在这里插入图片描述 在这里插入图片描述


ThreadLocal源码分析

ThreadLocal会为每一个线程创建独立的副本,这是如何实现的呢?要回答这个问题,需要阅读ThreadLocal的源码。

ThreadLocal构造方法

ThreadLocal构造方法,如下图所示:

public ThreadLocal() {
}

它只有一个构造方法,并且这个构造方法什么也没做。

set方法

查看set方法,一步一步分析ThreadLocal是如果存储数据的,set方法代码如下:

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

代码不复杂:

  1. 获取当前线程;
  2. 以当前线程为参数,获取map;
  3. 如果map不为null,则把数据存入map;
  4. 如果map为null,则创建map,然后把数据存入map。

获取map的getMap方法代码如下:

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

getMap中,返回的是Thread中的一个成员变量threadLocals。但在Thread中,并没有对这个成员变量threadLocals的初始化。所以,第一次调用会返回null。

调用getMap返回null,所有会调用createMap方法创建map,然后存入数据,createMap代码如下所示:

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

在这个方法中,会对当前线程的threadLocals成员变量初始化。threadLocals为ThreadLocalMap类型。ThreadLocalMap为ThreadLocal的内部静态类,ThreadLocalMap类中,又包含内部静态类Entry,Entry能存储一对键值对。我们要存储的数据存储在Entry键值对对象中,做为键值对的值,ThreadLocal对象的弱引用做为键。

所以,最终的存储关系为:Thread类中,有一个ThreadLocalMap对象,ThreadLocalMap对象中有一个Entry数组table,要存储的数据最终存储在Entry对象的value变量中,UML图如下图所示:

在这里插入图片描述因为数据最终是存储在Thread中,所以不同的线程对数据的操作是独立,不受影响的。

接着看ThreadLocalMap,构造方法代码如下所示:

ThreadLocalMap(ThreadLocal<"ff0000">提示: replaceStaleEntry方法比较复杂,比较难于理解,如果不理解,可以先看后面expungeStaleEntry和cleanSomeSlots两个方法的分析,然后再回来看这个方法,会更好的理解。

replaceStaleEntry方法,代码如下:

private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {
	Entry[] tab = table;
	int len = tab.length;
	Entry e;

	int slotToExpunge = staleSlot;
	for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len))
		if (e.get() == null)
			slotToExpunge = i;

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

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

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

			if (slotToExpunge == staleSlot)
				slotToExpunge = i;
			cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
			return;
		}

		if (k == null && slotToExpunge == staleSlot)
			slotToExpunge = i;
	}

	tab[staleSlot].value = null;
	tab[staleSlot] = new Entry(key, value);

	if (slotToExpunge != staleSlot)
		cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

replaceStaleEntry方法代码相对复杂,从名字可以看出,方法的功能是替换陈旧的元素。

如果tab是普通的数组,替换陈旧元素很简单,以下30、31这2行代码就搞定了:

tab[staleSlot].value = null;		// 置为null
tab[staleSlot] = new Entry(key, value);	// 更新为新元素

但因为tab是一个Hash表,所以更新的时候,还需要考虑Hash冲突的情况。

这是一个private方法,并且只在ThreadLocalMap.set方法中被调用过一次,调用场景如下:

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

什么时候k会等于null呢?k是Entry对ThreadLocal对象的弱引用,查看Entry的get方法说明,如下:

Returns this reference object’s referent. If this reference object has been cleared, either by the program or by the garbage collector, then this method returns null.

Entry的get方法中说明了,当引用对象被清理,或者被GC回收了,调用get方法会返回null。

所以调用replaceStaleEntry的前提就是,table数组中下标为i的元素不为null(即之前设置过),但是所引用的ThreadLocal对象被清理或者回收,也就是元素已经陈旧了。

逐步分析replaceStaleEntry方法的代码,第一个for循环,代码如下图所示:

int slotToExpunge = staleSlot;
for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len))
	if (e.get() == null)
		slotToExpunge = i;

这个循环的作用是,从staleSlot下标开始往前找,当tab[i]不等于null时,找到tag[i].get == null的最左下标slotToExpunge,这个下标用于标识需要删除陈旧元素的起始下标。

为什么要这么找呢?因为ThreadLocal.set方法用i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1)方式确定table数组下标i时,如果下标i已有元素,则说明发生了Hash冲突,处理冲突的办法,就是下标i + 1,如果i + 1后再冲突,再i + 1,直到找到tab元素为null的下标。

所以这个循环的作用,就是找到发生Hash冲突时的最左陈旧元素下标。

接着看replaceStaleEntry方法中第二个for循环,代码如下:

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

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

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

		if (slotToExpunge == staleSlot)
			slotToExpunge = i;
		cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
		return;
	}

	if (k == null && slotToExpunge == staleSlot)
		slotToExpunge = i;
}

这个循环的作用是,从staleSlot + 1下标开始往后找table数组中连续不为null的元素。如果有元素的key,等于参数传入的key,则说明此元素之前设置过,那么更新此元素的value值,然后和table[staleSlot]交换。这里为什么要交换?

因为调用replaceStaleEntry方法的前提就是table[staleSlot]元素是陈旧的(元素本身null,但key为null)。所以把更新后的有效元素e和table[staleSlot]元素互换。互换之后,staleSlot位置的元素就是有效的了,如果slotToExpunge == staleSlot,则更新slotToExpunge的位置。

接着调用expungeStaleEntry方法和cleanSomeSlots方法(这两个方法接下来详细讲解)清理陈旧的数据,然后结束返回。

接着分析没有进入for循环中if (k == key)语句的情况。

if (k == null && slotToExpunge == staleSlot)
		slotToExpunge = i;

如果k == null,说明这是一个陈旧的元素;slotToExpunge == staleSlot,说明在第一个for循环中,没用找到陈旧的元素,所以table[i]就是第一个陈旧的元素。

方法最后30 - 34行代码:

tab[staleSlot].value = null;		// 置为null
tab[staleSlot] = new Entry(key, value);	// 更新为新元素

if (slotToExpunge != staleSlot)
	cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);

如果slotToExpunge == staleSlot,slotToExpunge标示的是需要删除陈旧元素的下标,但是tab[staleSlot]元素已经替换为有效的新元素了,所以没有需要删除的陈旧元素。

所以只有在slotToExpunge != staleSlot的情况下,才说明有陈旧元素,需要调用expungeStaleEntry方法和cleanSomeSlots方法(这两个方法接下来详细讲解)清理陈旧的数据。

ThreadLocalMap.expungeStaleEntry

这个方法的功能是删除陈旧的元素。具体是如何删除的,需要分析源代码,方法源代码如下:

private int expungeStaleEntry(int staleSlot) {
	Entry[] tab = table;
	int len = tab.length;

	tab[staleSlot].value = null;
	tab[staleSlot] = null;
	size--;

	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;

				while (tab[h] != null)
					h = nextIndex(h, len);
				tab[h] = e;
			}
		}
	}
	return i;
}

如果只是普通的数组,则通过第5、6、7行代码,把元素置空,然后size--即可。

然而这里处理的不是普通的数组,而是Hash表,所有还要考虑有Hash冲突的情况下,如何删除陈旧元素,这个处理放在了for循环中,接下来分析for循环。

在for循环中,从staleSlot开始往后找连续的table中不为null的元素,如果key为null,则说明元素已经陈旧,则删除。如果key不为null,则通过k.threadLocalHashCode & (len - 1)计算下标(存放在h中),如果下标等于i则不处理,不等于i则处理。那等于i,不等于i分别代表什么意思呢?

  • 等于i,说明没有Hash冲突,k.threadLocalHashCode & (len - 1)计算的下标就是i
  • 不等于i,说明有Hash冲突,处理的方式是k.threadLocalHashCode & (len - 1)加1,如果还冲突,再加1,直到不冲突。也就是说,ik.threadLocalHashCode & (len - 1)加1,或者多次加1的值,所有h必然小于i

所以,19 - 25行代码的作用是整理发生冲突的元素,对发生冲突的元素重新Hash,整理的效果如下图所示:

在这里插入图片描述比如图中,i = 5,此时tab[i] != null,并且k != null。因为发生了Hash冲突,h = k.threadLocalHashCode & (len - 1)后,h = 2。按照19 - 25行代码整理后,tab[5] = null,tab[3]等于原来tab[5]的值。

为什么要对元素重新Hash呢?因为不重新Hash,可能会因为之前的元素已经陈旧被删除,而找不到发生冲突后续的元素。比如上图中整理前,tab[5]Hash下标为2,但因为tab[3],tab[4]为null,导致找不到tab[5]元素。

总结:ThreadLocalMap.expungeStaleEntry方法的功能是,删除staleSlot到之后第一个为null元素之间陈旧的元素,并可能会对期间的元素重新Hash。最后返回staleSlot之后第一个为null元素的下标。

ThreadLocalMap.cleanSomeSlots

cleanSomeSlots方法代码如下:

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;
 }

方法中会执行一个循环,循环的次数是n的对数级别。在循环中,如果元素不为null,并且key为null(这就是陈旧的元素),则调用expungeStaleEntry方法,删除陈旧元素,设置removed为true(标示已经删除元素),重新赋值n为table数组长度(扩大n的值)。

cleanSomeSlots的注释如下:

在这里插入图片描述意思是:试探性的扫描一些陈旧元素。这个方法会在插入新元素,或者删除陈旧元素时调用。它执行指数级扫描,是不扫描(更块,但是会存留垃圾)和线性扫描的一种折中。线性扫描会找到所有的垃圾,但是会导致一些插入时间复杂度为O(n)。

所以这个方法,可能会找到一些陈旧元素并删除,也可能不会。如果找到,返回true,否则返回false。

ThreadLocalMap.rehash

rehash方法代码如下:

private void rehash() {
    expungeStaleEntries();

    // Use lower threshold for doubling to avoid hysteresis
    if (size >= threshold - threshold / 4)
        resize();
}

在rehash方法中,先调用expungeStaleEntries方法删除所有的陈旧元素,expungeStaleEntries方法代码如下:

private void expungeStaleEntries() {
    Entry[] tab = table;
    int len = tab.length;
    for (int j = 0; j < len; j++) {
        Entry e = tab[j];
        if (e != null && e.get() == null)
            expungeStaleEntry(j);
    }
}

expungeStaleEntries方法很简单,就是对每一个元素进行判断,如果元素不为null,并且key为null,则为陈旧元素,即调用expungeStaleEntry方法删除陈旧元素。

回到rehash方法中,只剩下2行代码:

if (size >= threshold - threshold / 4)
	resize();

在对Hash表table进行调整前,还做了一次判断size >= threshold - threshold / 4,注释中给出了解释:用更低的阈值,更好的避免滞后。

最后调用resize方法对Hash表table进行调整,resize接下来详细分析。

ThreadLocalMap.resize

resize方法代码如下:

private void resize() {
	Entry[] oldTab = table;
	int oldLen = oldTab.length;
	int newLen = oldLen * 2;
	Entry[] newTab = new Entry[newLen];
	int count = 0;

	for (int j = 0; j < oldLen; ++j) {
		Entry e = oldTab[j];
		if (e != null) {
			ThreadLocal<?> k = e.get();
			if (k == null) {
				e.value = null; // Help the GC
			} else {
				int h = k.threadLocalHashCode & (newLen - 1);
				while (newTab[h] != null)
					h = nextIndex(h, newLen);
				newTab[h] = e;
				count++;
			}
		}
	}

	setThreshold(newLen);
	size = count;
	table = newTab;
}

resize方法并不复杂,总结起来做了3件事:

  1. 扩容Hash表,扩容后的大小为原来的2倍。
  2. 对原Hash表中的每一个元素,在新Hash表中重新Hash。
  3. 设置新的阈值和Hash表元素数量。

到这里,ThreadLocalMap.set方法分析完毕,接下来分析ThreadLocal.get方法。

get方法

get方法代码如下:

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();
}

方法中,先去获取当前线程,然后获取当先线程的ThreadLocalMap变量threadLocals。于是分为2种情况:

  1. threadLocals变量为null;
  2. threadLocals变量不为null;

先看第1种情况,threadLocals为null时,调用setInitialValue方法,代码如下:

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;
}

查看代码,发现这个方法和set方法极度相似,区别只是set方法有参数、无返回值;setInitialValue方法无参数,有返回值。

setInitialValue方法的作用是设置初始值,并返回初始值。设置初始值的方式和set方法完全一样。那设置的初始值从哪里来呢?

从initialValue方法中来,initialValue方法代码如下:

protected T initialValue() {
	return null;
}

方法返回null,可以从写此方法,设置ThreadLocal的初始值。

接着看get方法中的第2中情况,threadLocals不为null。这时会调用ThreadLocalMap的getEntry获得元素,如果获得元素为null,处理和第一种情况threadLocals为null一样。

ThreadLocalMap.getEntry方法代码如下:

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);
}

这里也分为2中情况,通过key拿到的元素不为null并且key相等,则直接返回该元素,否则返回getEntryAfterMiss方法调用结果。

getEntryAfterMiss代码如下:

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;
}

在getEntryAfterMiss方法中,如果传入的元素e为null,则返回null;如果传入的元素e不为null,则利用while循环,继续查找元素,while循环中的处理如下:

  1. 判断元素是否为null,如果为null,结束循环,否则继续循环;
  2. 判断元素的key和传入key是否相同,相同则返回该元素;
  3. 找到元素不为null,但key为null,说明是陈旧元素,则调用expungeStaleEntry方法删除陈旧元素,经过这一步处理之后,tab[i]的元素要么为null,要么不为null并且key也不为null(如果不理解,请看expungeStaleEntry方法分析),进入步骤5。
  4. 找到元素不为null,key也不为null,用处理Hash冲突的方式,拿到下一个元素的下标i;
  5. 取得tab[i],跳转到1;

所以getEntryAfterMiss方法的调用结果只有2中,一种是找到元素,一种就是null。因此getEntry返回的结果也只有2种,一种是找到元素,一种就是null。

最后总结,get方法获取到的结果有3种:

  1. 找到元素;
  2. null;
  3. initialValue方法设置的初始值;如果默认,也为null。

remove方法

remove方法代码如下:

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

代码很简单,就是调用ThreadLocamMap的remove方法,ThreadLocamMap.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; e = tab[i = nextIndex(i, len)]) {
        if (e.get() == key) {
            e.clear();
            expungeStaleEntry(i);
            return;
        }
    }
}

ThreadLocamMap.remove代码和很简单。查找元素,如果key相同,清除元素key,使key为null。当key为null后,元素则为陈旧元素,再调用expungeStaleEntry删除陈旧元素。

到这里,ThreadLocal的核心代码就分析完了。


ThreadLocal内存泄漏

内存泄漏的原因

ThreadLocal的引用关系如下图所示:

在这里插入图片描述要保存的数据,保存在Entry对象的value变量中,为强引用。Entry对象的key为ThreadLocal的弱引用。Entry对象保存在ThreadLocalMap对象table数组中,也为强引用。ThreadLocalMap保存在Thread对象的threadLocals变量中,也为强引用。因此存在以下2条引用链:

  1. Thread ——> ThreadLocalMap ——> Entry ——> Object;
  2. Thread ——> ThreadLocalMap ——> Entry ------> ThreadLocal;

在第2条引用链中,Entry对ThreadLocal的引用为弱引用,GC进行垃圾回收的时候,如果没有其他对ThreadLocal的强引用,ThreadLocal就会被回收。

在第1条引用链中,所有的引用都是强引用,所以引用链上对象生命周期都和Thread相同,如果Thread长时间未结束,那么Entry、Object对象就不能被回收,从而引起内存泄漏。

内存泄漏的处理

知道了内存泄漏的原因,ThreadLocalMap的设计中也已经考虑到了这种情况,所以Entry中对ThreadLocal的引用为弱引用,当GC回收垃圾的时候,ThreadLocal如果没有其他强引用,则会被回收。这个时候Entry中的key为null,在replaceStaleEntry方法分析中,说到Entry不为null,key为null的情况,即为Entry已经陈旧。

对于陈旧的元素,set、get、remove方法的调用,都会删除它。删除陈旧元素的方法就是元素的value置为null,元素置为null,这样Entry对象就能被GC回收了。

但是并不能保证set、get、remove方法一定会被调用。所以为了确保不发生内存泄漏,需要在ThreadLocal使用完毕后,手动调用remove删除元素。这一点在线程池中尤为重要,因为线程池中的线程会复用,如果复用时,使用的是上一次ThreadLocal的结果,会引发严重的后果。


参考资料

  • ThreadLocal 和神奇的数字 0x61c88647
  • 深入分析 ThreadLocal 内存泄漏问题
  • ThreadLocal和synchronized的区别?

相关阅读

分享到:

栏目导航

推荐阅读

热门阅读