Skip to content

JVM

JVM组成部分

JVM是什么

JVM是Java虚拟机,解决的核心问题是一次编写,到处运行。JVM通过在操作系统之上建立一个抽象的计算机,让字节码文件可以跨平台执行。

JVM组成结构

  • 类加载器:加载字节码文件到内存,进行加载、链接、初始化操作,生成对应的Class对象。
  • 运行时数据区:JVM的内存管理区域,包含共享区域(堆、方法区)、线程私有区域(虚拟机栈、本地方法栈、程序计数器)。
  • 执行引擎:将字节码翻译为操作系统可以执行的机器指令,包含解释器、JIT编译器两种方式。
  • 本地方法接口(JNI):一套标准接口,允许Java代码调用c/c++等语言实现的本地方法。
  • 本地方法库:用c/c++等编写并编译好的动态链接库,提供Java无法直接完成的系统级操作,通过JNI被JVM加载和调用。

类加载机制

一个类的生命周期:加载 -》 验证 -》准备 -》解析》初始化 -》使用 -》卸载

双亲委派模型是类加载机制的核心,当一个类加载器收到加载请求时,自己不先尝试加载,而是逐级向上委派给父加载器,直到最顶层的Bootstrap ClassLoader。只有父加载器无法加载该类时,子加载器才尝试加载。

  • 启动类加载器:Bootstrap ClassLoader。
  • 扩展类加载器:Extension ClassLoader。
  • 应用类加载器:Application ClassLoader。

为什么需要双亲委派模型?

  • 安全:保证Java核心类库由启动类加载器加载,避免被篡改。
  • 唯一:优先交给父加载器加载,避免重复加载。

如何打破双亲委派模型? 通过自定义类加载器,打破"父加载器无法直接加载子加载器可见类"。 实现方式:每个线程都有一个contextClassLoader,默认是应用类加载器。核心库的代码通过Thread.currentThread().getContextClassLoader()拿到线程上下文加载器,然后用它来加载子类,从而打破"父加载器无法直接加载子加载器可见类"。

类的生命周期:

  • 加载:根据全限定名找到字节码,在堆中生成Class对象。
  • 验证:检查字节码是否安全合规,防止恶意代码。
  • 准备:为静态变量分配内存并赋予默认值。普通静态变量设置为0、null或false,编译器静态变量(static final修饰的基本类型或String类型)设置为代码中指定值。
  • 解析:把符号引用转成能直接定位的内存引用,如类引用、方法引用等。
  • 初始化:执行类的构造器方法,为静态变量赋值、执行静态代码块。
  • 使用:程序通过Class对象创建实例或调用方法。
  • 卸载:该类的Class对象被回收,方法区数据清除。

运行时数据区

运行时数据区是JVM的内存管理区域,规定了程序在运行时的数据该放在哪里、由谁共享、何时创建销毁等。

JVM运行时数据区

线程安全

线程共享

JVM中最大的一块内存,几乎所有对象实例都在此分配。从GC视角,堆采用分代设计,将内存分为:

  • 新生代:包含Eden区和两个Survivor区,比例为:8:1:1。绝大多数对象诞生在Eden区,熬过垃圾回收的对象会晋升到Survivor区,再从Survivor区晋升到老年代(每熬过一次Minor GC,则对象的GC年龄+1,达到默认值15时,晋升到老年代)。
    • TLAB:线程本地分配缓冲区,全称:Thread Local Allocation Buffer。TLAB是JVM为加速多线程下对象分配而设计的核心优化,在Eden区为每个线程划分一块独享空间,让线程在自己的独享空间内分配内存,与其他线程互不干扰。
  • 老年代:存放长期存活对象,如缓存、数据库连接池等。

因为大部分对象都"朝生夕死",所以在不同生命周期的对象采用不同的垃圾回收算法。新生代大部分对象生命周期较短,适合标记复制算法。老年代对象生命周期相对较长,适合标记整理或标记清除算法。

