《深入理解java虚拟机》二 垃圾收集器与内存分配策略

一、概述

java内存中运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭。栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈的操作。
每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具有确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随回收了。而java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的就是这部分内存。

二、对象已死吗

在堆里面存放着java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(即不可能再被任何途径使用的对象)。

(1)引用计数算法

引用计数算法:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器就加1,当引用失效时,计数器值就减1。任何时刻计数器为0的对象就是不可能再被使用的。
客观的说,引用计数算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法,但是,它最大的缺点就是,它很难解决对象之间相互循环引用的问题。

package jvm.chp2;

/**
 * @author chengzhengda
 * @version 1.0
 * @date 2019-12-26 17:14
 * @desc
 */
public class ReferenceCountingGC {
    public Object instance = null;

    private static final int _1MB = 1024 * 1024;

    private byte[] bigSize = new byte[2 * _1MB];

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

        System.gc();
    }

    public static void main(String[] args) {
        testGC();
    }
}

在这里插入图片描述
从运行结果来看,内存减少了,意味着虚拟机并没有因为这两个对象互相引用就不回收它们,这也从侧面说明虚拟机并不是通过引用计数算法来判断对象是否存活的。

(2)可达性分析算法

这个算法的基本思想就是通过一系列的称为“GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。如下图所示,对象object5、object6、object7虽然相互有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。
在这里插入图片描述
在java语言中,可作为GC Roots的对象包括下面几种:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中JNI(即一般说的Native方法)引用的对象

(3)再谈引用

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与”引用“有关。
在JDK1.2之后,java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种,这四种引用强度依次逐渐减弱。

  1. 强引用就是指在程序代码之中普遍存在的,类似
Object obj = new Object();

这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
2. 软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。用SoftReference类来实现软引用。
3. 弱引用也是用来描述非必须的对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。用WeakReference类来实现弱引用。
4. 虚引用也称为幽灵引用或者幻影引用。它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例,为一个对象设置虚引用关联的唯一目的就是能再这个对象被收集器回收时收到一个系统通知。用PhantomReference类来实现虚引用。

(4)生存还是死亡

即使在可达性分析算法中不可达的对象,也并非是”非死不可“的,这时候它们暂时处于”缓刑“阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:
如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法,当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为”没有必要执行“。
如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个叫F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可。

package jvm.chp2;

/**
 * @author chengzhengda
 * @version 1.0
 * @date 2019-12-26 19:59
 * @desc
 */
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();
        System.out.println("finalize method executed");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws Throwable {
        SAVE_HOOK = new FinalizeEscapeGC();
        SAVE_HOOK = null;
        System.gc();
        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");
        }

    }
}

在这里插入图片描述
从以上结果可以看出,SAVE_HOOK对象的finalize()方法确实被GC收集器触发过,并且在被收集前成功逃脱了。但是任何一个对象的finalize()方法都只会被系统自动调用一次,所以第二次自救失败了。

(5)回收方法区

永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。会后废弃常量和回收java堆中的对象非常类似。
判定一个常量是否是”废弃常量“比较简单,而要判定一个类是否是”无用的类“的条件则相对苛刻许多。类需要同时满足下面3个条件才能算是”无用的类“:
该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
加载该类的ClassLoader已经被回收。
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

三、垃圾收集算法

(1)标记-清除算法

算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
它的主要不足有两个:

  1. 一个是效率问题,标记和清除两个过程的效率都不高
  2. 另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后再程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
    标记-清除算法的执行过程如下图所示:
    在这里插入图片描述

(2) 复制算法

为了解决效率问题,一种称为“复制算法”的收集算法出现了,它将可用内存按容量分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉,这样使用每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半,未免太高了一点。
复制算法的执行过程如下图所示:
在这里插入图片描述
现在的商业虚拟机都采用这种收集算法来回收新生代,通过研究表明,新生代中的对象98%是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活中的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%,只有10%的内存会被浪费掉。当然,我们没法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(老年代)进行分配担保。
如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

(3)标记-整理算法

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低,更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象的100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
标记-整理算法不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,“标记-整理”算法的执行过程如下所示:
在这里插入图片描述
(4)分代收集算法

一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用适当的收集算法,在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高,没有额外空间对它进行分配担保,就必须使用“标记-清除”或者“标记-整理”算法来进行回收。

四、垃圾收集器

