здрасти Продължаваме нашето изследване на многопоточността. Днес ще се запознаем с
volatile
ключовата дума и yield()
метода. Нека се потопим :)
Нестабилната ключова дума
Когато създаваме многонишкови applications, можем да се сблъскаме с два сериозни проблема. Първо, когато се изпълнява многонишково приложение, различни нишки могат да кешират стойностите на променливите (вече говорихме за това в урока, озаглавен „Използване на volatile“ ). Може да имате ситуация, в която една нишка променя стойността на променлива, но втора нишка не вижда промяната, защото работи с нейното кеширано копие на променливата. Естествено, последствията могат да бъдат сериозни. Да предположим, че това не е просто няHowва стара променлива, а по-скоро салдото по вашата банкова сметка, което изведнъж започва произволно да скача нагоре-надолу :) Това не звучи забавно, нали? Второ, в Java операциите за четене и писане на всички примитивни типове,long
double
, са атомни. Ами, например, ако промените стойността на int
променлива в една нишка и в друга нишка прочетете стойността на променливата, ще получите or старата й стойност, or новата, т.е. стойността, която е резултат от промяната в нишка 1. Няма "междинни стойности". Това обаче не работи с long
s и double
s. Защо? Поради поддръжката на различни платформи. Помните ли в началните нива, че казахме, че водещият принцип на Java е „пиши веднъж, изпълнявай навсякъде“? Това означава поддръжка на различни платформи. С други думи, Java приложение работи на всяHowви различни платформи. Например на операционни системи Windows, различни версии на Linux or MacOS. Ще работи безпроблемно на всички тях. С тегло 64 бита,long
double
са „най-тежките“ примитиви в Java. А някои 32-битови платформи просто не прилагат атомарно четене и запис на 64-битови променливи. Такива променливи се четат и записват с две операции. Първо, първите 32 бита се записват в променливата, а след това се записват още 32 бита. В резултат на това може да възникне проблем. Една нишка записва няHowва 64-битова стойност в X
променлива и го прави в две операции. В същото време втора нишка се опитва да прочете стойността на променливата и го прави между тези две операции - когато първите 32 бита са бor записани, но вторите 32 бита не са. В резултат на това той чете междинна, неправилна стойност и имаме грешка. Например, ако на такава платформа се опитаме да напишем номера на 9223372036854775809 към променлива, тя ще заема 64 бита. В двоична форма изглежда така: 100000000000000000000000000000000000000000000000000000001 Първата нишка започва да записва числото в променливата. Първоначално записва първите 32 бита ( 100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 ) И втората нишка може да се вклини между тези операции, четейки междинната стойност на променливата (100000000000000000000000000000000000000000000000), които са първите 32 бита, които вече са записани. В десетичната система това число е 2 147 483 648. С други думи, просто искахме да запишем числото 9223372036854775809 в променлива, но поради факта, че тази операция не е атомарна на някои платформи, имаме злото число 2,147,483,648, което се появи от нищото и ще има неизвестен ефект върху програма. Втората нишка просто прочете стойността на променливата, преди да е приключила записването й, т.е. нишката видя първите 32 бита, но не и вторите 32 бита. Разбира се, тези проблеми не са възникнали вчера. Java ги решава с една ключова дума: volatile
. Ако използвамеvolatile
ключова дума, когато декларираме няHowва променлива в нашата програма...
public class Main {
public volatile long x = 2222222222222222222L;
public static void main(String[] args) {
}
}
…означава, че:
- Винаги ще се чете и пише атомарно. Дори ако е 64-битов
double
orlong
. - Java машината няма да го кешира. Така че няма да имате ситуация, в която 10 нишки работят със собствените си локални копия.
Методът yield().
Вече прегледахме много отThread
методите на класа, но има един важен, който ще бъде нов за вас. Това е yield()
методът . И прави точно това, което подсказва името му! Когато извикаме yield
метода на нишка, той всъщност говори с другите нишки: „Хей, момчета. Не бързам особено за никъде, така че ако за някой от вас е важно да получи процесорно време, вземете го — мога да изчакам. Ето прост пример How работи това:
public class ThreadExample extends Thread {
public ThreadExample() {
this.start();
}
public void run() {
System.out.println(Thread.currentThread().getName() + " yields its place to others");
Thread.yield();
System.out.println(Thread.currentThread().getName() + " has finished executing.");
}
public static void main(String[] args) {
new ThreadExample();
new ThreadExample();
new ThreadExample();
}
}
Ние последователно създаваме и стартираме три нишки: Thread-0
, Thread-1
и Thread-2
. Thread-0
започва пръв и веднага отстъпва на останалите. След това Thread-1
се стартира и също дава. След това Thread-2
се стартира, което също дава. Нямаме повече нишки и след като Thread-2
даде последното си място, планировчикът на нишки казва: „Хм, няма повече нови нишки. Кого имаме на опашката? Кой отстъпи мястото си преди Thread-2
? Изглежда, че беше Thread-1
. Добре, това означава, че ще го оставим да работи. Thread-1
завършва работата си и след това планировчикът на нишки продължава своята координация: „Добре, Thread-1
готово. Имаме ли още някой на опашката?“. Нишка-0 е в опашката: отстъпи мястото си точно предиThread-1
. Сега идва редът му и се изпълнява докрай. След това планировчикът завършва координирането на нишките: „Добре, Thread-2
, отстъпихте на други нишки и всички те вече са готови. Ти беше последният, който се предаде, така че сега е твой ред. След това Thread-2
работи до завършване. Конзолният изход ще изглежда така: Нишка-0 отстъпва мястото си на други Нишка-1 отстъпва мястото си на други Нишка-2 отстъпва мястото си на други Нишка-1 е завършила изпълнението. Thread-0 завърши изпълнението. Thread-2 завърши изпълнението. Разбира се, планировчикът на нишки може да стартира нишките в различен ред (например 2-1-0 instead of 0-1-2), но принципът остава същият.
Случва се - преди правила
Последното нещо, което ще засегнем днес, е понятието „ случва се преди “. Както вече знаете, в Java планировчикът на нишки изпълнява по-голямата част от работата, свързана с разпределянето на време и ресурси на нишките за изпълнение на техните задачи. Също така многократно сте виждали How нишките се изпълняват в произволен ред, който обикновено е невъзможно да се предвиди. И като цяло, след „последователното“ програмиране, което направихме преди, многопоточното програмиране изглежда като нещо произволно. Вече сте повярвали, че можете да използвате множество методи за контрол на потока на многонишкова програма. Но многонишковостта в Java има още един стълб — 4-те правила „ случва се преди “. Разбирането на тези правила е съвсем просто. Представете си, че имаме две нишки —A
иB
. Всяка от тези нишки може да изпълнява операции 1
и 2
. Във всяко правило, когато казваме „ А се случва преди Б “, имаме предвид, че всички промени, напequalsи от нишката A
преди операцията, 1
и промените, произтичащи от тази операция, са видими за нишката, B
когато операцията 2
е извършена и след това. Всяко правило гарантира, че когато пишете многонишкова програма, определени събития ще се появят преди други в 100% от времето и че по време на работа 2
нишката B
винаги ще бъде наясно с промените, които нишката A
е направила по време на работа 1
. Нека ги прегледаме.
Правило 1.
Освобождаването на mutex се случва преди същият монитор да бъде придобит от друга нишка. Мисля, че разбирате всичко тук. Ако мутексът на обект or клас е придобит от една нишка, например от нишкаA
, друга нишка (нишка B
) не може да го придобие по същото време. Трябва да изчака, докато мютексът бъде освободен.
Правило 2.
МетодътThread.start()
се случва преди Thread.run()
. Отново няма нищо трудно тук. Вече знаете, че за да започнете да изпълнявате codeа вътре в run()
метода, трябва да извикате start()
метода в нишката. По-конкретно методът за стартиране, а не run()
самият метод! Това правило гарантира, че стойностите на всички променливи, зададени преди Thread.start()
извикването, ще бъдат видими в run()
метода, след като той започне.
Правило 3.
Краят наrun()
метода се случва преди връщането от join()
метода. Да се върнем към нашите две теми: A
и B
. Извикваме join()
метода, така че нишката B
гарантирано ще изчака завършването на нишката, A
преди да свърши работата си. Това означава, че методът на обекта A run()
гарантирано ще се изпълнява до самия край. И всички промени в данните, които се случват в run()
метода на нишката, A
са сто процента гарантирани, че ще бъдат видими в нишката, B
след като приключи, чакайки нишката A
да завърши работата си, за да може да започне собствената си работа.
Правило 4.
Записването вvolatile
променлива се случва преди четене от същата променлива. Когато използваме volatile
ключовата дума, всъщност винаги получаваме текущата стойност. Дори с long
or double
(говорихме по-рано за проблемите, които могат да възникнат тук). Както вече разбирате, промените, напequalsи в някои нишки, не винаги са видими за други нишки. Но, разбира се, има много чести ситуации, в които подобно поведение не ни устройва. Да предположим, че присвояваме стойност на променлива в нишка A
:
int z;
….
z = 555;
Ако нашата B
нишка трябва да покаже стойността на z
променливата на конзолата, тя лесно може да покаже 0, защото не знае за присвоената стойност. Но правило 4 гарантира, че ако декларираме променливата z
като volatile
, тогава промените в нейната стойност в една нишка винаги ще бъдат видими в друга нишка. Ако добавим към думата volatile
към предишния code...
volatile int z;
….
z = 555;
... тогава предотвратяваме ситуацията, при която нишката B
може да показва 0. Записването в volatile
променливи се случва преди четене от тях.
GO TO FULL VERSION