二、JVM垃圾回收与内存分配策略

Java与C++的一个不同点在于,C++需要手动分配和清理内存,而Java中,内存的分配和回收是由JVM自动进行的。

1、对象存活判断算法

JVM中,对内存自动回收之前,首先需要做的就是判断一个对象是否仍然存活。判断对象是否存活时有两种算法。

引用计数算法

给每一个对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1。当计数器值为0时,这个对象就不可能再被使用,因此判断其不再存活,可以被回收。引用计数算法实现简单,但是主流的JVM里面并未使用这种算法,其中最主要的原因就是它很难解决对象间的循环引用问题。如以下代码:

public class ReferenceCountingGC {
    private 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;
    }
}

如下图所示,经过以上代码,两个对象objA和 objB已经没有任何地方可以访问到,但是,由于循环引用,两个对象的引用计数都为1,导致GC收集器无法对其进行回收。

可达性分析算法

通过一系列的被称为"GC Roots"的对象作为起点,从这些节点还是向下搜索,搜索走过的路径叫作引用链。当一个对象到GC Roots没有任何引用链相连时(图论里面的术语就是,从GC Roots到该对象不可达),则说明此对象是不可用的,其将被判断为可回收的对象。这样就可以避免因为循环引用导致的对象不可回收的问题。因此,可达性分析算法是目前的主流实现算法。

GC Roots对象:

1、虚拟机栈(栈帧中的本地变量表)中引用的对象;

2、方法区中类的静态属性引用的对象;

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

4、本地方法栈中JNI(即一般说的native方法)引用的对象。

 Java引用:

具有4种类型,按照引用强度由强到弱分别是:

强引用:对象只要存在强引用,永远不会被回收

软引用:对于软引用关联的对象,在系统将要发生内存溢出异常之前,将会对该类对象进行回收。

弱引用:只有弱引用的对象,在GC时,无论内存是否足够,都会被回收。

虚引用:无法通过虚引用来获取一个对象实例,它与GC无关,唯一目的是在对象被回收时能够收到一个系统通知。

二次标记:

通过可达性算法分析之后,不可达对象将会被进行一次标记。在这次标记之后,不会对标记的对象立即进行清理,而是会将其放入一个队列中。稍后,虚拟机会对这个队列中的对象进行二次标记。进行二次标记时会进行一次判断,如果对象重写了finalize方法,且该对象的finalize方法从未执行过,那么会调用该对象的finalize方法,否则就会对其进行再次标记并进行GC回收。在该方法中,该对象可以通过将自己的引用(this)赋给其它的某个类或成员的变量,从而使自己从新变为可达的,以逃脱本次GC。但是每个对象的finalize方法只会执行一次,因此编写代码时不应依赖此特性。

方法区的回收:

方法区,也是HotSpot JVM中的永久代,由于对方法区进行GC的收益较低,JVM规范中没有要求在方法区中必须实现垃圾收集。但是仍然也有对方法区进行GC的,永久代回收的主要分为两部分内容:废弃的常量(如已经没有地方在使用的字符串常量)和无用的类。满足以下3个条件才算是无用的类:

1、类的所有实例对象都已被回收,即堆中不存在该类的实例

2、加载该类的ClassLoader已经被回收

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

2、垃圾收集算法

1、标记-清除算法

标记-清除算法是最基本的垃圾回收算法,顾名思义,它分为标记和清除两个阶段。后续的收集算法都是基于标记-清除的思想,对此算法的不足进行改造得到的。其主要不足有两个:一是效率问题,标记和清除两个阶段的效率都不高;二是标记清除后会产生大量的不连续的内存碎片,导致以后在分配需要较大内存的对象时找不到连续的空闲内存,从而不得不提前触发一次新的GC。

2、复制算法

相对于标记-清除算法,复制算法有效提升了垃圾回收的效率,并且避免了内存碎片的问题。复制算法的基本思想是,将可用内存一分为二,划为大小相等的两块区域,每次只使用其中一块区域。垃圾回收时,将存活的对象复制到另外一块区域后,再对之前使用的那一半区域进行清理。但是这样就存在一个问题,每次只能使用一半的内存,内存使用率太低。

当前主流的虚拟机,在新生代都是使用的这种算法。研究发现,新生代的对象中,98%的对象存活时间都很短,在垃圾回收时,绝大部分的对象都已经死亡。因此,为了提高内存的使用率,并不需要按照1:1的比例来分配内存,而是将内存划分一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden空间和其中一块Survivor空间。

垃圾回收时,将Eden空间和当前使用的那块Survivor空间空仍然存活的对象,复制到另外一块Survivor空间中,最后清理掉Eden空间和之前使用的那块Survivor空间。HotSpot虚拟机默认Eden空间和Survivor空间的大小比例为8:1,也就是每次新生代中可用的内存中间为整个新生代内存容量的90%(80%+10%),只有10%的内存被“浪费”。

当然,我们没有办法保证每次GC时,都只有不多于10%的对象存活。当新生代采用复制算法进行垃圾回收,遇到Survivor空间不够用时,就需要依赖其他内存区域(这里指老年代)进行分配担保。也即是,当另外一块Survivor空间不足以存放存活的对象时,这些对象将会通过分配担保机制进入老年代。

3、标记-整理算法

复制算法适合在对象存活率较低的新生代中适用,在对象存活率较高老年代中就不适合了。根据老年代的特点,有人提出了标记-整理算法。标记-整理算法与标记-清除算法有些类似,不同之处在于,在将对象标记之后,不是直接对可回收对象进行清除,而是让所有存活的对象在内存中都向一端移动,然后清理掉边界以外的内存。这样的好处是避免了空闲内存碎片化的问题。

