← ReJava · 学习目录

REJAVA / LEARNING NOTES

JVM(Java 虚拟机)内存区域与对象创建

JVM 五块运行时数据区、永久代到元空间的变化、对象创建,以及看报错判断溢出区域。

Java 17 / 21更新于 2026.10.12约 26 分钟阅读

根据课程材料整理

本次更新说明
  • 整理为公开学习笔记,附两个可下载示例程序。
  • 实测数字注明示例环境:macOS/Aarch64,Azul 17.0.14、Temurin 21.0.10,JDK 8 对照用 Zulu 1.8.0_312。

热身 · 一 全景 · 二 栈 · 三 堆 · 四 方法区与元空间 · 五 创建对象 · 六 看报错判断区域 · 七 检验 · 八 讲出来 · 九 追问 · 十 隔日复测

学完这一课,你能做到

能画出 JVM(Java 虚拟机) 运行时数据区的五块区域,说清哪些每个线程一份、各放什么、各会抛什么错;看到 StackOverflowError(栈溢出错误) 或 OutOfMemoryError(内存溢出错误) 的报错信息,能判断是哪块内存不够、先往哪个方向查;能讲清 JDK 7、8 方法区的变化,以及 new 一个对象时 JVM(Java 虚拟机) 做了哪几步。

为什么学这个: “内存区域、对象创建与回收”是 JVM(Java 虚拟机) 部分的第一个主题(见学习路线速查)。后面的垃圾回收、线上排查,都要先知道“对象在哪、类信息在哪”,这张图是地基。

主读: JVM(Java 虚拟机) 规范(Java SE 21)§2.5 运行时数据区。六个小节都很短,每节末尾的异常情况一定要读。规范只规定“必须有什么”,HotSpot 怎么实现(元空间、对象头等),课件里会单独标出来。

贯穿全课的场景: 一个办件系统里,几个请求线程同时处理“查看办件详情”,各自调用 loadApplicant(applyNo) 查出申请人。每一节都回到这几行代码,看它们的数据落在哪块内存。

必学与拓展: 一至六节的正文都是必学,检验题只考必学内容。标着 拓展 的段落(直接内存、程序计数器与本地方法栈、对象的访问方式)时间紧可以先跳过。第四节末尾是一个可以暂停的位置。

关于文中的“验证”: 运行结果来自示例环境:macOS/Aarch64,Azul JDK 17.0.14 与 Temurin JDK 21.0.10(JDK 8 的对照用 Zulu 1.8.0_312)。递归深度、对象大小这类数字只作参考,你在自己的平台、版本和参数下得到的结果可能不同。

热身:先回忆前几课(4 分钟)

先凭记忆回答,再展开核对。答不上来就记一句,接着往下学,这不是进入新课的门槛。前三题是第8课的隔日复测。

第8课:exceptionally(异常兜底方法)、handle(结果与异常处理方法)、whenComplete(完成回调方法) 各在什么时候执行?哪个不改变原来的结果?

exceptionally(异常兜底方法) 只在出错时执行,返回替代值;handle(结果与异常处理方法) 成功、失败都执行,同时拿到结果和异常,算出新结果;whenComplete(完成回调方法) 成功、失败都执行, 不改变 原来的结果,适合记日志。对照第8课第四节。

第8课:CompletableFuture(可编排的异步结果) 失败后,join(等待完成方法) 和 get(获取结果方法) 各抛什么?原始异常在哪?

join(等待完成方法) 抛 CompletionException(不强制捕获),get(获取结果方法) 抛 ExecutionException(必须捕获或声明),原始异常都在 getCause() 里。

第8课:下游服务很慢,超时要设在哪两层?各管什么?

结果层:orTimeout(超时失败方法) 或 completeOnTimeout(超时默认值方法),让调用方不再等这个结果。远程调用层:给 HTTP、SQL 设超时,让任务自己结束、把线程还回线程池。调线程池参数解决不了“慢”。

第3课:JMM(Java 内存模型)和本课要讲的“JVM(Java 虚拟机) 内存区域”是一回事吗?

不是。JMM(Java 内存模型) 讲的是多个线程读写共享变量时,什么时候能看到对方的写入、顺序怎么保证(可见性、有序性、happens-before(先行发生关系))。内存区域讲的是 JVM(Java 虚拟机) 把内存分成哪几块、各放什么。两个都叫“内存”,最容易混淆。对照第3课第二节。

一、全景:五块区域,三块每个线程一份

JVM(Java 虚拟机) 的内存分为哪几块?哪些是线程私有的,哪些是共享的?

JVM(Java 虚拟机) 规范规定,程序运行时用到几块“运行时数据区”。有的随 JVM(Java 虚拟机) 启动创建、到 JVM(Java 虚拟机) 退出才销毁;有的 每个线程一份 ,随线程创建和销毁[1]。

先看场景代码。请求线程1 查办件 A-001,请求线程2 查办件 A-002,两个线程同时调用:

static Applicant loadApplicant(String applyNo) {
    String name = queryName(applyNo);       // applyNo、name:局部变量
    if (name == null) name = "未知申请人";   // 字符串字面量
    return new Applicant(name);             // 新建对象
}

验证:完整版本是MemoryMapExample.java,三个线程同时调用后用断言检查:各线程的结果互不干扰;每次 new 都是不同的对象;所有对象的 getClass() 是同一个;两个线程拿到的“未知申请人”是同一个实例。Java 17/21 都输出“检查通过”。这些是 Java 层面能观察到的现象,不能直接证明对象位于哪块物理内存;“位于哪块”的依据仍是 JVM(Java 虚拟机) 规范 §2.5 与 JEP 122。

这几行代码运行时,数据分布在下面这些地方:

每个线程一份

  • 请求线程1
    • 程序计数器: 执行到 loadApplicant 的哪条指令
    • 虚拟机栈: 栈帧 loadApplicant(applyNo = “A-001”,name);下面压着调用它的控制层方法的栈帧
    • 本地方法栈: 本例用不到
  • 请求线程2
    • 程序计数器: 它自己执行到哪
    • 虚拟机栈: 栈帧 loadApplicant(applyNo = “A-002”,name)

所有线程共享

  • 堆: 两次 new 得到的两个 Applicant 对象;字符串常量池里的“未知申请人”
  • 方法区(JDK 8 起由元空间实现): Applicant 类的结构:字段 name、构造方法和 loadApplicant 的字节码。只有一份,所有线程共用

对照规范,五块区域的完整说明是:

区域谁的放什么规范列出的错误
程序计数器每个线程一份当前执行到哪条字节码指令没有列任何异常情况
虚拟机栈每个线程一份方法调用的栈帧:局部变量、中间结果StackOverflowError(栈溢出错误)、OutOfMemoryError(内存溢出错误)
本地方法栈每个线程一份本地方法(非 Java 写的方法)的调用StackOverflowError(栈溢出错误)、OutOfMemoryError(内存溢出错误)
堆所有线程共享所有对象实例和数组OutOfMemoryError(内存溢出错误)
方法区所有线程共享每个类的结构:运行时常量池、字段和方法信息、方法字节码OutOfMemoryError(内存溢出错误)

依据:JVM(Java 虚拟机) 规范 §2.5.1–2.5.6[1]。规范没有为程序计数器列任何异常情况;常见说法“它是唯一不会 OOM 的区域”就是由此而来。

记忆方法(看上面的分布): 和“执行”有关的 (执行到哪、调用了哪些方法)每个线程各管各的,所以是私有的; 和“数据”有关的 (对象、类信息)大家都要用,所以是共享的。第3–8课讲的“多线程同时读写”的问题,读写的就是堆里的共享对象;每个线程自己栈上的局部变量,别的线程根本看不到。

拓展 另外有一块 直接内存 ,不属于规范的运行时数据区:NIO 的直接缓冲区在 Java 堆外分配,可以用 -XX:MaxDirectMemorySize 限制,也会 OutOfMemoryError(内存溢出错误)[JG]。知道有这一块、它也会溢出即可。

先自己说一遍,再看参考回答

JVM(Java 虚拟机) 规范规定了五块运行时数据区。程序计数器、虚拟机栈、本地方法栈是线程私有的,随线程创建和销毁;堆和方法区是所有线程共享的。程序计数器记录当前执行到哪条指令,规范没有给它列任何异常;虚拟机栈存方法调用的栈帧,太深会 StackOverflowError(栈溢出错误);堆放所有对象和数组,不够会 OutOfMemoryError(内存溢出错误);方法区放类的结构和运行时常量池,也会 OutOfMemoryError(内存溢出错误)。另外还有直接内存,不属于这五块,但也会溢出。

二、虚拟机栈:每调用一次方法,压一个栈帧

虚拟机栈里放的是什么?StackOverflowError(栈溢出错误) 是怎么发生的?

每调用一次方法,JVM(Java 虚拟机) 就在当前线程的栈上新建一个 栈帧 ;方法结束(不管是正常返回还是抛出了没被捕获的异常),这个栈帧就销毁[2]。栈帧里有三样东西:

  • 局部变量表: 方法参数和局部变量,按下标存放,大小在编译时就定好了。
  • 操作数栈: 字节码指令计算时用的临时区域。比如 a + b,先把 a、b 压进去,加法指令再把它们取出来、把结果压回去。
  • 指向运行时常量池的引用: 用来找到要调用的方法、要访问的字段(第四节讲)。

回到场景:请求线程1 从控制层方法调用 loadApplicant,loadApplicant 再调用 queryName,它的栈上就依次压着三个栈帧;queryName 返回,它的栈帧就弹出。请求线程2 有自己的一套栈帧,里面的 applyNo 是 “A-002”,和线程1 互不相干。

栈帧只属于创建它的线程,别的线程引用不到[2]。这就是“局部变量天然线程安全”的原因。但要注意:局部变量如果是个 引用 ,它指向的对象还是在堆上,别的线程拿到这个对象照样能改。

StackOverflowError(栈溢出错误): 线程需要的栈深度超过允许值时抛出[1]。最常见的原因是递归没写出口,或者数据有问题导致递归太深。栈的大小用 -Xss 设置,默认值看平台,比如 Linux/x64 是 1024 KB,macOS/Aarch64 是 2048 KB[5]。

static void recurse() {
    depth++;
    recurse();   // 没有出口,每调用一次多一个栈帧
}

验证:示例程序 stack 模式。-Xss256k 时递归约 1460 层就抛 StackOverflowError(栈溢出错误);-Xss1m 时约 16000–19000 层(Java 17/21)。栈越大能递归越深,但不是简单的比例关系。

栈也可能 OutOfMemoryError(内存溢出错误):规范说,栈能动态扩展但内存不够时,或者 新建线程时连最初需要的栈都分配不出来 时,抛 OutOfMemoryError(内存溢出错误)[1]。所以线程不是越多越好:每个线程都要一份栈,几千个线程乘以每个 1–2 MB,加起来非常可观。这也是第5课要用线程池控制线程数的原因之一。

拓展 程序计数器 和 本地方法栈 了解即可:前者记录每个线程执行到哪条指令,线程切换回来才能接着执行;后者给本地方法用,规范允许实现不提供它[1]。

先自己说一遍,再看参考回答

虚拟机栈是线程私有的。每调用一个方法就压入一个栈帧,方法结束就弹出。栈帧里有局部变量表、操作数栈,和指向运行时常量池的引用。调用太深,比如递归没有出口,就抛 StackOverflowError(栈溢出错误);栈大小用 -Xss 设置。新建线程时连栈都分配不出来,会抛 OutOfMemoryError(内存溢出错误),所以线程数要控制。局部变量在栈上,别的线程看不到,但它引用的对象在堆上,仍然是共享的。

三、堆:所有对象都在这里

堆里放什么?堆的大小怎么设?堆内存溢出会报什么?

堆是所有线程共享的, 所有对象实例和数组 都从这里分配。堆里的对象不需要、也不能手动释放,由垃圾回收器自动回收[1]。怎么回收、分代是怎么回事,是下一课的内容。

堆的大小用两个参数设置[5]:

  • -Xms:堆的初始大小,也是最小大小。
  • -Xmx:堆的最大大小,等同于 -XX:MaxHeapSize。不设时,JVM(Java 虚拟机) 按机器配置自动选。

手册里写着:服务器部署时,-Xms 和 -Xmx 常常设成同一个值[5]。手册没写原因,可以这样理解:设成一样,堆一开始就是最大大小,运行中不用再扩容或收缩。

回到场景:两个线程各 new 了一个 Applicant,两个对象都在堆上,是两个不同的对象。线程的局部变量 name 只是指向它们的引用。请求处理完,不再有任何地方引用这两个对象,它们就可以被回收了;怎么判断“不再被引用”,是下一课的内容。

对象越来越多、又一直被引用着(垃圾回收器回收不掉),堆就会满,抛 OutOfMemoryError: Java heap space。

List<byte[]> keep = new ArrayList<>();
while (true) keep.add(new byte[1024 * 1024]);   // 每次 1MB,一直被 keep 引用着,回收不掉

验证:示例程序用 java -Xmx32m reference/JvmMemoryDemo.java heap 运行,Java 17/21 都输出 OutOfMemoryError: Java heap space。

业务里最常见的堆溢出是 一次加载太多数据 :比如导出全年办件,一条 SQL 把几十万行全部查进一个 List(列表接口),再拼成一个大 Excel 对象放在内存里。解决方向是分页、分批查,边查边写文件,而不是把堆调大了事。

先自己说一遍,再看参考回答

堆是所有线程共享的,所有对象实例和数组都在堆上分配,由垃圾回收器自动回收。-Xms 设初始大小,-Xmx 设最大大小,服务器上常设成一样。对象一直被引用、回收不掉,堆满了就抛 OutOfMemoryError: Java heap space。业务里最常见的是一次查出太多数据放进内存,要用分页、分批处理解决。

四、方法区、永久代、元空间:规范与实现

方法区、永久代、元空间是什么关系?JDK 8 为什么用元空间替代永久代?字符串常量池在哪?

先分清“规范”和“实现”

方法区是 规范 里的概念:所有线程共享,存每个类的结构,包括运行时常量池、字段和方法信息、方法的字节码。规范说它“逻辑上是堆的一部分”,但 不规定它放在哪、怎么管理 [1]。

永久代和元空间是 HotSpot 对方法区的两种 实现 。可以类比成接口和实现类:方法区是接口,永久代是 JDK 7 及以前的实现,元空间是 JDK 8 起的实现[JG]。

三样东西从永久代搬走了

JDK 6JDK 7JDK 8 起(包括 17、21)
类元数据(类的结构)永久代永久代元空间 (本地内存)
字符串常量池永久代堆堆
类的静态变量永久代见下方说明堆

依据:JDK 7 发行说明写明“入池字符串不再分配在永久代,而是分配在堆的主体部分”(RFE 6962931)[4]。JEP 122(JDK 8 交付)写明“类元数据分配在本地内存,入池字符串和类静态变量移到 Java 堆”[4]。JavaGuide 称静态变量在 JDK 7 就已移到堆[JG],官方依据能确认的是“最迟 JDK 8”。

为什么要换? JEP 122 给出的原因[4]:

  1. 永久代的大小要调,而且不好调。 永久代是堆里固定大小的一块,必须装得下所有类元数据、入池字符串和静态变量,上限由 -XX:MaxPermSize 定死。改成元空间后,类元数据放在本地内存,分配受“可用的本地内存”限制,不再被这个固定值卡住。
  2. 和 JRockit 合并。 JRockit 虚拟机从来没有永久代,它的用户也不习惯配这个参数。

元空间也会溢出。 手册说 -XX:MaxMetaspaceSize 默认 不设上限 [5](示例环境实测默认值是 size_t 的最大值)。不设上限,类元数据一直增长时,会一直吃本地内存,直到把机器拖垮;设了上限,超出时就抛 OutOfMemoryError: Metaspace,问题能早点暴露。什么情况下类会越来越多?比如运行时反复生成动态代理类,而生成它们用的类加载器又一直没被回收。下面的实验就是这样把元空间撑爆的。

for (int i = 0; ; i++) {
    ClassLoader loader = new ClassLoader(parent) { };     // 每次一个新的类加载器
    keep.add(Proxy.newProxyInstance(loader, new Class<?>[]{Runnable.class}, handler));  // 每次生成一个新的代理类
}

验证:示例程序用 java -XX:MaxMetaspaceSize=32m reference/JvmMemoryDemo.java metaspace 运行,Java 17/21 都在生成不到 5000 个代理类时输出 OutOfMemoryError: Metaspace。片段里用到的几个变量在完整示例中定义。

回到场景:Applicant 这个类的结构只有一份,放在方法区里(JDK 8 起就是元空间),每个线程创建的 Applicant 对象,都通过对象头里的类型指针指向它(第五节讲)。代码里的字面量“未知申请人”入了字符串常量池,在堆里;两个线程拿到的是同一个实例(MemoryMapExample 验证)。

字符串常量池在堆里,实测一下: 堆上限 32 MB 时,不停地 intern() 新字符串,Java 17/21 报的是 Java heap space,不是元空间溢出(示例程序:java -Xmx32m reference/JvmMemoryDemo.java intern)。JDK 8 上同样是堆的报错[6]。

运行时常量池和字符串常量池不是一个东西: 运行时常量池每个类一份,由 class 文件里的常量池表转换而来,存字面量和符号引用(类名、方法名这些“名字”),属于方法区[1];字符串常量池是 HotSpot 保存已入池字符串的结构,JDK 7 起在堆里[4]。

一个真实的升级坑

老项目的启动脚本里常有 -XX:MaxPermSize=256m。示例环境实测:JDK 8 只打印一句警告“support was removed in 8.0”,照常启动; JDK 17、21 直接报 Unrecognized VM option,JVM(Java 虚拟机) 启动失败 [6]。JDK 17 的手册已经把它列在“已移除的选项”里[5]。这和第4课偏向锁参数是同一类问题:升级 JDK 前,要先清理启动参数。

先自己说一遍,再看参考回答

方法区是 JVM(Java 虚拟机) 规范里的概念,存类的结构和运行时常量池,规范不规定它怎么实现。永久代和元空间是 HotSpot 的两种实现:JDK 7 及以前用永久代,它是堆里一块固定大小的区域;JDK 8 起用元空间,类元数据放到本地内存。字符串常量池在 JDK 7 就搬到了堆里,静态变量最迟 JDK 8 也在堆里。换掉永久代,一是因为它的大小被 MaxPermSize 定死,不好调,二是和没有永久代的 JRockit 合并。元空间默认不设上限,生产上建议用 MaxMetaspaceSize 设一个,动态生成类太多时会报 OutOfMemoryError: Metaspace。另外 JDK 17 以后启动参数里再写 MaxPermSize,JVM(Java 虚拟机) 会直接启动失败。

可以在这里暂停。 前四节讲区域本身,后两节讲对象怎么创建、报错怎么判断。继续之前,先合上课件,按第一节的分布把五块区域默写一遍。

五、new 一个对象:JVM(Java 虚拟机) 做了哪几步

说一下对象的创建过程。一个对象在内存里长什么样?new Object() 占多少字节?

先看一行 new 编译成什么:

return new Applicant("张三");

// javap -c 看到的字节码
0: new           #7      // class Applicant      ← 分配内存,字段设为默认值
3: dup                                            ← 复制一份引用(一份给构造方法用,一份留着返回)
4: ldc           #9      // String 张三
6: invokespecial #11     // Method Applicant."<init>"  ← 执行构造方法
9: areturn

验证:示例环境用 JDK 17 和 21 分别编译后执行 javap(字节码查看工具) -c,字节码相同[6]。

规范对 new 指令的规定是:解析类;类还没初始化就先初始化;从垃圾回收管理的堆里分配内存;实例字段设为默认值;把引用压入操作数栈。规范还特别注明: new 并没有完整创建实例,要调用 <init> 之后才算完成 [3]。

HotSpot 的五步(常见讲法)

  1. 类加载检查: 看这个类有没有加载、解析、初始化过,没有就先加载。
  2. 分配内存: 从堆里划出一块。堆是规整的(用过的、空闲的各在一边),就把分界指针挪一下,叫“指针碰撞”;不规整,就从一张空闲块列表里找,叫“空闲列表”。堆规不规整,取决于用的垃圾回收器会不会整理内存。多个线程同时分配怎么办?HotSpot 先给每个线程在堆里预留一小块(TLAB(线程本地分配缓冲)),线程在自己那块里分配,不用抢;用完了再用 CAS(比较并交换) 加失败重试去公共区域分配[JG]。示例环境 JDK 17/21 的 UseTLAB 默认是 true[6]。
  3. 初始化零值: 字段全部设为默认值(0、false、null(空值))。所以字段不赋值也能直接用。
  4. 设置对象头: 写入这个对象是哪个类的实例、哈希码、垃圾回收的分代年龄、锁状态等。
  5. 执行 <init>: 执行构造方法,按代码给字段赋值。到这里,对象才真正可用。

第 2–4 步的具体做法属于 HotSpot 实现,依据 JavaGuide[JG]。规范层面的依据是上面的 new 指令说明。

对象在内存里的样子(HotSpot)

规范 不规定 对象的内部结构[2]。HotSpot 的对象分三部分[JG]:

  • 对象头: Mark Word(标记字段,存哈希码、分代年龄、锁状态等,第4课讲的锁状态就存在这里)+ 类型指针(指向类元数据,JVM(Java 虚拟机) 靠它知道这是哪个类的对象)。
  • 实例数据: 你定义的字段。
  • 对齐填充: 把对象大小补齐到 8 字节的整数倍,本身没有含义。

new Object() 占多少字节? 示例环境 JDK 17/21 默认配置下实测 16 字节 [6]:

对象实测大小(字节)怎么来的
new Object()16对象头 12(Mark Word(标记字段) 8 + 压缩的类型指针 4),补齐到 16
只有一个 int 字段1612 + 4 = 16,刚好不用补
两个 int 字段2412 + 8 = 20,补齐到 24
一个 int,关闭压缩类型指针24对象头变成 16,16 + 4 = 20,补齐到 24

