JVM-垃圾回收机制

简介

​ 为了更好的内存动态分配,Java引入了自动垃圾回收。垃圾收集主要是针对堆和方法区进行;程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后也会消失,因此不需要对这三个区域进行垃圾回收

GC如何识别垃圾

引用计数法

​ **对象被引用一次,在它的对象头上加一次引用次数,如果没有被引用(引用次数为 0),则此对象可回收。**但是存在一个问题,无法解决循环引用问题。

//实例  两个对象都在等待对方撤销,所有这两个资源均不能释放
class A{
  public B b;

}
class B{
  public A a;
}
public class Main{
    public static void main(String[] args){
    A a = new A();
    B b = new B();
    a.b=b;
    b.a=a;
    }
}

优缺点

1)引用计数法可以在对象不活跃时(引用计数为0)立刻回收其内存。因此可以保证堆上时时刻刻都没有垃圾对象的存在。

2)引用计数法的最大暂停时间短。由于没有了独立的GC过程,而且不需要遍历整个堆来标记和清除对象,取而代之的是在对象引用计数为0时立即回收对象,这相当于将GC过程“分摊”到了每个对象上,不会有最大暂停时间特别长的情况发生。

3)引用计数的增减开销在一些情况下会比较大,比如一些根引用的指针更新非常频繁,此时这种开销是不能忽视的。另外对象引用计数器本身是需要空间的。

4)若是发生循环引用问题,按照引用计数法的要求,它们将无法被回收,造成内存泄漏。

可达性算法

通过 GC Roots 作为起始点进行搜索,能够到达到的对象都是存活的,不可达的对象可被回收。即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候他们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要两次标记过程。

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

  • 虚拟机栈中引用的对象
  • 本地方法栈中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中的常量引用的对象
  • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象
  • Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象
  • 所有被同步锁(synchronized 关键字)持有的对象
  • 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存

标记过程

  1. 对象不可达(可回收),会进行第一次标记并进行一次筛选,筛选条件是此对象是否有必要执行 finalize() 方法
  2. 如果有必要执行 finaize() 方法,这个对象会被放在一个叫做 F-Queue 的队列中,稍后会由 JVM 自动建立的、低优先级的 Finalizer 线程去执行(触发,并不会等其运行结束),这时进行第二次标记,仍然不可达,则会被真的回收。

​ 任何一个对象的 finalize() 方法只会被系统自动调用一次,如果第一次执行 finalize 方法此对象变成了可达确实不会回收,但如果对象再次被 GC,则会忽略 finalize 方法,对象会被回收

引用类型

​ 无论是通过引用计算算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。Java 具有四种强度不同的引用类型:强引用、软引用、弱引用、虚引用。

强引用

被强引用关联的对象不会被回收。

//使用 new 一个新对象的方式来创建强引用。
Object obj = new Object();

软引用

被软引用关联的对象只有在内存不够的情况下才会被回收。

//使用 SoftReference 类来创建软引用。
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null;  // 使对象只被软引用关联

弱引用

被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。例如:finalize方法

//使用 WeakReference 类来实现弱引用。
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;

虚引用

​ 又称为幽灵引用或者幻影引用。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象。为一个对象设置虚引用关联的唯一目的就是能在这个对象被回收时收到一个系统通知。

//使用 PhantomReference 来实现虚引用
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj);
obj = null;

方法区的回收

​ 永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,因此在方法区上进行回收性价比不高。主要是对常量池的回收和对类的卸载。

在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载功能,以保证不会出现内存溢出

​ 虚拟机可以对满足以上 3 个条件的无用类进行回收,并不是一定会被回收。是否对类回收,HotSpot 虚拟机提供了 -Xnoclassgc 参数进行控制

无用类条件:

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

finalize()

finalize() 类似 C++ 的析构函数,用来做关闭外部资源等工作。当一个对象可被回收时,如果需要执行该对象的 finalize() 方法,那么就有可能通过在该方法中让对象重新被引用,从而实现自救。自救只能进行一次。如果回收的对象之前调用了 finalize() 方法自救,后面回收时不会调用 finalize() 方法。

记忆集和卡表

为了解决对象跨代引用所带来的问题,垃圾收集器在老年代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进GC Roots扫描范围。事实上并不是只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集行为的垃圾收集器,典型如G1,ZGC收集器,都会面临相同的问题,因此我们有必要进一步理清记忆集的原理和实现方式。

记忆集

**是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。**下面列举了一些可供选择的记录精度,由高到低依次为:

  • 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
  • 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
  • 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
  • 卡精度所指的是用一种称为"卡表"(caed table)的方式实现记忆集,是目前最常用的一种实现方式,卡表与记忆集的关系,类似于Java语言中HashMap与Map的关系。

卡表

​ 将整个堆划分为一个个指定大小的内存块,这个内存块称为"卡页",卡页大小在HotSpot中默认为512字节,并且维护一个卡表(可以是一个字节数组)。

一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或多个)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。也就是说如果卡表的值标识为1,则当前卡页存在跨代指针。

​ 卡表是使用一个字节数组实现:CARD_TABLE[ ],每个元素对应着其标识的内存区域一块特定大小的内存块,称为“卡页”。hotSpot使用的卡页是2^9大小,即512字节

垃圾收集算法

标记复制

​ 分配两块不同的空间,标记所有存活对象。把存活对象复制到另一块区域,清除原本的所有对象。

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

空间分配担保

​ 在发生 MinorGC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果大于,那么 Minor GC 可以确保是安全的。如果不大于,那么虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于则进行 Minor GC,否则可能进行一次 Full GC。

标记清除

​ 标记所有存活对象。把没有标记的进行清除。标记-清除可能会产生大量连续的内存碎片。

不足

  • 效率问题。如果标记太多对象。效率不高
  • 空间问题。会产生大量不连续的碎片。不利于后续对象的存储

标记整理

让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

分代收集

​ 现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。一般将堆分为新生代和老年代。

  • 新生代使用: 复制算法
  • 老年代使用: 标记 - 清除 或者 标记 - 整理 算法,主要看虚拟机采用的哪个收集器。两种算法的区别是:标记-清除可能会产生大量连续的内存碎片。在老年代中的GC则为Major GC。Major GC和Full GC会造成stop-the-world(应用程序的停止)。

内存整齐度:复制算法=标记/整理算法>标记/清除算法。

内存利用率:标记/整理算法=标记/清除算法>复制算法。

垃圾收集器

​ 如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java 虚拟机规范并没有规定垃圾收集器应该如何实现,因此一般来说不同厂商,不同版本的虚拟机提供的垃圾收集器实现可能会有差别,一般会给出参数来让用户根据应用的特点来组合各个年代使用的收集器。

  • 在新生代工作的垃圾回收器:Serial、ParNew、ParallelScavenge
  • 在老年代工作的垃圾回收器:CMS、Serial Old(MSC)、Parallel Old
  • 同时在新老生代工作的垃圾回收器:G1

​ 垃圾收集器如果存在连线,则代表它们之间可以配合使用。

JVM-垃圾回收机制_第1张图片

Serial 收集器(单线程)

Serial 收集器是工作在新生代的,单线程的垃圾收集器,单线程意味着它只会使用一个 CPU 或一个收集线程来完成垃圾回收。也就是说它在进行垃圾收集时,其他用户线程会暂停,直到垃圾收集结束,直白说在 GC 期间,此时的应用不可用

它是 Client 模式下的默认新生代收集器,因为在用户的桌面应用场景下,分配给虚拟机管理的内存一般来说不会很大。Serial 收集器收集几十兆甚至一两百兆的新生代停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿是可以接受的。

ParNew 收集器(多线程)

ParNew 收集器是 Serial 收集器的多线程版本,是 Server 模式下的虚拟机首选新生代收集器。除了使用多线程,其他像收集算法、STW、对象分配规则、回收策略与 Serial 收集器完全一样。

​ 除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作。默认开启的线程数量与 CPU 数量相同,可以使用 -XX:ParallelGCThreads 参数来设置线程数。

Parallel Scavenge 收集器(多线程高吞吐)

​ Parallel Scavenge 收集器也是一个使用复制算法多线程,工作于新生代的垃圾收集器,看起来功能和 ParNew 收集器一样。是一个遵循吞吐量优先的收集器。而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务

其他收集器区别

​ 1)其它收集器关注点是尽可能缩短垃圾收集时用户线程的停顿时间

