news 2026/9/9 12:15:27

缓存明明还留着引用,为什么内存紧张时它说没就没了

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
缓存明明还留着引用,为什么内存紧张时它说没就没了

「Java 进阶之路」系列 Day35

写在前面

前几篇一直在讲垃圾"怎么回收"(标记清除/整理/复制、分代收集、CMS/G1/ZGC),但更前置的问题其实是:JVM怎么判断一个对象是不是垃圾?很多人第一反应是"引用计数",但Java压根没用这个方案;还有人写缓存时纳闷——明明一个Map里还攥着对象的引用,为什么内存紧张时这些对象说没就没了。这篇把这两个问题一次讲清楚。


一、是什么:可达性分析怎么判断一个对象活着还是死了

为什么不用引用计数

最直观的判活方案是给每个对象挂一个计数器,被引用一次加一,引用失效减一,计数器归零就判定为垃圾——这就是引用计数法,简单直接,但有一个硬伤:处理不了循环引用

对象A持有对B的引用

对象B持有对A的引用

如果A和B互相引用,即使外部所有代码都不再持有A、B的引用了,它们两个的计数器依然都是1,永远不会归零,会被误判为"还活着",造成内存泄漏。正是因为这个致命缺陷,主流JVM都没有采用引用计数法(Python等语言用了引用计数,但要靠额外的循环检测器来弥补这个漏洞)。

可达性分析:从"根"出发,摸得到就是活的

Java采用的方案是可达性分析(Reachability Analysis):选定一批特殊的对象作为GC Roots,从这些Roots出发,沿着引用链一路往下摸,凡是能够被摸到(可达)的对象,都判定为存活;摸不到的对象,才判定为垃圾

GC Roots

对象A

对象B

对象C

对象D

对象E

对象F

上图里,A、B、C、D、E都能从GC Roots出发摸到,判定为存活;F虽然自己引用着E,但没有任何Roots能摸到F本身,F就是垃圾,即使F和E之间有引用关系也无济于事——这正好解决了引用计数法处理不了的循环引用问题:即使F和E互相引用,只要它们整体上摸不到GC Roots,照样会被正确回收。

常见能作为GC Roots的对象包括:各个线程当前调用栈里的局部变量、方法区里的静态变量、被JNI(Native方法)引用的对象、以及一些和类加载、类型相关的固定引用。


二、为什么明明有引用还是被回收:强引用之外还有三种引用

如果只有"有引用就存活、没引用就回收"这一条规则,会有一类场景很难处理:想让JVM在内存充裕时尽量保留一批对象(比如缓存),但内存紧张、快要OOM之前,又希望能优先把它们清掉,而不是眼睁睁看着系统因为死抱着缓存不放而崩溃

Java用四种强度不同的引用类型来满足这类需求:

引用类型什么时候被回收典型场景
强引用(Strong Reference)只要还可达,永远不会被回收,哪怕内存溢出也不放日常的new Object()赋值
软引用(SoftReference)内存足够时不回收;内存不足、快要OOM之前会被回收内存敏感的缓存
弱引用(WeakReference)不管内存够不够,只要发生一次GC就会被回收ThreadLocal的Entry key
虚引用(PhantomReference)形同虚设,随时可能被回收,拿不到引用的对象本身,只用来在对象被回收时收到一个通知配合Cleaner做资源清理跟踪

强引用 永远不回收

软引用 内存不足才回收

弱引用 只要GC就回收

虚引用 完全不影响生存 只用来收通知

回到开头的疑问:如果缓存的Map里存的是软引用而不是强引用,即使这个引用链条一直存在、对象在可达性分析里依然"可达",只要JVM快要内存不足了,垃圾回收器依然会主动把软引用指向的对象清掉,把内存腾出来——这不是可达性分析出了错,而是软引用本身在设计上就允许JVM在特定条件下"选择性地"把它们视为可以牺牲的对象。


三、怎么用:软引用做缓存,弱引用防内存泄漏

软引用:一个天然的、内存敏感的缓存实现

Map<String,SoftReference<byte[]>>cache=newHashMap<>();// 存入缓存cache.put("key1",newSoftReference<>(loadBigData()));// 取用时要判断是否已经被回收SoftReference<byte[]>ref=cache.get("key1");byte[]data=(ref!=null)?ref.get():null;if(data==null){data=loadBigData();// 已被回收,重新加载cache.put("key1",newSoftReference<>(data));}

用软引用包一层,缓存就能自动获得"内存宽裕时尽量保留,内存紧张时自动让路"的能力,不需要手写LRU淘汰逻辑去猜测什么时候该清理——代价是每次取用都要判空重新加载,且软引用对象本身如果引用关系复杂,也会带来一定的额外管理开销,所以规模较小、访问频繁的缓存,用现成的Guava Cache/Caffeine这类工具会比自己手搓软引用更省心。

弱引用:ThreadLocal为什么用它做key

回到Day09讲过的ThreadLocal内存泄漏问题——ThreadLocalMap的Entry对key(也就是ThreadLocal实例本身)用的就是弱引用:只要一次GC发生,只要没有其他强引用指着这个ThreadLocal实例,它就会被回收,Entry里的key会自动变成null。这样设计是为了让"外部代码已经不再持有这个ThreadLocal引用"这件事,能尽快被JVM感知到,而不需要用户显式调用remove()才能让key被回收——但value依然是强引用,不会跟着自动清理,这才是ThreadLocal真正的内存泄漏风险点:key没了、value还在,占着位置出不去,所以remove()该调用还是要调用。

