java虚拟机之GC机制和内存分配

版权声明:转载请注明出处!! https://blog.csdn.net/IPI715718/article/details/84430110

前言:java和c++有一堵由内存动态分配的和垃圾自动回收的高墙,墙外的人都想进来而墙里面的人想出去。

虚拟机运行时数据区域从周期可划分为两部分线程私有(程序计数器,虚拟机栈,本地方法栈)和线程共享(java堆,方法区),线程私有的部分会伴随着线程的结束而回收,因此我们不关心线程私有的内存空间的垃圾回收,只关注线程共享区域的垃圾回收和内存的分配。

垃圾的回收思路

确定对象或者数据是否为不可用或者死亡,然后垃圾收集器对已经判定为死亡的对象清理并且回收内存空间。

判断对象已死吗?判定对象已死算法

引用计数算法 

很多教科书判断对象是否存活的算法是这样的,每个对象设置一个引用计数器,对象每被引用一次,计数器就+1,当引用失效时就-1,当对象的计数器为0时,说明对象已经无法使用,证明其已经死亡,但算法存在缺陷在对象的相互引用时就会失去效果。

在周志明著的《深入理解java虚拟机》一书中用代码做出了验证,表明虚拟机不是用此算法进行判断的。验证如下

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;

        objA = null;
        objB = null;

        System.gc();

    }

}

运行结果:

 

分析:

        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();

        objA.instance = objB;
        objB.instance = objA;

按照引用算法 到这两个对象计数器都应该是 2。

        objA = null;
        objB = null;

两个对象都是一个引用失效,计数器应该为1不应该被回收。但是在运行结果 4603k->210k 中看到两个ReferenceCountingGC都被回收。证明了算法存在缺陷,并且也从侧面说明了虚拟机并不是使用引用计数算法。

可达性分析算法(主流)

目前主流的虚拟机都是采用可达性分析算法来确定对象时否已经死亡。

算法的基本思路:一些称为 GC Roots 的对象作为起始点,从这些起始点向下搜索经过的路径为引用链,当一个对象不在引用链上,也就意味着该对象到 GC Roots是不可达的,证明此对象不可用,为可回收。
 

可以看到 obj1,obj2,obj3都在引用链上判定为存活对象, obj4,obj5,obj6虽然连接着,但是未与GC roots对象与之相连所以是不可达的,判定为可回收对象。

再谈引用

垃圾的回收都与引用有关,可以了解一下引用。 

引用分为:强引用,软引用,弱引用,虚引用。

  • 强引用 我们定义的 Object obj  = new Object(); object就是强引用,强引用关联的对象只要是引用存在,GC就不能回收该对象。
  • 软引用比强引用弱点,软引用关联的是有用但不必须的对象,当内存溢出时,软引用关联的对象会在下一次GC时被回收。
  • 弱引用,是不必须的对象,会在下一次GC时被清理掉。
  • 虚引用,最弱的引用关系,关联对象根本无任何作用,只是在关联的对象被清理时,得到被清理的的信息。

可作为GC Roots的对象

也可以理解为:全局性的引用(常量和类静态变量)和执行上下文(栈帧中本地变量表也就是reference对象)。

  • 虚拟机栈(局部变量表)中的引用的对象
  • 方法区中静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈JNI(native方法)引用的对象

存活和死亡 

对一个对象判断其是存活还是死亡并不是可达性分析算法的一次标记就能判定的,需要两次标记才能真正认为对象时死亡的,在可达性分析算法标记后的对象可以认为死出入“缓刑”阶段,接下来虚拟机会进行第二次筛选,筛选是通过查看对象是否有必要执行finalize()方法,判断是否需要的两种情况 1.对象覆盖了finalize()方法 2.虚拟机已经执行过一次finalize()方法。没必要的对象直接判为死亡状态,有必要的对象会被加入到F-Queue队列中,虚拟机并不会让主线程去遍历F-Queue中的对象去执行finalize()方法,而是开启一个低级的线程finalizer去执行每个对象的finalize()方法,这也是对象的最后一次逃出被回收的机会,finalize()后的对象在可达性分析算法如果能和任意一个在引用链上的对象发生关联都将被清除出回收对象的行列中,如果finalize()后没有与任何一个引用链上的对象发生关联,那就只能被判定死亡,等待垃圾回收器的回收。流程图如下(自己画的有点粗糙)

 垃圾回收算法

以上总结了一下垃圾是怎么标记的算法和思路,现在可以看一下垃圾是怎么被收集的。

标记-清除算法

标记-清除算法是最基本的垃圾回收算法,具体的思路就是和他的名字一样,先是经过标记后确定哪些是需要清理的垃圾,然后进行清理,具体是怎么标记的就是可行性分析算法进行的两次标记。

