Любимое изменчивое ключевое слово интервьюера

Java

На собеседованиях при приеме на работу, связанных с Java, многие интервьюеры хотели бы проверить, понимает ли интервьюируемый параллелизм Java, иvolatileВ качестве небольшой точки входа часто можно задавать ключевые слова в конце, включая модель памяти Java (JMM) и некоторые функции параллельного программирования на Java.Углубленно вы также можете изучить базовую реализацию JVM и связанные с ней знания о Операционная система.

Давайте возьмем гипотетический процесс собеседования, чтобы узнать больше о изменчивых ключевых словах!

Интервьюер: Откуда вы знаете о параллелизме в Java? Расскажите о своем понимании ключевого слова volatile


Насколько я понимаю, общие переменные, модифицированные volatile, имеют следующие две характеристики:

1. Гарантировать видимость в памяти работы переменной разными потоками;

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

Интервьюер: Не могли бы вы уточнить, что такое видимость памяти и что такое переупорядочивание?


Об этом можно долго говорить Позвольте мне начать с модели памяти Java.

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

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

Это делает меня немного неясным, поэтому я взял лист бумаги и нарисовал:

JMM
При выполнении потока он сначала прочитает значение переменной из основной памяти, затем загрузит его в копию в рабочей памяти, после чего передаст процессору для выполнения, а затем присвоит значение копии в рабочей памяти после выполнения, а затем в рабочую память будет помещено значение, которое передается обратно в основную память, и значение в основной памяти обновляется.

Использование оперативной памяти и основной памяти хоть и быстрее, но тоже приносит некоторые проблемы. Взгляните на следующий пример:

i = i + 1;

Предполагая, что начальное значение i равно 0, когда его выполняет только один поток, результат должен быть 1, а когда его выполняют два потока, будет ли результат равен 2? Не обязательно. Это может быть так:

线程1: load i from 主存    // i = 0
        i + 1  // i = 1
线程2: load i from主存  // 因为线程1还没将i的值写回主存,所以i还是0
        i +  1 //i = 1
线程1:  save i to 主存
线程2: save i to 主存

Если два потока следуют указанному выше потоку выполнения, то окончательное значение i фактически равно 1. Если последняя обратная запись вступает в силу медленно, и вы снова читаете значение i, оно может быть равно 0, что является проблемой несогласованности кэша.

Вопрос, который вы только что задали, будет упомянут ниже. JMM в основном построен на том, как иметь дело с тремя характеристиками атомарности, видимости и упорядочения в параллельном процессе. Решая эти три проблемы, вы можете устранить проблемы несогласованности кеша. И volatile связано как с видимостью, так и с упорядочением.

Интервьюер: А как насчет этих трех характеристик?


1. Атомарность:В Java операции чтения и присвоения базовых типов данных являются атомарными операциями, так называемые атомарные операции, означающие, что эти операции не прерываются и должны быть либо завершены, либо не выполнены. Например:

i = 2;
j = i;
i++;
i = i + 1;

В вышеуказанных 4 операцияхi=2это операция чтения, которая должна быть атомарной операцией,j=iВы думаете, что это атомарная операция, но на самом деле она разделена на два шага, один из которых состоит в том, чтобы прочитать значение i, а затем присвоить его j. Это двухэтапная операция, которую нельзя назвать атомарной операцией. .i++иi = i + 1На самом деле это эквивалентно чтению значения i, добавлению 1, а затем записи его обратно в основную память, что представляет собой 3-шаговую операцию. Следовательно, в приведенном выше примере конечное значение может иметь много ситуаций, потому что атомарность не может быть удовлетворена.

Таким образом, есть только простое чтение, присваивание является атомарной операцией, и ему можно присвоить только число, Если используется переменная, есть дополнительная операция чтения значения переменной. Единственным исключением является то, что спецификация виртуальной машины позволяет обрабатывать 64-битные типы данных (long и double) в двух 32-битных операциях, но последняя реализация JDK по-прежнему реализует атомарные операции.

JMM реализует только базовую атомарность, как указано выше.i++Такую операцию нужно делать с помощьюsynchronizedиLockЧтобы обеспечить атомарность всего блока кода. Перед снятием блокировки нить должнаiЗначение сбрасывается обратно в основную память.

2. Видимость:

Говоря о видимости, Java использует volatile для обеспечения видимости. Когда переменная изменяется с помощью volatile, ее изменение будет немедленно сброшено в основную память, а когда другим потокам потребуется прочитать переменную, они прочитают новое значение в памяти. Обычные переменные этого не гарантируют.

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

3. Заказ

JMM позволяет компиляторам и процессорам переупорядочивать инструкции, но задает семантику "как если бы-последовательно", то есть независимо от того, как переупорядочены, результат выполнения программы не может быть изменен. Например, следующий программный сегмент:

double pi = 3.14;    //A
double r = 1;        //B
double s= pi * r * r;//C

