An easy way to synchronize threads… until you need atomicity
@Volatile
– This is such a budget method of synchronization. It doesn't block threads like the good old one synchronized
but it does an important job: it ensures that all changes to a variable are immediately visible to all threads. Without it, threads can happily live with outdated data and not even realize that everything around them has changed a long time ago.
But I’ll say right away:@Volatile
— This is not a universal tabletand from all multithreading problems. It is good for simple tasks where only the visibility of changes is needed. But as soon as your requirements begin to include atomic operations or complex logic – here @Volatile
is losing ground. And that's okay. Each tool has its limitations, and it is important to understand when to use it and when to run for something more serious.
We’ll talk about the limitations of this tool and more in this article. Let's start with its working mechanism.
How @Volatile works
Let's start with what happens when you add @Volatile
before the variable. Essentially, this is a directive for the processor and compiler that guarantees two key things:
Visibility of changes: When one thread changes the value of a variable, that change is immediately available to other threads. This is achieved due to the fact that the entry in
volatile
the variable is immediately flushed to main memory and not to the processor cache.Prohibition of reordering instructions: The compiler and processor cannot reorder operations with
volatile
variables.
Sample code:
public class VolatileExample {
private volatile boolean flag = false;
public void writer() {
flag = true;
}
public void reader() {
if (flag) {
// Делаем что-то, только если флаг установлен
System.out.println("Flag is true");
}
}
}
Here's the method writer
changes the value of a variable flag
. Thanks to @Volatile
as soon as one thread changes flag
the other thread will immediately see this change the next time it reads.
@Volatile interaction with processor memory
Each modern processor has several levels of caches (L1, L2, L3), which allow you to speed up data processing without requesting access to main memory each time. This is great for single-player applications, but in multi-threaded applications it's a little more complicated.
When one thread writes data to a variable, it first goes into the cache of the processor processing that thread. This is where the dilemma arises: other threads running on other cores or processors may not see these changes immediately, since they will be working with outdated data from their cache. This can lead to incorrect behavior of the program – and it is in such situations that it is good @Volatile
.
When a variable is marked as volatile
this signals to the compiler and processor that the variable should not be cached and should always be read directly from main memory. This prevents problems with variable visibility between threads. @Volatile
causes each thread to flush the variable's data to main memory immediately after writing it, and other threads retrieve it from main memory rather than from the processor cache.
Memory barriers
Now about another important point of work @Volatile
— memory barriers. These are barriers that prevent the compiler or processor from reordering instructions. Without these barriers, code may not execute in the order you wrote it, especially when the processor is trying to optimize performance.
When you use @Volatile
the compiler and processor are required to insert memory barriers before and after writing/reading from volatile
variable. This ensures that:
All write operations performed before writing to
volatile
variable are completed before the write occurs.All read operations performed after reading from
volatile
variable will begin after this reading.
Let's look at an example:
public class VolatileMemoryBarrierExample {
private volatile boolean ready = false;
private int number = 0;
public void writer() {
number = 42; // Операция записи (без @Volatile)
ready = true; // Запись в @Volatile переменную, после чего установится memory barrier
}
public void reader() {
if (ready) {
System.out.println("Number: " + number); // Благодаря memory barriers, number всегда будет 42
}
}
}
Here @Volatile
guarantees that the entry in ready
will happen after entries in number
even if the processor decides to optimize and execute instructions in a different order.
MESI protocol
Now let's deal with one more thing – MESI protocol. This protocol describes how processor caches communicate with each other to maintain data consistency.
MESI stands for:
Modified: Data in the cache has been modified and has not yet been flushed to main memory.
Exclusive: The data is only in the cache of a given processor and is the same as the data in main memory.
Shared: Data may reside in caches of other processors.
Invalid: The data is invalid and must be updated from main memory.
When a variable is marked as volatile
,The processor uses this protocol to coordinate the caches. Here's what happens when recording:
Sign up for
volatile
variable immediately marks the cache lines as Invalid in other processors.Other cores trying to access this variable will be forced to go to main memory for the latest data, rather than using stale data from the cache.
Example of work @Volatile
at the processor level:
public class VolatileMESIExample {
private volatile int sharedData = 0;
public void updateData() {
sharedData = 10; // Это помечает кеш-линии других процессоров как Invalid
}
public int readData() {
return sharedData; // Это заставляет процессор загружать данные из основной памяти
}
}
Application examples
Stop thread flag
One of the most common use cases @Volatile
is a flag to stop the thread. Let's say there is a thread that is doing some work in an infinite loop, and you need to stop it from another thread:
public class StopFlagExample {
private volatile boolean stopRequested = false;
public void run() {
while (!stopRequested) {
// Выполняем какую-то работу
}
}
public void stop() {
stopRequested = true;
}
}
Method stop
sets the flag stopRequested
V true
. Due to the fact that the variable is marked as volatile
another thread executing the method run
will immediately see this change and complete execution.
Double checking Singleton initialization
Before Java 5 was supported @Volatile
the double-check implementation of the Singleton pattern was unreliable due to problems with instruction reordering.
public class Singleton {
private static volatile Singleton instance;
private Singleton() {
// Приватный конструктор
}
public static Singleton getInstance() {
if (instance == null) { // Первая проверка
synchronized (Singleton.class) {
if (instance == null) { // Вторая проверка
instance = new Singleton();
}
}
}
return instance;
}
}
Here @Volatile
ensures that changes to a variable instance
will be immediately visible to all threads.
Communication between threads
It often happens that one thread generates data, while another processes it. In that case @Volatile
can be used to pass a signal between threads.
public class DataSharingExample {
private volatile boolean dataReady = false;
private int data = 0;
public void producer() {
data = 42; // Генерация данных
dataReady = true; // Сигнал о готовности данных
}
public void consumer() {
while (!dataReady) {
// Ожидание готовности данных
}
System.out.println("Data: " + data); // Обработка данных
}
}
Here the variable dataReady
signals to another thread that the data is ready. Thanks to @Volatile
variable change dataReady
immediately becomes visible to another thread.
Access counter – when @Volatile doesn't work
This example shows the limitation @Volatile
. If you just need to store the value of a variable, accessible to multiple threads, @Volatile
will be useful. But if you need to provide an atomic operation, @Volatile
won't cope.
public class VolatileCounterExample {
private volatile int counter = 0;
public void increment() {
counter++; // Не атомарная операция
}
public int getCounter() {
return counter;
}
}
Instead use AtomicInteger
:
import java.util.concurrent.atomic.AtomicInteger;
public class SafeCounter {
private AtomicInteger counter = new AtomicInteger(0);
public void increment() {
counter.incrementAndGet(); // Атомарная операция инкремента
}
public int getCounter() {
return counter.get();
}
}
And now more about the restrictions.
@Volatile restrictions
Now let's talk about where @Volatile
is starting to be irrelevant and cannot solve all multithreading problems.
The Atomicity Problem
As we already know, @Volatile
performs one important task: it guarantees visibility of variable changes between threads. When one thread writes a value to a variable marked as volatile
other threads immediately see this change. However, this does not make the operation atomic and does not provide the ability to synchronize access to complex data operations.
Atomicity – this is the property of operations in which they are performed completely or not performed at all. For atomic operations, it is guaranteed that no other thread can interfere with the execution process. However @Volatile
does not guarantee atomicity of operationssuch as incrementation, which makes it unsuitable for scenarios where multiple threads simultaneously read and modify a single variable. For example, an increment operation consists of several steps: read, increment, and write. There is no synchronization between these steps, leading to data races where both threads can write the same result, losing one increment.
Additionally, the Java specification specifies that reading and writing 64-bit variables (such as long
And double
) are not atomic by default. The processor can split write and read operations of such variables into two 32-bit parts. If one processor core attempts to perform an operation on such a variable at the same time as another, this may result in a partial write or reading of an incorrect value. In a multi-threaded environment, this becomes a source of errors and bugs. Marking a variable as volatile
eliminates the data partitioning problem by making read and write operations consistent, but it does not solve the atomicity problem for more complex operations.
For example, even if long
-variable is marked as volatile
incrementation will still remain unsafe because it involves several steps. For such cases it is necessary to use stronger synchronization mechanisms such as synchronized
or atomic classes AtomicLong
.
No data race protection
Data races occur when multiple threads work on the same variable at the same time. @Volatile
guarantees visibility of changes, but does not protect against incorrect data modification.
public class VolatileRaceCondition {
private volatile int value = 0;
public void writer() {
value = value + 1; // Не атомарная операция
}
public int reader() {
return value;
}
}
Even though using @Volatile
this program is subject to data race.
Lack of support for multiple related operations
If you need to perform several operations that depend on each other, @Volatile
won't help. For example, if you need to update several variables at the same time or perform several dependent actions, @Volatile will not give integrity to these operations:
public class VolatileMultiVariable {
private volatile int x = 0;
private volatile int y = 0;
public void updateBoth(int newX, int newY) {
x = newX;
y = newY;
}
public void check() {
if (x > 0 && y == 0) {
// Нарушение инварианта!
}
}
}
Value consistency is not guaranteed here x
And y
because write operations are performed separately, and threads can see a state where one of the values has already been updated and the other has not yet.
Conclusion
So, @Volatile
is like a mini pocket multithreading tool: ideal for simple tasks where you just need to make changes visible between threads. It's light, fast, and doesn't slow down the flow… but if you try to hammer nails with it, you'll most likely break the tool. So, as in any other situation, use it as intended. And if you need something more serious, then it's time to get it out of the toolbox synchronized
, Locks
or atomic classes.
Let me take this opportunity to remind you about the open lessons that will be held as part of the Otus “Java Developer. Professional” course: