摘要:本文主要学习了虚拟机的垃圾回收机制。
环境
Windows 10 企业版 LTSC 21H2
Java 1.8
1 垃圾回收
1.1 定义
垃圾回收机制是由垃圾收集器GC(Garbage Collection)实现的,GC是后台的守护进程。
GC的特别之处是它是一个低优先级进程,但是可以根据内存的使用情况调整优先级,在内存低到一定限度时会自动运行,从而实现对内存的回收。这就是垃圾回收的时间不确定的原因。
1.2 发生位置
JVM的内存结构包括五大区域:程序计数器、本地方法栈、虚拟机栈、堆区、方法区。
程序计数器、本地方法栈、虚拟机栈三个区域随线程而生、随线程而灭,因此这几个区域的内存分配和回收都具备确定性,就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。
堆区和方法区则不一样,这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。
1.3 内存泄漏
内存泄漏指的是无法回收不再使用的对象,导致内存中没有空闲空间。
内存泄漏的八种情况:
- 单例模式,单例模式中的对象生命周期和应用程序是一样长的,如果单例程序中持有外部对象的引用,这个外部引用就不能被回收。
- 资源未被关闭,数据库连接和网络连接以及输入输出流都需要手动关闭,否则不能被回收。
- 静态集合类,如果这些容器为静态的,那么它们的生命周期与应用程序一致,容器中的对象在程序结束之前将不能被释放,不能被回收。
- 内部类持有外部类,如果一个外部类的实例对象的方法返回了一个内部类的实例对象,这个内部类对象被长期引用了,即使那个外部类实例对象不再被使用也不能被回收。
- 改变哈希值,当对象被存入HashSet中后,就不能再修改这个对象的哈希值了,否则就会导致无法从HashSet中检索到该对象,这个对象就不能被回收。
- 不合理的作用域,变量定义的作用范围大于其使用范围,可能会导致内存泄漏,另外,如果没有及时将对象置空,这个对象就不能被回收。
- 缓存泄漏,一旦将对象放入到缓存中,就会很容易遗忘缓存对象,缓存对象就不能被回收。
- 监听器和回调,如果客户端在接口中注册回调,但没有显示取消,相关对象就不能被回收。
1.4 内存溢出
内存溢出指的是定义的对象占用的内存过大,需要的内存溢出了内存空间。
1.5 STW
STW(Stop The World)指的是GC事件发生过程中,会产生应用程序的停顿,整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉。
1.6 安全点和安全区域
1.6.1 安全点
从线程角度看,安全点(Safe Point)可以理解成是在代码执行过程中的一些特殊位置,在线程执行到这些位置时,说明虚拟机当前的状态是安全的。
安全点的选择很重要,太少会导致等待进入安全点的时间过长,太多会导致性能问题,可以将执行时间较长的程序作为安全点,比如方法调用、循环跳转、异常跳转等。
对于一些需要暂停的操作,比如STW,需要等线程进入安全点才能执行,线程进入安全点的方式有两种:
- 抢先式中断:首先中断所有线程,如果还有线程不在安全点,就恢复线程,让线程跑到安全点。过时,目前没有虚拟机采用。
- 主动式中断:设置一个中断标志,各个线程运行到安全点的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。
1.6.2 安全区域
当需要暂停线程时,如果线程正在执行可以等待进入安全点,但如果线程处于休眠状态或者阻塞状态,等待时间就会变得很长。
为了解决这个问题,引入了安全区域的概念。
安全区域是指在一段代码片中,引用关系不会发生改变,在这个区域中的任何位置开始GC都是安全的,可以看做是安全点的扩展。
当线程进入安全区域时,会标识已经进入安全区域,此时发生GC会忽略进入安全区域的线程。
当线程离开安全区域时,会检查是否完成GC,只有完成GC线程才可以离开,否则需要等待GC完成才可以离开。
2 对象存活判断
2.1 堆的存活判断
2.1.1 引用计数算法
每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。
此方法简单,但无法解决对象相互循环引用的问题:
1 | public class Demo { |
在第一步和第二步执行后,在堆中创建了两个实例对象:
- demoA引用实例对象A,引用数量变为1。
- demoB引用实例对象B,引用数量变为1。
在第三步和第四步执行后:
- demoB的instance属性引用实例对象A,引用数量变为2。
- demoA的instance属性引用实例对象B,引用数量变为2。
在第五步和第六步执行后:
- demoA不再引用实例对象A,引用数量变为1。
- demoB不再引用实例对象B,引用数量变为1。
此时如果发生GC,虽然demoA和demoB均已经不再引用实例对象了,但是其内部的instance属性还在引用实例对象,所以此时实例对象的引用不为0,不能被GC回收。
2.1.2 可达性算法
从GCRoots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GCRoots没有任何引用链相连时,则证明此对象是不可用的,可以回收,但不一定会被回收,原因在于虚拟机的二次标记机制。
可以作为GCRoots的对象:
- 虚拟机栈的栈帧中的局部变量表引用的对象,比如各个线程中被调用的参数和局部变量等。
- 本地方法栈中JNI(Native方法)引用的对象,比如线程中的
start()
方法中使用的对象。 - 静态属性引用的对象,比如引用类型的静态变量。
- 方法区中常量引用的对象,比如在方法区中使用字符串常量池中的对象。
- 被synchronized所持有的对象。
- 虚拟机内部的引用,比如基本类型对应的Class对象,常驻异常对象,系统类加载器等。
- 本地代码缓存。
- 除了固定的对象外,根据用户选用的垃圾回收器和当前回收的内存区域,还可以有临时对象加入,比如分代收集和局部收集。
再回到相互循环引用的问题上,demoA和demoB是方法中的局部变量,其存储位置是虚拟机栈的栈帧中的局部变量表,可以作为GCRoots对象。instance属性是类中的成员属性,其存储位置是堆,不可以作为GCRoots对象。当demoA和demoB不再引用实例对象后,从GCRoots向下搜索,会发现实例对象没有引用链相连,可以被GC回收。
2.1.3 二次标记
Object类有一个finalize()
方法,该方法会在该对象被回收之前调用,并且任何一个对象的fianlize()
方法都只会被系统自动调用一次。
在被标记后,如果重写了finalize()
方法,并且在方法里将该对象重新加入到了引用链中。此时虽然已经被标记了,但并不会被回收,原因在于虚拟机的二次标记机制:
- 第一次标记,标记不在引用链的对象,判断是否需要执行
finalize()
方法。如果已经被执行或者没有被重写,就表示不需要执行,否则表示需要执行。 - 将需要执行
finalize()
方法的对象放在F-Queue的队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程去执行。 - 第二次标记,遍历F-Queue队列中的对象,判断是否存在引用链。如果存在引用链,表示该对象不需要被回收,否则标记不存在引用链的对象,等待回收。
该机制在JDK1.9已被弃用。
2.2 方法区的存活判断
方法区主要回收废弃常量和无用的类。对于废弃常量也可通过引用的可达性来判断,但是对于无用的类则需要同时满足下面三个条件:
- 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
- 加载该类的ClassLoader已经被回收。
- 该类对应的Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。
3 对象的引用
在JDK1.2以前的版本中,若一个对象不被任何变量引用,那么程序就无法再使用这个对象。也就是说,只有对象处于可达状态,程序才能使用它。
从JDK1.2版本开始,对象的引用被划分为四种级别:
- 强引用(StrongReference):不会被垃圾回收器回收,即使以后也不会用到。
- 软引用(SoftReference):比强引用弱,当系统内存不足时才会被回收。通常用在对内存敏感的程序中,比如高速缓存。
- 弱引用(WeakReference):比软引用弱,生命周期更短,只要发生了垃圾回收,不管内存空间是否足够都会被回收。
- 虚引用(PhantomReference):最弱,在任何时候都有可能被垃圾回收器回收。通常配和引用队列联合使用,在被回收前能够收到系统通知。
无论引用计数算法还是可达性分析算法都是基于强引用而言的。
如果对象是不可达的,不管是哪种引用都会被垃圾回收器回收。
3.1 强引用
强引用是使用最普遍的引用。
如果强引用的对象可达,虚拟机宁愿抛出OutOfMemoryError错误使程序异常终止,也不会回收对象来解决内存不足的问题。
示例:
1 | public class Demo { |
结果:
1 | test |
3.2 软引用
如果软引用的对象可达,只有当内存空间不足时才会被回收,当内存充足时不被回收。
软引用通常用来实现内存敏感的缓存。
示例:
1 | public class Demo { |
结果:
1 | test |
3.3 弱引用
无论弱引用的对象是否可达,无论内存是否充足,在下一次垃圾回收时都会被回收。
弱引用通常用来保存可有可无的缓存数据。
示例:
1 | public class Demo { |
结果:
1 | test |
3.4 虚引用
与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么随时都可能被垃圾回收器回收。
虚引用必须和引用队列联合使用,当垃圾回收器在回收有虚引用的对象之前,会加入到引用队列,以通知应用程序对象的回收情况。
虚引用通常用来跟踪对象被垃圾回收器回收的活动,也可以将一些资源释放操作放置在虚引用中执行和记录。
示例:
1 | public class Demo { |
结果:
1 | null |
4 垃圾回收算法
4.1 标记-清除算法
标记-清除(Mark-Sweep)算法分为两个阶段:
- 标记阶段的任务是标记出所有需要被回收的对象.
- 清除阶段的任务是回收被标记的对象所占用的空间。
说明:
这种方法的标记和清除过程的效率都不高,并且在标记清除之后会产生大量不连续的内存碎片,当程序需要分配较大对象时,无法找到足够的连续内存,不得不提前触发另一次垃圾收集动作。
4.2 复制算法
为了解决标记-清除算法的缺陷,复制(Copying)算法就被提了出来。
它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。
很显然,复制算法的效率跟存活对象的多少有很大的关系,如果存活对象很多,那么复制算法的效率将大大降低。
说明:
这种算法虽然实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。
4.3 标记-压缩算法
为了解决复制算法的缺陷,充分利用内存空间,提出了标记-压缩(Mark-Compact)算法。
该算法标记阶段和标记-清除算法的标记阶段一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。
说明:
这种方法的效率比较低,并且在移动过程中,需要全面暂停应用程序,即会触发STW。
4.4 分代收集算法
分代收集(Generational Collection)算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域,根据不同区域的特点采取最适合的收集算法。
一般情况下将堆区划分为年轻代和老年代两个区域。
4.4.1 年轻代
在年轻代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,所以选用复制算法。
分配:
- 因为大部分新生成的对象的生命周期都很短,所以将年轻代分为一块较大的Eden区和两块较小的Survivor区。
- 一块较大的Eden区用来存放新生成的对象,两块较小的Survivor区用来存放在多次GC存活下来的对象,一块称为S0区,另一块称为S1区。
年轻代发生的GC叫做MinorGC,也称为YoungGC,MinorGC发生频率比较高。
过程:
- 当第一次发生GC时,先将垃圾对象清除,然后将Eden区还存活的对象一次性复制到任意一个Survivor区,最后清空Eden区。为了区分方便,将使用的Survivor区称为From区,将空闲的Survivor区称为To区。
- 当再次发生GC时,先将垃圾对象清除,然后将Eden区和From区还存活的对象一次性复制到To区,最后清空Eden区和From区。每次GC完成之后,将正在使用的To区称为From区,将空闲的From区称为To区。
- 对象在放到Survivor区时都会设置一个年龄,并且每经过一次GC后都会将年龄加一,当对象的年龄超过虚拟机设置的阈值之后,会将对象放到老年代。
4.4.2 老年代
因为老年代中对象存活率高、没有额外空间对它进行分配担保,所以使用标记清除算法或标记压缩算法来进行回收。
分配:
- 大对象直接进入老年代。
- 多次不被回收的对象,经过多次MinorGC后仍在Survivor区的对象进入老年代。
- 动态年龄判断,计算某个年龄的对象数量超过了Survivor区总数量的一半,大于或等于这个年龄的对象进入老年代。
- 空间分配担保,经过MinorGC后Survivor区不足以存放对象进入老年代。
老年代发生的GC也叫做MajorGC,也称为OldGC,MajorGC发生频率比较低。
5 垃圾收集器
5.1 Serial系列
Serial是年轻代垃圾收集器,串行运行,采用复制算法,响应速度优先,使用STW机制,停顿时间长。
SerialOld是老年代垃圾收集器,串行运行,采用标记-压缩算法,响应速度优先,使用STW机制,停顿时间长。
对于单个CPU环境而言,Serial系列的收集器由于没有线程交互开销,可以获取最高的单线程收集效率。
配置:
- Serial系列的收集器是Client模式下默认的垃圾收集器。
- 可以通过
-XX:+UseSerialGC
来指定年轻代和老年代都使用Serial系列的收集器。
5.2 ParNew
年轻代垃圾收集器,并行运行,采用复制算法,响应速度优先,使用STW机制,停顿时间长。
ParNew是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现。
配置:
- ParNew收集器是Server模式下默认的垃圾收集器。
- 可以通过
-XX:+UseParNewGC
来指定年轻代使用ParNew收集器。 - 可以通过
-XX:ParallelGCThreads
来限制垃圾收集的线程数,默认开启和CPU数据相同的线程数。
5.3 Parallel系列
Parallel是年轻代垃圾收集器,并行运行,采用复制算法,吞吐量优先,使用STW机制,停顿时间长。
ParallelOld是老年代垃圾收集器,并行运行,采用标记-压缩算法,吞吐量优先,使用STW机制,停顿时间长。
追求高吞吐量,高效利用CPU,主要是为了达到可控的吞吐量,适合在后台运算而不需要太多交互的任务
配置:
- Parallel系列的收集器是JDK1.8默认的垃圾收集器。
- 可以通过
-XX:+UseParallelGC
来指定年轻代使用Parallel收集器。 - 可以通过
-XX:+UseParallelOldGC
来指定老年代使用ParallelOld收集器。 - 可以通过
-XX:ParallelGCThreads
来限制年轻代垃圾收集器的线程数。一般最好与CPU数量相等,以避免过多的线程数影响垃圾收集性能。 - 可以通过
-XX:MaxGCPauseMillis
来指定垃圾收集器的STW的时间,单位是毫秒。
5.4 CMS
JDK1.5推出,JDK1.9废弃,JDK1.14移除。
CMS(Current Mark Sweep)是老年代垃圾收集器,并发运行,采用标记-清除算法,响应速度优先,使用STW机制,停顿时间长。
过程:
- 初始标记,标记GCRoots能直接关联到的对象,有STW现象,暂停时间非常短。
- 并发标记,进行可达性分析过程,时间很长,不需要暂停用户线程,可与其他垃圾收集线程并发运行。在这个阶段使用了三色标记。
- 重新标记,修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,时间较长,不需要暂停用户线程。
- 并发清除,回收内存空间,时间很长,不需要暂停用户线程。
其中,并发标记与并发清除两个阶段耗时最长,但是可以与用户线程并发执行。
优缺点:
- 优点是并发收集和低延迟。
- 缺点是会产生内存碎片,对CPU资源非常敏感,并且无法处理浮动垃圾。
5.5 G1
JDK1.7推出,JDK1.9默认。
G1(Garbage First)是年轻代和老年代垃圾收集器,支持并发运行和并行运行,采用复制算法和标记-压缩算法,响应速度优先,同时注重吞吐量。
G1的目标是在延迟可控的情况下获得尽可能高的吞吐量。
使用G1收集器时,将整个堆划分为多个大小相等的独立区域(Region),分区如图:
说明:
- 每个独立区域都按照分代收集算法代表一种分区,分区有Eden区,S0区,S1区等分类。
- 所有的独立区域大小相同,且在JVM生命周期内不会被改变。
- 增加Humongous内存区域,主要用于存储大对象,如果超过0.5个独立区域就会放到H区域,如果一个H区装不下就会寻找连续的H区来存储。
过程:
- 当Eden空间耗尽时,启动年轻代GC,只回收Eden区和Survivor区:
- 首先停止应用程序的执行触发STW机制,创建回收集,包含Eden区和Survivor区所有的内存分段。
- 回收剩余存活的对象会被复制到新的S区,S区达到一定的阈值会被放到O区,或者S区空间不足也会被放到O区。
- 当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程:
- 初始标记,标记GCRoots直接可达的对象,会触发年轻代GC,也会触发STW机制。
- 根区域标记,标记Survivor区引用老年代的对象,必须在下一次年轻代GC前完成,否则会阻塞年轻代GC。
- 并发标记,在整个堆中进行并发标记,支持与用户线程并发。
- 最终标记,由于应用程序持续进行,需要修正上一次的标记结果,使用SATB处理并发期间的引用变化,会触发STW机制。
- 清理,计算各个区域的存活对象和GC回收比例,并进行排序,识别并清理完全空闲的区域,会触发STW机制。
- 当老年代中的对象进一步增加时,会触发MixedGC混合回收,回收整个年轻代和部分老年代:
- 并发标记结束以后,老年代中完全空闲的区域被回收了,部分空闲的区域被计算了出来。默认情况下,每个区域都会分8次回收。
- 由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。
- 如果上述方式不能正常工作,G1会执行兜底的FullGC回收,G1收集器退化为SerialOld收集器,性能会非常差,会触发STW机制,触发条件:
- 回收阶段没有足够的空间存放对象,解决办法是增加堆空间。
- 并发标记过程完成之前空间耗尽,解决办法是调小触发老年代并发标记的阈值,默认是45%。
- 最大GC停顿时间太短导致在规定的时间间隔内无法完成垃圾回收,解决办法是增加STW时间。
特点:
- 并行性:在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力。
- 并发性:拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,不会在整个回收阶段发生完全阻塞应用程序的情况。
- 无需连续:从堆的结构上看,不要求整个年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。
- 分代收集:独立管理整个堆,但是能够采用不同的方式处理新对象和旧对象。
- 空间整合:独立区域之间是复制算法,整体上可以看作是标记-压缩算法,这两种算法都能避免产生内存碎片。
- 可预测的停顿:能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
配置:
- G1收集器是JDK1.9默认的垃圾收集器。
- 可以通过
-XX:+UseG1GC
来指定使用G1收集器。 - 可以通过
-XX:G1HeapRegionSize
来指定每个独立区域的大小。值是2的幂,范围是1MB到32MB之间。默认为堆内存的1/2000大小。 - 可以通过
-XX:ParallelGCThread
来指定工作线程数的值,最多设置为8。 - 可以通过
-XX:ConcGCThreads
来指定并发标记的线程数。默认为工作线程数(ParallelGcThreads)的1/4左右。 - 可以通过
-XX:InitiatingHeapOccupancyPercent
来指定触发老年代并发标记的阈值,默认为45%。 - 可以通过
-XX:G1MixedGCLiveThresholdPercent
来指定是否要回收区域垃圾占用的比例,当垃圾占用的比例达到比例时才会被混合回收,默认为65%。 - 可以通过
-XX:G1HeapWastePercent
来指定允许垃圾占用的比例,当垃圾占用的比例低于比例时就不再进行混合回收,默认为10%。 - 可以通过
-XX:MaxGCPauseMillis
来指定垃圾收集器的STW的时间,单位是毫秒。
6 调优配置
配置日志的打印信息:
1 | # 输出日志 |
配置日志文件:
1 | # 设置日志文件的输出路径 |
配置内存大小:
1 | # 设置线程栈的大小,不建议修改 |
7 执行分析
7.1 查看内存
示例:
1 | public static void main(String[] args) { |
结果:
1 | max memory: 7260MB |
本机电脑是32G,去掉一些自身的占用后,堆内存的最大值约为物理内存的1/4,堆内存的初始值为物理内存的1/64。
7.2 修改内存
配置VM参数:
1 | -Xms5m |
示例:
1 | public static void main(String[] args) { |
结果:
1 | [GC (Allocation Failure) 1012K->648K(5632K), 0.0005734 secs] |
说明堆内存已经被修改了。
7.3 占用内存
配置VM参数:
1 | -Xms5m |
示例:
1 | public static void main(String[] args) { |
结果:
1 | [GC (Allocation Failure) 1012K->632K(5632K), 0.0009215 secs] |
初始,最大内存约为20M,空闲内存约为4M,总内存约为5M。
分配1M的内存,空闲内存足够,空闲内存约为3M,总内存约为5M。
继续分配10M的内存,空闲内存不足,自动增加内存,空闲内存约为2M,总内存约为15M。
7.4 内存溢出
配置VM参数:
1 | -Xms5m |
示例:
1 | public static void main(String[] args) { |
结果:
1 | [GC (Allocation Failure) 1012K->652K(5632K), 0.0005981 secs] |
当年轻代内存不足时,触发MinorGC,当老年代内存不足时,触发MajorGC,当内存仍不足时,触发FullGC,如果内存还不足则触发OOM异常。
条