先宏观角度看下JVM的工作流

image-20241004141954355

流程:

  1. Java 源代码
    • 编写的 Java 源代码文件(如 MyClass.java)首先通过编译器(javac)编译为字节码文件(.class)。
  2. 编译后的字节码
    • 生成的 .class 文件包含了 Java 字节码,这是一种平台无关的中间代码,可以在任何支持 JVM 的平台上运行。
  3. JVM 执行
    • JVM 负责加载、验证、解释和执行字节码。
  4. JVM 关键组件
    • Class Loader(类加载器):负责加载 .class 文件到 JVM 中。
    • Bytecode Verifier(字节码验证器):确保字节码的安全性,防止恶意代码执行。
    • Execution Engine(执行引擎):包括解释器和 JIT(Just-In-Time)编译器,负责将字节码转为机器码并执行。
  5. Runtime Data Area(运行时数据区)
    • 管理内存的不同区域,包括堆(Heap)、栈(Stack)、方法区(Method Area)等。
  6. Garbage Collection(垃圾回收)
    • JVM 的垃圾收集器自动回收不再使用的对象,防止内存泄漏。

总结:

  1. 源代码编译:Java 源代码编译成字节码。
  2. 类加载器:将字节码文件加载到 JVM 中。
  3. 字节码验证器:检查字节码的安全性。
  4. 执行引擎:通过解释或即时编译执行字节码。
  5. 运行时数据区:管理内存分配(堆、栈、方法区等)。
  6. 垃圾回收器:管理内存回收,释放不再使用的对象。

Java内存区域与内存溢出

运行时数据区域

image-20241004142257298

线程共享内存

方法区(Method Area)

作用:方法区是 JVM 内存中用于存储类的结构信息、常量池、静态变量和类加载后的代码等区域。每个线程共享这个区域。

方法区包含以下内容**:**

  • 类元数据:包含类的名称、父类、访问修饰符、接口等信息。
  • 运行时常量池(Runtime Constant Pool):包括类文件中的常量,如字符串字面量、数值字面量、方法和字段的符号引用。
  • 静态变量:类中的静态字段存储在方法区中,且它们属于类的唯一实例。
  • 方法代码:类的方法,包括构造函数、静态方法块等都会被存储在方法区中。
  • 即时编译器的代码:JVM 的 JIT 编译器会将热代码(Hot Code)编译为本地机器码,这部分代码也会被存储在方法区中。

当我们定义一个类 MyClass 时,类的元数据(如类名、方法名、访问权限等)会存储在方法区中。

1
2
3
4
5
6
public class MyClass {
static int staticVar = 10;
public void myMethod() {
System.out.println("Hello World");
}
}
  • MyClass 的静态变量 staticVar 和方法 myMethod 会存储在方法区中。

堆(Heap)

  • 作用:堆是 JVM 内存中最大的一块,用于存储所有的对象实例和数组。堆是线程共享的,所有对象都在堆中分配内存。

  • 示例

    1
    MyClass obj = new MyClass();
    • 当我们创建一个 MyClass 对象时,实例 obj 会在堆中分配内存。

线程私有内存

虚拟机栈(VM Stack)

  • 作用:虚拟机栈为每个线程独立分配,存储局部变量、操作数栈、方法调用等信息。每个线程都有自己的栈,栈中的每一帧对应一个方法的执行。

  • 示例:

    1
    2
    3
    public void calculate(int a, int b) {
    int sum = a + b;
    }
    • 当方法 calculate 被调用时,局部变量 absum 都会被存储在该方法对应的栈帧中。

程序计数器(Program Counter Register)

  • 作用:程序计数器用于存储每个线程当前执行的指令地址。它记录了 JVM 当前执行的字节码指令位置。每个线程都有独立的程序计数器。
  • 示例:
    • 当程序执行到 calculate(2, 3) 时,程序计数器会记录当前正在执行的字节码指令。

本地方法栈(Native Method Stack)

  • 作用:本地方法栈与虚拟机栈类似,但它是为 JVM 执行本地(Native)方法服务的。Native 方法通常是用 C 或 C++ 编写的、与操作系统交互的代码。

    • Native 是指使用非 Java 语言(如 C、C++ 等)编写的方法,这些方法通过 JNI(Java Native Interface) 调用,由 JVM 外部的代码实现。
  • 示例:

    1
    System.loadLibrary("nativeLib");
    • 当 Java 程序调用本地库 nativeLib 中的本地方法时,会使用本地方法栈进行管理。

