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 synchronizedbut 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:@VolatileThis 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 @Volatileas soon as one thread changes flagthe 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 volatilethis 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 @Volatilememory 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 @Volatilethe 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 numbereven 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:

  1. Sign up for volatile variable immediately marks the cache lines as Invalid in other processors.

  2. 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 volatileanother thread executing the method runwill immediately see this change and complete execution.

Double checking Singleton initialization

Before Java 5 was supported @Volatilethe 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 @Volatilevariable 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 volatileother 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 volatileincrementation 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 @Volatilethis 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 ybecause 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:

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *