Design techniques
There are basically four techniques to make sure we won't have problems accessing variables in shared memory. These are, in order of preference:
- Containment. Do not share objects between threads.
- Immutability. Make shared data immutable. All fields in the class must be final.
- Use thread-safe data types. We encapsulate shared data in an existing data type with security that performs coordination.
- For example, the package java.util.concurrent also contains some concurrent map, queue, set, list, and atomic variable classes. These classes can be used and shared without fear of causing race conditions.
- Synchronization. Use synchronization to prevent threads from accessing at the same time.
- monitor objects are objects that can only be accessed by one thread at a time. These allow you to define critical sections of code. It is the most used method.
- You can also use the reentrant locks (lock / unlock).
Next we will see an example of race condition, the most representative problem of shared state between threads. Two threads try to increment a counter on a shared object. The end result is not what we expect:
public class RaceThread {
static class SharedObject {
int counter;
}
static class MyRunnable implements Runnable {
SharedObject so;
MyRunnable(SharedObject so) {
this.so = so;
}
void increment() {
so.counter ++;
}
@Override
public void run() {
for (int i=0; i<1_000_000; i++) {
increment();
}
}
}
public static void main(String[] args) throws InterruptedException {
SharedObject so = new SharedObject();
Thread t1 = new Thread(new MyRunnable(so));
Thread t2 = new Thread(new MyRunnable(so));
t1.start();
t2.start();
t1.join();
t2.join();
log("counter is " + so.counter);
}
}
In this example, the increment()
method is not thread-safe. The result is that the value of counter
is not what we expected. This is because the increment()
method is not atomic. This means that it is not a single operation, but is broken down into several smaller operations. In this case, the compiler breaks the increment()
method into three operations:
- Read the value of
counter
from memory. - Increase the read value.
- Write the incremented value to memory.
To synchronize, we need to define the concept of monitor (lock): a monitor is an object that can only be accessed by a single thread at the same time. In our problem, the monitor would be the so
object. Each thread must acquire the monitor before accessing the counter
variable. This is done with the synchronized keyword.
A first solution would be to disallow direct access to the counter
field of the so
object. In this case, the counter
field would be private
and could only be accessed through methods. These methods would be synchronized:
static class SharedObject {
private int counter;
synchronized void increment() {
counter++;
}
synchronized int getCounter() {
return counter;
}
}
A second solution would be to make it impossible for two threads to access the counter
variable at the same time. This is done in Java using a monitor (lock). The increment()
method would look like this:
void increment() {
synchronized (so) {
so.counter++;
}
}
When we create critical sections of code with Java's mechanisms we can order the events that occur. Ordering rules are explained with the concept of "happens-before". In short, if an event A occurs before an event B, then B cannot occur before A. This allows us to ensure that the events occur in the order we want.
There is a major difficulty in designing thread-safe code, in other words, that is safe from multiple thread access. Tests can be prepared for our code to see if a large number of threads running our code concurrently causes problems. But it is not always easy to simulate this situation.
If we look at the Java Standard Edition documentation, we see that the condition is sometimes referred to as " thread-safe" of the classes.
For example, in the class java.util.regex.Pattern is called:
- Instances of this class are immutable and are safe for use by multiple concurrent threads. Instances of the
Matcher
class are not safe for such use.
It is important when designing our code to be aware of whether we need more than one thread to access. And if so, design the class accordingly and document it.