ThreadLocal详解

介绍

  • 顾名思义这个类提供线程局部变量
  • 每个线程(通过其get或set方法)都有自己独立初始化的变量副本

ThreadLocal思想

在多线程环境下,不同的线程同时访问同一个共享变量会有并发问题。一种解决方法是进行同步,例如使用synchronized。另外一种比较常见的形式就是局部(local)变量(这里排除局部变量引用指向共享对象的情况),这样资源就不是被两个线程共享,那么也不会出现竞争问题。

自定义类实现ThreadLocal的功能

一个简单的思路是使用 Map 存储每个变量的副本,将当前线程的 Name 作为 key,副本变量作为 value 值:

public class Test {

    /** 用于存储每个线程对应的数据 */
    public static class CustomThreadLocal{
        public final Map<String,Integer> cacheValueMap=new HashMap<String, Integer>();
        private int defaultValue;

        public CustomThreadLocal(int value){
            defaultValue=value;
        }
        public void set(Integer value){
            cacheValueMap.put(Thread.currentThread().getName(),value);
        }
        public Integer get(){
            String threadName=Thread.currentThread().getName();
            if(cacheValueMap.containsKey(threadName)){
                return cacheValueMap.get(threadName);
            }
           return defaultValue;
        }
    }

    /** 数据资源类,提供进行加减操作*/
    public static class Number {
        private CustomThreadLocal value = new CustomThreadLocal(0);

        public void increase() throws InterruptedException {
            value.set(10);
            Thread.sleep(10);
            System.out.println("increase value: " + value.get());
        }
        public void decrease() throws InterruptedException {
            value.set(-10);
            Thread.sleep(10);
            System.out.println("decrease value: " + value.get());
        }
    }

    public static void main(String[] args) {

      final  Number number=new Number();

        Thread increaseThread=new Thread(new Runnable() {
            public void run() {
                try {
                    number.increase();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"th1");

        Thread decreaseThread=new Thread(new Runnable() {
            public void run() {
                try {
                    number.decrease();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"th2");

        increaseThread.start();
        decreaseThread.start();
    }
}

这种写法存在的问题:

  • 即便线程执行完,只要 number 变量存在,线程的副本变量依然会存在(存放在 number 的 cacheMap 中)。
  • 多个线程有可能会同时操作 cacheMap,需要对 cacheMap 进行同步处理

为了解决上面的问题,我们换种思路,每个线程创建一个 Map,存放当前线程中副本变量,用 CustomThreadLocal 的实例作为 key 值,下面是一个示例:

public class Test {

   /** 自定义线程,并定义map存放当前线程中副本变量 */
   public static class ManualThread extends Thread{
       public final Map<Integer,Integer> cacheValueMap=new HashMap<Integer, Integer>();
   }

   /** 通过实例本身映射出线程的副本数据,并对其进行操作 */
   public static class CustomThreadLocal{
       private int defaultValue;

       public CustomThreadLocal(int value){
           defaultValue=value;
       }
       public void set(Integer value){
           Integer id = this.hashCode();
           Map<Integer, Integer> cacheMap = getMap();
           cacheMap.put(id, value);
       }
       public Integer get(){
           Integer id = this.hashCode();
           Map<Integer, Integer> cacheMap = getMap();
           if (cacheMap.containsKey(id)) {
               return cacheMap.get(id);
           }
           return defaultValue;
       }
       //注意这个方法
       public Map<Integer, Integer> getMap() {
           ManualThread thread = (ManualThread) Thread.currentThread();
           return thread.cacheValueMap;
       }
   }

   /** 数据资源类,提供进行加减操作*/
   public static class Number {
       private CustomThreadLocal value = new CustomThreadLocal(0);

       public void increase() throws InterruptedException {
           value.set(10);
           Thread.sleep(10);
           System.out.println("increase value: " + value.get());
       }
       public void decrease() throws InterruptedException {
           value.set(-10);
           Thread.sleep(10);
           System.out.println("decrease value: " + value.get());
       }
   }

   public static void main(String[] args) {

     final  Number number=new Number();

     //使用自定义线程类
       Thread increaseThread=new ManualThread(){
           public void run() {
               try {
                   number.increase();
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
       };

       Thread decreaseThread=new ManualThread(){
           public void run() {
               try {
                   number.decrease();
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
       };

       increaseThread.start();
       decreaseThread.start();
   }
}

这种写法,当线程消亡之后,线程中存放的副本变量也会被全部回收,并且 cacheMap 是线程私有的,不会出现多个线程并发问题。在 Java 中,ThreadLocal 类的实现就是采用的这种思想,注意只是思想,实际的实现和上面的并不一样

基本原理

ThreadLocal 的实现思想,我们在前面已经说了,每个线程维护一个 ThreadLocalMap 的映射表,映射表的 key 是 ThreadLocal 实例本身,value 是要存储的副本变量。ThreadLocal 实例本身并不存储值,它只是提供一个在当前线程中找到副本值的 key。 如下图所示:


image.png

API总览

get函数用来获取与当前线程关联的ThreadLocal的值,如果当前线程没有该ThreadLocal的值,则调用initialValue函数获取初始值返回,initialValue是protected类型的,所以一般我们使用时需要继承该函数,给出初始值。而set函数是用来设置当前线程的该ThreadLocal的值,remove函数用来删除ThreadLocal绑定的值,在某些情况下需要手动调用,防止内存泄露

关键点分析

ThreadLocal 散列值

当创建了一个 ThreadLocal 的实例后,它的散列值就已经确定了,下面是 ThreadLocal 中的实现

private final int threadLocalHashCode = nextHashCode();
private static AtomicInteger nextHashCode =new AtomicInteger();
private static final int HASH_INCREMENT = 0x61c88647;
private static int nextHashCode() {
    return nextHashCode.getAndAdd(HASH_INCREMENT);
}

我们看到 threadLocalHashCode 是一个常量,它通过 nextHashCode() 函数产生。nextHashCode() 函数其实就是在一个 AtomicInteger 变量(初始值为0)的基础上每次累加 0x61c88647,使用 AtomicInteger 为了保证每次的加法是原子操作。而 0x61c88647 这个就比较神奇了,它可以使 hashcode 均匀的分布在大小为 2 的 N 次方的数组里。
具体散列测试请看https://www.jianshu.com/p/fe9ffcf51f4b

ThreadLocalMap

被定义为一个静态类,包含的主要成员:

  • 首先是Entry的定义;
  • 初始的容量为INITIAL_CAPACITY = 16;
  • 主要数据结构就是一个Entry的数组table;
  • size用于记录Map中实际存在的entry个数;
  • threshold是扩容上限,当size到达threashold时,需要resize整个Map,threshold的初始值为len * 2 / 3;
  • nextIndex和prevIndex则是为了安全的移动索引

Entry

  • ThreadLocalMap 使用 Entry 类来存储数据
  • entry的key是ThreadLocal实例,value是Object(即我们所谓的“线程本地数据”)
  • 为避免占用空间较大或生命周期较长的数据常驻于内存引发一系列问题,hash table的key是弱引用WeakReferences
static class Entry extends WeakReference <ThreadLocal <?>> {
    /** The value associated with this ThreadLocal. */
    Object value;
    Entry(ThreadLocal <?> k, Object v) {
        super(k);
        value = v;
    }
}

set 函数

private void set(ThreadLocal <?> key, Object value) {
    Entry[] tab = table;
    int len = tab.length;
    // 根据 ThreadLocal 的散列值,查找对应元素在数组中的位置
    int i = key.threadLocalHashCode & (len - 1);
    // 使用线性探测法查找元素
    for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
        ThreadLocal <?> k = e.get();
        // ThreadLocal 对应的 key 存在,直接覆盖之前的值
        if (k == key) {
            e.value = value;
            return;
        }
        // key为 null,但是值不为 null,说明之前的 ThreadLocal 对象已经被回收了,当前数组中的 Entry 是一个陈旧(stale)的元素
        if (k == null) {
            // 用新元素替换陈旧的元素,这个方法进行了不少的垃圾清理动作,防止内存泄漏
            replaceStaleEntry(key, value, i);
            return;
        }
    }
    // ThreadLocal 对应的 key 不存在并且没有找到陈旧的元素,则在空元素的位置创建一个新的 Entry。
    tab[i] = new Entry(key, value);
    int sz = ++size;
    // cleanSomeSlot 清理陈旧的 Entry(key == null),具体的参考源码。如果没有清理陈旧的 Entry 并且数组中的元素大于了阈值,则进行 rehash。
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

set函数注意点

  • int i = key.threadLocalHashCode & (len - 1);,这里实际上是对 len-1 进行了取余操作。之所以能这样取余是因为 len 的值比较特殊,是 2 的 n 次方,减 1 之后低位变为全 1,高位变为全 0。例如 16,减 1 之后对应的二进制为: 00001111,这样其他数字中大于 16 的部分就会被 0 与掉,小于 16 的部分就会保留下来,就相当于取余了。
  • 在 replaceStaleEntry 和 cleanSomeSlots 方法中都会清理一些陈旧的 Entry,防止内存泄漏
    threshold 的值大小为 threshold = len * 2 / 3;
  • rehash 方法中首先会清理陈旧的 Entry,如果清理完之后元素数量仍然大于 threshold 的 3/4,则进行扩容操作(数组大小变为原来的 2倍)
private void rehash() {
    expungeStaleEntries();
    // Use lower threshold for doubling to avoid hysteresis
    if (size >= threshold - threshold / 4)
        resize();
}

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

因为 ThreadLocalMap 中采用开放定址法,所以当前 key 的散列值和元素在数组中的索引并不一定完全对应。所以在 get 的时候,首先会看 key 的散列值对应的数组元素是否为要查找的元素,如果不是,再调用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;
}

所以首先e如果为null的话,那么getEntryAfterMiss还是直接返回null的,如果是不满足e.get() == key,那么进入while循环,这里是不断循环,如果e一直不为空,那么就调用nextIndex,不断递增i,在此过程中一直会做两个判断:

  1. 如果k==key,那么代表找到了这个所需要的Entry,直接返回;
  2. 如果k==null,那么证明这个Entry中key已经为null,那么这个Entry就是一个过期对象,这里调用expungeStaleEntry清理该Entry。 这里就解答了导致内存泄露的原因,即ThreadLocal Ref销毁时,ThreadLocal实例由于只有Entry中的一条弱引用指着,那么就会被GC掉,Entry的key没了,value可能会内存泄露的,其实在每一个get,set操作时都会不断清理掉这种key为null的Entry的。

为什么循环查找

主要是因为处理哈希冲突的方法,我们都知道HashMap采用拉链法处理哈希冲突,即在一个位置已经有元素了,就采用链表把冲突的元素链接在该元素后面,而ThreadLocal采用的是开放地址法,即有冲突后,把要插入的元素放在要插入的位置后面为null的地方,具体关于这两种方法的区别可以参考:解决哈希(HASH)冲突的主要方法。所以上面的循环就是因为我们在第一次计算出来的i位置不一定存在key与我们想查找的key恰好相等的Entry,所以只能不断在后面循环,来查找是不是被插到后面了,直到找到为null的元素,因为若是插入也是到null为止的。

分析完循环的原因,其实也可以深入expungeStaleEntry看看是怎么清理的。

expungeStaleEntry

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

看上面这段代码主要有两部分:

  1. expunge entry at staleSlot:这段主要是将i位置上的Entry的value设为null,Entry的引用也设为null,那么系统GC的时候自然会清理掉这块内存;
  2. Rehash until we encounter null: 这段就是扫描位置staleSlot之后,null之前的Entry数组,清除每一个key为null的Entry,同时若是key不为空,做rehash,调整其位置。

为什么要做rehash呢

因为我们在清理的过程中会把某个值设为null,那么这个值后面的区域如果之前是连着前面的,那么下次循环查找时,就会只查到null为止。

举个例子就是:...,<key1(hash1), value1>, <key2(hash1), value2>,...(即key1和key2的hash值相同) 此时,若插入<key3(hash2), value3>,其hash计算的目标位置被<key2(hash1), value2>占了,于是往后寻找可用位置,hash表可能变为: ..., <key1(hash1), value1>, <key2(hash1), value2>, <key3(hash2), value3>, ... 此时,若<key2(hash1), value2>被清理,显然<key3(hash2), value3>应该往前移(即通过rehash调整位置),否则若以key3查找hash表,将会找不到key3

remove方法

删除其实就是将 Entry 的键值设为 null,变为陈旧的 Entry。然后调用 expungeStaleEntry 清理陈旧的 Entry

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

副本变量存取

ThreadLocal的set、get方法

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,将 ThreadLocal 实例作为键值传入 Map,然后就是进行相关的变量存取工作了。线程中的 ThreadLocalMap 是懒加载的,只有真正的要存变量时才会调用 createMap 创建,下面是 createMap 的实现:

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

如果想要给 ThreadLocal 的副本变量设置初始值,需要重写 initialValue 方法,如下面的形式:

ThreadLocal <Integer> threadLocal = new ThreadLocal() {
    protected Integer initialValue() {
        return 0;
    }
};

SuppliedThreadLocal

SuppliedThreadLocal是JDK8新增的内部类,只是扩展了ThreadLocal的初始化值的方法而已,允许使用JDK8新增的Lambda表达式赋值。需要注意的是,函数式接口Supplier不允许为null

static final class SuppliedThreadLocal<T>extends ThreadLocal<T>{
    private final Supplier<?extends T> supplier;
     SuppliedThreadLocal(Supplier<?extends T> supplier){
       this.supplier= Objects.requireNonNull(supplier);
   }
     @Override
   protected T initialValue(){
       return supplier.get();
   }
}

总结

为什么会内存泄漏

ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用来引用它,那么系统 GC 的时候,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现keynullEntry,就没有办法访问这些keynullEntryvalue,如果当前线程再迟迟不结束的话,这些keynullEntryvalue就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永远无法回收,造成内存泄漏。

怎么避免内存泄漏

每次使用完ThreadLocal,都调用它的remove()方法,清除数据

为什么没有next

ThreadLocalMap 中使用开放地址法来处理散列冲突,而 HashMap 中使用的分离链表法。之所以采用不同的方式主要是因为:在 ThreadLocalMap 中的散列值分散的十分均匀,很少会出现冲突。并且 ThreadLocalMap 经常需要清除无用的对象,使用纯数组更加方便。所以不需要next

为什么使用弱引用

因为如果这里使用普通的key-value形式来定义存储结构,实质上就会造成节点的生命周期与线程强绑定,只要线程没有销毁,那么节点在GC分析中一直处于可达状态,没办法被回收,而程序本身也无法判断是否可以清理节点。弱引用是Java中四档引用的第三档,比软引用更加弱一些,如果一个对象没有强引用链可达,那么一般活不过下一次GC。当某个ThreadLocal已经没有强引用可达,则随着它被垃圾回收,在ThreadLocalMap里对应的Entry的键值会失效,这为ThreadLocalMap本身的垃圾清理提供了便利。

如何清理entry

调用expungeStaleEntry进行实现

使用场景及方式

  • 主要应用场景为按线程多实例(每个线程对应一个实例)的对象的访问,并且这个对象很多地方都要用到,例如数据库连接、Session管理、日志的uniqueID等

使用注意事项

  • ThreadLocal实例通常来说都是private static类型
  • ThreadLocal并未解决多线程访问共享对象的问题,而是为每个线程创建一个单独的变量副本,提供了保持对象的方法和避免参数传递的复杂性;
  • ThreadLocal并不是每个线程拷贝一个对象,而是直接new(新建)一个;
  • 如果ThreadLocal.set()的对象是多线程共享的,那么还是涉及并发问题。
  • 过度使用ThreadLocal很容易加大类之间的耦合度与依赖关系(开发过程可能会不得不过度考虑某个ThreadLocal在调用时是否已有值,存放的是哪个类放的什么值)
  • 应用一定要自己负责 remove,并且不要和线程池配合,因为woker线程往往是不会退出的。

参考地址

http://blog.zhangjikai.com/2017/03/29/%E3%80%90Java-%E5%B9%B6%E5%8F%91%E3%80%91%E8%AF%A6%E8%A7%A3-ThreadLocal
http://blog.xiaohansong.com/2016/08/06/ThreadLocal-memory-leak/
https://juejin.im/post/5a5efb1b518825732b19dca4#heading-9
https://www.cnblogs.com/micrari/p/6790229.html

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

推荐阅读更多精彩内容

  • 1. 概念 ThreadLocal 用于提供线程局部变量,在多线程环境可以保证各个线程里的变量独立于其它线程里的变...
    zly394阅读 1,695评论 0 1
  • 前言 ThreadLocal很多同学都搞不懂是什么东西,可以用来干嘛。但面试时却又经常问到,所以这次我和大家一起学...
    liangzzz阅读 12,419评论 14 228
  • Android Handler机制系列文章整体内容如下: Android Handler机制1之ThreadAnd...
    隔壁老李头阅读 7,599评论 4 30
  • ThreadLocal是一个关于创建线程局部变量的类。 通常情况下,我们创建的变量是可以被任何一个线程访问并修改的...
    icecrea阅读 716评论 0 2
  • 秋讯 也许!你早应该相信 大地上布满了 明亮的波纹—— 它们像回声一样湮开 吞没了湿湿的 苦颜色草叶 大口大口喘气...
    爱亦如诗阅读 293评论 0 1