
从3个角度分析 Java 不同版本的新特性
- 语法层面:Lambda 表达式、switch、自动拆箱装箱、enum
- API 层面:Stream API、新的日期时间、Optional、String、集合框架
- 底层优化:JVM 优化、GC 的变化、元空间、静态域、字符串常量池位置变化
按线程数分(垃圾回收线程数),可以分为串行垃圾回收器和并行垃圾回收器。

按照工作模式分,可以分为并发式垃圾回收器和独占式垃圾回收器。

按碎片处理方式分,可分为压缩式垃圾回收器和非压缩式垃圾回收器。
按工作的内存区间分,又可分为年轻代垃圾回收器和老年代垃圾回收器。
吞吐量:运行用户代码的时间占总运行时间的比例
垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例。
暂停时间:执行垃圾收集时,程序的工作线程 (用户线程) 被暂停的时间。
收集频率:相对于应用程序的执行,收集操作发生的频率。
内存占用:Java 堆区所占的内存大小。
快速:一个对象从诞生到被回收所经历的时间。
吞吐量、暂停时间、内存占用,这三者共同构成一个“不可能三角”。三者总体的表现会随着技术进步而越来越好。一款优秀的收集器通常最多同时满足其中的两项。
这三项里,暂停时间的重要性日益凸显。因为随着硬件发展,内存占用多些越来越能容忍,硬件性能的提升也有助于降低收集器运行时对应用程序的影响,即提高了吞吐量。而内存的扩大,对延迟反而带来负面效果。
简单来说,主要抓住两点:


不幸的是”高吞吐量”和”低暂停时间”是一对相互竞争的目标(矛盾)。
垃圾收集机制是 Java 的招牌能力,极大地提高了开发效率。这当然也是面试的热点。
那么,Java 常见的垃圾收集器有哪些?
GC 垃圾收集器是和 JVM 一脉相承的,它是和 JVM 进行搭配使用,在不同的使用场景对应的收集器也是有区别
有了虚拟机,就一定需要收集垃圾的机制,这就是 Garbage Collection,对应的产品我们称为 Garbage Collector。

官方手册:https://www.oracle.com/technetwork/java/javase/tech/memorymanagement-whitepaper-1-150020.pdf



为什么 CMS GC 不可以和 Parallel Scavenge GC 搭配使用?
答:Parallel Scavenge GC 底层框架和其他垃圾回收器不同。
-XX:+PrintCommandLineFlags:查看命令行相关参数(包含使用的垃圾收集器)(补充)jinfo -flag 相关垃圾回收器参数、进程 ID
Serial 收集器是最基本、历史最悠久的垃圾收集器了。JDK 1.3 之前回收新生代唯一的选择。
Serial 收集器作为 HotSpot 中 Client 模式下的默认新生代垃圾收集器。
Serial 收集器采用复制算法、串行回收和"Stop-The-World"机制的方式执行内存回收。
除了年轻代之外,Serial 收集器还提供用于执行老年代垃圾收集的 Serial Old 收集器。Serial Old 收集器同样也采用了串行回收和"Stop The World"机制,只不过内存回收算法使用的是标记-压缩算法。

-XX:+UseSerialGC 参数可以指定年轻代和老年代都使用串行收集器。
总结

由于 ParNew 收集器是基于并行回收,那么是否可以断定 ParNew 收集器的回收效率在任何场景下都会比 Serial 收集器更高效?
ParNew 收集器运行在多 CPU 的环境下,由于可以充分利用多 CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。
但是在单个 CPU 的环境下,ParNew 收集器不比 Serial 收集器更高效。虽然 Serial 收集器是基于串行回收,但是由于 CPU 不需要频繁得做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。
除 Serial Old GC 外,目前只有 ParNew GC 能与 CMS 收集器配合工作
在程序中,开发人员可以通过选项-XX:+UseParNewGC手动指定使用 ParNew 收集器执行内存回收任务。它表示年轻代使用并行收集器,不影响老年代。
-XX:ParallelGCThreads 限制线程数量,默认开启和 CPU 数据相同的线程数。
- 那么 Parallel 收集器的出现是否多此一举?
和 ParNew 收集器不同,Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput),它也被称为吞吐量优先的垃圾收集器。
自适应调节策略也是 Parallel Scavenge 与 ParNew 一个重要区别。
高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。
Parallel 收集器在 JDK 1.6 时提供了用于执行老年代垃圾收集的 Parallel Old 收集器,用来代替老年代的 Serial Old 收集器。
Parallel Old 收集器采用了标记-压缩算法,但同样也是基于并行回收和"Stop-The-World"机制。

