多线程之ThreadLocal 第二弹

前言

  上篇文章简单介绍了 ThreadLocal 的用法以及与 Thread 之间的关系,这篇文章开始讨论下上篇文章提到的 ThreadLocalMap ,这个类就是专门用于存储我们需要存的値,是 ThreadLocal 思想的核心类,所以大家打起精神来跟我一起探讨。

ThreadLocalMap 的概念

   ThreadLocalMap 顾名思义:就是一个key-value 对的散列表,key 就是我们的 ThreadLocal 对象,値为我们需要存储的値,其中 key 是弱引用类型,即内存不足时会被 GC 掉,这点是为了应对空间很大且保存时间很长的场景,但是如果使用不当极有可能造成 OOM 。ThreadLocalMap 不是一个普通的散列表,而是一个环形的散列表,解决冲突采用的是 线性探测法。

ThreadLocalMap 类定义

   平平无奇的文字无法描述 ThreadLocalMap 类的巧妙定义,因此我们直接膜拜一下大神的代码:

 static class ThreadLocalMap {
        /**
          * 散列表的中的条目 继承了 WeakReference ,
          * 将条目中的键变为弱引用。因此 null 键则表示不再引用该键了,
          * 该条目称为 ‘过期的条目’,这个条目会在一定时机进行清除
          */
        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** 与ThreadLocal 关联的値 */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
              // key 为弱引用
                super(k);
                value = v;
            }
        }

        /**
         * 初始容量,必须为2 的幂次方
         */
        private static final int INITIAL_CAPACITY = 16;

        /**
         * 散列表,根据需要调整大小
         * 表的长度,必须为2的幂次方
         */
        private Entry[] table;

        /**
         * 表中条目的个数
         */
        private int size = 0;

        /**
         * 调整表大小的阈值
         */
        private int threshold;
}

大家可以看下👆的代码,根据我的注释自己理解下,在这里就不赘述了。

ThreadLocal:: set

   一个正常的数据结构都有增改删查,按照顺序我们先来介绍下增改set 方法,下面我们来看下它的源码:

        /**
         * 设置与健关联的值
         *
         * @param key  ThreadLocal 对象
         * @param value 要设置的值
         */
        private void set(ThreadLocal<?> key, Object value) {
            //开启一个新的引用引用散列表
            Entry[] tab = table;
            int len = tab.length;
            //获取 key 对应的下标
            int i = key.threadLocalHashCode & (len-1);
            //根据线性探测法寻找key 
            for (Entry e = tab[i];
                 e != null;
                 e = tab[i = nextIndex(i, len)]) {
                ThreadLocal<?> k = e.get();
                //若找到与之相等的则直接替换值即可
                if (k == key) {
                    e.value = value;
                    return;
                }
                //若该位置的 key 为null ,如上文所说该位置的条目为一个过期的条目,
                // 需要替换它,并且执行一些清除操作
                if (k == null) {
                    replaceStaleEntry(key, value, i);
                    return;
                }
            }
             //如果没有找到则新建一个条目
            tab[i] = new Entry(key, value);
            int sz = ++size;
             //启发性地清理过期条目 然后判断size 是否达到阈值 是否需要重新hash 
            if (!cleanSomeSlots(i, sz) && sz >= threshold)
                rehash();
        }