虚引用:连"拿到对象"这件事都不允许

虚引用是四种里最特殊的一种——通过PhantomReference.get()永远返回null,也就是说你压根不能靠虚引用去访问这个对象,它唯一的作用是配合一个引用队列(ReferenceQueue),在对象被垃圾回收器真正回收前的某个时机,往队列里塞一个通知。这给了开发者一个"对象即将/已经被回收"的钩子,常用来替代已经过时的finalize()方法,做一些堆外内存、文件句柄之类的资源清理收尾工作(JDK 9之后的java.lang.ref.Cleaner底层就是基于虚引用实现的)。


四、面试追问

Q1:为什么Java不用引用计数法判断对象是否存活?

引用计数法无法正确处理循环引用的场景:两个互相引用的对象,即使外部已经没有任何代码持有它们,各自的计数器依然不为零,永远不会被判定为垃圾,导致内存泄漏。Java采用的可达性分析是从GC Roots出发摸引用链,天然能正确处理循环引用——只要整体上摸不到GC Roots,即使对象之间互相引用也会被回收。

Q2:哪些对象可以作为GC Roots?

常见的包括:各线程当前调用栈里的局部变量、方法区里的静态变量、被JNI(Native方法)引用的对象,以及一些和类加载、类型信息相关的固定引用。可达性分析就是从这批对象出发,判断哪些对象能被引用链摸到。

Q3:软引用和弱引用的核心区别是什么?

软引用只有在内存不足、即将发生OutOfMemoryError之前才会被回收,内存充裕时会尽量保留,适合做内存敏感的缓存;弱引用则不管内存够不够,只要发生一次垃圾回收就会被清理,生存周期比软引用短得多,典型应用是ThreadLocalMap里Entry对key的引用。

Q4:ThreadLocal为什么会内存泄漏,跟弱引用有什么关系?

ThreadLocalMap的Entry对key(ThreadLocal实例)用的是弱引用,只要没有其他强引用,一次GC后key就会被回收变成null;但Entry对value的引用依然是强引用,不会自动清理。如果线程是线程池里的核心线程、长期存活,且用完ThreadLocal后没有调用remove(),这些key已经为null、但value还在的Entry会一直占用内存,无法被回收,这才是真正的内存泄漏点,不是弱引用本身导致的问题,而是value没有配套清理导致的。

Q5:虚引用有什么实际用途?

虚引用无法通过get()方法获取到对象本身(永远返回null),唯一作用是配合引用队列,在对象被垃圾回收前后收到一个通知。它常被用来替代已经过时、不推荐使用的finalize()方法,跟踪对象生命周期的结束时机,去做一些堆外内存释放、文件句柄关闭之类的资源清理工作,JDK 9之后推荐的java.lang.ref.Cleaner底层就是基于虚引用实现的。


下一篇预告

Day36 结合线上实战场景,讲讲OOM排查和连接池问题该怎么定位——从这几篇讲的GC原理落地到真实的故障排查思路上。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 12:15:00

多级指针存在的意义

就以二级指针来说吧。其他多级指针也是类似。 二级指针的定义比较明确&#xff0c;就是指向指针的指针&#xff0c;或者说一级指针变量的地址。 那么&#xff0c;二级指针存在的意义是什么呢&#xff1f; 或者说&#xff0c;为什么需要二级指针呢&#xff1f; 一句话解释&#…

作者头像 李华
网站建设 2026/9/9 12:14:36

LVGL图片转C数组全攻略:从原理到工程落地的完整指南

简介&#xff1a;这份资源是面向嵌入式开发者的 LVGL 图片转化工具&#xff0c;可将 PNG、BMP 等图像文件直接转换为 C 语言数组&#xff0c;帮助基于 LVGL 图形库的 MCU 项目在有限内存下高效加载并显示图片&#xff0c;省去运行时解码开销&#xff0c;适合从事 GUI 开发、低资…

作者头像 李华
网站建设 2026/9/9 12:14:35

JDK 17反射异常InaccessibleObjectException详解与解决方案

最近把一个服务从JDK 8直接升到JDK 17&#xff0c;启动第一分钟就被java.lang.reflect.InaccessibleObjectException拍脸。我猜你现在搜到这&#xff0c;十有八九也是被这一串异常信息折腾得够呛。这篇就把这个异常彻底讲透&#xff1a;它为什么会冒出来、怎么从堆栈里快速定位…

作者头像 李华
网站建设 2026/9/9 12:12:50

LangChain入门指南:核心组件、LCEL、RAG与Agent实战

1. LangChain入门&#xff0c;先搞懂它到底在解决什么问题1.1 没有LangChain的时候&#xff0c;我们是怎么写代码的先聊点实在的。你在接触LangChain之前&#xff0c;肯定写过一段直接调大模型接口的代码&#xff0c;大概长这样&#xff1a;import openaiopenai.api_key "…

作者头像 李华
网站建设 2026/9/9 12:12:20

夜视机芯SDK跨平台集成实战:Android与Linux双平台对接全流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/9 12:11:19

Swift开发IDE怎么选?从Xcode到VS Code的工具链解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华