垃圾收集器与内存分配策略

2020-02-10  本文已影响0人  JBryan

1、对象已死吗

堆里面存放着Java几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中,哪些还“存活”着,哪些已经“死去”。

1.1、引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
主流的Java虚拟机没有引用计数算法来管理内存,最主要原因是它很难解决对象之间相互循环引用的问题。
循环引用示例:

package com.ljessie.jvm;

public class ReferenceCountingGC {

    public Object instance = null;

    public static void testGC(){
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
    }

}
1.2、可达性分析算法

通过一系列的"GC Root"作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到"GC Root"没有任何引用链相连时,则证明此对象是不可用的。如图所示,对象Object5,Object6,Object7虽然互相有关联,但是他们到"GC Root"是不可达的,所以它们将会被判定为是可回收的对象。


可达性分析算法.jpg

可作为GC Roots的对象包括以下几种:
1、虚拟机栈(栈帧中的本地变量表)中引用的对象。
2、方法区中静态属性引用的对象。
3、方法区中常量引用的对象。
4、本地方法栈中JNI(Native方法)引用的对象。

1.3、再谈引用

强引用:指在程序代码中普遍存在的,类似"Object obj = new Object();"这类的引用。只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

软引用:描述一些还有用但非必须的对象。在系统将要发生内存溢出异常时,将会把这些对象列进回收范围之中,进行第二次回收。如果这次回收,还没有足够的内存,才会抛出异常。SoftReference实现软引用。

弱引用:也是用来描述非必须对象。此类对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会被回收掉。WeakReference实现弱引用。

虚引用:一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个引用设置虚引用的唯一目的就是能在这个对象被收集器回收时,收到一个系统通知。PhantomReference实现虚引用。

1.4、生存还是死亡,这是个问题

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候只是处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程:

1、如果对象在进行可达性分析后,没有与GC Root相连接的引用链,那么它将会被第一次标记并且进行一次筛选。筛选条件是此对象是否有必要执行finalize()方法,如果对象没有重写finalize(),或者finalize()已经被调用过,虚拟机都将这两种情况被视为“没有必要执行”。

2、如果这个对象有必要执行finalize(),那么这个对象会被放置到F-Queue的队列之中,并在稍后由一个虚拟机自动建立的优先级比较低的Finalizer线程执行。但是如果对象在finalize()方法中执行缓慢,将很可能导致F-Queue队列中其他对象处于永久等待,甚至导致系统崩溃。finalize()是对象逃脱死亡的最后一次机会,如果要在finalize()中拯救自己,只要把自己(this)赋值给某个类的变量;如果没有拯救自己,稍后GC会对F-Queue中的对象进行第二次标记,就真的被回收了。


对象是否被回收.jpg

示例代码:

package com.ljessie.jvm;

public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;
    public void isAlive(){
        System.out.println("Yes,I am still alive");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        FinalizeEscapeGC.SAVE_HOOK = this;
        System.out.println("finalize method executed!");
    }

    public static void main(String[] args) throws InterruptedException {
        SAVE_HOOK = new FinalizeEscapeGC();
        //对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        //因为finalize方法优先级比较低,所以暂停0.5s等待它
        Thread.sleep(500);
        if(SAVE_HOOK != null){
            SAVE_HOOK.isAlive();
        }else{
            System.out.println("No,I am dead....");
        }

        //下面这段代码与上一段相同,但是这次却自救失败。
        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(500);
        if(SAVE_HOOK != null){
            SAVE_HOOK.isAlive();
        }else{
            System.out.println("No,I am dead....");
        }

    }

}

运行结果:

finalize method executed!
Yes,I am still alive
No,I am dead....

结论:SAVE_HOOK对象的finalize()在第一次触发时,成功脱逃了。但是如果对象面临第二次回收,finalize()方法将不会被再次执行。实际上,finalize()能做的所有事情,try-finally可以做的更好,所以还是忘了finalize()吧。

1.5、回收方法区

方法区(HotSpot中的永久代)的垃圾收集主要回收两部分内容:废弃常量和无用的类。

