Здравствуйте, хаброжители. Наконец дошли руки написать что-то на хабр. Первая статья была немного скучной и узкоспециализированной. Поэтому я пишу в песочницу во второй раз. (UPD но почему то попал не в песочницу оО)

На этот раз речь пойдет о нововведениях 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)


  1. sand14
    26.02.2018 11:20

    Обобщая написанное, у меня все еще остается несколько вопросов.

    Неужели этот код накидан на скорую руку и никто не хочет им заниматься?

    Тоже возникало достаточно много вопросов при изучении исходников как JDK, так и стандартной библиотеки .NET.


    И если в случае .NET есть .NET Core, где старый код переписывается с нуля,
    то в случае Java ситуация чуть другая — в какой-то момент она стала быстро развиваться, в разы быстрее обычного, и действительно есть ощущение, что часть фич не успевает продуматься как со стороны контракта, так и со стороны реализации.


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


    Например, в .NET, когда был введен оператор "is null", вначале он был реализован "в лоб" — вызовом object.Equals(o, null), что было неоптимально как для ссылочных типов, так и структур (упаковка).
    Потом исправили.


    Наверное, и в Java будут дорабатывать реализацию JDK.


  1. mayorovp
    26.02.2018 11:21

    Разве загрузка класса и инициализация статических полей не происходит всегда лениво и потокобезопасно?


    1. Sultansoy Автор
      26.02.2018 11:25

      Насчет потокобезопасности вы правы, спасибо, исправил.


    1. Sultansoy Автор
      26.02.2018 11:31

      Насчет ленивости:
      habrahabr.ru/post/129494


      1. mayorovp
        26.02.2018 11:35

        А что я должен найти по вашей ссылке? Вот этот комментарий? habrahabr.ru/post/129494/#comment_4286933
        (Там пишут про enum, но и к первому варианту это тоже применимо)


      1. lany
        26.02.2018 12:26
        +1

        Lazy init в случае List0 совершенно не нужен. Вы, видимо, плохо понимаете вообще, когда он нужен. Если не бывает обращений к классу без обращений к инстансу синглтона, то смысла в lazy init никакого. Тут как раз такой случай.


        1. Sultansoy Автор
          26.02.2018 13:26

          Не поспорю, как-то эта часть про синглтон пришла в голову в последний момент, сейчас понимая, что зря добавил. Убрал.


  1. 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 словите громкое исключение и сразу же поймёте, что в коде ошибка.


  1. dernasherbrezon
    26.02.2018 12:21

    Согласен с автором статьи. Такое ощущение, что одна рука совсем не знает, что творит другая. С одной стороны Шипилёв радостно пишет, как они тюнят GC, а с другой стороны в стандартной библиотеке генерируют мусор просто так.


    Вы кстати ещё не видели "новый и модный" time api. Теперь на каждую операцию они предлагают создавать десяток объектов.


    1. lany
      26.02.2018 12:25

      Вы измеряли реальный GC pressure от использования time API в реальных сценариях? Или так сорцы почитали и сразу поняли, что всё будет тормозить?


      1. dernasherbrezon
        26.02.2018 12:31

        Ох сколько раз мне задавали этот вопрос, что я уже придумал стандартный ответ :)


        Если объект выделяется, значит на это тратиться память и CPU циклы, а также значит, что когда он потом не будет нужен gc алгоритм (каким бы продвинутым он не был) тоже потратит CPU циклы чтобы обработать ссылку. Если объект не выделяется то CPU = 0, память = 0. А теперь сравниваем (для этого не нужно прогонять micro benchmark тесты). CPU = 0 (без выделения объекта) << CPU !=0 (с выделением объекта).


        Это однозначный и правильный ответ в не зависимости от Вашей нагрузки на приложение, количество ядер, алгоритма GC, версии Java и пр.


        1. lany
          26.02.2018 12:37
          +1

          Возражение №1: А что если вам только кажется, что объект выделяется? Если в коде написано new, это ещё не означает, что объект действительно выделится.


          Возражение №2: А что если накладные расходы на выделение объекта настолько малы, что составляют 0.0001% от производительности программы, но при этом уберегают вас от возможных ошибок, которые бы вы совершили, если бы API выделяло меньше объектов? Если же вы не готовы пожертвовать даже 0.0001% производительности, тогда вам не следует писать на Java, потому что вы уже жертвуете гораздо большим процентом.


          1. dernasherbrezon
            26.02.2018 12:44

            Re: Возражение №1: Я не знаю таких оптимизаций в JVM, которые бы удаляли new в момент компиляции. Если они работают в runtime, значит JVM будет тратить CPU циклы, до inline метода.


            Re: Возражение №2: У меня нет другого выбора, кроме как использовать стандартную библиотеку. Тут я ничего не могу поделать. Но если рассуждать с позиции "зачем они так сделали?" и предлагать альтернативу, то я бы конечно выбрал реализацию, где объектов выделяется меньше.


            1. 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 — это старый добрый знакомый метод, а стримы — новое, а значит неизвестное и страшное.


              1. dernasherbrezon
                26.02.2018 12:56
                -3

                Приходите ко мне кодить JRadio. Обещаю множество интересных разрушений мифов :)


                1. Regis
                  27.02.2018 06:23

                  Вам бы сперва с JVM поближе познакомиться, прежде чем заниматься «разрешением мифов».


          1. dernasherbrezon
            26.02.2018 12:55
            -1

            Кстати про "0.0001%" мне тоже часто говорили. Но тут у меня есть пример из жизни:
            разработали приложение, которое было "просто прокси". Брало данные из одного сокета и клало в другой. 0ms для такой операции. Спустя год и и пару переписываний на новую модную Java 8, оно стало делать это за 10ms. Тоже вроде немного и укладывается в SLA. Потом добавили еще один сокет, и пришёл бизнес с криками "почему так медленно". Оказалось что приложение работает за 100ms и уже не укладывается в SLA. Как нибудь измеряет производительность после каждого внедрения модного API? Нет, конечно.


            Эта история из жизни не для того, чтобы показать какой List.of плохой. А для того, чтобы предостеречь неокрепшие умы против мифического "0.0001%".


            1. lany
              26.02.2018 12:59
              +1

              — «A» тормозит.
              — Вы уверены?
              — Конечно! Когда я писал «B» с использованием «C», а потом перешёл на «D», то стало медленнее. Значит, «A» тормозит.


            1. mayorovp
              26.02.2018 13:20

              Ваш комментарий неполный без упоминания что же такого там по-написали…


              1. dernasherbrezon
                26.02.2018 13:24

                Основное изменение в critical path было переписывание циклов на stream api.


                1. mayorovp
                  26.02.2018 13:27

                  Каких именно? Циклов по сокетам или циклов по байтам в буфере?


                  1. dernasherbrezon
                    26.02.2018 13:29

                    А это настолько важно? Не могу сказать за все циклы в программе, которую ревьил 3 года назад, но большинство было в циклах по доменным объектам.


                    1. mayorovp
                      26.02.2018 15:38

                      Просто в моем понимании если приложение — «просто прокси» и перекладывает байтики из сокета в сокет, то именно это перекладывание и становится critical path. И его же очень трудно испортить новыми API, просто потому что не лезет оно в эти API :-)

                      Потому и удивляюсь проседанию производительности…


                      1. dernasherbrezon
                        26.02.2018 15:51

                        Ааа!


                        Да, если вдаваться с детали, там чуть сложнее. И 0ms и 10ms — это медиана latency во время симуляции нагрузки.


                1. Maccimo
                  26.02.2018 18:40

                  Зачем вносить изменения, не дающие никаких преимуществ?
                  Изменения ради изменений?


                  1. dernasherbrezon
                    26.02.2018 18:47

                    Нет, конечно. Как я сказал основное было в critical path. Помимо critical path в приложении существует еще сотня-другая других путей.


                  1. shishmakov
                    28.02.2018 11:33

                    Если вы работаете в IDEA и она начинает "желтить" с предложением перевести куски кода циклов в Stream API, то вы не будете на это обращать внимание? Я потихоньку за полгода, год почти все циклы конвертнул.


                    1. Maccimo
                      28.02.2018 12:25
                      +1

                      Если IDEA начинает надоедать инспекцией, НЕ указывающей на проблему в коде, я всё взвешу, и, возможно, отключу эту инспекцию для _этого_ проекта. Неиспользование Stream API, очевидно, проблемой не является.

                      Если инспекция потенциально указывает на баг, то для начала разберусь, баг ли это в самой инспекции или баг в проекте. Затем — либо багрепорт в JB, либо исправление бага в проекте.


                    1. lany
                      01.03.2018 06:23

                      IDEA так не делает, не надо инсинуаций. Эта инспекция по умолчанию в режиме information-only (то есть код не жёлтый, но по Alt+Enter фикс доступен). Если вы её сами включили в своём проекте, IDEA не виновата. Если вы не включали и она вам подсвечивает на только что созданном проекте, то, пожалуйста, сообщите нам.


        1. TheDeadOne
          27.02.2018 11:04

          Аллокация объекта в TLAB осуществляется десятком инструкций процессора, почти бесплатно, а в случае скаляризации память в куче вообще не используется. Так что ваш стандартный ответ — это экономия на спичках.


          1. lany
            27.02.2018 11:28

            Это с одной стороны. А с другой при большом наплыве даже короткоживущих объектов потребуется более частая аллокация новых TLAB'ов и в итоге более частые вызовы minor GC (пусть и работы им не прибавится). В общем, нет простых ответов, всё надо измерять.


          1. dernasherbrezon
            27.02.2018 12:00

            А вы знаете как работает моё приложение? Мой стандартный ответ работает для любого приложения.


            Вот lany говорит, что JVM может оптимизировать оператор new. Было бы круто если бы он привёл хоть одну ссылку.


            1. lany
              27.02.2018 12:32
              +1

              Кушайте на здоровье, если сами гуглить не умеете.


              1. dernasherbrezon
                27.02.2018 12:52

                О! Спасибо! Не знал про такие штуки. Теперь буду спать спокойнее :)


                Однако, опять же согласно статье:
                1) Оптимизация происходит в runtime.
                2) Техника очень спекулятивная. Может где то сработать, где то нет.


                Моя основная претензия к List.of и пр. в том, что разработчики стандартной библиотеки не могут знать где выполняется их код: на критическом участке программы или нет. Поэтому они должны хотеть оптимизировать код для любого варианта. И оптимизировать java код значительно проще, чем с++ код различных хитрых алгоритмов JVM.


                1. lany
                  27.02.2018 14:26

                  Оптимизация происходит в runtime.

                  А что, Java какие-то оптимизации делает не в рантайме?


                  1. dernasherbrezon
                    27.02.2018 14:33

                    В том то и дело, что я не знаю. Есть какие то простые вроде заменить конкатенацию строк StringBuilder'ом. Но про сложные оптимизации уровня С++ я не слышал.


                    1. lany
                      27.02.2018 14:37

                      Ну StringBuilder — это не оптимизация, а необходимость, потому что специального байткода для конкатенации строк нет. Раз ее знаете, то я вам говорю: оптимизации делает оптимизирующий JIT-компилятор, который работает в рантайме. Javac ничего не оптимизирует, если не считать минимального фолдинга констант и удаления недостижимых веток в соответствии с JLS.


  1. lany
    26.02.2018 12:23
    +1

    И к чему нужна была такая вот реализация? Этот вопрос все еще для меня открыт.

    Лучше сразу в публичном API сделать оверлоады, а потом дооптимизировать реализацию, если необходимо. Это можно сделать даже в минорных апдейтах, и ваша программа автоматически станет быстрее (даже без перекомпиляции). Ну и байткод короче будет у вас, что иногда полезно.


  1. koldyr
    26.02.2018 16:02
    -1

    Как всегда ответы на некоторые из вопросов лежат не в Java а в теории категорий. null не хранится в List потому что null не принадлежит T. А List должен быть функтором. В качестве альтернативы, при необходимости, может быть использовано List<Option>.


    1. koldyr
      26.02.2018 20:07

      Если минусанувшим не лень, объясните за что.
      Мне кажется, что я понятно ответил на вопрос: «почему List<T> не может содержать null?».
      К сожалению при наборе потерял <T>, возможно это послужило причиной недопонимания.


      1. leventov
        27.02.2018 01:59

        Вы программируете на Java? В вашем комментарии совсем не та проблематика что на самом деле.


      1. lany
        27.02.2018 05:54

        Не думаю, что авторы этой фичи руководствовались теорией категорий. Скорее практическими соображениями, что где null'ы, там баги. Но три минуса вам действительно многовато, разбавил.


  1. fRoStBiT
    26.02.2018 19:03
    -2

    По поводу ListN и varargs: если вызывать перегрузки List.of() без varargs, ListN использоваться не будет. Он как раз для случая с заранее неизвестным количеством элементов.


    машина (речь больше о компиляторе) глупее человека

    Не компилятор дурак, а Java не позволяет выразить, что метод не возвращает управление нормальным образом. В Kotlin, например, это пофиксили типом Nothing.


    Почему у меня в списке не может храниться null?

    Потому, что null где попало лучше избегать, это известный подход. И конечно же, такое поведение не сюрприз, а написано в документации


    Зачем было создавать столько методов?

    Чтобы не плодить массивы из varargs на ровном месте. Я не нашёл подтверждения в статье, что это не работает.


    1. mayorovp
      26.02.2018 19:05

      static <E> List<E> of(E e1, E e2, E e3) {  
          return new ImmutableCollections.ListN<>(e1, e2, e3);  
      }  


      1. fRoStBiT
        26.02.2018 19:15

        Главное, что в пользовательском коде не будет vararg. Добавить классы List4-List10 хоть в следующем обновлении JDK — не проблема. Если бы не было этих методов, то от использования vararg можно было бы избавиться только перекомпиляцией пользовательского кода.
        Если это ещё не сделали — значит, это не так уж приоритетно и влияет на производительность.


    1. lany
      27.02.2018 05:56

      что метод не возвращает управление нормальным образом.

      В данном случае это не применимо. Метод checkIndex может вернуть управление нормальным образом в зависимости от параметров. Если ему передавать вторым параметром 0, тогда не может.


  1. konsoletyper
    27.02.2018 01:10

    А что я тут ещё заметил: для списков разной длины возвращается свой класс. Это плохо, потом будет какой-то код, написанный для List<T> получать всё это многообразие разных реализаций и нарвёмся мы на мегаморфные вызовы, что сразу убьёт все полезные оптимизации.