In the previous lesson you ended up with a counter that lost half a million increments. Before fixing it you have to learn to handle the tool that broke it properly: the thread.
This lesson is the one about mechanisms. You are going to see the four ways of creating a thread in Java and why only one of them is advisable; the classic mistake of calling run() when you meant start(), demonstrated with output that leaves no room for doubt; how to wait for a thread to finish with join(); why naming threads is not cosmetic but an operational necessity; and —the most important thing in the whole lesson— the interruption protocol, which is the only cooperative cancellation mechanism Java has and the one that will let BiblioTech's catalogue import stop being an eight-second block that cannot be halted.
By the end, CatalogImporter will run on its own thread called bibliotech-importer, will report its progress and will respond to a cancellation request in under half a second, leaving the catalogue in a consistent state.
A note on what you learn here. Almost none of this lesson is what you will write in production: in 08-05 the executors appear, and they create and reuse threads for you. But an executor is a layer on top of this, and when it fails —and it will— you will have to reason in terms of threads,
joinand interruption. This is the layer underneath.
Contents
- The four ways of creating a thread
- Why
Runnablebeats extendingThread start()versusrun(): the classic mistake- The basic cycle: create, start, wait with
join() - Naming threads: why it is essential
- Priorities and why they are almost never useful
- Daemon threads: when to call
setDaemon - Sleeping:
Thread.sleepandTimeUnit - Interruption: the cancellation protocol
- Exceptions in a thread: they do not go where you think
- Passing data in and collecting results
- BiblioTech: the import on its own thread, cancellable
- What you will almost never do in production
- Common Mistakes and Tips
- Exercises
- The four ways of creating a thread
Java offers four ways of associating a piece of code with a thread. They all end up in the same place —a Thread object with a run() to execute— but they differ a great deal in design quality.
Way 1: extend Thread and override run().
public class NoticeThread extends Thread {
private final int totalNotices;
public NoticeThread(int totalNotices) {
super("bibliotech-notices"); // the thread name
this.totalNotices = totalNotices;
}
@Override
public void run() {
for (int i = 1; i <= totalNotices; i++) {
System.out.println(getName() + " sending notice " + i);
}
}
}
// Usage:
NoticeThread t = new NoticeThread(3);
t.start();Here the class is a thread. It works, it is the first thing taught everywhere, and it is what you should use least.
Way 2: implement Runnable and pass it to the Thread constructor.
public class NoticeTask implements Runnable {
private final int totalNotices;
public NoticeTask(int totalNotices) {
this.totalNotices = totalNotices;
}
@Override
public void run() {
for (int i = 1; i <= totalNotices; i++) {
System.out.println(Thread.currentThread().getName()
+ " sending notice " + i);
}
}
}
// Usage: the TASK is NoticeTask; the MECHANISM is Thread.
Thread t = new Thread(new NoticeTask(3), "bibliotech-notices");
t.start();Here the class describes a piece of work; who runs it is a separate decision. This is the recommended way, and section 2 explains why.
Way 3: a lambda that implements Runnable.
Runnable is a functional interface —a single abstract method void run()—, exactly the kind of interface you saw in 04-05 and 04-06. So it can be written as a lambda:
Thread t = new Thread(() -> {
for (int i = 1; i <= 3; i++) {
System.out.println(Thread.currentThread().getName()
+ " sending notice " + i);
}
}, "bibliotech-notices");
t.start();It is the most concise form and the usual one for short tasks. Remember from 04-05 that a lambda captures the variables of its environment, and that it can only capture effectively final variables: if you try to modify an int declared outside from inside the lambda, the compiler stops you. This is not a whim —it is precisely what prevents a race condition on the stack of a thread that may no longer exist.
Way 4: an anonymous class.
Thread t = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Running on " + Thread.currentThread().getName());
}
}, "bibliotech-notices");It is way 3 written the old-fashioned way (04-04). Today it only makes sense when the implementation needs its own state or several methods, neither of which happens with Runnable. With a single-method interface, the lambda always wins.
Comparison:
| Way | Uses up inheritance | Reusable with executors | Verbosity | When to use it |
|---|---|---|---|---|
Extend Thread |
Yes | No (it is a thread, not a task) | Medium | Almost never; only if you need to change the behaviour of the thread itself |
Implement Runnable |
No | Yes | Medium | Tasks with their own state, name and tests |
Runnable lambda |
No | Yes | Minimal | Short tasks, the most frequent case |
| Anonymous class | No | Yes | High | Legacy, or if you need your own fields without creating a class |
- Why
Runnable beats extending Thread
Runnable beats extending ThreadThere are three reasons, and the third is the weighty one.
Reason 1: inheritance is a single, scarce resource. Java has no multiple inheritance of classes. If NoticeThread extends Thread, it can no longer extend anything else. And in BiblioTech that hurts: if tomorrow you want your task to extend a base class BiblioTechTask with the error policy from 06-07, you cannot.
Reason 2: it separates the task from the mechanism. It is exactly the inheritance versus composition discussion of 03-05. extends Thread says "this class is a thread", which is false: NoticeTask is not a thread, it is a piece of work. Whether that work is run by one thread, two threads, a pool or the current thread should be a decision independent of the definition of the work.
Reason 3, the decisive one: Runnable is the common currency of the entire concurrency API.
Runnable task = () -> Catalog.recalculateStatistics();
// 1. Run it on a new thread.
new Thread(task, "statistics").start();
// 2. Run it in a pool (08-05).
executor.submit(task);
// 3. Schedule it every 10 minutes (08-05).
scheduler.scheduleAtFixedRate(task, 0, 10, TimeUnit.MINUTES);
// 4. Run it when the JVM shuts down (module 7 shutdown hook).
Runtime.getRuntime().addShutdownHook(new Thread(task, "shutdown"));
// 5. Run it right here, with no threads, for a unit test.
task.run();Five completely different destinations for the same task, without touching a line of the task. With extends Thread you can do none of the first four, and the fifth —testing the task without creating threads— is the one you will be most grateful for when you get to JUnit in 11-04: testing business logic by starting threads is slow and flaky; testing a Runnable's run() by calling it directly is a normal, deterministic test.
Rule. Write tasks, not threads. Let whoever uses them decide where they run.
start() versus run(): the classic mistake
start() versus run(): the classic mistakeThis is the most frequent and most silent beginner's mistake on the topic, because it compiles, it runs and it produces no error at all. There simply is no concurrency.
run()is an ordinary method. Calling it runs the code on the calling thread, like any method.start()asks the JVM to create a new operating-system thread and have that thread runrun(). It returns immediately.
public class StartVersusRun {
static Runnable task = () ->
System.out.println(" task running on: "
+ Thread.currentThread().getName());
public static void main(String[] args) throws InterruptedException {
System.out.println("main running on: "
+ Thread.currentThread().getName());
System.out.println("\n--- Calling run() (WRONG) ---");
Thread t1 = new Thread(task, "thread-A");
t1.run(); // does NOT create any thread
System.out.println(" state of thread-A: " + t1.getState());
System.out.println("\n--- Calling start() (CORRECT) ---");
Thread t2 = new Thread(task, "thread-B");
t2.start(); // creates a real thread
t2.join();
System.out.println(" state of thread-B: " + t2.getState());
}
}Output:
main running on: main
--- Calling run() (WRONG) ---
task running on: main
state of thread-A: NEW
--- Calling start() (CORRECT) ---
task running on: thread-B
state of thread-B: TERMINATEDThe two lines that give the mistake away:
- With
run(), the task printsmain: it ran on the main thread. There was no parallelism, no responsiveness, nothing. - The state of
thread-Ais stillNEW: thatThreadobject never started. It just sat there, constructed and unused. The states are detailed in 08-03.
Two related rules:
start()on an already-started thread throwsIllegalThreadStateException. AThreadis single-use: it starts once and, once finished, it cannot be reused. If you need to repeat the task, you create anotherThread(or, better, use a pool).start()returns immediately, not when the task finishes. The code afterstart()carries on running in parallel with the task. Waiting isjoin()'s job.
How to spot it in a code review. Search for
.run()in production code. Almost any explicit.run()on aThreadis a bug; on aRunnableit is sometimes intentional (inline execution), but it deserves a comment justifying it.
- The basic cycle: create, start, wait with
join()
join()The minimal cycle has three steps and one trap.
import java.util.concurrent.TimeUnit;
public class BasicCycle {
public static void main(String[] args) throws InterruptedException {
// 1. CREATE: this only builds an object. Nothing runs yet.
Thread importer = new Thread(() -> {
System.out.println("[importer] starting");
pause(1500);
System.out.println("[importer] finished");
}, "bibliotech-importer");
// 2. START: from here on there are two flows of execution.
importer.start();
System.out.println("[main] the menu stays alive while the import runs");
// 3. WAIT: main stops here until the thread finishes.
importer.join();
System.out.println("[main] import confirmed, I can now use the result");
}
static void pause(long ms) {
try {
TimeUnit.MILLISECONDS.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}Output:
[importer] starting
[main] the menu stays alive while the import runs
[importer] finished
[main] import confirmed, I can now use the resultThe trap: without join(), the result may not be ready. It is a very common mistake:
Thread t = new Thread(() -> report = computeReport());
t.start();
System.out.println(report); // BUG: probably null, the thread has not finished yetjoin() is not only "waiting": it is also a memory barrier. Everything the thread wrote before finishing is visible to whoever calls join() afterwards. This is the first concrete happens-before relationship you meet, and it is explained formally in 08-04. Without join() (or some other synchronisation mechanism), not only can you read too early: you can read a stale value even though the thread had already finished.
join() with a time limit. The join(long millis) overload waits at most that long:
Thread importer = new Thread(new ImportTask(), "bibliotech-importer");
importer.start();
importer.join(5000); // waits at most 5 seconds
if (importer.isAlive()) {
// The join ended because of TIME, not because the thread finished.
// CAREFUL: join(long) does NOT throw an exception when the deadline
// expires, nor does it cancel anything. You check with isAlive().
System.out.println("The import is taking too long; requesting cancellation");
importer.interrupt(); // section 9
importer.join(1000); // grace period for a clean finish
if (importer.isAlive()) {
System.out.println("The thread is not responding to the interruption");
}
} else {
System.out.println("Import completed within the deadline");
}An important detail that surprises everybody the first time: join(5000) does not tell you whether the thread finished or the deadline expired. It returns void. The only way to tell them apart is isAlive() right afterwards. It is an old and clumsy API; in 08-05 you will see Future.get(timeout), which does throw TimeoutException and is what you will use in practice.
| Method | What it does | When to use it |
|---|---|---|
join() |
Waits indefinitely for the thread to finish | When termination is guaranteed |
join(ms) |
Waits at most ms milliseconds |
When you want a deadline; check isAlive() afterwards |
isAlive() |
Has it started and not yet finished? | Diagnosis and checking after join(ms) |
interrupt() |
Requests cooperative cancellation | Section 9 |
join()throwsInterruptedException. Whoever waits can in turn be interrupted. Handle it with the protocol from section 9, never with an emptycatch.
- Naming threads: why it is essential
An unnamed thread gets Thread-0, Thread-1, Thread-2… That is fine for a three-line example and utterly useless in a real system.
// BAD: automatic names
new Thread(importTask).start();
new Thread(noticeTask).start();
new Thread(backupTask).start();
// GOOD: the name says what it is doing
new Thread(importTask, "bibliotech-importer").start();
new Thread(noticeTask, "bibliotech-notices").start();
new Thread(backupTask, "bibliotech-backups").start();With names, a thread dump (08-03) or an error trace tells you immediately which part of the system is involved:
"bibliotech-importer" #21 prio=5 os_prio=0 tid=0x... nid=0x... waiting on condition
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.base/java.lang.Thread.sleep(Native Method)
at com.nexussoftware.bibliotech.persistence.CatalogImporter.read(...)Without a name, that same line starts with "Thread-3" and forces you to work out from the trace what it is. With twenty threads, that is not viable.
Recommended convention for the project: <application>-<function>[-<index>].
bibliotech-importerbibliotech-notices-1,bibliotech-notices-2, …bibliotech-scheduler
Three concrete reasons why this is a necessity and not an ornament:
- Thread dumps: it is the only way to read a dump from an application with many threads.
- Logs:
OperationLog.threadTracefrom the 08-01 exercise includes the thread name; without meaningful names, the trace is useless. - Monitoring:
jconsoleand VisualVM list threads by name; a panel full ofpool-1-thread-7does not tell you which pool it belongs to.
In 08-05 you will see ThreadFactory, which is how you make a pool's threads have decent names too, because by default they are called pool-1-thread-1 and that is useless again once there are three pools.
// The name can be set in the constructor or afterwards,
// but before start() (afterwards works, though it confuses earlier logs).
Thread t = new Thread(task);
t.setName("bibliotech-importer");
t.start();
// And it is read from inside:
System.out.println(Thread.currentThread().getName());
- Priorities and why they are almost never useful
Thread has a priority field between 1 and 10:
Thread.MIN_PRIORITY // 1
Thread.NORM_PRIORITY // 5 (default)
Thread.MAX_PRIORITY // 10
Thread t = new Thread(task, "bibliotech-notices");
t.setPriority(Thread.MAX_PRIORITY);
t.start();It looks like a way of saying "this thread is more important". In practice, do not rely on it, for four reasons:
- It is a hint, not an order. The JVM translates the priority into the operating system's, and the operating system decides. It can ignore it entirely.
- The mapping depends on the system. Windows has 7 useful levels; Linux, with the default scheduler, practically ignores user thread priorities without special permissions. The same program behaves differently on each system.
- It guarantees no execution order. A priority-1 thread can run before a priority-10 one. If your correctness depends on that, your program is incorrect.
- It invites starvation. If a high-priority thread never yields, a low-priority one may never run —the third danger from 08-01—.
// ANTIPATTERN: "solving" a race condition with priorities.
writer.setPriority(Thread.MAX_PRIORITY);
reader.setPriority(Thread.MIN_PRIORITY);
// This synchronises NOTHING. It only makes the failure rarer and
// therefore harder to diagnose. The solution is 08-04.What to do instead: if you need some work not to compete with the important work, do not lower its priority: put it in a separate pool with few threads (08-05). That is real, portable control.
The legitimate and almost only use: lowering the priority of non-urgent maintenance threads (cache cleanup, metrics collection) as a hint that it does not matter if they are slow. Never as a correctness mechanism.
- Daemon threads: when to call
setDaemon
setDaemonYou already saw the rule in 08-01 —the JVM terminates when no non-daemon thread is left—. Here are the usage details.
Thread monitor = new Thread(() -> {
while (true) {
System.out.println("[monitor] live threads: " + Thread.activeCount());
pause(1000);
}
}, "bibliotech-monitor");
monitor.setDaemon(true); // BEFORE start(). Afterwards: IllegalThreadStateException
monitor.start();Practical rules:
- Always before
start(). Afterwards it throwsIllegalThreadStateException. - It is inherited. A thread created from a daemon thread is born a daemon. It is a source of surprises: if you start a worker thread from inside a daemon, that thread will not prevent shutdown either.
- A daemon does not run its
finallywhen the JVM shuts down. There is no guarantee that anything runs. Therefore: nothing that writes files, closes resources or commits transactions should live on a daemon.
| BiblioTech task | Daemon? | Why |
|---|---|---|
| Catalogue import | No | It must complete or be cancelled cleanly |
| Saving state on exit | No (and it is also a shutdown hook) | Losing data is unacceptable |
| Live-thread monitor | Yes | Purely informational |
Periodic CardCache cleanup |
Yes | Rebuildable; losing it costs nothing |
| Sending notices | No | A half-sent notice is a lost notice |
- Sleeping:
Thread.sleep and TimeUnit
Thread.sleep and TimeUnitThread.sleep(ms) suspends the current thread for at least that long. Two equivalent forms:
Thread.sleep(2000); // milliseconds: you have to count zeros
TimeUnit.SECONDS.sleep(2); // readable, unambiguous
TimeUnit.MILLISECONDS.sleep(500);
TimeUnit.MINUTES.sleep(5);TimeUnit (from java.util.concurrent) is preferable for readability: TimeUnit.MINUTES.sleep(5) versus Thread.sleep(300000). The second is an off-by-one-zero bug waiting to happen. On top of that, TimeUnit is the type used by all the concurrency APIs you will see in 08-05 (awaitTermination, Future.get, scheduleAtFixedRate), so it is worth getting used to.
Four facts about sleep you have to know:
1. "At least", not "exactly". The delay is a minimum. If the system is loaded, the thread may wake up considerably later. Do not build logic that depends on millisecond precision.
2. Sleeping does NOT release locks. This is the critical point and it will be revisited in 08-04:
A thread asleep inside a synchronized block keeps the monitor and blocks everybody else. It is a recipe for disaster. Object.wait() —which does release the monitor— is what is used to coordinate, and it is in 08-03.
3. sleep throws InterruptedException. It is a checked exception; the compiler forces you to deal with it. How to deal with it properly is the next section.
4. sleep(0) is not "do not sleep". It can cause a yield to the scheduler. If what you want is to suggest a yield, there is Thread.yield() (08-03), and it still guarantees nothing.
In this module we will use
sleepto simulate latency —writing to disk, waiting for a slow response— because networks are module 9. In production,sleepin a loop to "wait for something to happen" is an antipattern (busy waiting with a nap): the right answer iswait/notify(08-03), aCountDownLatchor aBlockingQueue(08-05 and 08-06).
- Interruption: the cancellation protocol
This is the most important section of the lesson. Java has no way to kill a thread. Thread.stop() existed and was removed for being dangerous (08-03). All there is is interruption: a cooperative mechanism in which one thread asks another to finish, and the other decides when and how to do it.
9.1 The three pieces
| Element | What it does | Effect on the flag |
|---|---|---|
thread.interrupt() |
Marks the target thread's interrupt flag | Sets it to true |
thread.isInterrupted() |
Reads a thread's flag | Leaves it as it is |
Thread.interrupted() |
Reads the current thread's flag | Clears it (careful!) |
InterruptedException |
Thrown if the thread was blocked in sleep, wait, join… |
Clears it when thrown |
The two marked rows are the cause of nearly every mistake in this section. Thread.interrupted() is read and clear; calling it twice in a row returns true and then false. And an InterruptedException, when thrown, clears the flag: the interrupted thread stops looking interrupted at exactly the moment when knowing it matters most.
9.2 What happens depending on what the thread is doing
- If the thread is computing,
interrupt()only sets the flag. Nothing else happens. The thread has to check it to find out. - If the thread is blocked in
sleep,wait,joinor on aBlockingQueue, it wakes up with anInterruptedException. - If the thread is blocked on classic I/O (
InputStream.read), the interruption does not wake it up. It is a real and annoying limitation. (NIO channels are interruptible, and the virtual threads of 10-06 improve this.)
9.3 The mistake of swallowing the exception
// THE WORST CONCURRENCY CODE WRITTEN IN JAVA
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// ignored
}What has just happened: somebody asked the thread to stop; the exception was thrown, the flag was cleared, and the empty catch swallowed it. The thread carries on as if nothing had happened and there is not a trace of the request left. The thread has become non-cancellable.
In BiblioTech, this means the fifty-thousand-line import cannot be stopped and the application does not close when asked to.
Demonstration of the problem:
public class SwallowedInterruption {
public static void main(String[] args) throws InterruptedException {
Thread bad = new Thread(() -> {
while (true) {
System.out.println("[bad] still working");
try {
Thread.sleep(300);
} catch (InterruptedException e) {
// WRONG: swallows the request and clears the flag
}
}
}, "immortal-thread");
bad.start();
Thread.sleep(1000);
System.out.println(">>> main requests cancellation");
bad.interrupt();
Thread.sleep(1000);
System.out.println(">>> still alive: " + bad.isAlive());
System.exit(0); // the only way to finish it off
}
}Output:
[bad] still working
[bad] still working
[bad] still working
>>> main requests cancellation
[bad] still working
[bad] still working
[bad] still working
>>> still alive: true9.4 The two correct answers
Faced with an InterruptedException there are only two legitimate answers:
Answer A — propagate. If your method can declare throws InterruptedException, propagate it. It is the best option: your caller decides.
public void awaitConfirmation() throws InterruptedException {
TimeUnit.SECONDS.sleep(2); // the exception goes up on its own
}Answer B — restore the flag and finish. If you cannot propagate —for example, inside run(), whose signature does not allow checked exceptions—, restore the flag and leave in an orderly fashion.
@Override
public void run() {
try {
while (!Thread.currentThread().isInterrupted()) {
processOneBatch();
TimeUnit.MILLISECONDS.sleep(100);
}
} catch (InterruptedException e) {
// Restore the flag: the code further up (or the pool)
// must be able to know that this thread was interrupted.
Thread.currentThread().interrupt();
} finally {
closeResources(); // cleanup always
}
}What you must never do: an empty catch, or log the exception and carry on with the loop as if nothing had happened.
9.5 The complete cancellable-task pattern
This is the skeleton you will use over and over again. It combines the two ways of detecting an interruption: the flag for the computing stretches and the exception for the blocking ones.
import java.util.concurrent.TimeUnit;
public class CancellableTask implements Runnable {
private final int totalItems;
public CancellableTask(int totalItems) {
this.totalItems = totalItems;
}
@Override
public void run() {
String me = Thread.currentThread().getName();
int processed = 0;
try {
for (int i = 0; i < totalItems; i++) {
// 1) CHECK THE FLAG on every pass.
// Necessary because computing work does not throw
// InterruptedException on its own.
if (Thread.currentThread().isInterrupted()) {
System.out.println("[" + me + "] cancellation detected at item " + i);
return; // the finally still runs
}
processItem(i); // real work
processed++;
// 2) Blocking point: here the interruption arrives
// as an InterruptedException, not as a flag.
TimeUnit.MILLISECONDS.sleep(20);
}
System.out.println("[" + me + "] completed");
} catch (InterruptedException e) {
System.out.println("[" + me + "] interrupted while waiting");
// 3) RESTORE the flag: we do not own this information.
Thread.currentThread().interrupt();
} finally {
// 4) Cleanup ALWAYS: reached through success, cancellation or error.
System.out.println("[" + me + "] items processed: " + processed);
}
}
private void processItem(int i) { /* work */ }
}And the usage, with the cancellation-with-deadline pattern:
public class CancellableTaskDemo {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(new CancellableTask(10_000), "bibliotech-importer");
t.start();
TimeUnit.MILLISECONDS.sleep(500);
System.out.println(">>> requesting cancellation");
t.interrupt();
t.join(2000); // grace period for a clean finish
if (t.isAlive()) {
System.out.println(">>> the thread does NOT respond to interruption (bug)");
} else {
System.out.println(">>> cancelled cleanly");
}
}
}Output:
>>> requesting cancellation
[bibliotech-importer] interrupted while waiting
[bibliotech-importer] items processed: 23
>>> cancelled cleanlyThe four points of the pattern, summarised:
- Check
isInterrupted()on every pass of the work loop. - Catch
InterruptedExceptionat the blocking points. - Restore the flag with
Thread.currentThread().interrupt()before leaving. - Clean up in a
finally, which runs on all three paths alike.
Checking frequency. The check has to be frequent enough for the cancellation to be noticeable (ideally, less than a second of latency) and spaced out enough not to dominate the cost. In a loop over CSV lines, checking on every line is fine:
isInterrupted()is a field read, it costs nanoseconds.
- Exceptions in a thread: they do not go where you think
An exception thrown inside run() does not propagate to the thread that called start(). It cannot: by the time it happens, the creating thread is somewhere else, or has already finished. Every thread has its own stack and its own error boundary.
public class ExceptionInThread {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
System.out.println("[thread] I am going to fail");
throw new IllegalStateException("corrupt catalogue");
}, "bibliotech-importer");
try {
t.start();
t.join();
System.out.println("[main] join() returned WITHOUT an exception");
} catch (RuntimeException e) {
System.out.println("[main] this is NEVER printed: " + e);
}
System.out.println("[main] thread state: " + t.getState());
}
}Output:
[thread] I am going to fail
Exception in thread "bibliotech-importer" java.lang.IllegalStateException: corrupt catalogue
at ExceptionInThread.lambda$main$0(ExceptionInThread.java:7)
at java.base/java.lang.Thread.run(Thread.java:1583)
[main] join() returned WITHOUT an exception
[main] thread state: TERMINATEDRead it carefully: the trace is printed on the console, but main learns nothing. join() returns normally and the state is TERMINATED, exactly as if it had finished successfully. If main carried on assuming the import worked, it is now working with an empty catalogue and does not know it.
That Exception in thread "..." message is printed by the default uncaught exception handler. You can replace it, and it is exactly the Thread.setDefaultUncaughtExceptionHandler you introduced in 06-07 with GlobalHandler:
import java.util.logging.Level;
import java.util.logging.Logger;
public class ThreadHandler {
private static final Logger LOG = Logger.getLogger("bibliotech");
public static void main(String[] args) throws InterruptedException {
// 1) GLOBAL handler: covers every thread that does not have its own.
// It is the GlobalHandler from 06-07, now fully meaningful.
Thread.setDefaultUncaughtExceptionHandler((thread, error) ->
LOG.log(Level.SEVERE,
"Uncaught failure on thread " + thread.getName(), error));
// 2) PER-THREAD handler: it takes precedence over the global one.
Thread importer = new Thread(() -> {
throw new IllegalStateException("unreadable catalogue file");
}, "bibliotech-importer");
importer.setUncaughtExceptionHandler((thread, error) -> {
LOG.log(Level.SEVERE, "The import has failed; the catalogue "
+ "keeps its previous state", error);
// This is where BiblioTech would mark the import as failed
// so the menu can inform the user.
});
importer.start();
importer.join();
// 3) The global handler in action, with a different thread.
new Thread(() -> { throw new RuntimeException("monitor failure"); },
"bibliotech-monitor").start();
}
}Lookup order when a thread dies from an uncaught exception:
- The thread's own handler (
setUncaughtExceptionHandler), if it has one. - The
ThreadGroup's handler. - The global handler (
setDefaultUncaughtExceptionHandler). - The default behaviour: print the trace on
System.err.
Design rule for BiblioTech: a thread task should catch its own business exceptions and turn them into a result —the Result you already have from 06-07—, leaving the uncaught handler purely as a safety net for the unforeseen. That is exactly the problem Future solves cleanly in 08-05: Future.get() does give you back the thread's exception, wrapped in an ExecutionException.
- Passing data in and collecting results
Input: through the constructor. It is the right way, and it makes the task immutable and safe:
public class ImportTask implements Runnable {
// final: they do not change after construction. Safe publication (08-04).
private final Path file;
private final Catalog catalog;
public ImportTask(Path file, Catalog catalog) {
this.file = file;
this.catalog = catalog;
}
@Override
public void run() {
// uses file and catalog
}
}With a lambda, the input is captured, with the restriction already known from 04-05: captured variables must be effectively final.
Path file = Path.of("data/inventory.csv");
Thread t = new Thread(() -> importFile(file, catalog), "bibliotech-importer");
// file = somethingElse; // <-- if you uncomment this, the lambda does NOT compileOutput: the problem. Runnable.run() returns void and cannot throw checked exceptions. There is nowhere to put the result. The manual solutions are all unsatisfactory:
// Manual solution 1: a field on the task.
public class ImportTaskWithResult implements Runnable {
private final Path file;
private volatile Report result; // volatile: visibility (08-04)
private volatile Exception error;
public ImportTaskWithResult(Path file) { this.file = file; }
@Override
public void run() {
try {
result = new CatalogImporter().importFrom(file);
} catch (Exception e) {
error = e; // you have to catch it by hand
}
}
public Report result() { return result; }
public Exception error() { return error; }
}
// Usage:
ImportTaskWithResult task =
new ImportTaskWithResult(Path.of("data/inventory.csv"));
Thread t = new Thread(task, "bibliotech-importer");
t.start();
t.join(); // MANDATORY before reading
if (task.error() != null) {
throw new BiblioTechException("The import failed", task.error());
}
Report report = task.result();It works, but look at everything you have had to do by hand:
- Declare the fields
volatileso the result is visible. - Catch the exception yourself and store it in another field.
- Remember to
join()before reading, with nothing to remind you. - Manually check whether there was an error before using the result.
- You have no way of waiting with a deadline, or of cancelling and knowing whether it was cancelled.
All of this is solved in the library. Callable<Report> is like Runnable but it returns a value and can throw checked exceptions, and Future<Report> is the object representing "the result that will arrive":
// PREVIEW of 08-05, do not use it yet:
Callable<Report> task = () -> new CatalogImporter().importFrom(file);
Future<Report> future = executor.submit(task);
Report report = future.get(30, TimeUnit.SECONDS); // waits, with a deadline,
// and rethrows the errorThe <Report> in Future<Report> is simply the type of the result that future will deliver: a Future<Report> promises a Report, a Future<String> promises a String. Generics are studied in depth in 10-01; here it is enough to read them like that.
- BiblioTech: the import on its own thread, cancellable
Now everything comes together. This is case A of 08-01 solved: the import stops blocking the menu, reports its progress and responds to cancellation.
package com.nexussoftware.bibliotech.persistence;
import com.nexussoftware.bibliotech.service.Catalog;
import com.nexussoftware.bibliotech.domain.Material;
import java.io.BufferedReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Catalogue import runnable on a separate thread.
*
* Properties:
* - It publishes its progress (lines read) so the menu can display it.
* - It responds to interruption within less than one line of work.
* - It leaves the catalogue UNTOUCHED if cancelled: it builds a separate
* list and only dumps it into the catalogue if the import completes.
*/
public class ImportTask implements Runnable {
private static final Logger LOG =
Logger.getLogger(ImportTask.class.getName());
private final Path file;
private final Catalog catalog;
private final CsvReader reader = new CsvReader();
// State published towards other threads. 'volatile' guarantees that the
// menu thread sees up-to-date values; the details are in 08-04.
private volatile int linesRead = 0;
private volatile int imported = 0;
private volatile int discarded = 0;
private volatile boolean completed = false;
private volatile Exception error = null;
public ImportTask(Path file, Catalog catalog) {
this.file = file;
this.catalog = catalog;
}
@Override
public void run() {
String me = Thread.currentThread().getName();
long start = System.nanoTime();
LOG.log(Level.INFO, "[{0}] importing {1}", new Object[] { me, file });
// Temporary list: the real catalogue is not touched until the end.
List<Material> fresh = new ArrayList<>();
try (BufferedReader br = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
String line = br.readLine(); // header
while ((line = br.readLine()) != null) {
// --- CANCELLATION POINT ---
// One check per line: nanoscopic cost,
// practically zero cancellation latency.
if (Thread.currentThread().isInterrupted()) {
LOG.log(Level.WARNING,
"[{0}] import CANCELLED after {1} lines; "
+ "the catalogue keeps its previous content",
new Object[] { me, linesRead });
return; // finally still runs
}
linesRead++;
try {
fresh.add(reader.toMaterial(line));
imported++;
} catch (InvalidFormatException e) {
discarded++; // 06-07 policy: degrade
LOG.log(Level.FINE, "Line {0} discarded: {1}",
new Object[] { linesRead, e.getMessage() });
}
// Simulation of an expensive validation, so the example
// lasts long enough for it to be cancellable.
if (linesRead % 1000 == 0) {
TimeUnit.MILLISECONDS.sleep(50);
}
}
// Only if we have got this far is the result published.
catalog.replaceAll(fresh);
completed = true;
long ms = (System.nanoTime() - start) / 1_000_000;
LOG.log(Level.INFO, "[{0}] import COMPLETED: {1} imported, "
+ "{2} discarded, {3} ms",
new Object[] { me, imported, discarded, ms });
} catch (InterruptedException e) {
LOG.log(Level.WARNING, "[{0}] interrupted during a pause", me);
Thread.currentThread().interrupt(); // RESTORE the flag
} catch (Exception e) {
// We do not let it die from an uncaught exception: we store it
// so the menu thread can inform the user.
error = e;
LOG.log(Level.SEVERE, "[" + me + "] import failed", e);
} finally {
LOG.log(Level.INFO, "[{0}] import thread finished", me);
}
}
// --- State readable from other threads ---
public int linesRead() { return linesRead; }
public int imported() { return imported; }
public int discarded() { return discarded; }
public boolean completed() { return completed; }
public Exception error() { return error; }
}And the start-up from the application, with progress and cancellation on a deadline:
package com.nexussoftware.bibliotech.presentation;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
public class BackgroundImport {
public static void main(String[] args) throws InterruptedException {
Catalog catalog = new Catalog();
ImportTask task =
new ImportTask(Path.of("data/inventory.csv"), catalog);
Thread thread = new Thread(task, "bibliotech-importer");
thread.start();
// The main thread is NOT blocked: it can paint progress,
// and in the real application it would serve the menu.
long limitMs = 10_000;
long start = System.currentTimeMillis();
while (thread.isAlive()) {
System.out.printf("\r[main] progress: %d lines (%d ok, %d discarded)",
task.linesRead(), task.imported(), task.discarded());
if (System.currentTimeMillis() - start > limitMs) {
System.out.println("\n[main] deadline expired, cancelling");
thread.interrupt();
break;
}
TimeUnit.MILLISECONDS.sleep(200);
}
thread.join(2000); // grace period for a clean shutdown
System.out.println();
if (task.completed()) {
System.out.printf("[main] catalogue updated: %d materials%n",
task.imported());
} else if (task.error() != null) {
System.out.println("[main] the import failed: " + task.error().getMessage());
System.out.println("[main] the catalogue keeps its previous content");
} else {
System.out.println("[main] import cancelled; catalogue unchanged");
}
}
}Output on cancellation:
[main] progress: 34000 lines (33871 ok, 129 discarded)
[main] deadline expired, cancelling
WARNING: [bibliotech-importer] import CANCELLED after 34218 lines; the catalogue keeps its previous content
INFO: [bibliotech-importer] import thread finished
[main] import cancelled; catalogue unchangedThe four design points that make this correct:
- The catalogue is not touched until the end. A separate list is built and only dumped if the import completes. A cancellation halfway through does not leave the catalogue half old and half new. It is the same idea as the atomic write of 07-06, applied to memory instead of disk.
- Cancellation is checked once per line. Negligible cost, imperceptible cancellation latency.
- Exceptions are caught and published, not allowed to escape.
maincan tell the three possible endings apart: completed, cancelled or failed. - The published state is
volatile. Without that, themainthread might never see the updated progress. The exact reason is in 08-04, and it is subtler than it looks.
What is still not right, and what 08-05 fixes: a thread is created by hand for every import; there is no clean way of obtaining the result (you have to read fields); the progress pattern with a loop that sleeps 200 ms is crude; and if there were five simultaneous imports, there would be no control at all over how many threads get created.
- What you will almost never do in production
After thirteen sections teaching you to create threads, the honest conclusion: in modern production code you will almost never write new Thread(...).
You already know the reasons from 08-01: creating a thread costs tens of microseconds and ~1 MB of stack, and there is no limit stopping the code from creating ten thousand. A typical bug —"one thread per file in the folder"— with a folder of twenty thousand files takes down the JVM.
What is used is the thread pool: a bounded set of reused threads consuming tasks from a queue. That is ExecutorService, and it is 08-05. It replaces:
// This (08-02):
Thread t = new Thread(task, "bibliotech-importer");
t.start();
t.join();
// With this (08-05):
Future<Report> f = executor.submit(taskThatReturnsAReport);
Report report = f.get(30, TimeUnit.SECONDS);Even so, everything in this lesson is still necessary: the pool's threads are ordinary threads, a Future's cancel(true) is an interrupt(), the names of a pool's threads are set by a ThreadFactory that creates Threads, and when you read a dump in 08-03 you will see Thread objects. The executor does not replace the knowledge: it automates it.
A note on virtual threads. Java 21 introduced virtual threads (Project Loom): threads managed by the JVM, not by the operating system, whose creation cost is in nanoseconds and whose stack grows dynamically on the heap. With them, a million concurrent threads is viable and the whole "do not create threads, use a pool" argument is reversed for I/O tasks. They are created with
Thread.ofVirtual().start(task). They are not developed here: they are 10-06, once you have mastered the classic model they rest on.
Common Mistakes and Tips
Mistake 1: calling run() instead of start(). Everything runs on the current thread and there is no concurrency. You detect it by printing Thread.currentThread().getName() inside the task: if it says main, there is the bug.
Mistake 2: swallowing InterruptedException with an empty catch. It makes the thread non-cancellable and stops the application from being able to close. Propagate, or restore the flag and finish. No exceptions to this rule.
Mistake 3: using Thread.interrupted() thinking it is isInterrupted(). The first one clears the flag. If you use it in the while condition and also read it in the catch, the second read will give false and you will break your own cancellation logic.
Mistake 4: reading the result without join(). It is not only a timing problem: without synchronisation, the value you read can be stale even though the thread has already finished. join() establishes the happens-before relationship that makes the result visible.
Mistake 5: start() twice on the same Thread. IllegalThreadStateException. A Thread is single-use.
Mistake 6: setDaemon(true) after start(). IllegalThreadStateException. And worse still: entrusting important work to a daemon, which is aborted without running its finally.
Mistake 7: believing an exception in a thread will reach the one that called start(). It does not. join() returns normally and the state is TERMINATED just as in the success case. Catch and publish the error, or use Future (08-05).
Mistake 8: sleeping inside a synchronized block. sleep does not release the monitor. It is covered fully in 08-04, but note it now: it is a common cause of applications that crawl.
Mistake 9: creating threads in a loop over input data. for (Path p : files) new Thread(...).start(); with twenty thousand files is an OutOfMemoryError. Pool, always.
Tip 1: name every thread. No exceptions. The cost is a string; the benefit is being able to diagnose in production.
Tip 2: write the task as a Runnable, never as a subclass of Thread. You will be able to test it by calling run() directly in a test, without starting threads, and move it into a pool without touching it.
Tip 3: make every task that lasts more than a second cancellable. The pattern in 9.5 is short and prevents an entire class of user complaints.
Tip 4: do not publish half-finished results. Build separately and publish at the end, the way ImportTask does with its temporary list. A cancellation must never leave state half-done.
Tip 5: use TimeUnit instead of raw milliseconds. TimeUnit.MINUTES.sleep(5) cannot be misread; Thread.sleep(300000) can.
Exercises
Exercise 1: The four ways and the start versus run demonstration
Write a class FourWays that runs the same task —print the current thread's name three times with a 100 ms pause— using the four ways from section 1: subclass of Thread, class implementing Runnable, lambda and anonymous class. All the threads must have their own name (way-1-thread, way-2-runnable, way-3-lambda, way-4-anonymous) and main must wait for them all with join(). Add a fifth run at the end that calls run() instead of start() and comment in the output on why the printed name is different.
Exercise 2: A cancellable word counter
Write WordCounter implements Runnable that receives a Path in the constructor and counts the words in the file line by line. The task must:
- Check for interruption every 100 lines.
- Publish its progress (lines processed and words counted) in
volatilefields readable from outside. - Restore the interrupt flag if it is interrupted during a wait.
- Log, in a
finally, the partial result and the elapsed time measured withSystem.nanoTime().
Also write a main that launches it on a large file, shows the progress every 300 ms and cancels it after 2 seconds, reporting whether it finished or was cancelled.
Exercise 3: Collecting results from several threads
Write NoticeCollector which launches four threads, each responsible for "sending" a batch of 25 BiblioTech notices (simulate each send with TimeUnit.MILLISECONDS.sleep(20)). Each thread must record in its own task how many notices it sent successfully and how many failed (simulate a failure when the notice number is a multiple of 7, by throwing and catching a RuntimeException). main must wait for the four with join(), add up the results and print a summary, as well as measuring the total time with System.nanoTime() and comparing it with the time the sequential version would have taken (100 × 20 ms = 2000 ms).
Also install a global setUncaughtExceptionHandler that logs any unforeseen failure, and check that it never fires.
Solutions
Solution to Exercise 1
import java.util.concurrent.TimeUnit;
public class FourWays {
/** Common body of the task, so the comparison is fair. */
static void work() {
for (int i = 1; i <= 3; i++) {
System.out.printf(" [%s] step %d%n",
Thread.currentThread().getName(), i);
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}
// --- WAY 1: extend Thread ---
// The class IS a thread: it uses up the only inheritance available.
static class OwnThread extends Thread {
OwnThread() { super("way-1-thread"); }
@Override public void run() { work(); }
}
// --- WAY 2: implement Runnable ---
// The class describes a TASK; who runs it is decided outside.
static class OwnTask implements Runnable {
@Override public void run() { work(); }
}
public static void main(String[] args) throws InterruptedException {
System.out.println("=== WAY 1: extend Thread ===");
Thread t1 = new OwnThread();
t1.start();
t1.join();
System.out.println("=== WAY 2: implement Runnable ===");
Thread t2 = new Thread(new OwnTask(), "way-2-runnable");
t2.start();
t2.join();
System.out.println("=== WAY 3: lambda ===");
// Runnable is a functional interface (04-06): a single run() method.
Thread t3 = new Thread(() -> work(), "way-3-lambda");
t3.start();
t3.join();
System.out.println("=== WAY 4: anonymous class ===");
Thread t4 = new Thread(new Runnable() {
@Override public void run() { work(); }
}, "way-4-anonymous");
t4.start();
t4.join();
System.out.println("=== CLASSIC MISTAKE: run() instead of start() ===");
Thread t5 = new Thread(() -> work(), "way-5-never-starts");
t5.run(); // creates NO thread: runs right here
System.out.println(" state of t5: " + t5.getState()
+ " (NEW: it never started)");
}
}Output (final fragment):
=== CLASSIC MISTAKE: run() instead of start() ===
[main] step 1
[main] step 2
[main] step 3
state of t5: NEW (NEW: it never started)The demonstration is in the printed name: it says main, not way-5-never-starts. The task's code ran, but on the wrong thread. And the NEW state confirms that the Thread object never came into existence as an operating-system thread.
Solution to Exercise 2
package com.nexussoftware.bibliotech.persistence;
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
public class WordCounter implements Runnable {
private static final Logger LOG =
Logger.getLogger(WordCounter.class.getName());
private final Path file;
// State published towards the observing thread. 'volatile' ensures
// that this thread's writes are visible from outside (08-04).
private volatile long linesProcessed = 0;
private volatile long wordsCounted = 0;
private volatile boolean completed = false;
private volatile boolean cancelled = false;
public WordCounter(Path file) {
this.file = file;
}
@Override
public void run() {
String me = Thread.currentThread().getName();
long start = System.nanoTime();
try (BufferedReader br = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
String line;
while ((line = br.readLine()) != null) {
// 1) Periodic check of the flag. Every 100 lines is
// enough: the cancellation latency will be in
// microseconds and the cost of the check, zero.
if (linesProcessed % 100 == 0
&& Thread.currentThread().isInterrupted()) {
cancelled = true;
LOG.log(Level.WARNING, "[{0}] cancelled at line {1}",
new Object[] { me, linesProcessed });
return; // the finally still runs
}
linesProcessed++;
// Count words: split on whitespace.
// The trim stops an empty line counting as one word.
String clean = line.trim();
if (!clean.isEmpty()) {
wordsCounted += clean.split("\\s+").length;
}
// 2) Simulated heavy work so it can be cancelled.
if (linesProcessed % 500 == 0) {
TimeUnit.MILLISECONDS.sleep(30);
}
}
completed = true;
} catch (InterruptedException e) {
// 3) RESTORE the flag: we do not own that information.
cancelled = true;
Thread.currentThread().interrupt();
LOG.log(Level.WARNING, "[{0}] interrupted during a pause", me);
} catch (IOException e) {
LOG.log(Level.SEVERE, "[" + me + "] error reading " + file, e);
} finally {
// 4) A report ALWAYS, however we got here.
long ms = (System.nanoTime() - start) / 1_000_000;
LOG.log(Level.INFO,
"[{0}] end ({1}): {2} lines, {3} words, {4} ms",
new Object[] { me,
completed ? "completed" : (cancelled ? "cancelled" : "error"),
linesProcessed, wordsCounted, ms });
}
}
public long linesProcessed() { return linesProcessed; }
public long wordsCounted() { return wordsCounted; }
public boolean completed() { return completed; }
public boolean cancelled() { return cancelled; }
// --- Demonstration ---
public static void main(String[] args) throws InterruptedException {
WordCounter task =
new WordCounter(Path.of("data/catalog-large.txt"));
Thread thread = new Thread(task, "bibliotech-counter");
long start = System.nanoTime();
thread.start();
while (thread.isAlive()) {
System.out.printf("[main] %d lines, %d words%n",
task.linesProcessed(), task.wordsCounted());
if ((System.nanoTime() - start) > 2_000_000_000L) { // 2 s
System.out.println("[main] deadline expired -> interrupt()");
thread.interrupt();
break;
}
TimeUnit.MILLISECONDS.sleep(300);
}
thread.join(1000);
if (task.completed()) {
System.out.printf("[main] COMPLETED: %d words in %d lines%n",
task.wordsCounted(), task.linesProcessed());
} else if (task.cancelled()) {
System.out.printf("[main] CANCELLED after %d lines (partial result: %d words)%n",
task.linesProcessed(), task.wordsCounted());
} else {
System.out.println("[main] finished with an error; see the log");
}
}
}Details worth noting:
- The flag check is guarded by
linesProcessed % 100 == 0so as not to pay for it on every line; in truthisInterrupted()is so cheap that it could be done always, but the guarded pattern is the one you will use when the check is expensive (reading a clock, for instance). completedandcancelledare deliberately separate fields: they let you distinguish three endings (success, cancellation, error), and the message to the user is different in each case.- The
try-with-resourcescloses theBufferedReadereven when youreturninside the loop. It is exactly the guarantee from 06-06, and that is why there is no need to close it by hand in thefinally.
Solution to Exercise 3
package com.nexussoftware.bibliotech.service;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
public class NoticeCollector {
private static final Logger LOG =
Logger.getLogger(NoticeCollector.class.getName());
/** Task that sends a batch of notices and publishes its result. */
static class NoticeBatch implements Runnable {
private final int from;
private final int to; // exclusive
private volatile int sent = 0;
private volatile int failed = 0;
NoticeBatch(int from, int to) {
this.from = from;
this.to = to;
}
@Override
public void run() {
String me = Thread.currentThread().getName();
try {
for (int n = from; n < to; n++) {
if (Thread.currentThread().isInterrupted()) {
LOG.log(Level.WARNING, "[{0}] cancelled at notice {1}",
new Object[] { me, n });
return;
}
try {
sendNotice(n);
sent++;
} catch (RuntimeException e) {
// 06-07 policy: one failed notice does not abort the batch.
failed++;
LOG.log(Level.FINE, "[{0}] notice {1} failed: {2}",
new Object[] { me, n, e.getMessage() });
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
/** Simulates the send: 20 ms of latency and failure on multiples of 7. */
private void sendNotice(int n) throws InterruptedException {
TimeUnit.MILLISECONDS.sleep(20);
if (n % 7 == 0) {
throw new RuntimeException("recipient " + n + " has no address");
}
}
int sent() { return sent; }
int failed() { return failed; }
}
public static void main(String[] args) throws InterruptedException {
// Safety net: any unforeseen failure is logged instead of
// being printed on System.err and lost (06-07).
Thread.setDefaultUncaughtExceptionHandler((thread, error) ->
LOG.log(Level.SEVERE,
"UNCAUGHT failure on thread " + thread.getName(), error));
final int BATCHES = 4;
final int PER_BATCH = 25;
NoticeBatch[] tasks = new NoticeBatch[BATCHES];
Thread[] threads = new Thread[BATCHES];
long start = System.nanoTime();
// 1) Create and start. start() returns straight away: the four
// batches progress at the same time.
for (int i = 0; i < BATCHES; i++) {
tasks[i] = new NoticeBatch(i * PER_BATCH, (i + 1) * PER_BATCH);
threads[i] = new Thread(tasks[i], "bibliotech-notices-" + (i + 1));
threads[i].start();
}
// 2) Wait for them all. Each thread's join() also guarantees
// that its writes are visible from main (08-04).
for (Thread t : threads) {
t.join();
}
long ms = (System.nanoTime() - start) / 1_000_000;
// 3) Aggregate the results.
int totalSent = 0;
int totalFailed = 0;
System.out.println("=== RESULT PER BATCH ===");
for (int i = 0; i < BATCHES; i++) {
System.out.printf(" %-24s sent=%2d failed=%d%n",
threads[i].getName(), tasks[i].sent(), tasks[i].failed());
totalSent += tasks[i].sent();
totalFailed += tasks[i].failed();
}
long sequentialMs = (long) BATCHES * PER_BATCH * 20;
System.out.println();
System.out.println("=== SUMMARY ===");
System.out.println("Notices processed : " + (totalSent + totalFailed));
System.out.println("Sent : " + totalSent);
System.out.println("Failed : " + totalFailed);
System.out.println("Actual time : " + ms + " ms");
System.out.println("Sequential time : " + sequentialMs + " ms (estimated)");
System.out.printf ("Speed-up : %.2fx%n", (double) sequentialMs / ms);
}
}Indicative output:
=== RESULT PER BATCH ===
bibliotech-notices-1 sent=21 failed=4
bibliotech-notices-2 sent=21 failed=4
bibliotech-notices-3 sent=22 failed=3
bibliotech-notices-4 sent=22 failed=3
=== SUMMARY ===
Notices processed : 100
Sent : 86
Failed : 14
Actual time : 528 ms
Sequential time : 2000 ms (estimated)
Speed-up : 3.79xThree lessons from this result:
- The speed-up is nearly 4x, not 4x. What is missing is the cost of creating the threads, of starting them in staggered fashion, and the fact that the batches do not take exactly the same time. It is case B of 08-01 half solved: the 100 notices go from 2 s to 0.5 s.
- Individual failures do not take down the batch. Each
sendNoticesits in its owntry; the 06-07 policy —degrade, do not abort— is still in force and now inside a thread too. - The global handler never fires. That is the objective: if it appears in the output, it means a task let an exception escape, and that is a bug in the task, not a normal situation.
And the obvious limitation: what if instead of 4 batches there were 200 individual notices? You would create 200 threads. Each one costs ~1 MB of stack and tens of microseconds, and for a 20 ms task that is a waste. The answer is in 08-05.
Conclusion
You now know how to create threads and, more importantly, you know how to handle them properly.
The four ways of creating a thread —extending Thread, implementing Runnable, a lambda and an anonymous class— all end up in the same place, but only two are advisable: Runnable as a class when the task has state and deserves its own tests, and a lambda for everything else. The reason for avoiding extends Thread is not stylistic: it uses up the only inheritance available, it confuses the task with the mechanism —inheritance versus composition, 03-05 again— and, above all, it puts pools, periodic schedulers and the unit test that calls run() without starting any thread out of your reach. Write tasks, not threads.
You know the mistake that produces no error: run() runs on the current thread, start() creates a new one. You detect it in a second by printing Thread.currentThread().getName() inside the task, and the object's getState() confirms it: a Thread that only had run() called on it stays in NEW forever. And you know a Thread is single-use: a second start() throws IllegalThreadStateException.
You have mastered the basic cycle —create, start(), join()— with its two important nuances: join() is not only waiting, it also makes visible the writes of the finishing thread, and join(ms) does not say whether it finished or the deadline expired, so you have to ask isAlive(). You know why naming threads is an operational necessity and not an ornament, with the bibliotech-<function> convention; why priorities are a hint the operating system may ignore and must never form part of a correctness argument; and when a thread should be a daemon —only if losing its work halfway is acceptable—, with setDaemon(true) always before start().
And you have the most valuable thing in the lesson: the interruption protocol. You know Java cannot kill a thread and that all that exists is a cooperative request; that interrupt() sets a flag, isInterrupted() reads it, Thread.interrupted() reads it and clears it, and that an InterruptedException, when thrown, clears the flag exactly when it is most needed. Hence the only two legitimate answers: propagate the exception, or restore the flag and finish. An empty catch (InterruptedException e) { } turns a thread into a non-cancellable one and an application into something that cannot be closed; it is, by a distance, the worst common mistake in Java concurrency.
You also know that an exception thrown in a thread does not reach the one that called start(): join() returns normally and the state ends up TERMINATED just as if everything had gone well. That is why a task must catch and publish its error, and why setUncaughtExceptionHandler —the GlobalHandler from 06-07— is the safety net and not the strategy. And you know how to pass data in through the constructor with final fields, and why Runnable cannot return anything, with Callable and Future<Report> waiting for you at the next stop.
BiblioTech no longer blocks the menu. ImportTask runs on bibliotech-importer, publishes its progress in volatile fields, checks for cancellation once per line, distinguishes the three possible endings —completed, cancelled, failed— and, most important of all, builds a separate list and only dumps it into the catalogue if it finishes, so that a cancellation halfway through does not leave the catalogue half old and half new. It is the atomic write of 07-06 carried over into memory.
But the code leaves three things unresolved, and all three are visible at a glance: you create a thread by hand for every task, and with 200 notices that would be 200 threads and 200 MB of stacks; there is no clean way of collecting a result, only volatile fields and an implicit convention of "call join() before reading"; and progress is read with a loop that sleeps 200 ms, which is polling, not coordination.
In the next lesson, Thread Lifecycle, you go one level down before going two levels up. You will see the six Thread.State values and which exact operation causes each transition; the difference between BLOCKED —waiting for a monitor— and WAITING —waiting for a signal—, which is what makes a thread dump readable; the low-level coordination mechanism wait/notify/notifyAll, with its mandatory while loop and why using an if there is a bug; why stop, suspend and resume were withdrawn from the API; and —the most useful thing in practice— how to obtain and read a thread dump with jstack to find a blocked thread or a deadlock the JVM has detected for you.
Java Programming Course
Module 1: Introduction to Java
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
