垃圾回收机制与内存分配策略

上篇文章提到,程序计数器,虚拟机栈,本地方法栈这三个区域生命周期与线程相同;因此这三个区域的内存分配以及垃圾回收都是确定的,方法结束或者线程结束时,内存自然就跟着回收了。

但是有方法区和Java堆内存的分配和回收都是动态的。

首先,在进行回收的时候,先想清楚,该不该回收;然后就是怎么回收;再就是,用什么回收。

1.如何判断对象已死

堆上存放的几乎都是对象实例,再垃圾回收之前,就是要确定对象是"活"还是"死"。

1.1 引用计数算法

算法思想:给对象中添加一个引用计数器;每当一个地方引用它时,计数器就加1;当引用失效时,计数器就减1;任何时刻计数器为0的对象就是不可能再被使用的。

优点:实现简单,判定效率高,大部分场景都适用;

缺点:无法解决循环引用的问题。

public class Test {
    public Object instance = null;
    private static final int _1MB=1024*1024;
    private byte[] bigSize = new byte[2*_1MB];
    public static void testGC(){
        Test test1=new Test();
        Test test2 = new Test();
        test1.instance=test2;
        test2.instance=test1;
        test1=null;
        test2=null;
        System.gc();
    }
    public static void main(String[] args) {
        testGC();
    }
}

打印的结果:

[GC (System.gc())  6019K->752K(60928K), 0.0240924 secs]

按照引用计数算法逻辑来说,test1.instance与test2.instance的计数器都没有为0,按照引用计数算法来说,这两个对象还活着。

但是从结果可以看出,两个对象还是被回收了,就说明虚拟机并不是通过引用计数法来判断对象是否存活。

1.2可达性分析算法

算法思路:通过一系列的”GC Roots“的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路经称为引用链,当一个对想到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

可作为GC Roots的对象:

1.虚拟机栈中引用的对象

2.方法区中类静态属性引用对象

3.方法区中常量引用的对象

4.本地方法栈中JNI引用的对象。

1.3再谈引用

JDK1.2以前,引用的定义:如果引用数据类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。

期望:在内存空间足够的时候,则可以保存在内存中;如果进行垃圾回收之后还是很紧张,则可以抛弃这些对象。

因此,就有了4种引用的划分:

1.强引用:类似于:Object  obj = new  Object();只要引用还存在,垃圾回收器就不会回收被引用的对象得实例。

2.软引用:描述还有用,但是非必需的的对象,在系统即将发生内存泄露的时候,将会把软引用列为进行回收范围之中进行二次回收,如果这次回收还是没有足够的空间,才会抛出内存溢出异常。JDK1.2提供了SoftReference类来实现软引用。

3.弱引用:弱引用来描述非必需对象,它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,都会回收只被弱引用关联的对象。JDK1.2提供了WeakReference类来实现弱引用。

4.虚引用也称为幽灵引用或者幻影引用,它是最弱的。一个对象是否有虚引用,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。设置虚引用的目的就是能在这个对象被收集器回收时收到一个系统消息。JDK1.2提供了PhantomReference类来实现虚引用。

在可达性算法中不可达对象,也不是”非死不可“,此时还处在”缓刑阶段“。

要宣告对象真的死亡,需要经过两个阶段的标记

如果对象在进行可达性分析的时候发现没有与GC Roots相连的引用链,那么它就会被进行第一次标记并且进行一次筛选,筛选条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法或者finalize()方法已经被JVM调用过了,虚拟机会将这种情况都视为”没有必要执行“,此时对象才是真正的”死“的对象。

如果这个对象被判定有必要执行finalize()方法,那么这个对象会被放置在一个叫做F-Queue的队列中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它。finalize()方法是对想逃脱死亡的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模标记,如果对象在finalize()中成功拯救自己(重新与引用链上的对象建立起关联关系),那么第二次标记时它将会被移出”即将回收“集合;如果这个时候对象没有自救,那就会被回收。

public class Test {

