JVM 内存结构详解

目录

一、JVM、JRE、JDK关系

[二、JVM 整体运行流程](#二、JVM 整体运行流程)

[三、JVM 内存结构](#三、JVM 内存结构)

四、程序计数器

[五、Java 虚拟机栈](#五、Java 虚拟机栈)

六、堆

七、对象创建全过程、4种引用

八、对象什么时候会被回收?根据垃圾回收算法判断

[九、GC 垃圾回收机制](#九、GC 垃圾回收机制)

十、垃圾回收算法

[十一、为什么 JVM 要分代?](#十一、为什么 JVM 要分代?)

十二、双亲委派机制

[十三、常见 OOM 问题](#十三、常见 OOM 问题)


一、JVM、JRE、JDK关系

在我们进行JAVA开发的时候,第一步就是安装JDK、JDK里面又包含了JRE,三者关系如下:

二、JVM 整体运行流程

众所周知,Java的特点是一次编译,到处运行,而这个到处运行的关键就是JVM虚拟机,项目跑在JVM虚拟机上,而虚拟机适配各种系统,如windows、linux、mac

我们写的 Java 文件,并不能直接运行。Java 程序真正运行流程如下:

.java

javac 编译

.class 字节码文件

ClassLoader 类加载

JVM 内存区域

Execution Engine 执行引擎

JVM 本质上:就是一个运行 Java 字节码的虚拟机

三、JVM 内存结构

JVM 在运行过程中,会把内存划分成多个区域

核心结构如下:

线程共享:

方法区(元空间)

线程私有:

程序计数器

虚拟机栈

本地方法栈

四、程序计数器

程序计数器(Program Counter Register):

本质上是:当前线程正在执行的字节码行号指示器

作用

因为 JVM 支持多线程并发执行,例如

就会导致CPU 会不断线程切换

切换回来后,必须知道:线程执行到哪了

程序计数器就是干这个的。

生命周期随线程

线程的时候创建程序计数器

线程销毁的时候程序计数器也销毁

为什么程序计数器不会 OOM?

因为它占用内存极小,也就整个JVM中唯一一个不会发生 OOM 的区域

五、Java 虚拟机栈

虚拟机栈:Java 方法执行的内存模型

每次方法调用都会创建:栈帧(Stack Frame)

栈帧中包含什么?

局部变量表

操作数栈

动态链接

方法出口

示例

java 复制代码
public void test(){

    int a = 1;

    int b = 2;

    int c = a + b;
}

其中:a、b、c

都存储在:局部变量表中

为什么递归容易 StackOverflow?

例如:

java 复制代码
public void test(){
    test();
}

每次递归:都会创建新栈帧

最终 栈空间耗尽,抛出StackOverflowError

栈大小如何调整?

-Xss

例如:-Xss1m

表示:每个线程栈大小 1MB

六、堆

堆是 JVM 中最大的一块内存区域 ,所有对象实例、数组都在堆中分配内存,也是垃圾回收(GC)的核心区域 堆分为两大块:

  1. 新生代(Young Generation):存放刚创建、生命周期短的对象
  2. 老年代(Old Generation / Tenured):存放长期存活、多次 GC 未被回收的对象

默认占比(HotSpot 虚拟机,JDK8)

1. 新生代:老年代 默认比例

默认 1 : 2

  • 新生代占堆总大小的 1/3
  • 老年代占堆总大小的 2/3

2. 新生代内部结构

新生代又分为:Eden 区 + Survivor0(S0) + Survivor1(S1) 默认比例:8 : 1 : 1

  • Eden:80%(大部分新对象在这里创建)
  • S0:10%
  • S1:10%(始终一块空闲,做复制存活对象用)

3. 举个直观例子

假设堆总大小 -Xms -Xmx = 300M

  • 新生代:100M
    • Eden:80M
    • S0:10M
    • S1:10M
  • 老年代:200M

堆结构

为什么对象优先进入 Eden?

因为绝大多数对象"朝生夕死",也就是说的用完就没用了该回收了,创建和回收较频繁

例如:

  • 方法中的临时对象
  • 查询结果
  • DTO
  • JSON 对象

因此 JVM 专门设计:新生代 来高效回收

对象什么时候进入老年代?

老年代是什么?老年代存储的是存活时间较长的,也就好几轮没回收的,认为该对象活的时间较长,放在老年代就避免频繁判断是否回收。也可能当新生代内存不够了会直接将对象放在老年代中

主要有几个条件:

1. 年龄达到阈值

对象每熬过一次 Minor GC(新生代垃圾回收),就会 年龄 +1

默认:15 岁 进入老年代,也就是说如果这个对象在 新生代 发生垃圾回收15次,这个对象还没被回收

2. 大对象直接进入老年代

例如:超大数组 、 超大字符串

避免:新生代频繁复制

常用堆JVM参数

  • -Xms:初始堆内存大小
  • -Xmx:最大堆内存大小 线上通常设置 -Xms=-Xmx,固定堆大小,避免扩容抖动
  • -Xmn:新生代总内存大小 (Eden 区 + Survivor0 + Survivor1 的总大小)
  • -XX:NewRatio=2:新生代:老年代 = 1:2
  • -XX:SurvivorRatio=8:Eden:S0:S1=8:1:1
  • -XX:MaxTenuringThreshold=15:对象晋升老年代的年龄阈值

七、对象创建全过程、4种引用

对象创建过程

java 复制代码
User user = new User();

到底发生了什么?

1. 类加载检查

JVM 首先检查:User 类是否已经加载

如果没有:先执行类加载

2. 分配内存

JVM 在堆中:划分对象空间

3. 初始化零值

例如:

java 复制代码
int -> 0
boolean -> false

4. 设置对象头

对象头中包含:

  • hashcode
  • GC 分代年龄
  • 锁状态

5. 执行 init 方法

构造方法 真正执行

Java 4 种引用

  1. 强引用:默认,不回收
  2. 软引用:内存不足回收
  3. 弱引用:下次 GC 必回收
  4. 虚引用:仅做回收通知

八、对象什么时候会被回收?根据垃圾回收算法判断

目前常见的:引用计数法、GC Root可达性分析法,但是目前基本都是GCRoot

引用计数法为什么不用?

因为:循环引用问题

例如:

A -> B

B -> A

即使没人引用它们:引用计数也不为 0

JVM 使用什么

把整个内存里的对象,想象成一棵倒立的大树

  • GC Roots = 树根(起点)
  • 普通对象 = 树枝、叶子

可达性分析逻辑:GC Roots(根) 出发,顺着引用链往下遍历

  1. 能走到的对象 → 可达,存活,不回收
  2. 走不到的对象 → 不可达,垃圾,要回收

只要树根能顺着树枝到达这个叶子 → 活着 树根摸不到这个叶子 → 垃圾,回收

GC Root 包括

  • 虚拟机栈(本地变量表)引用的对象
  • 方法区中静态变量引用的对象
  • 本地方法栈(JNI)引用的对象
  • 运行中的活跃线程
  • Class 对象、锁对象等

九、GC 垃圾回收机制

Minor GC

发生在:新生代空间不时,Eden 满出发

特点:频率高、速度快、STW 时间短

Full GC

回收:整个堆

包括:

  • 新生代
  • 老年代

Full GC触发条件

  1. 老年代空间不足
  2. 元空间不足
  3. 手动调用 System.gc()(注:只是"建议 JVM 进行 Full GC",不一定立即执行

为什么 FullGC 可怕

因为 STW(Stop The World) , 会暂停所有用户线程

线上现象

如果出现频繁 FullGC 或单次 FullGC 时间过长:

  • 接口响应超时
  • 服务明显卡顿
  • TPS、QPS 暴跌
  • 严重时引发服务雪崩

单次 FullGC 停顿太久(几秒)→ 直接超时、雪崩

频繁 FullGC(几秒一次)→ CPU 打满、服务持续卡顿

十、垃圾回收算法

1. 标记清除

过程:

标记垃圾

直接删除

问题:会产生内存碎片

图有点问题,标记的是存活对象,未标记的是要回收的对象,并且回收结束后不会移动存活对象

2. 复制算法

把存活对象 ,复制到另一块区域,这样就可以内存连续

优点:无碎片

缺点:浪费空间,要再单独搞出一块内存放 内存连续 没有被回收的对象

3. 标记整理

整理内存:避免碎片

十一、为什么 JVM 要分代?

不同对象的生命周期不一样,分代后用不同的垃圾回收算法,提升 GC 效率,减少 STW 停顿

JVM 发现对象有非常明显的特点:

  • 大部分对象朝生夕死:方法内临时变量、接口临时数据、DTO、JSON 对象,用完马上就回收;
  • 少部分对象长期存活:全局缓存、单例对象、静态变量,长期不回收。

如果不分代,所有对象混在一起,每次 GC 都要扫描整个堆,效率极低、停顿极长。

2. 新生代、老年代用不同 GC 算法

新生代(对象死亡率极高)

  • 特点:存活对象少、死亡对象多
  • 适合:复制算法
  • 优势:复制少量存活对象,没有内存碎片,回收速度极快,Minor GC 毫秒级

老年代(对象存活率极高)

  • 特点:存活对象多、死亡对象少
  • 适合:标记 - 清除 / 标记 - 整理算法
  • 优势:不用大量复制,避免浪费内存

3. 分代带来的好处

  1. GC 频率分开:新生代频繁快速回收,老年代很少回收
  2. STW 时间变短:大部分垃圾在新生代就被清理,不用频繁扫描整个堆
  3. 内存利用率更高:针对不同生命周期优化内存布局

4. 不分代的缺点

不分代时,不管新对象老对象,每次 GC 都要全部扫描,GC 慢、卡顿久、性能差

十二、双亲委派机制

双亲委派机制是 Java 类加载器的加载规则: 当一个类收到加载请求时,先交给父类加载器;父类加载不了,自己再加载。 向上委派,向下查找

四层类加载器(从上到下)

  1. 启动类加载器(Bootstrap ClassLoader) :加载 JDK 核心类,如java.lang.*,C++ 实现
  2. 扩展类加载器(Extension ClassLoader):加载 jre/lib/ext 扩展包
  3. 应用类加载器(App ClassLoader):加载我们自己写的项目代码
  4. 自定义类加载器:自己继承 ClassLoader 实现

注意:双亲不是指两个父类,是向上层层委托

完整加载流程

  1. 我自己的类 → 先给应用类加载器
  2. 应用类加载器 → 委托给扩展类加载器
  3. 扩展类加载器 → 委托给启动类加载器
  4. 启动类加载器找不到 → 退回给扩展
  5. 扩展找不到 → 退回给应用加载器自己加载

原则:向上委托,向下查找

为什么要双亲委派?

  1. 安全,防止核心类被篡改 比如你自己写一个java.lang.String类, 因为启动类加载器已经加载过系统的 String,不会加载你写的,避免安全漏洞

  2. 类全局唯一,避免重复加载 保证一个类在内存中只加载一次,节省内存

复制代码

十三、常见 OOM 问题

1. Java heap space

堆内存不足。

常见原因:

  • 内存泄漏
  • 大缓存
  • 集合无限增长

2. StackOverflowError

递归过深,例如我们写的错误递归算法

3. Metaspace OOM

元空间不足

常见:动态生成大量类

(部分图示来自AI生成)

相关推荐
@杰克成3 小时前
Java学习28
java·python·学习
随风丶飘3 小时前
DeepSeek TUI 让后端告别窗口切来切去
java·ai编程
中国胖子风清扬3 小时前
PageIndex:用推理替代向量的下一代 RAG 架构
java·spring boot·python·spring·ai·embedding·rag
带刺的坐椅3 小时前
Solon Flow 实战:用 50 行 YAML 实现一个请假审批流(含中断恢复、并行网关、条件分支)
java·solon·工作流·审批流·流程编排
张二娃同学3 小时前
02_C语言数据类型_整型浮点型字符型一次讲清楚
android·java·c语言
程序猿乐锅3 小时前
【Tilas|第九篇】登录认证功能实现
java·笔记·tlias
optimistic_chen3 小时前
【AI Agent 全栈开发】RAG(检索增强生成)
java·linux·运维·人工智能·ai编程·rag
诸葛李3 小时前
集成构建xxxxx
java·junit·单元测试
Co_Hui3 小时前
Java: 集合
java·开发语言