JVM 内存结构

我用通俗易懂的方式,来详细介绍一下 JVM 的内存结构。

为了让你更好地理解,我们先把 JVM 想象成一个 "Java 程序的操作系统"。就像电脑操作系统需要内存来运行各种程序一样,JVM 这个 "小操作系统" 也需要自己的一块内存区域来运行 Java 程序。

这块内存区域被 JVM 划分为几个不同的 "房间",每个房间有自己特定的用途和 "入住规则"。我们把这些 "房间" 称为内存区域。

核心概念:两种内存划分方式

在介绍具体的房间之前,你需要知道一个关键点:JVM 的内存可以从 "线程" 的角度分为两大类:

线程私有区 (Thread Private Area):每个线程都有自己独立的一份,不和其他线程共享。这块区域的内存随着线程的创建而创建,随着线程的结束而销毁。

线程共享区 (Thread Shared Area):所有线程都可以访问的公共区域。这块区域的内存随着 JVM 的启动而创建,随着 JVM 的关闭而销毁。

线程私有区(每个工人都有自己的工具)

这些区域是每个线程的 "私人空间",互不干扰。

1. 程序计数器 (Program Counter Register)

如果把 JVM(Java 虚拟机)比作 "正在读剧本的演员",那 程序计数器 就是演员手里的 "小书签"------ 它永远指着 "下一句该念哪句台词",确保演员不会忘词、不会跳戏,哪怕中途被打断,回来也能精准接戏。

先搞懂:程序计数器是 "干嘛的?"

核心作用就一个:记录当前线程 "下一条要执行的代码指令位置"

你写的 Java 代码编译后会变成 "字节码指令"(类似剧本里的一句句台词),JVM 执行代码时,就得按顺序一条一条读这些指令。程序计数器就是用来 "记位置" 的 ------ 比如现在刚执行完第 10 条指令,它就记着 "下一条该读第 11 条";如果遇到 if 分支要跳去第 20 条,它就立刻更新为 "下一条读第 20 条"。

关键:为什么必须要有它?(两个核心场景)
1. 线程切换时,不会 "断片"

现在的电脑都是 "多线程" 的 ------ 比如你一边聊微信,一边刷网页,CPU 会在不同线程间快速切换(给每个线程 "挤时间" 执行)。

举个例子:

  • 线程 A 正在执行代码的第 15 条指令,突然 CPU 说 "你先停一下,让线程 B 跑会儿";
  • 线程 A 暂停时,程序计数器会牢牢记住 "我停在第 15 条,下一条该读 16 条";
  • 等 CPU 再切回线程 A,它一看程序计数器的记录,就知道 "哦,上次读到这,继续从 16 条开始",不会从头再读一遍。

要是没有程序计数器,线程切换后就像 "断片" 了,根本不知道自己上次执行到哪,代码就乱套了。

2. 遇到分支、循环时,不会 "迷路"

代码不总是 "从头到尾直线执行",比如 if-else 分支、for 循环、break 跳转,这些都需要 "跳着读指令"。

比如这段代码:

复制代码
int a = 5;
if (a > 3) {
    System.out.println("大于3"); // 分支1:对应第 10 条指令
} else {
    System.out.println("小于等于3"); // 分支2:对应第 15 条指令
}
  • 执行到 if 判断时,JVM 会先看条件:如果 a>3 成立,就把程序计数器改成 "下一条读第 10 条";如果不成立,就改成 "下一条读第 15 条";
  • 这样 JVM 就不会在分支里 "迷路",能精准跳转到该执行的指令位置。

循环也是同理:每次循环结束,程序计数器会 "跳回" 循环开始的指令位置,直到满足退出条件才往下走。

补充:程序计数器的两个 "小特点"
  1. 线程私有:每个线程都有自己的 "小书签",互不干扰。比如线程 A 的计数器记着第 10 条,线程 B 的记着第 20 条,不会弄混。
  2. 永远不会 "内存不够":它是 JVM 里最小的内存区域,只需要存 "指令位置"(一个数字),大小固定,永远不会出现 "内存溢出" 的问题。
