Java基础(11) | JVM 基础:内存结构、类加载与垃圾回收
本系列系统梳理了 Java 开发的详细知识点从基础语法到工程实践层层递进内容详实成体系建议先收藏再慢慢阅读方便日后随时回顾查阅。前言JVM 是 Java 程序运行的基石——理解它的内存结构、类加载机制和垃圾回收原理不仅面试必考更是排查线上 OOM、GC 停顿、类冲突等问题的前提。这篇文章把 JVM 最核心的三大块知识梳理清楚。1. JVM 内存结构JVM 把内存划分为几个区域各司其职JVM 内存线程共享堆 (Heap)新生代Eden S0 S1老年代方法区 / 元空间类信息、常量池、静态变量线程私有虚拟机栈栈帧程序计数器 (PC)本地方法栈1.1 堆Heap存放所有对象实例和数组是 GC 管理的主要区域。ObjectobjnewObject();// obj 引用在栈上Object 实例在堆上int[]arrnewint[100];// 数组对象也在堆上堆分为两大区域新生代Young Generation新创建的对象在这里分配。又细分为 Eden 区和两个 Survivor 区S0、S1。大部分对象朝生夕死在新生代就被回收。老年代Old Generation经过多次 GC 仍然存活的对象被晋升到这里。老年代的对象生命周期长GC 频率低但耗时长。对象分配流程 new Object() → Eden 区分配 → Eden 满了触发 Minor GC → 存活对象复制到 Survivor 区 → 多次 GC 后仍存活默认 15 次 → 晋升到老年代 → 老年代满了触发 Major GC / Full GC1.2 虚拟机栈VM Stack每个线程一个栈每调用一个方法就压入一个栈帧Stack FramepublicvoidmethodA(){intx10;// x 在 methodA 的栈帧中methodB(x);}publicvoidmethodB(inty){Stringshello;// 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:MaxMetaspaceSize256m1.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{staticinta10;// 准备阶段a 0默认值// 初始化阶段a 10真正赋值staticfinalintB20;// 特殊编译期常量准备阶段直接就是 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传入initializefalseClass.forName(类名)定义数组类型Demo[] arrmain 方法所在的类// 不会触发 Demo 初始化B 是编译期常量准备阶段就有了System.out.println(Demo.B);// 输出 20不会打印类初始化了// 会触发 Demo 初始化System.out.println(Demo.a);// 先打印类初始化了再输出 102.2 双亲委派模型什么是类加载器JVM 不会一次性把所有 .class 文件都加载进内存而是用到哪个类才加载哪个。负责加载的组件就是类加载器ClassLoader。为什么有多个类加载器不同的类放在不同的位置各司其职类加载器加载什么举例Bootstrap ClassLoaderJava 核心类库String、ArrayList、HashMap等java.*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());// nullBootstrap 是 C 实现的Java 中显示为 nullSystem.out.println(MyService.class.getClassLoader());// AppClassLoader你的代码由 Application ClassLoader 加载System.out.println(MyService.class.getClassLoader().getParent());// ExtClassLoaderApplication 的父加载器是 ExtensionSystem.out.println(MyService.class.getClassLoader().getParent().getParent());// nullExtension 的父加载器是 Bootstrap显示为 null2.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[]bytesloadClassBytes(name);// 从自定义位置读取字节码returndefineClass(name,bytes,0,bytes.length);}}3. 垃圾回收GC3.1 如何判断对象是否可回收GC 的核心问题堆上有一堆对象哪些还有用哪些是垃圾有两种判断方式。方式一引用计数法JVM 不用了解即可每个对象维护一个计数器有人引用它就 1引用断了就 -1减到 0 说明没人用了可以回收ObjectanewObject();// 对象 A 被 a 引用计数 1Objectba;// 对象 A 又被 b 引用计数 2anull;// a 不再引用计数 1bnull;// b 不再引用计数 0 → 可以回收听起来很简单但有一个致命问题——循环引用classNode{Noderef;// 指向另一个节点}NodeanewNode();// 对象 A 计数 1被变量 a 引用NodebnewNode();// 对象 B 计数 1被变量 b 引用a.refb;// 对象 B 计数 2被变量 b 对象 A 的 ref 引用b.refa;// 对象 A 计数 2被变量 a 对象 B 的 ref 引用anull;// 对象 A 计数 1还被对象 B 的 ref 引用着bnull;// 对象 B 计数 1还被对象 A 的 ref 引用着// 两个对象计数都不为 0无法回收// 但实际上已经没有任何变量能访问到它们了——这就是内存泄漏所以 JVM 不用引用计数法用下面这种。方式二可达性分析JVM 实际使用思路很简单从一组肯定活着的对象出发沿着引用链往下找能找到的就是活的找不到的就是垃圾。这组肯定活着的起点叫GC Roots。哪些对象有资格当 GC Roots就是那些你的代码正在直接使用的东西GC Root为什么肯定活着举例栈中的局部变量方法正在执行变量正在被用void foo() { List list new ArrayList(); }中的liststatic 变量类活着它就活着static Map cache new HashMap();中的cache常量引用不会变static final String NAME test;中的testsynchronized 锁持有的对象正在被锁着不能回收synchronized(obj)中的obj用上面循环引用的例子走一遍可达性分析a null; b null; 之后 从 GC Roots 出发栈中的局部变量 a 和 b 都是 null 了 → 没有任何 GC Root 指向对象 A 或对象 B → 对象 A 和对象 B 都不可达 → 都是垃圾可以回收 ✅ 引用计数法搞不定的循环引用可达性分析轻松解决再看一个正常的例子voidfoo(){ListStringlistnewArrayList();// list 是 GC Root栈中局部变量list.add(hello);// hello 被 list 引用可达MapString,ListStringmapnewHashMap();map.put(key,list);// map 也是 GC Root}// foo() 执行完毕list 和 map 从栈中弹出不再是 GC Root// → ArrayList、HashMap、hello 都不可达 → 全部可回收3.2 四种引用类型GC 判断能不能回收时不是只看有没有引用还要看引用的强度。Java 有四种引用强度从高到低强引用Strong Reference日常写的代码都是强引用ObjectobjnewObject();// obj 就是强引用// 只要 obj 还指着这个对象GC 绝对不会回收它// 哪怕内存不够了宁可抛 OOM 也不回收强引用对象objnull;// 断开引用后才能被回收软引用Soft Reference内存够就留着不够就回收// 场景图片缓存。图片占内存大缓存着能加速但内存不够时宁可丢掉缓存也别 OOMSoftReferencebyte[]cachenewSoftReference(newbyte[10*1024*1024]);byte[]datacache.get();// 尝试获取if(data!null){// 内存充足缓存还在直接用}else{// 内存不足时 GC 回收了它返回 null需要重新加载dataloadFromDisk();}弱引用Weak Reference不管内存够不够下次 GC 就回收// 场景WeakHashMapkey 被 GC 回收后对应的 entry 自动删除防止内存泄漏WeakReferenceObjectweaknewWeakReference(newObject());weak.get();// 能拿到GC 还没来System.gc();// 触发 GCweak.get();// nullGC 一来就被回收了虚引用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% —— 因为大部分对象活不过第一次 GCSurvivor 不需要太大。3.5 主流垃圾回收器回收器算法区域特点Serial复制 / 标记-整理新 / 老单线程STW适合客户端Parallel (默认)复制 / 标记-整理新 / 老多线程并行吞吐量优先CMS标记-清除老低延迟已废弃Java 14 移除G1Java 9 默认分区 复制 整理全堆可预测停顿兼顾吞吐和延迟ZGCJava 15着色指针 读屏障全堆停顿 1ms适合大堆ShenandoahBrooks 指针全堆类似 ZGCRed Hat 主导G1 核心思想4. JVM 常用参数4.1 内存设置# 堆大小-Xms512m# 初始堆大小建议和 Xmx 设为一样避免动态扩缩-Xmx2g# 最大堆大小# 新生代-Xmn512m# 新生代大小-XX:NewRatio2# 老年代 : 新生代 2 : 1默认-XX:SurvivorRatio8# Eden : S0 : S1 8 : 1 : 1默认# 元空间-XX:MetaspaceSize128m# 初始大小-XX:MaxMetaspaceSize256m# 最大大小# 栈-Xss256k# 每个线程的栈大小4.2 GC 选择-XX:UseG1GC# 使用 G1Java 9 默认-XX:UseZGC# 使用 ZGCJava 15-XX:MaxGCPauseMillis200# G1 期望最大停顿时间4.3 GC 日志# Java 9 统一日志推荐-Xlog:gc*:filegc.log:time,uptime,level,tags# 打印更详细的信息-Xlog:gcheapdebug:filegc.log4.4 排查工具# 查看 JVM 进程jps-l# 查看堆内存使用jmap-heappid# 导出堆快照分析内存泄漏jmap-dump:formatb,fileheap.hprofpid# 查看线程状态排查死锁、CPU 飙高jstackpid# 实时监控 GC 情况每秒刷新jstat-gcutilpid1000# 可视化工具# jconsole / jvisualvmJDK 自带# 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线程、jstatGC、Arthas线上诊断下一篇预告注解与反射——动态类信息获取与运行时行为修改 如果这篇文章对你有帮助别忘了点赞、收藏、关注三连关注我让你在 Java 学习的道路上不迷路持续为你带来成体系的 Java 干货~