Java Concurrency

Lesson 1 of 5

Threads, and What the OS Gives You

"To do two things at once is to do neither." — Publilius Syrus, Sentences (1st century BC)

A thread is the smallest thing an operating system will schedule. Java gives you an object that wraps one, a method to start it, and from that point your program has more than one place where execution is happening — which changes what "the program does X then Y" means, and invalidates most of the reasoning you have used until now.

This Part is the one Java interviews return to more than any other, and it is worth saying why before the mechanics. Concurrency is hard not because the APIs are complicated — Thread, start, join is three things — but because the reasoning is unfamiliar. Code that is obviously correct when one thread runs it can be wrong when two do, and the wrongness is intermittent, environment-dependent, and usually invisible in testing.

This chapter is about the primitive itself: what a thread costs, what you can observe about one, and the two ways to stop one — only one of which works. The chapters after it are about what goes wrong when two of them touch the same data.

The big question

You call thread.interrupt(). What have you actually done to that thread — and what would make it stop?

What you can see from outside

Start with the object and its observable life.

Java — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

A Thread object has a lifecycle and it is one-way. NEW before you start it, RUNNABLE once started, TERMINATED when the task returns — and calling start() on a terminated thread throws IllegalThreadStateException. A thread is not a reusable worker; it is one execution of one task, and the object is spent afterwards. That is most of the argument for thread pools, which reuse the operating system thread while giving each task a fresh run.

join() is the only coordination primitive in this block, and it is the one most worth knowing because it needs no shared state. Calling a.join() from thread B blocks B until A finishes. The timed form, join(50), returns after fifty milliseconds whether or not A has finished — so it needs an isAlive() check afterwards to know which happened, which the block shows.

Two notes on this playground before going further. It reports one processor, so nothing here runs genuinely in parallel; threads interleave on a single core. And getState() reports only NEW, RUNNABLE and TERMINATED — a real JVM also reports TIMED_WAITING while a thread sleeps, BLOCKED while it waits for a monitor, and WAITING on Object.wait(). Those three are real and are covered below as prose rather than as a block, because this runtime will not show them.

An analogy

A thread is a cook, and the processor is the stove. One stove with four cooks does not produce food four times faster; the cooks take turns, and the turns are decided by somebody else — the scheduler — who may swap them mid-chop. What you gain is that while one cook is waiting for the oven, another can use the stove, so the kitchen is never idle. What you lose is any guarantee about the order things happen in, which is fine until two cooks reach for the same bowl. Adding stoves is what makes the cooks genuinely faster, and that is the difference between concurrency — several things in progress — and parallelism — several things happening at once.

What a thread costs

Threads are not free, and the number people reach for is usually wrong by an order of magnitude.

A Java thread maps one-to-one onto an operating system thread. Creating one is a system call; each gets its own stack, by default around one megabyte of reserved address space, and the operating system keeps a scheduling entry for it. The consequences are concrete: a few thousand threads is a realistic ceiling on a normal machine, creating one takes on the order of tens of microseconds, and switching between them costs a kernel transition plus the cache disruption of running different code.

That cost is why the obvious design — one thread per request — stopped working as services got busier, and why thread pools exist. A pool pays the creation cost once and reuses the thread for many tasks, so the cost per task is a queue operation rather than a system call.

It is also why Java 21's virtual threads matter, and they are worth knowing about even though this runtime cannot run them. A virtual thread is scheduled by the JVM rather than the operating system, has a growable stack on the heap rather than a fixed one, and costs a few hundred bytes instead of a megabyte. Millions of them are practical. The programming model is deliberately identical — you still write Thread.ofVirtual().start(task), still join, still block — but blocking a virtual thread parks it and frees the carrier thread underneath, so the "one thread per request" design becomes viable again at a scale it never was before.

On Java 17, which is where this playground sits, you have platform threads and pools. The reasoning in this Part applies to both; what changes with virtual threads is how many you can afford, not what happens when two of them share data.

It is worth separating two words that get used interchangeably and mean different things. Concurrency is having several tasks in progress at once; parallelism is having several executing at the same instant. A single processor gives you concurrency and no parallelism — tasks take turns, and the turns are short enough that everything appears to progress together. Four processors give you up to four-way parallelism as well.

The distinction matters because the two solve different problems. Concurrency is what makes a server responsive while waiting on a database: the thread handling request A is blocked on I/O, so the processor runs request B. That is a win with one core and no arithmetic involved. Parallelism is what makes a computation finish sooner by splitting it, and it requires both multiple cores and work that divides.

