ARTICLE DETAIL

资讯详情

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

Java字节码详解:从javap到Class文件结构,一文读懂JVM底层运行原理

Java字节码详解:从javap到Class文件结构,一文读懂JVM底层运行原理 写Java写了几年有一个很明显的分水岭你开始好奇自己写的那行代码在JVM里到底是怎么跑的。语法、集合、框架用久了很多人卡在“会用”这一步面试问到底层就露馅。而“查看Java字节码”正是捅破这层窗户纸最直接的方式。这篇内容我准备把我平时看字节码的整套方法分享出来包括最基础的javap命令、IDEA可视化插件、几个高频面试点的字节码拆解以及Class文件的结构速查。适合已经掌握Java基础语法、准备进阶JVM方向或者正在准备面试、想读框架源码的开发者。字节码这个东西听起来唬人但你只要跟着实操一遍会发现它就是一串有规律的指令。我们不需要成为一个字节码专家但学会“读懂”它绝对能让你对Java的理解上一个台阶。1. 为什么要看字节码先搞清楚它解决了什么问题1.1 字节码到底是什么先把概念说透。JVM是不认识.java源文件的它只认.class文件。我们写的Java代码经过javac编译后会生成一个.class文件里面存的不是机器码而是JVM定义的一套指令集这套指令就是字节码。你可以把它理解成“Java代码的中间翻译”编译器把人类容易读的Java源码翻译成JVM容易执行的字节码JVM再通过解释器和JIT编译器把字节码变成机器码执行。打个比方源码像是你给厨师写的一份菜谱“锅中放油烧热后倒入蛋液翻炒凝固后盛出”。字节码则是标准化之后的操作指令“取油→热锅→下蛋→翻匀→出锅”。机器只认后者的精确动作而人要理解机器的动作就得学会看这套指令。1.2 看字节码能帮你解决哪些实际问题很多人觉得看字节码是框架作者、JVM工程师才需要做的事。其实不然我实际工作里遇到下面这些场景全都靠字节码找答案面试题里那些“i和i有什么区别”“String拼接到底用了什么”“自动拆装箱有没有隐藏对象创建”之类的问题只看源码层面永远是背答案看了字节码你就能说出底层原理而且不容易忘。读框架源码的时候比如Spring的AOP、MyBatis的Mapper代理、JDK动态代理很多逻辑是运行期动态生成字节码来实现的。你不看字节码就很难理解为什么一个接口能被new出对象来。排查线上问题的时候比如判断某段代码是否产生了不必要的对象分配反编译或查看字节码能看出端倪。有些时候代码被编译后和你想象的根本不是一回事。1.3 学会看字节码意味着什么它意味着你从“只会写Java”过渡到“理解Java是怎么运行的”。很多人说Java找工作难其实难的不是语法而是对JVM、并发、底层原理的真正理解。字节码作为这一切的“地基”是你进阶绕不开的一环而且它并没有想象中那么难。2. 第一把刀javap命令行工具的完整用法2.1 javap的基本参数体系javap是JDK自带的字节码查看工具不用装任何额外的软件你只要装了JDK就能用。我平时最常用的参数就是下面这几个参数作用举例无参数只显示public/protected修饰的方法和字段签名javap Simple.class-p显示所有类和成员包括privatejavap -p Simple.class-c输出方法对应的字节码指令javap -c Simple.class-v输出完整信息包括常量池、注解、局部变量表等javap -v Simple.class-s输出内部类型签名泛型、描述符javap -s Simple.class-l输出行号表和局部变量表javap -l Simple.class-verbose等同于-vjavap -verbose Simple.class最常用的组合是javap -c -p大多数场景下这两个参数就够用了。如果你需要对类结构有更深入的了解再用-v或-s补充。2.2 第一次实操对一个最简单的类运行javap我写一个最基础的类public class Simple { private int count; public int add(int a, int b) { return a b; } }编译加反编译javac Simple.java javap -c -p Simple输出public class Simple { private int count; public Simple(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object.init:()V 4: return public int add(int, int); Code: 0: iload_1 1: iload_2 2: iadd 3: ireturn }第一次看可能有点懵我解释一下。add方法有3行有效指令iload_1把局部变量表第1个位置的int值压入操作数栈也就是参数aiload_2把局部变量表第2个位置的int值压入操作数栈也就是参数biadd从栈顶弹出两个int值相加后把结果压回栈顶ireturn从栈顶返回一个int值注意一个关键点实例方法里局部变量表的第0位是this引用。所以add(int a, int b)的两个参数占据第1和第2位。这就是为什么指令是iload_1和iload_2而不是从0开始。这段代码也解释了JVM“基于栈”的运算模型几乎所有操作都是先把数据压入操作数栈然后执行指令再把结果存回局部变量表或返回。理解了这一点后面看任何字节码都会轻松很多。2.3 javap -v查看完整的Class信息如果你用javap -v Simple输出会多出很多内容包括class文件版本号比如Java 8是52.0Java 11是55.0Java 17是61.0常量池Constant Pool里面存放了类名、方法名、字段名、字符串字面量等符号引用每个方法的Code属性包括字节码指令、异常表、行号表、局部变量表只看一段常量池Constant pool: #1 Methodref #4.#15 // java/lang/Object.init:()V #2 Fieldref #3.#16 // Simple.count:I #3 Class #17 // Simple #4 Class #18 // java/lang/Object这里#2 Fieldref #3.#16的意思是字段引用指向类Simple的count字段类型是Iint。#15和#16在常量池后面会有对应的NameAndType条目分别保存字段名和类型描述符。字节码指令里写的invokespecial #1实际上就是“调用常量池第1项指向的方法”这是一次符号引用。真正的方法地址在类加载的解析阶段才被替换为直接引用。这就是JVM“懒解析”机制在字节码层面的体现。2.4 javap的局限性javap不是反编译器它不会帮你还原成好看的Java代码。它展示的是最底层的真实执行顺序。如果你想看“这段代码编译后长什么样”可以直接用IDEA的反编译功能或者工具CFR、FernFlower。但如果你想知道“这段代码在JVM里怎么执行”javap才是正解。两者的定位不一样别用错场景。3. 可视化方案IDEA插件让字节码不再劝退3.1 jclasslib Bytecode Viewer的安装和用法如果你觉得在命令行看字节码太枯燥我强烈推荐IDEA插件jclasslib Bytecode Viewer。直接在IDEA的Plugins市场搜索安装就行。装好后有两种打开方式选中一个已经编译好的.class文件右键选择“Show Bytecode With jclasslib”直接打开一个.java源文件右键选择“Show Bytecode With jclasslib”插件会先帮你编译再展示这个插件的界面非常友好左侧是Class文件的各个部分常量池、接口、字段、方法、属性。点开任意一个方法右侧会显示它的字节码指令、局部变量表、异常表等。最爽的一点是指令和常量池是联动的高亮鼠标点到invokevirtual能直接看到调用的方法在常量池里的完整描述。3.2 用jclasslib看常量池双击看详细引用解析常量池是Class文件里最繁琐、也最有信息量的部分。在jclasslib里你不需要像命令行那样记编号。双击任意一项插件会展示完整的解析结果。比如你看到一个String类型的常量双击后能看到它对应的字符串值看到一个Methodref双击后能看到它指向的类和方法签名。对初学者来说这种方式能避免被一堆数字编号绕晕建议第一次接触字节码的人从这个插件入手。3.3 ASM Bytecode Viewer与ASMifier如果你的目标不只是“看”而是想做字节码增强、写ASM代码那IDEA里的ASM Bytecode Viewer插件更合适。它能把你选中的Class文件直接显示成ASM框架对应的Java代码。举个例子你写一个简单的类再点开这个插件它会生成一串ClassWriter、MethodVisitor调用的代码。这就节省了大量手写ASM代码的时间。对想研究CGLIB、ByteBuddy、Arthas这类工具实现原理的人这一步基本是必经之路。3.4 什么时候该用工具什么时候该用javap我的使用习惯是这样的快速查看某个类的方法和字段结构用javap不带参数或带-p想研究某个方法的字节码逻辑IDEA里直接用jclasslib界面直观做自动化处理或者临时在服务器上看一个class文件用命令行javap涉及字节码生成/增强用ASM Bytecode Viewer导出ASM代码可视化工具再好命令行基本功也别丢。毕竟真实环境中你大概率只能拿到一个.class文件没有IDE环境。4. 一个完整的实战案例从Java源码到字节码的全程拆解与其空谈指令不如直接拿几个高频面试考点来拆。我会先给出源码再给出关键字节码最后告诉你这些字节码解释了什么问题。4.1 演示1String拼接可能不是你想的那样先看一段常见的代码public class StringDemo { public String concat() { String s a b; String t s c; return t; } }编译后执行javap -c -p StringDemo关键字节码public java.lang.String concat(); Code: 0: ldc #2 // String ab 2: astore_1 3: new #3 // class java/lang/StringBuilder 6: dup 7: invokespecial #4 // Method java/lang/StringBuilder.init:()V 10: aload_1 11: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 14: ldc #6 // String c 16: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 19: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; 22: astore_2 23: aload_2 24: areturn这个结果非常有意思。第一行的a b在编译期就被折叠成常量字符串ab了字节码里直接ldc #2加载这个常量根本没有拼接操作。这是编译器做的“常量折叠”优化。但s c就不一样了因为s是运行期变量编译器没法提前算出来所以它生成了new StringBuilder()然后连续两次append最后toString。也就是说字符串变量拼接本质上是创建了StringBuilder再append。如果你在循环里写s str每次循环都会new一个StringBuilder性能自然差。从字节码上就能直观看到创建对象的指令。4.2 演示2i和i的区别从指令层面看差异看这段代码public class IncDemo { public void test() { int i 0; int a i; int b i; } }字节码public void test(); Code: 0: iconst_0 1: istore_1 2: iload_1 3: iinc 1, 1 6: istore_2 7: iinc 1, 1 10: iload_1 11: istore_3 12: return注意看差异int a i;先iload_1把i的旧值0压栈再iinc 1, 1局部变量表第1位的i自增为1然后istore_2把栈顶的0存给a。所以a是0i是1。int b i;先iinc 1, 1i自增到2再iload_1把新值2压栈然后istore_3栈顶的2存给b。所以b是2i是2。这个结果和面试八股文里说的完全一致但你现在是亲眼看到了指令顺序。i是先压旧值再自增i是先自增再压新值。明白了这一点以后谁再问这个题你直接画个栈模型就完事了。4.3 演示3自动拆装箱编译后发生了什么源码public class BoxDemo { public void test() { Integer num 200; int x num; } }字节码public void test(); Code: 0: sipush 200 3: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer; 6: astore_1 7: aload_1 8: invokevirtual #3 // Method java/lang/Integer.intValue:()I 11: istore_2 12: returnInteger num 200对应的不是直接new Integer而是Integer.valueOf(200)。int x num对应的是Integer.intValue()。这就是自动装箱和拆箱的本质它们分别调用了valueOf和intValue。如果面试官问“为什么Integer 200和Integer 200用比较是false”你可以顺着这个思路回答400会走Integer.valueOf而valueOf内部对-128到127有缓存200正好超出这个范围所以每次都会新建对象。这是源码层面看不到、但字节码能反映出来的行为。4.4 演示4try-with-resources关闭资源的字节码原理源码public class TryDemo { public void test() throws Exception { try (AutoCloseableResource r new AutoCloseableResource()) { r.doSomething(); } } }出于篇幅我这里给一个简化过的字节码逻辑实际用javap能看到异常表Exception table0: new #2 // class AutoCloseableResource 3: dup 4: invokespecial #3 // Method AutoCloseableResource.init:()V 7: astore_1 8: aload_1 9: invokevirtual #4 // Method AutoCloseableResource.doSomething:()V 12: aload_1 13: invokevirtual #5 // Method AutoCloseableResource.close:()V 16: goto 30 19: astore_2 20: aload_1 21: invokevirtual #5 // Method AutoCloseableResource.close:()V 24: aload_2 25: athrow ... Exception table: from to target type 8 12 19 any我解释一下。编译器自动在try块后面插入了close调用。如果正常执行完直接调用close()再goto到方法出口如果try块内出现异常就先把异常对象存到局部变量表调用close后再athrow抛出。这就是为什么你写在try-with-resources里的资源一定会被关闭不需要手写finally块。字节码层面的“自动关闭”逻辑在异常表上看得清清楚楚。4.5 演示5动态代理生成的字节码长什么样JDK动态代理的代理类是在运行期由ProxyGenerator生成的源码里看不到。想看的话可以在启动参数里加上-Djdk.proxy.ProxyGenerator.saveGeneratedFilestrue运行一次之后项目目录下会生成com/sun/proxy/$Proxy0.class然后你用javap去看javap -c -p $Proxy0.class你会看到这个类继承了java.lang.reflect.Proxy实现了你传入的接口并且所有接口方法的实现最终都调用了h.invoke(this, method, args)这个h就是你在Proxy.newProxyInstance里传入的InvocationHandler。看完这个字节码你对“动态代理到底干了什么”的认知会彻底不一样。5. 字节码结构入门读懂Class文件的骨架5.1 Class文件的结构概述看字节码不只是看指令还得知道Class文件本身的骨架。每个class文件都是按固定顺序排列的二进制结构魔数4字节→ 次版本号2字节→ 主版本号2字节→ 常量池 → 访问标志 → 类索引 → 父类索引 → 接口索引集合 → 字段表 → 方法表 → 属性表魔数固定是0xCAFEBABEJVM靠它识别这是不是有效的class文件主版本号决定这个class文件能在哪个版本的JDK上运行比如61.0对应Java 17你拿Java 17编译的class放到Java 8环境会报UnsupportedClassVersionError常量池是Class文件里最庞大的部分存了所有符号引用方法表里每个方法都带一个Code属性也就是字节码本体想知道这些信息用xxd直接看原始二进制最快xxd Simple.class | head -n 5开头几行会看到cafe babe后面的十六进制数字分别对应版本号、常量池计数等。配合javap -v一起看二进制和解析结果对得上很快就有感觉了。5.2 最常用的字节码指令速查指令太多不可能全背但常用的其实就那几十条。我按功能分类给你整理了一份速查指令功能iconst_0 / bipush / sipush / ldc把常量加载到操作数栈iload / aload / lload / fload从局部变量表加载到操作数栈istore / astore从操作数栈存入局部变量表iadd / isub / imul / idivint类型算术运算new / newarray / anewarray创建对象或数组getfield / putfield / getstatic / putstatic访问字段invokevirtual / invokestatic / invokespecial / invokeinterface / invokedynamic调用方法ifeq / ifne / if_icmpne / goto控制流跳转ireturn / areturn / return返回int/引用/void观看字节码时看到aload_0基本就是在加载this看到invokespecial通常是调用构造方法、私有方法或者super方法看到invokeinterface是调用接口方法invokedynamic则是Java 8之后lambda表达式、字符串拼接新版JDK等特性在字节码层面的体现。5.3 方法描述符的规则javap输出里大量出现类似(II)I、(Ljava/lang/String;)V这样的描述符很多人看不懂。其实规则很简单I表示intJ表示longS表示shortB表示byteC表示charF表示floatD表示doubleZ表示booleanV表示void引用类型用L开头分号结尾Ljava/lang/String;表示String数组用[开头[I表示int[]括号里是参数列表括号后是返回值所以(II)I就是“两个int参数返回int”。(Ljava/lang/String;)V就是“一个String参数无返回”。会读描述符之后你再去看框架源码里的Method对象、反射调用相关的报错会顺很多。6. 常见问题与排查技巧实录6.1 常见问题速查表我整理了一下平时大家看字节码时问得最多的问题问题原因与解决办法javap提示找不到类class文件路径不对或者文件名带后缀问题实际使用javap -p 类名不带.class后缀且需要保证class文件在当前路径看到的字节码和源码对不上没有重新编译改了Java文件后忘了javac建议先clean再build反编译后一堆$类说明有内部类、匿名类比如Outer$1.class是匿名内部类用javap -p可以看私有细节泛型信息消失了泛型在运行时会被擦除想看泛型签名需要加-s参数方法只有签名没有Code属性该方法是abstract方法或native方法没有字节码实现看字节码出现“Variable is already defined”之类的反编译错误用IDEA反编译时如果是混淆过的class直接看反编译结果可能出错建议用javap看原始字节码混淆后的class信息对应不上生产环境经常是混淆过的行号、方法名都可能被改需要对照混淆映射表6.2 怎么拿到“第一现场”的Class文件有些类的字节码不是静态编译出来的而是运行期动态生成的比如JDK动态代理类设置-Djdk.proxy.ProxyGenerator.saveGeneratedFilestrue保存到本地CGLIB代理类设置System.setProperty(DebuggingClassWriter.DEBUG_LOCATION_PROPERTY, 目录)Spring、MyBatis等框架生成的代理类可以利用Arthas的jad命令直接反编译运行中的类或通过classloader命令查看类是从哪个jar加载的如果项目已经部署到服务器上最效率的方式是在本地写一个最小复现然后通过设置系统属性或JVM参数把动态生成的类导出来再配合javap去分析。直接看远程环境里的jar包也行但要注意JDK版本差异。6.3 现场实战排查一个性能隐患有个实际场景。同事写了一段看起来正常的代码public String buildInfo(ListUser users) { String result ; for (User user : users) { result user.getName() : user.getAge() \n; } return result; }代码没问题但压测时接口耗时偏高。我们用javap看了一下编译后的字节码发现循环体内每执行一次都出现了一个新的StringBuilder而且每次循环还伴随new和invokespecial。原因就是result result xxx在循环里产生大量临时对象。解决办法很简单手动在外面创建一个StringBuilder循环里只做append。改完再看字节码new StringBuilder只剩一次。这波排查我给同事上了一课看字节码并不是为了炫技而是能直接对接性能优化。6.4 一些实用的排查习惯最后分享几个我长期积累的习惯。看一个陌生方法时别从字节码第0行直接啃到尾。先看方法描述符确认参数和返回类型再看局部变量表了解有哪些变量最后看代码指令重点看方法调用指令都invoke了谁基本就能还原这段代码做了什么。涉及try-catch-finally逻辑时优先看异常表Exception table它会告诉你哪段指令在哪个范围有异常处理器。遇到invokedynamic别慌十有八九是lambda表达式或者新版字符串拼接引入的。想看具体逻辑需要找到对应的Bootstrapped方法javap -v输出里会有BootstrapMethods属性顺着看就能找到真正执行的代码。我个人把“读懂字节码”定义为Java进阶的必修课。说真的一旦你能独立看下一段class文件哪怕只是看懂了一个add方法你对JVM执行模型的理解就已经超过大多数只背八股文的人了。字节码并不神秘它只是Java代码的“真相”。希望这篇实操笔记能帮你也迈过这个坎以后再遇到任何和“底层”相关的问题别急着背答案先编译一个class文件看看再说。
返回列表