ARTICLE DETAIL

资讯详情

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

【从0到1学习JVM · 17】引用计数法明明那么简单,为什么JVM垃圾回收却坚决不用它

【从0到1学习JVM · 17】引用计数法明明那么简单,为什么JVM垃圾回收却坚决不用它 前言写 C/C 时忘了手动释放内存就会泄漏而 Java 程序员之所以能只管new对象全靠 JVM 在后台默默做垃圾回收。本文从“到底什么是垃圾”切入拆解引用计数法的运转原理并讲透为什么 JVM 最终放弃了它。文章目录前言一、JVM 到底把什么样的对象算作垃圾1.1 没有任何引用指向的对象就是垃圾1.2 不清理垃圾对象为什么迟早会撑爆堆内存二、回收内存前必须先做垃圾标记三、引用计数法是怎么统计对象死活的3.1 在对象头上挂个计数器随时加减3.2 为什么每次引用变化都要付出额外代价四、让引用计数法彻底歇菜的循环引用问题4.1 两个对象互相指着为什么计数器永远不为零4.2 用一段代码验证 JVM 到底有没有用引用计数法五、写在最后一、JVM 到底把什么样的对象算作垃圾刚接触 JVM 垃圾回收Garbage Collection简称 GC时很多人一上来就去背各种回收算法的名字却忽略了一个最基础的前提。JVM 在堆内存里扫来扫去它到底凭什么认定某个对象是“垃圾”1.1 没有任何引用指向的对象就是垃圾在 Java 的运行期内存模型里我们通过new关键字创建出来的对象实例全都安安稳稳地躺在**堆Heap内存里而指向这些对象的局部变量引用通常保存在每个线程私有的虚拟机栈局部变量表**里。只要栈上还有引用变量指着堆里的某个对象代码随时都可能顺着这根引用线去读写对象里的字段。但如果随着方法执行结束、栈帧出栈或者你手动把变量赋值为null导致整个 JVM 里再也没有任何引用指向堆里的这个对象那它就彻底成了没人认领的孤岛。既然没有任何引用指向它后续的任何业务代码都不可能再访问到它。这种在运行期再也无法被程序触达的对象就是 JVM 眼里的垃圾对象。JVM 堆内存 (Heap)虚拟机栈 (线程局部变量表)引用已断开有效引用指针Order 局部变量 ref1(原本指向 0x1001现已置为 null)Order 局部变量 ref2(指向 0x2008)0x1001 Order 对象实例【无任何引用指向 → 沦为垃圾对象】0x2008 Order 对象实例【被 ref2 引用 → 存活对象】1.2 不清理垃圾对象为什么迟早会撑爆堆内存有人可能会想服务器内存动辄几十个 G几个用完的废弃对象留在堆里占点地方又能怎样问题在于业务请求是源源不断的。每次接口调用都会在堆里创建一大批临时对象方法一跑完这些对象就全都失去了引用。如果 JVM 对这些死掉的对象不管不顾它们就会一直死赖在堆内存里不走白白占着茅坑别人想申请内存也拿不到。随着死对象越攒越多堆里的可用空间被一点点蚕食殆尽等新来的请求再想new对象时JVM 就会直接抛出那个让所有后端开发头皮发麻的java.lang.OutOfMemoryError: Java heap space即 OOM。垃圾回收存在的唯一目的就是把这些死对象占用的内存重新腾出来循环利用给新创建的对象。二、回收内存前必须先做垃圾标记明确了为什么要回收垃圾接下来的问题是怎么干活。很多初学者以为垃圾回收就是直接去内存里擦除数据。其实不然任何一套垃圾回收机制在真正动手清理内存之前都必须先走完极其关键的第一步也就是垃圾标记阶段Garbage Marking。道理很简单。堆内存里同时躺着成百上千个正在干活的存活对象和已经死掉的垃圾对象如果收集器闭着眼睛乱删把正在处理支付订单的存活对象给回收了线上业务当场就得崩溃。所以 GC 必须先把所有垃圾对象精准地找出来、打上标记然后才能在第二阶段安全地回收它们的内存空间。因致命缺陷被主流 JVM 淘汰HotSpot 等主流 JVM 实际采用触发 JVM 垃圾回收 (GC)第一阶段垃圾标记阶段(精准判定堆内哪些对象已经死掉)思路一引用计数法(Reference Counting)思路二可达性分析算法(Reachability Analysis)无法处理循环引用放弃采用第二阶段垃圾清除阶段(复制 / 标记-清除 / 标记-整理)在计算机科学里用来在标记阶段判断一个对象是不是垃圾的主流算法只有两种一种叫引用计数法另一种叫可达性分析算法。我们今天先来彻底拆解第一种。三、引用计数法是怎么统计对象死活的引用计数法Reference Counting是早期很多编程语言比如 Python 的部分机制、早期 COM 组件非常青睐的一种垃圾判定思路。它的设计直觉非常朴素我第一次听到这个算法时甚至觉得它简直就是为垃圾标记量身定做的方案。3.1 在对象头上挂个计数器随时加减引用计数法的规则用一句话就能说明白给堆里的每一个对象都在内部配一个专门的“引用计数器”用来实时记录当前到底有多少个地方正在引用它。当然这个计数器属性完全不需要我们写 Java 代码时手动去定义而是由虚拟机底层悄悄塞在对象的**对象头Object Header**或者对象关联的元数据里。它的运行规则非常干脆每当有一个新的引用指向这个对象时比如把对象赋值给一个变量它头顶的计数器就自动1每当指向它的某个引用失效时比如局部变量离开方法作用域被销毁或者被显式赋值为null它头顶的计数器就自动-1只要在任何一个瞬间某个对象的引用计数器归零变成了0就说明此时此刻再也没有任何引用指向它了收集器当场就能判定它是垃圾对象随时可以回收。垃圾回收判定器堆内 Order 对象 (对象头含计数器 RC)业务线程执行代码垃圾回收判定器堆内 Order 对象 (对象头含计数器 RC)业务线程执行代码引用计数器 RC 1 (存活)引用计数器 RC 2 (存活)引用计数器 RC 1 (仍存活)引用计数器 RC 0Order a new Order()新引用指向对象1Order b a增加第二个引用指向同一对象2a null第一个引用断开3b null最后一个引用断开4计数器归零立即标记为可回收垃圾5不得不承认引用计数法的优点太诱人了。它不仅理解起来毫无门槛、实现逻辑直接而且判定效率极高不需要把所有业务线程停下来去做全堆大扫描只要某个对象在减一后计数器变成0当场就能认定它死了。3.2 为什么每次引用变化都要付出额外代价既然引用计数法这么好懂为什么设计师们在落地时却开始犯嘀咕了呢如果你顺着底层的执行细节往深里算一笔账就会发现它平时藏着两笔不小的日常开销。第一笔是额外的空间开销。堆内存里可不是只有几十个对象一个中大型 Java 服务跑起来堆里动辄躺着几千万甚至上亿个小对象。如果每个对象头里都必须硬生生腾出几个字节来存引用计数器积少成多算下来光是为了存这些数字就得吃掉一大块宝贵的堆内存。第二笔是额外的时间维护开销。原本一句简单的引用赋值objA objB在 CPU 眼里只需要改一下栈上的指针地址就完事了。但在引用计数法下每次发生引用赋值或引用失效运行期都必须在背后偷偷插入额外的指令先把objA原先指向对象的计数器减一还得顺手判断是不是减到 0 了再把objB指向对象的计数器加一。我们可以写一段 Java 模拟代码直观感受一下每次指针赋值背后多出来的维护动作packagecom.crayontech.jvm;importjava.util.concurrent.atomic.AtomicInteger;/** * 模拟引用计数法在每次引用变动时的底层维护开销 */publicclassReferenceCountingSimulator{staticclassManagedObject{privatefinalStringname;// 1. 额外空间开销每个对象都必须携带一个计数器多线程下甚至需要原子更新privatefinalAtomicIntegerrefCountnewAtomicInteger(0);publicManagedObject(Stringname){this.namename;}// 新增引用指向该对象时必须执行 1publicvoidretain(){intcurrentrefCount.incrementAndGet();System.out.println([name] 新增引用当前计数器 current);}// 引用断开或失效时必须执行 -1 并实时检查是否归零publicvoidrelease(){intcurrentrefCount.decrementAndGet();System.out.println([name] 断开引用当前计数器 current);if(current0){System.out.println([name] 计数器归零立即触发垃圾回收);}}}publicstaticvoidmain(String[]args){ManagedObjectordernewManagedObject(Order-10086);// 模拟 Order a new Order()order.retain();// 模拟 Order b aorder.retain();// 模拟 a nullorder.release();// 模拟 b nullorder.release();}}更要命的是在多线程高并发环境里多个线程可能同时把各自栈上的变量指向堆里的同一个共享对象。为了防止计数器加减算错这一下1和-1还得加锁或者使用底层的 CAS 原子指令就像上面代码里的AtomicInteger。原本纳秒级的简单赋值操作生生被拖慢了好几倍。四、让引用计数法彻底歇菜的循环引用问题平心而论多占一点对象头空间、每次赋值多花一点维护时间这些都还只是性能层面的取舍咬咬牙做点优化也能忍。真正让主流 JVM包括我们每天都在用的 HotSpot 虚拟机彻底放弃引用计数法的是一个它在逻辑上根本绕不过去的死穴也就是无法处理循环引用Circular Reference。4.1 两个对象互相指着为什么计数器永远不为零什么叫循环引用我们在日常写业务代码时经常会遇到这种双向关联的场景比如父子菜单互相持有引用或者双向链表里的前后节点互相指着对方。假设我们在方法里创建了两个对象Node A和Node B刚创建出来时栈上的局部变量a指着堆里的Node A局部变量b指着堆里的Node B。这时候Node A和Node B头顶的引用计数器都是1。接着我们让Node A内部的成员变量指一下Node Ba.next b同时让Node B内部的成员变量也指一下Node Ab.next a。因为各自多了一个指向自己的引用Node A和Node B的引用计数器双双变成了2。重点来了现在这个方法执行完毕或者我们手动写了两行a null; b null;把栈上的局部变量引用全部掐断。JVM 堆内存 (循环引用孤岛)虚拟机栈 (方法局部变量表)a.next 指向 Node Bb.next 指向 Node A外部引用已断开 (-1)外部引用已断开 (-1)局部变量 a null局部变量 b null0x3001 Node A 对象【引用计数器 RC 1】0x3002 Node B 对象【引用计数器 RC 1】你仔细看上面这张图里的状态当栈上的a和b断开连接后Node A和Node B的计数器各自减一从2变成了1。此时此刻虚拟机栈上已经没有任何变量指向它们俩了外界的任何代码都再也摸不到Node A和Node B。从客观事实来看它们俩已经是一对彻头彻尾的垃圾对象必须立刻被清理掉。但在引用计数法的死规则里只有计数器等于0才算垃圾因为Node A肚子里指着Node B帮Node B维持着1的计数Node B肚子里又指着Node A帮Node A维持着1的计数。它俩就像两个掉进深井里却互相拽着对方衣领的人计数器一辈子都是1永远降不到0。结果就是引用计数法对这对互相引用的孤岛对象完全视而不见导致它们永远无法被回收活生生造成严重的内存泄漏。4.2 用一段代码验证 JVM 到底有没有用引用计数法口说无凭我们直接写一段存在循环引用的 Java 代码看看我们手头的 HotSpot JVM 在面对这种“你指着我、我指着你”的对象时到底能不能把它们回收掉。packagecom.crayontech.jvm;/** * 验证 JVM 是否采用引用计数法构造互相引用的孤岛对象并触发 GC * 建议启动参数加上 -Xlog:gc* (JDK 9) 或 -XX:PrintGCDetails (JDK 8) */publicclassCircularReferenceGcTest{staticclassCycleNode{// 塞入 5MB 的字节数组方便从内存变化中看清对象是否被回收privatefinalbyte[]payloadnewbyte[5*1024*1024];publicCycleNodepartner;}publicstaticvoidmain(String[]args){CycleNodenodeAnewCycleNode();CycleNodenodeBnewCycleNode();// 构造双向循环引用A 指着 BB 也指着 AnodeA.partnernodeB;nodeB.partnernodeA;// 掐断栈上的外部引用使 nodeA 和 nodeB 成为外部无法访问的孤岛nodeAnull;nodeBnull;// 主动提醒 JVM 执行垃圾回收System.gc();}}如果 JVM 真的用的是引用计数法那么即便nodeA和nodeB都被置为null由于它们内部的partner字段还互相指着对方计数器均为1这10MB两个5MB的内存是绝对不可能在System.gc()时被回收的。但只要你把这段代码贴进 IDE 跑一下打开 GC 日志就会清楚地看到在System.gc()触发后新生代已用内存瞬间掉了超过10MBnodeA和nodeB被干干净净地清理掉了一点内存泄漏都没留下。这个实验直接证明了一个核心事实Java 虚拟机在判断对象是否为垃圾时根本没有使用引用计数法而是采用了能够天然免疫循环引用的第二种方案即可达性分析算法Reachability Analysis。评估维度引用计数法 (Reference Counting)可达性分析算法 (Reachability Analysis)核心判定依据对象头里的引用计数器是否等于0从根对象GC Roots出发能否顺着引用链找到该对象理解与实现难度极其简单直观计数器归零即可判定死亡实现较复杂需枚举 GC Roots 并遍历整条引用链日常运行期开销每次引用赋值/失效都得额外执行加减运算占对象头空间平时赋值无额外负担集中在 GC 扫描阶段统一标记能否解决循环引用无法解决互相引用会导致计数器恒大于 0内存泄漏天然解决只要与 GC Roots 断联抱团孤岛照样回收主流 JVM 是否采用未采用HotSpot 等主流 JVM 全面采用五、写在最后复盘引用计数法的整套逻辑你会发现计算机世界里没有白捡的便宜引用计数法把判定垃圾的工作平摊到了每一次引用加减上图的是归零即回收的省事但它只盯着“有没有人指着我”却不管“指着我的那个人自己是不是已经死了”这才会在两个对象互相引用时彻底败下阵来。既然引用计数法走不通JVM 采用的可达性分析算法到底是怎么顺藤摸瓜找出垃圾的哪些对象才有资格当所谓的GC Roots如果这篇文章帮你把引用计数法的死穴看明白了记得给作者点个关注下一篇我们接着把可达性分析算法拆个底朝天。
返回列表