Handler消息机制(二)ThreadLocal原理

1,024 阅读8分钟

原文链接

本文对ThreadLocal实现线程间分别存储数据,进行了深层次的探索,源码采用Android SDK 28版本进行分析。

该系列的其他文章

  1. Handler消息机制(一)Message复用原理
  2. Handler消息机制(二)ThreadLocal原理
  3. Handler消息机制(三)MessageQueue原理
  4. Handler消息机制(四)Looper原理
  5. Handler消息机制(五)Handler原理 本文内容主要包括三部分:
  • ThreadLocal是什么
  • ThreadLocal使用
  • ThreadLocal源码分析

1. ThreadLocal是什么

首先贴官方描述:

This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its get or set method) has its own. 大概意思是,该类提供线程局部变量。 这些变量和普通变量不同,因为每个线程(通过其get或set方法)操作的都是线程自己的。

通过对官方描述的理解,我们知道ThreadLocal记录的变量跟线程相关,其他线程无法获取和修改该线程记录的变量。此特性在Handler中得到了很好的应用,帮助Handler实现了消息的跨线程通信,后续系列文章会详细分析如何借助该特性实现跨线程通信的。 ThreadLocal针对一个线程只能记录一个变量,但是一个线程内可以通过多个ThreadLocal来记录多个变量,在记录多个变量的时候,因为其存储方式,有可能会存在hash冲突的问题,后续结合源码我们进一步分析,如何解决hash冲突问题,下面我们来实际验证下。

2. ThreadLocal使用

    public static void main(String[] args) {
        sThreadLocal.set("Jon");
        new Thread(new OneRunnable(), "SubThread").start();
        String name = sThreadLocal.get();
        System.out.println(Thread.currentThread().getName() + ", name: " + name);
    }

    private static class OneRunnable implements Runnable {

        @Override
        public void run() {
            sThreadLocal.set("jaymzyang");
            String name = sThreadLocal.get();
            System.out.println(Thread.currentThread().getName() + ", name: " + name);
        }
    }
  输出内容:
  main, name: Jon
  SubThread, name: jaymzyang

在main线程中我们设置"Jon",在SubThread线程中我们设置"jaymzyang",分别在对应线程下打印get值,我们发现在main线程中输出"Jon",在SubThread线程中输出"jaymzyang",所以ThreadLocal是会根据线程记录值的,如果在main线程中再次调用set方法,只会将main线程内的sThreadLocal变量内的值改为新值。

3. ThreadLocal源码分析

  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 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 getMap(Thread t) {
      return t.threadLocals;
  }

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

结合源码发现线程通过threadLocals参数持有ThreadLocalMap对象。

  • set方法:
    • 如果线程未持有map时,则通过createMap创建一个ThreadLocalMap对象存储在线程中threadLocals变量中,构造map时将存储的值传递给ThreadLocalMap构造方法,value被存储到map对象中。
    • 如果线程持有map时,则通过线程获取到所持有的ThreadLocalMap对象,然后将value存储到map对象中。
  • get方法:
    • 通过线程获取到所持有的ThreadLocalMal对象,然后查询存储在map中的value。

3.1 ThreadLocalMap是如何实现存储?

ThreadLocalMap从名称看应该是Map类型的数据结构,但是并没有继承自Map接口。接着分析发现,map在其内部维护了一个默认大小为16的Entry数组,Entry继承自WeakReference<ThreadLocal<?>>,采用key-value结构模型,key为ThreadLocal类型并存入WeakReference对象中,因此key为弱引用类型,易被GC回收,后续分析根此相关的泄漏问题。

3.1.1 ThreadLocalMap构造方法
  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);
  }

首先初始化一个容量为16的Entry数组,然后用key即thradLocal对象的hash值计算存储在数组中的索引位置,接着创建一个Entry对象,将key和value作为构造参数传入,数组大小加1,最后是setThreshold(INITIAL_CAPACITY),该方法是计算扩容因子,即当数组内元素达到数组大小的2/3时,会对数组进行扩容。

setThreshold方法:

  private void setThreshold(int len) {
       threshold = len * 2 / 3;
  }

这里解释下为什么是在达到数组长度的2/3时进行扩容,主要是hash冲突的原因:

  • 通过hash计算索引时会存在hash冲突的问题,当数组容量较大时,hash冲突的概率降低
