ARTICLE DETAIL

资讯详情

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

JVM类加载与内存模型实战:从报错到排查的完整指南

JVM类加载与内存模型实战:从报错到排查的完整指南 很多人在学 JVM 的时候都会卡在“类加载”和“内存模型”这两块。原因很简单这两块内容表面上都是概念背下来并不难难的是把它们和实际遇到的报错对上号。比如错误: 找不到或无法加载主类、Exception in thread main java.lang.OutOfMemoryError: Java heap space、应用频繁 Full GC 导致接口超时——这些线上问题追根溯源最后都会回到类加载机制、字节码结构和运行时内存模型上。所以这篇笔记我不打算把《深入理解 Java 虚拟机》那本书的内容复述一遍而是想以“排查问题”的视角把第三篇笔记里涉及的类加载、字节码技术和内存模型串起来讲。换句话说报错是入口机制是地图字节码是显微镜。学完这一篇你再看那些报错应该能把“现象”和“原理”连成一条线而不是继续靠猜。1. 从“找不到或无法加载主类”说起类加载机制到底在干什么先看一个特别常见的报错。你在命令行敲了java com.example.Main结果 JVM 直接甩给你一句错误: 找不到或无法加载主类 com.example.Main很多人第一反应是“类名写错了”。但对了一半另一半是这个类根本就没有被 JVM 加载进去。那 JVM 为什么会加载不了一个类这就得从类加载机制的前置流程讲起。1.1 那条看似莫名其妙的报错背后是七个步骤JVM 把一个 class 文件从磁盘上“变成”堆内存里一个可用的Class对象总共要经历七个阶段加载验证准备解析初始化使用卸载其中前五个阶段是类加载机制的核心也就是我们常说的“类加载过程”。找不到或无法加载主类这个报错发生在第一阶段“加载”也可以发生在“初始化”阶段的某个细节上甚至可能发生在类已经加载进去、但静态初始化块抛了异常的时候。很多人会把“类加载”和“类初始化”混为一谈这是第一个需要纠正的认知。加载阶段JVM 要做的事情是根据类的全限定名读取二进制字节流把字节流转化为方法区里的运行时数据结构然后在堆内存中生成一个Class对象作为访问入口。注意这里说的“二进制字节流”不一定是 class 文件它可以是 ZIP 包、网络字节流、动态代理生成的字节码甚至可以是数据库里读出来的一段二进制。如果你在 Eclipse 或者 IDEA 里遇到找不到或无法加载主类 org.apache.catalina.startup.Bootstrap大概率是启动配置里主类路径写错了或者依赖没有构建进classpath。org.apache.catalina.startup.Bootstrap是 Tomcat 的启动类IDEA 的 Tomcat 插件本质上是启动这个类。找不到它说明你的依赖里压根没有 Tomcat 核心库的 jar或者 IDE 没有把依赖同步到运行配置里。1.2 加载与验证字节码进入 JVM 的第一道关卡验证阶段经常被忽略因为正常情况下我们的代码不会出问题。但字节码并不一定是你写的 Java 代码编译出来的它可以是任何工具生成的东西——恶意构造的字节码甚至能让 JVM 崩溃。所以验证阶段做的是“安全检查”包括文件格式验证、元数据验证、字节码验证和符号引用验证。这里面有个值得一提的点即使你的 Java 代码是合法的字节码层面也可能存在 JVM 不认可的结构。比如条件分支的目标地址越界、类型转换不合法等。Java 编译器在编译时通常会生成规范的字节码所以绝大多数人一辈子不会碰到验证阶段报错。但如果你用 ASM、Javassist 这类字节码操作框架手写字节码那就一定要小心生成的东西过不了验证直接就是java.lang.VerifyError。网上有句话叫“Class 文件是一堆严格按照规范排列的二进制流”一点都不夸张。文件开头四个字节是魔数0xCAFEBABE紧接着是次版本号和主版本号。主版本号决定你用的 JDK 版本52 对应 Java 8、61 对应 Java 17。如果你用 JDK 17 编译的 class 文件放到 JDK 8 环境里跑会报UnsupportedClassVersionError这个我也踩过。1.3 准备、解析与初始化真正“活过来”的时刻准备阶段是给类的静态变量分配内存并设置默认值的阶段。这里有一个高频面试陷阱private static int count 10;这条语句在准备阶段执行完count的值是 0不是 10。10这个真正的值要等到初始化阶段调用clinit方法时才被赋上去。所以面试时如果问你“静态变量默认值是什么”别答反了。解析阶段是把常量池里的符号引用替换为直接引用的过程。符号引用就是一组字面量比如java/lang/System、out、println直接引用就是内存中的指针、偏移量或者句柄。这一步很多书讲得玄乎其实可以类比成“查通讯录”符号引用是联系人的名字直接引用是对方的电话号码解析就是把名字翻译成能直接拨出去的号码。初始化阶段才真正执行 Java 代码也就是类构造器clinit方法。它会按照代码顺序执行静态变量的赋值语句和静态代码块。那么问题来了什么时候会触发初始化JVM 规范里规定了六种主动引用场景包括 new 对象、访问静态字段、调用静态方法、反射、初始化子类先初始化父类、以及作为虚拟机启动的主类也就是main方法所在的类。但如果是通过子类引用父类的静态字段子类不会初始化只会初始化父类定义引用类型的数组也不会触发初始化。这些点考试经常考实际排查问题的时候也容易碰到——比如某个类的静态代码块抛异常但你以为它根本没被用到结果日志刷了一堆错误就是因为某个底层类在加载链路中被主动引用了。2. 双亲委派机制类加载器之间的“上下级”关系类加载的“加载”阶段关键动作是“通过类加载器获取类的二进制字节流”。而 JVM 里默认存在三层类加载器它们之间不是平级关系而是一种“上级对下级委派”的父子关系。2.1 三层类加载器与委派规则启动类加载器Bootstrap ClassLoader负责加载$JAVA_HOME/lib目录下 JVM 自身需要的类比如rt.jar、java.lang.*。这个加载器在 HotSpot 里是用 C 实现的Java 代码中拿不到它的引用。扩展类加载器Extension ClassLoaderJDK 9 之前负责加载$JAVA_HOME/lib/ext目录下的类JDK 9 之后变成了平台类加载器负责加载一些 JDK 内部模块。应用程序类加载器Application ClassLoader负责加载classpath下的所有类也就是我们写的业务代码默认由它加载。双亲委派的规则很简单当一个类加载器收到加载请求时它不会自己先去加载而是把这个请求委派给父类加载器每一层都这么做直到最顶层的启动类加载器。只有父加载器反馈自己无法加载时子加载器才会尝试自己加载。2.2 为什么要设计双亲委派隔离与安全这一点可以从两个角度理解。第一是安全。如果我们可以自定义一个java.lang.String类并且让 JVM 先用我们自定义的版本那整个 Java 生态就乱套了。双亲委派机制保证了java.lang.*核心类一定由启动类加载器加载用户自定义的同名类永远不会被优先使用从源头上杜绝了核心类被篡改的问题。第二是避免重复加载。同一个全限定名只能被加载一次父加载器能加载的类子加载器没必要重复加载一遍。这保证了同一个类在 JVM 中是“唯一”的。但注意双亲委派不是“强制”的它只是loadClass方法的默认实现逻辑。你可以继承ClassLoader并重写loadClass方法破坏双亲委派。最有名的场景有两个JDBC 的ServiceLoader机制JDK 核心类需要调用第三方数据库驱动和 Tomcat 的 Web 应用类加载器每个 Web 应用需要隔离不同的依赖版本。这些属于“打破双亲委派”的经典案例面试经常问实际工作中你如果写自定义类加载器也会面临同样的设计取舍。2.3 实际工作中遇到的类加载器冲突排查我见过的真实案例是某个项目里同时引入了 A 库 1.0 和 B 库B 库内部又传递依赖了 A 库 2.0。两个版本里的同一个类com.example.internal.CoreUtils都被打进了最终产物运行时报了一堆NoSuchMethodError或者ClassCastException。这种问题的排查思路是用-verbose:class启动参数JVM 会把每个类由哪个类加载器加载以及从哪个 jar 加载输出到控制台。用jmap -clstats pid查看某个进程的类加载器统计信息。用arthas的sc命令直接查找某个类被哪个类加载器加载。定位后通常在构建工具里排除掉冲突的传递依赖或者统一版本。类加载器的问题难的不是原理而是“你压根没想到是类加载器的问题”。所以我的经验是遇到NoSuchMethodError、NoClassDefFoundError、ClassCastException这类诡异报错先默认它是依赖冲突或类加载器冲突再考虑代码逻辑问题。3. 字节码技术看懂 class 文件才算看懂 Java很多 Java 开发者写了几年代码没见过.class文件长什么样。这很可惜因为字节码是连接“Java 源码”和“JVM 运行时”之间的桥梁。很多“语法糖”在源码层面看是一个样子编译成字节码之后又是另一个样子只有看懂字节码才能真正理解 Java 语言背后做了什么。3.1 class 文件结构与魔数、版本号我之前已经提过class 文件开头 4 个字节是0xCAFEBABE这是 JVM 识别 class 文件的唯一凭据。你如果把一个 txt 文件改名成.classJVM 一读就知道这不是 class 文件直接抛ClassFormatError。魔数后面是版本号再往后是常量池、访问标志、字段表、方法表、属性表等结构。整个 class 文件就是一张巨型的表里面定义了类的基本信息、字段信息、方法信息以及字节码指令。手工解析 class 文件非常痛苦正常不会有人这么做。我们需要的是工具javap -c反编译字节码指令最常用。javap -v输出更详细的常量池、行号表、局部变量表信息。javap -p包含私有成员。jadx/CFR反编译成 Java 源码适合逆向理解别人的逻辑。3.2 一套顺手可用的 javap 实战我写一段极简代码public class Demo { public static void main(String[] args) { int a 100; int b 200; int c a b; System.out.println(c); } }编译后执行javap -c Demo你会看到类似这样的输出public class Demo { public static void main(java.lang.String[]); Code: 0: bipush 100 2: istore_1 3: sipush 200 6: istore_2 7: iload_1 8: iload_2 9: iadd 10: istore_3 11: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 14: iload_3 15: invokevirtual #3 // Method java/io/PrintStream.println:(I)V 18: return }这里有几个细节值得深入bipush 100表示把一个 byte 范围内的整数-128 到 127压入操作数栈。200 超出了 byte 范围所以编译器用了sipush。如果常量更大比如 32768 以上指令就会变成ldc从常量池加载。这就是为什么说“Java 里的 int 常量在字节码层面有不同的加载指令”——性能差异其实可以忽略但面试官爱问。istore_1表示把操作数栈顶的值存入第 1 个局部变量槽位。局部变量表的下标从 0 开始在实例方法里第 0 位是this。静态方法没有this所以args占的是第 0 位。这个细节你写一个静态方法再写一个实例方法对比一下javap输出就一目了然。iadd是整数加法它从操作数栈弹出两个 int相加后再把结果压回栈顶。理解这一点你就知道 JVM 是基于栈的指令集架构——所有运算都在操作数栈上完成而不是像寄存器架构那样直接在寄存器里操作。3.3 常见字节码指令与面试考点字节码指令一共 200 多个全背下来不现实但核心的几类必须眼熟加载和存储指令iload、lload、fload、dload、aload、istore、lstore、astore。运算指令iadd、isub、imul、idiv、iinc局部变量自增。类型转换指令i2l、l2i、f2d等。对象创建与访问指令new、getfield、putfield、getstatic、putstatic。方法调用指令invokevirtual、invokespecial、invokestatic、invokeinterface、invokedynamic。其中invokedynamic是 JDK 7 引入的也是 JDK 8 中 Lambda 表达式实现的基础。你可以写一个 Lambda 表达式的代码然后javap -c -p去看会发现编译器生成了一个lambda$main$0之类的私有静态方法并通过invokedynamic动态调用。理解了这一点面试时被问到“Lambda 底层怎么实现的”你就不只是背答案而是真的能讲清楚。3.4 字节码技术在工程里的实际应用字节码技术离工程实践并不远。常见的应用场景有动态代理JDK 动态代理基于InvocationHandler加反射cglib 基于继承加字节码生成。APT 与 LombokLombok 在编译期通过注解处理器生成 getter/setter。热部署Spring Boot DevTools 通过自定义类加载器实现类文件热替换。性能监控Arthas 通过字节码增强在方法入口和出口插入监控代码实现动态的watch、trace。如果你对这些框架的底层原理感兴趣建议从Javaassist或者ASM入手。ASM 上手曲线陡但它是很多框架的真实选择Javaassist 更简单适合理解进阶概念。我自己第一次用 Javaassist 生成一个类并加载运行时才真正理解“类加载器加载的是字节流”这句话的含义。4. 运行时内存模型六块区域与对象的一生类加载机制解决的是“类能不能被 JVM 认识”的问题而一旦类被加载、初始化完成真正干活的还是运行时数据区——也就是我们常说的 JVM 内存模型。这一块是面试重灾区也是线上 OOM 排查的知识基础。4.1 六块内存区域划分JVM 运行时数据区可以分成六块按线程是否共享来分内存区域是否线程共享存储内容异常情况程序计数器线程私有当前线程执行的字节码行号无虚拟机栈线程私有栈帧每个方法对应一个栈帧StackOverflowError / OutOfMemoryError本地方法栈线程私有为 native 方法服务StackOverflowError / OutOfMemoryErrorJava 堆线程共享对象实例和数组OutOfMemoryError: Java heap space方法区线程共享类信息、常量、静态变量等OutOfMemoryError: Metaspace运行时常量池属于方法区编译期生成的字面量和符号引用OutOfMemoryErrorJDK 8 之后方法区的实现变成了元空间Metaspace。和之前的永久代最大的区别是元空间不在 JVM 堆内存里而是使用本地内存。这就是为什么遇到OutOfMemoryError: Metaspace时-Xmx调得再大也没用要调-XX:MaxMetaspaceSize。4.2 栈帧结构局部变量表、操作数栈与动态连接虚拟机栈对应的是“线程执行到某个时刻的方法调用链”。每次调用一个方法JVM 就往当前线程的虚拟机栈压入一个栈帧。栈帧里面有四样东西局部变量表存放方法参数和局部变量单位是变量槽Slot。操作数栈存放计算过程中的中间结果。动态连接指向方法所属类的运行时常量池引用。方法返回地址方法执行完后回到调用位置。有一个常见的错误认知Java 的“基本类型变量存在栈上对象存在堆上”。这句话严重不准确。准确的说法是局部变量表里的基本类型变量存的是值本身引用类型的变量存的是对象的“引用”指向堆中对象的地址对象本身一定在堆上。如果你在一个方法里new一个对象这个对象的引用在栈帧的局部变量表里对象实体在堆里。由于虚拟机栈是有深度的递归调用太深就会抛出StackOverflowError。解决办法往往不是调栈大小而是检查递归终止条件和数据规模。4.3 对象的一生创建、内存布局与访问定位一个对象从创建到被回收大致经历这几个步骤执行new指令在堆上分配内存。内存分配完成后JVM 将内存空间初始化为零值。JVM 对对象头进行设置包括 Mark Word、类元数据指针等。执行init方法按构造函数和实例代码块初始化。对象在堆中的内存布局分为三块对象头、实例数据和对齐填充。对象头里有两个核心部分Mark Word存储对象自身的运行时数据比如哈希码、GC 分代年龄、锁状态标志和类型指针指向类的元数据JVM 通过它确定这个对象是哪个类的实例。这就是为什么面试问“一个空Object对象占多少内存”时答案不一定是 16 字节——要分 32 位还是 64 位、是否开启指针压缩等具体情况。关于对象访问主流实现方式有两种句柄池堆里单独有一块句柄池引用先指向句柄句柄再指向对象实例数据和类元数据。直接指针引用直接指向对象实例数据对象里再通过类型指针访问类元数据。HotSpot 默认采用这种方式。这也说明一个点Java 语言规范只定义了“通过引用访问对象”具体实现由 JVM 决定。学习时不要把所有 JVM 特性都以为是语言规范。4.4 逃逸分析与栈上分配提到对象分配很多人默认“对象一定分配在堆上”。但实际上 JVM 在开启逃逸分析后默认开启如果对象不会被其他线程或方法外部访问可能进行栈上分配或标量替换对象就不一定存活在堆上。简单例子public class EscapeTest { public static void main(String[] args) { long start System.currentTimeMillis(); for (int i 0; i 100000000; i) { createObject(); } System.out.println(System.currentTimeMillis() - start); } public static void createObject() { Point p new Point(1, 2); } }Point对象在createObject方法内部创建并返回但返回后没用。JVM 把这部分 allocate 优化掉了循环跑得飞快。如果关闭逃逸分析-XX:-DoEscapeAnalysis性能就会断崖式下降。理解逃逸分析不是为了炫技而是为了建立“JVM 比你想象的聪明”的认知很多代码层面的“优化”JIT 编译时自己会做你硬改成复杂写法反而阻止了优化。这也是为什么我一直建议优先写语义清晰的代码过早的“性能优化”往往适得其反。5. 内存问题排查与 JVM 调优工具实战理论讲完接下来进入最实际的部分线上遇到 OOM、GC 频繁、CPU 飙高怎么一步步定位。5.1 OOM 的几种典型场景java.lang.OutOfMemoryError: Java heap space堆内存不足。典型原因是对象太多、存在大对象、或者内存泄漏。java.lang.OutOfMemoryError: GC overhead limit exceededGC 回收效率极低连续多次 GC 释放的内存不到 2%JVM 直接心态崩了。java.lang.OutOfMemoryError: Metaspace元空间不足。通常因为动态生成大量类CGLIB 代理、热部署。java.lang.OutOfMemoryError: unable to create new native thread操作系统的线程数达到上限这不是 JVM 堆的问题而是系统资源耗尽。所以一看到 OOM不要立刻调-Xmx。先看是哪一种 OOM再对症下药。5.2 用一整套工具定位问题的完整链路以一个典型的“测试环境偶发接口超时日志出现 heap space”为例完整链路如下第一步jps确认进程号。jps -l输出类似30560 /Users/xxx/Demo/TestApplication.jar记下 PID假设是 30560。第二步jstat看 GC 情况。jstat -gcutil 30560 1000 10这个命令每秒输出一次 GC 统计共输出 10 次。重点关注FGCFull GC 次数、FGCTFull GC 总耗时、O老年代使用率。如果老年代不断上涨且 Full GC 后下降不明显基本可以判断有对象堆在里面出不去。第三步jmap导出堆快照。jmap -dump:formatb,file/tmp/heap.hprof 30560堆快照文件比较大生产环境小心使用建议在低峰期操作或者干脆在启动参数里指定-XX:HeapDumpOnOutOfMemoryError让 JVM 在 OOM 时自动生成快照。第四步用 MAT 或 VisualVM 分析快照。MAT 打开后先看Leak Suspects报告它会自动帮你找出占用堆内存最大的对象和引用链。5.3 jstack 排查线程问题与 arthas 动态观测如果问题是线程阻塞、死锁或者 CPU 飙高jstack是首选。jstack 30560 thread_dump.txt在 thread_dump.txt 里搜索deadlock如果存在死锁JVM 会直接打印出死锁的线程和锁的持有方。如果线程大量卡在WAITING状态多半是线程池配置不合理或某个任务长期占用了工作线程。Arthas 是我用过最顺手的动态排查工具推荐几个高频命令dashboard实时查看线程、内存、GC 概况。thread -n 3列出 CPU 占用前三的线程并显示栈信息。watch com.example.Service methodName returnObj动态查看方法入参和返回值。trace追踪方法内部调用耗时分布。有了 Arthas很多时候都不需要重启服务直接在运行中的应用上做诊断真的能救命。5.4 日常预防 OOM 的开发层建议排查很重要但更重要的还是预防。以我自己的经验开发阶段能做的事包括在 IDEA 里调大 JVM 运行内存防止开发测试时 OOM。比如Help - Edit Custom VM Options里配置 IDEA 自身内存。运行配置的VM options里给业务项目分配合理堆内存比如-Xms256m -Xmx1024m。注意集合类使用。静态集合是内存泄漏重灾区比如static ListObject CACHE new ArrayList()只增不减时间一长必然 OOM。小心大对象和大数组。比如一次性把几十 MB 的日志读到内存里解析堆很容易被打满。能流式处理就流式处理。关注线程池。每个线程默认栈大小是 1MB-Xss200 个线程就是 200MB 内存不属于堆但也是进程级资源。线程数设置过大会直接耗尽系统内存。合理配置元空间大小。如果项目里用了很多动态代理和 CGLIB建议显式设置-XX:MaxMetaspaceSize避免元空间无限扩张。在这个主题上我还想分享的一点这套笔记写下来我的感受是JVM 的知识不是靠背诵掌握的而是靠“遇到问题、查原理、回到代码、验证结论”这个循环慢慢积累的。就拿类加载和内存模型来说你可以在写代码时留意某个类的静态代码块什么时候执行一个对象到底占多少内存为什么调大堆内存反而让 Full GC 更久带着这些问题去查书、去看实际日志比单纯刷十遍面试题都管用。如果你刚开始接触 JVM我建议按这个顺序来先把类加载机制和运行时数据区的划分搞熟再用javap反编译几个简单类建立字节码的直觉最后把工具链jps、jstat、jmap、jstack、Arthas在日常开发里用起来。三者串起来之后再看那些 JVM 调优文章你会发现过去看不懂的参数和理论突然就说得通了。下一篇笔记我会继续整理垃圾回收器选型和 GC 日志分析。到时候这一篇里的“内存区域划分”会成为很多结论的底层依据建议先把这篇里的基础概念消化透。
返回列表