前言

《深入理解 Java 虚拟机》是周志明老师的又一力作,系统深入地讲解了 Java 虚拟机的内部原理。本文是我阅读本书的学习笔记,涵盖内存管理、垃圾回收、类加载、并发等核心主题。

我所践行的知识整理方法是”将思考具象化”。做技术不仅要去看、去读、去想、去用,更要去说、去写。将自己”认为掌握了的”知识叙述出来,在此过程之中,会挖掘出很多潜藏在”已知”背后的”未知”。

如无特殊说明,本文默认使用 HotSpot 虚拟机(目前最常见的 Java 虚拟机)。

结论先行

理解 JVM 的核心在于理解内存管理和垃圾回收机制。 掌握 JVM 内存模型、GC 算法、类加载机制和锁优化策略,是 Java 开发者进阶的必经之路。这些知识不仅能帮助你写出高性能的代码,更能在生产环境中快速定位和解决内存溢出、性能瓶颈等问题。

一、内存区域

image-20230902125517344

Java 内存分为图中几个区域,各个区域的功能如下:

方法区(Method Area)

功能:存储常量值、静态变量、类信息(类的元数据)。

特点:所有线程共享。

堆(Heap)

功能:存储对象实例。

特点

  • 所有线程共享
  • GC 的主要工作区域
  • 通常是 JVM 中最大的一块内存区域

栈(Stack)

功能:用于记录方法的调用情况。

命名由来:因为一层一层的方法调用和返回很像数据结构的”栈”而得名。

栈帧:栈中的一个元素称为”栈帧”,栈帧中包含:

  • 局部变量表:包含方法中基本数据类型和引用
  • 操作数栈
  • 动态链接
  • 方法返回地址

注意:如果一个方法内用很多局部变量,这样一个方法对应的栈帧也会很大。

HotSpot 优化:HotSpot 将虚拟机栈和本地方法栈合二为一了。

特点:线程私有,每个线程都有自己的栈。

程序计数器(Program Counter Register)

功能:指向下一条要执行的指令在内存中的位置。

作用:通过改变它的值可以实现:

  • 顺序执行
  • 分支跳转
  • 循环
  • 异常处理

特点:线程私有。

直接内存(Direct Memory)

定义:在堆外分配的内存。

应用场景:在一些 I/O 场景下可以避免数据在堆内存和本地(native)内存之间的拷贝开销。

管理特点:不受 GC 的管理,所以需要手动释放。

内存区域总结

内存区域 线程共享 主要存储内容 是否 GC
方法区 类信息、常量、静态变量 是(较少)
对象实例 是(主要)
方法调用、局部变量
程序计数器 指令地址
直接内存 - NIO Buffer

二、垃圾回收

如何判断对象的存活

引用计数法(未被采用)

原理

给对象添加一个引用计数器:

  • 每当有一个地方引用它时计数器加 1
  • 引用释放时计数减 1
  • 当计数器为 0 时可以回收

优点:实现简单,判断高效。

缺点:无法解决对象相互循环引用的问题。如果有两个对象互相引用,即使它们都已经不可达了,但是它们会因为计数器不为 0 而一直存在。

结论:在主流的 Java 虚拟机中没有使用该方法。

可达性算法(主流方案)

原理

可达性算法从一系列被称为 “GC Roots” 的对象出发,通过引用关系构建一个图:

  • 在图里的就是还存活的对象
  • 其他的对象就是应该被回收的对象

image-20230902134443507

即使 Object 5、6、7 之间仍然存在互相引用,但是它们实际上已经不可能被使用了,所以应该被回收。

GC Roots 包括

  1. 栈帧的局部变量表中引用的对象
  2. 静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中 JNI(Native 方法)引用的对象
  5. JVM 内部的引用(如类加载器、异常对象等)

四种引用类型

Java 提供了四种引用类型,以满足不同场景下的内存管理需求:

1. 强引用(Strong Reference)

定义:最常见的引用,如 Object obj = new Object()

特点:只要强引用存在,GC 永远不会回收被引用的对象,就算发生 OOM 也不会回收。

2. 软引用(SoftReference)