    public static Test test;
    public void isAlive(){
        System.out.println("I am alive :");
    }
    protected  void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed");
        test = this;
    }
    public static void main(String[] args) throws InterruptedException {

        test = new Test();
        test =null;
        System.gc();
        Thread.sleep(500);
        if (test!=null){
            test.isAlive();
        }else {
            System.out.println("no ,i am dead");
        }
        test =null;
        System.gc();
        Thread.sleep(500);
        if (test!=null){
            test.isAlive();
        }else {
            System.out.println("no ,i am dead");
        }
    }
}

运行结果:

finalize method executed

I am alive :

no ,i am dead 

上面代码演示了两点:

1.对象在GC时进行了自救。

2.这种自救机会只有一次,一个对象的finalize()方法只会被系统自动调用一次。 

2.方法区回收

方法区的垃圾回收主要收集两部分内容:废弃常量和无用类

假如一个字符串”abc“已经进入了常量池中,但是当前系统没有任何一个String对象引用常量池的”abc“常量,也没有其它地方引用这个字面量,如果此时发生GC并且有必要的话,这个”abc“常量会被清理出常量池。常量池中的其它类(接口)、方法、字段的符号引用也与此类似。

判断一个类是否是”无用类“需要满足:

1.该类所有的实例都已经被回收(在Java堆中不存在该类的任何实例)

2.加载该类的ClassLoader(类加载器)已经被回收

3.该类对应的Class对象没有在任何其它地方被引用,无法在任何地方通过反射访问该类的方法。

3.垃圾收集算法

3.1标记-清除算法

算法:首先标记出所有需要回收的对象,在标记完成之后统一进行回收标记的对象。

缺点:

1.效率问题:标记和清除的过程效率都不高

2.空间问题:标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致程序运行中需要分配较大对象时,无法找到足够连续内存而不得不提前出发另一次垃圾回收。

3.2复制算法

算法:将可用内存按容量划分为大小相等的两块,每次使用其中的一块。当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。

现在的商业虚拟机都采用这种收集算法来回收新生代。

但是新生代中的98%的对象都是”朝生夕死“,所以并不需要按照1:1的比例划分内存空间,而是将内存分为一块较大的Eden区和两块Survivor区。当回收时,将Eden和Survivor中还存活的对象一次性复制到另一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。部分对象在两块Survivor区域来回复制交换15次,最终如果还存活,就存入老年代

当Survivor空间不够用时,需要依赖其他内存进行分配担保。

默认Eden与Survivor大大小比例是8:1。

3.3标记整理算法

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

3.4分代收集算法

将Java堆分为新生代和老年代。在新生代中,每次回收都有大批对象死去,只有少量存活,因此采用复制算法;

老年代中对象存活率较高、没有额外空间对他进行分配担保,采用”标记-整理“或者”标记-清理“算法。

Minor GC 和 Full GC的区别:

1.Minor GC又称为新生代垃圾回收,因为新生代的对象总是“朝生夕死”,所以Minor GC(采用复制算法)的频率较快,而且回收速度比较快。

2.Full GC又称为老年代垃圾回收,Full GC总会伴随至少一次Minor GC(并非绝对,在Parallel Scavenge收集器中就有直接收集Full GC的策略),Full GC比Minor GC 慢至少10倍以上。

4.垃圾收集器

 上述的收集器表示了不同分代的收集器,所处的区域,表示他是属于哪个区域的收集器,如果两个收集器之间存在连线,就说明它们之间可以搭配使用。

首先看看三个概念:

1.并行(Parallel):指多条垃圾收集器线程并行工作,用户线程仍处于等待状态。

2.并发(Concurrent):指用户线程与垃圾收集器线程同时执行(不一定并行,可能会交替执行),用户线程将继续运行,而垃圾收集程序在另外一个CPU上。

3.吞吐量:就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即 吞吐量 = 运行用户代码时间/(运行用户代码时间+垃圾收集时间)。虚拟机总共运行了100分钟,其中垃圾收集花掉了1分钟,那么吞吐量就是99%。

4.1 Serial收集器(新生代收集器,串行GC)

这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集器线程去完成垃圾收集工作。

当它进行垃圾收集时,必须暂停其他所有线程,直到它收集结束。

