Java多线程编程-(18)-借ThreadLocal出现OOM内存溢出问题再谈弱引用WeakReference

简介: 引用类型分为:强引用( Strong Reference)、软引用( Soft Reference)、弱引用( Weak Reference)、虚引用( Phantom Reference)四种,这四种引用强度依次逐渐减弱。

前几篇:

Java多线程编程-(3)-线程本地ThreadLocal的介绍与使用

Java多线程编程-(8)-多图深入分析ThreadLocal原理

Java多线程编程-(9)-ThreadLocal造成OOM内存溢出案例演示与原理分析

一、简单回顾

在上几篇的时候,已经简单的介绍了不正当的使用ThreadLocal造成OOM的原因,以及ThreadLocal的基本原理,下边我们首先回顾一下ThreadLocal的原理图以及各类之间的关系:

1、Thread、ThreadLocal、ThreadLocalMap、Entry之间的关系(图A):

这里写图片描述

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

2、ThreadLocal各类引用关系(图B):

在ThreadLocal的生命周期中,都存在这些引用。( 实线代表强引用,虚线代表弱引用)

这里写图片描述

ThreadLocal到Entry对象key的引用断裂,而不及时的清理Entry对象,可能会造成OOM内存溢出!

二、引用的类型

我们对引用的理解也许很简单,就是:如果 reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。但是书上说的这种方式过于狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态,对于如何描述一些“食之无味,弃之可惜”的对象就显得无能为力。我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。

一般的引用类型分为:强引用( Strong Reference)、软引用( Soft Reference)、弱引用( Weak Reference)、虚引用( Phantom Reference)四种,这四种引用强度依次逐渐减弱。

1、下边是四中类型的介绍:

(1)强引用就是指在程序代码之中普遍存在的,类似“Object obj = new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象,也就是说即使Java虚拟机内存空间不足时,GC收集器也绝不会回收该对象,如果内存空间不够就会导致内存溢出。

(2)软引用用来描述一些还有用,但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中并进行回收,以免出现内存溢出。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。在 JDK 1.2 之后,提供了 SoftReference 类来实现软引用。

软引用适合引用那些可以通过其他方式恢复的对象,例如:数据库缓存中的对象就可以从数据库中恢复,所以软引用可以用来实现缓存。等会会介绍MyBatis中的使用软引用实现缓存的案例。

(3)弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 JDK 1.2 之后,提供了 WeakReference 类来实现弱引用。ThreadLocal使用到的就有弱引用。

(4)虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是希望能在这个对象被收集器回收时收到一个系统通知。在 JDK 1.2 之后,提供了PhantomReference 类来实现虚引用。

2、各引用类型的生命周期及作用:

这里写图片描述

三、ThreadLocal中的弱引用

上述我们知道了当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。我们的ThreadLocal中ThreadLocalMap中的Entry类的key就是弱引用的,如下:

这里写图片描述

而弱引用会在垃圾收集器工作的时候进行回收,也就是说,只要执行垃圾回收,这些对象就会被回收,也就是上述图B中的虚线连接的地方断开了,就成了一个没有key的Entry,下边演示一下:

1、演示案例简介:

我们知道一个线程Thread可以有多个ThreadLocal变量,这些变量存放在Thread中的ThreadLocalMap变量中,那么我们下边就在主线程main中定义多个ThreadLocal变量,然后我们想办法执行几次GC垃圾回收,再看一下ThreadLocalMap中Entry数组的变化情况。

2、演示代码:

public class ThreadLocalWeakReferenceGCDemo {

    private static final int THREAD_LOOP_SIZE = 20;

