Думаю, большинство джавистов согласится, что java.util.ArrayList — наиболее используемая коллекция в мире Java. Она появилась в версии 1.2 и быстро стала "коллекцией по умолчанию", ведь в большинстве случаев её возможностей вполне достаточно для повседневной работы. В этот класс вносилось множество изменений (см., например, историю изменений в репозитории JDK 8), чтобы сделать его как можно более производительным. В этой заметке я покажу, что даже такой прокачанный компонент, как ArrayList всё ещё хранит в себе возможности для улучшения.


Положим, нам необходимо преобразовать в массив часть списка. Для этого опишем метод:


public T[] toSubArray(ArrayList<T> list, int from, int to) {
  return list
      .subList(from, to)
      .toArray(new T[0]);
}

Оценим его производительность в сравнении с преобразованием в массив исходного списка:


@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(jvmArgsAppend = {"-XX:+UseParallelGC", "-Xms1g", "-Xmx1g"})
public class SubListToArrayBenchmark {

  /**
   * baseline
   */
  @Benchmark
  public Integer[] list(Data data) {
    return data.list.toArray(new Integer[0]);
  }

  @Benchmark
  public Integer[] subList(Data data) {
    return data.list.subList(0, data.size).toArray(new Integer[0]);
  }

  @State(Scope.Thread)
  public static class Data {
    ArrayList<Integer> list;

    @Param({"0", "10", "100", "1000"})
    int size;

    @Setup
    public void setup() {
      list = IntStream
          .range(0, size)
          .boxed()
          .collect(toCollection(ArrayList::new));
    }
  }
}

Выполнив замеры обнаружим, что производительность метода subList() значительно уступает производительности baseline-а:


Benchmark size Score Error Unit
list 0 7,2 0,1 ns/op
subList 0 12,8 0,2 ns/op
list 10 34,6 3,9 ns/op
subList 10 44,7 1,0 ns/op
list 100 141,9 2,2 ns/op
subList 100 252,1 4,9 ns/op
list 1000 1201,6 21,0 ns/op
subList 1000 2310,4 53,0 ns/op

Учитывая то обстоятельство, что в обоих случаях перемещается равный объём данных, значительная разница выглядит удивительной.


Разгадка лежит врутри класса ArrayList:


public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
  //...

  public Object[] toArray() {
    return Arrays.copyOf(elementData, size);
  }

  public <T> T[] toArray(T[] a) {
    if (a.length < size)
      // Make a new array of a's runtime type, but my contents:
      return (T[]) Arrays.copyOf(elementData, size, a.getClass());
    System.arraycopy(elementData, 0, a, 0, size);
    if (a.length > size)
      a[size] = null;
    return a;
  }

  //...

}

Оба метода напрямую обращаются к массиву, используя Arrays.copyOf() и System.arraycopy() для перемещения данных. Заглянем внутрь:


public class Arrays {

  //...

  @HotSpotIntrinsicCandidate // since Java 9
  public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
    @SuppressWarnings("unchecked")
    T[] copy = ((Object)newType == (Object)Object[].class)
      ? (T[]) new Object[newLength]
      : (T[]) Array.newInstance(newType.getComponentType(), newLength);
    System.arraycopy(original, 0, copy, 0, Math.min(original.length, newLength));
    return copy;
  }

  //...

}

public final class System {

  //...

  @HotSpotIntrinsicCandidate // since Java 9
  public static native void arraycopy(Object src, int srcPos, Object dest, int destPos, int length);

  //...

}

Указанные методы помечены как @HotSpotIntrinsicCandidate, что позволяет ВМ создавать для них высокопроизводительный машинный код подменять их реализацию высокопроизводительным машинным кодом для достижения наилучшего быстродействия.


Теперь обратимся к методу subList():


public List<E> subList(int fromIndex, int toIndex) {
  subListRangeCheck(fromIndex, toIndex, size);
  return new SubList<>(this, fromIndex, toIndex);
}

Как видим, ArrayList располагает собственной реализацией данного метода, и (что гораздо важнее) собственной реализацией представления части списка:


private static class SubList<E> extends AbstractList<E> implements RandomAccess {
  private final ArrayList<E> root;
  private final SubList<E> parent;
  private final int offset;
  private int size;

  //...
}

Теперь главное: хотя SubList и помечен как RandomAccess и через поле root имеет прямой доступ к массиву, он не располагает собственной реализацией методов toArray() и toArray(T[]). А раз так, то используются унаследованные методы класса AbstractCollection:


public Object[] toArray() {
  // Estimate size of array; be prepared to see more or fewer elements
  Object[] r = new Object[size()];
  Iterator<E> it = iterator();
  for (int i = 0; i < r.length; i++) {
    if (! it.hasNext()) // fewer elements than expected
      return Arrays.copyOf(r, i);
    r[i] = it.next();
  }
  return it.hasNext() ? finishToArray(r, it) : r;
}

public <T> T[] toArray(T[] a) {
  // Estimate size of array; be prepared to see more or fewer elements
  int size = size();
  T[] r = a.length >= size ? a :
        (T[])java.lang.reflect.Array
        .newInstance(a.getClass().getComponentType(), size);
  Iterator<E> it = iterator();

  for (int i = 0; i < r.length; i++) {
    if (! it.hasNext()) { // fewer elements than expected
      if (a == r) {
        r[i] = null; // null-terminate
      } else if (a.length < i) {
        return Arrays.copyOf(r, i);
      } else {
        System.arraycopy(r, 0, a, 0, i);
        if (a.length > i) {
          a[i] = null;
        }
      }
      return a;
    }
    r[i] = (T)it.next();
  }
  // more elements than expected
  return it.hasNext() ? finishToArray(r, it) : r;
}

Здесь массив заполняется в цикле с помощью итератора, а это работает медленнее, чем перенос данных с помощью Arrays.copyOf() и System.arraycopy(). Отсюда следует, что для улучшения производительности нам нужно переопределить toArray() и toArray(T[]) и использовать тот же подход, что и ArrayList. Дополним:


private static class SubList<E> extends AbstractList<E> implements RandomAccess {
  private final ArrayList<E> root;
  private final SubList<E> parent;
  private final int offset;
  private int size;

  //...

  @Override
  public Object[] toArray() {
    return Arrays.copyOfRange(root.elementData, offset, offset + size);
  }

  @Override
  public <T> T[] toArray(T[] a) {
    if (a.length < size)
      return (T[]) Arrays.copyOfRange(root.elementData, offset, offset + size, a.getClass());
    System.arraycopy(root.elementData, offset, a, 0, size);
    if (a.length > size)
      a[size] = null;
    return a;
  }

 //...

}

Всё ли мы сделали правильно? Нет! Переопределённые нами методы не учитывают вероятность того, что исходный список может быть изменён уже после вызова метода subList(). Мы обязаны учесть такую возможность. Поэтому добавим проверку в начало каждого из переопределённых методов:


@Override
public Object[] toArray() {
  checkForComodification(); // <--
  return Arrays.copyOfRange(root.elementData, offset, offset + size);
}

@Override
public <T> T[] toArray(T[] a) {
  checkForComodification(); // <--
  if (a.length < size)
      return (T[]) Arrays.copyOfRange(root.elementData, offset, offset + size, a.getClass());
  System.arraycopy(root.elementData, offset, a, 0, size);
  if (a.length > size)
      a[size] = null;
  return a;
}

Прогнав бенчмарк с изменённым ArrayList-ом обнаруживаем, что теперь производительность метода subList() лишь незначительно уступает производительности baseline-а. Небольшое отставание обусловлено созданием подсписка и вызовом checkForComodification() в начале метода toArray(T[]).


Benchmark size Score Error Unit
list 0 7,2 0,1 ns/op
subList 0 7,5 0,2 ns/op
list 10 24,5 0,5 ns/op
subList 10 25,4 0,6 ns/op
list 100 142,8 4,5 ns/op
subList 100 141,6 2,5 ns/op
list 1000 1243,6 28,5 ns/op
subList 1000 1247,8 23,7 ns/op

