Java内存模型(JMM)与JVM内存区域完整详解

⚠️ 重要说明

本篇文章以JVM内存区域的实际实现为主,JMM作为理论基础简要介绍。重点讲解JVM内存区域的物理结构、对象创建、内存布局等实际内容。

JMM(理论基础) :简要介绍抽象的内存访问规范
JVM内存区域(重点) :详细讲解物理的内存划分和实际实现
Android相关:说明Android(ART)与JVM的区别


第一部分:JMM基础理论(简要)

1. JMM基本概念

1.1 什么是Java内存模型

JMM的定义:

Java内存模型(Java Memory Model,JMM)是Java虚拟机规范中定义的一种规范,用于屏蔽不同硬件平台和操作系统的内存访问差异,使得Java程序在各种平台上都能正确地运行。

JMM的作用:

  1. 解决可见性问题:保证一个线程对共享变量的修改能够被其他线程看到
  2. 解决有序性问题:保证程序的执行顺序按照代码的先后顺序执行
  3. 解决原子性问题:配合其他机制保证操作的原子性

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规则:

  1. 程序顺序规则:同一线程内,前面的操作happens-before后面的操作
java 复制代码
int x = 1;      // 操作1
int y = x + 1;  // 操作2:操作1 happens-before 操作2
  1. 监视器锁规则:解锁操作happens-before加锁操作
java 复制代码
synchronized (lock) {
    x = 1;  // 操作1
}  // 解锁

synchronized (lock) {  // 加锁:解锁 happens-before 加锁
    int y = x;  // 能看到x=1
}
  1. volatile变量规则:volatile写操作happens-before volatile读操作
java 复制代码
volatile boolean flag = false;

// 线程1
flag = true;  // 写操作

// 线程2
if (flag) {  // 读操作:写 happens-before 读
    // 能立即看到flag=true
}
  1. 传递性规则:如果A happens-before B,B happens-before C,则A happens-before C

1.4 volatile关键字(简要)

volatile的特性:

  1. 保证可见性:volatile变量对所有线程立即可见
java 复制代码
private volatile boolean flag = false;  // 保证可见性

public void setFlag() {
    flag = true;  // 修改后立即对所有线程可见
}

public boolean getFlag() {
    return flag;  // 能立即看到最新值
}
  1. 禁止指令重排序: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
    }
}
  1. 不保证原子性: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内存区域分为两大类:

  1. 线程私有区域(每个线程独立):

    • 程序计数器
    • 虚拟机栈
    • 本地方法栈
  2. 线程共享区域(所有线程共享):

    • Java堆
    • 方法区
  3. 直接内存(堆外内存):

    • NIO使用,不属于JVM运行时数据区

2.2 内存区域分类说明

线程私有区域:

每个线程都有自己独立的私有区域,私有区域随着线程的创建而创建,随着线程的销毁而销毁。

  • 程序计数器:记录当前线程执行的字节码指令地址
  • 虚拟机栈:存储局部变量、方法参数等
  • 本地方法栈:为Native方法服务

线程共享区域:

所有线程共享同一块内存区域,共享区域在JVM启动时创建,JVM关闭时销毁。

  • Java堆:存储对象实例和数组
  • 方法区:存储类信息、常量、静态变量等

2.3 线程私有区域与共享区域的区别和作用

2.3.1 线程私有区域

定义:

线程私有区域是每个线程独立拥有的内存区域,线程之间无法访问对方的私有区域。私有区域随着线程的创建而创建,随着线程的销毁而销毁。

包含的区域:

  • 程序计数器
  • Java虚拟机栈
  • 本地方法栈

作用:

  1. 存储线程私有的数据

    • 局部变量:方法内部的变量
    • 方法参数:方法的参数
    • 返回值:方法的返回值
    • 中间结果:计算过程中的临时数据
  2. 记录线程的执行状态

    • 当前执行的指令位置(程序计数器)
    • 方法调用栈(虚拟机栈)
    • 异常处理信息
  3. 保证线程安全

    • 线程私有,无需同步
    • 天然线程安全
    • 避免数据竞争

工作机制:

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完全独立,互不影响

特点:

  1. 线程间隔离:每个线程的私有区域完全独立,互不影响
  2. 访问速度快:无需同步机制,直接访问,速度快
  3. 内存占用:内存占用与线程数成正比(每个线程都有自己的栈)
