天天看点

JAVA垃圾回收(GC)

JAVA垃圾回收:将已经分配出去的,但却不再使用的内存回收回来,以便能够再次分配。在 Java 虚拟机的语境下,垃圾指的是死亡的对象所占据的堆空间。

探索所有存活的对象:

  • 可达性分析

防止在标记过程中堆栈的状态发生改变:

  • 安全点机制来实现 Stop-the-world 操作,暂停其他非垃圾回收线程

回收死亡对象的内存的方式:

  • 会造成内存碎片的清除(标记清除)
  • 性能开销较大的压缩(标记整理)
  • 以及堆使用效率较低的复制

一、判断对象可以回收

1.引用计数法(JAVA不用)

简介:

引用计数法就是如果一个对象没有被任何引用指向,则可视之为垃圾。

首先需要声明,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存。

实现:

  • 给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加1;
  • 当引用失效时,计数器值减1.任何时刻计数器值为0的对象就是不可能再被使用的。

弊端:

不能检测到环的存在,很难解决对象之间相互循环引用的问题,会造成内存泄露

2.可达性分析算法

Java 虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象

扫描堆中的对象,看是否能够沿着 GC Root对象为起点的引用链找到该对象,如果找不到,表示该对象可以回收

优点:

  • 解决循环引用问题

可达性分析算法的问题(在多线程环境下)

  • 误报:将引用设置为 null时会发生(没有什么伤害)
  • 漏报:将引用设置为未被访问过的对象(一旦从原引用访问已经被回收了的对象,则很有可能会直接导致 Java 虚拟机崩溃)

哪些对象可以作为 GC Root ?

(1). 虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中引用的对象。

public class Test {
    public static void main(String[] args) {
    Test a = new Test();//其中a是栈帧中的本地变量,充当了GC Root 的作用
    a = null;//a的引用变为空,上面的new Test()对象变为可回收对象
    }
}      

(2). 方法区中的类静态属性引用的对象。

public class Test {
    public static Test s;//类中的静态属性,充当GC Root 的作用
    public static  void main(String[] args) {
    Test a = new Test();
    a.s = new Test();
    a = null;//Test()对象变为可回收
    }
}      

(3). 方法区中常量引用的对象。

​
public class Test {
    public static final Test s = new Test();//同上,只是这里s由静态变量变为常量
        public static void main(String[] args) {
        Test a = new Test();
        a = null;
        }
}      

(4). 本地方法栈中JNI(Native方法)引用的对象。

本地方法为JAVA调用非Java代码的接口, Java 通过 JNI 来调用本地方法, 而本地方法是以库文件的形式存放的,虚拟机只是简单地动态连接并直接调用指定的 native 方法。

(5)已启动且未停止的 Java 线程

二、Stop-the-world (STW)以及安全点(safepoint)

怎么解决对象引用漏报的问题呢?

停止其他非垃圾回收线程的工作,直到完成垃圾回收。(Stop-the-world),从而产生暂停时间(GC pause)

安全点(safepoint)机制

  • 当 Java 虚拟机收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,才允许请求 Stop-the-world 的线程进行独占的工作。

safepoint指的特定位置主要有:

  1. 循环的末尾 (防止大循环的时候一直不进入safepoint,而其他线程在等待它进入safepoint)
  2. 方法返回前
  3. 调用方法的call之后
  4. 抛出异常的位置

为什么不在每一条机器码或者每一个机器码基本块处插入安全点检测?

  • 安全点检测本身也有一定的开销
  • 即时编译器生成的机器码打乱了原本栈桢上的对象分布状况在进入安全点时,机器码还需提供一些额外的信息,来表明哪些寄存器,或者当前栈帧上的哪些内存空间存放着指向对象的引用,以便垃圾回收器能够枚举 GC Roots。由于这些信息需要不少空间来存储,因此即时编译器会尽量避免过多的安全点检测。

三、3种垃圾回收算法

1、标记清除(清除(sweep))Eden区

实现方法:

  1. 把死亡对象所占据的内存标记为空闲内存
  2. 将空闲内存记录在一个空闲列表(free list)之中
  3. 需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。

优点:

  • 速度快
  • 原理极其简单

缺点:

  • 产生大量内存碎片
  • 分配效率较低:需要逐个访问列表中的项来分配合适的内存空间

2、标记整理(压缩(compact))老年区

实现方法:

把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。

优点:

  • 没有内存碎片化的问题

缺点:

  • 速度慢(压缩算法的性能开销)

3、复制(copy)Survivor区

实现方法:

把内存区域分为两等分,分别用两个指针 from 和 to 来维护,并且只是用 from 指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到 to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容。

优点:

  • 没有内存碎片化的问题

缺点:

  • 堆空间的使用效率极其低下

四、分代回收算法

1、堆内存空间分布

JAVA垃圾回收(GC)

Java对象特点:

