Handler消息机制(一)ThreadLocal

原文链接
本文对ThreadLocal实现线程间分别存储数据,进行了深层次的探索,源码采用Android SDK 28版本进行分析。
本文内容主要包括三部分:

  • 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冲突之后的表格如下:

哈希地址 0 1 2 3 4 5 6 7 8 9 10
关键字 33 34 13 47 38 27 3 52

线性探测法优缺点:

  • 算法简单
  • 容易产生聚集现象,效率低
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无法被清理,出现内存泄漏问题。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,951评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,606评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,601评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,478评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,565评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,587评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,590评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,337评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,785评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,096评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,273评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,935评论 5 339
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,578评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,199评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,440评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,163评论 2 366
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,133评论 2 352