Hi! We continue our study of multithreading. Today we'll get to know the
volatile
keyword and the yield()
method. Let's dive in :)The volatile keyword
When creating multithreaded applications, we can run into two serious problems. First, when a multithreaded application is running, different threads can cache the values of variables (we already talked about this in the lesson entitled 'Using volatile'). You can have the situation where one thread changes the value of a variable, but a second thread doesn't see the change, because it's working with its cached copy of the variable. Naturally, the consequences can be serious. Suppose that it's not just any old variable but rather your bank account balance, which suddenly starts randomly jumping up and down :) That doesn't sound like fun, right? Second, in Java, operations to read and write all primitive types, exceptlong
and double
, are atomic.
Well, for example, if you change the value of an int
variable on one thread, and on another thread you read the value of the variable, you'll either get its old value or the new one, i.e. the value that resulted from the change in thread 1. There are no 'intermediate values'.
However, this doesn't work with long
s and double
s. Why?
Because of cross-platform support.
Remember on the beginning levels that we said that Java's guiding principle is 'write once, run anywhere'? That means cross-platform support. In other words, a Java application runs on all sorts of different platforms. For example, on Windows operating systems, different versions of Linux or MacOS. It will run without a hitch on all of them.
Weighing in a 64 bits, long
and double
are the 'heaviest' primitives in Java. And certain 32-bit platforms simply don't implement atomic reading and writing of 64-bit variables. Such variables are read and written in two operations. First, the first 32 bits are written to the variable, and then another 32 bits are written.
As a result, a problem may arise. One thread writes some 64-bit value to an X
variable and does so in two operations. At the same time, a second thread tries to read the value of the variable and does so in between those two operations — when the first 32 bits have been written, but the second 32 bits have not. As a result, it reads an intermediate, incorrect value, and we have a bug.
For example, if on such a platform we try to write the number to a
9223372036854775809
to a variable, it will occupy 64 bits. In binary form, it looks like this:
1000000000000000000000000000000000000000000000000000000000000001
The first thread starts writing the number to the variable. At first, it writes the first 32 bits
(1000000000000000000000000000000)
and then the second 32 bits
(0000000000000000000000000000001)
And the second thread can get wedged between these operations, reading the variable's intermediate value (10000000000000000000000000000000), which are the first 32 bits that have already been written.
In the decimal system, this number is 2,147,483,648.
In other words, we just wanted to write the number 9223372036854775809 to a variable, but due to the fact that this operation is not atomic on some platforms, we have the evil number 2,147,483,648, which came out of nowhere and will have an unknown effect the program. The second thread simply read the value of the variable before it had finished being written, i.e. the thread saw the first 32 bits, but not the second 32 bits.
Of course, these problems didn't arise yesterday. Java solves them with a single keyword: volatile
.
If we use the volatile
keyword when declaring some variable in our program…
public class Main {
public volatile long x = 2222222222222222222L;
public static void main(String[] args) {
}
}
…it means that:
- It will always be read and written atomically. Even if it's a 64-bit
double
orlong
. - The Java machine won't cache it. So you won't have a situation where 10 threads are working with their own local copies.
The yield() method
We've already reviewed many of theThread
class's methods, but there's an important one that will be new to you. It's the yield()
method.
And it does exactly what its name implies!
When we call the yield
method on a thread, it actually talks to the other threads: 'Hey, guys. I'm not in any particularly hurry to go anywhere, so if it's important for any of you to get processor time, take it — I can wait'.
Here's a simple example of how this works:
public class ThreadExample extends Thread {
public ThreadExample() {
this.start();
}
public void run() {
System.out.println(Thread.currentThread().getName() + " yields its place to others");
Thread.yield();
System.out.println(Thread.currentThread().getName() + " has finished executing.");
}
public static void main(String[] args) {
new ThreadExample();
new ThreadExample();
new ThreadExample();
}
}
We sequentially create and start three threads: Thread-0
, Thread-1
, and Thread-2
.
Thread-0
starts first and immediately yields to the others. Then Thread-1
is started and also yields. Then Thread-2
is started, which also yields.
We don't have any more threads, and after Thread-2
yielded its place last, the thread scheduler says, 'Hmm, there aren't any more new threads. Who do we have in the queue? Who yielded its place before Thread-2
? It seems it was Thread-1
. Okay, that means we'll let it run'.
Thread-1
completes its work and then the thread scheduler continues its coordination: 'Okay, Thread-1
finished. Do we have anyone else in the queue?'. Thread-0 is in the queue: it yielded its place right before Thread-1
. It now gets its turn and runs to completion.
Then the scheduler finishes coordinating the threads: 'Okay, Thread-2
, you yielded to other threads, and they're all done now. You were the last to yield, so now it's your turn'. Then Thread-2
runs to completion.
The console output will look like this:
Thread-0 yields its place to others
Thread-1 yields its place to others
Thread-2 yields its place to others
Thread-1 has finished executing.
Thread-0 has finished executing.
Thread-2 has finished executing.
Of course, the thread scheduler might start the threads in a different order (for example, 2-1-0 instead of 0-1-2), but the principle remains the same.
Happens-before rules
The last thing we'll touch on today is the concept of 'happens before'. As you already know, in Java the thread scheduler performs the bulk of the work involved in allocating time and resources to threads to perform their tasks. You've also repeatedly seen how threads are executed in a random order that is usually impossible to predict. And in general, after the 'sequential' programming we did previously, multithreaded programming looks like something random. You've already come to believe that you can use a host of methods to control the flow of a multithreaded program. But multithreading in Java has one more pillar — the 4 'happens-before' rules. Understanding these rules is quite simple. Imagine that we have two threads —A
and B
. Each of these threads can perform operations 1
and 2
.
In each rule, when we say 'A happens-before B', we mean that all changes made by thread A
before operation 1
and the changes resulting from this operation are visible to thread B
when operation 2
is performed and thereafter.
Each rule guarantees that when you write a multithreaded program, certain events will occur before others 100% of the time, and that at the time of operation 2
thread B
will always be aware of the changes that thread A
made during operation 1
.
Let's review them.
Rule 1.
Releasing a mutex happens before the same monitor is acquired by another thread. I think you understand everything here. If an object's or class's mutex is acquired by one thread., for example, by threadA
, another thread (thread B
) can't acquire it at the same time. It must wait until the mutex is released.
Rule 2.
TheThread.start()
method happens before Thread.run()
.
Again, nothing difficult here. You already know that to start running the code inside the run()
method, you must call the start()
method on the thread. Specifically, the start method, not the run()
method itself!
This rule ensures that the values of all variables set before Thread.start()
is called will be visible inside the run()
method once is begins.
Rule 3.
The end of therun()
method happens before the return from the join()
method.
Let's return to our two threads: A
and B
.
We call the join()
method so that thread B
is guaranteed to wait for the completion of thread A
before it does its work.
This means that the A object's run()
method is guaranteed to run to the very end. And all changes to data that happen in the run()
method of thread A
are one-hundred percent guaranteed to be visible in thread B
once it is done waiting for thread A
to finish its work so it can begin its own work.
Rule 4.
Writing to avolatile
variable happens before reading from that same variable.
When we use the volatile
keyword, we actually always get the current value. Even with a long
or double
(we spoke earlier about problems that can happen here).
As you already understand, changes made on some threads are not always visible to other threads. But, of course, there are very frequent situations where such behavior doesn't suit us.
Suppose that we assign a value to a variable on thread A
:
int z;
….
z = 555;
If our B
thread should display the value of the z
variable on console, it could easily display 0, because it doesn't know about the assigned value.
But Rule 4 guarantees that if we declare the z
variable as volatile
, then changes to its value on one thread will always be visible on another thread.
If we add to the word volatile
to the previous code...
volatile int z;
….
z = 555;
...then we prevent the situation where thread B
might display 0. Writing to volatile
variables happens before reading from them.
GO TO FULL VERSION