JVM之垃圾回收与内存分配

1、判断对象是否可回收

1.1 引用计数算法

在对象头处维护一个计数变量counter,每增加一次对该对象的引用时,计数器自加,如果对该对象的引用失联,则计数器自减。当counter为0时,表明该对象已经被废弃,不处于存活状态。

这种方式一方面无法区分软、虛、弱、强引用类别。另一方面,会造成死锁,假设两个对象相互引用始终无法释放counter,永远不能GC。

Redis中采用这种方式实现内存的回收。

1.2 可达性分析算法

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

可达性分析算法
如果对象在进行可行性分析后发现没有与GC Roots相连的引用链,也不会立即死亡。它会暂时被标记上并且进行一次筛选,筛选的条件是是否有必要执行finalize()方法。如果被判定有必要执行finaliza()方法,就会进入F-Queue队列中,并有一个虚拟机自动建立的、低优先级的线程去执行它。稍后GC将对F-Queue中的对象进行第二次小规模标记。如果这时还是没有新的关联出现,那基本上就真的被回收了。

Java 虚拟机使用该算法来判断对象是否可被回收,GC Roots 一般包含以下内容:

  • 虚拟机栈中局部变量表中引用的对象(存放了编译期可知的各种基本数据类型、对象引用、returnAddress类型)
  • 本地方法栈中JNI(Java Native Interface) 中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中的常量引用的对象

可达性分析算法是通过枚举根节点来实现的,最重要的问题是GC停顿。为了确保一致性(在分析过程中不可以出现对象引用关系在不断变化的情况)而导致GC进行时必须进行停顿。

为了减少GC停顿时间,在HotSpot中,使用OopMap的数据结构。在类加载时,JVM就把对象内什么偏移量上是什么数据计算出来;在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC扫描时不需要一个不漏地检查完所有执行上下文和全局引用位置,可以直接得知这些信息,以减少GC停顿带来的影响。

同时,为了节约空间,HotSpot没有在所有的指令生成OopMap,只是在“特定位置”记录这些信息,这些位置就是安全点。程序执行时并非在所有的位置上都能停顿下来GC,只有在到达安全点时才能暂停。安全点选取基本上是以“是否让程序长时间执行的特征”选定。当GC发生时,有两种办法让所有线程都跑到安全点上再停顿:抢先式中断和主动式中断(轮询)。

安全点的选取?

以程序是否有让程序长时间执行的特征(如指令序列复用)为标准进行选定。例如方法调用、循环跳转、异常跳转等。具有这些功能的指令才会产生Safepoint

如何在GC发生时让所有线程到达最近的安全点上停顿?

抢先式中断:GC发生时,首先把所有线程都中断。如果有线程中断的地方不在安全点上,就恢复线程,让它跑到安全点上。

主动式中断:GC需要中断线程时,仅简单设置一个标志,各线程执行时主动轮询此标志,发现标志位真,自己中断挂起。轮询标志的地方和安全点是重合的。

安全区域

SafePoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GCSafepoint。但当线程处于sleep或blocked状态,无法响应JVM的中断请求—采用安全区域(Safe Region)来解决。

安全区域:在一段代码片段中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。

线程执行到Safe Region中的代码时,首先标识自己。在线程离开时,检查是否完成了GC过程,若完成,继续执行;否则等待,直到收到可以安全离开的信号。

1.3 引用

1、强引用

强引用指在程序代码中普遍存在的,类似Object obj = new Object()这类的引用。
如果一个对象具有强引用,垃圾回收器绝不会回收它。当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。

强引用是造成内存泄露的主要原因。

2、软引用

软引用描述一些有用但非必须的对象。被软引用关联的对象只有在内存不够的情况下才会被回收。

软引用通过SoftReference类实现,可用来实现内存敏感的高速缓存。

3、弱引用

弱引用通过WeakReference类实现,被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。

4、虚引用

虚引用又称为幽灵引用或者幻影引用,通过PhantomReference类实现。一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。

为一个对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知(跟踪对象的垃圾回收状态)。

