我用通俗易懂的方式,来详细介绍一下 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 就不会在分支里 "迷路",能精准跳转到该执行的指令位置。
循环也是同理:每次循环结束,程序计数器会 "跳回" 循环开始的指令位置,直到满足退出条件才往下走。
补充:程序计数器的两个 "小特点"
- 线程私有:每个线程都有自己的 "小书签",互不干扰。比如线程 A 的计数器记着第 10 条,线程 B 的记着第 20 条,不会弄混。
- 永远不会 "内存不够":它是 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;
}
}
它的虚拟机栈操作是这样的:
- 启动线程执行
main()
,创建 "main 栈帧",压入栈顶; main()
里调用add()
,创建 "add 栈帧",压到 "main 栈帧" 上面(此时 add 栈帧是 "当前工作帧");add()
执行完,"add 栈帧" 弹出销毁;- 回到
main()
继续执行,直到main()
结束,"main 栈帧" 弹出,虚拟机栈清空。
关键:一个栈帧里有什么?(用 "做菜" 比喻)
每个栈帧(单次方法调用的任务包)里,装着 4 样核心东西,正好对应 "厨师做菜的全流程":
1. 局部变量表:"食材摆放区"
就像厨师把要用到的鸡蛋、面粉、调料整齐摆放在操作台上,局部变量表就是用来放 "方法参数" 和 "方法内部定义的变量" 的地方。比如 add(int x, int y)
方法的局部变量表,会先放参数 x
和 y
,如果方法里还有 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 读文件" 举个具体例子,看看本地方法栈怎么运作:
- Java 代码发起请求 :你写
new FileReader("test.txt")
,Java 知道 "读文件要调底层功能",于是喊:"调用本地方法read0()
!"(这个read0()
就是 C 写的); - 创建本地栈帧 :JVM 给这个
read0()
调用,在本地方法栈里创建一个 "本地栈帧"------ 里面记着read0()
需要的参数(比如文件路径)、临时变量(比如读出来的字节数据); - "翻译" 并执行底层代码:本地栈帧帮 Java 把 "读 test.txt" 的请求,翻译成 C 语言能懂的指令,然后让 C 代码去调用操作系统的 "文件读取 API";
- 返回结果:C 代码读完文件,把数据通过本地栈帧 "递回" 给 Java;
- 销毁栈帧 :
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 堆的特点
- 线程共享:这个 "产品仓库" 是所有 "生产线"(线程)共享的。任何一个线程创建的对象,理论上其他线程都可以访问到(当然,需要通过引用)。
- 动态分配:仓库的空间不是一开始就被占满的,而是随着程序运行,不断有新对象被 "放进来",旧对象被 "清理出去"。
- 垃圾回收的主战场 :这是 Java 堆最重要的特点。这个 "仓库" 是自动管理 的,你不需要手动去清理不再需要的对象。JVM 内置了一个 "清洁机器人"------垃圾回收器(Garbage Collector, GC),它会自动找出仓库里那些没人要的 "废弃产品"(不再被引用的对象),然后把它们清理掉,腾出空间给新的产品。
3. 为什么要分 "新生代" 和 "老年代"?
你可能听说过 Java 堆内部还分了 "新生代" 和 "老年代",这是为了让 "清洁机器人"(GC)工作得更高效。这基于一个重要的 "生活经验":
大部分产品都是 "一次性" 的,刚生产出来没多久就没用了;只有少数产品能 "经久耐用",长期被使用。
基于这个经验,仓库被分成了两个区域:
-
新生代(Young Generation):
- 比喻:像一个 "快消品货架"。
- 存放 :所有新创建的对象。
- 特点:对象 "周转率" 极高。GC 在这里工作非常频繁,但速度很快。大部分对象在新生代里 "诞生",也在新生代里 "死亡"。
-
老年代(Old Generation / Tenured Generation):
- 比喻:像一个 "收藏品仓库"。
- 存放:那些在新生代里 "存活" 了很久的对象。它们就像 "老员工",经历了多次 GC 清理都没被回收。
- 特点:对象相对稳定,GC 在这里工作的频率很低,但一旦工作,耗时会比较长。
工作流程:
- 新对象先放在 "快消品货架"(新生代)。
- "清洁机器人" 定期打扫货架,把没用的垃圾清掉。
- 如果一个对象在货架上被打扫了好几次都还在(说明它有用),就会被 "升级" 到 "收藏品仓库"(老年代)。
- 当 "收藏品仓库" 也满了,就会进行一次大规模的彻底清理(Full GC)。
4. Java 堆会出什么问题?
最常见的问题就是 "仓库爆仓"。
OutOfMemoryError: Java heap space
- 原因:你不断地创建新对象,但这些对象因为某些原因(比如内存泄漏)一直不被 GC 回收,导致仓库空间被占满,再也放不下新东西了。
- 比喻:客户不停地生产产品送进仓库,但仓库里的废弃产品因为某种原因(比如系统故障)没人清理,最后仓库堆满了,新的产品进不来。
- 解决 :
- 增加仓库容量 :通过 JVM 参数
-Xms
(初始堆大小) 和-Xmx
(最大堆大小) 来调大 Java 堆的内存。 - 查找内存泄漏:使用内存分析工具(如 MAT)找出那些 "该被清理却一直没被清理" 的对象,修复代码中的 Bug。
- 增加仓库容量 :通过 JVM 参数
总结
特性 | 比喻 | 描述 |
---|---|---|
是什么 | 产品仓库 | JVM 中最大的一块内存,存放所有 new 出来的对象。 |
给谁用 | 所有生产线 | 线程共享,所有线程都可以访问堆中的对象。 |
谁来管理 | 清洁机器人 | 垃圾回收器(GC) 的主要工作区域,自动清理废弃对象。 |
内部结构 | 快消品货架 + 收藏品仓库 | 分为新生代 (存放新对象,GC 频繁)和老年代(存放长期存活对象,GC 不频繁但耗时)。 |
常见问题 | 仓库爆仓 | OutOfMemoryError ,通常由内存泄漏或堆内存不足引起。 |
一句话总结 Java 堆:它是 Java 对象的 "大本营" 和 "坟场",所有对象在这里出生、工作,最后被 GC 清理,是 Java 内存管理的核心区域。
2. 方法区 (Method Area)
如果把 JVM(Java 虚拟机)比作 "一家公司",那 方法区 就是公司的 "人事档案库 + 共享资源柜"------ 里面存着所有 "员工(类)" 的身份档案、工作手册,还有全公司共用的设备和资料,确保每个员工知道自己该做什么、能调用什么资源。
先搞懂:方法区是 "存什么的?"
核心作用就一个:存放 "类的元数据" 和 "共享资源" 。你写的每个 Java 类(比如 User
、Order
),编译后会变成 .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 会把频繁执行的代码(热点代码)编译成更快的机器码,存在方法区里,下次执行直接用,提升效率。
关键:方法区的两个 "特殊点"
- 线程共享 :和 Java 堆一样,方法区是所有线程共用的 "档案库"------ 比如线程 A 加载了
User
类,线程 B 再用User
类时,直接读方法区里已有的档案,不用重新加载,省时间。 - "永久" 存储(但不是真的永久):早期 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 |