Scala 28:擦除、装箱与字节码
三段源码都返回整数,JVM 接口却不同
泛型恒等函数接收整数并返回整数,普通方法也接收整数并返回整数,闭包还可以捕获一个整数再执行加法。只看 Scala 调用结果,三者都很简单;如果要解释装箱、桥接方法或闭包表示,就必须查看实际 class 文件。
1 | |
本章在 Scala 3.3.7、Scala CLI 1.9.1、JDK 21.0.11 上编译这些方法,并保存 javap -c -p -s -v 原始输出。正例断言 identity(7) == 7、increment(7) == 8 和 addCaptured(5)(3) == 8。三个值正确只是起点,字节码才能回答它们用什么描述符、调用了哪些转换方法。
这组实验不直接测量性能。字节码里出现装箱调用,不代表每次热执行都发生新的堆分配;没有显式 new 指令,也不代表整个执行路径没有对象。类加载、库实现、整数缓存和 JIT 优化都会影响运行成本。这里先建立从源类型到 JVM 表示的可检查对应关系。
完整源码在 examples/scala-lab/snippets/28/Chapter28.scala,拒绝样例在 negative/28/。泛型数组缺少 ClassTag 与不可靠泛型类型测试分别编译验证,避免把两个不同问题都归结为一句“泛型被擦除了”。
描述符说明 JVM 怎样调用方法
生成产物中,泛型恒等函数保留一个供工具阅读的泛型签名,但实际 JVM 方法描述符是:
1 | |
参数和返回值在该描述符中都是引用类型。相比之下,increment 的描述符为 (I)I,I 表示 JVM 整数。它的指令可以直接加载整数、执行加法、返回整数,不必为了该方法参数本身改成对象引用。
这里要同时看源码签名、泛型 Signature 属性与 JVM 描述符。它们承担不同工作。工具仍可能从元信息了解声明中的泛型参数,但方法链接和字节码调用使用 JVM 能表达的类型。说“擦除后所有类型信息都不存在”过于绝对;说“class 文件还保留泛型签名,所以 JVM 能完整检查每个容器元素类型”同样不成立。
本章调用 identity(7) 的字节码中出现 BoxesRunTime.boxToInteger,随后调用以 Object 为参数与结果的恒等方法,再使用 unboxToInt 恢复整数。这个观测限定在当前调用点与当前编译器,没有推导出所有泛型函数必然具有完全相同的指令序列。
increment(7) 则通过原始整数描述符调用。两者都满足 Scala 层面的返回类型要求,但适配路线不同。类型系统能够让调用者写出统一的泛型代码,后端仍需把这种抽象映射到 JVM 可执行表示,装箱就是可能出现的适配操作之一。
阅读产物时应定位方法体,而不只搜索常量池。常量池中出现某个名字,说明类文件引用了相关符号;要证明某个调用点确实执行相应转换,还应找到 Code 区域中的调用指令。本章同时保存完整输出与方法位置,不把一次字符串命中当成全部证明。
装箱调用不等于一次新对象分配
boxToInteger 的存在足以说明生成代码在这个边界需要对象形式,但不能单凭名字统计分配次数。整数包装可能使用缓存,JIT 也可能消除部分对象或调用开销。是否真的分配、何时分配、逃逸到哪里,需要运行时证据。
这一区别关系到性能结论的可靠性。如果看到一个装箱调用就声称“每个元素必分配一个对象”,可能把缓存或优化后的情况描述错;如果看到一个短小泛型方法就声称“抽象完全零成本”,也可能忽略无法消除的对象边界。两种绝对化都没有由当前实验支持。
合理的研究顺序是先用字节码提出假设,再设计同结果基准,记录预热、输入范围和分配指标。例如把整数输入从缓存范围内改到更大范围,可能影响包装行为;让结果逃逸到集合,也可能改变 JIT 能否消除对象。具体影响要由实验回答,而不是由语法风格决定。
本章没有执行这种性能基准,所以只报告生成表示。后续性能篇会使用 JMH,多 fork、多轮运行,并把诊断与计时分开。保留这条界线能避免把“源码能看出一个可能成本”夸大为“线上已经测得这个成本”。
装箱还与 API 边界有关。返回 Any、通用集合元素、Java Object 参数、泛型接口调用,都可能需要引用表示;保留具体 Int 参数则给后端更多直接使用原始值的空间。但选择业务 API 时还要考虑可组合性与约束,不应为了减少一个未经测量的转换而破坏领域模型。
桥接方法维持擦除后的覆盖关系
考虑一个泛型生产者接口:
1 | |
在 Scala 源码中,TextProducer.get 返回字符串,符合 Producer[String] 的契约。擦除后,泛型接口的方法需要以对象形式返回;具体类的方法却可以保留字符串描述符。为了让通过泛型接口进行的调用仍正确分派,编译器生成桥接方法。
实际 javap 输出包含两个 get:
1 | |
第二个方法调用第一个并返回结果。它不是程序员手写的第二套业务实现,也不是意外重载。桥接把擦除接口所需的形状与具体实现连接起来。本章正例通过 Producer[String] 引用调用 get 并断言结果为 text,同时检查类文件中的桥接标志和转发指令。
这类产物对反射工具有影响。枚举一个类的所有方法时,可能同时看到源方法与桥接方法。若工具按方法数量推断业务 API,或不检查 synthetic/bridge 标志就执行重复处理,容易把编译器生成成员当成独立用户声明。框架如何过滤要查它自身规则,不能假设所有工具都会自动处理。
桥接也说明源码方法列表不能直接等同于二进制接口。修改返回类型、父接口或泛型边界,可能影响生成描述符与适配成员。源代码在重新编译后通过,并不证明旧客户端不重编译也能加载新产物。二进制兼容要按真实依赖与 class 文件边界验证。
本章没有反编译出伪 Java 再当作真实源码。javap 输出是描述符、标志和指令视角,已经足以支持桥接结论。反编译器为了生成可读 Java 可能重建某些结构,适合辅助理解,但不能替代原始指令证据。
捕获整数的闭包如何出现在产物里
addCaptured(offset) 返回一个函数值。运行时先提供 offset = 5,随后对返回函数传入 3,得到 8。参数 offset 需要在第一次调用返回后仍能参与第二次运算,因此闭包必须保留这份环境信息。
本次产物在 addCaptured 中使用 invokedynamic,并关联一个接收两个整数的辅助实现方法。生成路径将捕获的整数提供给函数对象构造机制;调用函数时再提供另一个整数。辅助方法名称和具体生成方式属于编译器实现,不应作为公共 API 依赖。
这不是“函数值只是一个方法指针”的完整模型。没有捕获的函数与携带环境的函数可能具有不同表示;捕获不可变整数与捕获可变局部状态也可能不同。前面的闭包实验涉及的 IntRef,就是可变局部状态需要共享表示的一种具体观测。
但看到 invokedynamic 也不能断言每次调用都分配新的对象,更不能把它当成异步或反射调用的同义词。该指令提供动态链接调用点的机制,具体启动方法与目标由产物信息决定。本章只核验它用于当前闭包生成,并验证捕获值参与结果。
调试闭包问题时,可先写出生命周期:外层参数何时给出,函数值何时返回,环境何时读取。如果捕获的是一个可变对象引用,后续对象变化可能被闭包观察到;如果只保存一个已求值的整数,则观察到的是那个整数值。应根据源码和产物分别确认,不能用“捕获会复制变量”概括所有情况。
ClassTag 解决数组表示需求,不恢复完整泛型
泛型函数想创建 Array[A],需要知道运行时数组元素类应是什么:
1 | |
正例创建整数数组与字符串数组,得到的运行时类名分别是 [I 和 [Ljava.lang.String;。这说明 JVM 数组确实具有不同元素表示,ClassTag 为泛型创建操作提供必要信息。移除上下文边界后,隔离反例实际报出 No ClassTag available。
数组与一般泛型容器因此不能简单按同一句擦除规则描述。Array[Int] 对应原始整数数组,Array[String] 对应字符串引用数组;而正例中单元素 List[Int] 与 List[String] 的对象类相同。运行时容器类相同,不代表编译期间两个类型可以任意互换。
ClassTag[List[Int]] 与 ClassTag[List[String]] 的 runtimeClass 在实验中也相同。它保存的是运行时类相关信息,不是任意嵌套泛型参数的完整结构。拿到一个 ClassTag 并不能自动验证列表每个元素确实是字符串。
这里可以再次对照宏里的 Type[A]:宏在编译阶段可以利用静态类型结构生成代码,ClassTag[A] 则支持某些运行时表示操作。两者的阶段与信息内容不同。若外部数据以 Any 进入程序,需要完整验证结构,就应生成或编写真正检查元素的逻辑。
数组创建成功也不证明元素业务合法。Array[String] 可以含有任意字符串,默认空值模式下还可能涉及 null。类型表示、数据范围与外部可信性依然是不同约束。ClassTag 的用途明确之后,就不容易把它误用成通用校验令牌。
泛型类型测试的 unchecked 警告在提示什么
以下测试看起来像是在验证“字符串列表”:
1 | |
冻结编译器发出不能在运行时检查该泛型测试的诊断。本章把 -Werror 只用于该反例目录,让 unchecked 警告成为非零退出。它不是说不能识别任何列表对象,而是说运行时测试没有足够信息证明所写的完整元素参数。
若忽略警告并根据测试结果强制读取字符串元素,就可能把整数元素带入后续字符串操作。错误通常发生在更晚的转换或方法调用处,而不是测试所在行。距离拉长会使故障看起来像业务数据偶发损坏,实际根因却是边界验证不完整。
一种修复是对外部输入逐个检查元素并构造新结果,显式返回错误;另一种是让上游 API 保留可靠的静态类型,避免不必要地扩大成 Any。选择取决于数据是否来自可信的同类型代码,不能用同一种转换处理两种边界。
容器为空时还要注意证据强度。检查每个元素是否是字符串,对空列表会自然通过,但它不能恢复历史上原本声明的泛型参数。运行时结构校验回答的是当前内容是否满足条件,静态来源信息则是另一个问题。设计解码器时应明确需要哪一种保证。
将警告升级为错误也不是所有项目的无条件默认要求。这里为了验证这条特定反例,单独设置严格参数;迁移大型项目时应先审查现有警告来源,再决定规则范围。重要的是不把被忽略的 unchecked 警告描述成已得到完整类型证明。
查看产物时保留版本和输入
本章使用的 JDK 是 21,但生成 class 文件显示的目标主版本不必等于 JDK 21 对应的 class 版本。编译器目标设置与运行编译器的 JDK 是不同维度。发布到较旧 JVM 时,应检查实际目标版本和所引用 API,不能仅看本机 java -version。
同样,Scala 编译器升级可能改变闭包辅助方法命名、桥接生成细节或优化结果。文章保存的具体名称属于本次产物观测;概念推导则依赖描述符、覆盖关系和求值语义。把稳定机制与实现细节分开,才能在升级后判断哪些差异需要重新验收。
复跑时应先执行正例生成最新 class,再对对应产物运行 javap。只在旧构建目录里找到同名类,可能检查到前一版代码。证据 JSON 记录了实际命令和完整路径,输出还包含类文件哈希。修改源码后重新生成并另存证据,比覆盖旧日志更容易比较。
对于接口兼容问题,还需加入真实旧客户端与新库组合运行。对于分配成本问题,需加入运行时分配指标。对于闭包捕获问题,需用能区分环境变化的输入。字节码工具提供的是一个关键视角,不能包办所有工程验证。
沿一次调用手推操作数类型
以 identity(7) 为例,调用点最初产生 JVM 整数值。目标方法描述符要求对象引用,所以需要先经过包装适配;调用结束得到对象形式的返回值,后续整数比较又要求原始整数,因此出现拆箱。把这三个位置连起来,装拆箱就不再是没有原因的额外指令。
如果返回值没有马上用于整数运算,而是保存到对象类型容器,后面的拆箱位置可能改变。反过来,如果某个调用点通过更具体的方法签名完成计算,可能不需要在同一位置经过对象边界。因此不能只统计源文件中出现多少次泛型参数,就估算包装次数。
桥接也可以沿调用契约推导。调用者静态持有泛型接口,擦除后的调用形状期望对象返回;具体类提供字符串返回;桥接接收接口要求的调用并转交具体实现。由于字符串本身已经是对象,这里不需要整数那样的装箱。桥接与装箱都属于表示适配,但触发原因不同,不能看到两者同时出现就认为它们是一件事。
闭包则多出环境参数。外层调用把 offset 交给函数值构造,内层调用把 n 交给计算逻辑。辅助实现方法能接到二者,才有条件相加。这个模型解释了为什么一个看上去只接收一个参数的函数体,生成的辅助方法可能具有额外参数;额外位置对应捕获环境,而不是业务调用者突然多传了一个参数。
这样手推之后,工具检查可以集中在少数位置:目标描述符是什么、调用前后发生哪些适配、捕获值如何进入目标、最终结果是否保持原语义。无需逐字阅读整个常量池,也无需根据陌生的合成方法名称猜测功能。
为表示变化设计能失败的测试
正例把类型约束与值约束放在一起,但不同假设需要不同输入。检查桥接时通过接口引用调用具体实现;若总是用具体类引用,接口调用路径可能没有被实际执行。检查捕获时给出不同的外层和内层数字,避免错误地使用同一个变量也碰巧得到相同结果。
检查数组时同时创建原始整数与引用元素数组,才容易观察到不同运行时表示。检查泛型信息丢失时选择相同具体容器实现、不同元素参数,避免把列表与向量的类差异误当成泛型参数被保留。当前测试使用两个非空单元素列表,确保对照主要针对元素参数。
检查类型测试边界时则保留编译警告,并明确把它提升为该反例的失败条件。如果把所有警告隐藏,程序能够运行只能证明编译器接受了风险,不能证明风险消失。对编译器实验而言,正常结果与预期警告同样是有效观察,关键在于提前声明它们分别支持什么结论。
实验记录与练习
本章为 LAB_VERIFIED。命令如下:
1 | |
最终记录位于 examples/scala-lab/evidence/20261002-ch28-r2/。正常入口退出零;两个反例分别因缺少 ClassTag 和 unchecked 泛型测试被拒绝。javap.json/.log 保存类文件检查,关键观测包括对象描述符、整数描述符、装拆箱调用、桥接标志和闭包调用点。
手算题:Producer[String] 引用调用具体类 get 时,为什么可能需要返回 Object 的桥接?答案是擦除后接口形状与具体返回字符串的方法描述符不同,桥接维持调用约定。不能把它解释成程序员写了两个业务重载。
第二题:有 ClassTag[List[String]] 就能证明一个来自 Any 的列表全部是字符串吗?不能,正例已经验证两个不同元素参数的列表标签具有相同运行时类。要验证内容,必须有更多信息或执行元素检查。
执行练习把 identity(7) 的结果先保存到 Any,再增加一个使用原始整数的对照方法。检查调用处描述符和转换指令,解释哪里发生表示变化。随后把捕获值从整数改成包含可变字段的对象,用修改前后两次调用观察环境共享;不要把结果推广为所有闭包统一按值或按引用捕获。
参考
JDK 21 javap 文档说明检查选项;JVMS 21 class 格式定义描述符、签名与方法标志;ClassTag 2.13.16 API限定它保存的信息。本章结论中的具体生成方式来自实际日志。下一篇继续追踪类型树、TASTy、后端与增量编译之间的关系。