Most server code wants concurrency and gets no benefit from parallelism, because it spends its time waiting rather than computing. Most data-processing code wants the opposite. Confusing them produces both of the classic mistakes: adding threads to a CPU-bound task on one core, which only adds switching overhead, and adding cores to an I/O-bound service, which leaves them idle.

A little history

Java shipped with threads in 1.0, in 1996, which was genuinely unusual — most languages of the era treated concurrency as a library or an operating-system concern, and Java put synchronized in the language itself. That early commitment produced two of the mistakes this Part keeps returning to. Thread.stop(), suspend() and resume() were deprecated within a few years because they could leave objects half-modified with their locks released, which is unfixable rather than merely dangerous — and their removal is why cooperative cancellation is the only mechanism Java offers today. And the original memory model was vague enough that compiler writers and programmers disagreed about what volatile meant, which took until JSR-133 in Java 5 to repair. The next chapter is about what that repair actually says.

Interruption is a request

Here is the part that surprises people: there is no way to stop a thread. There is only a way to ask.

Java — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

interrupt() sets a boolean on the target thread and returns. That is all it does. A thread that never checks the flag is not interruptible, as the second worker demonstrates: it was interrupted and ran to completion anyway.

The two reading methods differ in a way that causes real bugs. isInterrupted() is an instance method that reads the flag and leaves it set. Thread.interrupted() is static, reads the flag for the current thread, and clears it. Code that uses the static form in a loop condition clears the flag on the first check, so a later check — or a library further down the stack — sees a thread that was never interrupted.

The missing half of the picture is what happens when the thread is blocked rather than looping. On a real JVM, interrupting a thread inside Thread.sleep(), wait(), join() or most blocking I/O causes that call to throw InterruptedException and clear the flag. I measured this on OpenJDK 17: the sleeping thread threw immediately, and isInterrupted() inside the catch block was false.

In this playground it does not happen — an interrupted sleep runs to completion — so the block above polls rather than blocks. That limitation is worth stating plainly because the clearing behaviour is the source of the rule everyone half-remembers: when you catch InterruptedException, the flag has already been cleared, so if you are not going to stop, you must put it back:

catch (InterruptedException e) {
    Thread.currentThread().interrupt();   // restore what the catch consumed
    return;                               // or actually stop
}

Swallowing an InterruptedException without restoring the flag discards a cancellation request that something above you issued — which is why Part II listed it among the things you must never catch silently.

How a thread dies, and who finds out

Interruption is the polite ending. The other one is an exception, and what happens then is not what people expect.

Java — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

An exception that escapes a thread's task kills that thread and nothing else. The main thread carries on, the program does not exit, and the only evidence is a stack trace on stderr. Nothing returns a failure, nothing is thrown anywhere you can catch it, and in a service with a busy log it is entirely possible for a worker to die and nobody to notice for days.

setUncaughtExceptionHandler is how you find out. It receives the thread and the throwable, and it is where a log line with real context belongs. Thread.setDefaultUncaughtExceptionHandler installs one for every thread that has none of its own, which is the right place for a last-resort "something died" alert.

This is also the second half of the executor point from the next section. A task submitted to an executor does not reach these handlers — its exception is captured into the Future instead — so the two mechanisms cover different cases and a system usually wants both.

Daemon threads are the other half of a thread's relationship with the JVM. The JVM exits when the last non-daemon thread finishes; daemon threads are abandoned wherever they are, without running finally blocks. That makes them right for background housekeeping — a cache cleaner, a metrics flusher — and wrong for anything that must complete. The flag has to be set before start(), as the last section shows, and the default is non-daemon, which is why an un-shut-down executor keeps a finished program alive.

Pools, and why they exist

Creating a thread per task stops working quickly. The standard answer is a pool.

Java — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

Six tasks ran on four threads, which is the point: the pool creates its threads once and feeds them work from a queue. The thread names make the reuse visible.

Future is the handle to a result that does not exist yet. get() blocks until it does, and — importantly — it carries failures across the thread boundary. A task that throws does not print a stack trace and vanish; the exception is captured and rethrown from get() wrapped in an ExecutionException, with the original available through getCause(). A task whose Future is never inspected can fail completely silently, which is one of the most common causes of "the job just stopped working" in scheduled systems.

