【GC系列】JVM堆内存分代模型及常见的垃圾回收器
作者:行百里er
博客:https://chendapeng.cn (opens new window)
提示
这里是 行百里er 的博客:行百里者半九十,凡事善始善终,吾将上下而求索!
# 1. 内存分代模型
为什么要说JVM的内存分代模型呢,因为内存分代和垃圾回收器的运行是有关系的。
现在大部分用到的垃圾回收器在逻辑上是分代的,除了G1之外的其他垃圾回收器在逻辑上和物理上都是分代的。
- 除了Epsilon、ZGC、Shenandoah之外的GC都是是逻辑分代模型
- G1是逻辑分代,物理上不分代
- 除此之外的不仅逻辑分代,而且物理分代
逻辑分代是给内存做一些概念上的区分,物理分代是真正的物理内存。
具体划分
新生代(young)和老年代(old/tenured)。
新生代:刚new出来的那些对象
老年代:垃圾回收了很多次都没有把它回收掉的老对象
新生代又分为:
- eden 默认比例是8。新new出来的对象放在eden区。
- survivor(s1) 默认比例是1。垃圾回收一次之后跑到这个区域,该区域存放的对象不同,采取的垃圾回收算法也不同。
- survivor(s2) 默认比例是1。
新生代存活的对象较少,使用的垃圾回收算法是拷贝算法(Copying)。
老年代活着的对象较多,垃圾回收算法适用标记压缩(Mark Compact)或者标记清除(Mark Sweep)。
几个GC的概念
- MinorGC/YGC 新生代空间耗尽时触发的垃圾回收。
- MajorGC/FullGC 在老年代无法继续分配空间时触发,新生代、老年代同时进行垃圾回收。
分代空间参数配置
-Xms-Xmx
-Xmn
2
3
X是非标参数,m是memory内存,s是最小值,x是最大值,n是new
# 2. 一个对象的生命历程-从出生到消亡
一个对象被new出来之后,首先尝试进行栈上分配,栈上如果分配不下才会进入eden区;
eden区经过一次垃圾回收之后进入一个survivor区-s1区;
survivor区(s1)经过一次垃圾回收之后又进入另一个survivor区-s2区,同时eden区的某些对象也会跟着进入s2;
当对象年龄到某一个值后,会进入到old区。这个值可以通过参数:
-XX:MaxTenuringThreshold
进行配置。
下面这个图能够帮助我们了解JVM中内存分区的概念。
# 3. 对象如何进行栈上分配
C语言中的struct结构体就可以直接在栈上分配,在Java中也有栈上分配的理念。
在JVM中,堆是线程共享的,因此堆上的对象对于各个线程都是共享和可见的,只要持有对象的引用,就可以访问堆中存储的对象数据。虚拟机的垃圾收集系统可以回收堆中不再使用的对象,但对于垃圾收集器来说,无论筛选可回收对象,还是回收和整理内存都需要耗费时间。
如果确定一个对象的作用域不会逃逸出方法之外,那可以将这个对象分配在栈上,这样,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,无须通过垃圾收集器回收,可以减小垃圾收集器的负载。
JVM允许将线程私有的对象打散分配在栈上,而不是分配在堆上。分配在栈上的好处是可以在函数调用结束后自行销毁,而不需要垃圾回收器的介入,从而提高系统性能。
综上,栈上分配:
- 线程私有小对象
- 无逃逸
在某一段代码中使用,出了这段代码就没有其他的代码认识它。
比如若一个对象拥有两个字段,会将这两个字段视作局部变量进行分配。
- 支持标量替换
用普通的属性、普通的类型代替对象就叫标量替换。
意思是说JVM允许将对象打散分配在栈上,比如若一个对象拥有两个字段,会将这两个字段视作局部变量进行分配。
- 无需调整
站上分配的对象随栈帧出栈而销毁,无需内存调整。
# 4. 线程本地分配
对象在栈上分配不下了,会优先进行本地分配。
线程本地分配,Thread Local Allocation Buffer,简称TLAB。
很多线程都向Eden区分配对象,分配对象的线程会进行内存空间的征用,谁抢到就算谁的。出现多线程的同步,效率就会降低,因此设计了这个TLAB机制-线程本地分配。
TLAB特征:
- 占用Eden区的大小模式是1%,这1%的空间时线程独有,分配对象的时候先向这块空间进行分配。
- 多线程的时候不用竞争Eden区就可以申请空间,效率提高。
- 分配的是小对象。
- 无需调整。
我们来测试一下,在栈上分配和TLAB是否提升了效率。
public class TLABTest {
// -XX:-DoEscapeAnalysis 去掉逃逸分析
// -XX:-EliminateAllocations 去掉标量替换
// -XX:-UseTLAB 去掉TLAB
public static void main(String[] args) {
TLABTest t = new TLABTest();
long start = System.currentTimeMillis();
//执行1000万次alloc
for (int i = 0; i < 1000_0000; i++) {
t.alloc(i);
}
long end = System.currentTimeMillis();
System.out.println("spends " + (end - start));
}
// 该方法只new一个对象出来,没有任何引用指向他,
// 除了这个方法就没人认识它,所以没有逃逸
void alloc(int id) {
new User(id, "name" + id);
}
class User {
int id;
String name;
public User(int id, String name) {
this.id = id;
this.name = name;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
执行时加上
-XX:-DoEscapeAnalysis -XX:-EliminateAllocations -XX:-UseTLAB
- -XX:-DoEscapeAnalysis 去掉逃逸分析
- -XX:-EliminateAllocations 去掉标量替换
- -XX:-UseTLAB 去掉TLAB
运行结果:
spends 1237
然后在去掉-XX:-DoEscapeAnalysis -XX:-EliminateAllocations -XX:-UseTLAB
,也就是默认情况下执行:
spends 643
效率很明显提升了!
# 5. 常见的垃圾回收器
# 5.1 Serial
当Serial工作的时候,所有正在工作的线程全部停止。
线程停止有一个safe point(安全点),需要找到一个安全点上进行线程停止,该垃圾回收器停顿时间较长(STW - Stop The World),现在用的较少。
# 5.2 Serial Old
用于老年代,也是STW,采用的是标记清除算法(Mark-Sweep),单线程。
# 5.3 Parallel Scavenge
如果JVM没有做过任何参数设定的话,默认就是Parallel Scavenge和Parallel Old(PS+PO)。
Parallel Scavenge和Serial的区别是PS是多线程清理垃圾。
# 5.4 Parallel Old
用于老年代,STW,多线程执行,采用标记压缩整理算法(Mark-Compact)。
# 5.5 ParNew
Parallel NEW的意思,就是Parallel Scavenge的新版本。它就是在PS的基础上做了一些增强一遍它能和CMS配合使用,CMS在某一个特定阶段的时候会和ParNew同时运行。
ParNew工作的时候其余线程不能工作,必须等GC结束才行。
它工作时也STW,采用的是Copying算法,多线程执行。
# 5.6 CMS
前面几种垃圾回收器有一个共性就是STW,就是我垃圾回收器在工作的时候其他人都不许动,得等着,我干完了,你们才能继续工作。
CMS的诞生就是试图解决这个问题。然而CMS本身的问题很多,目前任何JDK版本默认的垃圾回收器都不是CMS。
CMS - Concurrent Mark Sweep,并发标记清除。从线程的角度看,CMS进行垃圾回收的时候和工作线程同时进行,但是它依然很慢。
在以往内存较小的时候,速度很快,但现在服务器内存已经很大了,相当于原来在10平米的房间内清理垃圾,现在需要在100平米甚至更大的空间内清理垃圾,即使使用多线程来清理也需要很长的时间。
CMS的四个阶段
- CMS initial mark 初始标记阶段
该阶段STW,直接找到最根上的对象并标记,其他对象不标记。
- CMS concurrent mark 并发标记阶段
GC 80%的时间浪费在并发标记阶段。所以该阶段和工作线程同时运行,客户端可能感觉响应变慢了,但是至少还有点反应。
工作线程一边产生垃圾,一边对垃圾进行标记,这个过程不可能标记完,所以了重新标记阶段。
- CMS remark 重新标记阶段
这也是一个STW,在并发标记阶段产生的新垃圾,在该阶段进行重新标记一下,需要工作线程停下来,时间不是很长。
- CMS concurrent sweep 并发清理阶段
该阶段由于有工作线程也在运行,因此在执行过程中会产生新的垃圾,这时候的垃圾叫浮动垃圾,浮动垃圾在下一次CMS运行的时候再把它清理掉。
CMS的触发条件
老年代分配不下了会触发CMS,其初始标记是单线程,重新标记是多线程。
CMS的缺点
- Memory Fragmentation 内存碎片
如果内存很大,一旦老年代产生了很多内存碎片的时候,从年轻代进入到老年代的对象就找不到空间了-PromotionFailed。
这时,CMS请出了Serial Old这个上古时代的回收器使用单线程进行标记压缩,那效率就可想而知了。
-XX:+UseCMSCompactAtFullCollection
-XX:CMSFullGCsBeforeCompaction 默认为0 指经过多少次FGC才进行压缩
2
- Floating Garbage 浮动垃圾
可以通过参数配置降低触发CMS的阈值。
-XX:CMSInitiatingOccupancyFraction 92% 可以降低该值,让老年代有足够的空间
# 5.7 G1
G1也能并发进行垃圾回收,与CMS相比,其优点如下:
- G1在GC过程中会进行整理内存,不会产生很多内存碎片
- G1的STW更可控,可以指定可期望的GC停顿时间
在G1中将内存区域划分为多个不连续的区域(Region),每个Region内部是连续的。
在划分的区域中H区(Humongous),这表示这些Region存储的是巨大对象(humongous object,H-obj)-大小大于等于region一半的对象。
一个Region的大小可以通过参数
-XX:G1HeapRegionSize
设定,取值范围从1M到32M,且是2的指数。如果不设定,那么G1会根据Heap大小自动决定。
# 5.8 ZGC
ZGC是一种并发的、不分代的、基于Region且支持NUMA的压缩收集器。因为只会在枚举根节点的阶段STW, 因此停顿时间不会随着堆大小或存活对象的多少而增加。
ZGC的目标
- 垃圾回收停顿时间不超过10ms
- 无论是相对小的堆(几百MB)还是大堆(TB级)都能应对自如
- 与G1相比,吞吐量下降不超过15%
- 方便日后在此基础上实现新的gc特性、利用colored pointers和读屏障进一步优化收集器
# 6. 参考资料
- Getting Started with the G1 Garbage Collector (opens new window)
- Java Garbage Collection Basics (opens new window)
- https://wiki.openjdk.java.net/display/zgc/Main (opens new window)
首发公众号 行百里er ,欢迎老铁们关注阅读指正。