JVM 笔记

JVM内存模型

以HotSpot JVM举例。

1.7

java-runtime-data-areas-jdk1.8

1.8

java-runtime-data-areas-jdk1.8

注意

HotSpot JVM内存结构主要分为:堆内存(线程共享)、方法区(线程共享)、栈+程序计数器(线程私有)。

概念上:

堆内存:存放对象实例和数组。

方法区:存储类元数据、运行时常量、字符串常量、静态变量。

**方法区是JVM规定的一个概念,JVM必须要存在这么一个区域,但是实现可以由不同的方式实现(元空间、永久代 或者其他名字)。**甚至不同的实现,方法区保存的数据也不同。

比如JDK 1.7实现的永久代物理内存被设计在Java Heap中,但是实际上不属于堆。以及字符串常量池不在永久代,而在Java Heap中。

比如JDK 1.8实现的元空间物理内存不放在堆中,而放在本地内存Native Memory。

比如Android ART虚拟机实现的方法区放在堆中。

static静态变量本身(其作为类的一部分)的存储位置,分两种情况:

  • 对于基本类型(int、boolean等)的静态变量,其值直接存储在元空间的类定义中。
  • 对于非基本类型的静态变量,引用(指针)存储在元空间,而被引用的对象实例存储在堆中。

堆(Heap)

被所有线程共享。对象实例、数组存储区域(GC主战场)。

大多数对象是朝生夕死,只有少数对象会存活很久,因此将堆分代可以大幅提高 GC 效率。

堆可以进一步细分为:

年轻代(Young Generation)

年轻代用于新生对象的分配与回收,它又细分为 3 个区;

Eden区:相对较小,当Eden区满时,会触发一次Minor GC(新生代垃圾回收,频率高)。

S0(Survivor 0)和S1(Survivor 1):两个 Survivor 区轮流作为“to/from”。from 区是上次GC的存活对象,to 区存本次 GC 后的Eden+Survivor活对象,每次 Minor GC 后,两块 Survivor 互换身份,确保始终有一块空白用于复制。(碎片化小,GC+复制算法不能在同一区域执行)

老年代(Old Generation)

经历过一次或多次Minor GC仍存活的对象会被移动到老年代。老年代的GC叫Major GC(也称为Full GC)发生的频率相对较低,执行时间通常比Minor GC长。老年代的空间通常比新生代大,以存储更多的长期存活对象。

大对象通常会直接分配到老年代,因为通常需要连续的内存空间,老年代比较大,防止分配失败。

如果在堆中没有内存完成实例分配,并且堆也无法扩展时会抛出 OutOfMemoryError 异常。

对象如何晋升到 Old 区?

晋升路径:

  1. 年龄超过阈值(默认 15)
  2. Survivor 放不下 → 直接晋升
  3. 动态年龄判断(Survivor 同龄对象占比超过 50%)

方法区(Method Area)

存储类元数据、运行时常量、字符串常量(JDK7及以后被移至堆)、静态变量(但被引用对象在堆)。方法区可以选择不实现垃圾收集。

虚拟机栈(JVM Stack)

每个线程都有自己独立的 Java 虚拟机栈,生命周期与线程相同。每个Java方法在执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。可能会抛出 StackOverflowError 和 OutOfMemoryError 异常。

本地方法栈(Native Method Stack)

每个线程都有自己独立的本地方法栈,生命周期与线程相同。与 Java 虚拟机栈作用类似。Native方法执行时也会创建栈帧,同样可能出现 StackOverflowError 和 OutOfMemoryError 两种错误。

程序计数器(PC Register)

可以看作是当前线程所执行的字节码的行号指示器,用于存储当前线程正在执行的 Java 方法的 JVM 指令地址。如果线程执行的是 Native 方法,计数器值为 null。是唯一一个在 Java 虚拟机规范中没有规定 OutOfMemoryError 情况的区域,生命周期与线程相同。

直接内存 Direct Memory

可以通过 NIO、Unsafe 类操作,可以显著提高 I/O 性能。直接内存是对Native Memory for JVM的再分配,使用受到本机总内存的限制,若分配不当,可能导致 OutOfMemoryError 异常。

Stack 和 Heap 的区别?

存放内容不同

  • 栈:方法调用的栈帧、局部变量(基础类型)、操作数栈
  • 堆:所有对象实例、数组

管理方式不同

  • 栈:由线程自动分配和销毁(线程私有)
  • 堆:由 JVM 的垃圾回收器(GC)管理(线程共享)

生命周期不同

  • 栈:随线程创建,线程结束时销毁
  • 堆:JVM 运行过程中一直存在

访问速度不同

  • 栈:速度快(编译期确定结构)
  • 堆:速度慢(需要 GC、对象查找)

线程共享性不同

  • 栈:线程私有
  • 堆:线程共享

注意

如果局部变量是基本类型(如int, double等)则在线程栈的局部变量表上;方法返回基本类型,是值传递,不会分配堆内存,值直接写入返回值寄存器(或返回值槽)

如果局部变量是非基础类型,**栈上只存引用;对象始终在堆上。**因为 Java 的对象生命周期不由方法结束来决定,可能方法结束后对象还在使用(比如返回值、闭包、线程间共享),因此对象必须放在 Heap(共享、可 GC)。

当程序中通过对象或类直接调用某个方法时,JVM执行了哪些操作?

预备知识

Java文件被编译成Class文件后,变量和方法的调用都作为符号引用保存在Class文件中的常量池中。

// 源代码
public class Demo {
    public int add(int a, int b) {
        return a + b;
    }
    
    public static void main(String[] args) {
        Demo demo = new Demo();
        int result = demo.add(1, 2);
    }
}

