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 thestart()
method. Callingrun()
directly simply executes therun
method’s code within the current thread, not a new one. Thestart()
method registers the new thread with the thread scheduler and invokes itsrun()
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 uniquelong
identifier assigned to each thread by the JVM. - Name (
getName()
,setName()
): A descriptiveString
name, helpful for debugging. Can be set by the programmer. - Priority (
getPriority()
,setPriority()
): An integer value (typically 1-10, withThread.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 theThread.State
enum (e.g.,NEW
,RUNNABLE
,BLOCKED
,WAITING
,TIMED_WAITING
,TERMINATED
).
Example: Monitoring Thread States and Priorities
This code demonstrates creating multiple threads, setting different priorities, and observing their states during execution.
Observations from Running Such Code:
- Threads frequently enter
BLOCKED
orWAITING
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.
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.
Efficient Solution: thread.join()
The join()
method provides an efficient way for one thread to wait for another thread’s termination.
- When
threadA
callsthreadB.join()
,threadA
enters aWAITING
state and blocks. - The thread scheduler will not run
threadA
again untilthreadB
terminates.
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 onceotherThread
terminates (even if due to an exception). The joining thread won’t automatically know an exception occurred.
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:
- Per-Thread: Use
thread.setUncaughtExceptionHandler(handler)
. - Per-ThreadGroup: Use
threadGroup.uncaughtException(thread, exception)
(often set viaThreadGroup
constructor or implicitly). - 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.
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.
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;
):
The sequence is typically:
getfield
: Read the current value ofthis.value
from memory into a temporary location (e.g., operand stack).iload
: Load the value ofdelta
.iadd
: Add the two values.putfield
: Write the result back tothis.value
in memory.
Interleaving: The thread scheduler can pause one thread and switch to another between any of these bytecode instructions. For example:
- Thread A reads
value
(e.g., 10). - Context Switch
- Thread B reads
value
(still 10). - Thread B calculates
10 - 1 = 9
. - Thread B writes
9
back tovalue
. - Context Switch
- Thread A calculates
10 + 1 = 11
(using its old read value). - Thread A writes
11
back tovalue
.
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)