62 the Role of Volatile and How It Differs From Synchronized

62 The Role of volatile and How It Differs from synchronized #

In this lesson, we will mainly introduce the usage and scenarios of volatile, as well as the similarities and differences between volatile and synchronized.

What is volatile? #

First of all, let’s introduce volatile. It is a keyword in Java and a synchronization mechanism. When a variable is a shared variable and is declared as volatile, it guarantees that when its value is modified and then read, the obtained value is the latest value, not an outdated value.

Compared to synchronized or Lock, volatile is more lightweight because using volatile does not incur expensive operations like context switching or thread blocking. However, precisely because of its relatively small cost, its effect (i.e., its ability) is also relatively limited.

Although volatile is used to ensure thread safety, it does not provide synchronization protection like synchronized does. volatile is effective only in a limited number of scenarios. Let’s take a look at its applicable scenarios. First, we will provide scenarios where volatile should not be used, and then we will provide two scenarios where volatile is suitable.

Applicable Scenarios for volatile #

Not applicable: a++ #

First, let’s consider scenarios where using volatile is not suitable. volatile is not suitable for scenarios that require atomicity, such as situations where the update relies on the original value. The most typical scenario is the a++ scenario. Using volatile alone cannot guarantee the thread safety of a++. Here is an example:

public class DontVolatile implements Runnable {

    volatile int a;

    AtomicInteger realA = new AtomicInteger();

    public static void main(String[] args) throws InterruptedException {

        Runnable r = new DontVolatile();

        Thread thread1 = new Thread(r);

        Thread thread2 = new Thread(r);

        thread1.start();

        thread2.start();

        thread1.join();

        thread2.join();

        System.out.println(((DontVolatile) r).a);

        System.out.println(((DontVolatile) r).realA.get());

    }

    @Override

    public void run() {

        for (int i = 0; i < 1000; i++) {

            a++;

            realA.incrementAndGet();

        }

    }

}

In this code, we have a volatile-modified variable a of type int, and below it, we have an atomic class realA. Atomic classes ensure thread safety, so we use it as a comparison to see the differences in their actual effects.

In the main function, we create two threads and let them run. These two threads perform 1000 iterations of an addition operation. Each iteration increments the volatile variable a and the atomic variable realA. After both threads have finished running, we print the results. One possible result is:

1988

2000

You can see that the final values of a and realA are 1988 and 2000, respectively. Even though the a variable is modified 2000 times in total (as evidenced by the final value of the atomic variable), some increments were not effective. So the final result is less than 2000, proving that volatile cannot ensure atomicity. So, in what scenarios is volatile suitable?

Applicable Scenario 1: Boolean flags #

If a shared variable is only assigned or read by different threads throughout its lifetime and has no other operations (e.g., read and modify on the basis of its value), then we can use volatile instead of synchronized or atomic classes. This is because the assignment operation itself is atomic, and volatile ensures visibility, which is sufficient to guarantee thread safety.

A typical scenario is that of a boolean flag, such as volatile boolean flag. Usually, boolean flags are directly assigned, and in this case, there are no compound operations (like a++), only single operations to change the value of flag. Once flag is declared as volatile, it guarantees visibility. Thus, flag can be used as a flag. When its value changes, all threads can immediately see it. Therefore, volatile is suitable for this scenario.

Let’s take a look at the code example:

public class YesVolatile1 implements Runnable {

    volatile boolean done = false;

    AtomicInteger realA = new AtomicInteger();

    public static void main(String[] args) throws InterruptedException {

        Runnable r = new YesVolatile1();

        Thread thread1 = new Thread(r);

        Thread thread2 = new Thread(r);

        thread1.start();

        thread2.start();

        thread1.join();

        thread2.join();

        System.out.println(((YesVolatile1) r).done);
    System.out.println(((YesVolatile1) r).realA.get());
}

@Override
public void run() {
    for (int i = 0; i < 1000; i++) {
        setDone();
        realA.incrementAndGet();
    }
}

private void setDone() {
    done = true;
}

}

This code is very similar to the previous code, except that we changed volatile int a to volatile boolean done, and called the setDone() method during the 1000 loop iterations. The setDone() method simply sets the variable done to true, without any additional checks or complicated logic. Therefore, this code will always print “true” and “2000” in the console, confirming that it indeed performs 2000 operations, and the final “true” result demonstrates that volatile ensures thread safety in this scenario.

