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

187 阅读1小时+

⚠️ 重要说明

本篇文章以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内存区域的关系:

特性JMMJVM内存区域
性质抽象的逻辑概念物理的内存划分
关注点多线程并发访问共享变量程序运行时数据存储位置
主要内容主内存、工作内存堆、栈、方法区等
解决问题可见性、有序性、原子性数据存储和管理

对应关系:

  • JMM中的"主内存"主要对应JVM内存区域中的"堆"(存放对象实例)
  • JMM中的"工作内存"主要对应JVM内存区域中的"虚拟机栈局部变量表"和"程序计数器"

示例说明:

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内存区域中的虚拟机栈局部变量表。

特点:

  • 每个线程有独立的工作内存
  • 工作内存存储该线程使用的变量的副本
  • 线程不能直接访问主内存,只能通过工作内存访问

主内存与工作内存的关系:

┌─────────────┐
│  主内存      │ ← 存储所有共享变量(对应堆)
└──────┬──────┘
       │ 读取/写入
       ↓
┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│ 工作内存1    │    │ 工作内存2    │    │ 工作内存3    │
│ (线程1)      │    │ (线程2)      │    │ (线程3)      │
│ (对应栈)     │    │ (对应栈)     │    │ (对应栈)     │
└─────────────┘    └─────────────┘    └─────────────┘

内存可见性问题的产生:

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

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后面的操作
int x = 1;      // 操作1
int y = x + 1;  // 操作2:操作1 happens-before 操作2
  1. 监视器锁规则:解锁操作happens-before加锁操作
synchronized (lock) {
    x = 1;  // 操作1
}  // 解锁

synchronized (lock) {  // 加锁:解锁 happens-before 加锁
    int y = x;  // 能看到x=1
}
  1. volatile变量规则:volatile写操作happens-before volatile读操作
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变量对所有线程立即可见
private volatile boolean flag = false;  // 保证可见性

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

public boolean getFlag() {
    return flag;  // 能立即看到最新值
}
  1. 禁止指令重排序:volatile变量的读写操作不会被重排序
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不能保证复合操作的原子性
private volatile int count = 0;

public void increment() {
    count++;  // 这不是原子操作!
    // 相当于:
    // int temp = count;
    // temp = temp + 1;
    // count = temp;
    // 这三个操作之间可能被其他线程打断
}

正确做法:

// 使用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 整体架构图(重点)

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. 保证线程安全

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

工作机制:

线程私有区域:

线程1: [程序计数器] [虚拟机栈] [本地方法栈]
线程2: [程序计数器] [虚拟机栈] [本地方法栈]
线程3: [程序计数器] [虚拟机栈] [本地方法栈]

每个线程的私有区域完全独立

实际代码示例:

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. 资源共享

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

工作机制:

线程共享区域:

     [Java堆] [方法区]
        ↑      ↑
        │      │
   ┌────┼──────┼──┐
   │    │      │  │
线程1  线程2  线程3

所有线程共享堆和方法区

实际代码示例:

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的主要区域,需要垃圾回收
访问速度快速,无需同步,直接访问需要同步,可能较慢
数据隔离线程间完全隔离,互不影响线程间可以共享数据

实际应用场景:

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 数据如何在私有区域和共享区域之间流转

数据流转的完整过程:

步骤1:线程从共享区域读取对象引用到私有区域

┌────────────────────┐
│  共享区域(堆)     │
│  ┌──────────────┐ │
│  │  对象实例    │ │
│  │  value = 42  │ │
│  └──────┬───────┘ │
│         │         │
└─────────┼─────────┘
          │ 读取引用
          ↓
┌────────────────────┐
│  私有区域(栈)     │
│  ┌──────────────┐ │
│  │  obj引用     │ │ ← 引用存储在栈中
│  └──────────────┘ │
└────────────────────┘

步骤2:线程在私有区域中操作对象引用

┌────────────────────┐
│  私有区域(栈)     │
│  ┌──────────────┐ │
│  │  obj引用     │ │ ← 引用在栈中
│  │  localVar    │ │ ← 局部变量也在栈中
│  └──────────────┘ │
└────────────────────┘