​ 2)它的目标是达到一个可控制的吞吐量,它被称为“吞吐量优先”收集器。这里的吞吐量指 CPU 用于运行用户代码的时间占总时间的比值。

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

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

Serial Old 收集器(单线程)

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

  • 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用
  • 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

Parallel Old 收集器(多线程高吞吐)

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

CMS 收集器(并行 最短 STW 时间为目标)

​ CMS(Concurrent Mark Sweep)收集器是以实现最短 STW 时间为目标的收集器,如果应用很重视服务的响应速度,希望给用户最好的体验,则 CMS 收集器是个很不错的选择!Mark Sweep 指的是标记 - 清除算法

CMS算法的基础是通过可达性分析找到存活的对象,然后给存活的对象打个标记,最终在清理的时候,如果一个对象没有任何标记,就表示这个对象不可达,需要被清理。

标记算法就是使用的三色标记。并发标记阶段是从GC Root直接关联的对象开始枚举的过程。对于三色标记算法而言, 对象会根据是否被访问过(也就是是否在可达性分析过程中被检查过)被分为三个颜色:白色、灰色和黑色:

三色标记

白色

​ 表示对象尚未被垃圾收集器访问过。 显然在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段, 仍然是白色的对象, 即代表不可达。

灰色

​ 这个对象已经被访问过,但是这个对象所直接引用的对象中,至少还有一个没有被访问到,表示这个对象正在枚举中。

黑色

​ 对象和它所直接引用的所有对象都被访问过。若后续有其他对象引用指向了黑色对象,也不会进行再次扫描,所以会引发漏标问题。

漏标问题如何处理:

​ 可以通过读屏障、写屏障方式来处理,写屏障进行异步处理。在对象赋值前后进行操作。

1)**增量更新。写后屏障。把新建立的引用对象记录到表中。**比如;a.b=b;将对象a中对b的引用记录到表中。

​ 就是当黑色对象插入新的指向白色对象的引用关系时, 就将这个新插入的引用记录下来, 等并发扫描结束之后, 再将这些记录过的引用关系中的黑色对象为根, 重新扫描一次。 这可以简化理解为, 黑色对象一旦新插入了指向白色对象的引用之后, 它就变回灰色对象了。
2)原始快照。写前屏障。在对象要删除引用时。把此对象引用关系记录到表中。

​ 就是当灰色对象要删除指向白色对象的引用关系时, 就将这个要删除的引用记录下来, 在并发扫描结束之后, 再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色(目的就是让这种对象在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾)

工作流程:

  • 初始标记: 仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
  • 并发标记: 进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。因为用户程序继续运行,可能会有导致已经标记过的对象状态发生改变。
  • 重新标记: 为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
  • 并发清除: 不需要停顿。在并发标记过程中,同时GC线程开始对未标记的区域做清扫。这个阶段如果有新增对象会被标记为黑色不做任何处理

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

具有以下缺点:

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

总结

​ 重新标记阶段会STW,以此保证标记结果的正确性(主要是漏标)。并发清理阶段产生的垃圾会被当做浮动垃圾,只能留待下一次GC被清理。原因是只要在并发清理阶段产生的对象,直接就认为是黑色对象,全部都不是垃圾。到了清理阶段,标记工作已经完成,没有办法再找到合适的方式去处理这个问题,不然一次GC可能永远也结束不了。标记的总体原则就是,“另可放过,不可杀错”。

G1收集器

G1是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。

堆被分为新生代和老年代,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。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,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

具备如下特点:

  • 空间整合: 整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
  • 可预测的停顿: 能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。

ZGC 收集器(JDK11)

​ 是一款低延迟垃圾回收器。适用于大内存低延迟服务的内存管理和回收。

总结

​ 在生产环境中我们要根据不同的场景来选择垃圾收集器组合。

​ 1)运行在桌面环境处于 Client 模式的,则用 Serial + Serial Old 收集器绰绰有余。

​ 2)如果需要响应时间快,用户体验好的,则用 ParNew + CMS 的搭配模式,即使是号称是「驾驭一切」的 G1,也需要根据吞吐量等要求适当调整相应的 JVM 参数

​ 3)如果只要求吞吐量。Parallel Scavenge(多线程高吞吐) + Parallel Old收集器

jdk1.7 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)

jdk1.8 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)

jdk1.9 默认垃圾收集器G1

你可能感兴趣的:(JVM,jvm)