Різниця між летючими та синхронізованими в Java


233

Мене цікавить різниця між оголошенням змінної як volatileі завжди доступом до змінної в synchronized(this)блоці на Java?

Відповідно до цієї статті http://www.javamex.com/tutorials/synchronization_volatile.shtml можна сказати багато, і є багато відмінностей, а також деякі подібності.

Мені особливо цікава ця інформація:

...

  • доступ до мінливої ​​змінної ніколи не має можливості блокувати: ми лише завжди робимо просте читання або запис, тому на відміну від синхронізованого блоку ми ніколи не будемо триматися на жодному блокуванні;
  • оскільки доступ до змінної змінної ніколи не блокує, це не підходить для випадків, коли ми хочемо читати-оновити-записати як атомну операцію (якщо тільки ми не готові "пропустити оновлення");

Що вони означають під час читання-оновлення-запису ? Це не запис також оновлення або вони просто означають, що оновлення - це запис, який залежить від прочитаного?

Найбільше, коли більш доречно оголошувати змінні, volatileа не отримувати доступ до них через synchronizedблок? Чи корисно використовувати volatileдля змінних, які залежать від введення? Наприклад, є змінна, яка називається, renderщо зчитується через цикл візуалізації та встановлюється подією натискання клавіші?

Відповіді:


383

Важливо розуміти, що для безпеки потоку є два аспекти.

  1. контроль виконання та
  2. видимість пам'яті

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

Використання synchronizedперешкоджає будь-якому іншому потоку отримувати монітор (або блокувати) для одного і того ж об'єкта , тим самим запобігаючи одночасному виконанню всіх блоків коду, захищених синхронізацією на одному об'єкті . Синхронізація також створює бар'єр пам’яті «раніше», викликаючи обмеження видимості пам’яті таким чином, що все, що робиться до того моменту, коли деяка нитка звільняє замок, з’являється в іншому потоці, згодом набуваючи той самий замок що відбувся до того, як він придбав замок. На практиці, на поточному апаратному забезпеченні це, як правило, спричиняє змивання кешів процесора при придбанні монітора і записуванні в основну пам'ять при його звільненні, і те і інше (відносно) дорого.

Використання volatile, з іншого боку, змушує всі звернення (читання або запис) до змінної змінної відбуватися до основної пам'яті, ефективно утримуючи змінну змінну з кеш-процесорів. Це може бути корисно для деяких дій, коли просто потрібно, щоб видимість змінної була правильною, а порядок доступу не важливий. Використання volatileтакож змінює лікування longта ). З метою наочності кожен доступ до летючого поля діє як половина синхронізації.double вимагає доступу до них, щоб бути атомним; для деяких (старих) апаратних засобів це може потребувати блокування, хоча не для сучасного 64-бітного обладнання. Відповідно до нової моделі пам'яті (JSR-133) для Java 5+, семантика енергонезалежності була посилена настільки ж сильно, як і синхронізована щодо видимості пам'яті та впорядкування інструкцій (див. Http://www.cs.umd.edu /users/pugh/java/memoryModel/jsr-133-faq.html#volatile

Згідно з новою моделлю пам'яті, все ще вірно, що мінливі змінні не можуть бути впорядковані між собою. Різниця полягає в тому, що тепер вже не так легко змінити звичайний доступ до поля навколо них. Запис у мінливе поле має той самий ефект пам'яті, що і випуск монітора, а читання з мінливого поля має такий же ефект пам'яті, що і монітор. По суті, через те, що нова модель пам’яті ставить більш жорсткі обмеження щодо переупорядкування доступу до летючого поля з іншими полями доступу, мінливими чи ні, все, що було видно для потоку, Aколи воно записує в мінливе поле, fстає видимим для потоку, Bколи воно читає f.

