Chapter 06 · Article 32 of 55

Creating and Managing Threads

Threads are the fundamental units of execution within a process. Creating and managing threads effectively is critical for building responsive, high-performance systems. This ar…

Article outline14 sections on this page

Overview

Threads are the fundamental units of execution within a process. Creating and managing threads effectively is critical for building responsive, high-performance systems. This article covers the complete lifecycle of a thread - from creation through termination - along with coordination mechanisms that allow threads to work together safely.

Key concerns when working with threads:

  • Creation: How to spawn new threads and assign work to them
  • Lifecycle Management: Controlling thread states - starting, pausing, resuming, and stopping
  • Coordination: Ensuring threads cooperate without corrupting shared state or deadlocking
  • Resource Management: Preventing thread leaks, managing thread counts, and cleaning up properly

Understanding these fundamentals is prerequisite to higher-level concurrency constructs like thread pools, executors, and concurrent data structures.


Thread Lifecycle

Every thread passes through a well-defined set of states from birth to death:

                          ┌─────────────────────────────────┐
                          │                                 │
                          ▼                                 │
┌─────┐   start()   ┌──────────┐   scheduler   ┌─────────┐│
│ NEW │─────────────▶│ RUNNABLE │──────────────▶│ RUNNING ││
└─────┘              └──────────┘◀──────────────└─────────┘│
                          ▲        time slice expired   │   │
                          │                             │   │
                          │   notify()/                 │   │
                          │   timeout/                  │   │
                          │   lock acquired             │   │
                          │                             │   │
                     ┌────┴──────────────────┐          │   │
                     │ BLOCKED / WAITING /   │◀─────────┘   │
                     │ TIMED_WAITING         │  sleep()/    │
                     └───────────────────────┘  wait()/    │
                                                join()/    │
                                                lock       │
                          ┌────────────┐                   │
                          │ TERMINATED │◀──────────────────┘
                          └────────────┘   run() completes
                                           or exception

State Descriptions

StateDescription
NEWThread object created but start() not yet called. No system resources allocated.
RUNNABLEThread is eligible to run. Waiting for CPU time from the scheduler.
RUNNINGThread is actively executing on a CPU core.
BLOCKEDThread is waiting to acquire a monitor lock held by another thread.
WAITINGThread is waiting indefinitely for another thread's signal (wait(), join() with no timeout).
TIMED_WAITINGThread is waiting with a timeout (sleep(ms), wait(ms), join(ms)).
TERMINATEDThread has completed execution - either normally or via an unhandled exception. Cannot be restarted.

Key Transitions

  • NEW → RUNNABLE: Calling start() allocates OS-level resources and places the thread in the ready queue.
  • RUNNABLE → RUNNING: The OS scheduler selects this thread for execution.
  • RUNNING → RUNNABLE: Time slice expires or yield() is called voluntarily.
  • RUNNING → BLOCKED: Thread attempts to enter a synchronized block held by another thread.
  • RUNNING → WAITING: Thread calls wait() or join() without timeout.
  • RUNNING → TIMED_WAITING: Thread calls sleep(ms) or wait(ms).
  • WAITING/TIMED_WAITING → RUNNABLE: Notification received, timeout expires, or interrupted.
  • RUNNING → TERMINATED: run() method returns or throws an uncaught exception.

Creating Threads

Approach 1: Extending the Thread Class

class FileProcessor extends Thread:
    field filePath

    constructor(filePath):
        this.filePath = filePath

    method run():
        data = readFile(filePath)
        process(data)
        print("Processed: " + filePath)

// Usage
thread = new FileProcessor("/data/input.csv")
thread.start()

Approach 2: Implementing the Runnable Interface

class DataCruncher implements Runnable:
    field dataset

    constructor(dataset):
        this.dataset = dataset

    method run():
        result = computeStatistics(dataset)
        print("Stats computed: " + result)

// Usage
task = new DataCruncher(myData)
thread = new Thread(task)
thread.start()

Approach 3: Implementing Callable (with Return Value)

class PriceCalculator implements Callable<Double>:
    field items

    constructor(items):
        this.items = items

    method call() -> Double:
        total = 0.0
        for item in items:
            total += item.price * item.quantity
        return total

// Usage
calculator = new PriceCalculator(cartItems)
future = executorService.submit(calculator)
totalPrice = future.get()  // blocks until result is ready

Comparison Table

AspectExtend ThreadImplement RunnableImplement Callable
InheritanceConsumes single inheritanceAllows extending other classesAllows extending other classes
Return valueNoNoYes (via Future)
Exception handlingCannot throw checked exceptionsCannot throw checked exceptionsCan throw checked exceptions
ReusabilityLow - tightly coupledHigh - separates task from threadHigh - separates task from execution
Use with thread poolsAwkwardNatural fitNatural fit
RecommendedRarelyGeneral purposeWhen result needed

