垃圾回收

51

相关概述

手动回收

在默认情况下调用System.gc()或者Runtime.getRuntime().gc()会显示的触发FullGC从所有丢弃的空间回收兑现,但是只是提醒jvm进行垃圾回收,无法保证对垃圾收集器的调用

可以通过手动调用触发来决定JVM的GC行为,但是一般情况下垃圾回收应该是自动进行的,无需手动触发.不过在一些特殊情况,例如编写一个性能基准测试,在执行前先触发一次GC,保证之后性能测试结果更精准

日常开发中不要使用System.gc()会导致STW

public void localGC1() {
    byte[] buffer = new byte[10 * 1024 * 1024];
    System.gc(); // buffer不会被释放
}
public void localGC2() {
    byte[] buffer = new byte[10 * 1024 * 1024];
    buffer = null;
    System.gc(); // buffer为null,会被释放
}
public void localGC3() {
    {
        byte[] buffer = new byte[10 * 1024 * 1024];
    }
    System.gc(); // 特殊情况,buffer不会被释放
}
public void localGC4() {
    {
        byte[] buffer = new byte[10 * 1024 * 1024];
    }
    int value = 10;
    System.gc(); // buffer会被释放
}

局部变量表是垃圾回收的根结点,虽然localGC3的buffer已经超过了作用域,但是还存在在局部变量表中所以不会被释放.localGC4的buffer会被释放,那是因为局部变量表的复用机制,变量value覆盖了buffer的slot,导致buffer不存在于局部变量表中,所以导致会被释放

可以通过添加-XX:PrintGCDetails来查看GC执行情况

内存溢出

当应用程序占用的内存增长速度快,造成垃圾回收已经跟不上内存消耗的速度则会造成内存溢出.在大部分情况下,GC会对各个年龄段的内存区域进行回收,直到最后一次独占式的FullGC还是无法申请到足够的内存,则会造成OutOfMemoryError(没有空闲内存并且垃圾收集器也无法提供更多的内存)

导致内存溢出的原因主要有二

  • JVM的内存空间设置不合理,可以通过-Xmx和-Xmx来调整

  • 创建了大量对象并且长时间不能被垃圾回收器收集

内存泄漏

内存中的对象不会被应用程序用到了,但是垃圾收集器又不能回收这些对象的时候就被称为内存泄漏.尽管内存泄漏并不会立刻引起应用程序崩溃,但是一旦发生内存泄漏,可用的内存就会被逐步蚕食,直到耗尽所有内存,最终更有可能发生内存溢出并导致应用程序的崩溃

一些不太好的代码会导致对象生命周期变得过长,也属于宽泛意义上的内存泄漏.如将可以是局部变量的变量写在成员变量上并使用static修饰

垃圾指的是运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾

如果不对内存中的垃圾进行清理,那么这些垃圾对象所占用的内存空间会一直保留到应用程序结束,被保留的空间无法被其它对象使用,还会导致内存泄漏.并且垃圾回收也可以清除内存中的碎片,以便腾出空间分配给新的对象.随着应用程序所应付的业务越来越复杂,没有GC就不能保证程序的正常执行,但是不断GC也会频繁造成STW,所以JVM的开发者才会不断尝试堆GC进行优化

自动内存管理,可以使开发人员无需手动分配和回收内存,降低内存泄漏和溢出的风险,更专注于业务开发.不过如果开发人员过于依赖,则会弱化开发人员定位和解决内存异常问题的能力.所以必须采用一些技术来监控和调节程序

STW

stop-the-world(让整个世界停下来)指在GC发生的过程中会产生的应用程序的停顿,在停顿的产生时整个应用程序都会被暂停并且没有任何响应

通过可达性分析中枚举节点(GC Roots)会导致所有java执行程序的停顿,被STW中断的应用程序将会在完成GC之后恢复,频繁中断会让用户体验下降,所以要减少STW的发生

分析工作必须在一个能确保一致性的快照中进行,整个分析期间整个执行系统就像被冻结在这个时间点,如果出现分析过程中对象的引用关系还在不断的变化,则分析结果的准确性讲无法保证

所有垃圾回收器都有STW,不过现在的垃圾回收器效率越来越高,并且更加注重缩短暂停时间

并行与并发

了解并发需要了解时间片的概念,操作系统会将所有就绪的进程先来先服务的原则排成队列,每次调度时把cpu分配给队首进程并令其执行一个时间片(几十毫秒),当执行的时间片用完时,会有一个计时器中断请求,并且据此信号来暂时停止该进程的执行,然后在分配给队列中新的队首进程,同时也让它执行下一个时间片.这样就可以保证就绪队列中的所有进程,在一给定的时间段内均能获得一时间片的处理机执行时间

  • 并发是指一个处理器同时处理多个任务,只在逻辑上同时发生.在同一时刻只能有一条指令执行,但是多个进程被快速轮换执行,使得在一个时间段(宏观)上具有多个进程同时执行,但是在时间点(微观)上并不是同时执行的

  • 并行是指多个处理器或者是多核的处理器同时处理多个不同的任务,而并行是物理上的同时发生.在同一时刻,有多条指令在多个处理器上执行,在时间段上观察还是在时间点上观察都是同时执行的

  • 串行是指一个处理器一次只执行一个任务,只有执行完当前任务以后才会执行下一个任务

只有在多核CPU或者多个CPU上才存在并行

以上概念对应的垃圾回收器

  • 并发 指用户线程与垃圾收集线程同时执行,垃圾回收线程在执行时不会停顿用户程序的运行

CMS、G1

  • 并行 指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态

ParNew、Parallel Scavenge、Parallel Old

  • 串行 单线程执行,如果内存不够则程序暂停,启动垃圾回收器进行垃圾回收,回收完再启动程序的线程

Serial、Serial Old

安全点

应用程序的执行并非所有的地方都是和停顿下了进行GC,只有在特定的位置才适合,这些位置被称为安全点

安全点的选择很重要,如果太少可能导致GC等待时间过长,如果太频繁则会导致性能问题.选择一些执行时间较长的指令时更适合作为安全点,如方法调用、循环跳转、异常跳转等,而在一些指令本身就执行的过于短暂的时候作为安全点反而会导致用户体验下降

检查所有线程都处于安全点的方式

  • 抢占式 首先主动过暂停所有线程,如果还有线程不在安全点就恢复线程,直到所有线程跑到安全点

  • 主动式 设置中断标志,各个线程运行到安全点的时候判断该标志,如果为真则将本线程挂起

但当线程执行处于休眠状态或阻塞状态时,此时线程无法响应中断请求,JVM也不可能等待线程被唤醒,此时就需要安全区域.安全区域指的是一段代码片中,对象的引用关系不会发生变化,在这个区域中任何位置进行GC都是安全的,可以把安全区域理解为扩展了的安全点

如果需要发生GC,JVM会忽略此时处于安全区域的线程,并且只有JVM完成GC后才被允许离开安全区域

引用

在jdk1.2之后对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用,这4种引用强度逐渐减弱

除了强引用外,其它的3种引用都能在java.lang.ref中找到

  • 强引用 代码中普遍存在的引用一般通过new关键字创建,只要强引用关系还存在垃圾回收器就不会回收掉被引用的对象

对于一个强引用的对象只要是可触及的,JVM宁可抛出OOM也不会回收强引用的对象,就不可能被垃圾回收器回收,只有超过了作用域范围或者显示的赋值为null才会被垃圾回收

强引用会导致内存泄漏,也是造成OOM的主要原因

  • 软引用 在虚拟机发生内存溢出之前,将会把这些对象放入回收范围之内进行二次回收(第一次回收指的是回收不可达对象)

软引用通常用来实现高速缓存,如果还存在空闲内存就暂存缓存,不足时再清理,这样保证了使用缓存的同时不会耗尽内存

当垃圾回收器觉得需要回收软可达的对象时,会清除软引用并可选的把引用存放到一个引用队列中(前提准备了引用队列到构造方法中)

// 声明强引用并传递给SoftReference对象
Object obj = new Object();
SoftReference<Object> objectSoftReference = new SoftReference<>(obj);
obj = null; // 最后只保留弱引用
  • 弱引用 被弱引用关联的对象只能存活到下一次垃圾回收之前,只要垃圾回收器工作就会回收弱引用的对象

弱引用和软引用类似,不过弱引用只能存活到下一次垃圾收集发生为止,不管内存是否充足都会将弱引用回收

不过垃圾收集器的线程通常优先级较低,因此并不能很快发现持有弱引用的对象,在这种情况下,弱引用的对象也可以存在较长时间

static Map<Object, Object> weakHashMap = new WeakHashMap<>();
​
public static void main(String[] args) {
     weakHashMap.put("test", new Object());
     Object test = weakHashMap.get("test");
 }

相比于弱引用,软引用可能会导致内存泄漏,只有在内存不足才会被回收,所以软引用何时回收难以预测,并且jvm需要花费额外的开销来跟踪对象的引用情况.所以在缓存的角度上使用弱引用的场景更多

  • 虚引用 虚引用完全不会对其生存构成影响,也无法通过虚引用获得对象的实例,为一个对象设置虚引用关联的唯一目的就是能在垃圾收集器收集时获得一个系统通知

虚引用完全不会决定对象的什么周期,通过get方法获取对象时永远是null

和其它的Reference的子类不一样的是,创建虚引用必须要传递引用队列.当垃圾收集器准备回收一个对象时,如果发现它还有虚引用就会在回收对象后将这个虚引用加入引用队列,以通知应用程序对象的回收情况

static PhantomReferenceTest obj;
static PhantomReferenceTest obj2;
static ReferenceQueue<PhantomReferenceTest> phantomQueue = null;
​
public static void main(String[] args) {
      phantomQueue = new ReferenceQueue<PhantomReferenceTest>();
      obj = new PhantomReferenceTest();
      obj2 = new PhantomReferenceTest();
​
      PhantomReference<PhantomReferenceTest> phantomRef = new PhantomReference<>(obj, phantomQueue);
      PhantomReference<PhantomReferenceTest> phantomRef = new PhantomReference<>(obj2, phantomQueue);
}

每个Reference都有一个带ReferenceQueue的构造函数,可以在外部对这个queue进行监控.如果有对象即将被回收,那么相应的reference对象就会被放到这个queue里

public class CheckRefQueue extends Thread {
    @Override
    public void run() {
        while (true) {
            if (phantomQueue != null) {
                PhantomReference<PhantomReferenceTest> objt = null;
                try {
                    objt = (PhantomReference<PhantomReferenceTest>) phantomQueue.remove();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                if (objt != null) {
                    System.out.println("追踪垃圾回收过程,实例被GC了");
                }
            }
        }
    }
}

回收器概述

GC性能指标主要分为以下三种,三者总体表现会随着技术迭代而越来越好,一款优秀的收集器通常最多同时满足其中的两个

