掘地攀登
100.61M · 2026-03-29
Java 虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建(线程共享),随着虚拟机退出而销毁。另外一些则是与线程一一对应的(线程私有),这些与线程一一对应的数据区域会随着线程开始和结束而创建和销毁。
堆是 JVM 内存模型是最大的一块,堆内存是所有线程共享的,存放的数据是对象实例和数组。另外字符串常量池也设置在堆内存中。
为了进行高效的垃圾回收,虚拟机把堆内存逻辑上划分成三块区域
分代存储的目的:优化 GC 性能
Java 堆内存结构
graph TB
subgraph Heap[堆内存]
subgraph Young[新生代 Young Generation]
Eden[Eden 区 8/10]
S1[Survivor0 1/10]
S2[Survivor1 1/10]
end
Old[老年代 Old Generation]
Meta[元空间 Metaspace<br/>JDK8+]
end
NewObject[新对象] --> Eden
Eden -->|Minor GC| S1
S1 -->|存活| S2
S2 -->|存活| S1
S1 -->|年龄阈值 15| Old
BigObject[大对象] -.-> Old
新生代被分为三个部分——伊甸园(Eden Memory)和两个幸存区(Survivor Memory,被称为:from/to 或 s0/s1),默认比例是 8:1:1
大多数新创建的对象都位于 Eden 内存空间中,当 Eden 区被填满后,会触发 Minor GC(也称 Young GC)
Eden区实现回收的策略:
-XX:MaxTenuringThreshold 配置晋升到老年代的交换次数的阈值,默认是15flowchart TD
A[新对象创建] --> B{对象大小 > PretenureSizeThreshold?}
B -->|是,大对象 | C[直接进入老年代]
B -->|否 | D[尝试在 Eden 区分配]
D --> E{Eden 空间足够?}
E -->|足够 | F[分配成功]
E -->|不足 | G[触发 Minor GC]
G --> H{GC 后空间足够?}
H -->|是 | I[分配成功]
H -->|否 | J{老年代有空间?}
J -->|是 | K[触发 Full GC]
J -->|否 | L[抛出 OutOfMemoryError]
-Xms2g -XX:InitialHeapSize=2g # 设置初始堆内存大小
-Xmx2g -XX:MaxHeapSize=2g # 设置最大堆内存大小
# 通常会把 `-Xms` 和 `-Xmx` 两个参数配置为相同的值,目的就是为了能够在垃圾回收后,堆内存不需要重新分隔计算堆的大小,从而提升性能。
# 设置堆内存大小后,可以通过 Arthas 工具,使用 dashboard 命令进一步查看验证
-XX:NewRatio=2 # 老年代:新生代 = 2:1
-XX:SurvivorRatio=8 # Eden:Survivor0:Survivor1 = 8:1:1
-XX:MaxTenuringThreshold=15 # 对象在年轻代最大年龄
-XX:PretenureSizeThreshold=1m # 大于1m的对象直接进入老年代
虚拟机栈是线程私有的内存,生命周期和线程保持一致,线程创建时就会创建一个虚拟机栈;对栈来说,最小的工作单位是栈帧,方法调用的开始和结束分别对应入栈和出栈
栈帧的组成:
| 组成部分 | 作用 | 特点 |
|---|---|---|
| 局部变量表 | 存储方法参数、局部变量 | 编译时确定大小,Slot 为单位 |
| 操作数栈 | 字节码指令的工作区 | 压栈/出栈,后进先出 |
| 动态链接 | 指向运行时常量池的方法引用 | 实现多态的关键 |
| 方法返回地址 | 方法执行完后返回调用处 | 正常退出/异常退出 |
public class StackOverflowDemo {
public static void recursiveCall() {
recursiveCall(); // 无限递归,没有出口
}
public static void main(String[] args) {
recursiveCall(); // 报错:java.lang.StackOverflowError
}
}
// 参数:-Xss1m (设置栈大小为 1MB,默认 1M 左右)
public class StackOOMDemo {
public static void main(String[] args) {
while (true) {
new Thread(() -> {
try { Thread.sleep(Long.MAX_VALUE); } catch (InterruptedException e) {}
}).start(); // 无限创建线程,每个线程占用栈空间
}
// 报错:java.lang.OutOfMemoryError: unable to create new native thread
}
}
方法区是 JVM 规范中定义的一个概念,用于存储类信息、常量池、静态变量、JIT编译后的代码等数据,它只是定义了规范,并没有规定如何去实现它,不同的厂商、版本有不同的实现。
在 JDK1.7 及其之前的版本,JVM 使用使用永久代来实现方法区,位于**堆内存(Heap)**中,受堆内存大小的限制
相关 JVM 参数:-XX:PermSize 初始容量,-XX:MaxPermSize 最大容量
后面版本抛弃永久代原因:
在 JDK1.8 及之后的版本,JVM 使用元空间来实现方法区,位于本地内存 Native Memory中,受操作系统的限制
理论上只要操作系统的内存足够,元空间就不会发生 OutOfMemoryError
相关 JVM 参数:-XX:MetaspaceSize 初始容量, -XX:MaxMetaspaceSize 最大容量。默认值会依赖平台
元空间对方法区中的数据存放是这么分配的:
字符串常量池可以理解为是分担了部分运行时常量池的工作。字符串耗费高昂的时间与空间代价,大量频繁的创建字符串,极大程度地影响程序的性能,为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化,为字符串开辟一个缓存区。实现该优化的基础是因为字符串是不可变的,可以不用担心数据冲突进行共享。
String str1="abc"; JVM 会在字符串常量池中创建 "abc" 这个字符串,并返回该字符串的引用地址给 str1String str2 = new String("abc"); ,JVM 会在堆中创建一个 "abc" 对象实例,将字符串常量池中这个 "abc" 字符串的引用地址返回赋给 str2程序计数器是线程私有的一块小内存,可以看作是当前线程所执行的字节码的行号指示器,分支、循环、跳转、多线程恢复都依赖它
它位于 CPU 寄存器,不占用内存地址,是唯一一个在 JVM 规范中没有规定任何 OutOfMemoryError 情况的区域
PC 寄存器用来存储指向下一条指令的地址,这个地址指向要执行的代码。CPU 工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
sequenceDiagram
participant CPU
participant PC as PC 寄存器
participant Bytecode as 字节码
CPU->>PC: 读取下一条指令地址
PC->>Bytecode: 根据地址获取字节码
Bytecode-->>CPU: 执行指令
CPU->>PC: PC++ (指向下一条)
Note over PC: 多线程切换后<br/>靠 PC 恢复执行位置
Java 虚拟机栈用于管理 Java 方法的调用,而本地方法栈用于管理 native 方法的调用。除此之外,本地方法栈和虚拟机栈区别不大
Native 方法示例:
public class Object {
// 调用 C/C++ 实现的本地方法
public native int hashCode();
protected native Object clone() throws CloneNotSupportedException;
}
| 区域 | 线程共享 | 作用 | 异常情况 |
|---|---|---|---|
| 堆 | 共享 | 存储对象实例、数组、字符串常量池 | OutOfMemoryError |
| 方法区 | 共享 | 存储类信息、常量、静态变量引用 | OutOfMemoryError |
| 栈 | 私有 | 方法调用、存储局部变量 | StackOverflowError(常见于无限方法递归) OutOfMemoryError(无法申请到足够内存) |
| 程序计数器 | 私有 | 记录当前执行指令 | JVM 唯一不会发生OOM的区域 |
| 本地方法栈 | 私有 | native 方法调用 | 和栈的情况相同 |
基于对象生命周期理论,98%的对象都是朝生夕死的,不同区域使用不同垃圾回收算法,提升 GC 效率
为了避免内存碎片,根据复制算法的要求,必须有一个 From 区、一个 To 区,在执行回收时,将存活对象复制到 To 区,然后清空 From 区,而后两块区域角色互换
StackOverflowErrorOutOfMemoryError主要回收不再使用的类,但是条件苛刻:
JVM 为每个线程都分配了一个独立的栈以及程序计数器,在单线程环境中,所有的条件分支、循环、跳转都依赖它来实现;在多线程环境中,CPU 根据时间片会在多个线程中切换,这样程序就存在着频繁的线程中断和恢复,JVM 需要精准记录各个线程正在执行的方法的情况,线程切换后,需要直到当前方法从哪一行(哪一个指令)继续执行,程序计数器给 CPU 返回需要执行字节码指令的的地址
# 查看当前 JVM 参数
jinfo -flags <PID>
# 进入 arthas 后
jvm # 查看 JVM 完整信息
dashboard # 动态查看 JVM 整体情况,包括内存、gc等信息
| 工具 | 作用 | 命令 |
|---|---|---|
jmap | 堆内存快照 | jmap -dump:format=b,file=heap.hprof <pid> |
jstat | GC 统计 | jstat -gcutil <pid> 1000 |
jstack | 线程栈 | jstack <pid> |
| VisualVM | 可视化监控 | 图形界面 |
| MAT | 堆转储分析 | 图形界面 |
| Arthas | 分析 JVM 的瑞士军刀 | 参考 Arthas 命令 |
一天一个开源项目(第57篇):Unsloth - 2x 更快、70% 更省显存的 LLM 微调库
活用 Claude Code : 从协作者变成可编程的智能基础设施
2026-03-29
2026-03-29