年轻代和老年代分别适合什么样的垃圾回收算法

2021/9/27 22:10:59

本文主要是介绍年轻代和老年代分别适合什么样的垃圾回收算法,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

年轻代

1. 复制算法的背景引入

  针对新生代的垃圾回收算法,他叫做复制算法

  简单来说,就是如下图所示,首先把新生代的内存分为两块。

 


 

  接着假设有如下代码,在“loadReplicasFromDisk()”方法中创建了对象,此时对象就就会分配在新生代其中一块内存空间里。而且是由“main线程”的栈内存中的“loadReplicasFromDisk()”方法的栈帧内的局部变量来引用的,如下图所示。

 


 
 

  接着大家想象一下,假设与此同时,代码在不停的运行,然后大量的对象都分配在了新生代内存的其中一块内存区域里,也只会分配在那块区域里,而且分配过后,很快就失去了局部变量或者类静态变量的引用,成为了垃圾对象

此时如下图所示。

 


 

接着这个时候,新生代内存那块被分配对象的内存区域基本都快满了,再次要分配对象的时候,发现里面内存空间不足了。

那么此时就会触发Minor GC去回收掉新生代那块被使用的内存空间的垃圾对象。

那么回收的时候是怎么做的呢?

2. 一种不太好的垃圾回收思路

假设现在采用的垃圾回收思路,就是直接对上图中被使用的那块内存区域中的垃圾对象进行标记

也就是根据上篇文章讲的那套思路,标记出哪些对象是可以被垃圾回收的,然后就直接对那块内存区域中的对象进行垃圾回收,把内存空出来。大家想想,这种思路好吗?

这种思路去垃圾回收,可能会在回收完毕之后造成那块内存区域看起来跟下图一样。

 


 

看上面的图,不知道大家发现什么没有,在那块被使用的内存区域里,回收掉了大量的垃圾对象,但是保留了一些被

人引用的存活对象

但是呢,存活对象在内存区域里东一个西一个,非常的凌乱,而且造成了大量的内存碎片。

那么什么是内存碎片呢?

我们再看下面的图我用红线标记出来的区域,那些就是所谓的内存碎片。

 


 

看到了吗?在各种凌乱的存活对象的中间,出现了大量的红圈圈出来的内存碎片

这些内存碎片的大小不一样,有的可能很大,有的可能很小。

那么内存碎片太多会造成什么问题呢?内存浪费

啥意思?比如现在打算分配一个新的对象,尝试在上图那块被使用的内存区域里去分配

此时如下图所示,可能因为内存碎片太多的缘故,虽然所有的内存碎片加起来其实有很大的一块内存,但是因为这些

 


 

内存都是碎片式分散的,所以导致没有一块完整的足够的内存空间来分配新的对象。

所以这种直接对一块内存空间回收掉垃圾对象,保留存活对象的方法,绝对是不可取的

因为内存碎片太多,就是他最大的问题,会造成大量的内存浪费,很多内存碎片压根儿是没法使用的。

3.一个合理的垃圾回收思路

那么能不能用一种合理的思路来进行垃圾回收呢?

可以!这个时候上图中一直没派上用场的另外一块空白的内存区域就出场了。

首先,并不是按照上述思路直接对已经使用的那块内存把垃圾对象全部回收掉,然后保留全部存活对象。

而是先对那块在使用的内存空间标记出里面哪些对象是不能进行垃圾回收的,就是要存活的对象

然后先把那些存活的对象转移到另外一块空白的内存中,如下图。不知道大家发现这里的玄机没有?

 


 

没错,通过把存活对象先转移到另外一块空白内存区域,我们可以把这些对象都比较紧凑的排列在内存里

这样就可以让被转移的那块内存区域几乎没有什么内存碎片,对象都是按顺序排列在这块内存里的。

然后那块被转移的内存区域,是不是多出来一大块连续的可用的内存空间?

此时就可以将新对象分配在那块连续内存空间里了,如下图。

 


 

这个时候,再一次性把原来使用的那块内存区域中的垃圾对象全部一扫而空,全部给回收掉,空出来一块内存区域,

如下图。这就是所谓的“复制算法“,把新生代内存划分为两块内存区域,然后只使用其中一块内存

 


 

