Lecture from: 27.02.2024 | Video: Video ETHZ

Understanding Java Threads

Core Concepts

  • Main Thread: Every Java application starts with a single thread, known as the “main” thread, which executes the main() method.
  • Creating Threads: New threads are created by instantiating the java.lang.Thread class (or a subclass).
  • Starting Threads: Crucially, creating a Thread object does not start its execution. You must explicitly call the start() method. Calling run() directly simply executes the run method’s code within the current thread, not a new one. The start() method registers the new thread with the thread scheduler and invokes its run() method in a separate context.
  • Program Termination: A Java program continues to run as long as at least one non-daemon thread is active. The main thread is a non-daemon thread by default. Worker threads are also non-daemon unless explicitly set otherwise (setDaemon(true)). Daemon threads (e.g., garbage collector) do not prevent the program from exiting.
  • Independence: Threads execute independently. A thread can continue running even after the main method that might have started it has finished.

Useful Thread Attributes and Methods

Java threads have several informative attributes and control methods:

  • ID (getId()): A unique long identifier assigned to each thread by the JVM.
  • Name (getName(), setName()): A descriptive String name, helpful for debugging. Can be set by the programmer.
  • Priority (getPriority(), setPriority()): An integer value (typically 1-10, with Thread.NORM_PRIORITY = 5) suggesting the thread’s importance to the scheduler. Higher priority threads may get more CPU time, but this is highly OS-dependent and not guaranteed.
  • State (getState()): The current execution state of the thread, represented by the Thread.State enum (e.g., NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED).

Diagram illustrating Thread states

Example: Monitoring Thread States and Priorities

This code demonstrates creating multiple threads, setting different priorities, and observing their states during execution.

Java code example for thread states and priorities (Part 1)

Java code example for thread states and priorities (Part 2)

Java code example for thread states and priorities (Part 3)

Observations from Running Such Code:

  • Threads frequently enter BLOCKED or WAITING states, often due to I/O operations (waiting for input/output) or explicit synchronization calls.
  • While not strictly guaranteed, threads with higher priority often tend to complete their work sooner than lower-priority threads, assuming they are CPU-bound and the OS scheduler honors priorities.

Example output showing thread states and completion order

Waiting for Threads: join()

A common pattern is for a main thread to start several worker threads and then need to wait for them to complete before proceeding (e.g., to aggregate results).

Inefficient Approach: Busy Waiting

One could repeatedly check the state of worker threads in a loop (while (worker.isAlive()) { /* do nothing */ }). This is highly inefficient as it consumes CPU cycles unnecessarily.

Conceptual illustration of busy waiting

Code example demonstrating busy waiting (inefficient)

Efficient Solution: thread.join()

The join() method provides an efficient way for one thread to wait for another thread’s termination.

  • When threadA calls threadB.join(), threadA enters a WAITING state and blocks.
  • The thread scheduler will not run threadA again until threadB terminates.

Code example demonstrating the use of join() (efficient)

Performance Note: While join() is generally preferred, it does involve overhead (context switching). If the worker threads perform extremely trivial, short tasks, the overhead of join() might theoretically exceed the time saved compared to a brief busy wait. However, for most practical purposes, join() is the correct and more efficient approach.

Handling Exceptions in Threads

Exception handling differs between single-threaded and multi-threaded applications:

  • Single-threaded: An uncaught exception typically prints a stack trace and terminates the entire program.
  • Multi-threaded: If an unchecked exception occurs in a worker thread’s run() method:
    • The stack trace is usually printed to the console.
    • The specific thread terminates.
    • Crucially, this does not directly affect other threads.
    • A thread waiting via otherThread.join() will still successfully return once otherThread terminates (even if due to an exception). The joining thread won’t automatically know an exception occurred.

Code example showing an exception in a worker thread and its effect on join()

Custom Exception Handling: UncaughtExceptionHandler

To detect and react to uncaught exceptions in threads, Java provides the Thread.UncaughtExceptionHandler interface. You can implement this interface and register your handler:

  1. Per-Thread: Use thread.setUncaughtExceptionHandler(handler).
  2. Per-ThreadGroup: Use threadGroup.uncaughtException(thread, exception) (often set via ThreadGroup constructor or implicitly).
  3. Globally: Use the static method Thread.setDefaultUncaughtExceptionHandler(handler) to set a default handler for all threads that don’t have a specific handler set.

This allows for logging, resource cleanup, or other actions when a thread terminates unexpectedly.

Challenges with Shared Resources

When multiple threads access and modify the same data concurrently, unexpected and incorrect results can occur if access is not properly coordinated.

Illustration depicting multiple threads accessing a shared resource

Analogy

Imagine multiple threads trying to write to the system console simultaneously. Without coordination, their output messages might get interleaved and become nonsensical – they are “fighting” over the shared console resource.

Example: Non-Atomic Increment/Decrement

Consider a shared counter object accessed by two threads: one repeatedly increments the counter, and the other repeatedly decrements it.

Java code example with two threads incrementing/decrementing a shared value unsynchronized

The Problem: Race Condition

If you run this code, the final value of this.value is often not zero, even if both threads perform the same number of operations. This happens because the increment (+=) and decrement (-=) operations are not atomic.

  • Non-Atomic Operations: A single line of Java code like this.value += delta; compiles into multiple underlying bytecode instructions (and further into machine instructions).

Relevant Bytecode (for this.value += delta;):

Bytecode for the increment operation, showing multiple steps

The sequence is typically:

  1. getfield: Read the current value of this.value from memory into a temporary location (e.g., operand stack).
  2. iload: Load the value of delta.
  3. iadd: Add the two values.
  4. putfield: Write the result back to this.value in memory.

Interleaving: The thread scheduler can pause one thread and switch to another between any of these bytecode instructions. For example:

  1. Thread A reads value (e.g., 10).
  2. Context Switch
  3. Thread B reads value (still 10).
  4. Thread B calculates 10 - 1 = 9.
  5. Thread B writes 9 back to value.
  6. Context Switch
  7. Thread A calculates 10 + 1 = 11 (using its old read value).
  8. Thread A writes 11 back to value.

Result: One increment and one decrement occurred, but the final value is 11 instead of the expected 10. This is a classic race condition. Proper synchronization mechanisms (like locks) are needed to make such operations atomic.

Continue here: 05 Introduction to Threads and Synchronization (Part III)