Команда Spring АйО перевела и адаптировала доклад Брайана Гоетца “Valhalla — эпичный рефакторинг Java”, и сегодня мы публикуем вторую часть из трех.

В первой части серии было рассказано об истории и причинах появления проекта Valhalla. Во второй части подробно разбирается вопрос о том, с какими фундаментальными сложности команда столкнулась на пути к решению поставленных задач.


Препятствия на пути к более плоским и плотным объектам

Достижение таких характеристик, как простота представления объекта в памяти (flatness) и плотность (density) требует преодоления ряда препятствий. Одно из главных — identity объекта. В реальности это означает, что объект должен существовать в одном конкретном месте или, как минимум, иметь фиксированное местоположение в каждый момент времени. Сборщик мусора может перемещать объект, но всегда должна существовать его официальная копия. Это особенно важно для изменяемых объектов (mutable), так как гарантированное место хранения необходимо для корректного обновления их значений. 

Еще одна проблема — null-значения. Ссылки на объекты могут быть null, и это требует дополнительных данных для их представления, помимо самих данных объекта. Например, в общем случае, для представления всех значений Long нам нужно 64 бита (264 уникальных значений). Но если мы вспомним, что Long как референс тип может быть null, то нам нужен еще один, какой-либо уникальный bit pattern для представления значения null. Поэтому 64 бит нам просто не хватит, нам нужен дополнительный бит. С учетом hardware ограничений и выравнивания, на деле, нам, потенциально, придется добавить не один бит и сделать 65 бит для Long, а куда больше. Таким образом, поддержка null становится препятствием как для увеличения плотности данных, так и для их упрощенного хранения (flattening).

Более того, даже если применить сложные схемы кодирования null, которые, например, используют младшие биты машинных указателей/поинтеров, то возникает неприятная зависимость данных - Мы просто не можем использовать более старшие биты абстрактного числа до тех пор, пока мы не убедимся, что они на самом деле представляют значение, а не null. Для этого мы должны посмотреть на младшие биты числа. Т.е. нам придется при обращении делать проверку, является ли число null, или нет. Все это создает дополнительные сложности и неудобства.

Если nullability создает сложности, то проблемы с инициализацией могут оказаться еще серьезнее. Прежде чем объект станет пригодным для использования, он должен пройти через конструктор. В то же время примитивные типы можно использовать без инициализации: например, int по умолчанию имеет значение 0, и его не нужно специально конструировать перед применением.

Не все классы, которые могли бы стать value-классами, имеют какое-либо адекватное значение по умолчанию. Например, у предположительного класса Complex, который бы представлял комплексные числа в Java, кажется, есть естественное значение по умолчанию — 0. С другой стороны, есть класс LocalDate, и у него адекватного значения по умолчанию нет, что требует дополнительной инициализации.

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

Комментарий редакции Spring АйО

Простейший пример:

public class ClassA {

    private int myInt;
    
    {
        System.out.println(myInt);
    }

    public ClassA() {
        this.myInt = 132;
    }
    
    static class Nested {

        public static void main(String[] args) {
            new ClassA();
        }
    }
}

В данном случае, в блоке инициализации мы получили доступ к полю myInt до его инициализации, и простой запуск Nested.main() приведет к тому, что напечатается 0.

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

Дополнительную проблему создает взаимодействие с моделью памяти Java. Мы привыкли к тому, что если объект сконструирован, другие потоки увидят его в целостном состоянии. Это кажется логичным.

Комментарий редакции Spring АйО

Простой пример - GC Shenandoah использует forwarding pointer для того, чтобы атомарно (через CAS) менять ссылку на объект при его перемещении. Эта ссылка она завязана на наличие заголовка в объекте, которого у нас в случае value object у нас нет и соот-но атомарное обновление референса невозможно.

В результате может появиться объект, нарушающий JLS invariant — а это уже серьезная проблема. Таким образом, упрощение структуры объектов порождает дополнительные архитектурные сложности.

Давайте пройдемся по всему этому более подробно. Команда Valhalla с самого начала понимала, что identity объекта — главная преграда на пути к созданию более плоских объектов. В чем суть проблемы? Каждый объект в Java получает уникальную identity, которая назначается при вызове new Foo(). Именно она используется оператором равенства для определения, ссылаются ли две переменные на один и тот же объект.

Identity дает множество возможностей: изменяемость объектов, полиморфные структуры данных, блокировки и многое другое. Для одних классов это полезно, но для других — совершенно избыточно. Более того, даже классы, которым identity не нужна, вынуждены за нее «платить», что приводит не только к издержкам, но и к багам.

Например, вы могли замечать, что два объекта типа Integer могут быть равны друг другу, если представляют малые числа (благодаря кешированию), но не равны, если это большие числа.

Комментарий редакции Spring АйО

Пример, о котором говорит Брайен: 

Integer.valueOf(100) == Integer.valueOf(100) // true
Integer.valueOf(300) == Integer.valueOf(300) // false

В силу нативного кеширования в JDK.

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

Value-классы

Identity объекта мешает созданию более плоских объектов, потому что она требует фиксированного расположения объекта в памяти. Это вынуждает JVM использовать модель «коробочка и указатель», что ограничивает оптимизацию. Без возможности доказать отсутствие алиасов для объекта, JVM не может предложить более плоскую структуру в куче и лишь в ограниченной степени выполняет скаляризацию для объектов с identity.

Если identity — это проблема, почему бы не дать разработчикам возможность отказаться от нее, если она не нужна? Именно так и появились value-классы. Объявляя класс как value класс, вы заявляете: «Моему классу identity не нужна». Это решение имеет свои ограничения: без identity вы теряете возможности, такие как мутации. Однако многие классы и так являются immutable, поэтому для них отказ от identity становится естественным выбором.

Value-классы, в отличие от обычных, не могут быть расширены. Однако для таких типов, как Complex, Option или LocalDate, это не проблема. Их ключевая особенность в том, что принадлежность к value-классам — это не про производительность, а про семантику. Она утверждает: «Экземпляры этого класса не имеют identity». Такое заявление, по сути, говорит JVM: «Эта свобода мне не нужна», что позволяет более эффективно оптимизировать работу с объектами.

Многие классы, которые мы пишем, особенно небольшие, уже соответствуют требованиям value-классов. Числовые типы, даты, пары (tuples), курсоры — все они по своей природе ближе к value-классам. Не исключено, что в будущем JDK мигрирует многие из них в эту категорию.

Что значит быть value-классом? Такие классы сохраняют большинство привычных возможностей: поля, методы, конструкторы, реализацию интерфейсов и так далее. Но есть важные отличия: класс объявляется final, его поля — тоже final. А главное, при сравнении двух объектов-значений с помощью == сравниваются их состояния, то есть значения полей, а не identity, которой у них нет.

С отсутствием identity также связаны некоторые ограничения: нельзя синхронизироваться на value объекте, также нельзя иметь слабые ссылки (WeakReference) на value объект, а также есть ограничения на суперклассы. Но общий подход стал проще по сравнению с ранними идеями проекта Valhalla.

Экземпляры value-классов все еще остаются объектами, на которые можно ссылаться, как и на любые другие. Однако благодаря отсутствию identity JVM получает свободу оптимизировать их представление в памяти. Например, объекты можно копировать без ограничения: если два экземпляра имеют одинаковые поля, то для программы не важно, один это объект или два, или даже набор отдельных полей. JVM самостоятельно решит, как лучше хранить и передавать такие объекты, минимизируя издержки.

Скаляризация вызовов

Использование value-объектов на стеке позволяет минимизировать количество аллокаций объектов в целом. Поля, составляющие объект, могут быть переданы виртуальной машиной в вызываемый метод через регистры CPU или т.п, зависимо от calling convention. Тем самым можно вообще избежать создание объекта. Такой подход сочетает безопасность immutability с производительностью mutability.

Далее рассмотрим пример ImmutableAccumulator:

value record ImmutableAccumulator(int total) {
    InmutableAccumulator() { 
      this(0); 
    }

    ImmutableAccumulator plus(int n) {
      return new ImmutableAccumulator(total + n);
    }
}
ImmutableAccumulator acc = new ImmutableAccumulator();
for (String s : strings)
  acc = acc.plus(s.length());

Итак, у нас есть record, который содержит общую сумму, этот класс, возможно, выглядит несколько странно, но для нас важно то, что он является immutable. 

Он начинает цикл от нуля и содержит метод для добавления чего-либо, но по факту метод plus() присваивает новый ImmutableAccumulator с новым значением.   

Если использовать этот подход на практике, мы создаем аккумулятор, проходим в цикле по массиву и вызываем метод plus(), чтобы добавить длину каждой строки.

На деле, никаких аллокаций памяти тут не происходит. Вся логика выше сводится к простой ADD ассемблер инструкции для конкретного регистра, который хранит total. Никаких присваиваний не требуется: общая сумма поднимается в регистр, acc.plus() просто прибавляет значение к этому регистру. В итоге получается тот же оптимизированный машинный код, который вы бы написали вручную на ассемблере, но с сохранением всех преимуществ объектно-ориентированного программирования.

То же самое происходит с соглашением о вызовах (она же calling convention), так что если у нас есть record для точки на плоскости, которая имеет поля типа Double для координат X и Y, и мы хотим сгенерировать точку на окружности для заданного угла, то наш метод вызывает косинус и синус и делает из этого точку.

value record Point(double x, double y) { }

Point unitVector(double angle) {
  return new Point(Math.cos(angle), Math.sin(angle));
}
Point p = unitVector(Math.PI); // no allocation, sin and cos returned in registers

Когда вызывается этот метод, можно подумать, что точка формируется как объект, но по факту этого не происходит. Происходит следующее: этот код просто вернет x и y в регистрах, и они могут использоваться локально вызывающим кодом. Таким образом, calling convention хорошо оптимизирован. Большую часть времени аллокации не происходят совсем.

JEP 401

Команда Valhalla делают свою работу поэтапно, разбивая JEP 401 на небольшие фрагменты. Первый JEP отвечает за одну новую возможность языка, value-классы, это единственное изменение синтаксиса и единственное изменение концепции, но в то же время существует план мигрировать большое количество JDK классов, таких как Integer и Optional и LocalDate, в value-классы. 

Это похоже на закладку фундамента: вводится понятие о том, что не все объекты требуют identity. Это создает базу для оптимизаций, где команда Valhalla может применять скаляризацию для локальных объектов, оптимизированные соглашения о вызове и более плоскую кучу для небольших объектов — при этом не усложняя жизнь разработчикам без необходимости. Главное — выработать новую привычку: задавать себе вопрос, нужно ли объекту иметь identity. Если нет, лучше ее не добавлять, так как она только усложнит работу и снизит производительность. Такой подход способствует более эффективным и быстрым приложениям.

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

Nullability

Но все это — лишь часть истории. Важно также рассмотреть nullability, что неожиданно связано с упрощением структуры объектов (flattening) и их плотностью (density). Если у нас есть nullable-ссылка на Complex, свободный от identity, нужно учесть все возможные комбинации значений Complex плюс еще одно для null. Это создает определенные риски как с точки зрения плотности данных, так и простоты их представления.

Эта проблема напрямую связана с моделью памяти: по мере увеличения размера объекта дополнительный бит для null может превысить порог размера атомарных операций на вашем аппаратном обеспечении. В результате это может повлиять на производительность и эффективность работы с памятью.

Комментарий редакции Spring АйО

Речь о том, что большинство современных ISA и архитектур CPU умеют производить атомарные операции в рамках computer word относительно быстро. 

Для 64-битных машин computer word это 8 байт или 64 бита. Расширение Long с 8 байт, которое возможно в силу отсутствия места под null, приведет к тому, что CAS (атомарное изменение) будет работать на уровне hardware медленнее, о чем и говорит Брайен.

Кроме того, есть причина, почему примитивы являются non-nullable. И дело не только в том, что мы не хотим тратить на это пространство. Nullability не является такой уж полезной в контексте числового кода. В большинстве случаев в цифровом коде null будет только мешать. Поэтому опять, как и в случае с identity, если эта степень свободы не помогает вам, у вас должна быть возможность отказаться от нее. Это позволит сделать ваш код не только более понятным, но и более подходящим для оптимизации. 

Как же решить эту проблему? Очевидный ответ — позволить переменным и типам отказаться от nullability. Подобный подход уже реализован в других языках: Scala, Kotlin, C#, Swift — все они используют различные формы контроля над nullability с помощью знакомых операторов ? и !.

Эти идеи не были изобретены с нуля. На самом деле, механизмы контроля nullability, которые вы видите в Scala или Kotlin, — это упрощенные версии системы типов, основанной на кардинальности. Эта система была разработана в рамках эксперимента Microsoft под названием C Omega. Именно оттуда были заимствованы основные концепции, которые легли в основу современных решений для управления nullability

Так или иначе, один из самых частых запросов от разработчиков — возможность ограничить использование null в Java. Однако это как раз тот случай, когда стоит быть осторожным с желаниями: подобная функциональность приносит с собой множество новых сложностей. Тем не менее, стремление к такому контролю хорошо сочетается с духом и целями проекта Valhalla.

