JVM 经典面经
更新: Invalid Date 字数: 0 字 时长: 0 分钟
JVM 基础概念
什么是 JVM?JVM 的作用是什么?
JVM 是 Java 程序的运行时环境,它负责 加载类、执行字节码、管理内存和提供垃圾回收机制,并通过不同平台的 JVM 实现 Java 的跨平台能力。
Java 为什么能够实现“一次编译,到处运行”?
Java 能够实现“一次编译,到处运行”,主要是因为 Java 使用了字节码和 JVM 的架构设计。
首先,Java 源代码 .java 文件会通过 javac 编译器编译成 .class 字节码文件。 这种 字节码是一种平台无关的中间格式,不依赖具体的操作系统和硬件。
在程序运行时,字节码并不是直接在操作系统上执行,而是由 JVM(Java 虚拟机)负责加载和执行。
不同的操作系统(如 Windows、Linux、Mac)都有各自实现的 JVM,只要这些 JVM 能够正确解析并执行 Java 字节码,同一个 .class 文件就可以在不同平台上运行。
因此,Java 通过 “统一的字节码 + 不同平台的 JVM 实现”,实现了“一次编译,到处运行”。
JVM 的执行流程是什么?
JVM 的执行流程大致可以分为三个阶段:编译、类加载和执行。
首先,开发者编写的 Java 源代码 .java 文件会通过 javac 编译器编译成 .class 字节码文件。字节码是一种平台无关的中间格式。
当程序运行时,JVM 的 类加载子系统会将 .class 文件加载到内存中,并完成加载、验证、准备、解析和初始化等过程。
加载完成后,类的信息会存储在 JVM 的 运行时数据区中,例如方法区、堆、虚拟机栈等。
接着,JVM 的 执行引擎开始执行字节码指令,执行方式包括解释执行和 JIT 即时编译。对于热点代码,JVM 会将其编译为机器码以提高执行效率。
在程序运行过程中,JVM 还会通过 **垃圾回收机制(GC)**对堆内存中的对象进行回收,并通过 JNI 调用底层的本地方法。
因此,JVM 的整体执行流程可以概括为:
源码编译 → 类加载 → 数据进入运行时数据区 → 执行引擎执行字节码 → GC 管理内存。即时编译器(JIT)
什么是即时编译器(JIT)?
JIT(Just-In-Time Compiler,即时编译器) 是 JVM 在程序运行过程中,将 热点字节码动态编译为本地机器码 的技术,从而提高程序执行效率。
在 JVM 中,字节码最初是通过 解释器(Interpreter)逐行解释执行 的。但解释执行虽然 启动快,却 执行效率较低。
为了提高性能,JVM引入了 JIT 编译器。
当 JVM 发现某段代码被 频繁执行(热点代码) 时,就会触发 JIT 编译,将这段字节码 编译成本地机器码,之后直接执行机器码,从而大幅提升执行效率。
JIT 编译发生在哪个线程?
在 HotSpot JVM 中,JIT 编译不是在业务线程执行,而是由 专门的 Compiler Thread(编译线程) 在后台完成。
当代码被识别为热点代码后,JVM 会将编译任务加入 Compile Queue,由编译线程异步执行编译,生成机器码,从而替换解释执行。
JVM 如何判断热点代码?
JVM 通过 基于计数器的热点探测机制 判断热点代码。 主要使用 方法调用计数器 和 回边计数器:
- 方法调用计数器:统计方法调用次数
- 回边计数器:统计循环执行次数
当执行次数超过阈值时,JVM 会认为该代码是 热点代码,并触发 JIT 编译,将字节码编译为本地机器码,从而提高执行效率。
为什么 JVM 要用回边计数器?
JVM 使用 回边计数器 的主要原因是:
仅通过方法调用次数无法识别循环热点代码。
很多方法可能只调用一次,但内部循环执行次数非常多。如果没有回边计数器,JVM 就无法识别这些高频执行的循环代码。
因此 HotSpot JVM 引入 回边计数器 来统计 循环回跳次数。当循环执行次数超过阈值时,就会触发 JIT 编译,从而优化循环体的执行效率。
JIT 有哪些优化手段?
JIT 的优化手段主要包括:
方法内联:减少方法调用开销,为进一步优化提供机会循环优化:循环展开、范围检查消除逃逸分析:对象栈上分配、锁消除锁优化:偏向锁、轻量级锁、锁消除条件优化:分支预测、类型推断、多态内联缓存分层编译:C1/C2 编译器结合,延迟深度优化
总体目标:在保证程序语义正确的前提下,减少 CPU 指令开销和内存开销,提升热点代码执行效率。
什么是逃逸分析?
逃逸分析是 JIT 编译器在运行时分析对象作用域的一种技术,用于判断对象是否 逃出方法或线程。 基于分析结果,JIT 可以进行两类优化:
- 栈上分配:对象只在方法内使用,避免堆分配和 GC
- 锁消除:对象不逃出线程,移除无竞争锁,提升性能
核心目标:通过分析对象的使用范围,减少内存开销和同步开销,从而优化热点代码性能。
为什么有些代码永远不会被 JIT?
只有达到一定 调用次数阈值 或 循环次数阈值 的方法/代码才会被 JIT 编译。 永远不会被 JIT 的情况:
冷代码:从未或极少被调用的代码。例如错误处理代码、日志输出但平时不触发的分支。非方法内独立代码:JIT 通常对方法级或循环级热点编译,而单次执行的短代码片段可能不被编译。虚拟机约束的代码:如使用了 synchronized 锁但在解释器中频繁抛出异常、抑制优化的反射调用、native 方法(非 Java 实现),可能不走 JIT。动态语言特性或复杂控制流:使用 invokedynamic、频繁改变类型的对象,JIT 优化成本高,可能保持解释器执行。安全或调试模式下:JVM 启动参数限制 JIT(如 -Xint 强制全程解释执行),或者为了调试而禁用 JIT 的方法永远不会被编译。
内存结构
JVM 内存模型?
JVM 运行时内存模型主要分为 线程私有区域和线程共享区域。
线程私有的包括:
程序计数器:记录当前线程执行的字节码位置。虚拟机栈:每个方法调用都会创建栈帧,里面包含局部变量表、操作数栈等信息。本地方法栈:为 Native 方法服务。
线程共享的包括:
堆(Heap):JVM 最大的一块内存区域,主要用于存储对象实例,也是垃圾回收的主要区域。堆一般分为年轻代和老年代。方法区(Method Area):用于存储类信息、常量、静态变量以及 JIT 编译后的代码。在 JDK8 之后由 **Metaspace(元空间)**实现,使用本地内存。
整体来说,JVM 通过这样的内存划分,实现了 线程隔离和数据共享,同时配合 垃圾回收机制 来管理堆内存。
堆和栈区别?
在 JVM 中,堆和栈是两种不同的内存区域,它们主要有以下几个区别:
存储内容不同栈主要用于方法调用,每个方法执行时都会创建一个栈帧,里面存放局部变量、操作数栈、方法出口等信息;而堆主要用于存储对象实例和数组。
线程关系不同栈是线程私有的,每个线程都有自己的虚拟机栈,因此不需要考虑线程安全问题;而堆是线程共享的,所有线程创建的对象都存放在堆中。
生命周期不同栈中的数据随着方法调用开始创建,方法结束自动销毁;而堆中的对象生命周期不固定,需要依赖垃圾回收器(GC)进行回收。
性能不同栈采用先进后出的结构,内存分配和回收只需要移动栈指针,因此效率非常高;而堆需要动态分配内存,并且可能触发 GC,因此效率相对较低。
异常类型不同如果栈空间不足会抛出 StackOverflowError;如果堆内存不足则会抛出 OutOfMemoryError。
因此可以总结为:栈主要负责方法执行和局部变量管理,堆主要负责对象实例的存储。
方法区是什么?
方法区是 jVM 运行时数据区的一部分,用于存储类的元数据信息,并且时线程共享的内存区域。 存储的具体内容有:
类的元数据信息
- 类的全限定类名
- 访问修饰符
- 父类信息
- 接口信息
字段信息
方法信息
- 方法名
- 方法描述符
- 方法字节码
运行时常量池
- 字符串常量
- 类和方法引用
静态变量(static 变量)
JIT 编译后的代码缓存
JDK7 及之前,方法区通常由永久代实现
JDK8 之后,永久代被移除,改为使用元空间,并且元空间使用的本地内存
元空间是什么?
元空间时 JVM 运行时数据区的一部分,是 hotspot jvm 对方法区的一种实现方式,主要用于存储类的元数据信息。并且使用的是 本地内存而不是 JVM 堆内存。
为什么移除永久代,而改用元空间实现方法区?
永久代使用的是堆内存,内存大小固定,容易出现内存溢出,并且调优困难;永久代实现复杂,难以维护
元空间使用的是本地内存,大小限制取决于本地内存,可以动态的调节分配给元空间的内存。
类在 JVM 中什么时候才可以被卸载?
在 JVM 中,类是否可以被卸载取决于类加载器是否可以被回收。 一个类只有在满足以下 三个条件同时成立 时,才可能被 JVM 卸载。
该类的所有实例对象都已经被回收,也就是说
堆中没有任何该类的对象实例加载该类的 ClassLoader 已经被回收。在 JVM 中,类是由 ClassLoader 持有的,只要 ClassLoader 还存在,它加载的类就不会被卸载。
该类的 Class 对象没有被任何地方引用
在实际开发中:绝大多数类不会被卸载。
程序计数器作用?
程序计数器(Program Counter Register,PC 寄存器) 是 JVM 运行时数据区中的一块 线程私有的内存区域,用于 记录当前线程正在执行的字节码指令地址。
它的主要作用有以下几个:
- 记录当前线程执行到哪一条字节码指令
- 支持线程切换后恢复执行位置,程序计数器是实现
线程切换与恢复执行的关键。 - Native 方法的特殊情况
- 程序计数器不会发生 OOM:程序计数器是唯一一个不会发生 OutOfMemoryError 的区域。
类加载
类加载过程?
Java 中类加载过程分为五个阶段:
加载:把 class 文件加载到 JVM 中,具体步骤:
- 通过类的全限定名获取 class 文件二进制流
- 将二进制流转换为方法区的运行时数据结构
- 在堆中生成一个 java.lang.Class 对象
验证 :确保字节码安全,不会破坏 JVM,主要有四类校验:
- 文件格式验证: 检查 class 文件格式是否正确
- 元数据验证 : 验证类结构是否符合 Java 规范
- 字节码验证 : 确保代码逻辑安全(类型转换是否合法?栈操作是否合法?方法参数类型是否匹配?等等)
- 符号引用验证: 验证常量池中的引用是否合法(类是否存在?方法是否存在?字段访问权限是否合法?等等)
准备: 为类变量(static 变量)分配内存并赋默认值(不是代码值)
解析:把符号引用替换为直接引用
初始化:执行类构造器
<clinit>方法,<clinit>是由 JVM 自动生成的:由 静态变量赋值 + 静态代码块 合并产生。只有主动使用类才会触发初始化,例如:- 创建对象(new)
- 访问静态变量(非 final)
- 调用静态方法
- 反射:
Class.forName() - JVM 启动主类:
public static void main - 子类初始化,会先初始化父类
双亲委派机制?
双亲委派机制是 JVM 类加载器的一种工作机制,其核心思想是 类加载请求先委托给父类加载器处理,只有当父加载器无法完成加载时,子加载器才会尝试自己加载。
JVM 主要有三层类加载器(自上到下):
- Bootstrap ClassLoader(启动类加载器)
- Extension ClassLoader (扩展类加载器)
- Application ClassLoader(应用类加载器)
- (可选)Custom ClassLoader(自定义类加载器)
双亲委派机制的主要作用有两个:
- 保证 Java 类库核心的安全性
- 避免类的重复加载
怎么打破双亲委派机制?
核心方式只有一种: 自定义类加载器,重写 loadClass 方法,不再委派给父类加载器
堆
什么是堆?
堆是 Java 对象的主要存储区,通过分代和垃圾回收机制管理内存,保证了程序运行的内存安全和效率。在面试中提到分代结构和 GC 策略,能显著加分。
JVM 堆的内存结构及新生代与老年代的划分?
堆主要分为两大部分:新生代和老年代。 新生代(Young Generation):
- 存放 新创建的对象,大多数对象在这里就会被回收。
- 新生代又划分为三个区域:
- Eden 区:绝大多数新对象分配在这里。
- Survivor 0(S0)区:用作存活对象的暂存区。
- Survivor 1(S1)区:同样用于存活对象的暂存,与 S0 交替使用。
- 对象晋升规则:经过多次
Minor GC仍存活的对象,会根据年龄阈值被晋升到老年代。 - 垃圾回收:Minor GC,速度快,频率高。
老年代(Old / Tenured Generation):
- 存放
长期存活的对象,例如缓存、单例对象等。 - 对象在老年代中存活时间较长,垃圾回收较少,但每次回收开销大。
- 垃圾回收:Major GC / Full GC,通常比 Minor GC 慢。
JVM 堆
├─ 新生代 (Young Generation)
│ ├─ Eden 区
│ ├─ Survivor 0 (S0)
│ └─ Survivor 1 (S1)
└─ 老年代 (Old / Tenured Generation)
说明:新生代占堆总容量的约 1/3 左右,老年代占 2/3 左右(可调)。不同垃圾回收器可能略有不同。新生代中为什么要分 Eden、Survivor?
新生代划分为 Eden 和 Survivor,是为了配合复制算法实现高效的 Minor GC。Eden 用于存放新对象,Survivor 用于保存 GC 后的存活对象,并通过两个 Survivor 区轮流复制,从而减少内存碎片并筛选出长期存活对象。
为什么新生代中的 Eden 区和 Survivor 区 默认是 8:1:1?
JVM 将新生代划分为 8:1:1,是基于对象大多短生命周期的假设,让 Eden 足够大以减少 GC 频率,同时 Survivor 只需容纳少量存活对象,并配合复制算法实现高效的 Minor GC。
如果 Minor GC 时 Survivor 放不下存活对象会发生什么?
在 Minor GC 时,如果 Survivor 区无法容纳存活对象,JVM 会将这些对象直接晋升到老年代,并通过空间分配担保机制确保老年代有足够空间,
如果老年代空间不足则可能触发 Full GC。
对象一般在哪个区域创建?
Java 对象通常在新生代的 Eden 区创建,经过多次 Minor GC 后存活的对象会进入 Survivor 区,并在达到年龄阈值后晋升到老年代;在某些情况下,如大对象或 Survivor 空间不足时,对象也可能直接进入老年代。
什么是对象分配担保机制?
对象分配担保机制是 JVM 在执行 Minor GC 之前进行的一种空间检查,用来确保新生代存活对象在晋升到老年代时有足够的空间,如果老年代空间不足,则可能提前触发 Full GC。
垃圾回收(GC)
什么是垃圾?
垃圾(Garbage)指的是在程序运行过程中不再被任何对象引用的对象。
如何判断对象是否是垃圾?
在 JVM 中,判断一个对象是否是垃圾,本质就是判断 该对象是否还被程序使用。主要有两种经典算法:
- 引用计数算法
- 可达性分析算法
但 JVM 实际使用的是 可达性分析算法。
什么是引用计数算法?
引用计数算法(Reference Counting)是一种用于判断对象是否可以被回收的垃圾回收算法,其核心思想是:
为每个对象维护一个引用计数器,记录有多少个引用指向该对象。
当对象的引用计数为 0 时,说明该对象已经不再被任何地方使用,可以被垃圾回收。
为什么 JVM 不使用引用计数算法?
JVM 没有采用引用计数算法,主要是因为该算法无法解决对象之间的循环引用问题,并且维护引用计数会带来额外性能开销,因此 JVM 采用可达性分析算法,通过 GC Roots 判断对象是否可达。
什么是可达性分析算法?
可达性分析算法是 JVM 判断对象是否存活的核心算法,它以 GC Roots 作为起点向下搜索对象引用关系,如果对象与 GC Roots 之间存在引用链,则对象存活,否则会被判定为垃圾对象并在 GC 时回收。
GC Root 包括哪些对象?
GC Roots 是 JVM 进行可达性分析时的起点,常见的 GC Roots 包括虚拟机栈中的局部变量引用、方法区中的静态变量引用、常量池中的对象引用以及本地方法栈中的 JNI 引用对象
什么是对象引用链?
对象引用链(Reference Chain)是指在 JVM 的 可达性分析算法 中,从 GC Roots 出发,通过对象之间的引用关系逐步连接到某个对象所形成的一条路径。
如果一个对象能够通过引用链与 GC Roots 相连,则说明该对象是 可达对象,不会被垃圾回收;如果对象与 GC Roots 之间不存在引用链,则说明该对象是 不可达对象,可以被 GC 回收。
什么情况下对象会被回收?
在 JVM 中,当对象通过可达性分析发现与 GC Roots 之间不存在引用链时,会被判定为不可达对象;如果对象在 finalize() 执行后仍然不可达,则该对象会在 GC 时被回收。
对象一定会被 GC 吗?
对象即使已经被判定为不可达对象,也不一定会立即被 GC,因为 GC 只有在满足触发条件时才会执行,并且对象在第一次标记后还可以通过 finalize() 方法进行一次自救。
finalize() 方法有什么作用?
finalize() 是 Object 类提供的方法,当对象被判定为不可达时,在真正被 GC 回收前可能会执行该方法,用于资源清理或对象自救,但由于执行时间不可控且影响 GC 性能,从 Java 9 开始已被废弃,不建议使用。
垃圾回收算法
常见的垃圾回收算法有哪些?
JVM 中常见的垃圾回收算法主要包括以下几种:
- 标记-清除算法(Mark-Sweep)
- 复制算法(Copying)
- 标记-整理算法(Mark-Compact)
- 分代收集算法(Generational GC)
什么是标记-清除算法?
标记-清除算法(Mark-Sweep) 是一种基础的垃圾回收算法,它的核心思想是:先标记所有存活对象,然后清除所有未被标记的对象,从而回收内存空间。
该算法主要分为 两个阶段:
- 标记阶段(Mark):JVM 会通过 可达性分析算法,从 GC Roots 出发遍历对象引用关系。所有 可以访问到的对象都会被标记为:存活对象
- 清除阶段(Sweep):在清除阶段,垃圾回收器会遍历整个堆空间,将 未被标记的对象释放掉。
优点:
- 实现简单
- 不需要移动对象
- GC 成本相对较低
缺点:
- 内存碎片问题
- 影响内存分配效率
什么是标记-整理算法?
标记-整理算法(Mark-Compact) 是一种改进的垃圾回收算法,它在 标记-清除算法 的基础上,通过移动存活对象来解决 内存碎片问题。
其核心思想是:先标记所有存活对象,然后将存活对象向一端移动,最后清理掉边界外的所有空间。
标记-整理算法主要分为 两个阶段:
- 标记阶段:JVM 通过 可达性分析 从 GC Roots 出发,标记所有存活对象。
- 整理阶段:垃圾回收器会将 所有存活对象向一端移动,然后直接清理掉边界外的空间。
优点:
- 解决内存碎片问题
- 提高内存利用率
缺点:
- 需要移动对象
- GC 停顿时间更长
什么是复制算法?
复制算法(Copying) 是一种垃圾回收算法,其核心思想是:将内存划分为两块区域,每次只使用其中一块。当发生 GC 时,将存活对象复制到另一块区域,然后清空原区域。
这样就可以一次性回收所有垃圾对象。
优点:
- 没有内存碎片
- 分配速度快
- 实现简单
缺点:
- 内存利用率低
- 存活对象多时成本高
为什么新生代使用复制算法?
新生代对象大多数生命周期很短,存活率很低,而复制算法的成本主要取决于存活对象数量,因此在新生代使用复制算法只需要复制少量对象即可完成垃圾回收,效率非常高,同时还能避免内存碎片问题。
为什么老年代使用标记整理算法?
老年代对象存活率较高,如果使用复制算法会产生大量对象复制开销并浪费一半内存空间,因此通常采用标记整理算法,通过移动存活对象来整理内存,从而避免内存碎片并提高内存利用率。