// main方法的字节码(简化版)
0: new           #2      // #2 = class com/example/Demo
3: dup
4: invokespecial #3      // #3 = Method "<init>":()V
7: astore_1
8: aload_1
9: iconst_1
10: iconst_2
11: invokevirtual #4     // #4 = Method add:(II)I ⬅️ 方法调用点

编译期只是产生符号引用(Symbolic Reference),编译器不会决定最终调用哪个类、哪个类的方法,真正运行时先写入MetaSpace常量池,执行某条字节码命令时才会解析Symbolic Reference确定类/方法。

所有方法的调用在Class文件里面都只是符号引用,非虚方法的调用在类加载时会被直接确定(一次性且最终的),虚方法的调用在字节码执行(运行期)期间才能确定,这是Java强大的动态扩展能力的基础。

非虚方法:

static 方法 不能覆写

private 方法 不能子类可见

final 方法 明确禁止覆盖

构造方法 只能属于本类

super 调用 语义明确定向父类

invoke的解析

invokevirtual #4 指向常量池的符号引用(声明类 + 方法名 + 描述符)。

// 字节码中的方法调用引用
invokevirtual #4  // 常量池索引4

// 常量池项结构
ConstantPoolEntry {
    tag: CONSTANT_Methodref (10)
    class_index: 指向类名
    name_and_type_index: 指向方法名和描述符
}

JVM 会解析该符号引用,

1.通过CONSTANT_Methodref 确定是方法的符号引用

2.解析 class_index → 如果类没被加载,就加载类。

// 类加载时构建方法表
class ClassLoader {
  void prepare_methods(Klass* klass) {
    for (Method* method : klass->methods()) {
      if (method->is_static() || 
          method->is_private() || 
          method->is_constructor()) {
        // 非虚方法,不放入vtable
        method->set_vtable_index(-1);
      } else if (method->is_final()) {
        // final方法:放入vtable,但不会被子类覆盖
        method->set_vtable_index(calculate_index());
      }
    }
    
    // 构建vtable(只包含虚方法)
    build_virtual_method_table(klass);
  }
}

3.解析 name_and_type_index → 获取方法名和描述符

4.检查类的方法表(MethodTable / methods 数组),找到对应的方法解析,得到ResolvedMethod。

struct ResolvedMethod {
    void* _entry_point;      // 方法入口地址
    int   _vtable_index;     // vtable索引(虚方法)
    bool  _is_virtual;       // 是否是虚方法
};

准备执行

如果是invokestatic/invokespeacil,那么接下来可以直接使用刚刚获取的ResolvedMethod,不走虚分派。

如果是 invokevirtual/invokeinterface,那么以刚刚的ResolvedMethod,作为起点方法。

1.获得对象的引用

2.检查对象的实际类型

3.通过对象头找到实际类型的Klass指针

4.访问实际类型的vtable

5.根据“起点方法”的slot index(只有相同的slot index才能保证覆盖方法),得到实际类型对应方法在其vtable的偏移量。

6.获取实际要执行的方法入口地址

这就是 动态绑定。

建立栈帧

方法调用过程非常简单,直接跳转到目标方法入口地址。

每一次方法调用都会创建一个新的栈帧,包括:

  • 局部变量表(包括 this)
  • 操作数栈
  • 常量池引用
  • 返回地址
  • 方法返回值槽

栈帧入栈后,JVM 就开始按字节码解释执行。

执行

执行方式可能有两种:

解释执行(Interpreter)

逐条解释字节码。

JIT 编译成本地机器码

热点方法会被 C1/C2 编译器编译成本地代码,执行速度极快。

结束、销毁栈帧

栈帧弹出、返回值写到上层栈:

  • 基本类型 → 写到返回值槽或寄存器
  • 引用类型 → 返回对象引用

栈帧销毁,程序回到调用方。

总结

方法调用时,JVM 会前往MetaSpace的常量池,确认类加载,然后解析符号引用为方法的直接引用 → 准备执行,确定实际调用的方法版本(静态或动态分派) → 为方法创建栈帧 → 执行方法的字节码或 JIT 机器码 → 返回结果并销毁栈帧。

String的奇妙机制

String 是 Java 的字符串类型(类),而“字符串”是它的实例。

1. 字面量方式

String s1 = "abc";
String s2 = "abc";

查找字符串常量池(String Intern Pool)(在堆中),是否有"abc",

无:"abc" 会被放入字符串常量池

有:s2 指向常量池中的这个对象

特点:

  • 常量池内的字符串具有 全局共享、唯一性
  • 重复出现 "abc" 时不会创建新的对象

2. new 创建方式

String s1 = new String("abc");
String s2 = new String("abc");

发生两件事:

  1. "abc" → 在常量池中创建/查找
  2. new String("abc") → 在堆上创建一个新的 String 对象,这个String对象指向"abc"

因此:

  • 常量池有一个对象
  • 堆中有两个的对象

s1 和 s2 是两个引用变量,分别指向两个不同的堆对象,地址不同。但是分别指向的对象的内容相同。

s1 == s2  // false
s1.equals(s2) → true

引用类型

