前言
JVM垃圾回收器的相关知识已经学了好几年了,但一直没有将学习内容记录下来, 由于工作中需要jvm调优场景也不多,所以很容易就忘记了,所以就决定将学习内容记录下来,以备以后查阅。
环境:jdk1.8
堆和栈
当我们创建一个对象时,对象会被分配到堆内存中,内存又分为堆和栈,其中堆内存一般是用来存放对象的,栈内存一般是用来存放基本数据类型的。
那么问题来了,当我们new一个对象的时候一定会分配到堆上么,其实不,因为JVM 有个逃逸分析机制,默认是开启的
逃逸分析:JVM在分配对象存储位置时,会分析对象的作用域,如果对象的作用域仅限于方法内部,那么JVM会使用标量替换(默认开启)将对象分配到栈上,而不是堆上,这样对象所 占用的内存就会随着栈帧出栈而自动实方,不需要进行垃圾回收,提升性能。
如下面这个方法
private void alloc() {
User user = new User();
user.setId(1);
user.setName("pq");
// 存储数据库
}
这个方法中的user对象只在方法内部使用,这种就可以存到栈上,不会占用堆内存,从而提升性能。
堆内存模型
大部分情况下,一个对象还是会落到堆上,堆内存又分为新生代和老年代,新生代又分为Eden区、Survivor0区、Survivor1区,新生代和老年代的比例默认是1:2,即新生代占整个堆内存的1/3,老年代占2/3。
如下图所示:
一个对象在堆中声明周期一般如下:
- 当创建一个对象时,会首先进入Eden区
- 当Eden区内存满了之后,会触发一次Young GC,此时很多对象会被作为垃圾回收销毁掉
- 因为大部分情况下对象是朝生夕死的,所以经过这次GC存活的对象很少,此时会将存活对象移动到S0区
- 此时新产生的对象会继续进入Eden区
- 当Eden区再次满了之后,又会触发一次Young GC,此时会检查Eden区和S0区的存活对象,存活对象会移动到S1区,然后Eden区和S0区的垃圾对象会被销毁
- 每个存活对象在经过一次GC之后,年龄会+1(存储在对象头中),当年龄达到一定值时(可配),会晋升到老年代
- 老年代满了之后,会触发Full GC,此时会使用标记-清除-整理 算法进行垃圾回收
总结: 大部分对象是朝生夕灭的,所以新生代的占比是8:1:1,即Eden区占8,S0和S1各占1,年轻代使用的是标记-复制算法,当一个对象经过多次GC之后还存活, 整明这个对象是个顽固对象,会晋升到老年代,等待full gc再看是否清除,老年代使用的是标记-清除-整理算法。
对象内存分配
按上两章的介绍,对象内存分配可以整理如下图(摘抄自网上)
对象进入老年代的条件
对象年龄
如上所属,对象如果年龄不断增长,就会进入老年代,但这是一般情况
但按照上述逻辑,如果一次Young GC剩余的对象Survivor区放不下,会怎么办?答案当然是提前进入老年代
动态年龄判断
如果一次Young GC之后,Survivor区某个年龄及以下的对象占用超过了Survivor区的一半, 那么这个年龄及以上的对象会直接进入老年代,这个年龄就是动态年龄判断,有点绕口
比如年龄为1,2,3的对象已占用超过Survivor区一半空间,那么3岁及以上年龄的对象会直接进入老年代
总之执行的结果就是一次Young GC之后,Survivor区中的对象占用空间不能超过一半,谁岁数大谁先进入老年代
这就好比一个幼儿园,某一年小孩入学非常多,导致座位不够了,怎么办,把年龄大的优先升入小学,这样就腾出位置了
大对象
大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数 -XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小会直接进入老年代, 不会进入年轻代,这个参数只在Serial和ParNew两个收集器下有效
垃圾回收器
垃圾回收器一般都是成对出现的,新生代一套,老年代一套
Serial和Serial Old
这应该是最早的垃圾回收器,Serial即串行回收器,就一个线程进行垃圾回收,回收过程stop the world(以下叫STW),即GC时会暂停应用程序
Parallel和Parallel Old
这个是jdk1.8默认的垃圾回收器,其实就是多线程版本的Serial,回收过程也是STW,但多线程回收,所以速度更快
ParNew和CMS
CMS就很出名了,包括很多后续的垃圾回收器都有借鉴CMS的思想,CMS是Concurrent Mark Sweep的缩写,即并发标记清除,这个回收器是为了减少STW的时间而生的,
ParNew和Parallel没啥区别,只是为了适配CMS,由于新生代的清理速度要远高于老年代,所以CMS主要是优化老年代的回收,新生代的回收交给ParNew
那CMS相比于Parallel Old优化了什么呢?看一下CMS的回收过程
看起来非常复杂,且整个过程经历两个STW,但实际上,虽然STW次数变多了,但这两次STW时间都很短
- 初始标记:标记GC Roots能直接关联到的对象,这个阶段会STW,但很快
- 并发标记:这个阶段回合应用线程同时执行,不会STW,这个阶段根据初始标记的结果继续向下遍历可达树,标记所有对象 但这里有个问题,这个阶段应用程序还在执行,所以这个阶段有可能产生新的垃圾怎么办呢?其实很简单,增量更新即可,即把这段时间变动的对象记录下来,再来一次只针对变化的重新标记
- 重新标记:针对上一阶段变化的对象进行标记,这个阶段会STW,但很快,必须要STW,否则又重复上一阶段的操作,就没头了
- 并发清除:这个阶段回合应用线程同时执行,不会STW,这个阶段清除前几阶段标记的垃圾对象,但问题又来了,如果这个阶段 又产生了新垃圾怎么办,答案很简单,留着,等下一次清除再清,这就是浮动垃圾
CMS虽然增加了STW的次数,但大大减少了STW的总时间
G1
G1是第一款不分代的垃圾回收器,但是只是物理上没有绝对的区分,逻辑上还是保留了分代思想
G1把堆内存分为多个Region,JVM目标是不超过2048个Region,每个区域可以扮演不通的角色,比如Eden区,Survivor区,Old区,Humongous区等
G1的回收过程与CMS很相似,如下:
- 初始标记(STW):标记GC Roots能直接关联到的对象
- 并发标记:同CMS的并发标记
- 最终标记(STW):同CMS的重新标记
- 筛选回收(STW):这个阶段与CMS不同,因为STW了,主要原因是太复杂没实现,不过到了后来的ZGC这一步就并发清除了,这个阶段 还有个特殊点,得益于G1的Region划分,这个阶段可以只清理部分Region,而不是全部,这样就减少了STW的时间,那清除哪部分Region呢? 答案是G1会对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿STW时间(参数可配)来制定回收计划,也就是说 使用G1用户可以指定一个期望的GC停顿时间,G1会根据这个时间来制定回收计划,这个特性是G1的一大亮点,STW时间可控
G1还有个好处是主要使用标记-复制算法,直接把存活对象复制到新的Region,不会像CMS那样导致内存碎片
G1这种特殊的分Region的特性导致它适合大内存的应用,因为大内存会导致其它垃圾回收器的STW时间变长,但G1可以根据用户的期望停顿时间来制定回收计划, 只清理 那些最适合清理的Region,所以适合大内存应用
总结
上面对各垃圾回收器的介绍还是比较枯燥的,如果现实显示中的例子就好理解了:
比如现在某个学校雇佣了个扫厕所的大妈,这个大妈要定期清理厕所,他就是垃圾回收器,需要清扫的坑位就是垃圾对象,再假设这个厕所有多个房间 每个房间有多个坑位,每个房间相当于GC root直接引用,每个坑位相当于间接引用
-
这个大妈是一个人,每次清扫都要把厕所们锁上,不让学生进,清扫完了再开门,这就是Serial
-
这个大妈是一个团队,有N个人,每次清扫都要把厕所们锁上,不让学生进,清扫完了再开门,但多个人干活快,这就是Parallel
-
学生开始抱怨,厕所停用的时间太长了,好多人憋得不行,于是大妈团队开始优化行动,CMS:
- 先停止使用,标记那个房间需要清扫(初始标记)
- 然后开始针对初始标记每个房间进行坑位标记,标记哪个坑位需要清扫,此过程不影响学生使用厕所(并发标记)
- 再次停止使用,针对上一阶段学生使用的坑位进行重新标记(重新标记)
- 恢复使用,清除前三个阶段标记需要清理的坑位,此过程不影响学生使用厕所(并发清除),但会产生新的需要清理的坑位(浮动垃圾)
- 学校不断扩招,学生越来越多,CMS的暂定时间虽短,但这么大量的学生,还是会导致暂停时间变长,于是大妈团队决定换个方式,把一个大厕所 分成多个小厕所,每次清理时根据校方规定的停顿时间,制定清理计划,只清理那些最适合清理的小厕所,尽量保证再时间范围内完成清理,这就是G1