JVM系列(三):java的垃圾回收机制
浪潮之巅、 人气:2java垃圾回收机制介绍
上一篇讲述了JVM的内存模型,了解了到了绝大部分的对象是分配在堆上面的,我们在编码的时候并没有显示的指明哪些对象需要回收,但是程序在运行的过程中是会一直创建对象的,之所以没有内存溢出是因为我们的虚拟机帮我我们自动进行了垃圾回收,保证程序运行的时候有足够的空间来分配我们创建的对象。
JVM被分为五大内存区域,其中程序计数器、虚拟机栈,本地方法栈是线程私有的,内存随着线程的销毁而退出。堆和方法区是动态分配的,由于方法区的垃圾收集收效甚微,所以本章所说的垃圾回收主要指的是堆内存的垃圾回收。
什么样的对象会被回收
什么样的对象会被回收呢?我们想象下在生活中,什么样的东西会被我们扔进垃圾桶呢,是不是已经不再使用的东西或者说是没有任何利用价值的东西,在java中也是一样的,就是不会再使用到的对象。那么在java中,怎么判断这个对象是不是不会再被使用呢?显然,这似乎要比现实生活中判断哪些东西是垃圾要复杂许多。
如何确定一个对象是垃圾
前面说到,我们需要知道哪些对象是需要被回收的,那么怎么判断这个对象是否需要回收呢?
引用计数法。
创建对象的时候,给对象添加一个引用计数器,每当有一个地方引用的时候,就给计数器加1,当引用失效时,就给计数器减1,当引用计数器为0的时候,说明这个对象不会再被使用。这种方法被称为引用计数法。引用计数法的逻辑比较简单,效率高,但是却无法解决对象和对象之间的循环引用的问题。
可达性算法分析
可达性分析算法的基本思想是通过被称为GC Roots的起始点向下搜索,搜索走过的链路被称为引用链,如果没有任何一条链路到达这个对象,那么这个对象就不会再被使用,就可以将其回收。
在java语言中,以下对象可以被称为GC Roots:
- 虚拟机栈中引用的对象,
- 方法区的类的静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中Native方法引用的对象。
如图所示:Object1、Object2、Object3通过Gc Roos是可达的,所以这些对象是不可回收的,而Object4、Object5通过GC Roots不可达,这些对象是可以回收的。
垃圾回收算法
1.标记-清除算法(Mark-Sweep)
标记清除算法是最基础的,它分为两个阶段,标记和清除。先标记回收的对象,然后清除这一部分对象的内存。
标记阶段堆中所有的对象都会被扫描一遍才能确定需要回收的对象,比较耗时。
缺点:
(1)标记和清除两个过程都比较耗时,效率不高
(2)会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
2.复制-回收算法
复制回收,顾名思义,就是将存活的对象复制出来,然后清理剩下的内存。这种算法不会产生内存碎片。将内存划分为两块相等的区域,把存活的对象直接复制到另一块内存,之所以分配相等,是因为在极端的情况下,第一块内存区域的对象都是存活的。但是这样内存的利用率非常低,后来经过研究新生代中的对象基本都是存活率比较低,基本98%的对象都会在垃圾回收的时候被回收掉。所以将新生代划分为三个区域,eden区,survivor0和survivor1区,默认按照 8:1:1的比例分配,eden区经过回收后,将存活的对象复制到survivor0区,这样就只会有10%的空间没有使用到,但是,我们无法保证每次回收的对象都低于10%,因此,当survivor空间不够用的时候,就需要依赖于其他的内存空间。
3.标记-整理算法
复制-回收算法在对象存活比较少的情况下效率很高,但是当对象存活率很高的时候就不适合使用了。标记-整理算法与标记清除有点类似,都是先标记,但是标记-整理算法会将可回收的对象都向一端移动,然后直接清理掉可回收对象边界以外的对象。这样的好处是不会产生内存碎片。
4.分代收集算法
其实这种算法可以看做是前几个算法的结合,根据对象存活的特点,将堆分为新生代和老年代。新生代的对象存活率低,存活对象少,使用复制回收算法的效率高,而老年代对象存活率高,存活对象多,显然是使用标记整理的算法效率高。
java堆的内存模型
前面提到,根据对象存活的特点以及使垃圾回收产生算法产生最大的收益,将堆区分为两大块,一个是Old区,一个是Young区。Young区分为两大块,一个是Survivor区(S0+S1),一块是Eden区。 S0和S1一样大,也可以叫From和To。
对象创建所在区域
一般情况下,新创建的对象都会被分配到Eden区,一些特殊的大的对象会直接分配到Old区。
比如有对象A,B,C等创建在Eden区,但是Eden区的内存空间肯定有限,比如有100M,假如已经使用了
100M或者达到一个设定的临界值,这时候就需要对Eden内存空间进行清理,即垃圾收集(Garbage Collect),
这样的GC我们称之为Minor GC,Minor GC指得是Young区的GC。经过GC之后,有些对象就会被清理掉,有些对象可能还存活着,对于存活着的对象需要将其复制到Survivor区,然后再清空Eden区中的这些对象。
为什么要分为surivor0和surivor1
下面根据垃圾收集算法,详细讲解下为什么要分为surivor0和surivor1,难道一个survivor区不行吗?
假设只有一个s0区,eden区回收之后,一部分对象存放到了s0区,此时eden区空间全部释放,内存都是连续的。但是因为s0区也会进行垃圾回收,它有一部分存活的对象进入到了Old区,还有一部分对象存活留下来,这时候s0区就产生了内存碎片,为了使s0区的内存空间相对连续,再分配一个s1区,大小和s0一样,每次垃圾回收的时候,将eden区和s0区存活的对象移动到s1区,这样永远都能保证s0或者s1的内存空间是连续的。当然,这样的情况下会使得s0或者s1区有一个空间永远为空,浪费10%的内存空间,当然为了最大化的利用young区,这样的浪费是被接受的。所以,young区一次GC流程是这样的:在同一个时间点上,S0和S1只能有一个区有数据,另外一个是空的。假设s0区有数据,此时进行一次GC操作,s0区中对象的年龄就会+1,而Eden区中所有存活的对象会被复制到是s1区,s0区中还能存活的对象会有两个去处。若对象年龄达到之前设置好的年龄阈值,此时对象会被移动到Old区,Eden区和s0区没有达到阈值的对象会被复制到s1区,s0区将又会变为空的。
整个young区的回收过程是这样的:
一个对象的一生
我是一个普通的对象,我出生在Eden区,周围还有一些和我长得很像的兄弟姐妹,我在Eden区玩了一段时间后,后来我的兄弟们越来越多,多到住不下了,于是我的JVM爸爸就把我赶出了Eden区,我被发配到了s0区,在s0区,我认识了一个女生Baby,她说它的故乡也是Eden区,她比我早来几年,我们互相心生好感,我们彼此约定白头偕老,在这段蜜月期我们时不时的从s0区逛到s1区,又从s1区逛到s0区,可是好景不长,有一天早上醒来,我发现我的Baby不见了,卧槽不见了,我抓狂,她给我留了个字条,说n年后去Old区找她。我很伤心,但是我一直觉得老天用一根无形的丝线将我们联系在一起。我想了一下,两情若是久长时,又岂在朝朝暮暮,我心里有她就行。n多年过去了,我一直记得这个事情,这一天终于到来了,我立即收拾包袱来到了Old区,找了许久,可当我找到她的时候,她已白发苍苍,行将就木,她说她终于等到我了,我要是晚来几分钟连她最后一面都见不到。说完她就拜拜了,身体消散在Old区,我心里已然了无牵挂,决定追随我的爱人,于是我也消散在这片天空,泯然于世间,仿佛我从来没有来过一样。
Minor GC、Major GC、Full GC
新生代的垃圾回收叫Minor GC,老年代的垃圾回收叫Major GC,Full GC是指清理整个堆空间,包括年新生代和老年代。由于老年代大部分场景是由新生代垃圾回收触发,所以,Major GC通常也会伴随着一次Minor GC。
八种垃圾收集器
前面讲到了垃圾收集的算法,这只是一种理论思想,我们需要把思想转化为一种具体的垃圾收集工具,垃圾收集器就是垃圾收集算法的具体实现。它们分别是新生代的:Serial、ParNew、Parallel Scavenge 老年代的:Serial Old、 Parallel Old、CMS以及适用于新生代和老年代的G1。算上jdk11的ZGC目前一共是八种垃圾收集器。目前现代互联网公司基本都采用CMS和G1作为线上的垃圾收集器,因此本文后续篇幅将会着重介绍这两个垃圾收集器。
Serial收集器
Serial是最早的垃圾收集器,这是一个单线程收集器,它只适用一个CPU或者是一条收集线去执行回收任务。
如图所示,Serial收集器在工作的时候必须暂停所有的用户线程,也就是 STW(Stop The World),用户线程必须在收集任务完成之后才能工作。如果回收的时间过长的话是很影响用户体验的。Serial适用于单个CPU的环境,其实随着计算机的发展,如今多核CPU已经很普遍,就算是个人的PC也是多核的更别说线上的服务器了,所以个人认为Serial以后使用的场景将会非常少。
2.ParNew收集器
ParNew是一个新生代的多线程的收集器,它相当于是Serial的多线程版本。它的一些参数配置和Serial基本完全相同。只不过ParNew收集器在工作的时候,是多个线程工作的,如图所示:
ParNew适合在多个CPU场景下使用,而我们的线上服务器基本都是多核CPU,所以,使用新生代的ParNew搭配老年代的CMS收集器还是挺常见的,我所在的部门的系统线上就是使用的ParNew+CMS组合。与Serial相同的是,ParNew在进行垃圾回收的时候,也会暂停所用的用户线程。
3.Parallel Scavenge收集器
Parallel Scavenge 也是一个新生代收集器,并且也是一个多线程收集器,Parallel Scavenge关注的点是应用的吞吐量,吞吐量 = 用户代码运行时间/用户运行代码时间+GC时间,它提供了两个参数用来控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis 参数和直接设置吞吐量大小的-XX:GCTimeRatio 参数。GCTimeRatio参数的值是一个大于0且小于100的整数,也就是垃圾收集时间占总时间的比率,相当于是吞吐量的倒数。高吞吐量可以高效的利用CPU时间,尽快完成计算任务,因此,Parallel Scavenge收集器也用于需要密集计算不需要进行用户交互的一些后台。
4.Serival Old收集器
Serival Old 收集器是垃圾收集的老年代版本,也是一个单线程收集器。
5.Parallel Old收集器
Parallel Old收集器是Parallel Scavenge的老年代版本。可以使用Parallel Scavenge+Parallel Old组合,在注重吞吐量和CPU资源敏感的场合可以优先考虑Parallell SCavenge 和Parallell Old组合。
6.CMS(Concurrent Mark Sweep) 收集器
CMS(Concurrent Mark Sweep),并发标记清除,这是一种追求低停顿时间为的收集器。互联网时代,用户体验为王,垃圾收集的时间越短,给用户带来的体验就越好。CMS收集器整个回收过程可以分为四个步骤:
- 初始标记(CMS inint mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark mark)
- 并发清除(CMS concurrent sweep)
如图所示:
初始标记:
初始标记只是标记着GC Roots 能直接关联到的对象,这个过程需要对所有的对象进行标记,为了防止标记的过程中有对象的状态发生改变,需要暂停用户线程,因为只是标记GC Roots 能直接关联到的对象,因此这部分的执行速度很快。
并发标记:
对初始标记中标记的存活对象进行trace,标记这些对象为可达对象,这个阶段在标记的时候可以执行用户线程,由于用户线程会和标记的线程一起工作,可能会有新的垃圾对象产生而没有标记完整。所以会将在并发阶段新生代晋升到老年代的对象、直接在老年代分配的对象以及老年代引用关系发生变化的对象所在的card标记为dirty,避免在重新标记阶段扫描整个老年代。
重新标记:
重新标记阶段是为了修正并发标记阶段产生的垃圾对象,这一部分是暂停用户线程的,但是执行时间也很快。
并发清除:
这个阶段是是清除标记好的垃圾对象,会和用户线程同时进行。
cms垃圾收集允许一定的误差,因为并发标清除的阶段会有用户线程同时工作,又将会有新的垃圾对象产生。但是它主要考虑的是低停顿时间。由于整个过程中,并发标记和并发清除,收集器线程可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行的。
cms收集器很好的展示了它的优点,低停顿,但是,它也存在着以下几个缺点。
- 吞吐量降低:由于是和用户线程并行执行的,会占用一部分的CPU资源,会导致用户进程变慢影响吞吐量,这也是和Parallel Old相反的地方。
- 产生浮动垃圾:什么是浮动垃圾,前面也提到了,在并发清理的阶段,由于清理的工作是和用户线程一起工作的,那么就会在清理的阶段而再次产生垃圾对象,但是前面的标记阶段已经结束,所以清理阶段是无法清除这些新产生的垃圾对象的,只能等待下一次的垃圾回收,所以,就必须要留有一部分的内存空间给这些对象存储。如果预留空间不够的话,会出现“Concurrent Model Failure”,这时虚拟机会临时启用Serial Old收集器来收集,这样就会造成停顿时间过长。
- 会产生内存碎片:由于CMS是采用标记-清除算法来实现的,由前面的图可知,标记清除算法会使内存空间不连续,如果有大的对象分配过来而刚好又没有足够的连续空间存储的话就会再次触发Full GC。为了解决这个问题CMS提供了参数-XX:+UseCMSCompactAtFullCollection 来在Full GC之前进行压缩空间,但是这不得不导致停顿时间变长。
G1(GarBage-First)收集器
G1收集器是一款面向服务端的收集器,也就是说,它将低停顿时间作为终极目标。G1与其他垃圾收集器的区别是它可以控制垃圾收集时间在某一个范围之内。与CMS垃圾收集的运行过程类似,它分为初始标记,并发标记,最终标记,筛选回收。G1之所以能够将停顿时间控制在一个指定的时间内,就是因为它可以选择性的进行回收。
G1尝试着去满足最小的停顿时间,在G1中,停顿时间是可以设置的,是可控制的,之所以可以建立可预测的停顿时间模型,是因为G1避免了在java堆中进行全区域的垃圾收集。传统的新生代老年代的内存模型被多个大小相等的独立区域(Region)所取代。如下图所示,虽然新生代和老年代的概念还保留着,但是他们不再是物理隔离的了,他们都是由Region所组成。G1在清除阶段是有选择性的,它会根据设置的停顿时间,选择回报率最大的Region。Region可以说是G1回收器一次回收的最小单元。即每一次回收都是回收N个Region。这个N是多少,主要受到G1回收的效率和用户设置的软实时目标有关。
G1的内存布局:
G1中的巨型对象是指,占用了Region容量的50%以上的一个对象。Humongous区,就专门用来存储巨型对象。如果一个H区装不下一个巨型对象,则会通过连续的若干H分区来存储。因为巨型对象的转移会影响GC效率,所以并发标记阶段发现巨型对象不再存活时,会将其直接回收。分区可以有效利用内存空间,因为收集整体是使用“标记-整理”,Region之间基于“复制”算法,GC后会将存活对象复制到可用分区(未分配的分区),所以不会产生空间碎片。
前面说到,G1会选择性的回收Region,避免扫描整个堆。但是正常情况下,每一个Region之间可能都会有互相引用的对象,这样的话在垃圾收集扫描的时候还是不可避免的扫描整个堆来确定哪些是垃圾对象,G1是如何解决这一问题的呢?G1通过让每一个Region都维护一个Remembered Set来避免全堆扫描,在程序对引用类型的对象进行写操作的时候,虚拟机会检查Reference引用对象是否在不同的Region,并且会把这些引用的信息记录在Renembered Set中。
整个G1的垃圾回收阶段可以分为:
初始标记:标记GC Roots能直接关联到的对象,需要暂停用户线程。
并发标记:从GC Root开始对堆中的对象进行可达性分型,标记出存活的对象,用时比较久,可与用户线程并发执行。
重新标记:修正在并发标记阶段因用户线程运行发生改变的记录,需要暂停用户线程。
筛选回收:对各个Region的回收价值进行排序,根据用户所设置的回收时间制定回收计划,这个阶段可与用户线程并发执行。
如图所示:
;
G1目前是jdk9的默认垃圾收集器,一般在以下场景中,需要考虑是否需要使用G1垃圾收集器:
(1)50%以上的堆被存活对象占用
(2)对象分配和晋升的速度变化非常大
(3)垃圾回收时间比较长
ZGC
Z垃圾收集器(ZGC)是可伸缩的低延迟垃圾收集器。ZGC可以同时执行所有昂贵的工作,而不会将应用程序线程的执行停止超过10ms,这使得它适合于要求低延迟和/或使用非常大的堆(数TB)的应用程序。
目前ZGC没有分代,每次GC都会标记整个堆,将堆分为 2M(small), 32M(medium), n*2M(large)三种大小的页面(Page)来管理,根据对象的大小来判断在哪种页面分配,大部分对象标记和对象转移都是可以和应用线程并发。只会在以下阶段会发生stop-the-world:
GC开始时对root set的标记时
在标记结束的时候,由于并发的原因,需要确认所有对象已完成遍历,需要进行暂停
在relocate root-set 中的对象时
虽然ZGC属于最新的GC技术, 但是只在特定情况下具有绝对的优势, 如巨大的堆和极低的暂停需求。
本篇文章只是对java的垃圾回收涉及到的方面作一个简单的概括,并没有涉及到具体的算法的分析以及垃圾收集器的内部实现原理。其旨在对java的垃圾回收机制有一个整体的了解,下一章将介绍垃圾收集器用到的一些参数来为GC日志的分析和调优作准备。
参考书籍
深入理解java虚拟机--周志明 著
加载全部内容