待那块内存快满的时候,就把里面的存活对象一次性转移到另外一块内存区域,保证没有内存碎片

接着一次性回收原来那块内存区域的垃圾对象,再次空出来一块内存区域。两块内存区域就这么重复着循环使用。

4.复制算法有什么缺点?

复制算法的缺点其实非常的明显,如果按照上述的思路,大家会发现,假设我们给新生代1G的内存空间,那么只有512MB的内存空间是可以用的,另外512MB的内存空间是一直要放在那里空着的,然后512MB内存空间满了,就把存活对象转移到另外一块512MB的内存空间去

从始至终,就只有一半的内存可以用,这样的算法显然对内存的使用效率太低了。

5.复制算法的优化:Eden区和Survivor区

之前我给大家分析过,系统运行时,对JVM内存的使用模型,大体上就是我们的代码不停的创建对象然后分配在新生代里,但是一般很快那个对象就没人引用了,成了垃圾对象。

接着一段时间过后,新生代就满了,此时就会回收掉那些垃圾对象,空出来内存空间,给后续其他的对象来使用。

但是我们之前分析过,其实绝大多数的对象都是存活周期非常短的对象,可能被创建出来1毫秒之后就没人引用了,他就是垃圾对象了。

所以大家可以想象一下,可能一次新生代垃圾回收过后,99%的对象其实都被垃圾回收了,就1%的对象存活了下来,可能就是一些长期存活的对象,或者还没使用完的对象。所以实际上真正的复制算法会做出如下优化,把新生代内存区域划分为三块:

1个Eden区,2个Survivor区,其中Eden区占80%内存空间,每一块Survivor区各占10%内存空间,比如说Eden区有800MB内存,每一块Survivor区就100MB内存,如下图。

 


 

平时可以使用的,就是Eden区和其中一块Survivor区,那么相当于就是有900MB的内存是可以使用的,如下图所示。

 


 

但是刚开始对象都是分配在Eden区内的,如果Eden区快满了,此时就会触发垃圾回收

此时就会把Eden区中的存活对象都一次性转移到一块空着的Survivor区。接着Eden区就会被清空,然后再次分配新对象到Eden区里,然后就会如上图所示,Eden区和一块Survivor区里是有对象的,其中Survivor区里放的是上一次Minor GC后存活的对象。

如果下次再次Eden区满,那么再次触发Minor GC,就会把Eden区和放着上一次Minor GC后存活对象的Survivor区内的存活对象,转移到另外一块Survivor区去。

所以这里大家就能体会到,为啥是这么分配内存空间了。因为之前分析了,每次垃圾回收可能存活下来的对象就1%,所以在设计的时候就留了一块100MB的内存空间来存放垃圾回收后转移过来的存活对象

比如Eden区+一块Survivor区有900MB的内存空间都占满了,但是垃圾回收之后,可能就10MB的对象是存活的。

此时就把那10MB的存活对象转移到另外一块Survivor区域就可以,然后再一次性把Eden区和之前使用的Survivor区里的垃圾对象全部回收掉,如下图。

 


 

接着新对象继续分配在Eden区和另外那块开始被使用的Survivor区,然后始终保持一块Survivor区是空着的,就这样一直循环使用这三块内存区域。

这么做最大的好处,就是只有10%的内存空间是被闲置的,90%的内存都被使用上了

无论是垃圾回收的性能,内存碎片的控制,还是说内存使用的效率,都非常的好。

老年代

1.新生代里的对象一般在什么场景下会进入老年代

首先我们来看下面的图,我们写好的代码在运行的过程中,就会不断的创建各种各样的对象,这些对象都会优先放到新生代的Eden区和Survivor1区。

 


 

接着假如新生代的Eden区和Survivor1区都快满了,此时就会触发Minor GC,把存活对象转移到Survivor2区去

 


 

如下图所示然后接着就会使用Eden区和Survivor2区,来分配新的对象,如下图所示。

 


 

这个过程上文已经讲的非常的清楚了。那么我们就来依次看看各种情况下,对象是如何进入老年代的,以及老年代的垃圾回收算法是什么样的?

2.躲过15次GC之后进入老年代

按照上面的图示的那个过程,其实大家可以理解为我们写的系统刚启动的时候,创建的各种各样的对象,都是分配在新生代里的。