对象分配流程:

  • 线程创建对象,先检查TLAB剩余空间是否足够,足够则直接在TLAB内指针碰撞,完成分配。
  • 若TLAB剩余空间不够:
    • 申请新TLAB分配:申请一块更大的TLAB,在新TLAB分配。
    • 在Eden区分配:对象大小适中,但新申请TLAB不划算,则在Eden区的公共区域通过同步操作分配。
    • 在老年区分配:对象较大,则跳过Eden区,直接在老年代分配,避免在新生代频繁复制。

方法区

线程安全

线程共享

存储类的元数据、运行时常量池、静态变量、JIT编译后的代码缓存等。

  • 类的元数据:存放类的全限定名、字段描述、方法描述等。
  • 运行时常量池:存放字面量和符号引用。(动态链接就靠它)
  • 静态变量:static修饰的变量。从JDK1.7起,字符串常量池和静态变量从原来的方法区移动到了堆中,但逻辑上还是方法区。
  • JIT编译后的代码缓存:热点方法被JIT编译称本地机器码后,缓存在方法区。

演变历史:

  • JDK1.7:在堆内实现为永久代,容易OOM。
  • JDK1.8:改为使用本地内存的元空间,可以直接使用本地系统内存,仅受物理内存限制。同时,原来在永久代的字符串常量池和静态变量转移到了堆中。

虚拟机栈

线程安全

线程私有

每个线程创建时,JVM会为其分配一个私有的虚拟机栈,内部由一个个栈帧组成。每个方法被执行时,都会创建一个栈帧入栈,方法结束后出栈。

栈帧的结构:

  • 局部变量表:存放方法参数和局部变量,以槽位Slot为最小单位。
  • 操作数栈:一个后进先出的栈,用于计算的临时工作区。例如计算 int a = 1+2; 先压入1、2,执行iadd指令相加,结果放回栈顶,再存入局部变量表。
  • 动态链接:指向方法区中的运行时常量池对该方法的符号引用,并替换为直接引用。(多态)
  • 方法返回地址:记录方法调用后的下一条指令地址,正常退出或异常退出都要恢复调用者现场。

栈常见错误:

  • StackOverFlowError:栈溢出,通常由于方法递归深度太大导致。
  • OutOfMemoryError:栈无法申请足够内存时。

程序计数器

线程安全

线程私有

程序计数器是当前线程所执行的字节码行号指示器,CPU时间片在各线程间切换,每个线程都有自己的"进度条"。

  • 当线程正在执行一个Java方法,程序计数器记录的是当前字节码指令的地址。
  • 当线程正在执行一个Native方法,程序计数器的值为空。

为什么需要程序计数器?

  • 流程控制:字节码指令执行时,解释器根据计数器取下一行指令。
  • 线程恢复:多线程抢占CPU时,任何线程被挂起后再恢复,是程序计数器来告诉线程"上一次执行到哪一行"的。
  • 异常处理:抛出异常时,JVM通过当前计数器位置查找异常表,确定该在哪一行catch。

本地方法栈

线程安全

线程私有

本地方法栈是为Native方法调用服务的栈,它让c、c++代码能在JVM内部安全运行。通过JNI,Java可以调用c、c++实现的函数,而本地方法栈就为这些Native方法提供栈环境。

执行引擎

执行引擎是JVM的心脏,它负责把字节码翻译成操作系统能执行的机器指令并运行。但为了既快又灵活,采用了"解释执行+编译执行"混合驱动的设计。

执行引擎组成部分:

  • 解释器:逐条将字节码翻译为本地机器指令并执行。类比:同声传译。
  • JIT编译器:将热点代码一次性编译成本地机器码,后续直接运行。类比:翻译后的文字稿。
  • 垃圾回收器:严格说垃圾回收器也属于执行引擎的一部分,负责自动内存回收。

为什么要用解释器+JIT编译器混合模式? 为了找到一种性能平衡,所以采用了混合模式:

  • 解释器:负责"预热"、"兜底"。程序刚启动时,快速响应。当JIT编译失败时,退回解释执行保证正确性。
  • JIT编译器:负责"加速"。收集到足够热点信息后,对真正的性能瓶颈进行深度优化。 这也是说Java是编译和解释共存的原因。