    public static void main(String[] args) throws InterruptedException {

        try {
            //等待连接JConsole
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        for (int i = 1; i < THREAD_LOOP_SIZE; i++) {
            ThreadLocal<Map<Integer, String>> threadLocal = new ThreadLocal<>();
            Map<Integer, String> map = new HashMap<>();
            map.put(i, "我是第" + i + "个ThreadLocal数据!");
            threadLocal.set(map);
            threadLocal.get();

            System.out.println("第" + i + "次获取ThreadLocal中的数据");

            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

2、正常执行:

当for循环执行到最后一个的时候,看一下ThreadLocalMap的情况:

这里写图片描述

可以看到此时的ThreadLocalMap中有21个ThreadLocal变量(也就是21个Entry),其中有3个表示main线程中表示的其他ThreadLocal变量,这是正常的执行,并没有发生GC收集。

3、非正常执行:

当for循环执行到中间的时候手动执行GC收集,然后再看一下:

通过JConsole工具手动执行GC收集:

这里写图片描述

执行结果:

这里写图片描述

可以看出算上主线程中其他的Entry一共还有6个,也就可以证明在执行GC收集的时候,弱引用被回收了。

4、你可能会问道,弱引用被回收了只是回收了Entry的key引用,但是Entry应该还是存在的吧?

事情是这样的,我们的ThreadLocal已经帮我们把key为null的Entry清理了,在ThreadLocal的get(),set(),remove()的时候都会清除线程ThreadLocalMap里所有key为null的value。

这里写图片描述

上述源码中描述了清除并重建索引的过程,源码过多,不截图显示。所以,我们最后看到的实际上是已经清除过key为null的Entry之后的结果。这也说明了正常情况下使用ThreadLocal是不会出现OOM内存溢出的,出现内存溢出是和弱引用没有半点关系的!

5、上述代码虽然是手动执行的GC,但正常情况下的GC也是会回收弱引用的

如下(注意:实验请适当调节参数,避免电脑死机),假如我们上述的代码的主函数main改成如下方式:

public static void main(String[] args) throws InterruptedException {

        ThreadLocal<Map<Integer, String>> threadLocal1 = new ThreadLocal<>();
        Map<Integer, String> map1 = new HashMap<>(1);
        map1.put(1, "我是第1个ThreadLocal数据!");
        threadLocal1.set(map1);

        ThreadLocal<Map<Integer, String>> threadLocal2 = new ThreadLocal<>();
        Map<Integer, String> map2 = new HashMap<>(1);
        map2.put(2, "我是第2个ThreadLocal数据!");
        threadLocal2.set(map2);

        for (int i = 3; i <= MAIN_THREAD_LOOP_SIZE; i++) {
            ThreadLocal<Map<Integer, String>> threadLocal = new ThreadLocal<>();
            Map<Integer, String> map = new HashMap<>(1);
            map.put(i, "我是第" + i + "个ThreadLocal数据!");
            threadLocal.set(map);
            threadLocal.get();

            if (i > 20) {
                //-Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8
                //会触发GC
                byte[] allocation1, allocation2, allocation3, allocation4;
                allocation1 = new byte[2 * 1024 * 1024];
                allocation2 = new byte[2 * 1024 * 1024];
                allocation3 = new byte[2 * 1024 * 1024];
                allocation4 = new byte[4 * 1024 * 1024];
            }
        }
        System.out.println("-------" + threadLocal1.get());
        System.out.println("-------" + threadLocal2.get());
    }

设置VM参数:

这里写图片描述

最后的运行结果:

这里写图片描述

调试中查看threadLocal的数据,如下:

这里写图片描述

这里写图片描述

可见,虽然这里我们自己定义了30个ThreadLocal变量,但是最后的确只有14个,其中还有三个是属于其他的,还有一点值得注意的是,我们的threadLocal1threadLocal2 变量,在进行GC垃圾回收的时候,弱引用的Key是没有进行回收的,最后存活了下来!使得我们最后通过get方法可以获取到正确的数据。

6、为什么threadLocal1和threadLocal2变量没有被回收?

这里我们就需要重新认识一下,什么是:当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象,这里的重点是:只被弱引用关联的对象

首先举个实例:

public class WeakRefrenceDemo {

    public static void main(String[] args) {
        User user = new User("hello", "123");
        WeakReference<User> userWeakReference = new WeakReference<>(user);
        System.out.println(userWeakReference.get());
        //另一种方式触发GC,强制执行GC
        System.gc();
        System.runFinalization();
        System.out.println(userWeakReference.get());
    }

    public static class User {
        private String userName;
        private String userPwd;
        //省去全参构造方法和toString()方法
    }
}

执行结果:

这里写图片描述

可以看到,上述过程尽管GC执行了垃圾收集,但是弱引用还是可以访问到结果的,也就是没有被回收,这是因为除了一个弱引用userWeakReference 指向了User实例对象,还有user指向User的实例对象,只有当user和User实例对象的引用断了的时候,弱引用的对象才会被真正的回收,看下图:

这里写图片描述

由上图可知道,usernew User()是在不同的内存空间的,他们之间是通过引用进行关联起来的。

如果把上述主函数改成代码如下,将user = null,则断开了他们之间的引用关系,但是还有一个弱引用userWeakReference 指向new User()

public static void main(String[] args) {

        User user = new User("hello", "123");
        WeakReference<User> userWeakReference = new WeakReference<>(user);
        System.out.println(userWeakReference.get());
        user = null; //断开引用
        System.gc(); //强制执行GC
        System.runFinalization();
        System.out.println(userWeakReference.get());
    }

执行结果如下:

这里写图片描述

可以看到断开了user和new User()之间的引用之后,就只有弱引用了,因此,上述的那段话:都会回收掉只被弱引用关联的对象。因此该new User()会被回收。

因此,就出现了最开始看到的threadLocal1、threadLocal2都还可以访问到数据(for循环里边的,由于作用于的问题,引用已经断开了),那我我们只有通过手动设为null的方式,看一下效果,代码改为如下:

    public static void main(String[] args) throws InterruptedException {

        ThreadLocal<Map<Integer, String>> threadLocal1 = new ThreadLocal<>();
        Map<Integer, String> map1 = new HashMap<>(1);
        map1.put(1, "我是第1个ThreadLocal数据!");
        threadLocal1.set(map1);
        threadLocal1 = null;
        System.gc(); //强制执行GC
        System.runFinalization();

        ThreadLocal<Map<Integer, String>> threadLocal2 = new ThreadLocal<>();
        Map<Integer, String> map2 = new HashMap<>(1);
        map2.put(2, "我是第2个ThreadLocal数据!");
        threadLocal2.set(map2);
        threadLocal2 = null;
        System.gc(); //强制执行GC
        System.runFinalization();

        ThreadLocal<Map<Integer, String>> threadLocal3 = new ThreadLocal<>();
        Map<Integer, String> map3 = new HashMap<>(1);
        map3.put(3, "我是第3个ThreadLocal数据!");
        threadLocal3.set(map3);

        ThreadLocal<Map<Integer, String>> threadLocal4 = new ThreadLocal<>();
        Map<Integer, String> map4 = new HashMap<>(1);
        map4.put(4, "我是第4个ThreadLocal数据!");
        threadLocal4.set(map4);
        System.out.println("-------" + threadLocal3.get());
    }
}

执行结果:

这里写图片描述

可以看到,是我们想要的结果,弱引用也被回收了。

另外还有一种可能是,我们得到的结果有3个,分别是2、3、4,这是有可能的,这是由于垃圾回收器是一个优先级较低的线程, 因此不一定会很快发现那些只具有弱引用的对象,即只有等到系统垃圾回收机制运行时才会被回收。但是我们已经看到了我们想要的结果。

7、总结

到了这里,你应该明白,并不是所有弱引用的对象都会在第二次GC回收的时候被回收,而是回收掉只被弱引用关联的对象。因此,使用弱引用的时候要注意到!希望以后在面试的时候,不要上来张口就说,弱引用在第二次执行GC之后就会被回收!知其然,知其所以然!

四、引用队列

在很多场景中,我们的程序需要在一个对象的可达性(GC可达性,判断对象是否需要回收)发生变化的时候得到通知,引用队列就是用于收集这些信息的队列。

在创建SoftReference对象时,可以为其关联一个引用队列,当SoftReference所引用的对象被回收的时候,Java虚拟机就会将该SoftReference对象添加到预支关联的引用队列中。

需要检查这些通知信息时,就可以从引用队列中获取这些SoftReference对象。

不仅仅是SoftReference支持使用引用队列,软引用和虚引用也可以关相应的引用队列。

这里写图片描述

先看一个简单的案例:

public class WeakCache {

    private void printReferenceQueue(ReferenceQueue<Object> referenceQueue) {
        WeakEntry sv;
        while ((sv = (WeakEntry) referenceQueue.poll()) != null) {
            System.out.println("引用队列中元素的key:" + sv.key);
        }
    }

    private static class WeakEntry extends WeakReference<Object> {
        private Object key;

        WeakEntry(Object key, Object value, ReferenceQueue<Object> referenceQueue) {
            //调用父类的构造函数,并传入需要进行关联的引用队列
            super(value, referenceQueue);
            this.key = key;
        }
    }

    public static void main(String[] args) {
        ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();
        User user = new User("xuliugen", "123456");
        WeakCache.WeakEntry weakEntry = new WeakCache.WeakEntry("654321", user, referenceQueue);
        System.out.println("还没被回收之前的数据:" + weakEntry.get());

        user = null;
        System.gc(); //强制执行GC
        System.runFinalization();

        System.out.println("已经被回收之后的数据:" + weakEntry.get());
        new WeakCache().printReferenceQueue(referenceQueue);
    }
}

执行结果:

这里写图片描述

ReferenceQueue引用队列记录了GC收集器回收的引用,这样的话,我们就可以通过引用队列的数据来判断引用是否被回收,以及被回收之后做相应的处理,例如:如果使用弱引用做缓存则需要清除缓存,或者重新设置缓存等。

其实,上述的代码,是从MyBatis的源码中抽离出来的,MyBatis在缓存的时候也提供了对弱引用和软引用的支持,MyBatis相关的源码如下:

这里写图片描述

任何一个牛逼的框架,也是一个一个知识点的使用。这篇文章的内容很多,似乎有点又长又臭,不过还是希望对你有所帮助!另外,个人能力有限,难免有所疏漏,如果有也请你及时提出来,以免误人子弟。

目录
相关文章
|
7天前
|
存储 缓存 算法
JVM简介—1.Java内存区域
本文详细介绍了Java虚拟机运行时数据区的各个方面,包括其定义、类型(如程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区和直接内存)及其作用。文中还探讨了各版本内存区域的变化、直接内存的使用、从线程角度分析Java内存区域、堆与栈的区别、对象创建步骤、对象内存布局及访问定位,并通过实例说明了常见内存溢出问题的原因和表现形式。这些内容帮助开发者深入理解Java内存管理机制,优化应用程序性能并解决潜在的内存问题。
JVM简介—1.Java内存区域
|
8天前
|
存储 网络协议 安全
Java网络编程,多线程,IO流综合小项目一一ChatBoxes
**项目介绍**:本项目实现了一个基于TCP协议的C/S架构控制台聊天室,支持局域网内多客户端同时聊天。用户需注册并登录,用户名唯一,密码格式为字母开头加纯数字。登录后可实时聊天,服务端负责验证用户信息并转发消息。 **项目亮点**: - **C/S架构**:客户端与服务端通过TCP连接通信。 - **多线程**:采用多线程处理多个客户端的并发请求,确保实时交互。 - **IO流**:使用BufferedReader和BufferedWriter进行数据传输,确保高效稳定的通信。 - **线程安全**:通过同步代码块和锁机制保证共享数据的安全性。
59 23
|
19天前
|
存储 IDE Java
java设置栈内存大小
在Java应用中合理设置栈内存大小是确保程序稳定性和性能的重要措施。通过JVM参数 `-Xss`,可以灵活调整栈内存大小,以适应不同的应用场景。本文介绍了设置栈内存大小的方法、应用场景和注意事项,希望能帮助开发者更好地管理Java应用的内存资源。
30 4
|
1月前
|
安全 Java 开发者
【JAVA】封装多线程原理
Java 中的多线程封装旨在简化使用、提高安全性和增强可维护性。通过抽象和隐藏底层细节,提供简洁接口。常见封装方式包括基于 Runnable 和 Callable 接口的任务封装,以及线程池的封装。Runnable 适用于无返回值任务,Callable 支持有返回值任务。线程池(如 ExecutorService)则用于管理和复用线程,减少性能开销。示例代码展示了如何实现这些封装,使多线程编程更加高效和安全。
|
25天前
|
Java Shell 数据库
【YashanDB 知识库】kettle 同步大表提示 java 内存溢出
【问题分类】数据导入导出 【关键字】数据同步,kettle,数据迁移,java 内存溢出 【问题描述】kettle 同步大表提示 ERROR:could not create the java virtual machine! 【问题原因分析】java 内存溢出 【解决/规避方法】 ①增加 JVM 的堆内存大小。编辑 Spoon.bat,增加堆大小到 2GB,如: if "%PENTAHO_DI_JAVA_OPTIONS%"=="" set PENTAHO_DI_JAVA_OPTIONS="-Xms512m" "-Xmx512m" "-XX:MaxPermSize=256m" "-
|
3月前
|
Java
Java—多线程实现生产消费者
本文介绍了多线程实现生产消费者模式的三个版本。Version1包含四个类:`Producer`(生产者)、`Consumer`(消费者)、`Resource`(公共资源)和`TestMain`(测试类)。通过`synchronized`和`wait/notify`机制控制线程同步,但存在多个生产者或消费者时可能出现多次生产和消费的问题。 Version2将`if`改为`while`,解决了多次生产和消费的问题,但仍可能因`notify()`随机唤醒线程而导致死锁。因此,引入了`notifyAll()`来唤醒所有等待线程,但这会带来性能问题。
Java—多线程实现生产消费者
|
2月前
|
缓存 安全 算法
Java 多线程 面试题
Java 多线程 相关基础面试题
|
3月前
|
安全 Java Kotlin
Java多线程——synchronized、volatile 保障可见性
Java多线程中,`synchronized` 和 `volatile` 关键字用于保障可见性。`synchronized` 保证原子性、可见性和有序性,通过锁机制确保线程安全;`volatile` 仅保证可见性和有序性,不保证原子性。代码示例展示了如何使用 `synchronized` 和 `volatile` 解决主线程无法感知子线程修改共享变量的问题。总结:`volatile` 确保不同线程对共享变量操作的可见性,使一个线程修改后,其他线程能立即看到最新值。
|
3月前
|
消息中间件 缓存 安全
Java多线程是什么
Java多线程简介:本文介绍了Java中常见的线程池类型,包括`newCachedThreadPool`(适用于短期异步任务)、`newFixedThreadPool`(适用于固定数量的长期任务)、`newScheduledThreadPool`(支持定时和周期性任务)以及`newSingleThreadExecutor`(保证任务顺序执行)。同时,文章还讲解了Java中的锁机制,如`synchronized`关键字、CAS操作及其实现方式,并详细描述了可重入锁`ReentrantLock`和读写锁`ReadWriteLock`的工作原理与应用场景。
|
3月前
|
存储 安全 Java
Java多线程编程秘籍:各种方案一网打尽,不要错过!
Java 中实现多线程的方式主要有四种:继承 Thread 类、实现 Runnable 接口、实现 Callable 接口和使用线程池。每种方式各有优缺点,适用于不同的场景。继承 Thread 类最简单,实现 Runnable 接口更灵活,Callable 接口支持返回结果,线程池则便于管理和复用线程。实际应用中可根据需求选择合适的方式。此外,还介绍了多线程相关的常见面试问题及答案,涵盖线程概念、线程安全、线程池等知识点。
291 2