参数配置
-XX:+UseParallelGC手动指定年轻代使用 Parallel 并行收集器执行内存回收任务。-XX:+UseParalleloldGC手动指定老年代都是使用并行回收收集器。
-XX:ParallelGcrhreads设置年轻代并行收集器的线程数。一般地,最好与 CPU 数量相等,以避免过多的线程数影响垃圾收集性能。
-XX:MaxGCPauseMillis设置垃圾收集器最大停顿时间(即 STW 的时间)。单位是毫秒。
-XX:GCTimeRatio 垃圾收集时间占总时间的比例(= 1 /(N+1))。用于衡量吞吐量的大小。
-XX:MaxGCPauseMillis 参数有一定矛盾性。暂停时间越长,Radio 参数就容易超过设定的比例。-XX:+UseAdaptiveSizePolicy 设置 Parallel Scavenge 收集器具有自适应调节策略。



CMS 整个过程比之前的收集器要复杂,整个过程分为4个主要阶段,即初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段。(涉及 STW 的阶段主要是:初始标记和重新标记)
初始标记(Initial-Mark)阶段:在这个阶段中,程序中所有的工作线程 (用户线程) 都将会因为“Stop-The-World”机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出 GC Roots 能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快。
并发标记(Concurrent-Mark)阶段:从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
重新标记(Remark)阶段:由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
并发清除(Concurrent-Sweep)阶段:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
尽管 CMS 收集器采用的是并发回收(非独占式),但是在其初始化标记和再次标记这两个阶段中仍然需要执行“Stop-the-World”机制暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要“Stop-The-World”,只是尽可能地缩短暂停时间。
由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的。
另外,由于在垃圾收集阶段用户线程没有中断,所以在 CMS 回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在 CMS 工作过程中依然有足够的空间支持应用程序运行。要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。
CMS 收集器的垃圾收集算法采用的是标记-清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。那么 CMS 在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配。

CMS 为什么不使用标记整理(压缩)算法?
答案其实很简单,因为当并发清除的时候,用 Compact 整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响。Mark Compact 更适合“Stop The World”这种场景下使用。
优点
缺点
-XX:+UseConcMarkSweepGC 手动指定使用 CMS 收集器执行内存回收任务。
-XX:+UseParNewGC 打开。即:ParNew(Young 区用)+ CMS(Old 区用)+ Serial Old 的组合。-XX:CMSInitiatingoccupanyFraction 设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。
-XX:+UseCMSCompactAtFullCollection 用于指定在执行完 Full GC 后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。-XX:CMSFullGCsBeforecompaction 设置在执行多少次 Full GC 后对内存空间进行压缩整理。-XX:ParallelcMSThreads 设置 CMS 的线程数量。
HotSpot 有这么多的垃圾回收器,那么如果有人问,Serial GC、Parallel GC、Concurrent Mark Sweep GC 这三个 GC 有什么不同呢?
请记住以下口令:
-XX:+UseConcMarkSweepGC来开启 CMS 收集器的话,用户会收到一个警告信息,提示 CMS 未来将会被废弃。 -XX:+UseConcMarkSweepGC 的话,JVM 不会报错,只是给出一个 Warning 信息,但是不会 exit,JVM 会自动回退以默认 GC 方式启动 JVM。OpenJDK 64-bit Server VM Warning: Ignoring option UseConcMarkSweepGC; support was removed in 14.0 and the VM will continue execution using the default collector.
既然我们已经有了前面几个强大的 GC,为什么还要发布 Garbage First(G1)?
为什么名字叫 Garbage First(G1)呢?
与其他 GC 收集器相比,G1 使用了全新的分区算法,其特点如下所示:
并行与并发
分代收集

而是这样的一个区域

