资讯动态

JVM系统学习笔记:内存模型、垃圾回收、类加载与性能调优实战

发布时间:2026/10/16 5:24:40 来源:尧图企业网站定制
1. 为什么值得系统啃一遍JVM很多人学Java写着写着就撞上一堵墙代码逻辑明明没问题可线上就是莫名其妙卡顿、内存飙升、甚至进程直接挂掉。你去看日志日志里只有一句冷冰冰的OutOfMemoryError或者GC日志里一堆看不懂的数字。这时候你会发现平时写业务代码那套本事到了排查性能问题的时候基本使不上劲。JVM就是那道分水岭——懂它的人能从一堆指标里定位到问题根源不懂的人只能重启了事。我自己当年也是被线上一个内存泄漏问题逼着回头补的JVM。那会儿项目跑着跑着堆就满了每次都是重启续命后来硬着头皮把JVM的内存结构、垃圾回收、类加载这几块啃了一遍才慢慢摸到门道。这次整理这份学习笔记核心参考的是一套体系比较完整的JVM视频教程和配套PPT我把里面最值得反复看的内容重新梳理了一遍加上自己踩坑的经验形成这份汇总。这份笔记适合谁看如果你已经写过一段时间Java对基本语法和面向对象不陌生但一提到内存模型、垃圾回收器、类加载机制就发怵那这份内容就是给你准备的。如果你正准备面试JVM几乎是绕不开的必考区这份笔记也能当复习提纲用。我不会堆砌概念而是尽量把每个知识点讲清楚它解决什么问题为什么这么设计实际怎么用。整份笔记会围绕四大块展开内存与运行时数据区、垃圾回收机制、类加载与字节码、性能调优与问题排查。每一块我都会先讲原理再讲实操最后附上我自己踩过的坑和常见问题的排查思路。你可以按顺序读也可以挑自己薄弱的环节跳着看。2. 内存结构与运行时数据区拆解2.1 运行时数据区到底分了哪几块JVM在运行Java程序的时候会把它管理的内存划分成若干个区域每个区域有各自的用途、创建时间和销毁时间。理解这张内存地图是后面所有内容的基础因为垃圾回收、线程安全、内存溢出这些问题本质上都跟数据放在哪个区有关。按照常见的划分方式运行时数据区主要包括这么几块程序计数器当前线程所执行的字节码的行号指示器。它是线程私有的占用的内存非常小。为什么需要它因为CPU是多线程轮转执行的线程切换回来之后得知道上次执行到哪了计数器就是干这个的。这块区域是唯一一个不会抛出内存溢出异常的区域。虚拟机栈也是线程私有的生命周期跟线程相同。每个方法执行的时候会创建一个栈帧里面存局部变量表、操作数栈、动态链接、方法出口等信息。方法调用就是栈帧入栈和出栈的过程。本地方法栈跟虚拟机栈作用类似区别是它为本地方法服务。堆线程共享是JVM管理的内存中最大的一块几乎所有的对象实例都在这里分配。它也是垃圾回收的主战场。方法区线程共享用于存储已被加载的类信息、常量、静态变量、即时编译后的代码等。不同版本实现有差异后面会细说。运行时常量池方法区的一部分存放编译期生成的各种字面量和符号引用。我建议你在脑子里建立一个画面线程私有的区域随线程生灭线程共享的区域随JVM进程生灭。这个区分非常关键因为线程私有区域基本不用考虑垃圾回收而共享区域才是回收的重点。2.2 堆内存的分代设计逻辑堆是垃圾回收的核心区域它的设计直接决定了回收效率。为什么要把堆分成新生代和老年代核心原因就一个词分代收集。大部分对象都是朝生夕死的创建出来没多久就没用了。如果每次回收都扫描整个堆效率会非常低。分代的思想就是根据对象的存活周期把堆划分开对不同区域采用不同的回收策略。新生代里的对象存活率低可以用复制算法快速回收老年代对象存活率高用标记整理或者标记清除更合适。堆的典型划分是这样的区域默认比例特点常用回收算法新生代 Eden8对象优先在这里分配复制算法新生代 Survivor01存放幸存对象复制算法新生代 Survivor11与S0轮换使用复制算法老年代-长期存活对象标记清除/标记整理新生代和老年代默认比例是1:2也就是说新生代占整个堆的1/3。新生代内部Eden和两个Survivor的比例默认是8:1:1。这个比例不是随便定的是基于大量统计得出的——绝大多数对象在第一次回收时就会被清掉所以Eden需要大Survivor只需要留够幸存对象就行。对象在新生代里的流转过程是这样的新对象优先在Eden分配当Eden满了触发Minor GC存活的对象被复制到Survivor区同时年龄加1。每经历一次Minor GC还活着的对象年龄就加1当年龄达到阈值默认15就会被晋升到老年代。另外还有几种特殊情况会提前晋升比如大对象直接进老年代、Survivor区空间不够时的分配担保等。提示分代比例可以通过参数调整但不是所有场景都适合默认值。比如大量短生命周期的大对象场景可能需要调整Eden大小或者晋升阈值这个后面调优部分会细讲。2.3 方法区的版本演进与坑点方法区这块是很多人容易混淆的地方因为它涉及到几个不同的概念方法区、永久代、元空间。我当年就被这几个词绕晕过。方法区是规范永久代和元空间是不同版本的实现。在JDK 7及之前HotSpot用永久代来实现方法区永久代在堆内存里。到了JDK 8永久代被彻底移除改用元空间实现元空间使用的是本地内存而不是堆内存。这个变化带来的直接影响是以前永久代满了会抛OutOfMemoryError: PermGen space现在元空间满了抛的是OutOfMemoryError: Metaspace。更重要的是元空间默认不限制大小受本地内存限制所以理论上可以加载更多的类但也意味着如果类加载器泄漏可能把本地内存吃光。方法区里存的东西包括类的全限定名、字段描述符、方法描述符、常量池、静态变量、即时编译后的代码缓存等。运行时常量池是方法区的一部分它跟Class文件里的常量池不是一回事——Class文件常量池是静态的运行时常量池是动态的运行期间也可以往里面加内容比如String.intern()方法。这里有个经典问题String s new String(abc)创建了几个对象答案是两个如果常量池里还没有abc。一个在堆里一个在常量池里。这种题面试常考理解了常量池的位置就很容易答对。3. 垃圾回收机制与回收器选型3.1 怎么判断一个对象该被回收垃圾回收的第一步是判断哪些对象还活着、哪些已经死了。主流的方法有两种引用计数法和可达性分析。引用计数法很简单给对象加一个计数器有引用就加1引用失效就减1计数为0就可以回收。但它有个致命缺陷——循环引用。两个对象互相引用计数永远不为0但实际上它们已经没用了。所以主流JVM都没用这个方法。HotSpot用的是可达性分析。基本思路是从一系列称为GC Roots的对象出发向下搜索走过的路径叫引用链。如果一个对象到GC Roots没有任何引用链相连就说明它不可达可以被回收。哪些对象可以作为GC Roots常见的有虚拟机栈中引用的对象局部变量等方法区中静态属性引用的对象方法区中常量引用的对象本地方法栈中引用的对象活跃线程的引用这里有个容易忽略的点不可达的对象不一定马上被回收。一个对象被判定不可达后还会经历一次缓刑——如果它重写了finalize()方法且没被执行过会被放进F-Queue队列等待Finalizer线程执行。如果它在finalize()里重新跟引用链建立了连接就能复活。不过finalize()这个方法现在基本不推荐用了执行时机不确定性能也差知道有这么回事就行。3.2 三种基础回收算法的取舍垃圾回收算法是回收器的底层逻辑理解了算法再看各种回收器就清楚它们为什么那么设计了。标记清除算法是最基础的。分两步先标记所有需要回收的对象然后统一清除。它的问题很明显——效率不高而且清除后会产生大量不连续的内存碎片。碎片多了之后大对象可能找不到足够的连续空间只能提前触发一次GC。复制算法解决了碎片问题。它把内存分成两块每次只用一块回收时把存活对象复制到另一块然后清空当前块。优点是简单高效、没有碎片。缺点是内存利用率只有一半。不过在实际的新生代里因为存活对象少不需要1:1划分而是用Eden加两个Survivor的方式利用率能到90%。标记整理算法适合老年代。标记过程跟标记清除一样但后续不是直接清除而是让所有存活对象向一端移动然后清理掉边界以外的内存。这样既没有碎片又不需要浪费一半空间。代价是移动对象需要更新所有引用效率比复制算法低。算法优点缺点适用区域标记清除实现简单碎片多、效率低老年代配合整理复制无碎片、效率高内存利用率低新生代标记整理无碎片移动成本高老年代3.3 主流垃圾回收器怎么选回收器是算法的具体实现不同回收器适合不同场景。我按发展顺序和特点梳理一下常见的几款。Serial收集器是最基础的单线程回收时必须暂停所有用户线程Stop The World。它简单高效在单核或者小内存场景下反而有优势。现在主要用在客户端模式或者资源受限的环境。ParNew收集器是Serial的多线程版本除了多线程之外基本一样。它曾经是配合CMS使用的首选新生代收集器。Parallel Scavenge收集器关注的是吞吐量也就是用户代码运行时间占总时间的比例。它适合后台运算、不需要太多交互的场景。可以通过参数控制最大停顿时间和吞吐量。CMS收集器Concurrent Mark Sweep关注的是停顿时间适合对响应时间敏感的场景比如Web应用。它的大部分工作跟用户线程并发执行停顿时间短。但它有几个明显缺点对CPU资源敏感、产生浮动垃圾、有内存碎片。所以后来被G1取代了。G1收集器是目前的主流。它把堆划分成很多个大小相等的Region每个Region可以扮演Eden、Survivor或老年代的角色。G1可以预测停顿时间用户可以指定期望的停顿时间目标。它兼顾了吞吐量和停顿时间适合大内存多核场景。ZGC和Shenandoah是更新的低延迟收集器停顿时间可以控制在10毫秒以内适合超大堆几十GB甚至上百GB的场景。不过它们对JDK版本有要求实际使用要看环境。选型的时候我一般这么考虑如果堆不大几个G以内、追求吞吐量Parallel系列够用如果是Web服务、堆在几G到十几G、要求响应快G1是稳妥选择如果堆特别大、停顿要求极严再考虑ZGC。不要盲目追新适合业务场景的才是最好的。注意回收器选型不是一锤子买卖。同一个应用在不同阶段比如数据量增长后可能需要换回收器。上线前一定要做压测观察GC日志再决定。4. 类加载机制与字节码入门4.1 类加载的完整生命周期一个类从被加载到虚拟机内存到卸载出内存整个生命周期包括加载、验证、准备、解析、初始化、使用、卸载七个阶段。其中验证、准备、解析统称为连接。加载阶段做三件事通过类的全限定名获取定义此类的二进制字节流把这个字节流代表的静态存储结构转化为方法区的运行时数据结构在内存中生成一个代表这个类的Class对象作为方法区这个类各种数据的访问入口。验证阶段是为了确保Class文件的字节流包含的信息符合规范不会危害虚拟机安全。包括文件格式验证、元数据验证、字节码验证、符号引用验证。准备阶段是正式为类变量分配内存并设置初始值。注意这里说的是类变量static变量不包括实例变量。而且初始值通常是数据类型的零值比如static int value 123准备阶段过后value是0赋值为123是在初始化阶段才执行的。解析阶段是把常量池内的符号引用替换为直接引用。符号引用就是一组字面量直接引用就是指向目标的指针或者偏移量。初始化阶段是执行类构造器clinit()方法的过程。这个方法由编译器自动收集类中所有类变量的赋值动作和静态代码块中的语句合并产生。这里有个关键点虚拟机保证父类的clinit()先执行所以父类的静态代码块总是先于子类执行。4.2 双亲委派模型为什么这么设计类加载器负责把字节流加载到JVM中。JVM中有几类加载器启动类加载器、扩展类加载器、应用程序类加载器还可以自定义加载器。双亲委派模型的工作过程是一个类加载器收到加载请求先不自己加载而是委派给父加载器父加载器再往上委派直到启动类加载器。只有当父加载器无法完成加载时子加载器才尝试自己加载。为什么这么设计核心是为了安全。如果没有双亲委派任何人都可以写一个java.lang.String类放到classpath里加载后就会覆盖掉核心类库的String后果不堪设想。有了双亲委派加载请求最终都会传到启动类加载器核心类库永远由它加载用户自定义的同名类根本没机会加载。但双亲委派不是强制的可以打破。比如SPI机制Service Provider Interface就需要打破。数据库驱动java.sql.Driver在核心类库里但具体实现比如MySQL驱动在应用classpath里。核心类库的加载器是启动类加载器它加载不到应用classpath里的实现类。解决办法是用线程上下文类加载器让父加载器反向委派给子加载器。还有像Tomcat这种Web容器为了实现应用隔离也打破了双亲委派。每个Web应用有自己的类加载器优先加载自己目录下的类加载不到再委派给父加载器。4.3 字节码文件结构速览Class文件是一组以8位字节为基础单位的二进制流各个数据项严格按照顺序紧凑排列。它采用类似C语言结构体的伪结构来存储只有两种数据类型无符号数和表。无符号数属于基本数据类型用u1、u2、u4、u8表示1、2、4、8个字节。表是由多个无符号数或其他表构成的复合数据类型习惯以_info结尾。Class文件的结构按顺序包括魔数、版本号、常量池、访问标志、类索引、父类索引、接口索引集合、字段表集合、方法表集合、属性表集合。魔数是文件头固定值0xCAFEBABE用来标识这是一个Class文件。版本号包括次版本号和主版本号高版本JDK编译的Class文件不能在低版本JVM上运行反过来可以。常量池是Class文件中占用空间最大的部分之一主要存放两大类常量字面量和符号引用。字面量比如文本字符串、声明为final的常量值符号引用包括类和接口的全限定名、字段名称和描述符、方法名称和描述符。看字节码不用死记硬背每个字节的含义实际工作中用javap -v命令反编译就能看到可读的字节码。理解字节码有助于分析一些底层问题比如为什么i不是原子操作、字符串拼接在编译期做了什么优化等。5. 性能调优与线上问题排查5.1 常用调优参数与设置思路JVM参数分几大类堆内存设置、栈设置、回收器设置、GC日志设置、异常处理设置。我列几个最常用的。堆内存相关-Xms初始堆大小-Xmx最大堆大小-Xmn新生代大小-XX:SurvivorRatioEden与Survivor的比例-XX:MaxTenuringThreshold晋升老年代的年龄阈值栈相关-Xss每个线程的栈大小回收器相关-XX:UseG1GC使用G1回收器-XX:MaxGCPauseMillisG1期望的最大停顿时间-XX:ParallelGCThreads并行GC线程数GC日志相关-Xlog:gc*输出GC日志JDK 9-XX:PrintGCDetails打印GC详细信息JDK 8及之前设置的时候有几个原则。-Xms和-Xmx建议设成一样避免堆动态扩容缩容带来的性能波动。新生代大小要结合对象存活情况太小会导致频繁Minor GC太大会导致单次GC停顿变长。栈大小默认一般是512KB到1MB如果递归深度大可以适当调大但线程数多的时候要注意总内存占用。提示参数不是越多越好。我见过有人一口气配了几十个参数结果自己都记不清哪个是干嘛的。建议只配必要的每个参数都要知道为什么配。5.2 用工具定位内存与CPU问题光看参数不够出问题的时候得靠工具定位。我常用的几类工具命令行工具jps查看Java进程jstat查看GC统计信息比如jstat -gcutil pid 1000每秒打印一次jmap生成堆转储快照jmap -dump:formatb,fileheap.bin pidjstack生成线程快照排查死锁和CPU高的问题jinfo查看和调整JVM参数可视化工具JConsoleJDK自带可以看内存、线程、GC情况VisualVM功能更全可以分析堆转储、CPU采样MAT专门分析堆转储找内存泄漏的利器排查CPU飙高的思路先用top找到占用高的进程再用top -Hp pid找到占用高的线程把线程ID转成十六进制然后在jstack的输出里搜这个十六进制ID就能定位到具体是哪段代码在跑。排查内存泄漏的思路用jmap导出堆转储用MAT打开看支配树和直方图找占用内存最大的对象然后看它的引用链定位到是谁在持有引用没释放。5.3 常见线上问题速查表我把实际遇到过的问题整理成一张表方便快速对照。现象可能原因排查方向解决思路频繁Full GC老年代增长快、内存泄漏jstat看GC频率、jmap看对象分布定位泄漏对象、调大老年代、换回收器CPU持续高死循环、频繁GC、锁竞争topjstack定位线程优化代码、减少GC、调整锁内存溢出堆不够、泄漏、大对象看异常类型、导出堆转储调大堆、修复泄漏、拆分大对象响应变慢GC停顿长、线程阻塞看GC日志、jstack看线程状态换低延迟回收器、优化锁类加载异常类冲突、版本不一致看异常栈、检查classpath排除重复依赖、统一版本这里重点说下频繁Full GC。Full GC会暂停所有用户线程频繁发生会严重影响响应。常见原因有几个一是老年代空间不足可能是晋升阈值太低导致对象过早晋升二是内存泄漏对象一直被引用无法回收三是元空间不足触发Full GC。排查的时候先看GC日志确认Full GC的触发原因再针对性处理。还有一个坑是显式调用System.gc()。有些框架或者代码里会手动调这会触发Full GC。可以通过-XX:DisableExplicitGC参数禁用显式GC但要注意有些依赖NIO的框架靠这个回收直接内存禁用了可能导致直接内存溢出。所以禁用前要确认没有这种依赖。6. 我踩过的坑和几条实在建议学JVM这件事光看视频和PPT是不够的必须动手。我当年看完教程觉得自己懂了结果一上生产环境还是懵。后来总结了几条经验分享给你。第一一定要自己写代码触发各种异常。比如写个死循环创建对象触发堆溢出写个递归触发栈溢出写个类加载器泄漏触发元空间溢出。亲眼看到异常信息比看十遍书都管用。我当初就是自己跑了一遍OutOfMemoryError的各种类型才真正分清了堆溢出、栈溢出、元空间溢出的区别。第二GC日志要会看。很多人配了GC日志但从来不分析等于白配。建议找个测试环境跑一段压测把GC日志导出来用工具分析。看看Young GC的频率、每次回收了多少、晋升了多少、Full GC有没有发生。看多了自然就有感觉了。第三调优不要凭感觉。我见过有人听说G1好就把所有服务都换成G1结果小内存服务反而更慢。调优的前提是有数据支撑先监控、再分析、后调整调整完还要对比验证。没有监控数据的调优都是瞎猜。第四面试和实战是两回事。面试会问对象在内存里的布局双亲委派的原理这些要背。但实战更看重你能不能从GC日志里看出问题、能不能用工具定位到泄漏点。两者都要抓但别只背八股文。最后分享一个小技巧如果你觉得JVM参数太多记不住就记住最核心的几个——-Xms、-Xmx、-Xmn、-XX:UseG1GC、-XX:MaxGCPauseMillis、-Xlog:gc*。这几个覆盖了80%的日常场景剩下的用到再查。JVM这东西入门靠理解精通靠踩坑踩的坑多了自然就成专家了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