步骤3:线程通过引用访问共享区域中的对象

┌────────────────────┐     通过引用访问      ┌────────────────────┐
│  私有区域(栈)     │ ──────────────────> │  共享区域(堆)     │
│  ┌──────────────┐ │                     │  ┌──────────────┐ │
│  │  obj引用     │ │                     │  │  对象实例    │ │
│  └──────────────┘ │                     │  │  value = 42  │ │
└────────────────────┘                     │  └──────────────┘ │
                                           └────────────────────┘

步骤4:修改共享区域中的对象(需要同步)

┌────────────────────┐     修改对象         ┌────────────────────┐
│  私有区域(栈)     │ ──────────────────> │  共享区域(堆)     │
│  ┌──────────────┐ │    (需要同步)      │  ┌──────────────┐ │
│  │  obj引用     │ │                     │  │  对象实例    │ │
│  └──────────────┘ │                     │  │  value = 43  │ │ ← 被修改
└────────────────────┘                     │  └──────────────┘ │
                                           └────────────────────┘

实际示例代码:

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的引用,它们都可以访问堆中的同一个对象
    }
}

多线程环境下的数据流转:

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. 数据隔离

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

实际好处:

public class BenefitsExample {
    // 局部变量(私有区域):无需同步,性能好
    public void method() {
        int local = 0;  // 存储在栈中,线程私有,无需同步
        local++;  // 快速,无需加锁
    }
    
    // 共享变量(共享区域):需要同步,但允许线程间通信
    private static int shared = 0;  // 存储在堆中,线程共享
    
    public synchronized void increment() {
        shared++;  // 需要同步,但允许多个线程协作
    }
}
2.3.6 多线程环境下的工作流程

多线程访问共享对象的完整流程:

场景:三个线程访问堆中的两个共享对象

┌──────────────────────────────────────────────┐
│          共享区域(堆)                        │
│  ┌──────────────────────────────────────┐   │
│  │         Java堆                       │   │
│  │  ┌────────────┐  ┌────────────┐    │   │
│  │  │   对象A    │  │   对象B    │    │   │
│  │  │ value = 1  │  │ value = 2  │    │   │
│  │  └─────┬──────┘  └─────┬──────┘    │   │
│  └────────┼───────────────┼────────────┘   │
└───────────┼───────────────┼─────────────────┘
            │               │
            │               │
    ┌───────┘               └───────┐
    │                               │
    ↓                               ↓
┌─────────┐                     ┌─────────┐
│ 私有区域 │                     │ 私有区域 │
│ (栈1)   │                     │ (栈2)   │
│ ┌─────┐ │                     │ ┌─────┐ │
│ │objA │ │                     │ │objB │ │
│ └─────┘ │                     │ └─────┘ │
└─────────┘                     └─────────┘
    ↑                               ↑
    │                               │
    │                               │
┌─────────┐                     ┌─────────┐
│ 线程1   │                     │ 线程2   │
│ 访问A   │                     │ 访问B   │
└─────────┘                     └─────────┘

            │
            ↓
    ┌───────────────┐
    │  私有区域      │
    │  (栈3)        │
    │  ┌─────────┐  │
    │  │objA引用 │  │ ← 也指向对象A
    │  │objB引用 │  │ ← 也指向对象B
    │  └─────────┘  │
    └───────────────┘
            ↑
            │
    ┌───────┘
    │
┌─────────┐
│ 线程3   │
│ 访问AB│
└─────────┘

工作流程:
1. 每个线程的栈是独立的(私有区域)
2. 多个线程可以共享堆中的对象(共享区域)
3. 线程通过栈中的引用访问堆中的对象
4. 访问共享对象需要同步机制(synchronized、volatile等)

实际代码示例:

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 内存区域大小关系图

内存大小关系:

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

大小关系说明:

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

2.5 对象在内存中的流转图

对象生命周期:

创建 → 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,回收不再使用的对象

实际代码示例:

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 作用

程序计数器是一块很小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。

具体作用:

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

