JVM 垃圾回收

接上篇文章, 本篇文章主要总结 Garbage Collector 的相关知识点.

1. 存活判定

存活判定一般依据引用计数或者可达性分析, 引用计数时, 某一个对象每一个地方引用它, 计数器加一, 某引用失效, 计数器减一, 计数器为0的对象不可使用. 缺点在于难以解决对象互相引用的问题.

可达性分析是利用 GC Roots 从这些节点向下搜索, 当一个对象从 GC Roots 不可达, 则认为该回收. 可以当作 GC Roots 的为:

i. 本地变量(虚拟机栈)

ii. 静态属性引用(方法区)

iii. 常量引用(方法区)

iv. JNI(Native 方法引用)

当对象被标记为不可达时, 如果该对象没必要执行 finalize() 方法, 则会被认为死亡. 如果需要执行 finalize(), 那么会将该对象加入 F-Queue, 由 Finalize 线程执行该方法. 执行过 finalize() 的方法对象不会再次执行该方法. 此时对象真正死亡.

PermGen 也会回收废弃的常量和无用的类, 无用的类是指

i. 该类的所有实例都被回收

ii. 加载该类的 ClassLoader 被回收

iii. 该类对应的 Class 对象没有被任何地方引用, 无法在任何地方通过反射访问该类的方法

大量使用反射, 动态代理, CGLib 等字节码框架, 动态 JSP 等频繁自定义 ClassLoader 需要对方法区回收, 以确保不会溢出.

2. 垃圾回收算法:

i. Mark-Sweep 算法, 算法如名, 标记和清除效率低下且容易产生内存碎片, 当分配较大对象时会反复触发 GC

ii. Copying 算法, 年轻代 (Young Generation) 分为8:1:1的 Eden/Survivor1/Survivor2 的空间, 每次使用 Eden 和一块 Survivor , 触发 GC 时将 Eden 和 Survivor 中的内容移到另一个 Survivor 上. 当 Survivor 不够时, 会触发老年代的分配担保(Handle Promotion)

iii. Mark_Compact算法, full GC(Major GC)时, 对老年代中的对象进行回收时, 在Mark-Sweep之后将所有的对象向一端移动. (可想而知一次 Major GC 多么消耗时间资源)

3. 保守式 GC 与准确 GC

保守式GC

如果JVM选择不记录任何这种类型的数据,那么它就无法区分内存里某个位置上的数据到底应该解读为引用类型还是整型还是别的什么。这种条件下,实现出来的GC就会是“保守式GC(conservative GC)

在进行GC的时候, JVM开始从一些已知位置(例如说JVM栈)开始扫描内存, 扫描的时候每看到一个数字就看看它”像不像是一个指向GC堆中的指针”. 这里会涉及上下边界检查(GC堆的上下界是已知的), 对齐检查(通常分配空间的时候会有对齐要求,假如说是4字节对齐,那么不能被4整除的数字就肯定不是指针)之类的. 然后递归的这么扫描出去.

由于JVM要支持丰富的反射功能, 本来就需要让对象能了解自身的结构, 而这种信息GC也可以利用上, 所以很少有JVM会用完全保守式的GC.

JVM可以选择在栈上不记录类型信息, 而在对象上记录类型信息. 这样的话, 扫描栈的时候仍然会跟上面说的过程一样, 但扫描到GC堆内的对象时因为对象带有足够类型信息了, JVM就能够判断出在该对象内什么位置的数据是引用类型了. 这种是”半保守式GC”, 也称为”根上保守(conservative with respect to the roots)”. 为了支持半保守式GC, 运行时需要在对象上带有足够的元数据. 如果是JVM的话, 这些数据可能在类加载器或者对象模型的模块里计算得到, 但不需要JIT编译器的特别支持.

JVM如果能够判断出所有位置上的数据是不是指向GC堆里的引用, 包括活动记录(栈+寄存器)里的数据, 则是”准确GC”. 为了实现”准确GC”, 可以从外部记录下类型信息, 存成映射表. 要实现这种功能,需要虚拟机里的解释器和JIT编译器都有相应的支持, 由它们来生成足够的元数据提供给GC.

使用这样的映射表一般有两种方式:

i. 每次都遍历原始的映射表, 循环的一个个偏移量扫描过去; 这种用法也叫”解释式”;

ii. 为每个映射表生成一块定制的扫描代码(想像扫描映射表的循环被展开的样子), 以后每次要用映射表就直接执行生成的扫描代码; 这种用法也叫”编译式”.

举一个通俗的例子: 许多废弃的对象如同许多黑色的垃圾袋摆成一排, 现在需要将垃圾回收.

保守时GC中, 根据垃圾袋的大小尺寸判断这里面装的是不是垃圾, 然后决定是否回收.

半保守GC中, 垃圾袋自己贴上标签(元数据), 标签上写着自己里面装的有什么, 有多少. 这些标签上的数据可以(类加载器/对象模型)通过计算得到

准确GC, 维护一个统计表格, 对每个垃圾袋的详细信息都了如指掌. 需要JIT和解释器的特殊支持.

4. OopMap与Safepoint

在HotSpot中, 对象的类型信息里有记录自己的OopMap, 记录了在该类型的对象内什么偏移量上是什么类型的数据. 所以从对象开始向外的扫描可以是准确; 这些数据是在类加载过程中计算得到的.

