简述
在上篇文章中简单介绍了 JVM 内部结构, 线程隔离区域随着线程而生, 随着线程而忘. 线程共享区域因为是共享, 所以可能多个线程都用到, 不能轻易回收, 与 C 语言不同, 在 Java 虚拟机自动内存管理机制的帮助下, 不再需要为每个 new 操作去写配对的 delte/free 代码, 能够帮助程序员更好的编写代码. 那么 JVM 是如何进行对象内存分配以及回收分配给对象内存呢?
内存分配
几乎所有的对象实例都分配在堆中, 为了进行高效的垃圾回收, 虚拟机把堆划分成新生代(Young Generation), 老年代(Old Generation).
新生代
新生代又分为 1 个 Eden 区和 2 个 survivor 区(S0,S1),Eden 区与 Survivor 区的内存大小比例默认为 8:1.
Eden
Eden 伊甸园, 在大多数情况下对象优先在 Eden 区中分配
Survivor
Survivor 幸存者, 当 Eden 区没有足够内存进行分配, 会触发一次 Minor GC, 会将幸存的对象移动到内存区域 S0 区域, 并清空 Eden 区域. 当再次发生 Minor GC 时, 将 Eden 和 S0 中幸存的对象移动到 S1 内存区域.
幸存对象会反复在 S0 和 S1 之间移动, 当对象从 Eden 移动到 Survivor 或者在 Survivor 之间移动时, 对象的 GC 年龄自动累加, 当 GC 年龄超过默认阈值 15 时, 会将该对象移动到老年代, 可以通过参数 - XX:MaxTenuringThreshold 对 GC 年龄的阈值进行设置.
老年代
除了长期存活的对象会分配到老年代, 还有以下情况对象会分配到老年代:
大对象 (需要大量连续内存空间的 Java 对象) 直接进入老年代, 可以通过参数
-XX:PretenureSizeThreshold 设定对象大小阈值, 超过其值进入老年代
若 Survivor 区域中所有相同 GC 年龄的对象大小超过 Survivor 空间的一半, 年龄不小于该年龄的对象就直接进入老年代
分配方法
指针碰撞法
假设 Java 堆中内存是完整的, 已分配的内存和空闲内存分别在不同的一侧, 通过一个指针作为分界点, 需要分配内存时, 仅仅需要把指针往空闲的一端移动与对象大小相等的距离.
空闲列表法
事实上, Java 堆的内存并不是完整的, 已分配的内存和空闲内存相互交错, JVM 通过维护一个列表, 记录可用的内存块信息, 当分配操作发生时, 从列表中找到一个足够大的内存块分配给对象实例, 并更新列表上的记录.
对象创建是一个非常频繁的行为, 进行堆内存分配时还需要考虑多线程并发问题, 可能出现正在给对象 A 分配内存, 指针或记录还未更新, 对象 B 又同时分配到原来的内存, 解决这个问题有两种方案:
1, 采用 CAS 保证数据更新操作的原子性;
2, 把内存分配的行为按照线程进行划分, 在不同的空间中进行, 每个线程在 Java 堆中预先分配一个内存块, 称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB);
内存回收
如何判断哪些对象占用的内存需要回收? 虚拟机有如下方法:
引用计数法
给对象添加一个引用计数器, 每当有一个地方引用它时, 计数器就加 1; 当引用失效时, 计数器就减 1; 当计数器为 0 时就代表此对象已死, 需要回收.
此方法无法解决对象之间相互引用的问题
- public class ReferenceCountingGC {
- public Object instance = null;
- private static final int _1MB = 1024 * 1024;
- /**
- * 方便 GC 能看清楚是否被回收
- */
- private byte[] bigSize = new byte[2 * _1MB];
- public static void testGC(){
- ReferenceCountingGC objA = new ReferenceCountingGC();
- ReferenceCountingGC objB = new ReferenceCountingGC();
- objA.instance = objB;
- objB.instance = objA;
- objA = null;
- objB = null;
- System.gc();
- }
- }
结果:
- [GC 6758K->632K(124416K), 0.0016573 secs]
- [Full GC 632K->530K(124416K), 0.0148864 secs]
从结果可以看出这两个对象依然被回收
可达性分析算法
通过一些节点开始搜索, 当一个对象到 GC Roots 没有任何引用链 (通过路径) 时, 代表该对象可以被回收
GC Roots 的对象包括:
本地变量表中引用的对象
方法区中类静态属性引用的对象
方法区中常量引用的对象
Native 方法引用的对象
判定一个对象是否可回收, 至少要经历两次标记过程:
若对象与 GC Roots 没有引用链, 则进行第一次标记
若此对象重写了 finalize()方法, 且还未执行过, 那么它会被放到 F-Queue 队列中, 并由一个虚拟机自动创建的, 低优先级的 Finalizer 线程去执行此方法(并非一定会执行).finalize 方法是对象逃脱死亡的最后机会, GC 对队列中的对象进行第二次标记, 若该对象在 finalize 方法中与引用链上的任何一个对象建立联系, 那么在第二次标记时, 该对象会被移出 "即将回收" 集合.
自我救赎示例:
- public class FinalizeGC {
- public static FinalizeGC obj;
- public void isAlive() {
- System.out.println("yes, i am still alive");
- }
- @Override
- protected void finalize() throws Throwable {
- super.finalize();
- System.out.println("method finalize executed");
- obj = this;
- }
- public static void main(String[] args) throws Exception {
- obj = new FinalizeGC();
- // 第一次执行, finalize 方法会自救
- obj = null;
- System.gc();
- Thread.sleep(500);
- if (obj != null) {
- obj.isAlive();
- } else {
- System.out.println("I'm dead");
- }
- // 第二次执行, finalize 方法已经执行过
- obj = null;
- System.gc();
- Thread.sleep(500);
- if (obj != null) {
- obj.isAlive();
- } else {
- System.out.println("I'm dead");
- }
- }
- }
结果:
method finalize executed
yes, i am still alive
I'm dead
从结果来看, 第一次 GC 时, finalize 方法执行, 在回收之前成功自我救赎
第二次 GC 时, finalize 方法已经被 JVM 调用过, 所以无法再次逃脱
垃圾回收算法
知道了如何判断对象为 "垃圾", 接下来就是如何清理这些对象
标记 - 清除
对 "垃圾" 对象进行标记并删除
算法缺点:
效率问题, 标记和清除这个两个过程的效率都不高
空间问题, 标记清除后会产生大量不连续的内存碎片, 不利于大对象分配
复制算法
将可用内存一分为二, 每次只用其中一块, 当一块内存用完了, 就把存活的对象复制到另一块去, 并清空已使用过的内存空间. 相对于复制算法不需要考虑内存碎片等复杂问题, 只要移动指针, 按顺序分配内存即可.
缺陷: 总有一块空闲区域, 空间浪费
标记 - 整理
在老年代中, 对象存活率较高, 复制算法效率较低. 基于标记 - 清除, 让所有存活对象都移动到一端, 然后直接清理边界以外的内存.
垃圾收集器
Serial 收集器
Serial 是一个单线程, 基于复制算法, 串行 GC 的新生代收集器. 在 GC 时必须停掉所有其他工作线程直到它收集完成. 对于单 CPU 环境来说, Serial 由于没有线程交互的开销, 可以很高效的进行垃圾收集, 是 Clinet 模式下新生代默认的收集器
ParNew 收集器
ParNew 是 Serial 收集器的多线程版本(并行 GC), 除了使用多条线程进行 GC 以外, 其余行为与 Serial 一样
Parallel Scavenge 收集器
Parallel Scavenge 是一个多线程, 基于复制算法, 并行 GC 的新生代收集器. 其关注点在于达到一个可控的吞吐量.
吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)
Parallel Scavenge 提供两个参数用于精确控制吞吐量:
-XX:MaxGCPauseMillis 控制垃圾收集的最大停顿时间
-XX:GCTimeRatio 设置吞吐量大小
Serial Old 收集器
Serial Old 是基于标记 - 整理算法的 Serial 收集器的老年代版本, 是 Client 模式下老年代默认收集器
Parallel Old 收集器
Parallel Old 是基于标记 - 整理算法的 Parallel 收集器的老年代版本, 在注重吞吐量以及 CPU 资源敏感的场合, 可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器
CMS 收集器
CMS 收集器是一种以获取最短回收停顿时间为目标的老年代收集器(并发 GC), 基于标记 - 清除算法, 整个过程分为以下 4 步:
初始标记: 只标记与 GC Roots 直接关联到的对象, 仍然会 Stop The World
并发标记: 进行 GC Roots Tracing 的过程, 可以和用户线程一起工作
重新标记: 用于修正并发标记期间因用户程序继续运行而导致标记产生变动的那部分对象记录, 此过程会暂停所有线程, 但停顿时间, 比初始标记阶段稍长, 远比并发标记的时间短
并发清理: 清理 "垃圾对象", 可以与用户线程一起工作
CMS 收集器缺点:
对 CPY 资源非常敏感, 在并发阶段, 虽然不会导致用户停顿, 但是会占用一部分线程资源 (或者说 CPU 资源) 而导致应用程序变慢, 总吞吐量降低
无法处理浮动垃圾, 在并发清理阶段用户线程还在运行依然会产生新的垃圾, 这部分垃圾出现在标记过程之后, 只能在下一次 GC 时回收
CMS 基于标记 - 清除算法实现, 即可能收集结束会产生大量空间碎片, 导致出现老年代还有很大空间剩余, 不得不提前触发一次 Full GC
G1 收集器
G1 垃圾收集器被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征(JDK9 默认垃圾收集器), 基于 "标记 - 整理" 算法实现.
G1 收集器优点:
并行与并发: 充分利用多 CPU 来缩短 Stop-The-World(停用户线程)停顿时间
分代收集: 不需要其他收集器配合, 采用不同的方式处理新建的对象和已经存活一段时间, 熬过多次 GC 的旧对象来获取更好的收集效果
空间整合: 因为基于 "标记 - 整理" 算法实现, 避免了内存空间碎片问题, 有利于程序长时间运行, 分配大对象时不会因为无法找到连续内存空间而提前触发一次 GC
可预测停顿: G1 建立了可预测的停顿时间模型, 能让使用者明确指定在 M 毫秒时间片段内, 消耗在垃圾收集上的时间不得超过 N 毫秒
G1 运行步骤:
初始标记: 只标记与 GC Roots 直接关联到的对象, 仍然会 Stop The World
并发标记: 从 GC Root 开始对堆中对象进行可达性分析, 找出存活对象, 可与用户线程并发执行
最终标记: 修正在并发标记期间因用户程序继续运作而导致标记产生变动的那部分标记记录, 虚拟机将对象变化记录在线程 Remembered Set Logs 里, 并合并到 Remembered Set 中, 此过程会暂停所有线程.
筛选回收: 对各个 Region 的回收价值与成本进行排序, 根据用户所期望的 GC 停顿时间来指定回收计划
注:
并行: 多条垃圾收集线程并行工作, 但此时用户线程仍然处于等待状态
并发: 用户线程与垃圾收集线程同时执行(不一定是并行, 可能交替执行), 用户程序在继续运行, 而垃圾收集程序运行于另一个 CPU 上
感谢
深入理解 JAVA 虚拟机
https://www.jianshu.com/p/eaef248b5a2c
来源: https://juejin.im/post/5b228537f265da59b95299d5