一句话总结程序计数器

它就是线程执行代码的 "定位小书签"------ 记着下一条该读哪条指令,确保线程切换不 "断片"、分支循环不 "迷路",是 JVM 能有序执行代码的 "导航仪"。

2. Java虚拟机栈 (Java Virtual Machine Stack)

如果把 JVM(Java 虚拟机)比作一家 "餐厅",那 虚拟机栈 就是每个 "厨师"(线程)专属的 "操作台 + 任务清单"------ 厨师要做什么菜(执行什么方法)、需要哪些食材(变量)、做到哪一步了(执行进度),全在这个操作台上记着,而且每个厨师的操作台互不干扰。

先搞懂:虚拟机栈是 "给谁用的?"

首先明确一个核心:虚拟机栈是 "线程私有" 的。就像餐厅里每个厨师都有自己的操作台,Java 里每个线程启动时,JVM 都会给它分配一个专属的虚拟机栈。线程结束,这个栈也会跟着销毁,不会和其他线程 "抢资源"。

它的唯一作用,就是支撑 "方法调用" 和 "方法执行" ------ 比如你写的 main() 方法、add() 方法,只要被调用,就会在对应的虚拟机栈里 "占个位置" 干活。

再看:虚拟机栈里装的是什么?

虚拟机栈不是 "一整块混沌的空间",而是由一个个 "栈帧"(Stack Frame)组成的。你可以把 "栈帧" 理解成 "单次方法调用的任务包"------ 每次调用一个方法,就会创建一个栈帧,然后 "压" 进虚拟机栈的顶端(类似叠盘子,新的盘子放最上面);等方法执行完,这个栈帧就会被 "弹" 出去(盘子从最上面拿走)。

比如这段代码:

复制代码
public class Demo {
    public static void main(String[] args) { // 方法1
        int a = 1;
        int b = add(a, 2); // 调用方法2
        System.out.println(b);
    }

    public static int add(int x, int y) { // 方法2
        return x + y;
    }
}

它的虚拟机栈操作是这样的:

  1. 启动线程执行 main(),创建 "main 栈帧",压入栈顶;
  2. main() 里调用 add(),创建 "add 栈帧",压到 "main 栈帧" 上面(此时 add 栈帧是 "当前工作帧");
  3. add() 执行完,"add 栈帧" 弹出销毁;
  4. 回到 main() 继续执行,直到 main() 结束,"main 栈帧" 弹出,虚拟机栈清空。
关键:一个栈帧里有什么?(用 "做菜" 比喻)

每个栈帧(单次方法调用的任务包)里,装着 4 样核心东西,正好对应 "厨师做菜的全流程":

1. 局部变量表:"食材摆放区"

就像厨师把要用到的鸡蛋、面粉、调料整齐摆放在操作台上,局部变量表就是用来放 "方法参数" 和 "方法内部定义的变量" 的地方。比如 add(int x, int y) 方法的局部变量表,会先放参数 xy,如果方法里还有 int temp = x + y,那 temp 也会存在这里。而且这个 "摆放区" 的大小是固定的 ------ 编译时就确定了要放多少变量,执行时不会突然 "不够用"。

2. 操作数栈:"炒菜的锅"

厨师不会直接在食材摆放区炒菜,得把食材倒进锅里混合翻炒;操作数栈就是方法执行时的 "临时运算区"。比如算 x + y 时,步骤是:

  • 先把 x(比如 1)"倒进" 操作数栈;
  • 再把 y(比如 2)"倒进" 操作数栈;
  • 执行 "加法" 指令,从栈里 "捞" 出 1 和 2,算完得 3,再把 3 "倒回" 栈里;
  • 最后把 3 从栈里 "捞出来",作为方法返回值。
3. 动态链接:"菜谱索引"

厨师做菜时,可能需要查 "红烧肉的做法在菜谱第 5 页";动态链接就是告诉方法:"你要调用的其他方法(比如 System.out.println()),它的代码在 JVM 的哪个位置"。比如 main() 里调用 println(),动态链接会帮 main() 找到 println() 方法的 "真实地址",确保能准确调用到。

4. 方法出口:"上菜后回哪继续"

厨师做完一道菜,得知道接下来要做什么 ------ 是继续做下一道,还是关火下班?方法出口就是记录 "方法执行完后,要回到调用它的地方继续执行"。比如 add() 执行完,要回到 main() 里 "int b = add(...)" 这行的下一句,也就是 System.out.println(b),方法出口就存着这个 "回到哪里" 的位置。

常见问题:虚拟机栈会出问题吗?

会!主要两种情况,对应 "操作台不够用":

1. 栈溢出(StackOverflowError):"盘子叠太高塌了"

如果方法调用嵌套太深(比如递归没终止条件),虚拟机栈里的栈帧会越压越多,超过 JVM 规定的栈大小,就会 "叠塌"。比如这段无限递归代码,马上会报栈溢出:

复制代码
public static void recursion() {
    recursion(); // 无限调用自己,栈帧越压越多
}
2. 内存溢出(OutOfMemoryError):"没地方放新操作台了"

虽然每个线程的虚拟机栈不大,但如果同时启动成千上万的线程,每个线程都要一个 "操作台",JVM 总的内存不够分配新的虚拟机栈,就会报内存溢出。

一句话总结虚拟机栈

它是每个线程专属的 "方法执行工作台",用 "栈帧" 记录每次方法调用的食材(局部变量)、操作(运算)、索引(链接)和返回位置(出口),方法调用时压栈、执行完弹栈,是 Java 代码能 "一步步跑起来" 的核心支撑。

3. 本地方法栈(Native Method Stack)

如果把 JVM(Java 虚拟机)比作 "Java 代码的小世界",那 本地方法栈 就是这个小世界的 "海关 + 翻译官"------ 当 Java 代码想 "走出世界",调用电脑底层的 "本土功能"(比如读文件、调硬件)时,就得通过本地方法栈这个 "海关",它还会帮 Java 和底层语言(比如 C/C++)"翻译沟通"。

先搞懂:为什么需要 "本地方法"?

Java 代码虽然方便跨平台,但有个 "弱点":它不能直接操作电脑底层的硬件或系统功能(比如直接读硬盘数据、控制显卡)------ 这是为了安全和跨平台,故意 "屏蔽" 了这些危险操作。

但实际开发中又离不开这些功能(比如你用 Java 读一个文件,总得和电脑的文件系统打交道吧?),这时候就需要 "本地方法":

  • 本地方法:用 C/C++ 等 "底层语言" 写的代码,能直接调用操作系统 API 或操作硬件;
  • Java 代码不用自己写底层逻辑,只要喊一句 "调用某个本地方法",就能让底层代码帮它干活。

本地方法栈,就是 Java 调用这些 "底层帮手" 时,专门用来 "记笔记、管流程" 的地方。

再看:本地方法栈是 "怎么干活的?"

和虚拟机栈类似,本地方法栈也是 线程私有 的 ------ 每个线程调用本地方法时,都会在自己的本地方法栈里 "记事情",互不干扰。

它的核心工作,就是用 "栈帧" 管理 "本地方法的调用流程"------ 每次调用一个本地方法,就创建一个 "本地栈帧" 压进栈里;方法执行完,栈帧就弹出去销毁。

我们拿 "Java 读文件" 举个具体例子,看看本地方法栈怎么运作:

  1. Java 代码发起请求 :你写 new FileReader("test.txt"),Java 知道 "读文件要调底层功能",于是喊:"调用本地方法 read0()!"(这个 read0() 就是 C 写的);
  2. 创建本地栈帧 :JVM 给这个 read0() 调用,在本地方法栈里创建一个 "本地栈帧"------ 里面记着 read0() 需要的参数(比如文件路径)、临时变量(比如读出来的字节数据);
  3. "翻译" 并执行底层代码:本地栈帧帮 Java 把 "读 test.txt" 的请求,翻译成 C 语言能懂的指令,然后让 C 代码去调用操作系统的 "文件读取 API";
  4. 返回结果:C 代码读完文件,把数据通过本地栈帧 "递回" 给 Java;
  5. 销毁栈帧read0() 执行完,对应的本地栈帧从栈里弹出去,内存释放。