Thread Lifecycle Management

start()

Transitions thread from NEW to RUNNABLE. Allocates native thread resources.

thread = new Thread(myRunnable)
thread.start()  // spawns new OS thread, calls run() in that thread
// WRONG: thread.run()  -  executes in CURRENT thread, no new thread created

sleep(milliseconds)

Pauses the current thread for a specified duration. Does NOT release locks.

method performWithBackoff(operation, maxRetries):
    for attempt in 1..maxRetries:
        try:
            return operation.execute()
        catch TransientError:
            backoffMs = 100 * power(2, attempt)
            Thread.sleep(backoffMs)  // current thread enters TIMED_WAITING
    throw MaxRetriesExceeded

join()

Calling thread waits until the target thread terminates.

method processInParallel(tasks):
    threads = []
    for task in tasks:
        t = new Thread(task)
        t.start()
        threads.add(t)

    // Wait for all threads to complete
    for t in threads:
        t.join()  // caller blocks until t finishes

    print("All tasks completed")

yield()

Hints to the scheduler that the current thread is willing to give up its time slice. Not guaranteed to have any effect.

method cooperativeLoop():
    while not done:
        if noWorkAvailable():
            Thread.yield()  // let other threads run
        else:
            processNextItem()

interrupt()

Sets the interrupt flag on a target thread. If the thread is sleeping or waiting, it throws InterruptedException.

// Worker thread
method run():
    while not Thread.currentThread().isInterrupted():
        item = queue.poll(1, SECONDS)  // throws InterruptedException
        if item != null:
            process(item)

// Controller
workerThread.interrupt()  // signals the worker to stop

Daemon Threads vs User Threads

AspectUser ThreadsDaemon Threads
JVM shutdownJVM waits for all user threads to finishJVM exits even if daemon threads are running
DefaultYesNo - must explicitly set
Use caseCore application logicBackground services (GC, monitoring, logging)
Resource cleanupGuaranteed (finally blocks execute)NOT guaranteed on JVM exit
CreationDefault behaviorthread.setDaemon(true) before start()

When to Use Each

  • User threads: Request processing, transaction handling, any work that must complete
  • Daemon threads: Heartbeat senders, cache eviction, log flushing, metrics collection

Pseudocode Example

// Background cache cleanup  -  daemon thread
class CacheEvictor implements Runnable:
    field cache

    method run():
        while true:
            Thread.sleep(30_000)  // every 30 seconds
            cache.removeExpiredEntries()
            log("Evicted stale cache entries")

evictor = new Thread(new CacheEvictor(appCache))
evictor.setDaemon(true)   // MUST set before start()
evictor.start()

// Application continues  -  when main work finishes, JVM exits
// and the evictor is terminated automatically

Thread Priority

Threads can be assigned priority values that hint at scheduling preference:

PriorityConstantTypical Use
1MIN_PRIORITYBackground, low-importance tasks
5NORM_PRIORITYDefault for all threads
10MAX_PRIORITYTime-critical operations

Scheduling Implications

  • Priority is a hint, not a guarantee. The OS scheduler makes final decisions.
  • Higher-priority threads are more likely to be scheduled first, but starvation of low-priority threads is possible.
  • Platform dependency: Windows uses 7 priority levels mapped from 10 Java levels. Linux often ignores thread priorities entirely for non-root processes (nice values apply instead).

Best Practices

  • Do not rely on priority for correctness - use synchronization primitives instead.
  • Avoid extreme priorities; they cause starvation and priority inversion.
  • Use priorities only for performance tuning in well-understood scenarios.
thread = new Thread(criticalTask)
thread.setPriority(Thread.MAX_PRIORITY)
thread.start()

backgroundThread = new Thread(loggingTask)
backgroundThread.setPriority(Thread.MIN_PRIORITY)
backgroundThread.start()

Thread Groups

Thread groups provide hierarchical organization of threads for bulk management.

Organization

// Create a group for all worker threads
workerGroup = new ThreadGroup("download-workers")

for i in 1..5:
    t = new Thread(workerGroup, new DownloadTask(urls[i]), "worker-" + i)
    t.start()

// Query group
print("Active threads: " + workerGroup.activeCount())

Bulk Operations

// Interrupt all threads in a group
workerGroup.interrupt()

// List all threads
threads = new Thread[workerGroup.activeCount()]
workerGroup.enumerate(threads)

Uncaught Exception Handling

class LoggingThreadGroup extends ThreadGroup:
    constructor(name):
        super(name)

    method uncaughtException(thread, exception):
        log.error("Thread " + thread.getName() + " failed: " + exception)
        alertOps(thread, exception)
        // Optionally restart the thread

