JVM 基础

前言

介绍 JVM。基于 JDK8、64 位 HotSpot,所有“接口”都用实现的“类”描述,比如方法区之于元空间和堆、记忆集之于卡表。

JVM 内存结构

  • JVM 虚拟机数据区:程序计数器、本地方法栈、虚拟机栈、堆(线程共享)。
  • 本地内存:元空间(线程共享)、直接内存(线程共享)。

未说明的是线程私有的。

程序计数器

程序计数器是线程私有的,用于指示下一条要执行的指令地址,比如分支、循环、跳转、异常处理、线程恢复等。生命周期随着线程创建而创建,随着线程结束而死亡。

Java 虚拟机栈

虚拟机栈和程序计数器一样是线程私有的,生命周期与线程相同。它描述的是 Java 方法执行的线程内存模型,每个方法执行都会创建一个栈帧(包括局部变量表、操作数栈、动态链接、方法返回地址等,它的大小在编译时确定),每个方法的执行也就被描述为虚拟机栈的栈帧压栈和出栈,其中出栈的两种方式是 return 和 抛出异常。

这个区域可能发生 StackOverFlowError (栈深度超过当前 Java 虚拟机栈的最大深度) 和 OutOfMemoryError (申请时超过了栈内存大小 ThreadStackSize)。

本地方法栈

本地方法栈和 Java 虚拟机栈相同,区别是本地方法栈是为 Native 方法服务的,而 Java 虚拟机栈是为 Java 方法服务的。

堆是线程共享的,在虚拟机启动时创建,它是虚拟机管理的最大一块内存(不一定是物理连续的),几乎所有的对象实例内存都在这里分配,还有一部分是通过逃逸技术分析将未逃逸(方法外不会使用)的对象实例或标量替换(对象分解)直接分配到栈帧或寄存器。

这个区域是垃圾回收的重点,目前的垃圾回收器基本都是分代回收,所以可以将堆细分为新生代和老年代(比例由 -XX:NewRatio=<ratio> 控制),再详细可以分为如下:

  • 新生代(Eden 和 Survivor 比例由 -XX:SurvivorRatio=<ratio> 控制):Eden、From Survivor(S0)、To Survivor(S1)
  • 老年代

堆的大小用 -Xms(或 -XX:InitialHeapSize=<size>)和 -Xmx(-XX:MaxHeapSize=<size>)设置初始化内存和最大内存。

默认对象是在新生代的 Eden 区分配的,也可以设置 -XX:PretenureSizeThreshold=<size> 来控制超过 size 直接在老年代分配,新生代 Survivor 区分为两部分是为了避免内存碎片化,结合后面 对象内存分配 中的实例理解。

元空间

存放类的元数据,就是 JVM 读取的 Class 信息。垃圾回收可能回收被卸载的类。

元空间大小由 -XX:MetaspaceSize=<size> 和 -XX:MaxMetaspaceSize=<size> 控制,默认元空间内存占用是没有上限的,无法再分配后会抛出 OutOfMemoryError。

直接内存

NIO 类的可能申请堆外内存,直接操作这块内存避免复制数据,因此可能会发生 OutOfMemoryError。

类加载

对象创建的第一步就是类加载检查,如果类没被加载到 JVM 中就要完成类加载过程,类加载过程分为加载、验证、准备、解析、初始化,其中验证、准备、解析统称为连接。

加载

加载阶段有三件事:

  • 通过类的全限定名获取类的二进制字节流(可以通过 jar 包、war 包、网络、动态代理生成、数据库、加密文件等方式加载)。
  • 将二进制字节流转换为元空间的运行时数据结构。
  • 在堆中创建 java.lang.Class 对象作为元空间这个类各种数据的访问入口。

加载阶段和连接阶段的部分动作是交叉进行的,加载阶段还没完成,连接阶段可能就已经开始了,但两个阶段的开始时间仍然有固定的先后顺序。

验证

验证是为了保证类的二进制流符合 JVM 的规范要求,保证不会威胁 JVM 本身。包括:

  • 文件格式验证:验证是否是 Class 文件(摩数 0xCAFEBABE 开头)等。
  • 元数据验证:对字节码描述信息进行分析,保证其符合 Java 语言规范的语义。
  • 字节码验证:最复杂的阶段。对类的方法体进行校验分析,保证验证的方法在运行时不会威胁到 JVM 安全(比如跳转指令不会跳转到方法体外)。
  • 符号引用验证:这个阶段发生在解析阶段,保证解析正常执行。比如可以引用的类、字段、方法是否可以被当前类访问。

准备

为类的静态变量分配内存并设置零值,如果被 final 修饰的话会直接设置指定的值。

数据类型 零值 数据类型 零值
byte 0 int 0
boolean false float 0.0f
short 0 double 0.0d
char '\u0000' (空格) long 0L
reference null

解析

将符号引用转为直接引用。符号引用只包含语义信息;直接引用就是可以定位到目标的位置,可以是直接指针、偏移量、句柄。

初始化

执行类的 <clinit> 方法,完成类的初始化。比如字段赋值和静态方法执行,因此这个 <clinit> 都可以没有。

类卸载

类卸载就是类的 Class 对象被回收,卸载类的要满足三个条件:

  1. 类的所有实例对象都已被回收。
  2. 类没有被其他任何地方引用,没有反射调用。
  3. 类的 ClassLoader 已被回收。

对象创建

对象创建包括:类加载检查、分配内存、初始化零值、设置对象头、对象初始化。

类加载检查

检查类是否被加载、解析和初始化,没有的话要执行类加载过程。

分配内存

类加载检查后开始为对象分配内存(这时对象需要的内存已经确定),分配内存的方式由空闲内存是否完整决定,这又由垃圾回收器回收后是否整理了空闲空间决定:

  • 完整(Serial、ParNew)时采用指针碰撞,也就是把指针向空闲空间移动这个对象大小的距离。
  • 不完整(CMS)时采用空闲列表

另外在内存分配遇到的并发问题有两种办法,第一种是 CAS + 失败重试;第二种是 TLAB(Thread Local Allocation Buffer),也就是为每个线程单独分配一块本地缓存,只有当本地缓存满了之后才会用第一种办法分配。

初始化零值

内存分配完后将内存空间都设为零值(除了对象头),这保证了对象实例字段不赋值也可以使用。如果分配内存是用 TLAB 的话这步工作在分配内存时就可以进行。

设置对象头

初始化零值后虚拟机要对对象进行一些设置,例如对象是哪个类的实例、GC 分代年龄、锁等信息。

对象初始化

现在对象对于 JVM 已经创建完了,接下来会执行 Java 字节码的 <init> 方法(相当于构造器)完成我们想要的初始化。

对象的内存布局

对象在堆内存可以划分为三部分:对象头、实例数据、对象填充。

  • 对象头:有两类信息,一部分(Mark Word)是存储对象自身运行时的数据,比如哈希码、GC 分代年龄、锁状态等等;另一部分(klass word)是类型指针,指向它的类元数据,以此确定是哪个类的实例。
  • 实例数据:对象真正的有效信息,就是各种类型的字段。
  • 对象填充:只起占位的作用,没有任何含义,因为 HotSpot 虚拟机要求对象起始地址必须是 8 直接的整数倍,所以对象的大小也要是 8 的整数倍。

下面是本机默认 JVM 参数 和 jol 0.16 版本 的 VM.current().details() 及 ClassLayout.parseInstance(new Demo()).toPrintable() 输出:

$ java -XX:+PrintCommandLineFlags -version
-XX:InitialHeapSize=132173184 -XX:MaxHeapSize=2114770944 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC
openjdk version "1.8.0_252"
OpenJDK Runtime Environment (build 1.8.0_252-b09)
OpenJDK 64-Bit Server VM (build 25.252-b09, mixed mode)

# Running 64-bit HotSpot VM.
# Using compressed oop with 0-bit shift.
# Using compressed klass with 3-bit shift.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]

com.xxx.Demo object internals:
OFF  SZ               TYPE DESCRIPTION               VALUE
  0   8                    (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4                    (object header: class)    0x2002ec5c
 12   4                int Demo.int4                 0
 16   8             double Demo.double8              0.0
 24   8               long Demo.long8                0
 32   4              float Demo.float4               0.0
 36   2              short Demo.short2               0
 38   2               char Demo.char2                 
 40   1               byte Demo.byte1                0
 41   1            boolean Demo.boolean1             false
 42   2                    (alignment/padding gap)   
 44   4   java.lang.Object Demo.object               null
Instance size: 48 bytes
Space losses: 2 bytes internal + 0 bytes external = 2 bytes total

第一段是默认的 JVM 参数,-XX:+UseCompressedClassPointers -XX:+UseCompressedOops 分别表示开启类型指针压缩和普通对象指针压缩,指针压缩是为了减少内存损耗,大部分时候都是内存都不到 32 G,因此 64 位浪费了一部分空间,因此压缩为 32 位,在实际寻址时再左移 3 位。

第二段是 VM 的详细信息。

  • 第一行是运行 64 位的 HotSpot VM。
  • 第二和第三行表示使用指针压缩的普通对象指针和类型指针需要左移多少位(也就是压缩)。
    • 压缩的前提是第四行的信息:对象使用 8 字节对齐。
    • 压缩指针的目的是为了减少内存损耗,因为 32 位实际支持 4G(2^32) 内存,但 64 位的内存地址实际用不到,因此取 32G(2^32 << 3),左移的位数是和堆大小和本机内存有关,前面第一段打印的 JVM 参数可以看到 堆大小小于 4 G,因此普通对象指针只需要 8 字节就可以寻址,和 32 位一样不需要压缩,符合第二行打印的左移 0 位。类型指针指向的是元空间中的类型数据,元空间又在内存当中,所以需要 2^32 << 3 位才可以寻址,压缩后就是 2^32 (8 字节), 符合看到使用压缩的类型指针需要左移 3 位才能寻址。
  • 第五行和第六行的 4, 1, 1, 2, 2, 4, 4, 8, 8 分别表示 boolean, byte, char, short, int, float, long, double 8 种基础类型在字段和数组所占的字节。

第三段是 Demo 对象的内部信息(Demo 类只有 8 个基本类型的字段)。第一部分对象头:Mark word 为 8 个字节;类型指针为 4 个字节,符合指针压缩的的结果。第二部分是实际数据:顺序是按照先基本类型由大到小再引用类型排列,其中 int4 字段跑到第一位的原因是对象头的边界并不是 8 的整数倍,JVM 会选择最简单的方式填充,比如 4 个 1 字节 和 两个 2 字节会选择两个 2 字节直接去填充。

第三段是对象填充,填充并不是一定在最后做的,如果引用类型不存在就会直接填充到 8 的倍数,否则填充到 4 的倍数,之后引用类型排好后再填充到 8 的倍数,其他情况同理。

对象的访问定位

Java 虚拟机规范规定了栈上 reference 来操作堆上的对象,具体的对象访问有两种实现,一种是句柄,一种是直接指针。

  • 句柄:这种方式会在堆中开辟一块区域作为句柄池,每个句柄包括到实例数据的指针和到对象类型数据的指针。优点是当发生垃圾回收对象被移动时只需要改变句柄中指向实例数据的指针即可,缺点是多了一次寻址。
  • 直接指针:就是直接指向堆上的对象地址,但需要对象存储到对象类型数据的指针。优点是可以直接访问到对象,缺点是对象移动需要改变所有 reference,不过对象访问当然是远远大于对象移动的,因此直接指针更合适。

对象死亡

判断对象死亡有两种算法。

  • 引用计数法:给对象添加一个引用计数器,引用一次就加一,引用失效就减一,当计数器为 0 时就表示对象不会再被使用。这个方法实现简单,但无法解决循环引用的问题(对象objA和objB都有字段instance,令 objA.instance=objB 且 objB.instance=objA)。
  • 可达性分析法:与 GC Roots 联系不上(不可达)时对象不会再被使用,这种方法可以解决上面的循环引用问题。可以固定作为 GC Roots 的对象包括:虚拟机栈栈帧局部变量表中引用的对象,本地方法栈 Native 方法引用的对象,元空间类元数据上静态属性引用的对象等等。

对象内存分配

规则如下:

  • 对象优先在 Eden 区分配,空间不够触发新生代垃圾回收(Minor GC),回收后再分配。
  • 大对象直接分配在老年代。这可以避免在 Eden、S0、S1 之前复制的开销,大对象规则由 -XX:PretenureSizeThreshold=<threshold> 参数指定。
  • 长期存活的对象将进入老年代。每次新生代垃圾回收(Minor GC)后,存活的对象年龄加 1,年龄超过某个值时将超过的对象移到老年代中。
  • 动态对象年龄判定。动态指 Survivor 中年龄 为 1 到 n 的所有对象所在内存大于 Survivor 区的一半(大于 S0 或 S1),取 -XX:MaxTenuringThreshold=<threshold> 的 threshold 和 n 的最小值作为晋升老年代的分界线,大于等于它们最小值的晋升老年代。
  • 空间分配担保。在新生代垃圾回收(Minor GC)时,JVM 必须要保证老年代最大连续可用空间是否大于新生代所有对象中空间或历次新生代晋升的平均大小,如果大于,则这次新生代垃圾回收(Minor GC)是安全的,否则进行整个堆的垃圾回收(Full GC)。

下面是一种分配的场景:

  • 分配前: Eden 空间不够下次分配,需要 Minor GC,S0 区存活对象在 Minor GC 后也要晋升老年代。
  • 分配过程:在 Eden 区分配,空间不够触发 Minor GC,空间分配担保成功,开始进行 Minor GC,遍历所有存活对象,发现 Survivor 区年龄为 10 及以下的对象内存占比大于 Survivor 区一半,将年龄为 10及以上的对象晋升到老年代(默认 PretenureSizeThreshold 为 15 大于 10,不同垃圾回收器可能不一样),并将 S0(From Survivor)剩余的对象和 Eden 区存活的对象复制到 S1 (To Survivor)并清理复制完的对象。
  • 分配后:Eden 分配了一个新对象,S0 区为原 S0 区未晋升的对象和 Eden 区 GC 后存活的对象,S1 区为空,并且 S0 区不存在内存碎片。

垃圾回收算法

标记清除算法

分为两个步骤,第一步标记所有需要回收的对象;第二步清除所有需要回收的对象,当然也可以改为标记所有存活的对象再清除所有未标记的对象。

这个算法存在两个问题。一是效率问题,当堆中有大量对象要回收,这时标记和清除的时间都随着对象回收数量增长而增长;二是内存碎片,清除后剩余的内存不一定是连续的,如果有大对象可能碎片化的内存无法分配又导致另一次 GC。

标记复制算法(新生代)

也被称为复制算法。该算法将内存一分为二,只用其中一块分配内存,这块内存分配完后(发生垃圾回收)标记所有存活的对象,并将存活的对象复制到空白的另一块内存,然后把之前已满的那块内存清除掉。

这个算法的优点是没有内存碎片的问题,因为复制对象时内存分配就是简单的将空闲内存指针向空闲区域移动对象大小即可,缺点是浪费了整整一半的内存,如果不是一般的话又需要其他内存作为担保,另外存活对象很多的话复制对象也是可见的时间。

新生代需要考虑内存分配的问题,并且对象很少有存活的,移动对象的时间很短,符合复制算法的特点,新生代将内存区域分为 Eden 和个 Survivor,比例为 8:1,因此只有 10% 的内存浪费掉(存活的对象超过 10% 则由老年代作为内存担保)。

标记整理算法(老年代)

标记整理算法与标记清除算法的区别是标记清除后会整理存活对象向一边移动,保证另一边是空闲的。老年代使用这种算法的原因是这是堆分配内存兜底的空间,且对象存活很多,需要很长的复制时间,不能用复制算法;标记清除算法会导致的内存碎片,虽然可以靠更复杂的内存分配策略解决(比如将对象分片),但老年代对象的访问效率是关键,因此采用相对折中的标记整理算法。

分代回收

新生代和老年代对回收的要求不同,因此采用不一样的回收算法。

  • 新生代:复制算法。
  • 老年代:标记清除算法、标记整理算法。

分代回收会有一个跨代引用的问题,这个问题会导致新生代 GC 时为了寻找老年代引用的对象是否关联 GC Roots 而扫描整个老年代,因此垃圾回收器需要记录跨代引用的引用,而全部记录会导致空间占用高,因此选择 2 的 n 次幂作为每页大小将整个堆划分为 m 个页,这个管理每页是否存在跨代引用的结构就叫卡表,当每个页有一个或多个对象存在跨代引用就将卡表中这个页标记为存在跨代引用,发生垃圾回收时只要查询卡表就可以发现存在跨代引用的区域,并把这块区域加入 GC Roots 中一起扫描。

卡表页状态的更新是由写屏障解决的,写屏障相当于 AOP 的环绕通知,就是在给引用字段赋值后更新卡表(在后置通知执行),虽然多了一次操作,但也比遍历整个堆好的多。另外更新可能遇到高并发的伪共享问题(CPU 按照缓存行读取变量,一个变量又不一定暂满整个一行),解决办法是更新前先查询是否需要更新,不过这又带来了一次开销,默认是不查询的,开启使用 -XX:+UseCondCardMark。

三色标记和写屏障

三色标记是垃圾回收算法中标记的实现,它将 GC 时的对象标记为三种:

  • 白色:本对象尚未被垃圾回收器访问过,当标记结束后还是白色的就代表对象不可达,可以回收。
  • 黑色:本对象被垃圾回收器访问过,并且这个对象的所有引用都已经扫描过。
  • 灰色:本对象已被垃圾回收器访问过,但至少还有一个引用没有被访问过,当所有引用被访问过本对象变为黑色。

找到对象的过程如下:

  1. 把所有对象放到白色集合中。
  2. 把所有 GC Roots 直接引用的对象放到灰色集合中。
  3. 从灰色集合中取出一个对象
  4. 将这个对象引用的所有对象放到灰色集合中。
  5. 将这个对象从灰色集合移到黑色集合中。
  6. 重复 3、4、5 步骤直到灰色集合中没有对象,剩下白色集合中的对象就是不可达的对象,也就是可以回收的。

堆越大标记带来的 GC 停顿时间也越长,采用并发标记可以减少停顿时间,并发带来了多标和漏标两个问题:

  • 多标:开始遍历灰色对象,但这个灰色对象被放弃引用了,这个问题只会产生浮动垃圾的问题,不会带来安全问题。
  • 漏标:灰色对象放弃对白色对象的引用 且 黑色对象捡起对这条链上白色对象的引用,这明显不对,需要解决。

多漏标的解释中说明了其出现的条件,因此只要破坏了其中一个条件就可以解决漏标问题。

  1. 原始快照(Snapshot At The Beginning,SATB):在灰色对象放弃白色对象的引用时,记录这个引用,并发标记结束后重新遍历这个引用。这破坏灰色对象放弃对白色对象的引用。
  2. 增量更新:在黑色对象引用白色对象时,记录这个引用,并发标记结束后重新遍历这个引用。这破坏黑色对象捡起对这条链上白色对象的引用。

上面两种方式是通过前面卡表提到的写屏障(引用赋值时 AOP 环绕通知)实现的。原始快照是在前置通知中记录原始引用(CMS使用);增量更新是在后置通知中记录新引用(G1 使用)。

最后

剩下垃圾回收器和 JDK 的 JVM 工具再分两个写。

参考

深入理解Java虚拟机(第3版) (豆瓣) (douban.com)

JVM符号引用转换直接引用的过程? - RednaxelaFX的回答 - 知乎

JVM中新生代为什么要有两个Survivor(form,to)? - 干总院的回答 - 知乎

当Java处在偏向锁、重量级锁状态时,hashcode值存储在哪? - RednaxelaFX的回答 - 知乎

三色标记法与读写屏障 - 简书 (jianshu.com)

Java Platform, Standard Edition HotSpot Virtual Machine Garbage Collection Tuning Guide, Release 8 (oracle.com)

posted @ 2021-09-22 16:46  hligy  阅读(75)  评论(0编辑  收藏  举报