Shutdown has two forms and both matter. shutdown() stops accepting new work and lets the queue drain; shutdownNow() additionally interrupts the running tasks and returns the ones never started. Neither blocks, which is why awaitTermination exists. And a pool that is never shut down keeps non-daemon threads alive, so the JVM will not exit — a surprisingly common reason for a program that finishes its work and hangs.

From my notebook

I wrote the interruption block assuming I could show the canonical case: interrupt a sleeping thread, catch InterruptedException, observe that the flag has been cleared. It printed "slept the whole time". The interrupt had no effect on the blocking call at all, and the thread ran its full five seconds. On OpenJDK 17 the same program throws immediately and reports flag now false. So the single most important fact about interruption — that it unblocks blocking calls, and that the exception consumes the flag — is the one thing this runtime cannot demonstrate. The block polls instead, which is honest and teaches less, and the clearing behaviour is in the prose with the real-JVM measurement beside it. This is the fourth time in the volume that the demonstration I planned was the one the runtime would not do.

Per-thread state

One last primitive, because it solves a real problem and causes a famous leak.

Java — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

A ThreadLocal gives each thread its own value for the same variable. That is genuinely useful for context that every layer needs and nobody wants to thread through forty signatures — a request id, a transaction, a security principal, a locale. It is how logging frameworks attach a correlation id to every line without changing any method.

The last section is the trap. A pooled thread outlives the task that ran on it, so a ThreadLocal set during one task is still set during the next — and the block shows a later task reading left-behind. In a server that means request two can see request one's context, which is a correctness bug and sometimes a security one. It also retains whatever the value points at for the life of the thread, which is the leak shape from Part I's chapter on garbage collection.

The rule is unconditional: set a ThreadLocal in a try, remove it in the finally. Frameworks that manage context for you do exactly this, which is why Spring's RequestContextHolder and MDC-based logging work correctly on pooled threads and a hand-rolled equivalent usually does not.

In the real world

The patterns here are the ones operations teams meet. A batch job that finishes its work and never exits, because an executor was never shut down and its threads are not daemons. A scheduled task that silently stopped running, because it threw, the Future was never inspected, and nothing logged it. A request that sees another user's identity, because a ThreadLocal was set and not removed on a pooled thread. A graceful shutdown that is not graceful, because shutdown() was called without awaitTermination and the process was killed mid-task. And the classic under load: thread-per-request working perfectly in staging and collapsing in production, because the ceiling is a few thousand threads and the test never approached it.

Common Mistakes

  1. Expecting interrupt() to stop a thread. It sets a flag. A thread that never checks it, and is not blocked in an interruptible call, runs to completion.

  2. Using Thread.interrupted() in a loop condition. The static form clears the flag as it reads it, so a later check — or a library below you — sees a thread that was never interrupted. Use isInterrupted() unless you mean to consume it.

  3. Swallowing InterruptedException. Catching it clears the flag, so if you do not stop you must restore it with Thread.currentThread().interrupt(), or the cancellation is lost.

  4. Reusing a Thread object. TERMINATED is final; calling start() again throws IllegalThreadStateException. Create a new one, or use a pool.

  5. Submitting to an executor and ignoring the Future. A task that throws fails silently; the exception is held in the Future and surfaces only when something calls get().

  6. Never shutting an executor down. Its threads are not daemons by default, so the JVM will not exit and the program appears to hang after finishing its work.

  7. Calling shutdown() and assuming it waited. It returns immediately; awaitTermination is what blocks, and shutdownNow is what interrupts the running tasks.

  8. Setting a ThreadLocal on a pooled thread without removing it. The thread outlives the task, so the next task inherits the value and the referenced object is retained.

  9. One thread per request on platform threads. The ceiling is a few thousand and each costs about a megabyte of stack. Use a pool, or virtual threads on Java 21.

  10. Relying on thread priorities. They are hints passed to an operating system scheduler that may ignore them entirely, and behaviour differs by platform.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Warm-up
