Думаю, большинство джавистов согласится, что 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)

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

leventov
31.01.2018 23:24Вообще таких недоработок в JDK вагон и маленькая тележка. Они не хотят раздувать код классов коллекций, оптимизируя каждый вариант вызова каждого метода. Хотя с моей точки зрения, стоило бы.
vics001
01.02.2018 00:28Увеличение размера кода негативно влияет на JIT оптимизации.

leventov
01.02.2018 02:47Конкретизируйте? Насколько я знаю, в JIT нет общего лимита на количество или общий объем скомпилированных методов. Имеет значение глубина стека и размер конкретного метода в байтах, но замена общих методов из какого-нибудь AbstractList на оптимизированные реализации ни на то, ни на другое не влияет.
При росте общего объема машинного кода уменьшается эффективность использования кеша инструкций, но, во-первых, это не относится к JIT, а во-вторых, не так велик эффект… лучше оптимальный метод вне кеша инструкций, чем неоптимальный в кеше.

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

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

lany
01.02.2018 05:42+1Кстати, пометка
HotSpotIntrinsicCandidateне означает, что это на самом деле интринсик, и уж точно не «позволяет ВМ создавать для них высокопроизводительный машинный код». Чтобы сделать интринсик одной пометки мало. И помечено существенно больше методов, чем на самом деле интринсиков (на то оно и «candidate»). Поэтому закладываться на это не стоит.
Кстати, можно ещё приложиться к Arrays.asList. Там вроде SubList вообще специально не реализован, а мог бы быть.

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);
и обойтись без перебора итератором.

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

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
smer44
ещё лучше будет вообще без копиварония а пометить начало (i0) и длинну, и доступ к элементамget(i) => get(i0+i), только чтоб юзер помнил что там ссылки на те же элементы, а метод который физически копирует должен бы иметь имя copySubList
tsypanov Автор
Спецификация требует, чтобы возвращалась именно копия массива.