javaperformancearraylistbenchmarkingjmh

Why does ArrayList.add() call ArrayList.grow() without exceeding ArrayList.size()?


EDIT2: rephrasing the question due to more details.

This got me curious. Profiling this code:

public class ProfilerTest {
    private static int loops = 1_000_000_000;
    private static int listSize = 10;

    public static void main(String[] args) {
        for (int i = 0; i < loops; i++) {
            var list = new ArrayList<Integer>(listSize);
            for (int j = 0; j < listSize; j++) {
                list.add(j);
            }
            assert list.size() == listSize;
        }
    }
}

Profiler says grow() gets called by add(Object). Looking at the source, my JDKs implementation of ArrayList.add(Object) calls this helper:

private void add(E e, Object[] elementData, int s) {
    if (s == elementData.length)
        elementData = grow();
    elementData[s] = e;
    size = s + 1;
}

which should only call grow() when exceeding the size (s).

Even when I increase the initialCapacity to 2*listSize, grow() gets called. What am I missing here?


original question:

I have found through profiling, that one of my methods is sometimes calling ArrayList.grow(). The thing is, the method should not call grow() at all since I know the desired size. Modifications to the size can happen in other methods though.

Surprised by this, I did a little benchmark. The results seem strange to me. Not only is MatchingCapacity performing much worse, its variability is also much higher, at least for small sizes, which, in my use case are the most common.

The questions I have:

unfortunately I am getting an error when trying to profile the JMH benchmark for more insights..

EDIT: as was pointed out in the comments I was reading the numbers all wrong, assuming I was looking at time instead of throughput, so the remaining question is about the profiler.

@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 1, time = 3)
@Measurement(iterations = 3, time = 3)
@Fork(value=2)
public class PerformanceTest {
    private int size = 30;

    @Benchmark
    public ArrayList<Integer> MatchingCapacity() {
        var list = new ArrayList<Integer>(size);
        for (int i = 0; i < size; i++) {
            list.add(i);
        }
        return list;
    }

    @Benchmark
    public ArrayList<Integer> MismatchingCapacity() {
        var list = new ArrayList<Integer>(size-1);
        for (int i = 0; i < size; i++) {
            list.add(i);
        }
        return list;
    }

    @Benchmark
    public ArrayList<Integer> OversizedCapacity() {
        var list = new ArrayList<Integer>(size + size/2);
        for (int i = 0; i < size; i++) {
            list.add(i);
        }
        return list;
    }

    @Benchmark
    public ArrayList<Integer> DefaultCapacity() {
        var list = new ArrayList<Integer>();
        for (int i = 0; i < size; i++) {
            list.add(i);
        }
        return list;
    }
}
# JMH version: 1.35
# VM version: JDK 17.0.3.1, Java HotSpot(TM) 64-Bit Server VM, 17.0.3.1+2-LTS-6

size=3
PerformanceTest.DefaultCapacity      thrpt    6  37681589.763 � 10696982.725  ops/s
PerformanceTest.MatchingCapacity     thrpt    6  54285199.864 � 20674575.714  ops/s
PerformanceTest.MismatchingCapacity  thrpt    6  21430358.674 � 11202223.852  ops/s
PerformanceTest.OversizedCapacity    thrpt    6  50519838.911 � 19575866.550  ops/s

size=30
PerformanceTest.DefaultCapacity      thrpt    6  4253362.573 �  120027.530  ops/s
PerformanceTest.MatchingCapacity     thrpt    6  8071248.285 � 5138133.875  ops/s
PerformanceTest.MismatchingCapacity  thrpt    6  5108706.433 �  562235.033  ops/s
PerformanceTest.OversizedCapacity    thrpt    6  6867041.239 � 5561825.025  ops/s

size=300
PerformanceTest.DefaultCapacity      thrpt    6  466885.040 � 486083.752  ops/s
PerformanceTest.MatchingCapacity     thrpt    6  693234.753 � 268499.890  ops/s
PerformanceTest.MismatchingCapacity  thrpt    6  644182.306 � 324785.510  ops/s
PerformanceTest.OversizedCapacity    thrpt    6  773775.315 �  26808.161  ops/s

size=3000
PerformanceTest.DefaultCapacity      thrpt    6  58673.411 � 23803.125  ops/s
PerformanceTest.MatchingCapacity     thrpt    6  64863.127 � 33194.262  ops/s
PerformanceTest.MismatchingCapacity  thrpt    6  58921.951 � 21346.733  ops/s
PerformanceTest.OversizedCapacity    thrpt    6  62658.432 � 32061.866  ops/s

Solution

  • The code in your sample does not cause the internal elementArray to be resized for the ArrayLists referenced by list. To demonstrate that, I've used nasty reflection tricks to look into list:

    private static final int LOOPS = 1_000_000;
    private static final int LIST_SIZE = 10;
    
    public static void main(String[] args) throws Exception {
        Field elementData = ArrayList.class.getDeclaredField("elementData");
        elementData.setAccessible(true);
        for (int i = 0; i < LOOPS; i++) {
            var list = new ArrayList<Integer>(LIST_SIZE);
            var initialLength = ((Object[]) elementData.get(list)).length;
            for (int j = 0; j < LIST_SIZE; j++) {
                list.add(j);
            }
            var endLength = ((Object[]) elementData.get(list)).length;
    
            if (initialLength != endLength) {
                throw new Exception("array was resized!");
            }
            if (list.size() != LIST_SIZE) {
                throw new Exception("Unexpected list size!");
            }
        }
    }
    

    This needs to be run with --add-opens=java.base/java.util=ALL-UNNAMED on recent JDKs, since it's doing ugly stuff that no production code should do.

    What it does however is demonstrate that the elementData of the listhas the same size before and after adding objects to it.