JVM垃圾回收器上篇

学习背景

在正式开始垃圾回收器的知识学习之前,我们要先介绍一下本次学习的主要目标。

大部分人,包括我自己学习垃圾回收器的主要目标就是因为这个知识点实在是太重要了,而且很少有系统并且全面的介绍,系统并且全面的程度至少要足够应付面试。哈哈,面向面试学习。

本章节的学习目标主要是介绍各个垃圾回收器的实现思想,以及部分垃圾回收器的实现原理。为了方便各位阅读,这里再介绍一下一些术语

  • JVM java virtual machine, java虚拟机的缩写
  • STW Stop the world,指垃圾回收器回收垃圾过程中,暂停整个JVM中用户程序的过程

垃圾回收器是什么

在回答这个问题之前,我们先回顾一下java语言的特性-内存安全。程序员不需要手动释放内存,内存分配以及回收全部交给垃圾回收器完成。这句话概括了垃圾回收器的职责,但是不够具体。垃圾回收器作为JVM的一个模块,应该是更加具体,有实际的物理指代的实体。当然,从JVM的发展史,以及生态来说,不同的JVM实现有不同的垃圾回收器,甚至在一个JVM实现里有多个垃圾回收器(这种做法也是比较主流的JVM的垃圾回收器实现做法)。不同的垃圾回收器针对被回收对象的特点选择合适的算法,以及内存分配方式管理内存,帮助我们高效的使用JVM。在正式开始垃圾回收器的介绍之前,我先介绍一下比较重要的几个JVM实现,以及历史上的JVM实现,因为我们要学习的垃圾回收器,其理论实现也是由这些JVM完成的。

  • Sun Classic 伴随着JDK1.0发布的第一款商用虚拟机,目前已经退出舞台。
  • HotSpot VM 虚拟机领域的王者,可以说是目前使用最广泛的jvm。虽然这款JVM是目前JAVA服务器领域使用最广泛的实现,但是它一开始并不是为java语言设计的,甚至不是SUN公司设计的。HotSpot一开始由一家小公司"Longview Technologies"设计,Sun公司注意到这款JVM的很多设计非常可圈可点,于是收购了这家公司。当然后来SUN又被Oracle收购了,现在HotSpot的实现由Oracel负责。
  • JRockit 由BEA System实现,在JVM内部大量使用即时编译技术,曾经号称"世界上运行最快的JVM"。后来也被Oracle收购了(怎么哪里都有你,oracle!),现在已经不再发展。
  • IBM J9 IBM主力发展的JVM,曾经和HotSpot,JRockit并称JVM三雄。后来被IBM捐给Eclipse基金会,实际上Eclipse也是IBM成立的,不过是单独运作。

在后面的垃圾回收器的理论学习里,我们将主要选择HotSpot的实现来讨论。

JVM内存区域

在讨论垃圾回收器之前,还需要了解JVM的内存布局,了解内存布局能帮助我们更好的理解垃圾回收器的工作机制,不仅仅是垃圾回收器的工作机制,后续的编译器的知识也需要提前了解jvm内存布局。根据《java虚拟机规范》jvm的内存布局分为下面几个部分。