大部分的 Java 对象只存活一小段时间,而存活下来的小部分 Java 对象则会存活很长一段时间。

这里考虑新生代和老年区,其中新生代分为三个区,分别为:Eden 区,以及两个大小相同的 Survivor(from,to) 区,大小比默认为8:1:1。

2、堆空间的分配过程为:

  • 对象首先分配在伊甸园区域
  • 新生代空间不足时,触发 minor gc,伊甸园和 from 存活的对象使用 copy 复制到 to 中,存活的对象年龄加 1并且交换 from to(总有一个Survivor区是空的)
  • 如果出现大对象使得新生代空间不足,则会直接晋升到老年代
  • minor gc 会引发 stop the world,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
  • 当对象寿命超过阈值时,会晋升至老年代,最大寿命是15(4bit)
  • 当老年代空间不足,会先尝试触发 minor gc,如果之后空间仍不足,那么触发 full gc,STW的时间更长

3、内存分配的方法:TLAB

每个线程可以向 Java 虚拟机申请一段连续的内存,比如 2048 字节,作为线程私有的 TLAB。这个操作需要加锁,线程需要维护两个指针(实际上可能更多,但重要也就两个),一个指向 TLAB 中空余内存的起始位置,一个则指向 TLAB 末尾。接下来的 new 指令,便可以直接通过指针加法(bump the pointer)来实现,即把指向空余内存位置的指针加上所请求的字节数。

4、卡表(Card Table)

出现原因:

老年代的对象可能引用新生代的对象,在Minor GC标记存活对象的时候,我们需要扫描老年代中的对象。如果该对象拥有对新生代对象的引用,那么这个引用也会被作为 GC Roots。为了不进行全扫描(full GC),出现了卡表技术。

实现方法:

  • 将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。
  • 如果可能存在,那么我们就认为这张卡是脏的。
  • 在进行 Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的 GC Roots 里。
  • 当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。
  • 由于 Minor GC 伴随着存活对象的复制,而复制需要更新指向该对象的引用。因此,在更新引用的同时,我们又会设置引用所在的卡的标识位。这个时候,我们可以确保脏卡中必定包含指向新生代对象的引用。

五、垃圾回收器

1、串行(单线程)

-XX:+UseSerialGC = Serial + SerialOld

分为两个收集器:分别为Serial,SerialOld

特点:

  • 单线程:只用一条单线程执行垃圾收集工作
  • 垃圾回收时,所用的线程必须暂停。
  • 优势:简单高效,由于采用的是单线程的方法,因此与其他类型的收集器相比,对单个cpu来说没有了上下文之间的的切换,效率比较高。
  • 缺点:用户会在不知道的情况下停止所有工作线程,用户体验感极差,令人难以接受。
  • 适用场景:Client 模式(桌面应用);单核服务器。

其中SerialOld用法:

  • 和Serial一起使用
  • 与Parallel Scavenge收集器搭配
  • 作为CMS收集器的后备方案,在并发收集发生Concurrent Mode Failure时使用

2、 吞吐量优先

分为两个收集器:Parallel Scavenge,Parallel Old

实现方法:要垃圾收集时所有用户线程暂停并全部进入GC线程进行垃圾回收

优点:

  • 追求高吞吐量,高效利用CPU,是吞吐量优先,且能进行精确控制。
  • 支持多线程

缺点:stop the world 次数较多

3、响应时间优先

分为两个收集器:分别为ParNewGC,CMS

ParNewGC工作方式:当用户线程都执行到安全点时,所有线程暂停执行,采用复制算法进行垃圾收集工作,完成之后,用户线程继续开始执行。

与Parallel Scavenge不同的是,ParNew收集器关注点在于尽可能的缩短垃圾收集时用户线程的停顿时间

CMS工作流程:

  • 初始标记,标记GC Roots 能够直接关联到达对象
  • 并发标记,进行GC Roots Tracing 的过程
  • 重新标记,修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录
  • 并发清除,用标记清除算法清除对象。

其中初始标记和重新标记这两个步骤仍然需要"stop the world"。耗时最长的并发标记与并发清除过程收集器线程都可以与用户线程一起工作,总体上来说CMS收集器的内存回收过程是与用户线程一起并发执行的。

GC Roots Tracing(跟搜索算法):JVM中对内存进行回收时,需要判断对象是否仍在使用中,可以通过GC Roots Tracing辨别。

CMS优点:

  • 并发收集
  • 低停顿

