深入理解Java虚拟机技术-GC回收


1. JVM垃圾回收概括

JavaGC机制是Java提供给开发者的帮助技术,利用此项技术,开发者可以避免内存清理不当所造成的程序内存泄漏。而垃圾收集的核心原理也非常简单:对需要进行垃圾收集的内存进行标记,随后采用一些合理的回收策略,不定期实现垃圾空间的释放,并且此操作在整个Java程序的执行过程中永不停息,保证JVM中有可用的内存空间,以防止内存泄漏和溢出问题的产生

由于JVM堆内存采用了分代管理模型,因此在进行GC操作的时候就可以避免全部堆内存回收处理,从而提高GC的处理性能。GC在进行内存释放的时候提供了两种不同的机制。分别是新生代的MinorGC和老年代的MajorGC

2. JVM垃圾收集流程

上面流程图的做法简单来说就是不断检查新生代和老年代中的内存空间是否充足,一旦需要到某一个区的内存空间,而该内存空间又已经满了的时候,这时候就要将一部分对象晋级到下一个区域中去。具体描述如下:

  • 当使用关键字new创建了一个新对象的时候,JVM会将新对象保存在伊甸园区,但是这时候需要判断伊甸园是否有足够的空间分配给新对象,如果有的话就会直接将新对象保存在伊甸园区之内,如果没有的话就会执行MinorGC
  • JVM在执行完MinorGC之后会清除不活跃的对象,从而释放出伊甸园的内存空间,随后会对伊甸园区的空间进行再次判断,如果此时剩余空间可以直接容纳新对象,则会直接为新对象申请内存空间,如果此时伊甸园的空间依然不足,则会将部分活跃对象保存在存活区(当空间不足时发生新一轮晋级)
  • 由于存活区也有对象存储在内,所以保存在伊甸园区发送过来的对象首先需要判断其空间是否充足。如果存活区有足够的空余的空间,则直接保存伊甸园区晋升来的对象,那么此时伊甸园区将得到空间的释放,随后可以在伊甸园区为新的对象申请内存空间。如果存活区空间不足,那么就需要将存活区的部分活跃对象保存到老年代
  • 老年代如果有足够的内存空间,则会对存活区发送过来的对象进行保存,如果此时老年代的内存空间已满,则将执行MajorGC,相当于使用Runtime.getRuntime.gc()处理,以释放老年代中保存的不活跃对象。如果在释放后有足够的内存空间,则会将存活区发送过来的对象保存在老年代,而存活区将保存伊甸园区发送来的对象,这样伊甸园区内就有足够的内存保存新的对象。
  • 如果此时老年代的空间已经被占满,那么直接宣告程序崩溃,无法再分配新对象的空间,抛出OOM

OOM异常出现之前往往会进行一系列的GC操作,当整个JVM堆内存被全部占满的时候,无法再分配新的内存空间给该对象的时候异常就会出现,而一旦出现此异常也就意味着整个程序彻底崩溃。

如果想要查看GC日志信息,可以通过参数-Xlog:gc*来实现

3. 垃圾回收算法

所有的对象再存储之前都需要进行堆内存的申请,但是一个应用的运行时间越长,可以分配的内存空间也就比较少,这时候就需要一个算法。这个算法可以及时清理掉些不再使用的对象,以释放更多的可用内存空间。对无效对象的内存回收称为垃圾收集,为了实现垃圾收集就需要垃圾收集算法。

3.1 引用计数法

主要的实现原理:在每个引用对象上追加一个引用计数器,当该对象被一个对象引用的时候,计数+1,当该对象引用失败的时候,对象引用计数-1,当引用计数为0的时候,则表示该对象没有引用了,允许进行回收。

这个所谓的引用失败其实就是断开与对象实例的引用关闭,可以这样理解

class Book{
    public StringBuilder title = new StringBuilder();
}
public void test(){
    Book book = new Book();
    book.title.append("2");//此时产生一个新的引用。
    StringBuilder newTile = new StringBuilder("hello");
    book.title = newTile;//这时候book对象内的原始title的引用地址就被改变了
}

但是要注意的是,引用计数法的核心是在于一个对象是否被外部对象所引用,如:

class Book{
    Author author;
}
class Author{
    Set<Book> books;
}
public void test(){
    Author a = new Author();
    Book b = new Book();
    b.author = a;
    a.books.add(b);
}

注意到计数法的引用计数只要为0就表示该对象可以被回收,但是它却无法解决循环引用的问题。 尽管有些对象在后续已经没有使用价值了,但是如上程序,Book被 Author引用,Author又引用了Book,这时候两者互相引用,永远无法归零。

3.2 标记清除法

标记清除法将垃圾收集的处理分为标记清除两个部分,首先从根对象找到所有的引用对象,并进行有效性的标记,随后对所有未被标记的对象进行清理。

在对象标记过程中,程序会从根对象开始扫描所有的可达对象,如果发现有用的对象则将其标记为1,如果发现无用的对象则将其标记为0,如果此时的内存空间耗尽了,则会暂停JVM并进行对象标记,标记完成后会再开启清除阶段,此时会将所有未标记的对象的内存空间释放,而后所有的可达对象的标记就被清空。

什么可以被判断为有用?什么可以被判断为无用?

当从根对象出发,一直遍历到叶子节点,这条路径上的所有节点都是有用的,因为它们都可以从根对象出发,遍历之后被找到。

注意这里的叶子节点是变量名所代表的变量,也就是说,对于

class Author{
    Set<Book> books
}

实际上节点是books所代表的集合区域,而不是一个个的Book

此方法会产生STW问题以及内存碎片问题。

内存碎片很好理解,因为要释放的对象空间地址是不确定的。

STW机制

为了保证垃圾收集的可靠性,在垃圾收集当前应用中的全部线程都会暂停。这一操作被称为Stop The WorldSTW机制具有如下特点:

  • 所有的Java停止执行,原生(native)代码可以执行,但是不能和JVM交互
  • 多半由GC引起
  • Dump(倾倒垃圾)诊断线程
  • 死锁检查

使用标记清除法可以解决引用计数法造成的循环引用的问题。

为什么能够解决?

一般来说,循环引用通常在使用SetListStack等这些Collections的时候发生,而标记清除法能够针对栈内变量名来进行标记,而不是对集合内部的对象进行标记,因此这样的话就能够针对栈内变量进行可达性分析。

以上面的例子:

class Author{
    Set<Book> books;
}

只要books没有再被任何一个对象所引用了,它就不可达了。

总得来说,标记清除法能够解决循环引用的问题,但是在并发访问较多的情况下会造成严重的性能问题,而且还会产生大量的内存碎片

3.3 标记压缩法

标记压缩法是在标记清除法的基础之上进行了内存碎片优化的算法。在内存清除完成之后,程序会将所有存活的对象保存在一端,并整理所有的回收内存,使其可以形成一个连续的内存空间。

但是这样一来又增加了内存整理的操作,所以会对处理性能产生影响。

3.4 复制算法

复制算法的核心是将内存空间分为两部分,每次只使用其中的一部分,在进行垃圾收集的时候,将正在使用的对象复制到另外一部分内存中,而后将该对象的内存空间交换。

在使用复制算法进行回收处理,在垃圾较多的情况下处理性能较高,同时不会造成内存碎片,但是在垃圾相对较少的环境下性能不佳,对于老年代的堆内存就不适用,所以该算法通常用于新生代的内存回收。

实际上某些对象是不需要移动的,因此该算法会造成额外的开销。

3.5 分代算法

在实际使用中任何一种垃圾收集算法都有各自的设计缺陷,为了弥补这些设计缺陷,JVM提出了堆内存的分代设计模型,即将新生代与老年代的回收算法分开运行,新生代由于会存储大量新生对象,所以更适合使用复制算法发,而老年代由于回收的可能性不高,所以适合使用标记清除法或者标记压缩法

不同的GC算法需要搭配不同的垃圾收集器。

4. 垃圾收集器

