JVM 虚拟机

《深入理解 Java 虚拟机 - JVM 高级特性与最佳实践》

TODO虚拟机器执行子系统、GC、内存区域、调优

内存结构

方法区、永久代(PermGen space)、元空间(Metaspace)的关系

方法区
  • 主要存储类的相关信息,所以对于动态生成类的情况比较容易出现永久代的内存溢出
  • 方法区是 JVM 的规范,元空间是 HotSpot 虚拟机在 Java8 具体实现的方法区(JDK8 之前是永久代)

Metaspace(元空间)替换 PermGen(永久代)的原因

永久代存储在虚拟机堆中,元空间存储在本地内存中

  • 字符串存在永久代中,不方便管理维护,需要单独进行垃圾管理
  • 类及方法的信息等比较难确定其大小,因此难以指定永久代的大小,太小容易出现永久代溢出,太大则容易导致老年代溢出
  • 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低
  • 和其他虚拟机架构看齐,方便合并

静态变量和基本数据类型包装类的常量池存在哪里?

  • jdk1.6 及之前静态变量和 String Table 在永久代方法区
  • jdk1.8后 运行时常量池和静态变量在元空间,字符串常量池在堆空间

运行时常量池

  • 存储常量、引用、整数和浮点数等内存区域,是类加载后的第一个创建的内存区域,并且在整个 JVM 进程中共享
  • 常量池中的内容是不可变的,可以通过符号引用进行共享,从而避免内存浪费

方法区:全局,所有栈都可以访问

  • static、ClassLoader

JVM 内存区域

线程私有的

  • 程序计数器(当前线程执行位置;依次读取代码)
  • 本地方法栈(存储栈帧:局部变量表【值、引用指针、句柄】、操作数栈、动态链接【当一个方法要调用其他方法,将常量池中指向方法的符号引用转化为其在内存地址中的直接引用】、方法返回地址)
  • 虚拟机栈

