Issue
I'm trying to understand how the java interpreter works.
To see exactly what bytecodes are executed i build myself a jdk fastdebug build and used the -XX:+TraceBytecodes
option.
Additionally i turned off the JIT-Compiler with -XX:-UseCompiler
.
My expectation was that the bytecodes are the same for multiple runs of the same program. I noticed that there are always differences like some bytecode parts get executed earlier or later and the total sum of bytecodes differs from run to run.
Why is that? To my knowledge the java interpreter can not optimize the code and always runs the same instructions in the same order every run.
Edit:
public class TestSimple2 {
public static void main(String[] args) throws Exception {
System.out.println("start prog");
System.out.println("end prog");
}
}
Solution
Code execution is not always deterministic and in this specific case, it’s deliberate. However, the methods shown in the trace are not invoked by your code, so this must be part of the internal startup/class initialization code.
Apparently, the code in question iterates over a Set
created via one of the Set.of(…)
methods introduced with Java 9, with more than two elements.
In this case, the implementation randomizes the iteration order. As Stuart Marks, one of the core developers, explains in this answer:
Hashed Collection Iteration Order. The new
Set.of
andMap.of
structures randomize their iteration order. The iteration order ofHashSet
andHashMap
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 another answer, he also explains:
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
andHashMap
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 ofHashMap
more difficult.
You can read the linked answer for more details regarding the motivation, but one implementation detail is important to understand the difference in the trace of executed byte code instructions:
… 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.
This has changed slightly between Java 9 and recent versions, the former used int idx = Math.floorMod(pe.hashCode() ^ SALT, elements.length);
when probing for a location, e.g. within contains
, the newer versions use idx = Math.floorMod(SALT, table.length >> 1) << 1;
when initializing an iterator with a starting point.
In either case, we end up calling Math.floorMod
at one point with a value depending on SALT
, which is the value different in each JVM invocation. floorMode
invokes floorDiv
internally, which is implemented as
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;
}
So here, we have a conditional depending on the incoming value, hence the SALT
, which is the reason why we see different sequences of executed bytecode, as sometimes, the branch is taken and sometimes not. Note that the last instruction before the difference is ifeq
, a conditional branch.
For the difference in the execution of the next
method, we have to refer to yet another answer:
The current implementation of
SetN
is a fairly simple closed hashing scheme, as opposed to the separate chaining approach used byHashMap
.
…
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 toHashSet
.
So the internal array is twice as large as the Set
’s actual size and contains null
entries that have to be skipped when iterating. When we take into account that the iteration order has been randomized, it’s clear that this code may encounter the empty array slots at different times, hence, also cause differences in the reported executed byte code.
Note that the last instruction before the difference is ifnonnull
, a conditional branch taken when the tested value is not null
. Since the code between the branch instruction and its target bears an invocation of nextIndex()
, I suppose, you ran the code under a JRE newer than Java 9¹.
¹ The difference is that Java 9 randomizes the actual array locations, which adds costs to the probing in the contains
method, whereas newer versions use only hash code based array locations, but randomize the order right in the iterator, by using a SALT
dependent starting index and direction, which adds slight costs to the iterator initialization instead.
Answered By - Holger
Answer Checked By - David Marino (JavaFixing Volunteer)