На этот раз речь пойдет о нововведениях Java. А именно про ImmutableCollections. Вы наверное где-то уже использовали List.of(). Скорее всего в тестах, ибо какой-то практической ценности в этих методах я не вижу. Но даже в тестах можно наткнуться на банальные подводные камни. Банальны они по причине того, что разок прочитав их код, сразу все становится на свои места, но остается очень и очень много вопросов, почему сделанно именно так, а не по другому.
Начнем пожалуй с интерфейса List, в котором есть статические функции of.
List<E> List<E>.<E>of(E e1);
List<E> List<E>.<E>of(E e1, E e2);
List<E> List<E>.<E>of(E e1, E e2, E e3);
List<E> List<E>.<E>of(E e1, E e2, E e3, E e4);
List<E> List<E>.<E>of(E e1, E e2, E e3, E e4, E e5);
List<E> List<E>.<E>of(E e1, E e2, E e3, E e4, E e5, E e6);
List<E> List<E>.<E>of(E e1, E e2, E e3, E e4, E e5, E e6, E e7);
List<E> List<E>.<E>of(E e1, E e2, E e3, E e4, E e5, E e6, E e7, E e8);
List<E> List<E>.<E>of(E e1, E e2, E e3, E e4, E e5, E e6, E e7, E e8, E e9);
List<E> List<E>.<E>of(E e1, E e2, E e3, E e4, E e5, E e6, E e7, E e8, E e9, E e10);
List<E> List<E>.<E>of(E... elements)
Зачем же в джаве столько методов??? Долго я задавался этим вопросом, но никак не доходили руки порыться в source коде и погуглить. Сегодня таки я нашел ответ, который меня абсолютно неудовлетворил.
The fixed argument overloaded methods are provided to save the overhead of array-allocation, initialization and garbage collection in case of vararg calls.
Казалось бы, все же достаточно логично, меньше объектов, меньше памяти, меньше работы сборщика мусора. Хотя, какая разница, если использовать мы это будем по большому счету в тестах. Но, дело в том, что я считаю этот ответ неверным.
Если мы посмотрим код этих методов, то увидем следующее:
static <E> List<E> of() {
return ImmutableCollections.List0.instance();
}
static <E> List<E> of(E e1) {
return new ImmutableCollections.List1<>(e1);
}
static <E> List<E> of(E e1, E e2) {
return new ImmutableCollections.List2<>(e1, e2);
}
static <E> List<E> of(E e1, E e2, E e3) {
return new ImmutableCollections.ListN<>(e1, e2, e3);
}
static <E> List<E> of(E e1, E e2, E e3, E e4) {
return new ImmutableCollections.ListN<>(e1, e2, e3, e4);
}
/* ... Все остальные такие же и ... */
static <E> List<E> of(E... elements) {
switch (elements.length) { // implicit null check of elements
case 0:
return ImmutableCollections.List0.instance();
case 1:
return new ImmutableCollections.List1<>(elements[0]);
case 2:
return new ImmutableCollections.List2<>(elements[0], elements[1]);
default:
return new ImmutableCollections.ListN<>(elements);
}
}
Первые 3 метода возвращают нам странные объекты классов List0, List1, List2. Все остальные вернут ListN. И последний метод, в котором используются varargs может вернуть один из перечисленных списков. Начиная от 3 элементов и до 10 мы действительно отправляем в метод аргументы, и ни в какой массив эти данные не обернутся.
Казалось бы, как же все хорошо, но давайте копать еще дальше. Посмотрим реализацию конструктора ListN<>
ListN(E... input) {
@SuppressWarnings("unchecked")
E[] tmp = (E[])new Object[input.length]; // implicit nullcheck of input
for (int i = 0; i < input.length; i++) {
tmp[i] = Objects.requireNonNull(input[i]);
}
this.elements = tmp;
}
Как можно заметить, тут уже присутствует синтаксис varargs. А это значит, что даже если во время первого вызова оборачивания в массив избежать удалось, в случае вызова конструктора это все равно произошло. И к чему нужна была такая вот реализация? Этот вопрос все еще для меня открыт. Буду рад, если кто в комментариях расскажет.
Теперь про подводные камни этих коллекций. Давайте посмотрим на реализацию этих коллекций изнутри.
Во главе всех Immutable list-ов стоит:
abstract static class AbstractImmutableList<E> extends AbstractList<E>
implements RandomAccess, Serializable
Все методы этой абстрактой коллекции кидают UnsupportedOperationException. Не все абстрактные методы из AbstractList заоверрайдили в этом классе. Поэтому прилагаю код:
@Override public boolean add(E e) { throw uoe(); }
@Override public boolean addAll(Collection<? extends E> c) { throw uoe(); }
@Override public boolean addAll(int index, Collection<? extends E> c) { throw uoe(); }
@Override public void clear() { throw uoe(); }
@Override public boolean remove(Object o) { throw uoe(); }
@Override public boolean removeAll(Collection<?> c) { throw uoe(); }
@Override public boolean removeIf(Predicate<? super E> filter) { throw uoe(); }
@Override public void replaceAll(UnaryOperator<E> operator) { throw uoe(); }
@Override public boolean retainAll(Collection<?> c) { throw uoe(); }
@Override public void sort(Comparator<? super E> c) { throw uoe(); }
То есть, к примеру, метод containsAll будет иметь ту же реализацию, что и все остальные коллекции, которые мы успешно использовали, и то не всегда. Но сейчас не об этом.
Классы List0, List1, List2 и ListN наследуются от класса AbstractImmutableList. Каждый класс реализует часть методов.
Возьмем к примеру класс List0. Метод contains может выкинуть NullPointerException.
@Override
public boolean contains(Object o) {
Objects.requireNonNull(o);
return false;
}
Вот это очень неожиданно. Почему нельзя было просто всегда возвращать false? Зачем тут проверка на null. Это мне остается непонятным.
Поведение метода containsAll такое же, как и в обычных списках.
public boolean containsAll(Collection<?> o) {
return o.isEmpty(); // implicit nullcheck of o
}
Правда NPE выскочит из-за вызова метода isEmpty(), а не цикла for each как в обычных списках.
В source коде я заметил один комментарий, который поднял настроение и напомнил мне, сколь машина (речь больше о компиляторе) глупее человека.
@Override
public E get(int index) {
Objects.checkIndex(index, 0); // always throws IndexOutOfBoundsException
return null; // but the compiler doesn't know this
}
Поехали далее к List1. Тут вопросов больше. Начнем с конструткора.
List1(E e0) {
this.e0 = Objects.requireNonNull(e0);
}
Почему у меня в списке не может храниться null? В чем тут логика? Поехали далее. Метод contains все так же выкидывает NPE.
@Override
public boolean contains(Object o) {
return o.equals(e0); // implicit nullcheck of o
}
Хотя тут уже логичнее. Если конструктор не дает создание списков с null, то это ожидаемо. Но что мешало написать:
return o != null && o.equals(e0);
или куда красивее:
return e0.equals(o);
Опять же опираясь на то, что e0 не может быть null. Реализация метода containsAll лежит в классе AbstractCollection:
public boolean containsAll(Collection<?> c) {
for (Object e : c)
if (!contains(e))
return false;
return true;
}
Если коллекция null, то мы получим такой же NPE, как и в случае с обычными коллекциями. Но так же мы получим NPE, если в коллекции из параметров есть null, так как присутствует зависимость от метода contains, который и будет давать нам этот NPE.
При чем NPE здесь достаточно опасный.
List<String> list = new ArrayList<>();
list.add("FOO");
list.add(null);
List<String> immutableList = List.of("foo", "bar");
immutableList.containsAll(list);
В этом случае мы не словим NPE, так как наш список не содержит строки «FOO». Мы сразу получим в ответ false. Если же в нашем ArrayList первый элемент был бы «foo», то мы тут же словили бы NPE. Поэтому, будьте крайне осторожны в таких ситуациях.
List2 и ListN грешны тем же.
Обобщая написанное, у меня все еще остается несколько вопросов. Почему данные коллекции не ведут себя так же, как и обычные ArrayList, LinkedList? Почему коллекции не могут содержать null. Зачем было создавать столько методов? Неужели этот код накидан на скорую руку и никто не хочет им заниматься? Но, так как ответы на эти вопросы я дать не могу, остается использовать, что есть, зная о подводных камнях, которые присутствуют в этих новых удобных фичах.
П.С. Предполагаю, что Set и Map тоже имеют свои схожие подводные камни. Но до них я дотянусь как-нибудь потом, когда появится еще несколько минут свободного времени.
Комментарии (48)
mayorovp
26.02.2018 11:21Разве загрузка класса и инициализация статических полей не происходит всегда лениво и потокобезопасно?
Sultansoy Автор
26.02.2018 11:31Насчет ленивости:
habrahabr.ru/post/129494mayorovp
26.02.2018 11:35А что я должен найти по вашей ссылке? Вот этот комментарий? habrahabr.ru/post/129494/#comment_4286933
(Там пишут про enum, но и к первому варианту это тоже применимо)
lany
26.02.2018 12:26+1Lazy init в случае List0 совершенно не нужен. Вы, видимо, плохо понимаете вообще, когда он нужен. Если не бывает обращений к классу без обращений к инстансу синглтона, то смысла в lazy init никакого. Тут как раз такой случай.
Sultansoy Автор
26.02.2018 13:26Не поспорю, как-то эта часть про синглтон пришла в голову в последний момент, сейчас понимая, что зря добавил. Убрал.
lany
26.02.2018 12:20Неужели этот код накидан на скорую руку и никто не хочет им заниматься?
Нет, этот код писали и переписывали очень долго и обсуждали с кучей людей, и ругались. И в Java 10 ещё допереписывали. Это выверенная и продуманная реализация.
Вот это очень неожиданно. Почему нельзя было просто всегда возвращать false? Зачем тут проверка на null. Это мне остается непонятным.
Открываем контракт класса Collection и читаем:
Throws:
NullPointerException
— if the specified element is null and this collection does not permit null elements (optional)Коллекция поддерживает null-элементы? Нет. Значит, по контракту имеет право кидать NPE. Зачем это сделано? Если вы случайно вызовете на ней
contains(null)
(что не очень имеет смысл) вы вместо тихого false словите громкое исключение и сразу же поймёте, что в коде ошибка.
dernasherbrezon
26.02.2018 12:21Согласен с автором статьи. Такое ощущение, что одна рука совсем не знает, что творит другая. С одной стороны Шипилёв радостно пишет, как они тюнят GC, а с другой стороны в стандартной библиотеке генерируют мусор просто так.
Вы кстати ещё не видели "новый и модный" time api. Теперь на каждую операцию они предлагают создавать десяток объектов.
lany
26.02.2018 12:25Вы измеряли реальный GC pressure от использования time API в реальных сценариях? Или так сорцы почитали и сразу поняли, что всё будет тормозить?
dernasherbrezon
26.02.2018 12:31Ох сколько раз мне задавали этот вопрос, что я уже придумал стандартный ответ :)
Если объект выделяется, значит на это тратиться память и CPU циклы, а также значит, что когда он потом не будет нужен gc алгоритм (каким бы продвинутым он не был) тоже потратит CPU циклы чтобы обработать ссылку. Если объект не выделяется то CPU = 0, память = 0. А теперь сравниваем (для этого не нужно прогонять micro benchmark тесты). CPU = 0 (без выделения объекта) << CPU !=0 (с выделением объекта).
Это однозначный и правильный ответ в не зависимости от Вашей нагрузки на приложение, количество ядер, алгоритма GC, версии Java и пр.
lany
26.02.2018 12:37+1Возражение №1: А что если вам только кажется, что объект выделяется? Если в коде написано
new
, это ещё не означает, что объект действительно выделится.
Возражение №2: А что если накладные расходы на выделение объекта настолько малы, что составляют 0.0001% от производительности программы, но при этом уберегают вас от возможных ошибок, которые бы вы совершили, если бы API выделяло меньше объектов? Если же вы не готовы пожертвовать даже 0.0001% производительности, тогда вам не следует писать на Java, потому что вы уже жертвуете гораздо большим процентом.
dernasherbrezon
26.02.2018 12:44Re: Возражение №1: Я не знаю таких оптимизаций в JVM, которые бы удаляли new в момент компиляции. Если они работают в runtime, значит JVM будет тратить CPU циклы, до inline метода.
Re: Возражение №2: У меня нет другого выбора, кроме как использовать стандартную библиотеку. Тут я ничего не могу поделать. Но если рассуждать с позиции "зачем они так сделали?" и предлагать альтернативу, то я бы конечно выбрал реализацию, где объектов выделяется меньше.
lany
26.02.2018 12:53Я не знаю таких оптимизаций в JVM, которые бы удаляли new в момент компиляции.
Ну если вы не знаете, это же не значит, что их нет.
то я бы конечно выбрал реализацию, где объектов выделяется меньше.
Такое чувство, что вам просто не нравятся объекты сами по себе. Не там вы ищете узкое место производительности, совсем не там.
Напоминает мне людей, которые говорили, что
IntStream.range(0, 100).forEach(System.out::println)
ужасно тормозит по сравнению сfor(int i=0; i<100; i++) System.out.println(i);
и тоже рассуждали про кучу объектов, про ужасно развесистую реализацию стримов, про то что method-reference разворачивается в класс в рантайме. При этом никого не волнует, что каждый вызовSystem.out.println
на несколько порядков медленнее всего остального (а если он пишет в системную виндовую консоль, то это вообще капец). Просто потому чтоprintln
— это старый добрый знакомый метод, а стримы — новое, а значит неизвестное и страшное.dernasherbrezon
26.02.2018 12:56-3Приходите ко мне кодить JRadio. Обещаю множество интересных разрушений мифов :)
Regis
27.02.2018 06:23Вам бы сперва с JVM поближе познакомиться, прежде чем заниматься «разрешением мифов».
dernasherbrezon
26.02.2018 12:55-1Кстати про "0.0001%" мне тоже часто говорили. Но тут у меня есть пример из жизни:
разработали приложение, которое было "просто прокси". Брало данные из одного сокета и клало в другой. 0ms для такой операции. Спустя год и и пару переписываний на новую модную Java 8, оно стало делать это за 10ms. Тоже вроде немного и укладывается в SLA. Потом добавили еще один сокет, и пришёл бизнес с криками "почему так медленно". Оказалось что приложение работает за 100ms и уже не укладывается в SLA. Как нибудь измеряет производительность после каждого внедрения модного API? Нет, конечно.
Эта история из жизни не для того, чтобы показать какой List.of плохой. А для того, чтобы предостеречь неокрепшие умы против мифического "0.0001%".
lany
26.02.2018 12:59+1— «A» тормозит.
— Вы уверены?
— Конечно! Когда я писал «B» с использованием «C», а потом перешёл на «D», то стало медленнее. Значит, «A» тормозит.
mayorovp
26.02.2018 13:20Ваш комментарий неполный без упоминания что же такого там по-написали…
dernasherbrezon
26.02.2018 13:24Основное изменение в critical path было переписывание циклов на stream api.
mayorovp
26.02.2018 13:27Каких именно? Циклов по сокетам или циклов по байтам в буфере?
dernasherbrezon
26.02.2018 13:29А это настолько важно? Не могу сказать за все циклы в программе, которую ревьил 3 года назад, но большинство было в циклах по доменным объектам.
mayorovp
26.02.2018 15:38Просто в моем понимании если приложение — «просто прокси» и перекладывает байтики из сокета в сокет, то именно это перекладывание и становится critical path. И его же очень трудно испортить новыми API, просто потому что не лезет оно в эти API :-)
Потому и удивляюсь проседанию производительности…dernasherbrezon
26.02.2018 15:51Ааа!
Да, если вдаваться с детали, там чуть сложнее. И 0ms и 10ms — это медиана latency во время симуляции нагрузки.
Maccimo
26.02.2018 18:40Зачем вносить изменения, не дающие никаких преимуществ?
Изменения ради изменений?dernasherbrezon
26.02.2018 18:47Нет, конечно. Как я сказал основное было в critical path. Помимо critical path в приложении существует еще сотня-другая других путей.
shishmakov
28.02.2018 11:33Если вы работаете в IDEA и она начинает "желтить" с предложением перевести куски кода циклов в Stream API, то вы не будете на это обращать внимание? Я потихоньку за полгода, год почти все циклы конвертнул.
Maccimo
28.02.2018 12:25+1Если IDEA начинает надоедать инспекцией, НЕ указывающей на проблему в коде, я всё взвешу, и, возможно, отключу эту инспекцию для _этого_ проекта. Неиспользование Stream API, очевидно, проблемой не является.
Если инспекция потенциально указывает на баг, то для начала разберусь, баг ли это в самой инспекции или баг в проекте. Затем — либо багрепорт в JB, либо исправление бага в проекте.
lany
01.03.2018 06:23IDEA так не делает, не надо инсинуаций. Эта инспекция по умолчанию в режиме information-only (то есть код не жёлтый, но по Alt+Enter фикс доступен). Если вы её сами включили в своём проекте, IDEA не виновата. Если вы не включали и она вам подсвечивает на только что созданном проекте, то, пожалуйста, сообщите нам.
TheDeadOne
27.02.2018 11:04Аллокация объекта в TLAB осуществляется десятком инструкций процессора, почти бесплатно, а в случае скаляризации память в куче вообще не используется. Так что ваш стандартный ответ — это экономия на спичках.
lany
27.02.2018 11:28Это с одной стороны. А с другой при большом наплыве даже короткоживущих объектов потребуется более частая аллокация новых TLAB'ов и в итоге более частые вызовы minor GC (пусть и работы им не прибавится). В общем, нет простых ответов, всё надо измерять.
dernasherbrezon
27.02.2018 12:00А вы знаете как работает моё приложение? Мой стандартный ответ работает для любого приложения.
Вот lany говорит, что JVM может оптимизировать оператор new. Было бы круто если бы он привёл хоть одну ссылку.
lany
27.02.2018 12:32+1Кушайте на здоровье, если сами гуглить не умеете.
dernasherbrezon
27.02.2018 12:52О! Спасибо! Не знал про такие штуки. Теперь буду спать спокойнее :)
Однако, опять же согласно статье:
1) Оптимизация происходит в runtime.
2) Техника очень спекулятивная. Может где то сработать, где то нет.
Моя основная претензия к List.of и пр. в том, что разработчики стандартной библиотеки не могут знать где выполняется их код: на критическом участке программы или нет. Поэтому они должны хотеть оптимизировать код для любого варианта. И оптимизировать java код значительно проще, чем с++ код различных хитрых алгоритмов JVM.
lany
27.02.2018 14:26Оптимизация происходит в runtime.
А что, Java какие-то оптимизации делает не в рантайме?
dernasherbrezon
27.02.2018 14:33В том то и дело, что я не знаю. Есть какие то простые вроде заменить конкатенацию строк StringBuilder'ом. Но про сложные оптимизации уровня С++ я не слышал.
lany
27.02.2018 14:37Ну StringBuilder — это не оптимизация, а необходимость, потому что специального байткода для конкатенации строк нет. Раз ее знаете, то я вам говорю: оптимизации делает оптимизирующий JIT-компилятор, который работает в рантайме. Javac ничего не оптимизирует, если не считать минимального фолдинга констант и удаления недостижимых веток в соответствии с JLS.
lany
26.02.2018 12:23+1И к чему нужна была такая вот реализация? Этот вопрос все еще для меня открыт.
Лучше сразу в публичном API сделать оверлоады, а потом дооптимизировать реализацию, если необходимо. Это можно сделать даже в минорных апдейтах, и ваша программа автоматически станет быстрее (даже без перекомпиляции). Ну и байткод короче будет у вас, что иногда полезно.
koldyr
26.02.2018 16:02-1Как всегда ответы на некоторые из вопросов лежат не в Java а в теории категорий. null не хранится в List потому что null не принадлежит T. А List должен быть функтором. В качестве альтернативы, при необходимости, может быть использовано List<Option>.
koldyr
26.02.2018 20:07Если минусанувшим не лень, объясните за что.
Мне кажется, что я понятно ответил на вопрос: «почему List<T> не может содержать null?».
К сожалению при наборе потерял <T>, возможно это послужило причиной недопонимания.leventov
27.02.2018 01:59Вы программируете на Java? В вашем комментарии совсем не та проблематика что на самом деле.
lany
27.02.2018 05:54Не думаю, что авторы этой фичи руководствовались теорией категорий. Скорее практическими соображениями, что где null'ы, там баги. Но три минуса вам действительно многовато, разбавил.
fRoStBiT
26.02.2018 19:03-2По поводу ListN и varargs: если вызывать перегрузки
List.of()
без varargs, ListN использоваться не будет. Он как раз для случая с заранее неизвестным количеством элементов.
машина (речь больше о компиляторе) глупее человека
Не компилятор дурак, а Java не позволяет выразить, что метод не возвращает управление нормальным образом. В Kotlin, например, это пофиксили типом
Nothing
.
Почему у меня в списке не может храниться null?
Потому, что
null
где попало лучше избегать, это известный подход. И конечно же, такое поведение не сюрприз, а написано в документации
Зачем было создавать столько методов?
Чтобы не плодить массивы из varargs на ровном месте. Я не нашёл подтверждения в статье, что это не работает.
mayorovp
26.02.2018 19:05static <E> List<E> of(E e1, E e2, E e3) { return new ImmutableCollections.ListN<>(e1, e2, e3); }
fRoStBiT
26.02.2018 19:15Главное, что в пользовательском коде не будет vararg. Добавить классы List4-List10 хоть в следующем обновлении JDK — не проблема. Если бы не было этих методов, то от использования vararg можно было бы избавиться только перекомпиляцией пользовательского кода.
Если это ещё не сделали — значит, это не так уж приоритетно и влияет на производительность.
lany
27.02.2018 05:56что метод не возвращает управление нормальным образом.
В данном случае это не применимо. Метод checkIndex может вернуть управление нормальным образом в зависимости от параметров. Если ему передавать вторым параметром 0, тогда не может.
konsoletyper
27.02.2018 01:10А что я тут ещё заметил: для списков разной длины возвращается свой класс. Это плохо, потом будет какой-то код, написанный для
List<T>
получать всё это многообразие разных реализаций и нарвёмся мы на мегаморфные вызовы, что сразу убьёт все полезные оптимизации.
sand14
Тоже возникало достаточно много вопросов при изучении исходников как JDK, так и стандартной библиотеки .NET.
И если в случае .NET есть .NET Core, где старый код переписывается с нуля,
то в случае Java ситуация чуть другая — в какой-то момент она стала быстро развиваться, в разы быстрее обычного, и действительно есть ощущение, что часть фич не успевает продуматься как со стороны контракта, так и со стороны реализации.
С другое стороны, реализацию, в отличие от контракта, всегда можно доработать в новых выпусках.
Если только уже не написано много кода, зависящего от побочных эффектов старых реализаций.
Например, в .NET, когда был введен оператор "is null", вначале он был реализован "в лоб" — вызовом object.Equals(o, null), что было неоптимально как для ссылочных типов, так и структур (упаковка).
Потом исправили.
Наверное, и в Java будут дорабатывать реализацию JDK.