工作示例:

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. 分支、循环、异常处理等控制流的基础

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)用于存储局部变量表、操作数栈、动态链接、方法出口等信息
  • 每个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程

实际示例:

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

示例:

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)的栈,用于保存计算过程中的中间结果。

作用:

  • 保存计算过程中的临时结果
  • 为其他指令提供操作数
  • 存放方法调用的参数和返回值

示例:

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。

产生原因:

  • 递归调用过深
  • 局部变量过多
  • 方法调用链过长

示例:

public class StackOverflow {
    private int count = 0;
    
    public void recursive() {
        count++;  // 每次递归,创建一个新的栈帧
        recursive();  // 无限递归,栈帧不断入栈,最终栈溢出
    }
}

解决方案:

  • 优化递归算法,改为迭代
  • 减少局部变量
  • 增加栈大小(-Xss参数)

2. OutOfMemoryError(内存溢出错误)

如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存时,会抛出OutOfMemoryError。

产生原因:

  • 创建线程过多,每个线程都需要栈空间
  • 栈设置过大,导致总内存不足

示例:

public class StackOOM {
    public static void main(String[] args) {
        while (true) {
            new Thread(() -> {
                while (true) {
                    // 线程不退出,栈空间不释放
                }
            }).start();  // 创建过多线程,导致OOM
        }
    }
}

解决方案:

  • 减少线程数
  • 减小栈大小
  • 优化线程使用方式

4.5 栈大小设置

-Xss参数:

-Xss1m  // 设置栈大小为1MB
-Xss2m  // 设置栈大小为2MB

默认值:

  • 不同平台和JVM版本不同
  • 通常为1MB左右(Linux/x86:1024KB,Windows:默认依赖于虚拟内存)

注意事项:

  • 栈大小设置过小:可能导致StackOverflowError
  • 栈大小设置过大:可能浪费内存,或导致可创建的线程数减少

4.6 与JMM工作内存的关系

对应关系:

  • JMM中的"工作内存"主要对应JVM内存区域中的"虚拟机栈局部变量表"
  • 局部变量表中存储的变量副本就是JMM工作内存的内容

示例:

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)调用

示例:

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 堆的结构图(详细)

Java堆结构:

┌─────────────────┐
│    新生代(1/3)   │
│  ┌───────────┐  │
│  │  Eden(80%)│  │
│  ├───────────┤  │
│  │ S0(10%)S1 │  │
│  └───────────┘  │
├─────────────────┤
│   老年代(2/3)    │
└─────────────────┘

6.2 堆的作用

主要作用:

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

代码示例:

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区是空的
  • 实现高效的复制算法
  • 避免内存碎片

对象在新生代中的流转:

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
   FromTo角色互换

5. 年龄达到阈值 → 晋升老年代
   对象年龄达到15(默认)→ 晋升到老年代

示例代码:

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区的一半,大于等于该年龄的对象进入老年代

示例:

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)

设置示例:

java -Xms512m -Xmx1024m -Xmn256m -XX:NewRatio=2 -XX:SurvivorRatio=8 MyApp

6.6 堆的异常

OutOfMemoryError: Java heap space

当堆内存不足以分配新对象时,会抛出OutOfMemoryError: Java heap space。

产生原因:

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

示例:

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内存区域中的"堆"
  • 共享变量存储在堆中
  • 堆是线程共享的,对应主内存的共享特性

示例:

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编译器编译后的机器码

示例:

public class MethodAreaExample {
    // 静态变量 → 方法区
    private static int staticVar = 10;
    
    // 类信息 → 方法区
    // - 类的名称、父类、方法、字段等信息都存储在方法区
    
    public void method() {
        // 字符串常量 → 方法区(运行时常量池)
        String str = "Hello";  // "Hello"字符串在方法区的常量池中
    }
}

7.3 JDK 8之前:永久代(PermGen)

永久代(Permanent Generation)的特点:

  • 堆的一部分,使用堆内存
  • 大小固定,需要预先设置
  • 容易发生内存溢出

大小设置:

