ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java四种引用类型实战:强引用、软引用、弱引用、虚引用与GC调优

Java四种引用类型实战:强引用、软引用、弱引用、虚引用与GC调优 作为一个天天和OOM、GC调优打交道的Java开发者我对这四种引用类型的理解经历过很长一段“背了忘、忘了背”的过程。直到有次线上服务因为一个静态Map里缓存了太多大对象导致频繁Full GC我才真正意识到搞懂强引用、软引用、弱引用、虚引用不光是应付面试更是在救命。这篇文章就想把这个技术点彻底讲透。我会从JDK为什么要设计这么一套引用体系讲起再逐个拆解四种引用的实现原理、生命周期、回收时机最后给出几个可以直接抄走的实战Demo和排查经验。不管你是刚学Java的初学者还是准备跳槽面Java岗的老兵这篇都值得静下心看完。1. 为什么Java要设计这么复杂的引用体系1.1 从GC的可达性分析说起Java和C/C最大的不同之一就是内存的分配和回收交给了JVM。程序员只管new对象不用管什么时候释放。但这带来一个很实际的问题JVM靠什么判断对象能不能回收答案是可达性分析。JVM从一组称为GC Roots的根节点出发沿着引用关系往下遍历能被访问到的对象就是“活的”没有被访问到的就是“可回收垃圾”。GC Roots包括虚拟机栈中引用的对象、静态变量引用的对象、常量池引用的对象、JNI引用的对象等等。这套机制的本质就是对引用链做一次遍历判断对象是否“有人用”。但问题来了传统意义上“有人用”只有一种判断就是能不能找到引用关系。可很多场景下我们需要的不是“要么活着、要么死掉”这种二值逻辑而是一种有弹性的存活策略。比如一个图片缓存你希望它平时一直在内存里访问速度快但当内存快满的时候又希望它能够自己“牺牲”把空间让给更关键的数据。再比如你写了一个异步任务需要知道某个大对象什么时候被回收好去释放对应的堆外资源。这种需求催生了Java的引用级别体系。1.2 强、软、弱、虚到底在“强”什么JDK从1.2版本开始在java.lang.ref包中引入了几种引用包装类把对象引用按“强度”分成几个梯度强引用最强任何情况下JVM都不会主动回收软引用次之内存不够时会被回收弱引用更弱只要发生一次GC就会被回收虚引用最特殊它连对象的生命周期都管不了唯一的作用是“被通知”。你可以把这四种引用想象成四种占座方式。强引用是你在座位上放了一本书占座任何人来了都不能把这个座让出去软引用是你在座位上写了张便利贴正常情况下座位是你的但来了VIP客人你必须让开弱引用是你在座位上放了一个随便丢的纸团保洁阿姨一打扫就会把它清走虚引用呢是你干脆没占座只在门口留了个登记簿记录这个座位什么时候空出来。从垃圾回收的角度看这四种引用其实是在告诉JVM这个对象的重要性等级是多少回收优先级怎么排。JVM在内存管理中会根据引用类型决定回收顺序这个过程对性能调优、缓存设计、资源管理至关重要。1.3 引用强度和内存泄漏的关系很多人有个误解觉得引用类型是JVM底层算法的事跟业务代码没关系。恰恰相反。线上最常见的OOM和Full GC问题绝大多数都和“强引用被错误持有”有关。一个明明用完该丢弃的对象因为被某个集合、某个静态变量、某个缓存强引用着就一直留在老年代里不被回收。我之前排查过一个案例一个导入工具在每次处理完一批数据后会把中间结果存到一个静态的List里想着“反正后面可能还要看”。结果这个List越积越大持有的对象越来越多不到一周就把堆内存撑爆了。这就是典型的“强引用滥用”。而软引用和弱引用的引入就是让你在“想要缓存”和“不拖垮内存”之间找到一个平衡点。我当时修复案例就是换成了软引用做缓存运行几个月再没出现过内存爆炸。所以说引用类型不是面试八股是实打实的工程武器。2. 四种引用的实现原理与核心逻辑2.1 强引用一把锁住的默认引用先说最熟悉的强引用。代码里最普通的赋值就是强引用new一个对象赋给变量或者把一个对象放进集合都是强引用。User user new User(); ListUser userList new ArrayList(); userList.add(user);只要强引用关系还在被引用的对象就永远不会被GC回收。哪怕JVM抛出OutOfMemoryError垃圾收集器也不会去动强引用指向的对象。强引用的回收条件是引用变量超出了作用域或者被主动置为null或者引用被重新赋值指向其他对象。这之后对象才变成“无根”的可以被GC回收。工程上的坑也集中在这一点。很多开发者习惯用静态集合存数据以为只是“占点内存”殊不知静态变量作为GC Roots的一部分会让集合里所有对象永远保持存活。常见的隐患包括HashMap、ArrayList等容器被声明为static持续往里面put数据。单例对象持有大对象的引用不释放。监听器、回调被注册到全局管理器后忘记移除。强引用还有个衍生问题叫“对象游离”就是对象已经没用了但代码里还留着一个很久以前的变量引用导致它始终无法被回收。解决思路其实很简单用完的引用显式置为null或者让变量作用域尽量小别用一个超长生命周期的集合兜底装数据。2.2 软引用内存紧张时的“预备牺牲者”软引用是JDK提供的第一种“可弹性回收”的引用方式对应SoftReference类。软引用描述的对象是“有用但非必须”的内存充足的时候它跟强引用一样可以正常访问内存即将耗尽、JVM面临OOM风险时回收器会把软引用指向的对象列入回收范围。软引用的典型用途就是缓存。比如图片缓存、网页缓存、文件内容缓存你希望数据能在内存里多待一会儿提升速度又不想让它把内存占死。个人博客里常见的头像缓存、搜索引擎里的搜索结果缓存用软引用做都非常合适。使用方式也很简单Image image new Image(big-image); SoftReferenceImage softRef new SoftReference(image); // 把强引用置为null让这个对象只被软引用持有 image null; // 使用时通过get方法获取 Image cached softRef.get(); if (cached null) { // 已经被回收了重新加载 } else { // 缓存命中 }理解软引用有一个关键点把对象交给软引用后原强引用要及时断开。如果不断开强引用软引用就形同虚设因为JVM判断对象回收优先级时会采用对象当前可以达到的最强引用来决定它的命运。对象同时存在强引用和软引用那它在GC眼里就是强引用对象OOM时照样不回收。JDK对软引用的回收有一个专门的LRU策略。JVM参数-XX:SoftRefLRUPolicyMSPerMB控制软引用对象在多长时间内未被访问就会被回收默认值1000单位是毫秒。这个参数的含义是每1MB的空闲堆空间允许软引用对象存活1000毫秒。堆内存剩余越少回收就越激进访问越频繁的软引用对象越容易被保留。这也是为什么软引用被称为“最近最少使用”的回收策略。2.3 弱引用活不过一场GC弱引用对应WeakReference类。它比软引用更“短命”——无论内存是否充足只要发生一次垃圾回收弱引用持有的对象就会被回收。弱引用的使用场景和软引用不太一样。它更适合那些本身生命周期就很短、且可以被外部随时重构的对象。最经典的案例是WeakHashMap它的key是用弱引用包装的。当某个key在外部没有强引用的时候下一次GC这个key就会被回收对应的entry也会被自动移除。WeakReferenceUser weakRef new WeakReference(new User()); // 主动触发GC实际项目中不推荐主动调用这里仅为演示 System.gc(); // 大概率已经是null了 User cachedUser weakRef.get();弱引用最大的工程价值其实是被很多框架用在了内部。比如ThreadLocal里的ThreadLocalMap它的Entry继承了WeakReferencekey是ThreadLocal的弱引用。这样设计是为了解决一个很大的隐患如果Entry对ThreadLocal是强引用那么只要线程存活ThreadLocal对象就无法被回收可能导致频繁的Full GC。但弱引用也带来了另一个经典问题ThreadLocal内存泄漏。后面我会专门用一个章节讲这个坑。一个比较容易混淆的点弱引用对象的生命周期并不是“一创建就马上回收”而是“下次GC时回收”。如果GC一直不发生弱引用对象会一直在内存里。实战中如果你要保证弱引用在某个时间点一定被清掉可以调用Reference对象的enqueue()逻辑或者配合ReferenceQueue来感知回收。2.4 虚引用你永远拿不到对象的“幽灵引用”虚引用也叫幽灵引用是四种引用里最特殊的一种。对应PhantomReference类。它有两个显著特点第一无法通过虚引用获取对象。调用phantomRef.get()方法永远返回null。你拿着虚引用完全无法触碰被引用的对象。第二它的唯一价值是“追踪对象被回收的时机”。虚引用必须和ReferenceQueue配合使用。当一个对象被GC回收时JVM会把它的虚引用放入关联的ReferenceQueue中你的代码可以从队列里取出这个引用得知“那个对象已经被回收了”。ReferenceQueueObject queue new ReferenceQueue(); Object obj new Object(); PhantomReferenceObject phantomRef new PhantomReference(obj, queue); obj null; // 等GC发生之后 Reference? ref queue.poll(); if (ref phantomRef) { System.out.println(对象已被回收); }虚引用最常见的用途是管理堆外内存的回收。比如JVM的DirectByteBuffer它本身是堆内的对象但它背后的内存是从操作系统申请的堆外内存不在JVM堆里。如果DirectByteBuffer对象被回收了却没有通知释放堆外内存就会造成堆外内存泄漏。NIO框架正是利用虚引用和ReferenceQueue的机制在DirectByteBuffer对象被回收后通过注册的Cleaner动作来释放对应的本地内存。这个思路也可以移植到自己的项目里当你用JNI申请了本地内存或者用了DirectBuffer就可以用一个虚引用挂上队列来跟踪它。收到回收通知后执行对应的资源释放操作。需要额外提一句的是JDK 9开始PhantomReference的构造方法要求必须传入ReferenceQueue不允许再使用无参构造。所以新项目里写虚引用直接按“必须配队列”的写法来就行。3. 实操演示与核心场景落地3.1 从代码层面看清四种引用的生命周期差异光讲原理不过瘾我习惯用一个对比Demo演示四种引用的区别。下面这段代码把四种引用对象都创建了一遍然后主动触发GC观察它们的存活状态。import java.lang.ref.*; public class ReferenceDemo { public static void main(String[] args) throws InterruptedException { // 10MB的数组当作“大对象” byte[] strongData new byte[10 * 1024 * 1024]; byte[] softData new byte[10 * 1024 * 1024]; byte[] weakData new byte[10 * 1024 * 1024]; byte[] phantomData new byte[10 * 1024 * 1024]; SoftReferencebyte[] softRef new SoftReference(softData); WeakReferencebyte[] weakRef new WeakReference(weakData); ReferenceQueuebyte[] queue new ReferenceQueue(); PhantomReferencebyte[] phantomRef new PhantomReference(phantomData, queue); // 断开强引用只保留软/弱/虚引用 softData null; weakData null; phantomData null; System.gc(); Thread.sleep(500); System.out.println(强引用对象: (strongData ! null ? 还活着 : 已被回收)); System.out.println(软引用对象: (softRef.get() ! null ? 还活着 : 已被回收)); System.out.println(弱引用对象: (weakRef.get() ! null ? 还活着 : 已被回收)); System.out.println(虚引用对象: (phantomRef.get() ! null ? 还活着 : 已被回收)); Reference? polled queue.poll(); System.out.println(虚引用是否进入队列: (polled phantomRef ? 是 : 否)); } }在默认堆内存较大的情况下运行结果是强引用对象还活着、软引用对象还活着因为内存还充足、弱引用对象大概率已被回收、虚引用对象get为null且进入了队列。这个Demo最直观地体现了一个规律从强引用到虚引用对象的存活约束是层层递减的。强引用对象在OOM前绝不回收软引用对象只在内存紧张时回收弱引用对象任何一次GC都可能被回收虚引用对象则彻底放弃了对对象的访问权只留下一个回收信号。3.2 用软引用实现一个不会OOM的图片缓存假设你正在开发一个图片浏览应用用户滑动的图片需要缓存但又不能把所有图片都常驻内存。用软引用可以做到“有用就缓存内存不足自动丢”。public class ImageCache { private final MapString, SoftReferenceImage cacheMap new ConcurrentHashMap(); public Image getImage(String key) { SoftReferenceImage softRef cacheMap.get(key); if (softRef null) { return null; } Image image softRef.get(); if (image null) { // 软引用指向的对象已被回收从Map中移除残留引用 cacheMap.remove(key); } return image; } public void putImage(String key, Image image) { SoftReferenceImage softRef new SoftReference(image); cacheMap.put(key, softRef); } }写这个缓存有几个隐藏细节要注意。第一从软引用取对象后要判空。因为对象随时可能被回收拿到null是常态此时要重新加载或生成对象。第二软引用本身也是个对象它也会占用内存。如果缓存条目非常多软引用对象积累起来也不容小觑。所以缓存Map本身要控制规模不能无脑往里塞。业界做法是给Map加个最大条目数超出后按策略淘汰。第三图片这种大对象加载完成后应立即把强引用断开否则缓存设计就白做了。实际代码里建议把Image image new Image(...)赋值给缓存后立刻image null确保只有软引用持有这份图片数据。软引用的优势在这个场景体现得很明显内存充足时图片缓存命中率很高内存紧张时JVM会优先回收最老的、最不常用的图片保证主流程不OOM。相比强引用的“宁可OOM也不放”软引用缓存显然更优雅。3.3 ThreadLocal内存泄漏的完整分析ThreadLocal算是弱引用应用得最出名、也最容易踩坑的地方。简单介绍一下背景每个Thread内部维护了一个ThreadLocalMapMap的Entry类继承自WeakReferencekey是ThreadLocal实例的弱引用value是线程持有的变量副本。为什么这么设计因为一个线程的生命周期通常比ThreadLocal长。一般用法是private static ThreadLocalSimpleDateFormat dateFormat new ThreadLocal();ThreadLocal本身是一个静态变量它被线程的ThreadLocalMap的key弱引用着也没关系因为静态强引用一直存在。但如果ThreadLocal是方法内部创建的局部变量用完就没人强引用它了此时ThreadLocal被GC回收Map里的key就变成了nullvalue却因为有Entry.value的强引用而一直存活。这时候就会发生内存泄漏线程还活着ThreadLocalMap的Entry还在key是nullvalue是永远不会被回收的垃圾。如果这个线程是个长期存活的后台线程比如线程池里的线程那泄漏就会持续累积。标准解法有两个层面。第一每次使用完ThreadLocal必须显式调用remove()第二ThreadLocalMap本身提供了保护机制在get和set时如果发现key为null的Entry就会顺带清理。但你不能依赖这个“随机清理”因为它在某些访问路径下不会被触发。我实际使用的模板是ThreadLocalTransactionInfo txHolder new ThreadLocal(); try { txHolder.set(transactionInfo); // 业务逻辑…… } finally { txHolder.remove(); }这里有两个心得一是凡是线程池场景ThreadLocal用完必须remove没有例外二是不要让ThreadLocal的value引用比自己生命周期更长的对象比如不要在value里存一个巨大的临时数据结构。3.4 虚引用实现对象回收追踪虚引用最适合做资源治理。下面这段代码演示如何监听一个对象何时被回收public class CleanerDemo { private static final ReferenceQueueObject QUEUE new ReferenceQueue(); public static void main(String[] args) throws InterruptedException { Object tracked new Object(); PhantomReferenceObject phantomRef new PhantomReference(tracked, QUEUE); // 专门启动一个线程监控队列 Thread monitor new Thread(() - { try { Reference? ref QUEUE.remove(); System.out.println(检测到对象回收执行资源清理: ref); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); monitor.start(); // 断开强引用让对象可以被回收 tracked null; System.gc(); Thread.sleep(1000); } }这个设计相当于给资源上了一份“回收保险”。它不关心你最终要不要手动释放内存而是关注当你无法掌控对象生命周期时怎么在对象消亡后拿到通知、接着做善后。但是有一个陷阱必须说清楚虚引用进队不等于对象已经完成回收而是“对象已经被判断为不可达”。它的真正语义是一旦检测到对象被回收就通过通知触发你的清理动作。如果你在清理动作里又去访问已回收对象的字段直接会拿到null或抛出NPE所以清理逻辑只能做“释放外部资源”这类独立操作不能依赖被追踪对象的内容。4. 常见问题与排查技巧实录4.1 为什么WeakReference指向的对象死活不被回收有人在代码里new了一个WeakReference结果GC之后对象还在于是百思不得其解。原因多半是这个对象同时还存在强引用。我再强调一次JVM在判断对象是否可回收时看的是引用链上“最强的引用类型”而不是“最弱的引用类型”。Object obj new Object(); WeakReferenceObject wr new WeakReference(obj); System.gc(); System.out.println(wr.get() ! null); // 很可能输出true因为obj这个强引用还活着所以对象不会被视为弱引用可达对象。要让WeakReference生效必须先断开强引用。这是初学者最容易踩的第一个坑。另一个原因可能是GC没有真正发生。System.gc()只是建议JVM执行Full GC不一定立刻生效在G1等一下GC器下System.gc()可能被某些参数改成“仅做一次GC”但通常内存充足时对象的回收队列调度也有滞后。调试弱引用逻辑时不建议依赖System.gc()可以配合-Xlog:gc参数观察真实GC日志。4.2 ThreadLocal泄漏在线上怎么排查ThreadLocal内存泄漏的典型特征是老年代稳步增长堆转储里能发现大量ThreadLocalMap$Entry且key是null。排查时先用jmap导出堆在MAT或VisualVM里筛选ThreadLocalMap相关实例观察Entry.value是不是业务对象。一旦确认处理路径基本就是三条代码审查查所有使用ThreadLocal的地方看是否都在finally中调用了remove。线程栈分析看哪些线程长期存活且持有ThreadLocalMap大多是线程池线程。修复习惯在项目的工具类里封装ThreadLocal的操作方法强制走统一的set/remove模板。我在项目里就是用“封装两个静态方法”解决的ThreadLocal对象创建后不允许直接在线程里调用set必须通过工具类的withTransaction方法传入业务逻辑这样remove逻辑就集中到了同一个地方想漏都漏不掉。4.3 软引用缓存为什么命中率这么低有不少同学把图片缓存改成软引用后发现缓存命中率断崖式下跌。一个重要原因是JVM的回收策略当堆内存处于压力下时软引用对象会非常积极地回收。如果服务本身内存比较紧张再加上并发压力高、堆被占用率经常超过80%那软引用基本留不住数据。这时应该优先优化的是堆内存规模和GC参数而不是软引用本身。比如调整-Xmx堆上限、给对象设置合理生命周期让软引用在“正常压力”下拥有足够的存活空间。还有一点是软引用的LRU参数。-XX:SoftRefLRUPolicyMSPerMB默认值是1000如果你希望软引用对象更“顽固”可以调大到5000或10000。但数值开太大会加剧内存压力需要结合业务做取舍。我的经验是缓存命中率低的时候先去观察堆使用曲线别一上来就调参数做“玄学优化”。4.4 虚引用一直不进入队列为什么虚引用不进入队列通常有两种情况第一被追踪的对象还有强引用压根没被回收第二你线程里调用的queue.remove()是阻塞方法或者queue.poll()当时时机不对刚好对象还没有被判定为不可达。在JDK 8及以后虚引用进入队列是在GC的“Reference Processing”阶段完成的。你要等一次真正标记回收执行完队列里才会有内容。如果代码里没等GC就直接poll拿到的自然是null。测试虚引用时我建议在启动参数上加上-XX:PrintGCDetails或者用-Xlog:gc看着GC日志对照队列状态定位问题会快很多。如果你想验证清理逻辑对不对可以先主动把强引用置null然后调用System.gc确认一次GC行为生效后再继续验证后续逻辑。4.5 引用队列的多种引用混用问题当同一个ReferenceQueue同时被软引用、弱引用、虚引用使用时程序从队列里取出引用后要判断它到底属于哪个类型。通常做法是拿它和你注册的那些Reference对象做比较或者判断instanceof。Reference? ref queue.poll(); if (ref instanceof WeakReference) { // 处理弱引用事件 } else if (ref instanceof PhantomReference) { // 处理虚引用事件 }这个问题看似简单但很容易在生产环境里造成“张冠李戴”的清理逻辑错误。我见过有人用单个队列监控多个资源对象没区分类型就直接做资源释放结果一个Netty连接被释放了两次另一个对象却一直不释放排查了很久才发现是引用队列混用导致的。总之引用类型每一个都可以单独拧出来讨论很久但工程上核心就三句话强引用要防泄漏、软引用做缓存要判空、弱引用做关联要配队列、虚引用做资源回收要分辨类型。把这些原则记扎实了遇到问题基本不会慌。我个人在实际项目中的体会是引用类型不只是面试官拿来考察记忆力的知识点它背后其实是“如何和垃圾回收器商量资源预算”的工程智慧。刚开始用软引用做缓存时我也战战兢兢担心回收策略不可控后来发现只要场景选对、对象生命周期管好、队列逻辑写对这套机制非常可靠。最后再分享一个排查小技巧遇到引用相关的诡异问题时第一时间开启-Xlog:gc*把GC日志中“Reference Processing”部分的耗时和数量盯住了很多“玄学”问题瞬间就变成了SQL级别的定位。
返回列表