JVM(7) 原理
2024-11-20 17:24:50 # Language # Java

1. 栈上的数据存储

局部变量表中, 每个数据元素空间(slot槽)大小如下

  • 32位虚拟机:4字节
  • 64位虚拟机:8字节

为了支持跨平台特性, 无论是32位还是64位, long 和 double 都会占用 2 个槽

实际编译时, byte boolean等占用1个槽的类型(不含float)会直接当成int处理

image-20241118160341564

栈中的数据要保存到堆上或者从堆中加载到栈上时怎么处理?

  • 堆中的数据加载到栈上, 由于栈上的空间大于或者等于堆上的空间, 所以直接处理但是需要注意下符号位。
    • boolean、char为无符号, 低位复制, 高位补0
    • byte、short为有符号, 低位复制, 高位非负则补0, 负则补1
  • 栈中的数据要保存到堆上, byte、char、short由于堆上存储空间较小, 需要将高位去掉。boolean比较特殊, 只取低位的最后一位保存

2. 对象在堆上的存储

Java对象内存布局

2.1 指针压缩

在64位的Java虚拟机中, Klass Pointer以及对象数据中的对象引用都需要占用8个字节, 为了减少这部分的内存使用量, 64位Java虚拟机使用指针压缩技术, 将堆中原本8个字节的指针压缩成4个字节, 此功能默认开启, 可以使用 -XX:-UseCompressedOops 关闭

指针压缩的思想是将寻址的单位放大, 比如原来按1字节去寻址, 现在可以按8字节寻址。如下图所示, 原来按1去寻址, 能拿到1字节开始的数据, 现在按1去寻址, 就可以拿到8个字节开始的数据。

image-20241118165552948

这样将编号当成地址, 就可以用更小的内存访问更多的数据。但是这样的做法有两个问题:

  1. 需要进行内存对齐, 指的是将对象的内存占用填充至8字节的倍数。存在空间浪费(对于Hotspot来说不存在, 即便不开启指针压缩, 也需要进行内存对齐)
  2. 寻址大小仅仅能支持 2 的 35 次方个字节(32GB, 如果超过32GB指针压缩会自动关闭)。不用压缩指针, 应该是2的64次方 = 16EB, 用了压缩指针就变成了8(字节) = 2的3次方 * 2的32次方 = 2的35次方

2.2 内存对齐

https://zhuanlan.zhihu.com/p/489358606

image-20241118172643208

3. 方法调用原理

方法调用的本质是通过字节码指令的执行, 能在栈上创建栈帧, 并执行调用方法中的字节码执行。以invoke开头的字节码指令就是执行方法调用

在JVM中, 一共有五个字节码指令可以执行方法调用:

  1. invokestatic: 调用静态方法
  2. invokespecial: 调用对象的private方法、构造方法, 以及使用 super 关键字调用父类实例的方法、构造方法, 以及所实现接口的默认方法
  3. invokevirtual: 调用对象的非private方法
  4. invokeinterface: 调用接口对象的方法
  5. invokedynamic: 用于调用动态方法, 主要应用于lambda表达式中, 机制极为复杂了解即可

Invoke指令执行时, 需要找到方法区中Instance Klass中保存的方法相关的字节码信息。但是方法区中有很多类, 每一个类又包含很多个方法, 怎么精确地定位到方法的位置呢?

3.1 静态绑定

编译期间, invoke指令会携带一个参数符号引用, 引用到常量池中的方法定义。方法定义中包含了类名 + 方法名 + 返回值 + 参数。

方法第一次调用时, 这些符号引用就会被替换成内存地址的直接引用, 这种方式称之为静态绑定

静态绑定适用于处理静态方法、私有方法、或者使用final修饰的方法, 因为这些方法不能被继承之后重写。

  • invokestatic
  • invokespecial
  • final修饰的invokevirtual

3.2 动态绑定

对于非static、非private、非final的方法, 有可能存在子类重写方法, 那么就需要通过动态绑定来完成方法地址绑定的工作。

动态绑定是基于方法表来完成的, invokevirtual使用了虚方法表(vtable), invokeinterface使用了接口方法表(itable), 整体思路类似。所以接下来使用invokevirtual和虚方法表来解释整个过程。