- поширені запитання JSR 133 (модель пам'яті Java)

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

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

// Declaration
public class SharedLocation {
    static public SomeObject someObject=new SomeObject(); // default object
    }

// Publishing code
// Note: do not simply use SharedLocation.someObject.xxx(), since although
//       someObject will be internally consistent for xxx(), a subsequent 
//       call to yyy() might be inconsistent with xxx() if the object was 
//       replaced in between calls.
SharedLocation.someObject=new SomeObject(...); // new object is published

// Using code
private String getError() {
    SomeObject myCopy=SharedLocation.someObject; // gets current copy
    ...
    int cod=myCopy.getErrorCode();
    String txt=myCopy.getErrorText();
    return (cod+" - "+txt);
    }
// And so on, with myCopy always in a consistent state within and across calls
// Eventually we will return to the code that gets the current SomeObject.

Виступаючи конкретно на запитання читання-оновлення-написання. Розглянемо наступний небезпечний код:

public void updateCounter() {
    if(counter==1000) { counter=0; }
    else              { counter++; }
    }

Тепер при несинхронізованому методі updateCounter () два потоки можуть вводити його одночасно. Серед багатьох перестановок того, що може статися, одна з них полягає в тому, що нитка-1 робить тест на counter == 1000 і знаходить це правдою, а потім призупиняється. Тоді нитка-2 робить той самий тест, а також бачить це правдою і призупиняється. Потім потік-1 поновлюється і встановлює лічильник на 0. Потім потік-2 поновлюється і знову встановлює лічильник 0, оскільки пропустив оновлення з потоку-1. Це також може статися, навіть якщо перемикання потоків відбувається не так, як я описав, а просто тому, що дві різні кешовані копії лічильника були присутні у двох різних ядрах процесора, і кожен потік працював на окремому ядрі. З цього питання, один потік може мати лічильник за одним значенням, а інший може мати лічильник при зовсім іншому значенні саме через кешування.

Важливим у цьому прикладі є те, що лічильник змінних зчитувався з основної пам'яті в кеш, оновлювався в кеші і лише записувався до основної пам'яті в якийсь невизначений момент пізніше, коли стався бар'єр пам'яті або коли пам'ять кешу була потрібна для чогось іншого. Створення лічильника volatileнедостатньо для безпеки потоку цього коду, оскільки тест на максимум та завдання - це дискретні операції, включаючи приріст, який є набором read+increment+writeінструкцій безатомної машини, щось на кшталт:

MOV EAX,counter
INC EAX
MOV counter,EAX

Летючі змінні корисні лише тоді, коли всі операції, які виконуються над ними, є "атомними", наприклад, мій приклад, коли посилання на повністю сформований об'єкт читається або записується (і, справді, зазвичай це пишеться лише з однієї точки). Іншим прикладом може бути посилання на мінливий масив, що підтримує список копіювання при записі, за умови, що масив зчитується лише попередньо взявши локальну копію посилання на нього.


5
Дуже дякую! Приклад із лічильником зрозумілий простий. Однак, коли справи стають справжніми, це трохи інакше.
Альбус Дамблдор

"На практиці це стосується поточного обладнання, як правило, спричиняє змивання кешів процесора при придбанні монітора і записуванні в основну пам'ять при його звільненні. Обидва вони дорогі (відносно кажучи)." . Коли ви говорите кеш процесора, чи це те саме, що Java стеки локальні для кожного потоку? чи у потоки є своя локальна версія Heap? Вибачте, якщо я тут нерозумно.
NishM

1
@nishm Це не те саме, але він би включав локальні кеші ниток, що займаються. .
Лоуренс Дол,

1
@ MarianPaździoch: Приріст або декремент НЕ є читанням або записом, це читанням і записом; це зчитування в регістр, потім приріст реєстру, потім повернення до пам'яті. Читання та запис є індивідуально атомними, але кілька таких операцій не є.
Лоуренс Дол

2
Отже, згідно з FAQ, не тільки дії, зроблені з моменту придбання блокування, стають видимими після розблокування, але й усі дії, зроблені цим потоком, стають видимими. Навіть дії, зроблені до придбання блокування.
Лій

97

непостійний - модифікатор поля , а синхронізований модифікує кодові блоки та методи . Таким чином, ми можемо вказати три варіанти простого аксесуара за допомогою цих двох ключових слів:

    int i1;
    int geti1() {return i1;}

    volatile int i2;
    int geti2() {return i2;}

    int i3;
    synchronized int geti3() {return i3;}

geti1()отримує доступ до значення, що зберігається в i1поточному потоці. Нитки можуть мати локальні копії змінних, і дані не повинні бути такими ж, як дані, що містяться в інших потоках. Зокрема, інший потік, можливо, оновився i1в його потоці, але значення в поточному потоці може відрізнятися від цього оновлене значення. Насправді Java має уявлення про "основну" пам'ять, і це пам'ять, яка містить поточне "правильне" значення для змінних. Нитки можуть мати власну копію даних для змінних, а потокова копія може відрізнятися від "головної" пам'яті. Тому насправді для "головної" пам'яті можливо значення 1 для i1, для потоку1 значення 2 для i1і для потоку2мати значення 3 для, i1якщо нитки1 та нитка2 оновлювали i1, але оновлене значення ще не поширювалося на "основну" пам'ять чи інші потоки.

З іншого боку, geti2()ефективно отримує доступ до значення i2"головної" пам'яті. Нестабільною змінною заборонено мати локальну копію змінної, яка відрізняється від значення, яке зараз міститься в "основній" пам'яті. Фактично, змінна, оголошена летючою, повинна синхронізувати дані по всіх потоках, так що коли ви отримуєте доступ або оновлюєте змінну в будь-якому потоці, всі інші потоки одразу бачать те саме значення. Як правило, мінливі змінні мають більший доступ та оновлення, ніж "прості" змінні. Зазвичай ниткам дозволяється мати власну копію даних для кращої ефективності.

Існує дві різниці між волітилом і синхронізованим.

По-перше, синхронізований отримує і випускає блокування на моніторах, які можуть змусити одночасно виконувати лише один потік. Це досить відомий синхронізований аспект. Але синхронізований також синхронізує пам'ять. Фактично синхронізований синхронізує всю потокову пам'ять з "основною" пам'яттю. Тож виконання geti3()виконує наступне:

  1. Нитка набуває замок на моніторі для об'єкта цього.
  2. Пам'ять потоку видаляє всі її змінні, тобто всі її змінні ефективно читаються з "головної" пам'яті.
  3. Блок коду виконується (у цьому випадку встановлюється повернене значення до поточного значення i3, яке, можливо, щойно було скинуто з "основної" пам'яті).
  4. (Будь-які зміни змінних зараз зазвичай записуються в "основну" пам'ять, але для geti3 () у нас немає змін.)
  5. Нитка звільняє замок на моніторі для об'єкта цього.

Отже, коли мінливий лише синхронізує значення однієї змінної між потоком пам'яті та "основною" пам'яттю, синхронізований синхронізує значення всіх змінних між потоком пам'яті та "основною" пам'яттю, а також блокує та звільняє монітор до завантаження. Чітко синхронізований, швидше за все, матиме більше накладних витрат, ніж мінливий.

http://javaexp.blogspot.com/2007/12/difference-between-volatile-and.html


35
-1, Volatile не набуває блокування, він використовує базову архітектуру процесора, щоб забезпечити видимість у всіх потоках після запису.
Майкл Баркер

Варто зазначити, що можуть бути деякі випадки, коли блокування може бути використане для гарантування атомності записів. Наприклад, довго писати на 32-бітній платформі, яка не підтримує права розширеної ширини. Intel уникає цього, використовуючи регістри SSE2 (шириною 128 біт) для обробки летючих довжин. Однак розгляд непостійного блокування може призвести до неприємних помилок у вашому коді.
Майкл Баркер

2
Важливою семантикою, що поділяється замками мінливих змінних, є те, що вони обидва забезпечують краї Happens-Before (Java 1.5 і новіші). Введення синхронізованого блоку, виймання блокування та зчитування з летучих вважаються "придбанням", а звільнення блокування, вихід із синхронізованого блоку та написання непостійного - це все форми "випуску".
Майкл Баркер

20

synchronizedє модифікатором рівня доступу / рівня блоку доступу. Це переконається, що одна нитка володіє замком для критичного перерізу. Лише нитка, у якої є замок, може входити до synchronizedблоку. Якщо інші потоки намагаються отримати доступ до цього критичного розділу, їм доведеться почекати, поки поточний власник звільнить замок.

volatileце модифікатор змінного доступу, який змушує всі потоки отримувати останнє значення змінної з основної пам'яті. Блокування не потрібно для доступу до volatileзмінних. Усі потоки можуть одночасно отримувати значення змінних змінних.

Хороший приклад використання летючої змінної: Dateзмінної.

Припустимо, що ви зробили змінну дати volatile. Усі потоки, які отримують доступ до цієї змінної, завжди отримують останні дані з основної пам'яті, щоб усі потоки показували реальне (фактичне) значення дати. Вам не потрібні різні потоки, що показують різний час для однієї змінної. Усі потоки повинні показувати правильне значення дати.

введіть тут опис зображення

Перегляньте цю статтю, щоб краще зрозуміти volatileпоняття.

Лоренс Дол клірик пояснив ваше read-write-update query.

Щодо інших ваших запитів

Коли доцільніше оголосити змінні змінними, ніж отримати доступ до них за допомогою синхронізованих?

Ви повинні використовувати, volatileякщо ви думаєте, що всі потоки повинні отримати фактичне значення змінної в режимі реального часу, як приклад, який я пояснив для змінної Date.

Чи корисно використовувати мінливі для змінних, які залежать від введення?

Відповідь буде такою ж, як у першому запиті.

Для кращого розуміння зверніться до цієї статті .


Отже, читання може відбуватися одночасно, і весь потік буде читати останнє значення, оскільки процесор не кешує основну пам'ять у кеш потоку процесора, але що робити з записом? Запис не повинен бути одночасно правильним? Друге питання: якщо блок синхронізований, але змінна не є мінливою, значення змінної в синхронізованому блоці все одно може бути змінено іншим потоком в іншому блоці коду, правильно?
the_prole

11

tl; dr :

Існує 3 основних проблеми з багатопотоковою читанням:

1) Умови гонки

2) Кешування / застаріла пам'ять

3) Оптимізація компілятора та процесора