图片: https://uploader.shimo.im/f/MHRSsut6Pogftfua.png
(1)Serial收集器

这个收集器是一个单线程的收集器,但它的“单线程”的意义并仅仅是说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程(又叫做"Stop The World"),直接它收集结束。"Stop The World"这个名字也许听起来很酷,但这项工作实际上是由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户的正常工作的线程全部停掉,这对很多应用来时候都是难以接受的。
在这里插入图片描述
Serial收集器也有优于其他收集器的地方:简单而高效(与其他收集器的单线程相比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。Serial收集器对于运行在Client模式下的虚拟机来说是一个很好的选择。

(2)ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样。
在这里插入图片描述
ParNew收集器在单CPU的环境下绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器咋通过超线程技术实现的两个CPU的环境中都不难百分之百地保证能超越Serial收集器。当然,随着可以使用的CPU的数量的增加,它对于GC时系统资源的利用还是很有好处的。

(3)Parallel Scavenge收集器

CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标就是达到一个可控制的吞吐量,所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即
吞吐量 = 运行用户代码时间/ (用户运行代码时间 + 垃圾收集时间)
也就是相对时间。
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户的体验,而高吞吐量则可以最高效率地利用CPU时间,尽快地完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾停顿时间的-XX:MaxPauseMills参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。
MaxPauseMills参数允许的值时一个大于0的毫秒值,收集器将尽可能地保证内存回收收费的时间不超过设定值。当然,GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的,因此不是越短越好。
GCTimeRatio参数的值应当是一个大于0且小于100的整数,也就是垃圾收集时间占总时间的比率,相当于是吞吐量的倒数,如果把此参数设置为19,那允许的最大GC时间就占总时间的5%,默认值为99,就是允许最大1%的垃圾收集时间。
Parallel Scavenge收集器也经常成为"吞吐量优先"收集器,除了上述两个参数之外,Parallel Scavenge收集器还有一个参数-XX:+UserAdaptiveSizePolicy值得关注。这是一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小等细节参数啦,GC会自适应的调节这些参数的大小,以提供最合适的停顿时间或者最大的吞吐量。

(4)Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用"标记-整理"算法。
在这里插入图片描述
(5)Parallel Old收集器

Parallel Old是Parallel Scavenge 收集器的老年代版本,使用多线程和"标记-整理"算法,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。
在这里插入图片描述
(6)CMS收集器

CMS收集器是一种以获取最短回收停顿时间为目标的收集器,目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,给用户带来较好的体验。
CMS收集器是基于"标记-清除"算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤,包括:

  1. 初始标记
  2. 并发标记
  3. 重新标记
  4. 并发清除

其中,初始标记、重新标记着两个步骤仍然需要"Stop The World"。初始标记仅仅只是标记一个GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Rracing的过程,而重新标记阶段就是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
在这里插入图片描述
CMS是一款优秀的收集器,它的主要优点在名字上已经体现出来了:并发收集、低停顿。
但是CMS还远远达不到完美的程度,它有以下3个明显的缺点:

  1. CMS收集器对CPU非常敏感
  2. CMS收集器无法处理浮动垃圾,可能出现"Concurrent Mode Failure"失败而导致另一次Full GC的产生
  3. 标记-清除算法结束时会有大量的空间碎片产生

(7)G1收集器

G1收集器是当今收集器技术发展的最前沿成果之一,G1是一款面向服务端应用的垃圾收集器,与其他收集器相比,G1具备如下特点:

  1. 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
  2. 分代收集
  3. 空间整合:G1从整体来看是基于"标记-整理"算法实现的收集器,从局部上来看是基于"复制"算法实现的。但无论如何,这两种算法都不会产生空间碎片
  4. 可预测的停顿,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
    G1收集器的Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域,虽然还保留有新生代和老年代的概念,但是新生代和老年代不再是物理隔离的了,它们都是一部分Region的集合。

五、内存分配与回收策略

Java中的内存管理可以归结为两个问题:给对象分配内存以及回收分配给对象的内存。

(1)对象优先在Eden分配

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

(2)大对象直接进入老年代

所谓的大对象是指需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串和数组,大对象对于虚拟机的内存分配来说就是一个坏消息。

(3)长期存活的对象进入老年代

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

(4)动态对象年龄判定

为了能更好的适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

(5)空间分配担保

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值