  • 吞吐量 运行用户代码的时间占运行时间的比例

  • 暂停时间 执行垃圾收集是程序工作线程被暂停的时间

  • 内存占用 JVM堆区所占用的内存大小

其中暂停时间的重要性日益凸显,因为随着硬件的发展,内存的占用越来越能容忍,硬件性能的提升也有助于降低收集器对应用程序的影响(吞吐量),而内存的扩大对延迟反而是负面效果

吞吐量和低延迟

高吞吐量和低暂停时间是一堆相互矛盾的目标

  • 吞吐量优先,那么必然需要降低内存回收的执行频率,这样会导致GC需要更长的暂停时间来执行内存回收

  • 低延迟优先,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收,但这又引起了年轻代内存的缩减和导致程序吞吐量的下降

每个GC算法只能针对两个目标之一,只专注较大吞吐量或最新暂停时间或者找到两者的折中(最大吞吐量有限的情况下降低停顿时间)

一个交互式的应用程序更会选择低延迟的垃圾回收器

官方文档

垃圾收集器组合

下图中蓝色矩形代表年轻代收集器,红色代表老年代收集器,其中G1GC都支持

使用黑线连接代表他们可以相互搭配使用

其中Serial Old作为CMS出现Concurrent Mode Failure失败的后备预案

红色虚线连接代表它们只有在jdk1.8之前才能搭配使用,之后的版本组合被废弃

绿色虚线代表在jdk14被弃用了该组合,CMS GC在jdk14被整个弃用

垃圾回收组合

java的使用场景很多,所以需要针对不同的场景提供不同的垃圾收集器提高垃圾收集的性能,虽然会对各个收集器进行比较,但并非为挑选一个最好的收集器,没有一个收集器能完美适应所有场景,所以只能选择最适合的垃圾收集器

可以通过以下指令查看当前jvm使用的垃圾收集器

-XX:+PrintCommandLineFlags

也可以使用命令行的方式查看,jdk1.8默认使用UseParallelGC和UseParallelOldGC

jps
$ 81093 GcUseTest
jinfo -flag  UseParallelGC 81093
$ -XX:+UseParallelGC
jinfo -flag  UseParallelOldGC 81093
$ -XX:+UseParallelOldGC

主要垃圾回收器

收集器

分类

作用位置

使用算法

特点

适用场景

Serial

串行运行

作用于新生代

复制算法

响应速度优先

适用于单CPU环境下的client模式

ParNew

并行运行

作用于新生代

复制算法

响应速度优先

多CPU环境Server模式下与CMS配合使用

Parallel

并行运行

作用于新生代

复制算法

吞吐量优先

适用于后台运算而不需要太多交互的场景

Serial Old

串行运行

作用于老年代

标记-压缩算法

响应速度优先

适用于单CPU环境下的Client模式

Parallel Old

并行运行

作用于老年代

标记-压缩算法

吞吐量优先

适用于后台运算而不需要太多交互的场景

CMS

并发运行

作用于老年代

标记-清除算法

响应速度优先

适用于互联网或B/S业务

G1

并发、并行运行

作用于新生代、老年代

标记-压缩算法、复制算法

响应速度优先

面向服务端应用

如何选择

  • 优先调整堆的大小让JVM自适应完成

  • 内存小于100M或是单核并且没有停顿时间要求,使用串行收集器

  • 在多CPU、追求高吞吐量、停顿时间超过1秒,可以选择并行

  • 在多CPU、追求低延迟,需要快速响应,使用并发收集器.官方建议使用G1收集器

Serial

Serial收集器(串行)是最基本也是历史最久的垃圾收集器,jdk1.3之前回收器的唯一选择

对于单核cpu上运行有不错的性能,相对于其它收集器在单线程的使用场景下更高效(没有于其它线程交互的开销),只会使用一个收集线程完成垃圾收集的工作,并且进行垃圾收集器就暂停其他工作线程直到它收集结束

  • 年轻代采用复制算法、串行回收和STW机制的方式执行内存回收

  • 老年代对应的是SerialOld收集器,和Serial的区别是采用标记压缩的算法

Serial收集器

单个CPU并且内存不大(几十到几百MB),并且较短时间内完成垃圾收集(几十毫米),只要不频繁发生回收行为使用该回收器都是可以接受的

以下参数开启Serial和SerialOld

-XX:+UseSerialGC

ParNew

除了采用并行回收的方式执行内存回收外与Serial几乎没有区别

可以利用多CPU或多核的物理硬件资源,可以更快速的完成垃圾收集提升程序吞吐量,但是在系统资源匮乏的场景下,由于不需要频繁的切换上下,Serial效率更高效

ParNew收集器

以下参数开启ParNew,在jdk9以后该收集器被移除

-XX:+USeParNewGC
-XX:+UseConcMarkSweepGC # 与CMS搭配
-XX:ParallelGCThreads # 手动指定收集线程数

Parallel Scavenge

和ParNew类似也是基于并行回收,也采用了复制算法和STW,不过与ParNew不同的是,它的目标是达到可控制的吞吐量,吞吐量优先的收集器,并且拥有自适应调节策略(内存分配情况)

在吞吐量优先的场景下Parallel和Parallel Old的组合内存回收性能不错,java8中默认使用该垃圾收集器组合

高吞吐量可以高效率的运行cpu,尽快完成程序的运算而不需要太多交互的任务,如批量处理,订单处理,工资支付等等

Parallel Old采用了标记压缩、并行回收和STW

相关参数设置

-XX:+UseParallelGC 
-XX:+UseParallelOldGC # 当一个开启另一个也会开启
-XX:ParallelGCThreads # 设置年轻代并行收集器线程数,最好与cpu核心数相等,默认核心数小于8则为8,否则值为3+[5*CPU_COUNT/8]

设置STW时间

-XX:MaxGCPauseMillis # 设置垃圾收集器的最大停顿时间,单位毫秒

为了将时间控制在MaxGCPauseMillis以内,JVM在工作时会调整java堆的大小和其他参数

该参数设置太小会导致GC频率过高

该参数设置需谨慎

设置吞吐量

-XX:GCTimeRatio # 垃圾收集时间占总时间比例 (运行用户代码的时间占运行时间的比例),默认99,也就是回收时间不超过1%

取值0-100,和上一个参数有一定矛盾性

是否开启自适应调节策略

-XX:+UseAdaptiveSizePolicy

年轻代的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数会自动调整,以达到堆大小、吞吐量、停顿时间之间的比例

默认开启,因为手动设置较为困难,让虚拟机自己完成调优工作

CMS

CMS(Concurrent-Mark-Sweep),是Hotspot虚拟机真正意义上的并发收集器,第一次实现了垃圾收集器与用户线程同时工作,尽可能的缩短垃圾收集时用户线程的停顿时间低延迟优先,良好的响应速度能提升用户的体验

该收集器在jdk14被删除,jdk9被弃用 如果在jdk14使用,则会自动切换到默认的垃圾收集器

CMS使用了标记清除算法,并且也会STW

不过CMS仅能作为老年代的收集器,并且无法与Parallel Scavenge配合工作,所以当选择CMS来收集老年代的时候,新生代只能选择ParNew和Serial中的一个

CMS收集器

CMS的收集较为复杂,分为4个阶段

  • 初始标记 所有用户线程会在此时进行STW(由于直接关联对象比较小,所以速度非常快),此时仅仅只是标记出GC Roots最初关联到的对象,一旦标记完成后就会恢复被暂停的线程

  • 并发标记 从GC Root关联对象开始遍历整个GC Roots,这个阶段耗时较长但是不需要STW,可以和用户线程并发执行

  • 重新标记 由于在并发标记阶段中,收集线程和用户线程交叉运行.为了修正并发标记期间用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,所以此时也需要STW(否则永远无法确定存活对象)

  • 并发清除 清理删除掉标记阶段判断的已经死亡的对象,释放内存空间.由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的

    用户线程正在执行如果标记整理(移动)的话,用户线程中使用的内存就会产生错误

    CMS的垃圾回收算法是标记清除,所以会产出不连续的内存块,所以分配新对象的时候还需要维护空闲列表

尽管CMS采用的是非独占式的并发回收,但是初始化阶段和再次标记阶段仍然会较短时间的STW

目前所有的垃圾回收器不可能不进行STW,只能尽可能缩短

并且CMS在回收过程中应该确保应用程序有足够的内存(堆内存使用率达到阈值就进行回收),而不能像其它的收集器一样等到内存几乎填满了在收集

因为CMS是并发执行的,在回收阶段用户线程也在执行,如果不预留足够的内存更有可能导致内存溢出

若CMS运行是预留内存也无法满足程序的需求就会导致Concurrent Mode Faiilure,JVM就会暂时使用Serial Old收集器(串行)来回收老年代,这样停顿时间反而会更长

通过上述总结,CMS有并发收集、低延迟的优点,同时为了实现这些优点,同时出现了更多缺点

  • 会产生内存碎片

因为使用了标记清除,所以更容易出现无法分配大对象导致Full GC

  • 对CPU资源非常敏感

垃圾清除线程和用户线程同时并发执行,导致总体程序运行效率降低

  • 无法处理浮动垃圾

在并发标记阶段本身没被标记为垃圾,但是在重新阶段被标记为垃圾(产生的新的垃圾对象),无法对这些对象进行回收,从而只能在下一次执行GC时释放这些内存空间

  • 需要预留更多内存

相关参数设置

设置该老年代收集器

-XX:+UseConcMarkSweepGC # 指定使用CMS收集器,自动打开-XX:+UseParNewGC

设置垃圾回收阈值

-XX:CMSInitiatingOccupanyFraction # 设置堆内存使用率的阈值,达到该阈值便开始进行回收

jdk5及以前版本的默认值为68,jdk6及以上版本默认值为92

如果内存增长缓慢可以设置一个较大的值,可以有效减少垃圾回收的频率

如果内存增长迅速则需要设置一个较小的值,以避免频繁触发老年代串行收集器

内存整理

-XX:+UseCMSCompactAtFullCollection # 开启内存整理,会导致GC时间延长
-XX:CMSFullGCsBeforeCompaction # 设置在执行多少次Full GC后对内存空间进行压缩整理

设置线程数量

-XX:ParallelcMSThreads

CMS默认启动的线程数是(ParallelGCThreads+3)/4,ParallelGCThreads是年轻代并行收集器的线程数

因为CMS对CPU资源非常敏感,当CPU资源比较紧张时,受到CMS收集器线程的影响,应用程序的性能在垃圾回收阶段大大降低

G1

在应用程序业务越来越庞大越来越复杂,并发量越来越高,所以需要对GC进行不断的优化,G1垃圾收集器实在java7引入的一个新垃圾收集器

G1的设计目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起全功能收集器的期望

G1的侧重点是回收垃圾的最大Region,所以它的名字是垃圾优先Garbage First

Region

G1是一个并行回收器,并且它把堆内存分割成大约2048个独立的Region块,多个Region来表示Eden、Survivor0区、Survivor1区、Old等

新生代和老年代在是物理隔离的,它们都是一个个Region的集合,通过Region的动态分配方式实现逻辑上的连续

所有Region大小相同,每个容量在1-32MB之间,且在JVM生命周期内不会改变

每个Region都是通过指针碰撞来分配空间

一个Region可能属于Eden、Survivor、Old,空白表示未使用的内存空间,G1还增加了新的类型Humongous主要存储大对象

对于堆中的大对象,默认会直接放入老年代,但是如果是一个短期存在的大对象就会对垃圾收集器造成负面影响,为了解决这个问题G1划分了Humongous来专门存储大对象.若一个H区装不下一个大对象,会选择连续的Region来存储.有时为了找到连续的H区不得不启动Full GC.G1大多是行为都把H区作为老年代的一部分看待

如果一个对象的大小已经超过Region大小的50%了就会被放入H区

分区算法

GC时有计划的避免在整个堆中进行全区域的垃圾收集,跟踪各个Region内的价值大小,并且维护一个优先列表,每次根据允许的收集时间,有限回收价值最大的Region

价值指得是回收所获的的空间大小和回收所需时间

优点