2.3.2 线程共享区域

定义:

线程共享区域是所有线程共享的内存区域,共享区域在JVM启动时创建,JVM关闭时销毁。所有线程都可以访问共享区域。

包含的区域:

  • Java堆:存储对象实例和数组
  • 方法区:存储类信息、常量、静态变量等(包括运行时常量池)

作用:

  1. 存储共享数据

    • 对象实例:所有线程都可以访问的对象
    • 类信息:类的元数据,所有线程共享
    • 常量:字符串常量等
  2. 线程间通信

    • 线程通过共享区域进行数据交换
    • 一个线程创建的对象,其他线程可以通过引用访问
  3. 资源共享

    • 多个线程共享同一个对象
    • 多个线程共享同一个类信息
    • 提高内存利用率

工作机制:

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对象,需要同步机制保证线程安全

特点:

  1. 线程间共享:所有线程可以访问同一个共享区域
  2. 需要同步机制:多个线程访问共享数据时,需要使用synchronized、volatile等机制保证线程安全
  3. 内存占用:内存占用与对象数量相关,与线程数无关
  4. 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 为什么需要区分私有区域和共享区域

设计原因:

  1. 性能考虑

    • 私有区域:访问快速,无需同步机制,提高执行效率
    • 共享区域:统一管理,减少内存占用,提高内存利用率
  2. 线程安全

    • 私有区域:天然线程安全,避免数据竞争,简化编程
    • 共享区域:需要同步机制保证线程安全,但允许线程间通信
  3. 内存管理

    • 私有区域:随线程销毁自动回收,管理简单
    • 共享区域:需要GC统一管理,生命周期长
  4. 数据隔离

    • 私有区域:线程间数据隔离,避免相互干扰
    • 共享区域:允许线程间数据共享和通信,实现协作

实际好处:

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() {
        // 工作代码
    }
}

关键点总结:

  1. 私有区域(栈)存储引用:对象引用存储在栈中,每个线程有自己的栈
  2. 共享区域(堆)存储对象:对象本身存储在堆中,所有线程可以共享
  3. 通过引用访问对象:线程通过栈中的引用访问堆中的对象
  4. 需要同步机制:多个线程访问同一个共享对象时,需要同步机制保证线程安全

2.4 内存区域大小关系图

scss 复制代码
内存大小关系:

┌─────────────┐
│  Java堆(最大)│
├─────────────┤
│   方法区    │
├─────────────┤
│  虚拟机栈   │ (每个线程)
├─────────────┤
│ 程序计数器  │ (很小)
├─────────────┤
│  直接内存   │
└─────────────┘

大小关系说明:

  1. Java堆:最大,通常占JVM内存的大部分(如-Xmx设置的堆大小)
  2. 方法区:中等,存储类信息、常量等(通常几十MB到几百MB)
  3. 虚拟机栈:较小,每个线程约1MB(线程数 × 1MB)
  4. 程序计数器:很小,可以忽略
  5. 直接内存:大小取决于使用情况

2.5 对象在内存中的流转图

markdown 复制代码
对象生命周期:

创建 → Eden区 → Minor GC → Survivor区 → 年龄增长 → 老年代 → Full GC → 回收

详细流程:
1. 创建对象 → Eden区
2. Eden区满 → Minor GC → 存活对象 → Survivor区
3. 多次GC后 → 年龄达到阈值 → 老年代
4. 老年代满 → Full GC → 对象回收

流转过程说明:

  1. 对象创建:新对象首先分配在Eden区
  2. Minor GC:当Eden区满时,触发Minor GC,存活对象复制到Survivor区
  3. Survivor区复制:对象在Survivor区的From和To之间来回复制,每经历一次GC,年龄+1
  4. 晋升老年代:对象年龄达到阈值(默认15)后,晋升到老年代
  5. 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 特点

  1. 唯一不会发生OutOfMemoryError的区域

    • 程序计数器占用的内存很小,几乎可以忽略
    • 不会因为程序计数器的原因导致内存溢出
  2. 线程私有

    • 每个线程都有自己的程序计数器
    • 线程之间互不影响
  3. 执行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 堆的作用

主要作用:

  1. 存储对象实例:所有通过new创建的对象都存储在堆中
  2. 存储数组:数组对象也存储在堆中
  3. 线程共享:所有线程共享堆内存,线程通过引用访问堆中的对象