看到这一坨不知道是做什么的区域划分是不是一脸懵逼,不要急,等我慢慢解释。

  1. 程序计数器 在jvm运行过程中,往往需要从一个线程切换到另外一个线程。但是实际上,物理意义上一个cpu核心一个时刻只能有一个线程在运行。那么jvm为了实现看起来在"一个时刻",比如说"1秒钟"内运行多个线程这种效果。本质就是通过在多个线程之间切换跳转完成的。程序计数器是一块线程私有的区域,他的作用就是用来记录当前线程执行的指令位置。(这里的知识如果了解操作系统关于CPU调度时间片的理论的话,理解起来会更加形象)
  2. 虚拟机栈 jvm的运行是基于字节码指令来完成的,关于字节码指令运行的细节以及指令之间切换的过程很复杂,在后续关于编译器的内容里我们再细说。这里大家有个大概的概念即可,所有指令按照后进先出的方式排队(也被称为压栈),虚拟机栈就是来记录每条指令的,每当一条指令被执行完毕,这条指令就从栈中移除,被称为弹栈。这里只是一个大概的描述,实际上虚拟机栈里除了存储指令还存储了指令需要使用的变量(局部变量表),方法的返回地址,方法出口等信息。简单的理解就是为了执行这条指令所必须得参数以及返回值地址等信息。
  3. native栈 也被称为本地方法栈,结构和虚拟机栈类似。区别是虚拟机栈是为了执行java方法的,但是本地方法栈则是为了执行非java方法的。比如USafe工具,里面的方法大部分都是native的,用native标记。
  4. 堆 jvm中内存最大的区域,也是存放数组和对象的位置。也是我们今天要讲的垃圾回收器的主要工作区域。在很多描述垃圾回收的理论里常出现"新生代","老年代"的概念。其实所谓新生代,老年代都是指的堆中的内存,只不过按照不同的划分方式划分。堆中的内存可以认为几乎是所有线程共享的,但不是全部。因为在实际的内存分配时,为了加速线程访问速度,每个线程在堆上还有一小块私有区域,叫做TLAB(Thread Local Allocation Buffer)。试想一下,假设没有这个TLAB区域,每个线程随机访问内存,那么很容易就产生线程竞争,拖慢线程访问速度。
  5. 方法区 方法区是存放被编译后的类信息,常量,静态变量等信息。方法区本质也是堆的一部分,但是因为它的重要性还是把它与堆区分开来。说到方法区,还要提到一个概念"永久代"。以HotSpot为例,在JDK7之前,HotSpot是使用永久代来实现方法区。具体做法是让垃圾回收器管理像管理堆一样管理这部分内存,这样就省去了专门为方法区编写内存管理代码的工作。但是同时期的一些JVM,比如J9,JRocikt是不存在永久代的概念的。这里就能看出来《java虚拟机规范》只是一个规范,具体的实现则掌握在开发团队手里。长久来看这种做法并不是一个好主意,因为这会导致内存溢出的风险,所以HotSpot团队在JDK7版本彻底放弃了永久代的概念,将原本永久代拆分成方法区和常量池。
  6. 运行时常量池 运行时常量池是方法区的一部分,用来存放类被编译后的常量,但是这个常量池也可以在运行时动态增加,比如String的intern方法就可以添加新的常量
  7. 直接内存 这一块内存不属于JVM运行时内存的部分,但是也可能被经常使用,比如NIO库中的DirectByteBuffer就是操作的直接内存,大名鼎鼎的Netty的ByteBuf也是使用的直接内存。

好吧前面我们叨逼叨了这么久,总算是快进入到正题了,但最后容许我再叨逼叨一点。实际上最好再讲讲对象创建以及对象内存布局的内容,但是如果继续铺垫,就显得"前奏"太冗长,所以这部分内容等后面讲编译器相关内容的时候我再拿出来详细介绍。

垃圾回收理论

指导思想

设想一下,要回收一个对象,要怎么回收。大致上我们可以分为两步。

  1. 确定那些对象需要回收-识别垃圾
  2. 将需要回收的对象所在的内存置空-回收垃圾 我们分别以这两个步骤来介绍垃圾回收器的设计思想。

识别垃圾

引用计数算法

假如我有一个额外的区域,里面记录了每个对象被引用的次数。比如objectA = 2;表示有两个对象引用了objectA对象。 每当有其他对象引用obecjtA,比如objectB.a = objectA时就将引用次数+1,当引用失效时引用-1(包括释放引用objectB.a=null以及持有引用的objectB被销毁)。当objectA的被引用次数归零时,说明没有其他对象再objectA,这时就可以销毁objectA。 这时一种很朴素的思路,也是很多软件采取的垃圾回收方式,比如FlashPlayer,Python,Redis里都有使用这个算法。但是java里主流的JVM都没有采用这种算法,因为它很难处理循环引用的情况,需要编写额外的代码。 一个简单的循环引用的例子

