跳转至

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 则是当前字符串的引用。

当我们声明了一个字符串变量:

String a = "abc"

此时,不会直接去堆区创建一个内容为 "abc" 的对象,而是先去字符串常量池里面找有问有内容为 ”abc" 的对象,如果有,则直接返回,没有,才去堆区创建一个 “abc" 对象,然后把这个对象的引用放到字符串常量池里面。 注意,如果是通过 new 关键字声明字符串,则一定会在堆区创建一个对象

String a = new ("abc")

三、类加载

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.jarresources.jar)。这就是为什么你不用导入就能直接使用 java.lang.ObjectString
  • 扩展类加载器 (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. 对象创建过程⭐️⭐️⭐️

  1. 类加载检查:先查当前类是否完成了加载,如果没有完成加载,会先将当前类加载到内存中。
  2. 分配内存空间:给当前对象分配内存空间,根据当前 JVM 的情况有不同的分配算法:如果内存整齐就用“指针碰撞”,如果不整齐就用“空闲列表”。
  3. 对象属性初始化:会给当前对象的属性赋为默认值,例如:int 的默认值为0,boolean 的默认值为 false
  4. 设置对象头:里面包含了对象是哪个类的实例、对象的哈希码、对象的 GC 分代年龄等信息。
  5. 执行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),每调用一个方法就会压入一个栈帧,方法执行完后再弹出。

  1. 无限递归(最常见原因)
  2. 递归调用层级过深
  3. 方法间循环调用
  4. 局部变量占用空间过大
  5. 栈内存设置过小 -Xss 设置的栈内存容量过小(例如只设置了 64KB),

2. 什么情况下会导致 OutOfMemory

在 Java 中,OutOfMemoryError (OOM) 表示 JVM 已经没有足够的内存空间来分配给新对象,且垃圾回收器(GC)也无法通过回收腾出更多空间。

与栈溢出不同,OOM 通常发生在 堆(Heap)方法区(元空间)

  • 堆中对象过多或存在内存泄漏。
  • 加载的类过多导致元空间溢出。
  • 创建线程过多。
  • 直接内存不足。

3. 如何排查 JVM 内存溢出?⭐

先根据异常信息判断是堆、元空间、线程还是直接内存问题,再通过 Heap Dump、MAT、VisualVM 等工具分析对象数量和引用关系,重点检查静态集合、缓存、监听器以及 ThreadLocal 是否长期持有对象。