代码示例:

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)的作用:

新生代是堆的一部分,用于存放新创建的对象。大部分对象在新生代中创建,并且很快就会被回收。

新生代的结构:

  1. Eden区(伊甸园)

    • 新对象首先分配在Eden区
    • 约占新生代的80%
    • 大部分对象生命周期很短,在Eden区就被回收
  2. 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仍然存活的对象会晋升到老年代。

老年代的特点:

  1. 对象生命周期长:存储的对象通常生命周期较长
  2. GC频率低:老年代的GC(Full GC)频率较低
  3. GC耗时长:Full GC通常比Minor GC耗时更长
  4. 占用空间大:通常占堆内存的2/3

对象进入老年代的条件:

  1. 年龄达到阈值:对象在Survivor区中经历GC的次数达到阈值(默认15次)
  2. 大对象:超过-XX:PretenureSizeThreshold设置的大对象直接进入老年代
  3. 动态年龄判定: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。

产生原因:

  1. 堆内存设置太小
  2. 创建的对象太多
  3. 内存泄漏:对象被引用无法回收

示例:

java 复制代码
public class HeapOOM {
    public static void main(String[] args) {
        List<Object> list = new ArrayList<>();
        while (true) {
            list.add(new Object());  // 不断创建对象,最终堆内存溢出
        }
    }
}

解决方案:

  1. 增加堆大小:-Xmx参数
  2. 优化代码:减少对象创建
  3. 排查内存泄漏:找出无法回收的对象

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

产生原因:

  1. 类加载过多
  2. 字符串常量过多(String.intern())
  3. 永久代大小设置过小

示例:

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

产生原因:

  1. 类加载过多
  2. 元数据过多
  3. 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及之后

为什么改用元空间?

  1. 提高GC效率:永久代的GC效率较低,元空间的GC效率更高
  2. 避免溢出:永久代固定大小容易溢出,元空间动态调整
  3. 更好的内存管理:元空间使用本地内存,管理更灵活
  4. 与HotSpot分离:元空间的实现与HotSpot分离,更容易优化

7.6 方法区的回收

方法区的回收主要包括:

  1. 常量池的回收

    • 常量池中的常量如果没有被引用,可以被回收
    • 字符串常量的回收
  2. 类型的卸载

    • 条件非常苛刻
    • 需要满足三个条件:
      • 该类的所有实例都已被回收
      • 加载该类的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 直接内存的特点

  1. 不受JVM堆内存限制:直接内存不在堆中,不受-Xmx限制
  2. 受操作系统内存限制:受物理内存和操作系统限制
  3. 分配和回收成本较高:分配和回收需要调用系统函数

示例:

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 直接内存的回收

回收机制:

  1. Full GC时回收:Full GC会回收直接内存
  2. System.gc()触发回收:调用System.gc()可能触发直接内存回收
  3. Cleaner机制:JDK 9之后使用Cleaner机制自动回收

注意:

  • 直接内存的回收不完全受JVM控制
  • 建议使用-XX:MaxDirectMemorySize限制大小

9.5 异常

OutOfMemoryError: Direct buffer memory

当直接内存不足时,会抛出OutOfMemoryError: Direct buffer memory。

产生原因:

  1. 直接内存使用过多
  2. 直接内存没有及时回收
  3. -XX:MaxDirectMemorySize设置过小

解决方案:

  1. 增加-XX:MaxDirectMemorySize
  2. 减少直接内存的使用
  3. 及时释放直接内存

第四部分:对象的创建与内存布局(重点)

10. 对象创建的完整流程

10.1 对象创建流程图

markdown 复制代码
对象创建流程:

1. 类加载检查
   ↓
2. 分配内存 (指针碰撞/空闲列表/TLAB)
   ↓
3. 初始化零值
   ↓
4. 设置对象头 (Mark Word/类型指针)
   ↓
5. 执行构造函数

10.2 类加载检查

检查过程:

当遇到new指令时,JVM首先检查:

  1. 检查这个指令的参数是否能在常量池中定位到类的符号引用
  2. 检查这个符号引用代表的类是否已被加载、解析和初始化

如果没有加载:

如果没有加载,则执行类加载过程(加载、验证、准备、解析、初始化)。

