java基础-JVM日志、参数、内存结构、垃圾回收器

一、基础基础

1.1 数据类型

Java的数据类型分为原始数据类型和引用数据类型。

  • 原始数据类型又分为数字型和布尔型。
    • 数字型又有byte、short、int、long、char、float、double。注意,在这里char被定义为整数型,并且在规范中明确定义:byte、short、int、long分别是8位、16位、32位、64位有符号整数,而char为16位无符号整数,表示UTF-16的字符。
    • 布尔型只有两种取值:true和false。
  • 引用数据类型分为3种:类或接口、泛型类型和数组类型

1.1.2 整数在Java虚拟机中的表示

整数有byte、short、int、long四种,分别表示8位、16位、32位、64位有符号整数。

  • 所谓原码,就是符号位加上数字的二进制表示。以int为例,第1位表示符号位(正数或者负数)

10的原码为:00000000 00000000 00000000 00001010
-10的原码为:10000000 00000000 00000000 00001010

  • 对于原码来说,绝对值相同的正数和负数只有符号位不同。反码就是在原码的基础上,符号位不变,其余位取反

以-10为。例,其反码为:11111111111111111111111111110101

负数的补码就是反码加1,整数的补码就是原码本身。

在Java中,可以使用位运算查看整数中每一位的实际值,方法如下
在这里插入图片描述

(2)使用补码可以简化整数的加减法计算,将减法计算视为加法计算,实现减法和加法的完全统一,实现正数和负数加法的统一。现使用8位(byte)整数说明这个问题;
计算-6+5的过程如下。
-6的补码:11111010
5的补码:00000101
直接相加得:11111111
通过计算可知,11111111表示-1。
计算4+6的过程如下。
4的补码:00000100
6的补码:00000110
直接相加得:00001010
通过计算可知,00001010表示10(十进制)。
可以看到,使用补码表示时,只需要将补码简单地相加,即可得
到算术加法的正确结果,而无须区别正数或者负数。

1.2 浮点数在Java虚拟机中的表示

在Java虚拟机中,浮点数有float和double两种,分别是32位和64位浮点数。浮点数在虚拟机中的表示比整数略显复杂。在IEEE754的定义中一个浮点数由3部分组成,分别是符号位、指数位和尾数位。以32位float类型为例,符号位占1位,表示正负数,指数位占8位,尾数位占剩余的23位

在这里插入图片描述

二、Java虚拟机的基本结构

2.1 Java虚拟机的架构

在这里插入图片描述
类加载子系统负责从文件系统或者网络中加载Class信息,

  • 方法区 加载的类信息存放于一块称为方法区的内存空间中。除了类的信息,方法区中可能还会存放运行时常量池信息,包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)

  • Java堆中:Java堆在虚拟机启动的时候建立,它是Java程序最主要的内存工作区域。几乎所有的Java对象实例都存放于Java堆中。堆空间是所有线程共享的,这是一块与Java应用密切相关的内存区域。

  • Java的NIO库允许Java程序使用直接内存。直接内存是在Java堆外的、直接向系统申请的内存区域。通常,访问直接内存的速度会优于Java堆。因此,出于性能考虑,读写频繁的场合可能会考虑使用直接内存。由于直接内存在Java堆外,因此,它的大小不会直接受限于Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统的最大内存。

  • 垃圾回收系统是Java虚拟机的重要组成部分,垃圾回收器可以对方法区、Java堆和直接内存进行回收。其中,Java堆是垃圾收集器的工作重点。和C/C++不同,Java中所有对象空间释放都是隐式的。也就是说,Java中没有类似free()或者delete()这样的函数释放指定的内存区域。对于不再使用的垃圾对象,垃圾回收系统会在后台默默工作,默默查找、标识并释放垃圾对象,完成包括Java堆、方法区和直接内存中的全自动化管理。有关垃圾回收系统的更多信息

  • Java栈:Java虚拟机线程都有一个私有的Java栈。一个线程的Java栈在线程创建的时候被创建。Java栈中保存着帧信息(参阅2.4节),Java栈中保存着局部变量、方法参数,同时和Java方法的调用、返回密切相关

  • 本地方法栈:和Java栈非常类似,最大的不同在于Java栈用于Java方法的调用,而本地方法栈则用于本地方法的调用。作为对Java虚拟机的重要扩展,Java虚拟机允许Java直接调用本地方法(通常使用C语言编写)

  • PC 寄存器:PC(Program Counter)寄存器也是每个线程私有的空间,Java虚拟机会为每一个Java线程创建PC寄存器。在任意时刻,一个Java线程总是在执行一个方法,这个正在被执行的方法称为当前方法。如果当前方法不是本地方法,PC寄存器就会指向当前正在被执行的指令。如果当前方法是本地方法,那么PC寄存器的值就是undefined。

  • 执行引擎:是Java虚拟机的最核心组件之一,它负责执行虚拟机的字节码。现代虚拟机为了提高执行效率,会使用即时编译技术将方法编译成机器码后再执行。执行引擎的进一步描述可以参阅第11章。

2.2 学会设置Java虚拟机的参数

Java进程的命令行使用方法如下
在这里插入图片描述
其中,-options表示Java虚拟机的启动参数,class为带有main()函数的Java类,args表示传递给主函数main()的参数。如果需要设定特定的Java虚拟机参数,在options处指定即可。目前,Hotspot虚拟机支持大量的虚拟机参数;

在这里插入图片描述
上述代码打印了传递给main()函数的参数,
在这里插入图片描述
如果读者使用Eclipse等开发工具运行程序,在运行对话框的参数选项卡上,也可以设置这两个参数,如图2.2所示,显示了“程序参数”和“虚拟机参数”两个文本框,将所需的参数填入即可。
在这里插入图片描述

2.3 辨清Java堆

Java堆是和Java应用程序关系最为密切的内存空间,几乎所有的对象都存放在堆中,并且Java堆是完全自动化管理的,通过垃圾回收机制,垃圾对象会被自动清理,而不需要显式地释放。

根据垃圾回收机制的不同,Java堆有可能拥有不同的结构。最为常见的一种构成是将整个Java堆分为新生代和老年代。其中,新生代存放新生对象或者年龄不大的对象,老年代则存放老年对象。新生代
有可能分为eden、s0、s1,其中s0和s1也被称为from和to区域,它们是两块大小相等、可以互换角色的内存空间
。详细信息可以参阅第4章。
在这里插入图片描述

在绝大多数情况下,对象首先在eden区分配,在一次新生代回收后,如果对象还存活,则会进入s0或者s1,之后,每经过一次新生代回收,对象如果存活,它的年龄就会加1。当对象的年龄达到一定条件后,就会被认为是老年对象,从而进入老年代

2.3.1Java堆、方法区和Java栈之间的关系

在这里插入图片描述
上述代码声明了一个SimpleHeap类,并在main()函数中创建了两个SimpleHeap实例,此时,各对象和局部变量的存放如图2.4所示。SimpleHeap实例本身在堆中分配,描述SimpleHeap类的信息存放在方法区,main()函数中s1和s2局部变量存放在Java栈中,并指向堆中的两个实例。
在这里插入图片描述

2.4 函数如何调用:出入Java栈

线程执行的基本行为是函数调用,每次函数调用的数据都是通过Java栈传递的。Java栈与数据结构中的栈有着类似的含义,它是一块先进后出的数据结构,只支持出栈和入栈两种操作。在Java栈中保存的主要内容为栈帧。每一次函数调用,都会有一个对应的栈帧被压入Java栈,每一次函数调用结束,都会有一个栈帧被弹出Java栈;当函数返回时,栈帧从Java栈中被弹出。Java方法有两种返回函
数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。

在一个栈帧中,至少要包含局部变量表、操作数栈和帧数据区几部分。
在这里插入图片描述
由于每次函数调用都会生成对应的栈帧,从而占用一定的栈空间。因此,如果栈空间不足,那么函数调用自然无法继续进行。当请求的栈深度大于最大可用栈深度时,系统就会抛出StackOverflowError栈溢出错误。

2.4.1 Java虚拟机提供了参数-Xss优化栈

**Java虚拟机提供了参数-Xss来指定线程的最大栈空间,这个参数也直接决定了函数调用的最大深度。**函数嵌套调用的层次在很大程度上由栈的大小决定,栈越大,函数可以支持的嵌套调用次数就越多。

2.4.2 局部变量表

局部变量表是栈帧的重要组成部分之一。它用于保存函数的参数及局部变量。局部变量表中的变量只在当前函数调用中有效,当函数调用结束后,函数栈帧销毁,局部变量表也会随之销毁。由于局部变量表在栈帧之中,因此**,如果函数的参数和局部变量较多,会使局部变量表膨胀,从而每一次函数调用就会占用更多的栈空间,最终导致函数的嵌套调用次数减少**。
使用jclasslib工具可以进一步查看函数的局部变量信息

2.4.3 帧数据区

除了局部变量表和操作数栈,Java栈帧还需要一些数据来支持常量池解析、正常方法返回和异常处理等。大部分Java字节码指令需要进行常量池访问,在帧数据区中保存着访问常量池的指针,方便程序访问常量池

2.4.4 栈上分配

栈上分配是Java虚拟机提供的一项优化技术,它的基本思想是,对于那些线程私有的对象(这里指不可能被其他线程访问的对象),可以将它们打散分配在栈上,而不是分配在堆上。分配在栈上的好处
是可以在函数调用结束后自行销毁,而不需要垃圾回收器的介入,从而提高系统的性能。

2.4.4.1 -XX:+DoEscapeAnalysis启用逃逸分析

这里使用参数-server执行程序,因为在Server模式下,才可以启用逃逸分析。参数-XX:+DoEscapeAnalysis启用逃逸分析,-Xmx10m指定了堆空间最大为10MB。显然,如果对象在堆上分配,必然会引起大量的GC。如果GC真的发生了,参数-XX:+PrintGC将打印GC日
志。参数-XX:+EliminateAllocations开启了标量替换(默认打开),允许将对象打散分配在栈上,比如对象拥有id和name两个字段,那么这两个字段将会被视为两个独立的局部变量进行分配。参数-XX:-
UseTLAB关闭了TLAB。
在这里插入图片描述

2.5 类去哪儿了:识别方法区

和Java堆一样,方法区是一块所有线程共享的内存区域。它用于保存系统的类信息,比如类的字段、方法、常量池等。方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误。

2.5.1 JDK 1.6、JDK 1.7中 方法区可以理解为永久区以及参数设置

2.5.1.1 方法区可以理解为永久区 数据存放

在JDK 1.6、JDK 1.7中,方法区可以理解为永久区(Perm)。在JDK 1.8、JDK1.9、JDK1.10中,永久区已经被彻底移除。取而代之的是元数据区,元数据区大小可以使用参数-XX:MaxMetaspaceSize指定(一个大的元数据区可以使系统支持更多的类),这是一块堆外的直接内存。与永久区不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。图2.12显示了JDK 1.8中的元数据区,JDK 1.9、JDK 1.10与此相同,不再赘述。

永久区可以使用参数-XX:PermSize和-XX:MaxPermSize指定,默认情况下,-XX:MaxPermSize为64MB。一个大的永久区可以保存更多的类信息。如果系统使用了一些动态代理,那么有可能会在运行时生成大量的类,这时就需要设置一个合理的永久区大小,确保不发生永久区内存溢出。

