写点什么

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:0015678

评论

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

FPGA

Kevin Z

译-面向前端开发人员的Docker入门指南

费马

Docker Linux 容器 运维 大前端

提高 TCP 性能的方法,你知多少?

小林coding

TCP 性能优化 高并发 网络

第一周架构师总结

不在调上

微服务架构中分布式事务实现方案怎样何取舍【转发】

古月木易

微服务

架构师训练营 - 食堂就餐卡系统设计

Pontus

极客大学架构师训练营

作为一个架构师,我是不是应该有很多职责?

架构师修行之路

程序员 架构 架构师

架构师训练营-第一周作业

zcj

极客大学架构师训练营

食堂打卡系统架构设计文档

Frank Zeng

架构师训练营-第1课总结-202006-架构设计

👑👑merlan

架构设计 UML #总结#

架构师训练营第一周总结

hifly

软件架构 架构师 极客大学架构师训练营 #总结#

【架构师训练营】第一个周课程总结

Mr.hou

极客大学架构师训练营

TOGAF认证自学宝典

涛哥 数字产品和业务架构

架构 企业架构

谈反应式编程在服务端中的应用,数据库操作优化,从20秒到0.5秒

newbe36524

C# Reactive netcore

作业1 餐卡系统设计

Geek_2e7dd7

架构师训练营-开营

zcj

极客大学架构师训练营

架构师训练营-第一周学习总结

zcj

极客大学架构师训练营

架构师训练营第一周课堂学习总结

Frank Zeng

架构师训练营第1周作业——食堂就餐卡系统设计

在野

极客大学架构师训练营

产品经理越来越不值钱了吗?

Neco.W

产品 产品经理

架构师训练营-第一周-学习总结

Anrika

极客大学架构师训练营 架构总结

系统梳理主流定时器算法实现的差异以及应用

古月木易

定时器

c# 之linq——小白入门级

moonlucy

系统梳理主流定时器算法实现的差异以及应用

奈学教育

定时器

区块链如何打通征信行业的“任督二脉”?

CECBC

CECBC 区块链技术 征信 数据共享

食堂就餐卡系统设计

鲁米

架构设计

架构师训练营第一周学习总结

jiangnanage

架构设计

数据结构与算法之基础入门

shirley

数据结构 算法

架构师必备技能(灵魂拷问篇)

鲁米

架构师

二叉树视频|留美六年毅然归国,85 后技术 VP 金超:我想把工业智能做好

二叉树视频

写作平台 二叉树 年少有为

Facebook缓存技术演进:从单集群到多区域

伴鱼技术团队

架构 系统架构 分布式系统 缓存穿透 cache

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