  1. 并行并发

  • 在回收期间可以有多个GC同时工作,有效利用多核计算能力

  • 拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,并且不会在回收阶段发生完全阻塞的情况

  1. 分代收集

  • 依然属于分代型垃圾回收器,但从对的结构上并不要求各个年轻代、老年代是连续的、固定大小、固定数量

  • 堆空间分为若干个Region

  • 并且和其它收集器不同,同时兼顾年轻代收集和老年代收集

  1. 空间整合

G1将内存划分为若干个的Region,内存的回收是以Region作为基本单位的.Region之间通过复制算法避免内存碎片

这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC

  1. 可预测的停顿时间模型

G1除了追求低延迟以外,还会建立可预测的停顿时间模型(soft real-time尽可能在给定的时间内),使JVM在执行M毫秒的时间段内消耗在垃圾收集上的时间不会超过N毫秒

  • 由于分区Region的原因,G1可以只选取部分区域进行回收,这样减少了回收范围,因此对于全局停顿的情况可以进行很好的控制

  • G1跟踪各个Region的价值大小,每次根据允许的收集时间,优先回收价值最大的Region.确保有限时间内尽可能高的回收效率

在一些场景下可以替换CMS

  • 超过一半的堆空间是活动数据

  • 对象分配频率或年轻代提升频率变化很大

  • GC停顿时间过长

缺点

应用程序运行中时,G1无论是垃圾收集产生的内存占用,还是运行时的额外复杂都比CMS高.在较小的内存上CMS的性能会优于G1,G1更适合大内存上进行运行.内存在6-8G两者差距不大

回收过程

GC回收过程分为以下

  • Young GC

  • 老年代并发标记过程(Concurrent Marking)

  • Mixed GC

  • Full GC (独占式高强度的Full GC是针对GC评估失败的一种失败保护机制)

执行优先级依次为Young GC、Young GC+Concurrent Marking、Mixed GC、Full GC

  • 当Eden区用尽时,开始年轻代回收过程,G1的年轻代收集阶段是一个并行的独占式收集器.在年轻代回收时,会进行STW,启动多线程执行年轻代回收.然后从年轻代区间移动存活对象到Survivor区间或者老年区间

  • 当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程.标记完成后会进行混合回收,GC从老年区间移动存活对象到空闲区,这些空闲区也就成为了老年代的一部分.老年代的G1回收器和其他GC不同.G1的老年代回收器不需要整个老年代被回收,一次只需要扫描和回收一小部分老年代的Region

老年代只回收部分Region,年轻代是完整回收

记忆集

一个Region不是孤立的,一个Region可能被其他Region中的对象引用,也就是说判断对象是否存活需要扫描整个堆内存.特别是年轻代的对象被老年代所引用(跨代引用),只有包含了跨代引用的内存中的老年代对象才会加入到GC Roots扫描中

回收新生代还需要完整扫描老年代.这会导致Minor GC的执行效率降低

记忆集

所有G1(以及其它包含分代的收集器)都是通过记忆集(Rembered Set)来避免全局扫描

  • 每个Region都对应一个记忆集

  • 每个引用类型的数据进行写操作时,都会产生一个写屏障(Write Barrier)来暂时中断操作.并且检查将要写入的引用指向的对象是否和该引用类型的数据在不同的Region.如果不同,则通过卡表(CardTable)把相关引用信息记录到引用指向对象所在的Region对应的记忆集中

Region内相互引用显然没有这个问题

卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等

  • 在卡表中有一个或多个的跨代指针,则将对应的卡表元素标为1,代表变脏.当虚拟机扫描卡表元素为1时,便将对应的卡表内存区域加入到GC ROOT中一并扫描

卡表中的脏元素是先通过脏卡队列(drty card quene)记录记录引用信息的,在年轻代回收的时候G1会对队列中的卡进行处理,更新记忆集,保证真实的引用关系

结果集的处理是线程同步的,为了性能需要事先放入队列中而不是在赋值是就更新记忆集

  • 当进行垃圾回收时,在GC根阶段在枚举范围内加入记忆集,就可以保证不进行全局扫描越不会遗漏

回收过程

G1垃圾回收