注意: 这里使用CGLIB动态产生类,不仅仅是对象实例。由于类的信息(字段、方法、字节码等)保存在方法区,因此,这个操作会占用方法区的空间

2.5.1.2 方法区可以理解为永久区以及参数设置 -XX:PermSize和-XX:MaxPermSize

永久区可以使用参数-XX:PermSize和-XX:MaxPermSize指定,默认情况下,-XX:MaxPermSize为64MB。一个大的永久区可以保存更多的类信息。如果系统使用了一些动态代理,那么有可能会在运行时生成大量的类**,这时就需要设置一个合理的永久区大小,确保不发生永久区内存溢出。
在这里插入图片描述

这里指定了初始永久区为5MB,最大永久区为5MB,即当5MB空间耗尽时,系统将抛出内存溢出异常
在这里插入图片描述

在JDK 1.8、JDK1.9、JDK1.10中,永久区已经被彻底移除。取而代之的是元数据区,元数据区大小可以使用参数-XX:MaxMetaspaceSize指定(一个大的元数据区可以使系统支持更多的类),这是一块堆外的直接内存。与永久区不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。图2.12显示了JDK 1.8中的元数据区,JDK 1.9、JDK 1.10与此相同,不再赘述。**

在这里插入图片描述
在这里插入图片描述
如果元数据区发生溢出,虚拟机一样会抛出异常,如下所示

在这里插入图片描述

三 、常用Java虚拟机参数

3.1 掌握跟踪调试参数GC 日志参数

3.1.1 JDK9、JDK10 GC参数 -XX:+PrintGC

最简单的一个GC参数是-XX:+PrintGC(在JDK9、JDK10中建议使用-Xlog:gc),使用这个参数启动Java虚拟机后,只要遇到GC,就会打印日志,如下所示
在这里插入图片描述
该日志显示,一共进行了4次GC,每次GC占用一行,在GC前,堆空间使用量约为4MB,在GC后,堆空间使用量为377KB,当前可用的堆空间总和约为16MB(15936KB)。最后,显示的是本次GC所
花的时间。

3.1.2 JDK9、JDK10 GC日志 -Xlog:gc

JDK9、JDK10默认使用G1作为垃圾回收器,使用参数-Xlog:gc来打印GC日志,如下所示

在这里插入图片描述
该日志显示,一共进行了1次GC,在GC前,堆空间使用量为16MB,在GC后,堆空间使用量为7MB,当前可用的堆空间总和为34MB。最后,显示的是本次GC所花的时间,为23.511ms。\

3.1.3 JDK8 GC 日志详情 -XX:+PrintGCDetails

如果需要更加详细的信息,可以使用-XX:+PrintGCDetails参数。JDK8(JDK9、JDK10建议使用-Xlog:gc*,后面讲述)中的输出可能如下:

在这里插入图片描述
从这个输出中可以看到,系统经历了3次GC,

第1次仅为新生代GC,回收的效果是新生代从回收前的8MB左右降低到1MB。整个堆从22MB左右降低到17MB。
第2次(加粗部分)为Full GC,它同时回收了新生代、老年代和永久区。日志显示,新生代在这次GC中没有释放空间(严格来说,这是GC日志的一个小bug,事实上,在这次FullGC完成后,新生代被清空,由于GC日志输出时机的关系,各个版本JDK的日志多少有些不太精确的地方,读者需要留意),老年代从16MB降低到13MB。整个堆大小从26MB左右降低为13MB左右(这个大小完全与老年代
实际大小相等,因此也可以推断,新生代实际上已被清空)。永久区的大小没有变化。日志的最后显示了GC所花的时间,其中user表示用户态CPU耗时,sys表示系统CPU耗时,real表示GC实际经历的时间。

参数-XX:+PrintGCDetails还会使虚拟机在退出前打印堆的详细信息,详细信息描述了当前堆的各个区间的使用情况。如上输出所示,当前新生代(newgeneration)总大小为9792KB,已使用4586KB。紧跟其后的3个16进制数字表示新生代的下界、当前上界和上界。
在这里插入图片描述

使用上界减去下界就能得到当前堆空间的最大值,使用当前上界减去下界,就是当前虚拟机已经为程序分配的空间。如果当前上界等
于下界,说明当前的堆空间已经没有扩大的可能性。在本例中(0x00000000f98a00000x00000000f8e00000)/1024=10880KB。这块空间正好等于eden+from+to的总和。而可用的新生代9792KB为eden+from(to)的总和,对于两者出现差异的原因,读者可以参考本书第4章。

3.1.4 JDK9、JDK10使用参数-Xlog:gc*来打印更加详细的GC日志

如下所示:
在这里插入图片描述
从这个输出中可以看到,堆的最大可用大小为32MB,系统经历了1次GC,为新生代GC,回收的效果是整个堆从14MB左右降低到了1MB。在JDK9、JDK10中,除了新生代、老年代,还新增了一个巨
型区域,即上述输出中的Humongous regions。另外,日志中有详细的时间信息,

第一列显示Java程序运行的时间,Pause Young (G1 Evacuation Pause) 14M->1M(32M) 7.028ms表示新生代垃圾回收花了7.028ms。Pre Evacuate Collection Set、Evacuate Collection Set、PostEvacuate Collection Set、Other代表G1垃圾回收标记—清除算法不同阶段所花费的时间。最后一行的时间信息跟JDK8相同,不再赘述。如果需要更全面的堆信息,还可以使用参数-XX:+PrintHeapAtGC(考虑到兼容性,从JDK9开始已经删除此参数,查看堆信息可以使用VisualVM,第6章将会讲述)。它会在每次GC前、后分别打印堆的信息,就如同-XX:+PrintGCDetails的最后输出一样。下面就是-XX:+PrintHeapAtGC的输出样式,限于篇幅,只给出部分输出

在这里插入图片描述
可以看到,在使用-XX:+PrintHeapAtGC后,在GC日志输出前、后都有详细的堆信息输出,分别表示GC回收前和GC回收后的堆信息,使用这个参数,可以很好地观察GC对堆空间的影响
如果需要分析GC发生的时间,还可以使用-XX:+PrintGCTimeStamps(JDK9、JDK10中使用-Xlog:gc已经默认打印出时间,前文关于-Xlog:gc已经有讲述,这里不再赘述)参数,该参数会在每次GC时,额外输出GC发生的时间,该输出时间为虚拟机启动后的时间偏移量。如下代码表示在系统启动后0.08s、0.088s、0.094s发生了GC

在这里插入图片描述
由于GC会引起应用程序停顿,因此还需要特别关注应用程序的执行时间和停顿时间。使用参数-XX:
+PrintGCApplicationConcurrentTime可以打印应用程序的执行时间,使用参数-XX:+PrintGCApplicationStoppedTime可以打印应用程序由于GC而产生的停顿时间,如下所示:如果想跟踪系统内的软引用、弱引用、虚引用和Finallize队列,可以打开-XX:+PrintReferenceGC(考虑到兼容性,从JDK9开始已经删除此参数,查看堆信息可以使用VisualVM,第6章将会讲述)开关
,结果如下:在这里插入图片描述
默认情况下,GC的日志会在控制台中输出,这不便于后续分析和定位问题。所以,虚拟机允许将GC日志以文件的形式输出,可以使用参数-Xloggc指定。比如使用参数-Xloggc:log/gc.log(在JDK9、
JDK10中建议使用-Xlog:gc:log/gc.log)启动虚拟机
,可以在当前目录的log文件夹下的gc.log文件中记录所有的GC日志。图3.1显示了由-Xloggc生成的gc.log文件,JDK9、JDK10生成的文件与JDK8相同,不再赘述
在这里插入图片描述

3.1.5 类加载/卸载的跟踪

Java程序的运行离不开类的加载,为了更好地理解程序如何执行,有时候需要知道系统加载了哪些类。一般情况下,系统加载的类存在于文件系统中,以jar的形式打包或者以class文件的形式存在,
可以直接通过文件系统查看。但是随着动态代理、AOP等技术的普遍使用,系统也极有可能在运行时动态生成某些类,这些类相对比较隐蔽,无法通过文件系统找到,此时虚拟机提供的类加载/卸载跟踪参数就显得格外有意义

3.1.5.1 参数-verbose:class跟踪类的加载/卸载

可以使用参数-verbose:class跟踪类的加载/卸载,也可以单独使用参数-XX:+TraceClassLoading(在JDK9、JDK10中建议使用-Xlog:class+load=info,跟JDK8中的参数-XX:+TraceClassLoading效果相同)跟踪类的加载,使用参数-XX:+TraceClassUnloading(在JDK9、JDK10中建议使用-Xlog:class+unload=info,跟JDK8中的参数-XX:+TraceClassLoading效果相同)跟踪类的卸载。这两类参数是等价的。

下面这段代码使用ASM动态生成名为Example的类,并将其反复加载到系统中(ASM的使用可以参阅9.3节)。
在这里插入图片描述
上述代码的第318行使用ASM生成名为Example的类,并将其保存在code数组中。第2027行使用ClassLoader将新生成的类反复加载到系统中,每次循环使用新的ClassLoader实例,并在循环结束前进行FullGC,释放上一次循环加载的类,因此,这一过程会涉及类的加载和卸载。使用参数-XX:+TraceClassUnloading和参数-XX:+TraceClassLoading执行上述代码,跟踪类的加载和卸载过程,部分输出如下在这里插入图片描述
从这份日志中可以看到,系统先加载了java.lang.Object类,作为所有类的父类。日志的后半部分显示,系统对Example类先后进行了10次加载和9次卸载(最后一次加载的类没有机会被卸载)。
注意:动态类的加载是非常隐蔽的,它们由代码逻辑控制,不出现在文件系统中,跟踪这些类需要使用-XX:+TraceClassLoading等参数。
Java虚拟机还允许研发人员在运行时打印、查看系统中类的分布情况,只要在系统启动时加上-XX:+PrintClassHistogram参数,然后在Java的控制台中按下Ctrl+Break组合键,控制台上就会显示当前的类信息柱状图,如下所示:

在这里插入图片描述
通过这个柱状图信息,可以看到当前系统中占用空间最大的对象,以及它的实例数量和所占用空间大小

3.1.6 查看系统参数

3.1.6.1参数-XX:+PrintVMOptions可以在程序运行时打印虚拟机接收到

的命令行显式参数。其输出如下:
在这里插入图片描述
这说明该虚拟机启动时,命令行明确指定了UseSerialGC、DisableExplicitGC两个参数。

3.1.6.2 参数-XX:+PrintCommandLineFlags可以打印传递给虚拟机的显式和隐式参数

参数-XX:+PrintCommandLineFlags可以打印传递给虚拟机的显式和隐式参数,隐式参数未必是通过命令行直接给出的,它可能是在虚拟机启动时自行设置的,使用-XX:+PrintCommandLineFlags后,
可能的输出如下:
在这里插入图片描述
在本例中,-XX:InitialHeapSize、-XX:MaxHeapSize和-XX:-UseLargePagesIndividualAllocation并未在命令行显式指定,是由虚拟机自行设置的。此外,另一个有用的参数是-XX:+PrintFlagsFinal,它会打印所有的系统参数的值。开启这个参数后,虚拟机可能会产生500多行输出,每一行为一个配置参数及其当前取值,读者如果对虚拟机的各参数感兴趣,可以打印出这些参数,逐个学习。

3.1.6.3另一个有用的参数是-XX:+PrintFlagsFinal,它会打印所有的系统参数的值

开启这个参数后,虚拟机可能会产生500多行输出,每一行为一个配置参数及其当前取值,读者如果对虚拟机的各参数感兴趣,可以打印出这些参数,逐个学习。

3.2 学习堆的配置参数

本节将主要介绍与堆有关的参数设置,这些参数可以说是Java虚拟机中最重要的,也是对程序性能有着重要影响的

图3.2显示了几个重要的堆分配参数的含义

在这里插入图片描述

3.2.1 最大堆和初始堆的设置 参数 -Xms -Xmx

当Java进程启动时,虚拟机就会分配一块初始堆空间,可以使用参数-Xms指定这块空间的大小。一般来说,虚拟机会尽可能维持在初始堆空间的范围内运行。但是如果初始堆空间耗尽,虚拟机将会对堆空间进行扩展,其扩展上限为最大堆空间,最大堆空间可以使用参数-Xmx指定

在这里插入图片描述

在这里插入图片描述
程序的输出如下:

在这里插入图片描述
在这里插入图片描述
可以看到,当前最大内存由-XX:MaxHeapSize=20971520指定,它正好是20×1024×1024=20971520字节。而打印的最大可用内存仅为20316160字节,比设定值略少。这是因为分配给堆的内存
空间和实际可用内存空间并非一个概念。由于垃圾回收的需要,虚拟机会对堆空间进行分区管理,不同的区采用不同的回收算法,一些算法会使用空间换时间的策略,因此会存在可用内存的损失,详细算法
可以参见4.2.3节,这里不展开讨论。最终的结果就是实际可用内存会浪费大小等于from/to的空间。因此,实际最大可用内存为-Xmx的值减去from的值。从堆的详细信息可以看到,from的大小为
0x33420000-0x33400000=0x20000=131072字节。但很不幸,读者应该会发现20971520-131072=20840448字节,与实际值20316160依然存在偏差。出现这个偏差是因为虚拟机内部并没有直接使用新生代from/to的大小,而是进一步对它们做了对齐操作。对于串行GC的情况,虚拟机使用以下方法估算from/to的大小,并进行对齐

在这里插入图片描述
提示: 在实际工作中,也可以直接将初始堆-Xms与最大堆-Xmx设置为相等。这样的好处是,可以减少程序运行时进行垃圾回收的次数,从而提高程序的性能。

3.2.2 新生代的配置 参数-Xmn 参数-XX:SurvivorRatio用来设置新生代中eden区和from/to区的比例

注意:
-XX:SurvivorRatio可以设置eden区与survivor的比例。-
XX:NewRatio可以设置老年代与新生代的比例。

参数-Xmn可以用于设置新生代的大小。设置一个较大的新生代会减小老年代的大小,这个参数对系统性能及GC行为有很大的影响。新生代的大小一般设置为整个堆空间的1/3到1/4。

参数-XX:SurvivorRatio用来设置新生代中eden区和from/to区的比例,它的含义如下
在这里插入图片描述
【示例3-3】考察以下这段简单的Java程序,它连续向系统请求10MB空间(每次申请1MB)。

在这里插入图片描述
使用不同的堆分配参数执行这段程序,因为虚拟机的表现受到堆空间分配的影响,所以运行过程不尽相同。下面分别使用不同的参数执行,读者可以一起来实战演习。

(1)使用-Xmx20m-Xms20m-Xmn1m-XX:SurvivorRatio=2-
XX:+PrintGCDetails运行上述Java程序,输出如下

在这里插入图片描述
这里eden区与from区的比值为2∶1,故eden区为512KB。总可用新生代大小为512KB+256KB=768KB,新生代总大小为512KB+256KB+256KB=1024KB=1MB。

由于eden区无法容纳任何一个程序中分配的1MB数组,故触发了一次新生代GC,对eden区进行了部分回收。同时,这个偏小的新生代无法为1MB数组预留空间,故所有的数组都分配在老年代,老年代
最终占用10354KB空间。

(2)使用参数-Xmx20m-Xms20m-Xmn7m-
XX:SurvivorRatio=2-XX:+PrintGCDetails运行上述程序,将新生代
扩大为7MB,则输出如下

在这里插入图片描述
在这个参数下,由于eden区有足够的空间,因此所有的数组都分配在eden区。但eden区并不足以预留10MB的空间,故在程序运行期间出现了3次新生代GC。由于每申请一次空间,同时也废弃了上一次申请的空间(上一次申请的内存失去了引用),故在新生代GC中,有效回收了这些失效的内存。最终结果是:所有的内存分配都在新生代进行,通过GC保证了新生代有足够的空间,而老年代没有为这些数组预留任何空间,只是在GC过程中,部分新生代对象晋升到老年代。

(3)使用参数-Xmx20m-Xms20m-Xmn15m-
XX:SurvivorRatio=8-XX:+PrintGCDetails运行上述程序,输出如下:
在这里插入图片描述
在这次执行中,由于新生代使用15MB空间,其中eden区占用了12288KB,完全满足10MB数组的分配,因此所有的分配行为都在eden区直接进行,且没有触发任何GC行为。因此from/to和老年代
tenured
的使用率都为0。

由此可见,不同的堆分布情况对系统会产生一定影响。在实际工作中,应该根据系统的特点做合理的设置,基本策略是:尽可能将对象预留在新生代,减少老年代GC的次数(本例中的第一种情况,对
象都分配在老年代,显然为后续的老年代GC埋下了伏笔)。除了可以使用参数-Xmn指定新生代的绝对大小,还可以使用参数-XX:NewRatio来设置新生代和老年代的比例
,如下所示:

在这里插入图片描述
(4)使用参数-Xmx20M-Xms20M-XX:NewRatio=2-XX:
+PrintGCDetails运行上述代码,输出如下:

在这里插入图片描述
此时,因为堆大小为20MB,新生代和老年代的比为1∶2,故新生代大小为20MB×1/3≈7MB,老年代为13MB。由于在新生代GC时,from/to空间不足以容纳任何一个1MB数组,影响了新生代的正
常回收,故在新生代回收时需要老年代进行空间担保。这导致两个1MB数组进入老年代(在新生代GC时,尚有1MB数组幸存,理应进入from/to,而from/to只有640KB,不足以容纳)。

注意:
-XX:SurvivorRatio可以设置eden区与survivor的比例。-
XX:NewRatio可以设置老年代与新生代的比例。

3.2.3 堆溢出处理 参数-XX: +HeapDumpOnOutOfMemoryError

在Java程序的运行过程中,如果堆空间不足,则有可能抛出内存溢出错误,简称OOM(OutOfMemory)。如下文字显示了典型的堆内存溢出

在这里插入图片描述
Java虚拟机提供了参数-XX:+HeapDumpOnOutOfMemoryError,可以在内存溢出时导出整个堆
的信息。和它配合使用的还有-XX:HeapDumpPath,可以指定导出堆的存放路径。

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
可以看到,虚拟机将当前的堆信息导出,并保存到D:/a.dump文件。使用MAT等工具打开该文件进行分析,如图3.3所示,可以很容易地找到这些byte数组和保存它们的Vector对象实例。有关MAT等工
具的使用方法,可以参阅第7章。除了在发生OOM时可以导出堆信息,虚拟机还允许在发生错误
时执行一个脚本文件。该文件可以用于崩溃的程序的自救、报警或者通知,也可以帮助开发人员获得更多的系统信息,如完整的线程转存(即Thread Dump或者Core Dump)文件。

在这里插入图片描述
这里给出一个在发生OOM时导出线程转存的例子。准备printstack.bat脚本如下:

在这里插入图片描述
以上脚本将会导出给定Java虚拟机进程的线程信息,并保存在D:/a.txt文件中。

使用如下参数执行上述代码

在这里插入图片描述
在程序异常退出时,系统D盘会生成新文件a.txt,里面保存着线
程转存信息。本例中,文件路径“D:/tools/jdk1.7_40”为笔者的JDK安
装目录,读者可以替换成自己的JAVA_HOME目录进行尝试。

3.3 了解非堆内存的参数配置

除了堆内存,虚拟机还有一些内存用于方法区、线程栈和直接内存。它们与堆内存是相对独立的。与堆内存相比,虽然这些内存空间和应用程序本身的关系可能不那么密切,但是从系统层面看,有效、
合理地配置这些内存参数,对系统的性能和稳定性也有着重要作用

3.3.1 方法区配置 -XX:MaxMetaspaceSize指定永久区的最大可用值

在JDK1.6和JDK1.7等版本中,可以使用-XX:PermSize和-XX:MaxPermSize配置永久区大小。其中,-XX:PermSize表示初始的永久区大小,-XX:MaxPermSize表示最大永久区大小。

从JDK 1.8开始,永久区被彻底移除,使用了新的元数据区存放类的元数据。在默认情况下,元数据区只受系统可用内存的限制,但依然可以使用参数-XX:MaxMetaspaceSize指定永久区的最大可用值。方法区的详细使用和配置可以参考2.5节。

3.3.2 栈配置 -Xss参数指定线程的栈大小

栈是每个线程私有的内存空间。在Java虚拟机中可以使用-Xss参数指定线程的栈大小。由于在2.4节中已经详细介绍了栈的配置和使用,这里不再展开。

3.3.3 直接内存配置 参数 -XX:MaxDirectMemorySize

直接内存也是Java程序中非常重要的组成部分,特别是在NIO被广泛使用后,直接内存的使用也变得非常普遍。直接内存跳过了Java堆,使Java程序可以直接访问原生堆空间。因此,从一定程度上加快
了内存空间的访问速度。但是,武断地认为使用直接内存一定可以提高内存访问速度也是不正确的。

最大可用直接内存可以使用参数-XX:MaxDirectMemorySize设置,如果不设置,默认值为最大堆空间,即-Xmx的值当直接内存使用量达到-XX:MaxDirectMemorySize时,就会触发垃圾回收,如果垃圾回收不能有效释放足够的空间,直接内存溢出依然会引起系统的OOM。

由此可以得出结论:直接内存适合申请次数较少、访问较频繁的场合。如果需要频繁申请内存空间,则并不适合使用直接内存。

3.4 Client和Server二选一:虚拟机的工作模式

目前Java虚拟机支持Client和Server两种运行模式。使用参数-client可以指定使用Client模式,使用参数-server可以指定使用Server模式。在默认情况下,虚拟机会根据当前计算机系统环境自动选择运
行模式。使用-version参数可以查看当前模式

在这里插入图片描述
使用-server参数后,就可以得到如下输出:

在这里插入图片描述
与Client模式相比,Server模式的启动比较慢,因为Server模式会尝试收集更多的系统性能信息,使用更复杂的优化算法对程序进行优化。因此,当系统完全启动并进入运行稳定期后,Server模式的执行速度会远远快于Client模式。所以,对于后台长期运行的系统来说,使用-server参数启动对系统的整体性能可以有不小的帮助。但对于用户界面程序而言,运行时间不长,又追求启动速度,Client模式

四、 垃圾回收的概念与算法