Вышеприведенное утверждение может бытьA->B->CВыполняем, результат 3,14, но можно и последоватьB->A->CПорядок выполнения, поскольку A и B являются двумя независимыми операторами, а C зависит от A и B, поэтому A и B можно переупорядочить, но C нельзя поставить перед A и B. JMM гарантирует, что переупорядочивание не повлияет на однопоточное выполнение, но подвержено проблемам в многопоточном.

Например такой код:

int a = 0;
bool flag = false;

public void write() {
    a = 2;              //1
    flag = true;        //2
}

public void multiply() {
    if (flag) {         //3
        int ret = a * a;//4
    }
    
}

Если есть два потока, выполняющих приведенный выше сегмент кода, поток 1 сначала выполняет запись, а затем поток 2 выполняет умножение, конечное значение ret должно быть 4? Результат не обязательно:

重排序
Как показано на рисунке, 1 и 2 в методе записи переупорядочены.Поток 1 сначала присваивает флагу значение true, затем выполняется потоку 2, ret непосредственно вычисляет результат, а затем потоку 1, после чего a присваивается значению 2. , явно опоздал на шаг.

В это время вы можете добавить ключевое слово volatile к флагу, чтобы запретить переупорядочивание, что может обеспечить «упорядочение» программы, а также вы можете использовать тяжеловесы synchronized и Lock для обеспечения упорядочивания, что может гарантировать, что код в эта область - это все. Она выполняется один раз.

Кроме того, у JMM есть некоторые врожденныеупорядоченность, то есть упорядоченность, которая может быть гарантирована без каких-либо средств, обычно называемаяhappens-beforeв общем.<<JSR-133:Java Memory Model and Thread Specification>>Перед определением правил происходит следующее:

  1. правила порядка программы: каждая операция в потоке происходит до любых последующих операций в этом потоке.
  2. Правила блокировки монитора: разблокировать нить, бывает, прежде чем заблокировать нить
  3. правила изменяемой переменной: запись в изменчивое поле происходит перед последующим чтением изменчивого поля.
  4. переходность: Если А происходит раньше В, а В происходит раньше С, то А происходит раньше С.
  5. правила запуска(): Если резьба A выполняетсяThreadB_start()(Начальная резьба б), то нитьThreadB_start()происходит перед любой операцией в B
  6. принцип соединения(): если A выполняетсяThreadB.join()и возвращается успешно, то любая операция в потоке B происходит до того, как поток A начнется сThreadB.join()Операция возвращается успешно.
  7. Принцип прерывания (): для нитиinterrupt()Вызов метода происходит первым, когда код прерванного потока обнаруживает возникновение события прерывания, которое может быть передано черезThread.interrupted()способ определить, произошло ли прерывание
  8. принцип завершения(): Инициализация объекта происходит до егоfinalize()начало метода

Правило 1 Правило порядка выполнения программы означает, что в потоке все операции идут по порядку, а в JMM, пока результат выполнения один и тот же, допускается переупорядочивание. результаты выполнения потока, но нет гарантии, что то же самое верно для многопоточности.

Второе правило монитора правил на самом деле легко понять, то есть перед блокировкой убедитесь, что блокировка была снята, прежде чем вы сможете продолжить блокировку.

Третье правило относится к рассматриваемому volatile: если один поток сначала записывает переменную, а другой поток ее читает, то операция записи должна предшествовать операции чтения.

Четвертое правило — транзитивность того, что происходит раньше.

Следующие несколько статей не будут повторяться одна за другой.

Интервьюер: Как ключевое слово volatile соответствует трем характеристикам параллельного программирования?

Затем необходимо повторить правила для изменчивых переменных: Запись в изменчивое поле происходит до последующего чтения этого изменчивого поля. На самом деле, если переменная объявлена ​​как volatile, то, когда я читаю переменную, я всегда могу прочитать ее последнее значение, где последнее значение означает, что независимо от того, какой другой поток записывает в переменную. Операция будет немедленно обновлена ​​в основной памяти. , и я также могу прочитать только что записанное значение из основной памяти. Другими словами, ключевое слово volatile может гарантировать видимость и упорядоченность.

Продолжайте использовать приведенный выше фрагмент кода в качестве примера:

int a = 0;
bool flag = false;

public void write() {
   a = 2;              //1
   flag = true;        //2
}

public void multiply() {
   if (flag) {         //3
       int ret = a * a;//4
   }
   
}

Этот код не просто страдает от переупорядочения, хотя 1, 2 не переупорядочиваются. 3 будет выполняться не так гладко. Предположим, что поток 1 выполняется первымwriteОперация, поток 2 выполняется сноваmultiplyоперация, так как поток 1 присваивает флагу 1 в рабочей памяти, он не может быть немедленно записан обратно в основную память, поэтому, когда выполняется поток 2,multiplyЗатем прочитать значение флага из основной памяти, оно может быть еще ложным, тогда оператор в скобках не будет выполнен.

Если изменить на следующее:

int a = 0;
volatile bool flag = false;

public void write() {
   a = 2;              //1
   flag = true;        //2
}

public void multiply() {
   if (flag) {         //3
       int ret = a * a;//4
   }
}

Затем поток 1 выполняется первымwrite, поток 2 выполняется сноваmultiply. В соответствии с принципом «случается раньше» этот процесс будет удовлетворять следующим 3 типам правил:

  1. Правило порядка выполнения программы: 1 происходит раньше 2; 3 происходит раньше 4; (изменяемость ограничивает переупорядочивание инструкций, поэтому 1 выполняется раньше 2)
  2. Изменчивое правило: 2 случается до 3
  3. Переходное правило: 1 случается раньше 4

С точки зрения семантики памяти

При записи изменчивой переменной JMM сбрасывает общую переменную в локальной памяти, соответствующей потоку, в основную память.

При чтении изменчивой переменной JMM делает недействительной локальную память, соответствующую потоку, и поток затем считывает общую переменную из основной памяти.

Интервьюер: Двухточечная семантика volatile может гарантировать видимость и упорядоченность, но может ли она гарантировать атомарность?

Прежде всего, мой ответ заключается в том, что атомарность не может быть гарантирована.Если она может быть гарантирована, то она атомарна только для чтения/записи одной volatile-переменной, но бесполезна для составных операций, таких как volatile++, как в следующем примере:

public class Test {
    public volatile int inc = 0;
 
    public void increase() {
        inc++;
    }
 
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
 
        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }

Само собой разумеется, что результат равен 10000, но, скорее всего, это значение меньше 10000 при работе. Кто-то может сказать, что volatile не гарантирует видимость, модификация inc одним потоком должна быть немедленно видна другим потоком! Но операция inc++ здесь является составной операцией, включающей чтение значения inc, его увеличение и последующую запись обратно в основную память.

Предположим, что поток A считывает значение inc, равное 10, в это время он заблокирован, поскольку переменная не была изменена, и правило volatile не может быть запущено.

Поток B в это время также считывает значение inc.Значение inc в основной памяти по-прежнему равно 10, которое автоматически увеличивается, а затем сразу же записывается обратно в основную память, равное 11.

В это время снова наступает очередь выполнения потока А. Поскольку в рабочей памяти хранится 10, он продолжает автоинкремент, а затем записывает обратно в основную память, и снова записывается 11. Таким образом, несмотря на то, что два потока выполняют функцию увеличения() дважды, результат добавляется только один раз.

кто-то сказал,Не делает ли volatile строку кэша недействительной?? Но здесь поток A не изменяет значение inc до того, как начнет работу поток B, поэтому, когда поток B читает, он по-прежнему читает 10.

Другой человек сказал, что поток B записывает 11 обратно в основную память,не сделает недействительной строку кэша потока A?? Однако операция чтения потока А уже выполнена. Только когда операция чтения выполняется и строка кэша оказывается недействительной, он будет считывать значение основной памяти, поэтому здесь поток А может продолжать выполнять только автоматические операции. -приращение.

Подводя итог, можно сказать, что в контексте этой сложной операции невозможно поддерживать функцию атомарности. Однако в приведенном выше примере установки значения флага volatile может по-прежнему гарантировать атомарность, поскольку операция чтения/записи флага представляет собой один шаг.

Чтобы обеспечить атомарность, мы можем полагаться только на классы синхронизированных, блокированных и атомарных операций в параллельных пакетах, то есть на автоинкремент (операция добавления 1), самоуменьшение (операция вычитания 1) и операции сложения для базовых типов данных ( добавление числа), операции вычитания (вычитание числа) инкапсулированы, чтобы гарантировать, что эти операции являются атомарными операциями.

Интервьюер: Все в порядке, так что вы знаете лежащий в основе механизм реализации volatile?

Если вы сгенерируете ассемблерный код из кода с ключевым словом volatile и кода без ключевого слова volatile, вы обнаружите, что код с ключевым словом volatile будет иметь дополнительную инструкцию префикса блокировки.

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

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

Интервьюер: Где бы вы использовали volatile, приведите два примера?

  1. Флаг количества состояния, как и флаг выше, я упомяну еще раз:
int a = 0;
volatile bool flag = false;

public void write() {
    a = 2;              //1
    flag = true;        //2
}

public void multiply() {
    if (flag) {         //3
        int ret = a * a;//4
    }
}

Такие операции чтения и записи для переменных, помеченных как volatile, гарантируют, что модификация будет немедленно видна потоку. По сравнению с синхронизированным, Lock имеет некоторое улучшение эффективности.

2. Реализация одноэлементного паттерна, типичная блокировка с двойной проверкой (DCL)

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

Это ленивый одноэлементный шаблон, объекты создаются только при использовании, а volatile добавляется к экземпляру, чтобы избежать переупорядочения инструкций для операций инициализации.

Интервьюер: Давайте поговорим о нескольких способах написания одноэлементного шаблона и о приведенном выше использовании, как насчет того, чтобы рассказать об этом подробнее?

Ну это уже другая тема, а вопрос с volatile окончательно закрыт. . . посмотрим, если вы получите его