Думая о переупорядочении доступа к памяти Java

Java задняя часть

предисловие

Давайте посмотрим на тестовый код и придумаем свой ответ, не прибегая к внешним инструментам.

import java.util.*;
import java.util.concurrent.CountDownLatch;

public class Reordering {
    static int a = 0;
    static int b = 0;
    static int x = 0;
    static int y = 0;
    static final Set<Map<Integer, Integer>> ans = new HashSet<>(4);
    public void help() throws InterruptedException {
        final CountDownLatch latch = new CountDownLatch(2);
        Thread threadOne = new Thread(() -> {
            a = 1;
            x = b;
            latch.countDown();
        });

        Thread threadTwo = new Thread(() -> {
           b = 1;
           y = a;
           latch.countDown();
        });
        threadOne.start();
        threadTwo.start();
        latch.await();
        Map<Integer, Integer> map = new HashMap<>();
        map.put(x, y);
        if (!ans.contains(map)) {
            ans.add(map);
        }
    }

    @Test
    public void testReordering() throws InterruptedException {
      for (int i = 0; i < 20000 && ans.size() != 4; i++) {
          help();
          a = x = b = y = 0;
      }
      help();
      System.out.println(ans);
    }
}

Ваш результат может быть[{0=>1}, {1=>1}, {1=>0}], поскольку планирование потоков является случайным, возможно, что один поток выполняется, а другой поток получает право на выполнение процессора, или два потока перекрывают выполнение. В этом случае ответом ans, несомненно, являются три приведенных выше результата. Порядок выполнения потока, соответствующий трем вышеприведенным результатам, я здесь моделировать не буду, это не суть. Но на самом деле, кроме трех вышеперечисленных результатов, есть еще один результат{0=>0}, почему это? Результатом {0=>0} является не что иное, как:

  1. threadOne сначала выполняет x = b => x = 0;
  2. threadTwo выполняет b = 1, y = a => y = 0
  3. threadOne выполняет a = 1. Или поменять местами роли threadOne и two. Вы можете быть удивлены, почемуx = b happens before a = 1Что? На самом деле это переупорядочивание инструкций.

изменение порядка инструкций

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

Когда не следует менять порядок инструкций

Итак, когда переупорядочивание инструкций не запрещено или как запретить переупорядочивание инструкций? Иначе все запутано.

зависимость данных

Во-первых, инструкции с зависимостями данных не будут переупорядочены!Что это значит?

a = 1;
x = a;

Как и две приведенные выше инструкции,xзависит отa, такx = aЭта инструкция не будет изменена наa = 1перед этой командой.

Существует три типа зависимостей данных:

  1. читать после записи, как в примере, который мы привели вышеa = 1а такжеx = a, что является типичным чтением после записи, при котором инструкции не переупорядочиваются.
  2. писать после записи, как вa = 1а такжеa = 2, который также не будет переупорядочиваться.
  3. Существует конечная зависимость данных, которая читается после записи, напримерx = aа такжеa = 1.

как-будто-серийная семантика

Что такое "как-будто-последовательный" Семантика "как-если-последовательного" такова: независимо от того, насколько переупорядочены (компилятор и процессор для улучшения параллелизма), результат выполнения однопоточной программы не может быть изменен. Таким образом, компилятор и процессор следуют семантике «как если бы» при переупорядочивании инструкций. Возьмите каштан:

x = 1;   //1
y = 1;   //2
ans = x + y;  //3

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

public int add() {
  int x = 1;
  int y = 1;
  int ans = x + y;
  return ans
}

Соответствующий байт-код

public int add();
    Code:
       0: iconst_1     // 将int型数值1入操作数栈
       1: istore_1     // 将操作数栈顶数值写到局部变量表的第2个变量(因为非静态方法会传入this, this就是第一个变量)
       2: iconst_1     // 将int型数值1入操作数栈
       3: istore_2     // 将将操作数栈顶数值写到局部变量表的第3个变量
       4: iload_1      // 将第2个变量的值入操作数栈
       5: iload_2      // 将第三个变量的值入操作数栈
       6: iadd         // 操作数栈顶元素和栈顶下一个元素做int型add操作, 并将结果压入栈
       7: istore_3     // 将栈顶的数值存入第四个变量
       8: iload_3      // 将第四个变量入栈
       9: ireturn      // 返回

В приведенном выше байт-коде нас интересуют только строки 0-> 7. Приведенные выше 8 строк инструкций можно разделить на:

  1. написать х
  2. напиши у
  3. читать х
  4. готов
  5. Операция сложения записывается обратно в an

Вышеупомянутые 5 операций, 1 операция и 2, 4 могут быть переупорядочены, 2 операция и 1, 3ch переупорядочены, операция 3 может быть переупорядочена с 2, 4, а операция 4 может быть переупорядочена с 1, 3. В соответствии с приведенным выше заданием x и задание y могут быть переупорядочены, да, это несложно понять, потому что нет четкой зависимости данных между записью x и записью y. Но операции 1, 3 и 5 нельзя переупорядочить, потому что 3 зависит от 1, 5 зависит от 3, и аналогичным образом нельзя переупорядочить операции 2, 4 и 5.

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