回收废弃常量:以字面量的回收为例,假如一个字符串"abc"进入了常量池,但是当前对象没有任何一个String引用"abc"。如果这时发生内存回收,而且必要的话,"abc"将会被清理。常量池中的其他类,方法,字段的符号引用也与此类似。

无用的类:判定无用的类需要满足三个条件。
1.该类所有的实例都已经被回收;
2.加载该类的ClassLoader已被回收;
3.该类对应的java.lang.Class对象没有在任何地方被引用。
虚拟机可以对满足上述三个条件的类进行回收,而不是像对象那样,不使用了,就一定会回收。是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc+TraceClassUnLoading查看类加载和卸载信息。
在大量使用反射,动态代理等ByteCode框架和动态生成JSP这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载功能,以保证永久代不会溢出。

2、垃圾回收算法

2.1、标记-清除算法

算法分为两个阶段:首先标记出所有需要收集的对象,然后回收所有被标记的对象。不足有两个:一个时效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后,会产生大量不连续的内存碎片。


标记-清除算法.jpg
2.2、复制算法

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


复制算法.jpg

现在的商业虚拟机都采用这种收集算法来回收新生代。IBM研究表明,新生代中98%的对象是“朝生夕死”的,所以并不需要按照1:1来划分内存空间,而是将内存分为较大的Eden区和两块较小的Survivor区。每次使用Eden和其中一块Survivor区,当回收时,将Eden和Survivo中活着的对象一次性的复制到另一块Survivor中,最后清理掉Eden和用过的Survivor区。

HotSpot虚拟机默认Eden和两块Survivor的大小是8:1:1。也就是每次新生代中可用内存空间为整个新生代容量的90%。当然98%的对象可回收只是一般场景下的数据,没有办法保证每次回收都只有不多于10%的对象存货,当Survivor内存不足时,需要依赖其他内存(老年代)进行分配担保。如果另外一块Survivor空间没有足够存放上一次新生代收集下来的存活对象时,这些对象将直接进入老年代。

2.3、标记-整理算法

与标记-清除算法类似,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。


标记-整理算法.jpg
2.1、分代收集算法

当前虚拟机的垃圾收集都采用“分代收集算法”。把Java堆分为新生代和老年代,在新生代中,每次垃圾收集都有大量对象死去,采用复制算法;而在老年代中,因为对象存活率高,没有额外空间对它进行担保,所以使用“标记-清除”或者“标记整理”算法。

3、垃圾收集器

垃圾收集器.jpg
3.1、Serial收集器

Serial收集器是一个单线程的收集器,在进行垃圾回收时,必须暂停其他所有的工作线程,"Stop The World",直到它收集结束。


Serial/SerialOld.jpg

Serial收集器适用于桌面应用。

3.2、ParNew收集器

ParNew收集器时Serial收集器的多线程版本,ParNew收集器在单CPU环境下,绝对不会有比Serial收集器更好的效果。默认开启与CPU数量相同的线程数。


ParNew/SerialOld.jpg

并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
并发(Concurrent):指用户线程与垃圾收集线程同时执行,用户程序在继续执行,而垃圾收集程序运行于另一个CPU上。

3.3、Parallel Scavenge收集器

Parallel Scavenge收集器是一个新生代收集器,目标是达到一个可控的吞吐量。所谓吞吐量就是CPU运行用户代码的时间与总时间的比值。适合在后台运算而不需要太多交互的任务。
Parallel Scavenge提供了两个参数控制吞吐量:
-XX:MaxGCPauseMillis:控制最大停顿时间
-XX:GCTimeRatio:设置吞吐量,参数的值是大于0小于100的整数,也就是垃圾收集占总时间的比例。比如设置为19,则最大GC时间占总时间的5%(1/1+19)。

3.4、Serial Old收集器

Serial Old是Serial收集器的老年代版本,是一个单线程收集器,使用“标记-整理”算法。主要意义是给Client模式下的虚拟机使用


Serial/Serial Old.jpg
3.5、Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。


ParallelScavenge/ParallelOld .jpg
3.6、CMS收集器

CMS收集器是一种以获取最短回收停顿时间为目标的收集器。适用于重视服务的响应速度,希望系统停顿时间短的,B/S系统的服务端上。