Note: Thread groups are considered somewhat legacy. Modern code prefers ThreadPoolExecutor with custom ThreadFactory and UncaughtExceptionHandler. However, understanding groups is valuable for interview contexts and legacy codebases.


Pseudocode Implementation

Multi-Threaded File Downloader

class DownloadProgress:
    field totalFiles
    field completedFiles = AtomicInteger(0)
    field failedFiles = AtomicInteger(0)
    field bytesDownloaded = AtomicLong(0)

    constructor(totalFiles):
        this.totalFiles = totalFiles

    method recordSuccess(bytes):
        completedFiles.incrementAndGet()
        bytesDownloaded.addAndGet(bytes)

    method recordFailure():
        failedFiles.incrementAndGet()

    method isComplete():
        return (completedFiles.get() + failedFiles.get()) >= totalFiles

    method report():
        print("=== Download Report ===")
        print("Total files: " + totalFiles)
        print("Completed: " + completedFiles.get())
        print("Failed: " + failedFiles.get())
        print("Bytes downloaded: " + bytesDownloaded.get())


class DownloadWorker implements Runnable:
    field url
    field destinationPath
    field progress

    constructor(url, destinationPath, progress):
        this.url = url
        this.destinationPath = destinationPath
        this.progress = progress

    method run():
        try:
            print("[" + Thread.currentThread().getName() + "] Downloading: " + url)
            bytes = httpDownload(url, destinationPath)
            progress.recordSuccess(bytes)
            print("[" + Thread.currentThread().getName() + "] Completed: " + url)
        catch IOException as e:
            progress.recordFailure()
            print("[" + Thread.currentThread().getName() + "] Failed: " + url + " - " + e.message)
        catch InterruptedException:
            Thread.currentThread().interrupt()  // preserve interrupt status
            progress.recordFailure()


class MultiThreadedDownloader:
    field maxConcurrentDownloads

    constructor(maxConcurrentDownloads):
        this.maxConcurrentDownloads = maxConcurrentDownloads

    method downloadAll(fileList):
        progress = new DownloadProgress(fileList.size())
        threads = []

        for entry in fileList:
            worker = new DownloadWorker(entry.url, entry.dest, progress)
            thread = new Thread(worker, "downloader-" + entry.name)
            threads.add(thread)

        // Start threads in batches to limit concurrency
        batches = partition(threads, maxConcurrentDownloads)

        for batch in batches:
            // Start all threads in this batch
            for t in batch:
                t.start()

            // Join all threads in this batch before starting next
            for t in batch:
                try:
                    t.join(30_000)  // 30-second timeout per thread
                    if t.isAlive():
                        t.interrupt()  // force stop if hung
                        t.join(5_000)  // brief wait for cleanup
                catch InterruptedException:
                    print("Main thread interrupted during join")
                    // Interrupt remaining threads
                    for remaining in batch:
                        remaining.interrupt()
                    break

        progress.report()
        return progress


// Usage
downloader = new MultiThreadedDownloader(maxConcurrentDownloads: 4)
files = [
    {url: "https://cdn.example.com/file1.zip", dest: "/tmp/file1.zip", name: "file1"},
    {url: "https://cdn.example.com/file2.zip", dest: "/tmp/file2.zip", name: "file2"},
    {url: "https://cdn.example.com/file3.zip", dest: "/tmp/file3.zip", name: "file3"},
    // ... more files
]
result = downloader.downloadAll(files)

Common Pitfalls

1. Starting a Thread Twice

thread.start()
thread.start()  // throws IllegalThreadStateException!

A thread in TERMINATED state cannot be restarted. Create a new thread instance instead.

2. Not Handling InterruptedException

// WRONG  -  swallows the interrupt signal
try:
    Thread.sleep(1000)
catch InterruptedException:
    // empty  -  other code checking isInterrupted() will miss it

// CORRECT  -  restore the interrupt flag
try:
    Thread.sleep(1000)
catch InterruptedException:
    Thread.currentThread().interrupt()  // restore flag
    return  // exit gracefully

3. Thread Leaks

Creating threads without proper lifecycle management leads to resource exhaustion:

// WRONG  -  unbounded thread creation
method handleRequest(request):
    new Thread(() -> process(request)).start()  // no limit, no tracking

// CORRECT  -  use bounded thread pool
executor = new ThreadPoolExecutor(coreSize: 10, maxSize: 50, queue: boundedQueue)
method handleRequest(request):
    executor.submit(() -> process(request))

4. Forgetting to Join

// WRONG  -  main thread may exit before workers finish
for task in tasks:
    new Thread(task).start()
print("Done!")  // printed before tasks complete

// CORRECT  -  join all threads
threads = []
for task in tasks:
    t = new Thread(task)
    t.start()
    threads.add(t)
for t in threads:
    t.join()
