深入理解java虚拟机 第二部分

Posted on 18-19-03

国内JVM相关书籍NO.1,Java程序员必读。读书笔记第二部分对应原书的第三章,主要介绍JVM的垃圾回收算法、实现。

第三章 垃圾收集器与内存分配策略

概述

思考GC需要完成的3件事情:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

再回头看看第二章介绍的Java内存运行时区域的各个部分:

  • 程序计时器、虚拟机栈、本地方法栈:随线程而灭,栈帧随方法而进行出栈和入栈,每一个栈帧分配的内存在类结构确定就已知,因此这几个区域不需要考虑回收;
  • 对于Java堆和方法区,只有程序运行期间才知道会创建哪些对象,内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存;

对象已死吗?

在垃圾收集器进行回收前,第一件事就是确定这些对象哪些还存活,哪些已经死去。

引用计数算法

给对象添加引用计数器,当有地方引用它时就加1,引用失效就减1,为0时就认为对象不再被使用可回收。该算法失效简单,判断高效,但并不被主流虚拟机采用,主要原因是它很难解决对象之间相互循环引用的问题。

可达性分析算法

通过一系列的称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),如果一个对象到GC Roots没有引用链相连,则该对象是不可用的。

可达性分析

在Java语言中,可作为GC Roots的对象包括:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象;
  • 方法区中类静态属性引用的对象;
  • 方法区中常量引用的对象;
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象;

再谈引用

在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用,这4种引用强度依次减弱。

生存还是死亡

要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize方法(如没有重写finalize方法或者已经被调用过则认为没有必要执行);如果有必要执行则将该对象放置在F-Queue队列中,并在稍后由一个由虚拟机自己建立的、低优先级的Finalizer线程去执行它;稍后GC将对F-Queue中的对象进行第二次标记,如果对象还是没有被引用,则会被回收。

但是作者不建议通过finalize方法“拯救”对象,因为它运行代价高、不确定性大、无法保证各个对象的调用顺序。

回收方法区

永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。

一个无用的类需要满足以下三个条件:

  • 该类的所有实例都已经被回收;
  • 加载该类的ClassLoader已经被回收;
  • 该类对象的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法;

在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGI这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能(HotSpot提供-Xnoclassgc参数控制),以保证永久代不会溢出。

垃圾收集算法

  • 标记-清除算法:首先标记出所有需要回收的对象,然后统一回收所有被标记的对象;缺点是效率不高且容易产生大量不连续的内存碎片;
  • 复制算法:将可用内存分为大小相等的两块,每次只使用其中一块;当这一块用完了,就将还活着的对象复制到另一块上,然后把已使用过的内存清理掉。在HotSpot里,考虑到大部分对象存活时间很短将内存分为Eden和两块Survivor,默认比例为8:1:1。代价是存在部分内存空间浪费,适合在新生代使用;
  • 标记-整理算法:首先标记出所有需要回收的对象,然后让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。适用于老年代。
  • 分代收集算法:一般把Java堆分新生代和老年代,在新生代用复制算法,在老年代用标记-清理或标记-整理算法,是现代虚拟机通常采用的算法。

HotSpot的算法实现

枚举根节点

  • 由于要确保在一致性的快照中进行可达性分析,从而导致GC进行时必须要停顿所有Java执行线程;
  • 在HotSpot里通过一组OopMap数据结构来知道哪些地方存放着对象引用;

安全点

  • HotSpot只在特定的位置记录了OopMap,这些位置称为安全点(SafePoint);
  • 即程序执行时并非在所有地方都能停顿下来开始GC,只有到达安全点时才能暂停;
  • 对于安全点基本上是以程序“是否具有让程序长时间执行的特征”(比如方法调用、循环跳转、异常跳转等)为标准进行选定的;
  • 另外还需要考虑如果在GC时让所有线程都跑到最近的安全点上,有两种方案:抢先式中断和主动式中断(主流选择);

安全区域

  • 如果程序没有分配CPU时间(如线程处于Sleep或Blocked),此时就需要安全区域(Safe Region),其是指在一段代码片段之中,引用关系不会发生变化;
  • 线程执行到安全区域时,首先标识自己已经进入了安全区域,这样JVM在GC时就不管这些线程了;

垃圾收集器

  • 垃圾收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现。
  • 这里讨论JDK 1.7 Update 14之后的HotSpot虚拟机(此时G1仍处于实验状态),包含的虚拟机如下图所示(存在连线的表示可以搭配使用):

HotSpot垃圾收集器

Serial收集器

Serial收集器

  • 最基本、发展历史最悠久,在JDK 1.3之前是新生代收集的唯一选择;
  • 是一个单线程(并非指一个收集线程,而是会暂停所有工作线程)的收集器,采用的是复制算法;
  • 现在依然是虚拟机运行在Client模式下的默认新生代收集器,主要就是因为它简单而高效(没有线程交互的开销);

ParNew收集器

ParNew收集器

  • 其实就是Serial收集器的多线程版本;
  • ParNew收集器在单CPU环境中绝对不会有比Serial收集器更好的效果;
  • 是许多运行在Server模式下虚拟机首选的新生代收集器,重要原因就是除了Serial收集器外,只有它能与CMS收集器配合工作;
  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态;
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行,用户线程在继续执行而垃圾收集程序运行在另外一个CPU上;