volatileможе вирішити 2 і 3, але не вдається вирішити 1. synchronized/ явні блокування можуть вирішити 1, 2 і 3.

Розробка :

1) Розглянемо цей потік небезпечним кодом:

x++;

Хоча це може виглядати як одна операція, це насправді 3: зчитування поточного значення x з пам'яті, додавання до нього 1 та збереження його в пам'ять. Якщо кілька ниток намагаються зробити це одночасно, результат операції не визначений. Якщо xспочатку було 1, після 2 потоків, що оперують кодом, це може бути 2, а може бути 3, залежно від того, який потік виконано, яку частину операції перед тим, як управління було передано іншому потоку. Це форма перегонів .

Використання synchronizedблоку коду робить його атомним - це означає, що це робить так, ніби 3 операції відбуваються одразу, і немає ніякого способу, щоб інший потік потрапляв посередині і заважав. Отже, якщо xбуло 1, а 2 нитки намагаються заздалегідь виконати, x++ми знаємо, що в кінцевому підсумку це буде дорівнювати 3. Отже, це вирішує задачу про стан гонки.

synchronized (this) {
   x++; // no problem now
}

Позначення xяк volatileне робить x++;атомним, тому це не вирішує цю проблему.

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

Врахуйте , що в одному потоці, x = 10;. А дещо пізніше, в іншому потоці, x = 20;. Зміна значення значення xможе не відображатися в першому потоці, оскільки інший потік зберег нове значення в робочій пам'яті, але не скопіював його в основну пам'ять. Або що він скопіював його в основну пам'ять, але перший потік не оновив свою робочу копію. Тож якщо зараз перший потік перевіряє, if (x == 20)відповідь буде false.