What happens when you call start() on a thread, and how do you wait for it to finish?
Model answer — start() asks the JVM to create an operating-system thread and run the task on it, then returns immediately — so the calling thread carries on while the new one runs. To wait, call join() on the thread object, which blocks the caller until the other finishes; there is a timed version that gives up after a while, so you check isAlive() afterwards to find out which happened. A thread object is single-use: once it has terminated, calling start() again throws IllegalThreadStateException, which is one of the reasons pools exist. Calling run() directly instead of start() is the classic mistake — it executes the task on the current thread and creates no concurrency at all.
Intermediate
How do you stop a running thread?
Model answer — You ask it to stop; you cannot make it. interrupt() sets a flag on the target thread, and that is all it does directly. A thread in a loop must check isInterrupted() and return; a thread blocked in sleep, wait, join or interruptible I/O gets an InterruptedException thrown from that call, and importantly the exception clears the flag — so if you catch it and do not stop, you must restore it with Thread.currentThread().interrupt() or the cancellation is silently lost. There is no forcible stop: Thread.stop() existed and was deprecated because it could release locks with objects half-modified, leaving the program in a state nothing could reason about. So cancellation in Java is a cooperative contract, and a task that does not participate is not cancellable.
Advanced
Why can you not just use one thread per request, and what changed in Java 21?
Model answer — Because a platform thread maps one-to-one onto an OS thread: creating it is a system call, it reserves around a megabyte of stack, and the scheduler keeps an entry for it. A few thousand is a realistic ceiling, so a server handling ten thousand concurrent requests cannot give each one a thread. The industry's answer was pools and then asynchronous, non-blocking code — which scales but inverts control flow, so stack traces stop being meaningful and ordinary constructs like try-with-resources and loops no longer express what you mean. Virtual threads change the economics rather than the model. They are scheduled by the JVM onto a small number of carrier threads, their stacks live on the heap and grow as needed, and blocking one parks it and frees the carrier. So you write ordinary blocking code, one thread per request, and get the scalability of the asynchronous version — millions of threads are practical. The caveats are that synchronized blocks could pin a virtual thread to its carrier in the first releases, and that pooling virtual threads is pointless since the whole idea is that they are cheap to create.
Company lens — how the same topic gets asked
micro1Direct: start() versus run(), what join does, and how you stop a thread.
GoogleCost and scheduling — what a thread actually is to the OS, and what virtual threads change.
MetaCancellation as a contract: interrupt propagation, restoring the flag, and why stop() was removed.
AmazonOperational: a job that never exits, a scheduled task that failed silently, and context leaking between pooled requests.

Hands-on

Hands-on

Model the rules you have just measured. Implement nextState, moving a thread through its lifecycle; canStart, applying the single-use rule; interruptEffect, saying what an interrupt does to a thread in a given situation; flagAfter, tracking the flag through reads and catches; and poolOutcome, judging executor operations. A stub scores zero, so a passing run means the model is real rather than lucky.

Java — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

Summary

  • A thread is the unit the operating system schedules, and a Java platform thread maps one-to-one onto one — a system call to create, about a megabyte of stack, a few thousand as a practical ceiling.
  • A Thread object is single-use. TERMINATED is final and start() on a spent thread throws, which is most of the argument for pools.
  • join() waits without shared state, and the timed form needs an isAlive() check afterwards to know whether it finished or timed out.
  • interrupt() sets a flag and returns. A thread that neither polls it nor blocks in an interruptible call is not stoppable.
  • isInterrupted() reads the flag; Thread.interrupted() reads and clears it — and using the static form in a loop condition silently consumes the cancellation.
  • Catching InterruptedException clears the flag, so restore it with Thread.currentThread().interrupt() unless you are actually stopping.
  • A pool reuses threads across tasks, turning a system call per task into a queue operation.
  • A Future carries the task's exception across the thread boundary, so a task whose Future is never inspected can fail completely silently.
  • shutdown() returns immediately — awaitTermination blocks, shutdownNow interrupts — and a pool never shut down keeps the JVM alive.
  • A ThreadLocal on a pooled thread outlives its task, so set it in a try and remove() it in the finally, or the next request inherits it.

You now have more than one thread. The next chapter is about what that does to the guarantees you have been relying on — the Java Memory Model, happens-before, and why a value written by one thread may never be seen by another even when nothing is obviously wrong.

Editorial reflection

The detail from this chapter I would most want a reader to keep is that Thread.stop() was removed and never replaced. It is tempting to read that as a missing feature, and it is the opposite: there is no safe way to stop a thread from outside, because stopping it anywhere means stopping it somewhere, and "somewhere" includes halfway through updating a data structure while holding a lock that then gets released. Java tried, discovered the guarantee was unprovidable, and withdrew it rather than leaving a method that mostly worked. What replaced it is a convention — check a flag, honour an exception — which is weaker in every formal sense and the only thing that can actually be implemented. I find that an unusually honest piece of API design, and a good reminder that a cooperative protocol everyone must follow is sometimes not a workaround for a missing primitive. Sometimes it is the primitive.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.