浅谈虚拟机的垃圾回收

最近在极客时间上的《深入拆解Java虚拟机》课堂上学习,所以记录下了学习的笔记,与及对其相关的内容进行思考和拓展。

1. 如何辨别一个对象是存是亡?

用来辨别的计算方法有两种: 引用计数法与可达性分析。

1.1 引用计数法

实现方式: 每个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象的引用计数器为 0,则说明该对象已经死亡,便可以被回收了。

缺点:
1.需要额外的空间来存储计数器,以及繁琐的更新操作

2.引用计数法还有一个重大的漏洞,那便是无法处理循环引用对象。

1.2 可达性分析

实现方式: 这个是主流的垃圾回收器。这个算法的实质在于将一系列 GC Roots 作为初始的存活对象合集(live set),然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程我们也称之为标记(mark)。最终,未被探索到的对象便是死亡的,是可以回收的。

GC Roots 包括(但不限于)如下几种:

Java 方法栈桢中的局部变量;

已加载类的静态变量;

JNI handles;

已启动且未停止的 Java 线程。

缺点: 比如说,在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引用设置为 null)或者漏报(将引用设置为未被访问过的对象)。误报并没有什么伤害,Java 虚拟机至多损失了部分垃圾回收的机会。漏报则比较麻烦,因为垃圾回收器可能回收事实上仍被引用的对象内存。一旦从原引用访问已经被回收了的对象,则很有可能会直接导致 Java 虚拟机崩溃。

2. 怎么解决这个问题呢?

Java 虚拟机中的 Stop-the-world 是通过安全点(safepoint)机制来实现的。当 Java 虚拟机收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,才允许请求 Stop-the-world 的线程进行独占的工作。而详细怎样实现就不一一说了。总结来说就是,在每个方法一个安全点,然后调用一个方法,或者完成一个方法,就会触发这个安全点检查,如果安全点检查发现有需要停止进行垃圾回收的,线程就会先阻塞。而检查的颗度为方法,为什么不是每一个字节码,主要还是开销的问题。

3. 如何进行垃圾回收呢?

当标记完所有的存活对象时,我们便可以进行死亡对象的回收工作了。主流的基础回收方式可分为三种。

3.1 清除(sweep)

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

缺点:

1)会造成内存碎片。由于 Java 虚拟机的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。

2)分配效率较低。如果是一块连续的内存空间,那么我们可以通过指针加法(pointer bumping)来做分配。而对于空闲列表,Java 虚拟机则需要逐个访问列表中的项,来查找能够放入新建对象的空闲内存。

3.2 压缩(compact)

实现方式: 把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。这种做法能够解决内存碎片化的问题

缺点: 压缩算法的性能开销大,需要将大量对象迁移,过程消耗资源多,耗时大。

3.3 复制(copy)

实现方式: 把内存区域分为两等分,分别用两个指针 from 和 to 来维护,并且只是用 from 指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到 to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容。复制这种回收方式同样能够解决内存碎片化的问题。

缺点: 空间的使用效率极其低下。说明只能有一半是可以利用,一般是空着的。

4. jvm的垃圾回收简介

Java 虚拟机可以给不同代使用不同的回收算法。对于新生代,我们猜测大部分的 Java 对象只存活一小段时间,那么便可以频繁地采用耗时较短的垃圾回收算法,让大部分的垃圾都能够在新生代被回收掉。对于老年代,我们猜测大部分的垃圾已经在新生代中被回收了,而在老年代中的对象有大概率会继续存活。当真正触发针对老年代的回收时,则代表这个假设出错了,或者堆的空间已经耗尽了。

5. Java 虚拟机的堆划分

Java 虚拟机将堆划分为新生代和老年代。其中,新生代又被划分为 Eden 区,以及两个大小相同的 Survivor 区。如下图:

浅谈虚拟机的垃圾回收_第1张图片