可以把OopMap简单理解成是调试信息. 在源代码里面每个变量都是有类型的, 但是编译之后的代码就只有变量在栈上的位置了. oopMap就是一个附加的信息,告诉你栈上哪个位置本来是个什么东西. 这个信息是在JIT编译时跟机器码一起产生的. 因为只有编译器知道源代码跟产生的代码的对应关系. 每个方法可能会有好几个oopMap, 就是根据safepoint把一个方法的代码分成几段, 每一段代码一个oopMap, 作用域自然也仅限于这一段代码. 循环中引用多个对象, 肯定会有多个变量, 编译后占据栈上的多个位置. 那这段代码的oopMap就会包含多条记录.

每个被JIT编译过后的方法也会在一些特定的位置记录下OopMap,记录了执行到该方法的某条指令的时候,栈上和寄存器里哪些位置是引用. 这样GC在扫描栈的时候就会查询这些OopMap就知道哪里是引用了. 这些特定的位置主要在:

1、循环的末尾

2、方法临返回前 / 调用方法的call指令后

3、可能抛异常的位置

这种位置被称为“安全点”(safepoint). 之所以要选择一些特定的位置来记录OopMap,是因为如果对每条指令(的位置)都记录OopMap的话,这些记录就会比较大,那么空间开销会显得不值得. 选用一些比较关键的点来记录就能有效的缩小需要记录的数据量,但仍然能达到区分引用的目的. 因为这样,HotSpot中GC不是在任意位置都可以进入,而只能在safepoint处进入. 而仍然在解释器中执行的方法则可以通过解释器里的功能自动生成出OopMap出来给GC用.

平时这些OopMap都是压缩了存在内存里的, 在GC的时候才按需解压出来使用.

HotSpot是用”解释式”的方式来使用OopMap的, 每次都循环变量里面的项来扫描对应的偏移量.

对Java线程中的JNI方法, 它们既不是由JVM里的解释器执行的, 也不是由JVM的JIT编译器生成的, 所以会缺少OopMap信息. 那么GC碰到这样的栈帧该如何维持准确性呢?

HotSpot的解决方法是:所有经过JNI调用边界(调用JNI方法传入的参数、从JNI方法传回的返回值)的引用都必须用“句柄”(handle)包装起来. JNI需要调用Java API的时候也必须自己用句柄包装指针. 在这种实现中,JNI方法里写的“jobject”实际上不是直接指向对象的指针, 而是先指向一个句柄, 通过句柄才能间接访问到对象. 这样在扫描到JNI方法的时候就不需要扫描它的栈帧了——只要扫描句柄表就可以得到所有从JNI方法能访问到的GC堆里的对象.

但这也就意味着调用JNI方法会有句柄的包装/拆包装的开销, 是导致JNI方法的调用比较慢的原因之一.

5.各种各样的GC

对GC的分类首先可以按照并行/串行进行分类, 例如Serial Collector和ParNew Collector, Serial Collector是单线程收集器, 是因为收集时需要 Stop the World. ParNew是Seiral的多线程版本. 再例如Serial-Old Collector与Parallel-Old Collector, 使用标记整理方法对老年代进行回收.

还可以按照目标进行分类, 例如 Parallel Scavenge Collector目的是为了提供最大的吞吐量, 是Elden与Survivor相互复制的新生代收集器. CMS Collector是以获取最短回收停顿的收集器, 基于Mark-Sweep算法. 分为初始标记, 并发标记, 重新标记, 并发清除四个步骤. 初始标记即标记GC Roots, 并发标记即GC Roots Tracing, 重新标记为了修正记录. CMS 对 CPU 比较敏感, 容易产生浮动垃圾与空间碎片(触发Full GC).

G1 Collector是比较新的GC, 充分利用多核优势进行并行与并发收集, 同时支持分代收集与空间整合(不会产生碎片), 而且有可预测的停顿. 由于将整个Java堆规划成大小相等的Regions, 可以有计划的(垃圾堆积的价值大小)避免在整个堆中进行全区域的垃圾收集.

为了方便GC, 一般有这么几条策略:

i. 对象首先在Eden中分配

ii. 大对象直接进入老年代

iii. 长期存活的对象也直接进入老年代

iv. 动态的对象年龄判定.(MaxTenuringThreshold)

v. 空间分配担保(Handle Promotion)

最后需要值得注意的是, JVM的规范并没有明文规定必须划分老年代/新生代/Eden/Survivor 等区域, 甚至更没有规定方法区, 常量池, 堆空间这些概念. 除非阅读JVM源码, 从内存上无法看出JVM怎么对内存进行分配. 也就是说如何分配空间只是JVM的自由, 而上面研究的内容只不过是大多数JVM(例如HotSpot)的选择. 没有规定JVM必须要实现这些内容. JVM只是规定了哪些是线程共享, 哪些是线程隔离的区域.

JVM 监控工具

主要提几个Hotspot JVM 监控工具

i. jps

用来查看基于 JVM 里面所有进程的具体状态, 包括进程ID, 进程启动的路径等等.

ii. jstack

jstack用于生成 java 虚拟机当前时刻的线程快照, 主要目的是定位线程出现长时间停顿的原因, 如线程间死锁, 死循环, 请求外部资源导致的长时间等待等.

iii. jmap

打印 java 进程的堆内存信息

iv. jstat

查看classloader, compiler, gc相关信息, 实时监控资源和性能. jstat工具特别强大, 可以用来监视 VM 内存内的各种堆和非堆的大小及其内存使用量.

iiv. jconsole/jvisualvm

jconsole可以监控Java应用程序(如jar应用, tomcat等), 但被监视的应用程序必须和jconsole是用同一个用户运行的

除此之外, 在 Linux 机器上, 用到的还有

vmstat, sysstat(第三方), top, uptime 这些常用工具

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.