Думаю, большинство джавистов согласится, что 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 Автор
Спецификация требует, чтобы возвращалась именно копия массива.