文章原文:
https://gaoyubo.cn/blogs/6997cf1f.html
一、运行时数据区
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域 有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是 依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域
1.1程序计数器
线程私有
是一个非常小的内存区域,用于存储当前线程正在执行的字节码指令的地址。每个线程在JVM中都有一个独立的程序计数器。当JVM执行一条字节码指令时,程序计数器会更新为下一条指令的地址。
简而言之,程序计数器存储的是当前正在执行的字节码指令的地址。一旦这条指令执行完毕,程序计数器会立即更新为下一条指令的地址。这样,JVM就可以知道接下来应该执行哪条指令。
需要注意的是,对于那些会导致控制流跳转的指令(如条件跳转、循环等),程序计数器会根据指令的具体行为更新为相应的目标地址,而不是简单地递增到下一个地址。
- 执行 Java 方法和 native 方法时的区别:
- 执行 Java 方法时:记录虚拟机正在执行的
字节码指令地址
;
- 执行 native 方法时:空(Undefined);
- 是 5 个区域中
唯一不会出现 OOM 的区域
。
1.2虚拟机栈
线程私有
每个方法被执行的时候,Java虚拟机都 会同步创建一个栈帧用于存储
局部变量表
、
操作数栈
、
动态连接
、
方法出口
等信息。
- 服务于 Java 方法;
- 可能抛出的异常:
OutOfMemoryError
(在虚拟机栈可以动态扩展的情况下,扩展时无法申请到足够的内存);
StackOverflowError
(线程请求的栈深度 > 虚拟机所允许的深度);
- 虚拟机参数设置:
-Xss
.
局部变量表
存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、 float、long、double)、对象引用。
1.3本地方法栈
线程私有
- 服务于 native 方法;
- 可能抛出的异常:与 Java 虚拟机栈一样。
1.4堆
线程共享
“几乎”
所有的对象实例都在这里分配内存
由于即时编 译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致一些微妙 的变化悄然发生,所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。
- 唯一的目的:存放对象实例;
- 垃圾收集器管理的主要区域;
- 可以处于物理上不连续的内存空间中;
- 可能抛出的异常:
OutOfMemoryError
(堆中没有内存可以分配给新创建的实例,并且堆也无法再继续扩展了)。
- 虚拟机参数设置:
- 最大值:
-Xmx
- 最小值:
-Xms
- 两个参数设置成相同的值可避免堆自动扩展。
1.5方法区
常量池
线程共享
- 存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据;
- 类信息:即 Class 类,如类名、访问修饰符、常量池、字段描述、方法描述等。
- 垃圾收集行为在此区域很少发生;
- 不过也不能不清理,对于经常动态生成大量 Class 的应用,如 Spring 等,需要特别注意类的回收状况。
- 可能抛出的异常:
OutOfMemoryError
(方法区无法满足内存分配需求时)。
- JDK8之前:方法区称呼为
永久代
- JDK8以后:废弃了
永久代
的概念,改用与
JRockit
、
J9
一样在本地内存中实现的
元空间
方法区的类型信息、静态变量<------>class文件的相对应的表
方法区的运行时常量池<---------->class的常量池表
运行时常量
运行时常量池也是方法区的一部分;
运行时常量池相对于Class文件常量池的另外一个重要特征是具备
动态性
,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,
运行期间也可以将新的常量放入池中
,这种特性被开发人员利用得比较多的便是
String
类的
intern()
方法。
Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项是常量池,用于存放编译器生成的各种字面量(就是代码中定义的 static final 常量)和符号引用,这部分信息就存储在运行时常量池中。
Class文件不会保存各个方法和字段的最终内存布局信息,而是在将类加载到JVM后进行
动态链接
的,需要将字段、方法的符号引用经过运行期转换才能正常使用;
1.6 直接内存
- 直接内存不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域
- 直接内存是在Java堆外的、直接向系统申请的内存空间
- 来源于
NIO
,通过存在堆中的
DirectByteBuffer
操作Native内存
- 通常,访问直接内存的速度会优于Java堆,即读写性能高。因此处于性能考虑,读写频繁的场合可能会考虑使用直接内存。Java的NIO库允许Java程序使用直接内存,用于数据缓冲区
由于直接内存在Java堆外,因此它的大小不会直接受限于
-Xmx
指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存
缺点
直接内存大小可以通过
MaxDirectMemorySize
设置;如果不指定,默认与堆的最大值
-Xmx
参数值一致
参考:
JVM系列(九)直接内存(Direct Memory) - 掘金 (juejin.cn)
二、HotSpot虚拟机对象
2.1对象的创建
当Java虚拟机遇到一条字节码new指令时。
- 首先将去检查这个指令的参数是否能在
常量池
中定位到一个类的
符号引用
,
- 并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程
- 在堆中将为新生对象分配内存
内存分配策略
- 内存空间(但不包括对象头)都初始化为零值
- 对象头设置:是哪个类的实例、如何才能找到类的元数据信息、哈希码、GC分代年龄
- 从虚拟机的视角来看,一个新的对象已经产生了。
- 从Java程序的视角看来,Class文件中的
<init>()
方法还没有执行,执行构造方法。
这其中有两个问题,
- 如何为对象内存划分空间
- 如何保证创建内存时,划分内存
线程安全
划分可用的内存
- 指针碰撞(内存分配规整)
- 用过的内存放一边,没用过的内存放一边,中间用一个指针分隔;
- 分配内存的过程就是将指针向没用过的内存那边移动所需的长度;
- 空闲列表(内存分配不规整)
- 维护一个列表,记录哪些内存块是可用的;
- 分配内存时,从列表上选取一块足够大的空间分给对象,并更新列表上的记录;
划分内存的指针的同步问题
- 对分配内存空间的动作进行同步处理(CAS);
- 把内存分配动作按照线程划分在不同的空间之中进行;
- 每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB);
- 哪个线程要分配内存就在哪个线程的 TLAB 上分配,TLAB 用完需要分配新的 TLAB 时,才需要同步锁定;
- 通过
-XX:+/-UseTLAB
参数设定是否使用 TLAB。
2.2对象的内存布局
对象在堆内存中的存储布局可以划分为三个部分:
对象头(Header)
、
实例数据(Instance Data)
和
对齐填充(Padding)
。
- 对象头:
- 第一部分(Mark Word):哈希码(HashCode)、GC分代年龄、偏向状态、锁状态标志、偏向线程ID、偏向时间戳等信息。
- 第二部分:类型指针,指向它的类元数据的指针,虚拟机通过这个指针来判断这个对象是哪个类的实例(HotSpot 采用的是直接指针的方式访问对象的);
- 如果是个数组对象,对象头中还有一块用于记录数组长度的数据。
- 实例数据:
- 默认分配顺序:longs/doubles、ints、shorts/chars、bytes/booleans、oops (Ordinary Object Pointers),相同宽度的字段会被分配在一起,除了 oops,其他的长度由长到短;
- 默认分配顺序下,父类字段会被分配在子类字段前面。
- 填充数据:
HotSpot VM
要求对象的起始地址必须是8字节的整数倍,所以不够要补齐。
2.3对象的访问定位
Java 程序需要通过虚拟机栈上的 reference 数据来操作堆上的具体对象,reference 数据是一个指向对象的引用,不过如何通过这个引用定位到具体的对象,目前主要有以下两种访问方式:句柄访问和直接指针访问。
句柄访问
句柄访问会在 Java 堆中划分一块内存作为句柄池,每一个
句柄存放
着到对象实例数据和
对象类型数据的指针。
优势:对象移动的时候(这在垃圾回收时十分常见)只需改变句柄池中对象实例数据的指针,不需要修改reference本身。
直接指针访问
直接指针访问方式在 Java 堆对象的实例数据中
存放了一个指向对象类型数据的指针
,在
HotSpot
中,这个指针会被存
放在对象头
中。
优势:减少了一次指针定位对象实例数据的开销,速度更快。
三、OOM 异常
3.1Java 堆溢出
- 出现标志:
java.lang.OutOfMemoryError: Java heap space
- 解决方法:
- 先通过内存映像分析工具分析 Dump 出来的堆转储快照,确认内存中的对象是否是必要的,即分清楚是出现了内存泄漏还是内存溢出;
- 如果是内存泄漏,通过工具查看泄漏对象到 GC Root 的引用链,定位出泄漏的位置;
- 如果不存在泄漏,检查虚拟机堆参数(
-Xmx
和
-Xms
)是否可以调大,检查代码中是否有哪些对象的生命周期过长,尝试减少程序运行期的内存消耗。
- 虚拟机参数:
-XX:HeapDumpOnOutOfMemoryError
:让虚拟机在出现内存泄漏异常时 Dump 出当前的内存堆转储快照用于事后分析。
3.2Java 虚拟机栈和本地方法栈溢出
- 单线程下,栈帧过大、虚拟机容量过小都不会导致
OutOfMemoryError
,只会导致
StackOverflowError
(栈会比内存先爆掉),一般多线程才会出现
OutOfMemoryError
,因为线程本身要占用内存;
- 如果是多线程导致的
OutOfMemoryError
,在不能减少线程数或更换 64 位虚拟机的情况,只能通过减少最大堆和减少栈容量来换取更多的线程;
- 这个调节思路和 Java 堆出现 OOM 正好相反,Java 堆出现 OOM 要调大堆内存的设置值,而栈出现 OOM 反而要调小。
3.3方法区和运行时常量池溢出
- 测试思路:产生大量的类去填满方法区,直到溢出;
- 在经常动态生成大量 Class 的应用中,如
Spring 框架
(使用
CGLib
字节码技术),方法区溢出是一种常见的内存溢出,要特别注意类的回收状况。
3.4直接内存溢出
- 出现特征:Heap Dump 文件中看不见明显异常,程序中直接或间接用了 NIO;
- 虚拟机参数:
-XX:MaxDirectMemorySize
,如果不指定,则和
-Xmx
一样。
四、垃圾收集
垃圾收集(Garbage Collection,GC)
,它的任务是解决以下 3 件问题:
其中第一个问题很好回答,在 Java 中,
GC
主要发生在 Java 堆和方法区中,对于后两个问题,将在之后的内容中进行讨论,并介绍
HotSpot
的 7 个垃圾收集器。
4.1判断对象的生死
什么时候回收对象?当然是这个对象再也不会被用到的时候回收。
所以要想解决 “什么时候回收?” 这个问题,我们要先能判断一个对象什么时候什么时候真正的 “死” 掉了,判断对象是否可用主要有以下两种方法。
4.1.1判断对象是否可用的算法
引用计数算法
- 算法描述:
- 给对象添加一个引用计数器;
- 每有一个地方引用它,计数器加 1;
- 引用失效时,计数器减 1;
- 计数器值为 0 的对象不再可用。
- 缺点:
- 很难解决循环引用的问题。即
objA.instance = objB; objB.instance = objA;
,objA 和 objB 都不会再被访问后,它们仍然相互引用着对方,所以它们的引用计数器不为 0,将永远不能被判为不可用。
可达性分析算法(主流)
- 算法描述:
- 从 "GC Root" 对象作为起点开始向下搜索,走过的路径称为引用链(Reference Chain);
- 从 "GC Root" 开始,不可达的对象被判为不可用。
- Java 中可作为 “GC Root” 的对象:
- 栈中(本地变量表中的reference)
- 虚拟机栈中,栈帧中的本地变量表引用的对象;
- 本地方法栈中,JNI 引用的对象(native方法);
- 方法区中
即便如此,一个对象也不是一旦被判为不可达,就立即死去的,宣告一个的死亡需要经过两次标记过程。
并发情况的可达性分析
在可达性分析中,第一阶段 ”根节点枚举“ 是必须 STW 的,那么为什么因此必须在一个能
保障一致性的快照
上才能进行对象图的遍历,而不是同步用户线程进行呢?
引入三色标记作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:
- 白色:表示
对象尚未被垃圾收集器访问过
。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
- 黑色:表示
对象已经被垃圾收集器访问过
,且这个对象的所有引用都已经扫描过。黑色的对象代 表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对 象不可能直接(不经过灰色对象)指向某个白色对象。
- 灰色:表示
对象已经被垃圾收集器访问过
,但这个对象上
至少存在一个引用还没有被扫描
过。
关于可达性分析的扫描过程,可以看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程,此时如果用户线程改变了对象的引用关系,会发生两种情况:
- 一种是把原本消亡的对象错误标记为存活, 这下次收集清理掉就好。
- 另一种是把原本存活的对象错误标记为已消亡,那么可能会导致程序崩溃。
如上图所示,
b -> c 的引用被切断,但同时用户线程建立了一个新的从 a -> c 的引用
,由于已经遍历到了 b,不可能再回去遍历 a(黑色对象不会被重新扫描),再遍历 c,所以这个 c 实际是存活的对象,但由于没有被垃圾收集器扫描到,被错误地标记成了白色,就
会导致c被标记为需要回收的对象
。
总结下对象消失问题的两个条件:
- 插入了一条或多条从黑色对象到白色对象的新引用
- 删除了全部从灰色对象到该白色对象的直接或间接引用
当且仅当
以上两个条件同时满足时,才会产生 “对象消失” 的问题,即原本应该是黑色的对象被误标为白色
两种解决方案
- 增量更新
(Incremental Update):增量更新破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时(就是上图中的 a -> c 引用关系),就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象(a)为根,重新扫描一次。这可以简化理解为,
黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了
。
- 原始快照
(Snapshot At The Beginning,SATB):原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时(上图中的 b -> c 引用关系),就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象(b)为根,重新扫描一次。这也可以简化理解为,
无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索
。
对引用关系记录的插入还是删除,虚拟机的记录操作都是通过
写屏障
现的。在
HotSpot虚拟机
中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS是基于增量更新 来做并发标记的,G1、Shenandoah则是用原始快照来实现。
4.1.2四种引用类型
JDK 1.2 后,Java 中才有了后 3 种引用的实现。
- 强引用:
像
Object obj = new Object()
这种,只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象。
- 软引用:
用来引用还存在但非必须的对象。对于软引用对象,在 OOM 前,虚拟机会把这些对象列入回收范围中进行第二次回收,如果这次回收后,内存还是不够用,就 OOM。实现类:
SoftReference
。
- 弱引用:
被弱引用引用的对象只能生存到下一次垃圾收集前,一旦发生垃圾收集,被弱引用所引用的对象就会被清掉。实现类:
WeakReference
。
- 虚引用:
幽灵引用,对对象没有半毛钱影响,甚至不能用来取得一个对象的实例。它唯一的用途就是:当被一个虚引用引用的对象被回收时,系统会收到这个对象被回收了的通知。实现类:
PhantomReference
。
4.1.3死亡的两次标记过程
- 当发现对象不可达后,该对象被第一次标记,并进行是否有必要执行
finalize()
方法的判断;
- 不需要执行:对象没有覆盖
finalize()
方法,或者
finalize()
方法已被执行过(
finalize()
只被执行一次);
- 需要执行:将该对象放置在一个队列中,稍后由一个虚拟机自动创建的低优先级线程执行。
finalize()
方法是对象逃脱死亡的最后一次机会,不过虚拟机不保证等待
finalize()
方法执行结束,也就是说,虚拟机只触发
finalize()
方法的执行,如果这个方法要执行超久,那么虚拟机并不等待它执行结束,所以最好不要用这个方法。
finalize()
方法能做的,try-finally 都能做,所以忘了这个方法吧
4.1.4方法区的回收
永久代的 GC 主要回收:
废弃常量
和
无用的类
。
- 废弃常量:例如一个字符串 "abc",当没有任何引用指向 "abc" 时,它就是废弃常量了。
- 无用的类:同时满足以下 3 个条件的类。
- 该类的所有实例已被回收,Java 堆中不存在该类的任何实例;
- 加载该类的 Classloader 已被回收;
- 该类的 Class 对象没有被任何地方引用,即无法在任何地方通过反射访问该类的方法。
4.2垃圾收集算法
4.2.1标记 - 清除算法
算法描述:
- 先标记出所有需要回收的对象(图中深色区域);
- 标记完后,统一回收所有被标记对象(留下狗啃似的可用内存区域……)。
不足:
- 效率问题:标记和清理两个过程的效率都不高。
- 空间碎片问题:标记清除后会产生大量不连续的内存碎片,导致以后为较大的对象分配内存时找不到足够的连续内存,会提前触发另一次 GC。
4.2.2标记 - 复制算法
4.2.3标记 - 整理算法
- 算法描述:
- 标记方法与 “标记 - 清除算法” 一样;
- 标记完后,将所有存活对象向一端移动,然后直接清理掉边界以外的内存。
- 不足:
存在效率问题,适合老年代。
进化:分代收集算法
- 新生代:
GC 过后只有少量对象存活 ——
复制算法
- 老年代:
GC 过后对象存活率高 ——
标记 - 整理算法
4.3HotSpot 中 GC 算法的实现
通过之前的分析,GC 算法的实现流程简单的来说分为以下两步:
- 找到死掉的对象;
- 把它清了。
想要找到死掉的对象,我们就要进行可达性分析,也就是从 GC Root 找到引用链的这个操作,需要获取所有对象引用。
那么,首先要找到哪些是 GC Roots。
有两种查找 GC Roots 的方法:
- 遍历方法区和栈区查找(保守式 GC)。
- 通过
OopMap
数据结构来记录
GC Roots
的位置(准确式 GC)。
很明显,保守式 GC 的成本太高。准确式 GC 的优点就是能够让虚拟机快速定位到 GC Roots。
但是当内存中的对象间的引用关系发生变化时,就需要改变
OopMap
中的相应内容。可是能导致引用关系发生变化的指令非常之多,如果我们执行完一条指令就改下
OopMap
,这 GC 成本实在太高了。于此,安全点和安全区域就很重要了。
4.3.1安全点
因此,
HotSpot
采用了一种在
“安全点”
更新
OopMap
的方法,安全点的选取既不能让 GC 等待的时间过长,也不能过于频繁增加运行负担,也就是说,我们既要让程序运行一段时间,又不能让这个时间太长。
JVM 中每条指令执行的是很快的,所以一个超级长的指令流也可能很快就执行完了,所以
真正会出现 “长时间执行” 的一般是指令的复用,例如:方法调用、循环跳转、异常跳转等
,虚拟机一般会将这些地方设置为安全点更新
OopMap
并判断是否需要进行
GC
操作。
此外,在进行枚举根节点的这个操作时,为了保证准确性,我们需要在一段时间内 “冻结” 整个应用,即
Stop The World
,因为如果在我们分析可达性的过程中,对象的引用关系还在变来变去,那是不可能得到正确的分析结果的。即便是在号称几乎不会发生停顿的
CMS 垃圾收集器
中,枚举根节点时也是必须要停顿的。这里就涉及到了一个问题:
如何让所有线程跑到最近的安全点再停顿下来进行 GC 操作呢?
主要有以下两种方式:
- 抢先式中断:
- 先中断所有线程;
- 发现有线程没中断在
安全点
,恢复它,让它跑到安全点。
- 主动式中断:
(主要使用)
- 设置一个中断标记;
- 每个线程到达
安全点
时,检查这个中断标记,选择是否中断自己。
4.3.2安全区域
除此安全点之外,还有一个叫做
安全区域
的东西。
安全区域是指在一段代码片段之中,引用关系不会发生变化,因此在这个区域中的任意位置开始 GC 都是安全的。
一个一直在执行的线程可以自己 “走” 到安全点去,可是一个处于
Sleep
或者
Blocked
状态的线程是没办法自己到达安全点中断自己的,我们总不能让 GC 操作一直等着这些个 ”不执行“ 的线程重新被分配资源吧。对于这种情况,我们要依靠安全区域来解决。
当线程执行到安全区域时,它会把自己标识为
Safe Region
,这样 JVM 发起
GC
时是不会理会这个线程的。当这个线程要离开安全区域时,它会检查系统是否在
GC
中,如果不在,它就继续执行,如果在,它就等
GC
结束再继续执行。
4.4 记忆集、卡表、写屏障
记忆集
为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为
记忆集(Remembered Set)
的数据结构,用以
避免
把整个老年代加进
GC Roots
扫描范围。
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
收集器只需要通过记忆集判断出
某一块非收集区域是否存在
有指向了
收集区域的指针
就可以了。
采用种称为
卡表(Card Table)
的方式去实现记忆集。
卡表
卡表最简单的形式只是一个字节数组。
CARD_TABLE [this address >> 9] = 0;
字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个 内存块被称作
卡页(Card Page)
。
一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可 以看出HotSpot中使用的卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)。那如 果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了 地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块
一个卡页的内存中通常包含不止一个对象,
只要卡页内有一个(或更多)对象的字段存在着跨代 指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty)
,没有则标识为0。
在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它 们加入
GC Roots
中一并扫描。
写屏障
如何维护卡表元素?
如何维护卡表《=======》如何在
对象赋值的那一刻
去更新卡表
解释执行
的字节码,好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间
编译执行
的场景中经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。
在
HotSpot虚拟机
里是通过
写屏障(Write Barrier)
技术维护卡表状态的。
写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的
AOP切面
,在引用对象赋值时会产生一个
环形(Around)通知
供程序执行额外的动作。
在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier)
在赋值 后的则叫作写后屏障(Post-Write Barrier)
void oop_field_store(oop* field, oop new_value) {
// 引用字段赋值操作
*field = new_value;
// 写后屏障,在这里完成卡表状态更新
post_write_barrier(field, new_value);
}
伪共享问题
除了写屏障的开销外,卡表在高并发场景下还面临着
伪共享(False Sharing)
问题。
伪共享是处 理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以
缓存行(Cache Line)
为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。
假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓 存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对 象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。
为了
避免伪共享问题
,一种简单的解决方案是不采用无条件的写屏障,而是
先检查卡表标记
,只有当该卡表元 素未被标记过时才将其标记为变脏,即将卡表更新的逻辑变为以下代码所示:
if (CARD_TABLE [this address >> 9] != 0)
{
CARD_TABLE [this address >> 9] = 0;
}
在JDK 7之后,HotSpot虚拟机增加了一个新的参数
-XX:+UseCondCardMark
,用来决定是否开启卡表更新的条件判断
4.5垃圾收集器
垃圾收集器就是内存回收操作的具体实现。有的属于新生代收集器,有的属于老年代收集器,所以一般是搭配使用的。
查看垃圾收集器种类指令:
java -XX:+PrintCommandLineFlags -version
Serial / ParNew 搭配 Serial Old 收集器
Serial 收集器是虚拟机在 Client 模式下的默认新生代收集器,它的优势是简单高效,在单 CPU 模式下很牛。
ParNew 收集器就是 Serial 收集器的多线程版本,虽然除此之外没什么创新之处,但它却是许多运行在 Server 模式下的虚拟机中的首选新生代收集器,因为除了 Serial 收集器外,只有它能和 CMS 收集器搭配使用。
Parallel 搭配 Parallel Scavenge 收集器
它们的关注点与其他收集器不同,其他收集器关注于尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目的是达到一个可控的吞吐量。
吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )
因此,Parallel Scavenge 收集器不管是新生代还是老年代都是多个线程同时进行垃圾收集,十分适合于应用在注重吞吐量以及 CPU 资源敏感的场合。
可调节的虚拟机参数:
-XX:MaxGCPauseMillis
:最大 GC 停顿的秒数;
-XX:GCTimeRatio
:吞吐量大小,一个 0 ~ 100 的数,
最大 GC 时间占总时间的比率 = 1 / (GCTimeRatio + 1)
;
-XX:+UseAdaptiveSizePolicy
:一个开关参数,打开后就无需手工指定
-Xmn
,
-XX:SurvivorRatio
等参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,自行调整。
CMS 收集器
回收老年代
参数设置:
-XX:+UseCMSCompactAtFullCollection
:在 CMS 要进行 Full GC 时进行内存碎片整理(默认开启)
-XX:CMSFullGCsBeforeCompaction
:在多少次 Full GC 后进行一次空间整理(默认是 0,即每一次 Full GC 后都进行一次空间整理)
关于 CMS 使用 标记 - 清除 算法的一点思考:
之前对于 CMS 为什么要采用 标记 - 清除 算法十分的不理解,既然已经有了看起来更高级的 标记 - 整理 算法,那 CMS 为什么不用呢?
- 标记 - 整理 会将所有存活对象向一端移动,需要一个指针来维护这个分隔存活对象和无用空间的点,而CMS 是并发清理的,虽然我们启动了多个线程进行垃圾回收,不过如果使用 标记 - 整理 算法,为了保证线程安全,在整理时要对那个分隔指针加锁,保证同一时刻只有一个线程能修改它,
加锁的这一过程相当于将并行的清理过程变成了串行的,也就失去了并行清理的意义了。
- CMS关注的是最短停顿时间,标记 - 清除算法的Stop The World最小。
所以,CMS 采用了 标记 - 清除 算法。
Garbage First(G1)收集器
在G1收集器出现之前的所有 其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代
(Minor GC)
,要么就是整个老年代
(Major GC)
,再要么就是整个Java堆
(Full GC)
。
而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而
是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。
Region布局
G1不再坚持固定大小以及固定数量的 分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域
Region
,每一个
Region
都可以 根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。
- 大对象存储区域:Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个 Region容量一半的对象即可判定为大对象。
- 参数设置:每个Region的大小可以通过参数
-XX:G1HeapRegionSize
设 定,取值范围为1MB~32MB,且应为2的N次幂。
- 超大对象存储:对于那些超过了整个Region容量的超级大对象, 将会被存放在N个连续的
Humongous Region
之中,G1的大多数行为都把
Humongous Region
作为老年代 的一部分来进行看待
垃圾处理思路
具体的处理思路是让G1收集器去跟踪各个Region里面的垃 圾堆积的“价值”大小,价值即
回收所获得的空间大小以及回收所需时间的经验值
,然后在后台维护一 个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数
-XX:MaxGCPauseMillis
指定,默 认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。
难以解决的问题
- Region里面存在的跨Region引用对象如何解决?
使用记忆集避免全堆作为GC Roots扫描,但在G1收集器上记 忆集的应用其实要复杂很多。
G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更复杂
- 在并发标记阶段如何保证收集线程与用户线程互不干扰地运行
G1 收集器则是通过
原始快照(SATB)算法
来实现的
此外,G1为每一个Region设 计了两个名为
TAMS(Top at Mark Start)
的指针,把Region中的一部分空间划分出来用于并发回收过 程中的新对象分配,
并发回收时新分配的对象地址都必须要在这两个指针位置以上
。
G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。
对比CMS收集器
- 内存占用:
- G1的卡表实现更为复杂,每个Region都需要有一份卡表,无论其在新生代还是老年代中的角色。这可能导致G1的记忆集和其他内存消耗占用整个堆容量的20%甚至更多的内存空间。
- 相比之下,CMS的卡表相对简单,只有一份,并且只需处理老年代到新生代的引用。这减少了卡表维护的开销。
- 执行负载:
- 由于G1和CMS使用写屏障,它们在程序运行时的负载会有所不同。
- CMS使用写后屏障来更新维护卡表。
- G1除了使用写后屏障进行卡表维护外,为了实现原始快照搜索算法,还需要使用写前屏障来跟踪并发时的指针变化。原始快照搜索算法减少了并发标记和重新标记阶段的消耗,避免了CMS在最终标记阶段停顿时间过长的缺点,但在用户程序运行过程中会带来额外的负担。
- 写屏障实现:
- 由于G1对写屏障的复杂操作消耗更多的运算资源,CMS的写屏障实现是直接的同步操作。
- G1将写屏障实现为类似于消息队列的结构,异步处理队列中的写前屏障和写后屏障任务。
- 适用场景:
- 在小内存应用上,CMS的性能可能仍然优于G1。
- 在大内存应用上,G1通常能够发挥其优势,特别是在Java堆容量在6GB至8GB之间。
总体而言,对于选择垃圾收集器,需要考虑具体的应用场景和需求,并通过实际测试来得出最合适的结论。随着HotSpot对G1的不断优化,G1在不同场景中的表现可能会持续改善。
目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间,当然,以上这些也仅是经验之谈,不同应用需要量体裁衣地实际测试才能得出最合适的结论,随着HotSpot的开发者对G1的不断优化,也 会让对比结果继续向G1倾斜
里程碑
从G1开始,最先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用的内存分配速率 (Allocation Rate),而不追求一次把整个Java堆全部清理干净。
这样,应用在分配,同时收集器在收集,
只要收集的速度能跟得上对象分配的速度,那一切就能运作得很完美。
这种新的收集器设计思路 从工程实现上看是从G1开始兴起的,所以说G1是收集器技术发展的一个里程碑。
GC 日志解读
低延迟收集器
Shenandoah和ZGC是两种在Java平台上开发的具有低停顿时间目标的垃圾收集器。它们的目标是减少长时间停顿(Full GC)的发生,以提高Java应用程序的响应性和性能。以下是关于Shenandoah和ZGC的一些关键特点:
Shenandoah Garbage Collector:
- 低停顿时间
:Shenandoah的主要目标是实现非常低的停顿时间。它通过并发标记、并发标记-清除和并发整理等技术来降低垃圾收集期间的暂停时间。
- 适用范围
:Shenandoah适用于需要高度响应性的应用程序,例如在线事务处理系统,其中低延迟是至关重要的。
- 全局并发
:Shenandoah使用全局并发的方式,意味着它在整个堆内存上工作,而不仅仅是一部分。这使得它可以在更大的堆内存上表现出色。
- Java版本
:Shenandoah在Java 12中首次引入,是OpenJDK项目的一部分。
Z Garbage Collector (ZGC):
- 低停顿时间
:ZGC的目标也是降低暂停时间。它通过并发标记、压缩和垃圾回收来实现这一目标。
- 适用范围
:ZGC适用于需要低延迟和响应性的应用程序,特别是大内存应用,例如大数据处理。
- 全局并发
:类似于Shenandoah,ZGC也使用全局并发,这使得它可以在大型堆上工作。
- Java版本
:ZGC在Java 11中首次引入,也是OpenJDK项目的一部分。
这两个垃圾收集器的共同目标是减少垃圾收集期间的停顿时间,使Java应用程序更具响应性。选择哪个收集器取决于应用程序的具体需求和硬件环境。在Java 11及之后的版本中,开发者可以根据性能要求选择Shenandoah或ZGC,以提高应用程序的性能和用户体验。
Epsilon垃圾收集器
Epsilon垃圾收集器是一种特殊的垃圾收集器,它在Java中引入了一种不进行垃圾收集的策略。Epsilon垃圾收集器实际上是一种"无操作"的垃圾收集器,它不会执行任何垃圾回收操作,而是允许堆内存不断增长,直到达到操作系统的限制。
Epsilon垃圾收集器的设计目标是用于某些特殊用途,例如:
- 性能测试和基准测试
:Epsilon垃圾收集器可以用于执行性能测试和基准测试,其中不希望垃圾收集引入额外的性能变化。
- 短暂的、生命周期较短的应用程序
:对于某些应用程序,例如一次性命令行工具或短暂运行的应用程序,Epsilon垃圾收集器可以作为一种轻量级的选择,避免了垃圾收集器的启动和停顿。
- 堆外内存管理
:Epsilon垃圾收集器还可以用于管理堆外内存,这是一种不受垃圾收集器管理的内存,适用于一些特殊用途。
Epsilon垃圾收集器并
不适用于大多数常规Java应用程序
,因为它不会回收堆内存中的垃圾,这可能导致内存泄漏。它适用于那些确切了解自己的应用程序行为并且明确知道不需要垃圾收集的情况。
Epsilon垃圾收集器是Java 11中引入的,可以通过命令行参数
-XX:+UseEpsilonGC
启用。但大多数Java应用程序仍然使用其他垃圾收集器,例如G1、ZGC或Shenandoah,以满足它们的垃圾收集需求。
收集器的权衡
出发点
应用程序的主要关注点是什么?
如果是数据分析、科学计算类的任务,目标是能尽快算出结果, 那吞吐量就是主要关注点;
如果是
SLA
应用,那停顿时间直接影响服务质量,严重的甚至会导致事务超时,这样延迟就是主要关注点;而如果是客户端应用或者嵌入式应用,那垃圾收集的内存占用则是不可忽视的。
SLA(Service Level Agreement,服务级别协议)应用通常是指在服务提供者和服务使用者之间制定和遵守的一种协议,其中规定了服务的质量、性能、可用性等方面的标准和承诺。SLA应用通常与服务提供者和客户之间的服务交付和接受有关,特别是在云计算、网络服务、托管服务和其他IT服务领域。
以下是一些SLA应用的示例:
- 云服务提供商
:云服务提供商通常与客户签订SLA,以规定云计算服务的性能、可用性、数据备份、安全性等方面的承诺。如果云服务提供商未能满足SLA中的承诺,可能需要提供赔偿或补偿。
- 网络服务
:网络服务提供商通常与企业客户签订SLA,以规定网络连接的可用性、带宽、延迟等方面的服务质量。SLA可用于确保网络服务符合业务需求。
- 托管服务
:托管服务提供商通常与客户签订SLA,以规定托管服务的性能、可用性、安全性和数据备份等方面的标准。这有助于确保托管的应用程序和数据的可靠性。
- 电子商务
:在线商店和电子商务平台可能与物流服务提供商签订SLA,以确保订单交付的时间和质量达到一定标准。
- 移动应用程序和游戏
:开发者和移动应用程序平台或游戏服务提供商之间可以签订SLA,以规定应用程序或游戏的性能、稳定性和可用性。
运行应用的基础设施如何?
譬如硬件规格,要涉及的系统架构是x86-32/64、SPARC还是 ARM/Aarch64;
处理器的数量多少,分配内存的大小;选择的操作系统是
Linux
、
Solaris
还是
Windows
等
使用JDK的发行商是什么?版本号是多少?
是
ZingJDK/Zulu
、
OracleJDK
、
Open-JDK
、
OpenJ9
或是其他公司的发行版?
该JDK对应了《Java虚拟机规范》的哪个版本?
选择
一般来说,收集器的选择就从以上这几点出发来考虑。举个例子,假设某个直接面向用户提供服 务的B/S系统准备选择垃圾收集器,一般来说延迟时间是这类应用的主要关注点,那么:
C4
如果有充足的预算但没有太多调优经验,那么一套带商业技术支持的专有硬件或者软件解决方案是不错的选择,Azul公司以前主推的Vega系统和现在主推的Zing VM是这方面的代表,这样你就可以使用传说中的C4收集器了。
C4(Continuous Concurrent Compacting Collector)是一种用于Java虚拟机(JVM)的垃圾收集器,它专注于降低垃圾收集引起的停顿时间。C4收集器的目标是在减少停顿时间的同时提供高吞吐量和良好的性能。它是以低停顿时间为特色的垃圾收集器。
以下是C4垃圾收集器的一些关键特点:
- 低停顿时间
:C4收集器的设计目标是实现极低的停顿时间。它通过并发标记、并发标记-清除和并发整理等技术,使垃圾收集的大部分工作在应用程序运行时进行,从而降低了停顿时间。
- 适用范围
:C4收集器适用于需要快速响应和低延迟的应用程序,如在线事务处理系统、Web应用程序和其他对停顿时间要求较高的应用。
- 全局并发
:C4采用全局并发的方式,允许垃圾收集器与应用程序线程并发工作,而不是在停顿期间独占堆内存。
- 分代收集
:C4收集器通常使用分代收集策略,将堆内存划分为不同的代。这使得它可以更有效地管理内存,降低了垃圾收集的频率。
- 自适应调整
:C4收集器具有自适应调整的能力,可以根据应用程序和硬件环境的变化自动调整其行为。
需要注意的是,C4垃圾收集器通常不是Oracle JDK的默认垃圾收集器,而是一种商业JVM的特性,如Azul Zing。C4垃圾收集器在一些商业JVM中提供,而不是在开源JVM中普遍使用。选择使用C4垃圾收集器通常需要根据具体的商业JVM产品进行配置和许可。
ZGC
如果没有足够预算去使用商业解决方案,但能够掌控软硬件型号,使用较新的版本,同时又特别注重延迟,那ZGC很值得尝试。
Z Garbage Collector(ZGC)是一种用于Java虚拟机(JVM)的垃圾收集器,旨在降低大型Java应用程序的停顿时间。ZGC是由Oracle开发的,并于Java 11中首次引入。以下是ZGC垃圾收集器的一些关键特点和优势:
- 低停顿时间
:ZGC的主要设计目标之一是降低停顿时间。它采用了一种并发的方式来执行垃圾收集,以减少应用程序的停顿时间。通常,垃圾收集过程中的停顿时间在几毫秒到几十毫秒之间,这对需要快速响应的应用程序非常有利。
- 大堆支持
:ZGC适用于非常大的堆内存,可以处理几十GB甚至上百GB的堆内存。这使其适合大型数据处理应用和内存密集型应用。
- 并发处理
:ZGC的标记、清理和整理阶段是并发进行的,这意味着垃圾收集过程与应用程序线程并行执行。这有助于减少停顿时间。
- 可预测性
:ZGC致力于提供可预测的停顿时间,这对于需要满足服务级别协议(SLA)的应用程序非常重要。
- 无需特殊硬件
:ZGC不需要特殊的硬件支持,可以在标准的x86架构上运行。
- 多平台支持
:ZGC支持多种平台,包括Linux、Windows和macOS。
需要注意的是,ZGC并不适用于所有应用程序。它在大型内存需求和低停顿时间要求的情况下表现最佳。对于小型应用程序,传统的垃圾收集器(如G1或CMS)可能足够了。在选择ZGC时,还需要考虑Java版本的兼容性,因为它是从Java 11开始引入的。
总的来说,ZGC是一种在大型内存应用程序中降低停顿时间的有效垃圾收集器,特别适用于需要可预测性和低延迟的应用程序。
CMS/G1
如果接手的是遗留系统,软硬件基础设施和JDK版本都比较落后,那就根据内存规模衡量一 下,对于大概4GB到6GB以下的堆内存,CMS一般能处理得比较好,而对于更大的堆内存,可重点考察一下G1。
五、内存分配策略
新生代和老年代的 GC 操作
- 新生代 GC 操作:
Minor GC
- 老年代 GC 操作:
Full GC / Major GC
- 经常伴随着至少一次的
Minor GC
;
- 速度一般比
Minor GC
慢上 10 倍以上。
5.1优先在 Eden 区分配
- new的对象先放在伊甸园区
,此区有大小限制
- 当
伊甸园区满时
,程序又需要创建对象,此时JVM的垃圾回收器(YGC/Minor GC)对伊甸园区进行
垃圾回收
,将伊甸园区中不被对象所引用的对象进行销毁,再加载新的对象放到此区中。
- 然后将伊甸园中
剩余的对象
(存活的)
移动到幸存者0区
。
- 当
再次垃圾回收
时,还是先销毁对象并将存活对象移动到幸存者1区,然后
将处在幸存者0区的也移动到幸存者1区
(这些对
象的年龄++
)。
- 接下来重复,每次放入幸存者区时
,放入空的那个
(to区)
- 当再次垃圾回收时, 且当幸存者区中的对象的
年龄有到达15的
(可以更改
-XX:MaxTenuringThreshold=?
),则将此对象
移动到老年区
。
- 老年区相对悠闲,当老年区内存不足时,触发
Major GC
,进行老年区的清理。
- 若老年区
执行了Major GC之后发现依然无法进行对象的保存,就会产生OOM异常
。
- 虚拟机参数:
-Xmx
:Java 堆的最大值;
-Xms
:Java 堆的最小值;
-Xmn
:新生代大小;
-XX:SurvivorRatio=8
:Eden 区 / Survivor 区 = 8 : 1
5.2大对象直接进入老年代
- 大对象定义:
需要大量连续内存空间的 Java 对象。例如那种很长的字符串或者数组。
- 设置对象直接进入老年代大小限制:
-XX:PretenureSizeThreshold
:单位是字节;
- 只对
Serial
和
ParNew
两款收集器有效。
- 目的:
因为新生代采用的是复制算法收集垃圾,大对象直接进入老年代可以避免在 Eden 区和 Survivor 区发生大量的内存复制。
5.3长期存活的对象将进入老年代
- 固定对象年龄判定:
虚拟机给每个对象定义一个年龄计数器,对象每在 Survivor 中熬过一次 Minor GC,年龄 +1,达到
-XX:MaxTenuringThreshold
设定值后,会被晋升到老年代,
-XX:MaxTenuringThreshold
默认为 15;
- 动态对象年龄判定:
Survivor 中有相同年龄的对象的空间总和大于 Survivor 空间的一半,那么,年龄大于或等于该年龄的对象直接晋升到老年代。
5.4空间分配担保
我们知道,新生代采用的是复制算法清理内存,每一次 Minor GC,虚拟机会将 Eden 区和其中一块 Survivor 区的存活对象复制到另一块 Survivor 区,但
当出现大量对象在一次 Minor GC 后仍然存活的情况时,Survivor 区可能容纳不下这么多对象,此时,就需要老年代进行分配担保,即将 Survivor 无法容纳的对象直接进入老年代。
这么做有一个前提,就是老年代得装得下这么多对象。可是在一次 GC 操作前,虚拟机并不知道到底会有多少对象存活,所以空间分配担保有这样一个判断流程:
- 发生 Minor GC 前,虚拟机先检查老年代的最大可用连续空间是否大于新生代所有对象的总空间;
- 如果大于,Minor GC 一定是安全的;
- 如果小于,虚拟机会查看
HandlePromotionFailure
参数,看看是否允许担保失败;
- 允许失败:尝试着进行一次
Minor GC
;
- 不允许失败:进行一次
Full GC
;
- 不过 JDK 6 Update 24 后,
HandlePromotionFailure
参数就没有用了,规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC,否则将进行 Full GC。
5.5etaspace 元空间与 PermGen 永久代
Java 8 彻底将永久代 (PermGen) 移除出了
HotSpot JVM
,将其原有的数据迁移至 Java Heap 或 Metaspace。
移除 PermGen 的原因:
PermGen
内存经常会溢出,引发恼人的
java.lang.OutOfMemoryError: PermGen
,因此 JVM 的开发者希望这一块内存可以更灵活地被管理,不要再经常出现这样的
OOM
;
- 移除
PermGen
可以促进
HotSpot JVM
与
JRockit VM
的融合,因为
JRockit
没有永久代。
移除 PermGen 后,方法区和字符串常量的位置:
- 方法区:移至
Metaspace
;
- 字符串常量:移至
Java Heap
。
Metaspace 的位置:
本地堆内存(native heap)。
Metaspace 的优点:
永久代 OOM 问题将不复存在,因为默认的类的元数据分配只受本地内存大小的限制,也就是说本地内存剩余多少,理论上
Metaspace
就可以有多大;
JVM参数:
-XX:MetaspaceSize
:分配给类元数据空间(以字节计)的初始大小,为估计值。
MetaspaceSize
的值设置的过大会延长垃圾回收时间。垃圾回收过后,引起下一次垃圾回收的类元数据空间的大小可能会变大。
-XX:MaxMetaspaceSize
:分配给类元数据空间的最大值,超过此值就会触发
Full GC
,取决于系统内存的大小。JVM会动态地改变此值。
-XX:MinMetaspaceFreeRatio
:一次GC以后,为了避免增加元数据空间的大小,空闲的类元数据的容量的最小比例,不够就会导致垃圾回收。
-XX:MaxMetaspaceFreeRatio
:一次GC以后,为了避免增加元数据空间的大小,空闲的类元数据的容量的最大比例,不够就会导致垃圾回收。