可达性分析算法

主流的现代jvm都采用的可达性分析算法来判断一个对象是否可以被回收。可达性分析算法要求从一些列被称为"GC root"的对象出发,遍历这些对象引用的对象,我们称为子Field。并不断地再找到子Field引用的对象。通过这种不断向更深处找到被引用对象的方式,我们可以得到一条"引用链"(reference chain),处于引用链上的对象就是存活的。处于引用链之外的对象就是可以被回收的。 在实际的算法实现中,被用作GC ROOT的对象一般是以下几种

  1. 虚拟机栈中的对象,比如局部变量,临时变量
  2. 方法区静态属性引用的对象
  3. 方法区常量引用的对象
  4. native方法引用的对象
  5. 其他,这里可以概括为虚拟机内部持有的对象,比如类加载器持有的对象,JMX持有的对象,甚至Synchronized持有的对象。

何为引用

通过前面的介绍,我们发现一个很重要的概念,引用。引用描述了两个对象之间的持有关,它实际的定义是在JDK1.2后才定义的。我们把引用大致分为4类。

  1. 强引用 Strong Reference只要强引用还在,对象就不能被回收。例如Object o = new Object();
  2. 软引用 Soft Reference软引用是描述一些还有用但并非必须的对象,当JVM要发生内存溢出时,会把软引用关联的对象列入回收范围进行二次回收。如果还没有足够内存则抛出内存溢出异常。可以用SoftReference来描述软引用。
  3. 弱引用 Weak Reference弱引用关联的对象只能生存到下一次内存回收之前。当垃圾收集器工作时不论内存是否足够,都会回收掉弱引用对象。用WeakReference类实现软引用
  4. 虚引用 Phantom Reference 虚引用也被称为幽灵引用。一个对象是否有虚引用的存在,完全不影响其生存时间,因为无法通过虚引用来取得一个对象。使用PhantomReference来实现虚引用。

实际上引用的用法远不止这里说的确定两个对象之间的关系,比如Netty里使用WeakReference来检测内存泄漏。但是这不是重点,我们不展开介绍。

回收垃圾

分代收集算法

在介绍回收内存的方式之前我们先介绍分代收集算法,这也是现代虚拟机主要采用的收集算法。简单的说,根据对象的年龄对对象进行划分,年龄比较短的对象被存放在新生代(young 区域),年龄比较大的对象存放到老年代(old 区域)。一般计算对象年龄的方式是根据对象经过的垃圾回收次数,比如每次经过垃圾回收就将对象年龄+1岁。在HotSpot中,默认是对象经过15次垃圾回收还存活就会被转移到老年代,当然这个年龄是可以设置的。分带算法对垃圾回收算法影响很大,在接下来的介绍里,你将会看到不同的垃圾回收算法在不同算法的影响。

标记清除算法

标记清除算法的思路很朴素,算法分为标记和清除阶段。首先标记出所有需要回收的对象,在标记完成后统一回收被标记的对象。标记方式和可达性分析一致。但这么做容易产生大量内存不连续的碎片,导致后续虚拟机分配内存时没有足够的连续空间不得不触发下一次GC。

复制算法

复制算法是标记清除算法的改进版本,它将内存划分为大小相等的两块每次只使用其中一块。当这一块内存用完了,还存活的对象就被复制到另一块上面,然后再把以使用过的内存空间清理掉。这样每次都只对一半的区域进行回收,也不用考虑分配内存时内存不连续的问题,每次回收完毕都可以得到完整连续的内存区域。但是运行内存缩小为了原来的一半,代价太高。但是有办法改进。