Позначення змінної в volatileосновному вказує всім потокам робити операції читання та запису лише в основній пам'яті. synchronizedповідомляє кожному потоку перейти до оновлення їх значення з основної пам'яті, коли вони входять до блоку, і повернути результат назад в основну пам'ять, коли вони вийдуть з блоку.

Зауважте, що на відміну від перегонів даних, застарілу пам’ять не так легко (повторно) виробляти, оскільки в будь-якому випадку виникають перемикання на основну пам'ять.

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

Розглянемо наступний код:

boolean b = false;
int x = 10;

void threadA() {
    x = 20;
    b = true;
}

void threadB() {
    if (b) {
        System.out.println(x);
    }
}

Ви можете подумати, що threadB може надрукувати лише 20 (або взагалі нічого не надрукувати, якщо threadB if-check виконується перед встановленням bна true), як bвстановлено на true лише післяx буде встановлено значення 20, але компілятор / CPU може вирішити змінити порядок threadA, у цьому випадку threadB також може надрукувати 10. Позначення bяк volatileгарантує, що воно не буде переупорядковане (або відкинуто в певних випадках). Що означає, що ниткаB могла надрукувати лише 20 (або взагалі нічого). Позначення методів синхронізованими дасть той самий результат. Також маркування змінної як volatileтільки гарантує, що вона не буде впорядкована, але все до / після її все ще можна переупорядкувати, тому синхронізація може бути більш підходящою у деяких сценаріях.

Зауважте, що до появи нової моделі пам'яті Java 5, енергонезалежні не вирішили цю проблему.


1
"Хоча це може виглядати як одна операція, це насправді 3: зчитування поточного значення x з пам'яті, додавання до нього 1 та збереження його в пам'ять." - Правильно, тому що значення з пам'яті повинні проходити через схему процесора, щоб їх додавати / змінювати. Незважаючи на те, що це просто перетворюється на одну INCоперацію збірки , основні операції з процесором все ще в 3 рази і вимагають блокування для безпеки потоку. Гарна думка. Незважаючи на це, INC/DECкоманди можуть бути атомно позначені в збірці і все ще мати 1 атомну операцію.
Зомбі

@Zombies, тому коли я створюю синхронізований блок для x ++, чи перетворює він його в атомний INC / DEC, позначений прапором, або він використовує звичайний замок?
Девід Рефаелі

Не знаю! Що я знаю, це те, що INC / DEC не є атомними, оскільки для процесора він повинен завантажувати значення та читати його, а також ЗАПИСАТИ його (на пам'ять), як і будь-яка інша арифметична операція.
Зомбі
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.