4.1 常用的垃圾回收算法

主要内容是理解Java垃圾回收机制的理论基础,包括引用计数法、标记清除法、复制算法、标记压缩法、分代算法和分区算法

在这里插入图片描述

注意:
由于单纯的引用计数法隐含着循环引用及性能问题,Java虚拟机并未选择此算法作为垃圾回收算法。
【名词解释】

  • 可达对象: 指通过根对象进行引用搜索,最终可以达到的对
    象。
  • 不可达对象 :指通过根对象进行引用搜索,最终没有被引用到
    的对象。

4.1.1 引用计数法(Reference Counting)

引用计数法的实现很简单,对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1,当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,则对象A就不可能再被使
用。

引用计数器的实现也非常简单,只需要为每个对象配备一个整型的计数器即可。但是,引用计数器有两个非常严重的问题:
(1)无法处理循环引用。因此,在Java的垃圾回收器中没有使用这种算法。
(2)引用计算器要求在每次引用产生和消除的时候,伴随一个加法操作和一个减法操作,对系统性能会有一定的影响。

4.1.2 标记清除法(Mark-Sweep)

标记清除法是现代垃圾回收算法的思想基础。标记清除法将垃圾回收分为两个阶段:标记阶段和清除阶段。一种可行的实现如下。

  • 在标记阶段,首先通过根节点标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象。
  • 在清除阶段,清除所有未被标记的对象。标记清除法的最大问题是可能产生空间碎片。
    在这里插入图片描述
    回收后的空间是不连续的。在对象的堆空间分配过程中,尤其是大对象的内存分配,不连续内存空间的工作效率要低于连续空间。因此,这也是该算法的最大缺点。

注意:
标记清除法先通过根节点标记所有的可达对象,然后清除所有的不可达对象,完成垃圾回收。

4.1.3 复制算法(Copying)

复制算法的核心思想是:将原有的内存空间分为两块,每次只使用其中一块,在进行垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量就会相对较少。因此,在真正需要垃圾回收的时刻,复制算法的效率是很高的。。又由于对象是在垃圾回收过程中统一被复制到新的内存空间中的,可确保回收后的内存空间是没有碎片的。虽然有以上两大优点,但是复制算法的代价却是将系统内存折半,因此,单纯的复制算法也很难让人接受。
如图4.3所示,A、B两块相同的内存空间,A在进行垃圾回收时,将存活对象复制到B中,B在复制后保持连续。复制完成后,清空A,并将空间B设置为当前使用空间。

在这里插入图片描述
在Java的新生代串行垃圾回收器中,使用了复制算法的思想。新生代分为eden区、from区和to区3个部分。其中from区和to区可以视为用于复制的两块大小相同、地位相等且可进行角色互换的空间。
from区和to区也称为survivor区,即幸存者空间,用于存放未被回收的对象。

【名词解释】

  • 新生代: 存放年轻对象的堆空间。年轻对象指刚刚创建的或者经历垃圾回收次数不多的对象。
  • 老年代: 存放老年对象的堆空间。老年对象指经历多次垃圾回收后依然存活的对象。

在进行垃圾回收时,eden区的存活对象会被复制到未使用的survivor区(假设是to区),正在使用的survivor区(假设是from)的年轻对象也会被复制到to区(大对象或者老年对象会直接进入老年
代,如果to区已满,则对象也会直接进入老年代)。此时,eden区和from区的剩余对象就是垃圾对象,可以直接清空,to区则存放此次回收后的存活对象。这种改进的复制算法,既保证了空间的连续性,又避免了大量的内存空间浪费
,如图4.4所示,显示了复制算法的实际回收过程。当所有存活对象都复制到survivor区(图中为to)后,简单地清空eden区和备用的survivor区(图中为from)即可。

注意:
复制算法比较适合新生代,因为在新生代垃圾对象通常会多于存活对象,复制算法的效果会比较好。

4.1.4 标记压缩法(Mark-Compact)

复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本将很高。因此,基于老年代垃圾回收的特性,需要使用其他算法。

标记压缩法是一种老代的回收算法。它在标记清除法的基础上做了一些优化。和标记清除法一样,标记压缩法首先也需要从根节点开始,对所有可达对象做一次标记。但之后,它并不只是简单地清理
未标记的对象,而是将所有的存活对象压缩到内存的一端。然后,清理边界外所有的空间。这种方法既避免了碎片的产生,又不需要两块相同的内存空间,性价比较高。

如图4.5所示,在通过根节点标记出所有的可达对象后,沿虚线进行对象移动,将所有的可达对象都移动到一端,并保持它们之间的引用关系,最后清理边界外的空间,即可完成垃圾回收工作。

在这里插入图片描述
图4.5 标记压缩算法工作示意图
标记压缩法的最终效果等同于标记清除法执行完成后再进行一次内存碎片整理,因此,也可以把它称为标记清除压缩法。

4.1.5 分代算法(Generational Collecting)

在前面介绍的算法中,没有一种算法可以完全替代其他算法,它们都有自己的优势和特点。根据垃圾回收对象的特性,使用合适的算法,才是明智的选择。分代算法就基于这种思想,它将内存区间根据对象的特点分成几块,根据每块内存区间的特点使用不同的回收算法,以提高垃圾回收的效率

一般来说,Java虚拟机会将所有的新建对象都放入称为新生代的内存区域,新生代的特点是对象朝生夕灭,大约90%的新建对象会被很快回收,因此新生代比较适合使用复制算法。当一个对象经过几次回收后依然存活,对象就会被放入称为老年代的内存空间。在老年代中,几乎所有的对象都是经过几次垃圾回收依然得以存活的。因此,可以认为这些对象在一段时期内,甚至在应用程序的整个生命周期中,将是常驻内存的。在极端情况下,老年代对象的存活率可以达到100%。如果依然使用复制算法回收老年代,将需要复制大量对象。再加上老年代的回收性价比也低于新生代,因此这种做法是不可取的。根据分代的思想,可以对老年代的回收使用与新生代不同的标记压缩法或标记清除法,以提高垃圾回收效率。如图4.6所示,显示了这种分代回收的思想。

注意:
分代回收的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代。

在这里插入图片描述
对于新生代和老年代来说,通常新生代回收的频率很高,但是每次回收的耗时很短,而老年代回收的频率比较低,但是会消耗更多的时间。为了支持高频率的新生代回收,虚拟机可能使用一种叫作卡表
(Card Table)的数据结构。卡表为一个比特位集合,每一个比特位可以用来表示老年代的某一区域中的所有对象是否持有新生代对象的引用。这样在新生代GC时,可以不用花大量时间扫描所有的老年代对象来确定每一个对象的引用关系,可以先扫描卡表,只有当卡表的标记位为1时,才需要扫描给定区域的老年代对象,而卡表位为0的老年代对象,一定不含有新生代对象的引用。如图4.7所示,卡表中每一位表示老年代4KB的空间,卡表记录为0的老年代区域没有任何对象指向新生代,只有卡表位为1的区域才有对象包含新生代引用,因此,在新生代GC时只需要扫描卡表位为1的老年代空间。使用这种方式,可以大大加快新生代的回收速度

在这里插入图片描述
图4.7 根据卡表新生代GC只需扫描部分老年代

4.1.6 分区算法(Region)

分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。如图4.8所示。每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收小区
间的数量

一般来说,在相同条件下,堆空间越大,一次GC所需要的时间就越长,从而产生的停顿也越长(GC产生的停顿请参见4.4节)。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个
小块,根据目标停顿时间,每次合理地回收若干个小区间,而不是回收整个堆空间,从而减少一次GC所产生的停顿

在这里插入图片描述

4.2 谁才是真正的垃圾:判断可触及性

4.2.1 对象的复活

垃圾回收的基本思想是考查每一个对象的可触及性,即从根节点开始是否可以访问这个对象,如果可以,则说明当前对象正在被使用,如果从所有的根节点开始都无法访问到某个对象,说明该对象已
经不再使用了,一般来说,该对象需要被回收。但事实上,一个无法触及的对象有可能在某个条件下使自己“复活”,如果是这样的情况,那么对它的回收就是不合理的,为此,需要给出一个对象可触及性状态的定义,并规定在什么状态下才可以安全地回收对象

简单来说,可触及性包含以下3种状态。

  • 可触及的: 从根节点开始,可以到达这个对象。
  • 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()函数中复活。
  • 不可触及的: 对象的finalize()函数被调用,并且没有复活,那么就会进入不可触及状态,不可触及的对象不可能被复活,因为finalize()函数只会被调用一次。

以上3种状态中,只有在对象不可触及时才可以被回收。

4.2.2 引用和可触及性的强度

finalize()函数是一个非常糟糕的模式,不推荐读者使用finalize()函数释放资源。
第一,因为finalize()函数有可能发生引用外泄,在无意中复活对象;
第二,由于finalize()函数是被系统调用的,调用时间是不明确的,因此不是一个好的资源释放方案,推荐在try-catch-finally语句中进行资源的释放。

4.2.3 引用和可触及性的强度

在Java中提供了4个级别的引用:强引用、软引用、弱引用和虚引用。除强引用外,其他3种引用均可以在java.lang.ref包中找到。如图4.9所示,显示了这3种引用类型对应的类,开发人员可以在应用程序中直接使用它们。其中FinalReference为“最终”引用,它用以实现对象的finalize()函数

在这里插入图片描述

4.2.3.1 强引用
  • 强引用就是程序中一般使用的引用类型,强引用的对象是可触及的,不会被回收。软引用、弱引用和虚引用的对象是软可触及、弱可触及和虚可触及的,在一定条件下都是可以被回收的
    在这里插入图片描述
    假设以上代码是在函数体内运行的,那么局部变量str将被分配在栈上,而对象StringBuffer实例将被分配在堆上。局部变量str指向StringBuffer实例所在堆空间,通过str可以操作该实例,那么str就是
    StringBuffer实例的强引用,如图4.10所示。
    在这里插入图片描述
    此时,如果再运行一个赋值语句:
    在这里插入图片描述
    那么,str所指向的对象也将被str1所指向,同时在局部变量表上会分配空间存放str1变量,如图4.11所示。此时,该StringBuffer实例就有两个引用。引用的“==”操作用于表示两操作数所指向的堆空间
    地址是否相同,不表示两操作数所指向的对象是否相等。

本例中的两个引用都是强引用,强引用具备以下特点:

  • 强引用可以直接访问目标对象。
  • 强引用所指向的对象在任何时候都不会被系统回收,虚拟机宁愿抛出OOM异常,也不会回收强引用所指向的对象。
  • 强引用可能导致内存泄漏。
4.2.3.2 软引用

软引用是比强引用弱一点的引用类型。如果一个对象只持有软引用,那么当堆空间不足时,就会被回收。软引用使用java.lang.ref.SoftReference类实现。

下面的示例演示了软引用会在系统堆内存不足时被回收。
在这里插入图片描述
在这里插入图片描述
使用参数-Xmx10m运行上述代码,得到:在这里插入图片描述
因此,从该示例中可以得到结论:GC未必会回收软引用的对象,但是当内存资源紧张时,软引用对象会被回收,所以软引用对象不会引起内存溢出。

4.2.3.4 弱引用—发现即回收

