ThreadLocal原理解析与注意事项

一、引言

ThreadLocal是并发场景下用来解决变量共享问题的类,它能使原本线程间共享的对象进行线程隔离,即一个对象只对一个线程可见。但由于过度设计,比如使用弱引用和哈希碰撞,导致理解难度大、使用成本高,反而成为故障高发点,容易出现内存泄漏、脏数据、共享对象更新等问题。

本文从Java引用类型、ThreadLocal源码解析、ThreadLocal使用注意事项三个方面展开。首先来看一段ThreadLocal的使用示例:

// ThreadLocal使用示例
public class ThreadLocalUtil {
    private static final ThreadLocal<Integer> testThreadLocal = new ThreadLocal<>();

    public static void main(String[] args) {
        CyclicBarrier barrier = new CyclicBarrier(4);
        for (int i = 0; i < 4; i ++) {
            new Thread(new TestThread(barrier)).start();
        }
    }

    static class TestThread implements Runnable{
        private CyclicBarrier barrier;

        public TestThread(CyclicBarrier barrier) {
            this.barrier = barrier;
        }
        
        @Override
        public void run() {
            try {
                barrier.await();
                for (int i = 0; i < 100; i++) {
                    Integer value = testThreadLocal.get();
                    if (value == null) {
                        value = 0;
                    }
                    Integer sum = value + i;
                    testThreadLocal.set(sum);
                }
                System.out.println(Thread.currentThread().getName() + " sum is " + testThreadLocal.get());
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

从运行结果可以看出,每个线程的求和结果都是4950,线程之间没有相互影响。

二、引用类型

前面有篇文章介绍过JVM垃圾回收的可达性分析机制。对象在堆上创建之后所持有的引用是一种变量类型,引用的可达性是判断能否被垃圾回收的基本条件。我们可以把引用分为强引用、软引用、弱引用和虚引用四类。

  • 强引用(Strong Reference):最为常见。如Object object = new Object();这样的变量声明和定义就会产生对该对象的强引用。只要对象有强引用指向,并且GC Roots可达,那么Java内存回收时,即使濒临内存耗尽,也不会回收该对象。
  • 软引用(Soft Reference):引用力度弱于强引用,用于非必须对象上。在即将OOM之前,垃圾回收器会把这些软引用指向的对象加入回收范围,以获得更多的内存空间。软引用主要用来缓存中间计算结果及不需要实时保存的用户行为等。
  • 弱引用(Weak Reference):引用强度较前两者更弱,也是用来描述非必需对象的。如果弱引用指向的对象只存在弱引用这条线路,则在下一次YGC时会被回收。由于YGC时间的不确定性,弱引用何时被回收也具有不确定性。调用 WeakReference. get()可能返回null,要注意空指针异常。
  • 虚引用(Phantom Reference):是极弱的一种引用关系,定义完成后就无法通过该引用获取指向的对象。为一个对象设置虚引用的唯一目的就是希望能在这个对象被回收时收到一个系统通知。虚引用必须与引用队列联合使用,当垃圾回收时,如果发现存在虚引用,就会在回收对象内存前,把这个虚引用加入与之关联的引用队列中。

除强引用外,其他三种引用可以减少对象在生命周期中所占用的内存大小。使用这些引用,需要注意强引用劫持、内存泄漏等问题。

三、ThreadLocal原理

自己实现ThreadLocal?

如果让我们自己实现一个ThreadLocal,可能最直接的想法就是维护一个map,将线程作为key来获取该线程的value,例如下面这段代码:

public class MyThreadLocal<T> {
 
    private Map<Thread, T> keyValueMap = new WeakHashMap<>();
 
    public synchronized void set(T value) {
        Thread thread = Thread.currentThread();
        keyValueMap.put(thread, value);
    }
    
    public synchronized T get() {
        Thread thread = Thread.currentThread();
        return keyValueMap.get(thread);
    }
    
    public synchronized void remove() {
        Thread thread = Thread.currentThread();
        keyValueMap.remove(thread);
    }
}

这段代码最大的问题就是synchronized的使用会导致并发性能较差。那么,jdk中的ThreadLocal是如何实现的呢?

ThreadLocal类源码分析

set方法

先来看set方法,源码如下:

    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }
 
    void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }
 
    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }

可以看到,set方法是通过一个ThreadLocalMap对象来set值,而ThreadLocalMap虽然是ThreadLocal的静态内部类,却是Thread类中的成员变量。这跟我们设想的完全不一样!

get方法

再来看get方法,源码:

    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();
    }
 
    private T setInitialValue() {
        T value = initialValue();
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
        return value;
    }
 
    protected T initialValue() {
        return null;
    }

这里get方法也是将操作委托给了ThreadLocalMap,通过最终获得ThreadLocalMap.Entry来获取最终的值。