验证:用 JDK 自带的 Instrumentation.getObjectSize 测量。官方说明这个方法返回的是“随实现而定的近似值”[6]。“对象头 12 字节”是根据实测结果推出来的,和 JavaGuide 的说法一致。这些是示例环境里 HotSpot 的实现值,不是规范规定,换了 JDK 版本或参数可能不同。

拓展 怎么找到对象? 栈上的引用怎么指到堆里的对象,有两种做法:句柄(引用指向一个句柄,句柄再指向对象)和直接指针(引用直接存对象地址)。规范提到 Oracle 的某些实现用过句柄[2];HotSpot 主要用直接指针,少一次跳转,访问更快[JG]。

先自己说一遍,再看参考回答

new 一个对象分五步:先做类加载检查,没加载就先加载;然后在堆上分配内存,堆规整用指针碰撞,不规整用空闲列表,并发分配时 HotSpot 先用每个线程自己的 TLAB(线程本地分配缓冲),不够再用 CAS(比较并交换) 重试;接着把字段初始化为零值;再设置对象头,包括类型信息、哈希码、分代年龄、锁状态;最后执行构造方法 <init>,对象才真正可用。字节码上就是 new、dup、invokespecial <init>。HotSpot 的对象由对象头、实例数据、对齐填充组成。JDK 17、21 默认配置下,对象头是 12 字节,new Object() 补齐后是 16 字节,这是实现细节,规范不规定。

六、实战:看到报错,判断是哪块内存不够

线上报 OutOfMemoryError(内存溢出错误),你怎么判断是哪块内存的问题?

第一步不是调参数,而是 看报错信息里冒号后面那几个词 。它直接告诉你是哪块不够:

报错哪块常见原因先往哪个方向查
StackOverflowError(栈溢出错误)虚拟机栈递归没有出口;数据里有环导致递归停不下来看报错的调用栈,找反复出现的同一个方法
OutOfMemoryError: Java heap space堆一次加载太多数据;集合、缓存只加不删,对象一直被引用哪段代码在大量创建、长期持有对象(堆转储分析在“线上排查”一课)
OutOfMemoryError: Metaspace元空间运行时不断生成新类,类加载器没被回收有没有在反复生成动态代理类、反复新建类加载器
OutOfMemoryError: GC overhead limit exceeded堆垃圾回收花了大量时间却几乎回收不到内存(示例环境 JDK 8 实测出现)按堆溢出处理

验证:前三种由示例程序在 Java 17/21 实测得到;第四种是 JDK 8 上不停 intern(字符串入池方法) 字符串、堆上限 32 MB 时的报错,加 -XX:-UseGCOverheadLimit 后变为 Java heap space[6]。

举一个办件系统里可能出现的 StackOverflowError(栈溢出错误):部门树用递归往上找“上级部门”,如果数据录错了,A 的上级是 B、B 的上级又是 A,递归就会一直绕圈,直到栈溢出。修法是递归时记录走过的节点,或者设最大层数,而不是把 -Xss 调大。

具体怎么在线上抓现场(堆转储、jmap、jstack),是后面“线上排查”主题的内容。这一课先做到:看到报错,能说出是哪块、可能是什么原因。

先自己说一遍,再看参考回答

先看报错信息。StackOverflowError(栈溢出错误) 是栈,通常是递归太深或没有出口,看调用栈里反复出现的方法;OutOfMemoryError: Java heap space 是堆,通常是一次查太多数据或者集合只加不删,要找大量创建、长期持有对象的代码;OutOfMemoryError: Metaspace 是元空间,通常是动态生成的类太多、类加载器没回收。判断出区域以后再去抓现场分析,而不是直接把内存参数调大。

七、检验:先关掉上面,凭记忆做

6 道题:先回答,再展开核对

  1. 下面哪块区域是每个线程各有一份的?
  • A. 对象所在的堆区
  • B. 方法调用的栈区
  • C. 类信息的元空间
查看答案与解释

答案:B

虚拟机栈、程序计数器、本地方法栈都是线程私有的;堆和方法区(元空间是它的实现)所有线程共享。

  1. 递归方法没写终止条件,运行后最可能抛出什么?
  • A. 栈溢出错误
  • B. 堆内存溢出
  • C. 元空间溢出
查看答案与解释

答案:A

每次调用压一个栈帧,栈深度超过允许值就抛 StackOverflowError(栈溢出错误)。栈大小用 -Xss 设置。

  1. 用 JDK 17 启动,启动参数里还留着 -XX:MaxPermSize=256m,会怎样?
  • A. 打印警告照常启动
  • B. 自动换成元空间限
  • C. 不认参数无法启动
查看答案与解释

答案:C

JDK 8 只是警告并忽略;JDK 17、21 报 Unrecognized VM option,JVM(Java 虚拟机) 启动失败。升级前要清理启动参数。

  1. JDK 8 起,HotSpot 把类的元数据放在哪里?
  • A. 堆里的永久代中
  • B. 本地内存元空间
  • C. 线程各自栈帧里
查看答案与解释

答案:B

JEP 122:类元数据放到本地内存(元空间),入池字符串和静态变量放到堆。永久代在 JDK 8 被移除。

  1. 示例环境 JDK 17/21 默认配置下,new Object() 实测占多少字节?
  • A. 八个字节
  • B. 十二字节
  • C. 十六字节
查看答案与解释

答案:C

对象头 12 字节(Mark Word(标记字段) 8 + 压缩类型指针 4),补齐到 8 的整数倍是 16。这是 HotSpot 实现值,不是规范规定。

  1. 线上报 OutOfMemoryError: Java heap space,首先应该怀疑什么?
  • A. 递归调用是否太深
  • B. 对象是否大量堆积
  • C. 动态类是否太多个
查看答案与解释

答案:B

Java heap space 说明堆不够:对象太多、又一直被引用回收不掉。递归太深是栈溢出;动态类太多是元空间溢出。

八、用完整回答检验理解

连续回答下面四问,可以写下来,也可以先口述再整理成文字。写完再对照各节的参考回答。

  1. JVM(Java 虚拟机) 内存分哪几块?哪些是线程私有的?各会抛什么错?
  2. 方法区、永久代、元空间是什么关系?JDK 8 为什么换成元空间?字符串常量池在哪?
  3. new 一个对象,JVM(Java 虚拟机) 做了哪些事?new Object() 占多少字节?
  4. 线上报 OutOfMemoryError(内存溢出错误),你怎么判断是哪块内存的问题?举一个业务系统里可能出现的例子。

第四问可以参考第九节末尾的应用场景候选。

练习:请在自己的笔记中写出答案,再展开参考内容核对。

九、常见追问

每问先自己答,再展开核对。答案都在前面各节里。

关于区域划分(第一、二、三节)

程序计数器会内存溢出吗?

JVM(Java 虚拟机) 规范没有为程序计数器列任何异常情况。它只记录当前指令的位置,占用很小且固定。常见说法是“唯一不会 OOM 的区域”。

局部变量是线程安全的吗?

局部变量在线程自己的栈帧里,别的线程访问不到,所以变量本身是安全的。但如果它是一个引用,指向的对象在堆上,这个对象被别的线程拿到,照样会有并发问题。

-Xss 调大有什么代价?

每个线程都要一份栈。栈调大,能递归得更深,但同样的内存能开的线程更少;线程多时总占用很可观,新建线程分配不出栈也会 OutOfMemoryError(内存溢出错误)。递归太深应该先改代码。

为什么服务器上 -Xms 和 -Xmx 常设成一样?

手册里写着服务器部署时常这样设,但没写原因。可以这样理解:设成一样,堆一开始就是最大大小,运行中不用再扩容或收缩。

关于方法区与元空间(第四节)

元空间默认有上限吗?不设会怎样?

默认不设上限。类元数据一直增长时会一直占用本地内存,可能拖垮整台机器;设了 MaxMetaspaceSize,超出时会报 OutOfMemoryError: Metaspace,问题能早点暴露。

运行时常量池和字符串常量池有什么区别?

运行时常量池每个类一份,由 class 文件常量池表转换而来,存字面量和符号引用,属于方法区。字符串常量池是 HotSpot 保存入池字符串的结构,JDK 7 起在堆里。

类的静态变量存在哪?

JDK 8 起在堆里(JEP 122)。JavaGuide 说 JDK 7 就已经移到堆,和 Class(类对象类型) 对象放在一起。注意:静态变量引用的对象本来就在堆上,这里说的是“静态变量这个字段本身”放在哪。

直接内存是什么?会溢出吗?

NIO 直接缓冲区在 Java 堆外分配的内存,不属于规范的运行时数据区,可以用 -XX:MaxDirectMemorySize 限制,用多了也会 OutOfMemoryError(内存溢出错误)。

关于对象(第五节)

多个线程同时 new 对象,怎么保证不分配到同一块内存?

HotSpot 给每个线程在堆里预留一块 TLAB(线程本地分配缓冲),线程在自己的 TLAB(线程本地分配缓冲) 里分配,互不干扰;TLAB(线程本地分配缓冲) 用完或对象太大时,再用 CAS(比较并交换) 加失败重试在公共区域分配。