根据👆的代码注释,已经知道 set 方法的执行流程:①通过 ThreadLocal 对象的
threadLocalHashCode &(散列表的长度-1) 获取 key 在散列表中的位置 i ,② 通过线性探测法寻找 key是否已存在,若存在,判断原先的 key 是否为空,若不为空则直接替换,若原先的 key 为空,则说明为过期的条目需要替换它并且执行一些清除操作。③如果 key 不存在,则直接新建一个条目即可④启发性地清理一些槽并判断是否需要重hash。
   下面我们来看下 replaceStaleEntry 方法,该方法只是替换了过期条目那么简单吗,下面我们用代码来见证下吧:

        /**
         * 用指定键的项替换set操作期间遇到的过时项。
         * 在value参数中传递的值存储在条目中,无论指定键的条目是否已经存在
         *  还有另外一个副作用就是:删除位于两个空槽之间的所有过期条目
         * @param  key 指定的健
         * @param  value 与给定健关联的值
         * @param  staleSlot 搜索过期条目的第一个过期条目索引
         */
        private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                                       int staleSlot) {
            Entry[] tab = table;
            int len = tab.length;
            Entry e;

            //搜索一下位于staleSlot 之前 的过期索引
           //slotToExpunge 有两个结果:①staleSlot,说明在staleSlot 之前的 run 没有过期条目
           // 不等于staleSlot ,说明staleSlot之前有过期条目
            int slotToExpunge = staleSlot;
            for (int i = prevIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = prevIndex(i, len))
                if (e.get() == null)
                    slotToExpunge = i;

            // 向后寻找与key 相等的条目
            for (int i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();

         
               //如果找到对应的key 
                if (k == key) {
                  //将e的值替换为我们要设置的值
                    e.value = value;
                    //把当前 i 位置的条目置为过期条目
                    tab[i] = tab[staleSlot];
                   //并将e 赋给staleSlot 
                    tab[staleSlot] = e;

                    // 若staleSlot 之前有过期条目,则从slotToExpunge 寻找,
                    // 否则从当前i 开始清理过期条目
                    if (slotToExpunge == staleSlot)
                        slotToExpunge = i;
                    //启发性地清理过期条目,在清理之前清理从slotToExpunge到下一个为null之间的过期条目,并重新hash 
                    cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                    return;
                }

               //如果在向后扫描时没有找到过时的条目,那么在扫描密钥时看到的第一个过时条目就是在运行时仍然存在的第一个过时条目。
                if (k == null && slotToExpunge == staleSlot)
                    slotToExpunge = i;
            }

            //如果key 没有找则新建一个条目放到staleSlot 的位置
            tab[staleSlot].value = null;
            tab[staleSlot] = new Entry(key, value);

            //如果找到了其他过期的条目则清除他们
            if (slotToExpunge != staleSlot)
                cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
        }

上面的注释还是比较清晰的,但是为了增强理解,我们还是理下流程:①搜索 staleSlot之前到空槽的之间过期条目,搜索到的下标定为 staleToExpunge (可能等于 staleSlot 说明之前没有过期条目)②从 staleSlot 之后寻找 key 对应的条目,如果找到目标条目 则将目标条目的值替换为要设置的值,并且将目标条目替换到staleSlot ,然后判断 staleSlot 之前有无过期条目,如果没有则从目标条目位置开始清理,否则从 staleToExpunge 开始清理。如果没有找到 key 对应的条目则新建一个条目放到 staleSlot 位置处。③ 若找到了其他过期条目则清除他们。
   下面我们来看看 ThreadLocalMap 中的核心函数 expungeStaleEntry ,该方法的参数为 第一个过期条目的索引staleSlot,该函数从该 staleSlot开始清理过期条目,并重新hash 没有清理的条目,直到为空结束。ThreadLocalMap 的 set、get 方法都会用到的方法,我们来看下它的源码:

       /**
         * 通过重新哈希位于staleSlot和下一个空槽之间的任何可能碰撞的条目,
          * 删除陈旧的条目。
         * @param staleSlot 已知key 为null 的下标
         * @return staleSlot之后的下一个空槽的索引
         * (所有在staleSlot和这个槽之间的都将被清除).
         */
        private int expungeStaleEntry(int staleSlot) {
            Entry[] tab = table;
            int len = tab.length;

            // 清除在staleSlot 位置上的条目
            tab[staleSlot].value = null;
            tab[staleSlot] = null;
            size--;

            //重新哈希,直到遇到null
            Entry e;
            int i;
            for (i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();
                //遇到 键为 为null 的,删除掉
                if (k == null) {
                    e.value = null;
                    tab[i] = null;
                    size--;
                } else {
                    //重新对健进行hash 放到指定的位置  
                    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;
        }

这里我就不过多赘述了,该方法执行的操作是比较单一的
   下面我们来看看 启发性清理函数 cleanSomeSlots ,该方法中会调用 expungeStaleEntry ,我们可以把它理解成 expungeStaleEntry 方法的复数形式,下面我们来看看源码:

      /**
         * 启发式地扫描一些单元格,寻找陈旧的条目。
         * 这是在添加新元素或删除另一个陈旧元素时调用的。
         * 它执行对数数量的扫描,
         * 以平衡无扫描(快速但保留垃圾)和与元素数量成比例的扫描次数,
         * 这将找到所有垃圾,但会导致一些插入花费O(n)时间。
         *
         * @param i  一个已知不是过期条目的位置,从i 之后扫描
         *
         * @param n 扫描控制:{@code log2(n)}单元格被扫描,除非找到一个过时的条 
         * 目,在这种情况下,{@code log2(table.length)-1}额外的单元格被扫描。当从 
         * 插入调用时,这个参数是元素的数量,但是当从replaceStaleEntry调用时,它 
         * 是表长度。(注意:所有这些都可以通过加权n来改变,而不是直接使用log n。
         * 但 是这个版本简单、快速,而且似乎工作得很好。)
         *
         * @return 如果删除了任何陈旧的条目,则返回true
         */
        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];
                //找到过期条目,就调用expungeStaleEntry 方法删除
                if (e != null && e.get() == null) {
                    n = len;
                    removed = true;
                    i = expungeStaleEntry(i);
                }
            //对n 进行左移1 位
            } while ( (n >>>= 1) != 0);
            return removed;
        }

