写点什么

JVM 内存回收理论与实现

  • 2011-09-05
  • 本文字数:3634 字

    阅读完需:约 12 分钟

在上一篇《HotSpot 虚拟机对象探秘》中,我们讨论了在 HotSpot 里对象是如何创建的、有怎样的内存布局、如何查找和使用。在本篇中,我们将继续探讨虚拟机自动内存管理系统的最重要一块职能:虚拟机如何对死亡的对象进行内存回收。

本篇里面,所有涉及到具体 JVM 实现的内容,仍然默认为基于 HotSpot 虚拟机的实现,后文不再单独说明。

对象存活的判定

当一个对象不会再被使用的时候,我们会说这对象已经死亡。对象何时死亡,写程序的人应当是最清楚的。如果计算机也要弄清楚这件事情,就需要使用一些方法来进行对象存活判定,常见的方法有引用计数(Reference Counting)有可达性分析(Reachability Analysis)两种。

引用计数算法的大致思想是给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1;当引用失效时,计数器值就减 1;任何时刻计数器为 0 的对象就是不可能再被使用的。它的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法,也有一些比较著名的应用案例,例如微软 COM(Component Object Model)技术、使用 ActionScript 3 的 FlashPlayer、Python 语言和在游戏脚本领域得到许多应用的 Squirrel 中都使用了引用计数算法进行内存管理。但是,至少 Java 语言里面没有选用引用计数算法来管理内存,其中最主要原因是它没有一个优雅的方案去对象之间相互循环引用的问题:当两个对象互相引用,即使它们都无法被外界使用时,它们的引用计数器也不会为 0。

许多主流程序语言中(如 Java、C#、Lisp),都是使用可达性分析来判定对象是否存活的。这个算法的基本思路就是通过一系列的称为 GC 根节点(GC Roots)的对象作为起始点,从这些节点开始进行向下搜索,搜索所走过的路径成为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的。如图 1 所示,对象 object 5、object 6、object 7 虽然互相有关联,它们的引用并不为 0,但是它们到 GC Roots 是不可达的,因此它们将会被判定为是可回收的对象。

图 1 可达性分析算法判定对象是否可回收

枚举根节点

在 Java 语言里面,可作为 GC Roots 的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中。如果要使用可达性分析来判断内存是否可回收的,那分析工作必须在一个能保障一致性的快照中进行——这里“一致性”的意思是整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中,对象引用关系还在不断变化的情况,这点不满足的话分析结果准确性就无法保证。这点也是导致 GC 进行时必须“Stop The World”的其中一个重要原因,即使是号称(几乎)不会发生停顿的 CMS 收集器中,枚举根节点时也是必须要停顿的。

由于目前的主流 JVM 使用的都是准确式 GC(这个概念在第一篇中介绍过),所以当执行系统停顿下来之后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用。在 HotSpot 的实现中,是使用一组成为 OopMap 的数据结构来达到这个目的,在类加载完成的时候,HotSpot 就把对象内什么偏移量上是什么类型的数据计算出来,在 JIT 编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样 GC 在扫描时就就可以直接得知这些信息了。下面的代码清单 1 是 HotSpot Client VM 生成的一段 String.hashCode() 方法的本地代码,可以看到在 0x026eb7a9 处的 call 指令有 OopMap 记录,它指明了 EBX 寄存器和栈中偏移量为 16 的内存区域中各有一个普通对象指针(Ordinary Object Pointer)的引用,有效范围为从 call 指令开始直到 0x026eb730(指令流的起始位置)+142(OopMap 记录的偏移量)=0x026eb7be,即 hlt 指令为止。

代码清单 1 String.hashCode() 方法的编译后的本地代码

复制代码
[Verified Entry Point]
0x026eb730: mov %eax,-0x8000(%esp)
…………
;; ImplicitNullCheckStub slow case
0x026eb7a9: call 0x026e83e0 ; OopMap{ebx=Oop [16]=Oop off=142}
;*caload
; - java.lang.String::hashCode@48 (line 1489)
; {runtime_call}
0x026eb7ae: push $0x83c5c18 ; {external_word}
0x026eb7b3: call 0x026eb7b8
0x026eb7b8: pusha
0x026eb7b9: call 0x0822bec0 ; {runtime_call}
0x026eb7be: hlt

安全点

在 OopMap 的协助下,HotSpot 可以快速准确地地完成 GC Roots 枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说 OopMap 内容变化的指令非常多,如果为每一条指令都生成对应的 OopMap,那将会需要大量的额外空间,这样 GC 的空间成本将会变得很高。

实际上 HotSpot 也的确没有为每条指令都生成 OopMap,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safepoint),即程序执行时并非在所有的地方都能停顿下来开始 GC,只有在到达安全点时才能暂停。Safepoint 的选定既不能太少以至于让 GC 等待时间太长,也不能过于频繁以至于过分增大运行时的负荷。所以安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生 Safepoint。

对于 Sefepoint,另外一个需要考虑的问题是如何让 GC 发生时,让所有线程(这里不包括执行 JNI 调用的线程)都跑到最近的安全点上再停顿下来。我们有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension),抢先式中断不需要线程的执行代码主动去配合,在 GC 发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应 GC 事件。

而主动式中断的思想是当 GC 需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。下面的代码清单 2 中的 test 指令是 HotSpot 生成的轮询指令,当需要暂停线程时,虚拟机把 0x160100 的内存页设置为不可读,那线程执行到 test 指令时就会停顿等待,这样一条指令便完成线程中断了。

