ThreadLocal源码探究 (JDK 1.8)

ThreadLocal类之前有了解过,看过一些文章,自以为对其理解得比较清楚了。偶然刷到了一道关于ThreadLocal内存泄漏的面试题,居然完全不知道是怎么回事,痛定思痛,发现了解问题的本质还是需要从源码看起。
ThreadLocal可以保存一些线程私有的数据,从而避免多线程环境下的数据共享问题。ThreadLocal存储数据的功能是通过ThreadLocalMap实现的,这是ThreadLocal的一个静态内部类。ThreadLocal源码加注释总共700多行,ThreadLocalMap就占据了接近400行,基本上理解了ThreadLocalMap也就理解了ThreadLocal。本文先简介ThreadLocalMap,然后从ThreadLocal的核心方法开始讲起,需要用到ThreadLocalMap的地方顺带一起介绍。

1.ThreadLocalMap简介

ThreadLocalMap本质上仍然是一个Map,具有普通Map的特点,当遇到hash冲突的时候,采用线性探测的方式来解决冲突,底层使用数组作为存储结构,它的主要字段如下:

  • INITIAL_CAPACITY:初始容量,默认是16
  • Entry[] table:存储键值对的数组,其大小是2的整数幂
  • size:数组内存储的元素个数
  • threshold:扩容阈值
    ThreadLocalMap底层数组保存的是Entry类型键值对,EntryThreadLocalMap的一个内部类,它是用来存储键值对的对象,值得关注的是Entry继承了WeakReference这个弱引用类,这意味着Entry的key引用的对象,在没有其他强引用的情况下,在下一次GC的时候就会被回收(注意:这里忽略了软引用,因为软引用是在即将因为内存不足而抛出异常的时候才会回收)。并且EntrykeyThreadLocal对象,通过其祖父类Reference的构造函数可以看到,key实际上是被保存在referent字段中,Entry对象的get方法也是从Reference继承过来的,直接返回该referent字段。
    static class Entry extends WeakReference<ThreadLocal<?>> {          Object value;            Entry(ThreadLocal<?> k, Object v) {              super(k);              value = v;          }      }        //Entry的构造器调用了父类的构造,最终是通过Reference的构造器实现的      Reference(T referent) {          this(referent, null);      }        Reference(T referent, ReferenceQueue<? super T> queue) {          this.referent = referent;          this.queue = (queue == null) ? ReferenceQueue.NULL : queue;      }        //Reference的get方法      public T get() {          return this.referent;      }

在对Entry有了初步了解后,现在来思考一下为什么key要设计成弱引用呢?假设现在采有强引用来设计key,考虑如下代码:

    ThreadLocal<String> tl1 = new ThreadLocal<>();      tl1.set("abc");      tl1=null;

此时,相关的引用情况如下图:

tl1虽然不再引用堆上的ThreadLocal对象,但是线程的ThreadLocalMap里还保留着对该对象的强引用,要获取该对象就需要ThreadLocal对象作为key,但是这个key现在已经是null了。也就是说,此时已经没有任何办法能够访问到堆上的TheradLocal对象,但是由于还有强引用的存在,导致这个对象无法被GC回收。这种情况显然不是我们希望看到的,因此Entrykey不能被设计为强引用。设计成弱引用是合理的,一旦外界的强引用被取消,就应当允许key所引用的对象被回收。

