ARTICLE DETAIL

资讯详情

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

深入理解Java类字节码:从javap反编译到JVM性能优化

深入理解Java类字节码:从javap反编译到JVM性能优化 1. 从“一次编译到处跑”说到类字节码“一次编译到处运行”这句口号Java喊了几十年。为什么Java能跨平台答案就在字节码。而“类字节码”这个说法通常是指围绕class文件生成的指令序列——JVM真正执行的那套东西。如果你看过.class文件里的内容会发现一堆aload_0、invokevirtual、ireturn这类助记符它们就是JVM的“母语”。HotSpot、OpenJ9这些虚拟机不直接认识Java源码它们只认字节码。字节码之所以值得花时间研究是因为它决定了Java程序的启动速度、内存占用、执行效率和排查问题的难易度。比如标题里提到《我的世界》Java版性能受限——很多人吐槽它吃内存、GC卡顿根子就在于JVM的运行时机制对象分配、垃圾回收、即时编译全都在字节码这个层面发生。理解了字节码你就明白为什么new一个对象不是直接分配内存那么简单为什么循环里频繁创建对象会触发GC为什么某些写法在JIT编译后性能天差地别。这篇文章适合谁如果你写过Java代码但没看过class文件长什么样如果你想搞懂javap、-XX:PrintCompilation这些工具到底在输出什么如果你在排查线上问题、优化性能时总感觉隔着一层纱那这篇文章就是给你写的。我会从字节码的基本结构讲起拆解常量池、操作数栈、局部变量表这些核心概念然后带着你实操一把——用javap反编译、手动计算栈深度、模拟一次方法调用的完整流程最后分享几个排查GC和性能问题时真实踩过的坑。2. 类字节码的整体设计与核心思路2.1 为什么选栈式指令而不是寄存器指令JVM字节码最鲜明的特征它是一种基于栈的指令集。什么意思算一个a b你不能直接写ADD R1, R2, R3这种寄存器指令而是要先把a压入栈再把b压入栈然后执行iadd从栈顶弹出两个数相加把结果再压回栈里。当年设计JVM时为什么这么干说白了是为了跨平台。寄存器指令需要明确指定CPU的寄存器数量和使用规则x86有rax、rbxARM有r0-r12各不相同。如果JVM指令集跟具体CPU绑定就不可能在所有平台上统一执行。栈式指令不关心底层寄存器只维护一个逻辑上的操作数栈JVM解释器或JIT编译器再把这个逻辑栈映射到真实寄存器上。代价就是指令条数多、解释执行慢但换来的是极端统一的规范——任何平台上的.class文件语义完全一致。这也解释了为什么JVM调优经常把“栈上分配”、“标量替换”挂在嘴边。因为字节码层面用的是栈JIT编译器才能更方便地做优化——它可以把栈上的虚拟操作映射成物理寄存器操作绕过真实的内存分配。没有栈式设计这些优化做起来会别扭得多。2.2 class文件不是二进制堆砌而是有结构的“档案”很多新手第一次用十六进制编辑器打开.class文件看到cafebabe就发懵。其实class文件格式定义得非常规整它不是一坨无结构的二进制流而是由以下部分组成魔数magic固定0xCAFEBABE用来快速识别是否class文件。版本号minor version、major version比如52.0对应Java 865.0对应Java 21。常量池constant pool一个巨大的表存放所有类名、方法名、字段名、字符串字面量、数值常量等。它是整个class文件的“字典”。访问标志access_flags标记这个类是public、final、abstract、接口等。类索引、父类索引、接口索引集合指明继承关系。字段表集合、方法表集合描述字段和方法最大的特点是不包含方法体。属性表集合这才是核心Code属性里就藏着方法对应的字节码指令序列。这个设计的核心思路就是“符号引用与常量池分离”。所有对类、方法、字段的引用在class文件里不直接写内存地址或偏移量而是记录一个常量池索引。真正解析成运行时内存地址是类加载阶段做的事情。好处很明显.class文件可以在不重新编译的情况下被不同JVM加载坏处是第一次加载类时有额外的解析开销这也是为什么很多低延迟应用会用-XX:AlwaysPreTouch或CDSClass Data Sharing来“预热”。2.3 字节码执行时的那几个关键运行时区字节码不是直接操作对象的它要借助JVM的运行时数据区。理解这几个区你才能看懂字节码为什么那样写局部变量表Local Variable Table方法内的参数和局部变量都存在这里。注意它使用数组索引this在非静态方法里占用索引0第一个参数是索引1以此类推。long和double占两个槽位其他类型占一个。这解释了为什么方法参数越多、局部变量越多栈帧就越大。操作数栈Operand Stack字节码指令执行时的“草稿纸”。压入、弹出、复制、交换全在这里发生。每个栈帧有操作数栈的最大深度计算深度是JVM规范给的强制要求——ClassFormatError就是深度算错了导致的。帧数据Frame Data包括指向常量池的引用、异常处理表、方法返回地址等辅助指令跳转和异常抛出。日常你遇到的StackOverflowError本质就是方法调用链太深、栈帧太多把线程栈耗尽了。而OutOfMemoryError出现在堆上则是对象太多。这两类问题在字节码层面看到的现象完全不同后面我会结合实例讲。3. 核心机制拆解从javap反编译到一条指令的完整旅程3.1 用javap打开class文件的“黑箱”javap是JDK自带的class文件反汇编工具。它不还原Java源码但能把class文件变成人类可读的格式。用法很简单javap -v YourClass.class-vverbose是最常用的参数它会把类的前置信息、常量池、每个方法的字节码指令全部输出。如果只看方法签名和公共信息用javap -public就够。拿一个最简单的类做例子public class Hello { public static int add(int a, int b) { return a b; } }执行javap -v Hello.class后add方法对应的字节码是这样的我略去了常量池部分public static int add(int, int); descriptor: (II)I flags: ACC_PUBLIC, ACC_STATIC Code: stack2, locals2, args_size2 0: iload_0 1: iload_1 2: iadd 3: ireturn这段输出看起来简单但信息量很大。stack2说明这个方法最大操作数栈深度是2locals2说明局部变量表有2个槽位。iload_0把索引0的int型变量压入操作数栈iload_1把索引1的int型变量压栈iadd弹出栈顶两个int相加并压回结果ireturn把栈顶int值返回给调用者。这个流程一共4条指令每条指令前的数字是它在字节码里的偏移量PC值。3.2 操作数栈真的有“深度”限制很多人忽略stack2这一行但它非常关键。JVM规范要求每个方法最大操作数栈深度不能超过65535编译器在生成class文件时必须精确计算。如果人为修改字节码、把栈深度改小JVM在类加载校验阶段会直接抛出ClassFormatError根本不给执行的机会。为什么必须有这个深度因为JVM是解释执行和JIT执行混合的。解释器需要根据栈深度预先分配栈帧空间如果深度不确定栈帧大小就无法固定。而且深度也用于安全校验——防止字节码通过非法栈操作绕过类型检查。可以说操作数栈深度是字节码静态校验的一个核心约束。实操中如果你想手写字节码或者用ASM框架生成字节码算深度是最容易出错的地方。举个真实例子用ASM生成代码时visitMaxs(maxStack, maxLocals)这两个参数如果填错了运行时就会报ClassFormatError。我见过不少同事在生成代理类时踩这个坑排查方式就是用javap反编译看栈深度然后手动推演一遍指令执行前后栈的变化。3.3 模拟一次方法调用的完整执行流程光看指令不够我带着你走一遍方法调用的完整流程。以ClassA.methodB()为例调用方发出一条invokevirtual指令然后调用方方法的操作数栈上已经压入了ClassA对象引用和方法参数。JVM根据invokevirtual指向的常量池条目找到方法所属的类和方法描述符。在调用方栈帧中创建被调用方的栈帧——分配局部变量表空间、操作数栈空间、帧数据。把调用方的对象引用和方法实参复制到被调用方的局部变量表中。程序计数器PC跳转到被调用方方法体字节码的起始位置。被调用方开始逐条执行字节码直到遇到return系列指令。返回时返回值被压入调用方操作数栈被调用方栈帧弹出调用方继续执行下一条指令。这个过程看起来顺利但有个性能陷阱每次方法调用都要创建栈帧、复制参数这比直接执行几条本地指令开销大得多。所以JVM做了两个优化——栈上替换OSR和逃逸分析。如果方法很小且无逃逸对象JIT编译器可能直接把被调用方的方法体内联到调用方抹掉栈帧创建的全部开销。这也是为什么故意把代码拆成无数小方法不一定会变慢的原因——前提是你的JIT编译器足够聪明。3.4 常量池class文件的“字典”字节码指令里大量使用#数字这样的索引指向常量池。例如0: ldc #3 // String helloldc指令把常量池中第3项的值压入操作数栈。#3可能是CONSTANT_String_info也可能是CONSTANT_Class_info等。javap -v会自动帮你把索引翻译成可读文本但如果你用十六进制编辑器看class文件就得自己解码了。常量池的类型有十几种常见的有CONSTANT_Utf8_info字符串包括类名、方法名、字段名等所有符号信息最终都指向它。CONSTANT_Integer_info、CONSTANT_Float_info、CONSTANT_Long_info、CONSTANT_Double_info字面量数字。CONSTANT_String_infoString对象的字面量引用真正内容还是指向Utf8。CONSTANT_Class_info类或接口的符号引用。CONSTANT_Methodref_info、CONSTANT_Fieldref_info、CONSTANT_InterfaceMethodref_info方法引用、字段引用、接口方法引用。理解常量池对排查一个经典问题很有帮助字符串字面量为什么要用CONSTANT_String_info而不是直接嵌在指令里因为class文件要紧凑、支持跨类共享。多个方法引用同一个字符串常量池里只有一份JVM加载类时ldc指令执行时会把常量池里的字符串对象缓存到字符串常量池里。这也是为什么hello hello通常为true——它们指向同一个内部化后的对象。4. 实操反编译一段真实代码看看字节码怎么对应Java逻辑4.1 一个包含循环和对象创建的示例理论说再多不如动手看。我写一段略微真实的代码包含对象创建、循环和条件判断public class Calc { public static long sum(int[] arr) { long total 0L; for (int i 0; i arr.length; i) { if (arr[i] 0) { total arr[i]; } } return total; } }用javap -c -p Calc.class只输出字节码-p显示私有成员重点看sum方法public static long sum(int[]); Code: stack4, locals4, args_size1 0: lconst_0 1: lstore_0 2: iconst_0 3: istore_2 4: iload_2 5: arraylength 6: if_icmpge 42 9: aload_0 10: iload_2 11: iaload 12: ifle 32 15: lload_0 16: aload_0 17: iload_2 18: iaload 19: i2l 20: ladd 21: lstore_0 22: iinc 2, 1 25: goto 4 32: iinc 2, 1 35: goto 4 42: lload_0 43: lreturn不看Java源码你能读懂它吗我来逐段拆解lconst_0把0压入栈long型0lstore_0把它存到局部变量表索引0这就是long total 0L。iconst_0和istore_2对应int i 0注意this不占索引静态方法索引0是total索引1是arr索引2是i索引3是临时变量arr[i]。iload_2到if_icmpge 42是循环条件判断。iload_2压入iarraylength取出数组长度if_icmpge比较如果i 数组长度就跳转到偏移42——也就是退出循环。偏移9到21是循环体。aload_0压入数组引用iload_2压入iiaload从数组中取出arr[i]并压入栈。ifle 32表示如果该值小于等于0就跳转到偏移32处的i。如果大于0偏移15-21连续执行lload_0取total再次aload_0、iload_2、iaload取arr[i]i2l把int转成longladd做long加法lstore_0写回total。偏移22处的iinc 2, 1是给i加1goto 4跳回循环条件判断。注意偏移32处还有一个iinc 2, 1——这是arr[i] 0时的分支它也把i加1再跳回。这里编译器生成了一条重复的递增加载指令而不是统一的循环步进代码这是javac的常规做法。这段字节码很典型同样的arr[i]在判断和累加时各自取了一遍。你可能会觉得浪费但JIT编译器会在运行时做公共子表达式消除CSE把重复的数组访问优化掉。所以说字节码不等于最终机器码javac只做简单翻译真正的高性能优化主要靠JIT。4.2 字节码层面的性能隐患从这条代码里你能看出什么这段代码在字节码层面有一个值得注意的点每次循环迭代total都会被lstore_0写回局部变量表。局部变量表在内存里频繁读写肯定比寄存器慢。JIT编译器会自动把total提升到寄存器消除冗余读写。但如果你是解释模式运行-Xint这些开销就实打实存在了。另一个隐患是循环内的数组边界检查。字节码里没有显式的边界检查指令——JVM隐式地在每次iaload执行时进行数组越界检查如果越界就抛ArrayIndexOutOfBoundsException。JIT编译器会通过循环分析消除部分检查这就是著名的“边界检查消除”。所有JVM都做这个优化但前提是你的循环模式足够典型。如果你在循环里写得很诡异或者数组引用每次都在变优化可能就失效了。这也是为什么我在性能调优时会要求团队成员先看-XX:PrintAssembly输出而不是凭感觉乱改代码。4.3 手动计算操作数栈深度验证javap输出既然前面说过栈深度是硬约束这里我带你手工验证一次。以偏移9-21这段为例aload_0栈从0变为1压入数组引用iload_2栈从1变为2压入iiaload弹出两个操作数数组引用索引压入一个结果arr[i]栈从2变为1。ifle 32弹出一个操作数栈从1变为0。lload_0long型占两个槽位栈从0变为2。aload_02 - 3iload_23 - 4iaload4 - 3弹出2压入1i2l弹出1个int压入2个槽位的long3 - 4。ladd弹出两个long4个槽位压入一个long2个槽位4 - 2。lstore_0弹出一个long2个槽位2 - 0。整个过程中最大栈深度出现在i2l执行后达到了4。而javap输出首行就是stack4完全对得上。这个过程用循环判断长度时也一样arraylength不弹栈、压入数组长度栈深度最多2。所以stack4是正确的。这个验证过程看起来繁琐但如果你之后用ASM写字节码生成器就必须在脑海或纸面上做同样的计算。很多生成类运行时崩溃就是因为visitMaxs里给的maxStack比实际需要小。老老实实按上面步骤算一遍能省下大量排查时间。4.4 使用ASM框架动态生成字节码的实际体验除了反编译主动生成字节码也是进阶需求。比如写一个动态代理或者用ByteBuddy做AOP增强。ASM是底层事实标准Hibernate、Spring、CGLIB都在用它。一个典型的生成加法方法的代码长这样ClassWriter cw new ClassWriter(ClassWriter.COMPUTE_MAXS); cw.visit(V11, ACC_PUBLIC, GeneratedClass, null, java/lang/Object, null); MethodVisitor mv cw.visitMethod(ACC_PUBLIC | ACC_STATIC, add, (II)I, null, null); mv.visitCode(); mv.visitVarInsn(ILOAD, 0); mv.visitVarInsn(ILOAD, 1); mv.visitInsn(IADD); mv.visitInsn(IRETURN); mv.visitMaxs(0, 0); // 使用COMPUTE_MAXS会自动计算 mv.visitEnd(); cw.visitEnd();注意ClassWriter.COMPUTE_MAXS标志——它让ASM自动帮你计算最大栈深度和局部变量表大小。我强烈建议在大多数场景下使用这个模式除非你有特殊需求需要手控。COMPUTE_MAXS不负责计算帧数据比如跳转目标的栈映射帧如果你有分支跳转还需要COMPUTE_FRAMES。框架生成的类如果运行时被JVM拒绝十有八九是这两项没设置对。5. 字节码如何影响垃圾回收和《我的世界》这类大型游戏5.1 对象分配字节码与GC的关联标题里提到《我的世界》Java版在JVM上运行、存在GC卡顿这个现象特别适合用字节码运行时机制来解释。看这段代码的字节码public class Entity { public void tick() { Position pos new Position(1, 2, 3); // ... 使用pos } }对应字节码片段大概是new #2 // class Position dup iconst_1 iconst_2 iconst_3 invokespecial #3 // Method init astore_1new指令在Java堆上分配一块对象内存返回引用压入栈。dup复制引用——因为调用构造器时既需要this引用调用init方法又需要引用保存到局部变量。invokespecial执行构造器完成后栈顶被astore_1弹出并存入局部变量表。问题在于new一个对象必然涉及堆分配。如果tick()每帧调用几百次每次都创建PositionGC压力立刻上来。《我的世界》的卡顿很大一部分就来自这类高频小对象分配。虽然JVM有TLABThread-Local Allocation Buffer来让每个线程在Eden区快速分配对象不用全局锁但分配速度再快对象总量大起来Young GC仍然频繁发生。你经常在启动参数里看到-XX:UseG1GC、-XX:MaxGCPauseMillis50就是为了把GC停顿控制在几毫秒内避免肉眼可见的卡顿。5.2 从字节码层面看逃逸分析与栈上分配既然高频对象分配是痛点JVM怎么优化答案是逃逸分析。JIT编译器分析Position对象的作用域发现它没有“逃逸”出方法——没有作为参数传给别的方法、没有赋值给外部字段、没有返回给调用者。于是它会把这一连串字节码直接优化掉将对象的字段拆解为局部变量和寄存器标量替换压根不在堆上分配对象。所以同样的字节码解释执行时在堆上分配JIT编译后可能完全变成栈上标量GC根本感知不到。这就是为什么-Xint解释模式下运行《我的世界》会卡得离谱而JIT开启后流畅度大幅提升。HotSpot默认开启逃逸分析JDK 8以后但它是通过分层编译自动触发的不一定马上生效。如果你在优化这类高频创建对象的代码我的建议是先别手动改成复用对象对象池反而可能带来线程竞争和内存驻留问题而是让JIT的逃逸分析正常运作。前提是代码要满足逃逸分析的约束——不要把一个新对象建模成类字段、不要丢进集合里再拿出来。先写干净的代码再靠JVM优化是更稳妥的路。5.3 类加载机制与启动延迟的关系《我的世界》还有一个痛点启动慢。这跟字节码的类加载机制强相关。Java程序启动时要扫描、加载、链接、初始化大量类。每个.class文件都要经过“加载→验证→准备→解析→初始化”这个过程解析时要把常量池里的符号引用替换为直接引用这涉及大量I/O和CPU。为了加速启动JDK 9开始支持应用类数据共享AppCDS把类的元数据和字节码提前转换成一份归档文件.jsa下次启动时直接映射进内存省掉类加载和验证的大量开销。我建议所有追求启动速度的Java应用都考虑开CDS。另外你可以在启动参数里加-XX:TieredCompilation -XX:TieredStopAtLevel1来跳过C2编译牺牲峰值性能换取更快启动。对于游戏客户端这种场景启动速度和前几分钟的响应比极致的峰值吞吐重要得多这个取舍值得做。6. 常见问题与排查技巧实录6.1 如何判断程序是在解释执行还是JIT编译线上排查时经常需要确认某段热点代码是否被JIT编译了。最粗暴的方法是加-Xint但那个性能断崖谁都受不了。更好的方法是用-XX:PrintCompilation启动JVM会在编译方法时打印一行日志包含方法名、字节码大小、编译层级。例如180 4 java.util.HashMap::putVal (222 bytes)输出表示该方法的字节码大小、编译层级和触发时机。如果你发现某个热点方法一直没出现在编译日志里说明它没有被JIT优化性能瓶颈大概率在这。更细粒度的分析可以用-XX:UnlockDiagnosticVMOptions -XX:PrintAssembly输出JIT生成的汇编代码。这个输出量很大建议配合fgrep过滤指定方法。注意PrintAssembly需要JIT编译器内部接口在有些JDK发行版里可能不可用或者需要安装hsdis-amd64.so插件。我实际用下来效果最佳的是先用PrintCompilation缩小范围再用PrintAssembly看关键方法的局部变量和栈刷新情况。6.2 遇到ClassFormatError或VerifyError怎么排查ClassFormatError通常意味着class文件结构损坏比如常量池类型不对、栈深度计算错误、字段描述符非法。VerifyError则是字节码校验失败说明编译后的代码违反了类型安全或栈不变式。这两类错误在自研字节码生成器、热部署补丁、反混淆工具输出中最常出现。排查步骤我总结了四条用javap -v看class文件的常量池和Code属性对照规范检查每一项。重点查visitMaxs中stack和locals是否足够。用-Xverify:all强制JVM全量校验这样可以尽早暴露问题同时看完整错误信息和异常栈。如果是ASM生成的类把ClassWriter.COMPUTE_FRAMES打开让ASM自动生成StackMapTable。很多VerifyError就是因为StackMapTable缺失或错误。用Java Agent写出class文件转储下来对比修改前后的差异。推荐用-Dnet.bytebuddy.experimentaltrue配合ByteBuddy的TypeDump或者简单点用System.getProperty(java.class.path)加载到自定义ClassLoader的findClass里打印字节码。我遇到过一个很典型的案例某个代理库在JDK 11下正常到JDK 17就报VerifyError。原因是在新版本的类校验中对StackMapTable的要求变严格了。后来在ASM中设了COMPUTE_FRAMES问题消失。所以记住任何生成字节码的工具都要优先考虑帧数据是否合规。6.3 GC卡顿如何通过字节码定位问题热点很多人问GC卡顿怎么排查我一直建议先看字节码再看GC日志最后结合火焰图。原因是字节码能告诉你“对象从哪来”GC日志告诉你“垃圾有多少”火焰图告诉你“谁占用了CPU”。具体步骤先jcmd pid GC.class_histogram输出各类实例数量和大小找出数量最多的类。然后jmap -dump:live,formatb抓堆快照用MAT或JProfiler分析这些对象的创建堆栈。拿到创建堆栈后反编译对应方法的字节码查看是否存在循环内大对象、不必要的装箱、字符串拼接等常见陷阱。有一个我经常强调的陷阱自动装箱。Java里Integer i 1看着简单字节码层面是Integer.valueOf(1)这方法内部有缓存逻辑但超过缓存范围-XX:AutoBoxCacheMax参数默认128就会新建对象。循环里大量发生装箱Young GC的频率立刻飙升。用javap -c查看循环部分的字节码如果看到valueOf调用就说明有装箱。6.4 常见的字节码相关性能优化速查表优化方向字节码层面表现建议方案大量临时对象频繁出现newdup依赖逃逸分析避免对象逃逸或延迟初始化字符串拼接循环中出现StringBuilder的new/append/toString确认JDK 9字符串拼接是否用了invokedynamic避免显式拼接大循环方法调用过深大量invokevirtual/invokespecial减少代理层JIT内联失败时手动内联数组访问iaload/aaload频繁改进循环结构帮助边界检查消除装箱拆箱valueOf/intValue使用基本类型或扩大缓存异常路径athrow附近缺少返回分支避免用异常控制流程这张表不是让你背的而是排查问题时的思路引导。我一般先跑一轮perf或async-profiler看热点方法再反编译热点方法的字节码对照上表找模式。找到模式后修改代码、重新部署、再跑一轮持续验证比盲目改代码靠谱得多。7. 再往深走从字节码到运行时你可以继续拓展的方向把字节码钻研到这个程度之后回头看《我的世界》性能受限这件事你会看得更透。它的卡顿不只是“JVM垃圾回收”一个因素而是类加载、解释执行、JIT编译、GC、对象分配、甚至线程同步共同作用的结果。每一条都对应着字节码层面的某种模式。你掌握了从字节码层面思考问题的习惯就能在优化时直击要害。我给你一个具体的后续练习方向打开JDK源码找到java.lang.String类的hashCode方法用javap -c看看它的字节码。你会看到循环、乘法、数组访问交织在一起。然后尝试修改实现改成缓存hash对比修改前后字节码的差异再运行JMH基准测试。这个练习能让你把字节码、JIT、GC三者的互动关系彻底打通。另一个值得深入研究的方向是invokedynamic。它在Lambda表达式、字符串拼接JDK 9和动态代理中大量出现。invokedynamic把方法调用决策延迟到运行时由BootstrapMethod解析这在字节码层面打开了一扇新的大门。理解了它你就知道为什么Lambda表达式可以比匿名内部类性能更好——因为不需要编译期生成单独的类文件。最后说说调试工具。除了javap你还应该掌握javac -g生成调试信息、javap -l查看LineNumberTable和LocalVariableTable、-XX:TraceClassLoading跟踪类加载、-XX:PrintGCDetails查看GC细节。工具不在多在于你什么时候用什么、能看到什么。真正排查线上问题的时候这一整套组合拳能救你很多次。我个人的体会是字节码学习不要停留在背指令表。你不需要记住所有助记符——等你真的用ASM框架时查文档就行。你要记住的是指令背后的逻辑数据怎么流动、栈帧怎么创建、类型怎么校验、异常怎么处理、引用怎么解析。理解了这些JVM对你就不再是一团黑盒而是一套有章可循的执行引擎。下一次你遇到性能问题也不会再盲目地调堆大小、改GC器而是会先问一句让我看看字节码到底在执行什么。
返回列表