Pull to refresh

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

Reading time 5 min
Views 7.6K

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

Мораль


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

Tags:
Hubs:
+26
Comments 6
Comments Comments 6

Articles