每个类中都有一个虚方法表, 本质上它是一个数组, 记录了方法的地址。子类方法表中包含父类方法表中的所有方法, 子类如果重写了父类方法, 则使用自己类中方法的地址进行替换。

image-20241119143355436

产生invokevirtual调用时, 先根据对象头中的类型指针找到方法区中InstanceKlass对象, 获得虚方法表。再根据虚方法表获得方法的地址, 最后调用方法。

4. 异常捕获原理

在Java中, 程序遇到异常时会向外抛出, 此时可以使用try-catch捕获异常的方式将异常捕获并继续让程序按程序员设计好的方式运行。异常捕获机制的实现, 需要借助于编译时生成的异常表

异常表在编译期生成, 存放的是代码中异常的处理信息, 包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置。

起始/结束PC: 此条异常捕获生效的字节码起始/结束位置。

跳转PC: 异常捕获之后, 跳转到的字节码位置。

image-20241119145432761

程序运行中触发异常时, Java虚拟机会从上至下遍历异常表中的所有条目。当触发异常的字节码的索引值在某个异常表条目的监控范围内, Java虚拟机会判断所抛出的异常和该条目想要捕获的异常是否匹配。

  1. 如果匹配, 跳转到”跳转PC”对应的字节码位置。
  2. 如果遍历完都不能匹配, 说明异常无法在当前方法执行时被捕获, 此方法栈帧直接弹出, 在上一层的栈帧中进行异常捕获的查询。

多个catch分支情况下, 异常表会从上往下遍历, 先捕获RuntimeException, 如果捕获不了, 再捕获Exception。

同理, multi-catch的写法也是一样的处理过程。

finally的处理方式就相对比较复杂一点了, 分为以下几个步骤:

  1. finally中的字节码指令会插入到 try 和 catch 代码块中, 保证在 try 和 catch 执行之后一定会执行 finally 中的代码。
  2. 如果抛出的异常范围超过了Exception, 比如Error或者Throwable, 此时也要执行finally, 所以异常表中增加了两个条目。覆盖了try和catch两段字节码指令的范围, any代表可以捕获所有种类的异常。在最后将异常继续向外抛出

image-20241119145817512

5. JIT即时编译器

5.1 执行流程

在Java中, JIT即时编译器是一项用来提升应用程序代码执行效率的技术。字节码指令被Java虚拟机解释执行, 如果有一些指令执行频率高, 称之为热点代码, 这些字节码指令则被JIT即时编译器编译成机器码同时进行一些优化, 最后保存在内存中, 将来执行时直接读取就可以运行在计算机硬件上了。

在Hotspot中, 有三款即时编译器, C1、C2和Graal。C1编译效率比C2快, 但是优化效果不如C2。所以C1适合优化一些执行时间较短的代码, C2适合优化服务端程序中
长期执行的代码。

JDK7之后, 采用了分层编译的方式, 在JVM中C1和C2会一同发挥作用, 分层编译将整个优化级别分成了5个等级。

image-20241119151234797

C1即时编译器和C2即时编译器都有独立的线程去进行处理, 内部会保存一个队列, 队列中存放需要编译的任务。一般即时编译器是针对方法级别来进行优化的, 当然也有对循环进行优化的设计。

image-20241119151441082

C1与C2的协作过程

  1. 先由C1执行过程中收集所有运行中的信息, 方法执行次数、循环执行次数、分支执行次数等等, 然后等待执行次数触发阈值(分层即时编译由JVM动态计算)之后, 进入C2即时编译器进行深层次的优化。
    image-20241119151635570

  2. 方法字节码执行数目过少, 先收集信息, JVM判断C1和C2优化性能差不多, 那之后转为不收集信息, 由C1直接进行优化。

    image-20241119151747146

  3. C1线程都在忙碌的情况下, 直接由C2进行优化。

    image-20241119151821060

  4. C2线程忙碌时, 先由2层C1编译收集一些基础信息, 多运行一会儿, 然后再交由3层C1处理, 由于3层C1处理效率不高, 所以尽量减少这一层停留时间(C2忙碌着, 一直收集也没有意义), 最后C2线程不忙碌了再交由C2进行处理。

    image-20241119151941882

虚拟机参数:不加参数(开启完全JIT即时编译)

  • -Xint: 关闭JIT, 只使用解释器
  • -XX:TieredStopAtLevel=1: 分层编译下只使用 1 层 C1 进行编译

5.2 JIT优化-方法内联

方法内联(Method Inline): 方法体中的字节码指令直接复制到调用方的字节码指令中, 节省了创建栈帧的开销。

并不是所有的方法都可以内联, 内联有一定的限制:

  1. 方法编译之后的字节码指令总大小 < 35字节, 可以直接内联。(通过 -XX:MaxInlineSize=值 控制)
  2. 方法编译之后的字节码指令总大小 < 325字节, 并且是一个热方法。(通过 -XX:FreqInlineSize=值 控制)
  3. 方法编译生成的机器码不能大于1000字节。(通过 -XX:InlineSmallCode=值 控制)
  4. 一个接口的实现必须小于3个, 如果大于三个就不会发生内联。

5.3 JIT优化-逃逸分析

逃逸分析指的是如果JIT发现在方法内创建的对象不会被外部引用, 那么就可以采用锁消除、标量替换等方式进行优化。

锁消除

锁消除指的是如果对象被判断不会逃逸出去, 那么该对象就不存在并发访问问题, 对象上的锁处理都不会执行, 从而提高性能。比如如下写法

1
synchronized (new Test()) {}

锁消除优化在真正的工作代码中并不常见, 一般加锁的对象都是支持多线程去访问的。

标量替换

逃逸分析真正对性能优化比较大的方式是标量替换, 在Java虚拟机中, 对象中的基本数据类型称为标量, 引用的其他对象称为聚合量。标量替换指的是如果方法中的对象不会逃逸, 那么其中的标量就可以直接在栈上分配。

根据JIT即时编器优化代码的特性, 在编写代码时注意以下几个事项, 可以让代码执行时拥有更好的性能:

  1. 尽量编写比较小的方法, 让方法内联可以生效。
  2. 高频使用的代码, 特别是第三方依赖库甚至是JDK中的, 如果内容过度复杂是无法内联的, 可以自行实现一个特定的优化版本。
  3. 注意下接口的实现数量, 尽量不要超过2个, 否则会影响内联的处理。
  4. 高频调用的方法中创建对象临时使用, 尽量不要让对象逃逸。

6. G1垃圾回收器原理

6.1 年轻代回收

卡表(Card Table)

每一个Region都拥有一个自己的卡表, 如果产生了跨代引用(老年代引用年轻代), 此时这个Region对应的卡表上就会将字节内容进行修改, JDK8源码中0代表被引用了称为脏卡。这样就可以标记出当前Region被老年代中的哪些部分引用了。那么要生成记忆集就比较简单了, 只需要遍历整个卡表, 找到所有脏卡。

记忆集(RememberedSet, RS, RSet): 是一种记录了从非收集区域对象引用收集区域对象的这些关系的数据结构。每一个Region都拥有一个自己的记忆集, 如果产生了跨代引用, 记忆集中会记录引用对象所在的卡表位置。标记阶段将记忆集中的对象加入GC ROOT集合中一起扫描, 就可以将被引用的对象标记为存活。

image-20241119170908674

将堆内存空间按照每512字节划分为多个卡页, 每个region都有一个卡表(字节数组), 其中使用1字节标识每个卡页中是否有对象引用了本region中的对象, 引用了就设置为0, 即脏卡

根据卡表生成记忆集:将脏卡的在卡表中的下标记录下来即可

写屏障

JVM使用写屏障(Write Barrier)技术, 在执行引用关系建立的代码时, 可以在代码前和代码后插入一段指令, 从而维护卡表。

G1使用写屏障技术, 在执行引用关系建立的代码执行后插入一段指令, 完成卡表的维护工作。

记忆集中不会记录新生代到新生代的引用, 同一个Region中的引用也不会记录。

记忆集生成流程

  1. 通过写屏障获得引用变更的信息。
  2. 将引用关系记录到卡表中, 并记录到一个脏卡队列中。
  3. JVM中会由 Refinement 线程定期从脏卡队列中获取数据, 生成记忆集。不直接写入记忆集的原因是避免过多线程并发访问记忆集。

image-20241119173243346

年轻代回收

年轻代回收只扫描年轻代对象(Eden + Survivor), 所以从GC Root到年轻代的对象或者年轻代对象引用了其他年轻代的对象都很容易扫描出来。如果有老年代的对象引用了年轻代中的对象, 需要通过卡表来实现。

年轻代回收步骤如下, 整个过程是STW的

  1. Root扫描, 将所有的静态变量、局部变量扫描出来
  2. 处理脏卡队列中的没有处理完的信息, 更新记忆集的数据, 此阶段完成后, 记忆集中包含了所有老年代对当前Region的引用关系
  3. 标记存活对象。记忆集中的对象会加入到GC Root对象集合中, 在GC Root引用链上的对象也会被标记为存活对象
  4. 根据设定的最大停顿时间, 选择本次收集的区域, 称之为回收集合Collection Set
  5. 复制对象:将标记出来的对象复制到新的区中, 将年龄加1, 如果年龄到达15则晋升到老年代。老的区域内存直接清空。
  6. 处理软、弱、虚、终结器引用, 以及JNI中的弱引用。

6.2 混合回收

多次回收之后, 会出现很多Old老年代区, 此时总堆占有率达到阈值(默认45%)时会触发混合回收MixedGC。混合回收会由年轻代回收之后或者大对象分配之后触发, 混合回收会回收整个年轻代 + 部分老年代

老年代很多时候会有大量对象, 要标记出所有存活对象耗时较长, 所以整个标记过程要尽量能做到和用户线程并行执行。

混合回收的步骤:

  1. 初始标记, STW, 采用三色标记法标记从GCRoot可直达的对象
  2. 并发标记, 并发执行, 对存活对象进行标记
  3. 最终标记, STW, 处理SATB相关的对象标记
  4. 筛选回收, STW, 负责更新Region的统计数据, 对各个Region的回收价值和成本进行排序, 根据用户所期望的停顿时间来制定回收计划, 可以自由选择任意多个Region构成回收集, 然后把决定回收的那一部分Region的存活对象复制到空的Region中, 再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动, 是必须暂停用户线程, 由多条收集器线程并行完成的。

初始标记

初始标记会暂停所有用户线程, 只标记从GC Root可直达的对象, 所以停顿时间不会太长。采用三色标记法进行标记, 三色标记法在原有双色标记(黑也就是1代表存活, 白0代表可回收)增加了一种灰色, 采用队列的方式保存标记为灰色的对象。

  • 黑色: 当前对象在GC Root引用链上, 同时他引用的其他对象也都已经标记完成。
  • 灰色: 当前对象在GC Root引用链上, 他引用的其他对象还未标记完成。
  • 白色: 不在GC Root引用链上。

三色标记中的黑色和白色是使用位图(bitmap)来实现的, 比如每8个字节使用1个bit来标识标记的内容(因为内存中是按8字节对齐的), 黑色为1, 白色为0, 灰色不会体现在位图中, 会单独放入一个队列中。如果对象超过8个字节, 仅仅使用第一个bit位处理

image-20241120135933377

并发标记

接下来进入并发标记阶段, 继续进行未完成的标记任务。此阶段和用户线程并发执行。

image-20241120140138498

从灰色队列中获取尚未完成标记的对象B。标记B关联的A和C对象, 由于A对象并未引用其他对象, 可以直接标记成黑色, C对象有引用对象E, 所以先标记成灰色, 而B也完成了所有引用对象的标记, 也标记为黑色。

最后从队列获取C对象, 由于E并未引用其他对象, 标记为黑色, 再将C也标记为黑色。所以剩余对象F就是白色, 可回收。

image-20241120140431491

SATB

错标问题

三色标记存在一个比较严重的问题, 由于用户线程可能同时在修改对象的引用关系, 就会出现错标的情况, 比如: 这个案例中正常情况下, B和C都会被标记成黑色。但是在BC标记前, 用户线程执行了 B.c=null; 将B到c的引用去除了。同时执行了 A.c=c; 添加了A到C的引用。此时会出现错标的情况, C是白色可回收。

image-20241120140644873