关键:本地方法栈和虚拟机栈的区别?

很多人会把它俩搞混,其实一句话就能分清:

  • 虚拟机栈 :管 "Java 自己写的方法"(比如你写的 add()main())的调用;
  • 本地方法栈 :管 "Java 调用的 C/C++ 写的本地方法"(比如 read0()System.currentTimeMillis())的调用。

就像两个并行的 "工作台":一个给 Java 自己的代码用,一个给 Java 找的 "底层帮手" 用,互不干扰。

一句话总结本地方法栈

它是 Java 代码调用底层功能的 "海关 + 工作台"------ 帮 Java 对接 C/C++ 写的本地方法,记录调用参数和临时数据,确保底层功能能顺利执行,还能把结果安全递回给 Java,是 Java 连接 "小世界" 和 "底层大环境" 的关键桥梁。

线程共享区(工厂的公共设施)

这些区域是所有线程的 "公共设施",是内存管理的核心和难点。

1. Java堆 (Java Heap)

把 JVM(Java 虚拟机)想象成一个大型的、自动化的 "垃圾处理工厂"

  • Java 程序 :就是不断向这个工厂 "生产" 各种 "产品"(也就是 new 出来的对象)的客户。
  • Java 堆:就是这个工厂里最核心、最巨大的 "产品仓库"。所有生产出来的产品,都必须先存放在这里。

1. Java 堆是什么?

Java 堆 是 JVM 管理的内存中最大的一块

它的核心作用就是:存放所有通过 new 关键字创建的对象实例和数组。

只要你在代码中写下 new Object()new User() 或者 new int[10],所产生的对象或数组数据,就会被分配到 Java 堆中。

2. Java 堆的特点
  1. 线程共享:这个 "产品仓库" 是所有 "生产线"(线程)共享的。任何一个线程创建的对象,理论上其他线程都可以访问到(当然,需要通过引用)。
  2. 动态分配:仓库的空间不是一开始就被占满的,而是随着程序运行,不断有新对象被 "放进来",旧对象被 "清理出去"。
  3. 垃圾回收的主战场 :这是 Java 堆最重要的特点。这个 "仓库" 是自动管理 的,你不需要手动去清理不再需要的对象。JVM 内置了一个 "清洁机器人"------垃圾回收器(Garbage Collector, GC),它会自动找出仓库里那些没人要的 "废弃产品"(不再被引用的对象),然后把它们清理掉,腾出空间给新的产品。
3. 为什么要分 "新生代" 和 "老年代"?

你可能听说过 Java 堆内部还分了 "新生代" 和 "老年代",这是为了让 "清洁机器人"(GC)工作得更高效。这基于一个重要的 "生活经验":

大部分产品都是 "一次性" 的,刚生产出来没多久就没用了;只有少数产品能 "经久耐用",长期被使用。

基于这个经验,仓库被分成了两个区域:

  1. 新生代(Young Generation)

    • 比喻:像一个 "快消品货架"。
    • 存放 :所有新创建的对象。
    • 特点:对象 "周转率" 极高。GC 在这里工作非常频繁,但速度很快。大部分对象在新生代里 "诞生",也在新生代里 "死亡"。
  2. 老年代(Old Generation / Tenured Generation)

    • 比喻:像一个 "收藏品仓库"。
    • 存放:那些在新生代里 "存活" 了很久的对象。它们就像 "老员工",经历了多次 GC 清理都没被回收。
    • 特点:对象相对稳定,GC 在这里工作的频率很低,但一旦工作,耗时会比较长。

