从字节码来看线程是如何调用方法的一、概念解析Java 方法调用的核心不只是“找到方法然后执行”。从 JVM 角度看一个线程执行方法时会涉及三个重要部分线程私有的虚拟机栈 线程共享的方法区 方法区中的运行时常量池线程私有的意思是每个线程都有自己独立的一份运行空间。线程之间不会共用虚拟机栈。线程私有区域主要包括程序计数器 虚拟机栈 本地方法栈其中Java 方法调用最重要的是虚拟机栈。每当线程调用一个 Java 方法JVM 都会在当前线程的虚拟机栈中创建一个栈帧。方法开始执行栈帧入栈方法执行结束栈帧出栈。一个栈帧主要包含局部变量表 操作数栈 动态链接 方法返回地址局部变量表用来保存方法参数、局部变量、this引用等。例如FatherfnewSon();这里的f会保存在局部变量表中。它是一个对象引用指向堆中的Son对象。操作数栈用于字节码指令执行时临时保存数据。例如执行方法调用前JVM 会先把调用方法的对象引用压入操作数栈。重点是动态链接。很多人容易误解动态链接以为它连接的是某个对象。其实不是。动态链接保存的是指向当前方法所属类的运行时常量池的引用也就是说动态链接不是连接堆中的实例对象而是连接当前方法所在类的运行时常量池。例如classTest{voidtest(){FatherfnewSon();f.hello();}}当线程执行Test.test()方法时会创建test()方法对应的栈帧。这个栈帧中的动态链接指向Test 类的运行时常量池为什么要指向运行时常量池因为字节码中很多指令不会直接写完整的方法信息而是使用常量池索引。例如invokevirtual #5这里的#5不是方法地址也不是对象引用而是一个索引。它的意思是去当前类的运行时常量池中找第 5 项那么 JVM 怎么知道“当前类的运行时常量池”在哪里靠的就是当前栈帧里的动态链接。方法区是线程共享的区域主要存放类相关的信息例如类的基本信息 字段信息 方法信息 方法字节码 运行时常量池 即时编译后的代码当.class文件被加载进 JVM 后类的结构信息会进入方法区。其中运行时常量池非常重要。.class文件中本来就有常量池类加载后这份常量池会变成 JVM 运行时可以使用的运行时常量池。运行时常量池里不只是普通常量还保存大量符号引用例如类的符号引用 字段的符号引用 方法的符号引用 接口方法的符号引用 方法描述符 字段描述符比如classTest{voidtest(){FatherfnewSon();f.hello();}}编译后Test.class的常量池中可能有类似内容#5 Methodref Father.hello:()V这里要分清三个概念#5 字节码里的常量池索引 Methodref Father.hello:()V 运行时常量池中的方法符号引用 直接引用 符号引用解析后得到的、JVM 可以用来定位方法的内部引用符号引用只是名字描述。例如类名Father 方法名hello 参数无 返回值void它还不是方法的真实入口也不是对象地址。直接引用也不是对象引用。这是最容易混淆的地方对象引用 存在于局部变量表、操作数栈、对象字段中 用于指向堆中的具体对象 直接引用 由运行时常量池中的符号引用解析而来 用于定位类、字段、方法等结构信息所以Father f new Son();这里的f是对象引用指向堆中的Son对象。而invokevirtual #5这里的#5对应的是方法符号引用解析后得到的是方法相关的直接引用不是Son对象。二、运行过程原理解析现在把整个方法调用过程串起来。示例代码classFather{voidhello(){System.out.println(Father);}}classSonextendsFather{voidhello(){System.out.println(Son);}}classTest{voidtest(){FatherfnewSon();f.hello();}}当线程执行test()方法时会在自己的虚拟机栈中创建一个Test.test()栈帧。这个栈帧大概包含Test.test() 栈帧 ├─ 局部变量表 │ └─ f → 堆中的 Son 对象 │ ├─ 操作数栈 │ └─ 方法调用前会压入 f 这个对象引用 │ ├─ 动态链接 │ └─ 指向 Test 类的运行时常量池 │ └─ 方法返回地址当执行到f.hello();对应的字节码大致可以理解为aload_1 invokevirtual #5其中aload_1表示把局部变量表中第 1 个位置的变量加载到操作数栈。也就是把f这个对象引用压入操作数栈。此时操作数栈中保存的是f → 堆中的 Son 对象接着执行invokevirtual #5这条指令表示调用普通实例方法。这里会发生几件事。第一JVM 读取#5。#5是常量池索引不是方法本身。第二JVM 通过当前栈帧中的动态链接找到Test类的运行时常量池。第三JVM 根据#5找到运行时常量池中的方法符号引用。可能是#5 Methodref Father.hello:()V这表示从编译期类型来看要调用的是Father类中的hello()方法。第四如果这个符号引用还没有解析JVM 会进行解析。解析过程大致是确认 Father 类是否已经加载 如果没有加载先加载 Father 类 在 Father 的类元数据中查找 hello() 方法 检查访问权限 检查方法签名 得到 Father.hello() 相关的直接引用 缓存解析结果这里的直接引用不是指向某个Father对象也不是指向某个Son对象。它只是让 JVM 能够定位到Father.hello() 这个方法相关的信息也可以理解为JVM 先确定“这条调用指令要调用的是 hello 这个方法族”。但是对于invokevirtual来说到这里还没结束。因为普通实例方法可能被子类重写。所以 JVM 还要做动态分派。动态分派的关键是看操作数栈上的对象引用到底指向哪个真实对象前面aload_1已经把f压入操作数栈。而f实际指向的是堆中的 Son 对象所以执行invokevirtual #5时JVM 会从操作数栈中取出这个对象引用然后根据这个对象引用找到堆中的对象再根据对象头或相关结构找到它的真实类型。这里真实类型是Son然后 JVM 去Son的方法信息中查找hello()。发现Son重写了Father.hello()所以最终执行的是Son.hello()因此普通实例方法调用可以拆成两步第一步常量池解析 根据 #5 找到 Methodref Father.hello:()V 把符号引用解析成 Father.hello() 相关的直接引用 第二步动态分派 根据操作数栈上的对象引用找到真实对象 根据真实对象的类型 Son 最终执行 Son.hello()注意类加载或首次使用时确实会把符号引用解析成直接引用。 但是这个直接引用不是堆中实例对象的引用。 动态链接也不是连接实例对象。 实例对象的引用来自局部变量表和操作数栈。再看多个对象的情况Fatherf1newFather();Fatherf2newSon();f1.hello();f2.hello();两次调用的字节码中可能都使用同一个常量池索引invokevirtual #5#5可能都表示Methodref Father.hello:()V但是两次调用前压入操作数栈的对象引用不同。执行f1.hello();时aload_1 ↓ 把 f1 压入操作数栈 ↓ f1 指向 Father 对象 ↓ invokevirtual #5 ↓ 最终执行 Father.hello()执行f2.hello();时aload_2 ↓ 把 f2 压入操作数栈 ↓ f2 指向 Son 对象 ↓ invokevirtual #5 ↓ 最终执行 Son.hello()所以同一个#5不会把方法调用永久固定到某一个对象上。#5解决的是要调用哪个方法名、参数、返回值操作数栈上的对象引用解决的是这一次调用的是哪个对象对象的真实类型解决的是最终执行哪个类中的方法版本再补充类加载机制。类加载过程大致包括加载 验证 准备 解析 初始化其中“解析”就是把运行时常量池中的符号引用转换成直接引用。不过解析不一定在类加载时一次性全部完成。很多 JVM 会采用延迟解析也就是第一次真正使用某个类、字段、方法时才进行解析例如第一次执行invokevirtual #5如果#5对应的方法符号引用还没有解析就会触发解析。解析完成后JVM 会缓存解析结果。下次再执行同一条调用指令时就不需要重复完整解析流程。但是注意动态链接本身一般不变动态链接还是指向当前方法所属类的运行时常量池。变化的是运行时常量池相关结构中某个符号引用对应的解析状态和缓存结果。可以这样看第一次调用前 Test.test() 栈帧 动态链接 → Test 类运行时常量池 Test 类运行时常量池 #5 Methodref Father.hello:()V第一次调用时invokevirtual #5 ↓ 通过动态链接找到 Test 类运行时常量池 ↓ 根据 #5 找到 Methodref Father.hello:()V ↓ 解析符号引用 ↓ 得到 Father.hello() 相关的直接引用 ↓ 缓存解析结果 ↓ 再根据操作数栈中的 f 引用判断真实类型 ↓ 真实类型是 Son ↓ 执行 Son.hello()后续再执行时invokevirtual #5 ↓ 直接使用缓存的解析结果 ↓ 仍然要看本次操作数栈上的对象引用 ↓ 根据对象真实类型决定最终方法所以解析结果可以缓存但每次调用时的对象引用仍然来自当前线程当前栈帧的操作数栈。三、总结从字节码角度看线程调用方法的完整链路可以总结为线程执行 Java 方法 ↓ 在线程私有的虚拟机栈中创建栈帧 ↓ 栈帧中的动态链接指向当前方法所属类的运行时常量池 ↓ 执行到方法调用字节码例如 invokevirtual #5 ↓ #5 是常量池索引 ↓ JVM 通过动态链接找到当前类的运行时常量池 ↓ JVM 通过 #5 找到方法符号引用 ↓ 如果符号引用还没有解析就触发解析 ↓ 解析成方法相关的直接引用并缓存 ↓ invokevirtual 从操作数栈中取出对象引用 ↓ 根据对象引用找到堆中的真实对象 ↓ 根据真实对象的类型进行动态分派 ↓ 确定最终执行的方法版本 ↓ 为被调用方法创建新的栈帧 ↓ 新方法开始执行最重要的区别是动态链接 在栈帧中指向当前方法所属类的运行时常量池。 #5 在字节码指令中是常量池索引。 方法符号引用 在运行时常量池中例如 Methodref Father.hello:()V。 直接引用 符号引用解析后的结果用来定位类、字段、方法等结构信息。 对象引用 在局部变量表或操作数栈中用来指向堆中的具体对象。概念混淆动态链接不是动态链接 → 堆中的某个对象而是动态链接 → 当前类的运行时常量池直接引用不是直接引用 → 堆中的某个实例对象而是直接引用 → 类元数据 / 方法元数据 / 字段位置 / 方法表位置等对象引用才是对象引用 → 堆中的具体对象对于普通实例方法调用尤其是invokevirtual最终方法由两部分共同决定常量池中的方法符号引用 决定要调用哪个方法名、参数和返回值。 操作数栈上的对象引用 决定这一次调用发生在哪个对象身上。对象引用指向的真实对象类型决定最终执行哪个版本的方法。因此Father f new Son(); f.hello();虽然常量池里可能记录的是Methodref Father.hello:()V但运行时操作数栈中的f指向的是Son对象所以最终执行Son.hello()最简洁的结论是线程调用方法时栈帧中的动态链接负责找到当前类的运行时常量池字节码中的常量池索引负责找到方法符号引用符号引用解析后得到方法相关的直接引用而真正调用哪个对象的方法要看操作数栈上的对象引用以及对象的真实类型。