4.1 串行垃圾收集器

串行垃圾收集器在使用过程中,一旦发现堆内存不足,则会发现当前所处的内存环境启动相应的GC线程(新生代开启MinorGC,而老年代开启MajorGC),而在GC线程运行的时候,所有的用户线程必须暂停当前的任务,一直到GC线程执行完毕才会恢复其他线程的运行。

如果想要启用串行垃圾收集器,那么就可以在程序启动的时候,通过-XX:+UseSerialGC这个参数进行配置。

4.2 并行垃圾收集器

由于大量的对象都保存在新生代中,所以新生代之中必然会进行多次的MinorGC操作,而使用串行收集器会出现使得应用停顿的问题,为了解决这个问题, JDK在串行垃圾收集器的基础上提供了并行垃圾收集器,它可以提供更多的GC处理线程,以减少应用暂停的时间。

并行垃圾收集器可以充分地发挥多核CPU的硬件特点,使用更多的GC线程来减少GC暂停所耗费的时间。

序号 参数 描述
01 -XX:+UseParallelGC 启用串行垃圾收集器
02 -XX:MaxGCPauseMills GC最大暂停时间(单位:毫秒)
03 -XX:ParallelGCThread 并行回收的线程数量
04 -XX:+UseParallelOldGC 老年代并行回收

4.3 CMS垃圾收集器

CMS(Concurrent Mark Sweep):CMS垃圾收集器是一款针对老年代堆内存实现的垃圾收集器。其基于并发的GC模式,使用标记清除算法进行GC工作

CMS垃圾收集器已经被弃用

在G1收集器以前,CMS垃圾收集器是能够从容面对较大内存的最好用的垃圾收集器。

如果想要使用,应该通过-XX:+UseConcMarkSweepGC进行开启

CMS的GC可以分为不同的处理阶段,有些阶段是单线程处理的,有些阶段是多线程处理的,整个操作过程中只会出现两次短暂的STW暂停,所以对应用程序的影响比较小,但是在并发清除阶段GC线程会与用户线程抢夺资源,容易产生内存碎片。

  • 初始标记(Initial Mark):虚拟机暂停正在执行的任务STW阶段,由根对象扫描出所有的关联对象,并做出标记,此过程只会导致短暂的JVM应用暂停
  • 并发标记(Concurrent Marking):恢复所有暂停的线程对象,并对先前标记过程对象进行扫描,取得所有跟标记对象有关联的对象。
  • 并发预清理(Concurrent Precleaning):查找所有在并发标记阶段进入老年代的对象(一些对象可能从新生代晋升到老年代,或者有一些对象被分配到老年代),通过重新扫描,减少下一阶段的工作
  • 重新标记(Reamrk):此阶段会暂停虚拟机,对在并发标记阶段被改变引用或者新创建的对象进行标记
  • 并发清理(Concurrent Sweeping):恢复所有暂停的应用线程,对所有未标记的垃圾对象进行清理,并尽量将已经回收对象的空间重新拼凑为一个整体。在此阶段GC线程和用户线程并发执行
  • 并发重置(Concurrent Rest):重置CMS垃圾收集器的数据结构,等待下一次垃圾收集。

不管是串行还是并行的以及CMS处理,都是针对于一块完整的内存空间进行的垃圾收集,此时在一块完整的内存空间之中, 分为新生代和来年代,这些内存可能都是非常庞大,但随着内存成本的进一步的降低,和硬件技术的进一步发展,这样的回收策略也是无法适应于新时代的。

4.4 G1垃圾收集器

随着服务器应用要求的日益提升,多核CPU以及高内存的硬件配置已经成为了驻留,为了更好的提升Java垃圾回收的性能,在JDK1.7U4以后的版本正式提供了G1垃圾收集器,目的是为了代替CMS垃圾回收期的使用