线程共享的

  • 方法区(元空间、永久代)当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。(运行时常量池:即各种字面量和符号引用【解析阶段:JVM 将符号引用转为直接引用】)
  • 堆(字符串常量池【String#intern】、静态变量)
  • 本地内存

对象的创建过程

对象的创建过程

  1. 查找常量池中是否有类的符号引用
  2. 分配内存:指针碰撞 OR 空闲列表(保证线程安全:CAS + 失败重试 OR TLAB)
  3. 初始化零值
  4. 设置对象头:元数据信息、对象的哈希码、对象的 GC 分代年龄、是否启用偏向锁
  5. 执行 init 方法

对象访问定位

通过栈上的 引用指针 Reference 来操作堆上的具体对象:句柄、直接指针

  • Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。在对象被移动时(垃圾收集时移动对象是非常普遍的行为)只会改变句柄中的实例数据指针。
image-20240429100643112
  • 直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销,HotSpot 采用的是直接指针
image-20240429100908916

对象的内存布局

Mark Word 是一个具有动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。

GC 堆

新生代 Young Generation

  • Eden 区【新建对象分配地址】
  • 两个 Survivor 区 S0 和 S1 【survivor 的阈值年龄取小(占用超过一半 s 区的年龄,MaxTenuringThreshold)】

老生代 Old Generation

HotSpot VM 的实现里的 GC 其实准确分类只有两大种:

部分收集 (Partial GC):

  • 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;

  • 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;

    • 触发条件:老年代空间不足、方法区空间不足
  • 混合收集(Mixed GC):

垃圾回收

GC Root

虚拟机栈、本地方法栈、方法区中类静态属性、方法区中常量、JNI 引用的对象

被同步锁持有的对象

记忆集与卡表

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针,并不需要了解这些跨代指针的全部细节。老年代划分为若干个小块,标识出老年代哪一块内存会存在跨代引用。当发生 Minor GC 时,只有包含了跨代引用的小块内存中的老年代对象才会加入到 GC Roots 扫描中,避免整个老年代加入到 GC Roots 中

垃圾收集算法

垃圾收集算法 优点 缺点 适用范围
标记清除法 不需要移动对象,简单高效 标记效率低(访问全部内存)内存碎片 老年代
标记复制算法 无内存碎片 频繁复制;内存使用率低 新生代
标记整理法 结合了上面两个优点 移动局部对象 老年代
分代收集算法 根据对象存活概率,选择垃圾收集算法

垃圾回收器

GCRoot:栈帧中引用的对象、静态变量、常量、本地方法栈中对象
STW:Stop the World

Young GC Old GC
Serial 串行、标记-复制 Serial Old 标记-整理(STW)
PawNew 并行、标记-复制 CMS 降低单次垃圾收集时间
Parallel Scavenge 吞吐率 Parallel Old

CMS

以获得最短回收停顿时间为目标的收集器

流程

  • 初次标记 STW:标记直接与 root 相连的对象
  • 并发标记:标记可达对象,跟踪更新记录
  • 重新标记 STW:修正并发标记的变动
  • 并发清理:标记-清理(不用 STW,但清理会产生内存碎片,复制不会)

优点

  • 并发收集、低停顿

缺点

  • 对 CPU 资源敏感;
  • 无法处理浮动垃圾;
  • 它使用的回收算法“标记-清除”算法会导致收集结束时会有大量空间碎片产生,过量碎片会导致 Full GC;
  • 并发失败:并发清理的时候用户线程没有足够的 JVM 内存,会导致 STW 并产生内存碎片

G1

G1 跟踪每个区域的垃圾大小,在后台维护一个优先级列表,每次根据允许的收集时间,优先回收价值最大的区域,已达到在有限时间内获取尽可能高的回收效率

内存结构

  • Region 大小 1M~32M、个数大概 2000
  • >=0.5 Region <1Region:H 区(超大对象存储区)
  • >1Region:多个连续存储区

概念

  • RememberSets,又叫 Rsets 是每个 region 中都有的一份存储空间,用于存储本 region 的对象被其他 region 对象的引用记录
  • CollectionSets,又叫 Csets 是一次 GC 中需要被清理的 regions 集合,注意G1每次 GC 不是全部 region 都参与的,可能只清理少数几个,这几个就被叫做 Csets

Young GC 复制清理的过程

MixGC(没有 Old GC 的概念,新老代一起 GC)类似于 CMS

  • 初次标记 STW:标记直接与 GCRoot 相连的对象,和该对象所处的 Region(RootRegion)
  • 扫描 RootRrgion:遍历 old Region,如果 rset 中存在 RootRegion,则标记(即为可达的 Region)
  • 并发标记:同 CMS,但仅遍历标记的 Region
  • 重新标记 STW:SATB 算法
  • 筛选回收 STW:局部的标记整理垃圾收集

对比CMS,有哪些不同?

  • region化的内存结构,采用复制清理的方式,避免了内存碎片。但是这种清理也造成了STW
  • SATB速度更快
  • 初始标记,并发标记,重新标记,清理垃圾四个阶段很像,但是G1中有很多标记region的操作,并借助Rset进行了范围的缩小,提高了并发标记的速度。小结下就是初始标记和YGC的STW一起了,提高了效率;并发标记因为rset的设计,扫描范围缩小了,提高了效率;重新标记因为使用了SATB提高了效率;清理虽然造成了STW,但是复制使内存紧凑,避免了内存碎片。同时只清理垃圾较多的region,最大限度的降低了STW时间

GC 回收,针对堆内存
GC 回收问题:YGCFGC

类加载过程

类的生命周期:加载、连接、初始化、使用、卸载

image-20240429095206248
  • 加载:通过全类名获取定义此类的二进制字节流;将字节流所代表的静态存储结构转换为方法区的运行时数据结构;在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口;【通过类加载器实现加载,通过双亲委派模型决定采用哪个类加载器】
  • 验证:确保 Class 文件的字节流中包含的信息无误
  • 准备:分配内存并设置类变量初始值
  • 解析:将常量池内的符号引用替换为直接引用,也就是得到类或者字段、方法在内存中的指针或者偏移量;
  • 初始化:执行字节码中的构造器来初始化类

类加载器

负责加载类的对象,主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象);

将“通过类的全限定名获取描述类的二进制字节流”这件事放在虚拟机外部,由应用程序自己决定如何实现;

(启动类加载器:虚拟机的一部分)

双亲委派模型

决定类由哪个类加载器加载编程思想:在面向对象编程中,有一条非常经典的设计原则:组合优于继承,多用组合少用继承(即双亲委派的实现方法)

  • ClassLoader 类使用委托模型来搜索类和资源。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。
    • 获取该类的类加载器getClassLoader()方法;获取父类的类加载器getParent()
  • ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。

为什么用双亲委派?

  • 相同二进制名称的类只会被加载一次,已经加载的类会被放在 ClassLoader 中,防止重复加载;
  • 保证了 Java 的核心 API 不被篡改;

为什么打破双亲委派?

  • Tomcat 下 Web 应用之间的类需要实现隔离,打破双亲委派可以更好地管理多个Web应用程序的类加载,并避免类加载冲突。
  • SPI 的接口是由 Java 核心库提供的(BootstrapClassLoader),SPI 的实现是由第三方供应商提供的(AppClassLoader),实现类无法通过接口的加载器加载

ClassLoader 抽象类:

  • loadClass(String name, boolean resolve) :父类的加载器不为空,则通过父类的loadClass来加载该类。如果要打破双亲委派机制,就重写这个方法;
  • findClass(String name):当父类加载器无法加载时,根据类的二进制名称来加载该类;

线程上下文类加载器

  • 类加载器保存在线程私有数据里,跟线程绑定。解决了,默认情况下一个类及其依赖类由同一个类加载器加载,而接口的类加载器和子类或实现类的加载器不是同一个加载器。

JVM 调优

配置Java堆和元空间大小

选择垃圾回收器:CMS 升级到 G1,甚至 ZGC。

JVM性能监控和调试,分析堆内存状态,合理优化代码