为了解决该问题, G1使用了SATB(Snapshot At The Beginning, 初始快照)技术

  1. 标记开始时创建一个快照, 记录当前所有对象, 标记过程中新生成的对象直接标记为黑色
  2. 采用前置写屏障技术, 在引用赋值前比如 B.c = null 之前, 将之前引用的对象 c 放入 SATB 待处理队列中。SATB队列每个线程都有一个, 最终会汇总到一个大的SATB队列中。

例如, 假设在标记开始时对象 A 引用对象 B, 在标记过程中对象 A 不再引用对象 B, 但是根据 SATB 的快照, 对象 B 仍然被认为是存活的, 因为它在标记开始时是可达的。

最终标记

最终标记会暂停所有用户线程, 主要是为了处理SATB相关的对象标记。这一步中, 将所有线程的SATB队列中剩余的数据合并到总的SATB队列中, 然后逐一处理。

SATB队列中的对象, 默认按照存活处理, 同时要处理他们引用的对象。

SATB的缺点是在本轮清理时可能会将不存活的对象标记成存活对象, 产生一些所谓的浮动垃圾, 等到下一轮清理时才能回收。

转移

  1. 根据最终标记的结果, 可以计算出每一个区域的垃圾对象占用内存大小, 根据停顿时间, 选择转移效率最高(垃圾对象最多)的几个区域。
  2. 转移时先转移GC Root直接引用的对象, 然后再转移其他对象。
  3. 回收老的区域, 如果外部有其他区域对象引用了转移对象, 也需要重新设置引用关系。

7. ZGC原理

G1无法在转移时让用户线程和GC线程同时工作的原因

转移完之后,需要将A对对象的引用更改为新对象的引用。但是在更改前,执行A.c.count=2,此时更改的是转移前对象中的属性。更改引用之后,A引用了转移之后的对象,此时获取A.c.count发现属性值依然是1。这样就产生了问题,所以G1为了解决问题,在转移过程中需要进行用户线程的停止。ZGC和Shenandoah解决了这个问题,让转移过程也能够并发执行。

7.1 着色指针

在ZGC中,使用了读屏障Load Barrier技术,来实现转移后对象的获取。当获取一个对象引用时,会触发读后的屏障指令,如果对象指向的不是转移后的对象,用户线程会将引用指向转移后的对象。

image-20241120164608016

为了判断是否是转移前的对象以及获取转移后的对象地址,ZGC使用到了着色指针技术

访问对象引用时,使用的是对象的地址。在64位虚拟机中,是8个字节可以表示接近无限的内存空间。所以一般内存中对象,高几位都是0没有使用。着色指针就是利用了这多余的几位,存储了状态信息。

着色指针将原来的8字节保存地址的指针拆分成了三部分:

  1. 最低的44位,用于表示对象的地址,所以最多能表示16TB的内存空间
  2. 中间4位是颜色位,每一位只能存放0或者1,并且同一时间只有其中一位是1
    1. 终结位: 只能通过终结器访问
    2. 重映射位(Remap): 转移完之后,对象的引用关系已经完成变更。
    3. Marked0和Marked1: 标记可达对象
  3. 16位未使用

image-20241120165051187

正常应用程序使用8个字节去进行对象的访问,现在只使用了44位,不会产生问题吗?

应用程序使用的对象地址,只是虚拟内存,操作系统会将虚拟内存转换成物理内存。而ZGC通过操作系统更改了这层逻辑。所以不管颜色位变成多少,指针指向的都是同一个对象。

image-20241120165201715

不支持32位系统,不支持指针压缩

7.2 内存划分

在ZGC中,与G1垃圾回收器一样将堆内存划分成很多个区域,这些内存区域被称之为Zpage。

Zpage分成三类大中小,管控粒度比G1更细,这样更容易去控制停顿时间。

  • 小区域: 2M,只能保存256KB内的对象。
  • 中区域: 32M,保存256KB-4M的对象。
  • 大区域: 只保存一个大于4M的对象。

7.3 执行流程

image-20241120171237467

初始标记

标记GC Roots直接引用的对象为存活对象,数量不多,所以停顿时间非常短

将 M0 位设置为1

并发标记