举例

1
2
3
4
5
6
7
8
9
10
11
12
public class Main {
public static void main(String[] args) {
int a = 10;
int b = 20;
int sum = add(a, b);
System.out.println("Sum: " + sum);
}

public static int add(int x, int y) {
return x + y;
}
}

执行过程

  1. 编译阶段
    • Main.java 文件通过 javac 编译器编译成字节码文件 Main.class
  2. 类加载器(Class Loader)
    • 类加载器将 Main.class 文件加载到方法区中,类的元数据(如类名、方法名等)存储在方法区中。
  3. 方法执行
    • main 方法开始执行时,JVM 创建一个虚拟机栈,并在栈中为 main 方法创建栈帧,局部变量 ab 被存储在栈帧中。
    • 执行 add(a, b) 时,JVM 在虚拟机栈中为 add 方法创建另一个栈帧,局部变量 xy 被存储在该栈帧中。
  4. 程序计数器
    • 程序计数器记录当前执行的指令地址。例如,当 JVM 执行到 int sum = add(a, b); 时,程序计数器会记录当前字节码的地址。
  5. 执行引擎
    • 执行引擎负责解释字节码并执行 add 方法,最终返回 x + y 的结果。
    • 如果 add 方法是热点代码,JIT 编译器可能会将其编译成本地机器码,以提高性能。
  6. 垃圾回收
    • 当方法执行完毕,栈帧被销毁。如果在堆中分配了对象(如 new 关键字创建的对象),当这些对象不再被引用时,垃圾回收器会回收它们的内存。

HotSpot虚拟机对象

1.对象的创建

在 JVM 中,每当一个对象被创建时,JVM 会根据对象所属类的信息分配内存并初始化其字段。对象的创建过程如下:

  1. 类加载:在创建对象之前,JVM 首先需要确保类已经被加载和初始化,类加载器会负责将类的字节码加载到内存中。
  2. 内存分配:对象的内存分配通常发生在堆内存中,堆是所有线程共享的,但为了避免频繁的锁竞争,JVM 引入了 TLAB(线程本地分配缓冲区)来优化对象分配。
  3. 内存清零:分配的内存区域会被清零,确保对象的初始化操作不会读取到旧数据。
  4. 对象初始化:JVM 执行构造函数(<init> 方法)对对象进行初始化。

image-20241004145233161

补充:TLAB【Thread Local Allocation Buffer】

TLAB 是 JVM 为每个线程分配的一块堆内存,用于优化并发环境下的对象分配。每个线程可以在自己的 TLAB 中分配对象,避免了线程间的竞争。

TLAB 的工作原理:

  • 每个线程都有自己独立的 TLAB,分配对象时直接在自己的 TLAB 中操作,避免与其他线程竞争。
  • 当 TLAB 空间不足时,线程会尝试分配一个新的 TLAB,如果堆空间不足,则可能触发垃圾回收(GC)。

TLAB 内存结构:

  • start:TLAB 的起点。
  • top:当前分配的位置,随着对象分配,top 向右移动。
  • end:TLAB 的终点,当 top 到达 end 时,表示 TLAB 空间已经耗尽。

2.对象的内存布局

对象的内存布局

  • 对象头:8字节(Mark Word) + 4字节(Class Pointer)
  • 实例数据:4字节(field1) + 8字节(field2) + 4字节(field3
  • 对齐填充:为了对齐到 8 字节,可能需要 4 字节的填充。

3.内存溢出分析

内存区域 异常类型 JVM 参数 现象 解决方法
栈内存 StackOverflowError -Xss 当线程调用栈深度超过设置的栈大小时抛出栈溢出错误。 增加栈大小(增大 -Xss),或者优化递归调用,减少栈深度。
方法区(Metaspace) OutOfMemoryError (Metaspace溢出) -XX:MetaspaceSize
-XX:MaxMetaspaceSize
动态生成大量类时,元空间占满,导致类元数据无法存储,抛出 OutOfMemoryError 增加 MaxMetaspaceSize,减少不必要的类加载或动态类生成。
字符串常量池 OutOfMemoryError (常量池溢出) 无直接参数 字符串常量池溢出,通常发生在大量字符串被 intern() 方法动态加入常量池时。 JDK 7 后字符串常量池位于堆中,增大堆内存(-Xmx),避免频繁调用 intern()
直接内存 OutOfMemoryError (Direct Memory) -XX:MaxDirectMemorySize 通过 NIO 分配的直接内存超过设置的最大值,抛出 OutOfMemoryError 增加 MaxDirectMemorySize,检查程序中直接内存的使用是否合理,避免内存泄漏。
堆内存 OutOfMemoryError -Xms
-Xmx
-XX:+HeapDumpOnOutOfMemoryError
当堆内存占满且无法回收时,抛出 OutOfMemoryError 增大堆内存(增大 -Xmx),使用堆转储分析工具(如 Eclipse MAT)检查堆内存占用情况。

垃圾回收与内存分配

GC 可分解为 3 个子问题:which(哪些内存可被回收)、when(什么时候回收)、how(如何回收)

GC 条件

  1. 引用计数算法(reference counting)

  2. 可达性分析算法(reachability analysis)

  3. 四种引用类型

  4. finalize

什么时候会触发FULL GC?

在回答这个问题之前,先做个比喻降低理解。

比喻:你的房子和垃圾清理

假设你住在一栋房子里,每天都会产生一些垃圾。你有两个房间来放这些垃圾:

  1. 客厅(年轻代):放置你今天或最近几天产生的垃圾。
  2. 储藏间(老年代):放置你客厅里那些放了很久、还没处理的垃圾。

你每天都会把客厅的垃圾定期清理(Minor GC),但是清理完之后,有一些垃圾你舍不得扔(比如那些你觉得以后可能还会用的东西),你就把它们放到了储藏间里。

一旦储藏间里的垃圾放满了,你就不得不进行一次大规模的清理(Full GC),这时你不但要清理储藏间,还要检查客厅,确保整个房子都清理干净。

为什么会触发 Full GC?

理解 Full GC 的触发原因就像理解你什么时候需要彻底打扫你的房子。下面是一些常见的触发条件,配合这个房间和垃圾的比喻来解释。

1.储藏间(老年代)放满了

当你每天把客厅里的垃圾打扫一遍(Minor GC),把那些“可能有用”的垃圾放进储藏间(老年代)。但是如果储藏间的空间快满了,你就不得不进行一次彻底的清理(Full GC),确保储藏间里有足够的空间腾出来。

  • 对应到 JVM:当 JVM 的老年代(存放生命周期较长的对象)快满了,JVM 会触发 Full GC,清理那些不再使用的老对象。

2.客厅(年轻代)装不下垃圾了

有时你会发现客厅里突然堆满了垃圾,而储藏间也已经快满了,已经没有足够的空间可以把垃圾从客厅挪到储藏间。这时,你也不得不进行一次彻底的全房间清理,确保整个房子有足够的空间。

  • 对应到 JVM:当年轻代(放置新创建的对象)垃圾回收后,发现有很多对象需要移到老年代,但老年代空间不足时,就会触发 Full GC,尝试回收更多老年代的内存。

3.房子里有特别大的垃圾

有时候,你可能会有一个特别大的旧家具(比如一个沙发),你想把它放进储藏间,但储藏间里都是零散的垃圾,根本放不下这么大的东西。于是你只好进行一次全房间清理,腾出足够的空间来放这个大件物品。

  • 对应到 JVM:如果你的程序中创建了大对象(比如一个很大的数组或对象),这些大对象可能直接进入老年代。如果老年代中没有足够的连续空间来存放它们,JVM 就会触发 Full GC,腾出空间来存放这些大对象。

4.你自己主动打扫房子

假设你有强迫症,觉得房子需要随时保持干净,于是你主动决定对房子进行一次彻底清扫。

  • 对应到 JVM:调用 System.gc() 或其他类似的 API,显式请求 JVM 进行垃圾回收。虽然 JVM 不一定每次都执行,但如果执行,通常会触发 Full GC。

5.房子布局不合适,空间利用率低

有时候,储藏间的垃圾堆得很分散,即使空间总量看起来够用,但因为垃圾堆得不够紧凑,导致你无法继续存放垃圾。这时,你会感觉必须要重新整理一下储藏间,让空间更加紧凑。

  • 对应到 JVM:在某些情况下,老年代内存碎片化严重,虽然总的可用空间看起来很多,但因为没有足够大的连续内存区块,无法存放新对象。JVM 会触发 Full GC 来整理这些内存碎片,腾出更多连续的空间。

和上述例子对应起来,用专业的话叙述的话就是。

JVM 的堆内存(Heap)通常分为两个主要部分:

  • 年轻代(Young Generation):存放新创建的对象。年轻代通常又分为:
    • Eden 区:存放刚刚创建的新对象。
    • Survivor 区:存放从 Eden 区中存活下来的对象。
  • 老年代(Old Generation):存放生命周期较长的对象,即那些在年轻代中经过多次垃圾回收后仍然存活的对象。

总结一下上面提到的几种 Full GC 的触发条件:

  • 老年代空间不足:当老年代存放不下需要晋升(从年轻代移过来)的对象时,触发 Full GC。
  • 元空间(Metaspace)或永久代(PermGen)空间不足:JVM 需要存放类的元数据和常量池信息。如果这部分空间不足,可能触发 Full GC。
  • 年轻代对象晋升失败:当 Minor GC 后,年轻代中的对象需要晋升到老年代,但老年代空间不足时,触发 Full GC。
  • 显式调用 System.gc():强制触发 Full GC。
  • 老年代内存碎片化:当老年代内存碎片过多,无法分配大对象时,触发 Full GC。
  • 并发垃圾回收失败:某些垃圾回收器(如 CMS GC)在并发回收时未能及时完成回收任务,会触发 Full GC 作为补救措施。

双亲委派机制

双亲委派机制 是 Java 类加载器的一种工作模式,它规定了类加载器在加载类时的一种职责委派关系。通过这种机制,当一个类加载器接到加载某个类的请求时,它首先会将这个请求委派给父类加载器,一层层向上委托,直到最终由启动类加载器(Bootstrap ClassLoader)处理。如果父类加载器无法加载该类,加载请求才会传递回下层的类加载器,由其自行尝试加载。这种机制确保了核心类库(如 java.lang.Object)全局唯一的加载方式,保证了 Java 类加载的一致性和安全性。

假设我们有一个类 MyClass,请求加载的过程如下:

  1. **应用类加载器(Application ClassLoader)**接到加载 MyClass 的请求。
    • 它不会立刻尝试加载,而是委派给父类加载器(通常是扩展类加载器)。
  2. **扩展类加载器(Extension ClassLoader)**接到请求后,再次委派给其父类加载器(启动类加载器)。
  3. **启动类加载器(Bootstrap ClassLoader)*尝试加载 MyClass,如果该类不属于核心类库(如 rt.jar),它将返回*加载失败
  4. 扩展类加载器收到反馈后,尝试加载 MyClass,如果失败,则将请求传递回应用类加载器
  5. 应用类加载器最终尝试加载 MyClass。如果在类路径下找到了 MyClass,则成功加载;否则加载失败,抛出 ClassNotFoundException

为什么要使用双亲委派机制?

1.保证 Java 核心类的安全性和一致性

双亲委派机制的最主要的作用是保证 Java 核心类库的安全性和一致性

例如,java.lang.Object 类是 Java 中最基础的类,所有的类都直接或间接继承自 Object。如果类加载器没有遵循双亲委派机制,应用程序可以自定义一个 java.lang.Object 类,并让它与 JDK 中的 Object 类冲突,这将导致 Java 运行时异常甚至崩溃。

通过双亲委派机制,类加载器总是首先将类的加载请求委托给父类加载器。由于 Object 类是由引导类加载器(Bootstrap ClassLoader)加载的,应用程序类加载器无论如何都无法替换 Object 类。这样可以确保 Java 核心类库的安全性和一致性,避免核心类库被篡改。

2.避免类重复加载

双亲委派机制可以有效避免类的重复加载

假设某个类已经被父类加载器加载,子类加载器再加载同一个类就会造成类的重复加载问题。这样不仅浪费内存,而且会导致 JVM 中同一个类的多个版本共存,造成不可预知的行为。

通过双亲委派机制,类加载器总是先委托父加载器处理,父加载器加载成功后,子加载器不会再进行加载,保证了类只会被加载一次。

3.类的隔离与复用