Вопрос в стиле головоломок с offline-конференций: Что выведет этот код при запуске?
import java.util.concurrent.atomic.AtomicInteger; public class Disturbed { public static void main(String... args) { AtomicInteger counter = new AtomicInteger(1); System.out.println( "First two positive numbers: " + counter + ", " + counter.incrementAndGet() ); } }
Помедитируйте немного над кодом и приходите за ответом под кат.
Вероятно, что увидев код многие воскликнули «Это же элементарно, Ватсон!»
Ответом, однако, будет фраза «Зависит от компилятора и параметров компиляции».
Код, скомпилированный JDK 8 и более ранними выдаст ожидаемое:
First two positive numbers: 1, 2
Однако при компиляции в JDK 9 и более новых мы внезапно получим ответ:
First two positive numbers: 2, 2
Всё изложенное в данной заметке проверялось на компиляторах из Oracle JDK/OpenJDK, в других реализациях могут быть другие баги.
Предпосылки
Среди нововведений Java 9 был JEP 280, новый механизм конкатенации строк.
Конкатена́ция (лат. concatenatio «присоединение цепями; сцепле́ние») — операция склеивания объектов линейной структуры, обычно строк. Например, конкатенация слов «микро» и «мир» даст слово «микромир».
Конкатенация — Википедия
Целью было сделать возможной оптимизацию конкатенации строк без необходимости перекомпиляции программ из исходников. Обновил JDK — увеличил производительность. Магия!
Традиционно, с самого начала времён, конкатенация строк транслировалась компилятором в создание экземпляра класса StringBuilder, серию вызовов StringBuilder::append() и преобразование результата в строку при помощи вызова StringBuilder::toString() в финале.
Так, например, конструкция System.out.println("Hello, " + name + "!"); превращалась в
System.out.println( (new StringBuilder()) .append("Hello, ") .append(name) .append("!") .toString() );
При новом подходе все манипуляции с StringBuilder исчезают и заменяются одной инструкцией invokedynamic. В качестве bootstrap-метода при этом используется один из методов класса java.lang.invoke.StringConcatFactory.
Чистой Java это не передать, но javap -c -v покажет нам примерно такой байткод:
0: getstatic #23 // Field java/lang/System.out:Ljava/io/PrintStream; 3: aload_0 4: invokedynamic #27, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String; 9: invokevirtual #31 // Method java/io/PrintStream.println:(Ljava/lang/String;)V ... LocalVariableTable: Start Length Slot Name Signature 0 13 0 name Ljava/lang/String; ... BootstrapMethods: 0: #50 REF_invokeStatic java/lang/invoke/StringConcatFactory.makeConcatWithConstants:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite; Method arguments: #56 Hello, \u0001!
В чём проблема?
Само собой, предполагалось, что изменение никак не повлияет на поведение пользовательского кода. Но не всегда и не всё можно предусмотреть. Java 9 была выпущена в 2017 году, а в сентябре этого года был зарегистрирован баг JDK-8273914.
Как обнаружилось, javac генерирует байткод, нарушающий JLS, пункт §15.7.1. Последний требует для бинарных операций чтобы левая часть выражения была полностью вычислена перед тем, как будет вычислена правая:
15.7.1. Evaluate Left-Hand Operand First
The left-hand operand of a binary operator appears to be fully evaluated before any part of the right-hand operand is evaluated.
Это требование без всяких ухищрений выполняется при использовании старого-доброго StringBuilder, но не всегда выполняется при использовании новой стратегии.
Сравним поведение на примере выражения из Кода Для Привлечения Внимания, предварявшего эту статью:
StringBuilder
// Создаём буфер для формирования результата конкатенации. (new StringBuilder()) // Добавляем к результату строку "First two positive numbers: " .append("First two positive numbers: ") // Разыменовываем ссылку на объект counter и переводим его в // строковое представление, неявно вызывая метод toString() .append(counter) // Добавляем к результату строку ", " .append(", ") // Увеличиваем значение счётчика на единицу и получаем новое значение как // целое число. Полученное число переводим в строковое предствление // и добавляем к результату. .append(counter.incrementAndGet()) // Получаем содержимое буфера в виде строки. .toString()
JEP 280
Это ассемблер, но не пугайтесь, дальше будет псевдокод.
// Помещаем ссылку на экземпляр счётчика на стек. // Сейчас его внутреннее состояние хранит значение равное единице, // но это ничего не значит. aload_1; // Разыменовываем ссылку на экземпляр счётчика и вызываем его метод incrementAndGet() // Состояние счётчика меняется с 1 на 2, новое значение в виде целого числа // типа int возвращается в качестве ре��ультата вызова и помещается на вершину // стека. aload_1; invokevirtual Method java/util/concurrent/atomic/AtomicInteger.incrementAndGet:"()I"; // Ссылка на экземпляр счётчика и его последнее значение приходят в качестве // параметров в метод, реализующий конкатенацию. Там они будут переведены в // строковое представление и подставлены в строку-шаблон. invokedynamic InvokeDynamic REF_invokeStatic :Method java/lang/invoke/StringConcatFactory.makeConcatWithConstants :"(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;":makeConcatWithConstants :"(Ljava/util/concurrent/atomic/AtomicInteger;I)Ljava/lang/String;" { // Строка-шаблон. Символами \u0001 обозначаются места, в которые будут // подставлены значения из параметров. String "First two positive numbers: \u0001, \u0001" };
Процитированный выше фрагмент можно представить в виде такого псевдокода:
// Помещаем ссылку на экземпляр счётчика на стек. // Сейчас его внутреннее состояние хранит значение равное единице, // но это ничего не значит. AtomicInteger temp1 = counter; // Разыменовываем ссылку на экземпляр счётчика и вызываем его метод incrementAndGet() // Состояние счётчика меняется с 1 на 2, новое значение в виде целого числа // типа int возвращается в качестве результата вызова и помещается на вершину // стека. int temp2 = counter.incrementAndGet(); // Ссылка на экземпляр счётчика и его последнее значение приходят в качестве // параметров в метод, реализующий конкатенацию. Там они будут переведены в // строковое представление и подставлены в строку-шаблон. String result = makeConcatWithConstants( "First two positive numbers: \u0001, \u0001", temp1, temp2 ); ... System.out.println(result);
Другими словами, в метод makeConcatWithConstants() объект count придёт уже в изменённом состоянии и результат будет неверным. Мистерия раскрыта!
Добиться стабильной работы нашего КДПВ можно просто заменив в выражении counter на counter.get(), а в более общем случае — явно приведя к строковому представлению все значения ссылочных типов, встречающиеся в выражении.
Если этот баг вызывает у вас серьёзное беспокойство, то вы можете временно откатиться на использование старого способа конкатенации строк.
Для это нужно при компиляции передать javac параметр -XDstringConcat=inline:
javac -XDstringConcat=inline Disturbed.java
Мораль
Пишите хороший код, не пишите плохой и остерегайтесь побочных эффектов при конкатенации строк. Баги коварны и умеют ждать.
