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
.