cleanSomeSlots 方法从i 处开始 扫描 log2n 次,来清理过期条目,为什么扫描 log2n 次,为了平衡不扫描和扫描整个元素个数,真正清理的函数还是expungeStaleEntry,cleanSomeSlots 有个副作用就是会导致 set 方法的时间复杂度位O(n)。

ThreadLocaLMap::getEntry

   我们来探讨下 getEntry 方法会执行哪些操作,直接上源码:

       /**
         * 获取与key关联的条目。
         * 此方法本身只处理快速路径:直接命中现有键。
         * 否则,它就会转接到“事后”。
         * 这是为了最大限度地提高直接命中的性能而设计的,
         * 部分原因是通过使这种方法易于线性化。
         * @param  key  ThreadLocal 对象
         * @return 与key 关联的条目,或者为null
         */
        private Entry getEntry(ThreadLocal<?> key) {
           //计算key 应该在散列表的位置
            int i = key.threadLocalHashCode & (table.length - 1);
            Entry e = table[i];
           //若该位置不为空且key 刚好相等则返回条目e
            if (e != null && e.get() == key)
                return e;
            else
                 //否则基于线性探测法找到 与key相关的条目
                return getEntryAfterMiss(key, i, e);
        }

getEntry方法还是比较简单的,先根据 ThreadLocal 类型的健就算出 条目的下标 i ,然后若 i 处的条目 e 不为空且 key 等于 条目 e 的健,则直接返回条目 e.否则 基于线性探测法去找 key 关联的条目。下面我们来看下getEntryAfterMiss 方法:

       /**
         *  基于线性探测法找到key 对应的条目,
         *  顺便清理遇到的过期条目
         * @param  key ThreadLocal  对象
         * @param  i 键的哈希码的散列表索引
         * @param  e i 处的条目
         * @return 与key 相关联的条目,也可能为null
         */
        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;
        }

ThreadLocalMap::remove

下面我们来看下 删除 的方法,remove(ThreadLocal<?> key) ,该方法删除 key 对应的条目,顺便删除 key 在散列表中索引之后直到空槽之前的过期条目。来看下源码:

      /**
       *  删除key 对应的条目
       */
      private void remove(ThreadLocal<?> key) {
          Entry[] tab = table;
          int len = tab.length;
          int i = key.threadLocalHashCode & (len-1);
          //基于线性探测法找到key 对应的条目并清除
          for (Entry e = tab[i];
               e != null;
               e = tab[i = nextIndex(i, len)]) {
              if (e.get() == key) {
                  e.clear();
                  //删除从i 开始到空槽之间的过期条目
                  expungeStaleEntry(i);
                  return;
              }
          }
      }

总结

   通过对 ThreadLocalMap 核心源码的探讨,我们有个比较细致的理解,知道该散列表的 key 引用为弱类型,但是 value 引用 却为强引用,这就可能导致OOM了,哪钟场景下会导致OOM呢,大家可以在评论区写出来一起探讨哦,希望大家有收获。

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

推荐阅读更多精彩内容

  • 生命本是一场偶然你是我前世的遇见满嘴方言似懂非懂为江南看不见的牵挂镶嵌在天边我不是那山坡上变换的云彩世间所有的东西...
    守海入梦阅读 2,431评论 59 82
  • 入职新公司,上班第一天就开始接触公司的产品项目。之前项目是由一位后端大佬写的,因为要写公众号和小程序,所有当初选技...
    隔壁老樊啊阅读 1,514评论 1 3
  • 前言 时间管理是什么?秋叶老师在时间管理特训营中给出时间管理的本质是:如何分配自己的时间,从而使自己的人生更加符合...
    恒易勇泉阅读 1,487评论 0 0
  • 有些深刻的情绪总是和音乐在一起。 上大学的第一年,那时成都距离重庆还不是1个半小时的动车,而是12个小时的铁皮车。...
    路路英来合作小书集阅读 136评论 0 0
  • 古人说,书中自有黄金屋,书中自有颜如玉,书中自有千钟粟。 所以我从小就认为书是一个很神奇的东西。 当然,让我感觉神...
    泡泡的根据地阅读 1,203评论 1 4