引用类型主要分为强软弱虚四种:

  • 强引用:指的就是代码中普遍存在的赋值方式,比如A a = new A()这种。只要有强引用指向对象时,GC 永远不会回收它,哪怕内存不足也会抛 OutOfMemoryError 也不回收。

  • 软引用:可以用SoftReference来实现,指的是那些有用但是不是必须要的对象。**系统在发生内存溢出前会对这类引用的对象进行回收。**适合用作缓存。

    SoftReference<byte[]> ref = new SoftReference<>(new byte[1024 * 1024]);
    
  • 弱引用:可以用WeakReference来实现,他的强度比软引用更低一点,**弱引用的对象下一次GC的时候一定会被回收,而不管内存是否足够。**被回收后,get()方法会返回null,记得做check。

    WeakReference<MyObject> weakRef = new WeakReference<>(new MyObject());
    
  • 虚引用:也被称作幻影引用,是最弱的引用关系,可以用PhantomReference来实现,其必须和ReferenceQueue一起使用,同样的当发生GC的时候,虚引用也会被回收。不能通过 get() 得到其引用的对象,也就是不能使用其引用的对象。主要用途是跟踪对象被 GC 回收的时机。主要用于管理堆外内存、大对象的回收时机。

    PhantomReference<MyObject> phantom =
            new PhantomReference<>(new MyObject(), referenceQueue);
    

为了让开发者能够控制对象的生命周期强弱,减少缓存导致的内存泄漏(SoftReference),避免弱引用(WeakReference)关联的 key 泄漏(ThreadLocal、WeakHashMap),在对象被释放时做额外清理动作(PhantomReference 用于堆外内存清理)。

内存泄露、内存溢出的原因/例子

内存泄露

程序不再需要某些对象,但仍然保持对它们的强引用,导致 GC 无法回收,内存被长期占用。

在 JVM 中,只要对象还有“可达引用链”,GC 就不会回收它。因此,泄漏往往来自:

  • 数据结构忘记清理
  • 长生命周期的对象持有短生命周期对象的引用
  • 注册、回调未注销;I/O资源未关闭。
  • 静态变量持有大对象
  • 线程池使用ThreadLocal,且未手动remove

内存溢出

当 JVM 试图分配对象但找不到足够的内存空间时抛出。

Java Heap Space OOM

当出现Java.lang.OutOfMemoryError:Java heap space异常时,就是堆内存溢出了。准备分配内存给新对象,内存不够,在多次GC之后,还是无法找到一块足够大的内存容纳当前对象,就会抛出Java Heap Space OOM。

  • 数据量过大
  • 无限创建对象
  • 集合未限制大小
  • 缓存不当
  • 内存泄漏引起堆满

StackOverFlowError

方法递归太深:可能是参数配置错误、边界条件没有考虑、没有退出条件。

就会导致不断地进行压栈。类似这种情况,JVM 实际会抛出 StackOverFlowError,此时如果 JVM 试图去扩展栈空间的的时候失败,则会抛出 StackOverFlowError。

MetaSpace OOM

元空间的溢出,系统会抛出Java.lang.OutOfMemoryError: Metaspace。出现这个异常的问题的原因是系统的代码非常多、引用的第三方包非常多、通过动态代码生成类加载等方法,导致元空间的内存占用很大,在拓展元空间时又失败了,则会抛出MetaSpace OOM。

Direct Buffer Memory

在使用ByteBuffer中的allocateDirect()的时候,或者使用JavaNIO(像netty)的框架超出当前内存限制,会抛出Java.lang.OutOfMemoryError: Direct buffer memory异常。

逃逸分析

HotSpot C2 编译器会对每个 new 进行逃逸分析,判断对象作用域,决定对象是否进入堆,或者分解对象为标量在栈/寄存器上分配内存。(在编译期就决定好了)

什么是逃逸?

对象在方法内是否“逃出当前作用域”。

逃逸类型

完全不逃逸(NoEscape)

对象只在方法内部使用

int f() {
    Point p = new Point(1, 2);
    //JVM 会把 p 拆成两个独立的 int(标量替换)
    //对象的所有字段都能单独分析
	//对象不会被需要“真实存在”
    //那么分配在寄存器上
    return p.x + p.y;
}

void test() {
    Foo f = new Foo();
    // this 传入可能用到完整对象结构(非标量)
    // 栈上分配
    f.bar(this); 
}

优化结果:

不在堆上创建对象,直接分配在栈/寄存器上。


方法逃逸(ArgEscape)

对象作为参数传递出去:

void foo() {
    Bar b = new Bar();
    list.add(b); // 逃逸到方法外
}

b 被外部存储,逃逸。需要分配到堆上。


全局逃逸(GlobalEscape)

对象变成类字段、静态字段或被返回:

static Foo f;
f = new Foo(); // 全局逃逸

一定要在堆上分配。

逃逸优化策略

HotSpot C2 编译器可进行三种优化:


标量替换(Scalar Replacement)

如果对象内部字段都能单独处理,则:

  • 对象完全消失,不会分配内存(堆或栈均不会使用)
  • “new Point()” 会被优化掉
  • 字段如 x、y 会变成独立的局部变量或寄存器

例如:

Point p = new Point(1, 2);
int r = p.x + p.y;

最终不会产生任何对象,也不会调用构造方法。这节省最多的 GC 压力。


栈上分配(Stack Allocation)

如果对象不能标量化,但不会逃逸:

  • 对象分配在 栈帧
  • 方法结束自动回收
  • 不需要 GC

真实 JVM 中:
HotSpot 使用 逃逸分析 + 线程栈/寄存器优化,但“纯栈上分配”不直接暴露给开发者,更多通过标量替换完成。


普通堆分配(TLAB / Eden)

对象逃逸,则必须进入堆,进入下面讲的堆分配策略。

创建对象过程

(假定逃逸分析后,对象仍需要分配在堆上)

解析类符号引用 → 类加载检查 → 分配内存 → 零值初始化 → 设置对象头 → 执行构造方法

Foo f = new Foo();

// #3 是常量池中 Foo 的 Class 符号引用
new #3
// dup 把刚创建但未初始化的Reference,再复制一份到操作数栈顶部。
//因为接下来 invokespecial 需要Reference作为参数初始化接收对象
//而 new 本身也需要返回Reference,一份要给构造函数做参数,一份留给用户代码。
dup
// 调用 Foo类 <init> 构造方法。
// #4 是 Foo类构造函数 的符号引用