定义:有用但非必须的对象。

回收时机:如果即将 OOM,GC 会先回收软引用指向的对象,如果内存还不够才会 OOM。

适用场景:适合做缓存。

使用方式

1
SoftReference<Object> softReference = new SoftReference<>(new Object());

3. 弱引用(WeakReference)

定义:比软引用更弱一些。

回收时机:无论内存是否足够,GC 时都会被回收。

适用场景:ThreadLocalMap 的 Key 就是弱引用。

4. 虚引用(PhantomReference)

特点

  • 无法像软引用和弱引用一样获取对象
  • 不会对对象的生存时间有任何影响

用途:常结合 ReferenceQueue 使用,用于监控某个对象的回收事件。

ReferenceQueue 的作用

在软引用、弱引用的构造方法里,可以选择传入一个 ReferenceQueue 对象(虚引用则必须传入)。这个队列的作用是:

  • 在对象被回收后,一个对应的 Reference 对象会被添加进这个队列中
  • 你可以通过 poll()(不阻塞)或 remove()(阻塞)来获取这个被回收的对象对应的 Reference
  • 你可以做一些清理工作,比如释放一些相关资源
引用类型 回收时机 典型应用场景
强引用 永不回收 日常对象引用
软引用 内存不足时回收 缓存
弱引用 GC 时回收 ThreadLocalMap Key
虚引用 任意时刻 对象回收监控

垃圾回收算法

  • 标记-清除算法: 缺点是会产生大量内存碎片

    image

  • 复制算法: 将内存分为相同大小的两块, 一块满了就把存活的对象赋值到另一块, 这样能保证内存的规整, 缺点是内存利用率只有 50%

    image-20230902183426772

  • 标记-整理: 将存活的对象向同一方向移动, 可以保证内存的规整, 而且不需要额外的内存空间. 但是这样要更新所有对象的引用地址, 这必须暂停所有用户程序的执行.

    image-20230902183632150

在 gc 时, 把堆内存分为几个区域:

image-20230902184015516

新生代是指新创建的对象, 这些对象中 98% 都活不过第一轮 gc.

老年代是指熬过多轮 gc 的对象, 它们大概率还会继续存活.

针对不同区域, 采用不同的回收算法:

由于新生代中只有少部分对象能在 gc 中存活, 所以复制算法的效率就显得很高, 而且我们没有必要将内存划分为相等的两块, 事实上 Eden 和 两个 survivor 的大小比例是 8 : 1 : 1. 平时在分配内存时使用 Eden 和一个 survivor, gc 的时候就把存活的对象移动到另一个 survivor, 然后清理刚才的两块内存. (少数情况下, 新 survivor 可能装不下存活的对象, 这时候就把老年代当做担保内存)

由于老年代的大部分对象都会继续存活, 复制代价过高, 标记清除算法导致的内存碎片又会影响内存分配, 所以老年代采用标记整理算法.

对象晋升为老年代的情况

  1. 年龄计数:每个对象在年轻代中创建时,都会被初始化一个年龄计数器(初始为 1 岁)。每经历一次垃圾回收(通常是Minor GC),对象的年龄会增加。当对象的年龄达到一定阈值时(由-XX:MaxTenuringThreshold 参数决定, 默认是15),它会被晋升到老年代。
  2. 动态年龄: 如果在Survivor空间中相同年龄所有对象大小的总和大于 Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。
  3. 空间限制:使用复制算法时, 另一个survivor的空间不足. 把无法容纳的对象直接送入老年代.
  4. 大对象:非常大的对象通常会被直接分配到老年代,以避免在年轻代中产生大量的复制操作。这个阈值是参数-XX:PretenureSizeThreshold决定的

类加载的时机

类只会加载一次

  1. new 对象时
  2. 使用非 final 的静态字段时
  3. 调用静态方法时
  4. 使用反射时
  5. 初始化子类, 发现父类没有被初始化过, 会先去初始化父类
  6. 程序入口主类(包含 main 方法)
  7. 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSp ecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  8. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

类中的 static 代码块会在类加载时被执行, 且只会执行一次.

类加载机制

