Чому ця програма Java припиняється, незважаючи на те, що, мабуть, вона не повинна (і не стала)?


205

Чутлива операція в моїй лабораторії сьогодні пішла зовсім не так. Привід на електронному мікроскопі перейшов його межу, і після ланцюжка подій я втратив обладнання в $ 12 млн. Я звузив більше ніж 40 К рядків у несправному модулі до цього:

import java.util.*;

class A {
    static Point currentPos = new Point(1,2);
    static class Point {
        int x;
        int y;
        Point(int x, int y) {
            this.x = x;
            this.y = y;
        }
    }
    public static void main(String[] args) {
        new Thread() {
            void f(Point p) {
                synchronized(this) {}
                if (p.x+1 != p.y) {
                    System.out.println(p.x+" "+p.y);
                    System.exit(1);
                }
            }
            @Override
            public void run() {
                while (currentPos == null);
                while (true)
                    f(currentPos);
            }
        }.start();
        while (true)
            currentPos = new Point(currentPos.x+1, currentPos.y+1);
    }
}

Деякі зразки результатів, які я отримую:

$ java A
145281 145282
$ java A
141373 141374
$ java A
49251 49252
$ java A
47007 47008
$ java A
47427 47428
$ java A
154800 154801
$ java A
34822 34823
$ java A
127271 127272
$ java A
63650 63651

Оскільки тут немає арифметики з плаваючою комою, і всі ми знаємо, що підписані цілі числа добре поводяться при переповненні Java, я думаю, що в цьому коді нічого поганого. Однак, незважаючи на вихід, який вказує на те, що програма не досягла умови виходу, вона досягла умови виходу (вона була досягнута і не досягнута?). Чому?


Я помітив, що цього не відбувається в деяких умовах. Я на OpenJDK 6 на 64-розрядному Linux.


41
12 мільйонів техніки? мені дуже цікаво, як це могло статися ... чому ви використовуєте порожній блок синхронізації: синхронізований (це) {}?
Мартін В.

84
Це навіть не віддалено безпечно для потоків.
Метт Бал

8
Цікаво зазначити: додавання finalкласифікатора (який не впливає на створений байт-код) до полів xта y"вирішує" помилку. Хоча це не впливає на байт-код, поля позначені ним, що змушує мене думати, що це побічний ефект оптимізації JVM.
Niv Steingarten

9
@Eugene: Це не повинно закінчитися. Питання "чому це закінчується?". Побудовано A Point p, яке задовольняє p.x+1 == p.y, після чого посилання передається на поточну дільницю. Врешті-решт опитувальний потік вирішує вийти, оскільки він вважає, що умова не задоволена для одного з отриманих Points, але тоді вихід консолі показує, що його слід було задовольнити. Відсутність volatileтут просто означає, що нитка для опитування може застрягнути, але тут явно не проблема.
Ерма К. Пісарро

21
@JohnNicholas: Реальний код (який, очевидно, не такий) мав 100% тестовий покрив і тисячі тестів, багато з яких перевіряли речі в тисячах різних замовлень і перестановок ... Тестування не магічно знаходить кожного кращого випадку, викликаного недетермінованими JIT / кеш / планувальник. Справжня проблема полягає в тому, що розробник, який написав цей код, не знав, що будівництво не відбувається до використання об'єкта. Зауважте, як при видаленні порожнього synchronizedпомилка не відбувається? Це тому, що мені довелося випадково писати код, поки я не знайшов той, який би відтворив цю поведінку детерміновано.
Собака

Відповіді:


140

Очевидно, що запис у currentPos не відбувається - до його читання, але я не бачу, як це може бути проблемою.

currentPos = new Point(currentPos.x+1, currentPos.y+1);робить кілька речей, включаючи запис значень за замовчуванням до xта y(0), а потім записування їх початкових значень у конструктор. Оскільки ваш об’єкт не надруковано опублікований, ці 4 операції запису можуть бути вільно упорядковані компілятором / JVM.

Отже, з точки зору потоку читання, це легальне виконання, якщо читати xз новим значенням, але, наприклад, yзі значенням 0 за замовчуванням, наприклад. До того часу, як ви досягнете printlnоператора (який, до речі, синхронізований і, отже, впливає на операції зчитування), змінні мають свої початкові значення і програма виводить очікувані значення.

Позначення currentPosяк volatileзабезпечить безпечну публікацію, оскільки ваш об’єкт ефективно непорушний - якщо в реальному випадку використання об'єкта буде вимкнено після побудови, volatileгарантій буде недостатньо, і ви знову зможете побачити непослідовний об’єкт.

Крім того, ви можете зробити Pointнепорушним, що також забезпечить безпечне опублікування навіть без використання volatile. Щоб домогтися незмінності, вам просто потрібно позначити xі yостаточно.

Як бічне зауваження, і як уже згадувалося, synchronized(this) {}СВМ може сприйматись як неприйняття (я розумію, ви включили його для відтворення поведінки).


4
Я не впевнений, але чи не зробити так, щоб фінал x і y мав однаковий ефект, уникаючи бар'єру пам'яті?
Майкл Бёклінг

3
Більш проста конструкція - це непорушний точковий об'єкт, який тестує інваріанти на будівництві. Тому ви ніколи не ризикуєте опублікувати небезпечну конфігурацію.
Рон

@BuddyCasino Так, так - я це додав. Якщо чесно, я не пам’ятаю цілої дискусії 3 місяці тому (використання фіналу було запропоновано в коментарях, тому не впевнений, чому я не включив це як варіант).
Ассілія

2
Сама по собі незмінність не гарантує безпечного опублікування (якщо x a y були приватними, але піддавалися лише користувачі, однакова проблема із публікацією все ще існуватиме). остаточне або мінливе це гарантує. Я вважаю за краще фінал над мінливим.
Стів Куо

Імітабельність @SteveKuo вимагає остаточного - без остаточного найкращого можна отримати ефективну незмінність, яка не має однакової семантики.
assylias

29

Оскільки currentPosвін змінюється поза потоком, його слід позначити як volatile:

static volatile Point currentPos = new Point(1,2);

Без мінливості нитка не гарантується читати в оновленнях до currentPos, які робляться в основному потоці. Тож нові значення продовжують записуватися для currentPos, але потік продовжує використовувати попередні кешовані версії з міркувань продуктивності. Оскільки лише одна нитка змінює currentPos, ви можете піти без замків, що підвищить продуктивність.

Результати виглядають набагато інакше, якщо ви читаєте значення лише один раз у потоці для використання у порівнянні та подальшому їх відображенні. Коли я роблю наступне, xзавжди відображається як 1і yзмінюється між 0і великим цілим числом. Я думаю, що поведінка його в цей момент дещо визначена без volatileключового слова, і можливо, що компіляція коду JIT сприяє тому, щоб він діяв так. Крім того, якщо я коментую порожній synchronized(this) {}блок, тоді код також працює, і я підозрюю, що це відбувається через те, що блокування викликає достатню затримку, currentPosі його поля перечитуються, а не використовуються з кеша.

int x = p.x + 1;
int y = p.y;

if (x != y) {
    System.out.println(x+" "+y);
    System.exit(1);
}

2
Так, і я також міг просто поставити замок навколо всього. Який твій погляд?
Собака

Я додав додаткове пояснення щодо використання volatile.
Ед Плез

19

У вас є звичайна пам'ять, посилання "currentpos" та об'єкт Point та його поля за ним, спільне між двома потоками, без синхронізації. Таким чином, не існує визначеного впорядкування між записами, які трапляються з цією пам'яттю в основному потоці, і зчитуваннями в створеному потоці (називаємо це T).