代码清单 2 轮询指令

复制代码
0x01b6d627: call 0x01b2b210 ; OopMap{[60]=Oop off=460}
;*invokeinterface size
; - Client1::main@113 (line 23)
; {virtual_call}
0x01b6d62c: nop ; OopMap{[60]=Oop off=461}
;*if_icmplt
; - Client1::main@118 (line 23)
0x01b6d62d: test %eax,0x160100 ; {poll}
0x01b6d633: mov 0x50(%esp),%esi
0x01b6d637: cmp %eax,%esi

安全区域

使用 Safepoint 似乎已经完美解决如何进入 GC 的问题了,但实际情况却并不一定。Safepoint 机制保证了程序执行时,在不太长的时间内就会遇到可进入 GC 的 Safepoint。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配 CPU 时间,典型的例子就是线程处于 Sleep 状态或者 Blocked 状态,这时候线程无法响应 JVM 的中断请求,走到安全的地方去中断挂起,JVM 也显然不太可能等待线程重新被分配 CPU 时间。对于这种情况,就需要安全区域(Safe Region)来解决。

安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中任意地方开始 GC 都是安全的。我们也可以把 Safe Region 看作是被扩展了的 Safepoint。

在线程执行到 Safe Region 里面的代码时,首先标识自己已经进入了 Safe Region,那样当这段时间里 JVM 要发起 GC,就不用管标识自己为 Safe Region 状态的线程了。在线程要离开 Safe Region 时,它要检查系统是否已经完成了根节点枚举(或者是整个 GC 过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开 Safe Region 的信号为止。

到这里,我们简单介绍了虚拟机如何去发起内存回收的问题,但是虚拟机如何具体地进行内存回收动作仍然未涉及到。因为内存回收如何进行是由虚拟机所采用的 GC 收集器所决定的,而通常虚拟机中往往不止有一种 GC 收集器,像目前(JDK 7 时代)的 HotSpot 里面就包含有 Serial、Serial Old、ParNew、Parallel Scavenge、Parallel Old、Concurrent Mark Sweep 和 Garbage First 七种收集器,在下一篇中,我们将以最新最先进的 Garbage First(G1)收集器为例,介绍内存回收的具体过程。

参考资料

本文撰写时主要参考了以下资料:


感谢张凯峰对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家加入到 InfoQ 中文站用户讨论组中与我们的编辑和其他读者朋友交流。

2011-09-05 00:0015739

评论

发布
暂无评论
发现更多内容

从不一样的角度描述Android事件传递,字节跳动面试官

android 程序员 移动开发

模块一学习笔记、总结

吴霏

架构实战营 「架构实战营」

Docker环境搭建和使用

Fox666

Docker

Adts 解析及AAC 编码

webrtc developer

ffmpeg aac,

从三流Android外包到秒杀阿里P7,从理论到实践

android 移动开发

为了跳槽强刷1000道Android真题,研发4面真题解析(Android岗)

android 程序员 移动开发

聊聊产品的使用场景

石云升

场景应用 职场经验 10月月更

刚从阿里、头条面试回来,动脑学院课程值得买吗

android 程序员 移动开发

作为程序员一定不要仅仅追求物质,做了6年Android开发

android 程序员 移动开发

你还在把Java当成Android官方开发语言吗,字节跳动算法工程师总结

android 程序员 移动开发

华为云数据库内核专家为您揭秘MySQL Volcano模型迭代器性能提升千倍的秘密

华为云数据库小助手

GaussDB 华为云数据库 GaussDB(for MySQL)

023云原生之Kubernetes的存储

穿过生命散发芬芳

云原生 10月月更

作为一个程序员你觉得最大的悲哀是什么,安卓音视频开发

android 程序员 移动开发

免费Android高级工程师学习资源,苦熬一个月

android 程序员 移动开发

了解Android架构组件后构建APP超简单,阿里P7大牛手把手教你

android 程序员 移动开发

五面阿里拿下飞猪事业部offer,思维导图+源代码+笔记+项目

android 程序员 移动开发

做了3年Android还没看过OkHttp源码,学Android看这就完事了

android 程序员 移动开发

分享Android资深架构师的成长之路,系列篇

android 程序员 移动开发

史上超级详细:扔物线学堂

android 程序员 移动开发

这部分布式事务开山之作,凭啥第一天预售就拿下当当新书榜No.1?

冰河

数据库 分布式 分布式事务 微服务 数据一致性

紧张的336小时53分钟21秒,我等来了字节跳动offer(Java岗)

Java 编程 程序员 架构 面试

千言-情感分析2.0发布,三大数据集升级打造中文情感分析影响力

科技热闻

含爱奇艺,小米,腾讯,阿里,享学课堂怎么样

android 程序员 移动开发

什么是aPaaS?低代码与高生产率的aPaaS和RAD相比如何?

优秀

低代码 aPaaS RAD

事件分发流程图,扔物线课程怎么样

android 程序员 移动开发

架构设计-电商微服务拆分

小智

架构训练营

对话凡泰极客联合创始人杨涛: 小程序生态市场潜力广阔

FinClip

小程序 金融科技 移动开发

谈一谈使用Python入门量化投资

Regan Yue

量化交易 10月月更

三国与AI,交汇在中原

脑极体

中软国际用一场自我进化,推动云市场跨入下一幕

脑极体

架构训练营第3期模块一作业

吴霏

架构实战营 #架构实战营 「架构实战营」

JVM内存回收理论与实现_Java_周志明_InfoQ精选文章