【并发编程&JVM】--- 强软弱虚四种引用 + ThreadLocal内存泄漏原因分析

本篇文章整理自马士兵老师的公开课(哔哩哔哩)
源码地址:https://github.com/nieandsun/concurrent-study.git



【1】强软弱虚四种引用


【1.1】强引用

我们平时开发中写的如下代码,就是强引用:

M m = new M("yoyo");

其运行内存结构图如下:
在这里插入图片描述
即栈中的某个变量m直接指向堆内存中的某个具体对象,这种就是所谓的强引用。

强引用的特点为: 只要强引用还在,即使发生OOM,也无法通过GC回收堆中的具体对象。


【1.2】软引用

软引用的运行内存结构图如下(弱引用和虚引用的运行内存结构图与之类似):
在这里插入图片描述

软引用的特点为: 运行内存够用时不会回收软引用指向的对象,运行内存不够时软引用指向的对象会被自动回收

注意: m指向的软引用对象——SR对象,不会被自动回收,因为这里是一个强引用,只有手动将m置为null才可以将其回收掉。

软引用的使用场景: 比较适合做缓存,运行内存够用就存在内存里,不够用就将其自动回收。


验证代码如下:

package com.nrsc.ch4.threadlocal;

import java.lang.ref.SoftReference;

/***
 *
 * 软引用: 运行内存够用时不会被回收,运行内存不够时会被自动回收
 *  配置堆内存大小为: -Xmx20M ---> 最大堆内存设置为20M
 */
public class SoftReferenceDemo {
    public static void main(String[] args) {
        SoftReference<byte[]> m = new SoftReference<>(new byte[1024 * 1024 * 10]);
        System.out.println("软引用对象指向的对象:"+m.get());

        System.gc();

        //堆内存够用的情况下,gc后通过软引用指向的对象不会被回收
        System.out.println("堆内存够用时,发生GC后,软引用指向的对象:" + m.get());

        //再分配一个数组(强引用), heap将装不下,这时候系统会进行一次垃圾回收,
        //此时堆内存将不够用,就会把通过软引用指向的对象给gc掉
        byte[] b = new byte[1024 * 1024 * 11];
        System.out.println("堆内存不够用时,软引用指向的对象:"+m.get());

        System.gc();//即使再次gc也无法将m指向的SoftReference对象回收掉
        System.out.println("软引用指向的对象被gc后,SoftReference对象:" +m); //需要手动的将m置为null才能将其回收
    }
}

验证结果如下:
在这里插入图片描述


【1.3】弱引用

弱引用的运行内存结构图和软引用相似,这里就不再画图了。

弱引用的特点为:只要发生gc,则只有弱引用对象指向的对象就会被回收。

使用场景如:ThreadLocal

下面两段代码是理解ThreadLocal内存泄漏问题的关键。


验证代码1:

@Test
public void test01() {
    WeakReference<byte[]> m = new WeakReference<>(new byte[1024 * 1024 * 10]);
    System.out.println("弱引用对象指向的对象" + m.get());

    System.gc();

    //只要发生gc弱引用对象指向的对象就会被回收
    System.out.println("发生gc后,弱引用对象指向的对象" + m.get());
}

验证结果1:
在这里插入图片描述


验证代码2:

@Test
public void test02() {
    byte[] bytes = new byte[1024 * 1024 * 10];
    WeakReference<byte[]> m = new WeakReference<>(bytes);
    System.out.println("弱引用对象指向的对象" + m.get());

    System.gc();

    //只要发生gc弱引用对象指向的对象就会被回收
    System.out.println("发生gc后,弱引用对象指向的对象" + m.get());
}

验证结果2:
在这里插入图片描述


【1.4】虚引用

这里不讲了。。。,有兴趣的可以去B栈搜索原视频!!!


【2】ThreadLocal使用方式 + 底层原理分析


【2.1】ThreadLocal的使用方式

ThreadLocal的使用方式如下 — 相信大家都不陌生!!!

package com.nrsc.ch4.threadlocal;
import java.io.IOException;
public class ThreadLocalDemo {
    static ThreadLocal<String> tl = new ThreadLocal<>();

    public static void main(String[] args) throws IOException {
        //在主线程里set值
        tl.set("1111");

        new Thread(() -> {
            //其他线程里get不到主线程的值
            String s = tl.get();
            System.out.println("非主线程:" + s);
        }).start();


        // 主线程可以获取到主线程设置的值
        // --> 即当前线程可以从tl里获取到当前线程设置的值,无法获取到其他线程设置的值
        // --> 其他线程也获取不到当前线程设置的值
        System.out.println("主线程:" + tl.get());

        //清除当前线程的value值 ---> help GC
        tl.remove();
        //将tl1值为null,清除tl1与ThreadLocal对象之间的强引用 ---> help GC
        tl = null;

        System.in.read();
    }
}

