Java: вещи, которые могут показаться любопытными и разработчику со стажем

    Доброе время суток!

    Статья написана по следам публикации «Вещи, которые вы [возможно] не знали о Java» другого автора, которую я бы отнёс к категории «для начинающих». Читая и комментируя её, я понял, что есть некоторое количество достаточно любопытных вещей, которые я узнал, уже программируя на java не один год. Возможно, эти вещи покажутся любопытными кому-то ещё.

    Факты, которые, с моей точки зрения, могут быть полезны именно начинающим, я убрал в «спойлеры». Некоторые вещи там всё же могут быть интересны и для более опытных. Например, сам я до момента написания статьи не знал, что Boolean.hashCode(true) == 1231 и Boolean.hashCode(false) == 1237.

    для начинающих
    • Boolean.hashCode(true) == 1231
    • Boolean.hashCode(false) == 1237
    • Float.hashCode(value) == Float.floatToIntBits(value)
    • Double.hashCode(value) — xor первого и второго 32-битных полуслов Double.doubleToLongBits(value)

    Object.hashCode() уже давно не является адресом объекта в памяти


    Оговорка: это деталь jvm от Oracle (HotSpot).

    Когда-то очень давно это было так.
    Из jdk1.2.1/docs/api/java/lang/Object.html#hashCode():
    As much as is reasonably practical, the hashCode method defined by class Object does return distinct integers for distinct objects. (This is typically implemented by converting the internal address of the object into an integer, but this implementation technique is not required by the JavaTM programming language.)

    Потом от этого отказались. Вот что об этом написано в javadoc для jdk 12.

    vladimir_dolzhenko подсказал, что старое поведение можно восстановить при помощи -XX:hashCode=4. И само изменение поведения было чуть ли не с версии java 1.2.

    Integer.valueOf(15) == Integer.valueOf(15); Integer.valueOf(128) != Integer.valueOf(128)


    Оговорка: это часть jls.

    Понятно, при сравнении двух обёрток оператором == (!=) никакого autoboxing не происходит. Вообще говоря, именно первое равенство смущает. Дело в том, что для целых значений i: -129 < i < 128 объекты-обёртки Integer кэшируются. Поэтому для i из этого диапазона Integer.valueOf(i) не создаёт каждый раз новый объект, а возвращает уже созданный. Для i, не попадающих в этот диапазон, Integer.valueOf(i) всегда создаёт новый объект. Поэтому, если пристально не следить за тем, что именно и как именно сравнивается, можно написать код, который вроде бы работает и даже покрыт тестами, но в то же время содержащий такие вот «грабли».

    В jvm от Oracle (HotSpot) верхний предел кэширования может быть изменён через property «java.lang.Integer.IntegerCache.high».

    в некоторых случаях значения примитивных или строковых final static полей другого класса разрешаются во время компиляции


    Звучит запутанно, да и утверждение длинновато. Смысл такой. Если у нас есть класс, в котором определены константы примитивных типов или строк как final static поля с немедленной инициализацией,
    class AnotherClass {
        public final static String CASE_1 = "case_1";
        public final static String CASE_2 = "case_2";
    }
    

    то при их использовании в других классах,
    class TheClass {
        // ...
        public static int getCaseNumber(String caseName) {
            switch (caseName) {
                case AnotherClass.CASE_1:
                    return 1;
                case AnotherClass.CASE_2:
                    return 2;
                default:
                    throw new IllegalArgumentException("value of the argument caseName does not belong to the allowed value set");
            }
        }
    }
    

    значения этих констант («case_1», «case_2») разрешаются во время компиляции. И вставляются в код как значения, а не как ссылки. То есть, если мы используем такие константы из библиотеки, а потом получаем новую версию библиотеки, в которой значения констант изменились, нам стоит перекомпилировать проект. Иначе в коде могут продолжать использоваться старые значения констант.

    Такое поведение наблюдается во всех местах, где либо обязаны быть использованы константные выражения (например, switch/case), либо компилятору разрешено преобразовывать выражения к константным и он может это сделать.

    Эти поля не смогут использоваться в константных выражениях, как только мы уберём немедленную инициализацию, перенеся инициализацию в блок static.

    для начинающих

    при определённых условиях сборщик мусора может быть ни разу не запущен


    Как следствие, ни один finalize() не будет запущен. Поэтому не стоит писать код, который полагается на то, что finalize() всегда отработает. Да и если объект попал в мусор перед окончанием работы программы, скорее всего, он не будет собран сборщиком.

    метод finalize() для конкретного объекта может быть вызван один и только один раз


    В finalize() мы можем сделать объект снова видимым, и сборщик мусора его в этот раз не «уберёт». Когда этот объект снова попадёт в мусор, он будет «собран» без вызова finalize(). Если в finalize() будет выброшено исключение и объект по-прежнему останется никому не видимым, он будет потом «собран». Повторно finalize() вызван не будет.

    поток, в котором будет вызван finalize(), заранее не известен


    Гарантируется только то, что этот поток будет свободен от блокировок, видимых основной программой.

    наличие переопределённого метода finalize() у объектов замедляет процесс сборки мусора


    То, что лежит на поверхности, это необходимость двойной проверки на доступность объекта — один раз перед вызовом finalize(), один раз в одном из следующих запусков сборщика мусора.

    с дедлоками в finalize() очень тяжело бороться


    В нетривиальных finalize() могут быть необходимы блокировки, отлаживать которые, учитывая специфику, описанную выше, очень непросто.

    Object.finalize() начиная с 9-ой версии java помечен как deprecated!


    Что неудивительно, учитывая описанную выше специфику.

    классическая «ленивая» инициализация синглтона: двойная блокировка обязательна


    На эту тему есть заблуждение, что следующий подход (double-check idiom), выглядящий весьма логично, всегда работает:
    public class UnsafeDCLFactory {
        private Singleton instance;
    
        public Singleton get() {
            if (instance == null) {  // read 1, check 1
                synchronized (this) {
                    if (instance == null) { // read 2, check 2
                        instance = new Singleton();
                    }
                }
            }
            return instance; // read 3
        }
    }
    

    Мы смотрим, создан ли объект (read 1, check 1). Если да, то возвращаем его. Если нет, то ставим блокировку, убеждаемся, что объект не создан, создаём объект, (блокировка убирается), и возвращаем объект.

    Подход не работает по следующим причинам. (read 1, check 1) и (read 3) не синхронизированы. По концепции модели памяти java, изменения, сделанные в другом потоке, могут быть невидимы нашему потоку, пока мы не синхронизируемся.Спасибо mk2 за комментарий, вот правильное описание проблемы:
    Да, read1 и read3 не синхронизированы, но проблема не в другом потоке. А в том, что несинхронизированные чтения могут быть переупорядочены, т.е. read1 != null, но read3 == null. И заодно, из-за «instance = new Singleton();» мы можем получить ссылку на объект до того, как он был до конца сконструирован, и это действительно проблема синхронизации с другим потоком, но не read1 и read3, а read3 и доступа к членам instance.
    Лечится либо добавлением синхронизации при чтении, либо пометкой переменной, в которой живёт ссылка на синглтон, volatile. (Решение с volatile работает только с java 5+. До этого в java была модель памяти с неопределённостью именно в этой ситуации.) Вот работающий вариант (с дополнительной оптимизацией — добавлена локальная переменная `res`, чтобы уменьшить количество чтений из volatile поля).
    public class SafeLocalDCLFactory {
        private volatile Singleton instance;
    
        public Singleton getInstance() {
            Singleton res = instance;     // read 1
            if (res == null) {            // check 1
                synchronized (this) {
                    res = instance;       // read 2
                    if (res == null) {    // check 2
                        res = new Singleton();
                        instance = res;
                    }
                }
            }
            return res;
        }
    }
    

    Код взят отсюда, с сайта Алексея Шипилёва. Подробнее с этой проблемой можно ознакомиться на нём же.

    «initialization-on-demand holder idiom» — очень красивая «ленивая» инициализация синглтона


    java инициализирует классы (Class objects) только по мере необходимости и, естественно, только один раз. И этим можно воспользоваться! Механизм «initialization-on-demand holder idiom» именно этим и занимается. (Код отсюда.)
    public class Something {
        private Something() {}
    
        private static class LazyHolder {
            static final Something INSTANCE = new Something();
        }
    
        public static Something getInstance() {
            return LazyHolder.INSTANCE;
        }
    }
    

    Класс LazyHolder будет инициализирован только в момент первого вызова Something.getInstance(). Jvm позаботится о том, чтобы это произошло только один раз и притом весьма эффективно — в случае, если класс уже инициализирован, никаких накладных расходов не будет. Соответственно, LazyHolder.INSTANCE будет тоже инициализирован единожды, «лениво» и потокобезопасно.
    кусок спецификации про накладные расходы
    If this initialization procedure completes normally and the Class object is fully initialized and ready for use, then the invocation of the initialization procedure is no longer necessary and it may be eliminated from the code — for example, by patching it out or otherwise regenerating the code.
    Источник.

    Вообще говоря, синглтоны считаются не самой лучшей практикой.

    Материал не закончился. Так что, если «дойдут» руки и то, что уже написано будет востребовано, напишу как-нибудь ещё на эту тему.

    Спасибо за конструктивные комментарии. Несколько мест в статье были расширены благодаря sergey-gornostaev, vladimir_dolzhenko, OlehKurpiak, mk2.
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 30

      +2
      Не существует Oracle JVM. Вероятно, вы имели ввиду HotSpot.
        +2
        Понятно, при сравнении двух обёрток оператором == (!=) никакого autoboxing не происходит. Вообще говоря, именно первое равенство смущает. Дело в том, что для целых значений i: -129 < i < 128 объекты-обёртки Integer кэшируются. Поэтому для i из этого диапазона Integer.valueOf(i) не создаёт каждый раз новый объект, а возвращает уже созданный.

        А вот если бы не принудительный, где надо и не надо, ООП то этого бессмысленного дроча бы не было. По моему мнению если даже для сравнения чисел мне надо иметь ввиду какие то грабли, то тут явно что то не так…
          0
          Никаких граблей, просто в Java значения сравниваются с помощью equals.
            0

            Я думаю, это можно считать граблями. В 99% случаев программисту нужен equals. Даже для скорости == не обязательно использовать, т.к. любая адекватная реализация equals сама проверяет первым делом через == и после inline эта проверка будет стоять первой же инструкцией.


            Т.е. если оглядываться сейчас на этот вопрос, куда правильней было бы ввести перегрузку оператора конкретно для == (хотя можно было бы и для других операторов, но это уже другой вопрос) и вызывать equals, когда программист пишет ==. А для сравнения по адресу ввести функцию в Object, что-то вроде a.referenceEquals(b). Собственно и Kotlin и Scala сделали похожим образом и что-то не припомню, чтобы кто-то жаловался.


            Причём с точки зрения обратной совместимости это уже в Java к сожалению не исправить.

              +2
              Никаких граблей

              Ну такое себе:


              // a == null
              a.equals(b);
              // NullPointerException
                +3
                Objects.equals(a, b);
            +1
            Вообще говоря, синглтоны считаются не самой лучшей практикой.

            Как вежливо и дипломатично получилось.
            Жёстче надо с ними, синглтоны — вирусное зло в ООП и неисчерпаемый источник граблей!))
              +6

              Весь пост должен быть в спойлере "для начинающих" т.к мультипоточная инициализация синглтона это чуть ли не первое что показано в абсолютно всех пособиях по многопотрчности JAVA, с практически идентичными кодом. Здесь можно было бы наверно какую-нибудь нормальную теорию показать о том как выведено happenes-before.


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

                +4
                Сколько лет назад уже предложили одиночку-ENUM? Зачем в 2020-м насиловать стюардессу?
                  0
                  Да, вчера ночью об «одиночке-ENUMе» даже и не вспомнил. Наверно, потому, что стараюсь жить без «одиночек». Ну а стюардесса изредка в каком-нибудь проекте возьмёт, да и всплывёт…
                  +1

                  Для некоторых платформ улучшение возможно — по ссылке в статье Шипилев показал небольшую разницу для ARM.


                  На x86 разницы быть действительно не может — там чтение из volatile «бесплатное» (нет оверхеда, если не считать запрет на reordering)

                  +1
                  Дело в том, что для целых значений i: -129 < i < 128 объекты-обёртки Integer кэшируются.

                  Для начинающих и не только:
                  Я бы добавил еще что нет верхнего предела. Точнее он есть, но его можно изменить.

                  github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/lang/Integer.java#L1010
                    0
                    Спасибо, добавил.
                    +4

                    hashCode не является адресом объекта, кажется, ещё с версии 1.2. Что изменилось в Java 9 — так это исправили javadoc. Однако даже до этого было сказано, что оно может быть реализовано так, но это не требуется спецификацией. Не стоит выдавать желаемое за действительное.


                    Однако, при помощи -XX:hashCode=4 можно вернуть желаемое поведение.

                      0
                      Спасибо, про -XX:hashCode=4 я совсем забыл. Сейчас добавлю.

                      Версии java, начиная с которой поведение изменилось, в статье не указывалось.

                      Согласен, в данном месте следовало вставить оговорку, что это являлось особенностью конкретной jvm и спецификацией не требовалось. Сейчас вставлю.
                        0
                        Когда-то это было так.Теперь, как минимум с 11-ой версии java, это не так.

                        Как минимум с 9ой исправлен javadoc — и даже в минимум Sun JRE 1.2 hashCode не возвращал адрес объекта. Т.е референс, что когда-то это было так — сомнительный.

                          0

                          hashCode не может возвращать адрес объекта с тех пор, как GC научился эти самые объекты перемещать (естественно, не меняя их хеш-кода)

                            0
                            Скорее, с этого момента hashCode пришлось записывать.
                              0

                              Это ж одно и то же.

                              +1

                              может или не может — это вопрос второй, в теории никто не запрещает это делать, ведь hash-функция нужна для того, чтобы превращать поиск в hash-структурах из O(N) в O(1).
                              Но коллизии неизбежны

                                +2

                                Как раз таки запрещает: значение хеш-функции обязано быть детерминированным, и не имеет права меняться в ходе сборки мусора. Иначе положенные в хеш-таблицу записи потом будет не найти.

                                  +1

                                  Вычисленный hashCode по-умолчанию хранится в заголовке объекта и уже не меняется.
                                  Я имел в виду, что никто не запрещает, чтобы это значение было вычислено как адрес объекта, или просто константа (-XX:hashCode=5 кажется)

                              0
                              vladimir_dolzhenko поищу более ранний javadoc… Ну, или ссылку сотру, и вставлю прямым текстом…
                          +3
                          Подход не работает по следующим причинам. (read 1, check 1) и (read 3) не синхронизированы. По концепции модели памяти java, изменения, сделанные в другом потоке, могут быть невидимы нашему потоку, пока мы не синхронизируемся.

                          Здесь вы неправильно описываете проблему. Да, read1 и read3 не синхронизированы, но проблема не в другом потоке. А в том, что несинхронизированные чтения могут быть переупорядочены, т.е. read1 != null, но read3 == null. И заодно, из-за "instance = new Singleton();" мы можем получить ссылку на объект до того, как он был до конца сконструирован, и это действительно проблема синхронизации с другим потоком, но не read1 и read3, а read3 и доступа к членам instance.

                            +1
                            Спасибо, согласен. Хотелось написать проще, вот и доупрощался. Сейчас починю.
                              +1

                              Кстати, в одном из последних докладов АШ показывал, что volatile можно убрать из объявления поля собственно одиночки, перенеся его в объявление поля supplier (расширение вашего пример). Таким образом, мы сможем избежать волатильного доступа при чтении уже созданного одиночки.

                              0
                              И заодно, из-за «instance = new Singleton();» мы можем получить ссылку на объект до того, как он был до конца сконструирован

                              А разве ссылка на объект не будет сохранена в поле instance только после завершения работы конструктора (вызова )? Можете показать пример, доказывающий Ваше утверждение? (случай утечек ссылок this из самого конструктора исключаем)
                                +2

                                Java Memory Model.
                                Другой поток может увидеть, что в instance сохранена ссылка на объект, но не увидеть записи в члены объекта, если нет happens-before между его чтениями и записями создавшего объект потока. А если читающий поток не заходил в syncronized, а он мог не заходить, то happens-before нет.


                                И примеры в статье-источнике, которые показывают, что оно таки ломается.

                                  0
                                  Спасибо за ссылку на статью Шипилёва, достаточно интересно, хотя некоторые места и вызывают недоумение (в плане работы jvm).
                              +1

                              Есть хороший доклад Ивана Углянского, посвящённый всем тонкостям финализации: https://www.youtube.com/watch?v=XtijbFcQxyw

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

                              Самое читаемое