JVM面试(内存结构,垃圾回收,类载机制)
内存结构
运行时数据区 是 《Java 虚拟机规范》 中定义的一个标准模型。

Java线程隔离的数据区的生命周期与线程是相同的。
线程被创建时,这些数据区被分配;线程结束时,这些数据区也就被销毁回收了
对于线程私有的内存区域(程序计数器、虚拟机栈、本地方法栈),它们的大小和结构在编译期或类加载期就已经基本确定了,而不是在运行时动态决定的。
程序计数器(线程私有)
当前线程所执行的字节码的行号指示器
| 执行状态 | 程序计数器(PC Register)的值 | 原因 |
|---|---|---|
| 执行 Java 方法 | 字节码指令地址 | 需要记录下一条要执行的字节码位置 |
| 执行 Native 方法 | 空(Undefined) | 执行的是本地机器指令,不在JVM字节码体系内,无需记录字节码地址。 |
Java虚拟机栈(线程私有)
局部变量表
作用:存储方法的参数和方法内部定义的局部变量。
存储单位:以变量槽 为单位。
注意:这里存储的是基本数据类型的值 和对象实例的引用(相当于C语言的指针)。对象实例本身存储在堆 中。
局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、
float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress
类型(指向了一条字节码指令的地址),知道接下来到哪里执行。
本地方法栈
和虚拟机栈类似,只是执行native方法。
它是一个用 native 关键字声明、但没有方法体的Java方法。它的实际实现是由非Java语言(通常是C或C++)编写的,并存在于本地库(如 .dll 文件 on Windows 或 .so 文件 on Linux)中。
Java堆(GC堆)(各个线程共享) 对象实例
此内存区域的唯一目的就是存放对象实例用于存储所有通过 new 关键字创建的对象实例和数组,静态变量在JDK 8
及以后也移到了堆中
GC堆,字符串常量池**在JDK 7及以后也移到了堆中 “Garbage Collected Heap”——垃圾收集堆,
所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区
(Thread Local Allocation Buffer,TLAB)
虽然堆是共享的,但是为了优化对象分配的性能,JVM在堆中为每个线程划分了一小块私有的缓冲区(TLAB),线程分配对象时先在自己的TLAB中分配,从而避免了竞争。
Java堆
├── Class<Employee>对象 (唯一)
│ └── 静态变量表
│ └── companyName → "ABC Company" (唯一)
│
├── Employee对象1
│ └── 实例变量: name = "张三"
│
├── Employee对象2
│ └── 实例变量: name = "李四"
│
└── Employee对象3└── 实例变量: name = "王五"
分代垃圾收集下的分代模型
Java堆
├── 新生代 (Young Generation) [占堆的1/3]
│ ├── Eden区 (伊甸园) [新生代的8/10]
│ └── Survivor区 (幸存者区) [新生代的2/10]
│ ├── Survivor 0 (From区)
│ └── Survivor 1 (To区)
└── 老年代 (Old/Tenured Generation) [占堆的2/3]
方法区(各个线程共享)类
它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
方法区存储的是与类结构相关的数据,而不是对象实例。具体包括:
-
类型信息
- 类的完整有效名称(如
java.lang.String) - 类的直接父类的完整有效名称
- 类的修饰符(
public,abstract,final等) - 类直接实现的一个接口的列表
- 类的完整有效名称(如
-
运行时常量池
- 这是方法区中非常核心的一部分。它存储了:
- 编译期生成的各种字面量:如字符串字面量(
"Hello")、声明为final的常量值。 - 符号引用:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。这些符号引用在类加载的解析阶段会被转换为直接引用。符号引用 文本描述,逻辑上的引用 。直接引用,内存地址/偏移量,物理上的引用
- 编译期生成的各种字面量:如字符串字面量(
- 这是方法区中非常核心的一部分。它存储了:
-
字段信息
- 每个字段的名称、类型、修饰符(
public,private,static等)。
类的字段指的就是成员变量
- 每个字段的名称、类型、修饰符(
-
方法信息
- 每个方法的名称、返回类型、参数数量和类型、修饰符。
- 方法的字节码、局部变量表大小、操作数栈大小。
- 异常表。
-
静态变量
- 用
static修饰的变量。静态变量在JDK 7及之前存储在方法区(永久代),在JDK 8及之后,静态变量存储在堆中(作为Class对象的一部分)。 - 特例:静态变量如果被声明为
final,并初始化为一个编译时常量,则可能会被优化到运行时常量池(方法区的一部分)中。
- 用
-
JIT编译器编译后的代码缓存
- 即时编译器(JIT)将“热点代码”编译成本地机器码后,也会存储在方法区。
- 生命周期:与JVM进程相同。JVM启动时创建,JVM关闭时销毁。
- 垃圾回收:方法区是可以被垃圾回收的,但条件苛刻,回收效率也远低于堆。
- 回收目标主要是:不再使用的类 和 废弃的常量。
- 一个类被判定为“不再使用”需要满足3个条件:
- 该类的所有实例都已被回收。
- 加载该类的
ClassLoader已被回收。 - 该类对应的
java.lang.Class对象没有被任何地方引用,无法通过反射访问该类的方法。
运行时常量池(方法区的一部分)
用于存放编译期生成的各种字面量与符号引用。

直接内存
位置:它位于JVM进程之外的本地内存(操作系统管理的内存)中。
分配方式:不是由JVM的垃圾回收器管理,而是通过 ByteBuffer.allocateDirect() 这样的方法,底层调用的是 malloc() 这样的操作系统原生调用来申请内存。
访问方式:在Java中,通过一个特殊的Java对象——DirectByteBuffer——来引用和操作这块内存。这个对象本身在JVM堆上,但它持有一个指向堆外内存地址的指针。
常量存在哪里?
字符串常量:在 堆 中的字符串常量池里。
static final 基本类型和字符串字段:在 方法区 的运行时常量池中。
其他对象类型的 static final 常量:引用在方法区,对象本身在堆中。
智慧:
固定不变的东西(static final 基本类型和字符串字段(实际的字符串常量在堆))放在方法区获得最佳性能
动态可变的东西(对象)放在堆中获得灵活性
引用本身是固定的,所以放在方法区
对象本身是动态的,所以放在堆中
(字符串可以理解成一个对象,字符串字段就是引用,这样就统一了,对象在堆,引用和基本类型在方法区)
Java 7之前字符串常量池在方法区,后来移到堆中以便更好地管理内存
垃圾回收
引用计数式垃圾收集(直接垃圾收集) 引用计数算法判断是否存活
在《深入理解Java虚拟机》讨论到的主流Java虚拟机中均未涉及
追踪式垃圾收集(间接垃圾收集) 可达性分析算法判断是否存活
可达性分析算法
Java、C#的内存管理子系统,都是
通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。
GC Roots集合
固定部分
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象
- 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
- 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。(静态变量本身在JDK 8+中存储在堆中(在Class对象内),不在方法区)
- 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。(字符串常量池在JDK 7+中已移到堆中)
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
- 所有被同步锁(synchronized关键字)持有的对象。
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等动态部分
从一组GC Roots集合出发,找到连通的对象,联不通就是可回收
分代收集理论
一般至少会把Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域
弱分代假说(新生代)
绝大多数对象都是朝生夕死的
强分代假说(老年代)
熬过越多次垃圾收集过程的对象就越难以消亡。
一个问题:
假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性。这让性能大大降低了。
跨代引用假说
跨代引用相对于同代引用来说仅占极
少数。
所以我们不为少量的跨代引用扫描整个老年代,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。
这样从扫描这个给老年代变成了扫描老年代的少部分。

标记-清除算法(在某些收集器 老年代)
标记需要回收的或者不需要回收的,再回收标记的/没标记的
问题:面对大量可回收对象时效率低,因为要进行大量标记和清除
标记-复制算法(适合新生代)
内存分成两块,先用其中一块,满了之后,开始把存活的复制到另一块
问题:浪费了一半内存
标记-整理算法(为老年代设计)
老年代的对象在GC之后的存活率比较高。
标记存活对象后直接移动到一端
标记
内存初始状态:
[对象A][对象B][对象C][对象D][对象E][对象F]↓
标记存活对象:A、C、E存活,B、D、F死亡↓
标记结果:✓ × ✓ × ✓ ×
整理(移动存活对象)
将所有存活对象"向左对齐":
[对象A][对象C][对象E][空位][空位][空位]↑ ↑
存活对象紧凑排列 连续的空闲空间
直接清理右侧的空闲连续区域
垃圾收集器
CMS收集器
(Concurrent Mark Sweep)(并发标记扫描
符合需求:较为
关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。如网站。获取最短回收停顿时间为目标。
基于标记-清除算法
1)初始标记(CMS initial mark)
2)并发标记(CMS concurrent mark)
3)重新标记(CMS remark)
4)并发清除(CMS concurrent sweep)
其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(详见3.4.6节中关于增量更新的讲解),这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
G1收集器
传统分代堆 (固定边界)
┌─────────────────┐
│ Eden │ ← 固定大小
├─────────────────┤
│ Survivor 0 │ ← 固定大小
├─────────────────┤
│ Survivor 1 │ ← 固定大小
├─────────────────┤
│ 老年代 │ ← 固定大小
└─────────────────┘
前三个是新生代
Eden 圣经伊甸园 新对象的诞生地,纯洁初始
Survivor 生存挑战 经历GC考验的幸存者
对象的"生命旅程":
诞生期 (Eden)
┌─────────────────┐
│ 🌱 新对象大量诞生 │ ← 高出生率,高死亡率
│ 大量很快死亡 │
└─────────────────┘
↓ (Young GC)
成长期 (Survivor 0/1)
┌─────────────────┐ ┌─────────────────┐
│ 幸存对象来回复制(类似标记-复制算法) │ ↔ │ 促进内存整理 │
│ 年龄逐渐增长 │ │ 避免碎片化 │
└─────────────────┘ └─────────────────┘
↓ (年龄达到阈值)
成熟期 (Old Generation)
┌─────────────────┐
│ 🏛️ 长期存活对象 │ ← 低死亡率,偶尔Major GC
│ 相对稳定 │
└─────────────────┘
类载机制
本文的类型指的是可能是类也可能是接口
类型的生命周期