虚拟机接收的输入是字节码(.class 文件)而不是 java 代码, 这意味着只要你能用 class 的格式去定义类, 都能被 jvm 执行. 这也为其他 jvm 语言(如 kotlin)的出现奠定了基础.

类的加载是在程序运行期间发生的, 由于 class 文件的来源可以是多种多样的(网络/程序动态生成), 所以加载出来的类具有非常大的灵活性. 动态代理, mybatis 的 mapper 等高级技术都依赖此特性.

双亲委派机制

这个”双”很容易让人产生误解, 实际上叫做”父加载器优先机制”可能更加贴切.

在加载一个类的时候, 类加载器会优先让自己的父加载器去加载. 如果没有父加载器或者父加载器无法加载, 才会由自己来加载. 这实现了类加载的优先级. 比如我自己写了一个 java.lang.String 类, 但是 jvm 只会加载 java 自带的 String 类而不是我的假 String 类.

image-20230903140656410

泛型

Java 5 才出现泛型, 为了保证向后兼容, 即以前没有泛型的代码仍然能在新版本的 java 中运行, java 采取了一种简单粗暴的方式去实现泛型: 泛型仅体现在编码过程中, 编译后和运行中是没有”泛型”的概念的.

例如下面这段代码在编译后就丢失了泛型信息(又称为”泛型擦除”), 利用反编译工具得到的结果得到的第二段代码中多了几个强制类型转换.

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
Map<String, String> map = new HashMap<String, String>(); map.put("hello", "你好");
map.put("how are you?", "吃了没?");
System.out.println(map.get("hello"));
System.out.println(map.get("how are you?"));
}
public static void main(String[] args) {
Map map = new HashMap();
map.put("hello", "你好");
map.put("how are you?", "吃了没?");
System.out.println((String) map.get("hello"));
System.out.println((String) map.get("how are you?"));
}

编译和解释

Java 在执行过程中其实同时发生着编译和解释.

解释器不需要额外的准备工作, 但是执行速度慢. 编译器需要把代码即时编译(jit)成 native 代码, 执行速度快.

jvm 会在解释的过程中检测执行频率高的热点代码, 然后将这一段代码编译为 native 代码以便后续使用编译执行.

java 内存模型

java 线程工作内存和主内存的关系可以用计组的 cache 和主内存的关系来理解. 事实上线程工作内存提出的目的也是为了利用 cache 来提高读写效率.

image-20230903194713722

image-20230903194720352

java 线程的工作内存内储存着线程使用到的变量的主内存副本(引用对象也是如此, 只不过拷贝过去的只有引用本身, 对象本身还在主内存中), 线程在读写变量时总是操作线程的工作内存, 如果改变了变量值, 在把变量刷回主内存之前, 其他线程是观察不到变化的.

volatile

  • 不保证原子性: 对 volatile 修饰的变量的操作不是原子性的, 例如自增操作, 我们不能保证读取->加一->写回这三件事是一气呵成的. 如果有多个线程同时对一个 volatile 变量进行自增操作, 结果还是会小于预期值.

  • 可见性: 被 volatile 关键字修饰的变量在被线程读取时, 会强制去主内存读取, 在被线程写时, 会强制在写入后刷回主内存. 保证了一个变量在不同线程之间的可见性.

  • 禁止指令重排: 根据计组流水线的知识, cpu 在执行指令时并不一定会按照顺序执行, 而是会在保证最终效果一致的前提下进行指令重排. 在单线程场景下可以这样做, 但是在多线程场景下可能会出现问题. 例如双重检查的单例模式中, 如果不给实例对象加 volatile, 可能会出现下图的情况. 而加了 volatile 就能保证 new 对象的 3 步是按照顺序执行的.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public class Singleton {
    private volatile static Singleton instance;

    public static Singleton getInstance() {
    if (instance == null) {
    synchronized (Singleton.class) {
    if (instance == null) {
    instance = new Singleton();
    }
    }
    }
    return instance;
    }
    }

    在这里插入图片描述

Java 线程

java 线程是内核线程, 这意味着线程调度需要在用户态和内核态切换, 所以线程数量是有限的.