然后慢慢系统跑着跑着,新生代就满了,此时就会触发Minor GC,可能就1%的少量存活对象转移到空着的Survivor区中。

然后系统继续运行,继续在Eden区里分配各种对象,大概就是这个过程。

那么之前给大家讲过,我们写的系统中有些对象是长期存在的对象,他是不会轻易的被回收掉的,比如下面的代码。

 


 

只要这个“Kafka”类还存在,那么他的静态变量“replicaManager”就会长期引用“ReplicaManager”对象,所以你无论新生代怎么垃圾回收,类似这种对象都不会被回收掉的。

此时这类对象每次在新生代里躲过一次GC被转移到一块Survivor区域中,此时他的年龄就会增长一岁

默认的设置下,当对象的年龄达到15岁的时候,也就是躲过15次GC的时候,他就会转移到老年代里去。

这个具体是多少岁进入老年代,可以通过JVM参数“-XX:MaxTenuringThreshold”来设置,默认是15岁,大家看下图。

 


 

3.动态对象年龄判断

这里跟这个对象年龄有另外一个规则可以让对象进入老年代,不用等待15次GC过后才可以。

他的大致规则就是,假如说当前放对象的Survivor区域里,一批对象的总大小大于了这块Survivor区域的内存大小的50%,那么此时大于等于这批对象年龄的对象,就可以直接进入老年代了。

说着有点抽象,具体还是看图。

 


 

假设这个图里的Survivor2区有两个对象,这俩对象的年龄一样,都是2岁然后俩对象加起来对象超过了50MB,超过了Survivor2区的100MB内存大小的一半了,这个时候,Survivor2区里的大于等于2岁的对象,就要全部进入老年代里去。

这就是所谓的动态年龄判断的规则,这条规则也会让一些新生代的对象进入老年代。

另外这里要理清楚一个概念,就是实际这个规则运行的时候是如下的逻辑:年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n以上的对象都放入老年代。

其实说白了,无论是15岁的那个规则,还是动态年龄判断的规则,都是希望那些可能是长期存活的对象,尽早进入老年代

既然你是长期存活的,那么老年代才是属于你的地盘,别赖在新生代里占地方了。

4.大对象直接进入老年代

有一个JVM参数,就是“-XX:PretenureSizeThreshold”,可以把他的值设置为字节数,比如“1048576”字节,就是1MB。

他的意思就是,如果你要创建一个大于这个大小的对象,比如一个超大的数组,或者是别的啥东西,此时就直接把这个大对象放到老年代里去。压根儿不会经过新生代。

之所以这么做,就是要避免新生代里出现那种大对象,然后屡次躲过GC,还得把他在两个Survivor区域里来回复制多次之后才能进入老年代,那么大的一个对象在内存里来回复制,不是很耗费时间吗?

所以说,这也是一个对象进入老年代的规则。

5.Minor GC后的对象太多无法放入Survivor区怎么办?

现在有一个比较大的问题,就是如果在Minor GC之后发现剩余的存活对象太多了,没办法放入另外一块Survivor区怎么办?如下图。

 


 

比如上面这个图,假设在发生GC的时候,发现Eden区里超过150MB的存活对象,此时没办法放入Survivor区中,此时该怎么办呢?

这个时候就必须得把这些对象直接转移到老年代去,如下图所示。

 


 

6.老年代空间分配担保规则

这个时候大家又想提一个问题了,如果新生代里有大量对象存活下来,确实是自己的Survivor区放不下了,必须转移到老年代去,那么如果老年代里空间也不够放这些对象呢?这该咋整呢?

别急,一步一图,跟着下面的图来看。

首先,在执行任何一次Minor GC之前,JVM会先检查一下老年代可用的可用内存空间,是否大于新生代所有对象的总大小。

为啥检查这个呢?因为最极端的情况下,可能新生代Minor GC过后,所有对象都存活下来了,那岂不是新生代所有对象全部要进入老年代?如下图。

 


 

如果说发现老年代的内存大小是大于新生代所有对象的,此时就可以放心大胆的对新生代发起一次Minor GC了,因为即使Minor GC之后所有对象都存活,Survivor区放不下了,也可以转移到老年代去。

