Почему использование разных конструкторов ArrayList приводит к разной скорости роста внутреннего массива?

Кажется, я наткнулся на что-то интересное в реализации ArrayList которое я не могу обернуть вокруг. Вот код, который показывает, что я имею в виду:

public class Sandbox {

    private static final VarHandle VAR_HANDLE_ARRAY_LIST;

    static {
        try {
            Lookup lookupArrayList = MethodHandles.privateLookupIn(ArrayList.class, MethodHandles.lookup());
            VAR_HANDLE_ARRAY_LIST = lookupArrayList.findVarHandle(ArrayList.class, "elementData", Object[].class);
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException();
        }
    }

    public static void main(String[] args) {

        List<String> defaultConstructorList = new ArrayList<>();
        defaultConstructorList.add("one");

        Object[] elementData = (Object[]) VAR_HANDLE_ARRAY_LIST.get(defaultConstructorList);
        System.out.println(elementData.length);

        List<String> zeroConstructorList = new ArrayList<>(0);
        zeroConstructorList.add("one");

        elementData = (Object[]) VAR_HANDLE_ARRAY_LIST.get(zeroConstructorList);
        System.out.println(elementData.length);

    }
}

Идея в том, что если вы создадите ArrayList следующим образом:

List<String> defaultConstructorList = new ArrayList<>();
defaultConstructorList.add("one");

И посмотрите, что elementData (Object[] где хранятся все элементы) будет сообщать 10. Таким образом, вы добавляете один элемент - вы получаете 9 дополнительных слотов, которые не используются.

Если, с другой стороны, вы делаете:

List<String> zeroConstructorList = new ArrayList<>(0);
zeroConstructorList.add("one");

Вы добавляете один элемент, место зарезервировано только для этого элемента, не более того.

Внутренне это достигается через два поля:

/**
 * Shared empty array instance used for empty instances.
 */
private static final Object[] EMPTY_ELEMENTDATA = {};

/**
 * Shared empty array instance used for default sized empty instances. We
 * distinguish this from EMPTY_ELEMENTDATA to know how much to inflate when
 * first element is added.
 */
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

Когда вы создаете ArrayList через new ArrayList(0) - будет использоваться EMPTY_ELEMENTDATA.

Когда вы создаете ArrayList помощью new Arraylist() - используется DEFAULTCAPACITY_EMPTY_ELEMENTDATA.

Интуитивная часть изнутри меня - просто кричит "удалить DEFAULTCAPACITY_EMPTY_ELEMENTDATA " и позволяет обрабатывать все случаи с помощью EMPTY_ELEMENTDATA; конечно код комментария:

Мы отличаем это от EMPTY_ELEMENTDATA, чтобы знать, сколько раздувать при добавлении первого элемента

имеет смысл, но почему один раздувает до 10 (намного больше, чем я просил), а другой до 1 (ровно столько, сколько я просил).


Даже если вы используете List<String> zeroConstructorList = new ArrayList<>(0) и продолжаете добавлять элементы, в конечном итоге вы попадете в точку, где elementData больше, чем запрошенный:

    List<String> zeroConstructorList = new ArrayList<>(0);
    zeroConstructorList.add("one");
    zeroConstructorList.add("two");
    zeroConstructorList.add("three");
    zeroConstructorList.add("four");
    zeroConstructorList.add("five"); // elementData will report 6, though there are 5 elements only

Но скорость его роста меньше, чем в случае конструктора по умолчанию.


Это напоминает мне о реализации HashMap, где количество сегментов почти всегда больше, чем вы просили; но там это делается из-за необходимости в "силе двух" ведер, хотя здесь дело не в этом.

Итак, вопрос в том, может ли кто-нибудь объяснить мне эту разницу?

Ответ 1

Вы получите именно то, что просили, в соответствии с тем, что было указано, даже в более старых версиях, где реализация отличалась:

ArrayList()

Создает пустой список с начальной емкостью десять.

ArrayList(int)

Создает пустой список с указанной начальной емкостью.

Таким образом, построение ArrayList с помощью конструктора по умолчанию даст вам ArrayList с начальной емкостью десять, так что, пока размер списка равен десяти или меньше, операция изменения размера никогда не понадобится.

Напротив, конструктор с аргументом int будет точно использовать указанную емкость в соответствии с растущей политикой, которая указывается как

Детали политики роста не указаны за исключением того факта, что добавление элемента имеет постоянные амортизированные временные затраты.

который применяется, даже если вы указали начальную емкость ноль.

В Java 8 добавлена оптимизация, заключающаяся в том, что создание массива из десяти элементов откладывается до добавления первого элемента. Это конкретно относится к общему случаю, когда экземпляры ArrayList (созданные с емкостью по умолчанию) остаются пустыми в течение длительного времени или даже всего их срока службы. Кроме того, когда первой действительной операцией является addAll, она может пропустить первую операцию изменения размера массива. Это не влияет на списки с явной начальной емкостью, так как они обычно выбираются тщательно.

Как указано в этом ответе:

По мнению нашей команды по анализу производительности, примерно 85% экземпляров ArrayList создаются с размером по умолчанию, поэтому эта оптимизация будет действительной в подавляющем большинстве случаев.

Мотивация состояла в том, чтобы оптимизировать именно эти сценарии, а не касаться указанной емкости по умолчанию, которая была определена еще при создании ArrayList. (Хотя JDK 1.4 является первым, в котором это явно указано)

Ответ 2

Если вы используете конструктор по умолчанию, идея состоит в том, чтобы попытаться сбалансировать использование памяти и перераспределение. Следовательно, используется небольшой размер по умолчанию (10), который подходит для большинства приложений.

Если вы используете конструктор с явным размером, предполагается, что вы знаете, что делаете. Если вы инициализируете его с 0, вы, по сути, говорите: я почти уверен, что он либо останется пустым, либо не превзойдет очень мало элементов.

Теперь, если вы посмотрите на реализации ensureCapacityInternal в openjdk (ссылка), вы увидите, что только при первом добавлении элемента это различие вступает в игру:

private void ensureCapacityInternal(int minCapacity) {
    if (elementData == EMPTY_ELEMENTDATA) {
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    }

    ensureExplicitCapacity(minCapacity);
}

Если используется конструктор по умолчанию, размер увеличивается до DEFAULT_CAPACITY (10). Это должно предотвратить слишком много перераспределений, если добавлено несколько элементов. Однако если вы явно создали этот ArrayList с размером 0, он просто увеличится до размера 1 в первом добавленном элементе. Это потому, что вы сказали, что знаете, что делаете.

ensureExplicitCapacity основном просто вызывает grow (с некоторыми проверками диапазона/переполнения), поэтому давайте посмотрим на это:

private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // minCapacity is usually close to size, so this is a win:
    elementData = Arrays.copyOf(elementData, newCapacity);
}

Как видите, он не просто вырастает до определенного размера, но пытается быть умным. Чем больше массив, тем больше он будет расти, даже если minCapacity всего на 1 больше текущей емкости. Причина проста: вероятность того, что будет добавлено множество элементов, выше, если список уже большой, и наоборот. По этой же причине вы видите увеличение на 1, а затем на 2 после 5-го элемента.

Ответ 3

Краткий ответ на ваш вопрос: что есть в документе Java: у нас есть две константы, потому что теперь мы должны иметь возможность различить две разные инициализации позже, см. Ниже.

Вместо двух констант они, конечно, могли бы ввести, например, логическое поле в ArrayList, private boolean initializedWithDefaultCapacity поле private boolean initializedWithDefaultCapacity; но для этого потребуется дополнительная память для каждого экземпляра, что, кажется, противоречит цели экономии нескольких байт памяти.

Почему мы должны различать эти два?

Глядя на ensureCapacity() мы видим, что происходит с DEFAULTCAPACITY_EMPTY_ELEMENTDATA:

public void ensureCapacity(int minCapacity) {
    int minExpand = (elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
        // any size if not default element table
        ? 0
        // larger than default for default empty table. It already
        // supposed to be at default size.
        : DEFAULT_CAPACITY;

    if (minCapacity > minExpand) {
        ensureExplicitCapacity(minCapacity);
    }
}

Кажется, что это сделано таким образом, чтобы быть несколько "совместимым" с поведением старой реализации:

Если вы инициализировали список с емкостью по умолчанию, он фактически будет теперь инициализирован пустым массивом, но, как только первый элемент будет вставлен, он в основном вернется к тому же поведению, что и старая реализация, т.е. после первого элемент добавлен, резервный массив имеет DEFAULT_CAPACITY и с тех пор список ведет себя так же, как и ранее.

Если, с другой стороны, вы явно указываете начальную емкость, массив не "переходит" на DEFAULT_CAPACITY а растет относительно вашей указанной начальной емкости.

Я полагаю, что причиной этой "оптимизации" могут быть случаи, когда вы знаете, что будете хранить только один или два (т.е. меньше, чем DEFAULT_CAPACITY) элемента в списке, и вы соответственно указываете начальную емкость; в этих случаях, например, для одноэлементного списка, вы получите только одноэлементный массив вместо DEFAULT_CAPACITY -sized.

Не спрашивайте меня, какова практическая польза от сохранения девяти элементов массива ссылочного типа. Может быть до 9 * 64 бит = 72 байта ОЗУ на список. Йеайте. ;-)

Ответ 4

Это, скорее всего, связано с тем, что два конструктора имеют различное воспринимаемое использование по умолчанию.

Конструктор по умолчанию (пустой) предполагает, что это будет "типичный ArrayList ". Следовательно, число 10 выбрано в качестве разновидности эвристики, то есть "какое будет типичное среднее число вставленных элементов, которое не займет слишком много места, но и не будет без необходимости увеличивать массив". С другой стороны, у конструктора емкости есть предположение "вы знаете, что делаете" или "вы знаете, для чего вы будете использовать ArrayList for ". Поэтому никакой эвристики такого типа нет.

Ответ 5

Емкость с конструктором по умолчанию равна 10 просто потому, что в документах так сказано. Это было бы выбрано в качестве разумного компромисса между тем, чтобы не использовать слишком много оперативной памяти и не выполнять много копий массивов при добавлении первых нескольких элементов.

Нулевое поведение немного спекулятивно, но я вполне уверен в своих рассуждениях здесь:

Это потому, что если вы явно инициализируете ArrayList с нулевым размером, а затем добавляете что-то к нему, вы говорите: "Я не ожидаю, что этот список будет содержать много, если вообще что-нибудь". Следовательно, имеет гораздо больше смысла медленно наращивать резервный массив, как если бы он был инициализирован со значением 1, а не обрабатывать его так, как если бы у него вообще не было указано начальное значение. Таким образом, он обрабатывает особый случай увеличения его до 1 элемента, а затем продолжает работать как обычно.

Чтобы завершить картину, ArrayList явно инициализированный с размером 1, будет расти намного медленнее (до того момента, пока он не достигнет размера "10 элементов" по умолчанию), чем по умолчанию, иначе не было бы никакой причины инициализировать его с небольшим значением в первую очередь.

Ответ 6

но почему один раздувает до 10 (намного больше, чем я просил), а другой до 1 (ровно столько, сколько я просил)

Возможно, потому что большинство людей, которые создают списки, хотят хранить в нем более 1 элемента.

Вы знаете, если вам нужна ровно одна запись, почему бы не использовать Collections.singletonList() например.

Другими словами, я думаю, что ответ прагматизм. Когда вы используете конструктор по умолчанию, типичным случаем использования может быть быстрое добавление нескольких элементов.

Значение: "неизвестный" интерпретируется как "несколько", тогда как "точно 0 (или 1)" интерпретируется как "хмм, точно 0 или 1".