print("Done!")  // printed after all tasks complete

5. Calling run() Instead of start()

Calling run() directly executes the method in the current thread - no new thread is created. Always use start().


Real-World Examples

Web Server Request Handling

// Simplified thread-per-request model (e.g., traditional Tomcat)
class WebServer:
    field serverSocket
    field workerGroup = new ThreadGroup("request-handlers")

    method start():
        while serverSocket.isOpen():
            clientSocket = serverSocket.accept()
            handler = new Thread(workerGroup, new RequestHandler(clientSocket))
            handler.start()

Background Task Scheduling

// Periodic health check as daemon thread
class HealthChecker implements Runnable:
    method run():
        while not Thread.currentThread().isInterrupted():
            status = checkDatabaseConnection()
            status += checkCacheConnection()
            publishMetrics(status)
            Thread.sleep(10_000)

healthThread = new Thread(new HealthChecker(), "health-checker")
healthThread.setDaemon(true)
healthThread.start()

Periodic Cleanup

// Session cleanup running every 5 minutes
class SessionCleaner implements Runnable:
    field sessionStore

    method run():
        while not Thread.currentThread().isInterrupted():
            expired = sessionStore.findExpired()
            for session in expired:
                sessionStore.remove(session.id)
            log("Cleaned " + expired.size() + " sessions")
            Thread.sleep(300_000)

Parallel File Processing

// Process log files in parallel, aggregate results
method analyzeLogFiles(directory):
    files = listFiles(directory, "*.log")
    results = ConcurrentHashMap()
    threads = []

    for file in files:
        t = new Thread(() ->
            counts = countErrorsByType(file)
            results.put(file.name, counts)
        )
        t.start()
        threads.add(t)

    for t in threads:
        t.join()

    return aggregateResults(results)

Interview Follow-ups

Q1: What happens if you call start() on a thread that has already terminated?

A: It throws IllegalThreadStateException. A thread cannot transition from TERMINATED back to any other state. You must create a new Thread instance to re-execute the same task. This is a deliberate design choice - thread state is tied to OS-level resources that are released upon termination.

Q2: How does sleep() differ from wait()?

A: sleep() pauses the current thread for a fixed duration but does not release any locks it holds. wait() releases the monitor lock on the object and suspends the thread until notify()/notifyAll() is called. sleep() is a static method of Thread; wait() is an instance method of Object and must be called within a synchronized block.

Q3: Why might thread.join(timeout) return with the thread still alive?

A: join(timeout) waits at most the specified milliseconds. If the target thread hasn't terminated within that window, join() returns anyway. The caller should check thread.isAlive() afterward to determine whether the thread actually finished or the timeout elapsed. This is essential for implementing deadlock-free waiting patterns.

Hints-Only Questions

H1: How would you implement a graceful shutdown mechanism for a pool of worker threads?

  • Think about a shared volatile shutdown flag combined with interrupt()
  • Consider what happens to in-progress work vs. queued work

H2: What is priority inversion and how can it occur with thread priorities?

  • Consider a scenario with three threads at LOW, MEDIUM, and HIGH priority sharing a lock
  • Research priority inheritance protocols and how real-time systems solve this

Counter Questions to Ask Interviewer

  1. "What's the expected concurrency level?" - Determines whether raw threads, thread pools, or reactive patterns are appropriate.
  2. "Are threads CPU-bound or I/O-bound?" - CPU-bound work benefits from thread count ≈ core count; I/O-bound work can use many more threads.
  3. "Is there a preference for platform threading vs. virtual threads?" - Modern runtimes (e.g., Java 21+) offer virtual threads that change the cost model entirely.
  4. "What's the failure tolerance?" - Should a failed thread crash the application, be retried, or be logged and ignored?
  5. "Are there existing concurrency frameworks in the codebase?" - Avoids reinventing what's already available (Executors, Akka, structured concurrency).

References & Whitepapers

  1. "Java Concurrency in Practice" - Brian Goetz et al. (2006). The definitive guide to concurrent programming on the JVM.
  2. "Operating System Concepts" - Silberschatz, Galvin, Gagne. Chapters on process/thread management and scheduling.
  3. POSIX Threads (pthreads) Specification - IEEE Std 1003.1. Foundation for thread APIs across Unix systems.
  4. "A Java Fork/Join Framework" - Doug Lea (2000). Describes work-stealing and parallel decomposition.
  5. JEP 444: Virtual Threads - OpenJDK. Lightweight threads that decouple application concurrency from OS thread count.
  6. "The Problem with Threads" - Edward A. Lee (2006). UC Berkeley Technical Report on why threads are fundamentally difficult.
  7. Linux kernel documentation: CFS Scheduler - Explains how Linux schedules threads and why Java priorities have limited effect on Linux.