当前位置: 首页 > news >正文

Java基础(11) | JVM 基础:内存结构、类加载与垃圾回收

📚 本系列系统梳理了 Java 开发的详细知识点,从基础语法到工程实践层层递进,内容详实成体系,建议先收藏再慢慢阅读,方便日后随时回顾查阅。

前言

JVM 是 Java 程序运行的基石——理解它的内存结构、类加载机制和垃圾回收原理,不仅面试必考,更是排查线上 OOM、GC 停顿、类冲突等问题的前提。这篇文章把 JVM 最核心的三大块知识梳理清楚。

1. JVM 内存结构

JVM 把内存划分为几个区域,各司其职:

JVM 内存
线程共享
堆 (Heap)
新生代
Eden + S0 + S1
老年代
方法区 / 元空间
类信息、常量池、静态变量
线程私有
虚拟机栈
栈帧
程序计数器 (PC)
本地方法栈

1.1 堆(Heap)

存放所有对象实例和数组,是 GC 管理的主要区域。

Objectobj=newObject();// obj 引用在栈上,Object 实例在堆上int[]arr=newint[100];// 数组对象也在堆上

堆分为两大区域:

  • 新生代(Young Generation):新创建的对象在这里分配。又细分为 Eden 区和两个 Survivor 区(S0、S1)。大部分对象"朝生夕死",在新生代就被回收。
  • 老年代(Old Generation):经过多次 GC 仍然存活的对象被晋升到这里。老年代的对象生命周期长,GC 频率低但耗时长。
对象分配流程: new Object() → Eden 区分配 → Eden 满了触发 Minor GC → 存活对象复制到 Survivor 区 → 多次 GC 后仍存活(默认 15 次) → 晋升到老年代 → 老年代满了触发 Major GC / Full GC

1.2 虚拟机栈(VM Stack)

每个线程一个栈,每调用一个方法就压入一个栈帧(Stack Frame)