《Java虚拟机规范》:有且只有六种情况必须立即对类进行 “初始化”:
1)遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
·使用new键字实例化对象的时候。
·读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
·调用一个类型的静态方法的时候。
2)使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
3)当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
4)当虚拟机启动时,用户需要指定一个要执行的主类 (包含main()方法的那个类),虚拟机会先初始化这个主类。
5)当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
6)当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的****实现类发生了初始化,那该接口要在其之前被初始化。
类加载的过程
加载
加载是整个类加载的一个阶段,不要混.
在加载阶段,Java虚拟机需要完成以下三件事情:
1)通过一个类的全限定名来获取定义此类的二进制字节流。(并没有指明从哪里获取,因此

2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
加载数组类的特殊规则
一:如果数组的组件类型是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组C将被标识在加载该组件类型的类加载器的类名称空间上
二:如果数组的组件类型不是引用类型(例如int[]数组的组件类型为int),Java虚拟机将会把数组C标记为与引导类加载器关联。
引导类加载器 /启动类加载器 (不同的翻译)= JVM自带的、最核心的类加载器
它负责加载Java的基础类库
对于基本类型的数组,由于基本类型不是"类",所以它们的数组由这个"系统自带"的加载器管理
三:数组的可访问性
数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为public。
验证
确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
准备
正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段
注意这里的初始值通通是0,因为比如public static int value = 123,但是这里依旧是0,原因是value赋值为123的putstatic指令是程序被编译后,存放于类构造器()方法之中,所以把value赋值
为123的动作要到类的初始化阶段才会被执行。
解析
Java虚拟机将常量池内的符号引用替换为直接引用的过程
符号引用:可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
直接引用:可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。和虚拟机实现的内存布局直接相关的
初始化
初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。
初始化阶段就是执行类构造器()方法 的过程。
类加载器
在运行时将Java类的字节码(.class文件)动态地加载到JVM内存中,并转换成JVM可以执行的格式。
比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
分类

- 启动类加载器(Bootstrap Class Loader)
实现:C++编写,JVM组成部分
职责:加载Java核心库(jre/lib/rt.jar等)
特点:无法被Java程序直接引用 - 扩展类加载器(Extension Class Loader)
实现:Java语言实现,继承ClassLoader
职责:加载扩展目录(jre/lib/ext)的jar包
父加载器:启动类加载器 - 系统类加载器/应用程序类加载器
实现:Java语言实现
职责:加载用户类路径(ClassPath)的类库
特点:默认类加载器,可通过ClassLoader.getSystemClassLoader()获取
父加载器:扩展类加载器 - 自定义类加载器(Custom Class Loader)
目的:定制类加载方式
来源:网络、数据库、加密文件等
价值:扩展灵活性,增强安全性,体现Java动态性
双亲委派模型
双亲不是指的是两个父辈,而是英文的英译parent译过来的。
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(** 它的搜索范围中没有找到所需的类**)时,子加载器才会尝试自己去完成加载。
