Сюрпризы конкатенации

от автора

Вопрос в стиле головоломок с 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

Мораль

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


ссылка на оригинал статьи https://habr.com/ru/articles/581576/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *