初入 Java 的世界,大体有两方面需要学习,一是开发实践,这直接关系到能不能找到一份工作,二是基础知识,这决定了能在这条路上走多远。一天做十个功能的人固然厉害,但我觉得懂底层原理的人更厉害,因为前者是在怎么做的层面熟能生巧,后者能深入理解为什么要这样做,不信看他们解决 bug 的速度就知道了。
这篇文章讲一讲现阶段我对 JVM 和 GC 的理解。
还记得在一次重要面试中,我就因为不懂 GC 露了怯。
面试官:你了解 GC 吗?
我:对的,了解一点。
面试官:那你能简单介绍一下吗?
我:这里面有一个 ZGC,还有一个 G1 GC。。。(前一天晚上看的)
面试官:具体原理呢?
我:还没有深入研究。
面试官:哈哈,今天时候也不早了,回去等通知吧。
如果能对五个月前的自己说些什么,那就以下吧。
一、JVM
JVM 的全称是 Java Virtual Machine,它是一个虚拟计算机,通过仿真模拟真实计算机的处理器、堆栈和指令系统等硬件架构,使得 Java 语言在不同平台上运行时不需要再重新编译,实现了 Java 的平台无关性。在学习内存结构和内存参数的过程中,我画了这样一张图(以 JDK8 为例):
参照图片,简单讲解一下每个区域的作用:
堆「Heap」
存储对象、对象成员与类定义、静态变量。为了更好地回收内存,堆内存需要分代。于是堆内存中大大小小的对象便有了岁数「age」的概念。在 JVM 的世界里,每经过一次垃圾回收,便过去了一年,在场的对象都被宣判长大一岁,只要它不是被回收的那一个。年轻代与老年代的分界线默认为 15 岁。
新创建的对象默认进入新生代「Eden」区(也称伊甸园),但如果某个对象生来是个庞然大物(占用大片连续内存),就直接进入老年代,这是出于分配内存时的效率考虑的。
年轻代中除了新生代,还有两个生存区S0和S1,是用于垃圾回收的区域,它们始终总用一个是空的,会在 GC 部分详细讲解。
注意:代和区都是逻辑概念。
栈「Stack」
又称线程栈/方法栈/调用栈,存储方法中使用的原生数据类型和对象引用地址。每启动一个线程,JVM 内存中就多出一块线程栈区域,因此,在逻辑上:
JVM = 1✖️堆 ➕ n✖️栈
那么,各类数据究竟存储在堆还是栈上呢?
存储位置 | 原生数据类型 | 对象引用 |
---|---|---|
局部变量 | 栈 | 堆:对象本身;栈:对象的引用地址。 |
成员变量 | 堆 | 堆 |
非堆「Non-Heap」
堆是垃圾回收机制作用的区域,那么作用不到的地方就称为非堆,它非就非在这里
不进行垃圾回收。
非堆分为三个内存池:
- Metaspace:元空间,存放方法,使用的是直接内存
- CCS:全称 Compressed Class Space,存放 class 信息(故与 Metaspace 有重叠)
- Code Cache:存放 JIT 编译器编译后的本地机器代码
堆外「Heap-Except」
广义上讲,在分代算法下,堆内存中的新生代、老生代是连续的虚拟地址,其中的间隙可以认为是堆外内存,这显然不是它的普遍含义;狭义上讲,堆外内存主要是指 java.nio.DirectByteBuffer 在创建的时候分配内存,我们平常说的堆外内存溢出说的就是这个。堆外能直接分配和释放内存,提高效率。对于需要频繁操作的内存,并且仅仅是临时存在一会的,都建议使用堆外内存,并且做成缓冲池,不断循环利用这块内存。
控制参数
- Xmx: 堆内存上限值,一般为物理内存的1/4,建议设置在系统内存的60%~80%
- Xms: 堆内存初始值,一般为物理内存的1/64
- Xmn: 年轻代内存上限值,包括「Eden」区、「S0」区和「S1」区,建议设置-xmx的1/2~1/4
- Xss: 线程栈,默认1MB
例:-xmx3g
-xms3g
-xmn3g
-xss1m
- Metaspace: 元空间上限值,默认为无穷大,这意味着它只受系统内存的限制
例:-XX:MaxMetaspaceSize=256m
- Direct Memory: 堆外上限值
例:-XX:MaxDirectMemorySize=128m
二、GC
GC 的全称是 Garbage Collection,也就是上文提到过的垃圾回收机制,用于管理有限的内存资源。
GC 中的元操作
标记「Mark」
从根元素出发,遍历所有可达的活跃对象,并在本地内存中分门别类记下。
根元素「Roots」:局部变量、活动线程、静态域、JNI引用等。
一般标记是 STW 的,但并发标记不是 STW 的,比如在G1的混合回收过程中就有用到并发标记。
清除「Sweep」
不可达对象所占用的内存,在之后进行内存分配时可以重用。
整理「Compact」
实际上,标记-清除算法会产生许多内存碎片,为解决这一问题,引入了整理算法。通过图片,可以直观理解:
复制「Copy」
复制算法发生在堆里面的年轻代,也就是新生代「Eden」和两个生存区 「S0」/「S1」 之间。「S0」和「S1」是结构相同的两块区域,它们在任意时刻都有其一是空的,并且每经过一次 GC ,空的会变为非空,非空的会变为空,如此循环往复,其中非空的存活区称为**「From」区,空的那个则称为「To」区。GC 的实质就是清理那些用不到的、在逻辑上不可达的废物资源,而「Eden」区和「From」区存活下来的对象会被标记「Mark」,然后复制**到「To」区,这样就使得每次的内存回收都是对「From」区进⾏回收,同时「Eden」区也并不需要保存对象,它只需要接纳新的对象就可以了。每一轮GC最终过滤出来的精华都在这一轮的「To」区了,要不然就是晋升到老年代了。
停顿「STW」
Stop The World,指的是一种全局暂停现象,所有 Java 线程停止当前工作,native代码虽可以执行,但不能与JVM交互。在大部分 GC 算法中,不管是新生代还是老年代,为了把握对象间瞬时的引用关系,总会触发 STW,影响系统吞吐量,区别仅在于 STW 的时间长短。
GC 算法
理解了 GC 算法中的元操作以后,就可以开始排列组合了。
串行GC「Serial GC」
召唤方式:-XX:+UseSerialGC
年轻代:「Mark」+「Copy」
老年代:「Mark」+「Sweep」+「Compact」
适用范围:堆内存较小的单核CPU
并行GC「Parallel GC」
召唤方式:-XX:ParallelGCThreads=N
(N为线程数,默认为CPU核心数)`
年轻代:「Mark」+「Copy」
老年代:「Mark」+「Sweep」+「Compact」
适用范围:对延迟要求低,追求高吞吐量的多核服务器,是 JDK8 的默认GC
CMS GC
全称:Mostly Concurrent Mark and Sweep Garbage Collector,即:最大并发-标记-清除-垃圾收集器
召唤方式:-XX:+UseConcMarkSweepGC
年轻代:「Mark」+「Copy」 ,
此处又称「Parnew」回收器
老年代:「Mark」+「Sweep」+ 空闲列表
适用范围:对吞吐量要求低,追求低延迟的多核服务器,在 JDK13 以后被删除
G1 GC
全称:Garbage First Garbage Collector,即:垃圾优先垃圾回收器
召唤方式:-XX:+UseG1GC
特点:
虽然不需要其他收集器配合就能独⽴管理整个 GC 堆,但还是保留了分代的概念。每个内存分段都可以被标记为「Eden」区,「Survivor」区,「Old」区等。这样属于不同代,不同区的内存分段就可以不必是连续内存空间了;
G1收集器在后台维护了⼀个优先列表,每次根据允许的收集时间,优先选择回收价值最⼤的Region,每个Region拥有各自的分代属性,但这些分代不需要连续,可以有效避免内存碎片化问题;
步骤:
1. 初始标记
2. 并发标记
3. 最终标记
4. 筛选回收。
适用范围:大内存,追求低延迟的服务端,是JDK9以后版本的默认GC
ZGC
召唤方式:XX:+UseZGC
通过着色指针和读屏障技术,解决了转移过程中准确访问对象的问题,创造了GC最大停顿时间不超过10ms的奇迹。
总结
对于GC而言,
延迟和吞吐量是此消彼涨的两个指标。
延迟直观对应的是接口响应的速度,一个请求得到响应的速度会被 STW 影响,要想让接口延迟降低,单次STW的时间就要缩短,那就需要减少堆内存或者频繁 GC ,这样一来总体的 STW 时间势必变长,导致吞吐量下降;反之亦然。
「Serial GC」是经典的GC,后来的几代GC以它为基础进行改造、优化:
- 「Parallel GC」加入了并行,以高吞吐量为目标;
- 「CMS GC」加入了并发,以低延迟为目标;
- 「G1 GC」打破了分区的物理界限,让大内存也可以享受低延迟;
- 「ZGC」再度降低延迟,并且相较「G1 GC」仅仅损失不到15%的吞吐量。
可见,就目前而言,GC是朝着低延迟的方向发展演进的,我想这是因为延迟大小关系到用户体验的好坏吧。
最后,这篇文章写得贼累,求赞求转发~~