此算法存在着啷个缺点:1. 效率问题。 2. 在空间上清理过后会产生大量的碎片空间,当有大对象需要分配空间时,无法找到连续的内存空间。

 复制算法 

复制算法的具体思路:将内存空间分为大小相同的两块,将其中的一块内存区域作为存储区域使用,当这一块的内存空间用完时就行一次复制,将使用的这块区域内的还存活的对象复制到另一块区域中,然后将这一块内存区域的内容全部清理。这样就不会出现零碎的空间,对一半的内存全部清理也提高了效率。

缺点:内存空间只有一半能被利用造成一半空间的浪费 ,代价很高。

现在只要使用此种算法用于新生代的回收,但是使用的区域和保留的区域并不是1:1关系,而是将所有的内存区域划分出一个Eden空间和两个Servivor空间,一个Eden : 一个Serviver=8:1,在使用时使用Eden空间和Servivor空间中的一个,回收时将 Eden空间和Servivor空间的存活的对象复制到另一块未使用的Servivor空间,然后清理掉使用的Eden空间和Servivor空间。这种方法当复制时会面临一个问题,就是如果存活的对象需要的空间超过了,预留的Servicor空间时,空间会不够用,这里就需要老年代空间进行分配担保,当存活的对象需要的空间大于预留的servivor空间时,就将存活的新生代的对象直接转入老年代。

 标记-整理算法

当存活的对象较多时复制算法的复制效率将会很低,而且需要额外的空间作为分配担保。老年代一般不使用复制算法。

老年代使用标记整理算法,算法的思路:做回收时,存活的对象向一端移动,然后清理掉段边界以外的内存空间。

 

分代收集算法 

分代收集算法并不是一种新的算法,只是根据年代的特点使用不同的算法进行垃圾回收,例如新生代特点“朝生夕死”,存活的对象比较少,因此采用复制算法,老年代对象存活率高并且没有额外空间作分配担保,使用 标记-整理算法或者标记-清除算法。

hotspot虚拟机垃圾回收的算法的实现

 GC roots节点的枚举

 在可达性分析时需要去利用GC roots 节点去找到引用链,但是作为GC roots 的变量是全局性变量和上下文变量,相对较为分散,在寻找时比较耗费时间。

而在hotspot1是采用一种OopMap的数据结构来解决问题的,在类的加载过程中,虚拟机会吧对象的偏移量以及类型都计算出来保存到OopMap数据中,在JIT编译过程中,也会在特定的位置 记录下栈和寄存器中哪些是引用。这样在GC时直接便利OopMap就能得到GC roots节点信息进行可达性分析,节省了时间,效率比较高。

JIT编译:在运行期间进行的编译。

安全点 SafePoint

 OopMap确实为GC Roots的查找节约了时间,但是在程序的运行过程中,引用的关系会发生改变,会有新的引用产生,也会有引用消亡,这就存在着一个问题,是不是要为每一个指令都生成相应的OopMap呢?答案是否定的,并不会为每一条指令都生成一个OopMap只会在特定的位置生成OopMap,这些特定的位置就是安全点。而GC的发生也是在程序运行到安全点位置时,才会将程序停止下来进行GC。

垃圾回收器 (回收算法的实现)

hotspot垃圾回收器种类图 

Serial收集器(新生代)

Serial回收器是最古老的一种垃圾回收器,也是单线程回收器,在垃圾回收时要暂停所有线程,效率低。

垃圾收集过程示意图

  

 ParNew收集器(新生代)

ParNew收集器时Serial收集器的多线程的版本。

示意图

ParNew在多CPU的情况下可以提高效率,但是在单CPU的情况下效率远远比不上Serial,因为在线程并行时线程的切换会浪费更多的时间, ParNew默认开启得线程数与CPU的个数相等,你也可以使用-XX:ParNewGCThreads参数来限制垃圾收集的线程数量。

Paraller Scavenge 收集器(新生代)

 Paraller Scavenge收集器是一种新生的收集器,它不仅可以使用复制算法,而且也是一款并行的收集器,Paraller Scanvenge收集器的特别之处是在于他与前两款收集器的关注点不同,Serial和Parnew的关注点在停顿时间上,就是尽量缩短GC时用户线程的停顿时间,而Paraller Scanvenge的关注点是吞吐量,吞吐量就是用户线程所用的时间 / 用户线程所用的时间+GC所用的时间。提供了两个参数可以控制吞吐量,设置垃圾回收的停顿时间-XX:MaxGCPauseMillis和直接控制吞吐量-XX:GCTimeRitio。

通过设置垃圾回收的停顿时间不要以为设置为一个无限接近于0的数就可以将效率提高。时间变小是将吞吐量和新生代空间作为代价的,时间变小就通过一次GC将收集的垃圾减少来实现的。