-XX:PermSize=64m        // 初始永久代大小
-XX:MaxPermSize=256m    // 最大永久代大小

异常:

OutOfMemoryError: PermGen space

产生原因:

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

示例:

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时才会抛出异常

大小设置:

-XX:MetaspaceSize=64m        // 初始元空间大小
-XX:MaxMetaspaceSize=256m    // 最大元空间大小(默认无限制)

异常:

OutOfMemoryError: Metaspace

产生原因:

  1. 类加载过多
  2. 元数据过多
  3. MaxMetaspaceSize设置过小

示例:

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对象没有被引用

示例:

public class MethodAreaGC {
    public void example() {
        // 字符串常量可能被回收(如果没有被引用)
        String str1 = "Hello";  // 在常量池中
        // 如果str1不再被引用,常量"Hello"可能被回收
    }
}

8. 运行时常量池(Runtime Constant Pool)

8.1 作用

运行时常量池是方法区的一部分,用于存储编译期生成的字面量和符号引用。

存储内容:

  • 字面量:字符串、数字等常量值
  • 符号引用:类名、方法名、字段名等符号
  • 直接引用:类加载后,符号引用被解析为直接引用

8.2 常量池的内容

1. 字面量

public class ConstantPool {
    public void example() {
        String str = "Hello";    // 字面量"Hello"在常量池中
        int num = 100;           // 数字字面量
        boolean flag = true;     // 布尔字面量
    }
}

2. 符号引用

// 符号引用:类名、方法名、字段名等
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 常量池的回收

常量池中的常量可以被回收:

public class ConstantPoolGC {
    public void example() {
        String str1 = "Hello";  // "Hello"在常量池中
        str1 = null;            // str1不再引用"Hello"
        // 如果没有其他地方引用"Hello",它可能被GC回收
    }
}

String.intern()方法:

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. 分配和回收成本较高:分配和回收需要调用系统函数

示例:

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 直接内存的分配

分配方式:

// 方式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 对象创建流程图

对象创建流程:

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

10.2 类加载检查

检查过程:

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

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

如果没有加载:

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

示例:

public class ObjectCreation {
    public void create() {
        // 遇到new指令时,先检查MyClass是否已加载
        MyClass obj = new MyClass();  // 如果MyClass未加载,先执行类加载
    }
}

10.3 分配内存

内存分配的方式:

1. 指针碰撞(Bump the Pointer)

适用场景:堆内存规整(使用标记-整理算法)

指针碰撞:

[对象1][对象2][对象3][空闲][空闲]
                 ↑指针

分配后:
[对象1][对象2][对象3][新对象][空闲]
                         ↑指针后移

2. 空闲列表(Free List)

适用场景:堆内存不规整(使用标记-清除算法)

空闲列表:

堆状态: [对象1][空][对象2][空][对象3]
空闲列表: [位置1, 位置3]

分配: 从空闲列表中选择合适位置

适用于堆内存不规整

内存分配并发问题的解决方案:

问题: 多个线程同时分配内存时,可能出现线程安全问题。

解决方案1:CAS + 失败重试

// 伪代码
while (true) {
    if (CAS(指针位置, 预期值, 新值)) {
        // 分配成功
        break;
    } else {
        // 分配失败,重试
        continue;
    }
}

解决方案2:TLAB(Thread Local Allocation Buffer)

TLAB是每个线程在Eden区中独立的内存分配区域。

TLAB:

Eden区: [TLAB1] [TLAB2] [TLAB3] [共享区]
        线程1   线程2   线程3

每个线程有独立的TLAB,优先在此分配,减少竞争

TLAB的优势:

  • 避免多线程分配内存时的竞争
  • 提高内存分配效率
  • 减少同步开销

10.4 初始化零值

初始化过程:

内存分配完成后,JVM将分配的内存空间初始化为零值(不包括对象头)。

初始化的值:

  • 基本类型:0、false、0.0等
  • 引用类型:null

为什么需要初始化零值?

保证对象的实例字段可以不赋初始值就直接使用。

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. 按照声明顺序初始化实例变量

示例:

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 对象内存布局图

对象内存布局:

┌─────────────┐
│  对象头      │ (Mark Word + 类型指针 + 数组长度)
├─────────────┤
│  实例数据    │ (字段值)
├─────────────┤
│  对齐填充    │ (保证8字节对齐)
└─────────────┘

11.2 对象头详解

1. Mark Word(标记字段)

Mark Word是对象头的一部分,用于存储对象的运行时数据。

存储内容:

内容说明
hashCode对象的哈希码
GC年龄对象经历的GC次数
锁状态偏向锁、轻量级锁、重量级锁等
偏向线程ID偏向锁的线程ID
偏向时间戳偏向锁的时间戳

Mark Word的结构(64位JVM):

┌─────────────────────────────────────┐
│         Mark Word (64位)            │
├─────────────────────────────────────┤
│  锁状态  │  其他信息                │
├─────────────────────────────────────┤
│ 无锁    │ hashCode(31位) + GC年龄(4位) + ...│
│ 偏向锁  │ 线程ID(54位) + Epoch(2位) + ...│
│ 轻量级锁│ 指向栈中锁记录的指针(62位) + ...│
│ 重量级锁│ 指向monitor的指针(62位) + ...│
│ GC标记  │ 空(不需要记录信息)        │
└─────────────────────────────────────┘

示例:

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操作
  • 支持虚方法分派

示例:

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)

仅当对象是数组时,对象头才包含数组长度。

示例:

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. 子类字段在后:子类定义的字段在父类字段之后

示例:

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. 减少内存碎片:对齐可以减少内存碎片

示例:

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 对象大小的计算

计算公式:

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

实际计算示例:

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 句柄访问方式图

句柄访问:

栈引用 → 句柄池 → 对象实例数据
              → 类型数据

需要两次访问

句柄访问的特点:

  • 优点

    • 引用稳定:对象移动时只需更新句柄中的指针
    • GC效率高:GC时只需移动对象,不需要更新所有引用
  • 缺点

    • 需要两次访问:先访问句柄,再访问对象
    • 效率较低:多一次间接访问

12.2 直接指针访问方式图(HotSpot使用)

直接指针访问(HotSpot):

栈引用 → 对象实例数据 → 类型数据(通过对象头中类型指针)

只需一次访问

直接指针访问的特点:

  • 优点

    • 访问速度快:只需一次访问
    • 效率高:减少间接访问的开销
  • 缺点

    • 对象移动时需要更新所有引用
    • GC时开销较大:需要更新所有指向该对象的引用

12.3 两种方式的对比

特性句柄访问直接指针访问
访问次数两次(先访问句柄,再访问对象)一次(直接访问对象)
访问速度较慢较快
引用稳定性引用不变,对象移动只需更新句柄引用直接指向对象
GC开销较小(只需移动对象,更新句柄)较大(需要更新所有引用)
内存占用较大(需要额外的句柄池)较小(不需要句柄池)

HotSpot为什么使用直接指针访问?

HotSpot虚拟机使用直接指针访问,主要原因:

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

实际应用:

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是两个不同的运行时环境

关键区别:

特性JVMART
编译方式解释执行 + 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使用

整体架构图:

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. 数据隔离

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

实际好处:

// 私有区域:无需同步,性能好
public void method() {
    int local = 0;  // 在栈中,线程私有,无需同步
    local++;  // 快速,无需加锁
}

// 共享区域:需要同步,但允许线程间通信
private static int shared = 0;  // 在堆中,线程共享

public synchronized void increment() {
    shared++;  // 需要同步,但允许多个线程协作
}

5. 线程私有区域和共享区域各自的作用是什么?

  • 私有区域的作用:存储线程私有数据、记录执行状态、保证线程安全
  • 共享区域的作用:存储共享数据、线程间通信、资源共享

6. 数据是如何在私有区域和共享区域之间流转的?

数据流转的完整过程:

1. 读取引用:堆(共享) → 栈(私有)
   线程从堆中读取对象引用,存储到栈的局部变量表中

2. 操作引用:在栈中操作对象引用
   线程在栈中对引用进行操作(赋值、传递等)

