When a Program Performs Several Jobs
An application may need to wait for a network response while continuing to serve other requests, read data while updating a view or distribute a computation over several processing units. A concurrent program seems logically to be in several places during the same period. This image helps us begin, provided it does not make us imagine a single instruction order. Concurrent activities advance at times our program does not fully control. On several cores they can truly execute at the same instant; on one core they can alternate quickly enough to make progress within the same time interval.
A process is an instance of a program with its own resources managed by the operating system. A process’s threads share part of the memory space and can access the same Java objects. Passing them a reference lets them reach the same data, but does not by itself make communication correct: if one thread changes a value and another reads it, we must establish when that change can be observed. Shared memory does not require transferring every value through a message between processes, but communication still has synchronization costs and rules. Two increments of the same counter, for example, can lose an update if they are not coordinated; we will see this in the chapter’s program. Choosing between several processes and several threads also requires evaluating isolation, reliability, and distribution, alongside startup cost and the work needed to share data.
Definition – Concurrency and Parallelism. Concurrency means several activities are in progress and can advance over overlapping periods. Parallelism means at least two operations are physically executed at the same instant on distinct resources. A concurrent program can execute without parallelism; a parallel program is necessarily concurrent. This distinction prevents promising acceleration merely because we created two threads.
Threads offer advantages, but also entail costs: one response can remain ready while another operation waits; work on independent data can exploit several cores; shared resources can be reused. But every shared access can introduce race conditions, waiting, deadlocks or hard-to-reproduce results. Increasing the number of threads does not automatically make the program faster. If the bottleneck is a single serial resource, adding concurrent work can worsen total time.
From the Start Request to the End
A Thread object represents a thread of execution. A Runnable describes work to perform and returns no value. When we call start(), we request the start of a new thread that will execute run(). Directly calling run() instead executes the method in the current thread: it is an ordinary method call. This difference is the first experiment to perform, printing Thread.currentThread().getName() in both cases. In modern code it is often clearer to separate work from thread management, using a Runnable lambda rather than extending Thread, unless the subclass really represents specific behavior.
run() remains in the caller; start() requests a new thread that will execute the same work.The API exposes the states NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING and TERMINATED. NEW describes an unstarted object; TERMINATED a completed execution. BLOCKED indicates waiting to acquire a monitor; WAITING and TIMED_WAITING cover forms of waiting without or with a time limit. RUNNABLE includes both the thread executing on the processor and one ready to be chosen. Sometimes the thread using the CPU is called “running”, but RUNNING does not appear in the enumeration Thread.State: we keep the public state separate from an intuitive scheduler description.
The StartAndJoin program makes the distinction between object and activity visible. Before start, the state is NEW. A direct call to worker.run() prints executing: main, because the main thread performs the work; the state of the worker object remains NEW. After start, the Runnable prints executing: worker. main calls join and only afterwards observes TERMINATED. The program does not rely on random ordering between the two prints: the first occurs before start; the second must finish before join returns. A thread cannot be started twice with start: after the first start, attempting to restart the same object produces IllegalThreadStateException. For new work we create a new Thread or entrust the activity to an executor.
NEW -- start() --> RUNNABLE -- run() ends --> TERMINATED
|
+-- waits for monitor --> BLOCKED --> RUNNABLE
+-- waits for event ----> WAITING --> RUNNABLE
+-- waits for time -----> TIMED_WAITING --> RUNNABLE
Textual state diagram. Arrows indicate typical cases, rather than a predictable schedule. The same thread can return to RUNNABLE several times before terminating.
Thread.The method join() allows one thread to wait for another's end. If the current thread is interrupted while waiting, join() throws InterruptedException. sleep suspends the current thread for at least the requested interval under the conditions specified by the API, but does not ensure it resumes at an exact instant. yield() is a hint to the scheduler, which can ignore it. Platform-thread priorities are also hints dependent on implementation and operating system: they are not a tool for obtaining correct output order. If two threads endlessly print “high priority” and “low priority”, the result would capture only one execution and the program could consume resources without terminating; it would not demonstrate a language guarantee.
What We Can and Cannot Infer from Time
If main starts a worker and calls join(4000), waiting can end because the worker finished, because the specified time elapsed or because main was interrupted, in which case it receives an exception. After a time-limited join, we must therefore check whether the work is still alive before declaring it complete. The difference between unlimited join and join with a timeout concerns the possibility of returning before the worker finishes, rather than exact print order in one execution. A timeout can help avoid indefinite waiting, but does not automatically cancel still-active work.
sleep is useful for simulating waits in an example, but is not a coordination mechanism between threads. Writing “I wait a hundred milliseconds and therefore the data will be ready” ties correctness to a machine's speed and system load. We must instead wait for a condition with appropriate tools: join for a thread's completion, a blocking queue for an element's availability, a Future for an activity's result. The specification also clarifies that neither sleep nor yield alone introduces the synchronization relationships required to make a shared write visible.
Priority has a similar problem. setPriority accepts values in the range defined by MIN_PRIORITY, NORM_PRIORITY and MAX_PRIORITY for threads to which it applies, but its influence on scheduling depends on the platform. A test passing only if the high-priority thread prints first is wrong: it is measuring behavior the API does not guarantee. We can show reading and setting priority as a platform-thread property, without inferring a semantic precedence rule.
Shared Memory and the ++ Operation
Imagine a counter int value on which two threads execute value++ ten thousand times each. The expected arithmetic result is twenty thousand, but the ++ operation on the field is not an indivisible action. The value must be read, one added and the new value written. Both threads can read the same number before writing, losing an increment. Adding a random pause or choosing a different priority does not solve the problem: random ordering remains possible and the visibility problem between threads remains open.
In the ProtectedCounter program, increment() and read() are synchronized. Both methods use the monitor of the counter instance. Only one thread at a time can execute a section protected by that same monitor; exit and subsequent acquisition also establish a visibility relationship according to the Java Memory Model. After starting both threads, main calls join() on both and then reads the value. The verified output is 20000. Without synchronization conditions we could not use that number as proof of correctness, even if some lucky execution printed it.
A synchronized instance method uses the monitor of this. A static synchronized method instead uses the monitor of the Class object representing the class: it does not automatically lock the monitors of individual instances. A block synchronized(lock) allows a dedicated object to be chosen and limits the critical section to only statements needing it. The lock must be shared by threads protecting the same invariant. Two blocks using different objects do not exclude each other, even if both contain the word synchronized in the source.
private final Object lock = new Object();
private int value;
void increment() {
synchronized (lock) {
value++;
}
}
Here the field lock is private and final: external code cannot choose to acquire it and interfere with the internal protocol. final prevents replacing the reference, rather than automatically making objects a reference may point to immutable. The protected value remains mutable and must be read with the same discipline. If we added int read() { return value; } without synchronization or another appropriate mechanism, we would break the counter's visibility rule despite protecting writes.
When Two Locks Wait for Each Other
A deadlock can arise if thread A owns lock first and waits for second, while thread B owns second and waits for first. Neither can continue until it releases the lock the other requires. The system does not guarantee that the program automatically recognizes and resolves this situation. A simple rule is to always acquire multiple locks in the same documented order, or reduce the number of required locks. The problem is not that threads are “slow”: it is that the logical conditions for advancing have become circular.
Thread A: owns first → waits for second
Thread B: owns second → waits for first
This diagram describes a situation to avoid, rather than an example to execute in the normal path: two permanently blocked threads would make the test unhelpful. We can study it through a trace or in an isolated test with a time limit and thread diagnostics. In code review we look for pairs of sections acquiring the same locks in opposite order. Calling unknown code while holding a lock is also risky, because that code might seek another lock or take a long time.
Further reading – Three Different Properties. Mutual exclusion means two sections protected by the same lock do not execute simultaneously. Atomicity means an operation appears indivisible to other threads. Visibility means a write performed by one thread can be correctly observed by another.
volatileprovides particular visibility and ordering guarantees, but does not makevalue++atomic.synchronizedon a common monitor can offer both exclusion and necessary memory relationships. For an independent counter there are also atomic types, such asAtomicInteger; the choice depends on invariants to protect.
The word invariant indicates a condition that must remain true at observable points of the program. If a pair of fields balance and total must be updated together, protecting a single field with an atomic operation is not enough. The lock must cover the coherent change of both. This is why synchronization cannot be added blindly: first we identify which state is shared and which property it must maintain.
A Bank Account Read by Two People
A bank account shows how a race condition can be more serious than a wrong counter number. Suppose the balance is 500 euros and two withdrawal requests of 400 euros arrive almost together. Each request could read the balance, check sufficiency and decide to proceed. If both read 500 before one writes the new balance, both consider the withdrawal permitted. Depending on the form of the code, an outflow can be recorded twice while an inconsistent balance is retained, or one modification can be lost. Making subtraction alone atomic is not enough: the “sufficient balance” check and update must constitute a single operation from other threads' viewpoint.
synchronized (lock) {
if (balance < amount) {
return false;
}
balance -= amount;
return true;
}
This fragment assumes a shared private lock and an amount already validated as positive. Its central point is the critical-section boundary, rather than a complete bank-account solution: database transactions, persistence and several processes require further guarantees. If the balance is stored on a server, a Java monitor in a single process does not coordinate other application instances. A basic book must show the mechanism without extending the promise beyond the boundary in which it applies.
Definition – Critical Section. This is the part of the program accessing shared state that must respect a common rule. Exclusion has value only if all paths that modify or relevantly read that state use the same protocol. An excessively broad critical section increases waiting; an excessively narrow one leaves parts of the invariant uncovered. Its size is decided from the property to maintain, rather than the number of lines that seems convenient to enclose in brackets.
The Precise Role of volatile
A volatile field allows a write and subsequent read of the same field to participate in the happens-before relationship described by the Java Memory Model. In a simple signal, one thread reads stop until another sets it to true. The VolatileSignal program uses this form and prints stopped after the request. It does not use sleep to make the change visible: the volatile value is the communication medium. Thread.onSpinWait() is only a hint for optimizing the busy-wait loop; it does not replace the volatile declaration and is unsuitable when work could wait a long time.
volatile field, without imposing a particular cache architecture.Compare the loop with an ordinary boolean and then with volatile. The point is that the compiler and CPU can treat unsynchronized reads in ways surprising to those imagining a single immediately shared memory. However, we do not promise that the faulty version blocks in every execution or on every machine. An empirical test of one run does not define semantics: it is the memory relationship established by the specification that lets us reason about the correct version.
volatile does not protect a compound operation. With volatile int value, reading value, calculating +1 and writing remain separate steps. Two threads can read the same old value and write the same new one. If the only invariant is an independent counter, AtomicInteger offers incrementAndGet(), expressing an atomic increment. The program starts two threads, waits for their completion and prints 20000. This is a solution different from the ProtectedCounter monitor: it does not automatically make other modifications we forgot to include atomic.
Further reading –
happens-beforeIs Not the Stopwatch. The relationship indicates which memory effects must be visible and in which logical order for a correctly synchronized program. It does not merely mean that an instruction executed a few milliseconds before another.Thread.startestablishes a relationship from actions preceding the start to the new thread's actions; completion observed withjoinestablishes a relationship from the worker's actions to subsequent caller actions. This explains whymain, afterjoin, can read the final result of work performed by the terminated thread. For shared accesses while both work, a suitable protocol is still needed.
Interruption: a Cooperative Request
interrupt() signals to a thread that it should interrupt work or exit a wait. It is not equivalent to forcibly terminating the thread at an arbitrary point. If the thread is in an interruptible blocking call, such as sleep, join or BlockingQueue.take, it can receive InterruptedException. At that moment the interrupt signal is normally cleared by the call throwing the exception. If the current layer does not know how to conclude the operation and must return control, it can restore the signal with Thread.currentThread().interrupt() and terminate its loop, as the examples in this chapter do.
Consider an empty block catch (InterruptedException e) {}. An empty block can leave the thread working after another component requests shutdown. In a small demonstration program it might pass unnoticed; in a service with real resources it makes termination uncertain. If the method can declare throws InterruptedException, letting the exception propagate is often better. If it cannot, we document how the request is respected. There is no single response for every application, but accidentally ignoring the signal is not a strategy.
Note – Interruption and State.
isInterrupted()reads the signal without clearing it.Thread.interrupted()is static and queries the current thread while clearing its signal. The difference matters in loops waiting for a stop request. We do not use the two methods as synonyms; first we decide whether code must only observe interruption or also consume its indication.
Producer and Consumer, from Defect to Solution
The producer–consumer case deserves a gradual path, because every solution reveals a new requirement. A producer generates values; a consumer uses them. If the consumer reads a field before the producer inserts the next value, it may obtain unready data or reuse the previous one. A shared list without a protocol does not resolve the issue. Even with synchronized methods, a check if (list.isEmpty()) wait() is insufficient: after waking, the condition must be checked again in a while, because another consumer may have taken the element or waking may not correspond to expected availability.
wait() requires the monitor of the object on which it is called; it releases that monitor during waiting and reacquires it before continuing. notifyAll() wakes threads waiting on the same monitor, but does not immediately deliver the resource to them: they must compete for the monitor and check the condition again. An error can emerge with two consumers: we must observe that the waiting condition is not permanent permission to remove an element. A correct manual implementation requires a complete protocol for availability, closure and interruption.
The standard library provides BlockingQueue<E> for this case. A bounded queue can block put when full and take when empty. In the ProducerConsumer program, we use ArrayBlockingQueue<Integer> with capacity two. The producer inserts numbers zero through four; the consumer reads them one at a time. After the data, the producer inserts the sentinel -1, indicating the end. The sentinel is safe here because valid data is only zero through four. In a real system we must verify that a special value cannot be confused with data and that termination also works when the producer fails.
Building the Protocol by Hand to Understand It
Before using the ready queue, we construct a one-slot store and correct its synchronization step by step. In the WaitingStore program, the field product is null when the slot is empty. put waits with while (product != null) until the consumer has taken the previous value; take waits with while (product == null) until a value arrives. Both methods are synchronized on the store instance. After changing the condition, they call notifyAll() so waiting threads can check it again.
The while form is not a style preference. The specification allows wakeups without a corresponding notify, and another thread can change the condition before the awakened thread reacquires the monitor. With if, the check occurs once only and the method can continue when the store is still empty or still full. With two consumers, this defect can manifest even when a test with a single consumer seems to work. Our first manual program has only one consumer, so the reader can isolate the one-slot protocol before reasoning about competition between many consumers.
wait releases the monitor; after notification the thread reacquires the monitor and checks the condition again.public synchronized int take() throws InterruptedException {
while (product == null) {
wait();
}
int number = product;
product = null;
notifyAll();
return number;
}
The wait() call belongs to the store object and occurs while the thread owns its monitor. During waiting that monitor is released: otherwise the producer could not enter put and the condition would never change. Before wait returns, the thread reacquires the monitor; only then does it check the while again. notifyAll does not directly transfer the value to the consumer and does not immediately make the caller exit its method. It is a notification to whoever waits on the same object.
The value -1 concludes the flow, as in the BlockingQueue version. Printing values zero through four is deterministic because one producer inserts them in the expected order and one consumer takes them in the store's order. The example shows normal operation, but not yet every production case. If the producer is interrupted before inserting the sentinel, the consumer might wait forever. A robust service requires a closure procedure for errors too; a blocking queue simplifies transfer and capacity, but alone does not define the application's closure policy.
The Four Forms of Insertion and Retrieval
The BlockingQueue interface offers several ways of reacting to full and empty. add can throw an exception if there is no space; offer returns false; put waits. The timeout form of offer waits at most the declared time and returns a boolean. To read, remove can throw an exception on empty, poll returns null, and take waits; poll also has a timeout form. element and peek observe the head element without removing it, with similar differences for the empty case. The caller chooses according to the contract: “no space” can be an error, an ordinary answer or a condition to wait for.
| Operation | Immediate failure with exception | Immediate special response | Waiting | Waiting with timeout |
|---|---|---|---|---|
| Insert | add(e) |
offer(e) → false |
put(e) |
offer(e, time, unit) |
| Retrieve | remove() |
poll() → null |
take() |
poll(time, unit) |
| Observe the head | element() |
peek() → null |
not provided | not provided |
The null from poll cannot be mistaken for a valid element, because BlockingQueue does not allow insertion of null. This detail is part of the contract and makes the special response interpretable. A bounded queue such as ArrayBlockingQueue applies a defined capacity; a queue without a fixed limit can accept elements as long as resources remain available, so “unbounded” does not mean infinite memory. Capacity is a design decision: it limits pressure from producer to consumer and makes slowing observable rather than letting occupied memory grow indefinitely.
The output always contains the five consumed values in order, because a single FIFO queue feeds a single consumer, but we cannot infer from those lines exactly when the two threads obtained CPU. With several consumers, a print alternating producer and workers represents one possible interleaving, rather than the only “expected output”. With several consumers, allocation of values among thread names is nondeterministic; we instead verify properties such as “every valid value is consumed once” and “no consumer remains blocked after closure”. If there were two consumers, a single sentinel would not suffice to terminate both.
Platform Threads and Virtual Threads
So far we have seen two forms of startup: the counter creates platform threads with Thread.ofPlatform(), while the producer–consumer uses Thread.ofVirtual(). Both programs wait for completion with join. Calls to BlockingQueue.put and take maintain the same meaning; to understand why to choose one form over another, we must look at the work the thread performs and the time it spends waiting.
Further reading – Activities and Duration. If a server receives many independent requests waiting for external services, one virtual thread per request can be a readable choice. If instead we must multiply large matrices, useful work is limited by available cores and memory; creating an enormous number of threads does not produce additional cores. To choose well, we must measure the type of waiting, activity duration and quantity of shared state.
Why Another Type of Thread Was Needed
Imagine a service receiving loan requests. For each one it checks the catalog, queries a remote archive and finally prepares a response. The most natural code follows this order: call the catalog, wait for the response, call the archive, wait again and compose the result. During the two waits it is not calculating: the CPU could handle another request. With one platform thread per request, however, every wait also holds an operating-system thread for the request's entire duration. When simultaneous requests become numerous, that cost limits service capacity before the CPU is even full.
A thread pool reuses threads and saves repeated creation cost, but does not solve the constraint: if the pool contains a hundred workers and a hundred requests have stopped waiting for external responses, the next request remains queued. Another path is to split every request into callbacks or CompletableFuture stages, freeing workers while I/O is in progress. The price is that the logical path of a request is distributed among several functions and it becomes harder to follow values, errors and cancellation. Virtual threads were introduced to preserve the readable “one thread per request” model even when many requests wait simultaneously. They became a stable feature in Java 21; in this edition we use them with Java 25. The project's rationale and Java 25 guide describe this objective in terms of capacity to serve more concurrent work, rather than greater speed of an individual calculation.
To Understand – Concurrency, Throughput and Latency. Concurrency is how many requests are in progress within the same interval. Throughput is how many requests finish in a second. Latency is how long an individual request waits before its result. If every request lasts an average of 50 milliseconds and the service completes 200 requests per second, an average of about 10 requests are in progress:
200 × 0.050 = 10. To complete 2,000 per second with the same duration, about 100 simultaneous ones are needed. This is a relationship between averages, rather than a performance promise. Reducing the cost of waiting threads may allow more requests in progress; it does not shorten the database response.
What Executes Code While the Thread Waits
Even a virtual thread executes instructions on an operating-system thread: there is no additional “virtual” CPU. The JVM temporarily assigns the virtual thread to a platform thread, called a carrier. While Java code calculates, the carrier executes it. When the virtual thread encounters a blocking operation supported by the runtime, for example waiting for socket data, the JVM can suspend it and free the carrier. The carrier then executes another virtual thread. When data arrives, the suspended thread becomes runnable again and can resume on a carrier, possibly different from the previous one. The program continues after the blocking call as if it had simply waited.
Definition – Suspension and Carrier. Suspending a virtual thread means retaining the point from which it will resume, including its call chain. The carrier is the platform thread executing it at that moment. The carrier does not belong to the virtual thread forever: after a wait it can execute another.
Thread.currentThread()in request code continues to identify the virtual thread, rather than the carrier.
This distinction also explains what does not change. synchronized, volatile, exceptions, interruption and memory visibility rules remain Java thread rules. An unprotected increment can be lost even if both participants are virtual. Furthermore, not every blocking call always frees the carrier: in Java 25 native code or a foreign function can retain the association, a situation called pinning. A long wait while the thread is bound in this way can reduce service capacity. This is not a reason to automatically avoid virtual threads; it is a reason to observe the workload and actual operations. The Java 25 guide distinguishes this case from normal use of synchronized blocks.
One Request, One Virtual Thread, One Lifetime Boundary
For a small test we can create the thread directly: Thread.ofVirtual().name("loan-1").start(work) starts it and returns a Thread on which to call join(). In a service receiving many requests, Executors.newVirtualThreadPerTaskExecutor() instead creates a new virtual thread for every submitted activity. It is not a pool of virtual threads to fill and reuse. The separation is useful because the application can describe work with a Callable and then manage the result through Future, as in the VirtualExecutor program below. The resource try closes the executor after work and makes explicit when activities must have terminated.
The expected advantage emerges if many independent activities often wait for I/O. It does not emerge in the tiny calculation of 22 plus 33 in our example: that program serves to read the API, rather than measure speed. The external resource still has its own limits. If the remote catalog accepts twenty simultaneous requests, creating ten thousand virtual threads does not give it greater capacity. A Semaphore with twenty permits can limit access to the catalog, while virtual threads waiting for a permit do not unnecessarily occupy a carrier. The semaphore limits access to the scarce resource; the number of threads describes how many activities exist. These are two different decisions.
When They Are Really Useful. Choose a virtual thread for an independent activity spending a significant part of its life waiting for I/O: an HTTP request, a query or a network read are typical examples. If work continuously calculates, the limit remains the number of cores. If you share mutable state, accesses still need coordination. If you start an activity that must finish before the response or program closure, you must still await it and handle failure. None of these tasks is performed automatically by the word virtual.
Two Remaining Limits: Shared State and External Resources
The carrier mechanism explains the cost of many waits, but does not modify the Java Memory Model contract. Examples of synchronized, volatile and BlockingQueue maintain the same semantics if we move from Thread.ofPlatform() to Thread.ofVirtual(). An unprotected update remains a race condition; the program must choose the shared-state access protocol.
Even inexpensive waiting does not make the awaited resource unlimited. Every activity retains data, references and work to complete; a database or remote service may accept fewer requests than the JVM can start. If a hundred thousand activities request a connection to a database with few available connections, the problem becomes pressure on the service and queues. The semaphore of the previous example limits catalog access, while a bounded queue can prevent endless accumulation of requests. The virtual thread makes waiting economical; it does not create database capacity.
A loop continuously occupying the CPU remains limited by cores and memory. We can therefore distinguish three questions before choosing the concurrent form: does the activity mainly wait for I/O, calculate without pauses or contend for shared state? Virtual threads mainly answer the first; the second requires workload measurement and the third a correct protocol. This distinction prepares comparison with parallel streams.
Connection – Parallel Streams and Threads. A stream pipeline can be requested in parallel form, but its code must still respect shared-state rules. A mutable accumulator captured by a lambda can have a race condition just like a field updated by two
Threadobjects. Parallelizing a pipeline does not resolve operation semantics or ensure greater speed. Activities with many I/O waits and CPU data transformations have different profiles; this is why we do not automatically replace a virtual-thread executor withparallelStream().
Execution Services and Work Boundaries
Manually managing a Thread object for every operation is useful for understanding the model, but larger applications often separate description of work from execution policy. An ExecutorService accepts activities; a Future<T> can represent a result to await, distinguishing the value, an exception and a cancellation. A platform-thread pool can limit concurrency; a virtual-thread executor can offer a virtual thread to each activity. Closing the executor is part of the contract: we do not leave resources open after use.
The stable program below shows the lifetime boundary with Future and executor closure. Immediately afterwards we will examine the question this pair leaves to the caller: how to treat several child jobs as a single request. Java 25's StructuredTaskScope API tackles that problem, but is still preview; its status is clarified in the dedicated further reading.
The VirtualExecutor program uses Executors.newVirtualThreadPerTaskExecutor() and passes two Callable<Integer> objects through submit. The Callable type, unlike Runnable, produces a result and can throw an exception. Every call returns a Future<Integer>, on which get() waits for completion and returns the value or reports failure. The two jobs calculate 22 and 33; the program prints 55. The executor is closed by the resource try construct, which makes the duration of the execution service visible. The example shows no performance benefit, because both calculations are tiny; it shows how to represent activities with a result and an explicit lifetime boundary.
If an activity fails, Future.get() can throw ExecutionException, whose cause is the exception produced by the work. If the waiting caller is interrupted, it receives InterruptedException. These answers differ from “the result is zero” and should not be erased with an empty catch. Future.cancel also requires a choice: it can request work interruption, but work must cooperate and cancellation does not retroactively undo effects already occurring. A serious executor exercise must therefore include the error and closure path as well as the happy result.
A Look at Child Activities in Java 25
An application request may need two independent responses before constructing a page: for example a book profile and stock availability. With two Future objects we know how to start jobs and await their values, but must still jointly define what to do if one fails or the caller is interrupted. The problem is more than “execute in parallel”: it is aligning child jobs' duration with the duration of the request that created them. If the request is no longer needed, leaving a job running and occupying resources can be wasteful or erroneous.
Structured concurrency tackles this relationship between parent and child activities. In Java 25 StructuredTaskScope is still a preview API; details and signatures can change. This is why we do not use it in main programs or present it as a prerequisite for solving producer–consumer. Knowing the idea is nevertheless useful: a lexical scope gathers child jobs and ensures that the caller manages their conclusion and failure before leaving the scope. An executable example based on this API would require --enable-preview at both javac and java, as well as the other version options required by compilation.
Why Status Matters. A preview API belongs to a released JDK, but is offered to gather experience before a final form. An incubator API, such as the Vector API of C23, lives in an experimental module. The reader can try both, but a library intended for many environments should not hide these dependencies behind an example that looks stable. In C20's main body we use only stable JDK 25 APIs.
ScopedValue is instead final in Java 25. It describes a value associated with an execution scope, useful for passing immutable context information along a call chain without adding the same parameter to every method. It is not a global variable to modify freely: its value is bound to a scope and follows precise visibility rules. If a request carries a log identifier, a scoped value may be more suitable than a static field shared by all requests. However, it does not coordinate a counter increment or product availability; those problems concern shared modifications and waiting.
The RequestContext.java program makes binding duration observable. REQUEST_ID is the key shared by code, rather than the request's value: ScopedValue.newInstance() initially creates it without a bound value. where(...).run(...) associates P-17 only during that call. prepareResponse invokes another method reading the value with get() without receiving it as a parameter. After run returns, isBound() returns to false. Using get() outside the scope would throw NoSuchElementException; the example instead checks isBound() to show the boundary without turning the error into a normal result.
public class RequestContext {
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
private static void record(String action) {
System.out.println(REQUEST_ID.get() + ": " + action);
}
private static void prepareResponse() {
record("catalog consulted");
}
public static void main(String[] args) {
System.out.println("Before: " + REQUEST_ID.isBound());
ScopedValue.where(REQUEST_ID, "P-17").run(RequestContext::prepareResponse);
System.out.println("After: " + REQUEST_ID.isBound());
}
}
Compile with javac --release 25 -Xlint:all -d build RequestContext.java and run java -cp build RequestContext: the output is Before: false, P-17: catalog consulted, After: false. Change P-17 to P-18 and predict which line varies. Then ask why replacing the value with a mutable counter would require further rules: ScopedValue transmits context, but does not protect modifications to a shared object. The program remains on Java 25's stable path alone; transmission to child threads created with StructuredTaskScope would instead require the preview API and is left to the previous further reading.
These two tools illuminate an important distinction. A request's context can be transmitted to child functions without being concurrently updated; queue or balance state is instead modified by several activities and requires a protocol. If we confuse the two cases, we risk using a lock for every context datum or a contextual value where a transaction is needed. New language and library features do not erase fundamental questions: who owns data, who can change it and when the operation has concluded.
Naming Work and Understanding Who Keeps the JVM Alive
Thread names do not determine their behavior, but help read a log or trace. In StartAndJoin, the name worker makes the transition from directly calling run() to the actually started thread visible. In a server it is best to use names describing the role or activity type, rather than contextless numbers. The name can be duplicated and can change: it is a diagnostic aid, rather than a global identity identifier. To know which thread executes a line, we can call Thread.currentThread(); printing this reference in an exercise is more useful than assuming which thread a call comes from.
A platform thread can be daemon or non-daemon. The JVM can terminate when no non-daemon threads remain, without waiting for daemons to finish their work. This is why a daemon is suitable for certain support activities that must not keep the process alive; it is not the right place to save the last essential datum at closure without an explicit protocol. setDaemon(true) must be called before start() on the thread to which it applies. A virtual thread is always daemon according to the API contract: this makes explicitly awaiting activities that matter even more important, for example through join, Future.get() or executor closure. The end of method main must not substitute for managing activity duration.
Further reading – Thread Duration and Operation Duration. A thread can exist longer than the object that created it if nobody waits for or stops it. An application operation, on the other hand, has a moment when the caller must be able to say whether it is complete, failed or still in progress. Linking both durations is the program's responsibility. The
tryform with anExecutorServiceis one way of making that boundary visible; creating anonymous threads without retaining references can make it opaque.
The distinction between Runnable and a subclass of Thread deserves a broader explanation than a style preference. Runnable describes a task through method run(). The same instance of Runnable can be passed to a Thread, an executor or invoked directly in a test, albeit with different execution semantics. A subclass of Thread combines task and execution policy in a single object; it can be useful in particular cases, but also occupies the only class from which Java permits inheritance. A lambda is often the shortest form of a Runnable, provided its body remains readable and variable capture does not hide dangerous shared state.
Runnable work = () -> System.out.println(Thread.currentThread().getName());
Thread thread = new Thread(work, "reader");
thread.start();
thread.join();
The fragment presupposes a method declaring or handling InterruptedException for join. The Runnable does not automatically receive the name reader: the name belongs to the Thread executing it. If we directly called work.run(), the name would be the caller's. This is the same distinction observed in the complete program, now expressed in the form we will use more often.
Thread offers several constructors receiving a Runnable. All create a platform thread not yet started: after construction we must call start(). The parameter target indicates work; the name helps diagnosis, while group and stack size answer more specific needs.
| Constructor | What it adds |
|---|---|
Thread(Runnable target) |
Work with a platform-generated name. |
Thread(Runnable target, String name) |
A name chosen by the program. |
Thread(ThreadGroup group, Runnable target) |
An explicit thread group. |
Thread(ThreadGroup group, Runnable target, String name) |
Explicit group and name. |
Thread(ThreadGroup group, Runnable target, String name, long stackSize) |
Also an approximate request for stack size. |
The group is unnecessary for this chapter's small examples: it mainly serves when an existing application organizes platform threads in groups. The size stackSize is expressed in bytes, but JVM and operating system can interpret it differently or not apply it; it must not be used as a guarantee of a particular recursion depth. In Java 25 we can also use the builders Thread.ofPlatform() and Thread.ofVirtual(): they make thread type explicit and permit composing some options before startup. Constructor choice does not change Runnable.run() semantics or make shared-data access safe.
Synchronization Is an Agreement Between All Participants
To understand why the protected counter works, we follow one possible interleaving. The first thread enters increment, acquires the counter monitor, reads 10, calculates 11 and writes it. The second thread can be ready at the same moment, but cannot enter that same protected method on the same object before the first releases the monitor. When it enters, it reads the new value under the lock's memory guarantees. We do not need to guess which thread is first: both allowed sequences produce two increments. Correctness comes from enclosing every increment in the same protocol.
Suppose instead another class receives the counter reference and directly modifies a public value field. Even if increment is synchronized, that write does not participate in the protocol and can interfere. Field encapsulation is therefore part of the solution: making value private prevents external code from accidentally bypassing the lock. A read method protected by the same monitor makes the rule easy to apply. When shared state is distributed among several objects, we must decide which component owns the invariant; scattering synchronized across different objects does not magically create a single boundary.
Using a synchronized block also requires choosing the lock without exposing it. synchronized(this) can be correct when the whole object has a known protocol, but allows a caller holding the reference to acquire the same monitor and hold the object. A private lock makes the internal protocol less subject to external interference. The choice depends on the class's contract: a component explicitly wanting to let callers synchronize on it has different needs. In most application classes a private lock reduces surprises.
The monitor is reentrant: a thread owning it can enter a synchronized method on the same object again, for example when increment() calls another synchronized method of the same instance. This must not be confused with absence of deadlock: reentrancy concerns the same thread and same monitor. The previously described deadlock involves several threads and resources, with a cycle of waiting. A class can therefore be correct with respect to reentrancy and still dangerous if it acquires different locks in different orders.
Reading note –
synchronizedand Output. If two threads execute synchronized methods of the same object and each prints a line, the lock can serialize those sections, but does not decide which thread enters first. Output A then B and output B then A can both be correct. If order is a problem requirement, it must be encoded with a condition or passing channel, rather than hoping the lock always favors the same worker.
What Really Happens When a Thread Waits
The method Object.wait() belongs to the object used as monitor and requires the thread to own it. The call places the thread in the object's wait set, releases the monitor and suspends advancement until an event specified by the specification wakes it. After waking, the thread must reacquire the monitor before returning from the call. This is why a producer calling notifyAll() while still holding the monitor does not immediately hand control to the consumer: first it must exit the synchronized section.
notify() selects a waiting thread without promising which; notifyAll() makes all waiting threads candidates to proceed, one at a time for monitor acquisition. In the one-slot store, producer and consumer wait for opposite conditions on the same object. If notify() were used in a variant with several producers and consumers, it might wake a thread finding its condition still false, leaving stopped the one that could advance. notifyAll() does not eliminate the need for while, but avoids basing the protocol on an unguaranteed choice of awakened thread.
The timed variant wait(millis) does not turn waiting into an exact appointment. It can return because of notification, interruption, spurious wakeup or elapsed time. Code must check the condition again and, if there is an overall deadline, recalculate remaining time. Repeating wait(1000) in a loop without updating time could wait far longer than the limit imagined by the caller. For many applications the abstractions of java.util.concurrent, such as queues and locks with conditions, reduce the risk of getting these details wrong; understanding wait nevertheless remains useful for reading existing code and the semantics on which various tools are based.
Further reading – Interruption During
wait. If a thread is interrupted while waiting,waitthrowsInterruptedExceptionafter reacquiring the monitor according to specification rules. The calling method must decide whether to propagate the exception, restore the signal and terminate, or explicitly handle closure. Catching the exception and immediately repeating thewhilewithout a policy can turn a stop request into an infinite wait.
A Queue Is Also a Decision on Load
So far we have seen BlockingQueue as a remedy for reading an element not yet produced. There is a second problem: how much work do we allow the producer to accumulate? If the producer generates a hundred elements per second and the consumer uses fifty, a queue growing without a defined limit accumulates the delay. At first everything seems to work; after an hour occupied memory and time needed to catch up can become very large. A bounded queue makes the speed difference visible: when full, put suspends the producer until the consumer frees space. This mechanism is often called backpressure, meaning pressure back from the consuming side toward the producing one.
The number two in the example is chosen to make blocking easy to see, rather than a capacity to use in every application. Choosing a real capacity requires knowing element sizes, production frequency, consumption times and acceptable latency. A very small queue can unnecessarily restrain a producer working in short bursts; a very large one can hide a chronically late consumer. Capacity does not replace limits across the whole system: more queues, requests and instances can add memory even when each queue is formally bounded.
A LinkedBlockingQueue can be constructed with explicit capacity; without an application capacity chosen by the designer, it has a very large technical limit. An ArrayBlockingQueue has capacity fixed at construction. These differences matter more than preference for a class name: the full/empty contract must be clear to callers. Choosing between offer and put also reflects a policy. With immediate offer, the producer can reject work when the queue is full; with put, it waits; with timed offer, it grants a window and then returns false. Which of the three is correct depends on the promise made to the user or component submitting work.
Concrete case – A Web Request. A service receiving an order should not respond “accepted” if it has only attempted
offerand receivedfalse. It must recognize and communicate rejection, retry under a declared policy or use reliable delivery different from process memory. This chapter's queue is volatile: if the process terminates, its contents do not automatically become persistent. Good use of concurrency begins with the meaning of responses, rather than the number of threads.
When two consumers share a queue, every retrieved element goes to only one. The queue does not duplicate the message. If both must see every element, a different publication model is needed, with a copy or channel for each. Two consumers of the same queue are therefore workers sharing the load, rather than observers both receiving the same flow. Their print order changes between executions, but the useful requirement is that no element is lost or consumed twice in that model.
The end of work is a protocol message. The sentinel -1 in the example does not belong to normal data, so communicates “no more numbers will arrive”. With two consumers and one sentinel, one can terminate and the other remain blocked in take(). We must insert two sentinels, one per consumer, or design another closure mechanism. If consumer count varies during execution, sentinels require particular attention: we must know who is active at closure time. A higher abstraction can simplify this contract, but cannot decide for the application what “end” means.
Interruption is another possible closure event. A consumer blocked in take() can be interrupted; the method throws InterruptedException. If the loop catches the exception and continues without judgment, the stop request is not respected. If it exits, the program must decide what to do with elements already taken or remaining in the queue. In a teaching test, printing values is enough; in an order system, confirmation, repetition and possible loss rules are needed. The chapter retains the simple model to teach synchronization, but makes the solution's limit explicit.
From Random Printing to Correct Verification
Concurrency invites us to run the program once and draw conclusions from observed order. Many lines produced by threads with different priorities or several consumers show only one execution: they do not describe every possible interleaving. A concurrency test must formulate properties remaining true under different interleavings. For the protected counter the property is “after both threads have terminated, the value is twenty thousand”. For the queue with one producer and consumer it is “values zero through four are received in insertion order and the consumer terminates”. For two consumers it is “every value is received once in the total”, without constraining the name of the worker receiving it.
Absence of an error in a hundred executions does not demonstrate absence of a race condition. A faulty program can always print the expected value on our machine and fail only under a different load. Conversely, differently ordered output is not necessarily an error: it can be one sequence permitted by the contract. Separating program properties and scheduling details is a fundamental step for testing concurrency without inventing guarantees.
A useful way to read a concurrent program is tracing events that must be ordered. In the counter: starting both workers, each increment under the monitor, their completion, return of both join calls, reading the result. The first and second worker's increments have no predetermined global order, but are serialized when entering the same synchronized method. The final read occurs after both completions. If we remove join, reading could happen before all increments have been attempted, even if every increment were atomic. These are two different errors: failing to await completion and failing to protect updates.
For the manual store, the trace starts from “empty”. take arriving first waits and releases the monitor. put(0) enters, finds the slot free, writes zero and notifies. The consumer becomes a candidate again, reacquires the monitor, checks product != null again, takes zero, empties the slot and notifies. If the producer arrives first, it inserts zero; the consumer finds it without needing to wait. Both histories respect the same contract. Explanation of the protocol must work for both, otherwise it depends on favorable order rather than code.
Reading method – Three Columns. To analyze a difficult case, draw a column for each thread and one for shared state. Write reads, writes, lock acquisitions and waits. There is no need to list every local instruction: events that can affect the other thread matter. This diagram makes visible the double read of the old balance, the
whileprotecting the store and the waiting cycle of a deadlock.
Automatic verification can repeat experiments, but must have a time limit to avoid remaining blocked on a termination error. For an example using interruption or queues, a nonterminating test is already a significant defect. Instead of sleeping an arbitrary time and hoping everything has finished, the test awaits activities with join or Future.get() and checks the final property. If it must avoid infinite waits, it uses a timeout variant and then explicitly handles the still-running activity. Even in a test, a timeout is not implicit cancellation.
Workshop: Reconstructing Examples Without Hiding Steps
Programs used in preceding paragraphs are small because every test isolates a property. Placing them side by side shows which lines coordinate and which belong to application work. Complete files are linked to their respective paragraphs; here we follow a progressive reading. The reader can compile every source with javac --release 25 -Xlint:all and compare observed lines with expected properties. None requires preview options.
First Step: Start and Await
The program StartAndJoin calls the same Runnable in two ways. In the first case execution remains in thread main. In the second, start creates a new activity and join allows the completed state to be observed. The line printing after direct run: NEW is decisive: the Thread object is still new, although method run has executed as an ordinary call. When rereading code, it is helpful to mark the point at which concurrent activity truly begins.
public class StartAndJoin {
public static void main(String[] args) throws InterruptedException {
Runnable work = () -> System.out.println(
"executing: " + Thread.currentThread().getName());
Thread worker = new Thread(work, "worker");
System.out.println("before: " + worker.getState());
worker.run();
System.out.println("after direct run: " + worker.getState());
worker.start();
worker.join();
System.out.println("after join: " + worker.getState());
}
}
The printed sequence is NEW, main, NEW again, worker, TERMINATED, with the program's labels. This is not a favorable scheduler coincidence: the first call precedes start, and the last print follows join. If we remove join, the program may observe RUNNABLE or TERMINATED depending on the moment, and cannot declare work complete merely because it called start.
Second Step: Protect an Update
In the following counter, both threads' work is identical. The method increment is short, but is precisely the point at which a read, calculation and write must appear as a section not interfered with by the other thread. Even the final read goes through synchronized method read. The program waits for both threads' completion before requesting the value. If we changed the iteration count to 100, the expected result would become 200: reasoning does not depend on the particular number ten thousand.
public class ProtectedCounter {
private int value;
public synchronized void increment() {
value++;
}
public synchronized int read() {
return value;
}
public static void main(String[] args) throws InterruptedException {
ProtectedCounter counter = new ProtectedCounter();
Runnable work = () -> {
for (int i = 0; i < 10_000; i++) {
counter.increment();
}
};
Thread first = Thread.ofPlatform().start(work);
Thread second = Thread.ofPlatform().start(work);
first.join();
second.join();
System.out.println(counter.read());
}
}
A useful experiment is removing synchronized only from increment in a copy of the file. Code still compiles and may even print 20000; this does not demonstrate correctness. The reader must identify the point at which two interleavings can read the same old value. After restoring the protected version, the worker count can be changed, retaining a reference to each and calling join on all before reading.
Third Step: Separate Visibility and Increment
The stop signal uses volatile because a write of true must be observed by the worker. The program does not increment a counter in the same field and does not claim that the keyword makes several instructions indivisible. onSpinWait() is included to show that the loop is busy waiting; the program terminates it immediately. For real waiting of uncertain duration, a blocking mechanism or an explicitly handled interruption request would be more appropriate.
public class VolatileSignal {
private volatile boolean stop;
public static void main(String[] args) throws InterruptedException {
VolatileSignal signal = new VolatileSignal();
Thread worker = Thread.ofPlatform().start(() -> {
while (!signal.stop) {
Thread.onSpinWait();
}
System.out.println("stopped");
});
signal.stop = true;
worker.join();
}
}
Here main might write true before the new thread actually begins executing the loop. That case is also correct: the worker reads the value and exits. The test does not require both threads to alternate in a special order. If both volatile and every other form of synchronization on the field were removed, the program would lack the visibility contract on which this explanation rests; this is not an experiment to launch without a time limit because it might not terminate.
Fourth Step: a Store with One Slot
The manual store brings together what we learned about monitor, condition and wakeup. The field product is the only available slot. null means “empty”, while every inserted integer, including sentinel -1, means “full”. One method cannot read the value when the slot is empty and the other cannot overwrite it when full. The while guards both invariants even after an unhelpful wakeup. To observe the event queue, we can compile the file and verify that the five retrieved lines contain zero, one, two, three and four, in order.
public class WaitingStore {
private Integer product;
public synchronized void put(int number) throws InterruptedException {
while (product != null) {
wait();
}
product = number;
notifyAll();
}
public synchronized int take() throws InterruptedException {
while (product == null) {
wait();
}
int number = product;
product = null;
notifyAll();
return number;
}
public static void main(String[] args) throws InterruptedException {
WaitingStore store = new WaitingStore();
Thread producer = Thread.ofPlatform().start(() -> {
try {
for (int number = 0; number < 5; number++) {
store.put(number);
}
store.put(-1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread consumer = Thread.ofPlatform().start(() -> {
try {
while (true) {
int number = store.take();
if (number == -1) {
break;
}
System.out.println("retrieved: " + number);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producer.join();
consumer.join();
}
}
The path goes from an unsynchronized store to one with waiting, then to several consumers. Progression remains useful if every step makes the missing invariant visible. With one consumer, the correct form avoids premature reads. With two consumers, the reader must recognize that both can be awakened but only one will find a value: whoever arrives second returns to waiting thanks to while. A version with several consumers also requires a closure protocol for all; exactly copying the main above and adding a second consumer without changing the sentinel would produce a program that may not terminate.
Fifth Step: Use the Library Abstraction
The manual store teaches the mechanism, but every new requirement needs more protocol code. A BlockingQueue already declares waiting operations and capacity. The following program replaces the optional field and both synchronized methods with a bounded queue. Application work does not change: produce five numbers, consume them in order and terminate at the sentinel. We can therefore compare the number of decisions the programmer must make in the two forms, without claiming that a queue automatically resolves every closure problem.
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class ProducerConsumer {
private static final int END = -1;
public static void main(String[] args) throws InterruptedException {
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(2);
Thread producer = Thread.ofVirtual().name("producer").start(() -> {
try {
for (int number = 0; number < 5; number++) {
queue.put(number);
}
queue.put(END);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread consumer = Thread.ofVirtual().name("consumer").start(() -> {
try {
while (true) {
int number = queue.take();
if (number == END) {
break;
}
System.out.println("consumed: " + number);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producer.join();
consumer.join();
}
}
Both threads here are virtual, but the queue applies the same full/empty conditions as with platform threads. main awaits both. The print order of the sole consumer is the flow's ordered contents; if we added producer prints, the two line series could intertwine differently. This difference between value order and print-event order must be preserved when explaining concurrent examples.
Sixth Step: a Self-Contained Atomic Increment
When shared state is a single number, AtomicInteger offers already-defined atomic operations. The following program does not use volatile int: the operation incrementAndGet includes the update atomically. get reads the final value after both workers complete. Output similarity with ProtectedCounter does not mean both classes are interchangeable in every situation. If we had to maintain several coherent fields, an isolated atomic counter would not protect the entire invariant.
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounter {
public static void main(String[] args) throws InterruptedException {
AtomicInteger value = new AtomicInteger();
Runnable work = () -> {
for (int i = 0; i < 10_000; i++) {
value.incrementAndGet();
}
};
Thread first = Thread.ofPlatform().start(work);
Thread second = Thread.ofPlatform().start(work);
first.join();
second.join();
System.out.println(value.get());
}
}
The name incrementAndGet also says which value it would return to the caller: the new value after incrementing. getAndIncrement, on the other hand, returns the previous value. Both update the number atomically, but the difference matters when the value is used to assign an identifier or position. In the program we do not use the return value because we only want to count; the final value is read once, after the join calls. If we printed at every increment, the order of twenty thousand lines would have no useful meaning and printing itself would greatly change the experiment's timing profile.
Seventh Step: Activities with a Result
The virtual executor separates creating jobs from directly managing thread references. Both lambdas passed to submit are Callable<Integer> because they produce numbers. The Future objects make explicit the moment at which main wants results. get() can wait; when both calls have completed, the sum is deterministic. The resource try closes the executor at the block's end even if an operation exits with an exception handled by the caller under the API contract.
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class VirtualExecutor {
public static void main(String[] args)
throws InterruptedException, ExecutionException {
try (ExecutorService executor =
Executors.newVirtualThreadPerTaskExecutor()) {
Future<Integer> first = executor.submit(() -> 20 + 2);
Future<Integer> second = executor.submit(() -> 30 + 3);
System.out.println(first.get() + second.get());
}
}
}
In this example the first get could finish before the second, after it or find it already completed. The printed sum remains 55 because neither calculation shares mutable state and main uses results only after obtaining them. If we replaced calculations with two network calls, we would need to decide what to do when one fails: await the other, cancel it, show a partial result or propagate the error. The Future represents the individual job's state, but overall policy belongs to the calling operation.
A Short Guide to Thread Methods
Let us gather the methods of java.lang.Thread we encountered in a table. Some are easy to interpret because they seem to describe an action, but their contract requires clarification. start() schedules the startup of a new thread; it does not wait for run() to finish. run() is the work body, but directly calling it remains in the calling thread. currentThread() returns the thread executing at that point, rather than necessarily the one whose reference we previously obtained. join() awaits termination of the thread on which it is invoked, while the timeout variant can also return while the thread is still alive.
interrupt() signals an interruption request. isInterrupted() reads the signal on a Thread object without clearing it; interrupted() is a static method operating on the current thread and clearing the state. sleep() suspends the current thread and can be interrupted; it does not release monitors the thread owns. yield() offers a scheduler hint. getState() provides one state observable at call time: immediately afterwards it may already have changed. A measurement repeatedly querying getState() does not replace a coordination protocol.
setName and getName concern diagnostics; setPriority and getPriority concern a property whose scheduling effectiveness depends on platform and thread type. setDaemon must be decided before startup for platform threads; isDaemon allows observing it. The method isAlive() answers whether the thread has started and not yet terminated, but a true response does not mean it is on a core at that instant. All these questions concern the life or description of the thread; none makes an update to a shared object atomic.
| Method or group | Useful question | Common error |
|---|---|---|
start, run |
Is a new thread born or am I executing a method here? | Calling run believing it creates concurrency |
join, isAlive |
Has the work terminated? | Interpreting a timeout as completion |
interrupt, isInterrupted, interrupted |
How is closure requested and observed? | Swallowing interruption or clearing it unknowingly |
sleep, yield |
Does the current thread suspend or voluntarily yield? | Using them as synchronization tools |
getState |
Which state is observable now? | Assuming state remains the same after reading |
getName, setName |
How is the role recognized in logs? | Using the name as guaranteed global identity |
getPriority, setPriority |
Which priority is configured? | Demanding fixed execution order |
isDaemon, setDaemon |
Does the thread keep the JVM alive? | Leaving essential work to an unawaited daemon |
This table is an index for reading tests, rather than a replacement for API signatures. For example, join() can throw InterruptedException; setDaemon and setPriority have their own constraints. When behavior is necessary for correctness, documentation for the JDK version used is consulted. The overview links every method to the practical decision the reader must make.
A Controlled Experiment on Priorities
Two finite jobs can be started, each with a local count and only one final print, assigning different priorities to platform threads. If we repeat the test, which final line appears first can change. The result does not allow a law such as “high priority always finishes first” to be inferred. The operating system, JVM and machine load participate in scheduling; the specification does not transform priority into functional precedence between results. To make order deterministic, the second job can be made to wait with explicit coordination, but then coordination provides the guarantee, rather than priority.
Continuous printing in an endless loop would visually amplify differences observed on one machine, but make the program difficult to stop and mix console cost with thread scheduling. We limit iterations and declare that arrival order is not a test assertion. Critical observation of the scheduler teaches that when we define no relationship between activities, we cannot use the interleaving of their prints as part of the program's contract.
Choosing the Model from the Need
Let us revisit three different requests. If we must count independent accesses, an AtomicInteger can be sufficient. If we must change an account's balance and history together, we need a critical section protecting the whole invariant, or a transaction at the layer owning data. If we must transfer work from a producer to a consumer, a BlockingQueue expresses passing and availability. Using the same tool for all three cases makes the program less clear and may leave it incorrect.
The choice between platform and virtual threads comes after identifying the nature of work. For a few long CPU-intensive activities, a controlled worker count can be reasonable. For many I/O-waiting activities, virtual threads offer a simple style. In both cases shared data retains the same rules. Thread count cannot be the first optimization parameter if we have not yet established what “correct result” means and when the operation must terminate.
To Verify Understanding. Take the store program and describe two executions: one in which the consumer enters first, and one in which the producer enters first. In both, printing the five values must be justifiable. Then imagine a second consumer and explain why
whileremains indispensable and a second sentinel is necessary. Finally choose whether a counter of completed orders requires only an atomic number or a modification coordinated with order state. The answer must cite the invariant to maintain.
Errors That Ordered Printing Can Hide
A program with a race condition can produce orderly and reassuring lines. In the account case, for example, both withdrawals may appear one after another on the console although both read the balance before printing. The console is a resource with its own synchronization rules; the fact that two messages do not overlap character by character does not demonstrate coordinated domain operations. To evaluate the program, we must identify balance reads and writes, the lock protecting them and the point at which the outcome is decided.
Another common error is using an ordinary collection from several threads because every thread “touches different elements”. If threads modify the collection structure, for example adding to an ArrayList, they can interfere over size, internal array and indices even when added objects are distinct. The correct choice may be a concurrent collection, a lock around modification or collection of local results to combine after join. The point is understanding what is shared: logical data appearing different is not enough if the physical container is the same.
The opposite case is sharing only immutable objects. If a thread constructs a record with final data, hands it over through a BlockingQueue and another reads it, the consumer does not need to coordinate internal modifications that do not exist. It must still receive the object through a channel with necessary publication guarantees. The queue offers this passing under its contract. Immutability reduces the number of invariants to protect, but does not eliminate the need to define how and when the reference passes between threads.
Further reading – Safe Publication. An object can be constructed by one thread and made visible to another. Java Memory Model rules establish when the second thread correctly sees effects of construction and previous writes. A concurrent queue, a correctly used monitor or completion observed with
joinprovides useful relationships. Passing the reference through an ordinary field written and read without a protocol can be a data race. It is not solved because the object “was created first” according to the wall clock.
From the Infinite Loop to Explicit Closure
Many introductory examples use while (true) because it makes repeated work evident. In a real service we must answer another question: who decides when to stop? A thread reading from a queue can receive a sentinel; one waiting for I/O can be interrupted; an executor can be closed and await already accepted jobs. The choice must be compatible with resources in use. If the thread owns a stream, connection or file, termination must also lead to closing that resource.
The example's sentinel has a convenient property: it travels in the same channel as data. It arrives after five numbers inserted by the sole producer, so the consumer cannot encounter it before earlier values. With several producers, however, one could insert the sentinel while another still has data to produce. A coordinator knowing when all producers finished or a richer protocol is needed. Once again, the put/take mechanism is correct, but the meaning of the final element depends on work organization.
Interruption is a form of request not traveling in the queue. It can awaken a thread from take, but does not itself describe whether remaining data should be completed, returned to another worker or discarded. One strategy can be “terminate as soon as possible”; another “complete the already taken element and then terminate”. Both may be legitimate, but must be explicit. This is why catch (InterruptedException e) {} is more than a bad stylistic habit: it removes information the system uses to manage activity duration.
In example code, catch restores the signal and lets the lambda terminate. This form suits the normal test, where no thread is interrupted before the sentinel. If we wanted to test cancellation halfway through production, we would need to add a path awakening and terminating the consumer too. A test interrupting the producer and then waiting indefinitely for the consumer would reveal a closure-protocol defect, rather than a queue defect. Keeping data transfer and termination policy separate makes this defect easier to see.
The Hidden Cost of Busy Waiting
In the volatile signal, the worker executes a loop repeatedly checking the field stop. This is busy waiting: while awaiting, the thread continues to occupy processing time. Thread.onSpinWait() can help the platform optimize a very short spin loop, but does not turn waiting into costless blocking. For a request that may arrive after seconds or minutes, blocking waiting or a notification mechanism should be chosen. The example remains useful because it isolates visibility without confusing it with other tools.
Adding Thread.sleep(1) to the loop would reduce check count, but introduce arbitrary latency and would not make a non-volatile version correct. The specification clarifies that sleep is not a synchronization barrier. The thread might still lack a relationship obliging it to observe the other's write. If the requirement is “wake when an event arrives”, the appropriate form is a coordination primitive, rather than timed polling chosen by trial and error.
This example separates two efficiency types. A blocked thread does not continuously consume CPU asking whether the condition changed; a spinning thread can react with little latency if waiting really is very brief and the system has resources available. The choice requires measurements and workload knowledge. In a basic manual there is no need to turn a micro-optimization exception into general advice: first write a correct protocol, then measure whether waiting cost is a real problem.
Testing an Explanation of Concurrency
A good explanation must answer four questions. Which state is shared? Which threads read or write it? Which rule must remain true? Which event authorizes each thread to continue? In the store, state is product; producer and consumer modify it under the same monitor; the slot cannot simultaneously be empty and full; the while loops wait for the necessary condition. In the counter, state is value; both workers increment it; every increment must count once; the monitor serializes the critical section and join allows the final total to be read.
If one answer is missing, a test that “works on my computer” does not fill the gap. The same applies to a book's code. An example printing two lines in a particular order must explain which instruction or guarantee imposes that order. If none exists, the caption must call it possible output, rather than necessary output. This discipline is especially important when the text teaches diagnosis: the reader must learn to distinguish repeatable proof from a snapshot of one execution.
A useful self-check technique is deliberately changing scheduling without changing the contract: adding local work to one thread, reversing start order, using virtual rather than platform threads where the program permits. The final property must remain true if synchronization is correct. This is not a formal test of every interleaving, but forces a precise assertion to be formulated. If the program passes only when one thread “arrives first”, the solution depends on chance and must be reconsidered.
Connecting the Chapter to the Rest of the Book
Threads execute methods of ordinary classes: encapsulation, generics, exceptions and collection contracts still apply. A BlockingQueue<Integer> uses generics to declare the element type; InterruptedException imposes a handling decision on the caller; the Runnable lambda captures references under the rules seen in functional programming. Concurrency is not a separate world in which earlier rules disappear. It adds a dimension: several execution paths can observe and modify the same state.
This connection also explains the preference for objects with clear responsibilities. If class WaitingStore owns the value and waiting protocol, its users need not know when to call wait or which monitor to synchronize on. If the protocol were distributed between producer and consumer, each would need to know the other's internal details and a change could break the agreement. A well-chosen class boundary helps concurrent correctness as well as readability.
Finally, moving to the chapter on classloaders reminds us that the JVM manages both activities and type definitions during execution. A thread can request a class for the first time while another works; loading mechanisms have their own coordination guarantees. We do not need to implement them in our counter examples, but must avoid a wrong conclusion: the presence of several threads does not leave the JVM without rules. Our task is to respect contracts defined by APIs and language, rather than assume an order nobody promised.
A Final Trace We Should Be Able to Tell
To check understanding of the chapter, imagine that main creates a queue with two slots, starts a virtual producer and consumer and awaits both. The producer inserts 0 and 1; at that point the queue might be full or the consumer might already have taken a value. If full, put(2) waits. When the consumer executes take(), it frees a slot and lets the producer advance. None of these possibilities changes the value order received by the single consumer from the queue. After 4 comes the sentinel, making the consumer exit. Both join calls ensure that main does not declare the demonstration complete before the workers.
The same trace, read with the manual store, requires citing the monitor and both while loops. If the consumer arrives first, wait releases the monitor; the producer can insert 0; notifyAll allows the consumer to become a candidate again; the consumer reacquires the monitor and checks the condition. If the producer arrives first, the consumer finds the value without waiting. An explanation working only for the first case has not yet described the protocol: it has described only one possible event sequence.
Finally, if someone proposes replacing everything with Thread.sleep(100) after start, we ask which contract guarantees that a hundred milliseconds is enough, that the value is visible and the operation complete. There is no answer based solely on sleep. The chapter's path leads precisely here: define the property, choose the tool guaranteeing it, verify the outcome without turning the scheduler into an oracle.
Check question – Why Is a Successful Test Not Enough? A program with unprotected
value++can print the expected total even many times in a row. That result means only that observed interleavings did not make the loss visible, or the defect did not emerge under those conditions. The language contract does not promise atomicity of the expression. Proof of correctness passes through the protocol used by every thread: a common monitor or appropriate atomic operation. Once established, tests verify that implementation and duration management respect the protocol. Both forms of evidence complement each other: a theoretical explanation without execution can hide a code error, while successful execution without a model can hide a race condition.
In Chapter 20A on atomic operations, we start from the counter of these pages and change the question: what happens when incrementing or decrementing must also decide whether a request can be accepted? The case of the last available copies leads to compareAndSet; a frequently updated statistic instead leads to considering LongAdder. The criterion remains the one built here: first identify the invariant, then choose the boundary protecting it.
A second check concerns the meaning of “terminated”. A thread may have completed its run, but the program may still need to read a result, close a resource or communicate an error to the caller. join waits for the thread; Future.get waits and also reports the activity's result or failure. The choice depends on the information the caller must receive. This is why the chapter's final two examples use different tools even though both can execute a calculation in another thread.