线程本地存储ThreadLocal的原理及应用

概述

通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢? JDK 中提供的ThreadLocal类正是为了解决这样的问题。 **ThreadLocal类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。**每个线程都拥有了自己的数据这样也就避免了数据共享保证了线程安全。

如何用?

我们都知道SimpleDateFormat是线程不安全,但如果我们想在并发的场景中使用它,应该怎么办呢?

最简单的方法就是可以通过ThreadLocal给每个线程都分配一个SimpleDateFormat这样也就从根本上解决了共享的问题,可以保证SimpleDateFormat在并发场景下的线程安全,具体来说,我们可以看下边这段代码:

public class ThreadLocalExample implements Runnable{

    // SimpleDateFormat 不是线程安全的,所以每个线程都要有自己独立的副本
    private static final ThreadLocal<SimpleDateFormat> formatter = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyyMMdd HHmm"));

    public static void main(String[] args) throws InterruptedException {
        ThreadLocalExample obj = new ThreadLocalExample();
        for(int i=0 ; i<10; i++){
            Thread t = new Thread(obj, ""+i);
            Thread.sleep(new Random().nextInt(1000));
            t.start();
        }
    }

    @Override
    public void run() {
        System.out.println("Thread Name= "+Thread.currentThread().getName()+" default Formatter = "+formatter.get().toPattern());
        try {
            Thread.sleep(new Random().nextInt(1000));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //formatter pattern is changed here by thread, but it won't reflect to other threads
        formatter.set(new SimpleDateFormat());

        System.out.println("Thread Name= "+Thread.currentThread().getName()+" formatter = "+formatter.get().toPattern());
    }

}

在这段代码中,我们在线程开启之前创建了名为formatterThreadLocal对象,该对象初始化时为每个线程都放置了SimpleDateFormat对象。在具体使用的时候发现,虽然后边线程在执行期间对当前线程的SimpleDateFormat()对象进行了修改,但丝毫不影响其他线程的SimpleDateFormat对象的值。其具体结果如下所示:

Thread Name= 0 default Formatter = yyyyMMdd HHmm
Thread Name= 0 formatter = y/M/d ah:mm
Thread Name= 1 default Formatter = yyyyMMdd HHmm
Thread Name= 1 formatter = y/M/d ah:mm
Thread Name= 2 default Formatter = yyyyMMdd HHmm
Thread Name= 3 default Formatter = yyyyMMdd HHmm
Thread Name= 2 formatter = y/M/d ah:mm
Thread Name= 4 default Formatter = yyyyMMdd HHmm
Thread Name= 4 formatter = y/M/d ah:mm
Thread Name= 5 default Formatter = yyyyMMdd HHmm
Thread Name= 3 formatter = y/M/d ah:mm
Thread Name= 6 default Formatter = yyyyMMdd HHmm
Thread Name= 5 formatter = y/M/d ah:mm
Thread Name= 6 formatter = y/M/d ah:mm
Thread Name= 7 default Formatter = yyyyMMdd HHmm
Thread Name= 7 formatter = y/M/d ah:mm
Thread Name= 8 default Formatter = yyyyMMdd HHmm
Thread Name= 8 formatter = y/M/d ah:mm
Thread Name= 9 default Formatter = yyyyMMdd HHmm
Thread Name= 9 formatter = y/M/d ah:mm

如何实现?

从例子代码上看,ThreadLocal对象似乎用起来很容易,初始化之后,用的时候直接get(),需要修改的时候执行put()。但如果让我们来设计这个ThreadLocal类,我们该如何实现呢?

聪明的小伙伴可能很容易想到下边这种实现思路:

ThreadLocal内部持有一个"HConcurrentHashMap" 对象,然后key为线程对象,value为ThreadLocal中存放的值。示意图如下所示:

image.png

class MyThreadLocal<T> {
  Map<Thread, T> locals = 
    new ConcurrentHashMap<>();
  //获取线程变量  
  T get() {
    return locals.get(
      Thread.currentThread());
  }
  //设置线程变量
  void set(T t) {
    locals.put(
      Thread.currentThread(), t);
  }
}

聊聊十几行代码就是实现了我们心中想的ThreadLocal,但这是设计真的好吗?

我们接下来看看JDK中实际上是如何设计ThreadLocal的:

首先我们点进源码,看一下ThreadLoca类的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);
}
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

从源码中可以看到,ThreadLocal在放一个对象的时候,不是直接放到了自己所持有的Map对象中,而是从Thread中取到了一个Map。

image.png

我们接着看Thread源码,探索一下,这个ThreadLocalMap是怎么回事:

public class Thread implements Runnable {
    //......
    //与此线程有关的ThreadLocal值。由ThreadLocal类维护
    ThreadLocal.ThreadLocalMap threadLocals = null;

    //与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
    //......
}

从源码中,可以看到ThreadLocalMapThreadLocal子类,但持有者是Thread对象,并且Thread类持有两个ThreadLocalMap对象,第一个threadLocals存储与本线程相关的ThreadLocal值,另一个是inheritableThreadLocals用来存放继承过来的ThreadLocal的值。

我们可以简单理出Thread和ThreadLocal的关系图:

image.png

也就是说Java在实现里面也有一个Map,叫做ThreadLocalMap,不过持有ThreadLocalMap的并不是ThreadLocal,而是Thread。Thread这个类内部有一个私有的属性threadLocals,其类型就是ThreadLocalMap,在存储的时候key为ThreadLocal对象,value为Object对象(比如例子中的SimpDataFormate对象)。

当同一个线程中声明了两个ThreadLocal对象的话,实际上存放value值得时候还是存放在Thread所持有的那个ThreadLocalMap里边,只是key不同,key为对应的ThreadLocal对象,其具体示意图如下所示:

image.png

此时我们可能就有疑问了,为啥要让Thread持有ThreadLocalMap对象呢?ThreadLocal为啥不持有呢?

image.png

主要有原因可以从两方面思考:

首先从数据的亲缘性的角度来看,ThreadLocal中存放的数据是和Thread高度相关,因此数据存放在Thread内部更合适,另一方面,我们从垃圾回收的角度来考虑,如果某个Thread被回收,其存放在ThreadLocal中的对象,毫无疑问也应该一起被回收。但如果我们让ThreadLocal直接持有数据,只要 ThreadLocal 对象存在,那么 Map 中的 Thread 对象就永远不会被回收。ThreadLocal 的生命周期往往都比线程要长,所以这种设计方案很容易导致内存泄露。

而JDK实现的ThreadLocal中,Thread持有ThreadLocalMap,并且ThreadLocalMap里面使用的key为ThreadLocal为弱引用因此只有Thread对象被回收,那么其持有的ThreadLocalMap就能够被回收。

**但实际在使用的时候仍然会有内存泄露的风险,这是为什么呢?**❓

因就出在线程池中线程的存活时间太长,往往都是和程序同生共死的,这就意味着 Thread 持有的 ThreadLocalMap 一直都不会被回收,再加上 ThreadLocalMap 中的 Entry 对 ThreadLocal 是弱引用(“弱WeakReference”),所以只要 ThreadLocal 结束了自己的生命周期是可以被回收掉的。但是 Entry 中的 Value 却是被 Entry 强引用的,所以即便 Value 的生命周期结束了,Value 也是无法被回收的,从而导致内存泄露。也就说会出现下面这种情形:

image.png

如何解决这个问题呢?

很容易想到,既然JVM无法释放,那我们可以在对象使用完毕之后从ThreadLocal中移除,比如可以通过try{}finally{}方案:

ExecutorService es;
ThreadLocal tl;
es.execute(()->{
  //ThreadLocal增加变量
  tl.set(obj);
  try {
    // 省略业务逻辑代码
  }finally {
    //手动清理ThreadLocal 
    tl.remove();
  }
});

总结

ThreadLocal通过存储的思想来解决数据共享可能导致并发的问题,在使用时,ThreadLocal中存放的数据和线程高度绑定,每个线程都拥有独立的资源。在具体实现时,是通过Thread持有ThreadLocal的内部类对象ThreadLocalMap对象,并且ThreadLocalMap的Key为ThreadLocal对象,并且为弱引用,因而当Thread被回收时,ThreadLocalMap会一起被回收。但这样也存在一个问题,如果Thread存活时间过长,弱引用key对应的ThreadLocal对象可以被回收掉,但是其所对应Value因为是强引用一直无法回收,可能会造成内存泄露的问题。而解决这个问题的方法也很简单,就是在使用完对象之后可以手动将该对象从ThreadLocal中移除。

参考

  1. 《线程本地存储模式:没有共享,就没有伤害》
  2. 《2020最新Java并发进阶常见面试题总结》
  3. ThreadLocal 关键字解析》
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值