现在的商业虚拟机都是采用这种算法,IBM研究过新生代中的对象99%都是朝生夕死,所以并不需要按照1:1的比例来划分空间,而是把内存划分为一块较大的Eden区域和两块较小的Survivor区域,分别称为Eden区,From Survivor区,To Survivor区。每次使用Eden和一块From Survivor区域。回收时将Eden和From Survivor区域中还存活的对象复制到另一块To Survivor中,然后清理掉Eden和From Survivor区域。 HotSpot虚拟机的默认Eden和From Survivor,To Survivor区大小比例8:1:1,每次新生代可用内存占用整个新生代的90%。这里面有个问题,如果回收后需要转移到To Survivor区域的对象超过了To Survivor的容量,即当To Survivor不够时,需要老年代进行内存担保,将一些对象晋升老年代。

这里我画图并没有按照8:1:1的比例画,因为太难了,大家主要是理解复制算法的思想即可。

标记整理算法

从对复制算法的介绍来看,复制算法有两个不足。

  1. 当垃圾回收后存在大量的存活对象时,会花费很多时间做内存拷贝。
  2. 更重要的是,为了避免只使用可用内存的50%导致浪费内存,必须能够有额外的空间保证回收后当存活对象的内存大于互备空间时进行空间分配担保。如果没有额外的空间担保,那么很可能本次垃圾回收会失败。

很明显老年代存活的对象即是存活周期很长的对象,又没有额外的空间来给老年代做担保,所以我们还需要另外的算法来回收老年代。 老年代一般采用标记整理算法。这个算法的思路也比较朴素,首先标记出所有存活的对象,然后将存活的对象往一端移动,最后直接回收掉边界以外的内存。

HotSpot的垃圾回收设计

正如我一开始说的,《java虚拟机规范》只是一个规范,并不强制。jvm团队可以根据自己的需要设计虚拟机。接下来我们从HotSpot这款垃圾收集器来了解了在上面的垃圾回收指导思想下,一些实际的设计点。当然这里的设计点可能需要实际的结合具体的垃圾回收器才能彻底的理解,但是这里我还是先做一个介绍。

根节点枚举

从可达性分析算法来看,我们识别垃圾的步骤就是找到所有从GC Roots出发可达的对象,但是找到哪些对象是GC Roots却并非易事。如果只是逐个检查方法区和常量池等内存区域,把所有对象都遍历一遍,那么这个效果肯定低效的令人发指,因为枚举GC Roots这一步是伴随着STW (STOP THE WORLD,意思是暂停所有除垃圾回收器以外的线程,这么做是为了保证识别出的GC Roots准确,不会被用户线程改变引用)。 HotSpot采用一种被称为OopMap的结构来加速枚举GC Roots过程。当一个对象被加载完毕后,对象内什么位置是什么数据就会被记录下来,还会记录下栈和寄存器里的内存引用位置,这么做就避免了扫描方法区所有对象的过程。

安全点

有了Oop Map,我们就能快速完成GC Roots枚举,但是如果每次内存的引用变更都生成一次Oop Map,那么效率还是很低。。所以JVM要求生成Oop Map的位置只能在安全点生成,安全点生成的位置要求满足"能够让程序长时间执行的特征"。我们对生成安全点的期望是既不会间隔太长导致垃圾回收器等待,也不会间隔太短会导致运行时的回收开销太大。一般的安全点在方法调用,循环,异常跳转等位置。

安全区

安全区的定义与安全点类似,安全区是指引用关系在一段代码内引用关系没发生改变。可以吧安全区看做是安全点的拉伸。

垃圾回收器介绍

有了前面的理论铺垫,接下来我们正式开始学习实际垃圾回收器。

Serial搜集器

jdk最老的收集器,单线程运行,运行期间会暂停JVM中所有工作线程(STW)。虽然听起来让人难以接受,但仍然是Client模式下的新生代默认收集器,因为它的简单高效,开销极小。

Serial Old收集器

它是Serial收集器的老年代版本,同样是单线程收集器。使用标记整理算法。这个收集器的主要作用是在Client模式下给虚拟机使用。在Server模式下还可以配合Parallel Scavenge使用。还有就是作为CMS收集器的后备方案,当CMS收集器发生Concurrent Mode Failure时使用它来回收老年代。这个知识点我们在后续CMS收集器里详细介绍。 额外提一下,Parallel Scavenge其实有一个老年代版本叫PS MarkSweep,但是PS MarkSweep的实现和Serial Old基本一样,所以这里只介绍Serial Old收集器。

