Java 垃圾回收基本认知
什么是垃圾?
程序主要活动在 CPU 和内存上,所产生的临时数据在内存中。如果这些数据不会再使用了,那么就是一些“垃圾”,需要被清除掉,以便内存空间的重复利用。(之所以要重复利用,是因为内存是优先的,当然,什么资源是无限的呢?)
内存回收的本质?
操作系统管理者底层的所有硬件资源,包括这里的内存(或者叫做主存)。虚拟化技术让每个应用程序有一种“独占“硬件资源的错觉,不管是 CPU 或者说是内存也好。程序运行时需要进行内存的申请与释放,这些都是在虚拟内存中进行的,实际上操作系统会将虚拟内存地址映射到物理内存上。当内存空间需要释放时,只需要将特定的内存区域标记为空闲即可。当然,物理内存的回收取决于应用程序是否进行系统调用。
(应用程序负责管理虚拟内存,操作系统负责管理物理内存)
你可能会有这样的疑问,再次分配内存时,岂不是可能夹杂之前的旧数据?
对于 java 来说,new 对象的时候,会在将特定的内存区域恢复到初始状态,然后进行填充操作。
在哪里进行?
在 java 堆中进行内存的回收操作。线程独有的区域随着线程的生命周期而进行创建和销毁,不需要进行垃圾回收。而堆内存是所有 java 线程共享的区域,只要 java 程序不断的运行,随着时间的推移,该区域会不断的膨胀,所以需要进行垃圾回收。
怎么判断一个对象是否可被回收?
常用的有两种方式:引用计数法和可达性分析算法
1、引用计数法(java 未采用)
每个对象都有一个引用计数器,增加一个引用时,引用数量 +1,反之亦然。
对象之间出现循环依赖(A 引用 B,B 引用 A),计数器永不为0,无法被回收。
2、可达性分析算法
依赖关系就像一棵“多叉树”一样,我们从根节点出发,能够找到的对象,都是被引用了的,找不到的对象,都是没有引用的,可以进行垃圾回收工作。
根节点有个比较专业的名称叫做Gc Root
,一般能够作为根节点的有以下内容:
- 栈中引用的对象
- 方法区中静态属性及常量引用的对象
方法区的回收?
jdk 8 之前叫做永久代
,jdk 8及之后叫做元空间
。它叫什么不重要,重要的是它里面存的是些什么?
里面存的是:类结构信息,常量池,静态变量,即时编译后的热点机器码等。
这些东西在程序中的生命周期中,基本上不会发生变化。
但是还是存在垃圾回收,回收什么呢?
主要是对常量池的回收和对类的卸载。
类的卸载条件比较苛刻,需要达到以下三点:
- 堆内存中不存在该类的任何实例
- 加载该类的 ClassLoader 被回收
- 代码中没有任何地方通过反射访问该类的 Class 对象
(注:就算满足这三点,JVM 也不承诺一定会回收)
关于对象的自救?
finalize() 函数源自 Object 基类,java 官方已不推荐使用了。
这是个对象的生命周期钩子函数,会在对象被销毁之前调用,可在该方法中让对象重新被引用,完成自救。
当然,如果对象在该方法中完成了自救,后续就不会调用该方法了,即自救只能进行一次。
(注:JVM 不承诺对象回收之前一定会调用该方法)
Java 中的四大引用类型
1、强引用
直接new
对象就会产生一个强引用,被强引用关联的对象不会被回收:
Object obj = new Object();
2、软引用
使用SoftReference
类来创建,被软引用创建的对象只有在内存不够的时候才会进行回收:
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null; // 使对象只被软引用关联
3、弱引用
使用WeakReference
类来创建,被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前:
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;
4、虚引用
使用PhantomReference
来实现虚引用,无法通过虚引用获取实体对象,虚引用的目的就是为了能够在对象被回收时收到一个系统通知:
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj);
obj = null;
这四种类型引用的区别,本质上体现在进行垃圾回收时,对其引用对象的态度。被强引用的对象,堆内存满了或者不够分配时,也不会选择去干掉它,最坏的结果就是OOM(OutOfMemory)
;而软引用则有可能会被干掉,弱引用和虚引用就不用说了,纯粹就是垃圾回收的首选炮灰。
常见的垃圾回收算法
1、标记 - 清除
标记存活的对象,清除未被标记的对象:
标记和清除的效率并不高,而且会有内存碎片。
2、标记 - 整理
让存活的对象,统一移动到一端,然后清理端外内存:
3、复制
将堆内存分为两部分,每次清理的时候,将存活的对象复制到另一半,然后直接清理原来的内存区域即可:
缺点也很明显,无法有效利用所有的内存空间。
4、分代收集
这是现代虚拟机的主流垃圾回收方式,一般将堆内存划分为新生代和老年代,不同的内存区域采用不同的回收算法:
- 新生代:复制算法
- 老年代:[标记 - 清除] 或者 [标记 - 整理]
常见垃圾收集器概要
如图,Serial、ParNew、Parallel Scavenge 负责新生代的垃圾回收工作;
CMS、Serial Old、Parallel Old 负责老生代的垃圾回收工作;
G1 实现了对新生代和老生代垃圾回收工作的支持,即可以进行整堆收集。
1、Serial
如果,我们可以看到 Serial 垃圾收集器是单线程的工作方式,且在执行 GC 任务的时候,会暂停所有的用户线程。
在如今多核 CPU 架构下,回收效率的确不高,也不适用于大内存应用的垃圾回收。
2、ParNew
多线程版本的 Serial,回收效率大幅度提升,但回收时也会暂停用户线程的执行。
3、Parallel Scavenge
和 ParNew 一样是一个多线程版本的垃圾回收器。
先说两个概念:吞吐量和停顿时间(STW):
吞吐量
用户代码运行时间 / (用户代码运行时间 + 垃圾回收时间)。
停顿时间(STW)
STW 的全程是 “Stop The World”,中文译为“世界都暂停了”。指的是在垃圾回收的过程中,用户线程被暂停了,暂停的时间就叫做停顿时间。
Parallel Scavenge 是一款“吞吐量优先”的垃圾收集器,它更加的关注让用户线程“饱满的运行”,可以根据历史垃圾收集信息和程序的运行状态动态的调整停顿时间。(重点体现在可控制)
(停顿时间少了,垃圾回收的时间也就少了,吞吐量自然高了嘛)
4、Serial Old
Serial 收集器的老年代版本嘛。
单线程也不是一无是处,后面的 CMS 垃圾回收器出现Concurrent Mode Failure
的时候,Serial Old 可以作为一种预备方案。
5、Parallel Old
Parallel Scavenge 的老年代版本。
6、CMS
CMS(Concurrent Mark Sweep),中文译为“并发标记清除”。
主要有以下四个流程:
- 初始标记:仅仅只是标记一下
GC Roots
能够到达的对象,速度快,需要停顿 - 并发标记:对
GC Roots
可达的对象进行深度追踪,与用户线程并发执行,不需要停顿,耗时也最长 - 重新标记:修正在并发标记期间用户线程继续运行可能会造成的一些标记变动,需要停顿
- 并发清除:不需要停顿。
缺点也很明显:
基本上与用户线程并发的运行,吞吐量低,CPU利用率不高(对于用户线程而言)
标记 - 清除算法容易产生空间碎片,而且还是在老年代中,如果找不到连续的空间分配一个新对象时,就会提前触发一次
Full GC
。(如果没有空间碎片的话,原本是可以的,所以这里用了提前二字)无法处理浮动垃圾,可能会出现
Concurrent Mode Failure
。在并发清除的阶段,用户线程仍在运行,这时候产生的垃圾无法被清除,只能等到下一轮的垃圾回收周期。正因为有浮动垃圾的存在,所以需要预留出一部分内存去存放。如果预留的内存不够存放这些浮动垃圾,则会出现
Concurrent Mode Failure
,这时虚拟就会启用 Serial Old 来替代 CMS。
7、G1
G1全称是“Garbage-First”,多 CPU 和大内存的场景下拥有很好的性能。
其他的收集器要么在新生代工作,要么在老生代工作,G1可以直接对新生代和老生代一起回收。
先来看一下 HotSpot VM 中堆内存的一个整体结构:
当然这幅图的比例有点问题,Eden 和 Survivor 的大小比例是 8:1:1,每次复制时,将存活的对象复制到空的哪一块 Survivor 区中。这样就保证了新生代中,内存利用率达到了 90%。(当然,这个比例是可以调整的)
G1 把堆内存划分为一个又一个大小相等的区域(Region),新生代和老生代之间不再进行物理隔离,逻辑上是连续的就行了:
“石头大了难以搬动,就把它弄小一点嘛”。
每个 Region 都要自己的 Remembered Set,记录着引用这块区域中的对象的Region。有点绕,解释一下:A 在 RegionA 中,B 引用了 A,那么 A 的 Remembered Set 就记录着 B 的 Region。这样在做可达性分析的时候就可以做全堆扫描了。
通过记录每个 Region 的回收时间和回收所获得的内存空间,也有利用做成本分析,回收价值比较高的 Region。
最终标记
修正并发标记期间用户线程继续运行造成的标记变动。虚拟机会将这段时间对象的变化记录在线程的 Remembered Set Logs 中,在最终标记阶段把 Remembered Set Logs 中的数据合并到 Remembered Set 中。
筛选回收
对可回收的 Region 进行成本和价值分析排序,根据用户所期望的停顿时间制定回收计划。该阶段也可以与用户线程并发执行。
整体是“标记-整理”,局部(两个 Region 之间)上看是“复制” => 不会产生内存碎片。
用户可以指定在 M 毫秒的片段内,消耗在 GC 上的时间不超过 N 毫秒。
内存分配与回收策略
总体的回收策略可以分为整堆收集和部分收集:
整堆收集(Full GC)
收集整个 Java 堆以及方法区的垃圾
部分收集
- 新生代收集:只收集新生代的垃圾
- 老年代收集:只收集老年代的垃圾(目前只有 CMS 会这么做)
- 混合收集:收集新生代和部分老年代的垃圾
内存分配的策略:
- 优先在新生代中分配,新生代空间不够时,发起
young gc
- 大对象直接进入老年代,可以手动设置阈值,大于阈值的对象将直接进入老年代
- 新生代中经过多次 GC 还存活的对象进入老年代,默认阈值年龄是 15
- 如果幸存者区中相同年龄的对象大小总和超过了这个区间的一半大小,大于或等于该年龄的对象会进入老年代
young gc
之前会尝试让老年代进行担保,这个担保就是检查老年代中最大可用的连续内存空间是否大于历次晋升的平均水平,如果存在这样的空间,那么就会尝试去进行young gc
,否则就会直接full gc
full gc 的触发条件:
- 主动调用 System.gc(),但是虚拟机不承诺调用了就必须执行
- 老年代空间不足
- 老年代空间分配担保失败
- Concurrent Mode Failure
总结
基本的认知架构就这些,虽然有点多,慢慢消化总是好的。
2025/01/20
writeBy kaiven