JVM
一、JVM 基础
1. 什么是 JVM 呢?⭐⭐⭐
JVM 就是 java 虚拟机,它是 Java 实现跨平台的基石。程序在运行前,需要被 java 编译器编译为字节码文件,之后 JVM 会逐行解释这些字节码文件并执行,同时 JVM 并提供内存管理、垃圾回收等。
2.执行 Hello World 到底干了什么?⭐️⭐️
Hello World.java 源代码先被编译为字节码,JVM 启动后通过类加载器加载类,经过验证、准备、解析和初始化后,由执行引擎执行字节码;对于执行频繁的热点代码,JIT 编译器会将其编译为机器码并缓存以提高执行效率。
3. JVM 由哪些部分组成?⭐⭐⭐
JVM 主要由类加载器、运行时数据区和执行引擎组成:
- 类加载器:负责加载字节码。
- 运行时数据区:负责存储程序运行时的数据。
- 执行引擎:负责执行字节码,包括解释器和 JIT 编译器。
二、内存管理
1. 能说一下 JVM 的内存区域吗?⭐️⭐⭐
JVM 运行时数据区包括程序计数器、虚拟机栈、本地方法栈、堆和方法区。
- 程序计数器:记录当前线程下一条要执行的字节码指令。
- 虚拟机栈:线程私有,每次方法调用都会创建一个栈帧,保存局部变量、操作数栈和方法返回信息。
- 本地方法栈:为 Native 方法提供运行空间。
- 堆:线程共享,主要存放对象和数组,是垃圾回收器管理的主要区域。
- 方法区:线程共享,存放类信息、运行时常量池、静态变量等数据。JDK 8 以后通常由元空间实现。
2. 堆和栈有什么区别?
堆是线程共享的,主要存放对象,由 GC 管理;栈是线程私有的,主要存放方法调用产生的栈帧,方法执行结束后栈帧会自动释放。
3. JDK 8 为什么用元空间替代永久代?
永久代位于 JVM 内存中,容易因为类信息过多而发生内存溢出;元空间使用本地内存存储类元数据,不再占用 JVM 内存。
字符串常量池和静态变量在 JDK 7 以后位于堆中,并没有放入元空间。
4. 常量池 ⭐⭐⭐
java 文件被编译为 class 文件,class 文件除了包含类的版本,字段,方法,接口的等描述信息外,还有一项就是 Constant Pool,用于存放编译器生成的各类字面量和符号引用。
- 字面量就是我们所说的常量,如字符串,被声明为 final 的变量,基本数据类型的值。
- 编译阶段无法确定对象在内存中的真实地址,因此使用符号来描述引用目标
5. 运行时常量池
当类加载到内存中,JVM 就会将当前类的 class 常量池中的内容存放到运行时常量池,只不过,在运行时常量池里,符号引用会被替换为直接引用。(分为静态和动态解析)
6. 字符串常量池⭐️⭐️
为了提升性能和减少内存消耗。因为字符串在 Java 中使用频率极高,JVM 专门开辟了一块区域来缓存字符串。
- Java 6 及之前:位于 PermGen(永久代)中。
- Java 7/8 及之后:移动到了 Java Heap(堆) 中。这样做是为了防止永久代内存不足,同时也更方便垃圾回收(GC)。
在 JDK 1.7 及之后,字符串常量池存的是“字符串对象的引用”和“字符串的内容”。本质上是一个 HashTable,K 是字符串内容得到的Hash值,Value 则是当前字符串的引用。
当我们声明了一个字符串变量:
此时,不会直接去堆区创建一个内容为 "abc" 的对象,而是先去字符串常量池里面找有问有内容为 ”abc" 的对象,如果有,则直接返回,没有,才去堆区创建一个 “abc" 对象,然后把这个对象的引用放到字符串常量池里面。
注意,如果是通过 new 关键字声明字符串,则一定会在堆区创建一个对象
三、类加载
1. JVM 类加载过程⭐️⭐️⭐️
类加载的全过程可以概括为:加载 \(\rightarrow\) 链接(验证、准备、解析) \(\rightarrow\) 初始化。
1、加载就是根据类的全限定名(比如 com.test.User)找到对应的 .class 文件,以二进制流读入到内存中。
2、链接又可以分为三个阶段,分别为:
- 验证:安检。检查加载的字节码是否符合 JVM 规范。
- 准备:为类变量(
static修饰的变量)分配内存并设置初始值。 - 解析:把常量池内的将符号引用转换为直接引用。
3、初始化:执行类构造器 <clinit>() 方法。这个方法是编译器自动收集所有 类变量(static)的赋值动作 和 静态代码块(static {}) 中的语句合并而成的。
2. 什么是类加载器,类加载器有哪些?⭐️⭐️⭐
类加载器负责根据类的全限定名加载对应的字节码。常见类加载器包括启动类加载器、平台类加载器、应用类加载器和自定义类加载器。
- 启动类加载器 (Bootstrap ClassLoader):负责加载 Java 的核心库。
JAVA_HOME/lib目录下的 jar 包(如rt.jar、resources.jar)。这就是为什么你不用导入就能直接使用java.lang.Object或String。 - 扩展类加载器 (Extension ClassLoader):由 Java 编写,派生自
java.lang.ClassLoader。负责加载 Java 的扩展库。 - 应用程序类加载器 (Application ClassLoader) | 系统类加载器:负责加载用户类路径 (ClassPath) 上的所有类库。可以通过 ClassLoader.getSystemClassLoader()来获取它
- 自定义类加载器 (Custom ClassLoader):由开发者自己定义。通过继承 java.lang.ClassLoader类的方式实现
3. 双亲委派模型 (Parent Delegation Model)⭐⭐
当类加载器收到加载请求时,会优先将请求交给父类加载器处理,父类加载器无法加载时,子类加载器才尝试加载。
双亲委派的主要作用是避免类重复加载,并防止用户自定义的同名类替换 Java 核心类。
4. 如何破坏双亲委派模型
通过自定义类加载器并重写类加载逻辑,可以改变默认的双亲委派顺序。Tomcat 为实现不同 Web 应用之间的类库隔离,就使用了自定义类加载器。
5. 什么时候会使用到加载器?
根据 Java 虚拟机规范,只有在对类进行主动引用时,才会触发类加载(包括加载、链接、初始化)。
- 使用
new关键字实例化对象时,第一步就是检查当前类有没有加载完成,没有则进行类加载过程。 - 访问类或接口的静态成员时,会触发类加载。
- 使用反射调用类时,当你执行
Class.forName("com.test.User")或ClassLoader.loadClass(...)时,会显式触发加载。 - Java 规定:初始化一个类时,如果其父类还没初始化,必须先触发父类的初始化。
- JVM 启动时,必须先加载并初始化包含
main()方法的那个主类。
四、对象与垃圾回收
1. 对象创建过程⭐️⭐️⭐️
类加载检查:先查当前类是否完成了加载,如果没有完成加载,会先将当前类加载到内存中。分配内存空间:给当前对象分配内存空间,根据当前 JVM 的情况有不同的分配算法:如果内存整齐就用“指针碰撞”,如果不整齐就用“空闲列表”。对象属性初始化:会给当前对象的属性赋为默认值,例如:int 的默认值为0,boolean 的默认值为 false设置对象头:里面包含了对象是哪个类的实例、对象的哈希码、对象的 GC 分代年龄等信息。执行init方法:JVM 会执行构造方法<init>完成赋值操作,将成员变量赋值为预期的值,比如int age = 18,这样一个对象就创建完成了。
2. 如何判断一个对象是否可以被回收?⭐⭐⭐
Java 使用可达性分析判断对象是否存活:从 GC Roots 出发,如果对象无法通过引用链到达,就会被认为是不可达对象,之后可能被垃圾回收。
常见 GC Roots 包括虚拟机栈中的引用、静态变量引用、常量引用和 JNI 引用等。
3. 堆内存的分配方式⭐️⭐️
在堆中为对象分配内存时,主要使用两种策略:指针碰撞和空闲列表。指针碰撞适用碎片化较少的内存区域,而空闲列表适用于内存碎片化较严重的区域。
4. 堆内存的分区 ⭐⭐⭐
为了更高效地管理内存(主要是为了方便垃圾回收),JVM 把堆分成了两大块:
- 年轻代 (Young Generation):存放新创建的对象。
- 老年代 (Old Generation):存放熬过多轮 GC、大命不死的长寿对象。
而年轻代内部,又被细分为三个小区:
- Eden 区:名字取自“伊甸园”,意味着生命的起点。几乎所有新 new 出来的对象都产生在这里。
- Survivor From 区(也叫 S0):幸存者区。
- Survivor To 区(也叫 S1):幸存者区。
Eden : S0 : S1 = 8 : 1 : 1 也就是说,年轻代中 80% 的地方是用来让对象“出生”的,剩下的 20% 分成两半,轮流当“临时避难所”。
5. 对象的内存布局 ⭐
对象的内存布局是由 Java 虚拟机规范定义的,但具体的实现细节各有不同,如 HotSpot 和 OpenJ9 就不一样。在 HotSpot 中,对象在内存中包括三部分:对象头、实例数据和对齐填充。
1、对象头由两到三部分组成:
- Mark Word (标记字段):存储对象自身的运行时数据。
- 类型指针:对象指向它在方法区中 Class 对象 的指针。通过它,JVM 才知道这个对象是哪个类的实例。
- 数组长度:如果是数组对象,还需要这个字段
2、实例数据:实例数据是对象实际的字段值,也就是成员变量的值(包括从父类继承下来的),按照字段在类中声明的顺序存储。
3、这部分没有任何实际含义,它仅仅起到占位符的作用。HotSpot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍。如果对象头 + 实例数据加起来刚好是 14 字节,那么对齐填充就会补上 2 字节,凑成 16 字节。
6. 常见的垃圾回收算法有哪些?⭐
- 标记-清除:标记存活对象后清除不可达对象,容易产生内存碎片。
- 标记-复制:将存活对象复制到另一块区域,适合存活对象较少的场景。
- 标记-整理:将存活对象移动到一端,再清理剩余空间,可以减少碎片。
- 分代收集:根据对象生命周期划分年轻代和老年代,分别采用适合的回收策略。
7. 什么是 Minor GC 和 Full GC?⭐
- Minor GC:主要回收年轻代,通常发生在 Eden 区空间不足时。
- Full GC:回收整个堆和方法区,通常会造成更长时间的停顿,应尽量减少发生。
五、常见 JVM 错误
1. 什么情况下会产生 StackOverFlowError
在 Java 中,StackOverflowError 发生在虚拟机栈(JVM Stack) 空间耗尽时。每个线程都有自己独立的栈,用来存储栈帧(Stack Frame),每调用一个方法就会压入一个栈帧,方法执行完后再弹出。
- 无限递归(最常见原因)
- 递归调用层级过深
- 方法间循环调用
- 局部变量占用空间过大
- 栈内存设置过小
-Xss设置的栈内存容量过小(例如只设置了 64KB),
2. 什么情况下会导致 OutOfMemory
在 Java 中,OutOfMemoryError (OOM) 表示 JVM 已经没有足够的内存空间来分配给新对象,且垃圾回收器(GC)也无法通过回收腾出更多空间。
与栈溢出不同,OOM 通常发生在 堆(Heap) 或 方法区(元空间)。
- 堆中对象过多或存在内存泄漏。
- 加载的类过多导致元空间溢出。
- 创建线程过多。
- 直接内存不足。
3. 如何排查 JVM 内存溢出?⭐
先根据异常信息判断是堆、元空间、线程还是直接内存问题,再通过 Heap Dump、MAT、VisualVM 等工具分析对象数量和引用关系,重点检查静态集合、缓存、监听器以及 ThreadLocal 是否长期持有对象。