遍历所有对象,标记可以到达的每一个对象是否存活,用户线程使用读屏障,如果发现对象没有完成标记也会帮忙进行标记

并发处理

选择需要转移的Zpage,并创建转移表,用于记录转移前对象和转移后对象地址。

转移开始

转移GC Root直接关联的对象,不转移的对象remapped位设置成1,避免重复进行判断。转移之后将两个对象的地址记入转移映射表。

并发转移

将剩余对象转移到新的ZPage中,转移之后将两个对象的地址记入转移映射表。

转移完之后,转移前的Zpage就可以清空了,转移表需要保留下来。

此时,如果用户线程访问4’对象引用的5对象, 会通过读屏障,将4对5的引用进行重置,修改为对5’的引用,同时将remap标记为1代表已经重新映射完成。

image-20241120170508134

并发转移阶段结束之后,这一轮的垃圾回收就结束了,但其实并没有完成所有指针的重映射工作,这个工作会放到下一阶段,与下一阶段的标记阶段一起完成(因为都需要遍历整个对象图)。

第二次垃圾回收的标记阶段

使用M1作为标志位,如果M0为1代表上一轮的重映射还没有完成,先完成重映射从转移表中找到老对象转移后的新对象,再进行标记。如果Remap为1,只需要进行标记。

交替使用M0, M1

第二次垃圾回收的并发处理阶段

将转移映射表删除,释放内存空间。

并发转移阶段-并发问题

如果用户线程在帮忙转移时,GC线程也发现这个对象需要复制,那么就会去尝试写入转移映射表,如果发现映射表中已经有相同的老对象,直接放弃。

7.4 分代ZGC

在JDK21之后,ZGC设计了年轻代和老年代,这样可以让大部分对象在年轻代回收,减少老年代的扫描次数,同样可以提升一定的性能。同时,年轻代和老年代的垃圾回收可以并行执行。

分代之后的着色指针将原来的8字节保存地址的指针拆分成了三部分:

  1. 46位用来表示对象地址,最多可以表示64TB的地址空间。
  2. 中间的12位为颜色位。
  3. 最低4位和最高2位未使用

整个分代之后的读写屏障、着色指针的移位使用都变的异常复杂,仅作了解即可。

image-20241120171517640

8. ShenandoahGC

ShenandoahGC和ZGC不同,ShenandoahGC很多是使用了G1源代码改造而成,所以在很多算法、数据结构的定义上,与G1十分相像,而ZGC是完全重新开发的一套内容。

  1. ShenandoahGC的区域定义与G1是一样的。
  2. 没有着色指针,通过修改对象头的设计来完成并发转移过程的实现
  3. ShenandoahGC有两个版本,1.0版本存在于JDK8和JDK11中,后续的JDK版本中均使用2.0版本。

8.1 1.0版本

1.0版本,在对象头的前8个字节,增加了一个前向指针。前向指针指向转移之后的对象,如果没有就指向自己。

如果转移阶段未完成,此时转移前的对象和转移后的对象都会存活。如果用户去访问数据,需要使用转移后的数据。ShenandoahGC使用了读前屏障,根据对象的前向指针来获取到转移后的对象并读取。

image-20241120171851621

写入数据时,也会使用写前屏障,判断Mark Word中的GC状态,如果GC状态为0证明没有处于GC过程中,直接写入,如果不为0则根据GC状态值确认当前处于垃圾回收的哪个阶段,让用户线程执行垃圾回收相关的任务。

8.2 2.0版本

1.0版本的缺点:

  1. 对象内存大大增加,每个对象都需要增加8个字节的前向指针,基本上会占用5%-10%的空间。
  2. 读屏障中加入了复杂的指令,影响使用效率。

2.0版本优化了前向指针的位置,仅转移阶段将其放入了Mark Word中。

image-20241120172145216

8.3 执行流程

image-20241120172348267

并发转移阶段-并发问题

如果用户线程在帮忙转移时,ShenandoahGC线程也发现这个对象需要复制,那么就会去尝试写入前向指针,使用了类似CAS的方式来实现,只有一个线程能成功修改,其他线程会放弃转移的操作。

Prev
2024-11-20 17:24:50 # Language # Java