示例:

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 执行方法

方法的作用:

方法是类的构造函数,在对象创建的最后一步执行。

执行内容:

  1. 按照程序员的意愿初始化对象
  2. 执行构造函数中的代码
  3. 按照声明顺序初始化实例变量

示例:

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)存储:

对象的实例数据存储对象的实际字段值。

存储顺序:

  1. 父类字段在前:父类继承的字段在子类字段之前
  2. 相同宽度的字段分配在一起:long/double、int/float、short/char、byte/boolean
  3. 子类字段在后:子类定义的字段在父类字段之后

示例:

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字节的整数倍,对齐填充用于满足这个要求。

为什么需要对齐?

  1. 提高访问效率:对齐后的内存访问速度更快
  2. 硬件要求:某些CPU要求数据对齐
  3. 减少内存碎片:对齐可以减少内存碎片

示例:

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虚拟机使用直接指针访问,主要原因:

  1. 性能优先:直接访问速度快,减少间接访问开销
  2. 对象移动较少:现代GC算法(如G1)可以更好地处理对象移动
  3. 简单高效:实现简单,访问效率高

实际应用:

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 为什么需要了解这个区别

学习目的:

  1. 理解Android和传统Java应用的区别
  2. 知道JVM内存模型主要适用于传统Java应用
  3. 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的主要区域,需要垃圾回收
访问速度 快速,无需同步,直接访问 需要同步,可能较慢

关键区别总结:

  1. 生命周期:私有区域随线程,共享区域随JVM
  2. 线程安全:私有区域天然安全,共享区域需要同步
  3. GC影响:私有区域不涉及GC,共享区域是GC的主要区域
  4. 访问速度:私有区域快速,共享区域需要同步

4. 为什么需要区分线程私有区域和共享区域?

主要设计原因:

  1. 性能考虑

    • 私有区域:访问快速,无需同步机制,提高执行效率
    • 共享区域:统一管理,减少内存占用,提高内存利用率
  2. 线程安全

    • 私有区域:天然线程安全,避免数据竞争,简化编程
    • 共享区域:需要同步机制保证线程安全,但允许线程间通信
  3. 内存管理

    • 私有区域:随线程销毁自动回收,管理简单
    • 共享区域:需要GC统一管理,生命周期长
  4. 数据隔离

    • 私有区域:线程间数据隔离,避免相互干扰
    • 共享区域:允许线程间数据共享和通信,实现协作

实际好处:

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. 程序计数器的作用和特点?

作用:

  • 记录当前线程执行的字节码指令地址
  • 线程私有,每个线程独立
  • 分支、循环、跳转、异常处理、线程恢复等都需要依赖程序计数器

特点:

  1. 唯一不会发生OutOfMemoryError的区域

    • 程序计数器占用的内存很小,几乎可以忽略
    • 不会因为程序计数器的原因导致内存溢出
  2. 线程私有

    • 每个线程都有自己的程序计数器
    • 线程之间互不影响
  3. 执行Native方法时值为空

    • 执行Java方法时,记录字节码指令地址
    • 执行Native方法时,值为空(undefined)

实际应用:

  • 线程切换时保存和恢复执行位置
  • 分支、循环、异常处理等控制流的基础

8. Java虚拟机栈的作用和结构?请画出栈帧结构图

作用:

  • 存储局部变量、方法参数、返回值、中间结果等
  • 每个方法对应一个栈帧
  • 线程私有

栈帧结构图:

复制代码
栈帧结构:

┌─────────────┐
│ 局部变量表   │ - 存储方法参数和局部变量
├─────────────┤
│ 操作数栈     │ - 用于计算和临时存储
├─────────────┤
│ 动态链接     │ - 指向运行时常量池的方法引用
├─────────────┤
│ 方法返回地址 │ - 方法正常返回或异常返回的地址
└─────────────┘

栈帧各部分说明:

  1. 局部变量表:存储方法参数和局部变量(基本数据类型和对象引用)
  2. 操作数栈:用于计算和临时存储,LIFO结构
  3. 动态链接:指向运行时常量池的方法引用,支持多态
  4. 方法返回地址:方法退出后返回到哪条指令继续执行

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. 执行构造函数
   - 执行构造函数中的代码
   - 按照程序员的意愿初始化对象