publicvoidmethodA(){intx=10;// x 在 methodA 的栈帧中methodB(x);}publicvoidmethodB(inty){Strings="hello";// y 和 s 在 methodB 的栈帧中}

栈帧包含:

组成部分内容
局部变量表基本类型的值、对象引用
操作数栈方法执行时的中间计算结果
动态链接指向方法区中该方法的引用
返回地址方法执行完后回到哪里继续执行

栈的两种异常:

// StackOverflowError:栈深度超限(通常是无限递归)publicvoidinfinite(){infinite();// 每次调用压一个栈帧,最终溢出}// OutOfMemoryError:创建太多线程,每个线程都要分配栈空间

1.3 程序计数器(PC Register)

每个线程一个,记录当前正在执行的字节码指令地址。是 JVM 中唯一不会 OOM 的区域

1.4 方法区 / 元空间(Metaspace)

存储已加载的类信息、常量、静态变量、JIT 编译后的代码

Java 7 及之前:方法区在 JVM 内存中(永久代 PermGen),大小有限,容易 OOM Java 8 开始:改为元空间(Metaspace),使用本地内存(Native Memory),默认不限大小
// 元空间溢出场景:动态生成大量类(如 CGLIB 代理、大量 JSP)// 报错:java.lang.OutOfMemoryError: Metaspace// 调优:-XX:MaxMetaspaceSize=256m

1.5 各区域 OOM 总结

区域异常常见原因
OutOfMemoryError: Java heap space对象太多、内存泄漏
StackOverflowError无限递归、方法调用太深
OutOfMemoryError线程太多
元空间OutOfMemoryError: Metaspace动态生成大量类
直接内存OutOfMemoryError: Direct buffer memoryNIO ByteBuffer.allocateDirect 过多

1.6 JVM 内存区域总结

区域线程私有/共享存放内容生命周期
堆(Heap)共享所有对象实例、数组JVM 启动到关闭
虚拟机栈(VM Stack)私有局部变量(基本类型值、对象引用)、方法调用栈帧随线程创建/销毁
程序计数器(PC Register)私有当前执行的字节码指令地址随线程创建/销毁
方法区 / 元空间(Metaspace)共享类信息、常量、静态变量、JIT 编译代码JVM 启动到关闭
本地方法栈(Native Stack)私有native 方法(如 C/C++ 实现的 JNI 方法)的调用栈随线程创建/销毁

一个变量到底存在哪,取决于它是什么:

变量类型存放位置示例
局部变量(基本类型)int x = 10;方法内
局部变量(引用)栈上存引用,堆上存对象String s = "hi";方法内
实例变量堆(跟随对象)private String name;
静态变量方法区 / 元空间static int count;
常量方法区的常量池static final int MAX = 100;

2. 类加载机制

2.1 类的生命周期

一个.class文件从被加载到内存,到被卸载,经历以下阶段:

加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载 └─── 连接(Linking) ──┘
阶段做什么通俗理解
加载读取 .class 文件字节码,在内存中生成 Class 对象把简历读进来
验证校验字节码是否合法,防止恶意代码查验简历真假
准备为 static 变量分配内存,赋默认值(0 / null / false)先安排工位,名牌空着
解析将符号引用替换为直接引用(内存地址)把"人名"换成"工位号"
初始化执行static {}和 static 变量的真正赋值员工正式入职,开始干活

准备 vs 初始化是最容易混淆的,用一个例子说明:

publicclassDemo{staticinta=10;// 准备阶段:a = 0(默认值)// 初始化阶段:a = 10(真正赋值)staticfinalintB=20;// 特殊:编译期常量,准备阶段直接就是 20,不用等初始化static{System.out.println("类初始化了");// 初始化阶段才执行}}

时间线:

准备阶段完成后: a = 0, B = 20 初始化阶段完成后:a = 10, B = 20, 打印"类初始化了"

为什么要分两步?因为 JVM 需要先给所有 static 变量分配好内存空间(准备),然后才能按代码顺序执行赋值和 static 代码块(初始化)。如果两步合一,可能出现 A 类的 static 变量引用 B 类,但 B 类还没分配内存的情况。

什么时候会触发类的初始化?

触发不触发
new创建对象访问static final编译期常量(准备阶段就有了)
访问/修改 static 变量子类访问父类 static 变量(只初始化父类)
调用 static 方法Class.forName传入initialize=false
Class.forName("类名")定义数组类型Demo[] arr
main 方法所在的类
// 不会触发 Demo 初始化(B 是编译期常量,准备阶段就有了)System.out.println(Demo.B);// 输出 20,不会打印"类初始化了"// 会触发 Demo 初始化System.out.println(Demo.a);// 先打印"类初始化了",再输出 10

2.2 双亲委派模型

什么是类加载器?JVM 不会一次性把所有 .class 文件都加载进内存,而是用到哪个类才加载哪个。负责加载的组件就是类加载器(ClassLoader)

为什么有多个类加载器?不同的类放在不同的位置,各司其职:

类加载器加载什么举例
Bootstrap ClassLoaderJava 核心类库StringArrayListHashMapjava.*
Extension ClassLoaderJDK 扩展类库jre/lib/ext目录下的类
Application ClassLoader你写的代码和第三方依赖你项目里的类、Maven 引入的 jar 包
自定义 ClassLoader特殊位置的类从网络加载、热部署、插件系统

什么是双亲委派?当需要加载一个类时,不是自己先加载,而是先问父类能不能加载。父类也先问父类的父类,一层层往上问,直到最顶层的 Bootstrap。谁能加载谁来,都不能加载才轮到自己。

你的代码用到 String 类,触发加载: Application ClassLoader 收到请求 → "我先不加载,问问我父类" → Extension ClassLoader 收到请求 → "我也先不加载,问问我父类" → Bootstrap ClassLoader 收到请求 → "String 在 rt.jar 里,我能加载!" → 加载完成,返回 你的代码用到 com.company.MyService 类: Application ClassLoader 收到请求 → 委派给 Extension ClassLoader → 委派给 Bootstrap ClassLoader → "不在我管的范围,加载不了" → Extension ClassLoader: "也不在我这,加载不了" → Application ClassLoader: "在 classpath 里找到了,我来加载"

为什么要这么设计?安全。假如有人写了一个恶意的java.lang.String类放在项目里,双亲委派会让 Bootstrap 优先加载 JDK 自带的String,你写的假String永远不会被加载。保证了核心类不会被篡改。

// 验证类加载器层级System.out.println(String.class.getClassLoader());// null(Bootstrap 是 C++ 实现的,Java 中显示为 null)System.out.println(MyService.class.getClassLoader());// AppClassLoader(你的代码由 Application ClassLoader 加载)System.out.println(MyService.class.getClassLoader().getParent());// ExtClassLoader(Application 的父加载器是 Extension)System.out.println(MyService.class.getClassLoader().getParent().getParent());// null(Extension 的父加载器是 Bootstrap,显示为 null)

2.3 为什么要双亲委派?

安全性:防止用户自定义一个java.lang.String来替换核心类。无论谁请求加载String,最终都会由 Bootstrap ClassLoader 加载 rt.jar 中的那个。

一致性:保证同一个类在 JVM 中只被加载一次,所有代码用的是同一个String.class

2.4 打破双亲委派

某些场景需要打破双亲委派:

// 典型场景:// 1. Tomcat:每个 Web 应用有自己的 ClassLoader,同名类互不影响// 2. SPI 机制:Bootstrap ClassLoader 加载的接口需要加载 classpath 上的实现类// 3. 热部署:抛弃旧 ClassLoader,创建新的来加载修改后的类// 自定义 ClassLoader 示例publicclassMyClassLoaderextendsClassLoader{@OverrideprotectedClass<?>findClass(Stringname)throwsClassNotFoundException{byte[]bytes=loadClassBytes(name);// 从自定义位置读取字节码returndefineClass(name,bytes,0,bytes.length);}}

3. 垃圾回收(GC)

3.1 如何判断对象是否可回收?

GC 的核心问题:堆上有一堆对象,哪些还有用,哪些是垃圾?有两种判断方式。

方式一:引用计数法(JVM 不用,了解即可)

每个对象维护一个计数器,有人引用它就 +1,引用断了就 -1,减到 0 说明没人用了,可以回收:

Objecta=newObject();// 对象 A 被 a 引用,计数 = 1Objectb=a;// 对象 A 又被 b 引用,计数 = 2a=null;// a 不再引用,计数 = 1b=null;// b 不再引用,计数 = 0 → 可以回收

听起来很简单,但有一个致命问题——循环引用

classNode{Noderef;// 指向另一个节点}Nodea=newNode();// 对象 A 计数 = 1(被变量 a 引用)Nodeb=newNode();// 对象 B 计数 = 1(被变量 b 引用)a.ref=b;// 对象 B 计数 = 2(被变量 b + 对象 A 的 ref 引用)b.ref=a;// 对象 A 计数 = 2(被变量 a + 对象 B 的 ref 引用)a=null;// 对象 A 计数 = 1(还被对象 B 的 ref 引用着)b=null;// 对象 B 计数 = 1(还被对象 A 的 ref 引用着)// 两个对象计数都不为 0,无法回收// 但实际上已经没有任何变量能访问到它们了——这就是内存泄漏

所以 JVM 不用引用计数法,用下面这种。

方式二:可达性分析(JVM 实际使用)

思路很简单:从一组"肯定活着"的对象出发,沿着引用链往下找,能找到的就是活的,找不到的就是垃圾

这组"肯定活着"的起点叫GC Roots。哪些对象有资格当 GC Roots?就是那些你的代码正在直接使用的东西

GC Root为什么肯定活着举例
栈中的局部变量方法正在执行,变量正在被用void foo() { List list = new ArrayList(); }中的list
static 变量类活着它就活着static Map cache = new HashMap();中的cache
常量引用不会变static final String NAME = "test";中的"test"
synchronized 锁持有的对象正在被锁着,不能回收synchronized(obj)中的obj

用上面循环引用的例子走一遍可达性分析:

a = null; b = null; 之后: 从 GC Roots 出发(栈中的局部变量 a 和 b 都是 null 了) → 没有任何 GC Root 指向对象 A 或对象 B → 对象 A 和对象 B 都不可达 → 都是垃圾,可以回收 ✅ 引用计数法搞不定的循环引用,可达性分析轻松解决

再看一个正常的例子:

voidfoo(){List<String>list=newArrayList<>();// list 是 GC Root(栈中局部变量)list.add("hello");// "hello" 被 list 引用,可达Map<String,List<String>>map=newHashMap<>();map.put("key",list);// map 也是 GC Root}// foo() 执行完毕,list 和 map 从栈中弹出,不再是 GC Root// → ArrayList、HashMap、"hello" 都不可达 → 全部可回收

3.2 四种引用类型

GC 判断"能不能回收"时,不是只看"有没有引用",还要看引用的强度。Java 有四种引用,强度从高到低:

强引用(Strong Reference):日常写的代码都是强引用

Objectobj=newObject();// obj 就是强引用// 只要 obj 还指着这个对象,GC 绝对不会回收它// 哪怕内存不够了,宁可抛 OOM 也不回收强引用对象obj=null;// 断开引用后才能被回收

软引用(Soft Reference):内存够就留着,不够就回收

// 场景:图片缓存。图片占内存大,缓存着能加速,但内存不够时宁可丢掉缓存也别 OOMSoftReference<byte[]>cache=newSoftReference<>(newbyte[10*1024*1024]);byte[]data=cache.get();// 尝试获取if(data!=null){// 内存充足,缓存还在,直接用}else{// 内存不足时 GC 回收了它,返回 null,需要重新加载data=loadFromDisk();}

弱引用(Weak Reference):不管内存够不够,下次 GC 就回收

// 场景:WeakHashMap,key 被 GC 回收后,对应的 entry 自动删除,防止内存泄漏WeakReference<Object>weak=newWeakReference<>(newObject());weak.get();// 能拿到(GC 还没来)System.gc();// 触发 GCweak.get();// null(GC 一来就被回收了)

虚引用(Phantom Reference):最弱,get() 永远返回 null

// 场景:跟踪对象什么时候被回收了,做清理工作(比如释放堆外内存)// 日常开发基本用不到,了解即可

总结:

引用类型类比GC 态度典型场景
强引用亲儿子打死也不回收,宁可 OOM所有普通变量
软引用家里亲戚家里宽裕就住着,住不下了请你走内存敏感的缓存
弱引用临时访客打扫卫生(GC)就清走WeakHashMap
虚引用监控探头随时清走,只是通知你一声跟踪回收状态

日常开发 99% 都是强引用,偶尔用软引用做缓存,弱引用和虚引用在框架源码里才会见到。

3.3 GC 算法

标记-清除(Mark-Sweep)
标记阶段:从 GC Roots 遍历,标记所有可达对象 清除阶段:回收未被标记的对象 优点:简单 缺点:产生内存碎片
标记-复制(Copying)
将内存分为两半,每次只用一半 GC 时把存活对象复制到另一半,清空当前这半 优点:无碎片,分配快(指针碰撞) 缺点:可用内存减半 新生代使用此算法(Eden + S0 + S1 的设计就是优化版的复制算法)
标记-整理(Mark-Compact)
标记阶段:同标记-清除 整理阶段:将存活对象向一端移动,清空边界以外的内存 优点:无碎片 缺点:移动对象开销大 老年代使用此算法

3.4 分代回收策略

为什么要分代?研究发现大部分对象"朝生夕死"(比如方法里的局部变量,方法执行完就没用了),少部分对象长期存活(比如缓存、连接池)。把它们分开管理,用不同的策略回收,效率更高。

堆被分成两大区域

区域占比存放什么GC 频率
新生代(Young)约 1/3新创建的对象频繁,但每次很快
老年代(Old)约 2/3长期存活的对象很少,但每次很慢

新生代内部又分三块

新生代(Young Generation) ┌──────────────┬───────┬───────┐ │ Eden │ S0 │ S1 │ │ (80%) │ (10%) │ (10%) │ └──────────────┴───────┴───────┘ 新对象在这里诞生 两个 Survivor 区轮流使用

用搬家来理解整个流程

把 Eden 想象成一个临时宿舍,S0 和 S1 是两个小隔间,老年代是正式住所:

第一步:新对象在 Eden 出生 new Object() → 分配到 Eden 区 第二步:Eden 满了,触发 Minor GC GC 检查 Eden 里所有对象: 还有人引用的(存活)→ 搬到 S0,年龄标记为 1 没人引用的(垃圾)→ 直接清除 Eden 清空 第三步:Eden 又满了,再次 Minor GC GC 同时检查 Eden 和 S0: 存活的 → 全部搬到 S1,年龄 +1 垃圾 → 清除 Eden 和 S0 清空 第四步:Eden 又满了,再次 Minor GC GC 同时检查 Eden 和 S1: 存活的 → 全部搬到 S0,年龄 +1 垃圾 → 清除 Eden 和 S1 清空 (S0 和 S1 就这样轮流交替,始终有一个是空的) 第五步:某个对象年龄达到 15(默认阈值) 说明这个对象经历了 15 次 GC 都没死 → 搬到老年代(正式住下) 第六步:老年代也满了 触发 Major GC / Full GC → 整个堆大扫除,耗时很长(程序会卡顿)

为什么 Survivor 要两个轮流用?这是标记-复制算法的核心——每次 GC 把存活对象复制到另一个 Survivor,然后把原来那个整块清空。这样不会产生内存碎片(不像标记-清除会留下"洞"),而且新生代大部分对象都是垃圾,真正需要复制的很少,所以速度很快。

用具体数字感受一下

假设 Eden 每次 GC 有 100 个对象: → 大约 95 个是垃圾,直接清掉 → 只有 5 个存活,复制到 Survivor → 复制 5 个比整理 100 个快得多

这也是为什么 Eden 占 80%、Survivor 各占 10% —— 因为大部分对象活不过第一次 GC,Survivor 不需要太大。

3.5 主流垃圾回收器

回收器算法区域特点
Serial复制 / 标记-整理新 / 老单线程,STW,适合客户端
Parallel (默认)复制 / 标记-整理新 / 老多线程并行,吞吐量优先
CMS标记-清除低延迟,已废弃(Java 14 移除)
G1(Java 9 默认)分区 + 复制 + 整理全堆可预测停顿,兼顾吞吐和延迟
ZGC(Java 15+)着色指针 + 读屏障全堆停顿 < 1ms,适合大堆
ShenandoahBrooks 指针全堆类似 ZGC,Red Hat 主导
G1 核心思想

4. JVM 常用参数

4.1 内存设置

# 堆大小-Xms512m# 初始堆大小(建议和 Xmx 设为一样,避免动态扩缩)-Xmx2g# 最大堆大小# 新生代-Xmn512m# 新生代大小-XX:NewRatio=2# 老年代 : 新生代 = 2 : 1(默认)-XX:SurvivorRatio=8# Eden : S0 : S1 = 8 : 1 : 1(默认)# 元空间-XX:MetaspaceSize=128m# 初始大小-XX:MaxMetaspaceSize=256m# 最大大小# 栈-Xss256k# 每个线程的栈大小

4.2 GC 选择

-XX:+UseG1GC# 使用 G1(Java 9+ 默认)-XX:+UseZGC# 使用 ZGC(Java 15+)-XX:MaxGCPauseMillis=200# G1 期望最大停顿时间

4.3 GC 日志

# Java 9+ 统一日志(推荐)-Xlog:gc*:file=gc.log:time,uptime,level,tags# 打印更详细的信息-Xlog:gc+heap=debug:file=gc.log

4.4 排查工具

# 查看 JVM 进程jps-l# 查看堆内存使用jmap-heap<pid># 导出堆快照(分析内存泄漏)jmap-dump:format=b,file=heap.hprof<pid># 查看线程状态(排查死锁、CPU 飙高)jstack<pid># 实时监控 GC 情况(每秒刷新)jstat-gcutil<pid>1000# 可视化工具# jconsole / jvisualvm(JDK 自带)# Arthas(阿里开源,线上诊断神器)

5. 常见 OOM 排查思路

OutOfMemoryError: Java heap space → jmap -dump 导出堆快照 → 用 MAT 或 VisualVM 分析 → 找到占用内存最大的对象 → 检查是否有内存泄漏(长生命周期对象持有短生命周期对象的引用) → 常见原因: - 集合不断添加不清理(Map 做缓存没有淘汰策略) - 大查询一次性加载全部数据(应该分页) - 静态集合持有大量对象 OutOfMemoryError: Metaspace → 检查是否动态生成大量类(CGLIB 代理、反射、脚本引擎) → 增大 -XX:MaxMetaspaceSize StackOverflowError → 检查递归是否有终止条件 → 考虑将递归改为迭代 → 增大 -Xss(治标不治本)

6. 小结

主题关键要点
所有对象实例在这里分配;分新生代(Eden + S0 + S1)和老年代
线程私有,每个方法调用一个栈帧;存局部变量和引用
方法区/元空间类信息、常量池、静态变量;Java 8 改为本地内存
类加载加载 → 验证 → 准备 → 解析 → 初始化
双亲委派先委派父加载器,保证核心类安全和唯一
可达性分析从 GC Roots 出发,不可达即为垃圾
GC 算法标记-清除(碎片)、标记-复制(新生代)、标记-整理(老年代)
GC 回收器G1 是默认选择,ZGC 适合大堆低延迟场景
调优工具jmap(内存)、jstack(线程)、jstat(GC)、Arthas(线上诊断)

下一篇预告:注解与反射——动态类信息获取与运行时行为修改


🎯 如果这篇文章对你有帮助,别忘了点赞、收藏、关注三连!关注我,让你在 Java 学习的道路上不迷路,持续为你带来成体系的 Java 干货~

http://www.jsqmd.com/news/1084132/

相关文章:

  • 太原街道岗亭
  • SQL注入漏洞复现:从手工测试到自动化利用的实战指南
  • GCGR靶点深度解析:从糖代谢枢纽到多靶点代谢治疗的关键协同受体
  • LÖVE:用 Lua 写 2D 游戏的开源框架
  • ComfyUI-Impact-Pack V8:解决AI图像细节模糊的5大核心技术方案
  • Adobe-GenP 3.0终极指南:5步快速免费激活Adobe全家桶
  • 超维空间镜像 打造营区全场景物理空间透明化数智中枢 技术解析白皮书
  • 【第二部分】STM32CubeMX 创建 STM32F103CBT6 完整标准流程
  • 基于Fisher-Kolmogorov方程与几何简化的大脑疾病蛋白传播动力学建模
  • 开源网盘直链下载助手完整指南:告别限速困扰
  • 四川设备搬迁找他们,真的能省心又高效吗?
  • 化工厂跨厂区设备无线通信物联网方案
  • 开源4G GPS定位器开发与优化实践
  • 文艺复兴元素服饰库存周转测算程序,判断复古艺术款最优生产备货量。
  • 带你认识NSE
  • 【2026】超详细Maple 2025安装保姆级教程,数学代数系统环境配置和使用指南,看完这一篇就够了
  • Serverless 架构与自动化发布流水线:从冷启动优化到 GitOps 的工程实战
  • 2026填志愿用的资料,我帮你打包好了,直接拿
  • IPXWrapper实战指南:让经典游戏在Win10/11重获联机生命
  • 客户服务AI智能体采用率飙升:70%组织60天见成效,新定价模式加速企业应用
  • 3步精准定位:Windows热键冲突终极侦探工具揭秘
  • 如何零成本解锁Grammarly Premium:终极免费使用指南
  • 【Springboot毕设全套源码+文档】基于SpringBoot+Vue的眼科患者随访管理系统的设计与实现(丰富项目+远程调试+讲解+定制)
  • Altium Designer 2024 原理图高级功能:原理图和PCB网络颜色同步
  • 【AI大模型进阶】“预训练”和“微调”的区别:就像是“基础教育”和“岗前培训”
  • paraphrase-multilingual-MiniLM-L12-v2完整指南:3步实现多语言语义搜索
  • c++实现委托
  • 亚马逊AI业务崛起:MaaS领先、芯片布局完善,大模型借合作曲线救国?
  • iOS审核被拒:4.1 仿制品与马甲包——你的“创新”在苹果眼里只是复制粘贴
  • RISC-V进入汽车芯片:指令集授权风险,比你想的更严重