Serial Old收集器(老年代)

Serial Old收集器时Serial老年代的版本也是单线程的。使用标记-整理算法

 

Parallel Old收集器(老年代) 

Parallel Scavenge收集器 老年代的版本,多线程,使用标记-整理算法,是出现较晚的收集器,没有Parallel Old收集器之前,新生代的Parallel Scavenge收集器只能和老年代的Serial Old收集器组合,会拖累Parallel Scavenge的效率,Parallel Old收集器出现之后与新生代的Parallel Scavenge收集器组合。效率大大提高。

CMS收集器 

CMS是一种以获取用户线程停顿时间最短的收集器。使用标记-清除算法,可以与用户线程并发工作,收集过程分为四个步骤

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

初始标记和重新标记都需要用户线程停顿,初始主要是标记与GC Roots节点直接相连的对象,并发标记就是进行可行性分析的过程,重新标记是为了标记在重新标记时出现的变动,。

示意图

CMS收集器虽然效率很高。但是也有缺点

1.CPU资源敏感,虽然能并发处理,但是会抢占用户线程的资源,是用户程序变慢。

2.会出现浮动垃圾,在并发清理时会出现变动,产生浮动垃圾。

3.使用标记-清除算法,产生大量的零碎空间,大对象存储时会找不到连续空间。

GI收集器 

 Gi回收器是目前最先进的收集器之一。

特点

1. 并行和并发 GI能够利用多个CPU资源,缩短用户线程的停顿时间,也可以与用户线程同时工作。

2. 分代收集 GI仍然保留着分代的概念,但是GI收集器不需要与其他的收集器组合进行工作,它采用了一种不同的方式对新生对象和存活一段时间的对象和老对象进行收集。

3.空间上 采用标记-整理算法 不会产生零碎空间。

4.可预测的停顿时间

在GI之前的收集器都是堆新生代和老年代进行不同方式的收集的,但是GI将Java堆虽然也保留了新生代和老年代,但是并不是物理上的分隔了,GI收集器作为收集器时,将Java堆分为大小相等的独立区域,这些区域中不再分代,在回收时会出现一个优先类表,选择最有价值(收集后能获取最多的内存空间)的独立区域进行收集。

有人问那或许会出现一个问题,因为独立区域并不是独立的,另一个独立区域的引用的这个独立区域的对象,在做可达性分析时岂不是要扫描整个Java堆。

问题其实已经解决虚拟机是采用 Remembered Set 来避免真个Java堆的扫描的。每个独立区域都有一个Remembered Set,虚拟机发现由referrence进行写操作时,会产生一个Write Barrier来中断写操作,检查reference引用的对象是否处于不同的独立区域中,如果是,就通过CardTable把相关的引用信息记录到被引用对象所属的独立区域的Remembered Set中,垃圾回收时在枚举范围中加入Remembered Set即可保证扫描。

步骤

  1. 初始标记
  2. 并发标记
  3. 最终标记
  4. 帅选回收

初始标记是标记与GC Roots直接相连的对象,需要暂停用户线程,并发标记是对Java堆内对象进行可达性分析,并且标记。 最终标记是标记在并发标记出现的变动,是与线程同时工作的。筛选回收,筛选就是对各个独立区域的回收价值和停顿时间评估,选出最符合用户停顿时间和空间最大的独立区域进行回收,这个阶段需要暂停用户线程。

内存分配 

对象优先在Eden分配空间 

虚拟机的内存分配主要是在java堆上分配的,在新的对象建立时,会首先将新对象在Eden上分配空间,当在Eden分配空间时发现空间不够用就会进行一次monor GC(新生代GC),如果能通过GC将还存活的对象复制到保留的一个Servivor中,并且复制后Eden可以存放下新对象,使用复制算法进行GI,如果不能就将已经还存活的所有对象直接分配担保到老年代。

对象直接进入老年代 

大对象出现时,例如数组对象被新建了,需要很大的一块内存区域,这时直接放入老年代,一是为了节约新生代的内村空间,二是如果大对象GC后存活,复制算法复制大对象时会消耗很长的时间。

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

虚拟机会为每一个对象设立一个年龄计数器,每经过一次GC年龄+1,可以通过参数-XX:MaxTenuringThreshold设置晋升到老年代的阈值,年龄到达这个阈值的对象进入老年代。

空间分配担保 

新生代Eden如果存在大量的存活对象,并且Servivor空间小于存活对象需要的空间时需要老年代进行分配担保,但前提时老年代有足够的空间,如果有的话新生代就进行GC,没有的话就对java堆进行一次Full GC (老年代新生代都进行GC)。 

猜你喜欢

转载自blog.csdn.net/IPI715718/article/details/84430110
今日推荐