⚠️ 重要说明
本篇文章以JVM内存区域的实际实现为主,JMM作为理论基础简要介绍。重点讲解JVM内存区域的物理结构、对象创建、内存布局等实际内容。
JMM(理论基础) :简要介绍抽象的内存访问规范
JVM内存区域(重点) :详细讲解物理的内存划分和实际实现
Android相关:说明Android(ART)与JVM的区别
第一部分:JMM基础理论(简要)
1. JMM基本概念
1.1 什么是Java内存模型
JMM的定义:
Java内存模型(Java Memory Model,JMM)是Java虚拟机规范中定义的一种规范,用于屏蔽不同硬件平台和操作系统的内存访问差异,使得Java程序在各种平台上都能正确地运行。
JMM的作用:
- 解决可见性问题:保证一个线程对共享变量的修改能够被其他线程看到
- 解决有序性问题:保证程序的执行顺序按照代码的先后顺序执行
- 解决原子性问题:配合其他机制保证操作的原子性
JMM与JVM内存区域的关系:
| 特性 | JMM | JVM内存区域 |
|---|---|---|
| 性质 | 抽象的逻辑概念 | 物理的内存划分 |
| 关注点 | 多线程并发访问共享变量 | 程序运行时数据存储位置 |
| 主要内容 | 主内存、工作内存 | 堆、栈、方法区等 |
| 解决问题 | 可见性、有序性、原子性 | 数据存储和管理 |
对应关系:
- JMM中的"主内存"主要对应JVM内存区域中的"堆"(存放对象实例)
- JMM中的"工作内存"主要对应JVM内存区域中的"虚拟机栈局部变量表"和"程序计数器"
示例说明:
java
public class JMMExample {
private int count = 0; // 存储在堆中(JVM内存区域)
// 对应JMM的主内存
public void increment() {
int local = count; // 从主内存读取到工作内存(栈的局部变量表)
local = local + 1; // 在工作内存中计算
count = local; // 写回主内存(堆)
}
}
1.2 主内存与工作内存(简要)
主内存(Main Memory):
主内存是JMM中的一个抽象概念,它存储所有共享变量。在物理上,主内存主要对应JVM内存区域中的堆。
特点:
- 所有线程共享主内存
- 共享变量存储在主内存中
- 主内存是线程之间通信的媒介
工作内存(Working Memory):
工作内存是JMM中的一个抽象概念,每个线程都有自己的工作内存。在物理上,工作内存主要对应JVM内存区域中的虚拟机栈局部变量表。
特点:
- 每个线程有独立的工作内存
- 工作内存存储该线程使用的变量的副本
- 线程不能直接访问主内存,只能通过工作内存访问
主内存与工作内存的关系:
scss
┌─────────────┐
│ 主内存 │ ← 存储所有共享变量(对应堆)
└──────┬──────┘
│ 读取/写入
↓
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 工作内存1 │ │ 工作内存2 │ │ 工作内存3 │
│ (线程1) │ │ (线程2) │ │ (线程3) │
│ (对应栈) │ │ (对应栈) │ │ (对应栈) │
└─────────────┘ └─────────────┘ └─────────────┘
内存可见性问题的产生:
java
public class VisibilityProblem {
private static boolean flag = false; // 主内存(堆)中的共享变量
public static void main(String[] args) {
// 线程1:修改flag
Thread thread1 = new Thread(() -> {
flag = true; // 修改工作内存中的副本,可能还没写回主内存
System.out.println("线程1:flag已设置为true");
});
// 线程2:读取flag
Thread thread2 = new Thread(() -> {
while (!flag) {
// 从工作内存读取,可能看不到线程1的修改
// 导致无限循环!
}
System.out.println("线程2:检测到flag为true");
});
thread2.start();
thread1.start();
}
}
解决方案:使用volatile
java
private static volatile boolean flag = false; // volatile保证可见性
1.3 happens-before规则(简要)
happens-before关系的含义:
happens-before是JMM中的一个核心概念,用于描述两个操作之间的偏序关系。如果操作A happens-before操作B,那么:
- 操作A的结果对操作B可见
- 操作A在操作B之前执行
注意: happens-before不等于时间先后顺序,但如果有happens-before关系,则保证可见性。
主要的happens-before规则:
- 程序顺序规则:同一线程内,前面的操作happens-before后面的操作
java
int x = 1; // 操作1
int y = x + 1; // 操作2:操作1 happens-before 操作2
- 监视器锁规则:解锁操作happens-before加锁操作
java
synchronized (lock) {
x = 1; // 操作1
} // 解锁
synchronized (lock) { // 加锁:解锁 happens-before 加锁
int y = x; // 能看到x=1
}
- volatile变量规则:volatile写操作happens-before volatile读操作
java
volatile boolean flag = false;
// 线程1
flag = true; // 写操作
// 线程2
if (flag) { // 读操作:写 happens-before 读
// 能立即看到flag=true
}
- 传递性规则:如果A happens-before B,B happens-before C,则A happens-before C
1.4 volatile关键字(简要)
volatile的特性:
- 保证可见性:volatile变量对所有线程立即可见
java
private volatile boolean flag = false; // 保证可见性
public void setFlag() {
flag = true; // 修改后立即对所有线程可见
}
public boolean getFlag() {
return flag; // 能立即看到最新值
}
- 禁止指令重排序:volatile变量的读写操作不会被重排序
java
private volatile boolean ready = false;
private int value = 0;
public void write() {
value = 42; // 操作1
ready = true; // 操作2:不会被重排序到操作1之前
}
public void read() {
if (ready) { // 操作3
System.out.println(value); // 能保证看到value=42
}
}
- 不保证原子性:volatile不能保证复合操作的原子性
java
private volatile int count = 0;
public void increment() {
count++; // 这不是原子操作!
// 相当于:
// int temp = count;
// temp = temp + 1;
// count = temp;
// 这三个操作之间可能被其他线程打断
}
正确做法:
java
// 使用synchronized
private int count = 0;
public synchronized void increment() {
count++; // 保证原子性
}
// 或使用AtomicInteger
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // 原子操作
}
第二部分:JVM内存区域整体架构(图表+详解)
2. JVM内存区域整体架构图
2.1 整体架构图(重点)
scss
JVM内存区域整体架构:
┌─────────────────────┐
│ 程序计数器 (私有) │
│ 虚拟机栈 (私有) │
│ 本地方法栈 (私有) │
└─────────────────────┘
┌─────────────────────┐
│ Java堆 (共享) │
│ ┌─────┐ ┌─────┐ │
│ │新生代│ │老年代│ │
│ │ Eden │ │ │ │
│ │ S0S1 │ │ │ │
│ └─────┘ └─────┘ │
└─────────────────────┘
┌─────────────────────┐
│ 方法区 (共享) │
│ 运行时常量池 │
└─────────────────────┘
┌─────────────────────┐
│ 直接内存 (堆外) │
└─────────────────────┘
架构说明:
JVM内存区域分为两大类:
-
线程私有区域(每个线程独立):
- 程序计数器
- 虚拟机栈
- 本地方法栈
-
线程共享区域(所有线程共享):
- Java堆
- 方法区
-
直接内存(堆外内存):
- NIO使用,不属于JVM运行时数据区
2.2 内存区域分类说明
线程私有区域:
每个线程都有自己独立的私有区域,私有区域随着线程的创建而创建,随着线程的销毁而销毁。
- 程序计数器:记录当前线程执行的字节码指令地址
- 虚拟机栈:存储局部变量、方法参数等
- 本地方法栈:为Native方法服务
线程共享区域:
所有线程共享同一块内存区域,共享区域在JVM启动时创建,JVM关闭时销毁。
- Java堆:存储对象实例和数组
- 方法区:存储类信息、常量、静态变量等
2.3 线程私有区域与共享区域的区别和作用
2.3.1 线程私有区域
定义:
线程私有区域是每个线程独立拥有的内存区域,线程之间无法访问对方的私有区域。私有区域随着线程的创建而创建,随着线程的销毁而销毁。
包含的区域:
- 程序计数器
- Java虚拟机栈
- 本地方法栈
作用:
-
存储线程私有的数据
- 局部变量:方法内部的变量
- 方法参数:方法的参数
- 返回值:方法的返回值
- 中间结果:计算过程中的临时数据
-
记录线程的执行状态
- 当前执行的指令位置(程序计数器)
- 方法调用栈(虚拟机栈)
- 异常处理信息
-
保证线程安全
- 线程私有,无需同步
- 天然线程安全
- 避免数据竞争
工作机制:
ini
线程私有区域:
线程1: [程序计数器] [虚拟机栈] [本地方法栈]
线程2: [程序计数器] [虚拟机栈] [本地方法栈]
线程3: [程序计数器] [虚拟机栈] [本地方法栈]
每个线程的私有区域完全独立
实际代码示例:
java
public class PrivateAreaExample {
public void method() {
// 局部变量存储在私有区域(虚拟机栈)
int localVar = 10; // 每个线程都有自己的localVar副本
String name = "test"; // 对象引用在栈中,对象本身在堆中
}
}
// 多线程执行
Thread thread1 = new Thread(() -> {
PrivateAreaExample obj = new PrivateAreaExample();
obj.method(); // 线程1有自己的栈,存储自己的localVar
});
Thread thread2 = new Thread(() -> {
PrivateAreaExample obj = new PrivateAreaExample();
obj.method(); // 线程2有自己的栈,存储自己的localVar
});
// 两个线程的localVar完全独立,互不影响
特点:
- 线程间隔离:每个线程的私有区域完全独立,互不影响
- 访问速度快:无需同步机制,直接访问,速度快
- 内存占用:内存占用与线程数成正比(每个线程都有自己的栈)
2.3.2 线程共享区域
定义:
线程共享区域是所有线程共享的内存区域,共享区域在JVM启动时创建,JVM关闭时销毁。所有线程都可以访问共享区域。
包含的区域:
- Java堆:存储对象实例和数组
- 方法区:存储类信息、常量、静态变量等(包括运行时常量池)
作用:
-
存储共享数据
- 对象实例:所有线程都可以访问的对象
- 类信息:类的元数据,所有线程共享
- 常量:字符串常量等
-
线程间通信
- 线程通过共享区域进行数据交换
- 一个线程创建的对象,其他线程可以通过引用访问
-
资源共享
- 多个线程共享同一个对象
- 多个线程共享同一个类信息
- 提高内存利用率
工作机制:
css
线程共享区域:
[Java堆] [方法区]
↑ ↑
│ │
┌────┼──────┼──┐
│ │ │ │
线程1 线程2 线程3
所有线程共享堆和方法区
实际代码示例:
java
public class SharedAreaExample {
// 静态变量存储在方法区(共享区域)
private static int sharedCount = 0; // 所有线程共享
// 实例变量存储在堆中(共享区域)
private int instanceCount = 0; // 对象在堆中,所有线程可以通过引用访问
public void increment() {
// 访问共享变量,需要同步
synchronized (this) {
sharedCount++; // 所有线程共享同一个sharedCount
instanceCount++;
}
}
}
// 多个线程共享同一个对象
SharedAreaExample shared = new SharedAreaExample(); // 对象在堆中(共享区域)
Thread thread1 = new Thread(() -> {
shared.increment(); // 线程1访问堆中的对象
});
Thread thread2 = new Thread(() -> {
shared.increment(); // 线程2也访问同一个对象
});
// 两个线程共享同一个shared对象,需要同步机制保证线程安全
特点:
- 线程间共享:所有线程可以访问同一个共享区域
- 需要同步机制:多个线程访问共享数据时,需要使用synchronized、volatile等机制保证线程安全
- 内存占用:内存占用与对象数量相关,与线程数无关
- GC的主要区域:共享区域是垃圾回收的主要区域
2.3.3 两者的区别对比
| 特性 | 线程私有区域 | 线程共享区域 |
|---|---|---|
| 生命周期 | 与线程相同,线程创建时创建,线程销毁时销毁 | 与JVM相同,JVM启动时创建,JVM关闭时销毁 |
| 访问方式 | 线程独立访问,每个线程有自己的区域 | 所有线程共享访问同一个区域 |
| 线程安全 | 天然线程安全,无需同步机制 | 需要同步机制保证线程安全 |
| 存储内容 | 局部变量、方法参数、返回值等 | 对象实例、类信息、常量等 |
| 内存占用 | 与线程数成正比(每个线程都有栈) | 与对象数相关,与线程数无关 |
| GC影响 | 不涉及GC,线程销毁时自动回收 | GC的主要区域,需要垃圾回收 |
| 访问速度 | 快速,无需同步,直接访问 | 需要同步,可能较慢 |
| 数据隔离 | 线程间完全隔离,互不影响 | 线程间可以共享数据 |
实际应用场景:
java
public class MemoryAreaExample {
// 静态变量 → 方法区(共享区域)
private static int classVar = 0;
public void method() {
// 局部变量 → 虚拟机栈(私有区域)
int localVar = 10;
// 对象创建 → 堆(共享区域)
// 对象引用 → 虚拟机栈(私有区域)
Object obj = new Object(); // obj引用在栈中,Object对象在堆中
// 静态变量访问 → 需要同步(共享区域)
synchronized (MemoryAreaExample.class) {
classVar++;
}
// 局部变量访问 → 无需同步(私有区域)
localVar++;
}
}
2.3.4 数据如何在私有区域和共享区域之间流转
数据流转的完整过程:
ini
步骤1:线程从共享区域读取对象引用到私有区域
┌────────────────────┐
│ 共享区域(堆) │
│ ┌──────────────┐ │
│ │ 对象实例 │ │
│ │ value = 42 │ │
│ └──────┬───────┘ │
│ │ │
└─────────┼─────────┘
│ 读取引用
↓
┌────────────────────┐
│ 私有区域(栈) │
│ ┌──────────────┐ │
│ │ obj引用 │ │ ← 引用存储在栈中
│ └──────────────┘ │
└────────────────────┘
步骤2:线程在私有区域中操作对象引用
┌────────────────────┐
│ 私有区域(栈) │
│ ┌──────────────┐ │
│ │ obj引用 │ │ ← 引用在栈中
│ │ localVar │ │ ← 局部变量也在栈中
│ └──────────────┘ │
└────────────────────┘
步骤3:线程通过引用访问共享区域中的对象
┌────────────────────┐ 通过引用访问 ┌────────────────────┐
│ 私有区域(栈) │ ──────────────────> │ 共享区域(堆) │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ obj引用 │ │ │ │ 对象实例 │ │
│ └──────────────┘ │ │ │ value = 42 │ │
└────────────────────┘ │ └──────────────┘ │
└────────────────────┘
步骤4:修改共享区域中的对象(需要同步)
┌────────────────────┐ 修改对象 ┌────────────────────┐
│ 私有区域(栈) │ ──────────────────> │ 共享区域(堆) │
│ ┌──────────────┐ │ (需要同步) │ ┌──────────────┐ │
│ │ obj引用 │ │ │ │ 对象实例 │ │
│ └──────────────┘ │ │ │ value = 43 │ │ ← 被修改
└────────────────────┘ │ └──────────────┘ │
└────────────────────┘
实际示例代码:
java
public class DataFlowExample {
// 共享变量:存储在堆中(共享区域)
private int sharedValue = 0;
public void processData() {
// 1. 局部变量存储在私有区域(栈)
int localVar = 10; // 存储在栈的局部变量表
// 2. 对象存储在共享区域(堆),引用存储在私有区域(栈)
DataFlowExample obj = new DataFlowExample(); // 对象在堆中,obj引用在栈中
// 3. 通过引用访问堆中的对象
int value = obj.sharedValue; // 通过栈中的引用访问堆中的对象
// 4. 修改堆中的对象(需要同步,因为是共享区域)
synchronized (this) {
obj.sharedValue = 100; // 修改堆中的共享变量,需要同步
}
// 5. 多个线程可以共享同一个对象
// 如果多个线程持有同一个obj的引用,它们都可以访问堆中的同一个对象
}
}
多线程环境下的数据流转:
java
public class MultiThreadDataFlow {
// 共享对象:存储在堆中(共享区域)
private static SharedObject shared = new SharedObject();
public static void main(String[] args) {
// 线程1
Thread thread1 = new Thread(() -> {
// 从共享区域读取引用(栈中的引用指向堆中的对象)
SharedObject obj = shared; // obj引用在栈1中,指向堆中的shared对象
// 通过引用访问堆中的对象
obj.setValue(1); // 修改堆中的对象
});
// 线程2
Thread thread2 = new Thread(() -> {
// 从共享区域读取引用(栈中的引用指向堆中的对象)
SharedObject obj = shared; // obj引用在栈2中,指向同一个shared对象
// 通过引用访问堆中的对象
obj.setValue(2); // 修改同一个对象,需要同步
});
thread1.start();
thread2.start();
// 两个线程的栈是独立的(私有区域)
// 但都通过引用访问堆中的同一个对象(共享区域)
// 需要同步机制保证线程安全
}
}
class SharedObject {
private int value = 0;
public synchronized void setValue(int v) {
this.value = v;
}
}
2.3.5 为什么需要区分私有区域和共享区域
设计原因:
-
性能考虑
- 私有区域:访问快速,无需同步机制,提高执行效率
- 共享区域:统一管理,减少内存占用,提高内存利用率
-
线程安全
- 私有区域:天然线程安全,避免数据竞争,简化编程
- 共享区域:需要同步机制保证线程安全,但允许线程间通信
-
内存管理
- 私有区域:随线程销毁自动回收,管理简单
- 共享区域:需要GC统一管理,生命周期长
-
数据隔离
- 私有区域:线程间数据隔离,避免相互干扰
- 共享区域:允许线程间数据共享和通信,实现协作
实际好处:
java
public class BenefitsExample {
// 局部变量(私有区域):无需同步,性能好
public void method() {
int local = 0; // 存储在栈中,线程私有,无需同步
local++; // 快速,无需加锁
}
// 共享变量(共享区域):需要同步,但允许线程间通信
private static int shared = 0; // 存储在堆中,线程共享
public synchronized void increment() {
shared++; // 需要同步,但允许多个线程协作
}
}
2.3.6 多线程环境下的工作流程
多线程访问共享对象的完整流程:
css
场景:三个线程访问堆中的两个共享对象
┌──────────────────────────────────────────────┐
│ 共享区域(堆) │
│ ┌──────────────────────────────────────┐ │
│ │ Java堆 │ │
│ │ ┌────────────┐ ┌────────────┐ │ │
│ │ │ 对象A │ │ 对象B │ │ │
│ │ │ value = 1 │ │ value = 2 │ │ │
│ │ └─────┬──────┘ └─────┬──────┘ │ │
│ └────────┼───────────────┼────────────┘ │
└───────────┼───────────────┼─────────────────┘
│ │
│ │
┌───────┘ └───────┐
│ │
↓ ↓
┌─────────┐ ┌─────────┐
│ 私有区域 │ │ 私有区域 │
│ (栈1) │ │ (栈2) │
│ ┌─────┐ │ │ ┌─────┐ │
│ │objA │ │ │ │objB │ │
│ └─────┘ │ │ └─────┘ │
└─────────┘ └─────────┘
↑ ↑
│ │
│ │
┌─────────┐ ┌─────────┐
│ 线程1 │ │ 线程2 │
│ 访问A │ │ 访问B │
└─────────┘ └─────────┘
│
↓
┌───────────────┐
│ 私有区域 │
│ (栈3) │
│ ┌─────────┐ │
│ │objA引用 │ │ ← 也指向对象A
│ │objB引用 │ │ ← 也指向对象B
│ └─────────┘ │
└───────────────┘
↑
│
┌───────┘
│
┌─────────┐
│ 线程3 │
│ 访问A和B│
└─────────┘
工作流程:
1. 每个线程的栈是独立的(私有区域)
2. 多个线程可以共享堆中的对象(共享区域)
3. 线程通过栈中的引用访问堆中的对象
4. 访问共享对象需要同步机制(synchronized、volatile等)
实际代码示例:
java
public class MultiThreadWorkflow {
// 共享对象:存储在堆中(共享区域)
private static SharedResource resource1 = new SharedResource();
private static SharedResource resource2 = new SharedResource();
public static void main(String[] args) {
// 线程1:访问resource1
Thread thread1 = new Thread(() -> {
// obj1引用存储在栈1中(私有区域)
SharedResource obj1 = resource1; // 引用指向堆中的resource1
// 通过引用访问堆中的对象(共享区域)
synchronized (obj1) {
obj1.doWork();
}
});
// 线程2:访问resource2
Thread thread2 = new Thread(() -> {
// obj2引用存储在栈2中(私有区域)
SharedResource obj2 = resource2; // 引用指向堆中的resource2
// 通过引用访问堆中的对象(共享区域)
synchronized (obj2) {
obj2.doWork();
}
});
// 线程3:同时访问resource1和resource2
Thread thread3 = new Thread(() -> {
// obj1和obj2引用存储在栈3中(私有区域)
SharedResource obj1 = resource1; // 引用指向堆中的resource1
SharedResource obj2 = resource2; // 引用指向堆中的resource2
// 通过引用访问堆中的对象(共享区域)
synchronized (obj1) {
obj1.doWork();
}
synchronized (obj2) {
obj2.doWork();
}
});
thread1.start();
thread2.start();
thread3.start();
}
}
class SharedResource {
public void doWork() {
// 工作代码
}
}
关键点总结:
- 私有区域(栈)存储引用:对象引用存储在栈中,每个线程有自己的栈
- 共享区域(堆)存储对象:对象本身存储在堆中,所有线程可以共享
- 通过引用访问对象:线程通过栈中的引用访问堆中的对象
- 需要同步机制:多个线程访问同一个共享对象时,需要同步机制保证线程安全
2.4 内存区域大小关系图
scss
内存大小关系:
┌─────────────┐
│ Java堆(最大)│
├─────────────┤
│ 方法区 │
├─────────────┤
│ 虚拟机栈 │ (每个线程)
├─────────────┤
│ 程序计数器 │ (很小)
├─────────────┤
│ 直接内存 │
└─────────────┘
大小关系说明:
- Java堆:最大,通常占JVM内存的大部分(如-Xmx设置的堆大小)
- 方法区:中等,存储类信息、常量等(通常几十MB到几百MB)
- 虚拟机栈:较小,每个线程约1MB(线程数 × 1MB)
- 程序计数器:很小,可以忽略
- 直接内存:大小取决于使用情况
2.5 对象在内存中的流转图
markdown
对象生命周期:
创建 → Eden区 → Minor GC → Survivor区 → 年龄增长 → 老年代 → Full GC → 回收
详细流程:
1. 创建对象 → Eden区
2. Eden区满 → Minor GC → 存活对象 → Survivor区
3. 多次GC后 → 年龄达到阈值 → 老年代
4. 老年代满 → Full GC → 对象回收
流转过程说明:
- 对象创建:新对象首先分配在Eden区
- Minor GC:当Eden区满时,触发Minor GC,存活对象复制到Survivor区
- Survivor区复制:对象在Survivor区的From和To之间来回复制,每经历一次GC,年龄+1
- 晋升老年代:对象年龄达到阈值(默认15)后,晋升到老年代
- Full GC:老年代满时,触发Full GC,回收不再使用的对象
实际代码示例:
java
public class ObjectLifecycle {
public void createObjects() {
// 1. 创建对象 → 分配在Eden区
Object obj1 = new Object(); // 新对象在Eden区
// 2. 如果Eden区满了,触发Minor GC
// 存活的对象会被复制到Survivor区
// 3. 对象在Survivor区之间复制
// 每次GC,对象年龄+1
// 4. 年龄达到阈值 → 晋升到老年代
// 默认年龄阈值是15
// 5. 老年代满时 → 触发Full GC
// 回收不再使用的对象
}
}
第三部分:各内存区域详解
3. 程序计数器(Program Counter Register)
3.1 作用
程序计数器是一块很小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
具体作用:
- 记录当前线程执行的字节码指令地址
- 线程私有,每个线程独立
- 分支、循环、跳转、异常处理、线程恢复等都需要依赖程序计数器来完成
工作示例:
java
public void method() {
int a = 1; // 程序计数器指向这条指令
int b = 2; // 程序计数器移动到下一条指令
int c = a + b; // 程序计数器继续移动
}
3.2 特点
-
唯一不会发生OutOfMemoryError的区域
- 程序计数器占用的内存很小,几乎可以忽略
- 不会因为程序计数器的原因导致内存溢出
-
线程私有
- 每个线程都有自己的程序计数器
- 线程之间互不影响
-
执行Native方法时值为空
- 当线程执行Java方法时,程序计数器记录的是正在执行的虚拟机字节码指令的地址
- 当线程执行Native方法(本地方法)时,程序计数器的值为空(undefined)
示意图:
线程1:
┌─────────────┐
│ 程序计数器 │
│ 地址:0x100 │ ← 指向当前执行的指令
└─────────────┘
线程2:
┌─────────────┐
│ 程序计数器 │
│ 地址:0x200 │ ← 指向当前执行的指令(独立)
└─────────────┘
线程3:
┌─────────────┐
│ 程序计数器 │
│ 地址:0x300 │ ← 指向当前执行的指令(独立)
└─────────────┘
3.3 实际应用
1. 线程切换时保存和恢复执行位置
当CPU从一个线程切换到另一个线程时,需要:
- 保存当前线程的程序计数器值
- 恢复下一个线程的程序计数器值
- 这样线程恢复时才能从上次执行的位置继续执行
2. 分支、循环、异常处理等控制流的基础
java
public void example() {
int x = 10;
if (x > 5) { // 程序计数器记录if判断的指令地址
x = 20; // 如果条件为真,跳转到这里
} else {
x = 0; // 如果条件为假,跳转到这里
}
for (int i = 0; i < 10; i++) { // 程序计数器记录循环的指令地址
// 循环体
}
}
4. Java虚拟机栈(Java Virtual Machine Stack)
4.1 作用
Java虚拟机栈是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型。
主要作用:
- 存储局部变量、方法参数、返回值、中间结果等
- 每个方法在执行时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息
- 每个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程
实际示例:
java
public void methodA() {
int a = 1; // 局部变量,存储在methodA的栈帧中
methodB(); // 调用methodB,创建methodB的栈帧
// methodB执行完后,methodB的栈帧出栈
}
public void methodB() {
int b = 2; // 局部变量,存储在methodB的栈帧中
methodC(); // 调用methodC,创建methodC的栈帧
}
public void methodC() {
int c = 3; // 局部变量,存储在methodC的栈帧中
}
// 栈帧结构:
// ┌─────────────┐
// │ methodC栈帧 │ ← 栈顶(当前执行的方法)
// ├─────────────┤
// │ methodB栈帧 │
// ├─────────────┤
// │ methodA栈帧 │
// └─────────────┘
4.2 栈帧结构图
栈帧结构:
┌─────────────┐
│ 局部变量表 │
├─────────────┤
│ 操作数栈 │
├─────────────┤
│ 动态链接 │
├─────────────┤
│ 方法返回地址 │
└─────────────┘
4.3 栈帧各部分详解
1. 局部变量表(Local Variables)
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。
存储内容:
- 方法参数:传入方法的参数
- 局部变量:方法内部定义的变量
- 对象引用:对象的引用(对象本身在堆中)
槽(Slot)的概念:
- 局部变量表以变量槽(Slot)为最小单位
- 32位数据类型(boolean、byte、char、short、int、float、reference)占用1个Slot
- 64位数据类型(long、double)占用2个Slot
示例:
java
public void method(int param1, long param2) {
int local1 = 10; // 占用1个Slot
long local2 = 20L; // 占用2个Slot
Object obj = new Object(); // 引用占用1个Slot,对象在堆中
}
2. 操作数栈(Operand Stack)
操作数栈是一个后进先出(LIFO)的栈,用于保存计算过程中的中间结果。
作用:
- 保存计算过程中的临时结果
- 为其他指令提供操作数
- 存放方法调用的参数和返回值
示例:
java
public int calculate() {
int a = 10; // 操作数栈:push 10
int b = 20; // 操作数栈:push 20
int c = a + b; // 操作数栈:pop 20, pop 10, push 30
return c; // 操作数栈:pop 30作为返回值
}
3. 动态链接(Dynamic Linking)
动态链接指向运行时常量池中该栈帧所属方法的引用。
作用:
- 在方法调用时,将符号引用转换为直接引用
- 支持多态:在运行时确定实际调用的方法
4. 方法返回地址(Return Address)
方法返回地址存储方法退出后返回到哪条指令继续执行。
两种情况:
- 正常返回:方法正常执行完毕,返回到调用者的下一条指令
- 异常返回:方法执行过程中抛出异常,通过异常处理表确定返回地址
4.4 栈的异常
1. StackOverflowError(栈溢出错误)
当线程请求的栈深度超过虚拟机允许的最大深度时,会抛出StackOverflowError。
产生原因:
- 递归调用过深
- 局部变量过多
- 方法调用链过长
示例:
java
public class StackOverflow {
private int count = 0;
public void recursive() {
count++; // 每次递归,创建一个新的栈帧
recursive(); // 无限递归,栈帧不断入栈,最终栈溢出
}
}
解决方案:
- 优化递归算法,改为迭代
- 减少局部变量
- 增加栈大小(-Xss参数)
2. OutOfMemoryError(内存溢出错误)
如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存时,会抛出OutOfMemoryError。
产生原因:
- 创建线程过多,每个线程都需要栈空间
- 栈设置过大,导致总内存不足
示例:
java
public class StackOOM {
public static void main(String[] args) {
while (true) {
new Thread(() -> {
while (true) {
// 线程不退出,栈空间不释放
}
}).start(); // 创建过多线程,导致OOM
}
}
}
解决方案:
- 减少线程数
- 减小栈大小
- 优化线程使用方式
4.5 栈大小设置
-Xss参数:
arduino
-Xss1m // 设置栈大小为1MB
-Xss2m // 设置栈大小为2MB
默认值:
- 不同平台和JVM版本不同
- 通常为1MB左右(Linux/x86:1024KB,Windows:默认依赖于虚拟内存)
注意事项:
- 栈大小设置过小:可能导致StackOverflowError
- 栈大小设置过大:可能浪费内存,或导致可创建的线程数减少
4.6 与JMM工作内存的关系
对应关系:
- JMM中的"工作内存"主要对应JVM内存区域中的"虚拟机栈局部变量表"
- 局部变量表中存储的变量副本就是JMM工作内存的内容
示例:
java
public class JMMRelation {
private int shared = 0; // 主内存(堆)
public void method() {
int local = shared; // 从主内存读取到工作内存(局部变量表)
local = local + 1; // 在工作内存中计算
shared = local; // 写回主内存(堆)
}
}
5. 本地方法栈(Native Method Stack)
5.1 作用
本地方法栈与虚拟机栈的作用非常相似,区别在于:
- 虚拟机栈为Java方法服务
- 本地方法栈为Native方法(本地方法)服务
Native方法:
- 使用native关键字声明的方法
- 通常用C/C++等语言实现
- 通过JNI(Java Native Interface)调用
示例:
java
public class NativeExample {
// Native方法声明
public native void nativeMethod();
static {
// 加载本地库
System.loadLibrary("nativeLib");
}
}
5.2 与虚拟机栈的区别
| 特性 | 虚拟机栈 | 本地方法栈 |
|---|---|---|
| 服务对象 | Java方法 | Native方法 |
| 存储内容 | Java方法的局部变量、参数等 | Native方法的局部变量、参数等 |
| 语言 | Java字节码 | C/C++等本地代码 |
| 实现 | JVM实现 | 可能由JVM或操作系统实现 |
注意: 有些JVM实现(如HotSpot)将虚拟机栈和本地方法栈合二为一。
5.3 异常情况
本地方法栈也会发生StackOverflowError和OutOfMemoryError,原因和虚拟机栈类似。
6. Java堆(Java Heap)- 重点
6.1 堆的结构图(详细)
scss
Java堆结构:
┌─────────────────┐
│ 新生代(1/3) │
│ ┌───────────┐ │
│ │ Eden(80%)│ │
│ ├───────────┤ │
│ │ S0(10%)S1 │ │
│ └───────────┘ │
├─────────────────┤
│ 老年代(2/3) │
└─────────────────┘
6.2 堆的作用
主要作用:
- 存储对象实例:所有通过new创建的对象都存储在堆中
- 存储数组:数组对象也存储在堆中
- 线程共享:所有线程共享堆内存,线程通过引用访问堆中的对象
代码示例:
java
public class HeapExample {
public void createObjects() {
// 对象存储在堆中
Object obj1 = new Object(); // 对象在堆中,obj1引用在栈中
Object obj2 = new Object(); // 对象在堆中,obj2引用在栈中
// 数组也存储在堆中
int[] array = new int[10]; // 数组对象在堆中,array引用在栈中
// 字符串对象也在堆中(字符串常量池也在堆中,JDK 8之后)
String str = new String("Hello"); // 字符串对象在堆中
}
}
6.3 新生代详解
新生代(Young Generation)的作用:
新生代是堆的一部分,用于存放新创建的对象。大部分对象在新生代中创建,并且很快就会被回收。
新生代的结构:
-
Eden区(伊甸园)
- 新对象首先分配在Eden区
- 约占新生代的80%
- 大部分对象生命周期很短,在Eden区就被回收
-
Survivor区(存活区)
- 分为From Survivor和To Survivor两个区域
- 每个约占新生代的10%
- 用于存放Minor GC后存活的对象
为什么需要Survivor区?
如果没有Survivor区,Minor GC后存活的对象会直接进入老年代,导致:
- 老年代很快被填满
- 频繁触发Full GC
- 影响性能
有了Survivor区,可以:
- 让对象在新生代多存活几次
- 只有真正长期存活的对象才进入老年代
- 减少Full GC的频率
为什么需要两个Survivor区?
使用两个Survivor区可以实现复制算法:
- Minor GC时,将Eden区和From Survivor中的存活对象复制到To Survivor
- 清空Eden区和From Survivor
- From Survivor和To Survivor角色互换
这样可以:
- 保证新生代始终有一个Survivor区是空的
- 实现高效的复制算法
- 避免内存碎片
对象在新生代中的流转:
vbnet
1. 对象创建 → Eden区
new Object() → Eden区
2. Eden区满 → 触发Minor GC
Eden区满 → Minor GC开始
3. 存活对象 → Survivor区
Eden区存活对象 → 复制到Survivor区(To)
清空Eden区
4. 对象在Survivor区之间复制
Minor GC → From Survivor存活对象 → 复制到To Survivor
对象年龄+1
From和To角色互换
5. 年龄达到阈值 → 晋升老年代
对象年龄达到15(默认)→ 晋升到老年代
示例代码:
java
public class YoungGeneration {
public void createObjects() {
// 新对象在Eden区
for (int i = 0; i < 100; i++) {
Object obj = new Object(); // 大部分对象很快被回收
}
// 长期存活的对象会经历多次GC后进入老年代
Object longLived = new Object(); // 如果多次GC后仍存活,会进入老年代
}
}
6.4 老年代详解
老年代(Old Generation)的作用:
老年代用于存放长期存活的对象。经过多次Minor GC仍然存活的对象会晋升到老年代。
老年代的特点:
- 对象生命周期长:存储的对象通常生命周期较长
- GC频率低:老年代的GC(Full GC)频率较低
- GC耗时长:Full GC通常比Minor GC耗时更长
- 占用空间大:通常占堆内存的2/3
对象进入老年代的条件:
- 年龄达到阈值:对象在Survivor区中经历GC的次数达到阈值(默认15次)
- 大对象:超过-XX:PretenureSizeThreshold设置的大对象直接进入老年代
- 动态年龄判定:Survivor区中相同年龄的对象大小超过Survivor区的一半,大于等于该年龄的对象进入老年代
示例:
java
public class OldGeneration {
// 长期存活的对象
private static List<Object> longLivedObjects = new ArrayList<>();
public void createLongLivedObjects() {
// 这些对象经过多次GC后仍存活,会进入老年代
for (int i = 0; i < 1000; i++) {
Object obj = new Object();
longLivedObjects.add(obj); // 对象被引用,不会被回收
}
}
}
6.5 堆的大小设置(简要)
常用参数:
-
-Xms:初始堆大小
- 例如:-Xms256m(初始堆256MB)
-
-Xmx:最大堆大小
- 例如:-Xmx1024m(最大堆1GB)
-
-Xmn:新生代大小
- 例如:-Xmn256m(新生代256MB)
-
-XX:NewRatio:新生代与老年代的比例
- 例如:-XX:NewRatio=2(新生代:老年代=1:2)
-
-XX:SurvivorRatio:Eden与Survivor的比例
- 例如:-XX:SurvivorRatio=8(Eden:Survivor=8:1)
设置示例:
bash
java -Xms512m -Xmx1024m -Xmn256m -XX:NewRatio=2 -XX:SurvivorRatio=8 MyApp
6.6 堆的异常
OutOfMemoryError: Java heap space
当堆内存不足以分配新对象时,会抛出OutOfMemoryError: Java heap space。
产生原因:
- 堆内存设置太小
- 创建的对象太多
- 内存泄漏:对象被引用无法回收
示例:
java
public class HeapOOM {
public static void main(String[] args) {
List<Object> list = new ArrayList<>();
while (true) {
list.add(new Object()); // 不断创建对象,最终堆内存溢出
}
}
}
解决方案:
- 增加堆大小:-Xmx参数
- 优化代码:减少对象创建
- 排查内存泄漏:找出无法回收的对象
6.7 与JMM主内存的关系
对应关系:
- JMM中的"主内存"主要对应JVM内存区域中的"堆"
- 共享变量存储在堆中
- 堆是线程共享的,对应主内存的共享特性
示例:
java
public class HeapAndJMM {
// 共享变量存储在堆中(主内存)
private int shared = 0; // 在堆中
public void thread1() {
// 从主内存(堆)读取
int local = shared; // 从堆读取到栈
local++; // 在栈中计算
shared = local; // 写回主内存(堆)
}
public void thread2() {
// 也从主内存(堆)读取
int local = shared; // 从堆读取到栈
// 如果thread1的修改还没写回,thread2可能看不到最新的值
// 这就是可见性问题
}
}
7. 方法区(Method Area)
7.1 方法区结构图
方法区结构:
┌─────────────┐
│ 类信息 │
├─────────────┤
│ 常量池 │
├─────────────┤
│ 静态变量 │
├─────────────┤
│ JIT编译代码 │
└─────────────┘
7.2 作用
方法区用于存储:
- 类信息:类的元数据、方法信息、字段信息等
- 常量:字符串常量、数字常量等(运行时常量池)
- 静态变量:类变量(static修饰的变量)
- JIT编译后的代码:被JIT编译器编译后的机器码
示例:
java
public class MethodAreaExample {
// 静态变量 → 方法区
private static int staticVar = 10;
// 类信息 → 方法区
// - 类的名称、父类、方法、字段等信息都存储在方法区
public void method() {
// 字符串常量 → 方法区(运行时常量池)
String str = "Hello"; // "Hello"字符串在方法区的常量池中
}
}
7.3 JDK 8之前:永久代(PermGen)
永久代(Permanent Generation)的特点:
- 堆的一部分,使用堆内存
- 大小固定,需要预先设置
- 容易发生内存溢出
大小设置:
ini
-XX:PermSize=64m // 初始永久代大小
-XX:MaxPermSize=256m // 最大永久代大小
异常:
makefile
OutOfMemoryError: PermGen space
产生原因:
- 类加载过多
- 字符串常量过多(String.intern())
- 永久代大小设置过小
示例:
java
public class PermGenOOM {
public static void main(String[] args) {
// 不断加载类,可能导致永久代溢出
for (int i = 0; i < 100000; i++) {
// 动态加载类
}
}
}
7.4 JDK 8及之后:元空间(Metaspace)
元空间(Metaspace)的特点:
- 使用本地内存(Native Memory),不在堆中
- 大小动态调整
- 不再受JVM堆内存限制
- 只有达到MaxMetaspaceSize时才会抛出异常
大小设置:
ini
-XX:MetaspaceSize=64m // 初始元空间大小
-XX:MaxMetaspaceSize=256m // 最大元空间大小(默认无限制)
异常:
makefile
OutOfMemoryError: Metaspace
产生原因:
- 类加载过多
- 元数据过多
- MaxMetaspaceSize设置过小
示例:
java
public class MetaspaceOOM {
public static void main(String[] args) {
// 不断生成动态类,可能导致元空间溢出
while (true) {
// 动态创建类
}
}
}
7.5 永久代与元空间的区别
| 特性 | 永久代(PermGen) | 元空间(Metaspace) |
|---|---|---|
| 存储位置 | 堆内存中 | 本地内存(Native Memory) |
| 大小限制 | 固定大小,需要预先设置 | 动态调整,默认无限制 |
| 内存管理 | 受JVM堆内存限制 | 不受JVM堆内存限制 |
| GC影响 | GC效率较低 | GC效率较高 |
| 溢出问题 | 容易溢出 | 相对不容易溢出 |
| 适用版本 | JDK 8之前 | JDK 8及之后 |
为什么改用元空间?
- 提高GC效率:永久代的GC效率较低,元空间的GC效率更高
- 避免溢出:永久代固定大小容易溢出,元空间动态调整
- 更好的内存管理:元空间使用本地内存,管理更灵活
- 与HotSpot分离:元空间的实现与HotSpot分离,更容易优化
7.6 方法区的回收
方法区的回收主要包括:
-
常量池的回收
- 常量池中的常量如果没有被引用,可以被回收
- 字符串常量的回收
-
类型的卸载
- 条件非常苛刻
- 需要满足三个条件:
- 该类的所有实例都已被回收
- 加载该类的ClassLoader已被回收
- 该类对应的java.lang.Class对象没有被引用
示例:
java
public class MethodAreaGC {
public void example() {
// 字符串常量可能被回收(如果没有被引用)
String str1 = "Hello"; // 在常量池中
// 如果str1不再被引用,常量"Hello"可能被回收
}
}
8. 运行时常量池(Runtime Constant Pool)
8.1 作用
运行时常量池是方法区的一部分,用于存储编译期生成的字面量和符号引用。
存储内容:
- 字面量:字符串、数字等常量值
- 符号引用:类名、方法名、字段名等符号
- 直接引用:类加载后,符号引用被解析为直接引用
8.2 常量池的内容
1. 字面量
java
public class ConstantPool {
public void example() {
String str = "Hello"; // 字面量"Hello"在常量池中
int num = 100; // 数字字面量
boolean flag = true; // 布尔字面量
}
}
2. 符号引用
java
// 符号引用:类名、方法名、字段名等
public class SymbolReference {
public void method() {
// 符号引用:类名、方法名
Object obj = new Object(); // Object是符号引用,类加载后解析为直接引用
obj.toString(); // toString是符号引用,解析为直接引用
}
}
3. 直接引用
类加载后,符号引用被解析为直接引用(指向实际的内存地址)。
8.3 与方法区的关系
JDK版本差异:
- JDK 8之前:运行时常量池是方法区(永久代)的一部分
- JDK 8及之后:运行时常量池是堆的一部分(字符串常量池移到堆中)
变化说明:
JDK 8将字符串常量池从永久代移到了堆中,这样:
- 字符串可以被GC回收
- 减少永久代/元空间的压力
- 提高GC效率
8.4 常量池的回收
常量池中的常量可以被回收:
java
public class ConstantPoolGC {
public void example() {
String str1 = "Hello"; // "Hello"在常量池中
str1 = null; // str1不再引用"Hello"
// 如果没有其他地方引用"Hello",它可能被GC回收
}
}
String.intern()方法:
java
public class StringIntern {
public void example() {
String str1 = new String("Hello"); // 对象在堆中
String str2 = str1.intern(); // 将字符串放入常量池
String str3 = "Hello"; // 从常量池获取
System.out.println(str2 == str3); // true,同一个对象
}
}
9. 直接内存(Direct Memory)
9.1 作用
直接内存是堆外内存,不属于JVM运行时数据区,也不受JVM堆内存限制。
主要用途:
- NIO(New I/O)使用
- 提高I/O性能
- 避免Java堆和Native堆之间的数据复制
为什么需要直接内存?
传统I/O需要将数据从内核空间复制到用户空间(Java堆),然后Java程序才能访问。使用直接内存可以:
- 直接在堆外内存中操作数据
- 减少数据复制次数
- 提高I/O性能
9.2 直接内存的特点
- 不受JVM堆内存限制:直接内存不在堆中,不受-Xmx限制
- 受操作系统内存限制:受物理内存和操作系统限制
- 分配和回收成本较高:分配和回收需要调用系统函数
示例:
java
import java.nio.ByteBuffer;
public class DirectMemoryExample {
public void useDirectMemory() {
// 分配直接内存
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024); // 1MB直接内存
// 使用直接内存进行I/O操作
// ... I/O操作 ...
// 直接内存由Full GC或System.gc()回收
}
}
9.3 直接内存的分配
分配方式:
java
// 方式1:通过ByteBuffer分配
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
// 方式2:底层调用Unsafe.allocateMemory()
// Unsafe.allocateMemory(size);
底层实现:
- 直接内存的分配通过调用Unsafe.allocateMemory()实现
- 这是本地方法,直接操作操作系统内存
9.4 直接内存的回收
回收机制:
- Full GC时回收:Full GC会回收直接内存
- System.gc()触发回收:调用System.gc()可能触发直接内存回收
- Cleaner机制:JDK 9之后使用Cleaner机制自动回收
注意:
- 直接内存的回收不完全受JVM控制
- 建议使用-XX:MaxDirectMemorySize限制大小
9.5 异常
OutOfMemoryError: Direct buffer memory
当直接内存不足时,会抛出OutOfMemoryError: Direct buffer memory。
产生原因:
- 直接内存使用过多
- 直接内存没有及时回收
- -XX:MaxDirectMemorySize设置过小
解决方案:
- 增加-XX:MaxDirectMemorySize
- 减少直接内存的使用
- 及时释放直接内存
第四部分:对象的创建与内存布局(重点)
10. 对象创建的完整流程
10.1 对象创建流程图
markdown
对象创建流程:
1. 类加载检查
↓
2. 分配内存 (指针碰撞/空闲列表/TLAB)
↓
3. 初始化零值
↓
4. 设置对象头 (Mark Word/类型指针)
↓
5. 执行构造函数
10.2 类加载检查
检查过程:
当遇到new指令时,JVM首先检查:
- 检查这个指令的参数是否能在常量池中定位到类的符号引用
- 检查这个符号引用代表的类是否已被加载、解析和初始化
如果没有加载:
如果没有加载,则执行类加载过程(加载、验证、准备、解析、初始化)。
示例:
java
public class ObjectCreation {
public void create() {
// 遇到new指令时,先检查MyClass是否已加载
MyClass obj = new MyClass(); // 如果MyClass未加载,先执行类加载
}
}
10.3 分配内存
内存分配的方式:
1. 指针碰撞(Bump the Pointer)
适用场景:堆内存规整(使用标记-整理算法)
css
指针碰撞:
[对象1][对象2][对象3][空闲][空闲]
↑指针
分配后:
[对象1][对象2][对象3][新对象][空闲]
↑指针后移
2. 空闲列表(Free List)
适用场景:堆内存不规整(使用标记-清除算法)
ini
空闲列表:
堆状态: [对象1][空][对象2][空][对象3]
空闲列表: [位置1, 位置3]
分配: 从空闲列表中选择合适位置
适用于堆内存不规整
内存分配并发问题的解决方案:
问题: 多个线程同时分配内存时,可能出现线程安全问题。
解决方案1:CAS + 失败重试
java
// 伪代码
while (true) {
if (CAS(指针位置, 预期值, 新值)) {
// 分配成功
break;
} else {
// 分配失败,重试
continue;
}
}
解决方案2:TLAB(Thread Local Allocation Buffer)
TLAB是每个线程在Eden区中独立的内存分配区域。
ini
TLAB:
Eden区: [TLAB1] [TLAB2] [TLAB3] [共享区]
线程1 线程2 线程3
每个线程有独立的TLAB,优先在此分配,减少竞争
TLAB的优势:
- 避免多线程分配内存时的竞争
- 提高内存分配效率
- 减少同步开销
10.4 初始化零值
初始化过程:
内存分配完成后,JVM将分配的内存空间初始化为零值(不包括对象头)。
初始化的值:
- 基本类型:0、false、0.0等
- 引用类型:null
为什么需要初始化零值?
保证对象的实例字段可以不赋初始值就直接使用。
java
public class ZeroInit {
private int value; // 自动初始化为0
private boolean flag; // 自动初始化为false
private Object obj; // 自动初始化为null
public void method() {
// 可以直接使用,不需要先赋值
System.out.println(value); // 输出0
System.out.println(flag); // 输出false
System.out.println(obj); // 输出null
}
}
10.5 设置对象头
对象头包含两部分信息:
1. Mark Word(标记字段)
存储对象的运行时数据:
- 对象的hashCode
- GC分代年龄
- 锁状态标志
- 线程持有的锁
- 偏向线程ID
- 偏向时间戳
2. 类型指针(Class Pointer)
指向对象所属的类元数据(方法区中的类信息)。
3. 数组长度(如果是数组)
如果是数组对象,对象头还包含数组长度。
10.6 执行方法
方法的作用:
方法是类的构造函数,在对象创建的最后一步执行。
执行内容:
- 按照程序员的意愿初始化对象
- 执行构造函数中的代码
- 按照声明顺序初始化实例变量
示例:
java
public class InitExample {
private int value;
private String name;
// 构造函数
public InitExample(int v, String n) {
this.value = v; // 按照程序员的意愿初始化
this.name = n;
}
}
// 对象创建过程:
// 1. 类加载检查
// 2. 分配内存
// 3. 初始化零值(value=0, name=null)
// 4. 设置对象头
// 5. 执行构造函数(value=10, name="test")
InitExample obj = new InitExample(10, "test");
11. 对象的内存布局
11.1 对象内存布局图
scss
对象内存布局:
┌─────────────┐
│ 对象头 │ (Mark Word + 类型指针 + 数组长度)
├─────────────┤
│ 实例数据 │ (字段值)
├─────────────┤
│ 对齐填充 │ (保证8字节对齐)
└─────────────┘
11.2 对象头详解
1. Mark Word(标记字段)
Mark Word是对象头的一部分,用于存储对象的运行时数据。
存储内容:
| 内容 | 说明 |
|---|---|
| hashCode | 对象的哈希码 |
| GC年龄 | 对象经历的GC次数 |
| 锁状态 | 偏向锁、轻量级锁、重量级锁等 |
| 偏向线程ID | 偏向锁的线程ID |
| 偏向时间戳 | 偏向锁的时间戳 |
Mark Word的结构(64位JVM):
scss
┌─────────────────────────────────────┐
│ Mark Word (64位) │
├─────────────────────────────────────┤
│ 锁状态 │ 其他信息 │
├─────────────────────────────────────┤
│ 无锁 │ hashCode(31位) + GC年龄(4位) + ...│
│ 偏向锁 │ 线程ID(54位) + Epoch(2位) + ...│
│ 轻量级锁│ 指向栈中锁记录的指针(62位) + ...│
│ 重量级锁│ 指向monitor的指针(62位) + ...│
│ GC标记 │ 空(不需要记录信息) │
└─────────────────────────────────────┘
示例:
java
public class MarkWordExample {
private int value;
public void example() {
Object obj = new Object();
// Mark Word存储:
// - obj的hashCode
// - GC年龄(初始为0)
// - 锁状态(初始为无锁)
// 调用hashCode()时,如果hashCode为0,会计算并写入Mark Word
int hash = obj.hashCode();
// 经历GC后,GC年龄增加
// 加锁时,锁状态改变
}
}
2. 类型指针(Class Pointer)
类型指针指向对象所属的类元数据(方法区中的类信息)。
作用:
- JVM通过类型指针确定对象是哪个类的实例
- 支持instanceof操作
- 支持虚方法分派
示例:
java
public class ClassPointerExample {
public void example() {
Object obj = new String("Hello");
// 通过类型指针,JVM知道obj实际上是String类型
if (obj instanceof String) { // 检查类型指针
String str = (String) obj; // 类型转换
}
}
}
3. 数组长度(Array Length)
仅当对象是数组时,对象头才包含数组长度。
示例:
java
public class ArrayLength {
public void example() {
int[] array = new int[10]; // 数组对象,对象头包含数组长度10
// 对象头结构:
// - Mark Word
// - 类型指针(指向int[]类)
// - 数组长度(10)
// - 实例数据(10个int值)
}
}
11.3 实例数据
实例数据(Instance Data)存储:
对象的实例数据存储对象的实际字段值。
存储顺序:
- 父类字段在前:父类继承的字段在子类字段之前
- 相同宽度的字段分配在一起:long/double、int/float、short/char、byte/boolean
- 子类字段在后:子类定义的字段在父类字段之后
示例:
java
class Parent {
int parentInt; // 4字节
long parentLong; // 8字节
}
class Child extends Parent {
int childInt; // 4字节
long childLong; // 8字节
}
// 对象布局(实例数据部分):
// 1. parentInt (4字节)
// 2. childInt (4字节) - 相同宽度放在一起
// 3. parentLong (8字节)
// 4. childLong (8字节)
11.4 对齐填充
对齐填充的作用:
对象大小必须是8字节的整数倍,对齐填充用于满足这个要求。
为什么需要对齐?
- 提高访问效率:对齐后的内存访问速度更快
- 硬件要求:某些CPU要求数据对齐
- 减少内存碎片:对齐可以减少内存碎片
示例:
java
public class PaddingExample {
private byte b; // 1字节
private int i; // 4字节
// 对象大小计算:
// 对象头:12字节(假设压缩后)
// b:1字节
// 填充:3字节(为了对齐)
// i:4字节
// 总大小:12 + 1 + 3 + 4 = 20字节
// 但对象大小必须是8的倍数,所以:
// 实际大小:24字节(20 + 4字节对齐填充)
}
11.5 对象大小的计算
计算公式:
对象大小 = 对象头 + 实例数据 + 对齐填充
实际计算示例:
java
public class SizeCalculation {
private byte b; // 1字节
private int i; // 4字节
private long l; // 8字节
private Object ref; // 引用4字节(压缩后)或8字节(未压缩)
// 64位JVM,开启指针压缩:
// 对象头:12字节(Mark Word 8字节 + 类型指针 4字节)
// b:1字节
// 填充:3字节
// i:4字节
// l:8字节
// ref:4字节
// 对齐填充:0字节(已经是8的倍数)
// 总大小:12 + 1 + 3 + 4 + 8 + 4 = 32字节
}
12. 对象的访问定位
12.1 句柄访问方式图
markdown
句柄访问:
栈引用 → 句柄池 → 对象实例数据
→ 类型数据
需要两次访问
句柄访问的特点:
-
优点:
- 引用稳定:对象移动时只需更新句柄中的指针
- GC效率高:GC时只需移动对象,不需要更新所有引用
-
缺点:
- 需要两次访问:先访问句柄,再访问对象
- 效率较低:多一次间接访问
12.2 直接指针访问方式图(HotSpot使用)
直接指针访问(HotSpot):
栈引用 → 对象实例数据 → 类型数据(通过对象头中类型指针)
只需一次访问
直接指针访问的特点:
-
优点:
- 访问速度快:只需一次访问
- 效率高:减少间接访问的开销
-
缺点:
- 对象移动时需要更新所有引用
- GC时开销较大:需要更新所有指向该对象的引用
12.3 两种方式的对比
| 特性 | 句柄访问 | 直接指针访问 |
|---|---|---|
| 访问次数 | 两次(先访问句柄,再访问对象) | 一次(直接访问对象) |
| 访问速度 | 较慢 | 较快 |
| 引用稳定性 | 引用不变,对象移动只需更新句柄 | 引用直接指向对象 |
| GC开销 | 较小(只需移动对象,更新句柄) | 较大(需要更新所有引用) |
| 内存占用 | 较大(需要额外的句柄池) | 较小(不需要句柄池) |
HotSpot为什么使用直接指针访问?
HotSpot虚拟机使用直接指针访问,主要原因:
- 性能优先:直接访问速度快,减少间接访问开销
- 对象移动较少:现代GC算法(如G1)可以更好地处理对象移动
- 简单高效:实现简单,访问效率高
实际应用:
java
public class AccessExample {
public void example() {
Object obj = new Object();
// HotSpot使用直接指针访问:
// 1. obj引用直接指向堆中的对象
// 2. 通过对象头中的类型指针找到类信息
// 3. 访问对象时只需要一次内存访问
obj.toString(); // 直接通过引用访问对象,效率高
}
}
第五部分:Android说明(简要)
13. Android与JVM的区别说明
13.1 重要说明
Android开发语言:
- Android开发使用Java语言(或Kotlin语言)
- 语法和Java相同
Android运行时:
- Android运行在ART(Android Runtime)上,不是JVM
- ART与JVM是两个不同的运行时环境
关键区别:
| 特性 | JVM | ART |
|---|---|---|
| 编译方式 | 解释执行 + JIT编译 | AOT编译 + JIT编译(Android 7.0+) |
| 内存管理 | JVM内存区域(堆、栈等) | ART有自己的内存管理机制 |
| 垃圾回收 | JVM的GC算法 | ART的GC算法 |
| 参数调优 | JVM参数(-Xmx等) | ART参数(不同的参数) |
重要结论:
- JVM内存参数调优不适用于Android开发
- Android开发需要了解ART的内存管理机制
- 虽然语法相同,但运行时环境完全不同
13.2 ART与JVM的主要区别(简要)
1. 编译方式不同:
- JVM:解释执行字节码 + JIT编译热点代码
- ART:AOT(Ahead-Of-Time)编译,安装时编译为机器码
2. 内存管理不同:
- JVM:使用本文介绍的JVM内存区域模型
- ART:有自己的内存管理机制,不完全等同于JVM
3. 垃圾回收不同:
- JVM:多种GC算法和收集器(Serial、Parallel、CMS、G1等)
- ART:使用自己的GC算法(并发标记清除等)
13.3 为什么需要了解这个区别
学习目的:
- 理解Android和传统Java应用的区别
- 知道JVM内存模型主要适用于传统Java应用
- Android开发需要了解ART的内存管理,而不是JVM
实际应用:
- 如果是传统Java应用开发,学习JVM内存模型
- 如果是Android开发,需要学习ART的内存管理机制
- 两者虽然有相似之处,但是不同的技术栈
第六部分:面试题
14. JVM内存区域面试题
14.1 基础概念题
1. JVM内存区域有哪些?请画出整体架构图
JVM内存区域包括:
- 线程私有区域:程序计数器、虚拟机栈、本地方法栈
- 线程共享区域:Java堆、方法区
- 直接内存:堆外内存,NIO使用
整体架构图:
scss
JVM内存区域整体架构:
┌─────────────────────┐
│ 程序计数器 (私有) │
│ 虚拟机栈 (私有) │
│ 本地方法栈 (私有) │
└─────────────────────┘
┌─────────────────────┐
│ Java堆 (共享) │
│ ┌─────┐ ┌─────┐ │
│ │新生代│ │老年代│ │
│ │ Eden │ │ │ │
│ │ S0S1 │ │ │ │
│ └─────┘ └─────┘ │
└─────────────────────┘
┌─────────────────────┐
│ 方法区 (共享) │
│ 运行时常量池 │
└─────────────────────┘
┌─────────────────────┐
│ 直接内存 (堆外) │
└─────────────────────┘
2. 哪些区域是线程共享的?哪些是线程私有的?
- 线程私有区域:程序计数器、虚拟机栈、本地方法栈
- 线程共享区域:Java堆、方法区
3. 线程私有区域和共享区域的区别是什么?
主要区别:
| 特性 | 线程私有区域 | 线程共享区域 |
|---|---|---|
| 生命周期 | 与线程相同,线程创建时创建,线程销毁时销毁 | 与JVM相同,JVM启动时创建,JVM关闭时销毁 |
| 访问方式 | 线程独立访问,每个线程有自己的区域 | 所有线程共享访问同一个区域 |
| 线程安全 | 天然线程安全,无需同步机制 | 需要同步机制保证线程安全 |
| 存储内容 | 局部变量、方法参数、返回值等 | 对象实例、类信息、常量等 |
| 内存占用 | 与线程数成正比(每个线程都有栈) | 与对象数相关,与线程数无关 |
| GC影响 | 不涉及GC,线程销毁时自动回收 | GC的主要区域,需要垃圾回收 |
| 访问速度 | 快速,无需同步,直接访问 | 需要同步,可能较慢 |
关键区别总结:
- 生命周期:私有区域随线程,共享区域随JVM
- 线程安全:私有区域天然安全,共享区域需要同步
- GC影响:私有区域不涉及GC,共享区域是GC的主要区域
- 访问速度:私有区域快速,共享区域需要同步
4. 为什么需要区分线程私有区域和共享区域?
主要设计原因:
-
性能考虑
- 私有区域:访问快速,无需同步机制,提高执行效率
- 共享区域:统一管理,减少内存占用,提高内存利用率
-
线程安全
- 私有区域:天然线程安全,避免数据竞争,简化编程
- 共享区域:需要同步机制保证线程安全,但允许线程间通信
-
内存管理
- 私有区域:随线程销毁自动回收,管理简单
- 共享区域:需要GC统一管理,生命周期长
-
数据隔离
- 私有区域:线程间数据隔离,避免相互干扰
- 共享区域:允许线程间数据共享和通信,实现协作
实际好处:
java
// 私有区域:无需同步,性能好
public void method() {
int local = 0; // 在栈中,线程私有,无需同步
local++; // 快速,无需加锁
}
// 共享区域:需要同步,但允许线程间通信
private static int shared = 0; // 在堆中,线程共享
public synchronized void increment() {
shared++; // 需要同步,但允许多个线程协作
}
5. 线程私有区域和共享区域各自的作用是什么?
- 私有区域的作用:存储线程私有数据、记录执行状态、保证线程安全
- 共享区域的作用:存储共享数据、线程间通信、资源共享
6. 数据是如何在私有区域和共享区域之间流转的?
数据流转的完整过程:
markdown
1. 读取引用:堆(共享) → 栈(私有)
线程从堆中读取对象引用,存储到栈的局部变量表中
2. 操作引用:在栈中操作对象引用
线程在栈中对引用进行操作(赋值、传递等)
3. 访问对象:栈(私有) → 堆(共享)
线程通过栈中的引用访问堆中的对象
4. 修改对象:修改堆中的对象(需要同步)
多个线程访问同一个对象时需要同步机制
实际代码示例:
java
public class DataFlow {
private int shared = 0; // 在堆中(共享区域)
public void method() {
// 步骤1:从堆读取引用到栈
DataFlow obj = this; // obj引用在栈中,指向堆中的对象
// 步骤2:在栈中操作引用
int local = obj.shared; // 通过引用访问堆中的对象
// 步骤3:修改堆中的对象(需要同步)
synchronized (this) {
obj.shared = 100; // 修改堆中的共享变量
}
}
}
7. 程序计数器的作用和特点?
作用:
- 记录当前线程执行的字节码指令地址
- 线程私有,每个线程独立
- 分支、循环、跳转、异常处理、线程恢复等都需要依赖程序计数器
特点:
-
唯一不会发生OutOfMemoryError的区域
- 程序计数器占用的内存很小,几乎可以忽略
- 不会因为程序计数器的原因导致内存溢出
-
线程私有
- 每个线程都有自己的程序计数器
- 线程之间互不影响
-
执行Native方法时值为空
- 执行Java方法时,记录字节码指令地址
- 执行Native方法时,值为空(undefined)
实际应用:
- 线程切换时保存和恢复执行位置
- 分支、循环、异常处理等控制流的基础
8. Java虚拟机栈的作用和结构?请画出栈帧结构图
作用:
- 存储局部变量、方法参数、返回值、中间结果等
- 每个方法对应一个栈帧
- 线程私有
栈帧结构图:
栈帧结构:
┌─────────────┐
│ 局部变量表 │ - 存储方法参数和局部变量
├─────────────┤
│ 操作数栈 │ - 用于计算和临时存储
├─────────────┤
│ 动态链接 │ - 指向运行时常量池的方法引用
├─────────────┤
│ 方法返回地址 │ - 方法正常返回或异常返回的地址
└─────────────┘
栈帧各部分说明:
- 局部变量表:存储方法参数和局部变量(基本数据类型和对象引用)
- 操作数栈:用于计算和临时存储,LIFO结构
- 动态链接:指向运行时常量池的方法引用,支持多态
- 方法返回地址:方法退出后返回到哪条指令继续执行
9. 栈帧包含哪些部分?
- 局部变量表
- 操作数栈
- 动态链接
- 方法返回地址
10. 局部变量表的作用?
存储方法参数和局部变量,包括基本数据类型和对象引用。
11. 操作数栈的作用?
用于计算和临时存储,LIFO结构。
12. 本地方法栈的作用?
为Native方法服务,与虚拟机栈类似但服务于本地方法。
13. Java堆的作用和结构?请画出堆的结构图
作用:
- 存储对象实例和数组
- 所有线程共享
- JVM管理的最大一块内存区域
堆的结构图:
scss
Java堆结构:
┌─────────────────┐
│ 新生代(1/3) │
│ ┌───────────┐ │
│ │ Eden(80%)│ │ - 新对象首先分配在这里
│ ├───────────┤ │
│ │ S0(10%)S1 │ │ - Minor GC后存活对象复制到这里
│ └───────────┘ │
├─────────────────┤
│ 老年代(2/3) │ - 长期存活的对象
└─────────────────┘
比例(默认):
新生代 : 老年代 = 1 : 2
Eden : Survivor0 : Survivor1 = 8 : 1 : 1
各部分说明:
- Eden区:新对象首先分配在这里,约占新生代的80%
- Survivor区:分为S0和S1,用于Minor GC后存活对象的暂存,各约占新生代的10%
- 老年代:存储长期存活的对象,约占堆的2/3
14. 新生代和老年代的区别?
- 新生代:对象生命周期短,GC频繁,占堆的1/3
- 老年代:对象生命周期长,GC频率低但耗时长,占堆的2/3
15. 为什么要有Survivor区?
让对象在新生代多存活几次,只有真正长期存活的对象才进入老年代,减少Full GC的频率。
16. 为什么要有两个Survivor区?
实现复制算法,保证新生代始终有一个Survivor区是空的,避免内存碎片。
17. 方法区的作用?
存储类信息、常量、静态变量、JIT编译后的代码。
18. 永久代和元空间的区别?
主要区别:
| 特性 | 永久代(PermGen) | 元空间(Metaspace) |
|---|---|---|
| 存储位置 | 堆内存中 | 本地内存(Native Memory) |
| 大小限制 | 固定大小,需要预先设置 | 动态调整,默认无限制 |
| 内存管理 | 受JVM堆内存限制 | 不受JVM堆内存限制 |
| GC影响 | GC效率较低 | GC效率较高 |
| 溢出问题 | 容易溢出(PermGen space) | 相对不容易溢出(Metaspace) |
| 适用版本 | JDK 8之前 | JDK 8及之后 |
为什么改用元空间?
- 提高GC效率:元空间的GC效率比永久代高
- 避免溢出:永久代固定大小容易溢出,元空间动态调整
- 更好的内存管理:元空间使用本地内存,管理更灵活
19. 为什么永久代被元空间替代?
提高GC效率、避免溢出、更好的内存管理、与HotSpot分离。
20. 运行时常量池的作用?
存储编译期生成的字面量和符号引用。
21. 直接内存的作用?
NIO使用,堆外内存,提高I/O性能。
14.2 深入理解题
1. 对象创建的过程?请画出流程图
对象创建的完整流程:
markdown
对象创建流程:
1. 类加载检查
- 检查类是否已加载
- 如果没有,执行类加载过程
↓
2. 分配内存
- 指针碰撞(堆内存规整)
- 空闲列表(堆内存不规整)
- TLAB(每个线程独立的分配区域)
↓
3. 初始化零值
- 将内存空间初始化为零值
- 基本类型:0、false、0.0
- 引用类型:null
↓
4. 设置对象头
- Mark Word(hashCode、GC年龄、锁状态等)
- 类型指针(指向类元数据)
- 数组长度(如果是数组)
↓
5. 执行构造函数
- 执行构造函数中的代码
- 按照程序员的意愿初始化对象
详细说明:
- 类加载检查:遇到new指令时,检查类是否已加载
- 分配内存:在堆中分配内存空间(优先在Eden区)
- 初始化零值:将分配的内存初始化为零值,保证字段可以不赋值就使用
- 设置对象头:设置Mark Word和类型指针等元数据
- 执行构造函数:按照程序员的意愿初始化对象
2. 对象的内存布局?请画出布局图
对象在内存中的布局:
scss
对象内存布局:
┌─────────────┐
│ 对象头 │
│ - Mark Word│ (hashCode、GC年龄、锁状态)
│ - 类型指针 │ (指向类元数据)
│ - 数组长度 │ (仅数组对象有)
├─────────────┤
│ 实例数据 │ (字段的实际值)
│ - 父类字段 │
│ - 子类字段 │
├─────────────┤
│ 对齐填充 │ (保证8字节对齐)
└─────────────┘
对象大小 = 对象头 + 实例数据 + 对齐填充
各部分说明:
-
对象头(Header)
- Mark Word:存储对象的hashCode、GC年龄、锁状态等
- 类型指针:指向对象所属的类元数据
- 数组长度:仅数组对象有
-
实例数据(Instance Data)
- 存储对象的实际字段值
- 父类字段在前,子类字段在后
- 相同宽度的字段分配在一起
-
对齐填充(Padding)
- 保证对象大小是8字节的倍数
- 提高内存访问效率
3. 对象头包含哪些信息?
- Mark Word:hashCode、GC年龄、锁状态等
- 类型指针:指向类元数据
- 数组长度:仅数组对象有
4. Mark Word的作用和结构?
Mark Word的作用:
Mark Word是对象头的一部分,用于存储对象的运行时数据。
存储内容:
- hashCode:对象的哈希码
- GC年龄:对象经历的GC次数(4位,最大15)
- 锁状态:偏向锁、轻量级锁、重量级锁等
- 偏向线程ID:偏向锁的线程ID
- 偏向时间戳:偏向锁的时间戳
Mark Word的结构(64位JVM):
| 锁状态 | Mark Word(64位) |
|---|---|
| 无锁 | hashCode(31位) + GC年龄(4位) + ... |
| 偏向锁 | 线程ID(54位) + Epoch(2位) + ... |
| 轻量级锁 | 指向栈中锁记录的指针(62位) + ... |
| 重量级锁 | 指向monitor的指针(62位) + ... |
| GC标记 | 空(不需要记录信息) |
特点:
- Mark Word在不同锁状态下存储不同的信息
- 32位JVM:32位,64位JVM:64位
- 这是synchronized锁升级的基础
5. 类型指针的作用?
指向对象所属的类元数据,用于确定对象类型、支持instanceof等。
6. 对象的访问定位方式有哪些?请画出两种方式的对比图
对象的访问定位有两种方式:
方式1:句柄访问
markdown
句柄访问:
栈引用 → 句柄池 → 对象实例数据
→ 类型数据
访问过程:需要两次访问(先访问句柄,再访问对象)
方式2:直接指针访问(HotSpot使用)
直接指针访问(HotSpot):
栈引用 → 对象实例数据 → 类型数据(通过对象头中类型指针)
访问过程:只需一次访问(直接访问对象)
两种方式的对比:
| 特性 | 句柄访问 | 直接指针访问 |
|---|---|---|
| 访问次数 | 两次(先访问句柄,再访问对象) | 一次(直接访问对象) |
| 访问速度 | 较慢 | 较快 |
| 引用稳定性 | 引用不变,对象移动只需更新句柄 | 引用直接指向对象 |
| GC开销 | 较小(只需移动对象,更新句柄) | 较大(需要更新所有引用) |
| 内存占用 | 较大(需要额外的句柄池) | 较小(不需要句柄池) |
HotSpot为什么使用直接指针访问?
- 性能优先:访问速度快,减少间接访问开销
- 对象移动较少:现代GC算法可以更好地处理对象移动
7. 句柄访问和直接指针访问的区别?
主要区别:
| 特性 | 句柄访问 | 直接指针访问(HotSpot) |
|---|---|---|
| 访问次数 | 两次(先访问句柄,再访问对象) | 一次(直接访问对象) |
| 访问速度 | 较慢 | 较快 |
| 引用稳定性 | 引用不变,对象移动只需更新句柄 | 引用直接指向对象 |
| GC开销 | 较小(只需移动对象,更新句柄) | 较大(需要更新所有引用) |
| 内存占用 | 较大(需要额外的句柄池) | 较小(不需要句柄池) |
| 实现复杂度 | 较高(需要维护句柄池) | 较低 |
HotSpot使用直接指针访问的原因:
- 访问速度快,只需一次内存访问
- 对象移动频率低,现代GC算法优化了对象移动
- 实现简单,不需要维护句柄池
8. HotSpot为什么使用直接指针访问?
性能优先,访问速度快,减少间接访问开销。
9. TLAB是什么?有什么作用?
Thread Local Allocation Buffer,每个线程在Eden区有独立的分配区域,避免多线程分配内存时的竞争,提高分配效率。
10. 指针碰撞和空闲列表的区别?
- 指针碰撞:适用于堆内存规整,移动指针分配连续内存
- 空闲列表:适用于堆内存不规整,从空闲列表分配
11. 对象大小如何计算?
对象头大小 + 实例数据大小 + 对齐填充
12. 对齐填充的作用?
保证对象大小是8字节的倍数,提高内存访问效率。
15. 综合面试题
15.1 综合理解题
1. JMM主内存与JVM堆的关系?
JMM中的"主内存"主要对应JVM内存区域中的"堆"。共享变量存储在堆中,堆是线程共享的。
2. JMM工作内存与JVM虚拟机栈的关系?
JMM中的"工作内存"主要对应JVM内存区域中的"虚拟机栈局部变量表"。变量副本存储在局部变量表中。
3. 对象在堆中是如何存储的?请画出存储结构
对象在堆中的存储结构:
scss
对象在堆中的布局:
┌─────────────┐
│ 对象头 │
│ - Mark Word│ (64位或32位)
│ - 类型指针 │ (32位或64位,可能压缩)
│ - 数组长度 │ (仅数组,32位)
├─────────────┤
│ 实例数据 │
│ - 字段值 │ (按顺序存储)
├─────────────┤
│ 对齐填充 │ (保证8字节对齐)
└─────────────┘
存储说明:
- 对象头:包含Mark Word和类型指针,用于标识对象和指向类信息
- 实例数据:存储对象的实际字段值,按照某种策略排序
- 对齐填充:保证对象大小是8字节的倍数,提高访问效率
内存分配位置:
- 新对象首先分配在Eden区
- 长期存活的对象会进入老年代
4. 多线程如何访问堆中的共享对象?
- 线程通过栈中的引用访问堆中的对象
- 多个线程可以共享同一个对象
- 访问共享对象需要同步机制保证线程安全
5. 为什么需要JMM?JVM内存区域不够吗?
JVM内存区域只定义了数据存储在哪里,但没有定义多线程环境下如何保证内存的可见性和有序性。JMM提供了内存访问规范,解决多线程并发访问的问题。
16. 高级面试题
16.1 深入原理题
1. 对象在堆中的分配过程?请画出详细流程图
对象在堆中的分配过程:
markdown
分配过程:
1. 类加载检查
↓
2. 选择分配方式
├─ 指针碰撞(堆规整)
├─ 空闲列表(堆不规整)
└─ TLAB(线程本地分配缓冲)
↓
3. 在Eden区分配
└─ 优先在TLAB中分配
└─ TLAB用完后在Eden区共享区分配
↓
4. 如果Eden区满 → Minor GC
↓
5. 存活对象复制到Survivor区
↓
6. 多次GC后晋升到老年代
分配方式说明:
- 指针碰撞:适用于堆内存规整,移动指针分配连续内存
- 空闲列表:适用于堆内存不规整,从空闲列表分配
- TLAB:每个线程在Eden区有独立的分配区域,减少竞争
2. Mark Word的详细结构?
Mark Word结构(64位JVM):
Mark Word在64位JVM中占8字节(64位),在不同锁状态下存储不同的信息:
| 锁状态 | Mark Word(64位) | 说明 |
|---|---|---|
| 无锁 | 25位未使用 + 31位hashCode + 1位未使用 + 4位GC年龄 + 1位偏向锁标志(0) + 2位锁标志(01) | 正常状态 |
| 偏向锁 | 54位线程ID + 2位Epoch + 1位未使用 + 4位GC年龄 + 1位偏向锁标志(1) + 2位锁标志(01) | 偏向锁状态 |
| 轻量级锁 | 62位指向栈中锁记录的指针 + 2位锁标志(00) | 轻量级锁状态 |
| 重量级锁 | 62位指向monitor的指针 + 2位锁标志(10) | 重量级锁状态 |
| GC标记 | 空(不需要记录信息) + 2位锁标志(11) | GC标记状态 |
关键点:
- Mark Word在不同锁状态下复用,存储不同的信息
- 锁标志位(最后2位)用于标识锁状态
- 这是synchronized锁升级机制的基础
3. 对象头的完整结构?
对象头的完整结构:
对象头(Object Header):
-
Mark Word(标记字段)
- 大小:32位JVM占32位(4字节),64位JVM占64位(8字节)
- 内容:hashCode、GC年龄、锁状态、偏向线程ID等
- 特点:在不同锁状态下存储不同的信息
-
类型指针(Class Pointer)
- 大小:32位JVM占32位(4字节),64位JVM占64位(8字节,可能压缩为32位)
- 内容:指向对象所属的类元数据(方法区中的类信息)
- 作用:JVM通过这个指针确定对象是哪个类的实例
-
数组长度(Array Length)
- 大小:32位整数(4字节)
- 存在条件:仅当对象是数组时才有
- 内容:数组的长度
完整结构图:
scss
对象头结构:
┌─────────────────┐
│ Mark Word │ 8字节(64位)或4字节(32位)
│ (锁状态信息) │
├─────────────────┤
│ 类型指针 │ 8字节或4字节(可能压缩)
│ (指向类信息) │
├─────────────────┤
│ 数组长度 │ 4字节(仅数组对象)
│ (数组长度) │
└─────────────────┘
4. TLAB的工作原理?
每个线程在Eden区有独立的TLAB,对象优先在TLAB中分配,TLAB用完后在共享区分配(需要同步)。
5. 指针碰撞和空闲列表的实现?
1. 指针碰撞(Bump the Pointer)实现:
css
指针碰撞:
[对象1][对象2][对象3][空闲]
↑指针 → 后移 → [新对象]
2. 空闲列表(Free List)实现:
ini
空闲列表:
[对象1][空][对象2][空][对象3]
空闲列表: [位置1, 位置3] → 选择位置分配新对象
对比:
| 特性 | 指针碰撞 | 空闲列表 |
|---|---|---|
| 适用场景 | 堆内存规整 | 堆内存不规整 |
| 分配速度 | 快(只需移动指针) | 较慢(需要查找空闲列表) |
| 内存碎片 | 无碎片 | 可能有碎片 |
| 实现复杂度 | 简单 | 较复杂 |
6. 对象访问定位的底层实现?
HotSpot使用直接指针访问的底层实现:
scss
直接指针访问实现:
栈引用(地址) → 堆中的对象 → 对象头中类型指针 → 方法区中的类信息
只需一次内存访问,直接访问对象
优势:
- 访问速度快:只需一次内存访问
- 实现简单:引用直接指向对象
- 内存占用小:不需要额外的句柄池
代价:
- GC时需要更新所有引用:对象移动时需要更新所有指向该对象的引用
- HotSpot通过优化GC算法来减少对象移动,降低这个开销
16.2 新技术题
1. 元空间相比永久代的优势?
元空间相比永久代的主要优势:
-
提高GC效率
- 永久代GC效率较低,容易导致Full GC
- 元空间GC效率更高,可以更及时地回收类元数据
-
避免内存溢出
- 永久代大小固定,容易发生OutOfMemoryError: PermGen space
- 元空间大小动态调整,不容易溢出(除非达到MaxMetaspaceSize)
-
更好的内存管理
- 永久代在堆中,受堆内存限制
- 元空间在本地内存,不受堆内存限制,管理更灵活
-
与HotSpot分离
- 永久代的实现与HotSpot耦合
- 元空间的实现与HotSpot分离,更容易优化
实际好处:
java
// 永久代:固定大小,容易溢出
-XX:PermSize=64m
-XX:MaxPermSize=256m
// 如果类加载过多,可能溢出
// 元空间:动态调整,不容易溢出
-XX:MetaspaceSize=64m
-XX:MaxMetaspaceSize=256m
// 会根据实际使用情况动态调整
2. 现代JVM的内存管理优化?
- 指针压缩
- 对象对齐优化
- TLAB优化
- 大对象直接进入老年代
3. 大对象直接进入老年代的机制?
超过-XX:PretenureSizeThreshold设置的大对象直接进入老年代,避免在Eden区和Survivor区之间复制。
4. 动态对象年龄判定的原理?
Survivor区中相同年龄的对象大小超过Survivor区的一半时,大于等于该年龄的对象进入老年代。