Почему использование разных конструкторов ArrayList приводит к разной скорости роста внутреннего массива? - PullRequest
11 голосов
/ 18 июня 2019

Я, кажется, наткнулся на что-то интересное в реализации 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, где количество сегментов почти всегда больше, чем вы просили;но там это делается из-за необходимости в «силе двух» ведер, но здесь дело не в этом.

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

Ответы [ 6 ]

14 голосов
/ 18 июня 2019

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

ArrayList()

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

ArrayList(int)

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

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

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

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

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

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

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

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

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

3 голосов
/ 18 июня 2019

Если вы используете конструктор по умолчанию, идея состоит в том, чтобы попытаться сбалансировать использование памяти и перераспределение.Следовательно, используется небольшой размер по умолчанию (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-го элемента.

1 голос
/ 18 июня 2019

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

Вместо двух констант они, конечно, могли бы ввести, например, логическое поле в ArrayList, 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's already
        // supposed to be at default size.
        : DEFAULT_CAPACITY;

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

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

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

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

Я понимаю причину этого «оптимизации»n 'может быть в тех случаях, когда вы знаете, что вы будете хранить только один или два (то есть менее DEFAULT_CAPACITY) элементов в списке, и вы соответственно указываете начальную емкость;в этих случаях, например, для одноэлементного списка, вы получите только одноэлементный массив вместо DEFAULT_CAPACITY размера

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

0 голосов
/ 18 июня 2019

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

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

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

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

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

0 голосов
/ 18 июня 2019

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

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

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

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

0 голосов
/ 18 июня 2019

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

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

...