На ранних этапах разработки команда проекта несколько раз пыталась сделать value типы non-nullable по умолчанию. Однако эта идея столкнулась с серьезными проблемами совместимости, из-за чего от нее пришлось отказаться. Это наглядно показывает, как сложно внедрить подобные изменения в язык с богатой историей и огромным количеством существующего кода.

Поэтому, когда вы объявляете value-класс, его экземпляры будут nullable по умолчанию, если вы явно не запретите nullability — так же, как и для классов с identity. Контроль над nullability создает новые вызовы. Например, если у вас есть поле типа String! (где ! означает NotNull), возникает вопрос: какое значение будет у этого поля по умолчанию?

Во время инициализации объекта JVM автоматически присваивает полям значение null. Однако null выходит за пределы допустимых значений для String!. Было бы неудобно, если бы разработчики сталкивались с null там, где он не предусмотрен, но, к сожалению, это возможно, особенно на этапе работы конструктора.

Почему так происходит? Когда создается объект, сначала вызывается конструктор суперкласса, и на этом этапе поля еще не инициализированы. Если в конструкторе суперкласса вызывается метод, который может быть переопределен в подклассе (override), этот метод увидит null вместо ожидаемого значения. Такая ситуация недопустима, так как нарушает предположения о типовой безопасности и может привести к ошибкам, которые сложно отследить.

Поэтому команда Valhalla сформулировала определенные требования по присвоению значения, которые говорят, что необходимо присваивать эти значения перед вызовом конструктора суперкласса. 

В Java это никогда не удавалось реализовать, но решение предлагает JEP 482 — “Гибкие конструкторы” (Flexible Constructor Bodies). Этот механизм позволяет вставлять больше кода перед вызовом конструктора суперкласса, что открывает новые возможности для инициализации объектов.

Одна из ключевых функций — возможность инициализировать поля до вызова конструктора суперкласса. Фактически, если поле имеет ограничение по null (например, String!), вы обязаны его инициализировать заранее. Вы не сможете вызвать конструктор суперкласса, пока не будут заданы значения для всех NotNull-полей.

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

Существует похожая проблема и с массивами.

String![] names = new String![](n, i -> "Name #" + i);

Ситуация становится еще сложнее, когда вы создаете новый массив, потому что у массивов нет конструкторов. При создании массива JVM автоматически заполняет его элементы значениями null. Это создает проблему для типов с ограничением NotNull — например, String!, где наличие null недопустимо.

Для решения этой проблемы нужен новый подход. Вместо простого объявления new String![10] потребуется явно указать, как инициализировать каждый элемент. Например, это может быть:

  • Лямбда-выражение, которое вызывается для создания начальных значений каждого элемента.

  • Конкретное значение, которым будет заполнен весь массив.

Вы не сможете просто создать массив String![10] и оставить его без инициализации, так как это позволит невалидным значениям (в данном случае null) остаться незамеченными. Такой строгий контроль гарантирует, что массивы с типами NotNull всегда содержат корректные данные, что повышает безопасность и надежность кода.

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

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

Одним из ключевых решений может стать динамическая проверка соблюдения ограничений non-nullable объектов. Иными словами, если кто-то попытается присвоить null там, где это запрещено, JVM сможет сгенерировать NullPointerException на раннем этапе. Такой подход предпочтительнее, чем обнаружение ошибки в более поздний момент времени, так как он делает ошибки более предсказуемыми и упрощает их диагностику. Это важный шаг к повышению безопасности и надежности кода в Java.

Команда проекта воспользовалась возможностями JVM для обнаружения случаев записи null в массивы и поля с ограничением на null. Далее работа сосредоточилась на внедрении более строгих правил обязательной инициализации, чтобы гарантировать, что такие поля и элементы массива всегда начинают "жизнь" с валидного значения.

Контроль над nullability в Java будет отличаться от того, что вы, возможно, видели в Scala, Kotlin, Go, C# или Swift. Java — это язык с особым архитектурным подходом и богатой историей, что накладывает дополнительные требования. В частности, команда Valhalla обязана учитывать вопросы совместимости, включая миграционную совместимость с уже существующим кодом.

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

После объединения ограниченных по null-типов с value-типами проблемы с инициализацией становятся еще более серьезными. Допустим, у вас есть переменная типа String!, и вы неожиданно находите в ней null. Это неприятно, но, как правило, не нарушает целостности всей системы. Однако если речь идет об объекте, чье содержимое невалидно, и этот объект передается в код, который ожидает валидное значение, это уже создает риски для целостности данных и логики приложения. Такую ситуацию необходимо исключить.

Именно поэтому при комбинации value-типов и типов с ограничением по null требуется более строгое соблюдение ограничений. Чтобы добиться этого, команда проекта задействовала возможности JVM, усилив контроль на уровне виртуальной машины. Это гарантирует, что поля с ограничением NotNull не могут быть доступны или использоваться до того, как будут корректно инициализированы.

Такой подход позволяет не только выявлять ошибки на ранних этапах, но и повышает надежность приложений в целом, снижая риск скрытых багов, связанных с неинициализированными или некорректными данными.

Ранее уже упоминалась проблема с LocalDate. Некоторые value типы, такие как Complex, имеют логичные значения по умолчанию, например, ноль. Однако что делать с LocalDate? Какое значение по умолчанию он должен иметь, если вы не укажете другое? 1 января 1970 года? Это ужасный выбор. 2 января 1970 года? Ничуть не лучше. И это касается любой произвольной даты.

Многие сталкивались с подобными ошибками: сайты сообщают, что подписка истекла 1 января 1970 года, или приходят счета с начисленными пенями за 50 лет. Такое "значение по умолчанию" — это по сути скрытый баг, который просто ждет подходящего момента, чтобы проявиться.

Из этого следует важный вывод: некоторые value-классы действительно могут иметь безопасные значения по умолчанию, а некоторые — нет. И это должно стать частью программной модели. Иными словами, язык и среда выполнения должны учитывать, что для определенных типов отсутствие значения по умолчанию — это не баг, а осознанное архитектурное решение, направленное на предотвращение неожиданных и трудноуловимых багов будущем.

Все это имеет первостепенное значение, поскольку команда стремится сделать классы более плоскими (flatten). Это означает, что больше нельзя полагаться на указатели для контроля доступа к данным, как это происходит с объектами-ссылками. В плоской структуре данные хранятся непосредственно, и это исключает возможность использования null в качестве "страховочного значения" до того, как объект будет полностью инициализирован.

В такой архитектуре необходимо гарантировать, что объект всегда находится в валидном состоянии еще до того, как он станет доступным для наблюдения (observable). Иными словами, объект не должен быть виден другим частям программы, пока его данные не будут корректно инициализированы.

Для достижения этой цели команда Valhalla использует два подхода:

  1. Усиление инициализации на уровне исходного кода — ввод строгих правил, которые обязывают разработчиков явно инициализировать все необходимые поля до использования объекта. Это снижает вероятность ошибок на этапе разработки.

  2. Усиление инициализации на уровне JVM — внедрение механизмов на уровне виртуальной машины, которые будут следить за корректностью инициализации во время выполнения программы. Это создает дополнительный уровень защиты, предотвращая использование объектов с невалидным состоянием.

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

Эта цель была частично достигнута благодаря гибким конструкторам (flexible constructors), но проект Valhalla выводит решение на новый уровень, добавляя верификацию на уровне JVM. Это позволяет гарантировать, что все поля, которые должны быть строго инициализированы, действительно инициализируются до вызова конструктора суперкласса.

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

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

Strictly initialized fields

Существует скрытая концепция, которую вы не увидите в описании языка, — "строго инициализируемые поля" (strictly initialized fields). Это означает, что поле должно быть инициализировано до вызова конструктора суперкласса.

Все поля, принадлежащие value-типам, должны быть строго инициализированы. То же касается и полей с ограничением по null — они также требуют строгой инициализации. Более того, в будущем могут появиться и другие типы полей, для которых это правило станет обязательным.

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

Это отличный пример того, что можно назвать "скрытым активатором". Изначально никто из команды Valhalla не ставил задачу внедрить строго инициализируемые поля. Никто не приходил с просьбой: "Нам нужны именно такие поля!" Однако, как оказалось, добавление этой функциональности решило ряд серьезных проблем, над которыми разработчики проекта работали последние 10 лет. И это неудивительно — часто фундаментальные улучшения рождаются из необходимости решить глубинные задачи, которые на поверхности кажутся совсем другими.

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


На этом заканчивается вторая часть серии статей о проекте Valhalla по докладу Брайана Гоетца. В последней части мы расскажем о первых успехах в реализации проекта, о которых можно говорить уже сегодня и о том, как скоро можно будет увидеть первые итоги этой долгой работы воочию.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм - Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано

Комментарии (0)