1. Общ преглед
При липса на необходими синхронизации, компилаторът, времето за изпълнение или процесорите могат да прилагат всякакви оптимизации. Въпреки че тези оптимизации са полезни през повечето време, понякога те могат да причинят фини проблеми.
Кеширането и пренареждането са сред онези оптимизации, които могат да ни изненадат в едновременен контекст. Java и JVM осигуряват много начини за контрол на реда на паметта, а променливата ключова дума е един от тях.
В тази статия ще се съсредоточим върху тази основополагаща, но често неразбрана концепция на езика Java - променливата ключова дума. Първо ще започнем с малко предистория за това как работи основната компютърна архитектура и след това ще се запознаем с реда на паметта в Java.
2. Споделена многопроцесорна архитектура
Процесорите са отговорни за изпълнението на програмните инструкции. Следователно те трябва да извлекат както инструкции за програмата, така и необходимите данни от RAM.
Тъй като процесорите са способни да изпълняват значителен брой инструкции в секунда, извличането от RAM не е толкова идеално за тях. За да подобрят тази ситуация, процесорите използват трикове като Извън изпълнение на поръчки, Предвиждане на клонове, Спекулативно изпълнение и, разбира се, Кеширане.
Тук влиза в сила следната йерархия на паметта:

Тъй като различните ядра изпълняват повече инструкции и манипулират повече данни, те запълват кеша си с по-подходящи данни и инструкции. Това ще подобри общата производителност за сметка на въвеждането на предизвикателства за съгласуваност на кеша .
Казано по-просто, трябва да помислим два пъти какво се случва, когато една нишка актуализира кеширана стойност.
3. Кога да се използва летливо
За да разширим повече кохерентността на кеша, нека вземем един пример от книгата Java Concurrency на практика:
public class TaskRunner { private static int number; private static boolean ready; private static class Reader extends Thread { @Override public void run() { while (!ready) { Thread.yield(); } System.out.println(number); } } public static void main(String[] args) { new Reader().start(); number = 42; ready = true; } }
Класът TaskRunner поддържа две прости променливи. В основния си метод той създава друга нишка, която се върти върху готовата променлива, стига да е фалшива. Когато променливата стане вярна, нишката просто ще отпечата числовата променлива.
Мнозина могат да очакват тази програма просто да отпечата 42 след кратко забавяне. В действителност обаче забавянето може да бъде много по-дълго. Може дори да виси завинаги, или дори да отпечата нула!
Причината за тези аномалии е липсата на подходяща видимост и пренареждане на паметта . Нека ги оценим по-подробно.
3.1. Видимост на паметта
В този прост пример имаме две нишки на приложение: основната нишка и нишката на четеца. Нека си представим сценарий, при който ОС планира тези нишки на две различни ядра на процесора, където:
- Основната нишка има копие на готови и числови променливи в основния кеш
- Нишката на четеца също завършва с копията си
- Основната нишка актуализира кешираните стойности
В повечето съвременни процесори заявките за запис няма да бъдат приложени веднага след издаването им. Всъщност процесорите са склонни да поставят на опашка тези записи в специален буфер за запис . След известно време те ще приложат тези записи в основната памет наведнъж.
С всичко казано, когато основната нишка актуализира броя и готовите променливи, няма гаранция за това, което може да види нишката на четеца. С други думи, нишката на четеца може да види актуализираната стойност веднага, или с известно закъснение, или изобщо никога!
Тази видимост на паметта може да причини проблеми с оживеността в програми, които разчитат на видимост.
3.2. Пренареждане
За да бъде нещата още по-лоши, нишката на четеца може да вижда тези записи в произволен ред, различен от действителния ред на програмата . Например, тъй като първо актуализираме числовата променлива:
public static void main(String[] args) { new Reader().start(); number = 42; ready = true; }
Може да очакваме отпечатъци от нишка на четеца 42. Всъщност обаче е възможно да се види нула като отпечатана стойност!
Пренареждането е техника за оптимизация за подобряване на производителността. Интересното е, че различни компоненти могат да прилагат тази оптимизация:
- Процесорът може да измие своя буфер за запис във всеки ред, различен от реда на програмата
- Процесорът може да приложи техника за изпълнение извън поръчката
- JIT компилаторът може да оптимизира чрез пренареждане
3.3. изменчива заповед за памет
За да гарантираме, че актуализациите на променливи се разпространяват предсказуемо в други нишки, трябва да приложим променливия модификатор към тези променливи:
public class TaskRunner { private volatile static int number; private volatile static boolean ready; // same as before }
По този начин комуникираме с времето за изпълнение и процесора, за да не пренареждаме инструкции, включващи променливата променлива. Също така, процесорите разбират, че трябва да изтрият всички актуализации на тези променливи веднага.
4. нестабилна и нишка синхронизация
За многонишковите приложения трябва да осигурим няколко правила за последователно поведение:
- Взаимно изключване - само една нишка изпълнява критична секция наведнъж
- Видимост - промените, направени от една нишка в споделените данни, са видими за други нишки, за да се поддържа последователност на данните
синхронизираните методи и блокове предоставят и двете горепосочени свойства, за сметка на изпълнението на приложението.
volatile е доста полезна ключова дума, защото може да помогне да се осигури видимостта на промяната на данните, без, разбира се, да осигури взаимно изключване . По този начин е полезно на местата, където сме добре с множество нишки, изпълняващи блок код паралелно, но трябва да осигурим свойството видимост.
5. Случва се преди поръчка
The memory visibility effects of volatile variables extend beyond the volatile variables themselves.
To make matters more concrete, let's suppose thread A writes to a volatile variable, and then thread B reads the same volatile variable. In such cases, the values that were visible to A before writing the volatile variable will be visible to B after reading the volatile variable:

Technically speaking, any write to a volatile field happens before every subsequent read of the same field. This is the volatile variable rule of the Java Memory Model (JMM).
5.1. Piggybacking
Because of the strength of the happens-before memory ordering, sometimes we can piggyback on the visibility properties of another volatile variable. For instance, in our particular example, we just need to mark the ready variable as volatile:
public class TaskRunner { private static int number; // not volatile private volatile static boolean ready; // same as before }
Anything prior to writing true to the ready variable is visible to anything after reading the ready variable. Therefore, the number variable piggybacks on the memory visibility enforced by the ready variable. Put simply, even though it's not a volatile variable, it is exhibiting a volatile behavior.
Като използваме тази семантика, можем да дефинираме само няколко от променливите в нашия клас като нестабилни и да оптимизираме гаранцията за видимост.
6. Заключение
В този урок разгледахме повече за променливата ключова дума и нейните възможности, както и подобренията, направени в нея, започвайки с Java 5.
Както винаги, примерите за кодове могат да бъдат намерени в GitHub.