Подтвердить что ты не робот

Переупорядочение инструкций в Java JVM

Я читал этот пост.

И автор говорил о взломе hashCode() в String в многопоточной среде.

Имея:

public int hashCode() {
     int h = hash;
     if (h == 0) {
         int off = offset;
         char val[] = value;
         int len = count;

         for (int i = 0; i < len; i++) {
             h = 31*h + val[off++];
         }
         hash = h;
     }
     return h;
 }

Изменился на:

public int hashCode() {
     if (hash == 0) {
         int off = offset;
         char val[] = value;
         int len = count;

         int h = 0;
         for (int i = 0; i < len; i++) {
             h = 31*h + val[off++];
         }
         hash = h;
     }
     return hash;
 }

Который автор говорит, и я цитирую:

"То, что я сделал здесь, это добавление дополнительного чтения: второе чтение хэша перед возвратом. Как бы странно это ни звучало и как бы маловероятно это ни было, первое чтение может вернуть правильно вычисленное значение хеш-функции, и второе чтение может вернуть 0! Это разрешено в модели памяти, потому что модель допускает обширное переупорядочение операций. Второе чтение фактически может быть перемещено в вашем коде, так что ваш процессор сделает это раньше первого! "

Далее, просматривая комментарии, кто-то говорит, что его можно переупорядочить

int h = hash;
if (hash == 0) {
  ...
}
return h;

Как это возможно? Я думал, что переупорядочение включает только перемещение программных операторов вверх и вниз. Каким правилам он следует? Я погуглил, прочитал FAQ по JSR133, проверил книгу "Параллелизм Java на практике", но мне не удается найти место, которое поможет мне понять, в частности, при переупорядочении. Если кто-то может указать мне правильное направление, я был бы очень признателен.

Благодаря Луи, разъясняющему значение слова "Переупорядочение", я не думал в терминах "byteCode"

Тем не менее, я до сих пор не понимаю, почему разрешено перемещать 2-е чтение вперед, это моя наивная попытка перевести его в несколько "байт-код" формат.

Для упрощения операции, используемые для вычисления calchash() выражаются как calchash(). Поэтому я выражаю программу как:

if (hash == 0)  {       
    h = calchash();
    hash = h;
}
return hash;

И моя попытка выразить это в форме "байт-кода":

R1,R2,R3 are in the operands stack, or the registers
h is in the array of local variables

В порядке программы:

if (hash == 0)  {       ---------- R1 = read hash from memory (1st read)
                        ---------- Compare (R1 == 0)
    h = calchash();     ---------- R2 = calchash()
                        ---------- h = R2 (Storing the R2 to local variable h)
    hash = h;           ---------- Hash = h (write to hash)
}
return hash             ---------- R3 = read hash from memory again(2nd read)
                        ---------- return R3

Изменение порядка преобразования (Моя версия основана на комментариях):

                        ---------- R3 = read hash from memory (2nd read) *moved*
if (hash == 0)  {       ---------- R1 = read hash from memory (1st read)
                        ---------- Compare (R1 == 0)
    h = calchash();     ---------- R2 = calchash()
                        ---------- h = R2 (Storing the R2 to local variable h)
    hash = h;           ---------- hash = h (write to hash)
}
return hash             ---------- return R3

Проверяя комментарии еще раз, я нашел ответ автора:

Изменение порядка трансформации (из блога)

r1 = hash;
if (hash == 0) {
  r1 = hash = // calculate hash
}
return r1;

Этот случай фактически работает в одном потоке, но возможен сбой в нескольких потоках.

Кажется, что JVM делают упрощения на основе

h = hash and it simplifies the use of R1, R2, R3 to single R1

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

4b9b3361

Ответ 1

В вашем измененном коде:

public int hashCode() {
     if (hash == 0) { // (1)
         int off = offset;
         char val[] = value;
         int len = count;

         int h = 0;
         for (int i = 0; i < len; i++) {
             h = 31*h + val[off++];
         }
         hash = h;
     }
     return hash; // (2)
 }

(1) и (2) могут быть переупорядочены: (1) может читать ненулевое значение, в то время как (2) будет читать 0. Этого не может произойти в реальной реализации в классе String, потому что расчет производится по локальная переменная и возвращаемое значение также являются локальной переменной, которая по определению является потокобезопасной.

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

ps: автор этого блога, я считаю, является одним из авторов 17-й главы (модель памяти Java) JLS, поэтому я все равно склоняюсь к нему; -)


UPDATE

Следуя различным изменениям/комментариям, давайте рассмотрим байт-код более подробно с этими двумя методами (я предполагаю, что хэш-код всегда 1, чтобы все было просто):

public int hashcode_shared() {
    if (hash == 0) { hash = 1; }
    return hash;
}

public int hashcode_local() {
    int h = hash;
    if (h == 0) { hash = h = 1; }
    return h;
}

Компилятор java на моей машине генерирует следующий байт-код:

public int hashcode_shared();
   0: aload_0                           //read this
   1: getfield      #6                  //read hash (r1)
   4: ifne          12                  //compare r1 with 0
   7: aload_0                           //read this
   8: iconst_1                          //constant 1
   9: putfield      #6                  //put 1 into hash (w1)
  12: aload_0                           //read this
  13: getfield      #6                  //read hash (r2)
  16: ireturn                           //return r2

public int hashcode_local();
   0: aload_0                           //read this
   1: getfield      #6                  //read hash (r1)
   4: istore_1                          //store r1 in local variable h
   5: iload_1                           //read h
   6: ifne          16                  //compare h with 0
   9: aload_0                           //read this
  10: iconst_1                          //constant 1
  11: dup                               //constant again
  12: istore_1                          //store 1 into h
  13: putfield      #6                  //store 1 into hash (w1)
  16: iload_1                           //read h
  17: ireturn                           //return h

В первом примере есть 2 чтения общей переменной hash: r1 и r2. Как обсуждалось выше, поскольку отсутствует синхронизация, и переменная является общей, применяется модель памяти Java, и компилятор /JVM разрешено изменять порядок чтения двух строк: строка # 13 может быть вставлена ​​перед строкой # 1 *.

Во втором примере все операции с h, локальная переменная, должны быть последовательно последовательными, потому что из-за семантики внутри потока и гарантии порядка программ для не общих переменных.

Примечание. Как всегда, тот факт, что переупорядочение разрешено, не означает, что он будет выполнен. На самом деле это вряд ли произойдет в текущих комбинациях x86/hotspot. Но это может произойти и в других текущих или будущих архитектурах /JVM.


* Это немного ярлык, что может произойти на практике, так это то, что компилятор может переписать hashcode_shared следующим образом:

public int hashcode_shared() {
    int h = hash;
    if (hash != 0) return h;
    return (hash = 1);
}

Код строго эквивалентен в одной потоковой среде (он всегда будет возвращать то же значение, что и исходный метод), поэтому допускается переупорядочение. Но в многопоточной среде ясно, что если hash изменяется от 0 до 1 другим потоком между двумя первыми двумя строками, этот переупорядоченный метод будет неправильно возвращать 0.

Ответ 2

Я думаю, что главное, что в потоке, который получает неправильный ответ (возвращает 0), тело оператора if не выполняется - игнорировать его, может быть что угодно.

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

Ответ 3

В условиях неспециалиста я думаю, что эта проблема связана с переупорядочением чтения (выборки).

Каждый поток, T1 и T2, хочет получить все свои "входы" для обработки (и без строгой маркировки volatile) им предоставляется некоторая свобода относительно того, как/когда читать свои данные.

Плохой случай:

Каждый поток должен прочитать переменную (экземпляр) дважды, один раз, чтобы проверить if и один раз для возвращаемого значения. Предположим, что для аргумента T1 выбирает сначала читать if, а T2 выбирает сначала читать return.

Это создает условие гонки, в котором переменная hash изменяется (по T1) между обновлением hash на T1 и T2 во втором чтении (которое T2 использует для проверки if состояние). Итак, теперь тест T2 является ложным, он ничего не делает и возвращает то, что он читал (изначально) для переменной экземпляра, 0.

Фиксированный случай:

Каждому потоку нужно только прочитать переменную (экземпляр) один раз, а затем сразу же сохранить ее в своей локальной переменной. Это предотвращает возникновение проблемы переупорядочения чтения (так как есть только одно чтение).

Ответ 4

Сначала плохой код:

int hash = 0;

public int hashCode() {
     if (hash == 0) {
         int off = offset;
         char val[] = value;
         int len = count;

         int h = 0;
         for (int i = 0; i < len; i++) {
             h = 31*h + val[off++];
         }
         hash = h;
     }
     return hash;
 }

Ясно, что мы можем уменьшить это до своих голых костей, как:

int hash = 0;

public int hashCode() {
     if (hash == 0) {
         // Assume calculateHash does not return 0 and does not modify hash.
         hash = calculateHash();
     }
     return hash;
 }

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

// Pseudocode for thread 1 starts with <1>, thread 2 with <2>. 
// Rn are local registers.
public int hashCode() {
     <2> has not begun
     <1> load r1 with hash (==0) in preparation for return for when hash is != 0
     <2> begins execution - finds hash == 0 and starts the calculation
     <2> modifies hash to contain new value.
     <1> Check hash for zero - it is not so skip the contents of the if
     if (hash == 0) {
         // Assume calculateHash does not return 0 and does not modify hash.
         hash = calculateHash();
         <2> got here but at a time when <1> was up there ^^
     }
     <1> return r1 - supposedly containing a zero.
     return hash;
 }

но затем - для меня - аналогичное лечение можно сделать с хорошим кодом:

public int hashCode() {
     int h = hash;
     if (h == 0) {
         hash = h = calculateHash();
     }
     return h;
 }

а затем

public int hashCode() {
     <2> has not begun
     <1> load r1 with hash (==0) in preparation for return for when h is != 0
     <2> begins execution - finds h == 0 and starts the calculation
     <2> modifies hash to contain new value.
     <1> load r2 with hash - from now on r2 is h
     int h = hash;
     <1> Check r2 for zero - it is not so skip the contents of the if
     if (h == 0) {
         hash = h = calculateHash();
     }
     <1> we know that when hash/h are non-zero it doesn't matter where we get our return from - both r1 and r2 must have the same value.
     <1> return r1 - supposedly containing a zero.
     return h;
 }

Я не понимаю, почему это реальная возможность, а другая - нет.