拓冰建站拓冰建站
首页 / 资讯中心 / 正文

JVM速记:内存模型、类加载、垃圾回收与调优实战解析

直接说结论JVM的面试题和实战问题翻来覆去问的就是内存模型、类加载、垃圾回收、调优这几个大方向。很多人在准备时东看一眼西看一眼知识点记住了但串不起来面试时一说就散。这篇“JVM速记”就是帮大家把这些东西整理成一套可以随时调用的知识框架既能应付面试也能在线上出问题时知道从哪儿下手排查。不管你是刚接触Java的初学者还是写过几年代码但一直没系统梳理过的开发者这份内容都值得花半小时过一遍。我写这东西的起因很简单前阵子帮团队做了一次JVM相关的技术分享为了准备材料把HotSpot源码、各种调优案例、面试高频题重新翻了一遍发现很多以前模棱两可的点这次彻底想通了。整理出来的笔记被好几个同事要去做面试复习材料所以干脆扩充成一篇完整的速记把我认为最重要的理解和最容易踩的坑都写进来。1. 运行时数据区面试里80%的JVM题目都从这张图出很多人上来就背“堆、栈、方法区、程序计数器、本地方法栈”背得滚瓜烂熟但一问“为什么需要分成这么多区域”就卡住了。我建议换个思路不要按名字背要按“每个区域到底放着谁的什么数据”来理解。1.1 堆、栈、方法区的分工与协作JVM的运行时数据区本质上是在回答一个问题Java程序运行时数据放在哪里、由谁管理、生命周期多长。程序计数器是最小的一块区域它记录当前线程正在执行的字节码指令地址。为什么需要它因为线程切换后要能恢复现场。这个区域是唯一不会抛OOM的地方因为它的空间需求是固定的就是存一个地址。这个点很多面试官喜欢顺口问一句答不上来会显得基础不牢。虚拟机栈是线程私有的生命周期跟线程一致。栈里面装的是栈帧每个方法调用对应一个栈帧的入栈和出栈。栈帧里包含局部变量表、操作数栈、动态链接、方法返回地址。我之前给新人讲这块的时候喜欢打个比方栈就像一叠便签纸每调用一个方法就在最上面压一张方法执行完就撕掉一张。如果递归太深不退出便签纸堆得太高顶到天花板就是StackOverflowError。堆是最大的一块内存区域所有的对象实例和数组都在这里分配。它是垃圾回收的主战场所以后面聊GC的时候大头都在堆上。堆内部还分新生代和老年代新生代里面又分Eden区和两个Survivor区S0、S1默认比例是8:1:1。这里有个经典面试衍生题为什么需要两个Survivor区答案是避免内存碎片化——复制算法需要两块大小相同的区域来回倒腾。我在实际调优中确实见过有人把SurvivorRatio配成1:1让Survivor区大到没意义白白浪费内存这是新手容易犯的错。方法区在JDK 8之后改名为元空间Metaspace存放类元信息、常量、静态变量、JIT编译后的代码等。JDK 7及以前方法区在堆内叫永久代经常因为类加载过多导致PermGen OOM。JDK 8改为本地内存后默认不受堆大小限制但如果加载的类实在太多还是可能把物理内存耗光。这个演进背后的逻辑值得记住永久代的大小很难准确预估放到本地内存后可以按需分配减少OOM风险。还有本地方法栈它是为native方法服务的普通Java开发用到的情况很少知道它是线程私有的、跟虚拟机栈类似就够了。1.2 从OOM异常反推区域知识我对团队里新人的建议是用异常来反向记忆每个区域。因为每个区域OOM的表现和原因都不一样遇到线上故障时能靠异常信息快速定位是哪块区域出了问题。堆OOMjava.lang.OutOfMemoryError: Java heap space最常见。通常是对象太多或内存泄漏用jmap dump后分析堆快照。元空间OOMjava.lang.OutOfMemoryError: Metaspace常见于动态生成大量类的框架如CGLIB、反射批量加载。栈OOM/SOFStackOverflowError是递归过深OutOfMemoryError: unable to create new native thread是线程数超过系统限制本质上是无法再为线程栈分配内存。直接内存OOMNIO里用了大量DirectByteBuffer报错通常是OutOfMemoryError: Direct buffer memory。这里有个我踩过的坑以前遇到过一次堆内存一直涨但不OOM的情况因为代码里用了本地缓存又没设置过期时间导致Full GC后老年代还是满的。排查时先用jstat -gcutil看了各区域占比再dump堆分析最后才找到是一个静态Map只往里塞不清理。这种问题光会背内存模型是解不了的得把概念落到排查工具上。提示面试如果被问到“JVM运行时数据区哪些是线程共享的、哪些是私有的”一句话就能答清——堆和方法区是共享的程序计数器、虚拟机栈、本地方法栈是私有的。但别只丢结论补一句为什么比如栈必须私有才能保证线程执行上下文隔离档次立刻不一样。2. 类加载机制与双亲委派从.class到对象的中间环节看完内存模型下一个必然要碰到的主题就是类加载。因为对象在堆上分配之前类的元信息得先进入方法区这个“进入”的过程就是类加载机制在做的事。2.1 加载、验证、准备、解析、初始化的完整链路类的生命周期有七个阶段其中加载、验证、准备、解析、初始化是必须掌握的五步。面试时从头到尾说一遍不难难的是说清楚每一步到底干了什么事。加载阶段通过类的全限定名获取二进制字节流把字节流转化为方法区的运行时数据结构并在堆中生成一个Class对象作为访问入口。注意这里“获取字节流”不一定是class文件也可以从jar包、网络、动态代理生成。验证阶段是安全屏障检查字节流是否符合Class文件格式规范包括元数据验证、字节码验证等。我第一次看这块时觉得“这跟业务有什么关系”后来才知道如果关闭验证-Xverify:none类文件里有恶意修改的字节码就可能危害JVM安全。不过为了启动速度有些框架在特定场景下确实会跳过验证这是取舍问题。准备阶段是为类变量static变量分配内存并设置初始值。这里有个高频考点在准备阶段public static int value 123;这个value的值是0而不是123因为此时还没执行任何Java代码把value设成123是初始化阶段的事。但如果变量是public static final int value 123;那准备阶段就会直接赋值123因为ConstantValue属性在准备阶段就会生效。这个区别我在面试别人时经常问答对的人不到一半。解析阶段是把常量池中的符号引用替换为直接引用。符号引用就是字面量形式的定位直接引用是真实的内存地址或句柄。这一步对理解方法调用很重要Java的静态链接和动态链接就在这里分叉。初始化阶段是真正执行类构造器clinit方法的阶段为静态变量赋代码里写的值、执行静态代码块。这里有个经典问题父类和子类的初始化顺序是什么答案很简单——先父后子。但另一个问题就难一些如果通过子类访问父类的静态字段会触发父类初始化但不会触发子类初始化。因为静态字段解析到的是父类的引用子类只是个入口。2.2 双亲委派为什么是默认方案又为什么会被打破双亲委派模型的规则当一个类加载器收到加载请求时先把这个请求委派给父加载器每一层都往上抛直到顶层的启动类加载器Bootstrap ClassLoader。只有父加载器反馈自己无法加载时子加载器才尝试自己加载。这样做最核心的收益是保证Java核心库的类型安全。举个例子如果没有双亲委派你自己写一个java.lang.String就可能跟JDK自带的String冲突甚至替换掉核心类引发不可预知的错误。有了双亲委派加载java.lang.String的请求最终都会回到Bootstrap ClassLoader确保你写的那个同名类永远没机会被正常加载。但双亲委派不是万能的它有一个著名的例外JDBC。Java的核心类库中定义了java.sql.DriverManager但它要加载的数据库驱动实现比如MySQL的Driver是由各个厂商提供的存放在classpath下Bootstrap ClassLoader根本加载不到。为了解决这个问题JDK引入了线程上下文类加载器通过Thread.currentThread().getContextClassLoader()反向让父加载器去请求子加载器完成加载这等于从底层模型上“打破”了双亲委派。面试里被问到“如何打破双亲委派”时标准答案有两个一是继承ClassLoader并重写loadClass方法绕过那层先父后的逻辑二是使用线程上下文类加载器。我见过Tomcat等Web容器会自定义类加载器来实现应用之间类隔离每个应用一个WebAppClassLoader保证部署多个应用时同名类互不干扰这也是对双亲委派模型的合理改造。工作里如果遇到两个框架引入同一个类但不同版本导致冲突就能理解为什么要做这种隔离了。提示区分ClassNotFoundException和NoClassDefFoundError是实战中很容易踩的坑。前者是加载时找不到类通常是依赖没引入或类名写错后者是类在编译期存在但运行期初始化失败比如静态代码块抛了异常之后所有对那个类的引用都会报这个Error。线上排查时如果看到NoClassDefFoundError去查静态初始化逻辑往往比查依赖更靠谱。3. 垃圾回收机制JVM最硬核也最容易被问懵的部分垃圾回收是JVM面试的重灾区也是平时调优的主要发力点。新手看到一堆收集器和参数就头大我的建议是先抓住一条主线怎么判断对象该回收用什么算法回收不同的收集器解决了什么问题。3.1 判断垃圾与回收算法的底层逻辑判断对象是否可回收的主流方案是可达性分析。从一组称为GC Roots的根对象出发向下搜索引用链搜索不到的对象判定为可回收。GC Roots包括虚拟机栈中引用的对象、静态属性引用的对象、常量引用的对象、本地方法栈中JNI引用的对象。这里很多人容易跟引用计数法搞混。引用计数法是给每个对象加一个计数器被引用就加一失效就减一减到零就回收。听起来简单但解决不了循环引用问题——A引用BB引用A外部没人再引用它们计数器却永远是1导致这两个对象永远不被回收。JVM不用引用计数法就是为了避开这个坑。我在面试时喜欢追问一句“可达性分析会不会漏掉循环引用的对象”答案是不会因为从GC Roots出发根本没有路径能到达它们。回收算法主要有三种思路标记-清除是最基础的先标记可回收对象然后统一清除。缺点是产生大量内存碎片后续分配大对象时可能找不到连续空间提前触发另一次GC。标记-复制把内存分成两块每次只使用其中一块回收时把存活对象复制到另一块再清空当前块。它的代价是浪费一半空间所以HotSpot没有对整个堆用这个算法而是在新生代里用Eden和两个Survivor区做优化Eden区分配新对象GC后存活对象复制到S0下次GC时Eden和S0的存活对象一起复制到S1如此往复。默认Eden:Survivor8:1也就是说只有10%的空间会闲置比原来浪费50%好得多。标记-整理针对老年代标记存活对象后把它们向内存一端移动然后清理边界以外的空间。它解决碎片问题但移动对象的成本比清除高。所以老年代的GC停顿通常更长这是物理限制不是实现偷懒。3.2 收集器演进路线Serial到G1再到ZGC不同收集器解决的问题不一样按时间线记最好记Serial是最古老的单线程收集器GC时必须暂停所有工作线程Stop The WorldClient模式的默认选择现在已经很少直接用。Parallel Scavenge新生代Parallel Old老年代是JDK 8默认组合注重吞吐量适合后台计算任务。我已经很久没配过了因为默认值在大多数服务里表现已经可以接受。CMS是第一个实现并发收集的垃圾回收器目标是缩短STW时间。它用标记-清除算法所以会有碎片问题而且并发阶段会占用CPU资源在老年代碎片严重时可能出现Concurrent Mode Failure退化为Serial Old做Full GC停顿时间反而更长。CMS在JDK 9开始被标记为废弃JDK 14移除了但很多老项目还在跑排查线上GC日志时一定要认识它。G1是JDK 9之后的默认收集器也是当前主流。它的创新在于把堆划分成多个大小相等的Region不再严格区分新生代和老年代的物理区域而是让Region动态扮演不同角色。G1可以用-XX:MaxGCPauseMillis设定目标停顿时间它通过跟踪每个Region的回收价值能回收多少空间、需要多少时间来优先回收最有价值的Region这叫Garbage First。G1很适合大堆和低延迟场景但要注意它适合堆大小在4GB以上的场景小堆上优势不明显。ZGC代表最新的低延迟方向目标是让STW时间控制在10ms以内。它用了染色指针和读屏障等技术实现在GC过程中绝大部分阶段与业务线程并发执行。我实际测过ZGC在大堆下的表现确实惊艳但它对JDK版本有要求且在某些场景下吞吐量会略微下降。选不选它取决于业务对延迟的敏感度如果线上有大量长尾请求对GC停顿敏感ZGC值得尝试如果是纯后台批处理Parallel反而更合适。3.3 三色标记与漏标问题G1和ZGC都涉及并发标记面试里躲不开三色标记算法。把对象分成三种颜色白色尚未被扫描到标记结束后仍为白色的对象会被回收灰色自身被访问到但还没扫描完它所引用的对象黑色自身和它引用的对象都扫描完了标记完成并发标记最大的风险是漏标——本来存活的对象被当成垃圾回收掉。漏标必须同时满足两个条件黑色对象新增了一个指向白色对象的引用同时所有能到达该白色对象的其他路径都被切断了。解决办法有两个方向一是写屏障在黑色对象新增引用时把引用记录下来并在重新标记阶段扫描增量更新二是原始快照SATB标记开始时记录当时的引用关系快照只要某个对象在快照中被某个灰色对象引用即使后续引用被切断也不会被回收。G1用的就是SATB方案CMS用的是增量更新。理解了这个区别面试时把概念跟具体收集器对应起来比单纯背名词要深刻得多。提示GC调优的第一原则是别瞎调。不要一上来就改一堆参数先通过jstat -gcutil观察各代的使用情况确认是分配过快、晋升过早还是有泄漏再决定动哪个参数。我见过太多人在没定位问题的情况下把-Xmx调大一倍结果只是把崩溃延后了而已。4. 调优工具箱与两个高频报错的完整排查链路聊完机制层面的东西来点真正能落地的调优内容。我在排障时最常用的工具是JDK自带的命令行工具不用装额外的东西线上环境一般都有。4.1 先学会看数据再谈调优jps列出当前JVM进程及启动参数。用法就不多说了人人都会。jstat看JVM统计信息最常用的是jstat -gcutil pid 1000每秒打印一次GC各代使用率和GC时间。我调优时第一步永远是它先看新生代Eden区是否每次GC后都几乎满、老年代增长曲线是否正常、Full GC频率和耗时是否离谱。jmapdump堆快照和查看堆内存摘要。jmap -dump:live,formatb,fileheap.hprof pid可以拿到一份堆快照再用MAT或者VisualVM分析。注意在高峰期执行dump会STW对线上有影响尽量在低峰期操作或者用jmap -histo:live先看对象总量分布。jstack打印线程快照主要用于排查线程死锁、线程卡死、CPU飙升问题。CPU飙高时先top -Hp pid找到占用最高的线程id转成十六进制后去jstack输出里搜就能定位到具体代码行。这个技能我几乎每个月都会用到。jcmdJDK 7之后推出的多功能工具很多功能可以替代jmap和jstack的部分用法而且官方更推荐它。比如jcmd pid GC.class_histogram可以看类直方图jcmd pid VM.flags可以看生效参数。下面是常用命令速查建议收藏目的命令查看JVM进程jps -lv看GC情况jstat -gcutil pid 1000堆dumpjmap -dump:live,formatb,fileheap.hprof pid线程快照jstack pid看生效参数jcmd pid VM.flags立即Full GCjcmd pid GC.run4.2 “expiring daemon because jvm heap space is exhausted”的排查全过程这个报错在开发者的本地环境特别常见尤其是用Gradle构建项目时。我今年就帮两个同事解决过症状是构建到一半Gradle Daemon进程崩溃日志里出现这句话。它的直接原因是Gradle Daemon是个常驻JVM进程用来加速构建但它默认的堆内存如果不够大构建时类加载、增量编译、单元测试都需要大量内存堆空间耗尽就死了。排查链路第一步是确认Daemon的堆大小配置。Gradle的配置文件在~/.gradle/gradle.properties里面可以设置org.gradle.jvmargs-Xmx2048m -XX:MaxMetaspaceSize512m。如果这个文件里没配或者配得偏小遇到大项目就会崩。第二步是看系统总内存够不够如果机器本身内存就不多再调Daemon堆上限也白搭可能需要限制IDE等其他进程的内存占用。第三步是确认是不是有多个Gradle Daemon同时跑。gradle --status可以查看当前有多少Daemon进程多了就耗内存可以用gradle --stop把所有Daemon停掉再重新构建。我个人的建议配置中小型项目-Xmx2048m起步大型项目-Xmx4096m同时把org.gradle.daemontrue保持开启避免每次构建都重新起JVM。如果构建时还要跑大量的单元测试再往上加一些。调完之后用gradle --status确认Daemon启动参数生效这个问题基本就解决了。4.3 “cannot collect jvm options caused by: 0: cannot read”报错的路径与权限陷阱这个报错我是在用IntelliJ IDEA时遇到的当时一打开IDE就弹错提示类似于cannot collect jvm options caused by: 0: cannot read: d:v作业实训 vjetbrain_...。一眼看过去就知道问题出在路径上Windows路径中的冒号和中文目录名被解析错了d:v被当成了某种特殊格式后面的中文加字母组合看起来像乱码。这个报错的本质是IDE启动时需要读取jvmoptions配置文件但路径格式不对导致文件无法读取。常见原因有三个第一路径写错了或者在配置里用了不规范的写法。进入IDE的Help - Edit Custom VM Options检查里面的路径配置是否正确不要出现Windows路径里常见的\转义问题。我发现一个规律很多人喜欢把IDE或者项目放在带中文、带空格、带特殊符号的目录下这在国内用户里尤其常见。不是不能用但遇到工具链兼容性问题时这些目录往往就是源头。第二启动脚本或环境变量里指定了不存在的路径。检查IDEA_VM_OPTIONS环境变量如果指向的文件不存在IDE启动时就会报类似的错。解决办法是把环境变量改到有效位置或者直接删掉这个环境变量让IDE用默认配置。第三文件权限问题。配置文件虽然在但当前用户没权限读。Windows下比较少见Linux服务器上装JetBrains系的东西倒是经常遇到chmod 644一下就能解决。排查路径建议按顺序来先看报错里给的路径具体是什么确认有没有拼写问题再检查环境变量有没有指向死路径最后看文件权限。我之前在帮同事处理时最后发现是因为他在vmoptions文件里写了一个自定义参数时用错了分隔符把一个完整的参数拆成了两截IDE解析时自然就挂了。所以改配置时每个参数一行等号和冒号不要随意变更改完重启IDE再验证。4.4 一个可复用的线上调优决策流程调优不是玄学我总结了一个每次都能用的流程分享出来第一步明确目标。你是想让接口快一点、吞吐量高一点还是让Full GC不再频繁不同的目标对应的调优方向完全不同。把目标量化出来比如“Full GC次数从每小时10次降为0次”没有量化指标的调优都是耍流氓。第二步采集数据。用jstat看GC频率和时间用jmap看堆使用分布用top看CPU和内存。至少观察几个完整的GC周期不要看到一次异常就动手。第三步定位根因。GC频繁和GC时间长是两个问题频繁通常是对象分配太多或存活对象太多时间长通常是大对象进入老年代或者老年代空间太大导致标记整理耗时。把问题分类之后才能决定调什么参数。第四步小步调整。一次只改一个参数改完观察一段时间确认效果后再改下一个。不要一上来就-Xmx8g -Xms8g全部拉满风险太大回头出了新问题都不知道是哪个参数引起的。第五步验证与回滚预案。保留修改前的参数记录调优后如果吞吐量或延迟没有实质改善果断回滚。5. 面试高频考点速答JVM、JRE、JVM内存模型与垃圾回收器最后这部分我把热搜词里提到的面试相关问题全部揉在一起做成一个速答清单。直接背结论可以应付大多数面试当然我强烈建议你在理解上面内容之后再来看这份清单效果完全不同。JDK、JRE、JVM三者什么关系JDK是Java开发工具包包含JRE和开发工具javac、jdb等JRE是Java运行环境包含JVM和核心类库JVM是Java虚拟机是JRE的核心组成部分。一句话JDK用于开发JRE用于运行JVM保证跨平台。面试时可以补一句“所以只要装了JRE就能运行Java程序但要编译Java源码必须装JDK”。JVM内存模型和运行时数据区是一回事吗不是这是面试里最容易混淆的两个概念。运行时数据区描述的是JVM在运行时把内存分成哪些区域来存数据而JVM内存模型JMM是Java内存模型定义的是多线程环境下变量的可见性规则。JMM解决的是并发编程中共享内存的原子性、可见性、有序性问题和运行时数据区完全是两个层面的东西。面试时主动把这个区别讲清楚会加分不少。JVM的工作原理是什么一句话版本Java源码编译成字节码class文件JVM的类加载机制把class文件加载进内存字节码经过解释器或JIT编译器转化为机器码执行。完整的回答应该覆盖类加载加载到初始化、运行时数据区分配对象存堆、局部变量存栈、垃圾回收自动管理内存、执行引擎解释执行JIT编译。我在面试时听到能把“编译一次到处运行”背后的原理串起来的候选人基本都会给高分。对象一定在堆上分配吗不一定。JIT编译期间逃逸分析把不会逃逸出方法作用域的对象做标量替换直接分配到栈上方法结束就自动销毁连GC都省了。这个知识点很多人听过但没实际验证过面试里能主动说出来会被认为对现代JVM有了解。怎么选择垃圾回收器给出判断维度先用-XX:PrintGCDetails看现在的GC情况确认痛点。追求吞吐量选Parallel组合追求低延迟选G1或ZGC单机小堆选Serial也无不可。但一定要记住默认的G1在绝大多数场景下已经够好不要为了“用过更多收集器”而去调整默认配置。JVM调优到底调什么回答框架堆大小-Xms/-Xmx、新生代比例-Xmn、-SurvivorRatio、GC选型-XX:UseG1GC等、GC日志配置-Xloggc、-XX:PrintGCDetails、OOM处理-XX:HeapDumpOnOutOfMemoryError。调优的产出从低到高排序先保证不崩再减少GC停顿最后追求高吞吐。下面这张简表是我给新人准备的常见参数速查参数含义-Xms/-Xmx堆初始大小 / 堆最大大小建议设成一样避免动态扩容抖动-Xmn新生代大小-XX:SurvivorRatioEden区与Survivor区的比例默认8-XX:MaxMetaspaceSize元空间上限-XX:HeapDumpOnOutOfMemoryErrorOOM时自动dump堆快照线上必开-XX:PrintGCDetails打印GC细节日志JDK 9后可配到独立日志文件-XX:MaxGCPauseMillisG1期望的最大停顿时间只作为目标不保证一定达成我个人在面试时还有一个必问的延伸题如果有一个线上服务GC频率正常但每次Full GC要2秒你会怎么查这个问题没有标准答案但我期望的思路是先确认老年代有多大、是不是有大对象一直在往里塞然后看每次Full GC都扫了哪些Region、是不是有超大对象导致标记和复制时间过长再用jmap -histo:live看有没有大数据结构的对象最终可能是代码层面的问题——比如一个不小心加载了几十万条记录进内存。这类问题比单纯背概念更能看出一个人的真实经验。写这份速记的过程中我最大的感受就是JVM的知识不是孤立的内存模型、类加载、垃圾回收、调优工具是一条线上的不同节点。你只要把这条线串通了面试题不管怎么变都能回到几个核心原理上线上出了问题也能按同样的思路一步步往下查。如果时间有限优先把运行时数据区和垃圾回收吃透这两块占了面试和实战的七成以上。剩下的一边写代码一边积攒经验就行。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门