往期文章推荐:
java常见面试考点(十六):类加载器的常见考点
深入浅出JVM系列(一):JVM内存结构
深入浅出JVM系列(三):JVM生命周期
【版权申明】未经博主同意,谢绝转载!(请尊重原创,博主保留追究权);
本博客的内容来自于:深入浅出JVM系列(二):垃圾收集算法;
学习、合作与交流联系q384660495;
本博客的内容仅供学习与参考,并非营利;
众所周知,Java的垃圾回收是不需要程序员去手动操控的,而是由JVM去完成。本文介绍JVM进行垃圾回收的各种算法。
在JVM中,程序计数器、虚拟机栈、本地方法栈都是随线程生而生,随线程灭而灭;栈帧随着方法的进入和退出做入栈和出栈操作,实现了自动的内存清理;常说的垃圾回收主要集中在堆和方法区,这部分内存是随着程序运行动态分配的。
那么哪些对象是垃圾呢?
每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,当引用计数变为0的时候,这个对象就可以回收了。但是这个方法无法解决对象循环引用的问题,不采用。循环引用的例子如下:
public static void main(String[] args){
Object object1=new Object();
Object object2=new Object();
object1.object=object2;
object2.object=object1;
object1=null;
object2=null;
}
假设我们有上面的代码。程序启动后,objectA和objectB两个对象被创建并在堆中分配内存,它们都相互持有对方的引用,但是除了它们相互持有的引用之外,再无别的引用。而实际上,引用已经被置空,这两个对象不可能再被访问了,但是因为它们相互引用着对方,导致它们的引用计数都不为0,因此引用计数算法无法通知GC回收它们,造成了内存的浪费。如下图:对象之间的引用形成一个有环图。
为了解决引用计数法的循环引用问题,Java使用了可达性分析的方法。或者叫根搜索算法,在主流的JVM中,都是使用的这种方法来判断对象是否存活的。这个算法的思路很简单,它把内存中的每一个对象都看作一个结点,然后定义了一些可以作为根结点的对象,我们称之为“GC Roots”。果一个对象中有另一个对象的引用,那么就认这个对象有一条指向另一个对象的边。
通过一系列的“GC roots”对象作为起点搜索。如果在“GC roots”和一个对象之间没有可达路径,则称该对象是不可达的。要注意的是,不可达对象不等价于可回收对象,不可达对象变为可回收对象至少要经过两次标记过程。两次标记后仍然是可回收对象,则将面临回收。
所谓“GC roots”,或者说tracing GC的“根集合”,就是一组必须活跃的引用。例如说,这些引用可能包括:
我们在程序中正常创建一个对象,对象会在堆上开辟一块空间,同时会将这块空间的地址作为引用保存到虚拟机栈中,如果对象生命周期结束了,那么引用就会从虚拟机栈中出栈,因此如果在虚拟机栈中有引用,就说明这个对象还是有用的,这种对象可以作为GC Roots。
也就是使用了static关键字定义了的对象,这种对象的引用保存在共有的方法区中,因为虚拟机栈是线程私有的,如果保存在栈里,就不叫全局了,很显然,这种对象是要作为GC Roots的。
就是使用了static final关键字,由于这种引用初始化之后不会修改,所以方法区常量池里的引用的对象也作为GC Roots。
有时候单纯的java代码不能满足我们的需求,就可能需要调用C或C++代码(java本身就是用C和C++写的嘛),因此会使用native方法,JVM内存中专门有一块本地方法栈,用来保存这些对象的引用,所以本地方法栈中引用的对象也会被作为GC Roots。
比较常见的将对象视为可回收对象的原因:
执行完第一次的标记后,GC将对F-Queue队列中的对象进行第二次小规模标记。也就是执行对象的finalize()方法!如果对象在其finalize()方法中重新与引用链上任何一个对象建立关联,第二次标记时会将其移出"即将回收"的集合。如果对象没有,也可以认为对象已死,可以回收了。
finalize()方法是被第一次标记对象的逃脱死亡的最后一次机会。在jvm中,一个对象的finalize()方法只会被系统调用一次,经过finalize()方法逃脱死亡的对象,第二次不会再调用。由于该方法是在对象进行回收的时候调用,所以可以在该方法中实现资源关闭的操作。但是,由于该方法执行的时间是不确定的,甚至,在java程序不正常退出的情况下该方法都不一定会执行!所以在正常情况下,尽量避免使用!如果需要"释放资源",可以定义显式的终止方法,并在"try-catch-finally"的finally{}块中保证及时调用,如File相关类的close()方法
如果是进行根节点枚举,我们先要全栈扫描,找到变量表中存放为reference类型的变量,然后找到堆中对应的对象,最后遍历对象的数据(如属性等),找到对象数据中存放为指向其他reference的对象……这样的开销无疑是非常大的!
为解决上述问题,HotSpot 采用了一种 “准确式GC” 的技术,该技术主要功能就是让虚拟机可以准确的知道内存中某个位置的数据类型是什么,比如某个内存位置到底是一个整型的变量,还是对某个对象的reference,这样在进行 GC Roots枚举时,只需要枚举reference类型的即可。那怎么让虚拟机准确的知道哪些位置存在的是reference类型数据呢?OopMap+RememberedSet!
OopMap记录了栈上本地变量到堆上对象的引用关系,在GC发生时,线程会运行到最近的一个安全点停下来,然后更新自己的OopMap,记下栈上哪些位置代表着引用。枚举根节点时,递归遍历每个栈帧的OopMap,通过栈中记录的被引用对象的内存地址,即可找到这些对象( GC Roots )。这样,OopMap就避免了全栈扫描,加快枚举根节点的速度。
OopMap解决了枚举根节点耗时的问题,但是分代收集的问题依然存在!这时候就需要另一利器了- RememberedSet。对于位于不同年代对象之间的引用关系,会在引用关系发生时,在新生代边上专门开辟一块空间记录下来,这就是RememberedSet!所以“新生代的 GC Roots ” + “ RememberedSet存储的内容”,才是新生代收集时真正的GC Roots(G1 收集器也使用了 RememberedSet 这种技术)。
详细看这篇文章:你必须了解的java内存管理机制(三)-垃圾标记
在JVM规范中并没有明确GC的运作方式,各个厂商可以采用不同的方式去实现垃圾回收器。这里讨论几种常见的GC算法。
最基础的垃圾回收算法,分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间。如图:
标记-清除算法的缺点有两个,一个是空间问题,标记清除之后会产生大量的不连续内存碎片。内存碎片太多,程序在之后的运行过程中就有可能找不到足够的连续内存来分配较大的对象,进而不得不提前触发另一次垃圾回收,导致程序效率降低。标记-清除算法的另一个缺点是效率问题,标记和清除的效率都不高,两次扫描耗时严重。
详细实现可以参考这篇文章:Java面试 “核武器” JVM底层细节垃圾回收器串讲及 HostSpot 的细节实现
为了解决Mark-Sweep算法内存碎片化的缺陷而被提出的算法。按内存容量将内存划分为等大小的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉,如图:
这样就实现了简单高效的做法,每一次进行内存回收时,就不用再去考虑内存碎片这些复杂的情况,只需要移动堆顶指针就可以。但是缺点也很明显,可使用内存只有原来的一半了,而且持续复制生命力很旺盛的对象也会让效率降低哇。复制算法适用于存活对象少、垃圾对象多的情况,这种情况在新生代比较常见。
结合了以上两个算法,为了避免缺陷而提出。标记阶段和Mark-Sweep算法相同,标记后不是清理对象,而是将存活对象移向内存的一端。然后清除端边界外的对象。如图:
标记过程和标记清除算法一样,但是清理时不是简单的清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存,需要移动对象的成本。
详细实现可以参考这篇文章:垃圾回收算法(4)标记整理
分代收集法是目前大部分JVM所采用的方法,其核心思想是根据对象存活的不同生命周期将内存划分为不同的域,一般情况下将GC堆划分为老生代(Tenured/Old Generation)和新生代(Young Generation)。老生代的特点是每次垃圾回收时只有少量对象需要被回收,新生代的特点是每次垃圾回收时都有大量垃圾需要被回收,因此可以根据不同区域选择不同的算法。
目前大部分JVM的GC对于新生代都采取Copying算法,因为新生代中每次垃圾回收都要回收大部分对象,即要复制的操作比较少,但通常并不是按照1:1来划分新生代。一般将新生代划分为一块较大的Eden空间和两个较小的Survivor空间(From Space, To Space),每次使用Eden空间和其中的一块Survivor空间,当进行回收时,将该两块空间中还存活的对象复制到另一块Survivor空间中。
而老生代因为每次只回收少量对象,因而采用Mark-Compact算法。
另外,不要忘记在Java基础:Java虚拟机(JVM)中提到过的处于方法区的永久代(Permanet Generation)。它用来存储class类,常量,方法描述等。对永久代的回收主要包括废弃常量和无用的类。
对象的内存分配主要在新生代的Eden Space和Survivor Space的From Space(Survivor目前存放对象的那一块),少数情况会直接分配到老生代。当新生代的Eden Space和From Space空间不足时就会发生一次GC,MinorGC的过程(复制 --> 清空 --> 互换):
为什么需要Survivor区?
试想一下如果没有Survior区,每次进行Minor Gc,存活的对象直接进入老年代,老年代内存被占满会发生Major Gc,Major Gc执行的速度比Minor Gc慢十倍以上,当有了Survivor区,存活的对象在两块Survior区中倒腾,当倒腾的次数达到16次说明这个对象生命力真的很顽强,才会被放入老年代。这样就减少了Major Gc发生的频次。
为什么需要两块Survivor区?
为了避免内存碎片化的问题,大家想想复制算法是如何解决标记清除算法的内存碎片化问题的,将内存分为大小相等的两块,将存活的对象复制到另一块内存,这里也是一样的道理。
什么样的对象会进入老年代呢?
- 大对象会直接进入老年代: 所谓的大对象就是指需要大量连续内存空间的对象,典型的大对象就是很长的字符串和数组,如果大对象直接在Eden区分配内存空间会导致Eden和Survior之间的大量内存复制,很消耗性能,所以它会直接进入老年代。
- 长期存活的对象会进入老年代: 所谓长期存活的对象就是前面所述,来回倒腾十六次还不死的对象。
垃圾收集算法是垃圾收集器的理论基础,而垃圾收集器就是其具体实现。下面介绍HotSpot虚拟机提供的几种垃圾收集器。
新生代采用的停止复制算法中,“停 止(Stop-the-world)”的意义是在回收内存时,需要暂停其他所 有线程的执行。这个是很低效的,现在的各种新生代收集器越来越优化这一点,但仍然只是将停止的时间变短,并未彻底取消停止。
查看默认的垃圾收集器
java -XX:+PrintCommandLineFlags -version
最古老的收集器,是一个单线程收集器,用它进行垃圾回收时,必须暂停所有用户线程。Serial是针对新生代的收集器,采用Copying算法;而Serial Old是针对老生代的收集器,采用Mark-Compact算法。优点是简单高效,缺点是需要暂停用户线程,有STW问题。
使用-XX:+UseSerialGC可以使用Serial+Serial Old模式运行进行内存回收(这也是虚拟机在Client模式下运行的默认值)
-XX:+UseSerialGC
Seral/Serial Old的多线程版本,使用多个线程进行垃圾收集。使用-XX:+UseParNewGC开关来控制使用ParNew+Serial Old收集器组合收集内存(已经不推荐使用这种搭配组合了);使用-XX:ParallelGCThreads来设置执行内存回收的线程数。
-XX:+UseParNewGC
新生代的并行收集器,回收期间不需要暂停其他线程,采用Copying算法。该收集器与前两个收集器不同,主要为了达到一个可控的吞吐量。Parallel Scavenge收集器类似于ParNew收集器,因为与吞吐量密切,也称为吞吐量收集器。可以通过参数来打开自适应调节策略,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量;也可以通过参数控制GC的时间不大于多少毫秒或者比例。Parallel Scavenge收集器以高吞吐量为目标,减少垃圾收集时间,让用户代码获得更长的运行时间;GC停顿时间的缩短,是用牺牲吞吐量和新生代空间来换取的。
JVM运行100分钟,其中运行用户代码99分钟,垃 圾收集1分钟,则吞吐量是99%,这种收集器能最高效率的利用CPU,适合运行后台运算(关注缩短垃圾收集时间的收集器,如CMS,等待时间很少,所以适 合用户交互,提高用户体验
使用-XX:+UseParallelGC开关控制使用Parallel Scavenge+Serial Old收集器组合(jdk6之前)回收垃圾(这也是在Server模式下的默认值);使用-XX:GCTimeRatio来设置用户执行时间占总时间的比例,默认99,即1%的时间用来进行垃圾回收。使用-XX:MaxGCPauseMillis设置GC的最大停顿时间(这个参数只对Parallel Scavenge有效),用开关参数-XX:+UseAdaptiveSizePolicy可以进行动态控制,如自动调整Eden/Survivor比例,老年代对象年龄,新生代大小等,这个参数在ParNew下没有。
-XX:+UseParallelGC
Parallel Scavenge的老生代版本,采用Mark-Compact算法和多线程。
Parallel Old在多核计算中很有用。Parallel Old出现后(JDK 1.6),与Parallel Scavenge配合有很好的效果,充分体现Parallel Scavenge收集器吞吐量优先的效果。使用-XX:+UseParallelOldGC开关控制使用**Parallel Scavenge +Parallel Old(jdk8之后,默认组合)**组合收集器进行收集。
CMS(Concorrect mask sweep)收集器是一种以获取最短停顿时间为目标的收集器;也称为并发低停顿收集器。常用在WEB、B/S架构的服务系统中,因为这类应用很注重响应速度,尽可能减少系统停顿时间,给用户带来较好的体验。从名字上就可以看出来,它是基于“标记-清除”算法实现的,整个过程分为4步:
初始标记
初始标记仅仅标记GC Roots能直接关联到的对象,所以速度很快,需要停止服务(Stop The World)。
并发标记
并发标记是进行GC Roots Tracing的过程,为了标记上一步集合中的存活对象,因为用户程序这段时间也在运行,所以并不能保证可以标记出所有的存活对象。
重新标记
重新标记阶段是为了修正并发标记阶段因用户程序继续运作而导致标记变动的那一部分对象,采用多线程并行来提升效率,会停止服务,时间上远比并发标记短,较初始标记稍长。
并发清除
这个阶段即并发收集垃圾对象,可以与用户线程一起工作。虽然CMS收集器线程可以和用户线程一起进行,但是它肯定会占用CPU资源,拖慢应用程序是肯定的,总的吞吐量会降低。
使用-XX:+UseConcMarkSweepGC进行ParNew+CMS+Serial Old进行内存回收,优先使用ParNew+CMS(原因见后面),当用户线程内存不足时,采用备用方案Serial Old收集。
-XX:+UseConcMarkSweepGC
优点:并发收集低停顿
缺点:并发执行,对CPU资源压力大;采用的标记清除算法会导致大量碎片,所以内存不足时,要进行full gc;
Concurrent Mode Failure”失败
不知道大家在开发过程中有没有遇到过“Concurrent Mode Failure”失败的信息,不管你有没有遇到过,反正我是遇到过!这个异常是什么原因导致的呢。在并发标记和并发清除阶段,用户线程与GC线程并发工作,这会导致在清理的时候又会有用户的线程在拼命的创建对象,本身垃圾回收时候肯定是可用内存不够了,可万一这时候用户线程创建了大量的对象怎么办呢?所以一般CMS收集器的垃圾回收的动作不会在完全无法分配内存的时候进行,可以通过“-XX:CMSInitiatingOccupancyFraction”参数来设置CMS预留的内存空间!如果预留的空间无法满足程序的需要,就会出现 “Concurrent Mode Failure”失败。这时候JVM会启用后备方案,也就是前面介绍过的Serial Old收集器,这样会导致另一次的Full GC的产生,这样的代价是很大的,所以CMSInitiatingOccupancyFraction这个参数设置需要根据程序合理设置!
上面说到过在并发清理阶段,用户线程还在运行,这时候可能就会又有新的垃圾产生,而无法在此次GC过程中被回收,这成为浮动垃圾。
这是目前最新的前沿成果,它基于“标记-压缩”算法,可以进行空间整理,不会产生碎片。前面的垃圾收集器,收集范围都是整个新生代或老年代,但是G1收集器不是这样,使用G1收集器时,java堆的内存模型不同,它还保留有新生代和老年代的概念,它们都是一部分区域(可以不连续)的集合。除此之外,G1收集器还能建立可预测的停顿时间模型,可以明确指定M毫秒时间片内,垃圾收集消耗的时间不超过N毫秒。G1跟踪各个区域(Region)获得其收集价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。G1垃圾回收也分4步:
初始标记
仅标记GC Roots能直接关联到的对象。
并发标记
进行GC Roots Tracing的过程,并不能保证可以标记出所有的存活对象。这个阶段若发现区域对象中的所有对象都是垃圾,那个这个区域会被立即回收。
最终标记
为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录,G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning (SATB)。
筛选回收
首先排序各个Region的回收价值和成本,然后根据用户期望的GC停顿时间来制定回收计划,最后按计划回收一些价值高的Region中垃圾对象,回收时采用"复制"算法,从一个或多个Region复制存活对象到堆上的另一个空的Region,并且在此过程中压缩和释放内存。
详细原理参考这篇文章继续探究:一文理清JVM和GC(下)
与CMS收集器相比G1收集器有以下特点:
(1). 空间整合,G1收集器采用标记整理算法,不会产生内存空间碎片。分配大对象时不会因为无法找到连续空间而提前触发下一次GC。
(2). 可预测停顿,这是G1的另一大优势,降低停顿时间是G1和CMS的共同关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为N毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。
上面提到的垃圾收集器,收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分(可以不连续)Region的集合。
G1特点:
-XX:+UseSerialGC
-XX:+UseParallelGC或者-XX:+UseParallelOldGC
-XX:+UseConcMarkSweepGC
流行的组合
Serial
ParNew + CMS
ParallelYoung + ParallelOld
G1GC
面试题一:触发MinorGC(Young GC)
虚拟机在进行minorGC之前会判断老年代最大的可用连续空间是否大于新生代的所有对象总空间1、如果大于的话,直接执行minorGC
2、如果小于,判断是否开启HandlerPromotionFailure,没有开启直接FullGC
3、如果开启了HanlerPromotionFailure, JVM会判断老年代的最大连续内存空间是否大于历次晋升的大小,如果小于直接执行FullGC
4、如果大于的话,执行minorGC
面试题二:什么时候触发FullGC
(1)调用System.gc时,系统建议执行Full GC,但是不必然执行
(2)老年代空间不足
(3)方法区空间不足
(4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存
(5)由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
回答模板:能说明minor gc/full gc的触发条件、OOM的触发条件,降低GC的调优的策略。
分析:列举一些我期望的回答:eden满了minor gc,升到老年代的对象大于老年代剩余空间full gc,或者小于时被HandlePromotionFailure参数强制full gc;gc与非gc时间耗时超过了GCTimeRatio的限制引发OOM,调优诸如通过NewRatio控制新生代老年代比例,通过MaxTenuringThreshold控制进入老年前生存次数等……能回答道这个阶段就会给我带来比较高的期望了,当然面试的时候正常人都不会记得每个参数的拼写,我自己写这段话的时候也是翻过手册的。回答道这部分的小于2%。
面试题三:什么时候回收方法区,方法区回收哪些?
永久代的垃圾手机主要回收两部分内容:废弃常量和无用的类。 回收废弃常量与回收Java堆中的对象非常相似。以常量池中字面量的回收为例,若字符串“abc”已经进入常量池中,但当前系统没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用该字面量,若发生内存回收,且必要的话,该“abc”就会被系统清理出常量池。常量池中其他的类(接口)、方法、字段的符号引用与此类似。无用的类需要满足3个条件:
(1)该类所有的实例都已经被回收,即Java堆中不存在该类的任何实例; (2)加载该类的ClassLoader已经被回收;
(3)该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。HotSpot虚拟机提供了一些参数可以设置垃圾回收:
“-XX:MaxMetaspaceSize” (JDK8):指定类元数据区的最大内存大小;
“-XX:MetaspaceSize”(JDK8):指定类元数据区的内存阈值------超过将触发垃圾回收;
面试题四:gc堆哪些对象进行回收?
针对的是方法区、堆。
堆的回收:
使用可达性分析算法来判断哪些对象需要进行回收。
可达性算法:选定GC Roots,对于对象到GC Roots没有可达路径的就是可被回收的。
什么对象可被选定为GC Roots:
注意GC Roots是一个集合,从这个集合里的对象去可达性分析,能达到的对象都是不可以被回收的,GC Roots里面的对象都是引用,都是活跃的引用!GC Roots:我们只需要思考当前程序的运行,我们必须保证哪些对象必须存活即可。
以下四类组成的集合(然后做可达性分析(即为图遍历)),正好可以支撑当前程序运行。 a.虚拟机栈当前栈帧引用的对象,即局部变量表的引用。
b.方法区中类静态属性的引用(被static修饰的变量)。 c.方法区中常量的引用(被final修饰的变量)。
d.本地方法栈的栈帧中局部变量表的引用。可达性分析后标记的对象:第一次标记不可达的对象,然后在这群对象里面将第一次执行finalize()方法的对象放入一个队列中,去执行这个方法(可能自救成功),第二次是队列的小规模标记。这时候,还被标记的对象就可以被GC了。
注意:实际开发中finalize()忽视掉,不要去使用它,所以在生产中,第一次被标记上的就是say goodbye的对象。方法区中的回收: 回收常量:当没有一个引用使用这个常量的时候,被回收。(判断方式与收集对象类似)
回收类:回收不是必要的,可用参数来指定是否对无用类进行回收。 什么是无用类?
1.没有该类的实例对象了。
2.加载该类的ClassLoader被回收了
3.该类的Class对象没有被引用,无法通过反射访问该类的方法。
Java基础:JVM垃圾回收算法
Java 垃圾回收机制详解
垃圾回收的常见算法
JVM:这是一份全面 & 详细的 垃圾收集算法(GC) 学习指南
继续探究:一文理清JVM和GC(下)