В сухом остатке:
Тикет и ссылка на патч (закроют, скорее всего, в Java 11)


Что почитать:
Длинная, сложная и чрезвычайно полезная статья о чёрном колдовстве в омуте ВМ


Исходная переписка по теме заметки: находится тут


Выводы


  • даже до боли знакомые классы могут скрывать недоработки
  • абстрактные коллекции написаны для покрытия как можно большего числа случаев и предлагают обобщённые алгоритмы, поэтому при создании конкретной реализации нередко есть возможность написать более производительный код, заточенный под особенности вашей структуры данных
  • для внесения изменений необязательно быть сотрудником "Оракла"; если у вас есть патч, устраняющий доказанную ошибку или привносящий ощутимое улучшение, — он будет принят к рассмотрению
  • чаще заглядывайте в код платформы: джавист никогда не может знать о JDK слишком много

P. S. Тикет закрыли, изменения влиты.

Комментарии (19)


  1. smer44
    31.01.2018 22:35

    ещё лучше будет вообще без копиварония а пометить начало (i0) и длинну, и доступ к элементамget(i) => get(i0+i), только чтоб юзер помнил что там ссылки на те же элементы, а метод который физически копирует должен бы иметь имя copySubList


    1. tsypanov Автор
      01.02.2018 01:20

      Спецификация требует, чтобы возвращалась именно копия массива.


  1. leventov
    31.01.2018 23:18

    Ваша ссылка на историю изменений в ArrayList неправильная. Вот лучше: http://hg.openjdk.java.net/jdk8u/jdk8u/jdk/log/73d5bcd0585d/src/share/classes/java/util/ArrayList.java


    1. tsypanov Автор
      01.02.2018 01:23

      Спасибо, поменял.


  1. leventov
    31.01.2018 23:24

    Вообще таких недоработок в JDK вагон и маленькая тележка. Они не хотят раздувать код классов коллекций, оптимизируя каждый вариант вызова каждого метода. Хотя с моей точки зрения, стоило бы.


    1. vics001
      01.02.2018 00:28

      Увеличение размера кода негативно влияет на JIT оптимизации.


      1. leventov
        01.02.2018 02:47

        Конкретизируйте? Насколько я знаю, в JIT нет общего лимита на количество или общий объем скомпилированных методов. Имеет значение глубина стека и размер конкретного метода в байтах, но замена общих методов из какого-нибудь AbstractList на оптимизированные реализации ни на то, ни на другое не влияет.


        При росте общего объема машинного кода уменьшается эффективность использования кеша инструкций, но, во-первых, это не относится к JIT, а во-вторых, не так велик эффект… лучше оптимальный метод вне кеша инструкций, чем неоптимальный в кеше.


        1. lany
          01.02.2018 05:44
          +2

          1. leventov
            01.02.2018 19:40

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


    1. tsypanov Автор
      01.02.2018 01:29

      ЕМНИП, в старых версиях у `ArrayList`-а не было собственной реализации подсписка и итератора.


  1. lany
    01.02.2018 04:21
    +2

    А-а-а, ты везде!!! Приходишь на работу — там новые фичареквесты от тебя. Идёшь почитать, что в джаве делается, а там письма от тебя в мейлинг-листе. Хочешь передохнуть, заглядываешь на Хабр, и тут ты!!!


    1. tsypanov Автор
      01.02.2018 11:32
      +1

      Спасибо )) несу высокопроизводительную яву в массы )


  1. lany
    01.02.2018 05:42
    +1

    Кстати, пометка HotSpotIntrinsicCandidate не означает, что это на самом деле интринсик, и уж точно не «позволяет ВМ создавать для них высокопроизводительный машинный код». Чтобы сделать интринсик одной пометки мало. И помечено существенно больше методов, чем на самом деле интринсиков (на то оно и «candidate»). Поэтому закладываться на это не стоит.


    Кстати, можно ещё приложиться к Arrays.asList. Там вроде SubList вообще специально не реализован, а мог бы быть.


    1. tsypanov Автор
      01.02.2018 11:41

      Верно, в документации сказано:


      It indicates that an annotated method may be (but is not guaranteed to be) intrinsified by the HotSpot VM. A method is intrinsified if the HotSpot VM replaces the annotated method with hand-written assembly and/or hand-written compiler IR — a compiler intrinsic — to improve performance.

      Думаю, вместо "создавать для них высокопроизводительный машинный код" мне стоило написать "подменять их реализацию высокопроизводительным машинным кодом". Кстати, интересно было бы узнать, в каких условиях интринсификация не срабатывает.


      В Arrays.asList много чего не реализовано, например hashCode(): в текущей реализации вызывается метод абстрактного списка, использующий итератор, хотя можно было бы написать


      @Override
      public int hashCode() {
        return Arrays.hashCode(a);

      и обойтись без перебора итератором.


      1. lany
        01.02.2018 13:19

        в каких условиях интринсификация не срабатывает.

        Ну так кури сорцы :D


      1. lany
        01.02.2018 13:23

        и обойтись без перебора итератором.

        Надо, кстати, проверить. Теоретически его итератор добили до состояния, когда он может удалиться полностью escape-анализом в плотном цикле.


        1. tsypanov Автор
          02.02.2018 12:23

          Проверил с помощью -prof gc, действительно, даже на восьмёрке итератор не создаётся: gc.alloc.rate.norm постоянно около 0. По времени счётный перебор конечно же быстрее, хотя и ненамного.


          А вот для Collection.containsAll() скаляризация срабатывает только на небольших объёмах:


          @BenchmarkMode(Mode.AverageTime)
          @OutputTimeUnit(TimeUnit.NANOSECONDS)
          @Fork(jvmArgsAppend = {"-XX:+UseParallelGC", "-Xms1g", "-Xmx1g"})
          public class AllMatchVsContainsAllBenchmark {
          
              @Benchmark
              public boolean collectionContainsAll(Data data) {
                  return data.original.containsAll(data.half);
              }
          
              @State(Scope.Thread)
              public static class Data {
          
                  @Param({"10", "100", "1000"})
                  int count;
          
                  @Param({"ArrayList", "HashSet"})
                  String collectionType;
          
                  Collection<Integer> half;
                  Collection<Integer> original;
          
                  @Setup
                  public void setup() {
                      if ("ArrayList".equals(collectionType)) {
                          original = IntStream.range(0, count).boxed().collect(toCollection(ArrayList::new));
                          half = new HashSet<>(halfOfCollection(original));
                      } else {
                          original = IntStream.range(0, count).boxed().collect(toCollection(HashSet::new));
                          half = new HashSet<>(halfOfCollection(original));
                      }
                  }
          
                  private List<Integer> halfOfCollection(Collection<Integer> original) {
                      int newLength = original.size() / 2;
                      Integer[] integers = copyOf(original.toArray(new Integer[0]), newLength);
                      return asList(integers);
                  }
              }
          }

          даёт


          Benchmark           (collectionType)  (count)    Score     Error  Units
          gc.alloc.rate.norm         ArrayList       10   ? 10??             B/op
          gc.alloc.rate.norm         ArrayList      100    0,001 ±   0,001   B/op
          gc.alloc.rate.norm         ArrayList     1000   40,066 ±   0,003   B/op
          gc.alloc.rate.norm           HashSet       10   ? 10??             B/op
          gc.alloc.rate.norm           HashSet      100   ? 10??             B/op
          gc.alloc.rate.norm           HashSet     1000   20,004 ±   6,817   B/op


          1. lany
            02.02.2018 13:35

            Arrays.asList-то ты не проверил.


            1. tsypanov Автор
              02.02.2018 14:13

              Его проверил самым первым, там итератор всегда скаляризуется.