如追踪堆外内存的释放。

虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

5、ThreadLocal的内存泄露问题

如果创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal变量名的由来。他们可以使用 get()set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。

每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为key的键值对,value 就是ThreadLocal对象调用set方法设置的值。map结构是为了让每个线程可以关联多个 ThreadLocal变量。

例如以下代码:

public class ThreadLocalExample1 {
    public static void main(String[] args) {
        ThreadLocal threadLocal1 = new ThreadLocal();
        ThreadLocal threadLocal2 = new ThreadLocal();
        Thread thread1 = new Thread(() -> {
            threadLocal1.set(1);
            threadLocal2.set(1);
        });
        Thread thread2 = new Thread(() -> {
            threadLocal1.set(2);
            threadLocal2.set(2);
        });
        thread1.start();
        thread2.start();
    }
}

它所对应的底层结构图为:


ThreadLocal类封装了getMap()、Set()、Get()、Remove()4个核心方法。通过getMap()获取每个子线程Thread持有自己的ThreadLocalMap实例,因此它们是不存在并发竞争的。可以理解为每个线程有自己的变量副本。

ThreadLocal为每个线程都创建一块小的堆工作内存。线程很多时,容易导致堆内存溢出。所以把ThreadLocal里的key设置为弱引用,当垃圾回收的时候,引用的ThreadLocal的对象被回收了,ThreadLocal的引用ThreadLocalMap的Key为弱引用,那么ThreadLocalMap的Key将也会被回收。

public class ThreadLocal<T> {	
    //...       
	static class ThreadLocalMap {
        ...
        //弱引用
        static class Entry extends WeakReference<ThreadLocal<?>> {
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
   
        private Entry[] table;
    }
}

所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉(存在一条从Current Thread过来的强引用链)。这样一来,ThreadLocalMap 中就会出现key为null的Entry。假如我们不做任何措施的话,value 永远无法被GC 回收,这个时候就可能会产生内存泄露。

img

ThreadLocalMap实现中已经考虑了这种情况,在调用 set()get()remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后 最好手动调用remove()方法。

1.4 生存还是死亡

即使在可达性分析法中不可达的对象,也并非是非死不可的,要真正宣告一个对象死亡,至少要经历两次标记过程:可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。

被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。

当一个对象可被回收时,如果需要执行该对象的 finalize() 方法,那么就有可能在该方法中让对象重新被引用,从而实现自救。自救只能进行一次,如果回收的对象之前调用了 finalize() 方法自救,后面回收时不会再调用该方法。

1.5 回收方法区

因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,所以在方法区上进行回收性价比不高。

主要是对常量池的回收和对类的卸载。

为了避免内存溢出,在大量使用反射和动态代理的场景都需要虚拟机具备类卸载功能。

类的卸载条件很多,需要满足以下三个条件,并且满足了条件也不一定会被卸载:

  • 该类所有的实例都已经被回收,此时堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。

2、垃圾收集算法

2.1 标记-清除算法


标记阶段:1、根据GC Roots标记处所有的可达对象 2、将所有不可达的对象进行finalize判断 3、finalize判断最终得到的才是需要清除的对象

清除阶段:清除阶段就是把那些没有标记的对象,也就是非活动对象回收的阶段。

  • 清除阶段collector会遍历整个堆,回收没有打上标记的对象(即垃圾)。
  • 内存的合并操作也是在清除阶段进行的。

不足:

  • 标记和清除过程效率都不高;
  • 会产生大量不连续的内存碎片,导致无法给大对象分配内存。

2.2 复制算法(新生代)

为了解决效率问题

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

公众号

这样在分配内存时就不用考虑空间碎片,也不用使用空闲链表,避免了这类麻烦问题,只用移动堆顶指针,按顺序分配内存即可,有效提高了效率。

主要不足是只使用了内存的一半。

现在的商业虚拟机都采用这种收集算法回收新生代,因为新生代大都是朝生夕死的,这样复制成本较低。但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象全部复制到另一块 Survivor 上,最后清理 Eden 和使用过的那一块 Survivor。

HotSpot 虚拟机的 Eden 和 Survivor 大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 就不够用了,此时需要依赖于老年代进行空间分配担保,也就是借用老年代的空间存储放不下的对象。

2.3 标记-整理算法(老年代)

复制算法在对象存活率较高时要进行较多的复制操作,效率变低。在老年代中一般不直接选用这种算法。

标记-整理算法是根据老年代的特点提出的一种标记算法,标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。


优点:

  • 不会产生内存碎片

不足:

  • 需要移动大量对象,处理效率比较低。

2.4 分代收集

当前虚拟机的垃圾收集都采用分代收集算法,这种算法根据对象存活周期的不同将内存分为几块。一般将 java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法

比如在新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。

2.5 分区收集—G1

分区算法将堆空间划分为连续的大小不同的小区域,对每个小区域单独进行内存使用和垃圾回收,根据每个小区域的大小灵活使用和释放内存。

分区收集算法可以建立一个可预测的停顿模型,根据系统可接受的停顿时间,每次快速回收若干个小区域的内存,以缩短垃圾回收时系统停顿的时间,最后以多次并行累加的方式逐步完成整个内存区域的垃圾回收。如果垃圾回收机制一次性回收整个堆内存,则需要更长的系统停顿时间,影响系统运行。

堆的常见配置

-Xss //选置栈内存的大小
-Xms: //初始堆大小
-Xmx: //最大堆大小
-XX:NewSize=n: //设置新生代大小
-XX:NewRatio=n: //设置新生代和老年的比值。比如设置为3,表示年轻代与年老代比值为1:3
-XX:SurvivorRatio=n: //年轻代中Eden区与两个Survivor区的比值。注意Survivor区有两个。比如设置为3,表示Eden:Survivor=3:2,一个Survivor区占整个年轻代的1/5。
-XX:MaxPermSize=n: //设置永久代大小

3、垃圾收集器


以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。

  • Serial:单线程复制算法(新生代 stop the world 单线程垃圾回收)
  • ParNew:多线程复制算法(新生代 stop the world 多线程垃圾回收)
  • Parallel Scavenge:多线程复制算法(新生代 多线程 以提高吞吐量为目标:虚拟机动态调整参数以提供最合适的停顿时间或者最大的吞吐量,适合与用户交互不多的后台计算)(默认使用)
  • Serial Old:单线程标记整理算法(老年代 stop the world 单线程垃圾回收)
  • Parallel Old:多线程标记整理算法(Parallel Scavenge老年代版本)
  • CMS:多线程标记清除算法(垃圾回收与用户线程并发工作,无法处理浮动垃圾)
  • G1:分区收集(内存区域独立划分 根据优先级回收)

3.1 Serial收集器(与CMS、Serial Old搭配使用)


特点:单线程。

1、只会使用一条垃圾收集线程去完成垃圾收集工作;

2、在进行垃圾收集工作的时候必须暂停其他所有的工作线程( Stop The World ),直到收集结束。

优点:简单高效

在单个 CPU 环境下,由于没有线程交互的开销,因此拥有最高的单线程收集效率。

它是 Client 场景下的默认新生代收集器,因为在该场景下内存一般来说不会很大。它收集一两百兆垃圾的停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿时间是可以接受的。

3.2 ParNew收集器(与CMS、Serial Old搭配使用)

ParNew收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。


它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器配合工作。

//调节垃圾回收工作线程数
-XX:ParallelGCThreads

3.3 Parallel Scavenge收集器(与Serial Old、Parallel Old搭配使用)

ParNew 一样是多线程收集器。

其它收集器目标是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量,因此它被称为吞吐量优先收集器。这里的吞吐量指 CPU 用于运行用户程序的时间占总时间的比值。

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务。

缩短停顿时间是以牺牲吞吐量和新生代空间来换取的:新生代空间变小(小,所以收集快,停顿时间短),垃圾回收变得频繁,导致吞吐量下降。

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

-XX:MaxGCPauseMillis:控制最大垃圾收集停顿时间
-XX:GCTimeRatio:直接设置吞吐量大小
-XX:+UseAdaptiveSizePolicy:使用自适应调节策略

以上三个是新生代收集器,下面是老年代收集器。

3.4 Serial Old收集器


是 Serial 收集器的老年代版本,也是给 Client 场景下的虚拟机使用。如果用在 Server 场景下,它有两大用途:

  • 在1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
  • 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。(并发清除阶段无法清除浮动垃圾)

3.5 Parallel Old收集器


是 Parallel Scavenge 收集器的老年代版本。

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

3.6 CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。

CMS收集器是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。


从名字中的Mark Sweep这两个词可以看出,CMS 收集器是基于 标记-清除算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
  • 并发标记: 同时开启 GC 和用户线程,执行GC Roots跟踪标记过程。
  • 重新标记: 为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。
  • 并发清除: 开启用户线程,同时 GC 线程开始对为标记的区域做清扫。

在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。

缺点:

  • 吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
  • 无法处理浮动垃圾:浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS
  • 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC

3.7 G1收集器

G1为了避免全区域垃圾收集引起的系统停顿,将堆内存划分成大小固定的几个区域,独立使用这些区域的内存资源并跟踪垃圾收集进度。

传统的GC收集器将连续的内存空间划分为新生代、老年代和永久代(JDK 8去除了永久代,引入了元空间Metaspace),这种划分的特点是各代的存储地址(逻辑地址,下同)是连续的。

G1的各代存储地址是不连续的,每一代都使用了n个不连续的大小相同的Region,每个Region占有一块连续的虚拟内存地址。如下图所示:

g1 GC内存布局

通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能(G1根据这个模型统计计算出来的历史数据来预测本次收集需要选择的Region数量,从而尽量满足用户设定的目标停顿时间。停顿预测模型是以衰减标准偏差为理论基础实现的)。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。

每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。


如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:

  • 初始标记
  • 并发标记
  • 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
  • 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

具备如下特点:

  • 并行与并发:能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 程序继续执行。
  • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC堆,但是还是保留了分代的概念。
  • 空间整合:与 CMS 的标记–清理算法不同,G1 从整体来看是基于标记整理算法实现的收集器;从局部上来看是基于“复制”算法实现的。
  • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1CMS共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。

垃圾收集器的JVM配置

-XX:+UseSerialGC: //设置串行收集器
-XX:+UseParallelGC: //设置并行收集器
-XX:+UseParalledlOldGC: //设置并行年老代收集器
-XX:+UseConcMarkSweepGC: //设置并发收集器
//并行收集器设置
-XX:ParallelGCThreads=n: //设置并行收集器收集时使用的CPU数,并行收集线程数
-XX:MaxGCPauseMillis=n: //设置并行收集最大暂停时间
-XX:GCTimeRatio=n: //设置垃圾回收时间占程序运行时间的百分比,公式为1/(1+n)
//并发收集器设置
-XX:+CMSIncrementalMode: //设置为增量模式。适用于单CPU情况
-XX:ParallelGCThreads=n: //设置并发收集器年轻代收集方式为并行收集时,使用的CPU数。并行收集线程数

4、内存分配与回收策略

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC 堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。

堆空间的基本结构:

img

上图所示的 eden 区、s0("From") 区、s1("To") 区都属于新生代,tentired 区属于老年代。大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入s1("To"),并且对象的年龄还会加 1,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。经过这次GC后,Eden区和"From"区已经被清空。这个时候,“From"和"To"会交换他们的角色,也就是新的"To"就是上次GC前的“From”,新的"From"就是上次GC前的"To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,"To"区被填满之后,会将所有对象移动到老年代中。

4.1 对象优先在Eden分配

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