ParNew收集器

Serial收集器的多线程版本。虽然并没有什么创新之处,但它仍是JDK1.7 Server模式下新生代的首选收集器。除了性能之外很重要的原因就是他和Serial能配合CMS收集器工作。 注意,在单核环境下ParNew并不比Serial强。

Parallel Scavenge 收集器

使用多线程的新生代收集器,也被称为吞吐量优先收集器,它更关注达到可控的吞吐量(Throughput),一般的垃圾收集器的设计思想则是想要达到一个比较短的停顿时间,比如CMS。吞吐量 = 运行用户代码时间/(运行用户代码时间+垃圾收集器时间),停顿时间越短越适合需要与用户交互的程序。他有两个比较重要的参数

  • -XX:MaxGCPauseMillis 设置最大垃圾收集停顿时间,单位毫秒。收集器将尽可能保证内存回收花费的时间小于该值。这个值不是设置的越小越好。因为GC时间缩短其实是靠减小新生代空间来实现的,但是减小新生代空间同时会导致以前1次能容纳并且GC的对象现在需要2次GC才能容纳
  • -XX:GCTimeRatio 直接设置吞吐量大小,其实是吞吐量的倒数,即垃圾收集器占总时间的时间比率取值(0-100)。如果设置为19,则运行垃圾回收器的时间只能占整体时间的5%,即1/(1+19)。默认值99,即要求垃圾回收器的工作时间只能占用整体时间的1%。

Parallel Scavenge收集器还有一个可选参数-XX:+useAdaptiveSizePolicy,这个开关打开后就不需要指定新生代的小-Xmn,Eden和Survivor的比例(-XX:SurvivoRatio),晋升老年代对象年龄-XX:PretenureSizeThreshold等参数。虚拟机会根据系统运行状况进行性能监控信息,自动调整这些参数以提供最适合的停顿时间或者最大吞吐量,这种调节方式称为GC自适应调节策略。

Parallel Old收集器

Parallel Old是ParNew收集器的老年代版本,使用多线程+标记整理算法。主要用来和Parallel Scavenge配合使用达到一个可控的吞吐量。在注重吞吐量和资源敏感的场合适用。这个收集器在JDK1.6才正式提供,所以在JDK1.6之前只能使用Parallel Scavenge + Serial Old的搭配回收内存,但是在一些老年代内存空间比较大的场景下,因为Serial Old收集器本身的性能原因,导致这个组合的的吞吐性能并不高,甚至比不过ParNew+CMS的组合。这个现象直到Parallel Old收集器诞生才改善。

CMS(Concurrent Mark Sweep)收集器

CMS是作用于老年代的收集器,基于标记-清除算法实现。它的设计目标是为了尽可能的降低停顿时间,在一些常见的网络服务器上,很适合使用它。开启方法-XX:+UseConcMarkSweepGC,它回收过程包含4个步骤。

  1. 初始标记(CMS initial mark)
  2. 并发标记(CMS concurrent mark)
  3. 重新标记(CMS remark)
  4. 并发清楚(CMS consurrent sweep)

其中初始标记和重新标记任然要暂停所有线程,但是时间很短。初始标记仅仅记录下GC Roots直接关联到的对象,速度很快。并发标记就是GC Roots Tracing的过程,这个过程用户线程和垃圾回收线程并发运行。而重新并发标记则是为了修正并发标记期间因为用户线程继续运作导致的标记产生变动的标记记录,这个阶段停顿的时间一般会比初始标记阶段稍长,但远比并发标记的时间短。整个过程耗时最长的并发标记和并发清楚过程都是收集器和用户线程一起运行的。(并发是指多个线程交替执行)