// JVM 解析方法符号引用
// 1.进行类加载检查
// 2.接下来虚拟机将为新生对象分配内存,对象所需的内存大小在类加载完成后便可确定
// 3.零值初始化
// 4.设置对象头
// 在上面工作都完成之后,从JVM的视角来看,一个新的对象已经产生了
// 但从Java程序的视角来看,对象创建才刚开始——构造函数还没有执行,所有的字段都还为零。
// 定位到真正的构造方法,执行构造方法,初始化字段。
invokespecial #4
//将构造完成的Reference存入局部变量表 slot 1。
astore_1

类加载检查

若 Foo 尚未加载,立即进行类加载过程。

分配对象内存(堆 Heap)

否则,根据类计算出一个对象需要的内存大小,不同的内存大小影响着JVM的分配策略:

  • fast-path:直接分配在TLAB上 (线程本地分配缓冲区,Thread-Local Allocation Buffer)
  • slow-path:可能用到锁,在 Eden 全局区上分配(超过TLAB最大容量,只能分配在堆的共享区域)

分配的大小 = 对象头 + 实例字段总大小(含对齐),也就是说在堆分配对象需要的内存,需要类加载完成后进行。

指针碰撞、空闲列表、TLAB

指针碰撞和空闲列表是JVM分配内存的两种方式。

指针碰撞

JVM维护一个指向当前未使用内存起始位置的指针。当需要为新对象分配内存时,JVM只需将指针向前移动与对象大小相等的距离,并将这段内存分配给对象。这种分配方式就像是一个“碰撞”过程,因此得名指针碰撞。只适用于Java堆内存连续的情况。

空闲列表

空闲列表适用于Java堆内存不连续的情况。在这种情况下,已使用和未使用的内存块相互交错,无法简单地通过移动指针来分配内存。因此,JVM需要维护一个空闲列表,记录哪些内存块是可用的。空闲列表通常是一个链表结构,每个节点代表一个可用的内存块。

TLAB

假定只使用移动指针:

线程 A 检查某一内存区域可用,准备移动指针。但在它移动之前,线程 B 也检查到这一内存区域可用,并抢先移动了指针。这时线程 A 再移动指针,就会覆盖掉线程 B 分配的内存区域。

TLAB 的出现就是为了解决上述并发分配的性能瓶颈(只用锁效率太低)。它的核心思想非常直观:给每个线程分配一小块专属的内存区域(缓冲区),线程优先在自己的缓冲区里分配对象。

当然,如果 TLAB 剩余空间不足以容纳要分配的对象,或者 TLAB 完全用完,线程就需要申请一个新的 TLAB。申请新 TLAB 的过程需要同步,因为它涉及到从共享的 Eden 区获取一块新的内存。但相比于每次分配小对象都进行同步,申请 TLAB 的频率要低得多(只有当 TLAB 用完时才需要)。这大大减少了锁竞争的次数。

如果要分配的对象太大,超过了 TLAB 的容量(甚至可能超过了单个 TLAB 的最大允许大小),那么这个对象就不会在 TLAB 中分配。JVM 则会尝试直接在 Eden 区的共享部分(如果还有空间)进行分配。这个过程可能需要加锁。

类加载过程

加载 → 链接(验证、准备、解析) → 初始化

JVM的类加载过程是懒加载的,只有使用到的时候才会加载。

加载

JVM读取到对应的符号引用,查类加载器的内部哈希表是否存在对应 Klass 元数据。 否则,执行 classLoader 的 loadClass,通过类的全限定名(包名 + 类名),获取到该类的.class文件的二进制字节流,将二进制字节流所代表的静态存储结构,转化为方法区MetaSpace运行时的数据结构Klass,在堆中生成一个代表该类的Java.lang.Class对象,作为方法区Klass的访问入口(镜像对象)。

类加载器的内部哈希表(如 SystemDictionary):

  • key = fully-qualified name(类的完全限定名)
  • value = Klass 对象

因此,如果是同一完全限定下的类,被不同类加载器加载,可以说这两个类是不同的。Java判断一个类相同必须是:相同的完全限定名+相同的类加载器。

调用ClassLoader类的loadClass方法加载一个类,不算对类的主动使用,不会导致类的初始化。

连接

验证、准备、解析 3 个阶段统称为连接。

验证

确保class文件中的字节流包含的信息,符合当前虚拟机的要求,保证这个被加载的class类的正确性,不会危害到虚拟机的安全。验证阶段大致会完成以下四个阶段的检验动作:文件格式校验、元数据验证、字节码验证、符号引用验证。

准备

为类中的静态字段分配内存,并设置默认的初始值,比如int类型初始值是0。

被final修饰的static字段不会设置,因为final在编译的时候就分配了。

解析

解析 = 把运行时常量池中符号引用解析为指向“真实类、字段、方法”的直接引用。

符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用的时候可以无歧义地定位到目标即可。

直接引用可以是直接指向目标的指针、相对偏移量、一个能间接定位到目标的句柄(由虚拟机决定)。如果有了直接引用, 那引用的目标必定已经存在在内存中了。

解析阶段 不会一股脑解析全常量池。

那解析阶段到底做了哪些“必须要做”的解析?

HotSpot 主要做下面几类:

解析类层级结构相关的符号引用

立即解析

例如:

  • 父类(super class)
  • 接口(interfaces)
  • 注解类
  • 需要验证的方法签名、字段类型引用

解析阶段会确保:

父类能成功加载
父类已通过验证
接口能成功加载
接口也完成验证

这些都属于结构性解析,不延迟。

解析“非虚方法”的引用

能静态绑定的都立即解析

包括:

  • private 方法
  • static 方法(invokestatic)
  • 构造方法()
  • final 方法
  • super 调用方法(invokespecial)

这些在类加载时即可确定,不依赖运行时对象的多态,所以 HotSpot 直接解析。

例如 Test 类中调用:

invokestatic #42
invokespecial #15

解析阶段就会解析。

解析方法句柄

invokedynamic 的 bootstrap method 必须解析,
因为 bootstrap 必须在链接时执行。

一些 MethodHandle 也需要提前检查合法性。

对某些符号引用进行“合法性验证”

例如:

  • 字段签名是否存在对应类型
  • 方法的参数类型是否存在
  • 方法名是否与对应类匹配
  • 子类是否正确实现了父接口的方法要求

这些属于 semantic check(语义检查),
是“解析检查”,但不是“解析成直接引用”。

也就是说:

解析阶段会验证符号引用的合法性,但不会生成 Method* 或 Field。*

准备 vtable/itable 的结构

HotSpot 在解析阶段会构建:

  • vtable 大小
  • itable slot
  • 方法布局、重写关系

但不绑定具体 Method*

但不会在解析阶段解析每个虚方法引用。虚方法引用必须 Lazy Resolution。

哪些解析被延迟(Lazy Resolution)?

下面这些全部不在解析阶段做,而是字节码第一次执行时做:

A)invokevirtual 的方法解析(虚方法)

涉及运行时类型 + 动态绑定,不可能在解析阶段做完。

B)invokeinterface 的解析

需要查找接口方法在 itable 中的槽位。

C)getfield / putfield / getstatic / putstatic

字段 offset 的解析全部延迟。

D)new 指令中类引用(按需加载)

即使常量池里有 class A 的符号引用,也不会解析它,直到执行 new A 时。

初始化

初始化是整个类加载过程的最后一个阶段,要注意的是这里的构造器方法并不是开发者写的,而是编译器自动生成的。

执行类的 方法(静态代码块 + 静态变量初始化)。

使用

方法调用:通过 Method* 或 vtable 入口执行

字段访问:通过 Field offset

实例创建:采用分配策略(TLAB → Eden → 老年代)

卸载

一个类要被JVM卸载,条件非常苛刻,需要同时满足以下三点:

  • 该类所有的实例都已经被回收
  • 加载该类的ClassLoader已经被回收:类与其加载器是双向绑定的共生关系。一个类由哪个类加载器加载,这个信息是存储在Class对象里的。要卸载一个类,必须先卸载加载它的类加载器。
  • 类对应的Java.lang.Class对象没有任何地方被引用:不能在任何地方通过反射(如静态字段、全局变量)、静态变量、JNI等途径引用到这个Class对象。一旦这个Class对象还存在强引用,GC就不会回收它,那么这个类也就不会被卸载。

类加载器类型

classloader_type
  • 启动类加载器(Bootstrap Class Loader):这是最顶层的类加载器,负责加载Java的核心库(如位于jre/lib/rt.jar中的类),它是用C++编写的,是JVM的一部分。启动类加载器无法被Java程序直接引用,以null指代。
  • 扩展类加载器(Extension Class Loader):它是Java语言实现的,继承自ClassLoader类,负责加载Java扩展目录(jre/lib/ext或由系统变量Java.ext.dirs指定的目录)下的jar包和类库。ExtensionClassLoader由BootstrapClassLoader加载,并且父加载器是BootstrapClassLoader。
  • 系统类加载器(System Class Loader)/ 应用程序类加载器(Application Class Loader):这也是Java语言实现的,负责加载用户类路径(ClassPath)上的指定类库,是默认使用的类加载器。ApplicationClassLoader的父加载器是ExtensionClassLoader。它可以通过ClassLoader.getSystemClassLoader方法获取到。
  • 自定义类加载器(Custom Class Loader):开发者可以根据需求定制类的加载方式,比如从网络加载class文件、数据库、甚至是加密的文件中加载类等。自定义类加载器可以用来扩展Java应用程序的灵活性和安全性,是Java动态性的一个重要体现。

Java 9 引入了模块系统,并且略微更改了上述的类加载器。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。

上述类加载器之间的父子关系,使用组合关系来实现,代码层上没有通过继承实现。

public abstract class ClassLoader {
    private final ClassLoader parent;

    protected ClassLoader(ClassLoader parent) {
        this.parent = parent;
    }
}

类加载器的父子关系完全是 Java 层 ClassLoader.parent 字段构建出来的树形结构,其作用在于通过 loadClass 的递归调用来实现双亲委派。

双亲委派模型

类加载器之间的层级关系形成了双亲委派模型,其核心思想是当一个类加载器收到类加载的请求时,首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层次的类加载器都是如此,因此所有的加载请求最终都会传送到顶层的启动类加载器中。

只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

ClassLoader 类有两个关键的方法:

  • findClass(String name):根据类的二进制名称来查找类,默认实现是空方法。
  • loadClass(String name, boolean resolve):加载指定二进制名称的类 。name 为类的二进制名称,resolve 如果为 true,在加载时调用 resolveClass(Class<?> c) 方法解析该类。

如果需要自定义类加载器,同时沿用双亲委派机制,只需重写 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。

但是,如果想破坏双亲委派模型,则需要重写 loadClass() 方法。

