重新学习了一遍ThreadLocal

本文是学习极客时间每日一课《ThreadLocal原理分析及内存泄漏演示》和《ThreadLocal如何在父子线程及线程池中传递?》的学习笔记。

这两节小课主要讨论了以下几个问题:

  • 通过ThreadLocal来实现本地变量的原理
  • ThreadLocal内存泄漏的原因分析和演示
  • ThreadLocal内存泄漏的解决方案
  • 子线程如何获取父线程的本地变量
  • 使用线程池时,子线程如何获取到最新的父线程的本地变量

通过ThreadLocal来实现本地变量的原理

在看ThreadLocal的本地变量实现原理之前,我们首先需要了解的是Java语言中引用相关的知识,这样更利于学习ThreadLocal相关的知识。

Java中的四种引用关系

自JDK开始,Java提供了以下四种引用关系:

  • 强引用(Stong Reference):对象一直存活,只要有强引用在,GC就不会回收被引用对象。
  • 软引用(Soft Reference):对象有一次存货的机会,在系统将要发生内存溢出之前,JVM垃圾收集器将会把这些对象实例进行第二次回收,如果这次回收没有足够的内存,会抛出内存溢出异常。
  • 弱引用(Weak Reference):被弱引用关联的对象只能存活到下一次垃圾收集发生之前,当垃圾收集器工作时,无论当前的内存是否足够,都会回收只被弱引用关联的对象。
  • 虚引用(Phantom Reference): 也叫幽灵引用或者幻影引用,对象随时可能被回收。

ThreadLocal来实现本地变量的原理

当使用ThreadLocal维护变量时,该变量存储在线程本地,其他线程无法访问,做到了线程间隔离,也就没有线程安全的问题。

程序运行时,栈中会存储Thread和ThreadLocal的引用。堆中的每一个Thread中都有一个ThreadLocalMap对象,ThreadLocalMap中有一个Entry数组,一个Entry对象中,又包含一个key和一个value,key就是ThreadLocal对象实例。这里的ThreadLocal的key就是弱引用,value是通过java.lang.ThreadLocal#set方法实际写入的值。

数据存储结构源码分析

这里,主要看java.lang.ThreadLocal中的ThreadLocalMap内部静态类

  • INITIAL_CAPACITY 初始化容量,必须为2的整数次幂
  • Entry[] table 一个Map中可以保存多个ThreadLocal对象,这些对象都存储在table中
  • size ThreadLocal的个数
  • threshold 下次扩容时,应该扩容到多大
static class ThreadLocalMap {

        ...

        /**
         * The initial capacity -- MUST be a power of two.
         */
        private static final int INITIAL_CAPACITY = 16;

        /**
         * The table, resized as necessary.
         * table.length MUST always be a power of two.
         */
        private Entry[] table;

        /**
         * The number of entries in the table.
         */
        private int size = 0;

        /**
         * The next size value at which to resize.
         */
        private int threshold; // Default to 0
 			...
  }

set源码分析

这个方法里面的逻辑是数据存储到ThreadLocal中的全过程,主要包含以下几步:

  • 获取当前线程
  • 获取当前线程的ThreadLocalMap对象
  • 如果ThreadLocalMap不为空,将对象值value设置到ThreadLocalMap中
  • 如果ThreadLocalMap为空时,就是首次设置,需要以当前ThreadLocal对象作为Key创建ThreadLocalMap,并且将threadLocals这个引用指向新创建的ThreadLocalMap对象。
public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        map.set(this, value);//这里的this指的是ThreadLocal对象
    } else {
        createMap(t, value);//首次设置
    }
}

ThreadLocal内存泄漏

原因分析

ThreadLocal并不存储值,它只是作为一个key,让线程从ThreadLocalMap中获取value,ThreadLocalMap是使用ThreadLocal的弱引用作为key的,一个对象只剩下弱引用,则该对象在GC时就会被回收。

ThreadLocalMap使用ThreadLocal的弱引用作为key时,如果一个ThreadLocal没有外部强引用来引用它,比如,下图中,手动将ThreadLocal A这个对象赋值为null,系统GC时,这个ThreadLocal A会被回收,这时,ThreadLocalMap中就会出现key为null的Entry,Java程序无法访问这些key为null的Entry的value。

如果当前线程迟迟不结束,如使用了线程池,或者当前线程还要执行其他耗时的任务,那么这些key为null的Entry的value就会存在下图中,标红的这条强引用链:

TheadRef引用Thread,Thread引用ThreadLocalMap,ThreadLocalMap又引用Entry,Entry对象又引用了value,这个Map的Key已经是null,这个value则永远无法被回收。因为这条强引用链的存在,造成了内存泄漏。只有当前线程thread结束以后,ThreadRef就不存在与栈中,强引用断开,Thread对象、ThreadLocalMap,Entry数组、Entry对象、ObjC对象将全部被GC回收。

内存泄漏演示

public class MyThreadLocalOOM {
    public static final Integer SIZE = 500;
    static ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 5, 1,
            TimeUnit.MINUTES,new LinkedBlockingDeque<>());
    static class Stu{
        private byte[] locla = new byte[1024*1024*5];
    }

    static ThreadLocal<Stu> local = new ThreadLocal<>();

    public static void main(String[] args) {
        try {
            for(int i = 0; i < SIZE; i++){
                executor.execute(()->{
                    local.set(new Stu());
                    System.out.println("开始执行");
                });
                Thread.sleep(100);   
            }
             local = null; //会内存泄漏
              System.out.println("执行完毕");
        }catch (InterruptedException e){
            e.printStackTrace();
        }
    }
}

第20行代码,将local置为null后,总共有5*5=25MB的堆内存泄漏。for循环结束后,手动GC,visualvm监控到的堆内存使用情况如下:

主动调用remove()方法:

public class MyThreadLocalOOM {
    public static final Integer SIZE = 500;
    static ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 5, 1,
            TimeUnit.MINUTES,new LinkedBlockingDeque<>());
    static class Stu{
        private byte[] locla = new byte[1024*1024*5];
    }

    static ThreadLocal<Stu> local = new ThreadLocal<>();

    public static void main(String[] args) {
        try {
            for(int i = 0; i < SIZE; i++){
                executor.execute(()->{
                    local.set(new Stu());
                    System.out.println("开始执行");
                    local.remove();
                });
                Thread.sleep(100);   
            }
             System.out.println("执行完毕");
        }catch (InterruptedException e){
            e.printStackTrace();
        }
    }
}

for循环执行完毕后,手动GC,效果如下,内存泄漏完美解决:

解决方案

主动调用ThreadLocal对象的remove方法,将ThreadLocal对象中的值删除。

ThreadLocal在设计时,也已经做了一些防护措施,在调用ThreadLocal的get()、set()方法操作数据时,会调用expungeStaleEntry(int staleSlot)方法清除当前线程中ThreadLocalMap中key为null的value。如果ThreadLocal对象的强引用被删除后,线程长时间存活,又没有再对该线程的ThreadLocal对象进行操作,依然会造成内存泄漏。

所以,在使用ThreadLocal时,要主动调用remove方法,将ThreadLocal对象中的值删除。

ThreadLocal提供了存储变量的能力,这些变量都是私有的,但是实际工作当中,我们经常会遇到多个线程共同访问同一个共享变量的情况。此时,如果对并发不是很了解,很可能就会造成并发问题。解决并发问题常用的手段有以下三种:

  • 悲观锁:使用简单,锁定粒度大,读写一视同仁。
  • 乐观锁:适用于写少读多的场景。
  • 线程本地变量:线程内存储变量的能力。

以下内容介绍的是线程本地变量的解决方案。

子线程如何获取父线程的变量

JDK提供了InheritableThreadLocal(ITL)可以在创建子线程时,拷贝父线程的本地变量的值到子线程本地变量中。

代码演示

使用ThreadLocal

public class MyInheritableThreadLocal {
    public static ThreadLocal<Integer> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) throws InterruptedException{
        threadLocal.set(12345);
        System.out.println("获取父线程本地变量:"+threadLocal.get());
        new Thread(()-> System.out.println("获取子线程本地变量:"+threadLocal.get())).start();
        TimeUnit.SECONDS.sleep(1);
    }
}

通过main执行父线程,在main方法中创建子线程,使用threadLocal.set(12345);给父线程的本地变量赋值。运行结果如下,子线程获取不到父线程的本地变量

使用InheritableThreadLocal

public class MyInheritableThreadLocal {
    // use TL
//    public static ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
    //use ITL
    public static ThreadLocal<Integer> threadLocal = new InheritableThreadLocal<>();

    public static void main(String[] args) throws InterruptedException{
        threadLocal.set(12345);
        System.out.println("获取父线程本地变量:"+threadLocal.get());
        new Thread(()-> System.out.println("获取子线程本地变量:"+threadLocal.get())).start();
        TimeUnit.SECONDS.sleep(1);
    }
}