Основний потік виконує наступні записи (ігнорування початкової установки точки, це призведе до того, що px і py мають значення за замовчуванням):

  • до px
  • до пі
  • до поточнихпостів

Оскільки у цих записах немає нічого особливого з точки зору синхронізації / бар'єрів, час виконання може вільно дозволити потоку Т побачити, що вони виникають у будь-якому порядку (головний потік, звичайно, завжди бачить, як записується і читається впорядковано відповідно до програмного замовлення), і відбуваються у будь-яку точку між прочитаними в Т.

Так T робить:

  1. читає currentpos на с
  2. читати px та py (у будь-якому порядку)
  3. порівняйте і візьміть гілку
  4. читати px та py (будь-який порядок) та викликати System.out.println

Зважаючи на відсутність впорядкованих зв’язків між записом в основному і прочитаними в T, очевидно, є кілька способів, як це може призвести до вашого результату, оскільки T може побачити запис основного в currentpos перед записом в currentpos.y або currentpos.x:

  1. Спочатку він читає currentpos.x, перш ніж сталося записування x - отримує 0, потім читає currentpos.y до того, як y запис відбулося - отримує 0. Порівняйте овалі з істинними. Записи стають видимими для T. System.out.println.
  2. Спочатку він читає currentpos.x, після того, як відбулося записування x, а потім читає currentpos.y до того, як y запис відбулося - отримує 0. Порівняйте овалі з істинними. Записи стають видимими для T ... і т.д.
  3. Спочатку він читає currentpos.y, перш ніж відбудеться запис y (0), потім читає currentpos.x після запису x, дорівнює справжньому. тощо.

і так далі ... Тут є ряд перегонів даних.

Я підозрюю, що тут недосконалим припущенням є думка про те, що записи, отримані з цього рядка, стають видимими для всіх потоків у програмному порядку потоку, що виконує його:

currentPos = new Point(currentPos.x+1, currentPos.y+1);

Java не дає такої гарантії (це буде жахливо для продуктивності). Щось більше потрібно додати, якщо вашій програмі потрібне гарантоване впорядкування записів відносно читання в інших потоках. Інші пропонують зробити поля x, y остаточними або, як альтернативу, зробити струми непостійними.

  • Якщо ви зробите поля x, y остаточними, тоді Java гарантує, що записування їх значень буде видно, перш ніж конструктор повернеться, у всіх потоках. Таким чином, оскільки призначення конструктору topospos після конструктора, T-потік гарантовано бачить записи в правильному порядку.
  • Якщо ви робите currentpos мінливими, Java гарантує, що це точка синхронізації, яка буде повністю упорядкована wrt іншими точками синхронізації. Як і в основному, запис в x і y повинен відбуватися перед записом в currentpos, тоді будь-яке зчитування currentpos в іншому потоці повинно бачити також записи x, y, що відбувалися раніше.

Використання final має ту перевагу, що це робить поля непорушними, і, таким чином, дозволяє кешувати значення. Використання непостійних призводить до синхронізації кожного запису та читання поточних записів, що може зашкодити продуктивності.

Докладні відомості див. У главі 17 Спеціалізації мови Java: http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html