//ClassLoader#loadClass
protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        //首先,检查该类是否已经加载过
        Class c = findLoadedClass(name);
        if (c == null) {
            //如果 c 为 null,则说明该类没有被加载过
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    //当父类的加载器不为空,则通过父类的loadClass来加载该类
                    c = parent.loadClass(name, false);
                } else {
                    //当父类的加载器为空,则调用启动类加载器来加载该类
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                //非空父类的类加载器无法找到相应的类,则抛出异常
            }

            if (c == null) {
                //当父类加载器无法加载时,则调用findClass方法来加载该类
                //用户可通过覆写该方法,来自定义类加载器
                long t1 = System.nanoTime();
                c = findClass(name);

                //用于统计类加载器相关的信息
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
   			sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
            sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            //对类进行link操作
            resolveClass(c);
        }
        return c;
    }
}

双亲委派模型的作用

  • 保证类的唯一性、防止核心类被篡改:确保了所有加载请求都会传递到BootstrapClassLoader,避免了不同类加载器重复加载相同类的情况,保证了Java核心类库的统一性,也保证了类的唯一性,同时还保证了不会加载非信任类路径外的类,防止了用户自定义类覆盖核心类库的可能。
  • 安全隔离:不同层次的类加载器服务于不同的类加载需求,这种层次化的划分有助于实现沙箱安全机制,保证了各个层级类加载器的职责清晰,也便于维护和扩展。
  • 提高类加载效率,减少冗余扫描:委派后父级通常已缓存大量常用类,不必每个ClassLoader重复查找扫描。

如何破坏双亲委派模型

三种方式:

  1. 重写 loadClass(),不委派给 parent
  2. child-first:先 findClass,再 parent.loadClass
  3. 修改 parent(指向 Bootstrap 或其它 loader)

目的是让当前加载器优先加载类。

Android中插件化热修复

Android 的 ClassLoader 结构

常见的加载器链:

BootClassLoader
    ↑
PathClassLoader (加载 APK)
    ↑
DexClassLoader / InMemoryDexClassLoader (可加载补丁dex/插件dex)

PathClassLoader(系统): 负责加载 APK 中 classes.dex

DexClassLoader(第三方): 可加载任意PATH下的 dex、jar、apk

Android 热修复的三大主流方式

Tinker(主流,微信团队)

原理:向 PathClassLoader 插入“补丁 dex 的 DexPathList Element”

Android 的类查找流程:

PathClassLoader.pathList.dexElements = [x.dex, xx.dex, xxx.dex] //是按顺序查找类

Tinker 就是在这个数组前面插入补丁 dex。

示例(伪代码):

//反射
Field pathListField = findField(classLoader, "pathList");
Object pathList = pathListField.get(classLoader);

Field dexElementsField = findField(pathList, "dexElements");
Object[] oldElements = (Object[]) dexElementsField.get(pathList);

Object[] patchElements = makeDexElements(patchDexFiles);

Object[] newArray = concat(patchElements, oldElements);

dexElementsField.set(pathList, newArray);

效果:

  • 补丁 dex 先被搜索
  • 替换掉原有 class
  • 不改逻辑,不改 loadClass,只改搜索顺序

这属于 class 优先级覆盖,而不是字节码替换。

优点:

  • 最稳定
  • 不破坏 ClassLoader 结构
  • 支持任意类修复

缺点:

  • 不支持资源修复(需额外方案)
  • 不支持某些极端多 dex 情况

AndFix(即时替换方法实现)

直接修改方法指针,替换 ArtMethod。

Android ART 虚拟机中,每个方法包含一个结构体:

ArtMethod {
    entry_point_from_quick_compiled_code
}

AndFix 会:

  1. 从补丁中提取替代方法
  2. 将目标方法的 entrypoint 替换成补丁方法的入口地址

这是“方法级别”的替换,不是类加载器层面的。

优点:

  • 不需要重启
  • 修复速度快

缺点:

  • 只能修复方法
  • 不能新增字段
  • Android 版本变动影响大(兼容性很差)

已经比较过时。


Robust(美团)

原理:插桩(Instrumentation)+ 代理逻辑

构建阶段对代码插桩,把所有方法入口改为:

if (PatchProxy.isSupport(...)) {
    return PatchProxy.accessDispatch(...);
} else {
    // 原代码
}

当有补丁时,PatchProxy 会进入替代逻辑。

特点:

  • 不依赖 ClassLoader 或 Dex 替换
  • 属于 AOP 插桩 + 代理方法分发

优点:

  • 极稳定
  • 不依赖 ART 实现
  • 热修复兼容性最好

缺点:

  • 插桩增加 App 体积与开销
  • 语法受限制(某些方法不允许插桩)

垃圾回收(GC)机制

什么是垃圾回收

垃圾回收是 JVM 自动释放 不再被使用的对象 所占内存 的机制。

垃圾回收作用

  • 自动释放无用对象,避免内存泄漏,减少手动管理带来的错误从而提高效率
  • 避免系统内存耗尽,保证服务持续运行
  • 把碎片化内存整理得更紧凑,使得创建对象更快

GC如何触发

  • 内存不足时:当JVM检测到堆内存不足,无法为新的对象分配内存时,会自动触发垃圾回收。(堆被分成不同区域,不同区域的内存不足会触发不同类型的GC)
  • 手动请求:虽然垃圾回收是自动的,开发者可以通过调用 System.gc() 或 Runtime.getRuntime().gc() 建议 JVM 进行垃圾回收。不过这只是一个建议,并不能保证立即执行。
  • 对象数量或内存使用达到阈值:垃圾收集器内部实现了一些策略,以监控对象的创建和内存使用,达到某个阈值时触发垃圾回收。这些阈值在启动 Java 应用时,可以设置,比如:-Xmx(最大堆大小)、-Xms(初始堆大小)等。

判断垃圾算法类型

主要有两种。引用计数法和可达性分析算法。

引用计数