工作流程

  • 新对象先放在 "快消品货架"(新生代)。
  • "清洁机器人" 定期打扫货架,把没用的垃圾清掉。
  • 如果一个对象在货架上被打扫了好几次都还在(说明它有用),就会被 "升级" 到 "收藏品仓库"(老年代)。
  • 当 "收藏品仓库" 也满了,就会进行一次大规模的彻底清理(Full GC)。
4. Java 堆会出什么问题?

最常见的问题就是 "仓库爆仓"。

  • OutOfMemoryError: Java heap space
    • 原因:你不断地创建新对象,但这些对象因为某些原因(比如内存泄漏)一直不被 GC 回收,导致仓库空间被占满,再也放不下新东西了。
    • 比喻:客户不停地生产产品送进仓库,但仓库里的废弃产品因为某种原因(比如系统故障)没人清理,最后仓库堆满了,新的产品进不来。
    • 解决
      1. 增加仓库容量 :通过 JVM 参数 -Xms (初始堆大小) 和 -Xmx (最大堆大小) 来调大 Java 堆的内存。
      2. 查找内存泄漏:使用内存分析工具(如 MAT)找出那些 "该被清理却一直没被清理" 的对象,修复代码中的 Bug。
总结
特性 比喻 描述
是什么 产品仓库 JVM 中最大的一块内存,存放所有 new 出来的对象。
给谁用 所有生产线 线程共享,所有线程都可以访问堆中的对象。
谁来管理 清洁机器人 垃圾回收器(GC) 的主要工作区域,自动清理废弃对象。
内部结构 快消品货架 + 收藏品仓库 分为新生代 (存放新对象,GC 频繁)和老年代(存放长期存活对象,GC 不频繁但耗时)。
常见问题 仓库爆仓 OutOfMemoryError,通常由内存泄漏或堆内存不足引起。

一句话总结 Java 堆:它是 Java 对象的 "大本营" 和 "坟场",所有对象在这里出生、工作,最后被 GC 清理,是 Java 内存管理的核心区域。

2. 方法区 (Method Area)

如果把 JVM(Java 虚拟机)比作 "一家公司",那 方法区 就是公司的 "人事档案库 + 共享资源柜"------ 里面存着所有 "员工(类)" 的身份档案、工作手册,还有全公司共用的设备和资料,确保每个员工知道自己该做什么、能调用什么资源。

先搞懂:方法区是 "存什么的?"

核心作用就一个:存放 "类的元数据" 和 "共享资源" 。你写的每个 Java 类(比如 UserOrder),编译后会变成 .class 文件,当 JVM 加载这个类时,就会把类的 "核心信息" 存到方法区里。这些信息就像 "员工档案",记录着类的 "身份" 和 "能力"。

具体存什么?用 "公司档案库" 比喻

方法区里的内容,就像档案库里的三类核心资料:

1. 类的 "身份档案"(类元数据)

每个类被加载时,JVM 会把类的 "基本信息" 和 "能力清单" 存进来,相当于给类发 "身份证 + 工作手册":

  • 基本身份 :类的全名(比如 com.example.User,不是简单的 User,避免和其他公司的 "同名员工" 搞混)、继承的父类(比如 User 继承自 Object)、实现的接口(比如 User implements Serializable);
  • 能力清单 :类里的字段(成员变量,比如 private String name)、方法(比如 public void setName()),以及这些字段 / 方法的访问权限(public/private)、返回值类型、参数列表等 ------ 就像员工的 "岗位职责说明书",告诉 JVM 这个类能做什么。

比如你写了 class User { private String name; public void setName(String n) { this.name = n; }},方法区里就会存:"类名是 User,有个叫 name 的私有字符串字段,有个叫 setName 的公共方法,参数是字符串 n......"

2. 全公司共用的 "共享资源"(常量、静态变量)

