写点什么

快速掌握并发编程 --- 深入学习 ThreadLocal

用户头像
田维常
关注
发布于: 2020 年 11 月 02 日

生活中的 ThreadLocal

考试题只有一套,老师把考试题打印出多份,发给每位考生,然后考生各自写各自的试卷。考生之间不能相互交头接耳(会当做作弊)。各自写出来地答案不会影响他人的分数。


注意:考试题、考生、试卷。



用代码来实现:


public class ThreadLocalDemo {    //线程共享变量 localVar    public static ThreadLocal<String> localVar = new ThreadLocal<>();
    static void print(String str) {        //打印当前线程中本地内存中本地变量的值        System.out.println(str + " :" + localVar.get());        //清除本地内存中的本地变量        localVar.remove();    }
    public static void main(String[] args) {        Thread t1 = new Thread(new Runnable() {            @Override            public void run() {                //设置线程1中本地变量的值                localVar.set("全部写完");                String threadName = Thread.currentThread().getName();                //调用打印方法                print(threadName);            }        }, "张三");
        Thread t2 = new Thread(new Runnable() {            @Override            public void run() {                //设置线程2中本地变量的值                localVar.set("写了一半");                String threadName = Thread.currentThread().getName();                //调用打印方法                print(threadName);            }        }, "李四");        Thread t3 = new Thread(new Runnable() {            @Override            public void run() {                //设置线程2中本地变量的值                localVar.set("完全没写");                String threadName = Thread.currentThread().getName();                //调用打印方法                print(threadName);            }        }, "王二");
        t1.start();        t2.start();        t3.start();    }}
复制代码


输出


李四 :写了一半王二 :完全没写张三 :全部写完
复制代码


背景

ThreadLocal:字面意思为线程本地或者本地线程。但是其实真正含义并非如此,真正的含义是线程本地变量(副本)。


java.lang.ThreadLocal 是 JDK1.2 版本的时候引入的,本文是基于 JDK1.8 版本进行讲解的。


上面考试场景中的几个关键点我们这么可以这么理解:


考试题----共享变量,大家共享

试卷-----考试题的副本

考试----线程


ThreadLocal 可以理解为每个线程想绑定自己的东西,相互不受干扰。比如上面的考试场景,考试题大家都是一样的。但是考试题进行复印出来后,每人一份,各自写写各自的,相互不受影响,这就正是 ThreadLocal 想要实现的功能。


当使用 ThreadLocal 维护变量时,ThreadLocal 为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。


可以想想生活中还有没有类似的例子。肯定非常多,只要我们用心去体会。


下面我们就来看看 ThreadLocal 到底是如何实现的。


ThreadLocal 设计原理


ThreadLocal 名字中第一个单词 Thread 表示线程,Local 表示本地,我们就理解为线程本地变量了。想了解更多 Thread,可看:快速掌握并发编程---Thread 常用方法


先看看 ThreadLocal 的整体



最关心的三个公有方法:set、get、remove


构造方法

 public ThreadLocal() { }
复制代码


构造方法里没有任何逻辑处理,就是简单的创建一个实例。


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);}
复制代码


先看看 ThreadLocalMap 是个什么东东



ThreadLocalMap 是 ThreadLocal 的静态内部类。


set 方法整体为





ThreadLocalMap 构造方法


//这个属性是ThreadLocal的,就是获取hashcode(这列很有学问,但是我们的目的不是他)private final int threadLocalHashCode = nextHashCode();private Entry[] table;private static final int INITIAL_CAPACITY = 16;//Entry是一个弱引用        static class Entry extends WeakReference<ThreadLocal<?>> {    Object value;    Entry(ThreadLocal<?> k, Object v) {        super(k);        value = v;       } }
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {    //数组默认大小为16    table = new Entry[INITIAL_CAPACITY];    //len 为2的n次方,以ThreadLocal的计算的哈希值按照Entry[]取模(为了更好的散列)    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);    table[i] = new Entry(firstKey, firstValue);    size = 1;    //设置阈值(扩容阈值)    setThreshold(INITIAL_CAPACITY);  }
复制代码


然后我们看看 map.set()方法中是如何处理的


    private void set(ThreadLocal<?> key, Object value) {            Entry[] tab = table;            int len = tab.length;            //len 为2的n次方,以ThreadLocal的计算的哈希值按照Entry[]取模            int i = key.threadLocalHashCode & (len-1);            //找到ThreadLocal对应的存储的下标,如果当前槽内Entry不为空,            //即当前线程已经有ThreadLocal已经使用过Entry[i]            for (Entry e = tab[i];                 e != null;                 e = tab[i = nextIndex(i, len)]) {                ThreadLocal<?> k = e.get();                 // 当前占据该槽的就是当前的ThreadLocal ,更新value结束                if (k == key) {                    e.value = value;                    return;                }                //当前卡槽的弱引用可能会回收了,key:null value:xxxObject ,                //需清理Entry原来的value ,便于垃圾回收value,且将新的value 放在该槽里,结束                if (k == null) {                    replaceStaleEntry(key, value, i);                    return;                }            }           //在这之前没有ThreadLocal使用Entry[i],并进行值存储            tab[i] = new Entry(key, value);            //累计Entry所占的个数            int sz = ++size;            // 清理key 为null 的Entry ,可能需要扩容,扩容长度为原来的2倍,并需要进行重新hash            if (!cleanSomeSlots(i, sz) && sz >= threshold){                rehash();            }}
复制代码


从上面这个 set 方法,我们就大致可以把这三个进行一个关联了:


Thread、ThreadLocal、ThreadLocalMap。



get 方法



remove 方法


expungeStaleEntry 方法代码里有点大,所以这里就贴了出来。


//删除陈旧entry的核心方法private int expungeStaleEntry(int staleSlot) {    Entry[] tab = table;    int len = tab.length;                tab[staleSlot].value = null;//删除value    tab[staleSlot] = null;//删除entry    size--;//map的size自减    // 遍历指定删除节点,所有后续节点    Entry e;    int i;    for (i = nextIndex(staleSlot, len);         (e = tab[i]) != null;         i = nextIndex(i, len)) {        ThreadLocal<?> k = e.get();        if (k == null) {//key为null,执行删除操作            e.value = null;            tab[i] = null;            size--;        } else {//key不为null,重新计算下标            int h = k.threadLocalHashCode & (len - 1);            if (h != i) {//如果不在同一个位置                tab[i] = null;//把老位置的entry置null(删除)                // 从h开始往后遍历,一直到找到空为止,插入                                         while (tab[h] != null){                    h = nextIndex(h, len);                }                tab[h] = e;               }        }    }    return i;}
复制代码


对象引用


在 Java 里万事万物皆对象,这里有个对象,那么对象引用是什么呢?


User user=new User("老田");
复制代码


关于上面这段代码的解释,很大部分人会说 user 是个对象。


一开始培训机构什么书籍里都说 user 是个对象,于是也就这么叫 user 是对象,这里的 user 指向了对象"老田"。这里的 User user 是定义了一个对象引用,可以指向任意的 User 对象,比如:


User user;user = new User("张三");user = new User("李四");
复制代码


一个队对象被 user 引用了,这里 user 把他叫做对象引用 。


对象引用就好比男人,对象就是男人的老婆。根据目前我国法律规定,一个男人在任何时候最多只能有一个老婆,但是一辈子可以娶多个老婆。哈哈哈!!!


另外如果是下面


int a;a=1;a=100;
复制代码


这里的 a,我们通常称之为变量。所以上面的 user 我们也可以理解为变量。


在 Java 里对象的引用也是分几种类型的,分以下四种类型:


强引用

软引用

弱引用

虚引用


强引用

强引用就是我们平时开发中用的最多的,比如说:


Person person = new Person("老田");
复制代码


这个 person 就是强引用。


当一个对象被强引用时候,JVM 垃圾回收的时候是不会回收的,宁愿执行 OOM(Out Of Memory)异常也绝不回收,因为 JVM 垃圾回收的时候会认为这个对象是被用户正在使用,若回收了很有可能造成无法想象的错误。


软引用

如果一个对象具有软引用,内存空间足够,JVM 垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存,比如网页缓存、图片缓存等。


使用软引用能防止内存泄露,增强程序的健壮性。


java.lang.ref.SoftReference 的特点是它的一个实例保存对一个 Java 对象的软引用, 该软引用的存在不妨碍垃圾收集线程对该 Java 对象的回收。


也就是说,一旦 SoftReference 保存了对一个 Java 对象的软引用后,在垃圾线程对这个 Java 对象回收前,SoftReference 类所提供的 get()方法返回 Java 对象的强引用。


    /**     * Returns this reference object's referent.  If this reference object has     * been cleared, either by the program or by the garbage collector, then     * this method returns <code>null</code>.     *     * @return   The object to which this reference refers, or     *           <code>null</code> if this reference object has been cleared     */    public T get() {        T o = super.get();        if (o != null && this.timestamp != clock)            this.timestamp = clock;        return o;    }
复制代码


如果引用对象被清除或者被 GC 回收,这个 get 方法就返回 null。


弱引用

弱引用也是用来描述非必需对象的,当 JVM 下一次进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。在 java 中,用 java.lang.ref.WeakReference 类来表示。


与软引用不同的是,不管是否内存不足,弱引用都会被回收。


弱引用可以结合 来使用,当由于系统触发 gc,导致软引用的对象被回收了,JVM 会把这个弱引用加入到与之相关联的 ReferenceQueue 中,不过由于垃圾收集器线程的优先级很低,所以弱引用不一定会被很快回收。


虚引用

虚引用和前面的软引用、弱引用不同,它并不影响对象的生命周期。在 java 中用 java.lang.ref.PhantomReference 类表示。如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。


注意:虚引用必须和引用队列关联使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会把这个虚引用加入到与之 关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。


好了上面就大概说了一下对象的四大引用,主要本文后面需要用到弱引用。


ThreadLocal 内存泄漏


讲到内存泄漏,那我们还是把内存溢出和内存泄漏大致说一下。


内存溢出

在 JVM 如果发生内存溢出,说明内存不够实用,撑爆了,也就是我们说的 OOM。大量内存得不到释放,又不断申请内存空间。


系统内存使用 200M,已经使用了 180M,可是你说你还想使用 50M,于是系统就受不了。

就像气球一样,原本已经到极限了,你还是使劲打气,很容易就导致气球爆炸了。

就想你只能扛 100 斤的东西,现在给你 200 斤,肯定受不了。


内存泄漏

强引用所指向的对象不会被回收,可能导致内存泄漏,虚拟机宁愿抛出 OOM 也不会去回收他指向的对象。前面说到强引用的时候,如果对象一直被引用,JVM 是不会回收他的,直到最后系统 OOM。


看过《树先生》电影的人都知道,树先生家里的地被别人占用了,但是树先生不敢把人家怎么样。如果是很多人都去占用树先生家的地和财产,到最后树先生不就要饿死么。树先生这部电影确实好看,看完一遍基本上不知道在说什么,主要是树先生幻想得太多,很多人看了两遍也不是很懂。扯远了。。。


ThreadLocal 内存泄漏

内存泄漏案例


模拟了一个线程数为 THREAD_LOOP_SIZE 的线程池,所有线程共享一个 ThreadLocal 变量,每一个线程执行的时候插入一个大的 List 集合,这里由于执行了 500 次循环,也就是产生了 500 个线程,每一个线程都会依附一个 ThreadLocal 变量:


public class ThreadLocalOOMDemo {    private static final int THREAD_LOOP_SIZE = 500;    private static final int MOCK_BIG_DATA_LOOP_SIZE = 10000;
    private static ThreadLocal<List<User>> threadLocal = new ThreadLocal<>();
    public static void main(String[] args) throws InterruptedException {        ExecutorService executorService = Executors.newFixedThreadPool(THREAD_LOOP_SIZE);        for (int i = 0; i < THREAD_LOOP_SIZE; i++) {            executorService.execute(() -> {                threadLocal.set(new ThreadLocalOOMDemo().addBigList());                Thread t = Thread.currentThread();                System.out.println(Thread.currentThread().getName());                //threadLocal.remove(); //不取消注释的话就可能出现OOM            });            try {                Thread.sleep(1000L);            } catch (InterruptedException e) {                e.printStackTrace();            }        }        //executorService.shutdown();    }
    private List<User> addBigList() {        List<User> params = new ArrayList<>(MOCK_BIG_DATA_LOOP_SIZE);        for (int i = 0; i < MOCK_BIG_DATA_LOOP_SIZE; i++) {            params.add(new User("Java后端技术全栈", "123456" + i, "man", i));        }        return params;    }
    class User {        private String userName;        private String password;        private String sex;        private int age;
        public User(String userName, String password, String sex, int age) {            this.userName = userName;            this.password = password;            this.sex = sex;            this.age = age;        }    }}
复制代码


在设置 IDEA 或者 eclipse 中,设置 JVM 参数设置最大内存为 -Xmx64m,以便模拟出 OOM:



然后,运行上面的案例



从上面的案例中我们看到:线程池中的每一个线程使用完 ThreadLocal 对象之后再也不用,由于线程池中的线程不会退出,线程池中的线程的存在,同时 ThreadLocal 变量也会存在,占用内存!造成 OOM 溢出!


前面我们分析了 Thread、ThreadLocal、ThreadLocalMap 三者的关系



一个 Thread 中只有一个 ThreadLocalMap,一个 ThreadLocalMap 中可以有多个 ThreadLocal 对象,其中一个 ThreadLocal 对象对应一个 ThreadLocalMap 中一个的 Entry(也就是说:一个 Thread 可以依附有多个 ThreadLocal 对象)。



总结


每个 Thread 维护一个 ThreadLocalMap 映射表,这个映射表的 key 是 ThreadLocal 实例本身,value 是真正需要存储的 Object。


ThreadLocal 本身并不存储值,它只是作为一个 key 来让线程从 ThreadLocalMap 获取 value。


值得注意的是图中的虚线,表示 ThreadLocalMap 是使用 ThreadLocal 的弱引用作为 Key 的,弱引用的对象在 GC 时会被回收。


ThreadLocalMap 使用 ThreadLocal 的弱引用作为 key,如果一个 ThreadLocal 没有外部强引用来引用它,那么系统 GC 的时候,这个 ThreadLocal 势必会被回收,这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry,就没有办法访问这些 key 为 null 的 Entry 的 value。



如果当前线程再迟迟不结束的话,这些 key 为 null 的 Entry 的 value 就会一直存在一条强引用链:


Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value


永远无法回收,造成内存泄漏。


注意:其实在 ThreadLocalMap 的设计中已经考虑到这种情况,也加上了一些防护措施:在 ThreadLocal 的 get(),set(),remove()的时候都会清除线程 ThreadLocalMap 里所有 key 为 null 的 value


但是如果上述代码中的这行代码


threadLocal.remove(); 
复制代码


把注释放开,这不会抛出 OOM。


另外,网上很多文章都说这是由于弱引用导致的,个人认为不能把锅扔给弱引用,这和使用者有直接关系。如果使用得当是不会出现 OOM 的。


由于 Thread 中包含变量 ThreadLocalMap,因此 ThreadLocalMap 与 Thread 的生命周期是一样长,如果都没有手动删除对应 key,都会导致内存泄漏。


但是使用弱引用可以多一层保障:弱引用 ThreadLocal 不会内存泄漏,对应的 value 在下一次 ThreadLocalMap 调用 set(),get(),remove()的时候会被清除。


因此,ThreadLocal 内存泄漏的根源是:由于 ThreadLocalMap 的生命周期跟 Thread 一样长,如果没有手动删除对应 key 就会导致内存泄漏,而不是因为弱引用。


那为什么使用弱引用而不是强引用??


key 使用强引用


当 ThreadLocalMap 的 key 为强引用回收 ThreadLocal 时,因为 ThreadLocalMap 还持有 ThreadLocal 的强引用,如果没有手动删除,ThreadLocal 不会被回收,导致 Entry 内存泄漏。


key 使用弱引用


当 ThreadLocalMap 的 key 为弱引用回收 ThreadLocal 时,由于 ThreadLocalMap 持有 ThreadLocal 的弱引用,即使没有手动删除,ThreadLocal 也会被回收。当 key 为 null,在下一次 ThreadLocalMap 调用 set(),get(),remove()方法的时候会被清除 value 值。


发布于: 2020 年 11 月 02 日阅读数: 24
用户头像

田维常

关注

关注公众号:Java后端技术全栈,领500G资料 2020.10.24 加入

关注公众号:Java后端技术全栈,领500G资料

评论

发布
暂无评论
快速掌握并发编程---深入学习ThreadLocal