弱引用是一种比软引用弱的引用类型。在系统GC时,只要发现弱引用,不管系统堆空间使用情况如何,都会将对象进行回收。但是,由于垃圾回收器的线程通常优先级很低,并不一定能很快地发现
持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间。一旦一个弱引用对象被垃圾回收器回收,便会加入一个注册的引用队列(这一点和软引用很像)
弱引用使用java.lang.ref.WeakReference类实现。

下面的例子显示了弱引用的特点

在这里插入图片描述
在这里插入图片描述
可以看到,在GC之后弱引用对象立即被清除。弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况。读者可以参考4.3.3节中的介绍自行实现,在此不再赘述。

注意:
软引用、弱引用都非常适合保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当
长的时间,从而起到让系统加速的作用。

4.2.3.5 虚引用—对象回收跟踪

虚引用是所有引用类型中最弱的一个。一个持有虚引用的对象,和没有引用几乎是一样的,随时都可能被垃圾回收器回收。当试图通过虚引用的get()方法取得强引用时,总会失败。并且,虚引用必须和引用队列一起使用,它的作用在于跟踪垃圾回收过程。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

4.3垃圾回收时的停顿现象:Stop-The-World案例实战

垃圾回收器的任务是识别和回收垃圾对象,以进行内存清理。为了让垃圾回收器可以正常且高效地执行,在大部分情况下,会要求系统进入一个停顿的状态。停顿的目的是终止所有应用线程的执行,只
有这样系统中才不会有新的垃圾产生,同时停顿保证了系统状态在某一个瞬间的一致性,也有益于垃圾回收器更好地标记垃圾对象。因此,在垃圾回收时,都会产生应用程序的停顿。停顿产生时,整个应用程序会被卡死,没有任何响应,因此这个停顿也叫作“Stop-The-World”(STW)

使用以下参数运行上述代码
在这里插入图片描述
此参数设置了1GB的堆空间,并基本废弃了新生代(只保留512KB)。运行程序得到的部分输出如下:
在这里插入图片描述
注意加粗部分,原本应该每0.1秒进行的输出在这几处有明显的时间延长。对应的GC日志如下:
在这里插入图片描述

注意加粗部分的GC时间戳和实际花费的时间,不难发现,在程序打印的25.12025.956秒处,发生了大约0.8秒左右的停顿,对应于GC日志中25.286秒的FullGC。在程序打印的25.95626.56秒处,发生了大约0.6秒的停顿,对应于GC日志26.248秒处的0.6秒停顿。在26.75527.363秒处发生了大约0.6秒的停顿,对应于GC日志26.854秒处的0.61秒的停顿。最后,在27.36328.179秒处发生了大约0.8秒的停顿,对应于GC日志27.460秒处的0.82秒的停顿。由此可见,每一次应用程序的意外停顿都可以在GC日志中找到对应的线索给予解释。这也间接证明了GC对于应用程序的影响。笔者使用VisualGC观察上述程序的运行过程,如图4.12所示,可以看到老年代GC共进行了5次,合计耗时2.9秒,平均一次约0.6秒,而新生代GC合计进行了3895次,合计耗时5.1秒多,GC总耗时约8秒。
在这里插入图片描述
图4.12 使用Visual GC观察GC过程1
从这个例子可以看到,新生代GC比较频繁,但每一次GC耗时较短,老年代GC发生次数较少,但每一次所消耗的时间较长。这种现象和虚拟机参数设置有关。下面通过修改虚拟机参数改变这种现象。
使用下面的虚拟机参数执行上述代码:
在这里插入图片描述
从这个例子可以看到,新生代GC比较频繁,但每一次GC耗时较短,老年代GC发生次数较少,但每一次所消耗的时间较长。这种现象和虚拟机参数设置有关。下面通过修改虚拟机参数改变这种现象。
使用下面的虚拟机参数执行上述代码:
在这里插入图片描述
此参数设置了一个较大的新生代(900MB),并将from、to和eden区设置为各300MB。同时,修改上述代码第8行为(读者考虑一下为何需要做这个修改):
在这里插入图片描述
使用这种超大新生代的设置,会导致复制算法复制大量对象,也
会在很大程度上延长GC时间。程序的部分输出如下:
在这里插入图片描述
在这里插入图片描述
可以看到,在10.351~11.409处产生了大约1秒的停顿。翻阅GC
日志,不难发现:
在这里插入图片描述
使用Visual GC观察这次行为,如图4.13所示。
在这里插入图片描述
可以看到,在增大新生代空间后,新生代GC次数明显减少,但是每次耗时增加,这里显示的6次新生代GC合计耗时775毫秒。

第五章 垃圾收集器和内存分配

5.1 串行回收器

5.1.1 新生代串行回收器

串行回收器主要有两个特点:

  • 第一,它仅仅使用单线程进行垃圾回收。
  • 第二,它是独占式的垃圾回收方式。
    在串行回收器进行垃圾回收时,Java应用程序中的线程都需要暂停工作,等待垃圾回收完成。如图5.1所示,在串行回收器运行时,应用程序中的所有线程都停止工作,进行等待,这种现象称为“Stop-The-World”。

使用-XX:+UseSerialGC参数可以指定使用新生代串行回收器或老年代串行回收器。当虚拟机在Client模式下运行时,它是默认的垃圾回收器。

日志如下
新生代串行回收器的输出日志如下(使用-XX:+PrintGCDetails开关):
在这里插入图片描述
它显示了一次垃圾回收前的新生代内存占用量和垃圾回收后的新生代内存占用量,以及垃圾回收所消耗的时间。
注意:
串行垃圾回收器虽然古老,但是久经考验,在大多数情况下,其性能表现是相当不错的。

5.1.2 老年代串行回收器

老年代串行回收器使用的是标记压缩法。和新生代串行回收器一样,它也是一个串行的、独占式的垃圾回收器。由于老年代垃圾回收通常会需要比新生代垃圾回收更长的时间,在堆空间较大的应用程序中,一旦老年代串行回收器启动,应用程序很可能会因此停顿较长的时间。

老年代串行回收器可以和多种新生代回收器配合使用,同时它也可以作为CMS回收器的备用回收
器。若要启用老年代串行回收器,可以尝试使用以下参数。-XX:+UseSerialGC:新生代、老年代都使用串行回收器。

-XX:+UseParNewGC(JDK9、JDK10已经删除,因为ParNew需要和CMS搭配工作,而CMS已经被G1替代,不再支持此参数):新生代使用ParNew回收器,老年代使用串行回收器。

·-XX:+UseParallelGC:新生代使用ParallelGC回收器,老年代使用串行回收器。

在这里插入图片描述

它显示了垃圾回收前老年代和永久区的内存占用量,以及垃圾回收后老年代和永久区的内存占用量。

5.2 并行回收器

5.2.1 新生代ParNew回收器

ParNew回收器是一个工作在新生代的垃圾回收器。它只是简单地将串行回收器多线程化,它的回收策略、算法及参数和新生代串行回收器一样

开启ParNew回收器可以使用以下参数。

-XX:+UseParNewGC(JDK9、JDK10已经删除,因为ParNew需要和CMS搭配工作,而CMS已经被G1替代,不再支持此参数):新生代使用ParNew回收器,老年代使用串行回收器。

-XX+UseConcMarkSweepGC(JDK 9、JDK 10不建议使用,建议使用默认的G1垃圾回收器):新生代使用ParNew回收器,老年代使用CMS。

ParNew回收器工作时的线程数量可以使用-XX:ParallelGCThreads参数指定。一般,最好与CPU数量相当,避免过多的线程数影响垃圾回收性能。在默认情况下,当CPU数量小于8时,ParallelGCThreads的值等于CPU数量,当CPU数量大于8时,ParallelGCThreads的值等于3+((5×CPU_Count)/8)。

ParNew回收器的输出日志如下在这里插入图片描述
这个输出和新生代串行回收器的输出几乎是一样的,只有回收器标识符不同。

5.2.2 新生代ParallelGC回收器

新生代ParallelGC回收器也是使用复制算法的回收器。从表面上看,它和ParNew回收器一样,都是多线程、独占式的回收器。但是,ParallelGC回收器有一个重要的特点:它非常关注系统的吞吐
量。

新生代ParallelGC回收器可以使用以下参数启用。

-XX:+UseParallelGC: 新生代使用ParallelGC回收器,老年代使用串行回收器。

-XX:+UseParallelOldGC: 新生代使用 ParallelGC 回收器,老年代使用 ParallelOldGC回收器。ParallelGC回收器提供了两个重要的参数用于控制系统的吞吐量。

-XX:MaxGCPauseMillis: 设置最大垃圾回收停顿时间。它的值是一个大于0的整数。ParallelGC 在工作时,会调整 Java 堆大小或者其他参数,尽可能地把停顿时间控制在MaxGCPauseMillis 以内。如果读者希望减少停顿时间而把这个值设得很小,为了达到预期的停顿时间,虚拟机可能会使用一个较小的堆(一个小堆比一个大堆回收快),而这将导致垃圾回收变得很频繁,从而增加垃圾回收总时间,降低吞吐量。

-XX:GCTimeRatio: 设置吞吐量大小。它的值是一个 0 到100 之间的整数。假设GCTimeRatio的值为n ,那么系统将花费不超过1/(1+n )的时间进行垃圾回收。比如GCTimeRatio等于19(默认值),则系统用于垃圾回收的时间不超过1/(1+19)=5%。默认情况下,它的取值是99,即有不超过1/(1+99)=1%的时间用于垃圾回收。除此之外,ParallelGC回收器与ParNew回收器另一个不同之处在于,它还支持一种自适应的GC调节策略。使用-XX:+UseAdaptiveSizePolicy可以打开自适应GC策略。在这种模式下,新生代的大小、eden区和survivor区的比例、晋升老年代的对象年龄等参数会被自动调整,以达到在堆大小、吞吐量和停顿时间之间的平衡点。在手工调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标吞吐量(GCTimeRatio)和停顿时
间(MaxGCPauseMillis),让虚拟机自己完成调优工作。

ParallelGC回收器的输出日志如下
在这里插入图片描述

它显示了ParallelGC回收器的工作成果,也就是回收前的内存大小和回收后的内存大小,以及花费的时间

ParallelGC回收器关注系统吞吐量。可以通过-XX:MaxGCPauseMillis和-XX:GCTimeRatio设置期望的停顿时间和吞吐量。但是鱼和熊掌不可兼得,这两个参数是相互矛盾的,通常如果减少一次收集的最大停顿时间,就会同时减小系统吞吐量,增加系统吞吐量又可能会同时增加一次垃圾回收的最大停顿时间。

5.2.3 老年代ParallelOldGC回收器

老年代ParallelOldGC回收器也是一种多线程并发的回收器。和新生代ParallelGC回收器一样,它也是一种关注吞吐量的回收器。ParallelOldGC回收器使用标记压缩法,它在JDK1.6中才可以使
用。
在这里插入图片描述
使用-XX:+UseParallelOldGC可以在新生代使用ParallelGC回收器,老年代使用ParallelOldGC回收器。这是一对非常关注吞吐量的垃圾回收器。在对吞吐量敏感的系统中,可以考虑使用。参数-
XX:ParallelGCThreads也可以用于设置垃圾回收时的线程数量。