详细说明:

  1. 类加载检查:遇到new指令时,检查类是否已加载
  2. 分配内存:在堆中分配内存空间(优先在Eden区)
  3. 初始化零值:将分配的内存初始化为零值,保证字段可以不赋值就使用
  4. 设置对象头:设置Mark Word和类型指针等元数据
  5. 执行构造函数:按照程序员的意愿初始化对象

2. 对象的内存布局?请画出布局图

对象在内存中的布局:

scss 复制代码
对象内存布局:

┌─────────────┐
│  对象头      │ 
│  - Mark Word│ (hashCode、GC年龄、锁状态)
│  - 类型指针  │ (指向类元数据)
│  - 数组长度  │ (仅数组对象有)
├─────────────┤
│  实例数据    │ (字段的实际值)
│  - 父类字段  │
│  - 子类字段  │
├─────────────┤
│  对齐填充    │ (保证8字节对齐)
└─────────────┘

对象大小 = 对象头 + 实例数据 + 对齐填充

各部分说明:

  1. 对象头(Header)

    • Mark Word:存储对象的hashCode、GC年龄、锁状态等
    • 类型指针:指向对象所属的类元数据
    • 数组长度:仅数组对象有
  2. 实例数据(Instance Data)

    • 存储对象的实际字段值
    • 父类字段在前,子类字段在后
    • 相同宽度的字段分配在一起
  3. 对齐填充(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字节对齐)
└─────────────┘

存储说明:

  1. 对象头:包含Mark Word和类型指针,用于标识对象和指向类信息
  2. 实例数据:存储对象的实际字段值,按照某种策略排序
  3. 对齐填充:保证对象大小是8字节的倍数,提高访问效率

内存分配位置:

  • 新对象首先分配在Eden区
  • 长期存活的对象会进入老年代

4. 多线程如何访问堆中的共享对象?

  1. 线程通过栈中的引用访问堆中的对象
  2. 多个线程可以共享同一个对象
  3. 访问共享对象需要同步机制保证线程安全

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):

  1. Mark Word(标记字段)

    • 大小:32位JVM占32位(4字节),64位JVM占64位(8字节)
    • 内容:hashCode、GC年龄、锁状态、偏向线程ID等
    • 特点:在不同锁状态下存储不同的信息
  2. 类型指针(Class Pointer)

    • 大小:32位JVM占32位(4字节),64位JVM占64位(8字节,可能压缩为32位)
    • 内容:指向对象所属的类元数据(方法区中的类信息)
    • 作用:JVM通过这个指针确定对象是哪个类的实例
  3. 数组长度(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. 元空间相比永久代的优势?

元空间相比永久代的主要优势:

  1. 提高GC效率

    • 永久代GC效率较低,容易导致Full GC
    • 元空间GC效率更高,可以更及时地回收类元数据
  2. 避免内存溢出

    • 永久代大小固定,容易发生OutOfMemoryError: PermGen space
    • 元空间大小动态调整,不容易溢出(除非达到MaxMetaspaceSize)
  3. 更好的内存管理

    • 永久代在堆中,受堆内存限制
    • 元空间在本地内存,不受堆内存限制,管理更灵活
  4. 与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区的一半时,大于等于该年龄的对象进入老年代。

相关推荐
林栩link21 小时前
【车载Android】「场景引擎」设计思路分享
android
parade岁月21 小时前
把 Git 提交变成“可执行规范”:Commit 规范体系与 Husky/Commitlint/Commitizen/Lint-staged 全链路介绍
前端·代码规范
青莲84321 小时前
Java内存回收机制(GC)完整详解
java·前端·面试
pas13621 小时前
29-mini-vue element搭建更新
前端·javascript·vue.js
IT=>小脑虎1 天前
2026版 React 零基础小白进阶知识点【衔接基础·企业级实战】
前端·react.js·前端框架
IT=>小脑虎1 天前
2026版 React 零基础小白入门知识点【基础完整版】
前端·react.js·前端框架
CCPC不拿奖不改名1 天前
python基础:python语言中的函数与模块+面试习题
开发语言·python·面试·职场和发展·蓝桥杯
FinClip1 天前
微信AI小程序“亿元计划”来了!你的APP如何一键接入,抢先变现?
前端·微信小程序·app
西西学代码1 天前
Flutter---框架
前端·flutter