  • 新生代 GC(Minor GC):指发生新生代的的垃圾收集动作,Minor GC 非常频繁,回收速度一般也比较快。
  • 老年代 GC(Major GC/Full GC):指发生在老年代的 GC,出现了 Full GC 经常会伴随至少一次的 Minor GC(并非绝对),Full GC 的速度一般会比 Minor GC 慢 10 倍以上。

在给对象分配内存时,若Eden区已经用完,且Minor GC后也无法放入对象,则通过分配担保机制将Eden区对象提前转移到老年代去。

4.2 大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。

为什么要这样呢?

为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。

-XX:PretenureSizeThreshold:大于这个设置值的对象直接在老年代中分配。此参数只对Serial和ParNew两款收集器有效。

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

虚拟机给每个对象一个对象年龄(Age)计数器。

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

-XX:MaxTenuringThreshold:对象进入老年代的阈值

4.4 动态对象年龄判定

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

4.5 空间分配担保

在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。

如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC

5、Full GC的触发条件

对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发。而 Full GC 则相对复杂,有以下条件:

5.1 调用 System.gc()

只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。

5.2 老年代空间不足

老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。

为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过-Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。

5.3 空间分配担保失败

使用复制算法的 Minor GC需要老年代的内存空间作担保,如果担保失败会执行一次Full GC

5.4 JDK1.7 及以前的永久代空间不足

在 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。

当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError

5.5 Concurrent Mode Failure

执行 CMS GC的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC

6、GC优化

6.1 优化步骤

GC优化一般步骤可以概括为:确定目标、优化参数、验收结果。

确定目标

明确应用程序的系统需求是性能优化的基础,系统的需求是指应用程序运行时某方面的要求,譬如:高可用、低延迟、高吞吐。

上述性能指标间可能冲突。比如通常情况下,缩小延迟的代价是降低吞吐量或者消耗更多的内存或者两者同时发生。

优化

通过收集GC信息,结合系统需求,确定优化方案,例如选用合适的GC回收器重新设置内存比例调整JVM参数等。

进行调整后,将不同的优化方案分别应用到多台机器上,然后比较这些机器上GC的性能差异,有针对性的做出选择,再通过不断的试验和观察,找到最合适的参数。

验收优化结果

将修改应用到所有服务器,判断优化结果是否符合预期,总结相关经验。

接下来,我们通过三个案例来实践以上的优化流程和基本原则(本文中三个案例使用的垃圾回收器均为ParNew+CMS,CMS失败时Serial Old替补)。

6.2 GC优化案例

案例一:Full GCMinor GC频繁

服务情况:Minor GC每分钟100次 ,Full GC每4分钟一次,单次Minor GC耗时25ms,单次Full GC耗时200ms,接口响应时间50ms。

首先优化Minor GC频繁问题。通常情况下,由于新生代空间较小,Eden区很快被填满,就会导致频繁Minor GC,因此可以通过增大新生代空间来降低Minor GC的频率。例如在相同的内存分配率的前提下,新生代中的Eden区增加一倍,Minor GC的次数就会减少一半。

扩容Eden区虽然可以减少Minor GC的次数,但会增加单次Minor GC时间么?

扩容后,Minor GC时增加了T1(扫描时间),但省去T2(复制对象)的时间,更重要的是对于虚拟机来说,复制对象的成本要远高于扫描成本,所以,单次Minor GC时间更多取决于GC后存活对象的数量,而非Eden区的大小。因此如果堆中短期对象很多,那么扩容新生代,单次Minor GC时间不会显著增加。

服务中存在大量短期临时对象,扩容新生代空间后,Minor GC频率降低,对象在新生代得到充分回收,只有生命周期长的对象才进入老年代。这样老年代增速变慢,Full GC频率自然也会降低。

如何选择各分区大小应该依赖应用程序中对象生命周期的分布情况:如果应用存在大量的短期对象,应该选择较大的年轻代;如果存在相对较多的持久对象,老年代应该适当增大。

案例二:请求高峰期发生GC,导致服务可用性下降

GC日志显示,高峰期CMS在重标记(Remark)阶段耗时1.39s。Remark阶段是Stop-The-World的,即在执行垃圾回收时,Java应用程序中除了垃圾回收器线程之外其他所有线程都被挂起,意味着在此期间,用户正常工作的线程全部被暂停下来,这是低延时服务不能接受的。本次优化目标是降低Remark时间。

Remark阶段主要是通过扫描堆来判断对象是否存活。那么准确判断对象是否存活,需要扫描哪些对象?CMS对老年代做回收,Remark阶段仅扫描老年代是否可行?结论是不可行,原因如下:img如果仅扫描老年代中对象,即以老年代中对象为根,判断对象是否存在引用,上图中,对象A因为引用存在新生代中,它在Remark阶段就不会被修正标记为可达,GC时会被错误回收。 新生代对象持有老年代中对象的引用,这种情况称为跨代引用。因它的存在,Remark阶段必须扫描整个堆来判断对象是否存活,包括图中灰色的不可达对象。

灰色对象已经不可达,但仍然需要扫描的原因:新生代GC和老年代的GC是各自分开独立进行的,只有Minor GC时才会使用根搜索算法,标记新生代对象是否可达,也就是说虽然一些对象已经不可达,但在Minor GC发生前不会被标记为不可达,CMS也无法辨认哪些对象存活,只能全堆扫描(新生代+老年代)。由此可见堆中对象的数目影响了Remark阶段耗时。 分析GC日志可以得出同样的规律,Remark耗时>500ms时,新生代使用率都在75%以上。这样降低Remark阶段耗时问题转换成如何减少新生代对象数量

新生代中对象的特点是“朝生夕灭”,这样如果Remark前执行一次Minor GC,大部分对象就会被回收。CMS就采用了这样的方式,在Remark前增加了一个可中断的并发预清理(CMS-concurrent-abortable-preclean),该阶段主要工作仍然是并发标记对象是否存活,只是这个过程可被中断。此阶段在Eden区使用超过2M时启动,当然2M是默认的阈值,可以通过参数修改。如果此阶段执行时等到了Minor GC,那么上述灰色对象将被回收,Reamark阶段需要扫描的对象就少了。

除此之外CMS为了避免这个阶段没有等到Minor GC而陷入无限等待,提供了参数CMSMaxAbortablePrecleanTime ,默认为5s,含义是如果可中断的预清理执行超过5s,不管发没发生Minor GC,都会中止此阶段,进入Remark。 对于这种情况,CMS提供CMSScavengeBeforeRemark参数,用来保证Remark前强制进行一次Minor GC。

由于跨代引用的存在,CMS在Remark阶段必须扫描整个堆,同时为了避免扫描时新生代有很多对象,增加了可中断的预清理阶段用来等待Minor GC的发生。只是该阶段有时间限制,如果超时等不到Minor GC,Remark时新生代仍然有很多对象,我们的调优策略是,通过参数强制Remark前进行一次Minor GC,从而降低Remark阶段的时间。

新生代GC存在同样的问题,即老年代可能持有新生代对象引用,所以Minor GC时也必须扫描老年代。

JVM是如何避免Minor GC时扫描全堆的? 经过统计信息显示,老年代持有新生代对象引用的情况不足1%,根据这一特性JVM引入了卡表(card table)来实现这一目的。如下图所示:

img

卡表的具体策略是将老年代的空间分成大小为512B的若干张卡(card)。卡表本身是单字节数组,数组中的每个元素对应着一张卡,当发生老年代引用新生代时,虚拟机将该卡对应的卡表元素设置为适当的值。如上图所示,卡表3被标记为脏(卡表还有另外的作用,标识并发标记阶段哪些块被修改过),之后Minor GC时通过扫描卡表就可以很快的识别哪些卡中存在老年代指向新生代的引用。这样虚拟机通过空间换时间的方式,避免了全堆扫描。

总结来说,CMS的设计聚焦在获取最短的时延,为此它“不遗余力”地做了很多工作,包括尽量让应用程序和GC线程并发、增加可中断的并发预清理阶段、引入卡表等,虽然这些操作牺牲了一定吞吐量但获得了更短的回收停顿时间。

发布了3 篇原创文章 · 获赞 0 · 访问量 183

猜你喜欢

转载自blog.csdn.net/weixin_43867524/article/details/105597497