尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

JVM面试高频考点全解析:从内存模型到线上调优实战

JVM面试高频考点全解析:从内存模型到线上调优实战 面试问到JVM最怕的不是你不会而是你明明背了一堆八股文却答不到面试官的心趴上。这些年我面过不少候选人也带过不少新人发现一个规律凡是能把JVM讲出“为什么”的人基本都能扛住后面的压力面凡是只背定义、说不清底层逻辑的几个追问就露馅了。这次的《面试八股文》系列第20期我直接把JVM这条线给你捋干净。不堆概念不灌水我会把每个高频考点背后的原理、面试官真正想看的东西以及我自己在线上实战里踩过的坑一起讲清楚。内容比较多建议你收藏起来面试前一两天照着过一遍比盲目刷题有效得多。1. JVM基础三件套JDK、JRE、JVM到底是什么关系1.1 一个类比讲清三者关系很多人一上来就被这个基础问题问懵了。其实这三者的关系特别像“厨房、餐厅、灶台”JDKJava Development Kit是整套厨房厨具、食材、菜谱全在里面它是给开发者用的包含了编译器、调试工具、类库等一整套开发环境。JREJava Runtime Environment是餐厅消费者只需要进来吃饭不需要自己做饭它给运行Java程序提供了标准环境。JVMJava Virtual Machine是灶台真正把菜炒熟的地方它是Java程序运行的核心引擎。从包含关系上看JDK包含JREJRE包含JVM。更准确地说JDK JRE 开发工具javac、jdb、javap等JRE JVM 核心类库rt.jar等 Java运行所需的基础文件。1.2 面试官为什么喜欢问这个问题这道题表面是在考你对Java生态的基本认知实际上是在判断你有没有真正理解“跨平台”这几个字。Java号称“一次编写到处运行”靠的是什么靠的就是JVM这层虚拟化抽象。不同的操作系统上有不同的JVM实现比如Windows版、Linux版、macOS版但你的源码只要编译成字节码.class文件在任何平台的JVM上都能跑。也就是说真正和操作系统打交道的是JVM而不是你的代码。如果你在面试时能补充一句“Java的跨平台是JVM层面的跨平台不是字节码层面的跨平台”面试官会对你高看一眼。因为这说明你能区分“编译产物”和“运行环境”两个完全不同的概念。1.3 常见追问JRE和JVM的运行差异面试官还可能追问既然JRE已经包含了JVM为什么还要单独提JVM这个问题要这样答JRE是Java程序的运行环境它提供了类库和启动工具JVM是JRE内部最核心的子系统负责字节码的解析和执行。JVM不感知它跑在JDK还是JRE里它只认字节码。我还遇到过面试官这样考如果你的生产环境只是跑Java程序装JRE就够了但为什么很多公司还是选择装JDK因为线上排查问题的时候你需要用到JDK里自带的工具比如jmap、jstack、jstat这些工具都在JDK的bin目录下。我自己就吃过亏之前在一台只装了JRE的机器上部署服务出了问题想用jstack看线程状态结果发现命令不存在只能先装一个完整JDK再排查白白耽误了半小时。2. JVM内存模型这道题答得好不好决定了你的offer薪资2.1 运行时数据区到底分几块JVM内存模型严格来说应该叫“运行时数据区”这是JVM面试绝对绕不开的核心考点。面试官一开口问你“JVM内存模型是怎样的”你不是要背出JMM里的主内存和工作内存那套他问的其实是运行时数据区的划分。一共六块程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区、运行时常量池从方法区里分出来的。我建议你记这样一张表按“线程是否私有”和“会抛什么异常”两个维度去记区域线程私有/共享存储内容异常程序计数器私有当前线程执行的字节码行号无虚拟机栈私有栈帧局部变量表、操作数栈、动态链接、返回地址StackOverflowError、OOM本地方法栈私有native方法调用StackOverflowError、OOMJava堆共享几乎所有对象实例和数组OOM方法区共享类型信息、常量、静态变量、JIT缓存OOM运行时常量池共享编译期常量与运行期产生的符号引用OOM这个表背下来不难难的是理解每个区域背后为什么要这样设计。比如虚拟机栈为什么是线程私有因为每个线程都有自己的执行流程和局部变量如果栈是共享的多线程并发下局部变量的隔离性就彻底没了。2.2 堆内存为什么是OOM重灾区堆是所有线程共享的一块内存区域几乎所有对象和数组都在这里分配。堆也是面试中最高频的考点因为它直接关联到对象创建、GC回收和线上OOM排查。从上到下堆又被划分为新生代Young Generation和老年代Old Generation其中新生代又细分为Eden区和两个Survivor区S0、S1默认比例是8:1:1。这个比例来自一个经典的实验结论新生代中大约90%的对象生命周期很短活不过第一轮GC。所以Eden区划大一点Survivor区划小一点两个Survivor区轮流存放幸存对象用复制算法避免内存碎片。你如果只是背出这个比例面试官不会满意。你还要能说出为什么两个Survivor区必须是一样大的因为复制算法要求“从一个Survivor复制到另一个为空Survivor”如果两个区大小不一样那么较大的那个区永远不会被完全使用空间就浪费了。之前我调优过一个服务线上把SurvivorRatio设成了2结果Survivor区过小对象频繁晋升老年代老年代一下就被塞满了频频Full GC。调成默认的8之后GC频率直线下降。2.3 Direct Memory和元空间为什么单独存在在JDK 8之后方法区的实现从永久代PermGen换成了元空间Metaspace最大的区别是永久代在堆内大小受JVM堆限制元空间在本地内存默认不设上限只受操作系统的物理内存限制。这个改变的意义在于以前PermGen容易被类加载过多撑爆报OOM: PermGen space现在用元空间出现这个报错的概率小了很多。另一块容易被忽略的是直接内存Direct Memory它不属于JVM运行时数据区但被频繁使用。NIO和Netty底层会通过DirectByteBuffer在堆外分配内存好处是省去了一次从堆内拷贝到堆外的操作适合大块数据的IO读写。它的坑在于堆外内存不受-Xmx限制如果你用了Netty但没有合理设置MaxDirectMemorySize很容易出现“明明堆内存很充足机器却频繁Full GC或直接崩溃”的诡异情况。我之前处理过一个服务堆内存才用了40%GC也很正常但容器时不时被OOM Killer杀掉。排查到最后发现是Netty在堆外分配了大量内存加上容器cgroup限制没对上直接内存把整台机器的物理内存打满了。这种问题在容器环境里特别常见后面我在第5节会专门展开讲。3. 类加载机制与双亲委派面试里最容易翻车的一站3.1 类的生命周期不仅仅是“加载-初始化”面试总考“类加载过程”很多人能背出七个阶段加载、验证、准备、解析、初始化、使用、卸载但具体每步要背的更细加载阶段通过类的全限定名获取二进制字节流并在堆中生成一个Class对象作为方法区该类的访问入口。注意这里的“获取”不限于从文件系统读.class文件还可以从JAR包、网络、甚至是运行时动态生成的代理类里获取。验证阶段做的是字节码级别的安全检查包括文件格式验证、元数据验证、字节码验证、符号引用验证目的是防止恶意或错误的字节码破坏JVM。准备阶段为静态变量分配内存并设置初始零值。很多人在这里犯错public static int count 100准备阶段count是多少答案是0而不是100。赋值为100的动作要到初始化阶段才执行因为准备阶段只是“分配内存给默认值”真正的赋值指令要等编译器生成的构造器方法执行。解析阶段把常量池里的符号引用替换为直接引用。这里要理解一个概念常量池里的引用只是文字描述比如“java/lang/String”而直接引用就是真实内存地址或句柄。初始化阶段执行类构造器方法这里才真正执行静态变量的赋值和静态代码块。面试官会接着问你父类和子类的初始化顺序是什么答案永远是父类先初始化子类后初始化如果类有接口接口的初始化不触发父接口的初始化除非接口里定义了默认方法。3.2 双亲委派模型为什么这么设计什么时候会被破坏双亲委派模型的含义是当一个类加载器收到类加载请求时它不会自己先去加载而是先把请求委托给父类加载器每一层都往上抛直到最顶层的启动类加载器Bootstrap ClassLoader。只有父加载器反馈自己无法完成加载时子加载器才尝试自己加载。为什么要这么设计两个核心原因第一避免同一个类被重复加载同一个Class对象在不同加载器下会被视为不同的类这个容易导致类型转换异常第二保证核心类库的安全性比如java.lang.String永远由启动类加载器加载你就算自己写一个java.lang.String也加载不进去这就防止了恶意代码覆盖JDK核心类。但双亲委派模型不是永远正确的答案。面试官经常会追问什么情况下需要打破它你至少要知道以下几个场景SPIService Provider Interface机制的JDBC驱动加载比如DriverManager是启动类加载器加载的但JDBC的具体驱动实现如MySQL的Driver在classpath下按双亲委派模型启动类加载器根本加载不到第三方jar里的类。所以就用了线程上下文类加载器从下往上加载这打破了双亲委派。Tomcat的WebAppClassLoader也打破了双亲委派每个Web应用有独立的类加载器优先加载自己WEB-INF/lib下的类是为了实现不同应用之间的类隔离。你如果能把这两个场景举出来这道题基本满分。3.3 一个小实验验证双亲委派我自己面试候选人时喜欢让他手写一个类加载器或者问一个问题下面的代码会不会加载成功我故意写了一个全限定名为java.lang.MyObject的类然后放到classpath里。结果是不会加载成功因为JVM对java.*开头的包有保护机制这类类会直接交给启动类加载器处理。如果在自定义类加载器里强行加载java.lang开头的类会抛SecurityException。你可以自己试一下这样印象会很深刻。另外自定义类加载器的核心就是继承ClassLoader并重写findClass方法在里面调用defineClass来生成Class对象这个代码很简单网上很多但想真正理解还得动手跑一跑。4. 垃圾回收与G1收集器从原理到参数一条龙讲透4.1 判断对象是否可回收引用计数法的致命缺陷GC要回收垃圾第一步是找到垃圾。怎么找两种主流算法引用计数法和可达性分析算法。引用计数法听起来很简单每个对象维护一个引用计数器被引用一次就加一引用失效就减一计数器归零就代表可回收。但有个致命缺陷就是循环引用。A引用B、B引用A哪怕这两个对象已经没有任何外部引用了计数器依然不为零永远无法回收。所以JVM主流的商用虚拟机用的都是可达性分析算法。它的思想是从一组叫做“GC Roots”的根对象出发沿着引用链向下遍历能被遍历到的对象标记为存活遍历不到的对象就是可回收的。面试高频追问就会出现在这里哪些对象可以被当作GC Roots至少记住四类虚拟机栈栈帧中的本地变量表中引用的对象方法区中静态属性引用的对象方法区中常量引用的对象本地方法栈中JNI引用的对象。你可以把GC Roots理解成“不会回收的初始对象”所有存活对象都是从这里能走到的。我之前遇到过一道很有意思的面试题一个对象被局部变量引用着此时发生GC这个对象会被回收吗答案是不会因为局部变量在栈帧的局部变量表里局部变量表里的引用就是GC Roots只要这条引用链还存在对象就活着。但如果你在这个局部变量作用域之外再去强制GC这个对象就失去了可达性会被回收。4.2 四种引用类型别只背名字强引用、软引用、弱引用、虚引用这四种引用在面试里属于必考。关键是你要清楚它们的GC回收时机强引用就是普通的Object obj new Object()只要强引用还在JVM宁可抛OOM也不会回收它。软引用用来描述“还有用但非必需”的对象在内存不足时会被回收适合做缓存比如图片缓存。弱引用比软引用更脆弱只要发生GC就会被回收典型应用是ThreadLocal的ThreadLocalMap里的Entry它的key就是弱引用。虚引用最特殊它不会影响对象的生命周期也无法通过它取得对象实例唯一作用是在对象被回收时收到一个系统通知用于对象回收跟踪。我建议你顺带把ThreadLocal的内存泄漏场景准备一下ThreadLocalMap的key是弱引用value是强引用。如果线程池里的线程长期存活ThreadLocal的key被回收了但value还指向那个大对象就造成了value的内存泄漏。解决办法就是每次用完ThreadLocal都调用remove方法。这个点既能体现你看过源码又能展示你对内存泄漏的理解深度。4.3 三种基本回收算法与分代理论标记-清除、标记-复制、标记-整理这三种算法的优缺点要能脱口而出标记-清除先标记可回收对象再统一清除。缺点是会产生大量不连续的内存碎片后面分配大对象可能找不到连续空间而提前触发Full GC。标记-复制将内存分成两块每次只使用一块GC时把存活对象复制到另一块然后清空当前块。缺点是空间利用率低只有一半优点是实现简单、无碎片。新生代就用了优化的复制算法EdenS0S1不是一半开一半而是只浪费一个Survivor空间。标记-整理和标记-清除一样先标记但清除时会把存活对象向一端移动然后清理掉边界之外的内存。适合老年代因为老年代垃圾少、存活对象多大量移动对象的成本可以接受。分代收集理论简单说就是新生代对象死亡率高用复制算法成本低老年代对象存活率高用标记-清除或标记-整理避免频繁复制。面试时你把“为什么新生代用复制、老年代用整理”讲清楚面试官就不会再追了。4.4 G1收集器region模型和可预测停顿时间G1Garbage First是HotSpot JVM在JDK 9之后默认的垃圾收集器。它和之前的CMS最大的区别是G1不再严格区分年轻代和老年代而是把整个堆划分为多个大小相等的Region默认2048个每个Region都可以被单独回收。G1还有两个关键概念RSetRemembered Set每个Region都有一个RSet记录了哪些Region有引用指向当前Region。回收某个Region时只需要扫描它的RSet就知道谁引用了它不用全堆扫描。这就是G1能做到部分回收的理论基础。SATBSnapshot-At-The-BeginningG1的并发标记采用TAMSTop At Mark Start配合SATB保证了并发标记期间新分配的对象不会被误标同时用“原始快照”的方式解决并发标记时漏标的问题。在面试中你被问到G1时可以这样组织回答G1以Region为单位维护了一个优先级列表优先回收垃圾最多的Region这样能保证“有限时间内完成尽可能多的回收”这也是G1名字里“Garbage First”的由来。它通过 -XX:MaxGCPauseMillis参数来设定软性的停顿目标比如100msG1会根据历史数据自动调整新生代大小来达到这个目标。但要注意G1不是万能的。我之前在一个大内存、低延迟的服务上试过把G1的停顿目标设成50ms结果发现GC频繁触发吞吐量反而下降了。后来把目标放宽到100ms情况才好起来。这个经验说明停顿时间调的越短GC就越频繁你要在延迟和吞吐量之间找平衡不能一味追求低延迟。4.5 三色标记法为什么CMS会有漏标问题如果你想把GC聊得更深可以主动引出三色标记法。在并发标记阶段对象被分为三种颜色黑色表示自身已标记且它的引用都已被处理过灰色表示自身已标记但引用还没处理完白色表示还没被扫描到。回收时白色对象就是垃圾。CMS的并发标记用的是“增量更新”来解决漏标问题当一个黑色对象新增了一个到白色对象的引用时会把这个黑色对象记录下来标记结束后重新扫描它。G1用的是“原始快照SATB”当一个灰色对象删除了指向白色对象的引用时记录下这个引用标记结束后重新扫描这个引用这样即使引用关系变了也能保证那个白色对象被标记为存活。你不需要把每个细节都背得死死的但“增量更新 vs 原始快照”的区别最好能说出来因为这是区分“背过八股”和“真正理解”的分水岭。5. JVM调参与线上排查实战里的硬功夫5.1 高频JVM参数速查表JVM参数在面试中虽然不一定单独出题但线上调优和排查一定会用到。我整理了一些高频参数建议你直接存下来参数作用默认值/建议-Xms初始堆大小建议和服务器的物理内存匹配别设1M等不合理值-Xmx最大堆大小生产建议和-Xms设为相同值避免堆动态伸缩-Xmn新生代大小一般占堆的1/3到1/4-XX:SurvivorRatioEden区和Survivor区的比例默认8即8:1:1-XX:MaxTenuringThreshold对象晋升老年代的年龄阈值默认15-XX:MaxMetaspaceSize元空间上限根据类数量设定防止无限占用本地内存-XX:CompileThreshold方法触发JIT编译的调用次数阈值C1默认1500C2默认10000-XX:MaxGCPauseMillisG1停顿时间目标默认200ms-XX:HeapDumpOnOutOfMemoryErrorOOM时自动生成堆转储文件生产必开-XX:HeapDumpPath堆转储文件路径建议设到独立磁盘避免占满系统盘这里单独把-XX:CompileThreshold拎出来说因为它是热词里出现过的点。这个参数的含义是一个方法被调用多少次之后JVM会认为它是“热点方法”从而触发JITJust-In-Time编译。C1Client Compiler的默认阈值是1500次C2Server Compiler的默认阈值是10000次。通过-XX:CompileThreshold可以手动调整这个次数让热点方法更快被编译成本地机器码提高执行速度。但要注意阈值设置太小会让JIT编译启动得太早容易误判热点浪费编译资源设置太大又会让真正的方法在解释执行模式下运行太久性能上不去。一般我不建议乱调这个参数除非你很清楚代码里的热路径。5.2 线上Java进程异常重启或崩溃去哪找日志很多人线上遇到问题就慌尤其是容器部署的Java程序重启了问“日志在哪儿”其实是没搞清楚日志类型。JVM相关的日志主要有四类应用日志程序自己打的日志Logback、Log4j配置的log路径通常能看到业务异常和报错堆栈。GC日志通过JVM参数打印的回收日志比如-XX:PrintGCDetails -Xloggc:/path/to/gc.log分析GC频率、停顿时间、晋升情况。JVM Crash日志JVM进程崩溃比如JIT编译bug、OS资源耗尽时JVM会在工作目录下生成hs_err_pid*.log记录崩溃时的线程、寄存器、内存映射等信息。这个文件非常重要但很多人不知道它存在找了半天才发现就在启动目录下。系统日志对于容器环境需要查看docker logs或者k8s的事件观察进程是被killOOMKilled还是正常退出。我举一个真实案例。之前在K8s环境里部署Java服务容器频繁重启看应用日志没有任何报错看docker events发现是OOMKilled。我先用dmesg查了系统日志确认是容器进程被cgroup OOM Killer杀掉然后检查了Pod的memory limit发现只有512Mi而应用设置的-Xmx是512m加上元空间、直接内存、线程栈内存早就超过limit了。这就是典型的“容器内存limit比JVM实际占用还小”的问题。解决办法是把-Xmx调小到384m并且加上-XX:MaxDirectMemorySize128m限制堆外内存重启后再没崩过。5.3 OOM与频繁Full GC的排查思路如果线上出现OutOfMemoryError第一步是确认堆转储文件有没有生成。所以生产环境一定要开-XX:HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath。拿到堆文件后用MATMemory Analyzer打开重点看Dominator Tree和Leak Suspect找出占内存最大的对象以及它的引用链。如果只是频繁Full GC但没报OOM排查流程是这样先用jstat -gcutil pid 1000连续观察GC情况确认Full GC的频率和耗时长不长。如果Full GC很频繁基本可以考虑两种情况堆设置太小或者存在内存泄漏。接着用jmap -histo:live pid看存活对象的分布找出数量异常的对象。如果想进一步看引用链可以用jmap -dump导出堆转储再用MAT分析。我举一个之前处理过的案例一个订单处理服务运行几天后TP99从50ms涨到3秒jstat一看Full GC每分钟一次。追查堆转储发现有一个自定义的缓存Map一直在增长key是订单号value是订单详情对象。业务代码把订单号放进了map但从来没有移除逻辑导致订单越来越多最终把老年代撑爆。定位到问题后把缓存改成Caffeine并设置了过期时间Full GC降到了几小时一次。5.4 CPU飙高与线程问题定位CPU飙高的第一反应是找线程而不是重启服务。我常用的命令是这样的先jps找到Java进程PID再用top -Hp pid查看该进程内CPU占用最高的线程ID。拿到线程ID后转成十六进制printf %x\n然后执行jstack pid | grep -A 20 nid0x十六进制就能看到这个线程在干什么。大多数情况下要么是业务死循环要么是GC线程疯狂回收要么是锁竞争导致自旋等待。这里说一个容易踩的坑很多人会用jstack -F pid但-F是强制模式会暂停JVM生产环境慎用。正常情况下直接jstack pid就行如果输出的是“Unable to open socket file”大概率是进程不是Java启动的或者权限不够用和进程相同用户执行即可。6. 面试答题技巧与避坑经验八股文的正确打开方式6.1 不要把内存模型和运行时数据区搞混这是我在面试中最常遇到的误区。很多候选人开口就说“JVM内存模型分主内存和工作内存基于缓存一致性协议”这明显是被JMMJava Memory Model的并发语义带跑了。面试官如果问“JVM内存模型”你要先分辨他想问什么。如果聊的是并发比如volatile、synchronized那就答JMM的主内存和工作内存模型如果聊的是运行时区域那就答运行时数据区的六个分区。最好是主动问一句“您说的是运行时数据区的划分还是并发相关的JMM”这样既显得你专业也给自己的回答选对了方向。6.2 答八股文的核心技巧定义原理场景背八股文的通病是只背“是什么”不准备“为什么”和“怎么用”。我教你的方法是按“定义-原理-场景”三层来组织每个知识点。比如问到“双亲委派模型”定义讲清楚“请求逐层上抛父加载器加载不了才自己加载”原理讲清楚“避免重复加载和核心类被篡改”场景举出“JDBC的SPI打破双亲委派”、“Tomcat的类隔离”。这样回答下来时长至少能撑两分钟而且信息密度高面试官很难打断你。再问“G1收集器”定义是“一种以Region为单位的并发垃圾收集器目标是可预测的停顿时间”原理是“维护优先列表回收垃圾最多的Region配合RSet和SATB”场景讲清楚“适合大堆、多核、需要控制停顿时间的服务不适合小堆应用”。这套回答结构除了能应对记忆题还能让你在面对“你项目里怎么优化GC”这种开放性问题时也有话可说。6.3 面试时尽量避开这些坑点有几个坑是我面过很多人后总结出来的提醒大家注意第一不要死记硬背参数要理解参数的含义和影响。比如你背了-XX:SurvivorRatio8但面试官问“为什么是这个比例”如果你说不出来印象分会大打折扣。第二别把“并发”和“并行”混谈。G1的并发标记是“GC线程和应用线程同时执行”并行是“多条GC线程同时回收”这两个概念在JVM面试里是严格区分的。第三别拿Spring Boot的超时设置往JVM参数上靠。有人问我“jvm或者spring boot会设置一个sql执行10秒自动关闭吗”这个问题本身和JVM关系不大SQL超时是数据库连接池或驱动层控制的比如Druid的maxWait啊、HikariCP的connectionTimeout啊不是JVM参数能管的。面试的时候不要把这个和JVM混在一起讲一旦混了面试官会认为你概念不清晰。第四如果你主动提了“调优”一定要准备好被追问“你怎么发现需要调优的”。这个问题最好的回答路径是先观察GC日志jstat、gcviewer发现Full GC频率高再定位大对象或内存泄漏最后调参验证并对比指标。千万别一上来就说“我调了-Xmx”没有前后对比的调优等于没调。6.4 后续怎么继续深挖JVM这个主题其实很深一次面试卷肯定覆盖不完。我建议你照着这条学习路径往后延伸先从JMMJava内存模型和并发编程的关系入手搞懂volatile和synchronized的底层语义再去看JIT编译相关的逃逸分析和锁消除理解JVM到底做了哪些优化最后有条件的话拿一个压测工具跑几轮GC日志分析自己动手配一下不同的垃圾收集器观察它们在不同堆大小下的表现。我自己带人的时候经常要求新人做一件事用JDK自带工具打包解决一个模拟的OOM问题从jmap导出堆到MAT找出泄漏点写一份排查报告。能把这件事独立做完JVM这块的实战能力基本就够了。面试的时候能讲出一个这样的实际排查案例比背一百道八股文都管用。我在实际面试和带人过程中最大的体会是JVM的知识点并不难难的是把零散的概念串成体系。你不需要记住每一处细节但你要能从一个点延伸到另一个点比如从“垃圾回收”自然聊到“收集器”从“双亲委派”自然聊到“SPI”从“参数配置”自然聊到“线上排查”。这种串联能力才是面试官真正在意的。这些内容你花几天、甚至几周去啃都是值得的毕竟JVM这块在Java面试里的权重一直都没有降过。
返回列表