空间整合
可预测的停顿时间模型(即:软实时 Soft Real-Time)
这是 G1 相对于 CMS 的另一大优势,G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。
-XX:+UseG1GC:手动指定使用 G1 垃圾收集器执行内存回收任务-XX:G1HeapRegionSize:设置每个 Region 的大小。值是 2 的幂,范围是 1MB 到 32MB 之间,目标是根据最小的 Java 堆大小划分出约 2048 个区域。默认是堆内存的 1/2000。-XX:MaxGCPauseMillis:设置期望达到的最大 GC 停顿时间指标(JVM 会尽力实现,但不保证达到),默认值是 200ms。-XX:+ParallelGcThread:设置 STW 时GC线程数的值,最多设置为 8。-XX:ConcGCThreads:设置并发标记的线程数。将 n 设置为并行垃圾回收线程数(ParallelGCThreads)的 1/4 左右。-XX:InitiatingHeapOccupancyPercent:设置触发并发 GC 周期的 Java 堆占用率阈值。超过此值,就触发 GC,默认值是 45。-XX:G1HeapRegionSize 设定。所有的 Region 大小相同,且在 JVM 生命周期内不会被改变。
设置 H 的原因:
对于堆中的对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象就会对垃圾收集器造成负面影响。为了解决这个问题,G1 划分了一个 Humongous 区,它用来专门存放大对象。如果一个 H 区装不下一个大对象,那么 G1 会寻找连续的 H 区来存储。为了能找到连续的 H 区,有时候不得不启动 Full GC。G1 的大多数行为都把 H 区作为老年代的一部分来看待。
每个 Region 都是通过指针碰撞来分配空间

G1 GC 的垃圾回收过程主要包括如下三个环节:

顺时针,Young GC -> Young GC + Concurrent Mark -> Mixed GC 顺序,进行垃圾回收。


然后开始如下回收过程:
备注:对于应用程序的引用赋值语句 (老年代) object.field = object (新生代) ,JVM 会在代码执行之前和之后执行特殊的操作以在 Dirty Card Queue 中入队一个保存了对象引用信息的 Card。在年轻代回收的时候,G1 会对 Dirty Card Queue 中所有的 Card 进行处理,以更新 RSet,保证 RSet 实时准确的反映引用关系。
那为什么不在引用赋值语句处直接更新 RSet 呢?这是为了性能的需要,RSet 的处理需要线程同步,开销会很大,使用队列性能会好很多。

整个过程都是复制算法,不会产生内存碎片
-XX:G1MixedGCCountTarget 设置)被回收。-XX:G1MixedGCLiveThresholdPercent,默认为 65%,意思是垃圾占内存分段比例要达到 65% 才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。-XX:G1HeapWastePercent,默认值为 10%,意思是允许整个堆内存中有 10% 的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于 10%,则不再进行混合回收。因为 GC 会花费很多的时间但是回收到的内存却很少。导致 G1 Full GC 的原因可能有两个:
从 Oracle 官方透露出来的信息可获知,回收阶段(Evacuation)其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到 G1 只是回收一部分 Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了 G1 之后出现的低延迟垃圾收集器(即 ZGC)中。另外,还考虑到 G1 不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。
-Xmn 或 -XX:NewRatio 等相关选项显式设置年轻代大小截止 JDK 1.8,一共有 7 款不同的垃圾收集器。每一款的垃圾收集器都有不同的特点,在具体使用的时候,需要根据具体的情况选用不同的垃圾收集器。

记忆小技巧:年轻代都是复制算法,老年代除了CMS是标记清除算法之外,其他都是标记压缩算法

Java 垃圾收集器的配置对于 JVM 优化来说是一个很重要的选择,选择合适的垃圾收集器可以让 JVM 的性能有一个很大的提升。
怎么选择垃圾收集器?
最后需要明确一个观点:
通过阅读 GC 日志,我们可以了解 Java 虚拟机内存分配与回收策略。
内存分配与垃圾回收的参数列表:
打开GC日志 -verbose:gc
这个只会显示总的 GC 堆的变化,如下:

参数解析

打开 GC 日志 -verbose:gc -XX:+PrintGCDetails
输出信息如下

参数解析

打开 GC 日志:-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimestamps -XX:+PrintGCDatestamps
输出信息如下

说明:带上了日期和时间
把 GC 日志保存到文件 -Xloggc:/path/to/gc.log
日志补充说明