为什么说 new 指令执行完,对象还没创建完?

new 只分配内存、把字段设成默认值;构造方法 <init> 还没执行,字段还没按代码赋值。规范明确说要调用 <init> 后实例创建才完成。字节码是 new、dup、invokespecial <init>。

对象访问用句柄还是直接指针?各有什么好处?

HotSpot 主要用直接指针,少一次跳转,访问快。句柄的好处是对象被垃圾回收移动时,只改句柄里的地址,引用本身不用改。

应用场景:可能用在哪里(候选)

四个候选场景
  1. 大批量导出把堆撑爆: 导出全年办件、全量人员名单,一次查进内存再生成 Excel。改为分页查询、流式写文件。
  2. 部门树或审批流递归栈溢出: 上下级数据录成了环,递归停不下来。改为记录已访问节点,或设最大层数。
  3. 运行时反复生成类: 某段代码每次调用都新建类加载器、生成新的代理类,类加载器又没被回收,元空间一直涨。
  4. 升级 JDK 后服务起不来: 启动脚本里还留着 -XX:MaxPermSize、偏向锁等老参数,JDK 17 直接拒绝启动。

十、隔日复测:明天换条件再答(约 5 分钟)

展开题目
  1. 说出五块运行时数据区,并把它们分成“线程私有”和“线程共享”两组。
  2. JDK 6、7、8 三个版本,字符串常量池和类元数据分别在哪?
  3. 第8课:传给 thenApply(结果变换方法) 和 thenCompose(异步衔接方法) 的函数,返回值各是什么类型?分别在什么场景用?
  4. 第5课:corePoolSize(核心线程数) 是 4,最大线程数是 8,队列容量 10。池里已有 4 个线程都在忙,队列里有 3 个任务。新任务来了,线程池怎么做?
作答后核对

① 私有:程序计数器、虚拟机栈、本地方法栈;共享:堆、方法区。

② JDK 6 两者都在永久代;JDK 7 字符串常量池搬到堆,类元数据还在永久代;JDK 8 起字符串常量池在堆,类元数据在元空间(本地内存)。

③ thenApply(结果变换方法) 的函数返回普通值,用于同步变换结果;thenCompose(异步衔接方法) 的函数返回 CompletableFuture(可编排的异步结果),用于下一步本身也是异步、又依赖上一步结果的情况。对照第8课第二节。

④ 线程数已到核心数 4,队列还没满(3 < 10),所以 放进队列 ,不新建线程。只有队列满了才会扩到最大线程数。对照第5课接纳流程。

主来源与引用

  1. [1] JVM(Java 虚拟机) 规范 §2.5 运行时数据区: Java SE 21 版,包括各区域是否线程私有、存放内容和异常情况,方法区“逻辑上是堆的一部分但不规定位置”。

  2. [2] JVM(Java 虚拟机) 规范 §2.6 栈帧与 §2.7 对象的表示: 栈帧的组成与生命周期;对象的表示:规范不规定对象内部结构。

  3. [3] JVM(Java 虚拟机) 规范 §6.5 new 指令: 原文,包括在堆上分配内存、类初始化、默认值、“调用 <init> 后才完成”。

  4. [4] JEP 122 与 JDK 7 发行说明: JEP 122:移除永久代(JDK 8 交付);JDK 7 发行说明中入池字符串移到堆(RFE 6962931)。

  5. [5] java 命令手册: JDK 21 手册的 -Xss、-Xms、-Xmx、-XX:MetaspaceSize、-XX:MaxMetaspaceSize;JDK 17 手册“已移除的选项”中的 -XX:MaxPermSize、-XX:PermSize。在线版见 Java 21 命令手册、Java 17 命令手册。

  6. [6] 示例环境实验记录(本地资料): 四种溢出、JDK 8 的字符串实验、永久代参数在 8/17/21 的表现、默认参数值、对象大小、new 的字节码;环境见开头“关于文中的‘验证’”。Instrumentation.getObjectSize 的官方说明见 Java 21 API 文档:返回值是随实现而定的近似值,适合同一实现内比较,不适合跨实现比较。

  7. [JG] JavaGuide: Java 内存区域详解,用于 HotSpot 实现层面的补充:对象创建五步、指针碰撞与空闲列表、TLAB(线程本地分配缓冲)、对象头组成、直接指针、直接内存。

本课速查表 · 完整术语表 · 第8课 · 第4课 · 第3课

学完后,记下判断理由和仍不确定的地方。隔日回看时先尝试回答,再核对课程里的解释。