Python之类的使用。

  • 原理:为每个对象分配一个引用计数器,每当有一个地方引用它时,计数器加1;当引用失效时,计数器减1。当计数器为0时,表示对象不再被任何变量引用,可以被回收。
  • 缺点:不能解决循环引用的问题,即两个对象相互引用,但不再被其他任何对象引用,这时引用计数器不会为0,导致对象无法被回收。

可达性分析算法

这是JVM使用的算法,JVM不使用引用计数判断垃圾,因为无法处理循环引用问题。

从一组称为GC Roots(垃圾收集根)的对象出发,向下追溯它们引用的对象,以及这些对象引用的其他对象,以此类推。如果一个对象到GC Roots没有任何引用链相连(即从GC Roots到这个对象不可达),那么这个对象就被认为是不可达的,可以被回收。

GC Roots对象包括:

虚拟机栈(JVM Stack 栈帧中的局部变量表)中引用的对象

方法区中类静态属性引用的对象

本地方法栈中JNI(Java Native Interface)引用的对象

虚拟机内部的引用对象(活跃线程的引用)等。

GC的类型/GC的工作区域

根据其工作区域和触发条件的不同,可以将GC分为三种类型:Minor GC(也称为Young GC)、Major GC(有时也称为Old GC)、以及Full GC。

以下是这三种GC的区别和触发场景:

Minor GC (Young GC)

  • 作用范围:只针对年轻代进行回收,包括Eden区和两个Survivor区(S0和S1)。
  • 触发条件:当Eden区空间不足时,JVM会触发一次Minor GC,将Eden区和一个Survivor区中的存活对象移动到另一个Survivor区或老年代(Old Generation)。
  • 特点:通常发生得非常频繁,因为年轻代中对象的生命周期较短,回收效率高,暂停时间相对较短。

Major GC

  • 作用范围:只针对老年代进行回收。
  • 触发条件:
    • 当老年代空间不足时,触发Major GC。
    • 大量对象在 Survivor 区活得太久,会迅速提前晋升 Old → Old 区变满 → 触发 Major GC。
  • 特点:相比Minor GC,Major GC发生的频率较低,但每次回收可能需要更长的时间,因为老年代中的对象存活率较高。

Full GC

  • 作用范围:对整个堆内存、元空间进行回收。
  • 触发条件:
    • 直接调用System.gc()或Runtime.getRuntime().gc()方法时,虽然不能保证立即执行,但JVM会尝试执行Full GC。
    • 大对象无法放入老年代,则会触发Full GC。
    • 年轻代晋升老年代,但是空间不足,不足以容纳存活对象,触发Full GC。
    • 当永久代(Java 8之前的版本)或元空间(Java 8及以后的版本)空间不足时。
  • 特点:Full GC是最昂贵的操作,因为它需要停止所有的工作线程(Stop The World),遍历整个堆内存来查找和回收不再使用的对象,因此应尽量减少Full GC的触发。

GC算法类型

按 回收基本策略 分类。

  • 标记-清除算法(Mark Sweep):标记-清除算法分为“标记”和“清除”两个阶段,首先通过可达性分析,标记出所有需要回收的对象,然后统一回收所有被标记的对象。标记-清除算法实现简单,不需要移动对象,但有两个缺陷:清除阶段较慢、碎片化严重(空洞多),容易造成在申请大块内存的时候,因为没有足够的连续空间导致再次 GC。

  • 复制算法(Copying):为了解决碎片空间的问题,出现了“复制算法”。将堆分为两个区域:From、To
    GC 时:

    1. 扫描 From
    2. 将存活对象复制到 To
    3. 清空 From
    4. 两者角色交换

    复制算法解决了空间碎片的问题,但是也带来了新的问题,因为每次在申请内存时,都只能使用一半的内存空间,内存利用率严重不足。

  • 标记-整理算法(Mark Compact):如果存活对象比较多时,复制算法会执行较多的复制操作,效率就会下降。而老年代的对象在 GC 之后的存活率就比较高,因此需要新算法。标记-整理算法的“标记”过程与“标记-清除算法”的标记过程一致,但标记之后不会直接清理。而是将所有存活对象都移动到内存的一端,移动结束后,直接清理掉一块区域。

  • 分代回收算法:不算是算法,而是策略。把堆分为年轻代 + 老年代,分别用不同算法。

    • Young 区:对象朝生夕死 → 使用复制算法
    • Old 区:对象长寿、多对象 → 使用标记整理

JVM GC器类型

收集器 年轻代算法 老年代算法 是否并发 特点
Serial 复制 标记-整理 否 单线程,简单可靠
Parallel 复制 标记-整理 否 高吞吐
CMS
(Concurrent Mark-Sweep)
复制 标记-清除 是 低停顿,但有碎片
G1 复制(Region分区) 标记-整理(Region) 是 面向大堆、低停顿。服务端应用。
ZGC Region + 复制 Region + 并发压缩 是(几乎全并发) 子毫秒停顿
Shenandoah Region + 复制 Region + 并发压缩 是 极低延迟

现代收集器 G1、ZGC、Shenandoah 基于 Region,同时使用并发标记、复制与压缩技术以降低停顿时间。

ART GC器类型

ART 虽然没有 HotSpot 那样的 Young / Old 物理分区,但仍是 逻辑分代 GC:

  • Sticky GC 回 Young 部分
  • Partial GC 回 Young + 部分 Old
  • Full GC 回整个堆

这是一个软分代模型,不是 HotSpot 的硬分代模型。

ART 的 GC 不是一个收集器,而是 多个收集器协同 工作:

Sticky Mark-Sweep(粘性标记清除 GC)(最常触发)

作用:回收最近死亡的小部分对象(通常是当前 Young 区的小对象)

