Skip to content

深入理解 JVM

· 22 min

JVM是什么#

jvm是把Java代码转换为本地机器码的一个编译器

有哪些编译器#

编译质量上:JIT > AOT > 前端 ; 编译速度上:前端 > AOT > JIT

这三个编译器互相协作,使jvm 的编译质量和速度达到最优

JVM内存区域#

image-20251209140847070

程序计数器#

Java线程在执行字节码时候的 行号指示器,由于每个线程都有自己的指示器,所以是线程私有的,生命周期与线程的生命周期一致

虚拟机栈#

​ 每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个的栈帧 (Stack Frame),对应着一次次 Java 方法调用,是线程私有的,生命周期和线程一致。

​ 虚拟机栈的操作只有两个,入栈和出栈。当调用一个新的方法时,就构建一个栈帧压入到栈中,而一个方法执行结束,就会有一个栈帧出栈,遵循 先进后出或者后进先出 的原则。

本地方法栈#

​ 为本地方法执行时候提供的内存空间

#

​ Java堆是被所有线程共享的一块内存区域。“几乎”所有的对象实例都在这里分配内存。也是垃圾回收发生的地方(但不是唯一一个垃圾回收的地方,方法区也可以)

​ 堆的大小可以 通过 -Xmx-Xms 控制,如果堆中没有内存完成实例分配,并且堆无法再扩展时,就会抛出 OutOfMemoryError 异常。

方法区#

​ 用来存放被虚拟机加载的类的信息、常量、静态变量

​ 方法区的大小和堆空间一样,可以选择固定大小也可选择可扩展,方法区的大小决定了系统可以放多少个类,如果系统类太多,导致方法区溢出,虚拟机同样会抛出内存溢出OutOfMemoryError错误。JVM 关闭后方法区即被释放。

方法区的垃圾回收#

回收的内容:方法区中废弃的常量和不再使用的类型

垃圾回收#

判断垃圾的方式#

引用计数法#

只要对象被引用,对象头中的计数器就 + 1,那些计数器 为0 的就被当成垃圾。

致命缺陷:循环引用 三个对象互相引用技术器都是1 但是没有被其他对象引用

可达性分析法#

从GC Root 出发,所有可达的对象都是存活的对象,那些不可达的对象都被当作垃圾回收

GC Root包括以下内容:

  1. 虚拟机栈中引用的对象
  2. 本地方法栈中引用的对象
  3. 方法区中常量引用的对象
  4. 方法区中静态属性引用的对象
  5. 被同步锁持有的对象
  6. Java虚拟机内部的引用

并发情况下的可达性分析法#

三色标记法#

我们在判断对象的可达性的时候使用的是三色标记法。即:黑色、白色、灰色

并发情况下导致的对象消失的问题#

并发情况下一下两种情况同时满足会导致对象消失,情况如下:

  1. 并发标记的时候,新增了一条或者多条黑色对象到白色对象的引用
  2. 并发标记的时候,删除了所有灰色到白色对象的直接或间接引用

image-20251209113941893

情况1:

​ 扫描过程中, 还未扫描的 对象A 的灰色引用被移除了,同时 对象A 又被黑色引用了。由于黑色对象是已经被扫描的了,他引用的对象是不会再扫描,而 对象A 的灰色引用被移除后,往下扫描的时候是不会扫描到对象A,这就导致对象A被回收了

情况2

​ 黑色对象引用了 A,A引用了B。如果黑色对象切断了对A 的引用,但是引用了B,这就导致AB都会被回收

解决方法#

既然两者需要同时满足才会导致对象消失,那么我就破坏其中一个条件即可

垃圾回收算法#

标记清除#

image-20251209145026517

标记整理法#

image-20251209145108261

复制法#

image-20251209145154455

分代收集算法#

image-20251209145755471

垃圾收集器#

image-20251209145239167

serial收集器#

Serial [sɪriəl] 连续的,串行

Serial Old 收集器#

ParNew 收集器#

parallel scavenge 收集器#

parallel old 收集器#

CMS 垃圾收集器#

CMS 是以停顿时间短为目标的收集器,基于标记清楚算法实现的

整个回收过程分为四个步骤:

  1. 初始标记:标记与GC Root 直接关联的对象,会触发 STW
  2. 并发标记:进行可达性分析,标记所有可达的对象,耗时间,是并发执行的
  3. 重新标记:标记那些并发标记过程中,发生变化的对象(这是防止并发导致对象丢失),为了准确的标记到可达的对象,这个阶段是会触发 STW
  4. 并发清除:清除标记的垃圾

优点:低停顿,并发收集

缺点也很明显:

  1. 标记清除算法的缺点,会产生大量的浮动垃圾和空间碎片
  2. 并且为了追求低停顿,导致垃圾回收时间长,吞吐量变小了

Garbage First 收集器#

image-20251209153220597

回收过程#

  1. 初始标记:标记GC Root 直接关联的对象,并修改tams指针的位置(TAMS标志着Region的空白区域,新对象要在TAMS标记的区域中分配内存)方便后续的用户线程分配对象。短时间停顿
  2. 并发标记:可达性分析,这里采用的是快照的方式
  3. 最终标记:处理并发标记遗留的对象
  4. 筛选回收:负责更新region 的统计数据,对region的回收成本和价值进行排序,按照用户期望进行回收,可以选择多个region作为一个回收集,回收的时候把部分region存活的对象复制到空的region,然后清除旧的region。涉及到对象的移动,要暂停用户线程

如何解决Region跨时代引用问题#

G1在Region中维护了一个自己的记忆集,本质是一个哈希表,一个双向的卡表,记录了别人指向我的和我指向别人的。

对象#

对象的创建#

  1. 虚拟机遇到new指令后,先去常量池检查有没有该类的符号引用,并检查该类有没有被类加载器初始化,如果没有先执行类加载过程
  2. 通过类加载之后,给对象分配内存,如果内存是规整的就通过指针碰撞的方式来分配内存,如果不是连续的话,虚拟机需要维护一个空闲列表来记录每个内存的大小
  3. 分配内存完毕后,除了对象头外,所有的都初始化 0 值
  4. 对对象进行一系列的设置,把对象的元数据信息、哈希码、GC分代年龄存放在对象头
  5. 执行init方法,对象创建完毕

对象的内存布局#

对象在堆内存中的存储布局可以分为三个部分:对象头、实例数据、对其填充

4.3 对象的访问方式#

主流的访问方式主要有使用句柄和直接指针两种

类加载机制#

什么情况需要初始化类#

  1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始 化,则需要先触发其初始化阶段。
    1. 使用new关键字实例化对象的时候。 ·
    2. 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外) 的时候。
    3. ·调用一个类型的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需 要先触发其初始化。
  3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先 初始化这个主类。
  5. 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解 析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句 柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  6. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有 这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

类加载阶段#

image-20220404211512259

  1. 加载:
    1. 通过一个类的全限定名来获取定义此类的二进制字节流。
    2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
    3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入 口。
  2. 验证
    1. 文件格式验证:
      • 是否以魔数0xCAFEBABE开头、
      • 主次版本号的是否在虚拟机接受范围之内
    2. 元数据验证:
      • 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)、
      • 这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
      • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
    3. 字节码验证
    4. 符号引用验证:符号引用中的类、字段、方法的可访问性(private、protected、public)是否可被当 前类访问。
  3. 准备:准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初 始值的阶段,
  4. 解析:Java虚拟机将常量池内的符号引用替换为直接引用的过程
  5. 初始化:执行<clinit>()方法的过程。之前赋的零值的变量进行赋值,

类加载器#

双亲委派模型#

防止重复加载某一个类+ 避免核心类被篡改

自定义ClassLoader,重写loadClass方法(只要不依次往上交给父加载器进行加载,就算是打破双亲委派机制)

Tomcat 为实现隔离性,每个 webappClassLoader 加载自己目录下的 class 文件,而不会传递给父类加载器,打破了双亲委派机制

常见Jvm参数#

堆内存相关#

参数说明
-Xms<size>设置 JVM 初始堆内存大小(如 -Xms512m 表示初始 512MB)
-Xmx<size>设置 JVM 最大堆内存大小(如 -Xmx4g 表示最大 4GB)
-XX:NewRatio=<n>设置老年代与新生代的比例(如 -XX:NewRatio=2 表示老年代<新生代> = 2<1>
-XX:NewSize=<size> / -XX:MaxNewSize=<size>手动设置新生代初始/最大大小(通常不建议与 -Xmn 同时使用)
-Xmn<size>直接设置新生代大小(如 -Xmn1g

建议:生产环境中通常将 -Xms-Xmx 设为相同值,避免运行时动态扩容带来的性能抖动。


垃圾回收器(GC)相关#

参数说明
-XX:+UseSerialGC使用串行 GC(适合单核 CPU 或小内存应用)
-XX:+UseParallelGC使用并行 GC(吞吐量优先,默认在 JDK8 及以前)
-XX:+UseG1GC使用 G1 垃圾回收器(低延迟,推荐 JDK8u40+、JDK9+ 默认)
-XX:+UseZGC使用 ZGC(超低延迟,JDK11+ 实验性,JDK15+ 正式支持)
-XX:+UseShenandoahGC使用 Shenandoah GC(低停顿,需额外开启,OpenJDK 特有)

GC 日志与监控#

参数说明
-Xloggc:<file>指定 GC 日志输出文件(JDK8 及以前)
-XX:+PrintGC打印基本 GC 信息
-XX:+PrintGCDetails打印详细 GC 信息
-XX:+PrintGCTimeStamps在 GC 日志中添加时间戳
-Xlog:gc*:file=<file>:timeJDK9+ 的统一日志格式(替代旧参数)

元空间(Metaspace)相关(JDK8+)#

参数说明
-XX:MetaspaceSize=<size>触发 Metaspace GC 的初始阈值
-XX:MaxMetaspaceSize=<size>限制 Metaspace 最大大小(防止内存溢出)

注意:永久代(PermGen)在 JDK8 中已被 Metaspace 替代。


其他常用参数#

参数说明
-server启用“服务器”模式(默认在 64 位 JVM 中启用,优化吞吐量)
-XX:+HeapDumpOnOutOfMemoryError发生 OOM 时自动生成堆转储文件
-XX:HeapDumpPath=<path>指定堆转储文件路径
-XX:+UseCompressedOops启用压缩普通对象指针(节省内存,64 位 JVM 默认开启)
-D<name>=<value>设置系统属性(如 -Dfile.encoding=UTF-8

示例组合(生产环境常见配置)#

Terminal window
java -server \
-Xms4g -Xmx4g \
-Xmn1g \
-XX:+UseG1GC \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/logs/heapdump.hprof \
-Xloggc:/logs/gc.log \
-XX:+PrintGCDetails \
-XX:+PrintGCTimeStamps \
-jar myapp.jar