4、分代收集算法

前面介绍了最基本的标记-清除算法,适合在新生代使用的复制算法以及适合在老年代使用的标记-整理算法。也即是对于Java虚拟机来说,可以根据不同内存区域具备的不同特点,分别采用不同的垃圾回收算法,这就是分代收集算法。当前主流的Java虚拟机都是采用的分代收集算法,即针对新生代,采用复制算法,针对老年代,采用标记-清除或者标记-整理算法。

3、垃圾收集器

如果说前面介绍的垃圾回收算法是内存回收的方法内存,那么垃圾收集器就是内存回收的具体实现。JDK1.7 Update 14之后的HotSpot虚拟机(正式提供了商用的G1收集器)包含的所有收集器如下图所示。虚拟机所处的区域表示它是属于新生代还是老年代的收集器。两个收集器中的连线表明它们可以搭配使用。

1、Serial收集器

单线程收集器。垃圾回收时,必须暂停其他所有的工作线程,直到收集结束,也就是会产生Stop the World。优点:简单而高效,适用于单CPU的环境,是JVM运行在Client模式下的默认新生代收集器。

2、ParNew收集器

Serial收集器的多线程版本,除了使用多条线程进行垃圾回收,其余行为与Serial收集器相同。是运行在Server模式下的JVM中首选的新生代收集器。在单CPU环境中,表现绝对不会比Serial收集器更好。

3、Parallel Scavenge收集器

也是采用复制算法。与其他收集器重点关注缩短GC时间不同,Parallel Scavenge收集器的重点关注目标是吞吐量,所谓的吞吐量是CPU运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码的时间/(运行用户代码的时间+GC时间)。

4、Serial Old收集器

Serial收集器的老年代版本。单线程、标记-整理算法。意义在于给Client模式下的虚拟机使用。

5、Parallel Old收集器

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

6、CMS(Concurrent Mark Sweep)收集器

目标:获取最短的回收停顿时间。基于标记-清除算法实现。GC过程分为4个步骤:

初始标记:需要Stop The World,仅标记GC Roots能直接关联到的对象,速度快

并发标记:GC Roots Tracing的过程

重新标记:需要Stop The World,修正并发标记的结果,比初始标记稍长,远低于并发标记时间

并发清除:耗时较长

如下图所示,耗时较长的并发标记和并发清除的过程中,收集器线程和用户线程是可以一起运行的。

缺点:

对CPU资源敏感:默认GC线程数量是(CPU数量+3)/4,当CPU在4个以上时,GC线程占用不低于25%的CPU资源,且随着CPU数量的增加而下降。当CPU不足4个,例如2个,那么GC线程的CPU占用率就较高,让人无法接受。

导致空闲内存碎片化:基于标记-清除算法

无法处理浮动垃圾:即在并发清除阶段新产生的垃圾

7、G1(Garbage First)收集器

GC大致分为以下几个过程:

初始标记、并发标记、最终标记、筛选回收。

4、内存分配策略

前面已经介绍了内存回收相关内容,下面介绍一下Java的内存分配策略。对象的内存分配,主要就是在堆上进行分配,通常主要是分配在新生代的Eden区,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也有可能直接分配在老年代中。

假设新生代、老年代内存大小分别为10M,新生代中Eden区与Survivor区比例为8:1,即Eden区大小为8M。以下代码为例:

    private static final int _1MB = 1024 * 1024;
    
    public static void testAllocation() {
        byte[] b1, b2, b3, b4;
        b1 = new byte[2 * _1MB];
        b2 = new byte[2 * _1MB];
        b3 = new byte[2 * _1MB];
        b4 = new byte[4 * _1MB]; // 出现一次Minor GC
    }

首先,创建3个大小为2M的对象,3个对象都会分配到Eden区中,此时,Eden区中占用6M内存,剩余2M。然后再创建一个大小为4M的对象,为此对象分配内存时,发现Eden中剩余内存不足,因此会触发一次Minor GC。Minor GC时发现前面3个对象都是存活的,且Survivor空间无法存放,因此会将这3个对象通过分配担保机制提前转移到老年代中。这次GC结束后,4M的对象顺利分配在Eden区中,老年代被占用6M。

关于Minor GC、Major GC与Full GC :

  • 新生代GC(Minor GC):针对新生代的GC,非常频繁,回收速度很快
  • 老年代GC(Major GC / Full GC): 针对老年代的GC,Major GC通常伴随着一次Minor GC(不绝对),速度一般会比Minor GC慢10倍以上。

此外,占用内存较大的对象也可以直接在老年代中分配内存。通过设置-XX:PretenureSizeThreshold 参数可以指定,大于这个设置值的对象直接在老年代分配。

对于新生代中对象,每经历一次Minor GC,对象的年龄就增加1岁,默认增加到15岁时,就会被晋升到老年代中。此外,如果Survivor空间中,相同年龄的所有对象大小的总和超过Survivor空间的一般,那么年龄大于等于该年龄的对象也会直接进入老年代。

Minor GC之前,会先检查老年代剩余的最大可用连续空间是否大于新生代所有对象总和,如果是,那么此次MinorGC可以确保是安全的。如果否,虚拟机检查参数设置,判断是否允许担保时报,如果允许,并且老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,则尝试进行一次有风险的Minor GC。否则就要进行一次Full GC。

参考:

深入理解Java虚拟机【作者:周志明】

Guess you like

Origin blog.csdn.net/sun_lm/article/details/119833735