为什么使用 -XX:TraceBytecodes 记录的 java 程序的执行字节码存在差异

Why are there differences in the executed bytecode of a java program logged with -XX:TraceBytecodes

我正在尝试了解 java 解释器的工作原理。 为了准确查看执行了哪些字节码,我自己构建了一个 jdk fastdebug 构建并使用了 -XX:+TraceBytecodes 选项。 此外,我使用 -XX:-UseCompiler.

关闭了 JIT 编译器

我的期望是字节码对于同一程序的多个 运行 是相同的。我注意到总是存在差异,例如某些字节码部分执行得更早或更晚,字节码的总和不同于 运行 运行.

这是为什么? 据我所知,java 解释器无法优化代码,并且总是 运行 每隔 运行 以相同的顺序执行相同的指令。

编辑:

public class TestSimple2 {
    public static void main(String[] args) throws Exception {
        System.out.println("start prog");

        System.out.println("end prog");
    }
}

代码执行并不总是确定性的,在这种特定情况下,它是故意的。但是,跟踪中显示的方法未被您的代码调用,因此这必须是内部 startup/class 初始化代码的一部分。

显然,有问题的代码迭代了 Set,该 Set 通过 Java 9 引入的 Set.of(…) 方法之一创建,具有两个以上的元素。

在这种情况下,实现 运行domizes 迭代顺序。正如核心开发人员之一 Stuart Marks 在 中解释的那样:

Hashed Collection Iteration Order. The new Set.of and Map.of structures randomize their iteration order. The iteration order of HashSet and HashMap is undefined, but in practice it turns out to be relatively stable. Code can develop inadvertent dependencies on iteration order. Switching to the new collection factories may expose old code to iteration order dependencies, surfacing latent bugs.

中,他还解释说:

In any case, another reason for randomized iteration order is to preserve flexibility for future implementation changes.

This turns out to be a bigger deal than most people think. Historically, HashSet and HashMap have never specified a particular iteration order. From time to time, however, the implementation needed to change, to improve performance or to fix bugs. Any change to iteration order generated a lot of flak from users. Over the years, a lot of resistance built up to changing iteration order, and this made maintenance of HashMap more difficult.

您可以阅读链接的答案以了解有关动机的更多详细信息,但一个实现细节对于理解执行的字节码指令的跟踪差异很重要:

… Initially the order changed on every iteration, but this imposed some overhead. Eventually we settled on once per JVM invocation. The cost is a 32-bit XOR operation per table probe, which I think is pretty cheap.

这在 Java 9 和最新版本之间略有变化,前者在探测位置时使用 int idx = Math.floorMod(pe.hashCode() ^ SALT, elements.length);,例如在 contains 中,较新的版本在用起点初始化迭代器时使用 idx = Math.floorMod(SALT, table.length >> 1) << 1;

在任何一种情况下,我们最终都会在某个点调用 Math.floorMod,其值取决于 SALT,这是每次 JVM 调用中不同的值。 floorMode 在内部调用 floorDiv,实现为

public static int floorDiv(int x, int y) {
    int r = x / y;
    // if the signs are different and modulo not zero, round down
    if ((x ^ y) < 0 && (r * y != x)) {
        r--;
    }
    return r;
}

所以在这里,我们有一个取决于传入值的条件,因此是 SALT,这就是为什么我们看到不同的执行字节码序列的原因,有时,b运行ch被采取,有时不是。注意差异前的最后一条指令是ifeq,一个条件b运行ch.

关于next方法执行的区别,我们要参考:

The current implementation of SetN is a fairly simple closed hashing scheme, as opposed to the separate chaining approach used by HashMap.

Thus we have a class space-time tradeoff. If we make the table larger, there will be empty slots sprinkled throughout the table. When storing items, there should be fewer collisions, and linear probing will find empty slots more quickly.

In bringing up the implementation, we ran a bunch of benchmarks using different expansion factors. […] We chose 2.0 since it got most of the performance improvement (close to O(1) time) while providing good space savings compared to HashSet.

所以内部数组是 Set 实际大小的两倍,并且包含迭代时必须跳过的 null 个条目。当我们考虑到迭代顺序已经 运行domized 时,很明显这段代码可能会在不同的时间遇到​​空数组槽,因此,也会导致报告的执行字节代码不同。

注意差异前的最后一条指令是ifnonnull,当测试值不是null时采取的条件b运行ch。由于 b运行ch 指令与其目标之间的代码调用了 nextIndex(),我想,您 运行 JRE 下的代码比 Java 9¹ 更新。


¹ 不同之处在于 Java 9 运行domizes 实际数组位置,这增加了 contains 方法中的探测成本,而较新的版本仅使用基于哈希码的方法数组位置,但 运行 通过使用 SALT 相关的起始索引和方向,在迭代器中直接控制顺序,这反而会增加迭代器初始化的轻微成本。