ParallelOldGC回收器的输出日志如下:
在这里插入图片描述
它显示了新生代、老年代及永久区在回收前、后的情况,以及Full GC所消耗的时间。

5.3 CMS回收器(JDK 8及之前的版本)

与ParallelGC和ParallelOldGC不同,CMS回收器主要关注系统停顿时间。CMS是Concurrent Mark Sweep的缩写,意为并发标记清除,从名称上就可以得知,它使用的是标记清除法,同时它又是一个使用多线程并行回收的垃圾回收器。

5.3.1 CMS主要工作步骤

CMS回收器的工作过程与其他垃圾回收器相比,略显复杂。CMS工作时的主要步骤有:初始标记、并发标记、预清理、重新标记、并发清除和并发重置。其中初始标记和重新标记是独占系统资源的,而预清理、并发标记、并发清除和并发重置是可以和用户线程一起执行的。从整体上说,CSM不是独占式的,它可以在应用程序运行过程中进行垃圾回收。CMS的工作流程如图5.4所示。

在这里插入图片描述
根据标记清除法,初始标记、并发标记和重新标记都是为了标记出需要回收的对象。并发清理则是在标记完成后,正式回收垃圾对象。并发重置是指在垃圾回收完成后,重新初始化CMS数据结构和数
据,为下一次垃圾回收做好准备。并发标记、并发清理和并发重置都是可以和应用程序线程一起执行的。

在整个CMS的回收过程中,默认情况下,在并发标记之后,会有一个预清理的操作(也可以关闭开关-XX:-CMSPrecleaningEnabled,不进行预清理)。预清理是并发的,除了为正式清理做准备和检查,还会尝试控制一次停顿的时间。由于重新标记是独占CPU的,如果新生代GC发生后,立即触发一次重新标记,那么一次停顿的时间可能很长。为了避免这种情况,预处理时会刻意等待一次新生代GC的发生,然后根据历史性能数据预测下一次新生代GC可能发生的时间,在当前时间和预测时间的中间时刻进行重新标记。这样可尽量避免新生代GC和重新标记重合,尽可能减少一次停顿的时间。

5.3.2 CMS主要的参数

启用CMS回收器的参数是-XX:+UseConcMarkSweepGC。CMS是多线程回收器,设置合理的工作线程数量对系统性能有重要的影响

CMS默认启动的并发线程数是(ParallelGCThreads+3)/4。ParallelGCThreads表示GC并行时使用的线程数量,如果新生代使用ParNew,那么ParallelGCThreads也就是新生代GC的线程数量。这意味着有4个ParallelGCThreads时,只有1个并发线程,而有两个并发线程时,有5~8个ParallelGCThreads线程。

并发线程数量也可以通过-XX:ConcGCThreads或者-XX:ParallelCMSThreads参数手工设定。当CPU资源比较紧张时,受到CMS回收器线程的影响,应用系统的性能在垃圾回收阶段可能会非常糟糕。

注意:并发是指收集器和应用线程交替执行,并行是指应用程序停止,同时由多个线程一起执行GC。因此并行回收器不是并发的,因为并行回收器执行时,应用程序完全挂起,不存在交替执行。

由于CMS回收器不是独占式的回收器,在CMS回收过程中,应用程序仍然在不停地工作。因此,CMS回收器不会等待堆内存饱和时才进行垃圾回收,而是当堆内存使用率达到某一阈值时便开始进行回收,以确保应用程序在CMS工作过程中,依然有足够的空间支持应用程序运行。

这个回收阈值可以使用参数-XX:CMSInitiatingOccupancyFraction来指定,默认是68,即当老年
代的空间使用率达到68%时,会执行一次CMS回收。如果应用程序的内存使用率增长很快,在CMS的执行过程中,已经出现了内存不足的情况,CMS回收就会失败,虚拟机将启动老年代串行回收器进行垃圾回收。此时,应用程序将完全中断,直到垃圾回收完成,这时,应用程序的停顿时间可能会较长。

注意: 通过-XX:CMSInitiatingOccupancyFraction可以指定当老年代空间使用率达到多少时进行一次CMS垃圾回收。根据应用程序的特点,可以对参数-XX:CMSInitiatingOccupancyFraction进行调优如果内存增长缓慢,则可以设置一个稍大的值,大的阈值可以有效降低CMS的触发频率,减少老年代回收的次数,可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行回收器。CMS是一个基于标记清除法的回收器。在本章之前的篇幅中已经提到,标记清除法将会产生大量内存碎片,离散的可用空间无法分配给较大的对象。图5.5显示了CMS回收前、后老年代的情况。

在这里插入图片描述

在这种情况下,即使堆内存仍然有较大的剩余空间,也可能会被迫进行一次垃圾回收,以换取一块可用的连续内存。这种现象对系统性能是相当不利的,为了解决这个问题,CMS回收器还提供了几个用于内存压缩整理的参数。
-XX:+UseCMSCompactAtFullCollection参数可以使CMS在垃圾收集完成后,进行一次内存碎片整理,内存碎片的整理不是并发进行的。
-XX:CMSFullGCsBeforeCompaction参数可以用于设定进行多少次CMS回收后,进行一次内存压缩。

5.3.3 CMS的日志分析

CMS回收器工作时的日志输出如下
在这里插入图片描述

以上信息是一次CMS回收的输出。可以看到,在CMS回收器工作过程中,包括初始化标记、并发标记、预清理、重新标记、并发清理和重发重置等几个重要阶段。在日志中,还可以看到CMS的耗时及堆内存信息。

在1.409秒发生abortable-preclean,表示CMS开始等待一次新生代GC。在1.411秒ParNew回收器开始工作,在1.422秒abortable-preclean终止。之后,CMS根据之前新生代GC的情况,将重新标记的
时间放在一个最不可能和下一次新生代GC重叠的时刻,即两次新生代GC的中间点,这里为1.423秒处。

除此之外,CMS回收器在运行时还可能输出如下日志
在这里插入图片描述
这说明CMS回收器并发回收失败。这很可能是应用程序在运行过程中老年代空间不够导致的。如果在CMS工作过程中,出现非常频繁的并发模式失败,就应该考虑进行调整,尽可能预留一个较大的老年
代空间。或者可以设置一个较小的-XX:CMSInitiatingOccupancyFraction参数,降低CMS触发的阈值,使CMS在执行过程中仍然有较大的老年代空闲空间供应用程序使用。

注意:
CMS回收器是一个关注停顿的垃圾回收器。同时CMS回收器在部分工作流程中,可以与用户程序同时运行,从而减少应用程序的停顿时间。

5.3.4 有关Class的回收

在使用CMS回收器时,如果需要回收Perm区,那么默认情况下,还需要触发一次Full GC,如下所示:
在这里插入图片描述
如果希望使用CMS回收器回收Perm区,则必须打开
-XX:+CMSClassUnloadingEnabled开关。
使用-XX:+CMSClassUnloadingEnabled后,如果条件允许,系统会使用CMS的机制回收Perm区的Class数据,日志如下:

在这里插入图片描述
在这里插入图片描述

5.4 G1回收器(JDK 9及之后版本的默认回收器)

G1回收器(Garbage-First)是在JDK 1.7中正式使用的全新的垃圾回收器,从长期目标来看,它是为了取代CMS回收器。G1回收器拥有独特的垃圾回收策略,和之前提到的回收器截然不同。从分代上
看,G1依然属于分代垃圾回收器,它会区分年轻代和老年代,依然有eden区和survivor区,但从堆的结构上看,它并不要求整个eden区、年轻代或者老年代都连续。它使用了分区算法。作为CMS的长期
替代方案,G1使用了全新的分区算法,其特点如下

  • 并行性: G1在回收期间,可以由多个GC线程同时工作,有效利用多核计算能力。
  • 并发性: G1 拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,一般来说,不会在整个回收期间完全阻塞应用程序。
  • 分代 GC: G1 依然是一个分代回收器,但是和之前的回收器不同,它同时兼顾年轻代和老年代,其他回收器或者工作在年轻代,或者工作在老年代。
  • 空间整理: G1在回收过程中,会进行适当的对象移动,不像CMS,只是简单地标记清理对象,在若干次GC后,CMS必须进行一次碎片整理。而G1不同,它每次回收都会有效地复制对象,减少碎片空间。
  • 可预见性: 由于分区的原因,G1 可以只选取部分区域进行内存回收,这样缩小了回收的范围,全局停顿也能得到较好的控制。

5.4.1 G1的内存划分和主要收集过程

G1将堆进行分区,划分为一个个的区域,每次回收的时候,只
回收其中几个区域,以此来控制垃圾回收产生的一次停顿的时间。
G1的回收过程可能有4个阶段:

  • 新生代GC。
  • 并发标记周期。
  • 混合回收。
  • 如果需要,可能会进行Full GC。

5.4.2 G1的新生代GC

新生代GC的主要工作是回收eden区和survivor区。一旦eden区被占满,新生代GC就会启动。新生代GC只处理eden区和survivor区

新生代GC发生后,如果打开了PrintGCDetails选项,就可以得到类似如下的GC日志(这里只给出了部分日志,完全的日志及其分析请看5.4.6节):
在这里插入图片描述
和其他回收器的日志相比,G1的日志内容非常丰富。当然我们最关心的依然是GC的停顿时间及回收情况。从日志中可以看到,eden区原本占用235MB空间,回收后被清空,survivor区从5MB增长
到了11MB,这是因为部分对象被从eden区复制到survivor区,整个堆合计为400MB,堆内存从回收前的239MB下降到10.5MB。

5.4.3 G1的并发标记周期

G1的并发阶段和CMS有点类似,它们都是为了降低一次停顿时间,而将可以和应用程序并发的部分单独提取出来执行。

并发标记周期可以分为以下几步。

  • 初始标记: 标记从根节点直接可达的对象。这个阶段会伴随一次新生代GC,它是会产生全局停顿的,应用程序线程在这个阶段必须停止执行。
  • 根区域扫描: 由于初始标记必然会伴随一次新生代GC,所以在初始化标记后,eden区被清空,并且存活对象被移入survivor区。在这个阶段,将扫描由survivor区直接可达的老年代区域,并标记这些直接可达的对象。这个过程是可以和应用程序并发执行的。但是根区域扫描不能和新生代 GC 同时执行(因为根区域扫描依赖 survivor 区的对象,而新生代GC会修改这个区域),因此如果恰巧在此时需要进行新生代GC,就需要等待根区域扫描结束后才能进行。如果发生这种情况,这次新生代GC的时间就会延长。
  • 并发标记: 和CMS类似,并发标记将会扫描并查找整个堆的存活对象,并做好标记。这是一个并发的过程,并且这个过程可以被一次新生代GC打断
  • 重新标记: 和CMS一样,重新标记也是会产生应用程序停顿的。由于在并发标记过程中,应用程序依然在运行,因此标记结果可能需要进行修正,所以在此对上一次的标记结果进行补充。在G1中,这个过程使用SATB(Snapshot-At-The-Beginning)算法完成,即G1会在标记之初为存活对象创建一个快照,这个快照有助于加速重新标记的速度。
  • 独占清理: 这个阶段是会引起停顿的。它将计算各个区域的存活对象和 GC 回收比例,并进行排序,识别可供混合回收的区域。在这个阶段,还会更新记忆集(Remebered Set)。该阶段给出了需要被混合回收的区域并进行了标记,在混合回收阶段需要这些信息。
  • 并发清理: 这里会识别并清理完全空闲的区域。它是并发的清理,不会引起停顿。