  1. YoungGC

  • 扫描根 根节点和记忆集记录的外部引用对象作为扫描存活对象的入口

根节点指静态变量、正在执行的方法中的局部变量等

  • 更新记忆集 处理脏卡队列,更新记忆集

  • 处理记忆集 识别跨代引用并且存活对象

  • 复制对象 Eden中存活的对象会被复制到Survivor中,Survivor满足条件会被晋升到老年代中

具体参照[运行时数据区-垃圾收集]

并且复制过程中的对象都是连续存储的,所以复制过程可以达到内存整理的效果

  • 处理引用 最终Eden区数据为空,GC停止工作

  1. Young GC + Concurrent Marking

  • 初始标记阶段 标记从根节点直接可达的对象,此阶段时STW的,并且会触发YoungGC

  • 根区域扫描 扫描初始标记的存活区中(survivor 区)可达的老年代区域对象并标记可以被引用的对象,这一过程必须在YoungGC之前完成

因为记忆集是不记录从Young Region出发的引用,那么就可能出现一个老年代的存活对象,只被年轻代的对象引用.在一次Young GC中,这些存活的年轻代的对象会被复制到Survivor Region,因此需要扫描这些Survivor Region来查找这些指向老年代的对象的引用,作为并发标记阶段扫描老年代的根的一部分

  • 并发标记 整个对中进行并发标记(和应用程序并发执行),并且可能被YoungGC中断.再次阶段中,若发现Region中的对象全部都是垃圾,那这个区域会被立刻回收.并且此阶段还会判断Region中对象的存活比例

  • 再次标记 由于应用程序并发运行,所以需要通过STW修正上一次标记结果

G1使用了比CMS更快的初始快照算法SATB

  • 独占清理 计算各个区域的存活对象和GC回收比例,并进行排序.识别可以混合回收的区域

此阶段时STW的,并且不会进行垃圾收集

  • 并发清理 识别并清理完全空闲的区域

  1. MixedGC

为了避免堆内存被耗尽,会触发一个Mixed GC,除了回收整个Young Region还可以选择哪些Old Region进行收集,从而可以对垃圾回收的耗时时间进行控制

  • 并发标记结束以后,全部是垃圾的Region被回收了,部分为垃圾的Region被计算了出来.默认情况下,这些老年代的内存分段会分8次

可以通过-XX:G1MixedGCCountTarget设置分段次数

  • 混合回收包括年轻代Region和1/8的老年代Region.由于老年代要被分多次回收,所以G1会优先回收垃圾比例多的分段.并且有默认65%的阈值决定是否被回收

可以通过-XX:G1MixedGCLiveThresholdPercent设置Region回收阈值

如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间

  • G1允许整个堆内存默认10%的空间被浪费,如果比例低于该值则不会进行混合回收

可以通过-XX:G1HeapWastePercent设置整堆回收比例

GC会花费很多的时间,但是回收到的内存却很少

  1. FullGC G1设计的初衷就是避免FullGC,如果执行了FullGC,会导致STW,并且应用程序停顿时间会很长,一旦出现FullGC就要考虑进行调整

主要原因有2个

  • 没有足够的内存存储晋升对象

  • 并发处理过程之前内存就耗尽

解决方法

  • 增加JVM的堆内存

  • 避免使用-Xmn-XX:NewRatio等参数显示设置年轻代大小

因为年轻代回收是独占式的,固定年轻代大小会覆盖暂停时间的目标

  • 暂停时间目标不要太过于严苛.暂停时间过小影响到吞吐量,导致垃圾回收开销增大

G1GC的吞吐量目标是90%的应用程序时间和10%的垃圾回收时间

提高垃圾收集器的最大停顿时间

相关参数设置

开启G1

-XX:+UseG1GC

G1在jdk9中是默认收集器,如果需要在jdk8中使用则需要该指令

设置Region的大小

-XX:G1HeapRegionSize 

默认是堆内存的1/2000.值是2的幂,范围在1MB到32MB之间,根据最小的堆大小划分出约2048个区域

期望达到的最大GC停顿时间

-XX:MaxGCPauseMillis

默认值是200ms

JVM会尽力实现,但不保证达到

STW工作线程数的值

-XX:ParallelGCThread

STW是GC线程数,最多设置为8

设置并发标记的线程数

-XX:ConcGCThreads

设置并行垃圾回收线程数,建议设置在ParallelGCThreads的1/4

设置触发并发GC周期的堆占用率阈值

-XX:InitiatingHeapOccupancyPercent

超过此值触发GC.默认值是45

标记阶段

在堆中存放着所有对象实例,在垃圾回收前需要区分出哪些是存活对象哪些不是,之后被标记已经死亡的对象,GC才会在执行垃圾回收时释放掉其占用的内存,释放掉其占用的内存,这个阶段被称为垃圾标记阶段

引用计数

实现比较简单,对每个对象保存一个整形的引用计数器,用于记录对象被引用的情况

当对象A,只要有任何一个对象引用了A,则对象A的引用计数器就加1,当引用失效时,引用计数器就减1,只要对象A的引用计数器值为0,则说明该对象不再被使用可以被回收

这种算法实现简单,垃圾对象便于辨识效率高,回收没有延迟性(无需等待到内存满了在回收),也用很多缺点

  • 需要单独存储计数器,增加了存储空间的开销

  • 每次赋值需要更新计数器,伴随着加法或减法操作,增加了时间开销

