<< Back to blog

Material.values() copies 8.6KB every time you call it

so values() on an enum looks like a getter, but the compiler generates it as basically this:

public static Material[] values() {
    return $VALUES.clone();
}

it clones the whole array every time you call it. makes sense if you think about it, arrays are mutable, so if it gave you the real one any plugin could do values()[0] = null and break it for everyone else on the server.

the annoying part is the size. paper 26.2 has 2154 materials, and an array is a 16 byte header plus 4 bytes per slot (with compressed oops), so each call allocates

16 + 2154 * 4 = 8632 bytes

if you have a for (Material m : Material.values()) in some listener that fires 1000 times a tick, thats 8.6MB a tick, around 173MB/s at 20 tps. for scale, paper's docs say a 30 player server already allocates at least 800MB/s, so one loop is adding over a fifth on top of that.

the numbers

i threw it in JMH with a cached version next to it, plus BlockFace (19 constants) to see if small enums behave any different:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(2)
public class ValuesBench {

    private static final Material[] MATERIALS = Material.values();
    private static final BlockFace[] FACES = BlockFace.values();

    @Benchmark
    public int materialValues() {
        int sum = 0;
        for (Material m : Material.values()) sum += m.ordinal();
        return sum;
    }

    @Benchmark
    public int materialCached() {
        int sum = 0;
        for (Material m : MATERIALS) sum += m.ordinal();
        return sum;
    }

    @Benchmark
    public int blockFaceValues() {
        int sum = 0;
        for (BlockFace f : BlockFace.values()) sum += f.ordinal();
        return sum;
    }

    @Benchmark
    public int blockFaceCached() {
        int sum = 0;
        for (BlockFace f : FACES) sum += f.ordinal();
        return sum;
    }

    @Benchmark
    public BlockFace blockFaceIndex() {
        return BlockFace.values()[2];
    }

    @Benchmark
    public Material materialIndex() {
        return Material.values()[2];
    }
}

ran on zulu 25 with default G1, JMH 1.37 with -prof gc, paper-api 26.2 build 121, on my i7-10750H laptop. the timings wobble a bit cuz its a laptop, but the B/op column is exact.

Benchmark          ns/op            B/op
materialValues     4207 ± 932       8632
materialCached     2304 ± 489         ~0
blockFaceValues    30.3 ± 17.8        96
blockFaceCached     9.2 ± 2.4         ~0
blockFaceIndex      0.7 ± 0.2         ~0
materialIndex      1090 ± 202       8632

the loop is about 1.8x slower with the copy, and materialIndex shows what the copy costs on its own, about a microsecond and 8632 bytes just to read one constant lol

why doesnt the JIT just delete it

you'd think escape analysis would handle this, the array never leaves the method after all. it can delete an array like that, but it does it by turning each slot into a local variable, so it needs to know which slot every access hits (constant indexes only), and the array cant be bigger than 64 slots (-XX:EliminateAllocationArraySizeLimit, defaults to 64).

thats what the [2] rows are for. BlockFace.values()[2] gets deleted completely, 0.7ns and zero bytes. the same thing on Material still allocates all 8632 bytes since 2154 is way over 64. and a for-each loop uses a variable index, so even the tiny BlockFace copy sticks around, 96 bytes per call.

i double checked with flags too. -XX:-DoEscapeAnalysis brings the 96 bytes back on BlockFace.values()[2], and bumping the limit to 4096 makes Material.values()[2] free, but the loop still allocates the full 8632 bytes even with that. so yeah, it has to be fixed in code.

the fix is boring

private static final Material[] MATERIALS = Material.values();

just loop over that instead. dont hand it out to other code or write to it though, thats the whole reason values() copies in the first place. and if all you're doing is checking "is this one of these materials", an EnumSet you build once is the nicer option.

side note for anyone who read the compact headers post: with -XX:+UseCompactObjectHeaders the array header goes down to 12 bytes, so the BlockFace copy drops from 96 to 88 bytes. Material stays at 8632 though, 8628 just gets rounded back up to the next multiple of 8.