CMS收集器是JVM第一次尝试减少STW的时间并且取得了比较好的效果,一些文档甚至把CMS收集器称为"并发低停顿收集器"。但CMS收集器并不是完美的,它有3个明显的缺点:

  1. 对CPU资源敏感,CMS默认启动的回收线程是(CPU数量+3)/4,也就是说当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源。但当Cpu不足4个时,CMS对用户的影响就可能变大,如果本来CPU负载就比较大,还要分出一部分算力区执行收集器线程,那么就可能导致用户程序的执行速度骤降。为了应付这种情况,JVM提供了一种称为”增量式并发收集器(Icrremental Concurrent Mark Sweep/i-CMS)”的变种,它的不同之处是在并发标记,并发清除时让GC线程,用户线程交替运行,拉长垃圾回收的时间,这样对用户的影响就会少一些(用户感觉变的不那么慢,但是不那么慢的时间变得更长)。但实际上,增量CMS收集器效果很一般。在JDK9里增量并发收集器已经被废弃了。
  2. CMS收集器无法处理浮动垃圾(Floating Garbage),可能会出现Concurrent Mode Failure失败而导致一次完全STW的Full GC的产生。当CMS并发清理阶段用户线程还在产产生垃圾,这部分垃圾出现在标记过程之后,无法被CMS处理,只能等到下一次GC时再清理。这一部分垃圾被称为浮动垃圾。也是由于垃圾收集器工作时用户线程还在运行,那么需要足够的内存空间给用户线程使用,因此垃圾收集器不能像其他收集器那样等到老年代快满了才进行垃圾回收,因为还需要一部分空间提供并发收集器运行。在JDK1.5下,当老年代使用了68% CMS就会被激活,这是一个偏保守的设置,如果老年代增长不是特别快可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来提高出发百分比,以便降低内存回收次数获得更好的性能。在JDK1.6时这个值提升至92%。要是CMS运行期间预留的内存无法满足CMS的运行需要时就会触发Concurrent Mode Filure ,这是JVM就启动后背预案,临时启用Serial Old收集器来进行老年代垃圾收集,这样停顿时间就很长,所以说参数-XX:CMSInitiaingOccupancyFraction设置太高容易导致大量的Concurrent Mode Failure,性能反而更低。
  3. CMS收集器还有个缺点,因为它是基于标记清除算法实现的收集器,导致在收集结束时会产生大量的空间碎片,碎片过多导致大对象分配无法找到连续的空间不得不进行一次Full GC。为了解决这个问题,CMS提供了一个参数-XX:+UseCMSCompactAtFullCollection开关(默认开启),在CMS收集器快要进行FullGC时开启内存碎片整理,但整理过程需要移动存活对象无法并发,停顿时间不得不变长。为此,CMS还提供了另一个参数-XX:CMSFullGCsBeforeCompaction,这个参数是设置执行多少次不压缩的FullGC后跟着来一次带压缩(碎片整理)的,默认值为0,表示每次Full GC时都进行碎片整理。但是这两个参数在JDK9的时候也废弃了。

华丽分割线

到目前为止其实我们还是主要关注在垃圾回收器的理论,并没有实际的介绍垃圾回收器的回收细节。以目前的环境来看,只知道这些去面试肯定是不够的,而且还有最关键的垃圾回收器G1,ZGC,日志等信息我们都没有做讲解。大家放心,这些内容后续肯定会讲,不过文章如果太长反而会影响大家的阅读兴趣,所以垃圾回收器的内容我打算拆成上下两篇。在下篇文章里我会详细介绍G1和ZGC,以及GC日志的知识。大家敬请期待。

#java##晒一晒我的offer##GC##JVM##虚拟机#
全部评论

相关推荐

小红书 后端选手 n*16*1.18+签字费期权
点赞 评论 收藏
分享
11-01 20:03
已编辑
门头沟学院 算法工程师
Amazarashi66:这种也是幸存者偏差了,拿不到这个价的才是大多数
点赞 评论 收藏
分享
10-29 15:38
门头沟学院 Java
榕城小榕树:难道你简历里写了配送路径优化算法?
点赞 评论 收藏
分享
评论
3
4
分享
牛客网
牛客企业服务