特征:

  • 只扫描“自上次 GC 之后新增或修改过”的对象
  • 非常快(<1ms)
  • STW 时间极短
  • 不移动对象(无 compact)

用途:

  • 高频 GC,近年来 Android 优化最重要的一种 GC

你可以把它理解为 “增量式 Young GC”。


Partial Mark-Sweep(部分标记清除 GC)

回收部分堆(通常是 Young + 一部分 Old)

特征:

  • Stop-The-World + 并发
  • 速度比 Full GC 快
  • 不整理碎片

用途:

  • Sticky 扫不干净时,会触发它

Full Mark-Sweep(完整标记清除 GC)

回收整个堆的所有未引用对象

特征:

  • 扫描全堆
  • STW 较长
  • 不 compact(早期版本)
  • 触发时说明内存压力较高

用途:

  • 汇总性的 GC,当其他 GC 效果不足时触发

Full Mark-Compact(完整标记整理 GC)

清除整个堆 + 整理碎片

特征:

  • 标记整个堆
  • 移动对象 → 内存连续
  • 停顿较长(但比 Dalvik 时代短很多)
  • 提高长期运行稳定性

用途:

  • 防止碎片化
  • ART 运行若干天后,也要偶尔 compact

你可以说它类似 HotSpot 的 Mark-Compact。

Concurrent Copying GC

Android 8.0 引入:

并发复制收集器(Concurrent Copying)
是移动端的“ZGC/Shenandoah 思路”,但更轻。

特征:

  • 有读屏障(read barrier)
  • 对象移动是并发完成的
  • STW 时间进一步缩短
  • 类似 HotSpot 的 G1/ZGC,但为移动设备优化

HotSpt JVM启动流程

启动器(Launcher)阶段

你执行 java 命令时,其实启动的是C++文件:

/bin/java -> C++ 写的启动器

它做几件事:

  • 解析命令行参数(classpath、-Xmx、-XX… 等)
  • 找到 libjvm.so / jvm.dll 并加载进去
  • 使用 JNI Invocation API 创建 JVM

调用:

JNI_CreateJavaVM(JavaVM **pvm, JNIEnv **penv, void *args);

从这一刻起,JVM 虚拟机才真正开始运行。


JVM 初始化(JNI_CreateJavaVM)

JVM 线程开始执行,它内部依次初始化:

初始化内存模型

  • Java 堆(Heap)
  • 方法区 / 元空间(Metaspace)
  • CodeCache(JIT 机器码区)
  • JVM Stack 等

初始化 GC 子系统

不同 GC 器(G1、ZGC、Shenandoah)会在此完成内部结构建立。

初始化执行引擎

  • 解释器模板生成(TemplateInterpreter)
  • C1/C2 / JVMCI JIT 初始化

初始化类加载器子系统

加载器顺序:

  1. BootstrapClassLoader(C++ 实现)
  2. PlatformClassLoader(Java 9+)
  3. AppClassLoader

加载 JVM 基础类(Bootstrap 阶段)

JVM 必须先加载最基础的类,否则无法运行 Java 世界。

顺序通常如下:

  • 加载 java.lang.Object:一切类的起点。
  • 加载 java.lang.String:因为内部很多结构依赖 String(包括类名)。
  • 加载 java.lang.Class:没有 Class 对象,类元数据无从表示。
  • 加载 java.lang.Thread、java.lang.ThreadGroup:JVM 要靠这些表示 Java 层线程,因此启动过程必须提前加载。

加载这些类时都会触发 静态变量分配、 执行、常量解析等。

创建“主线程”(Main Thread)

虽然底层启动有一个 Native Thread,通过其启动JVM,但 JVM 必须创建一个代表它的 Java Thread 对象作为映射。

执行:

//fake code
Thread t = new Thread()

并将其设为:

  • 当前线程(main thread)
  • 所属 ThreadGroup(main group)

然后这个线程成为执行你main()方法的载体,也就是主线程。

主类(Main-Class)的加载

现在 JVM 使用 主线程 + Application ClassLoader 去加载具有main方法的类。

流程:

  1. 字节码通过 classpath / modulepath 查找
  2. 加载 class 文件
  3. 解析类结构(constant pool、methods、fields)
  4. 为静态变量分配空间
  5. 初始化父类
  6. 执行 <clinit>(类初始化:static 语句块 + static 字段赋值)

解析 main 方法的符号引用

JVM 需要找到:

public static void main(String[] args)

编译器会在常量池中记录方法的 符号引用:

"main", "([Ljava/lang/String;)V"

JVM 解析后得到:

  • 方法所在类
  • 方法描述符
  • 方法入口地址(解释器入口 or JIT 地址)

如果找不到,报错:

java.lang.NoSuchMethodError: main

执行 main 方法

启动器调用:

(*env)->CallStaticVoidMethod(env, mainClass, mainMethodID);

JVM 开始执行 main 方法的字节码:

  • 解释器逐条解释执行指令(iload、invokestatic…)
  • 或触发 JIT,生成机器码
  • 调度 Java 层线程
  • 执行整个程序生命周期

总结

java 命令启动
  → 解析参数
  → 加载 libjvm
  → 调用 JNI_CreateJavaVM
      → 初始化堆、元空间、GC、解释器、JIT、类加载器
      → Bootstrap 加载 Object/String/Class/Thread…
      → 创建 Java 主线程对象
      → 使用 AppClassLoader 加载主类
          → 类加载 → 验证 → 准备 → 解析 → 初始化
      → 解析 main 方法
      → 调用 main 方法(解释器/JIT 执行)

参考

https://www.xiaolincoding.com/interview/jvm.html

https://javaguide.cn/java/jvm/classloader.html