但是假如执行Minor GC之前,发现老年代的可用内存已经小于了新生代的全部对象大小了

那么这个时候是不是有可能在Minor GC之后新生代的对象全部存活下来,然后全部需要转移到老年代去,但是老年代空间又不够?

理论上,是有这种可能的。

所以假如Minor GC之前,发现老年代的可用内存已经小于了新生代的全部对象大小了,就会看一个“-XX:-

HandlePromotionFailure”的参数是否设置了

如果有这个参数,那么就会继续尝试进行下一步判断。

下一步判断,就是看看老年代的内存大小,是否大于之前每一次Minor GC后进入老年代的对象的平均大小。

举个例子,之前每次Minor GC后,平均都有10MB左右的对象会进入老年代,那么此时老年代可用内存大于10MB。

这就说明,很可能这次Minor GC过后也是差不多10MB左右的对象会进入老年代,此时老年代空间是够的,看下图。

 


 

如果上面那个步骤判断失败了,或者是“-XX:-HandlePromotionFailure”参数没设置,此时就会直接触发一次“Full

GC”,就是对老年代进行垃圾回收,尽量腾出来一些内存空间,然后再执行Minor GC。

如果上面两个步骤都判断成功了,那么就是说可以冒点风险尝试一下Minor GC。此时进行Minor GC有几种可能。

第一种可能,Minor GC过后,剩余的存活对象的大小,是小于Survivor区的大小的,那么此时存活对象进入Survivor区域即可。

第二种可能,Minor GC过后,剩余的存活对象的大小,是大于 Survivor区域的大小,但是是小于老年代可用内存大小的,此时就直接进入老年代即可。

第三种可能,很不幸,Minor GC过后,剩余的存活对象的大小,大于了Survivor区域的大小,也大于了老年代可用内存的大小。此时老年代都放不下这些存活对象了,就会发生“Handle Promotion Failure”的情况,这个时候就会触发一次“Full GC”。

Full GC就是对老年代进行垃圾回收,同时也一般会对新生代进行垃圾回收。

因为这个时候必须得把老年代里的没人引用的对象给回收掉,然后才可能让Minor GC过后剩余的存活对象进入老年代里面。

如果要是Full GC过后,老年代还是没有足够的空间存放Minor GC过后的剩余存活对象,那么此时就会导致所谓的“OOM”内存溢出了

因为内存实在是不够了,你还是要不停的往里面放对象,当然就崩溃了。

这段规则有点烧脑,但是我觉得如果大家仔细对这段文字多看两遍,然后结合我们的图,脑子里想一想,基本都能看懂这个规则。

7.老年代垃圾回收算法

其实把上面的内容都看懂之后,大家现在基本就知道了Minor GC的触发时机,然后就是Minor GC之前要对老年代空间大小做的检查,包括检查失败的时候要提前触发Full GC给老年代腾一些空间出来,或者是Minor GC过后剩余对象太多放入老年代内存都不够,也要触发Full GC。包括这套规则,还有触发老年代垃圾回收的Full GC时机,都给大家讲清楚了。

简单来说,一句话总结,对老年代触发垃圾回收的时机,一般就是两个:

要不然是在Minor GC之前,一通检查发现很可能Minor GC之后要进入老年代的对象太多了,老年代放不下,此时需要提前触发Full GC然后再带着进行Minor GC;

要不然是在Minor GC之后,发现剩余对象太多放入老年代都放不下了。

那么对老年代进行垃圾回收采用的是什么算法呢?

简单来说,老年代采取的是标记整理算法,这个过程说起来比较简单

大家看下图,首先标记出来老年代当前存活的对象,这些对象可能是东一个西一个的。

 


 

接着会让这些存活对象在内存里进行移动,把存活对象尽量都挪动到一边去,让存活对象紧凑的靠在一起,避免垃圾回收过后出现过多的内存碎片然后再一次性把垃圾对象都回收掉,大家看下图。

 


 

大家一定要注意一点,这个老年代的垃圾回收算法的速度至少比新生代的垃圾回收算法的速度慢10倍。

如果系统频繁出现老年代的Full GC垃圾回收,会导致系统性能被严重影响,出现频繁卡顿的情况。



这篇关于年轻代和老年代分别适合什么样的垃圾回收算法的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程