尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java四种引用类型详解:强引用、软引用、弱引用、虚引用实战指南

Java四种引用类型详解:强引用、软引用、弱引用、虚引用实战指南 强引用、软引用、弱引用、虚引用这四个词在Java面试里出现的频率几乎和技术面里的HashMap一个量级。但说句实话大部分时候靠背八股文应对只能答出“默认是什么、回收条件是啥”真到写代码和排查线上问题时照样一头雾水。我自己就曾被一个看起来没毛病的缓存搞出过一次线上OOM问题恰恰出在强引用身上。这篇文章不打算一上来列定义而是从JVM回收对象时那套判断逻辑聊起把四种引用的回收临界点、适用场景和实战写法一次讲透。无论你是面试前恶补基础还是在维护高并发服务时被内存问题折磨读完之后都能直接套用到手头项目里。1. 从垃圾回收视角看引用的本质1.1 对象“活没活”不是靠感觉而是看可达性先摆正一个基础认识JVM判断一个对象该不该回收核心算法是可达性分析。它从一组叫GC Roots的起点出发包括当前栈帧里的局部变量、静态字段、JNI引用等一路顺着引用关系往下走能走到的对象就是“活”的走不到的就是“死”的。这里的关键点在于引用不是只有“有”和“没有”两种状态而是按强度分成了四档。JVM在可达性分析时会考虑引用链上每一层的强度最后决定这条链的“有效长度”到哪为止。打个比方GC Roots好比公司老板对象就是员工。老板发了一封全员邮件能不能通过某个职级的同事最终传到某个实习生手里取决于这条传播链上每一个环节是否愿意转述。强引用就像“闭眼都要转达”的核心骨干软引用像“平时转达、紧急时可以不转”的普通员工弱引用像“随时可能不转”的实习生虚引用则干脆只留了个工作群记录人根本不在群里。这个类比虽然粗糙但对理解回收优先级非常有帮助。1.2 四类引用到底差在哪先把四个引用类型的核心差别拉一张表后面所有分析都围绕这张表展开引用类型代表类回收时机get()能否拿到对象典型用途强引用默认new的对象只要引用链存在永不回收能普通对象、业务数据软引用SoftReferenceJVM内存不足时在抛出OOM前回收能回收前内存敏感型缓存、大对象缓存弱引用WeakReference下一次GC发生时无论内存是否充足都回收能回收前ThreadLocal的key、WeakHashMap虚引用PhantomReference任何时候都可能回收永远返回null对象回收通知、堆外内存清理从这张表能明显看出四类引用其实是一条“回收优先级从低到高”的线强引用不回收软引用在濒临OOM时回收弱引用每次GC就回收虚引用则根本不对对象生命周期做任何挽留。这句话是整篇文章的纲理解后面所有场景都不用再靠背。1.3 先用代码感受一下回收差异理论说再多不如亲手跑一次。写个小实验单独运行下面这段代码记得加上-Xmx64m参数把堆上限固定住否则结果可能不够直观import java.lang.ref.SoftReference; import java.lang.ref.WeakReference; public class ReferenceDemo { public static void main(String[] args) throws Exception { SoftReferencebyte[] softRef new SoftReference(new byte[10 * 1024 * 1024]); WeakReferencebyte[] weakRef new WeakReference(new byte[10 * 1024 * 1024]); System.out.println(GC之前 软引用: softRef.get() , 弱引用: weakRef.get()); System.gc(); Thread.sleep(200); System.out.println(GC之后 软引用: softRef.get() , 弱引用: weakRef.get()); try { byte[] big new byte[100 * 1024 * 1024]; } catch (OutOfMemoryError e) { // 内存不足触发软引用回收 } System.out.println(内存不足后 软引用: softRef.get()); } }由于System.gc()只是向JVM“建议”一次垃圾回收不同JVM实现里软引用的表现可能稍有波动但在绝大多数HotSpot场景下运行结果是明确的GC之后弱引用对象已经变成null软引用对象还在等到堆内存被那一大块数组逼到临界点时软引用对象也被回收了。这个例子建议实际跑一遍亲手看到差异比背十遍定义都有用。2. 强引用默认行为下的内存泄漏重灾区2.1 强引用的回收规则强引用不需要任何包装类Object obj new Object()拿到的就是强引用。它的规则简单粗暴只要从GC Roots出发还能顺着强引用链到达这个对象GC就永远不会把它当作垃圾处理。换句话说一个被强引用指向的对象相当于手里握着一张免死金牌即使它已经彻底用不到了只要没人主动解开引用链内存就会一直占着。这也解释了为什么Java写的时间一长内存问题往往不是“GC不工作”而是“可回收的对象因为被强引用拽着GC想收也收不掉”。很多人以为Java有GC就不需要关心内存释放这句话在中小项目里勉强能站住一旦到了缓存、静态容器、线程池这些容易产生长生命周期的场景强引用的无意识持有就变成了一颗定时炸弹。2.2 一个让缓存把堆撑爆的现场我印象最深的一次线上故障是业务系统里一个看起来很常见的静态缓存public class BizCache { private static final MapString, byte[] DATA new HashMap(); public static void putData(String key, byte[] data) { DATA.put(key, data); } public static byte[] getData(String key) { return DATA.get(key); } }调用方不断往这里塞数据塞进去之后就再也没人管它。表面上看这个缓存只是放着而已但问题在于常量DATA是静态字段它本身就是一个GC Roots起点里面存的所有byte[]对象全都成了强可达对象。堆越积越大直到某天直接OOM。事后复盘真正的问题不是缓存设计得不好而是这个缓存完全没有回收约束数据进去之后生命周期等于整个JVM进程的生命周期。2.3 强引用泄漏的核心解法强引用本身没有错错的是“无意识”地让对象的生命周期被意外拉长。实际项目里推荐这样处理能用局部变量就不提成实例字段方法执行完栈帧弹出引用链自然断开需要长时间存活的容器务必设计容量上限或淘汰策略比如按时间清理、按大小淘汰静态集合是最容易出风险的地方在确定业务不需要时调用map.clear()或map.remove(key)手动解除引用如果缓存场景下希望让GC辅助回收就要考虑换成软引用或弱引用并配合引用队列做自动清理这也是后面要重点讲的内容。提示强引用导致的内存泄漏通常不报错、不打印异常只会让内存曲线缓慢抬升。观察GC日志中老年代持续增长且Full GC也回收不下来就要怀疑是不是有强引用把对象钉住了。3. 软引用与弱引用缓存的左右手3.1 软引用内存够用绝不回收内存紧张才出手SoftReference的设计目标非常明确当一个对象“死了可惜、留着占地”的时候由JVM根据内存压力来决定是否让它活。内存空间还充裕就正常返回对象内存紧缺到马上要抛OOMJVM会把软引用指向的对象直接回收把空间让给更重要的申请。这个特性让软引用特别适合做缓存尤其是那些重建成本不高但不便宜的场景比如图片、配置快照、临时大对象。它提供了一个天然的降级逻辑有内存缓存就生效没内存缓存自动失效重新走一遍源头加载就行。大型Web应用里的图片缓存、报表模块的临时数据集都可以用软引用包一层既享受缓存收益又不至于把堆撑爆。但这里必须提醒一句软引用的回收时机并不是一个精确的、可预测的时间点。它依赖JVM的具体实现和当前内存压力不同JVM、不同GC算法下甚至同一个程序的不同运行阶段软引用都可能给出不同的表现。尤其在Android这类对内存更敏感的环境中软引用被系统回收的频率可能远超预期所以绝不能把软引用当成“绝对可靠”的缓存机制业务逻辑里必须允许缓存随时失效。3.2 弱引用下一次GC来了就走弱引用比软引用更“薄情”。只要发生一次垃圾回收哪怕内存完全充足弱引用指向的对象也会被回收。注意这里的描述是“下一次GC”不等于“立刻”但只要GC真的触发了弱引用指向的对象基本就会从堆里消失。弱引用这种“不挽留任何对象”的特点适合的场景是对象本身有独立生命周期的管理机制外面只是一个顺带持有的观察者不该影响它正常消亡。牺牲这个引用后续还能从其他地方重新获取对象或者这个对象本来就允许被回收。最常见的两个案例就是ThreadLocal的Key设计和WeakHashMap下面会专门展开。3.3 实验验证内存压力下两者的行为边界把第一节的demo稍微精确化。运行时固定堆上限为64MB先放入两个10MB的数组分别用软引用和弱引用包装。接着触发一次GC弱引用对象基本必死再把堆内存用100MB数组去怼软引用对象也会被回收。这个实验揭示的核心不在于“会不会回收”而在于软引用多活的那段时间正好覆盖了“内存尚可维持业务运行”的区间这种弹性正是它适合做缓存的原因。还有一个实操上的坑JVM在回收软引用时并不会给程序一个回调通知。你只有在调用softRef.get()时才可能发现它返回了null。所以读取软引用缓存的代码必须处理拿到null的情况比如回源数据库或重新计算否则缓存一失效程序就直接空指针见面了。3.4 弱引用的成名作ThreadLocal为什么用弱引用却又要手动removeThreadLocal是理解弱引用价值的最佳教材也是最大的事故现场。ThreadLocalMap内部的Entry继承自WeakReferencekey就是ThreadLocal对象本身。这样设计的原因很清晰业务代码把ThreadLocal置成null之后这个ThreadLocal对象不再被业务引用按理说应该被GC回收。如果Entry里的key是强引用ThreadLocal对象就会被Entry拽着不松手那它永远无法被回收时间一长就成了长期存活在ThreadLocalMap里的脏条目。换成弱引用做key之后ThreadLocal对象一旦没有外部强引用下一次GC就会被回收key在Entry里自动变成null。问题的另一半在value上value字段是普通强引用只要ThreadLocalMap不清理这个Entryvalue就会一直驻留在内存里。因此使用完ThreadLocal必须调用remove()把整条Entry清掉。这也是面试官最常追问的点既然key已经是弱引用为什么还要remove答案是弱引用只解决了key的回收没解决value的回收。不动手removevalue照样会泄漏。WeakHashMap的原理也一脉相承它的key使用弱引用持有当key对象不再被外部引用时GC就能把它回收对应Entry也会在后续操作中被清除。所以WeakHashMap适合存放“临时关联数据”不适合做需要长期稳定存活的业务缓存。4. 虚引用只为通知存在的幽灵4.1 虚引用的三个反常识特性虚引用PhantomReference大概是四类引用里最容易被误解的。它有三个反常识的特性通过phantomRef.get()拿不到对象永远返回null这是它和另外三类引用最直观的区别它并不决定对象的生命周期被虚引用关联的对象跟没有被引用差不多随时可能被回收它的价值在于对象被GC回收之后这个PhantomReference对象会被放入关联的ReferenceQueue程序可以通过检查队列拿到“这个对象已经被回收了”的通知。简单说虚引用不是拿来“用对象”的而是拿来“监听对象死亡”的。你根本碰不到对象本身只能在它消亡后收到一条落网通知。4.2 用虚引用监听对象回收的完整姿势先看一段能跑通的示例代码import java.lang.ref.PhantomReference; import java.lang.ref.Reference; import java.lang.ref.ReferenceQueue; public class PhantomDemo { private static class Resource {} public static void main(String[] args) throws Exception { ReferenceQueueResource queue new ReferenceQueue(); Resource resource new Resource(); PhantomReferenceResource phantom new PhantomReference(resource, queue); resource null; System.gc(); Thread.sleep(200); Reference? extends Resource ref queue.poll(); System.out.println(phantom.get() phantom.get()); System.out.println(ref null ? 对象尚未被回收 : 收到对象回收通知); } }这里需要留意JVM对对象的回收判断取决于可达性分析即使外部强引用置空了GC的确切触发时间也不由我们控制。所以上面的代码第一次跑可能还看不到队列里有东西多触发几次GC或者循环压一会儿就能看到PhantomReference入队。实操中更稳妥的方式是启动一个后台线程循环调用queue.remove()阻塞等待只要对象被回收队列里必然会有元素线程再去做后续清理。4.3 虚引用与堆外内存清理虚引用最知名的实际应用是JDK中DirectByteBuffer堆外内存的回收机制。Java NIO分配的堆外内存不受堆大小限制却需要手动释放。JVM内部通过Cleaner机制把堆外内存的清理动作与一个虚引用关联起来当DirectByteBuffer对象本身变成垃圾时虚引用入队后台的垃圾清理线程从队列拿到引用调用对应的清理器把堆外内存归还给操作系统。整个过程把“对象回收”和“资源释放”两个动作解耦了——你不需要在对象被回收时手动分配内存去做释放而是让JVM在对象消亡后通过引用队列通知你。很多高性能框架处理堆外内存时都会借鉴这层设计大对象本身由GC管理真正瓶颈的资源比如堆外内存、文件句柄、连接池额度则由虚引用触发的回调来决定何时释放。注意虚引用的回收时机依然依赖GC发生它不能做到“对象不可达的瞬间立刻通知”。如果系统里对被监控资源的释放时机要求极高那就不能只依赖虚引用还是要在业务代码里显式释放为主虚引用只当兜底。5. 组合武器ReferenceQueue实现自动清理5.1 引用队列的工作原理引用队列ReferenceQueue本身不复杂它是跟Reference对象绑定在一起的队列。当一个软引用、弱引用或虚引用指向的对象被GC回收后这个Reference对象本身会被放进关联的ReferenceQueue里。程序只需要轮询或阻塞读取这个队列就能知道有哪些引用指向的对象已经没了。它的价值在于把“对象被回收”从一个不可感知的事件变成一个可编程的事件。你不再需要定期扫描Map去判断每个value还活没活着也不需要靠软引用返回null来事后推测引用队列会把回收事件主动告诉你。实际操作中有两种读取队列的方式poll()非阻塞有元素就返回没有就返回null适合在业务操作间隙顺手清理remove()阻塞在没有元素时会一直等待适合起一个独立的后台线程专职处理回收事件。5.2 一个软引用缓存管理器的完整实现把前面的知识组装起来写一个带自动清理的软引用缓存。核心技巧是让内部Entry继承SoftReference并额外保存一个key字段这样引用被回收后我们还能从队列里知道该清理Map中哪个key。import java.lang.ref.ReferenceQueue; import java.lang.ref.SoftReference; import java.util.HashMap; import java.util.Map; public class AutoCleanCacheK, V { private final MapK, SoftEntryV cache new HashMap(); private final ReferenceQueueV queue new ReferenceQueue(); private static class SoftEntryV extends SoftReferenceV { private final Object key; SoftEntry(Object key, V value, ReferenceQueueV queue) { super(value, queue); this.key key; } } public void put(K key, V value) { cleanExpiredEntries(); cache.put(key, new SoftEntry(key, value, queue)); } public V get(K key) { cleanExpiredEntries(); SoftEntryV entry cache.get(key); if (entry null) { return null; } V value entry.get(); if (value null) { cache.remove(key); } return value; } public void clear() { cache.clear(); } SuppressWarnings(unchecked) private void cleanExpiredEntries() { SoftEntry? expired; while ((expired (SoftEntry?) queue.poll()) ! null) { cache.remove(expired.key); } } }这段代码的意义在于软引用负责让缓存对象在内存紧张时自行退出引用队列负责在退出后清理Map中的脏数据。两个机制互相配合缓存容量会自动收敛不会无限膨胀。你可以把get()里的回源逻辑加上value为null就去数据库或远端加载再重新put进缓存。这就在业务层面做到了“有缓存用缓存没缓存重新加载”即使内存压力大了也不会直接OOM。5.3 场景选择表什么时候该用哪种引用业务场景推荐引用类型思路普通对象、短时间内必须存活的业务数据强引用默认方案别乱加引用包装图片、文档等大对象缓存软引用内存不足时自动降级需要严格跟随GC周期的观察者/旁路数据弱引用不延长对象生命周期需要在对象回收后执行资源清理虚引用 ReferenceQueue拿到回收通知再释放外部资源ThreadLocal存放线程内上下文弱引用key 手动remove防止ThreadLocal和value双重泄漏选型时有一条经验能用强引用讲清楚的逻辑就不要强行上引用包装类。引用类型是用来解决特定生命周期问题的不是拿来炫技的。过度使用引用包装不仅让代码难读还会引入“对象莫名消失”这类更难排查的问题。6. 常见问题与排查技巧实录6.1 面试高频问题速查这部分直接给结论每个问题后面附一句解题关键软引用和弱引用的本质区别是什么回收触发条件不同软引用在内存不足时才回收弱引用在每次GC时都可能回收后者的回收更激进。为什么ThreadLocal要配合弱引用目的是让ThreadLocal对象本身能被GC回收但value需要手动remove否则value会泄漏。WeakHashMap会丢数据吗会。当key对象没有其他强引用时GC就可能回收它对应的Entry会被清除。所以它只适合不怕丢的场景。虚引用能帮我清理普通对象吗不能。虚引用拿不到对象引用只能通过ReferenceQueue接收回收事件再做外部资源清理。软引用到底什么时候触发回收不同JVM实现不同无法精确预测设计上一定要处理get()返回null的情况。6.2 实际开发中踩过的坑第一坑无脑用弱引用做缓存。弱引用对象在下一次GC就被收掉缓存命中率会惨不忍睹尤其在高频访问场景里几乎等于每个请求都回源。正确做法是区分数据特性可重建且重建成本低的用弱引用重建成本高的用软引用不能容忍失效的用强引用加定时清理。第二坑用完ThreadLocal不remove。这是线上泄漏案例的重灾区。线程池里的线程长期存活ThreadLocalMap里的value会一直跟着线程活着哪怕业务早就不再需要它。我在服务里排查时通过堆转储直接看到某个业务对象攒了几千份副本全部都是ThreadLocal的value。修复方式就一句话在finally块里调remove()。第三坑在软引用缓存里忘了处理null。很多同学写缓存时直接判断if (ref.get() ! null) 返回value一旦返回null就NPE。设计时一定要把“软引用已失效”当作正常路径回源和失败处理要写完整。第四坑依赖虚引用做实时通知。虚引用的入队发生在GC回收之后而GC触发时机不可控。如果业务期待的是“对象一不可达就立刻收到通知”必然会失望。正确的定位是兜底清理不是实时监控。6.3 线上排查内存问题的小工具真遇到内存异常光靠口头分析不够我这里常用的三板斧加JVM参数打GC日志重点关注老年代占用曲线和Full GC频率。JDK 9以上可以用-Xlog:gc*JDK 8及更早版本用-verbose:gc内存快照直接交给MAT分析查看Dominator Tree看谁占着大对象再结合引用链看是哪条强引用路径系住了它针对静态集合和缓存容器用jmap -histo:live看对象数量对比上线前后几个时间点的对象数变化基本能定位到异常增长的对象类型。排查强引用泄漏时最容易被忽略的一步是查看被钉住对象的引用链。MAT里右键Dominator Tree节点选择“Path To GC Roots”就能看到是哪条引用路径让这个对象逃过了回收。看到是static字段牵出来的路径基本就锁定问题了。最后说点个人体会。引用类型这套机制说到底是一份“优先级约定”它让开发者可以在不影响业务逻辑的前提下把对象生命周期的一部分决定权交给GC。但用起来的度很重要我见过把业务数据全塞软引用的翻车案例也见过因为少写一个remove而泄漏到OOM的线上事故。最稳妥的做法是先明确对象的生命周期目标再选对应的引用类型最后一定要给缓存失效、引用回收这类路径写好兜底逻辑。你不需要在项目里刻意用满这四种引用但掌握它们处理起那些诡异的OOM和内存增长问题时你会比同行多一条清晰的排查路径。
返回列表