方法区还是 "共享资源柜",存放所有类都能共用的东西,不用每个类单独存一份:

  • 常量 :比如 public static final String DEFAULT_NAME = "guest",这种 "固定不变的值" 会存在这里,所有 User 对象用的都是同一个 DEFAULT_NAME,不用每个对象都存一遍;
  • 静态变量 :比如 public static int onlineCount = 0,这种 "属于类本身,不是单个对象" 的变量,也存在这里 ------ 不管你创建多少个 User 对象,onlineCount 只有一份,所有对象共用它来统计在线人数。

就像公司的 "公共打印机",不用每个部门都买一台,大家共用一台就行,省空间。

3. 其他 "辅助资料"

还有一些 "隐性资料" 也存在这里,比如:

  • 类的注解(比如 @Controller@Autowired),供框架(如 Spring)识别类的作用;
  • 即时编译器(JIT)编译后的 "优化代码"------JVM 会把频繁执行的代码(热点代码)编译成更快的机器码,存在方法区里,下次执行直接用,提升效率。
关键:方法区的两个 "特殊点"
  1. 线程共享 :和 Java 堆一样,方法区是所有线程共用的 "档案库"------ 比如线程 A 加载了 User 类,线程 B 再用 User 类时,直接读方法区里已有的档案,不用重新加载,省时间。
  2. "永久" 存储(但不是真的永久):早期 JDK 里,方法区叫 "永久代",大家以为里面的东西会一直存着;但 JDK 8 以后改成了 "元空间",用的是操作系统的本地内存 ------ 类如果长时间没人用(比如类加载器被销毁),它的档案也会被清理掉,释放空间。
常见问题:方法区会出问题吗?

会!主要是 "档案库满了":

  • JDK 7 及之前 :报 OutOfMemoryError: PermGen space(永久代溢出)------ 比如频繁动态生成类(如用反射、CGLIB),档案堆得太多,永久代装不下了;
  • JDK 8 及之后 :报 OutOfMemoryError: Metaspace(元空间溢出)------ 虽然元空间用的是系统内存,但如果动态生成的类太多,超过了系统内存上限,也会溢出。

解决办法:JDK 8 后可以通过 XX:MaxMetaspaceSize 参数设置元空间的最大容量,避免无限制占用内存。

一句话总结方法区

它是 JVM 的 "类档案库 + 共享资源柜"------ 存着类的身份信息和能力清单,还有全类共用的常量、静态变量,确保 JVM 认识每个类、知道类能做什么,是 Java 类 "能被正确使用" 的基础。

总结

内存区域 是否线程共享 主要作用 生命周期 可能抛出的异常
程序计数器 记录当前线程执行的字节码指令地址 与线程同生共死
虚拟机栈 描述 Java 方法的执行过程,存放局部变量等 与线程同生共死 StackOverflowError, OutOfMemoryError
本地方法栈 为 Native 方法服务 与线程同生共死 StackOverflowError, OutOfMemoryError
Java 堆 存放对象实例和数组,GC 的主要区域 JVM 启动时创建,关闭时销毁 OutOfMemoryError: Java heap space
方法区 存放类信息、静态变量、常量等 JVM 启动时创建,关闭时销毁 OutOfMemoryError: Metaspace / PermGen space
相关推荐
代码程序猿RIP12 小时前
【SQLite 库】sqlite3_open_v2
jvm·oracle·sqlite
柳贯一(逆流河版)1 天前
Spring Boot Actuator+Micrometer:高并发下 JVM 监控体系的轻量化实践
jvm·spring boot·后端
lpruoyu2 天前
颜群JVM【04】助记符
jvm
Flash Dog2 天前
【JVM】——实战篇
jvm
DKPT2 天前
JVM栈溢出和堆溢出哪个先满?
java·开发语言·jvm·笔记·学习
m0_475064502 天前
jvm双亲委派的含义
java·jvm
胡小禾2 天前
JDK17和JDK8的 G1
jvm·算法
海梨花2 天前
今日八股——JVM篇
jvm·后端·面试
fwerfv3453452 天前
使用PyTorch构建你的第一个神经网络
jvm·数据库·python