@Test
    public void testReordering2() {
        int x = 1;
        try {
            x = 2;     //A
            y = 2 / 0;  //B
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            System.out.println(x);
        }
    }

Приведенный выше код A и B можно переупорядочить, поскольку x и y не имеют зависимостей по данным и особых семантических ограничений. Но если B происходит до A, печатает ли он неправильное значение x в это время, это не так: Для обеспечения семантики «как если бы» механизм обработки исключений Java специально обрабатывает переупорядочивание: JIT вставит код компенсации ошибок в оператор catch при переупорядочивании (то есть переупорядочивании от A после B), хотя это усложнить логику в catch, принцип JIT-оптимизации таков: максимально оптимизировать логику нормальной работы программы, даже ценой усложнения логики блока catch.

Принцип порядка процедур

  1. если А происходит раньше Б
  2. если B произойдет раньше, чем C Так
  3. A happens-before C

Это происходит до транзитивности

Изменение порядка и JMM

Модель памяти Java (сокращенно JMM) обобщает следующие 8 правил, чтобы обеспечить соответствие следующим 8 правилам, происходит до того, как две операции до и после не будут переупорядочены, а последняя будет видна в памяти первой.

  1. Закон порядка программы: каждое действие А в потоке происходит перед каждым действием В в потоке, где в программе все действия В могут появиться после А.
  2. Правило блокировки монитора: Разблокировка блокировки монитора происходит перед каждой последующей блокировкой на той же блокировке монитора.
  3. Закон изменчивых переменных: запись в изменчивое поле происходит перед каждым последующим чтением или записью в то же поле.
  4. Правила запуска потока: Внутри потока вызов Thread.start происходит перед каждым действием, которое запускает поток.
  5. Закон завершения потока: любое действие в потоке происходит до того, как другой поток обнаружит, что поток завершился, или успешно завершится из вызова Thread.join, или Thread.isAlive вернет false.
  6. Закон прерывания: поток, вызывающий прерывание другого потока, происходит до того, как прерванный поток находит прерывание.
  7. Закон финализации: Конец конструктора объекта происходит до начала финализатора объекта.
  8. Переходный: если А происходит раньше В, а В происходит раньше С, то А происходит раньше С.

Переупорядочивание инструкций приводит к неправильному одноэлементному шаблону двойной проверки

Кто-то, должно быть, написал следующий одноэлементный шаблон двойной проверки

public class Singleton {
    private static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

Но является ли эта перепроверка заблокированного синглтона нормальной? Поскольку создание экземпляра объекта не является атомарной операцией, может происходить переупорядочение следующим образом: Предположим, что для создания объекта требуется:

  1. запросить память
  2. инициализация
  3. экземпляр указывает на выделенную память

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

Итак, как предотвратить такое изменение порядка инструкций? Измените следующим образом:

public class Singleton {
    private static volatile Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

volatileКлючевые слова имеют две семантики: Один гарантирует видимость памяти, о семантике мы поговорим в следующем блоге (на самом деле модификация одного потока будет видна другому потоку, если нетvolatile, все операции потока имеют копию в TLAB, после изменения значения копии она не сразу обновляется в основную память, а другие потоки невидимы) Во-вторых, переупорядочивание инструкций запрещено, если вышеуказанноеnewПри отключено переупорядочивание инструкций, поэтому получается ожидаемая ситуация.

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

public class Singleton {
    public static Singleton getInstance() {
        return Helper.instance;
    }

    static class Helper {
        private static final Singleton instance = new Singleton();
    }
}

Как отключить переупорядочивание инструкций

Мы разрешим переупорядочивание раньше и запретим переупорядочивание, но как реализуется запрет переупорядочивания? Он реализован с инструкциями процессора барьера памяти.Как следует из названия, он должен добавить барьер, чтобы предотвратить изменение порядка.

Барьеры памяти можно разделить на следующие типы:

  1. Барьер LoadLoad: Для такого оператора Load1, LoadLoad, Load2, до того, как данные, которые должны быть прочитаны Load2, и последующие операции чтения будут доступны, гарантируется, что данные, которые должны быть прочитаны Load1, были прочитаны.
  2. StoreStore Barrier: для оператора Store1, StoreStore, Store2, до выполнения Store2 и последующих операций записи операции записи Store1 гарантированно будут видны другим процессорам.
  3. Барьер LoadStore: для такого оператора Load1; LoadStore; Store2, прежде чем Store2 и последующие операции записи будут очищены, убедитесь, что данные, которые должны быть прочитаны Load1, были прочитаны.
  4. Барьер StoreLoad: для оператора Store1; StoreLoad; Load2 записи Store1 гарантированно будут видны всем процессорам до выполнения Load2 и всех последующих операций чтения. Его накладные расходы - самый большой из четырех барьеров. В большинстве реализаций процессоров этот барьер является универсальным барьером, функционирующим как три других барьера памяти.

Оригинальная ссылка