传统Java所提供的垃圾收集器(串行、并行、CMS)都是在堆内存分代的存储前提下设计并不断改善的,当使用内存较小的时候,这些垃圾收集器没有任何的问题,但是在内存较大的时候GC处理的时间就会增加,主要是因为内存碎片的产生以及GC之后整理内存之后产生的额外消耗,从而导致整个应用的暂停时间STW就会增加。在这个基础之上G1针对内存的划分进行了重新的设计,将整个堆内存划分了2048个内存区(region),每一个内存区的大小为1~32M这样的话,伊甸园区、存活区、以及老年代就变成了一系列不连续的内存区域,这样就避免了全内存的GC处理操作。

G1:内存划分最大的特点在于直接取消了堆内存中年轻代老年代的物理结构划分,而后将一块完整的物理内存拆分为若干子个区域,每一个子区域代表了不同的存储分代,也就是说每一个子区域可能会是伊甸园区,可能会是老年代以及巨型区的任意一种,这些区域变为了一系列不连续的内存区域,从而避免了全内存的GC操作,也就提升了JVM进程中GC处理性能。

G1中的分代回收

新生代在达到数据存储上限时需要对整个新生代进行回收和对象晋级处理,而不是分区处理,这样做的目的是保证新生代的分区策略和老年代保持相同,便于用户调整大小。

G1是一种带有压缩功能的收集器,在回收老年代分区的时候,会将存活对象从一个分区拷贝到另一个可用分区,这个拷贝的过程就实现了局部的压缩。

为了进一步提高G1垃圾收集器的回收性能,会同时进行N个垃圾最多的区域回收,同时G1垃圾收集器要维护一个区域链表(Collect Set),该链表将保存回收后的区域,包含有如下三种回收模式

  • Young GC:这种模式CSet只包含年轻代区域
  • Mixed GC:该模式下会选择所有的年轻代区域,并选择部分老年代区域(老年代的区域依据存活对象的计数,而GC会会选择存活对象最少的区域进行回收)
  • Full GC:如果对象内存分配速度过快,Mixed GC来不及回收导致老年代被填满,这就会触发一次Full GCG1Full GC算法就是单线程执行的老年代回收,会导致异常的长时间STW,需要不断的调优来避免Full GC

G1中的卡表

在对象进行标记与清除的逻辑之中,都需要通过根对象开始扫描全部的引用关系,这样就有可能会出现全堆扫描的问题(也就是最坏的情况整个堆都需要进行扫描),所以针对这种情况,G1提供了一个卡表Card Tbale的概念,每一个区域被分成了固定大小的若干张卡Card,每一张卡都使用一个字节来记录是否被修改过,而卡表就是这些记录字节的集合

垃圾回收的时候有两种处理形式

一种是实时垃圾回收,另外一种是软实时的垃圾回收策略,在指定的时间内完成垃圾回收,而后者会设置一个垃圾回收时间的限制,G1会努力在这个实现内完成垃圾回收,但是G1并不担保每次都能在这个时限内完成垃圾回收,通过设定一个合理的目标,可以达到90%以上的垃圾回收任务都在这个时限内

区域Rset记录

Remember Set(简称RSet)主要记录了非收集部分指向收集部分的指针集合(传统的垃圾回收中RSet的主要功能就是进行分代记录),在G1中的每一个区域都会有一个RSet记录,RSet记录其他区域中的对象引用关系,这样在回收一个区域时不需要进行全堆扫描,只需要检查它的Rset就能够找到其对应的外部引用,为了避免多线程并发修改,G1Rset划分为了多个哈希表,每个线程都在各自的哈希表中进行修改。

为了保证在进行垃圾回收的过程之中,堆内存不发生变化,因此采用了写屏障的锁机制。

写屏障的锁机制

当向堆内存中写入数据的时候,如果发现正在进行回收,则就需要将写入的进行操作暂停。

G1的垃圾回收的写屏障私用的是一种两级log buffer的结构

  • global set of filled buffer:所有线程共享的一个全局的,存放填满了的log buffer的集合
  • thread log buffer:每个线程都有自己的log buffer,所有的线程都会把写屏障记录先放进去的log buffer,装满后就会把log buffer放到global set of fillerd buffer,之后再申请一个新的log buffer

G1与STW

传统的垃圾收集器是针对于整块堆进行扫描回收,但是如果进行全堆的扫描的话那么势必会带来频繁的STW问题,虽然在G1里面将内存分为若干个子区域,但是如果所有的子区域进行全堆扫描,也会出现性能问题。

所以在G1中考虑到了这些问题,它认为已经被回收的空间有可能短期内不会出现大的垃圾空间,所以采用增量的方式,对那些新增加的对象的区域实现回收,这样就避免了全部区域的扫描操作,从而避免了长时间的STW问题出现。

G1垃圾回收

G1垃圾收集器在垃圾回收的时候主要有四个步骤:新生代垃圾收集并发标记周期混合收集以及必要的FullGC处理操作:

  • 新生代垃圾收集

当堆内存中所分配的所有的伊甸园区耗尽的时候就会触发新生代的GC回收,以释放新生代的内存空间,在新生代垃圾收集过程中会出现极短的STW问题,随后基于多线程方式进行回收,当某些对象回收后依然就存活,则会将其拷贝到新的存活区或者是直接晋升到老年代之中。

  • 并发标记周期

在并发标记周期可能有一次或者多次的新生代垃圾收集,这些对象可能会晋升到老年代之中,同时也会找出包含最多垃圾的老年代(如图中的X标记分区),这个操作过程使用的是SATB标记算法,会分为如下几个阶段

  • 初始标记(inital-mark):通常初始标记会跟一次新生代收集一起进行,会产生STW
  • 根分区扫描(root-region-scan):扫描存活区(根分区),所有被存活区所引用的对象都会被扫描并且标记,该阶段不会产生STW问题
  • 并发标记阶段(concurrent-mark):采用多线程实现并发标记,并将标记的结果保存在全局列表之中
  • 重新标记阶段(remarking):最后一个标记阶段,会产生STW问题,处理剩下的SATB日志缓冲区和所有更新的引用,同时G1会找出所有未被标记的存活对象
  • 清理阶段(clean-up):主要通过老年代回收,可回收的内存很小,同时还会识别空闲分区,Rset梳理等操作

SATB技术

SATB:是维持并发GC正确性的手段,G1垃圾回收的并发理论基础就是SATB

SATB算法创建了一个对象图,它是堆的一个逻辑快照,标记数据结构包括了两个位图previous和next位图

  • previous位图保存了最近一位完成的标记信息,并发标记周期会创建并更新位图。随着时间的推移previous位图会越来越过时,最终在并发标记周期结束的实时,next位图会将previous覆盖掉

混合收集策略

混合收集会执行多次,一直运行到几乎所有标记点老年代分区都被回收掉,在这之后就会恢复到常规的新生代垃圾收集周期,当整个堆的使用率超过指定的百分比的时候,G1垃圾收集器就会启动新一轮的并发标记周期,在混合收集周期中,对于要回收的分区,会将该分区中存活的数据拷贝到另一个分区,从而减少碎片。

G1垃圾收集器的设计问题

G1垃圾收集器通过部分区域回收的处理形式,解决了部传统你垃圾收集器中全堆扫描所带来的性能问题,极大的改善了在堆内存较大的情况下的停顿时间,但是随着硬件性能的发展,G1收集器也收到了极大的性能限制

4.5 ZGC

ZGC:是可伸缩(scalable)的低延迟、并发的垃圾回收器,可以通过-XX:+UseZGC参数就可以启动。

5. NUMA架构

内存分配的性能决定了JVM的处理性能,所以在ZGC中默认支持了NUMA架构,这样在进行小页面的内存分配的时候,会根据当前线程执行的CPU来选择最近的本地内存进行分配。当本地内存不足的时候才会通过远程内存进行分配。面对中页面或者大页面时并没有强调必须从本地内存进行分配,而是将具体的分配操作交由操作系统进行负责,由操作系统去寻找一块合适存放当前对象的内存空间进行存储。

6. Page内存管理