The main difference between the second example and the first example is that the first example performs the operation a++, which is not atomic, while in this example, the operation is simply setting done to true, which is atomic in itself. Therefore, volatile is suitable for this example.

Use Case 2: As a Trigger #

Now let’s look at the second scenario where volatile is suitable: as a trigger to ensure the visibility of other variables.

Here is a classic example provided by Brian Goetz:

Map configOptions;
char[] configText;
volatile boolean initialized = false;
. . .

Using volatile to ensure visibility and ordering of variables in multi-threaded programs #

// In thread A

configOptions = new HashMap();

configText = readConfigFile(fileName);

processConfigOptions(configText, configOptions);

initialized = true;

. . .

// In thread B

while (!initialized)

  sleep();

// use configOptions

In this code snippet, we have a map called configOptions, a char array called configText, and a volatile boolean variable called initialized which is initially set to false. The four lines of code executed by thread A are responsible for initializing configOptions and configText, and then passing these values to a method, effectively representing initialization behavior. Once these methods have been executed, the initialization work is considered complete, and thread A sets the initialized variable to true.

Thread B, on the other hand, starts by repeatedly executing the sleep method in a while loop until the initialized variable becomes true. Once initialized becomes true, thread B skips the sleep method and continues execution. Importantly, once initialized is true, thread B immediately uses the configOptions. This requires that the configOptions is fully initialized and the result of the initialization is visible to thread B, otherwise thread B may encounter errors during execution.

You may be concerned that since configOptions is modified in thread A, there could be visibility issues when it is read in thread B. Without volatile, this would indeed be a problem.

However, now we use the volatile modifier on the initialized variable as a trigger, so this problem is solved. According to the happens-before relation’s single-thread rule, the initialization of configOptions in thread A happens-before the write operation on the initialized variable, and the read of initialized in thread B happens-before the use of the configOptions variable. Additionally, according to the volatile rule of the happens-before relation, the write operation of initialized to true in thread A happens-before the subsequent read of the initialized variable in thread B.

If we have operations A and B, we use hb(A, B) to indicate that A happens-before B. Since happens-before has the transitivity property, if hb(A, B) and hb(B, C), then we can conclude that hb(A, C). Based on the above conditions, we can conclude that the initialization of configOptions in thread A happens-before the use of configOptions in thread B. Therefore, for thread B, since it has seen the latest value of initialized, it can also see the initialized state of variables, including configOptions. So, using configOptions in thread B is thread-safe. This usage of using a volatile variable as a trigger to ensure the visibility of other variables is a valuable technique worth mastering and can be highlighted during interviews.

The Role of volatile #

Above, we have analyzed two typical use cases. Now let’s summarize the role of volatile, which has two main effects.

The first effect is to ensure visibility. The happens-before relation describes the behavior of volatile in the following way: a write operation on a volatile variable happens-before a subsequent read operation on that variable.

This means that if a variable is declared volatile, each time it is modified, the subsequent reading of that variable will always retrieve the latest value.

The second effect is to prevent reordering. Let’s introduce the concept of “as-if-serial” semantics: regardless of how reordering is performed, the execution result of the (single-threaded) program will not change. Subject to as-if-serial semantics, due to compiler or CPU optimizations, the actual execution order of the code may be different from the order in which we wrote it. This is not a problem in a single-threaded context, but once we introduce multi-threading, such reordering can lead to serious thread-safety issues. Using the volatile keyword can partially prevent this type of reordering.

Relationship between volatile and synchronized #

Now let’s take a look at the relationship between volatile and synchronized:

Similarities: volatile can be seen as a lightweight version of synchronized. For example, if a shared variable is only assigned and read by multiple threads, without any other operations, then it can be replaced with volatile to ensure thread safety. In fact, each read or write operation on a volatile field is similar to a “semi-synchronous” operation. Reading a volatile variable has the same memory semantics as acquiring a synchronized lock, and writing to a volatile variable has the same semantics as releasing a synchronized lock.

Cannot be replaced: However, in most cases, volatile cannot replace synchronized. volatile does not provide atomicity and mutual exclusion.

Performance: Read and write operations on volatile variables are lock-free. Because of this absence of locking, time does not need to be spent on acquiring and releasing locks, making volatile high-performance and outperforming synchronized.

Summary #

In conclusion, this lesson primarily introduced what volatile is, its unsuitable scenarios, and two typical use cases. We then discussed the two effects of volatile: ensuring visibility and preventing reordering. Finally, we analyzed the relationship between volatile and synchronized.