ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JVM内存结构详解:从启动失败到性能调优一网打尽

JVM内存结构详解:从启动失败到性能调优一网打尽 一个跑了好几年的 Java 服务某天突然起不来了控制台里只有一行 error invoking method. failed to launch jvm连堆栈都没有。用户急运维也急大家只能一遍遍试启动参数。说实话JVM 相关的知识大学课本里讲过各种面试题里也见过但真正到了线上故障那一刻你才会发现内存结构、性能调优、JVM 与 JRE 的关系、启动失败的排查这些看似零散的知识点其实是一条完整的线索。这篇文章我想用一篇的篇幅把 JVM 的内存结构讲透再把基于这份地图的启动排障与性能调优方法串起来顺带回答几个热度一直很高的高频问题。适合正在学 JVM 的 Java 开发者也适合已经工作几年、想系统补全这块知识体系的人。1. 运行时数据区先把 JVM 的地盘划分搞清楚1.1 线程私有的三个区域程序计数器、虚拟机栈、本地方法栈JVM 在执行 Java 字节码的时候并不是把所有东西都扔到一个大池子里而是把内存按职责划分成了几个区域。规范里给这些区域起了一个总称运行时数据区Runtime Data Area。其中程序计数器、虚拟机栈、本地方法栈是线程私有的——每个线程都有自己的那一份互不干扰堆和方法区是进程级共享的所有线程都在里面抢地盘。程序计数器Program Counter Register是这里面最小的一个区域作用是记录当前线程正在执行的字节码指令地址。别小看它线程切换、异常处理、循环跳转都靠它恢复刚才看到哪一行了。这个区域是唯一一个不会抛 OutOfMemoryError 的区域因为虚拟机规范根本没有给它分配内存的规定它的天然体积也非常小。虚拟机栈JVM Stack就重要多了每个方法从调用到执行完毕对应一个栈帧Stack Frame入栈和出栈的过程。栈帧里有四样东西局部变量表存放基本类型、引用类型和 returnAddress、操作数栈像计算机里那个运算栈、动态链接指向运行时常量池的引用用于解析方法和字段、方法出口异常处理表和正常返回地址。方法一旦调得太深栈空间就会耗尽抛出 StackOverflowError如果是栈动态扩展时申请不到内存则是 OutOfMemoryError。栈的大小用 -Xss 控制默认在大多数平台上是 1MB 左右这个值不是越大越好调大了会占用更多总内存因为每个线程都有一份独立栈。本地方法栈Native Method Stack和虚拟机栈结构类似只是它服务于 native 方法。HotSpot 虚拟机做了一个很实际的合并把本地方法栈和虚拟机栈合二为一所以你在排查问题时常常会觉得这两个不是一回事吗——在 HotSpot 里它们确实就是同一个栈。1.2 线程共享的堆和方法区GC 的主战场堆Java Heap是 JVM 管理内存中最大的一块也是垃圾收集器的主战场。几乎所有 new 出来的对象实例都在这里分配栈上分配、标量替换等优化手段除外。堆是所有线程共享的所以这里最热闹也最容易出问题。堆的大小通过 -Xms初始和 -Xmx最大控制线上经验一般是把两者设为相同值避免运行期频繁扩容收缩。方法区Method Area在逻辑上属于堆的一部分但很多人喜欢把它单独拿出来记。它存储的是已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存。JDK 8 之前HotSpot 用永久代PermGen来实现方法区JDK 8 开始彻底改成了元空间Metaspace直接使用本地内存Native Memory不再受堆大小的限制而是受操作系统内存限制。这一个改动直接解决了一类经典 OOM——PermGen Space 溢出。方法区里还包含运行时常量池Runtime Constant Pool存放编译期生成的各种字面量与符号引用。类加载之后常量池里的信息会被放进来同时运行期也可以往里添加新内容比如 String.intern() 方法。把这块内容用个类比来记堆像公司的公共仓库所有线程都往里面放东西、取东西虚拟机栈是每个员工面前的工作台人走台空程序计数器就是员工手里的记事本方法区是公司的规章制度档案室装的是类结构和版本信息这类制度性内容。1.3 方法区在 JDK 8 之后的最大变化永久代彻底退出这里要专门强调一下永久代和元空间的区别因为面试和实际排查中都特别容易踩。永久代在 JDK 7 及以前是堆的一部分受 -XX:MaxPermSize 控制JDK 8 之后元空间用本地内存实现默认情况下大小几乎不受限只受系统物理内存约束你可以用 -XX:MaxMetaspaceSize 给它设一个上限防止失控。这个变化带来的实际影响是以前常见的 java.lang.OutOfMemoryError: PermGen space 在 JDK 8 之后基本消失了取而代之的是 Metaspace 相关的溢出。但注意元空间使用本地内存也是一把双刃剑——如果类加载太频繁且卸载不掉典型场景热部署、动态代理生成大量代理类元空间会持续膨胀最终把机器内存吃光表现出来就是机器突然卡死或者进程被系统的 OOM Killer 干掉而不是 JVM 自己抛异常。所以生产环境建议把 -XX:MaxMetaspaceSize 显式配起来。2. 堆内存的分代设计与对象的一生GC 判断垃圾靠的是这条路2.1 新生代里的 Eden 区与 Survivor 区怎么配合堆虽然是一大块但 JVM 不会傻乎乎地在整块堆里做统一扫描。商业虚拟机的经验法则是绝大多数对象朝生夕灭于是堆被划分为新生代Young Generation和老年代Old Generation新生代内部又分成一块 Eden 区和两块 Survivor 区S0 和 S1默认比例是 8:1:1可用 -XX:SurvivorRatio 调整。新对象一般先在 Eden 区分配。当 Eden 区满的时候触发 Minor GC整个过程用复制算法把存活对象复制到某一块 Survivor 区然后一次性清空 Eden 和另一块 Survivor。下次 Minor GC 时把 Eden 和这块存活区的对象再复制到另一块 Survivor 区如此反复。每经历一次 Minor GC 存活下来的对象年龄 1默认到 15 岁-XX:MaxTenuringThreshold 可调就晋升到老年代。为什么用两块 Survivor因为复制算法有个前提——大量对象需要被回收时复制存活对象很划算。如果只有一块 Survivor就没法实现一块收、一块空的交替复制也没办法及时整理碎片。这是分代设计里最容易考的点面试官问为什么是两个 Survivor 而不是一个答案就是这个。2.2 对象什么时候晋升到老年代除了年龄到达阈值会晋升还有几种情况会直接进入老年代。一是大对象-XX:PretenureSizeThreshold 设置了阈值超过该大小的对象直接在老年代分配避免在新生代和 Survivor 之间来回复制浪费性能。二是空间分配担保Minor GC 前JVM 会检查老年代最大可用连续空间是否大于新生代所有对象总大小如果不满足就可能直接触发一次 Full GC 来腾空间。还有一点容易被忽略动态年龄判定。虚拟机并不一定等对象到 15 岁才晋升如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半年龄大于或等于该年龄的对象就直接进入老年代。所以你在线上看到的对象年龄可能只有三四岁就晋升了这是正常现象。Minor GC 和 Full GC 的区别也要分清Minor GC 只回收新生代频率高、速度快Full GC 回收整个堆和方法区元空间通常伴随老年代的标记-整理或标记-清除过程停顿时间长是调优的重点关注对象。如果 Full GC 频繁要么是老年代空间不足要么是晋升速度过快要么是元空间膨胀要么是代码里有人在显式调用 System.gc()。2.3 判断对象是否可回收可达性分析与四种引用确定一个对象该不该死主流 JVM 用的是可达性分析Reachability Analysis而不是简单的引用计数。思路是从一组称为 GC Roots 的根对象出发沿着引用链向下搜索能到达的对象就算活的不可达的对象就视为垃圾。GC Roots 包括虚拟机栈帧中的局部变量所引用的对象、方法区中的静态变量和常量引用的对象、本地方法栈中 JNI 引用的对象、被 synchronized 持有的对象以及活跃线程等。引用计数法为什么不行因为它解决不了循环引用A 引用 BB 引用 A两个对象互相指着但外部已经没人用它们了引用计数永远不为 0内存就泄漏了。这也是一个高频面试点。Java 还把引用分成了四种强度强引用Strong是普通的 new 出来的引用GC 绝对不会动它软引用Soft在内存即将溢出时才会被回收适合做缓存弱引用Weak在下次 GC 时必然被回收典型应用是 ThreadLocal 的 key虚引用Phantom最弱无法通过它获取对象只用于在对象被回收时收到一个系统通知比如 NIO 里 DirectByteBuffer 的回收就依赖虚引用。3. 一次真实的启动失败现场error invoking method. failed to launch jvm 排查全过程3.1 这个报错到底是谁抛出来的先别急着改参数搞清楚报错的来源很重要。error invoking method. Failed to launch JVM 这句话并不是某个 Java 库抛出的异常而是启动器Launcher给出的提示。很多商业软件、集成安装包用 InstallAnywhere 之类的工具打的包、IDE 插件、甚至一些老的 WebLogic 安装脚本它们的启动器本身是一个原生程序启动时通过配置文件去定位 JVM 并执行启动方法。如果这一步失败启动器就用这个笼统的提示来搪塞你。知道了来源排查思路就清晰了你不是在排查一个 Java 异常而是在排查一个原生程序为什么没能成功拉起 JVM 进程。这决定了下面的排查顺序。3.2 排查链路从内存参数到架构匹配我这里有一条实际可复用的排查链路按优先级从高到低第一看启动器的 VM 配置。InstallAnywhere 打的包通常在同目录下有一个 .lax 文件比如 xxx.lax里面写着 vm 参数常见的有 -Xmx、-Xms、-D 选项。把 -Xmx 先降下来试比如从 1024m 改成 512m。很多小机器上这个报错就是因为 -Xmx 开得太大系统剩余物理内存不够或者 32 位 JVM 分配不到足够大的连续地址空间。第二确认 JVM 本身可用。命令行直接执行 java -version如果这一步都报错说明你的 JAVA_HOME 或者 PATH 环境有问题或者 PATH 里指向了一个损坏的 JDK。这里要特别提一下 32 位和 64 位的匹配问题启动器是 64 位的却找到了 32 位的 JVM很容易出现 Failed to launch JVM 这类错误。在 Linux 上用 file $(which java) 或者 uname -m 看一下架构Windows 上检查 Java 安装路径是 Program Files 还是 Program Files (x86)。第三检查环境变量。启动器在解析 JVM 路径时通常优先用配置里的绝对路径其次看 JAVA_HOME最后才看 PATH。JAVA_HOME 必须指向 JDK 或 JRE 的根目录而不是 bin 目录这个问题很经典值得反复强调。第四看看是不是杀毒软件或安全策略把临时目录里的文件给拦了。安装类软件经常把 JVM 相关文件解压到临时目录再执行如果被杀表现就是这个启动直接失败。第五如果应用跑在容器里还要看容器的内存限制。Java 8 之前的 JVM 不感知 cgroup 限制你 -Xmx 设了 2G但容器内存只有 1.5G外部启动器去拉进程的时候被系统拒绝报错形式多种多样这类报错只是其中一种。JDK 8u191 支持 -XX:MaxRAMPercentage 来按容器配额自适应。我遇到过最典型的一次一个安装包在 32 位 Windows 上跑得好好的换到 64 位 Windows 后偶发这个报错排查了很久才发现是 .lax 里写死了 -Xmx1024m而 32 位 JVM 在 64 位系统上运行时进程的地址空间分配经常出现碎片导致无法分配连续内存。把 -Xmx 降到 768m 之后问题再没出现过。3.3 预防同类问题的三个习惯这类启动问题与其每次现查不如提前堵住。我的经验是把 JVM 相关参数统一放在一个配置文件里写清楚每个参数的含义和单位别让 -Xmx 散落在各种启动脚本中。启动脚本里加一段自检逻辑先执行 java -version比对架构、版本再决定是否继续启动。看起来多花了几毫秒但能省下大量排查时间。记录基线每个环境开发、测试、生产的 JVM 参数、物理内存、JVM 版本都记录在案出问题时有据可查。4. JDK、JRE、JVM 之间的关系很多事故的源头其实在这里4.1 三者的职责边界jre和jvm之间的关系 是搜索热度长期居高不下的问题很多做了好几年 Java 的人也未必能一句话讲清楚。我的理解是JVMJava Virtual Machine是规范也是实现。它负责读取 class 字节码解释或即时编译成机器指令执行。你有 HotSpot、OpenJ9、GraalVM 这些不同的实现它们都遵循同样的 JVM 规范所以同一个 class 文件可以在不同 JVM 上运行。JREJava Runtime Environment是运行 Java 程序的最小环境JVM Java 核心类库java.、javax.这些 一些支撑文件。如果你只需要运行别人写好的 Java 程序装 JRE 就够了。JDKJava Development Kit是开发工具全家桶JRE 开发工具javac、jar、javap、jdb、javadoc 基础类库源码。你要编译 Java 代码必须装 JDK。另外 JDK 9 开始官方不再单独发布 JRE 安装包运行时镜像可以用 jlink 按需裁剪这算是这个关系模型里最新的变化但不影响 JVM 规范本身的划分。一句话总结JDK 包含 JREJRE 包含 JVMJDK JVM 类库 工具JRE JVM 类库。4.2 只装 JRE 的机器上跑程序常见的问题很多生产服务器为了精简只装 JRE结果跑应用时出各种莫名其妙的问题。最常见的三个一是启动脚本里调用了 JDK 专属工具。比如有些自动化部署脚本会执行 keytool 导入证书或者用 javap 做类检查甚至有些应用服务器会尝试调用 javac 做 JSP 预编译这些工具在 JRE 里都不存在。二是 JAVA_HOME 语义错误。如果 JAVA_HOME 指向 JRE 目录某些脚本在 ${JAVA_HOME}/bin/java 能找到 java但在 ${JAVA_HOME}/lib/tools.jar 或 ${JAVA_HOME}/bin/javadoc 等路径上就找不到文件了。像 JConsole、VisualVM 这类诊断工具纯 JRE 环境根本起不来。三是版本混淆。有的机器上 PATH 和 JAVA_HOME 指向两个不同版本的 JVM启动脚本用 JAVA_HOME 的版本而你自己手工敲 java -version 看到的又是另一个版本排查时极容易误判。我的建议是生产环境统一装 JDK 而不是 JRE。理由是现在 JDK 和 JRE 的体积差别已经不大而且很多运行期诊断工具只有 JDK 才有你不可能等线上出问题的时候再临时补装。5. 性能调优从哪下手GC 日志、参数与工具的配合5.1 先学会看 GC 日志再谈调优性能调优的第一步永远不是改参数而是观察。而观察 JVM 内部状态最直接的手段就是 GC 日志。JDK 8 及之前常用 -XX:PrintGCDetails -XX:PrintGCDateStamps -verbose:gcJDK 9 之后日志系统统一成了 -Xlog:gc*比如 -Xlog:gc*:filegc.log:time,uptime,level,tags。拿到 GC 日志先看三件事一次 Minor GC 的停顿时间有多长、一次 Full GC 的停顿时间有多长、两次 GC 的间隔是多久。比如下面这种典型输出[GC (Allocation Failure) [PSYoungGen: 65536K-8128K(76288K)] 65536K-12176K(251392K), 0.0245678 secs] [Full GC (Ergonomics) [PSYoungGen: 8128K-0K(76288K)] [ParOldGen: 40512K-48640K(175104K)] 48640K-48640K(251392K), 0.8734561 secs]Minor GC 停顿在几十毫秒内、Full GC 在几百毫秒内且一天才几次通常是健康状态如果 Full GC 每秒好几次或者单次停顿超过几秒那就不是调参能简单解决的了可能要怀疑代码层面有内存泄漏或超大对象。配合 jstat 可以看实时情况jstat -gcutil 1000 每秒打印一次各区域的实用率连续观察几分钟能看出 Eden 区是否频繁打满、老年代是否在稳步爬升。jmap -heap 可以看堆的分布jmap -histo:live 可以看哪些类占内存最多这是定位内存泄漏最常用的一招。提示JDK 9 之后 jmap 的部分功能被 jhsdb 取代但基本定位思路没变。5.2 核心调优参数逐个拆解下面是我在生产环境实际用过、且推荐你优先掌握的一组参数。参数作用我的建议-Xms / -Xmx堆初始/最大大小线上设为相同值避免动态扩容-Xmn新生代大小优先于 -XX:NewRatio 直接控制-XX:NewRatio老年代/新生代比值默认 2即新生代占 1/3 堆-XX:SurvivorRatioEden/Survivor 比值默认 8基本不用动-XX:MaxMetaspaceSize元空间上限必须显式设置防止本地内存失控-XX:MaxRAMPercentage容器内堆占配额百分比JDK 8u191 容器场景推荐-XX:HeapDumpOnOutOfMemoryErrorOOM 时自动导出堆转储强烈建议开启配合 HeapDumpPath-XX:MaxGCPauseMillisG1 的目标停顿时间从 200ms 起调别设太激进-XX:UseG1GC / -XX:UseParallelGC垃圾收集器选择G1 适合大堆和可控停顿场景ParallelGC 追求吞吐量-Xmx 设置多少合适经验上如果机器只有这一个 Java 进程堆最大可以用到物理内存的 60%~75%再留一部分给元空间、线程栈、直接内存和操作系统。盲目的越大越好很容易带来更长甚至更频繁的 Full GC因为堆变大后单次 GC 扫描范围更大。新生代大小对 GC 频率影响最大。新生代太小Minor GC 频繁太大老年代空间被压缩Full GC 风险升高。我一般从堆的 1/3 开始调然后观察 Minor GC 频率和停顿时间做微调。5.3 一套可复用的调优流程我给团队定了这么一套流程你也可以直接拿去用先量化指标记录当前的应用吞吐量、接口响应时间、GC 停顿时间、JVM 各区域使用率。分析瓶颈类型。用 jstat 看 GC 频率用 top 看 CPU用 jmap/jstack 看线程和堆。是 CPU 打满可能是代码热点或死循环、老年代爬升可能是泄漏、还是 Full GC 频繁可能是参数或晋升问题。定一个假设改一个参数。别一次改五六个参数否则出了问题你根本不知道是谁引起的。验证效果回归测试。用压测工具或者真实流量观察至少一天。把稳定的参数组合固化成基线写进部署文档和启动脚本。调优最忌讳的就是玄学调参——不看日志、不看监控凭感觉把 -Xmx 翻一倍。我见过太多系统崩溃其实问题在代码层参数改了只是把崩溃延后了几个小时而已。6. 高频面试题背后的三个底层考点6.1 内存结构类问题怎么答才不踩坑关于 JVM 内存面试官几乎必问JVM 运行时数据区有哪些。这个题看似简单但很多人的答案是背出来的一问JDK 8 之后方法区去哪儿了就卡住。我的建议是先答完整结构程序计数器、虚拟机栈、本地方法栈、堆、方法区/元空间然后立刻补充JDK 8 之后永久代被元空间替代使用本地内存再主动提到堆内分代新生代、老年代、Eden、Survivor。这样就展示了你不仅知道静态结构还知道版本演进。另一个高频变种是什么情况会抛出 StackOverflowError 和 OutOfMemoryError。回答时必须区分区域栈溢出通常是递归过深堆溢出通常是对象太多或泄漏元空间溢出通常是类加载过多。每个区域说一个典型场景面试官基本就满意了。6.2 GC 与类加载类问题的答题框架判断对象是否可回收这类问题答题框架是先说结论可达性分析再说 GC Roots 包括哪几类最后补一句为什么不用引用计数——因为有循环引用问题。这个框架里最容易被忽略的是 GC Roots 的具体内容很多人只说从根对象出发就没了一定要把栈帧局部变量、静态变量、常量引用、JNI 引用这四类说全。类加载过程加载、验证、准备、解析、初始化和双亲委派模型也是高频题。双亲委派的关键词是自下而上检查自上而下尝试加载一个类加载器收到加载请求先交给父加载器父加载器加载不了才自己尝试。它的好处是避免核心类被重复加载和伪造保证 java.lang.String 这种核心类始终是同一个。6.3 调优类问题的避雷姿势你做过 JVM 调优吗 是高级岗位的高频问题。最容易好高骛远地回答我把 -Xmx 调大到 8G——这不算调优只是改参数。正确的回答姿势是先讲你的监控手段GC 日志、jstat、jmap、Arthas 之类再讲你发现的具体问题比如 Full GC 频率高、老年代持续增长然后给出你的分析过程怀疑是缓存对象过多用 jmap 看到某个类实例数异常最后讲你的措施和验证结果。哪怕你只处理过一次真实的线上问题也比你背十个调优参数强。面试官考的是你有没有排查思路而不是知不知道参数名。最后说一点我自己的体会。JVM 这套东西初看是知识点再看是方法论真正用的时候其实是地图。内存结构是地图的坐标GC 是地图上的交通规则启动报错和调优是你在路上遇到的各种状况。把地图画熟遇到问题你就知道自己在哪个路口该往哪个方向排查而不是在群里发截图等别人给玄学方案。我建议每个 Java 开发者都亲手做一次这样的练习搭一个容易内存泄漏的小程序开 GC 日志用 jmap 定位泄漏点再调参数验证效果。这个过程走一遍你对 JVM 的理解会比刷十篇面试题都深。
返回列表