优势:简单而高效,对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然而然可以获得最高的单线程收集效率。

4.2 ParNew收集器(新生代收集器,并行GC)

ParNew收集器其实就是Serial收集器的多线程版本。

ParNew收集器在单CPU的环境中绝对不会有比Serial收集器更好的效果,甚至由于村在线程交互开销,该收集器在通过超线程技术实现的两个CPU的环境中也不一定绝对会超越Serial收集器。

4.3Parallel Scavenge收集器(新生代收集器,并行GC)

Parallel 收集器也是使用复制算法的收集器。

Parallel Scavenge 收集器的目标是达到一个可控的吞吐量。

因此有两个参数来控制吞吐量:

XX:MaxGCPauseMillis控制最大的垃圾收集停顿时间

XX:GCRatio 直接设置吞吐量的大小

GC自适应的调节策略:Parallel Scavenge 收集器还有一个参数 -XX:+UseAdaptiveSizePolicy.当这个参数打开之后,就不需要手工指定新生代大小、Eden与Survivor区的比例、晋升老年代的对象年龄等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最适合的停顿时间或者最大吞吐量。

4.4 Serial Old收集器(老年代收集器,串行GC)

单线程收集器,使用标记-整理算法

同样会暂停线程。

4.5Parallel Old收集器(老年代收集器,并行GC)

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

在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。

4.6 CMS收集器(老年代收集器,并发GC)

是一种以获取最短回收停顿时间为目标的收集器。

基于标记-清除算法实现的。

整个运作过程分为4个步骤:

1.初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“Stop The World”;

2.并发标记:进行 GC Roots Tracing的过程;

3.重新标记:为了修正并发标记期间用户程序继续运作而导致标记变动的那一部分对象标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长,但远比并发标记时间短,仍需要“Stop The World”;

4.并发清除:清除对象。

由于整个过程中的耗时最长的“并发标记”和“并发清除”阶段都是与用户线程一起工作的,所以,从整体上来说,CMS收集器的内存回收过程与用户线程一起并发执行的。

缺点:

1.CMS收集器对CPU资源非常敏感,在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。

CMS 默认启动线程个数为(CPU数量+3)/4。

2.CMS收集器无法处理浮动垃圾,可能会导致“Concurrent Mode  Failure”失败而导致另一次Full GC的产生。

由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉他们,只好留着下次清理,这部分垃圾就叫“浮动垃圾”。

由于在垃圾收集阶段用户线程还需要运行,那就还需要预留有足够的空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机就会启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾,这样停顿时间就长了。

3.CMS收集结束时会有大量空间碎片产生,空间碎片过多时,将会给大对象分配带来很大的麻烦。

4.7 G1收集器(唯一一个全区域的垃圾回收器)

G1垃圾回收器在对空间很大的情况下,把Heap划分为很多很多region块,然后并行的对其进行垃圾回收。

一个region可能属于Eden,Survivor或者Tenured内存区域。G1垃圾收集器还增加了一个新的内存区域,叫做Humongous内存区域,这种内存区域主要用于存储大对象-即大小超过一个region大小的50%的对象。

在G1垃圾回收器中,年轻代的垃圾回收过程使用复制算法。把Eden和Survivor区的对象复制到新的Survivor区域。

老年代分为四个阶段:

1.初始标记:同CMS垃圾回收器的标记阶段一样,G1也需要暂停线程,从跟对象出发,标记所有可达对象。并且,G1的标记阶段是同minor GC一同发生的,也就是说,G1触发minor GC的时候一并将老年代上的标记给做了。

2.并发标记:G1跟CMS一样。同时,G1还会发现哪些Tenured region中对象的存活率很小或者基本没有对象存活,那么G1就会在这个阶段将其回收掉。G1会计算每个region的对象存活率,方便后面的回收。

3.最终标记:G1跟CMS做的事一样,G1采用SATB算法,可以更快的标记可达对象。

4.筛选回收:挑选出存活率低的region进行回收,这个阶段也是和minor gc一同发生的。

5.GC日志

