JVM垃圾回收
第一章《JVM内存结构》
第二章《JVM垃圾回收》
本文是JVM学习的第二章
概述
程序计数器
、虚拟机栈
、本地方法栈
随着线程
创建而生,也随着线程
一起销毁。栈帧
随着方法的开始而入栈,执行完方法而出栈。所以这几个区域的内存分配和回收都有着确定性,不需要过多考虑回收的问题,因为随着线程结束或方法执行完成时,内存就自动的随着一起回收了
而对于Java堆
和方法区
来说,我们只有在程序的运行期间才知道会创建哪些对象,这部分的内存分配
和回收
都是动态
的。垃圾回收所关注的正是这部分内存。
堆空间的基本结构:
内存分配
新生代和老年代
- 老年代比新生代生命周期长
- 新生代和老年代默认比例为 1:2 ,JVM调参数 为XX:NewRatio = 1
- 新生代 分为 1个Eden Space和2个Survivor 比例是 8 : 1 : 1
- 几乎Java 对象都是在 Eden 区被 new 出来的,Eden 放不了的大对象,就直接进入老年代了。
对象分配过程
Eden区
: new 的对象先放在Eden区,大小有限制,超过大小,就直接进入老年代了,如果创建新对象时,Eden空间满了,就会触发 Minor GC 将Eden不再被其他对象所索引的对象进行销毁,将Eden区剩余的对象移动到Survivor To 区,再加载新的对象放入Eden区Survivor To 区
: GC 开始的时候,Survivor To 区
总是空的,然后将Eden区存活下的对象都复制到 To区Survivor From 区
,GC后仍然存活的对象会根据他们的年龄阀值来决定去向,超过15岁(1次GC1岁 默认15次 可以设置)会被复制到老年代,剩下的都复制到 To 区。- 经过一次GC后
Eden 区
和Survivor From 区
都已经被清空,这个时候From
和To
会交换角色,用来保证To 区
总是空的 - 无限重复上面的流程直到
To
区被填满,然后会将To
区所有的对象移动到老年代去。
垃圾回收
GC 分类
针对HotSpot VM
的实现,它里面的GC 其实准确分类只有2大类
部分收集(Partial GC)
- 新生代收集(Minor GC / Young GC)
: 只对新生代进行垃圾收集;
- 老年代收集(Major GC / Old GC)
: 只对老年代进行垃圾收集;
- 混合收集(Mixed GC)
: 对整个新生代和部分老年代进行垃圾收集;
整堆收集(Full GC) :整个Java虚拟机堆和方法区收集;
Young GC
当新生代Eden 区满了的时候触发
Full GC
当准备要触发一次young GC时,如果发现统计数据之前young GC的平均晋升大小比目前old gen剩余的空间大,则不会触发young GC而是转为触发Full GC,
Full GC 收集老年代的同时都会回收整个堆,所以不需要提前触发一次单独的 Young GC
空间分配担保
空间分配担保是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间。只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。
四种引用方式
强引用
创建一个对象并把这个对象赋给一个引用变量,一般普通 new 出来的对象变量都是强引用,有引用变量指向时,即使OutOfMemoryError
也不会被垃圾回收。可以将引用赋值为 null 那么它的指向对象就会被垃圾回收软引用
如果一个对象具有软引用,内存空间足够,垃圾回收器就不会回收它,如果内存空间不足,就会回收这些对象的内存,只要垃圾回收器没有回收它,该对象就可以被程序使用,软引用可用来实现内存敏感的高速缓存。弱引用
非必需对象,当JVM 垃圾回收时,无论内存是否充足都会回收被弱引用关联的对象,不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。虚引用
虚引用并不会决定对象的生命周期,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收
虚引用主要用来跟踪对象被垃圾回收的活动。
弱引用和软引用可以和一个引用队列联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个引用加入到与之关联的引用队列中。
虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
GC 触发时机
System.gc()
,此方法是建议虚拟机进行Full GC
,通常清况下,它会触发GC
老年代空间不足
,这种情况下会触发GC ,若进行该操作依旧空间不足,就会抛出OutOfMemoryError
**永久代空间不足**
JVM 规范中运行时数据区域中的方法区,在 HotSpot 虚拟机中也称为永久代(Permanet Generation)存放一些类信息、常量、静态变量等数据,当系统要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,会触发 Full GC;如果经过 Full GC 仍然回收不了,那么 JVM 会抛出如下错误信息:OutOfMemoryError
- CMS GC 时出现
promotion failed
和concurrent mode failure
promotion failed,就是上文所说的担保失败,而 concurrent mode failure 是在执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足造成的
- CMS GC 时出现
- 统计得到的 Minor GC 晋升到旧生代的平均大小大于老年代的剩余空间。
可达性分析法(GC Roots)
定义
所有和GC Roots 直接或者间接关联的对象,都是有效的对象,其他都是无效对象。首先我们通过GC Roots找到堆中被引用的对象,我们给他标记一次,然后顺着字段往下找,路径上的对象都标记下,然后违背标记的就是不可达对象,进行回收。
GC Roots
GC Roots
是指
- Java 虚拟机栈(栈帧里的本地变量表)中引用的对象
- 本地方法栈中引用的对象
- 方法区中常量引用的对象
- 方法区中类的静态属性引用的对象
GC Roots 并不包括堆中对象所引用的对象
垃圾回收算法
标记-清除算法
标记的过程是: 遍历所有的GC Roots,然后将所有GC Roots 可达的对象标记为存活的对象
清除的过程将遍历堆中的所有对象,将没有标记的对象全部清除掉,与此同时,清除那些被标记过的对象的标记,以便下次垃圾回收
这种方法有两个不足
- 效率问题:标记和清除两个过程的效率都不高
- 空间问题:标记清除之后会产生大量不连续的内存碎片,碎片太多可能导致以后需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
复制算法(新生代)
为了解决效率问题,“复制”收集算法出现了,它将内存按照容量划分成等分的两份,每次只使用其中的一块,但需要垃圾回收的时候,将标记的对象复制到另外一块,然后将第一块全部清除
优点 :解决了内存碎片的问题
缺点:需要两块内存,浪费空间
标记-整理算法(老年代)
标记 它的第一阶段是和标记-清除算法
是一样的,均是遍历GC Roots
,然后将存活的对象进行标记
整理 第二阶段是移动所有存活的对象,按照内存地址次序依次排列,然后将末端地址以后的内存全部回收,因此第二阶段才是整理阶段。
CMS 收集器
定义
CMS(Concurrent Mark Sweep)
收集器是一种以获取最短回收停顿时间为目标的收集器,也是HotSpot 虚拟机第一款真正意义上的垃圾收集器,主要使用了标记-清除算法,它在垃圾收集时使得用户线程和 GC 线程并发执行,因此在垃圾收集过程中用户也不会感到明显的卡顿。
工作流程
初始标记
会STW
但是只是标记一下GC Roots
直接关联的对象,速度很快
并发标记
不会STW
,与用户线程并发执行,进行可达性分析,标记出所有废弃对象,速度很慢
重新标记
会STW
,使用多条标记线程并发执行,修正并发标记期间变动的对象,很快,
并发清除
,不会STW
,只使用了1条GC 线程,与用户线程并发执行,过程非常耗时
内存碎片
, 由于CMS采取的是标记-清除算法,所以会产生大量内存碎片,所以CMS提供了参数,用于在Full GC后进行内存碎片整理,但是碎片整理需要STW浮动垃圾
在并发清理过程中,由于用户程序也在运行,就可能导致产生新的垃圾,这时候标记已经结束,进入了清理阶段,所以无法清理这部分垃圾,称之为”浮动垃圾“,只能留待下一次GC清理
G1 收集器
G1收集器(Garbage First)
的名称来源是,它来跟踪每个 Region
里面垃圾堆的大小(回收所需要的时间和空间的经验值),在后台维护了一个列表,每次根据允许的收集时间,优先回收价值最大Region
G1
和其他的垃圾收集器不同的是,它把新生代和老年代的划分取消了,这样我们再也不用单独的空间对每个代进行设置了,也不用关心每个代的内存是否够用,取而代之的是,G1
将堆划分成若干个区域(Region),但他仍然是分代收集器,这些区域的一部分包括新生代,新生代的垃圾依旧是暂停引用线程的方式,将存活的对象拷贝到老年代或者Survivor空间
从整体上看, G1 是基于“标记-整理”算法实现的收集器,从局部(两个 Region 之间)上看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
Region概念
- 横跨整个堆内存
- 它将整个Java堆划分为多个大小相等的独立区域(Region****),
初始标记
,和 CMS初始标记 一样,STW
标记一下GC Roots
直接关联的对象,速度很快
并发标记
从刚才产生的集合中标记出存活对象,不会STW
,耗时较长,但程序也在运行
最终标记
最终标记和CMS重新标记
一样,也是为了修正并发期间标记变动,会STW
筛选回收
有以下几个步骤
算法流程
1.首先我们从GC Roots开始枚举,它们所有的直接引用变为灰色,自己变为黑色。可以想象有一个队列用于存储灰色对象,会把这些灰色对象放到这个队列中
2.然后从队列中取出一个灰色对象进行分析,将这个对象所有的直接引用变为灰色,放入队列中,然后这个对象变为黑色,如果取出的灰色对象没有直接引用,那么就直接变为黑色
继续第二步,一直重复到灰色队列为龙
分析完成后,仍然是白色的对象,就是不可达的对象,可以当作垃圾处理
最后重制标记状态
并发标记带来的问题
如果整个标记过程是
STW
的,那么没有任何问题,但是在并发标记中,用户线程也在运行,那么对象的引用关系就可能发生改变,进而会导致两个问题垃圾被视为非垃圾(浮动垃圾)
,这种浮动垃圾会留给下一次GC去处理了,而且并发清理阶段也会产生浮动垃圾非垃圾被视为垃圾
,产生场景,在GC开始遍历时,对象C是对象B的引用,在GC遍历完对象A的时候,用户线程把对象C的引用交给了对象A,这就导致在遍历B的时候,没有找到C,这个时候C就会被视为垃圾了。这种问题就很致命
要解决非垃圾被视为垃圾问题,首先要分析问题来源,若果一个对象从对B引用,变更了被A引用,那么对比A来说多了一个直接引用,对于B来说就是少了一个直接引用,从这两个方面入手解决对应也有两个方案
增量更新
在用户线程增加一个写屏障(概念类似AOP),但黑色对象新增加引用时,会被记录下来,然后在重新标记
阶段,再以这些引用关系中的黑色对象为根,再扫描一次,以确保没有漏标。所以重新标记阶段时要STW
的
原始快照
原始快照
是站在减少引用的对象的角度上解决问题的,简单来说就是在赋值操作执行前添加一个写屏障,在写屏障中记录被置空的对象引用。记录之后就把它认定为黑色不清理,再遍历一遍。
从增量更新和原始快照来说,原始快照效率更高,但是会导致更多的浮动垃圾。CMS用的是增量更新,而G1用的是原始快照
ART GC
ART 有多个不同的GC方案,涉及到运行不同的垃圾回收器,从Android 8开始,默认方案是CC(Concurrent Copying)
CC 收集器
CC 收集器
和G1 收集器很像,它把ART 堆分成了很多个Region,整个收集阶段只会STW
1次,Android N 到Android 10 之间是不分代了,Android 10 开始又重新引入了分代概念
Pause Phase(暂停阶段)
整个回收阶段就这一个阶段是STW
的,该阶段主要有两个任务
- 找到需要GC的
region(区域)
,根据内存碎片的程度,选中碎片化高的region(区域)
,选中的称为source region
。 - 和G1、CMS 一样,标记出
GC Roots
Copying Phase(暂停阶段)
Copying 阶段是整个GC 耗时最长的阶段,根据 GC Roots
计算出有引用的对象,复制到另外一块region(区域)
,由于此阶段是和用户线程并发执行的,所以标记复制过程中,为了解决其他线程读取到source region
复制到新的区域的对象。ART使用了read barrier(读取屏障)
。
read barrier(读取屏障)
,在我们之前介绍三色标记法时说过,用来解决在并发标记过程中非垃圾被视为垃圾的问题,在CC收集器这里,同样也有这种问题。不同的是,CC并没有使用一次暂停来去重新标记或者增量更新,而是如果检测到有读写source region
的对象,即使这个对象被标记为垃圾,也要一起复制到新的区域,然后返回这个对象新地址。
Reclaim Phase
Reclaim阶段:在经过Copying阶段后,整个进程中就不再存在指向source regions
的引用了,GC就可以将这些source region
的内存释放供以后使用了。
由于Region 的实现,每个GC 过程都是对碎片的整理,所以ART堆碎片化的问题得到了很好的解决
鸣谢
参考《Java 虚拟机底层原理知识总结》
参考 Snailclimb:《JavaGuide》
参考 调试 ART 垃圾回收》
鸣谢 RednaxelaFX