  • 无法处理循环引用的情况

无法处理循环以来是一个严重缺陷,所以主流jvm中都没有使用这类算法

循环引用

虽然java没有使用这种算法,但是一些语言例如python使用,解决方式有两种

  • 手动解除,在合适的时机手动解除引用关系

  • 使用弱引用weakref,弱引用允许引用对象但不增加对象的引用计数

可达性分析

可达性分析不仅支持实现简单和执行高效,更重要的是该算法可以有效的解决在引用计数算法中无法释放循环引用的问题,防止内存泄漏.这种类型的垃圾收集也被称为追踪性垃圾收集

相比之下执行效率比引用计数差一些

可达性分析

可达性分析算法是以根对象集合为起始点,按照从上至下的搜索被根节点所连接的目标对象是否可达

内存中的存活对象都会被根节点直接或间接的连接着,搜索走过的路径被称为引用链

目标对象中没有任何引用链相连则是不可达的垃圾对象需要被标记为垃圾对象.只有被根节点直接或间接连接的对象才是存活对象

GC Root

主要包含以下几种元素

  • 虚拟机栈和本地方法栈中所引用的对象

  • 方法区中的静态属性所引用的对象

  • 常量引用的对象

  • 被同步锁synchronized所持有的对象

  • 基本数据类型所对应的对象,常驻的异常对象,系统类加载器

  • 内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

只要是一个引用,它保存了堆内存中的对象,但它本身又不存放在堆内存里,他就是Root

可达性分析算法来判断内存是否可以被回收,那么分析工作必须在一个能保证一致性的快照中进行,这点无法满足的话分析结果的准确性就无法保证,这也就是GC时必须STW的重要原因

工具使用

首先生成dump文件

  • 可以使用jmap命令

  • 使用visualVM的heap Dump(可以通过save as保存)

  • -XX:+HeapDumpOnOutOfMemoryError 当OOM生成dump文件

  • -XX:+HeapDumpBeforeFullGC 产生fullgc时生成dump文件

  • -XX:HeapDumpPath 可选,当使用以上两个命令时指定生成目录

生成dump文件后可以使用Mat工具分析gc roots

file-open heap dump选择需要导入的dump文件

也可以使用jprofiler,选择对应的attach后

在实时内存中选择需要的对象

进入后选择传入引用,选择需要的对象,点击显示到GC根路径

清除阶段

当成功区分出内存中存活的对象后,之后就是执行垃圾回收,释放掉无用的对象占用的内存空间,目前常用的垃圾收集算法是标记清除、复制算法、标记压缩算法

标记清除

当对中有效内存被耗尽时,就会停止整个程序(STW),然后进行标记和清除

  • 标记 收集器从引用根节点进行遍历标记所有被引用的对象(可达对象)

  • 清除 收集器对堆内存中进行线性遍历,如果发现某个对象没有标记为可达对象则进行回收

使用这种方法有一些明显的缺点标记清除算法效率不算高,执行GC时需要停止整个程序,并且使用这种方式i清理的内存时不连续的需要维护一个空闲列表

清除的对象保存在空闲列表里,当有新的对象创建时直接覆盖在原有的地址

标记清除

复制

将内存空间分为两块,每次只使用其中的一块,在垃圾回收时将正在使用中的内存对象复制到未使用的内存块中,之后清除正在使用的内存块中的所有对象,最后完成垃圾回收,在此之后存储到另一块内存区域中

优点很明显没有标记和清除的过程,实现更简单,运行也更高效,并且复制过后可以保存内存连续性不会出现碎片

缺点也很明显

  • 需要两倍的内存空间,或者说永远会有一半的内存空间无法被使用

  • 并且因为是复制,所以还需要特别维护对象与根节点的引用关系

栈内存指向堆内存地址改变了,并且如果没有使用句柄池而是直接指向所以有这方面的考虑

  • 如果存活对象过多,也会大大影响性能

标记压缩

在标记清除的基础上再进行一次内存碎片整理,现代的垃圾收集器都是用到了标记压缩算法

标记压缩的效果等同于标记清除指向后,在进行一次内存碎片的整理.二者的差异在于标记清除时一种非移动式的回收算法,运动回收是一项优缺点并存的决策,标记存活的对象会被整理按照内存地址依次排列,未被标记的内存会被清理掉,如此以来需要创建新对象时只需要持有一个内存的起始地址即可(指针碰撞),无需维护空闲列表.缺点就是移动对象的同时,如果对象被其他对象引用还需要调整引用地址

标记压缩

标记清除

标记压缩

复制

效率

中等

最慢

最快

空间开销

少,但会堆积碎片

少,堆积碎片

需要至少两倍的空间

移动对象

分代收集

上述的所有算法都具有自己独特的优势,并没有一种算法可以完全替代其他的算法,分代算法应运而生

不同的对象生命周期是不同的,因此不同生命周期的对象可以采用不同的收集算法来提高回收效率,可以根据不同内存区域的特点使用不同的回收算法

目前几乎所有的GC都采用分代收集算法执行垃圾回收的,几乎所有的垃圾回收器都区分新生代和老年代

年轻代

区域相对于老年代较小,对象生命周期短,存活率低,回收频繁.在年轻代使用复制算法回收整理,复制算法效率和当前对象的大小有关,而复制算法内存利用率不高的问题,可以通过survivor的设计得到缓解

以jvm为例,默认新生代与survivor的默认比例是8:1:1,新生代与老年代的比例是1:2,也就是说真正被浪费的内存空间只有1/30

也并非真正意义上的浪费,而是复制算法需要的空闲的内存空间

老年代

区域较大,对象生命周期长、存活率高,回收不频繁.这种情况下在大量存活的对象,复制算法变得不合适,一般由标记清除或者标记压缩的混合实现