3.1.2 ThreadLocalMap set方法
  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();
  }

根据key进行hash计算,得到在数组中的索引,如果下标中的Entry元素的key和要修改的key是同一个ThreadLocal对象,将新的value设置进去e.value = value,设置新值结束。 如果key不相同,则存在hash冲突,ThreadLocalMap处理hash冲突的方式为线性探测法,继续探测下一个索引的entry元素,判断key是否和查找到的entry.key相同,相同则将新的value设置进去,设置新值结束;否则表示当前数组中没有存储该值,新建Entry元素,存储到table中table[i] = new Entry(key, value)。 set流程如下: ThreadLocalMap set流程

在set数据时,会涉及到hash冲突,清理泄漏数据,扩容等操作

1. hash冲突处理

ThreadLocalMap的线性探测法和HashMap的链地址法,都是处理hash冲突的方式,线性探测法是发生hash冲突时,顺序地到存储区间中寻找存储位置,直到找到合适的位置。链地址法是在索引下标处建立一个链表结构,将新的数据插入链表中。 假设序列为"47, 34, 13, 12, 52, 38, 33, 27, 3",哈希数组表长为11,采用对11取模的hash算法进行存储。 Hash(47) = 47 % 11 = 3 Hash(34) = 34 % 11 = 1 Hash(13) = 13 % 11 = 2 ... Hash(3) = 3 % 11 = 3 由于47已经存储到下标为3的位置,因此3需要进行线性探测,直到找到空缺的位置7,前面的位置均被hash算法或探测占用。 利用线性探测法处理hash冲突之后的表格如下:

哈希地址012345678910
关键字333413473827352
线性探测法优缺点:
  • 算法简单
  • 容易产生聚集现象,效率低
2. 清理泄漏数据

因为Entry是继承自WeakReference<ThreadLoacl>类型,其key为ThreadLoacl类型被保存到WeakReference对象中,如果在key没有被外部强引用时,根据GC规则会对key进行回收,如果创建ThreadLocal的线程一直在运行,则Entry对象中的value一直不能被回收,从而导致内存泄漏。 如何清理导致泄漏的value? 结合源码发现在set操作时,如果key == null,会调用replaceStaleEntry方法。

replaceStaleEntry方法:

  private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                             int staleSlot) {
      ...
      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;
      }
      ...
  }
   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;
   }

最终在expungeStaleEntry中会对key为null的entry对象清理,然后重新rehash排列数组中的entry对象。 在源码中搜索expungeStaleEntry发现remove方法和getEntry方法当key == null时,都会调用到expungeStaleEntry操作,所以我们可以通过调用set、get方法,自动检测该threadLocal作为key的对象是否已被GC回收,如果回收,则将该threadLocal作为key的entry清理掉,或者手动调用remove来清理不需要的threadLocal对象,防止出现内存泄漏。 良好的code习惯:

  try {
     sThreadLocal.get();
     ...
  } finally {
     //使用完毕后回收掉
     sThreadLocal.remove();
  }
3. 扩容

在set填充数据时,一般很少用到,一个线程可以当记录到10个ThreadLocal对象记录的数据时,才会扩容,根据初始化table数据容量为16,达到扩容因子即容量的2/3大小时,会对数据进行扩容和rehash操作。

rehash方法:

  private void rehash() {
      expungeStaleEntries();

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

expungeStaleEntries()实际内部是expungeStaleEntry()操作,先进行key为null的数据进行清理,然后通过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会进行双倍扩容,然后将原数据重新通过hash算法计算放到新数组中,最后设置新的扩容因子和数组元素数量。

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

getEntry方法查询ThreadLocalMap中存储的已threadLocal为key的元素,如果查到会返回该entry对象;如果未找到会调用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方法中,如果查到key相同的元素,在返回;如果该元素的key为null,则调用之前分析的方法expungeStaleEntry将该entry清理,防止出现内存泄漏,如果未找到则返回null。 ThreadLocalMap get流程

结语:ThreadLocal是一个数据结构,根据线程来存储变量,一个ThreadLocal只能保存一个T类型的变量,使用完毕后养成良好的习惯,通过调用remove来清理,防止因为GC回收掉key,而value无法被清理,出现内存泄漏问题。