运行结果如下,子线程能获取到父线程的本地变量。

原因是,创建子线程时,ITL会拷贝一份父线程的本地变量给子线程。

ITL线程安全问题

代码演示

如果父子线程都引用了同一个对象,会有线程安全问题。

public class InheritableThreadLocalTest {
    public static ThreadLocal<Stu> threadLocal = new InheritableThreadLocal<>();
    public static ExecutorService executorService = TtlExecutors.getTtlExecutorService(Executors.newFixedThreadPool(1));

    public static void main(String[] args) throws InterruptedException {
        System.out.println("主线程开启");
        threadLocal.set(new Stu("张三",1));
        System.out.println("获取主线程本地变量:"+ threadLocal.get());
        executorService.submit(() -> System.out.println("获取子线程本地变量:"+threadLocal.get()));
        TimeUnit.SECONDS.sleep(1);
        threadLocal.get().setAge(2);
        System.out.println("主线程读取本地变量:"+threadLocal.get());
        executorService.submit(()-> {
                    System.out.println("子线程获取本地变量:"+threadLocal.get());
                    threadLocal.get().setAge(3);
                    System.out.println("子线程获取本地变量:"+threadLocal.get());
        });
        TimeUnit.SECONDS.sleep(1);
        System.out.println("主线程读取本地变量:"+threadLocal.get());
    }
}
  • 主线程创建姓名为张三,年龄为1的对象放入threadLocal中
  • 调用获取主线程的值,获取主线程本地变量:Stu{name='张三', age=1}
  • 通过线程池创建子线程,获取子线程本地变量值:获取子线程本地变量:Stu{name='张三', age=1}
  • 在子线程中设置年龄为3,获取子线程的本地变量子线程获取本地变量:Stu{name='张三', age=3}
  • 主线程中获取主线程本地变量值:主线程读取本地变量:Stu{name='张三', age=3},子线程中的修改同时影响了主线程中的值,存在线程安全问题。

解决方案

重写ITL中的childValue方法,实现对象的深拷贝,复制到子线程中的对象就是一个全新的对象,与父线程无关。

public class MyInheritableThreadLocalImpl<T> extends InheritableThreadLocal<T>{
    @Override
    protected T childValue(T parentValue) {
       String s = JSONObject.toJSONString(parentValue);
        return (T) JSONObject.parseObject(s, parentValue.getClass());
    }
}

使用线程池时,子线程获取父线程的变量问题

ITL只会在创建子线程时进行拷贝,如果使用线程池时,线程一直会存在于线程池中,后续可以用于执行多个提交到线程池的任务,此时,每次提交任务时,无法获取父线程的本地变量。

TransmittableThreadLocal

TransmittableThreadLocal是阿里开源的用于解决在使用线程池等会缓存线程的组件情况下传递ThrealLocal问题的ITL的扩展,通常简称为TTL,具体的实现原理就不分析了,今天写得太长了。

使用方法与ITL类似,如果要传递对象的话,需要重写TTL中的copy方法,代码如下:

/**
 * 实现对象深拷贝
 * @param <T>
 */
public class MyTransmittableThreadLocal<T> extends TransmittableThreadLocal<T> {
    @Override
    public T copy(T parentValue) {
        String s = JSONObject.toJSONString(parentValue);
        return (T) JSONObject.parseObject(s, parentValue.getClass());
    }
}

写在最后

昨天看曹政老师的公众号文章《谈谈关于学历的取舍》这篇文章时,有一段话对我触动挺大的:

如果你自学学不进去,烦请果断放弃,你说曹老师,你不经常卖课么,卖课其实也都是自学为主,看完课就学会了?怎么可能,至少要投入五倍以上的课程时间来实践和验证课程内容。很多人报了一堆课,最后学了啥?劝退劝退。我说句难听的话,虽然我卖了不少技术课程,但我觉得能认真学的进去的,1/5都不一定有。你真的学进去了,学扎实了,你超过80%的人。 话说回来,如果一门技术课程你听了就学会了,精通了,才卖你66?88?99?129?卖你5万、10万都是应该的!

别的办法我也不会,只能用练习+写笔记的这种本办法,让自己扎扎实实地学,踏踏实实地往前走。以后的学习我都会这么做。

写作平台真好用。

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/1ac2325adea7f51ddd38df3ba
  • 如有侵权,请联系 yunjia_community@tencent.com 删除。

扫码关注云+社区

领取腾讯云代金券