3. 访问对象:栈(私有) → 堆(共享)
   线程通过栈中的引用访问堆中的对象

4. 修改对象:修改堆中的对象(需要同步)
   多个线程访问同一个对象时需要同步机制

实际代码示例:

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管理的最大一块内存区域

堆的结构图:

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. 对象创建的过程?请画出流程图

对象创建的完整流程:

对象创建流程:

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. 对象的内存布局?请画出布局图

对象在内存中的布局:

对象内存布局:

┌─────────────┐
│  对象头      │ 
│  - 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:句柄访问

句柄访问:

栈引用 → 句柄池 → 对象实例数据
              → 类型数据

访问过程:需要两次访问(先访问句柄,再访问对象)

方式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. 对象在堆中是如何存储的?请画出存储结构

对象在堆中的存储结构:

对象在堆中的布局:

┌─────────────┐
│  对象头      │ 
│  - 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. 对象在堆中的分配过程?请画出详细流程图

对象在堆中的分配过程:

分配过程:

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字节)
    • 存在条件:仅当对象是数组时才有
    • 内容:数组的长度

完整结构图:

对象头结构:

┌─────────────────┐
│  Mark Word      │ 8字节(64位)或4字节(32位)
│  (锁状态信息)    │
├─────────────────┤
│  类型指针        │ 8字节或4字节(可能压缩)
│  (指向类信息)    │
├─────────────────┤
│  数组长度        │ 4字节(仅数组对象)
│  (数组长度)      │
└─────────────────┘

4. TLAB的工作原理?

每个线程在Eden区有独立的TLAB,对象优先在TLAB中分配,TLAB用完后在共享区分配(需要同步)。

5. 指针碰撞和空闲列表的实现?

1. 指针碰撞(Bump the Pointer)实现:

指针碰撞:

[对象1][对象2][对象3][空闲]
                 ↑指针 → 后移 → [新对象]

2. 空闲列表(Free List)实现:

空闲列表:

[对象1][空][对象2][空][对象3]
空闲列表: [位置1, 位置3] → 选择位置分配新对象

对比:

特性指针碰撞空闲列表
适用场景堆内存规整堆内存不规整
分配速度快(只需移动指针)较慢(需要查找空闲列表)
内存碎片无碎片可能有碎片
实现复杂度简单较复杂

6. 对象访问定位的底层实现?

HotSpot使用直接指针访问的底层实现:

直接指针访问实现:

栈引用(地址) → 堆中的对象 → 对象头中类型指针 → 方法区中的类信息

只需一次内存访问,直接访问对象

优势:

  • 访问速度快:只需一次内存访问
  • 实现简单:引用直接指向对象
  • 内存占用小:不需要额外的句柄池

代价:

  • GC时需要更新所有引用:对象移动时需要更新所有指向该对象的引用
  • HotSpot通过优化GC算法来减少对象移动,降低这个开销

16.2 新技术题

1. 元空间相比永久代的优势?

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

  1. 提高GC效率

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

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

    • 永久代在堆中,受堆内存限制
    • 元空间在本地内存,不受堆内存限制,管理更灵活
  4. 与HotSpot分离

    • 永久代的实现与HotSpot耦合
    • 元空间的实现与HotSpot分离,更容易优化

实际好处:

// 永久代:固定大小,容易溢出
-XX:PermSize=64m
-XX:MaxPermSize=256m
// 如果类加载过多,可能溢出

// 元空间:动态调整,不容易溢出
-XX:MetaspaceSize=64m
-XX:MaxMetaspaceSize=256m
// 会根据实际使用情况动态调整

2. 现代JVM的内存管理优化?

  • 指针压缩
  • 对象对齐优化
  • TLAB优化
  • 大对象直接进入老年代

3. 大对象直接进入老年代的机制?

超过-XX:PretenureSizeThreshold设置的大对象直接进入老年代,避免在Eden区和Survivor区之间复制。

4. 动态对象年龄判定的原理?

Survivor区中相同年龄的对象大小超过Survivor区的一半时,大于等于该年龄的对象进入老年代。