Parallel Scavenge收集器

  • 新生代收集器,使用复制算法,并行的多线程收集器;
  • 与其他收集器关注于尽可能缩短垃圾收集时用户线程停顿时间不同,它的目标是达到一个可控制的吞吐量;
  • 高吞吐量可以高效率利用CPU时间,适合在后台运算而不需要太多交互的任务;
  • -XX:MaxGCPauseMillis参数可以设置最大停顿时间,而停顿时间缩短是以牺牲吞吐量和新生代空间来换取的;
  • 另外它还支持GC自适应的调节策略;

Serial Old收集器

Serial Old收集器

  • 是Serial收集器的老年代版本,同样是单线程,使用标记-整理算法;
  • 主要是给Client模式下的虚拟机使用的;
  • 在Server模式下主要是给JDK 1.5及之前配合Parallel Scavenge使用或作为CMS收集器的后备预案;

Parallel Old收集器

Parallel Old收集器

  • 是Parallel Scavenge的老年代版本,使用多线程和标记-整理算法;
  • 是JDK 1.6中才开始提供的;

CMS收集器

CMS收集器

  • 是一种以获取最短回收停顿时间为目标的收集器,特别适合互联网站或者B/S的服务端;
  • 它是基于标记-清除 算法实现的,主要包括4个步骤:初始标记(STW,只是初始标记一下GC Roots能直接关联到的对象,速度很快)、并发标记(非STW,执行GC RootsTracing,耗时比较长)、重新标记(STW,修正并发标记期间因用户程序继续导致变动的那一部分对象标记)和并发清除(非STW,耗时较长);
  • 还有3个明显的缺点:CMS收集器对CPU非常敏感(占用部分线程及CPU资源,影响总吞吐量)、无法处理浮动垃圾(默认达到92%就触发垃圾回收)、大量内存碎片产生(可以通过参数启动压缩);

G1收集器

G1收集器

  • 一款面向服务端应用的垃圾收集器,后续会替换掉CMS垃圾收集器;
  • 特点:并行与并发(充分利用多核多CPU缩短Stop-The-World时间)、分代收集(独立管理整个Java堆,但针对不同年龄的对象采取不同的策略)、空间整合(基于标记-整理)、可预测的停顿(将堆分为大小相等的独立区域,避免全区域的垃圾收集);
  • 关于Region:新生代和老年代不再物理隔离,只是部分Region的集合;G1跟踪各个Region垃圾堆积的价值大小,在后台维护一个优先列表,根据允许的收集时间优先回收价值最大的Region;Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,采用Remembered Set来避免全堆扫描;
  • 分为几个步骤:初始标记(标记一下GC Roots能直接关联的对象并修改TAMS值,需要STW但耗时很短)、并发标记(从GC Root从堆中对象进行可达性分析找存活的对象,耗时较长但可以与用户线程并发执行)、最终标记(为了修正并发标记期间产生变动的那一部分标记记录,这一期间的变化记录在Remembered Set Log里,然后合并到Remembered Set里,该阶段需要STW但是可并行执行)、筛选回收(对各个Region回收价值排序,根据用户期望的GC停顿时间制定回收计划来回收);

理解GC日志

GC日志

  • 最前面的数字代表GC发生的时间(虚拟机启动以后的秒杀);
  • “[GC”和“[Full GC”说明停顿类型,有Full代表的是Stop-The-World的;
  • “[DefNew”、“[Tenured”和“[Perm”表示GC发生的区域;
  • 方括号内部的“3324K -> 152K(3712K)” 含义是 “GC前该内存已使用容量 -> GC后该内存区域已使用容量(该区域总容量)”;
  • 方括号之外的“3324K -> 152K(11904)” 含义是 “GC前Java堆已使用容量 -> GC后Java堆已使用容量(Java堆总容量)”;
  • 再往后“0.0025925 secs”表示该内存区域GC所占用的时间;

垃圾收集器参数总结

垃圾收集器参数1 垃圾收集器参数2

内存分配与回收策略

  • 对象优先在新生代分配
  • 大对象直接进入老年代
  • 长期存活的对象将进入老年代
  • 动态对象年龄判断:如果在Survivor空间中相同年龄所有对象大小总和大于Survivor空间的一半,大于或等于该年龄的对象直接进入老年代;
  • 空间分配担保:发生Minor GC前,虚拟机会先检查老年代最大可用连续空间是否大于新生代所有对象总空间,如果不成立,虚拟机会查看HandlePromotionFailure设置值是否允许担保失败,如果允许继续检查老年代最大可用的连续空间是否大于历次晋升到老年代的平均大小,如果大于会尝试进行一次Minor GC;如果小于或者不允许冒险,会进行一次Full GC;

本章小结

本章介绍了垃圾回收算法、几款JDK 1.7中提供的垃圾收集器特点以及运作原理。内存回收与垃圾收集器在很多时候都是影响系统性能、并发能力的主要因素之一,然而没有固定收集器和参数组合,也没有最优的调优方法,需要根据实践了解各自的行为、优势和劣势。

系列读书笔记