并发回收阶段的整体工作流程如图5.8所示,可以看到除了初始标记、重新标记和独占清理,其他几个阶段都可以和应用程序并发执行。
在这里插入图片描述

图5.7显示了并发标记周期前后堆的可能情况。由于并发标记周期包含一次新生代GC,故新生会被整理,但由于并发标记周期执行时,应用程序依然在运行,所以并发标记周期结束后,又会有新的
eden区的空间被使用。并发标记周期执行前后最大的不同是在该阶段后,系统增加了一些标记为G的区域。这些区域被标记,是因为它们内部的垃圾比例较高,希望在后续的混合GC中进行收集(注意在并发标记周期中并未正式收集这些区域)。这些将要被回收的区域会被G1记录在一个称为Collection Sets(回收集)的集合中。

在并发标记周期中,G1会产生如下日志。
(1)初始标记,它伴随一次新生代GC。
在这里插入图片描述

可以看到,初始化标记时,eden区被清空,并部分复制到survivor区。
(2)一次并发的根区域扫描,并发扫描过程不能被新生代GC中断。

在这里插入图片描述
根区域扫描不会产生停顿。

(3)并发标记,并发标记可以被新生代GC打断,下面的日志显示了一次并发标记被3次新生代GC打断。
在这里插入图片描述
在这里插入图片描述
(4)重新标记,是会引起全局停顿的,它的日志如下:
在这里插入图片描述
(5)重新标记后会进行独占清理,独占清理会重新计算各个区域的存活对象,并以此可以得到每个区域进行GC的效果(即回收比)。它的日志如下:
在这里插入图片描述
(6)并发清理,是并发执行的,它会根据独占清理阶段计算得出的每个区域的存活对象数量,直接回收已经不包含存活对象的区域。它的日志如下:
在这里插入图片描述

5.4.4 混合回收

在并发标记周期中,虽然有部分对象被回收,但是总体上说,回收的比例是相当低的。但是在并发标记周期后,G1已经明确知道哪些区域含有比较多的垃圾对象,在混合回收阶段就可以专门针对这些
区域进行回收。当然,G1会优先回收垃圾比例较高的区域,因为回收这些区域的性价比也比较高。这也正是G1名字的由来。G1垃圾回收器的全称为Garbage First Garbage Collector,直译为垃圾优先的垃圾回收器,这里的垃圾优先(Garbage First)指的就是回收时优先选取垃圾比例最高的区域。

这个阶段叫作混合回收,是因为在这个阶段既会执行正常的年轻代GC,又会选取一些被标记的老年代区域进行回收,它同时处理了新生代和老年代,如图5.9所示。因为新生代GC的原因,eden区必然被清空,此外,有两块被标记为G的垃圾比例最高的区域被清理。被清理区域中的存活对象会被移到其他区域,这样做的好处是可以减少空间碎片

在这里插入图片描述
混合GC会产生如下日志:
在这里插入图片描述
混合GC会执行多次,直到回收了足够多的内存空间,然后它会触发一次新生代GC。新生代GC后,又可能会发生一次并发标记周期的处理,最后又会引起混合GC的执行。整个过程可能如图5.10所
示。
在这里插入图片描述
混合GC及G1整体示意图

5.4.5 必要时的Full GC

和CMS类似,并发回收由于让应用程序和GC线程交替工作,总是不能完全避免在特别繁忙的场合出现在回收过程中内存不充足的情况。当遇到这种情况时,G1也会转入一个Full GC。

【示例5-1】当G1在并发标记时,由于老年代被快速填充,G1会终止并发标记而转入一个Full GC。
在这里插入图片描述
此外,如果在混合GC时空间不足,或者在新生代GC时survivor区和老年代无法容纳幸存对象,都会导致一次Full GC。

5.4.6 G1的日志

G1的日志与先前的回收器相比已经丰富了很多。在本书前文中尚未给出完整的G1日志,本节将给出一个较为完整的日志,并做出解释。
【示例5-2】以下是一个完整的G1新生代日志。
在这里插入图片描述

(1)日志第一行:
在这里插入图片描述
表示在应用程序开启1.619秒时发生了一次新生代GC,这是在初始标记时发生的,耗时0.038秒,意味着应用程序至少暂停了0.038秒。
(2)后续并行时间:
在这里插入图片描述
表示所有GC线程总的花费时间,这里为38毫秒。
(3)给出每一个GC线程的执行情况:
在这里插入图片描述
这里表示一共4个GC线程(因为第一行有4个数据),它们都在1619.3秒时启动。同时,还给出了这几个启动数据的统计值,如平均(Avg)、最小(Min)、最大(Max)和差值(Diff)。Diff表示最
大值和最小值的差。
(4)给出了根扫描的耗时:
在这里插入图片描述
在根扫描时,每一个GC线程的耗时,这里分别消耗了0.3、0.3、0.2、0.2秒,后一行给出了这些耗时的统计数据。
(5)给出了更新记忆集(Remembered Set)的耗时:
在这里插入图片描述
记忆集是G1中维护的一个数据结构,简称RS。每一个G1区域都有一个RS与之关联。由于G1回收时是按照区域回收的,比如在回收区域A的对象时,很可能并不回收区域B的对象,为了回收区域A的对
象,要扫描区域B甚至整个堆来判定区域A中哪些对象不可达,这样做的代价显然很大。因此,G1在区域A的RS中,记录了在区域A中被其他区域引用的对象,这样在回收区域A时,只要将RS视为区域A根集的一部分即可,从而避免做整个堆的扫描。由于系统在运行过程中,对象之间的引用关系是可能时刻变化的,为了更高效地跟踪这些引用关系,会将这些变化记录在Update Buffers中。这里的ProcessedBuffers指的就是处理这个UpdateBuffers数据。这里给出的4个时间也是4个GC线程的耗时,以及它们的统计数据。从这个日志中可以看到,更新RS时分别耗时5.7、5.4、28、5.3毫秒,平均耗时11.1毫秒。

(6)扫描RS的时间:
在这里插入图片描述
(7)在正式回收时,G1会对被回收区域的对象进行疏散,即将存活对象放置在其他区域中,因此需要进行对象的复制。
在这里插入图片描述
这里给出的Object Copy就是进行对象赋值的耗时。
(8)给出GC工作线程的终止信息:
在这里插入图片描述
这里的终止时间是线程花在终止阶段的耗时。在GC线程终止前,它们会检查其他GC线程的工作队列,查看是否仍然还有对象引用没有处理完,如果其他线程仍然有没有处理完的数据,请求终止的
GC线程就会帮助它尽快完成,随后再尝试终止。其中TerminationAttempts展示了每一个工作线程尝试终止的次数。
(9)显示GC工作线程的完成时间:
在这里插入图片描述
这里显示了在系统运行后1657毫秒,这几个线程都终止了。
(10)显示几个GC工作线程的存活时间,单位是毫秒:
在这里插入图片描述
(11)显示GC线程花费在其他任务中的耗时,单位是毫秒,可以看到这部分时间非常少:
在这里插入图片描述
(12)显示清空CardTable的时间,RS就是依靠CardTable来记录哪些是存活对象的:
在这里插入图片描述

(13)显示其他几个任务的耗时:
在这里插入图片描述
在这里插入图片描述
比如选择CSet(Collection Sets)的时间、Ref Proc(处理弱引用、软引用的时间)、RefEnq(弱引用、软引用入队时间)和FreeCSet(释放被回收的CSet中区域的时间,包括它们RS)。

注意: Collection Sets表示被选取的、将要被回收的区域的集合。
(14)显示比较熟悉的GC回收的整体情况:
在这里插入图片描述
这里显示了eden区一共32MB被清空,survivor区没有释放对象,整个堆空间没有释放空间。用户CPU耗时0.16秒,实际耗时0.04秒。

5.4.7 G1相关的参数

对于G1,可以使用-XX:+UseG1GC标记打开G1的开关,对G1进行设置时,最重要的一个参数就是-XX:MaxGCPauseMillis,它用于指定目标最大停顿时间。如果任何一次停顿超过这个设置值,G1就会尝试调整新生代和老年代的比例、调整堆大小、调整晋升年龄等,试图达到预设目标。对于性能调优来说,有时候总是鱼和熊掌不可兼得,如果停顿时间缩短,对于新生代来说,这意味着很可能要增加新生代GC的次数。对于老年代来说,为了获得更短的停顿时间,在混合GC时,一次收集的区域数量也会变少,这样无疑增加了进行FullGC的可能性。

另外一个重要的参数是-XX:ParallelGCThreads,它用于设置并行
回收时GC的工作线程数量。

此外,-XX:InitiatingHeapOccupancyPercent参数可以指定当整个堆使用率达到多少时,触发并发标记周期的执行。默认值是45,即当整个堆的占用率达到45%时,执行并发标记周期
InitiatingHeapOccupancyPercent一旦设置,始终都不会被G1修改,这意味着G1不会试图改变这个值来满足MaxGCPauseMillis的目标。如果InitiatingHeapOccupancyPercent值设置得偏大,会导致并发周期迟迟得不到启动,那么引起FullGC的可能性也大大增加,反之,一个过小的InitiatingHeapOccupancyPercent值会使得并发标记周期执行非常频繁,大量GC线程抢占CPU,导致应用程序的性能有所下降。

5.5 回眸:有关对象内存分配和回收的一些细节问题

5.5.1 禁用System.gc()

在默认情况下,System.gc()会显式直接触发Full GC,同时对老年代和新生代进行回收。

可以看到,如果设置了-XX:-+DisableExplicitGC,条件判断就无法成立,那么就会禁用显式GC,System.gc()等价于一个空函数调用。

System.gc()使用并发回收,在默认的情况下,即使System.gc()生效,会使用传统的Full GC
方式回收整个堆,而忽略参数中的UseG1GC和UseConcMarkSweepGC。比如使用以下参数运行程序:
在这里插入图片描述

5.5.3 并行GC前额外触发的新生代GC

对于并行回收器的Full GC(使用UseParallelOldGC或者UseParallelGC),细心的读者可能会发现,在每一次Full GC之前都会伴随一次新生代GC。这和串行回收器相比,有很大的不同。

这样做的目的是先将新生代进行一次回收,避免将所有回收工作同时交给
一次Full GC进行,从而尽可能地缩短一次停顿时间。如果不需要这个特性,那么可以使用参数-XX:-
ScavengeBeforeFullGC去除发生在Full GC之前的那次新生代GC。在默认情况下,ScavengeBeforeFullGC的值为true。

5.5.4 对象何时进入老年代

5.5.4.1新生代的eden区

对于一般情况而言,当对象首次创建时,会被放置在新生代的eden区
from、to和老年代tenured均未被使用。
在这里插入图片描述

可以看到,在整个过程中没有GC发生,分配的5MB数据都应该在堆中,从堆的日志中可以看到,eden区占据了6MB左右的空间,from、to和老年代tenured均未被使用

5.5.4.2 老年对象进入老年代