CMS缺点:

  • CMS收集器对CPU资源非常敏感,CMS默认启动对回收线程数(CPU数量+3)/4,当CPU数量在4个以上时,并发回收时垃圾收集线程不少于25%,并随着CPU数量的增加而下降,但当CPU数量不足4个时,对用户影响较大。
  • CMS无法处理浮动垃圾,可能会出现“Concurrent Mode Failure”失败而导致一次FullGC的产生。这时会地洞后备预案,临时用SerialOld来重新进行老年代的垃圾收集。由于CMS并发清理阶段用户线程还在运行,伴随程序运行自然还会有新的垃圾产生,这部分垃圾出现在标记过程之后,CMS无法在当次处理掉,只能等到下一次GC,这部分垃圾就是浮动垃圾。同时也由于在垃圾收集阶段用户线程还需要运行,那也就需要预留足够的内存空间给用户线程使用,因此CMS收集器不能像其他老年代几乎完全填满再进行收集。可以通过参数-XX:CMSInitiatingOccupancyFraction修改CMS触发的百分比。
  • 因为CMS采用的是标记清除算法,因此垃圾回收后会产生空间碎片。通过参数可以进行优化。

适用场景:

  • 重视服务器响应速度,要求系统停顿时间最短。

4、G1收集器

是JDK9的默认垃圾收集器

特点:

  • 并行与并发。G1能充分利用多CPU,多核环境下的硬件优势。
  • 分代收集。能够采用不同的方式去处理新创建的对象和已经存活了一段时间的对象,不需要与其他收集器进行合作。
  • 空间整合。G1从整体上来看基于“标记-整理”算法实现的收集器,从局部上看是基于复制算法实现的,因此G1运行期间不会产生空间碎片。
  • 可预测的停顿。G1能建立可预测的时间停顿模型,能让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

实现方法:

  • G1收集器将这个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但两者之间不是物理隔离的。他们都是一部分Region的集合。
  • 每一个方块就是一个区域,每个区域可能是 Eden、Survivor、老年代,每种区域的数量也不一定。JVM 启动时会自动设置每个区域的大小(1M ~ 32M,必须是 2 的次幂),最多可以设置 2048 个区域(即支持的最大堆内存为 32M*2048 = 64G),假如设置 -Xmx8g -Xms8g,则每个区域大小为 8g/2048=4M。
  • G1收集器可以有计划地避免在整个Java堆全区域的垃圾收集。G1可以跟踪各个Region里面垃圾堆积的价值大小(回收所获得的空间大小及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,收集加载最大的region,这种方式保证了有限时间内可以获取尽可能多高的收集效率。
  • 为了在 GC Roots Tracing 的时候避免扫描全堆,在每个 Region 中,都有一个 Remembered Set 来实时记录该区域内的引用类型数据与其他区域数据的引用关系(在前面的几款分代收集中,新生代、老年代中也有一个 Remembered Set 来实时记录与其他区域的引用关系),在标记时直接参考这些引用关系就可以知道这些对象是否应该被清除,而不用扫描全堆的数据。
  • 过程为:初始标记、并发标记、最终标记、筛选回收

具体过程:

  • 初始标记STW。标记出GC Roots直接关联的对象,这个阶段速度较快,需要停止用户线程,单线程执行。
  • 并发标记。从 GC Root 开始对堆中的对象进行可达性分析,找出存活对象,这个阶段耗时较长,但可以和用户线程并发执行。
  • 最终标记STW。修改在并发标记阶段用户程序执行而产生变动的标记记录。
  • 筛选回收STW。在回收阶段会对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来指定回收计划(用最少的时间来回收包含垃圾最多的区域,这就是 Garbage First ,第一时间清理垃圾最多的区块),这里为了提高回收效率,并没有采用和用户线程并发执行的方式,而是停顿用户线程。

适用场景:要求尽可能可控 GC 停顿时间;内存占用较大的应用。可以用 -XX:+UseG1GC 使用 G1 收集器,jdk9 默认使用 G1 收集器

六、GC相关问题:

\1. JVM的stop-the-world机制非常不友好,有哪些解决之道?原理是什么?

采用并行GC可以减少需要STW的时间,它们会在即时编译器生成的代码中加入写屏障或者读屏障。

压测时出现频繁的GC容易理解,但是有时出现毛刺(CPU占用一下子变低)是因为什么呢?

Y轴应该是时间,那毛刺就是长暂停,一般Full GC就会造成长暂停。

Full GC有卡顿,对性能很不利,怎么避免呢?

通过调整新生代大小,使对象在其生命周期内都待在新生代中。这样一来,Minor GC时就可以收集完这些短命对象了。

不管什么垃圾回收器都会出现stop the word吗?

目前的垃圾回收器多多少少需要stop the world,但都在朝着尽量减少STW时间发展。

完全的并发GC算法是存在的,但是在实现上一般都会在枚举GC roots时进行STW。

压缩算法是不是也用到了复制呢?

确实是需要复制数据,这样起名主要是为了区分复制到同一个区域中(需要复杂的算法保证引用能够正确更新),还是复制到另一个区域中(可以复制完后统一更新引用)。

JVM分代收集新生代对象进入老年代,年龄为什么是15而不是其他的?

HotSpot会在对象头中的标记字段里记录年龄,分配到的空间只有4位,最多只能记录到15。