深入理解Java虚拟机(二):垃圾收集器与内存分配策略

2018-04-28  本文已影响28人  susu2016

参考博客:https://www.cnblogs.com/parryyang/p/5748711.html

参考博客: https://blog.csdn.net/dongyuxu342719/article/details/78835431

概述

1、内存回收的区域主要在堆和方法区,虚拟机栈和本地方法栈的内存分配与回收具有确定性。

在Java内存运行时区域的各个部分中,程序计数器、虚拟机栈、本地方法栈3个部分是线程私有的,随线程而生,随线程而灭;栈中的栈帧随着方法进入和退出而执行着入栈和出栈的操作。每一个栈帧中分配多少内存基本上在类结构确定下来时就已知,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多的考虑回收的问题,因为方法结束或线程结束时,内存自然就随着回收了。

而在Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存可能不一样,我们只有在程序运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存。

2、如何判断哪些对象需要回收?

接下来介绍的引用计数法和可达性分析法,引用计数法具有局限性,不能回收循环引用的对象。

3、如何回收对象?

本章介绍的垃圾收集算法回答了这个问题。现在商用的垃圾回收机制一般使用分代收集算法,年轻代使用复制算法,老年代使用标记整理法。

一、对象已死吗

1、引用计数法

引用计数法是指给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时候计数器值为0就表示不会再被任何对象使用。

客观的说,引用计数法(Reference Counting)的实现简单,判断效率也很高,在大部分情况下都是一个不错的算法。但在主流的Java虚拟机里面没有使用引用计数法来管理内存,主要原因是它很难解决对象之间相互循环引用的问题。

引用计数无法解决下面两个对象相互引用但不可达的问题,但运行代码后发现对象实际上能够被GC。

public class ReferenceCountingGC {  
2.    public Object instance=null;  
3.    private static final int _1MB=1024*1024;  
4.    private byte [] bigSize=new byte[2*_1MB];  
5.      
6.    public static void testGC(){  
7.        ReferenceCountingGC objA=new ReferenceCountingGC();  
8.        ReferenceCountingGC objB=new ReferenceCountingGC();  
9.        objA.instance=objB;  
10.        objB.instance=objA;  
11.        objA=null;  
12.        objB=null;  
13.        System.gc();  
14.    }  
15.    public static void main(String []args){  
16.        testGC();  
17.    }  
18.}  

2、可达性分析算法

在主流的商用程序语言(Java、C#)的主流实现中,都是通过可达性分析(ReachabilityAnalysis)来判断对象是否存活的。这个算法的基本思路是通过一系列称为GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链(就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。

在Java语言中,可作为GC Roots的对象包括下面几种:

image

二、垃圾收集算法

1、标记-清除法

分为“标记”和“清除”两个阶段:首先标记出需要回收的所有对象,在标记完成后统一回收标记的对象,标记过程就是之前讲的通过引用计数法和可达性分析法进行判定。

它的主要不足有两个:

image

2、复制算法

将内存划分为大小相等的两块,每次只使用其中的一块。当这块内存用完了,就将还存活的对象复制到另一块内存上,然后把已使用过的内存空间一次清理掉。

优点:每次只对其中一块进行GC,不用考虑内存碎片的问题,并且实现简单,运行高效

缺点:内存缩小了一半

image

注:现在商用虚拟机都采用这种算法来回收新生代,因为新生代中98%的对象都是朝生夕死的,所以并不需要1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和一块Survivor空间。当回收时,将Eden和Survivor中开存活的对象一次性复制到另一块Survivor空间上,最后清理掉Eden和刚才使用过的Survivor空间。

HotSpot虚拟机默认Eden和Survivor的大小是8:1,也就是每次新生代可用空间是整个新生代容量的90%(80%+10%),只有10%的内存会被“浪费”。当然,98%可回收只是一般的情况的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

内存的分配担保类似于银行贷款,如果我们的信誉好。在98%的情况下都能按时偿还,于是银行可能默认我们下次也能按时偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有风险了。内存的分配担保也一样,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

3、标记-整理法

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费另外50%的空间,就需要额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以老年代一般不能直接选用这种算法。

根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程任然与“标记-清除”算法的标记过程相同,但后续步骤不是对可回收对象进行直接清理,而是让所有活的对象都移动到另一端,然后直接清理掉端边界以外的内存,

image

4、分代收集算法

当前商业虚拟机的垃圾收集都采用“分代收集”(GenerationalCollection)算法,这种算法只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆划分为新生代和老年代,这样就可以根据各个年代的特点采用最适合的收集算法。在新生代中每次都有大量的对象死去,只有少量存活,那就采用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它们进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

5、HotPot 分代收集算法

image

对象将根据存活的时间被分为:年轻代、年老代、永久代。

年轻代:

所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。年轻代分三个区。一个Eden区,两个Survivor区(一般而言)。大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到Survivor区(两个中的一个),当这个Survivor区满时,此区的存活对象将被复制到另外一个Survivor区。同时,根据程序需要,Survivor区是可以配置为多个的(多于两个),这样可以增加对象在年轻代中的存在时间,减少被放到年老代的可能。

年老代:

在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。

持久代:

用于存放静态文件,如Java类、方法等。

Scavenge GC

一般情况下,Eden空间满时,就会触发Scavenge GC,清除Eden区非存活对象,并且把尚且存活的对象移动到Survivor区。然后整理Survivor的两个区。Eden区的GC会频繁进行,速度也很快。

Full GC

对整个堆进行整理,包括Young、Tenured和Perm。Full GC因为需要对整个对进行回收,所以比Scavenge GC要慢,因此应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于FullGC的调节。

三、安全点:Stop the world

1、Stop the world

GC 操作的某些阶段,如可达性分析遍历 GC ROOT 节点找引用链,需要确保在一致性的快照中进行(一致性的意思指分析期间整个执行系统好像冻结在某个时间节点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足分析的准确性就无法保证)。这是导致GC进行时必须停顿所有的执行线程的其中一个重要原因。(Sun 将这件事情称之为 “Stop the world”。)即使是在号称几乎不会发生停顿的CMS收集器中,枚举根节点时也是必须停顿的。

2、OopMap (*选学)

OopMap:在所有执行线程停顿下来后,虚拟机并不需要逐个检查每个栈的局部变量表来查找引用的位置,而应当有办法直接得知哪些地方存着对象的应用。在执行到某条指令时,栈中什么位置存放了什么变量是确定的,在编译期间虚拟机就可以计算出这些信息。在 HotSpot 的实现中,用一组称为 OopMap 的数据结构记录了某一些执行节点哪些位置是引用,这样,在GC扫描时就可以直接获取这些信息了。

3、SafePoint (*选学)

在 OopMap 的协助下,HotSpot 可以快速且准确地完成 GC Roots 枚举。但是随着程序的运行,引用关系会随之变化,虚拟机不可能为每一个执行节点都生成 OopMap 来记录引用关系,那将需要大量额外的空间,GC的成本将会变得很高。实际上,HotSpot虚拟机也的确没有为每一条指令都生成OopMap,只是在特定位置记录这些信息,这些位置称为“安全点”(Safepoint),即程序执行时并非在所有地方都停顿下来开始GC,只有在到达安全点时才能暂停。

Safepoint既不能选的太少导致GC等待太长时间,也不能过于频繁以至于过分增大运行时的负荷。安全点一般选取在方法调用、循环跳转、异常跳转等让程序长时间执行的指令。

对于Safepoint,另一个需要考虑的问题是如何在GC发生时让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来。主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单的设置一个标志,各线程执行时主动去轮询这个标志,当发现中断标志为真时就自己中断挂起。轮询标志的位置和安全点是重合的,另外再加上创建对象需要分配内存的地方。

4、SafeRegion (*选学)

Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是程序不执行的时候呢?不执行就是程序没有分配到CPU时间,典型的例子就是线程处于Sleep状态或Blocked状态,这时候线程无法响应JVM的中断请求,“走”到安全的地方再挂起。JVM显然也不会等待线程重新分配CPU时间。对于这种情况就需要安全区域(Safe Region)来解决。

安全区域是指在一段代码片段中引用关系不会发生变化,在这个区域的任意地方开始GC都是安全的。我们也把Safe Region称为扩展的Safepoint。

在线程执行到Safe Region中的代码的时候,首先标识自己进入了Safe Region,这样,当在这段时间里JVM要发起GC时,就不用考虑标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举()或者整个GC过程如果完成了,那线程就继续执行,否则它就必须等待收到可以安全离开Safe Region的信号为止。

四、垃圾收集器

参考博客:https://blog.csdn.net/tjiyu/article/details/53983650

垃圾收集器是垃圾回收算法(标记-清除算法、复制算法、标记-整理算法)的具体实现,不同商家、不同版本的JVM所提供的垃圾收集器可能会有很在差别,下面主要介绍HotSpot虚拟机中的垃圾收集器。

本节介绍这些收集器的特性、基本原理和使用场景。没有最好的收集器,更没有万能的收集,选择的只能是适合具体应用场景的收集器。

1、垃圾收集器组合

JDK7/8后,HotSpot虚拟机所有收集器及组合(连线),如下图:

image

​ Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1;

​ Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;

2、 Minor GC 和 Full GC

​ 又称新生代GC,指发生在新生代的垃圾收集动作;

​ 因为Java对象大多是朝生夕灭,所以Minor GC非常频繁,一般回收速度也比较快;

​ 又称Major GC或老年代GC,指发生在老年代的GC;

​ 出现Full GC经常会伴随至少一次的Minor GC(不是绝对,Parallel Sacvenge收集器就可以选择设置Major GC策略);

​ Major GC速度一般比Minor GC慢10倍以上;

3、Serial 收集器(*选学)

image

4、ParNew收集器(*选学)

5、Parallel Scavenge收集器(*选学)

6、Serial Old收集器(*选学)

7、Parallel Old收集器(*选学)

8、CMS收集器(**选学)

9、G1收集器(**选学)

五、内存分配与回收策略

上一篇下一篇

猜你喜欢

热点阅读