eden区中的对象何时能进入老年代呢?一般来说,当对象的年龄达到一定的大小,就自然可以离开年轻代,进入老年代。一般把对象进入老年代的事件,称为“晋升”。对象的年龄是由对象经历过的GC
次数决定的。在新生代中的对象每经历一次GC,如果它没有被回收,它的年龄就加1。虚拟机提供了一个参数来控制新生代对象的最大年龄:MaxTenuringThreshold。在默认情况下,这个参数的值为15。也就是说,新生代的对象最多经历15次GC,就可以晋升到老年代。

在这里插入图片描述
这里分配了1GB的内存,用意是将对象尽可能预留在新生代(一个大的堆自然有一个大的新生代)。显式指定了MaxTenuringThreshold为15(和默认值一样,这里方便读者理解),并打开了PrintHeapAtGC开关,在每次GC时都打印堆的详细信息。

程序的部分输出如下(由于打开了PrintHeapAtGC,会产生大量信息,这里仅显示相关部分):
在这里插入图片描述
在这里插入图片描述
观察以上日志,在第一次GC开始前,eden区使用了99%,这也是触发新生代GC的原因。既然eden区不能容纳更多对象,之后又需要有新的对象产生,自然需要对eden区进行清理,清理的结果是将存
活对象移入了from区。从堆日志中可知,from区占用了16%,34944KB*0.16=5591KB,大约为5MB,与放置在map对象中的byte数量匹配,第一次GC的另一个影响是eden区被清空。
之后的14次GC情况和第1次是一样的,在日志中被省略。每一次GC都会使存活对象的年龄加1,在第16次GC时,可以看到它已将新生代清空。
在这里插入图片描述
对比第一次的日志
在这里插入图片描述
这里有明显的差异。从新生代被移除的对象,这里晋升到了老年代(指map对象中的byte数组),这从最后一次GC的后续堆日志中可以看到。老年代已经有5888KB被使用,而新生代有0KB被使用。这
说明这5MB对象成功晋升到老年代。读者可以尝试把MaxTenuringThreshold改为10,那么在这个示
例中,在第11次GC时,5MB对象就会晋升到老年代,此处从略。虽然有上述示例做铺垫,但仍然需要再次强调,MaxTenuringThreshold指的是最大晋升年龄。它是对象晋升到老年
代的充分非必要条件。即达到该年龄,对象必然晋升,而未达到该年龄,对象也有可能晋升。事实上,对象的实际晋升年龄,是由虚拟机在运行时自行判断的。

5.5.4.3大对象进入老年代

除了年龄,对象的体积也会影响对象的晋升。试想,如果对象体积很大,新生代无论eden区还是survivor区都无法容纳这个对象,自然这个对象无法存放在新生代,也非常有可能被直接晋升到老年代。

另外一个有趣的参数是PretenureSizeThreshold,它用来设置对象直接晋升到老年代的阈值,单位是字节。只要对象的大小大于指定值,就会绕过新生代,直接在老年代分配。这个参数只对串行回收器
和ParNew有效,对于ParallelGC无效。默认情况下该值为0,也就是不指定最大的晋升大小,一切由运行情况决定。

5.5.5 在TLAB上分配对象

TLAB的全称是Thread Local Allocation Buffer,即线程本地分配缓存。从名字上可以看到,TLAB是一个线程专用的内存分配区域。

为什么需要TLAB这个区域呢?这是为了加速对象分配。由于对象一般会分配在堆上,而堆是全局共享的。在同一时间,可能会有多个线程在堆上申请空间。因此,每一次对象分配都必须进行同步,而在
竞争激烈的场合分配的效率又会进一步下降。考虑到对象分配几乎是Java最常用的操作,因此Java虚拟机就使用了TLAB这种线程专属的区域来避免多线程冲突,提高对象分配的效率。TLAB本身占用了
eden区的空间。在TLAB启用的情况下,虚拟机会为每一个Java线程分配一块TLAB区域。

在这里插入图片描述
该参数打开了TLAB(默认即为开启状态,这里使用参数打开是为了让表述更清晰),并启用了对所有函数的JIT及禁止后台编译(这里只是希望在相对一致的环境中测试),同时禁用了逃逸分析,
以防止栈上分配的行为影响本次测试结果。开启Server模式是因为在Client模式下,不支持逃逸分析参数DoEscapeAnalysis。最终程序输出:
在这里插入图片描述

修改Java虚拟机参数为:
在这里插入图片描述
禁用了TLAB,程序输出为:
在这里插入图片描述
可以看到,TLAB是否启用对于对象分配的影响是很大的。

5.5.6 finalize()函数对垃圾回收的影响

在使用finalize()函数时可能会导致对象复活。

  • finalize()函数的执行时间是没有保障的,它完全由GC线程决定,在极端情况下,若不发生GC,finalize()函数将没有机会执行。
  • 一个糟糕的finalize()函数会严重影响GC的性能。

finalize()函数是由FinalizerThread线程处理的。每一个即将被回收并且包含finalize()函数的对象都会在正式回收前加入FinalizerThread的执行队列,该队列为java.lang.ref.ReferenceQueue
引用队列,内部实现为链表结构,队列中每一项都为java.lang.ref.Finalizer引用,它本质为一个引用,如图5.13所示,这和虚引用、弱引用等如出一辙。
在这里插入图片描述

Finalizer内部封装了实际的回收对象,如图5.14所示。可以看到next、prev为实现链表所需,它们分别指向队列中的下一个元素和上一个元素,而referent字段则指向实际的对象引用。比如,在后续的
示例中就为LongFinalize$LF。

5.6 温故又知新:常用的GC参数

5.6.1.与串行回收器相关的参数

·-XX:+UseSerialGC:在新生代和老年代使用串行回收器。
·-XX:SurvivorRatio:设置eden区大小和survivior区大小的比例。
·-XX:PretenureSizeThreshold:设置大对象直接进入老年代的阈值。当对象的大小超过这个值时,将直接被分配在老年代。
·-XX:MaxTenuringThreshold:设置对象进入老年代的年龄的最大值。每一次 Minor GC后,对象年龄就加1。任何大于这个年龄的对象,一定会进入老年代。

5.6.2.与并行GC相关的参数

·-XX:+UseParNewGC(考虑到兼容性问题,JDK 9、JDK 10已经删除):在新生代使用并行回收器。
·-XX:+UseParallelOldGC:老年代使用并行回收器。
·-XX:ParallelGCThreads:设置用于垃圾回收的线程数。通常情况下可以和CPU数量相等,但在CPU数量比较多的情况下,设置相对较小的数值也是合理的。
·-XX:MaxGCPauseMillis:设置最大垃圾回收停顿时间。它的值是一个大于0的整数。回收器在工作时,会调整 Java 堆大小或者其他一些参数,尽可能地把停顿时间控制在MaxGCPauseMillis以内。
·-XX:GCTimeRatio:设置吞吐量大小。它的值是一个 0 到 100之间的整数。假设GCTimeRatio的值为n ,那么系统将花费不超过1/(1+n )的时间用于垃圾回收。
·-XX:+UseAdaptiveSizePolicy:打开自适应GC策略。在这种模式下,新生代的大小、eden区和survivior区的比例、晋升老年代的对象年龄等参数会被自动调整,以达到在堆大小、吞吐量和停顿时间之间的平衡。

5.6.3.与CMS回收器相关的参数(JDK9、JDK10已经开始废弃CMS回收器,建议使用G1回收器)

·-XX:+UseConcMarkSweepGC:新生代使用并行回收器,老年代使用CMS+串行回收器。
·-XX:ParallelCMSThreads:设定CMS的线程数量。
·-XX:CMSInitiatingOccupancyFraction:设置 CMS 回收器在老年代空间被使用多少后触发,默认为68%。
·-XX:+UseCMSCompactAtFullCollection:设置 CMS 回收器在完成垃圾回收后是否要进行一次内存碎片的整理。
·-XX:CMSFullGCsBeforeCompaction:设定进行多少次CMS垃圾回收后,进行一次内存压缩。
·-XX:+CMSClassUnloadingEnabled:允许对类元数据区进行回收。
·-XX:CMSInitiatingPermOccupancyFraction:当永久区占用率达到这一百分比时,启动CMS回收(前提是激活了-XX:+CMSClassUnloadingEnabled)。
·-XX:UseCMSInitiatingOccupancyOnly:表示只在到达阈值的时候才进行CMS回收。
·-XX:+CMSIncrementalMode:使用增量模式,比较适合单CPU。增量模式在JDK8中标记为废弃,并且将在JDK9中彻底移除。

5.6.4.与G1回收器相关的参数

·-XX:+UseG1GC:使用G1回收器。
·-XX:MaxGCPauseMillis:设置最大垃圾回收停顿时间。
·-XX:GCPauseIntervalMillis:设置停顿间隔时间。

5.6.5.TLAB相关

·-XX:+UseTLAB:开启TLAB分配。
·-XX:+PrintTLAB(考虑到兼容性问题,JDK 9、JDK 10不再支持此参数):打印TLAB相关分配信息。
·-XX:TLABSize:设置TLAB区域大小。
·-XX:+ResizeTLAB:自动调整TLAB区域大小。

5.6.6.其他参数

·-XX:+DisableExplicitGC:禁用显式GC。
·-XX:+ExplicitGCInvokesConcurrent:使用并发方式处理显式GC。

5.7 动手才是真英雄:垃圾回收器对Tomcat性能影响的实验

5.7.2 配置性能测试工具JMeter

5.21显示了如何在JMeter中添加线程组。
在这里插入图片描述
(2)使用线程组可以设置请求的强度,如图5.22所示,设置了10个线程,并且规定每个线程进行1000次请求。这样,Tomcat就会在这次线程组的运行中,收到10000次请求。
在这里插入图片描述
(3)除了线程组,要让JMeter正常工作还需要一个采样器。采样器用于对具体的请求进行性能数据的采样,如图5.23所示。本例中,需要添加的是HTTP请求的采样。
在这里插入图片描述
(4)对于添加的HTTP请求,还需要对请求的具体目标进行设置,比如目标服务器的地址、端口号、访问路径等信息,如图5.24所示。JMeter会按照设置的要求进行批量的请求访问。
在这里插入图片描述
(5)对于批量请求的访问结果,JMeter可以以报告的形式呈现出来。在监听器中,添加聚合报告,如图5.25所示。聚合报告可以统计整个测试的性能参数。
在这里插入图片描述
(6)添加后,聚合报告的内容如图5.26所示。报告中主要内容为每次请求的延时情况和吞吐量。这里主要关注吞吐量,即图中用黑色矩形标注部分。
在这里插入图片描述
至此,已经完整介绍了通过JMeter建立线程组、采样器和聚合报告来进行压力测试并获得测试结果的方法,本实验将使用这个方法获得Tomcat的测试结果。

5.7.4 实战案例1—初试串行回收器

5.7.5 实战案例2—扩大堆以提升系统性能

5.7.6 实战案例3—调整初始堆大小

5.7.7 实战案例4—使用ParrellOldGC回收器

5.7.8 实战案例5—使用较小堆提高GC压力

5.7.9 实战案例6—测试ParallelOldGC的表现

5.7.10 实战案例7—测试ParNew回收器的表现

5.7.11 实战案例8—测试JDK 1.8的表现

5.7.12 实战案例9—使用高版本虚拟机提升性能

  • 4
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值