[GC [DefNew: 3324K->152K(3712K), 0.0025925 secs] 3324K->152K(11904K), 0.0031680 secs]    

[Full GC [Tenured: 0K->210K(10240K), 0.0149142 secs] 4603K->210K(19456K), [Perm : 2999K>2999K(21248K)], 0.0150007 secs] [Times: user=0.01 sys=0.00, real=0.02 secs] 

GC日志开头的“[GC”和"[Full GC"表示垃圾收集的停顿类型,“[Full GC”表示此次GC发生了Stop-The-World的。

例如:

[Full GC 283.736: [ParNew: 261599K->261599K(261952K), 0.0000288 secs]

新生代收集器ParNew的日志也会出现“[Full GC”(这一般是因为出现了分配担保失败之类的问题)。

接下来是"[DefNew"、"[Tenured"、"[Perm"表示GC发生的区域。

DefNew-Default New Generation(Serial收集器)

ParNew-Parallel New Generation(ParNew收集器)

PSYoungGen-Parallel Scavenge Young Generation(Paralle Scavenge收集器)

Tennured-老年代(Serial Old收集器)

ParOldGen-老年代(Parallel Old收集器)

Perm-永久代

接着后面跟着的数字:“GC前该内存区域已使用容量->GC后该内存区域已使用容量(该内存总容量)”

紧接着中括号后面的:“GC前Java堆已使用容量->GC 后Java堆使用的容量(Java堆的总容量)”

再往后表示该内存区域GC所占用的时间,单位是秒。

6.内存分配与回收策略

JVM如何给对象分配内存?

6.1 对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够的空间进行分配时,虚拟机将发生一次Minor  GC

从输出的结果也可以清晰地看出,Eden区大小为8192k;

from和to区都为1024k,老年代为10240k;

新生代总可用空间大小为 9216(一个Eden区加一个Survivor区);

执行allocation4的时候发生了一次GC,这次GC前新生代用了8154k,GC后新生代用了625k。

但是总空间容量几乎没变,这是因为,几乎没有可回收对象;这次GC发生的原因是,当给allocation4分配空间的时候,发现Eden区已经用了6MB,没有容量可以给allocation4分配空间了。所以,发生了GC,但是发现3个2MB大小的对象全部都无法放入到Survivor,所以就将3个allocation分配到老年代去。

所以GC结束之后,4MB的allocation4就在Eden区,老年代占用了6MB。

6.2 大对象直接进入老年代

所谓的大对象,就是需要大量连续内存空间的Java对象,比如数组和很长的字符串。

-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。

我们可以看到,老年代的10MB空间被使用了40%,也就是4MB的allocation对象就直接分配在老年代中。

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

虚拟机采用了分代收集的思想来管理内存,那么内存收时就必须区分哪些对象应该在新生代,哪个对象应该放在老年代中。

为了区分,虚拟机给每个对象定义了一个对象年龄计数器。

如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,就会被移动到Survivor空间中,并且对象年龄设为1.

当它的年龄增加到一定程度(默认为15岁),将会被晋升到老年代中。

对象晋升老年代的年龄阈值,可以通过参数:-XX:MaxTenuringThreshold设置。

-XX:MaxTenuringThreshold=1和-XX:MaxTenuringThreshold=15两种设置。

此方法中的allocation1只需要256KB内存,第一次GC的时候会放在Survivor区,allocation1对象在第二次GC的时候,会进入老年代,所以新生代GC后的容量为0K;

当MaxTenuringThreshold=15,第二次发生GC之后,allocation还留在Survivor区。

6.4 动态对象年龄判断

如果Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以进入老年代,无需达到MaxTenuringThreshold中要求的年龄。

运行结果可以看出,Sevivor区还是0%,而且老年代比预计的多了6%,这是因为allocation1、allocation2对象都直接进入老年代了,没有等到15岁的临界年龄。

6.5 空间分配担保

在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间;

如果大于,则此次Minor GC是安全的

如果小于,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。

如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;

如果小于或者HandlePromotionFailure=false,则改为进行一次Full GC。

 

 

好了,不多说了,敲会儿代码!!!

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值