它主要可以完成的功能就是线程间的隔离,即保证当前线程只能拿到在当前线程set的值 —》 如A线程只能get到A线程set的值。

—》具体应用可以看一下我之前写过的一篇文章《JDBC编程----借助TreadLocal类解决事务问题》,当然读过spring源码的肯定知道,在Spring源码里有很多地方也用到了ThreadLocal。


【2.2】ThreadLocal底层原理

相信不看ThreadLocal源码的话,你大概率会认为ThreadLocal的set方法和get方法的实现原理为:

  • set方法:将set的值为Value,将当前线程为key,放到一个map里
  • get方法:直接从map里根据key(当前线程)获取当前线程对应的Value值

如果我没记错的话,我上学时,我们老师就是这么讲的 —> 其实这样很好理解,但是ThreadLocal的底层却并不是这样去实现的。

先说一下ThreadLocal真实的底层实现方式,有兴趣的可以回过头来想一想为啥它没按照我上面说的那种方式去实现。


ThreadLocal的set方法源码:

public void set(T value) {
    Thread t = Thread.currentThread(); //获取当前线程
    ThreadLocalMap map = getMap(t); //根据当前线程获取ThreadLocalMap ---》这个ThreadLocalMap很重要,它其实是Thread类的一个属性
    if (map != null) 
        map.set(this, value); //注意:这里map的key是this,即ThreadLocal对象本身,value就是我们设置的value值
    else
        createMap(t, value);
}

由上面的源码可以知道ThreadLocal之所以可以保证线程间的隔离,是因为在每个线程内,使用的Map对象都是当前线程对象(Thread对象)独有的ThreadLocalMap。

也就是说如果在线程A里调用tl.set(obj),它会把当前的tl作为key,将obj作为value放到当前线程A独有的ThreadLocalMap中,那肯定就只有线程A可以get到其set的值。

更深入的研究源码后你会发现,其实ThreadLocalMap中真正存放数据的数据结构为一个Entry数组,其中这个Entry是一个键值对对象,比较值得注意的是该对象的key是弱引用类型,value是一个强引用类型

限于篇幅等原因,我不在本文分析具体源码了,这里做了一个图来表示ThreadLocal真正的底层原理:
在这里插入图片描述


【2.3】Entry的key为什么使用弱引用

反正法: 若是强引用,即使tl=null,但key的引用依然指向ThreadLocal对象,因此ThreadLocal将一直不会被回收,所以会有内存泄漏,而使用弱引用则不会。


【3】ThreadLocal可能存在内存泄漏原因分析 + 解决方法


【3.1】在执行tl = null之前,一定要先执行tl.remove()方法

2.3中通过反正法可知,Entry的key使用弱引用时,当执行tl = null时,可以解决ThreadLocal对象一直无法回收而带来的内存泄漏问题 —> 即断掉标号为①、②、③、④的四条线 —> 然后通过gc回收掉ThreadLocal对象1和ThreadLocal对象2;

但是如果仅仅只执行tl = null的话,仍然会有内存泄漏问题的存在, 即ThreadLocal对象如果率先被回收了,key 的值就会变成null,则导致value再也无法被访问到,因此依然存在内存泄漏。

所以在执行tl = null之前,一定要先执行tl.remove()方法,== 先将value对应的强引用给清掉—> 即删除⑤、⑥两根线。`

当然看过源码的人可能知道,在线程每次进行tl.set(XXX)和tl.get()时都会清除一下key为null的value,但是既然tl(即key)都置为null了,肯定就是希望对其进行回收了,那tl对应的value也就无法明确地(比如说当前线程执行了tl1.set和tl2.set,然后将tl1和tl2直接置为null,则Entry数组中的value到底是谁的,就无法知晓了)获取到了 —》 因此还是那句话,在执行tl = null之前,一定要先进行tl.remove();方法。


【3.2】使用线程池的情况下一定要在任务执行完清空ThreadLocal相关内容

其实明白了上面讲解的内容之后,这个问题就很好理解了。

假设 线程池中的某个线程(假设为线程A)在执行某个任务时,用到了ThreadLocal,如果任务执行完,没有清空ThreadLocal相关的内容,而是直接还回线程池了,那执行该任务时tlset到线程里的内容,就很可能影响到该线程再执行其他任务。。。

因此使用线程池的情况下一定要在任务执行完清空ThreadLocal相关内容。


【3.3】阿里《JAVA开发手册》上针对ThreadLocal使用的强制性规约

最后看看阿里java开发手册上对ThreadLocal使用注意事项的描述:
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/nrsc272420199/article/details/105960770