如何识别热点代码? HotSpot采用热点探测, 主要基于计数器:

  • 方法调用计数器:记录每个方法被调用的次数。
  • 回边计数器:记录循环体执行的次数。热点循环会触发OSR(栈上替换),将正在解释执行的循环替换为编译好的机器码。 需注意,为避免临时热点代码造成的资源浪费,计数器会随时间进行半衰减的"降温",只保留高频使用的热点代码。

JIT编译器采用分层编译,C1快速预热+收集数据 -》C2精准优化。

  • C1编译器:编译速度快,生成代码质量中等,适合需要快速处理的短任务。
  • C2编译器:编译耗时长,但会进行大量基于运行时信息的激进优化(如逃逸分析、方法内联)等,生成代码执行效率极高,适合长时间运行的服务器应用。

JVM垃圾回收

垃圾回收(Garbage Collection),简称GC,是JVM自动管理内存的机制。它会自动识别不再使用对象,并释放它们占用的空间。

GC解决的核心问题

  • 提高系统健壮性:把内存管理的负担从人交给机器,让程序不会因忘记释放内存而慢慢撑爆。
  • 支撑"一次编写,到处运行":内存管理交给JVM,是跨平台保障的一部分。

核心矛盾:GC时必须暂停用户线程,即STW。因为不能在一个正在变化的对象关系图上找垃圾。GC的进化史,都是在让STW时间更短、更可控。

寻找垃圾的两种方式

  • 引用计数法(Java不用):每个对象有一个计数器,有引用则+1,引用失效则减1,引用为0则表示可以回收。但存在循环引用问题时会导致永远无法回收。
  • 可达性分析(Java采用):从一组GC Roots根对象出发,沿着引用链搜索。不在引用链上的对象,就是不可达对象,表示可以回收。

GC Roots对象:

  • 虚拟机栈(栈帧中局部变量表)引用的对象
  • 方法区中静态变量引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI引用的对象
  • 所有被同步锁持有的对象

四种引用类型

不是所有引用都希望永远绑着对象不放,有时我们希望对象在内存紧张时可以被回收,有时可能只想跟踪对象是否被回收了。 这四种引用不是改变可达性分析的根,而是定义了引用强度的等级。

引用类型GC回收策略何时回收典型用途
强引用永不回收——最普通的对象引用
软引用内存不足时回收即将OOM内存敏感的高速缓存
弱引用GC时必定回收下一次GCWeakHashMap、ThreadLocal
虚引用任何时候,仅收通知对象被回收时通知管理堆外内存(DirectByteBuffer)

强引用: 常见的对象引用都是强引用,例如Object o = new Object

软引用:SoftReference 用SoftReference包裹对象,当系统内存充足时,GC不会回收。当内存不足即将OOM时,JVM会把软引用对象全部回收。

弱引用:WeakReference 用WeakReference包裹对象,一旦GC发生,不管内存是否充足,弱引用指向的对象必定被回收。它的生命周期只存活到下一次GC前。

  • 典型用途:ThreadLocal中的ThreadLocalMap的key是ThreadLocal对象,是弱引用。

虚引用:PhantomReference 用PhantomReference包裹对象,get永远返回null。它的唯一作用是:当指向的对象被GC回收时,虚引用会被放入关联的ReferenceQueue中,起到通知作用。

常见垃圾回收算法

  • 标记-清除:先标记,再统一清除。缺点是会产生内存碎片。
  • 标记-复制:把内存分两块,用一块,存活的对象复制到另一块。整体清空当前块。解决了内存碎片问题,但造成空间浪费。
  • 标记-整理:标记后,让存活对象往一端移动,直接清理边界外的内存。

FAQ

为什么说Java是解释和编译并存?

Java同时采用解释执行和编译执行(JIT即时编译),是一种性能优化平衡策略,保证启动速度和执行效率。

  • 解释器:逐行翻译字节码为机器码,并立即执行,不会保存结果。类比:同声传译。
  • 编译器:针对热点代码进行一次性的完整翻译,并保存结果,后续直接复用。类比:翻译后的文字稿。

Java是如何识别热点代码的?