(Первісна відповідь передбачала слабку модель пам'яті, тому що я не був впевнений, що гарантована мінливість JLS є достатньою. Відповідь відредаговано, щоб відобразити коментар Assylias, вказуючи на те, що модель Java сильніша - буває раніше, ніж є перехідною - і тому мінливою для поточнихпозицій також достатньо ).


2
Це найкраще пояснення на мій погляд. Дуже дякую!
skyde

1
@skyde, але неправильно щодо семантики мінливих. мінливі гарантії, що при читанні мінливої ​​змінної буде відображатися остання доступна запис летючої змінної , а також будь-яка попередня запис . У цьому випадку, якщо currentPosвін стає нестабільним, присвоєння забезпечує безпечну публікацію currentPosоб'єкта, а також його членів, навіть якщо вони самі не змінюються.
assylias

Ну, я казав, що я не міг зрозуміти, як саме JLS гарантував, що мінливий утворює бар'єр з іншими, нормальними способами читання та запису. Технічно я не можу помилитися в цьому;). Що стосується моделей пам'яті, то доцільно припустити, що замовлення не гарантується і помиляється (ви все-таки в безпеці), ніж навпаки, і будьте помилковими та небезпечними. Це чудово, якщо мінливі надають цю гарантію. Чи можете ви пояснити, яким чином це передбачено у розділі 17 JLS?
paulj

2
Словом, у Point currentPos = new Point(x, y)вас є 3 записи: (w1) this.x = x, (w2) this.y = yі (w3) currentPos = the new point. Програмний порядок гарантує, що hb (w1, w3) і hb (w2, w3). Пізніше в програмі, яку ви читаєте (r1) currentPos. Якщо currentPosвін не мінливий, немає hb між r1 і w1, w2, w3, тому r1 міг спостерігати будь-який (або жоден) з них. З мінливими ви вводите hb (w3, r1). І співвідношення hb є транзитивним, тому ви також введете hb (w1, r1) і hb (w2, r1). Це узагальнено у програмі «Конкурс Java на практиці» (3.5.3. Ідіомати безпечної публікації).
assylias

2
Ах, якщо hb транзитивний таким чином, то це досить сильний "бар'єр", так. Треба сказати, що неважко визначити, що 17.4.5 JLS визначає hb, щоб мати цю властивість. Це, звичайно, не в списку властивостей, наведених близько початку 17.4.5. Перехідне закриття згадується лише далі внизу після деяких пояснювальних записок! У всякому разі, добре знати, дякую за відповідь! :). Примітка: я оновлю свою відповідь, щоб відобразити коментар ассилії.
paulj

-2

Ви можете використовувати об'єкт для синхронізації записів і читання. В іншому випадку, як говорили інші раніше, запис у currentPos відбудеться в середині двох зчитувань p.x + 1 і py

new Thread() {
    void f(Point p) {
        if (p.x+1 != p.y) {
            System.out.println(p.x+" "+p.y);
            System.exit(1);
        }
    }
    @Override
    public void run() {
        while (currentPos == null);
        while (true)
            f(currentPos);
    }
}.start();
Object sem = new Object();
while (true) {
    synchronized(sem) {
        currentPos = new Point(currentPos.x+1, currentPos.y+1);
    }
}

Насправді це робить свою роботу. У своїй першій спробі я поклав читання всередину синхронізованого блоку, але згодом зрозумів, що це насправді не потрібно.
Германо Фронза

1
-1 JVM може довести, що semце не спільне, і трактувати синхронізований вислів як неоперативний ... Той факт, що він вирішує питання, є чистою удачею.
assylias

4
Я ненавиджу багатопотокове програмування, надто багато справ працює через удачу.
Джонатан Аллен

-3

Ви отримуєте доступ до currentPos двічі і не надаєте гарантії, що він не оновлюється між цими двома доступами.

Наприклад:

  1. х = 10, у = 11
  2. робоча нитка оцінює px як 10
  3. головний потік виконує оновлення, тепер x = 11 і y = 12
  4. робоча нитка оцінює py як 12
  5. робоча нитка помічає, що 10 + 1! = 12, тому друкує та виходить.

Ви по суті порівнюєте дві різні точки.

Зауважте, що навіть зробити currentPos змінним не захистить вас від цього, оскільки це два окремих читання робочої нитки.

Додайте

boolean IsValid() { return x+1 == y; }

метод до вашого класу балів. Це забезпечить використання лише одного значення currentPos під час перевірки х + 1 == у.


currentPos читається лише один раз, його значення копіюється в p. p читається двічі, але він завжди буде вказувати на те саме місце.
Джонатан Аллен
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.