包括四个步骤:
1.初始标记:标记GC Root能直接关联到的对象,速度很快。
2.并发标记:进行GC Root Tracing的过程。
3.重新标记:修正并发标记期间,因用户程序继续运行,而导致标记产生改变的那一部分对象的标记记录,这个阶段的停顿时间会比初始阶段稍长一点,但远比并发标记短。
4.并发清除。
初始标记和重新标记两个步骤仍然需要Stop The Wold。


CMS.jpg

CMS收集器并发收集,低停顿。但是有以下三个缺点:
1.CMS收集器对CPU资源非常敏感,当CPU数量不足4个时,CMS对用户程序的影响就可能变的很大。
2.CMS收集器无法处理浮动垃圾。由于CMS并发清除期间,用户线程还运行着,无法标记新产生的垃圾,也就无法在当前垃圾收集中清除它们,这部分垃圾称为浮动垃圾。
3.基于“标记-清除”算法,会产生大量的空间碎片。

3.7、G1收集器

G1收集器从整体上看是基于“标记-整理”算法的,从局部(两个Region之间)来看,还是基于“复制”算法。但是G1收集器不会象CMS收集器那样,产生不连续的内存空间碎片。G1收集器不再区分新生代和老年代,而是将Java堆划分为若干个大小相等的Region区域。
G1收集器是面向服务端应用的垃圾收集器,与其他垃圾收集器相比,G1具备如下特点:
1.并行与并发:G1能充分利用多CPU和多核环境下的硬件优势。
2.分代收集:分代概念在G1中依然得以保留。
3.可预测的停顿:G1除了追求低停顿以外,还能建立可预测的停顿时间模型。

G1收集器的运作大致可划分为以下几个步骤:
1.初始标记:标记GC Root能直接关联到的对象,耗时很短。
2.并发标记:从GC Root开始对堆中的对象进行可达性分析,找出存活的对象,这阶段耗时较长。
3.最终标记:修正并发标记阶段,用户程序继续运作,而导致标记变动的那部分标记记录。需要停顿线程。
4.筛选回收:首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。也需要停顿用户线程


G1.jpg

4、内存分配与回收策略

4.1、对象优先在Eden分配

大多数情况,对象在新生代Eden区中分配,当Eden区没有足够的空间时,虚拟机将发生一次Minor GC。
新生代GC(Minor GC):指发生在新生代的垃圾收集操作,因为Java对象大多都是朝生夕死,所以Minor GC非常频繁,回收速度也比较快。
老年代GC(Major GC/Full GC):指发生在老年代的垃圾收集操作,出现一次Major GC,经常伴随至少一次Minor GC。Major GC速度一般比Minor GC慢10倍以上。

4.2、大对象直接进入老年代**

所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是很长的字符串以及数组。经常出现大对象意味着,内存还有不少空间时,就提前触发一次垃圾收集,以获取足够的连续空间来“安置”他们。

4.3、长期存活的对象将进入老年代

虚拟机给每个对象定义了一个对象年龄计数器,如果对象在Eden出生并且经过第一次Minor GC仍然存活,并且能被Survivor容纳的话,将会被移到Survivor中,并设置年龄为1。对象在Survivor中每经历一次Minor GC,年龄就+1,加到一定程度(默认15岁),就会被晋升到老年代中。

4.4、动态对象年龄判定

虚拟机并不是永远的要求,对象年龄必须达到了MaxTenuringThreshold才能晋升到老年代中。如果在Survivor中相同年龄的所有对象大小的总和,大于Survivor一半;年龄大于活等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

4.5、空间分配担保

在发生Minor GC之前,虚拟机会先检查,老年代最大可用的连续空间,是否大于新生代所有对象空间。如果条件成立,则Minor GC可以确保是安全的。如果不成立,则虚拟机会查看是否允许担保失败。如果允许,会检查老年代连续空间,是否大于,历次晋升到老年代对象大小的平均值。如果大于,则进行Minor GC。如果小于或者不允许失败,这时要改为进行一次Full GC。


空间分配担保.jpg
上一篇下一篇

猜你喜欢

热点阅读