  • 标记阶段的开销与存活对象数量成正比

需要遍历出所有和根节点关联的可达对象

  • 清除阶段的开销与所管理区域的大小正相关

需要进行全老年代空间线性的遍历

  • 整理阶段的开销与存活的数据成正比

存活的对象越多,需要整理的对象越多

以Hotspot的CMS回收器为例,CMS是基于标记清除实现的,对于对象的回收效率很高,而对于碎片问题,CMS采用标记压缩算法的Serial Old回收器作为补偿措施.当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial Old执行Full GC以达到对老年代内存的整理

优化算法

增量收集算法

在上述的算法在垃圾回收过程中都会处于一种STW的状态,在此状态下应用程序所有用户线程都会挂起,直到垃圾回收完成,如果垃圾回收时间过长,那么应用程序就会被挂起很久从而影响用户的使用和系统的稳定性

一次性将所有的垃圾进行回收处理会造成系统的长时间停顿,那么就可以让垃圾收集线程和用户线程交替执行.每次垃圾收集线程只收集一小片区域的内存空间,接着切换到用户现场,依次反复直到垃圾收集完成.增量收集算法的基础任然是传统的标记清除和复制算法,不过增量收集算法是对线程间冲突的妥善处理,允许垃圾收集线程分阶段的方式完成标记清除和复制算法的工作

在垃圾回收过程中间歇性的执行了引用程序代码,所以能减少系统停顿时间,但是因为线程的不断上下文切换,会使得垃圾回收的总体成本下降,造成系统吞吐量下降

分区算法

在相同条件下,堆空间越大,一次GC所耗费的时间就越长,GC的停顿时间也会越长.为了更好的控制停顿时间,将一块大的内存区域氛围若干个小块.根据目标的停顿时间不再回收整个内存空间,而是每次合理的回收若干个小区间,从而减少每次GC所产生的停顿

分代算法是按照对象的生命周期长短划分区域,而分区算法是将整个堆空划分成若干个不同的小区域.每个区域都是独立使用、独立回收

分区算法

finalize

java允许开发人员在对象被销毁之前自定义逻辑,当垃圾回收器发现没有引用指向的对象,在垃圾回收之前,会先调用finalize方法.finalize方法需要在子类进行重写,一般用于资源释放,关闭文件,套接字和数据库连接等

  • finalize可能会导致对象方法复活

  • finalize方法执行时间是没有保障的,他完全有GC线程决定,若不发生GC则该方法没有执行的机会

  • finalize可能会影响GC性能

finalize一般不需要手动调用,而是完全交给JVM

@Deprecated(since="9")
protected void finalize() throws Throwable { }

如果从所有根节点都无发访问某个对象,说明该对象已经不在使用了并且此对象可能需要被回收,并进入缓刑阶段,一个无法触及的对象在某一个条件下可以复活自己

  • 可触及 从根节点开始,可以达到这个对象

  • 可复活 从对象所有引用都被释放,但是对象可能在finalize中复活

  • 不可触及 对象的finalize被调用并且没有复活,那么就会进入不可触及的状态,finalize方法只会被调用一次,所以不可被触及的对象无法复活

判断一个对象是否可以被回收,至少要经历两次标记过程

  • 对象obj到GC Roots没有引用链,则进行一次标记

  • 判断是否有必要执行finalize方法,如若该对象没有重写或者finalize已经被虚拟机调用则会被视为没有必要执行,obj对象会被判为不可触及

  • 如果有必要执行了finalize方法,那么obj会被插入到F-Queue队列中,并由一个由虚拟机自动创建、低优先级的Finalizer线程执行对应的finalize方法

  • 如果obj在执行了finallize方法中引用链进行联系,那么就会被移除即将回收的合集,如果再次出现了没有引用链的情况finalize方法不会被再次调用,对象会直接变成不可触及的状态

finalize方法是跳脱垃圾回收最后的机会,并且每个对象只会被Finalizer线程主动调用一次

public class CanReliveObj {
​
    private static CanReliveObj obj;
​
    @Override
    protected void finalize()  {
        obj = this;
    }
​
    public static void main(String[] args) throws InterruptedException {
        obj = new CanReliveObj();
        obj = null;
        System.gc(); // 手动置null,并且gc
        Thread.sleep(2000); // Finalizer线程优先度较低所以延迟2秒
        if (obj == null) { // alive
            System.out.println("obj is dead");
        } else {
            System.out.println("obj is still alive");
        }
        
        obj = null;
        System.gc();
        Thread.sleep(2000); // finalize只会被调用一次
        if (obj == null) { // dead
            System.out.println("obj is dead");
        } else {
            System.out.println("obj is still alive");
        }
    }
}

通过jdk1.9以后的版本也将finalize弃用,不建议使用

  • 不可控 调用的不可控下导致资源迟迟不被释放而出现异常,既然计算机资源有限,当要释放资源的时候就应该立刻释放

  • 影响性能 内存释放对于JVM来说只需要释放堆内存,而重写了finalize则需要做很多额外工作,如果出现死锁、异常、死循环消耗的成本就更高

  • 异常丢失 如果抛出了异常,这个异常会被舍弃,导致很难精准定位问题