Multithreading
Table of Contents
Introduction
Modern day processors have more than just one single CPU core. In fact, many consumer grade processors have anywhere between 2 to 24 cores on a single chip! Comparing these chips to hardware that was available at the start of Java's lifecycle would be like comparing a flintlock pistol to a Stinger missile. As a result, programmers, alongside Java's ecosystem itself, have had to adapt to these changes in hardware by developing new techniques that can properly leverage the additional computational firepower. The primary innovation comes in the form of multithreading which aims to take advantage of these multiple cores by running tasks simultaneously.
Execution Models
An execution model describes the behavior of how a program runs. If we focus less on the semantics/syntax of the language, but rather on the order in which instructions are processed we can define three distinct, but somwhat dependent, categories: sequential, concurrent, and parallel execution models.
Sequential
The sequential execution model is what we have been focusing on up until this point. The idea is simple, if we have three tasks, α, β, and γ, then we process α first, then β, then γ. Each task is executed until they have been completed in the order in which they arrived. We can visualize that as follows:
+-------+
| cpu1 |
+-------+
| α |
Time +-------+
| | α |
| +-------+
| | β |
| +-------+
| | β |
| +-------+
| | γ |
| +-------+
v | γ |
+-------+
Concurrent
The concurrent execution model spices things up a little bit. Now instead of tasks being completed from beginning to end tasks can be interleaved with one another:
+-------+
| cpu1 |
+-------+
| α |
Time +-------+
| | β |
| +-------+
| | γ |
| +-------+
| | α |
| +-------+
| | β |
| +-------+
v | γ |
+-------+
This is, more or less, what is happening behind the scenes in your operating system! A little bit of your web browser task is being executed, then a background system power governer task, then your text editor task, and so on. Each portion of each task is being processed so fast that it looks like they are all happening at exactly the same time.
Parallel
Our final execution model, and the one most relevant to this chapter, is the parallel execution model. Parallel execution means that multiple tasks can be processed at exactly the same time. This execution model requires hardware that can handle such behavior:
+-------+ +-------+ +-------+
| cpu1 | | cpu2 | | cpu3 |
Time +-------+ +-------+ +-------+
| | α | | β | | γ |
| +-------+ +-------+ +-------+
v | α | | β | | γ |
+-------+ +-------+ +-------+
Tasks do not need to be separate programs. They can be parts of the same program split along computational units.
Parallel Execution Example
I highly suggest running these examples on your own machine. It is not enough to see static renders of multithreading, you need to see it running to get a feel for it.
Below is an example of parallel execution using Java's Thread API:
1: import java.lang.Thread; 2: public class HelloWorlds extends Thread{ 3: int id; 4: 5: public void run(){ 6: System.out.println(String.format("Thread %d: Hello Worlds!", this.id)); 7: } 8: 9: public HelloWorlds(int id){ 10: this.id = id; 11: } 12: 13: public static void main(String[] args) throws Exception{ 14: int worlds = Integer.parseInt(args[0]); 15: Thread[] ts = new Thread[worlds]; 16: for(int i = 0; i < worlds; i++){ 17: ts[i] = new HelloWorlds(i); 18: ts[i].start(); 19: } 20: for(Thread t : ts){ 21: try{ 22: t.join(); 23: }catch(Exception e){ 24: System.out.println(e); 25: System.exit(-1); 26: } 27: } 28: } 29: }
There's a lot going on here which probably makes this look insane at first glance. Let's break it down into chunks.
First, I define a class HelloWorlds. HelloWorlds is a subclass of the Thread class. Thread is how Java abstracts a thread of execution in a program. In sequential execution (Listing 1) there is only ever a single thread. In parallel execution there is at least two threads. Because Thread implements the Runnable interface it provides an implementation for the run() method—all user-defined worker threads override run() to replace it with useful work associated with its class. In HelloWorlds, the "useful work" is printing out a "Hello World" message.
Second, in the main method I instantiate an array of worlds number of Thread objects. Each object is instantiated with a unique id and stored in the array. Each Thread is then started up using the start() method. The start() method informs the JVM that it is time to create a new thread of execution. This new execution thread is then queued up and will begin at the run() method.
Third, I call join() on all of the Thread objects. join() halts the calling thread's exeuction until the target thread has finished executing. The default, main thread of execution is just like any other potentially schedulable thread, so we want to make sure it doesn't stop executing before any of the child threads.
Try seeing what happens when you comment out the call to join()!
We can see what happens below when I set worlds equal to 2, 5, and 10, respectively:
josephraskind@stargazer:/tmp/multithreading$ java HelloWorlds.java 2 Thread 0: Hello Worlds! Thread 1: Hello Worlds! josephraskind@stargazer:/tmp/multithreading$ java HelloWorlds.java 5 Thread 3: Hello Worlds! Thread 0: Hello Worlds! Thread 1: Hello Worlds! Thread 2: Hello Worlds! Thread 4: Hello Worlds! josephraskind@stargazer:/tmp/multithreading$ java HelloWorlds.java 10 Thread 0: Hello Worlds! Thread 3: Hello Worlds! Thread 5: Hello Worlds! Thread 7: Hello Worlds! Thread 8: Hello Worlds! Thread 9: Hello Worlds! Thread 1: Hello Worlds! Thread 2: Hello Worlds! Thread 4: Hello Worlds! Thread 6: Hello Worlds!
One thing you'll notice is that the higher thread counts are all out of order. This is proof that our tasks ran in a parallel/concurrent fashion. Multithreaded scheduling is deeply non-deterministic—from the perspective of the programmer there's no telling which thread will process before another!
Sychronization
Multithreading is an incredibly powerful tool that can greatly speed up the execution of complex programs, but it often comes at a significant cost to developers. The cost is that it is very difficult to write consistent, thread-safe multithreaded processes. The principle reason for this is due to the phenomena of race conditions.
Race Conditions
Below is a multithreaded Java program which increments a counter upperBound times in numThreads number of threads:
1: import java.lang.Thread; 2: public class UnsafeCounter extends Thread{ 3: static int counter; 4: int countTo; 5: 6: public UnsafeCounter(int i){ 7: countTo = i; 8: } 9: 10: public void run(){ 11: for(int i = 0; i < this.countTo; i++){ 12: counter++; 13: } 14: } 15: 16: public static void main(String[] args){ 17: int upperBound = Integer.parseInt(args[0]); 18: int numThreads = Integer.parseInt(args[1]); 19: Thread[] threads = new Thread[numThreads]; 20: for(int i = 0; i < numThreads; i++) 21: threads[i] = new UnsafeCounter(upperBound); 22: 23: for(Thread t : threads) 24: t.start(); 25: try{ 26: for(Thread t : threads) 27: t.join(); 28: System.out.printf("Finished!\nCounter = %d\n", counter); 29: }catch(Exception e){ 30: System.err.println(e); 31: } 32: 33: } 34: }
Below I run UnsafeCounter with an upperBound = 10, 1,000, and 1,000,000 and numThreads = 2:
josephraskind@stargazer:/tmp/multithreading$ java UnsafeCounter.java 10 2 Finished! Counter = 20 josephraskind@stargazer:/tmp/multithreading$ java UnsafeCounter.java 1000 2 Finished! Counter = 2000 josephraskind@stargazer:/tmp/multithreading$ java UnsafeCounter.java 1000000 2 Finished! Counter = 1969985
You'll notice that the call with upperBound equal to 10 and 1,000 worked as expected, but the call with 1,000,000 did not. Why? Something called a race condition occurred. The two worker threads are both updating a shared resource in the form of a class field, counter. The update occurs on Line 12 where counter++ is processed. counter++ is actually four separate instructions, which we can see in JVM bytecode:
1: public void run(); 2: Code: 3: 0: iconst_0 4: 1: istore_1 5: 2: iload_1 6: 3: aload_0 7: 4: getfield #7 // Field countTo:I 8: 7: if_icmpge 24 9: 10: getstatic #13 // Field counter:I 10: 13: iconst_1 11: 14: iadd 12: 15: putstatic #13 // Field counter:I 13: 18: iinc 1, 1 14: 21: goto 2 15: 24: return
Lines 9-12 describe the process. This in and of itself is not a problem in a single thread of execution, but it becomes a massive problem with multiple threads of execution. For example, imagine a scenario where threads α and β have been scheduled to run at the same time on two separate CPUs. α processes the getstatic instruction and the exact same time as β—that means both threads will retrieve the same value from counter, say 100. α then loads a 1 on the operator stack, adds 100 to 1, and stores it in counter. At the same time, β does exactly the same thing. That means counter will store 101 despite the fact two separate additions have been executed! We can represent this visually as follows:
cpu1 (α) cpu2 (β)
+------------------+ +------------------+
| getstatic | | getstatic |
| counter → 100 | | counter → 100 |
+------------------+ +------------------+
| |
v v
+------------------+ +------------------+
| iconst_1 | | iconst_1 |
| stack: [100, 1] | | stack: [100, 1] |
+------------------+ +------------------+
| |
v v
+------------------+ +------------------+
| iadd | | iadd |
| stack: [101] | | stack: [101] |
+------------------+ +------------------+
| |
v v
+------------------+ +------------------+
| putstatic | | putstatic |
| counter ← 101 | | counter ← 101 |
+------------------+ +------------------+
| |
+------------------+-------------------+
|
v
+------------------+
| counter = 101 | ← should be 102!
+------------------+
What we see here is a pedagogical example. Real race conditions are often not this obvious and can be rather sinister.
How do we fix this? One way would be through the use of synchronization techniques.
Snychronization Techniques
Dealing with concurrency issues in computer science is not anything new. Many classic techniques that are still in use today were thought up in the 1960s and 70s. The most obvious solution to the race condition presented in Listing 9 is to enforce the rule of mutual exclusion. Mutual exclusion refers to using synchronization primitives to guarantee that only one thread executes within a region of that is operating on shared memory, often called the critical section. Using Listing 7 as an example, the critical section would be on Line 12 as that is where a resource shared between threads is being updated.
Java provides many ways of enforcing mutual exclusion. Below we will look at two: using the synchronized keyword and using standard library locks.
Using synchronized
As we have already established earlier, every object in Java is a subclass of Object. Every Object contains within it a monitor. This "monitor" acts as a built in synchronization primitive for every single object. Without digging into the details to much, we will briefly say that the monitor acts as a sort of talking stick: whoever holds it is allowed to speak. After unmixing our metaphores, we can say instead that whichever thread holds an object's monitor in a synchronized block is allowed to execute that block, whereas every other thread must wait. Let us alter UnsafeCounter to now rely on the synchronized keyword:
1: import java.lang.Thread; 2: public class SafeCounter extends Thread{ 3: static int counter; 4: static Object lock; 5: static{ 6: lock = new Object(); 7: } 8: int countTo; 9: 10: public SafeCounter(int i){ 11: countTo = i; 12: } 13: 14: public void run(){ 15: for(int i = 0; i < this.countTo; i++){ 16: synchronized(lock){ 17: counter++; 18: } 19: } 20: } 21: 22: public static void main(String[] args){ 23: int upperBound = Integer.parseInt(args[0]); 24: int numThreads = Integer.parseInt(args[1]); 25: Thread[] threads = new Thread[numThreads]; 26: for(int i = 0; i < numThreads; i++) 27: threads[i] = new SafeCounter(upperBound); 28: 29: for(Thread t : threads) 30: t.start(); 31: try{ 32: for(Thread t : threads) 33: t.join(); 34: System.out.printf("Finished!\nCounter = %d\n", counter); 35: }catch(Exception e){ 36: System.err.println(e); 37: } 38: 39: } 40: }
Let's run the same tests we did on UnsafeCounter:
josephraskind@stargazer:/tmp/multithreading$ java SafeCounter.java 10 2 Finished! Counter = 20 josephraskind@stargazer:/tmp/multithreading$ java SafeCounter.java 1000 2 Finished! Counter = 2000 josephraskind@stargazer:/tmp/multithreading$ java SafeCounter.java 1000000 2 Finished! Counter = 2000000
Now we can see that setting the upperBound to 1,000,000 no longer produces non-determinant output. Why? This is because of two key factors. (1) We have a new static object that is shared by all instances of the SafeCounter class in the form of lock. (2) We define a synchronized block which wraps around the critical section and holds lock's monitor. The Java runtime guarantees mututal exclusion in a synchronized block, so we can be confident that a race condition will not occur no matter how high upperBound is set or how many threads we create:
josephraskind@stargazer:/tmp/multithreading$ java SafeCounter.java 10000000 17 Finished! Counter = 170000000
You may be confused by Lines 5-7. That is what is called a "static block". It is a portion of code which is executed whenever the class is loaded in by the JVM for the first time. I could have used a simple assignment statement on Line 4, but I chose to show this syntax instead.
- Using Local Objects as Locks
One thing to note is that the
lockfield in Listing 10 is static for a reason. For a locking mechanism to work it must be shared between all threads of execution that need to abide by mutual exclusion laws. Let's see what would happen if I tried to use a local lock instead:1: import java.lang.Thread; 2: public class SafeCounter extends Thread{ 3: static int counter; 4: int countTo; 5: 6: public SafeCounter(int i){ 7: countTo = i; 8: } 9: 10: public void run(){ 11: Object lock = new Object(); 12: for(int i = 0; i < this.countTo; i++){ 13: synchronized(lock){ 14: counter++; 15: } 16: } 17: } 18: 19: // main... 20: }
If I try to run my original tests:
josephraskind@stargazer:/tmp/multithreading$ java SafeCounter.java 10 2 Finished! Counter = 20 josephraskind@stargazer:/tmp/multithreading$ java SafeCounter.java 1000 2 Finished! Counter = 2000 josephraskind@stargazer:/tmp/multithreading$ java SafeCounter.java 1000000 2 Finished! Counter = 1332926
We can see that my final test breaks again! This is because each
SafeCounterobject now has its ownlock. That means there are two, distinctlockvariables and thus two distinct monitors. There is nothing to synchronize with as each instance is synchronizing with itself, solpisistically.
Standard Java Locks
There is nothing inherently wrong or unsafe about using Java's built-in Object monitor locks; however, they are often not the first choice of locking mechanisms that Java developers reach for in production-level projects. Instead many developers will flip through the handful of lock-centric objects that are a part of the concurrent.locks package in the standard library. Here, we will take a look at the ReentrantLock object:
1: import java.lang.Thread; 2: import java.util.concurrent.locks.ReentrantLock; 3: public class SafeCounter extends Thread{ 4: static int counter; 5: int countTo; 6: static ReentrantLock lock = new ReentrantLock(true); // Creating lock with fair policy 7: 8: 9: public SafeCounter(int i){ 10: countTo = i; 11: } 12: 13: public void run(){ 14: for(int i = 0; i < this.countTo; i++){ 15: lock.lock(); // Acquiring the lock before critical section 16: counter++; 17: lock.unlock(); // Releasing the lock after critical section 18: } 19: } 20: 21: // main... 22: }
Now, instead of using the synchronized keyword, I've created a locking object which has explicit methods for acquiring and relinquishing the lock—what was done automatically by entering and exiting the block I must now do manually.
The advantage of these specialty locks is that they will often provide added functionality. For example, the ReentrantLock has an optional "fairness" policy which will do its best to grant the lock to the longest running thread. This is simply not possible when using an object's monitor. It also can be used as a "try lock" by invoking the tryLock method. If the lock cannot be grabbed, the thread in question can try to do some other work in the meantime.
Why Multithreading?
Some tasks are inherently suited to multithreading, such as web browsers and database management systems, and others can be broken down into smaller tasks which could benefit from multithreaded execution. Let's take an example of summing up the values in a very large array. Below we have both a sequential and a parallel solution:
1: import java.util.Arrays; 2: public class ArraySummation extends Thread{ 3: static int SIZE = 500_000_000; 4: 5: public static int parallelSum(int[] arr) { 6: int numThreads = Runtime.getRuntime().availableProcessors(); 7: int chunkSize = arr.length / numThreads; 8: int[] partial = new int[numThreads]; 9: 10: Thread[] threads = new Thread[numThreads]; 11: 12: for(int t = 0; t < numThreads; t++){ 13: final int threadIndex = t; 14: final int start = t * chunkSize; 15: final int end = (t == numThreads - 1) ? arr.length : start + chunkSize; 16: 17: threads[t] = new Thread(() -> { 18: int localSum = 0; 19: for(int i = start; i < end; i++) 20: localSum += arr[i]; 21: partial[threadIndex] = localSum; 22: }); 23: threads[t].start(); 24: } 25: 26: try{ 27: for(Thread thread : threads) 28: thread.join(); 29: }catch(Exception e){ 30: System.out.println(e); 31: System.exit(-1); 32: } 33: int total = 0; 34: for(int p : partial) 35: total += p; 36: return total; 37: } 38: 39: public static int sequentialSum(int arr[]){ 40: int sum = 0; 41: for(int v : arr) 42: sum += v; 43: return sum; 44: } 45: 46: public static void main(String[] args){ 47: int arr[] = new int[SIZE]; 48: Arrays.fill(arr, 1); 49: 50: long start = System.currentTimeMillis(); 51: int sum = sequentialSum(arr); 52: double duration = (System.currentTimeMillis() - start) / 1000.0; 53: System.out.printf("Sequential Sum: %d (%.2f s)%n", sum, duration); 54: 55: start = System.currentTimeMillis(); 56: sum = parallelSum(arr); 57: duration = (System.currentTimeMillis() - start) / 1000.0; 58: System.out.printf("Parallel Sum: %d (%.2f s)%n", sum, duration); 59: } 60: }
If we run both in interpretation mode:
josephraskind@stargazer:/tmp/multithreading$ java -Xint ArraySummation.java Sequential Sum: 500000000 (6.45 s) Parallel Sum: 500000000 (2.70 s)
We can see a ~2.4X speedup with the parallel execution method.
Exercises
- Compile and run Listing 16 on your machine. Record both the sequential and parallel times. How many logical processors does your machine have? Run
Runtime.getRuntime().availableProcessors()to find out. Is the speedup you observe close to that number? Based on what the chapter says about memory bandwidth and Amdahl's Law, explain why the speedup is lower than the number of cores. - The chapter shows that
counter++compiles to four bytecode instructions. Before running Listing 7, predict what valuecounterwill hold after two threads each count to 1000000. Will it be exactly 2000000, less, or more? Run it several times and record the results. Are they consistent? Explain why the output varies between runs in terms of thread scheduling. - The chapter introduces
synchronizedas a fix for the race condition. Addsynchronizedto therunmethod in Listing 7 and rerun it withupperBoundset to 1000000. Does the result now equal 2000000 consistently? Then measure the time taken with and withoutsynchronized. What cost does synchronization impose and why? Predict the output of the following program before running it:
public class Predict { static int x = 0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for(int i = 0; i < 1000; i++) x++; }); Thread t2 = new Thread(() -> { for(int i = 0; i < 1000; i++) x++; }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(x); } }
Run it ten times and record each result. Is the output always 2000? Always less? Could it ever be more than 2000? Explain each answer.
- The chapter states that
joincauses the calling thread to wait until the joined thread finishes. Remove bothjoincalls from Listing 16 and rerun it. What happens to the result? Explain in terms of what the main thread does after callingstartwithoutjoin. - The chapter motivates multithreading through hardware: modern CPUs have multiple cores that can execute instructions simultaneously. In your own words explain the difference between concurrency and parallelism. Is the sequential version of
ArraySummationconcurrent? Is the parallel version? Can a program be concurrent without being parallel? - The chapter shows that each thread in
parallelSumwrites to its own slot in thepartialarray. Why is this safe from race conditions when a sharedcounterfield is not? What property of thepartialarray makes it thread-safe without needingsynchronized? - The chapter introduces
Runnableas the interface used to define thread tasks. Write a program with three threads, each printing its own name and a count from 1 to 5. Run it several times. Is the output always in the same order? What does the variability in output tell you about how the OS schedules threads? - Thread creation carries overhead. Design an experiment that measures this overhead by creating a thread that does nothing (
() -> {}) and measuring how long it takes to start and join it. Run the experiment 1000 times and compute the average. How does this overhead compare to the time saved by parallelizingArraySummation? Under what circumstances would the overhead make parallelism not worth it? - The chapter presents multithreading as a way to leverage multiple CPU cores for performance. Consider a program that reads a large file from disk and processes each line. Would multithreading help this program? What would the bottleneck be? Compare this to
ArraySummationand explain why some workloads benefit more from multithreading than others.