Совместимость — фундаментальная характеристика платформы Java, обеспечивающая стабильную работу программ при эволюции JDK. Однако понятие «совместимость» многогранно: исходный код, бинарные файлы и поведение программ оцениваются по разным стандартам. В новом переводе от команды Spring АйО разберем три ключевых категории совместимости: на уровне исходного кода, бинарную и поведенческую, а также рассмотрим нюансы сериализуемой и миграционной совместимости.
При развитии JDK вопросы совместимости рассматриваются с особым вниманием. Однако к разным аспектам платформы применяются различные стандарты изменений. С определённой точки зрения, любое наблюдаемое изменение потенциально может привести к сбоям неизвестных приложений. Например, даже простое изменение номера версии уже считается несовместимым, поскольку, скажем, JNLP-файл может отказать в запуске приложения на более поздней версии платформы. Следовательно, полное отсутствие изменений явно не является жизнеспособной политикой развития платформы — изменения должны оцениваться и управляться в рамках различных контрактов совместимости.
Для Java-программ существуют три основных категории совместимости:
Совместимость на уровне исходного кода (Source):
Относится к преобразованию исходного кода Java в файл классов.Бинарная совместимость (Binary):
Определена в Спецификации Языка Java (JLS) как сохранение возможности связывания без ошибок.Поведенческая совместимость (Behavioral):
Охватывает семантику кода, исполняемого во время выполнения.
Следует отметить, что несовместимость на уровне исходного кода иногда в разговорной речи ошибочно называют «бинарной несовместимостью». Такое использование термина некорректно, поскольку Спецификация Языка Java посвящает целую главу точному определению бинарной совместимости; в подобных случаях чаще всего имеется в виду именно поведенческая совместимость.
Комментарий от экспертов сообщества Михаила Поливахи и Павла Кислова
В оригинальной статье отсутствуют примеры, поэтому Михаил и Павел решили рассмотреть эти виды совместимости на примере развития абстрактной кодовой базы. Итак, приступим.
Предположим, что у нас есть вот такой код:
// Main.java
public class Main {
public static void main(String[] args) {
System.out.println(Math.add(1, 2));
}
}
// Math.java
public class Math {
public static int add(int x, int y) {
return x + y;
}
}
Представим себе, что Main.java - это код, который пишем и мейнтейним мы сами, а Math.java - это какой-то Open Source продукт, который мы используем. Мы не контролируем разработку Math.java.
В какой-то момент разработчики Math.java понимают, что на самом-то деле может быть имеет место складывать не просто 2 числа, а произвольное количество чисел вместе, и решают заменить add(int x, int y) метод на более универсальный:
public class Math {
public static int add(int... x) {
return Arrays.stream(x).reduce((a, b) -> a + b).getAsInt();
}
}
Теперь читайте очень внимательно: можем ли мы в уже скомпилированной нашей программе, которая включает в себя библиотеку, содержащую Math.java, заменить эту библиотеку на более новую. Сломается ли при этом наше приложение?
Если ответ на вопрос "Да, сломается", то это значит, что изменение, которое внесли авторы, Math.java не является binary level compatible. Если ответ - нет, не сломается, то это изменение считается binary level compatible.
В нашем случае изменение не binary level compatible, поскольку сигнатура методов меняется, и метод, принимающий 2 int-а != метод, принимающий vararg int-ов это разные вещи. Виртуальная машина просто не найдет нужного метода в рантайме и мы упадем с NoSuchMethodError.
С другой стороны, если мы посмотрим на call site, то обнаружим, что Math.add(1, 2) может спокойно компилироваться напротив обеих версий Math.java - как версии add, где параметры - 2 int-а, так и версию add, где параметр - int vararg.
И вот тут снова читайте внимательно, друзья: Если мы можем успешно скомпилировать нашу программу Main.java напротив новой версии библиотеки, которая содержит Math.java, то мы говорим, что авторы библиотеки Math.java внесли source level compatible изменение. И действительно, в нашем случае, мы можем успешно скомпилировать наши исходники, включая Main.java, напротив новой версии Math.java.
Мы специально подобрали пример таким образом, чтобы изменение было именно source level compatible, но не было binary level compatible. Надеюсь, Вам теперь стало понятнее :)
Существуют и другие виды совместимости, включая сериализуемую совместимость для типов, реализующих интерфейс Serializable, а также миграционную совместимость. Миграционная совместимость была важным ограничением при внедрении дженериков в платформу: библиотеки и их клиенты должны были иметь возможность быть параметризованными независимо друг от друга при сохранении возможности компиляции и выполнения кода.
Основная задача в области совместимости заключается в том, чтобы оценить, перевешивают ли преимущества изменения возможные негативные последствия (если таковые имеются) для существующего программного обеспечения и систем. В условиях замкнутой среды, где известны все клиенты API и в принципе могут быть одновременно изменены, внедрение «несовместимых» изменений — это лишь вопрос согласования необходимых инженерных усилий. Напротив, в случае API, таких как JDK, которые широко используются, строгое определение всех возможных затронутых программ практически невозможно. Поэтому развитие таких API происходит в гораздо более ограниченных условиях.
Как правило, мы рассматриваем, совместима ли программа P в том или ином смысле с двумя версиями библиотеки — L1 и L2, которые отличаются друг от друга определённым образом. (Мы не рассматриваем влияние таких изменений на независимых разработчиков L.) Иногда интерес представляет только конкретная программа: является ли переход от L1 к L2 совместимым именно с этой программой? Однако при оценке направлений развития платформы используется более широкий подход: вызывает ли переход от L1 к L2 проблемы для какой-либо из существующих программ? Если да, то какая доля таких программ затрагивается? И, наконец, наиболее широкая перспектива: влияет ли изменение на любую потенциально возможную программу? После выпуска версии платформы последние два подхода часто сближаются, поскольку из-за неполных знаний о множестве реальных программ проще оценивать наихудший возможный исход для любой гипотетической программы, чем пытаться точно определить влияние на существующие приложения. Более формально, в зависимости от рассматриваемого изменения, зачастую разумнее оценивать его по наихудшему возможному исходу для любой программы, а не по какому-либо усреднённому показателю воздействия на известные программы.
Каждый вид совместимости, как правило, имеет как положительные, так и отрицательные аспекты: положительный — в сохранении работоспособности того, что уже работает, и отрицательный — в сохранении неработоспособности того, что не должно работать. Например, тесты TCK для Java-компиляторов включают как положительные тесты (программы, которые должны быть приняты), так и отрицательные (программы, которые должны быть отклонены). Во многих случаях сохранение или расширение положительного поведения является более предпочтительным и значимым, чем поддержание отрицательного поведения, и в этом руководстве основное внимание будет уделено именно положительной совместимости.
Комментарий от эксперта сообщества Михаила Поливахи
TCK тесты мы в повседневной разработке не пишем, т.к. эти тесты в общем случае проверяют совместимость реализации на соответствие требованиям и гарантиям спецификации. Например, JLS же спецификация, и она определяет синтаксис, семантику программ и т.д. И по-хорошему надо проверять, что та или иная JVM или другие компоненты платформы соответствует спеке.
На это у разработчиков того же HotSpot-а есть свои тесты, а точнее их там целый набор. Я хочу еще раз подчеркнуть, что TCK не относится исключительно к Java платформе, это общее понятие. Например, у спецификации Reactive Streams есть набор TCK под разные платформы, которые опять же решают ту же задачу.
С точки зрения относительной серьёзности, проблемы с совместимостью на уровне исходного кода обычно считаются наименее критичными, поскольку часто существуют простые обходные решения, например, изменение операторов импорта или использование полных имён классов. Ниже приведена градация видов совместимости на уровне исходного кода и их обсуждение. Проблемы с поведенческой совместимостью могут иметь различную степень воздействия, тогда как нарушения бинарной совместимости особенно проблематичны, поскольку они приводят к невозможности связывания.
Совместимость на уровне исходного кода
Базовая задача любого компоновщика или загрузчика проста: он сопоставляет более абстрактные имена с более конкретными, что позволяет программистам писать код, используя абстракции. (Linkers and Loaders)
Компилятор Java также выполняет задачу сопоставления абстрактных имён с конкретными, а именно — преобразует простые и квалифицированные (qualified) имена, встречающиеся в исходном коде, в бинарные имена, используемые в файлах классов. Совместимость на уровне исходного кода касается этого процесса преобразования: не только того, возможно ли такое преобразование, но и того, являются ли полученные файлы классов корректными и пригодными для использования. На совместимость исходного кода влияет изменение набора типов, доступных во время компиляции, например, добавление нового класса, а также изменения в существующих типах — например, добавление перегруженного метода. Существует обширный перечень возможных изменений классов и интерфейсов, который анализируется на предмет их влияния на бинарную совместимость. Все эти изменения также можно классифицировать с точки зрения их воздействия на совместимость на уровне исходного кода, но ниже будут рассмотрены лишь некоторые из них.
Наиболее простая форма положительной совместимости исходного кода — это возможность повторной компиляции: компилируется ли код, собранный против L1, без ошибок и против L2. Однако это не исчерпывает всех аспектов проблемы, поскольку результирующий файл класса может отличаться. В исходном коде Java часто используются простые имена типов; на основе информации об импортируемых элементах компилятор интерпретирует эти имена и преобразует их в бинарные имена, которые затем используются в сгенерированных файлах классов. В файле класса бинарное имя сущности (вместе с её сигнатурой в случае методов и конструкторов) служит уникальным и универсальным идентификатором, позволяющим ссылаться на эту сущность. Таким образом, можно выделить различные уровни совместимости исходного кода:
Удается ли скомпилировать клиентский код (или наоборот, не удается)?
Если код компилируется, разрешаются ли все имена в те же бинарные имена, что и ранее?
Если код компилируется, но некоторые имена разрешаются по-другому, приводит ли это к получению поведенчески эквивалентного файла класса?
Дополнительно, допустимость программы может быть изменена из-за изменений в языке. Обычно это выражается в том, что ранее некорректные программы становятся допустимыми, как это произошло при добавлении дженериков. Однако иногда ранее допустимые программы становятся недопустимыми, как в случае добавления новых ключевых слов (strictfp, assert и enum).
Номер версии результирующего файла класса также представляет собой своего рода внешний аспект совместимости, поскольку он ограничивает версии платформы, на которых данный код может быть запущен. Кроме того, стратегии компиляции и окружение могут отличаться при использовании разных версий платформы для генерации различных версий файлов классов — это означает, что исправления ошибок в компиляторе и изменения внутренних контрактов компилятора могут влиять на содержимое генерируемых файлов.
Полная совместимость на уровне исходного кода с любыми существующими программами обычно недостижима из-за использования *-импортов. Рассмотрим следующий пример: библиотека L1 содержит пакеты foo и bar, при этом в пакете foo находится класс Quux. Затем в библиотеку L2 добавляется класс bar.Quux. Теперь рассмотрим такую программу:
import foo.*;
import bar.*;
public class HelloQuux {
public static void main(String... args) {
Object o = Quux.class;
System.out.println("Hello " + o.toString());
}
}
Класс HelloQuux успешно компилируется с библиотекой L1, но не компилируется с L2, поскольку имя Quux теперь становится неоднозначным, что фиксируется компилятором javac следующим образом:
HelloQuux.java:6: reference to Quux is ambiguous, both class bar.Quux in bar and
class foo.Quux in foo match
Object o = Quux.class;
^
1 error
Вредоносная или недобросовестная программа почти всегда может включить *-импорты, конфликтующие с определённой библиотекой. Поэтому оценка совместимости на уровне исходного кода с требованием, чтобы компилировались абсолютно все возможные программы, является чрезмерно жёстким критерием. Тем не менее, при выборе имён для своих типов разработчики API должны избегать повторного использования имён, таких как String, Object и других классов из ключевых пакетов вроде java.lang и java.util, чтобы не создавать подобных досадных конфликтов имён.
С учётом сложности, связанной с *-импортами, более разумным определением совместимости исходного кода является рассмотрение программ, в которых все имена заменены на полностью квалифицированные. Пусть FQN(P, L) обозначает программу P, в которой каждое имя заменено на его полностью квалифицированную форму в контексте библиотеки L. Тогда преобразование библиотеки от L1 к L2 называется бинарно-сохраняющей совместимостью на уровне исходного кода с программой P, если FQN(P, L1) эквивалентна FQN(P, L2). Это строгое определение совместимости исходного кода, которое, как правило, приводит к тому, что файлы классов для P будут содержать одинаковые бинарные имена при компиляции с обеими версиями библиотеки.
Комментарий от эксперта сообщества Михаила Поливахи
На самом деле здесь академическим языком говорят простую вещь - вот смотрите, даже если вы внесли изменение в библиотеку L1 и выкатили новую версию - L2. И вот вашу новую L2 версию стали использовать, как бы вы ни старались, с учетом примера кода выше, вы просто не можете гарантировать, что программа P скомпилируется успешно как с L1, так и с L2, потому что P может включать в себя другие библиотеки, которые конфликтуют с вашей, например, посредством wildcard imports.
Но понятие обратной совместимости на уровне сорс кода надо же хотя бы как-то формализовать, вот авторы Java и пытаются это сделать.
Файлы классов с одинаковыми бинарными именами будут получены тогда, когда каждому типу соответствует уникальное полностью квалифицированное имя. Теоретически возможно, чтобы у нескольких типов было одно и то же полностью квалифицированное имя, но разные бинарные имена; однако такие случаи не возникают при соблюдении стандартных соглашений об именовании.
Для иллюстрации различных степеней совместимости на уровне исходного кода рассмотрим следующий класс Lib:
// Original version
public final class Lib {
public double foo(double d) {
return d * 2.0;
}
}
Изменение, которое может нарушить компиляцию существующих клиентов, — это удаление метода foo:
// Изменение, нарушающее компиляцию
public final class Lib {
// Куда же пропал метод foo?
}
Удаление метода также приводит к нарушению бинарной совместимости. Все последующие изменения класса Lib, рассмотренные ниже, сохраняют бинарную совместимость.
Добавление метода с именем, отличным от всех существующих методов, является изменением, сохраняющим бинарную совместимость на уровне исходного кода. Файлы классов, полученные при повторной компиляции существующих клиентов библиотеки, будут семантически эквивалентны, поскольку разрешение методов в исходном коде произойдёт так же, как и прежде:
// Изменение с сохранением бинарной совместимости и совместимости исходного кода
public final class Lib {
public double foo(double d) {
return d * 2.0;
}
// Добавлен метод с новым именем
public int bar() {
return 42;
}
}
Однако добавление перегруженных методов может изменить процесс разрешения вызова метода и, как следствие, изменить сигнатуры вызовов в результирующем файле класса. Проблемность такого изменения с точки зрения совместимости исходного кода зависит от требуемой семантики и от того, как различные перегруженные методы работают с одинаковыми входными значениями. Это, в свою очередь, связано с вопросами поведенческой эквивалентности. Например, рассмотрим добавление в Lib перегрузки метода foo, принимающей int:
// Совместимое с исходным кодом изменение с поведенческой эквивалентностью
public final class Lib {
public double foo(double d) {
return d * 2.0;
}
// Новая перегрузка
public double foo(int i) {
return i * 2.0;
}
}
В данном случае добавление перегрузки сохраняет поведение, если, например, все вызовы foo с int-аргументами ранее неявно преобразовывались в double, а теперь будут напрямую сопоставляться новой версии метода. Тем не менее, результат может отличаться, если перегруженные методы реализуют различную логику для одного и того же значения, переданного с разным типом.
В оригинальной версии класса Lib вызов метода foo с аргументом типа int будет разрешён как вызов метода foo(double), и в соответствии с правилами преобразования при вызове метода значение аргумента типа int будет преобразовано в double с помощью расширяющего (widening) преобразования примитивных типов. Таким образом, если рассмотреть следующий клиентский код:
public class Client {
public static void main(String... args) {
int i = 42;
double d = (new Lib()).foo(i);
}
}
Вызов foo будет транслирован в байт-код как последовательность инструкций, аналогичная следующему выводу в стиле javap:
...
10: iload_1
11: i2d
12: invokevirtual #4; // Method Lib.foo:(D)D
...
Инструкция i2d преобразует значение типа int в double. Строка "Lib.foo:(D)D" указывает, что вызывается метод foo в классе Lib, принимающий один аргумент типа double и возвращающий double.
Однако при компиляции того же клиентского кода против изменённой версии класса Lib, содержащей поведенчески эквивалентную перегрузку метода foo, принимающего int, именно эта новая перегрузка будет выбрана, и преобразование аргумента не потребуется:
...
10: iload_1
11: invokevirtual #4; // Method Lib.foo:(I)D
...
Строка "Lib.foo:(I)D" указывает, что выбран метод foo, принимающий int. При этом отсутствует промежуточная инструкция преобразования i2d, так как тип аргумента и сигнатура метода теперь совпадают напрямую.
С точки зрения операций над аргументами и вычисления результата, обе перегрузки метода foo функционально эквивалентны. В вызовах с int-аргументом обе версии начинают с преобразования int в double: в оригинальном методе это происходит до вызова метода, в новой версии — внутри метода перед операцией умножения на 2.0.
Комментарий от эксперта сообщества Михаила Поливахи
Во втором случае, где речь про метод foo(int), преобразование int в double происходит также неявно перед умножением int на double, т.к. этого просто требует спецификация языка Java.
После этого преобразованное значение умножается и результат возвращается.
Однако не все перегруженные методы поведенчески эквивалентны. Некоторые сохраняют только возможность компиляции. Например, рассмотрим добавление третьей перегрузки метода foo, принимающей long:
// Изменение, сохраняющее компиляцию, но не поведение
public final class Lib {
public double foo(double d) {
return d * 2.0;
}
public double foo(int i) {
return i * 2.0;
}
// Новая перегрузка, не поведенчески эквивалентна
public double foo(long el) {
return (double) (el * 2L);
}
}
В этом случае метод foo(long) может быть выбран компилятором при вызове с аргументом типа long, а логика выполнения будет отличаться: преобразование типов и операция умножения выполняются по-другому, что приводит к потенциально другому поведению. Таким образом, хотя такая перегрузка сохраняет возможность компиляции, она не гарантирует поведенческую эквивалентность.
В предыдущих версиях класса Lib вызов метода foo с аргументом типа long разрешался как вызов метода foo(double), при этом значение аргумента типа long предварительно преобразовывалось в double. Затем внутри метода foo это значение умножалось на 2.0 и возвращалось. Однако при наличии перегрузки метода foo(long) вызовы метода foo с аргументом типа long теперь будут разрешаться как вызов foo(long), а не foo(double).
Метод foo(long) сначала выполняет умножение на 2, а затем преобразует результат в double. Это противоположный порядок операций по сравнению с вызовом foo(double), где сначала происходит преобразование long в double, а затем умножение. Порядок этих операций имеет значение, поскольку результат может отличаться. Например, при умножении большого положительного значения long на 2 может произойти переполнение и результат станет отрицательным, в то время как большое положительное значение double при умножении на 2 сохранит положительный знак.
Подобное тонкое изменение поведения при перегрузке произошло при добавлении конструктора BigDecimal, принимающего аргумент типа long, в рамках спецификации JSR 13.
При добавлении перегруженного метода или конструктора в существующую библиотеку, если новый метод потенциально может подходить к тем же вызовам, что и исходный метод (например, если новый метод принимает то же количество аргументов, но с более конкретными типами), то при повторной компиляции клиентского кода вызовы могут быть разрешены в пользу нового метода.
Хорошо спроектированные программы будут следовать принципу подстановки Лисков и выполнять «одно и то же» действие над аргументом, вне зависимости от того, какая из перегрузок была выбрана. Менее качественные программы могут этот принцип не соблюдать, что приведёт к изменению поведения при повторной компиляции без изменения исходного кода.

Если новый метод или конструктор не может повлиять на разрешение вызовов в существующих клиентах, то такое изменение считается бинарно-сохраняющим преобразованием исходного кода. При бинарно-сохраняющей совместимости исходного кода повторная компиляция существующих клиентов приводит к получению эквивалентных файлов классов. Различие между поведенчески эквивалентной и просто компиляционно-совместимой (но не поведенчески эквивалентной) модификацией определяется реализацией соответствующих методов. Если добавленный метод изменяет разрешение вызова, но полученный новый файл класса демонстрирует поведение, достаточно близкое к прежнему, такое изменение может быть допустимым. Однако если изменение приводит к изменению разрешения вызова и при этом нарушает семантику, это, скорее всего, создаст проблемы. Изменения библиотеки, из-за которых существующий клиентский код перестаёт компилироваться, редко бывают оправданными.
Бинарная совместимость
Спецификация языка Java (JLS) §13.2 – Что такое бинарная совместимость и что ею не является
Изменение типа считается бинарно совместимым (или, эквивалентно, не нарушающим бинарную совместимость) с существующими бинарными файлами, если такие файлы, ранее успешно проходившие связывание, продолжают связываться без ошибок после внесения изменений.
JLS строго определяет бинарную совместимость с точки зрения связывания: если программа P связывается с библиотекой L1 и продолжает успешно связываться с L2, то изменения в L2 считаются бинарно совместимыми. Поведение программы во время выполнения в понятие бинарной совместимости не входит:
Спецификация языка Java (JLS) §13.4.22 – Тело метода и конструктора
Изменения тела метода или конструктора не нарушают [бинарную] совместимость с уже существующими бинарными файлами.
К примеру, если тело метода было изменено таким образом, что теперь метод выбрасывает исключение вместо вычисления полезного результата, такое изменение, безусловно, является проблемой совместимости, но не нарушает бинарную совместимость, так как связывание клиентского класса с библиотекой всё равно возможно.
Также не нарушает бинарную совместимость добавление методов в интерфейс. Файлы классов, скомпилированные против старой версии интерфейса, смогут связаться с новой версией, даже если они не реализуют новые методы. Однако если такой новый метод будет вызван во время выполнения, будет выброшено исключение AbstractMethodError. Если же новые методы не вызываются, существующий функционал работает корректно. (Следует отметить, что добавление метода в интерфейс нарушает совместимость на уровне исходного кода и может вызвать ошибки компиляции.)
Бинарная совместимость между версиями долгое время является важным принципом эволюции JDK. Долгосрочная переносимость бинарных файлов рассматривается как значительное преимущество для всей экосистемы Java SE. Именно по этой причине даже устаревшие (deprecated) методы продолжают оставаться в платформе, чтобы старые файлы классов, содержащие ссылки на эти методы, могли продолжать успешно связываться.
Поведенческая совместимость
Интуитивно поведенческая совместимость означает, что при одинаковых входных данных программа P выполняет «то же самое» или «эквивалентное» действие при использовании разных версий библиотек или платформы. Однако определить, что именно считается эквивалентностью, может быть непросто. Даже корректная реализация метода equals в классе уже требует внимательного подхода. Чтобы формализовать понятие поведенческой совместимости, потребовалось бы задать операционную семантику JVM применительно к тем аспектам системы, которые интересуют конкретную программу.
Например, существует принципиальная разница между программами, использующими интроспекцию, и теми, что не используют её. К примерам интроспекции относятся: вызовы механизма рефлексии, анализ стек-трейсов, измерение времени выполнения и использование этих данных для изменения поведения программы и т. д. Для программ, не использующих, скажем, рефлексию, изменения в структуре библиотек — такие как добавление новых публичных методов — являются полностью прозрачными. Напротив, «нехорошо ведущая себя» программа может использовать рефлексию, чтобы получить список публичных методов класса и, обнаружив неожиданный метод, выбросить исключение.
Особо сложная программа может даже принимать решения на основе побочной информации, например, временных каналов. Допустим, два потока многократно выполняют разные операции и сигнализируют о прогрессе, например, инкрементируя атомарный счётчик. Затем можно сравнивать относительную скорость прогресса каждого потока. Если соотношение превышает заданный порог, программа может выполнить (или не выполнить) определённое действие. Таким образом, создаётся зависимость от особенностей оптимизаций конкретной реализации JVM, что выходит за рамки разумного контракта поведенческой совместимости.
Развитие библиотеки ограничивается контрактами, определёнными в её спецификации. Например, для финальных классов контракт обычно не запрещает добавление новых публичных методов. Хотя конечный пользователь вряд ли будет заботиться о том, почему программа перестала работать с новой версией библиотеки, именно соблюдение или нарушение контрактов определяет, кто должен устранять проблему.
Тем не менее, при эволюции JDK иногда выявляются расхождения между специфицированным поведением и фактической реализацией (например, ошибки JDK-4707389, JDK-6365176). Существует два основных подхода к их устранению: изменить реализацию так, чтобы она соответствовала спецификации, или изменить спецификацию (в рамках выпуска новой версии платформы), чтобы она соответствовала существующему поведению реализации. Часто выбирается второй вариант, поскольку он оказывает меньшее фактическое влияние на поведенческую совместимость.
Хотя многие классы и методы в платформе точно описывают соответствие между входными аргументами и возвращаемыми значениями, некоторые методы сознательно избегают такой точности и имеют неопределённое поведение. Пример — HashSet:
[HashSet] не предоставляет никаких гарантий относительно порядка обхода множества; в частности, не гарантируется, что порядок останется постоянным во времени.
Метод
iterator()возвращает итератор по элементам множества. Элементы возвращаются в неопределённом порядке.
Алгоритм итерации действительно менялся со временем. Эти изменения полностью совместимы как по исходному коду, так и по бинарному представлению. Хотя такие поведенческие отличия допустимы в релизах платформы, в рамках технических релизов (maintenance release) они являются едва допустимыми, а в обновлениях (update release) — и вовсе сомнительными.
Другие виды совместимости
Помимо API Java SE, платформа JDK предоставляет множество других экспортируемых интерфейсов. Их развитие также должно происходить по аналогии с подходами к поведенческой совместимости Java SE API: необходимо избегать бессмысленных и неоправданных изменений, которые могут нарушить работу клиентских программ.
Управление совместимостью
Оригинальное предисловие к Спецификации языка Java (JLS):
За исключением зависимостей от времени или других недетерминированных факторов и при наличии достаточного времени и памяти, программа, написанная на языке программирования Java, должна вычислять один и тот же результат на всех машинах и во всех реализациях.
Это утверждение из оригинальной версии JLS можно было бы счесть тривиально истинным для любой платформы: если исключить недетерминизм, программа становится детерминированной. Однако отличие Java заключалось в том, что при соблюдении дисциплины со стороны программиста множество детерминированных программ было нетривиальным, а множество предсказуемо работающих программ — весьма обширным.
Иными словами, как поставщик платформы, так и программист несут ответственность за то, чтобы программы были переносимыми на практике: платформа должна соответствовать спецификации, а программы — быть устойчивыми к любой допустимой реализации этой спецификации.
Развитие платформы — это баланс между сохранением стабильности ради обеспечения различных видов совместимости и внесением изменений ради достижения различных форм прогресса.

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

SteveJacobs
22.10.2025 06:19Вопросы совместимости обязательно появляются в реальных приложениях при попытке перенести на новую версию Java, если конечно это не что-то типа hello world. Мне много раз приходилось переносить программы, как с более старых на более новых версий Java. А так же один раз на более старую, это случай оказался полным кошмаром. Там ещё фркеймворки, и задача становится совсем не тривиальной, например искать предельные версии фреймворков которые можно использовать. Иногда таких не найти, их не существует. А если существует, они не на maven репозитории, а где-то в отдаленных архивах интернета. Много гемов одним словом. Я бы сказал что совместимость Java по большому счёту это рекламный блеф. Совместимость гарантирована только там где используется одна и та же версия Java и одини и те же версии библиотек и фреймворков.
aleksandy
Тынц.