(Coursenotes for CSC 305 Individual Software Design and Development)

Multithreading

Processes

A program needs computer resources to run. Allocation of these resources is managed by the operating system.

A process is a computer program in execution, i.e., a program that’s actually running. Each process has its own memory space. Communication between different processes is possible through Inter-process communication (IPC) resources like pipes and sockets.

Process control block (PCB)

Each process is represented in the OS by a process control block (PCB). The PCB contains some information about the process. This is an incomplete list, but here are some important bits of information the PCB keeps track of.

Process ID (PID) — Each process has a unique ID.

Process State

CPU Scheduling information

Your CPU has a number of processes that need to be executed. It needs to determine the order in which processes will run, and which ones need priority. Moreoever, since processes may be long-running, processes will be allocated some “running time” before running time is given to some other process. The PCB keeps track of the information needed for the CPU to control scheduling of a process.

Memory — As mentioned above, each process has its own memory space and limits. If you are working on the Streams lab, you have likely encountered these limits.

I/O information — What I/O devices is the process accessing?

Threads

A thread is the basic unit of CPU utilisation within a process. A process will typically have a “main thread” that handles its computations, and it may spin up additional threads as needed.

Threads are sometimes called “lightweight processes”. Threads within the same process can share the process’s resources, including memory and open files. This makes for efficient communication between multiple threads, but can potentially lead to errors when multiple threads make conflicting requests to the same resources.

Why multi-threading?

A “traditional” process has just a single thread of control. This means it can perform a single task at a time.

While this certainly simplifies issues around synchronisation of multiple threads, this doesn’t take advantage of the fact that most modern computers have multi-core processors. This means our machines have the ability to do multiple things “at once”. This can increase the throughput of our application (i.e., the amount of data our application can process in a given amount of time).

In addition to just increasing throughput by parallelising processing of some data to speed up a single task, some applications simply need to do several things at once.

For example, your web browser manages many tasks at once — downloading a large file in the background, streaming a YouTube video in one tab, keeping Canvas open in another tab (to say nothing of the invisible requests it makes to various entities paying to place ads in front of you). The browser is, ultimately, a running program (a process). If the browser could handle just one thing at a time, our browsing experience would be very different. We’d have to wait for a file to finish downloading before doing anything else, or only visit one web page at a time.

Another example is your IDE — it does things like syntax highlighting, compilation, static analysis, and auto-completion seemingly all at once.

Memory management in threads

Java programs are executed by the Java Virtual Machine (JVM). It’s a virtual machine that is able to execute Java bytecode (i.e., the format that your Java source code compiles down to). “The JVM” is really a specification detailing what a JVM implementation should do. Different vendors might implement their JVMs separately—that’s okay, as long as they adhere to the specification.

In most implementations, the JVM is run as a single process. You can see the process’s PID by doing the following:

System.out.println(ProcessHandle.current().pid());

If it is easier to think about, you can more-or-less think about “the JVM” and “your running Java program” interchangeably for the purposes of this discussion. So we will talk about how, within that process, memory is managed and shared among multiple threads owned by that process.

There are three main memory areas in the JVM:

The method area and heap area are not thread-safe, since they are shared by all threads.

For each thread, when it begins running, a separate runtime Stack is created. The runtime Stack’s job is to store method calls. Each time a method is called, a new entry (called a stack frame) is pushed onto the stack. When the method terminates, that Stack frame is removed from the runtime stack and destroyed.

Each stack frame stores information about the method’s local variables and some space for performing operations on those local variables. Primitive variables are stored in the stack frame. For object types, a reference is stored that points to the object’s location in the heap area.

Unlike the heap area, the stack area’s name is meaningfully related to the data structure that you know of with the same name.

That’s why when you go into infinite recursion, you get a StackOverflowError — the runtime stack for that thread overflowed with too many stack frames, because there were too many method calls. It’s possible to get a StackOverflowError without recursion.

The stack area is thread-safe, since it belongs to a single thread.

Thread creation

As we’ve described, each Java program will begin running with a single thread (a “main” thread). You can create additional threads using the Runnable interface.

In the code below, the MyRunnable class is an instance of Runnable, which means it must implement the run method.

In the main thread (in Demo.java) we create a new MyRunnable object, and assign it to a new Thread. Then, we start the thread.

Can you predict the order in which the two print statements will execute?

// MyRunnable.java
public class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("Inside the thread.");
    }
}

// Demo.java
public class Demo {
    // This is where the main thread starts — the entry point of the program.
    public static void main(String[] args) {
        MyRunnable obj = new MyRunnable();
        Thread t1 = new Thread(obj);
        t1.start();
        System.out.println("Will this print after or before the other print statement?");
    }
}

Instead of creating a Runnable object, you can also create a class and directly extend the Thread class. You’ll still need to extend the run method.

Alternatively, note that Runnable is a functional interface. It has only one abstract method — run. This means you can define new Runnable objects using lambda expressions.

public class Demo {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> System.out.println("Inner thread"));
        System.out.println("In the main thread");
        t1.start();
    }
}

The Thread API

In the next class, we’ll talk about synchronising threads so they work with shared data safely and correctly.