而 go 协程使用用户线程, 用户线程的调度在用户态完成, 内核无法感知, 实现复杂度更高, 开销更低.

由于不同系统的线程优先级设计并不同, 为 Java 线程设定优先级不一定能实现预期的效果, 不建议依赖这个功能.

Java 线程的 6 种状态

  • 新建: 还未开始执行
  • 运行: 包括运行中和等待抢占时间片两种状态
  • 无限期等待: 例如调用了 wait() 方法
  • 有限期等待: 例如调用了Thread.sleep()方法
  • 阻塞: synchronized
  • 结束: 已经结束执行

image-20230904093745301

锁优化

旨在降低线程同步的开销

锁升级

java 一开始只有重量级锁, 这种锁需要依赖操作系统的同步机制, 开销较大.

于是新版本的 java 会先尝试使用开销更小的锁. 实在不行再用重量级锁.

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

  • 偏向锁: 优化依据是”一把锁经常被同一个线程反复获取”, 可以在锁对象的对象头 markword 中记录偏向的线程 id, 在该线程下一次再来获取锁时, 无需任何同步操作, 直接执行.

    一旦出现另一个线程来尝试获取这把锁, 就会立即撤销偏向锁, 升级为轻量级锁

  • 轻量级锁: 优化依据是”大部分情况下, 都不会发生锁的竞争”, 线程在进入同步代码块之前会在栈帧中建立一份lock record, 用于存储锁对象 mark word 的拷贝, 然后尝试用 CAS 将对象头 mark word 的指针更新为指向 lock record 的指针.

    如果更新成功说明没有竞争, 可以直接执行.

    如果更新失败, 说明锁已经被另一个线程抢到了, 此时升级为重量级锁.

编译优化

锁消除: 如果一段同步代码使用的锁对象不可能发生多线程竞争, 那么后端编译后, 执行这段代码时就不会出现同步操作.

锁粗化: 如果一段代码反复地获取同一把锁(例如在循环中反复获取), 那么编译器会扩大同步代码块的范围, 以减少加锁解锁的次数.

自旋锁

优化依据是”大部分情况下, 锁会很快地被释放”

当获取不到锁时, 线程可以不立即阻塞, 而是选择空转(自旋)一段时间等待锁的释放. 如果自旋成功了就可以直接开始执行而避免了阻塞-唤醒的开销, 如果达到了一定自旋次数还没有获取到, 就要阻塞了.

这个自旋次数是可以是适应性调整的, 如果这个锁对象刚刚被某个线程自旋成功, 那么针对这个锁对象的空转次数可以适当提高. 如果这个锁对象很少自旋成功, 那么自旋次数就会降低, 甚至不自旋.

总结

核心知识点总结

主题 核心要点 实践建议
内存区域 堆、栈、方法区、程序计数器 理解各区域职责,避免栈溢出和堆溢出
GC 算法 标记-清除、复制、标记-整理 新生代用复制,老年代用标记-整理
引用类型 强、软、弱、虚四种引用 缓存用软引用,ThreadLocal 用弱引用
类加载 双亲委派机制 理解类加载顺序,避免类加载问题
JMM 线程工作内存与主内存 使用 volatile 保证可见性
锁优化 偏向锁、轻量级锁、重量级锁 减少锁竞争,避免死锁

学习建议

  1. 理论与实践结合:学习 JVM 知识的同时,使用 jmap、jstack、jstat 等工具实践
  2. 关注性能调优:掌握 GC 日志分析,理解不同 GC 收集器的特点
  3. 深入源码学习:阅读 JDK 源码,理解底层实现原理
  4. 持续学习:JVM 在不断演进,关注新版本的特性变化

推荐工具

工具 用途
jps 查看 Java 进程
jmap 查看堆内存使用情况
jstack 查看线程堆栈信息
jstat 查看 GC 统计信息
JVisualVM 可视化监控工具
Arthas 阿里开源的 Java 诊断工具

深入理解 JVM 是 Java 开发者的必修课,不仅能帮助我们写出高性能的代码,更能在生产环境中快速定位和解决问题。