2.ThreadLocal核心方法

  • get
    get方法用来获取存储在ThreadLocal中的元素,其源码如下:
    public T get() {          Thread t = Thread.currentThread();          //获取当前线程内部的ThreadLocalMap对象          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;              }          }          //执行到这里的两种情况:1)map没初始化;2)map.getEntry返回null          return setInitialValue();      }        //从这里可以看到,每个Thread示例内部都有一个ThreadLocalMap类型的字段,线程局部变量就存在这个Map中      ThreadLocalMap getMap(Thread t) {          return t.threadLocals;      }        //ThreadLocalMap的getEntry方法      private Entry getEntry(ThreadLocal<?> key) {          //计算key位于哪个桶          int i = key.threadLocalHashCode & (table.length - 1);          Entry e = table[i];          if (e != null && e.get() == key)              return e;          //执行到这里的两种情况:1)e=null,即桶内没有存数据;2)桶内有数据,          //但不是当前这个ThreadLocal对象的,说明产生了hash冲突,导致键值对被放到了其他位置          else              return getEntryAfterMiss(key, i, e);      }

线程能够保存私有变量的原因就在于其成员变量threadLocals,每个线程都有这样的结构,互相不干扰。get方法的代码很简单,根据从线程内取到的ThreadLocalMap对象,如果ThreadLocalMap还没初始化,则先初始化;如果已完成初始化,调用其getEntry方法取元素,取不到的话,就会执行getEntryAfterMiss方法(ThreadLocal内部只在getEntry方法里调用了getEntryAfterMiss),先看看setInitialValue方法的逻辑:

    private T setInitialValue() {          T value = initialValue();          Thread t = Thread.currentThread();          ThreadLocalMap map = getMap(t);          //map已经初始化,就将键值对存入底层数组          if (map != null)              map.set(this, value);          else              createMap(t, value);          return value;      }        //默认的initialValue返回null,而且该方法是protected,目的显然是让子类进行重写      protected T initialValue() {          return null;      }

setInitialValue的逻辑很简单,假如map没有初始化,执行createMap方法进行初始化,否则将当前ThreadLocal对象和null构造成一个新的Entry放入数组内。接下来看一下createMap的初始化逻辑:

    //可以看到,初始化的过程就是对Thread内部变量threadLocals赋值的过程,用到了ThreadLocalMap的构造器      void createMap(Thread t, T firstValue) {          t.threadLocals = new ThreadLocalMap(this, firstValue);      }        //      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的扩容使用的是2/3作为加载因子,这点与HashMap等容器不同      private void setThreshold(int len) {          threshold = len * 2 / 3;      }

该方法通过ThreadLocalMap的构造器对内部数组进行初始化,并将对应的值添加到数组中。可以看到,ThreadLocalMap有容量的概念,但却没有办法指定其初始容量,在构造的时候使用固定值16作为初始容量,而且扩容阈值设置的是容量的2/3,这一点与HashMap等容器的做法不同。
接下来看看getEntryAfterMiss方法的源码:

    private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {          Entry[] tab = table;          int len = tab.length;            while (e != null) {              ThreadLocal<?> k = e.get();              //找到key就直接返回              if (k == key)                  return e;              //注意这里:当发现key=null是时,说明其对应的ThreadLocal对象已被GC回收,              //此时会通过expungeStaleEntry将一部分key为null的桶清空              if (k == null)                  expungeStaleEntry(i);              //走到这里说明存在hash冲突,当前桶被其他元素占了,使用nextIndex向后找一个位置              else                  i = nextIndex(i, len);              e = tab[i];          }          //如果e=null,在这里返回null          return null;      }        /nextIndex的主要作用是:查找下一个桶,如果到达末尾,则从头开始      private static int nextIndex(int i, int len) {          return ((i + 1 < len) ? i + 1 : 0);      }

在桶内的key=null时,会调用expungeStaleEntry方法,从命名可以看出,这个方法主要功能是将ThreadLocalMapkey=null的元素清理掉。下面是对这个方法的讲解:

  • expungeStaleEntry
        private int expungeStaleEntry(int staleSlot) {              Entry[] tab = table;              int len = tab.length;                // expunge entry at staleSlot              //分别将Entry的键和值清空              tab[staleSlot].value = null;              tab[staleSlot] = null;              //元素数量减1              size--;                // Rehash until we encounter null              Entry e;              int i;              //从当前下标的下一个位置开始遍历,清空key=null的桶,并更新hash冲突的元素的位置              //循环终止条件:顺序向后遍历时,找到一个非空的桶则循环终止,因此这里只是作了局部清理              for (i = nextIndex(staleSlot, len);                   (e = tab[i]) != null;                   i = nextIndex(i, len)) {                  ThreadLocal<?> k = e.get();                  //如果key = null,则清空该桶                  if (k == null) {                      e.value = null;                      tab[i] = null;                      size--;                  } else {                      int h = k.threadLocalHashCode & (len - 1);                      //h!=i说明当前元素是因为hash冲突,之前的桶被占了才放在了i这个桶内,                      //那么就从其原来的位置h开始向后查找,找到第一个空桶,就把元素挪过去,                      //目的是为了保证元素距离其正确位置最近,减少后续的查找成本                      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;          }

expungeStaleEntry的循环逻辑说明,在对失效元素进行清空时,不是清空所有失效的桶,而是从当前位置向后遍历,只要找到一个非空的桶,清理的过程就结束了。也就是说,这种清理会导致有一些过期失效的桶无法得到清理。

  • set
    介绍完get方法后,现在再来看看set方法的实现逻辑:
    public void set(T value) {          Thread t = Thread.currentThread();          ThreadLocalMap map = getMap(t);          //map已创建则直接设值          if (map != null)              map.set(this, value);          //map未创建,则创建map          else              createMap(t, value);      }  

来看看mapset方法是如何设值的:

    private void set(ThreadLocal<?> key, Object value) {            // We don't use a fast path as with get() because it is at          // least as common to use set() to create new entries as          // it is to replace existing ones, in which case, a fast          // path would fail more often than not.            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();                //如果当前ThreadLocal对应的有值,则更新              if (k == key) {                  e.value = value;                  return;              }                //如果k=null,说明对应的ThreadLocal对象已被GC回收,执行replaceStaleEntry的逻辑              if (k == null) {                  //这里是replaceStaleEntry方法的唯一调用点                  replaceStaleEntry(key, value, i);                  return;              }          }          //找到一个空位置,将值存进去          tab[i] = new Entry(key, value);          int sz = ++size;          //如果调用cleanSomeSlots没有清理任何桶,并且达到了扩容阈值,就执行扩容逻辑          if (!cleanSomeSlots(i, sz) && sz >= threshold)              rehash();      }

set的时候需要从对应的下标开始向后遍历,找到一个合适的位置将元素放进去,这里合适的位置是指:a)空桶;b)桶非空,但是key对应的ThreadLocal对象已被清理。在key已经被清理的情况下,会执行replaceStaleEntry方法的逻辑,接下来看看这个方法的代码:

    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;          //从staleSlot这个桶向前查找,遇到第一个空桶就停止          for (int i = prevIndex(staleSlot, len);               (e = tab[i]) != null;               i = prevIndex(i, len))               //如果桶内的key=null,说明该桶可以被回收,将slotToExpunge变量指向这个桶              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.              // k== key,说明这个ThreadLocal已经存在,但是距离正确的位置太远,需要对其位置进行更正              if (k == key) {                  e.value = value;                    //交换两个桶内的元素,把i位置的元素放在距离其正确位置最近的桶内。                  //注意,replaceStaleEntry的唯一调用点出现在set方法内,此时staleSlot对应的桶的key=null,                  //个人推测这里不直接赋值tab[i]=null的原因是让下一次expungeStaleEntry能够多清理一些空桶,                  //如果这里设置为null的话,下一次清理到这个位置就终止了                  tab[i] = tab[staleSlot];                  tab[staleSlot] = e;                    // Start expunge at preceding stale entry if it exists                  //下面的等式成立有两种情况:                  //1)staleSlot前一个桶就为空,此时上文中前向遍历的循环体会直接结束;                  //2)staleSlot前面的若干桶都不为空,且桶内的key!=null,即对应的ThreadLocal对象都没有被回收;                  //出现这两种情况的时候,都只能从i这个位置开始进行清理                  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.              //k!=null的时候,不能清理i这个桶;slotToExpunge != staleSlot时,              //说明在i这个位置之前就已经有需要清理的桶了,不能更新slotToExpunge这个指针的值              if (k == null && slotToExpunge == staleSlot)                  slotToExpunge = i;          }            // If key not found, put new entry in stale slot          //执行到这里说明,直到遇到空桶,都没有在数组中找到key,就把新的键值对放在staleSlot的位置。          tab[staleSlot].value = null;          tab[staleSlot] = new Entry(key, value);            // If there are any other stale entries in run, expunge them          //slotToExpunge != staleSlot说明在其他位置找到需要清理的键值对,那么就从对应的位置清理          if (slotToExpunge != staleSlot)              cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);      }

针对代码注释中的内容,有几点需要强调一下。首先replaceStaleEntry这个方法唯一的调用点就是ThreadLocalMapset方法内部,而且在调用replaceStaleEntry的时候,参数staleSlot对应的桶内的Entry对象的key=null。其次,replaceStaleEntry的逻辑是从staleSlot这个桶开始,先前向遍历,找到第一个空桶就停止遍历,期间如果发现某个桶内的key=null,就将slotToExpunge指针指向这个桶,表示下文要从这个桶开始进行过期键清理。前向遍历结束之后开始后向遍历,找到当前的ThreadLocal对象所在的桶,将其位置更新,调用清理方法之后代码返回,否则就一直向后找直到遇到空桶。
下面对replaceStaleEntry方法的执行流程进行梳理。
假设在方法执行时,ThreadLocalMap的存储结构如下所示:

首先前向遍历,遍历到LL位置结束,由于在L位置Entry.key=null,所以设置slotToExpunge=L

接下来开始向后遍历,遍历到R1位置时,虽然Entry.key=null,但是由于slotToExpunge的值已经被修改,不再对其进行赋值。代码接着遍历R2位置,在这里找到了key,因此将该位置的值与staleSlot位置进行交换,如下图:

之后执行expungeStaleEntry方法将LL位置清空,然后从L位置开始执行cleanSomeSlots的逻辑。
replaceStaleEntry方法内有两处用到了cleanSomeSlots方法,接下来对其进行介绍:

    private boolean cleanSomeSlots(int i, int n) {          boolean removed = false;          Entry[] tab = table;          int len = tab.length;          //循环的逻辑是:从i的下一个位置开始找那些桶不空,但是桶内Entry对应的key=null的元素,          //然后从这些元素开始向后进行清空。循环的过程中会跳过空桶或者桶内元素的key!=null的桶,          //循环的次数由n的大小决定,每次将n减半,直到减为0循环结束。          do {              i = nextIndex(i, len);              Entry e = tab[i];              if (e != null && e.get() == null) {                  n = len;                  removed = true;                  //注意:expungeStaleEntry方法的返回值是第一个不为空的桶的下标,循环的下一次会从这个下标开始遍历                  i = expungeStaleEntry(i);              }          } while ( (n >>>= 1) != 0);          //如果清理了元素,则返回true,否则返回false          return removed;      }
  • rehash
    set方法内部最后几行,如果调用cleanSomeSlots没有清理任何桶,并且达到了扩容阈值,就执行扩容逻辑,这段逻辑在rehash方法中,来看看方法的实现逻辑:
    private void rehash() {          //清理所有的过期桶          expungeStaleEntries();            // Use lower threshold for doubling to avoid hysteresis          //清理过后剩余元素达到threshold的0.75才进行扩容,回忆一下ThreadLocalMap的初始化过程,          //初始化时threshold=2/3*初始容量,这里在判断是否要扩容时,是已threshold*0.75为标准          if (size >= threshold - threshold / 4)              resize();      }      //这个方法会从头开始遍历整个数组,每遇到一个ThreadLocal对象被回收的桶,就调用expungeStaleEntry方法向后清理一部分桶      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);          }      }        private void resize() {          Entry[] oldTab = table;          int oldLen = oldTab.length;          //注意这里并没有对newLen作限制,也就是说有超限的可能,但是一般肯定不会在线程内放这么多本地变量          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;      }
  • remove
    最后来看一下ThreadLocalremove方法,方法很简单,底层逻辑仍然是通过ThreadLocalMapremove实现的:
     public void remove() {           ThreadLocalMap m = getMap(Thread.currentThread());           if (m != null)               m.remove(this);       }         private void remove(ThreadLocal<?> key) {          Entry[] tab = table;          int len = tab.length;          //计算key的位置          int i = key.threadLocalHashCode & (len-1);          for (Entry e = tab[i];               e != null;               e = tab[i = nextIndex(i, len)]) {              //如果找到key,则调用Reference类的clear方法,将referent置为null,然后从该位置开始向后清理一部分过期键值对              if (e.get() == key) {                  e.clear();                  expungeStaleEntry(i);                  return;              }          }      }        //Reference类的clear方法      public void clear() {          this.referent = null;      }