深入理解Java虚拟机(四)-垃圾回收算法
关于对象是否存活的判断已经在深入理解Java虚拟机(三)-JVM内存分配和回收中提到了,这里主要就针对垃圾回收算法做一下简单的介绍。
垃圾收集算法
查看默认垃圾收集算法
java -XX:+PrintCommandLineFlags -version
对象存活的判断
也就是说GC回收的时候,如果判断哪些对象要回收,那些对象不需要回收。
- 引用计数法 引用计数算法是这样的,给对象添加一个引用计数器,每当有一个地方调用他,就给计数器加1,每当引用失效,计数器就减1;任何时刻计数器为0的对象就不可以再被使用。引用计数算法简单高效但是Java虚拟机没有采用引用计数算法来管理内存,主要是因为很难解决对象之间互相循环引用的问题但是Python语言有使用。
- 可达性分析算法(Java采用可达性分析算法) Java和C#采用可达性分析算法,基本思路就是通过一系列称为"GC Roots"的对象作为起点,从这个点开始向下搜索,搜索所走过的路径就是引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
在明确了对象是否存活的算法后,那么Java程序中是怎么判断这个对象是不是‘非死不可’呢。其实即使在可达性分析算法中不可达的对象,也并非是‘非死不可’的,这时候他们只是暂时处于‘缓行’阶段,而要真正宣告一个对象死亡要至少经历两次标记过程。如果对象在经历可达性分析后发现没有与GC Roots相连接的引用,那么它将被第一次标记并且进行筛选,筛选的条件是此对象是否有必要执行finalize()方法,或者finalize()已经被虚拟机调用过,虚拟机将这两种情况都看作是‘没必要执行筛选’。
如果对象被判定是有必要执行筛选的,那么这个对象将被放在一个叫做F-Queue的队列中,并在稍后由一个虚拟机自己创建的低优先级的线程去执行它。如果一个对象想要逃离死亡的命运,那么这时候是最后的一次机会,只要对象可以和引用链上的任何一个对象建立关系,那么在第二次标记的时候它将被移出“即将回收集合”;如果对象此时还没有逃脱,那么稍后GC将对F-Queue中的对象进行第二次标记,被第二次标记上的对象基本上就被真的回收了。
一般能作为GC Root:类加载器、Thread、虚拟机栈的本地变量表、static成员、常量引用、本地方法栈
的变量等
什么时候会进行垃圾回收
GC回收一般是JVM自己控制的,我们也可以通过System.gc()来让JVM进行垃圾回收,因为GC的时候消耗的资源比较大,不建议手动进行。
- 当Eden区或者S区不够用了
- 老年代空间不够用了
- 方法区空间不够用了
- System.gc()
垃圾收集算法分类
- 标记-清除算法
- 特点:先标记处需要回收的对象,然后在清除需要被回收的对象
- 缺点
- 1.会产生大量内存碎片,空间不连续后面有大对象时无法使用,可能会引起一次垃圾回收
- 2.标记和清除两个过程都比较耗时,效率不高
- 标记-复制算法
- 特点:将内存分为相等的两部分,每次只用一块当内存块用完的时候,将存活的复制到另一块空间,然后把当前这块清理掉,因为要预留一半空间,因此不适合老年代
- 缺点
- 1.解决了标记-清除算法中清除慢的问题,但是空间利用率低
- 2.在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都有100%存活的极端情况
- 标记-整理算法
- 特点:标记过程和标记-清除算法一样,但是接下来不是直接回收对象,而是让所有存活的对象都向一端移动,然后清理掉边界以外的内存
- 分代收集算法
- 特点:
- 1.当前商业虚拟机基本都采取分代回收算法
- 2.Young区采用复制算法(Young区对象生命周期短,复制效率更高)
- 3.Old区一般是标记-清除或者标记-整理
- 特点:
标记-清除算法
“标记-清除”(Mark-Sweep)算法,如同名字一样,算法分为两个阶段,标记和清除阶段。首先标记出所有需要回收的对象(可达性分析),在标记完成后统一回收所有被标记的对象。这是最基础的算法,但是有两个问题,效率问题和空间问题,标记和清除两个过程效率都不高;另外回收完成之后会产生大量不连续的内存空间,会导致在后面如果有大对象的时候,有可能找不到足够的连续内存从而不得不进行一次垃圾收集动作。
回收前
回收后
标记-复制算法
为了解决“标记-清除”算法的效率问题,出现了复制算法,它将内存分为大小相等的两块,每次只使用一块。当这一块内存使用完了,就将还存活的的对象复制到另一块上面,然后再把另一块内存完全清理掉。这样就不用考虑内存碎片的问题,但是每次只能使用一般内存,代价太高。这种算法用来回收新生代,新生代中的对象大多都是‘朝生夕死’,所以并不用按照1比1来划分,而是将内存分为一块大的Eden空间和两块较小的Survivor空间,当回回收时,将Eden和1个Survior中存活的对象一次copy到另一个Survior中,也就是说最多就10%的浪费。HotSpon默认的Eden和Survior的比例是8:1。加入回收时1个Survior中的空间不够,那么需要依赖老年代来进行分配担保。
标记-整理算法
“标记-整理”(Mark-Compact),标记过程和标记-清除一样,但是接下来不是直接对可回收对象进行清理,而是让所有存活对象都想一端移动,然后清理掉端边界以外的部分。
分代收集算法
当前商业虚拟机的的垃圾收集都采用"分代收集"(Generational Collection)算法。就是根据对象存活周期将内存分为几块,一般Java堆是分为新生代和老年代,这样各个代就可以根据自己的特点选择对应的算法。在新生代对象只有少量存活就选择复制算法;而老年代对象存活率高,而且没有而外空间来担保,就必须采用“标记-清除”或者“标记-整理”算法来进行回收。
垃圾收集器
如果说算法是垃圾回收的方法论,那么垃圾收集器就是内存回收的具体实现了。虚拟机中并没有明确规定,因此不同的厂商,不同版本都有自己的实现,主要为一下几种:
- Serial收集器:单线程收集过程需要暂停所有线程,采用复制算法适合用于新生代(Young)
- Serial Old收集器:Serial的老年代版本,也是单线程的,采取标记-整理算法
- ParNew收集器:Serial的多线程版本,采用复制算法适用于新生代,多CPU时候比Serial效率高,单CPU时候效果不如Serial
- Parallel Scavenge收集器:新生代(Young)收集器采用复制算法,并行的多线程收集器,和ParNew相比跟关注吞吐量
- Parallel Old收集器:Parallel Scavenge的老年代版本采用标记-整理算法
- CMS收集器:并发收集、低停顿,采用标记-清除算法产生大量空间碎片,并发阶段会降低吞吐量
- G1收集器:将对划分为大小相等的Region,保留了新生代和老年代的概念但是不再是物理隔离了,采用标记-整理算法不会产生空间碎片,可以让使用者指定在M毫秒的时间内,花费在垃圾收集的时间不超过N毫秒
- ZGC:没有碎片问题,不存在新老年代的概念
Serial收集器
Serial是一个历史悠久的收集器,这个收集器是单线程的收集器。这里的单线程不是说只能有一个CPU或者1条收集线程来完成垃圾收集工作,而是说它在进行垃圾回收的时候,必须暂停其他工作线程,直到它收集结束。“Stop the world”实际用起来我们并不能接受,假设程序正在跑,但是隔一会就要程序停下来等待垃圾收集,例如等待5分钟,那么这种我们是肯定不能接受的。
Serial Old收集器
是Serial的老年代版本,也是单线程收集器,使用“标记-整理”算法。
ParNew收集器
ParNew其实就是Serial的多线程版本,其他和Serial比起来没太大差别,但是ParNew在单CPU的环境下不会比Serial效果好,在两个CPU的环境中ParNew也不能说会稳超Serial,但是多核变得很常见,ParNew在一些CPU比较多的环境下,效果还是比较好的。我们可以通过参数-XX:ParallelGCThreads来限制参与垃圾收集的线程数。默认情况下开启的线程数和CPU数量相同。很多时候我们并不期待使用全部CPU资源。
Parallel Scavenge收集器
Parallel Scavenge收集器是一个新生代收集器,采用复制算法也是多线程的收集器。它和ParNew的区别是,其他收集器关注的是收集是尽可能缩短用户线程的停顿时间,而Parallel Scavenge是为了达到一个可控的吞吐量。
所谓吞吐量=运行用户代码时间/(垃圾收集时间+用户代码运行时间)
/**
*设置最大垃圾收集停顿时间,但是必须是一个大于0的毫秒数,但是并不是说这个值设置到非常小,就能使垃圾收集速度快。
*/
-XX:MaxGCPauseMillis
/**
*设置吞吐量大小是一个大于0且小于100的整数,也就是垃圾收集时间占总时间的占比
*/
-XX:GCTimeRatio:
//手工指定新生代大小
-Xmn
//Eden和Survivor的占比
-XX:SurvivorRatio
//GC自适应调节策略,设置了该参数就不需要手动指定新生代,也不需要指定Eden和Survivor占比等参数
-XX:+UseAdaptiveSizePolicy
Parallel Old收集器
Parallel Scavenge的老年代版本,使用多线程和“标记-整理”算法。在一些注重吞吐量和CPU敏感的场合可以使用Parallel Scavenge+Parallel Old(Java8默认垃圾收集器)。
CMS收集器
CMS(Concurrent Mark Sweep)是一种以获取最短停顿时间的垃圾收集器,是“标记-清除”算法实现的。他的运行过程复杂点,有四个过程:
- 初始标记:需要“Stop the world”,初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。
- 并发标记:是进行GC Roots追踪(GC Roots Tracing)的过程.这个过程和用户线程并发执行。
- 重新标记:需要“Stop the world”,是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分的对象的标记记录,这个停顿时间大于初始标记,但是远小于并发标记过程。
- 并发清除:和用户程序并发执行。
CMS的优点是,并发收集,低停顿。有一个明显缺点就是因为采用了“标记-清除”算法,最后会出现大量碎片,有可能会出现在某一个时刻,当有大对象生成,不得不进行一次Full GC来解决这个问题。为了解决该问题CMS有一个参数-XX:UseCmsCompactAtFullCollection来解决因为空间不足进行Full GC。这个参数默认开启,用于在CMS收集器顶不住要进行Full GC是开启内存碎片合并整理的过程,内存整理过程是无法并发的,因此就会耗时。同时还有一个参数是-XX:CMSFullGCsBeforeCompaction,这个参数是用于执行多次不压缩的GC后,跟着来一次压缩的(默认是0,表示每次进入Full GC都进行碎片整理)。
G1收集器
G1(Garbage-First)收集器:并行和并发,分代收集,空间整合,可预测的挺顿。 G1和其他收集器很大的不同是,其他收集器收集范围都是整个新生代和老年代,而G1不再是这样。使用G1收集器的时候,Java堆分为多个大小相等的区域(Region),虽然也保留了新生代和老年代的概念,但是不再是物理隔离了,他们都是一部分Region的集合(这些Region不一定是连续的)。G1保留了Eden和Survivor的比例也是8:1:1。
工作过程
- 初始标记(Initial Marking) 标记以下GC Roots能够关联的对象,并且修改TAMS的值,需要暂停用户线程
- 并发标记(Concurrent Marking) 从GC Roots进行可达性分析,找出存活的对象,与用户线程并发执行
- 最终标记(Final Marking) 修正在并发标记阶段因为用户程序的并发执行导致变动的数据,需暂停用户线程
- 筛选回收(Live Data Counting and Evacuation) 对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间制定回收计划
HumongousObject:一个大小达到甚至超过分区Region 50%以上的对象称为巨型对象(Humongous Object),巨型对象会独占一个或多个连续分区。HumongousObject直接分配在老年代。
ZGC
JDK11引入的ZGC收集器,在物理和逻辑上已经没有新/老年代的概念了,会分为一个个page,当进行GC操作时候会对page进行压缩,因此没有碎片问题,只能在64位linux上使用。
- 可以达到10ms以内的停顿要求
- 支持TB级别的内存
- 堆内存变大后停顿时间还是在10ms以内
垃圾收集器分类
- 串行收集器:只能有一个垃圾回收线程执行,用户线程暂停,适用于内存小的嵌入式设备 Serial和Serial Old
- 并行收集器:吞吐量优先,多个垃圾收集线程同时工作,但是这时候用户线程处于等待状态 Parallel Scanvenge、Parallel Old
- 并发收集器:停顿时间优先,用户线程和垃圾收集线程同时执行(但并不一定是并行的,可能是交替执行的),垃圾收集线程在执行的时候不会停顿用户线程的运行,适合web场景 CMS、G1
附录
常用JVM调试命令
jstack:查看栈信息,可用于多线程时分析和查看线程运行状况。
jstack pid
#查看pid=443进程的栈信息
jstack 443
jmap:查看堆内存信息和生成dump文件
//查看进程的内存映像信息
1.jmap pid
//显示Java堆详细信息,打印一个堆的摘要信息,包括使用的GC算法、堆配置信息和各内存区域内存使用信息
2.jmap heap pid
//显示堆中对象的统计信息,其中包括每个Java类、对象数量、内存大小(单位:字节)、完全限定的类名。打印的虚拟机内部的类名称将会带有一个’*’前缀。如果指定了live子选项,则只计算活动的对象
3.jmap -histo:live pid
//打印类加载器信息,-clstats是-permstat的替代方案,在JDK8之前,-permstat用来打印类加载器的数据
打印Java堆内存的永久保存区域的类加载器的智能统计信息。对于每个类加载器而言,它的名称、活跃度、地址、父类加载器、它所加载的类的数量和大小都会被打印。此外,包含的字符串数量和大小也会被打印
4.jmap -clstats pid
//打印等待终结的对象信息,Number of objects pending for finalization: 0 说明当前F-QUEUE队列中并没有等待Fializer线程执行final
5.jmap -finalizerinfo pid
//生成堆转储快照dump文件,以hprof二进制格式转储Java堆到指定filename的文件中。live子选项是可选的。如果指定了live子选项,堆中只有活动的对象会被转储。想要浏览heap dump,你可以使用jhat(Java堆分析工具)读取生成的文件
6.jmap -dump:format=b,file=heapdump.phrof pid
jmap -dump:format=b,file=heapdump.phrof pid,这个命令执行,JVM会将整个heap的信息dump写入到一个文件,heap如果比较大的话,就会导致这个过程比较耗时,并且执行的过程中为了保证dump的信息是可靠的,所以会暂停应用, 线上系统慎用
jhat:可以在浏览器解析和查看dump文件
//查看dumpfile的内存详细
jhat dumpfile
q@troyMac oom % jhat java_pid3874.hprof
Reading from java_pid3874.hprof...
Dump file created Sat May 09 11:45:32 CST 2020
Snapshot read, resolving...
Resolving 814571 objects...
Chasing references, expect 162 dots..................................................................................................................................................................
Eliminating duplicate references..................................................................................................................................................................
Snapshot resolved.
Started HTTP server on port 7000
Server is ready.