Java虚拟机
先宏观角度看下JVM的工作流
流程:
- Java 源代码:
- 编写的 Java 源代码文件(如
MyClass.java
)首先通过编译器(javac
)编译为字节码文件(.class
)。
- 编写的 Java 源代码文件(如
- 编译后的字节码:
- 生成的
.class
文件包含了 Java 字节码,这是一种平台无关的中间代码,可以在任何支持 JVM 的平台上运行。
- 生成的
- JVM 执行:
- JVM 负责加载、验证、解释和执行字节码。
- JVM 关键组件:
- Class Loader(类加载器):负责加载
.class
文件到 JVM 中。 - Bytecode Verifier(字节码验证器):确保字节码的安全性,防止恶意代码执行。
- Execution Engine(执行引擎):包括解释器和 JIT(Just-In-Time)编译器,负责将字节码转为机器码并执行。
- Class Loader(类加载器):负责加载
- Runtime Data Area(运行时数据区):
- 管理内存的不同区域,包括堆(Heap)、栈(Stack)、方法区(Method Area)等。
- Garbage Collection(垃圾回收):
- JVM 的垃圾收集器自动回收不再使用的对象,防止内存泄漏。
总结:
- 源代码编译:Java 源代码编译成字节码。
- 类加载器:将字节码文件加载到 JVM 中。
- 字节码验证器:检查字节码的安全性。
- 执行引擎:通过解释或即时编译执行字节码。
- 运行时数据区:管理内存分配(堆、栈、方法区等)。
- 垃圾回收器:管理内存回收,释放不再使用的对象。
Java内存区域与内存溢出
运行时数据区域
线程共享内存
方法区(Method Area)
作用:方法区是 JVM 内存中用于存储类的结构信息、常量池、静态变量和类加载后的代码等区域。每个线程共享这个区域。
方法区包含以下内容**:**
- 类元数据:包含类的名称、父类、访问修饰符、接口等信息。
- 运行时常量池(Runtime Constant Pool):包括类文件中的常量,如字符串字面量、数值字面量、方法和字段的符号引用。
- 静态变量:类中的静态字段存储在方法区中,且它们属于类的唯一实例。
- 方法代码:类的方法,包括构造函数、静态方法块等都会被存储在方法区中。
- 即时编译器的代码:JVM 的 JIT 编译器会将热代码(Hot Code)编译为本地机器码,这部分代码也会被存储在方法区中。
当我们定义一个类 MyClass
时,类的元数据(如类名、方法名、访问权限等)会存储在方法区中。
1 | public class MyClass { |
- 类
MyClass
的静态变量staticVar
和方法myMethod
会存储在方法区中。
堆(Heap)
-
作用:堆是 JVM 内存中最大的一块,用于存储所有的对象实例和数组。堆是线程共享的,所有对象都在堆中分配内存。
-
示例
1
MyClass obj = new MyClass();
- 当我们创建一个
MyClass
对象时,实例obj
会在堆中分配内存。
- 当我们创建一个
线程私有内存
虚拟机栈(VM Stack)
-
作用:虚拟机栈为每个线程独立分配,存储局部变量、操作数栈、方法调用等信息。每个线程都有自己的栈,栈中的每一帧对应一个方法的执行。
-
示例:
1
2
3public 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
中的本地方法时,会使用本地方法栈进行管理。
- 当 Java 程序调用本地库
举例
1 | public class Main { |
执行过程:
- 编译阶段:
Main.java
文件通过javac
编译器编译成字节码文件Main.class
。
- 类加载器(Class Loader):
- 类加载器将
Main.class
文件加载到方法区中,类的元数据(如类名、方法名等)存储在方法区中。
- 类加载器将
- 方法执行:
- 当
main
方法开始执行时,JVM 创建一个虚拟机栈,并在栈中为main
方法创建栈帧,局部变量a
和b
被存储在栈帧中。 - 执行
add(a, b)
时,JVM 在虚拟机栈中为add
方法创建另一个栈帧,局部变量x
和y
被存储在该栈帧中。
- 当
- 程序计数器:
- 程序计数器记录当前执行的指令地址。例如,当 JVM 执行到
int sum = add(a, b);
时,程序计数器会记录当前字节码的地址。
- 程序计数器记录当前执行的指令地址。例如,当 JVM 执行到
- 执行引擎:
- 执行引擎负责解释字节码并执行
add
方法,最终返回x + y
的结果。 - 如果
add
方法是热点代码,JIT 编译器可能会将其编译成本地机器码,以提高性能。
- 执行引擎负责解释字节码并执行
- 垃圾回收:
- 当方法执行完毕,栈帧被销毁。如果在堆中分配了对象(如
new
关键字创建的对象),当这些对象不再被引用时,垃圾回收器会回收它们的内存。
- 当方法执行完毕,栈帧被销毁。如果在堆中分配了对象(如
HotSpot虚拟机对象
1.对象的创建
在 JVM 中,每当一个对象被创建时,JVM 会根据对象所属类的信息分配内存并初始化其字段。对象的创建过程如下:
- 类加载:在创建对象之前,JVM 首先需要确保类已经被加载和初始化,类加载器会负责将类的字节码加载到内存中。
- 内存分配:对象的内存分配通常发生在堆内存中,堆是所有线程共享的,但为了避免频繁的锁竞争,JVM 引入了 TLAB(线程本地分配缓冲区)来优化对象分配。
- 内存清零:分配的内存区域会被清零,确保对象的初始化操作不会读取到旧数据。
- 对象初始化:JVM 执行构造函数(
<init>
方法)对对象进行初始化。
补充: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 条件
-
引用计数算法(reference counting)
-
可达性分析算法(reachability analysis)
-
四种引用类型
-
finalize
什么时候会触发FULL GC?
在回答这个问题之前,先做个比喻降低理解。
比喻:你的房子和垃圾清理
假设你住在一栋房子里,每天都会产生一些垃圾。你有两个房间来放这些垃圾:
- 客厅(年轻代):放置你今天或最近几天产生的垃圾。
- 储藏间(老年代):放置你客厅里那些放了很久、还没处理的垃圾。
你每天都会把客厅的垃圾定期清理(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
,请求加载的过程如下:
- **应用类加载器(Application ClassLoader)**接到加载
MyClass
的请求。- 它不会立刻尝试加载,而是委派给父类加载器(通常是扩展类加载器)。
- **扩展类加载器(Extension ClassLoader)**接到请求后,再次委派给其父类加载器(启动类加载器)。
- **启动类加载器(Bootstrap ClassLoader)*尝试加载
MyClass
,如果该类不属于核心类库(如rt.jar
),它将返回*加载失败。 - 扩展类加载器收到反馈后,尝试加载
MyClass
,如果失败,则将请求传递回应用类加载器。 - 应用类加载器最终尝试加载
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.类的隔离与复用