remove方法

最后来看remove方法,同样是交给ThreadLocalMap来处理

     public void remove() {
         ThreadLocalMap m = getMap(Thread.currentThread());
         if (m != null)
             m.remove(this);
     }

ThreadLocalMap源码分析

上文说过,ThreadLocalMap是线程的私有成员变量。我理解这样做是为了避免多线程竞争,因为放在Thread对象中就相当于线程私有了,处理的时候不需要加锁。由于ThreadLocal本身的设计就是变量不与其他线程共享,不需要其他线程访问本对象的变量,放在Thread对象中不会有问题。

Entry数据结构

ThreadLocalMap维护了一个Entry类型的数据结构:

        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;
            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
        private Entry[] table;

Entry是一个以ThreadLocal为key,Object为value的键值对。需要注意的是,threadLocal是弱引用,即使线程正在执行中,只要ThreadLocal对象引用被置成null, Entry的Key就会自动在下一次YGC时被垃圾回收。而在 ThreadLocal使用set()和get()时,又会自动地将那些 key==null 的value置为null,使value能够被垃圾回收,避免内存泄漏。

但是理想很丰满,现实很骨感,ThreadLocal也因为这样的设计导致了一些问题,下文会讲到。

set方法

    private void set(ThreadLocal<?> key, Object value) { 
        Entry[] tab = table;
        int len = tab.length;
        // 定位Entry存放的位置
        int i = key.threadLocalHashCode & (len-1);
        // 处理hash冲突的情况,这里采用的是开放地址法
        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;
            }
        }
        // 新建entry并插入
        tab[i] = new Entry(key, value);
        int sz = ++size;
        // 清除脏数据,扩容
        if (!cleanSomeSlots(i, sz) && sz >= threshold)
            rehash();
    }

get方法

    private Entry getEntry(ThreadLocal<?> key) {
        // 确定entry位置
        int i = key.threadLocalHashCode & (table.length - 1);
        Entry e = table[i];
        // 命中
        if (e != null && e.get() == key)
            return e;
        else
            // 存在hash冲突,继续查找
            return getEntryAfterMiss(key, i, e);
    }
 
    private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
        Entry[] tab = table;
        int len = tab.length;
 
        while (e != null) {
            ThreadLocal<?> k = e.get();
            //找到entry
            if (k == key)
                return e;
            // 脏数据处理
            if (k == null)
                expungeStaleEntry(i);
            else
                //遍历
                i = nextIndex(i, len);
            e = tab[i];
        }
        return null;
    }

remove方法

   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) {
                //清空key
                e.clear();
                //清空value
                expungeStaleEntry(i);
                return;
            }
        }
    }

回头梳理

我们再回头梳理一下ThreadLocal和Thread的类关系图与堆栈关系图:

类关系图
堆栈关系图

上图中的关系简要概括:

  • 1个Thread有且仅有1个ThreadLocalMap对象
  • 1个Entry对象的key弱引用指向1个ThreadLocal对象
  • 1个ThreadLocalMap对象存储多给Entry对象
  • 1个ThreadLocal对象可以被多个结程所共享
  • ThreadLocal对象不持有Value,Value由线程的Entry对象持有

四、ThreadLocal注意事项

脏数据

线程复用会产生脏数据。由于结程池会重用Thread对象,那么与Thread绑定的类的静态属性ThreadLocal变量也会被重用。如果在实现的线程run()方法体中不显式地调用remove() 清理与线程相关的ThreadLocal信息,那么倘若下一个结程不调用set() 设置初始值,就可能get() 到重用的线程信息,包括 ThreadLocal所关联的线程对象的value值。

内存泄漏

通常我们会使用使用static关键字来修饰ThreadLocal(这也是在源码注释中所推荐的)。在此场景下,其生命周期就不会随着线程结束而结束,寄希望于ThreadLocal对象失去引用后,触发弱引用机制来回收Entry的Value就不现实了。如果不进行remove() 操作,那么这个线程执行完成后,通过ThreadLocal对象持有的对象是不会被释放的。

以上两个问题的解决办法很简单,就是在每次用完ThreadLocal时, 必须要及时调用 remove()方法清理。

父子线程共享线程变量

很多场景下通过ThreadLocal来透传全局上下文,会发现子线程的value和主线程不一致。比如用ThreadLocal来存储监控系统的某个标记位,暂且命名为traceId。某次请求下所有的traceld都是一致的,以获得可以统一解析的日志文件。但在实际开发过程中,发现子线程里的traceld为null,跟主线程的并不一致。这就需要使用InheritableThreadLocal来解决父子线程之间共享线程变量的问题,使整个连接过程中的traceId一致。

参考资料

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

推荐阅读更多精彩内容