JDK7环境

我们编写一个程序,用来说明 GC 收集的过程
/**
* 在jdk7 和 jdk8中分别执行
* -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC
*/
public class GCLogTest1 {
private static final int _1MB = 1024 * 1024;
public static void testAllocation() {
byte[] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[2 * _1MB];
allocation2 = new byte[2 * _1MB];
allocation3 = new byte[2 * _1MB];
allocation4 = new byte[4 * _1MB];
}
public static void main(String[] agrs) {
testAllocation();
}
}
设置JVM参数-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC
-Xms20M -Xmx20M -Xmn10M:堆空间初始大小20M,堆空间最大大小20M,新生代大小10M
-XX:SurvivorRatio=8:新生代中Eden区占8M,两个Survivor区各1M
-XX:+UseSerialGC:使用 SerialGC 垃圾回收器
程序运行结果

说明:
total 9216K:新生代总共10,实际可用9216K,因为8192K + 1024K = 9216K,Survivor区总共有两个,每次GC都是往另一个区移动,所以需要留下一个空闲的区,因此9216K + 1024K = 10240K (10M)
首先我们会将 3 个 2M 的数组存放到 Eden 区,然后后面 4M 的数组来了后,将无法存储,因为 Eden 区只剩下 2M 的剩余空间了,那么将会进行一次 Young GC 操作,将原来 Eden 区的内容,存放到 Survivor 区,但是Survivor 区也存放不下,那么就会直接晋级存入 Old 区。

然后我们将 4M 对象存入到 Eden 区中


可以用一些工具去分析这些 GC 日志
常用的日志分析工具有:GCViewer、GCEasy、GCHisto、GCLogViewer、Hpjmeter、garbagecat 等

官方地址:https://gceasy.io/

代码示例
/**
* -Xms60m -Xmx60m -XX:SurvivorRatio=8 -XX:+PrintGCDetails -Xloggc:./logs/gc.log
*/
public class GCLogTest {
public static void main(String[] args) {
ArrayList<byte[]> list = new ArrayList<>();
for (int i = 0; i < 500; i++) {
byte[] arr = new byte[1024 * 100]; //100KB
list.add(arr);
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
先在根目录下创建 logs 文件夹,然后再运行程序,就会生成一个日志文件

导入到GC easy


GC 仍然处于飞速发展之中,目前的默认选项 G1 GC 在不断的进行改进,很多我们原来认为的缺点,例如串行的 Full GC、Card Table 扫描的低效等,都已经被大幅改进,例如,JDK 10 以后,Full GC 已经是并行运行,在很多场景下,其表现还略优于 Parallel GC 的并行 Full GC 实现。
即使是 Serial GC,虽然比较古老,但是简单的设计和实现未必就是过时的,它本身的开销,不管是 GC 相关数据结构的开销,还是线程的开销,都是非常小的,所以随着云计算的兴起,在 Serverless 等新的应用场景下,Serial GC 找到了新的舞台。
比较不幸的是 CMS GC,因为其算法的理论缺陷等原因,虽然现在还有非常大的用户群体,但在 JDK 9 中已经被标记为废弃,并在 JDK 14 版本中移除。
Epsilon:A No-Op GarbageCollector(Epsilon 垃圾回收器,"No-Op(无操作)"回收器)http://openjdk.iava.net/ieps/318
ZGC:A Scalable Low-Latency Garbage Collector(Experimental)(ZGC:可伸缩的低延迟垃圾回收器,处于实验性阶段)

Open JDK 12 的 Shenandoash GC:低停顿时间的 GC(实验性)。

这是 RedHat 在 2016 年发表的论文数据,测试内容是使用 ES 对 200GB 的维基百科数据进行索引。从结果看:
总结
官方地址:[https://docs.oracle.com/en/java/javase/12/gctuning/]


停顿时间对比


JDK14 新特性
-XX:+UnlockExperimentalVMOptions-XX:+UseZGCAliGC 是阿里巴巴 JVM 团队基于 G1 算法,面向大堆(LargeHeap)应用场景。指定场景下的对比:

当然,其它厂商也提供了各种别具一格的 GC 实现,例如比较有名的低延迟 GC——Zing。