目录
2.根可达性分析(Reachability Analysis)
前言
jvm直接和操作系统去进行交互,属于底层操作原理,日常开发过程中使用到的jdk则依赖于jvm和jre。
JVM是Java程序运行的核心组成部分,它负责将编译后的Java字节码(.class文件)转换为计算机能够理解的指令,从而执行Java程序。
1、基本概念
1. 字节码
java程序在编译后,会生成中间代码,称为字节码(bytecode),这些文件通常以
.class
为后缀。字节码是跨平台的,可以在任何安装了JVM的设备上运行。
2. 平台无关性
动态编译和解释机制的结合使得Java程序具有“编写一次,处处运行”的能力。
2、结构组成
谈到jvm的结构,每个小伙伴的理解是不同的,下面将进行一个系统的理解。
如下图所示为完整的程序执行过程。
通过上述图看到执行过程被分为四个部分:
类加载系统+运行数据区+执行引擎+本地接口及本地库构成。
2.1、类加载子系统
负责加载
.class
文件,将其转换为JVM能够理解的格式。包括类加载器(ClassLoader),负责查找和加载类。
想了解更多详细的类加载系统可参考本人章节:java语言的类加载器-CSDN博客
2.2、运行数据区
分为线程共享数据区和线程私有数据区。可参考下图所示:
1.共享数据区
1.方法区(Method Area)
1.定义
存储类的信息、常量、静态变量、编译后的字节码等等。
2.加载字节码文件
当jvm使用类装载器装在某个类时,它首先要定位到对应的class文件,然后读入这个class文件,最后提取该文件的内容信息,并将这些信息存储到方法去,最后返回一个class实例。
更多字节码内存块详细知识,参考:JVM 内存与集群机器节点内存的关系-CSDN博客
如下图所示:
3.方法区存放内容
可参考完整类的信息去记忆:
1.类的全限定名(类的全路径名)。
2.类的直接超类的权全限定名(如果这个类是Object,则它没有超类)。
3.类的类型(类或接口)。
4.类的访问修饰符,public,abstract,final等。
5.类的直接接口全限定名的有序列表。
6.常量池(字段,方法信息,静态变量,类型引用(class))等
7.类变量:类变量为静态变量,在方法去中有个静态去,静态去专门用来存放静态变量以及静态块。
8.指向class对象实例的引用。
9.指向堆内存classloader对象的引用。
4.方法区内存大小设置
jdk1.6,jdk1.7 永久区
-XX:PermSize=10M 初始化方法区大小为10M。
-XX:MaxPermSize 方法区最大内存为10M。
-XX:PrintGCDetails 打印日志详情。
jdk1.8 元数据区
-XX:MetaspaceSize=10M
-XX:MaxMetaspaceSize=10M
元数据区发生溢出,虚拟机一样抛出异常,如下:java.lang.OutOfMemoryError Metaspace
2.堆(Heap)
存储所有对象实例和数组,是Java的垃圾回收器管理的主要区域。
由上图可知:可以看到TLAB存储在堆中
TLAB 本身是存储在堆中,但它对每个线程都是独立的。一个线程在创建对象时会使用其自己的 TLAB 来进行分配,而不是直接访问共享的堆内存区域。
面试常见问题:
此处引申一点:如果多线程情况下,jvm如何防止堆内存占用申请?
1、TLAB
2、cas
后续持续更新。
2.私有数据区
1. 程序计数器
记录正在执行的字节码的行号指示器。
2. 虚拟机栈
每个线程都有自己的Java栈,用于存储栈帧(包含局部变量、操作数栈,动态链接和方法出口等)。
虽然调用栈也占用内存(栈内存),但它是线程私有的固定大小区域(通过
-Xss
参数设置)。
3. 本地方法栈
专门用于支持 native 方法的执行:
-
存储 native 方法的调用状态
-
保存 native 方法的局部变量
-
处理 native 方法的参数传递和返回值
2.3、执行引擎
作用:负责将存储在内存中的字节码指令转换为可以在特定平台上执行的机器代码。
可以通过解释执行(逐行读取并执行)和即时编译(JIT)等方式优化性能。
JIT编译器将热点代码(经常执行的代码)转换为本地机器代码并缓存,以便后续调用时快速执行。
执行流程:
1.类加载:JVM加载Java类,将字节码读入内存。
2.执行字节码
- 如果字节码是热点代码,JVM可能会将其编译为本地机器代码(JIT编译),并缓存以提高性能。
- 如果不是热点代码,JVM则会使用解释器逐个字节执行。
3.内存管理:同时,执行引擎监控对象的生命周期,适时调用垃圾回收器释放不再使用的对象。
2.4、本地接口及库
允许Java程序调用C或C++等其他语言写的代码,主要通过JNI(Java Native Interface)实现,扩展Java的功能。
可参考下图来进行理解。
本地方法和本地方法库会和解析器和本地方法栈进行交互。
3、工作原理
关于类加载系统可以参考本人的文章:java语言的类加载器-CSDN博客 进行深入学习。
-
加载阶段:
- 类加载器加载字节码文件。
- 将字节码转换成JVM内部使用的类对象。
-
链接阶段:
- 验证:确保字节码的正确性、安全性。
- 准备:为静态变量分配内存。
- 解析:将符号引用转换为直接引用。
-
初始化阶段:
- 执行类的静态初始化块和静态变量初始化。
-
执行阶段:
- JVM执行加载的类的字节码,通过解释执行或者JIT编译执行。
4、内存结构
以下为java8版本的内存结构更加详细的模型图:
由上下两张图,可以看出内存=jvm内存+本地内存组成;
由上图可知:分为jvm内存和本地内存。
看到这里,可能有人会疑惑方法区怎么被移除jvm内存模型了呢?
方法区和本地内存的关系:
在 Java 8 之前,方法区是实现为永久代(PermGen)的一部分。这部分内存是 JVM 管理的,它属于 JVM 内存的一部分。
从 Java 8 开始,永久代被移除,取而代之的是元空间(Metaspace)。元空间使用的是本地内存,这意味着元空间本身存储在本地操作系统的内存中,而不是在堆内存中。这样的改进使得方法区的内存不再受 JVM 堆的大小限制,但方法区的性质仍然与 JVM 有关,因为它仍然是 JVM 的一部分,只是内存分配在底层进行了不同处理。
4.1、本地内存
本地内存由方法区、直接内存、JNI (Java Native Interface)调用的本地代码所使用的内存。
4.1、直接内存
直接内存(Direct Memory)是指通过 Java NIO(New Input/Output)提供的 API 进行的内存管理,不同于 JVM 堆内存中的对象存储。直接内存是在本地内存中分配的,它绕过了 JVM 堆,非常适合高性能 I/O 操作。
使用方式:
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);//分配1kb的本地内存
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024); // 分配1KB的直接内存
回收:
直接内存的管理并不由 Java 的垃圾回收机制控制。开发者需要确保在使用完直接内存后手动释放,以避免内存泄漏。不释放的直接内存会一直占用直到 JVM 进程结束。
访问速度:
比访问堆和栈速度都快,特别是在需要频繁的I/O操作时。
4.2、方法区
jdk7之前:
以永久代实现的。生命周期随着类的加载和卸载同步进行。在此实现中,方法区的大小通常是固定的,当达到上限时就会抛出OOM。
jdk1.8及之后:
采用本地内存(元空间),不是 JVM 堆中,因此不再受到固定大小的限制。当使用过多的方法区内存时,JVM 会自动分配更多的本地内存。
4.3、JNI内存
允许 Java 程序调用其他编程语言(通常是 C 或 C++)编写的本地代码。通过 JNI,Java 开发者可以访问本地系统资源、进行性能优化、使用已有的本地库等。
示例场景
1.调用本地方法
假设我们希望在 Java 程序中调用一个 C 函数来计算两个整数的和。首先,我们需要定义一个本地方法,并实现其对应的 C 代码。
Java 代码:
public class JNIExample {
// 声明本地方法
public native int add(int a, int b);
// 加载本地库
static {
System.loadLibrary("NativeLib");
}
public static void main(String[] args) {
JNIExample example = new JNIExample();
int result = example.add(5, 10);
System.out.println("Result: " + result); // 输出:Result: 15
}
}
C 代码(NativeLib.c):
#include <jni.h>
#include "JNIExample.h"
JNIEXPORT jint JNICALL Java_JNIExample_add(JNIEnv *env, jobject obj, jint a, jint b) {
return a + b; // 返回两个整数的和
}
在这个示例中,JNI 提供了一个 bridge,允许 Java 程序调用本地 C 函数来执行计算。
小结:
本地内存包含不同的内存区域(如方法区、直接内存和 JNI 内存),这些区域可以是线程共享的,也可以是线程私有的,具体取决于内存的分配和使用方式。
4.2、jvm内存
1.堆
堆内存由:新生代(eden、from和to区)、老年代组成。
而对于<jdk1.8:还有持久代。
对于>=jdk1.8: metaspace。
如下图所示:堆结构在分配内存空间的时候:老年代占比2/3,新生代1/3。
而新生代里面eden和from、to区的内存占比分别为8:1:1。
2.虚拟机栈
java虚拟机栈服务于java方法,StackOverflowError、
OutOfMemoryError(也有可能发生在栈内存空间)
,均发生在该区域。
在栈的调用过程中,可能会出现以下的问题:
1、StackOverflowError
-
原因:当线程的调用栈(Call Stack)深度超过虚拟机允许的最大深度(例如无限递归、方法循环调用),JVM 会抛出此错误。
触发场景:典型例子是无限递归:
void recursiveMethod() {
recursiveMethod(); // 无限调用自身
}
2、内存资源浪费
-
如果设置过大(如
-Xss10m
),且线程数较多时,可能导致总内存占用过高(因为每个线程独占栈内存)。 -
例如:1000个线程 × 2MB栈 → 总栈内存占用约2GB!
5、GC
前言概括:
堆内存是各个线程共享的空间,不能无节制的使用。通常服务器运行的时间通常都很长,累积的对象也会非常多。这些对象如果不做任何清理,任由它们数量不断累加,内存很快就会耗尽。所以GC就是要把不使用的对象都清理掉,把内存空间空出来,让项目可以持续运行下去。
5.1、GC空间
线程私有空间:无需由系统来执行GC。因为线程结束,释放自己刚才使用的空间即可,不影响其它线程。
线程共享空间:任何一个线程结束时,都无法确定刚才使用的空间是不是还有别的线程在使用。所以不能因为线程结束而释放空间,必须在系统层面统一垃圾回收。
5.2、确定方式
如何确定是否需要进行回收,主要有两种,分别是引用计数法与可达性分析法。
1.引用计数法(不采用)
工作流程:
引用计数法是在对象每一次被引用时,都给这个对象专属的『引用计数器』+1。
当前引用被取消时,就给这个『引用计数器』-1。
当前『引用计数器』为零时,表示这个对象不再被引用了,需要让GC回收。
可是当对象之间存在交叉引用的时候,对象即使处于应该被回收的状态,也没法让『引用计数器』归零。
优点:
实现简单,可以及时释放内存。
缺点:
无法处理循环引用的问题。如果两个对象相互引用,即使它们不再可达,引用计数也不会变为0。
2.根可达性分析(Reachability Analysis)
原理:
将“根”(GC Roots)视为起点,通过从根出发追踪每个对象的引用关系。GC Roots通常包括:只有根对象能够直接访问的对象会被认为是“存活”的,其他对象将被标记为“垃圾”。
正在运行的线程
方法中的局部变量
JVM内部使用的对象(如类对象等)
优缺点
- 优点:可以有效地处理循环引用,适用范围广泛。
- 缺点:在大对象的情况下可能需要消耗较多时间来进行完整的查找。
5.3、GC算法
1. 标记-清除(Mark-Sweep)
如下图所示:
原理
标记阶段:首先从根对象开始,查找所有可达的对象并进行标记。
清除阶段:遍历堆内存,将未被标记的对象视为不可达的垃圾,然后释放这些对象的内存。
优缺点
- 优点:实现简单,逻辑清晰。
- 缺点:产生内存碎片。在清除过程中,存活对象的布局可能会分散,使得后续分配变得更困难。
关于更多三色标记算法的介绍,可参考:垃圾回收的三色标记算法-CSDN博客
2. 标记-整理(Mark-Compact)
如下图所示:
原理
标记阶段:和标记-清除的一样,确定哪些对象是可达的。
整理阶段:将所有存活的对象移到堆的另一端,清理出一块连续的空闲内存。
优缺点
- 优点:消除了内存碎片;空闲内存连续,便于后续分配。
- 缺点:整理过程可能需要数据移动,额外的时间开销。
3. 分代收集(Generational Collection)
原理
基于“存活时间”的不同将堆分为多个代(Young Generation、Old Generation、Permanent Generation)。每一代对象的垃圾收集策略不同:
-
老年代:对象存活时间较长,使用标记-清除或标记-整理等方式进行回收。
- 年轻代:通常使用“复制算法”,对象在创建后大多数会很快被回收,年轻代的垃圾回收相对频繁。
优缺点
- 优点:根据对象的生命周期优化内存管理,减少了不必要的收集工作。
- 缺点:实现复杂,内存分配和收集的策略需要合理设计。
4.G1(Garbage-First)收集器和 ZGC(Z Garbage Collector)
设计是为了满足在处理大规模数据和要求低延迟的场景中。
特点:
- 大内存支持:现代应用程序常常运行在数 GB 或数 TB 的堆上。G1 旨在高效管理这么大的内存,对 JVM 的内存使用和垃圾回收提供更好的支持。
- 可预测的停顿时间:传统的垃圾回收机制(如 Parallel GC 或 CMS)在某些情况下会导致较长时间的停顿,G1 通过区域划分和并行处理来减少停顿时间,使得每次回收的停顿时间更加可控。
5.4、收集器分类
关于更多GC回收的可参考:Java对象的GC回收年龄的研究-CSDN博客
1、G1
G1(Garbage-First)收集器使用的算法结合了多个垃圾回收策略,主要是“标记-清除”(Mark-Sweep)和“复制”(Copying)。它将堆划分为多个区域(Region),对年轻代和老年代进行分开处理。
1.标记(Mark):
从根对象开始,标记所有可达的对象。
void markReachableObjects() {
// Pseudocode for marking reachable objects
for (Object root : gcRoots) {
mark(root);
}
}
void mark(Object obj) {
if (isMarked(obj)) return;
mark(obj); // Mark the object
for (Object reference : obj.getReferences()) {
mark(reference); // Recursively mark referenced objects
}
}
2.清除(Sweep):
清除未标记的对象,释放内存。
void sweep() {
for (Region region : heap) {
for (Object obj : region) {
if (!isMarked(obj)) {
free(obj); // Free unmarked object
}
}
}
}
3.并行与增量
G1 可以在多核 CPU 上并行工作,同时进行标记和清理,从而减少停顿时间。
参数设置:
java -XX:+UseG1GC -Xms4g -Xmx4g -jar yourapp.jar
2、ZGC(Z Garbage Collector)
ZGC 的设计目标是提供低延迟的垃圾回收,利用“并发标记”和“指针重定向”来管理对象。ZGC 使用的是一种基于区域的分代算法,该算法不移动对象,尽量减少停顿时间。在较老版本的JDK中,CMS是一种经过验证的选择,略低于ZGC。
1.并发标记(Concurrent Marking)
ZGC 在后台标记存活的对象,因此它不会引起应用程序的停顿。
void concurrentMarking() {
// Pseudocode for concurrent marking
for (Object root : gcRoots) {
mark(root); // Mark reachable objects
}
}
2.记忆碎片整理
为了减少内存碎片,ZGC 在必要时会对内存进行整理,同时移动对象的指针。
void relocateObject(Object obj) {
// This is pseudo-code illustrating object relocation
Pointer newPointer = allocateNewMemory();
copy(obj, newPointer); // Copy object to new memory location
updatePointers(obj, newPointer); // Update all references to point to new location
}
3.指针重定向:
ZGC 利用指针重定向技术,因此对象并不移动,而是通过逻辑指针来实现访问。
6、内存回收
对于运行时数据区的回收,如下图所示:
6.1、方法区的回收
jdk1.7及之前版本:
回收行为:
在永久代中,垃圾回收主要集中在类的卸载。当某个类不再被使用(即不存在任何实例对象,并且没有任何静态引用被持有)时,类的元数据和相关的信息会被回收,且回收频率低。
jdk1.8及之后版本:
回收行为
元空间的动态特性允许 JVM 在需要时请求更多的本地内存。
类的元数据会被回收,当没有任何实例引用该类,并且没有线程持有其引用时,JVM 会自动卸载该类并释放其使用的元空间。
6.2、堆内存的回收
如下图所示:
如下图是新对象申请堆内存时候,jvm的CG的流程:
1.内存溢出
指堆内存(Heap)或方法区(Metaspace/PermGen)被耗尽,无法分配更多对象。
OutOfMemoryError: Java heap space
(堆内存不足)。OutOfMemoryError: Metaspace
(类元数据过多)。
7、参数
合理化的配置jvm的运行数据区的参数,可以有效的保证服务的稳定性。以下是各个区域的参数设置:
7.1、堆内存
-Xms<size> # 初始堆大小
-Xmx<size> # 最大堆大小
java -Xms1M myapp
7.2、栈内存(-Xss
)
是 JVM(Java虚拟机)的一个启动参数,用于设置每个线程的栈内存(Stack Memory)大小,全称是 X Stack Size。它决定了每个线程的调用栈(Call Stack)可以使用的最大内存空间。
java -Xss2m MyApp # 设置为2MB(格式:-Xss<size>[k|m|g])
java -Xss256k MyApp # 设置为256KB
7.3、元空间
-
错误信息:
java.lang.OutOfMemoryError: Metaspace
-
触发条件:类元数据占用超过Metaspace大小
-
配置参数:
-XX:MetaspaceSize=<size>
-XX:MaxMetaspaceSize=<size>
7.4、直接内存(Direct Memory)
-
错误信息:
java.lang.OutOfMemoryError: Direct buffer memory
-
触发条件:NIO直接内存分配失败
-
配置参数:
-XX:MaxDirectMemorySize=<size>
7.5、永久代
在 Java 7 及之前版本中,永久代的大小是固定的(可以通过 -XX:PermSize
和 -XX:MaxPermSize
参数来设置)。
7.6、GC参数
1、G1
java -XX:+UseG1GC -Xms4g -Xmx4g -jar yourapp.jar
G1收集器有一些常用的参数,可以帮助调优其性能:
1.控制最大停顿时间:
-XX:MaxGCPauseMillis=200
-
这设置了最大期望的GC停顿时间(以毫秒为单位)。JVM会尽量保持GC停顿在该时间范围内。
2.设置Young Generation大小:
-XX:G1HeapRegionSize=16m
这设置了G1的区域大小(也称为Young Generation的大小),即堆的每个区域的大小。区域可以是1MB至32MB的幂次方。
3.启用并行处理:
-XX:ParallelGCThreads=4
-
指定用于并行GC的线程数,设定的线程数可以提高G1的回收效率。
2、ZGC
在JVM中启用ZGC,可以使用以下命令行参数:
java -XX:+UseZGC -Xms4g -Xmx4g -jar yourapp.jar
1.控制最大堆大小:
-XX:MaxHeapSize=20g
-
这可以设置最大堆大小,ZGC设计用于处理非常大(数TB级别)的堆。
2.启用并行GC线程
-XX:ParallelGCThreads=4
3.配置日志:
如果您希望监控ZGC的行为,可以启用日志记录:
-Xlog:gc*:file=gc.log:time,level,tags
-
这会将GC的日志记录输出到
gc.log
文件中。
小结:
选择和配置相应的垃圾回收器是优化Java应用性能的重要一步。根据应用程序的特性、内存需求和响应时间要求,可以配置适合的GC收集器以及其参数,以提高内存管理的效率。
8、OOM发生的区域
以下是常见的oom。
java.lang.OutOfMemoryError
├── Java heap space // 经典堆内存不足
├── Metaspace // 元空间不足
├── Compressed class space // 压缩类空间不足
├── Requested array size exceeds VM limit // 数组过大
├── Unable to create new native thread // 线程栈相关
├── GC overhead limit exceeded // GC效率过低
└── Kill process or sacrifice child // Linux系统级限制
8.1. 堆内存Heap Memory
- 描述:堆是JVM中用于存储对象和数组的主要内存区域。大多数的OOM错误发生在这里。
- 可能的原因:
- 对象创建过多:程序中创建了大量对象,超过了堆的配置大小。
- 内存泄漏:持有对不再需要的对象的引用,导致无法回收,从而耗尽所有堆内存。
public class OOMExample {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
while (true) {
list.add("OOM"); // 持续添加对象,导致堆内存溢出
}
}
}
8.2. 元空间(Metaspace)
- 描述:从Java 8开始,方法区被实现为元空间,存储类的元数据。
- 可能的原因:
- 类加载过多:应用程序动态加载了大量类(如使用反射)、频繁地创建和卸载类,从而耗尽元空间大小。
- 没有释放的类:某些框架在使用中可能未能正确释放类的元数据。
- 可能的原因:
public class MetaspaceOOM {
public static void main(String[] args) throws Exception {
int i = 0;
while (true) {
// 动态生成类并加载,可能导致元空间溢出
String className = "com.example.MyClass" + i++;
Class<?> clazz = Class.forName(className);
}
}
}
8.3. 直接内存(Direct Memory)
- 描述:直接内存是通过NIO的
ByteBuffer.allocateDirect()
直接分配的内存,不在JVM管理的堆中。 - 可能的原因:
- 未释放的直接内存:直接内存的分配和释放不受JVM的垃圾收集机制控制,容易造成内存泄漏。
public class DirectMemoryOOM {
public static void main(String[] args) {
while (true) {
// 分配直接内存,可能导致直接内存溢出
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024); // 1MB
}
}
}
8.4. 栈内存(Stack Memory)
- 描述:每个线程都有自己的Java栈,用于存储局部变量、方法调用和返回地址等。
- 可能的原因:
- 深递归调用:无限递归或过多的递归调用会占用大量栈空间,导致栈溢出(
StackOverflowError
)而非真正的OOM。
- 深递归调用:无限递归或过多的递归调用会占用大量栈空间,导致栈溢出(
public class StackOverflowErrorExample {
public static void recursiveMethod() {
recursiveMethod(); // 深度递归,导致栈溢出
}
public static void main(String[] args) {
recursiveMethod();
}
}
小结
OOM可以发生在多个区域,最常见的是堆内存(出于对象创建和内存泄漏等原因)。
元空间的消耗主要是由于类的加载,直接内存的消耗则是在使用NIO时未正确管理内存,而栈内存则通常由深递归调用引起。
总结、
通过对上面jvm的详细知识点的介绍,相信小伙伴们对JVM 的重要特性已经有了深入的理解。
1.自动内存管理:
使用垃圾回收(Garbage Collection)机制来管理内存,自动回收不再被引用的对象。
2.安全性:
提供了一些安全特性,如字节码验证和类加载安全,防止恶意代码的执行。
3.多线程支持:
JVM内置对多线程的支持,可以通过Java语言的特性(如同步、线程等)有效管理并发执行。
4.平台独立性:
JVM的实现可以针对不同平台进行优化,通过提供适配的JVM,可以使Java程序在各种操作系统和硬件上运行。