先宏观角度看下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 被调用时,局部变量 a、b 和 sum 都会被存储在该方法对应的栈帧中。

程序计数器(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 方法创建栈帧,局部变量 a 和 b 被存储在栈帧中。
    • 执行 add(a, b) 时,JVM 在虚拟机栈中为 add 方法创建另一个栈帧,局部变量 x 和 y 被存储在该栈帧中。
  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.类的隔离与复用