为了进一步简化内存的分配结构,在ZGC中并没有设置新生代与老年代的概念,而是以Page为单位进行对象的分配和回收处理,为了便于对象的存储,会根据不同的Page代进行内训的分配和回收,一共有三种存储处理操作。

  • 小页面存储Small Page:存放256K以下的对象
  • 中页面存储Medium Page:存放4M以下的对象
  • 大页面存储Large Page:存放4M以上的对象

7. 指针着色技术

ZGC必须工作在64位操作系统之中,其中ZGC的低42位用于定义JVM允许使用的堆内存空间,而后的4位(42-45位)用于进行元数据的描述,这种技术就叫做指针着色技术

这4位并不是用于地址寻址操作的,而是用于视图区分,分别将Remapped、Marked1以及Marked0设置为1即表示使用对应的视图,不同的视图通过mmap(内存映射文件方法)映射到同一个内存地址上,就可以利用指针着色技术实现并发标记、转移以及重定位的操作,而ZGC就结合指针着色以及SATB算法实现内存的并发回收处理

  • 初始化阶段:当ZGC初始化完成之后,此时的地址视图为Remapped,当前的程序正常进行堆内存的分配,当满足一定到的触发条件之后启动垃圾的回收处理
  • 标记阶段:在第一次进入标记阶段时对应的视图为Marked0,而进入到此阶段的时候,会同时并发执行应用线程与标记线程,在进行对象访问的时候,也有可能被应用线程和标记线程同时访问。
    • 标记线程处理:从根对象开始扫描并进行标记,如果发现此时的对象地址视图为Remapped,则由根对象开始进行扫描所有的活跃独享,随后将活跃对象的地址视图由Remapped调整为Marked0,如果在标记前发现对象地址视图已经是Marked0,那么就不进行重复的标记
    • 应用线程处理:此时的应用线程可能要进行新对象的创建,也有可能要进行已有对象的读取,在进行新对象的创建的时候,由于该对象属于活跃对象,则会将对象的地址视图直接标记为Marked0,如果要进行对象读取并且该对象的地址视图为Remapped的时候则会按照SATB算法,将该对象以及引用关联对象的视图调整到Marked0即可,而如果访问该对象的时候,该对象的地址视图为Marked0的时候就不需要进行任何的处理。
    • 当标记阶段完成后,所有的对象地址视图分为Remapped(待回收的垃圾对象),或者是Marked0(活跃对象),随后会将所有的活跃对象保存到活跃对象集合之中。

8. 条带标记技术

考虑到标记性能的因素,在ZGC标记过程中会使用多个GC线程进行标记,而为了放置这些线程之间彼此影响,所以在ZGC中引入了条带标记Striped Mark,一个条带中可能会包含有一个GC线程,但是却可以对应不同的内存区块。

9. 应用线程与读屏障

ZGC处理中,应用程序和标记线程可能都处于并行执行的状态,在应用线程访问已存在对象的时候,有可能该对象正在被标记线程所处理,为了解决此问题。应用线程在进行对象的访问的时候会设置一个读屏障(Load Barrier),读屏障主要在于对引用状态的检查,ZGC会在引用返回之前检查着色指针的几个状态位。

  • 如果检查通过(状态符合要求),则引用正确使用
  • 如果检查不通过,那么就会在应用返回之前,ZGC会根据不同状态来执行一些额外操作。

10. 并发转移阶段

将所有标记的活跃对象转移到新内存之中,并回收对象转移前的内存空间,在此阶段转移线程与引用线程并发执行,这两类线程的执行如下:

  • 转移线程处理:将所有地址视图为Marked0的对象进行转移,随后将该转移后的对象地址视图修改为Remapped,如果某些对象在转移的时候发现某些对象的地址视图为Remapped的话就无序进行任何处理。
  • 应用线程处理:在此阶段所有新创建的对象以及引用对象都标记为Remapped,并且新创建的对象不需要进行转移处理,而如果访问的是已有的活跃对象,并且该对象的地址视图为Marked0则需要进行转移处理,随后还需要将地址视图变更为Remapped

文章作者: 穿山甲
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 穿山甲 !
  目录