默认情况下,Java 虚拟机采取的是一种动态分配的策略(对应 Java 虚拟机参数 -XX:+UsePSAdaptiveSurvivorSizePolicy),根据生成对象的速率,以及 Survivor 区的使用情况动态调整 Eden 区和 Survivor 区的比例。

-XX:SurvivorRatio 来固定这个比例。但是需要注意的是,其中一个 Survivor 区会一直为空,因此比例越低浪费的堆空间将越高。

6. 垃圾回收的机制

6.1 Minor GC

Minor GC 触发时机,当Eden区满了,利用 标记 - 复制算法,做了什么事情呢?看下面流程图:

浅谈虚拟机的垃圾回收_第2张图片

上面的每个判断,其实都是有参数是可以配置设置的,这里省略了。

Minor GC 好处:

1)Eden区的对象基本都是只用一次就清除了,所以只需要标记还存活的对象进行复制,其实清除,这样就很高效。

2) 不用对整个堆进行垃圾回收。只是对新生代进行清除,效率高。

带来的问题:

那就是老年代的对象可能引用新生代的对象。也就是说,在标记存活对象的时候,我们需要扫描老年代中的对象。如果该对象拥有对新生代对象的引用,那么这个引用也会被作为 GC Roots。因此引出了Full GC的概念。

6.2 Full GC

Full 触发时机: 当有对象晋升为老年代时候,发现老年代的空间不足或,则会触发。者持久代空间不足。或者当在持久代创建对象,发现内存不足时候,但是现在通常的持久代是在系统的内存上了,所以通常比较少会出现内存不足。

实现流程: 不同的垃圾回收器用不同的算法,主要流程是在老年代创建对象,发现内存不足,然后就会进行对该区的对象不可达的对象回收,回收之后,如果发现还是内存不足,无法保存对象,这个时候就会报错内存泄露。

通常发送了full gc是对程序比较严重的预警,比较高度注意,从而阻止一些一起改full gc的行为,从而保证系统的正常运行。

7. 垃圾回收器

常见的垃圾回收器:Serial GC、ParNew GC、Parrallel GC、CMS GC、G1 GC

7.1 Serial GC

它是最古老的垃圾收集器,“Serial”体现在其收集工作是单线程的,并且在进行垃圾收集过程中,会进入臭名昭著的“Stop-The-World”状态。当然,其单线程设计也意味着精简的 GC 实现,无需维护复杂的数据结构,初始化也简单,所以一直是 Client 模式下 JVM 的默认选项。

7.2 ParNew GC

它是个新生代 GC 实现,它实际是 Serial GC 的多线程版本,最常见的应用场景是配合老年代的 CMS GC 工作。

7.3 Parallel GC

它是 server 模式 JVM 的默认 GC 选择,也被称作是吞吐量优先的 GC。它的算法和 Serial GC 比较相似,尽管实现要复杂的多,其特点是新生代和老年代 GC 都是并行进行的,在常见的服务器环境中更加高效。

7.4 CMS GC

基于标记 - 清除(Mark-Sweep)算法,设计目标是尽量减少停顿时间,这一点对于 Web 等反应时间敏感的应用非常重要,一直到今天,仍然有很多系统使用 CMS GC。但是,CMS 采用的标记 - 清除算法,存在着内存碎片化问题,所以难以避免在长时间运行等情况下发生 full GC,导致恶劣的停顿。另外,既然强调了并发(Concurrent),CMS 会占用更多 CPU 资源,并和用户线程争抢。 CMS 已经在 JDK 9 中被标记为废弃(deprecated)

7.5 G1 GC

G1 GC 这是一种兼顾吞吐量和停顿时间的 GC 实现,是 Oracle JDK 9 以后的默认 GC 选项。G1 可以直观的设定停顿时间的目标,相比于 CMS GC,G1 未必能做到 CMS 在最好情况下的延时停顿,但是最差情况要好很多。所以很多情况选择G1 GC作为服务器的垃圾回收器是很好的选择。所以这里对C1 GC详细说说。

7.5.1 首先我们对GC1的基本了解

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

如下图所示:

浅谈虚拟机的垃圾回收_第3张图片

G1的内存结构不仅和传统的结构上不同,而且还可以看到比传统的多了一个结构模块,就是图上的H,其全称是Humongous,字面的意思其实就是巨大的,而这些Region表示用来存储巨大的对象的。就是当一个对象大小超过某个阈值,就会被标记为Humongous。按照我们传统的,对于这种大对象,会直接安排到老生代上。

RSet: 全称Remembered Sets, 用来记录外部指向本Region的所有引用,每个Region维护一个RSet。利用这样记录的方法的好处是在回收一个Region的时候不需要执行全堆扫描,只需要检查它的RS就可以找到外部引用,而这些引用就是initial mark的根之一。

Card: JVM将内存划分成了固定大小的Card。这里可以类比物理内存上page的概念。

Card Table: 如果一个线程修改了Region内部的引用,就必须要去通知RS,更改其中的记录。为了达到这种目的,G1回收器引入了一种新的结构,CT(Card Table)——卡表。每一张卡,都用一个Byte来记录是否修改过。卡表即这些byte的集合。

Region、Card、Card Table、Rset之间的关系如下图:

浅谈虚拟机的垃圾回收_第4张图片

7.5.2 C1 GC的模式

其中主要包括:Young GC、Mixed GC、Full GC 。
当Eden区内存不足时候,就会发生Young GC。而当老年代占整个堆大小的百分比到达阈值时候,就会发生Mixed GC。而当G1的垃圾回收过程是和应用程序并发执行的,当Mixed GC的速度赶不上应用程序申请内存的速度的时候,Mixed G1就会降级到Full GC,使用的是Serial GC。

这里详细说一下Mixed GC。

  1. 什么是 Mixed GC?
    回收所有的年轻代的Region+部分老年代的Region,回收部分老年代是参数-XX:MaxGCPauseMillis ,用来指定一个G1收集过程目标停顿时间,默认值200ms,当然这只是一个期望值。G1的强大之处在于他有一个停顿预测模型(Pause Prediction Model),他会有选择的挑选部分Region,去尽量满足停顿时间。

  2. Mixed GC触发的条件是什么?
    Mixed GC的触发也是由一些参数控制。比如XX:InitiatingHeapOccupancyPercent表示老年代占整个堆大小的百分比,默认值是45%,达到该阈值就会触发一次Mixed GC。

  3. Mixed GC的过程是怎样?
    Mixed GC主要可以分为两个阶段:
    全局并发标记
    全局并发标记可以分为五个阶段如下:
    初始标记:标记了从GC Root开始直接可达的对象。初始标记阶段借用young GC的暂停,因而没有额外的、单独的暂停阶段。
    并发标记:这个阶段从GC Root开始对heap中的对象标记,标记线程与应用程序线程并行执行,并且收集各个Region的存活对象信息。
    最终标记:标记那些在并发标记阶段发生变化的对象,将被回收。
    清除垃圾:这个阶段如果发现完全没有活对象的region就会将其整体回收到可分配region列表中。 清除空Region。
    拷贝存活对象
    Evacuation阶段是全暂停的。它负责把一部分region里的活对象拷贝到空region里去(并行拷贝),然后回收原本的region的空间。Evacuation阶段可以自由选择任意多个region来独立收集构成收集集合(collection set,简称CSet),CSet集合中Region的选定依赖于上文中提到的停顿预测模型,该阶段并不evacuate所有有活对象的region,只选择收益高的少量region来evacuate,这种暂停的开销就可以(在一定范围内)可控。

  4. Mixed GC带来的效果是什么?
    Mixed GC主要可以对old区无用的对象进行提早清理回收,减少无用的对象一直积压,引起full GC,从而带来对象程序停顿 。

十分感谢学习文章:

1.垃圾回收有哪些?

2.Java Hotspot G1 GC的一些关键技术

3.G1从入门到放弃

4.G1垃圾回收详解

5.极客时间课堂-《深入拆解Java虚拟机》

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