Creating threads: Thread, Runnable and Callable
Every way to define and launch work in Java 21: Runnable, Callable, Thread subclasses, Thread.Builder and executors, what start() really asks the OS for, and where results and exceptions end up.
On this page
- Picture it first: the job ticket and the worker
- Three ways to define a task
- start() vs run(), and starting twice
- What start() really does
- The Java 21 way: Thread.Builder
- Passing data in, getting results out
- When a thread throws
- Real world
- Advanced: costs, constructors and comparisons
- How to say it in the interview
🧵 How do you create a thread in Java?
A task is the code you want to run (a Runnable or a Callable), and a thread is the worker that runs it. You get a new thread only by calling start() on a Thread object, either directly, through the Java 21 Thread.Builder API, or indirectly when an ExecutorService starts one for you. new Thread(() -> sendEmail(order), "mailer-1").start() is the whole idea in one line: a task, a named worker, and a start() that asks the OS for a real thread.
For this lesson, we'll cover every way to define and launch work: subclassing Thread, passing a Runnable, returning a value with Callable, the Java 21 builders, and handing tasks to an executor. Interviewers like this topic because the code is tiny but the follow-ups are sharp: "what if I call run()?", "can I start it twice?", "where did my exception go?". Those three questions separate people who have used threads from people who have debugged them.
Below the line (out of scope)
- Thread states after
start()and howjoin(),sleep()and locks move them: Thread lifecycle and states - Daemon flags and priorities in depth: Daemon threads and priorities; stopping threads: Interrupts and cancellation
- Pool sizing, queues and rejection policies: the planned "ExecutorService and thread pools" lesson;
Futurecancellation and composition: "Future and Callable" and "How CompletableFuture works" - Virtual threads internals (carriers, pinning): the planned "Virtual threads" lesson
The 30-second answer
Put the work in a Runnable (no result) or a Callable<V> (returns V, may throw checked exceptions), and keep it separate from the thread that runs it. Prefer Runnable over extending Thread: you keep your one superclass and can hand the same task to a thread, a pool or a virtual thread. Only start() creates a thread: the JVM asks the OS for a native thread with its own stack and that thread calls run(). Calling run() yourself executes on the current thread, and starting a thread twice throws IllegalThreadStateException. In production you rarely call new Thread per request: use an ExecutorService or, on Java 21, virtual threads, and name your threads. Exceptions from submit() hide inside the Future until someone calls get().
What you’ll learn
- Define work as a
RunnableorCallable<V>and explain **why passing a task beats extendingThread** - Run a
Callablethrough a **FutureTaskorExecutorService.submit()** and read it withFuture.get(), timeouts andExecutionException - Show that **
run()executes on the caller's thread** whilestart()creates a new one, and that a secondstart()throwsIllegalThreadStateException - Walk through **what
start()does under the hood**:start0(),JVM_StartThread,pthread_createand an-Xsssized stack - Create named platform and virtual threads with the **Java 21
Thread.BuilderAPI**,factory()andnewVirtualThreadPerTaskExecutor() - Pass data into threads safely and get results out without unsynchronized shared fields
- Explain why **exceptions from
submit()vanish** unless someone callsget(), and howexecute()andUncaughtExceptionHandlerdiffer - Quote the cost of a platform thread (tens of µs, ~1 MB stack on Linux/x64) and why production code uses pools or virtual threads
Interview radar
Asked very often: Runnable vs Callable? What happens if you call run() instead of start()?
Core follow-up: Can you start a thread twice? Why prefer implementing Runnable over extending Thread?
Production angle: A task submitted to a pool failed and nothing was logged. Why, and how do you fix it?
01Picture it first: the job ticket and the worker
Think of a print shop. A job ticket says what to print; a worker picks up the ticket and does it. You can write a ticket without hiring anyone, and one worker can handle a thousand tickets in a day. Runnable and Callable are tickets. Thread is a worker, and an ExecutorService is the shop manager who keeps a few workers busy.
Extending Thread is like printing the instructions on the worker's uniform: it works, but that worker can only ever do that one job. That's the core reason modern code passes tasks to threads instead of subclassing them.
The other half of the picture is the difference between doing the job yourself and handing it over. Here's the same Thread object used two ways. Calling run() is doing the dishes yourself: nothing else happens in the house until you're done. Calling start() is asking your flatmate to do them while you get on with something else.
Calling run() puts the task on the main thread's own stack, while start() creates a second thread with its own stack that runs the task
02Three ways to define a task
In plain words, there are only two kinds of errand. "Go water the plants" is a Runnable: you send someone off and don't expect anything back. "Go count how many plants need water and tell me" is a Callable: you expect an answer, and you want to hear about it if something went wrong on the way. Extending Thread is the third, older option, where the errand and the person doing it are welded together.
Try it yourself first
Before reading on, predict which thread name each line prints. Then guess: can you pass the Callable straight to new Thread(...)?
Here's the smallest program that shows all three shapes, plus the two ways to run a Callable:
import java.util.concurrent.*;
public class ThreeWays {
static class ReportThread extends Thread { // way 1: subclass Thread
ReportThread() { super("report-thread"); }
@Override public void run() { log("subclass run()"); }
}
static void log(String msg) {
System.out.println(Thread.currentThread().getName() + ": " + msg);
}
static void sendEmail() { log("method reference"); }
public static void main(String[] args) throws Exception {
Thread t1 = new ReportThread();
Thread t2 = new Thread(() -> log("lambda Runnable"), "lambda-thread"); // way 2
Thread t3 = new Thread(ThreeWays::sendEmail, "mail-thread");
t1.start(); t2.start(); t3.start();
t1.join(); t2.join(); t3.join();
Callable<Integer> countOrders = () -> { // way 3
log("callable computing");
return 42;
};
FutureTask<Integer> futureTask = new FutureTask<>(countOrders);
new Thread(futureTask, "futuretask-thread").start();
System.out.println("FutureTask result: " + futureTask.get()); // blocks
try (ExecutorService pool = Executors.newFixedThreadPool(2)) { // AutoCloseable since 19
Future<Integer> f = pool.submit(countOrders);
System.out.println("pool result: " + f.get(1, TimeUnit.SECONDS));
}
}
}
One run printed (the first three lines come out in any order):
mail-thread: method reference
lambda-thread: lambda Runnable
report-thread: subclass run()
futuretask-thread: callable computing
FutureTask result: 42
pool-1-thread-1: callable computing
pool result: 42
Notice four things:
Runnableis a functional interface, so a lambda or a method reference (ThreeWays::sendEmail) is a complete task. An anonymousnew Runnable() { public void run() {...} }does the same with more noise; the one real difference is thatthisinside an anonymous class means the anonymous object, while in a lambda it means the enclosing instance.Callable<V>.call()returns a value and is declaredthrows Exception, so checked exceptions are allowed.Runnable.run()returnsvoidand can't throw checked exceptions.- A
Callablecan't go intonew Thread(...)directly. Wrap it in aFutureTask, which implements bothRunnableandFuture, orsubmit()it to an executor. get()blocks until the result is ready.get(1, TimeUnit.SECONDS)gives up withTimeoutException. Ifcall()threw,get()throwsExecutionExceptionwith the original as its cause. Cancellation and composition belong to the "Future and Callable" lesson.
A Thread subclass, a Runnable and a Callable each reach a thread by a different route, and only the Callable path hands back a value or an ExecutionException
Why Runnable beats extending Thread
- Single inheritance.
class Job extends Threadburns your only superclass on plumbing.class Job extends BaseJob implements Runnablekeeps it. - Task separate from worker. A
Runnablesays what to do; theThreaddecides how it runs (name, daemon flag, stack size). Mixing them makes both harder to test: you can calltask.run()in a unit test with no threads at all. - Reuse. The same
Runnablegoes tonew Thread,Thread.ofVirtual(),pool.executeorpool.submitwithout changes. AThreadsubclass can only ever be started once, by itself.
Extending Thread is fine for a tiny demo or when you genuinely customise thread behaviour. For application work, my default is a Runnable or Callable and an executor.
03start() vs run(), and starting twice
Try it yourself first
Predict the four lines this prints. Which thread does worker.run() run on, and what happens on the second start()?
public class StartVsRun {
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> System.out.println("task runs on: " + Thread.currentThread().getName());
Thread worker = new Thread(task, "worker-1");
System.out.println("state before: " + worker.getState());
worker.run(); // a plain method call on main's stack
worker.start(); // a new OS thread, which then calls run()
worker.join();
System.out.println("state after join: " + worker.getState());
try {
worker.start(); // second start on the same Thread object
} catch (IllegalThreadStateException e) {
System.out.println("second start(): " + e);
}
}
}
state before: NEW
task runs on: main
task runs on: worker-1
state after join: TERMINATED
second start(): java.lang.IllegalThreadStateException
run() is an ordinary method. Calling it pushes a frame on your stack, runs the task on your thread and returns; no thread is created, and the caller waits for it. start() is the only call that makes a new thread. join() makes the caller wait until that thread finishes, which is why "state after join" is reliably TERMINATED.
A Thread object is single-use. start() checks an internal status field and throws IllegalThreadStateException if the thread was ever started, even after it has terminated. To run the work again, create a new Thread with the same Runnable, or better, submit the task to a pool that reuses its threads for you.
04What start() really does
Here's the everyday version first. new Thread(task) is writing a job offer: it's on paper, nobody has been hired yet. start() is the moment HR actually hires the person and gives them a desk. The desk is the thread's stack, HR is the operating system, and if the building is full, HR says no. Now the real steps.
Let's follow one start() call for a platform thread on Linux, using the JDK 21 sources (Thread.java, HotSpot's JVM_StartThread in jvm.cpp and os_linux.cpp).
start() checks the thread status, calls native start0, HotSpot asks the OS for a native thread with an -Xss sized stack, start() returns while the new thread enters Thread.run
new Thread(task)allocates a plain Java object on the heap, stateNEW. No OS resource exists yet.start()synchronizes on the thread, checks the status is still "never started" and calls the nativestart0().- HotSpot's
JVM_StartThreadcreates its C++JavaThreadand asks the OS for a native thread. On Linux that'spthread_create, with a stack size from theThreadconstructor'sstackSizehint or else-Xss(1 MB on Linux/x64, 2 MB on Linux/AArch64 and macOS/AArch64). - If the OS refuses (process or user thread limits, container PID limits, no address space for the stack),
start()throwsOutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached. Older JDKs said "unable to create new native thread". - The thread is marked
RUNNABLEand released, andstart()returns at once. It does not wait forrun()to begin. - The new thread begins in the JVM's thread entry routine, which calls
Thread.run(), which calls your task. Whenrun()returns or throws, the thread isTERMINATEDand its OS thread and stack are released.
The JLS gives you one guarantee for free here: a call to start() happens-before every action in the started thread (JLS §17.4.5). Whatever you wrote before start(), the new thread sees.
05The Java 21 way: Thread.Builder
Java 21 (JEP 444) added builders that make the thread's configuration explicit and give you virtual threads with the same API. Think of a builder as a form you fill in once (name pattern, daemon or not, platform or virtual) and then use to stamp out as many threads as you like, all set up the same way.
Try it yourself first
Guess the names: what does a builder with name("worker-", 0) call its second thread? What's the name of a virtual thread you didn't name?
import java.util.concurrent.*;
public class Builders {
static void show() {
Thread t = Thread.currentThread();
System.out.println("name='" + t.getName() + "' virtual=" + t.isVirtual() + " daemon=" + t.isDaemon());
}
public static void main(String[] args) throws Exception {
Thread.Builder.OfPlatform workers = Thread.ofPlatform().name("worker-", 0).daemon(false);
Thread a = workers.start(Builders::show); // worker-0
Thread b = workers.unstarted(Builders::show); // worker-1, state NEW
System.out.println("b is " + b.getState());
b.start();
a.join(); b.join();
Thread v = Thread.ofVirtual().name("vt-", 0).start(Builders::show);
v.join();
Thread.startVirtualThread(Builders::show).join(); // unnamed: ""
ThreadFactory factory = Thread.ofPlatform().name("orders-", 1).factory();
try (ExecutorService pool = Executors.newFixedThreadPool(2, factory)) {
pool.submit(Builders::show).get();
}
try (ExecutorService vexec = Executors.newVirtualThreadPerTaskExecutor()) {
vexec.submit(Builders::show).get();
}
System.out.println("default name: " + new Thread(() -> {}).getName());
}
}
b is NEW
name='worker-1' virtual=false daemon=false
name='worker-0' virtual=false daemon=false
name='vt-0' virtual=true daemon=true
name='' virtual=true daemon=true
name='orders-1' virtual=false daemon=false
name='' virtual=true daemon=true
default name: Thread-0
Notice five things:
name(prefix, start)numbers threads per builder:worker-0,worker-1.unstarted()returns a configured thread in stateNEWfor you to start later.- Virtual threads are always daemon and have an empty name unless you set one.
Thread.ofVirtual().start(r)andThread.startVirtualThread(r)do the same thing. factory()turns a builder into aThreadFactory, the interface every executor uses to create its threads, so pool threads get your names. The factory is safe to share between threads.ExecutorServiceisAutoCloseablesince Java 19.close()stops new submissions and waits for every submitted task to finish, so try-with-resources is the clean scope for short-lived pools.newVirtualThreadPerTaskExecutor()(Java 21) starts one new virtual thread per task.- Unnamed platform threads get
Thread-N, from a JVM-wide counter. Builder-named threads don't consume it, which is why this one isThread-0. Pools fromExecutorsdefault topool-N-thread-M.
Name every thread you create. A thread dump or log line that says pool-7-thread-3 tells you nothing at 3 a.m.; payment-retry-3 tells you where to look. The classic constructors take a name too: new Thread(task, "payment-retry-3").
06Passing data in, getting results out
Think of handing a courier a sealed envelope: once it leaves your hand, nobody should be able to swap what's inside. That's the rule for passing data to a thread.
Data goes in through the constructor of your task or through captured variables. A lambda can capture a local only if it's effectively final, which is a feature: the thread gets a value nobody can reassign under it.
String orderId = request.orderId(); // effectively final
Thread t = Thread.ofPlatform().name("order-" + orderId)
.start(() -> fulfil(orderId)); // captured by value
Results come out best through a Callable and its Future: get() hands you the value with the right memory visibility built in. Writing to a shared field and having another thread poll it without synchronization is a data race: the reader may never see the write. Reading a field after t.join() returns is safe, because a thread's last action happens-before join() returns (JLS §17.4.4), but that only works for one-shot threads you own. The "Happens-before" lesson makes this precise.
07When a thread throws
An uncaught exception ends only the thread that threw it. The JVM calls the thread's UncaughtExceptionHandler; if you set none, it falls back to Thread.setDefaultUncaughtExceptionHandler, and if that's unset too, the thread group prints Exception in thread "name" and the stack trace to System.err. Other threads, including main, carry on.
Executors add a twist. Here's the program I use to show it:
import java.util.concurrent.*;
public class LostException {
public static void main(String[] args) throws Exception {
Thread t = new Thread(() -> { throw new IllegalStateException("boom in plain thread"); }, "plain");
t.start();
t.join();
System.out.println("main is still alive");
Thread.setDefaultUncaughtExceptionHandler((th, ex) ->
System.out.println("handler saw '" + ex.getMessage() + "' on " + th.getName()));
try (ExecutorService pool = Executors.newFixedThreadPool(1)) {
pool.submit(() -> { throw new IllegalStateException("boom in submit"); }); // Future ignored
Future<?> kept = pool.submit(() -> { throw new IllegalStateException("boom, get() called"); });
try {
kept.get();
} catch (ExecutionException e) {
System.out.println("get() threw ExecutionException, cause: " + e.getCause().getMessage());
}
pool.execute(() -> { throw new IllegalStateException("boom in execute"); });
}
System.out.println("done (nobody ever heard about 'boom in submit')");
}
}
Exception in thread "plain" java.lang.IllegalStateException: boom in plain thread
at LostException.lambda$main$0(LostException.java:6)
at java.base/java.lang.Thread.run(Thread.java:1516)
main is still alive
get() threw ExecutionException, cause: boom, get() called
done (nobody ever heard about 'boom in submit')
handler saw 'boom in execute' on pool-1-thread-1
I ran this on JDK 26; on 21 the Thread.java line number differs. The last two lines can swap, because the dying pool worker reports its exception after the pool has already counted it as finished. To handle one thread's failures differently, call t.setUncaughtExceptionHandler(...) on it before start(), or set it once on a builder with uncaughtExceptionHandler(...).
Here's the layman's picture. execute() is a courier who rings your doorbell when a delivery fails: you can't miss it. submit() is a courier who quietly drops the failure note in your letterbox. If you never open the letterbox (call get()), you never find out.
submit() wraps your task in a FutureTask, which catches everything and stores it in the Future. If nobody calls get(), the exception is gone: no log, no handler, no trace. execute() doesn't wrap, so the exception escapes, kills that pool worker (the pool replaces it) and reaches the uncaught handler. My rule: use execute() for fire-and-forget work, or submit() and always consume the Future; and in either case catch and log inside the task.
08Real world
Spring's @Async void methods ran on an executor and failed with no trace at all: issue SPR-8995 ("exceptions in Async proxied methods fail silently") collected 19 votes before Spring 4.1 added AsyncUncaughtExceptionHandler. Tomasz Nurkiewicz's widely shared "ExecutorService: 10 tips and tricks" lists the same trap (submit(() -> 1/0) prints nothing) next to "name pool threads", because the default pool-N-thread-M names make dumps unreadable. The opposite failure is too many threads: in a public Jetty reactive HttpClient issue, a load test with cancelled requests leaked client threads until new requests failed with OutOfMemoryError: unable to create native thread. Thread creation is an OS request, and the OS says no long before the heap is full.
09Advanced: costs, constructors and comparisons
Many candidates say "run() starts the thread, start() is the old way" or wrap a Callable in a Runnable lambda and wonder where the result went. run() never creates a thread. The second classic: "my pool task failed but there's no stack trace, so it must have succeeded". With submit(), a missing log means you didn't call get(), not that the task worked.
I measured creating, starting and joining 10,000 empty threads on my laptop (Apple M4 Pro, macOS, JDK 26, several runs of the program below): about 35 to 40 µs per platform thread versus 1 to 2 µs per virtual thread. Each platform thread also reserves its full -Xss stack as address space (1 MB on Linux/x64, 2 MB on this Mac) and costs a kernel thread, while a virtual thread's stack lives on the heap and grows as needed. Linux numbers differ in detail but sit in the same tens-of-microseconds range for platform threads; run the benchmark below on your own box before quoting a figure. Tens of microseconds per request is real latency, and 1 MB per request is real memory: never create a platform thread per request in production. Use a bounded pool, or on Java 21 a virtual thread per task.
import java.util.ArrayList;
import java.util.List;
public class ThreadCost {
static long nanosPerThread(Thread.Builder builder, int n) throws InterruptedException {
List<Thread> threads = new ArrayList<>(n);
long start = System.nanoTime();
for (int i = 0; i < n; i++) threads.add(builder.start(() -> { })); // empty task
for (Thread t : threads) t.join();
return (System.nanoTime() - start) / n;
}
public static void main(String[] args) throws InterruptedException {
for (int round = 1; round <= 3; round++) { // round 1 includes JIT warm-up
System.out.printf("round %d: platform %,6d ns/thread virtual %,5d ns/thread%n", round,
nanosPerThread(Thread.ofPlatform(), 10_000), nanosPerThread(Thread.ofVirtual(), 10_000));
}
}
}
round 1: platform 35,581 ns/thread virtual 1,932 ns/thread
round 2: platform 35,162 ns/thread virtual 1,340 ns/thread
round 3: platform 35,109 ns/thread virtual 1,416 ns/thread
Some depth that separates strong answers:
- The full constructor is
Thread(ThreadGroup group, Runnable task, String name, long stackSize).stackSizeis a hint the Javadoc says some platforms ignore; prefer-XssorThread.ofPlatform().stackSize(...). Thread groups are legacy: since Java 19 they're kept mostly for compatibility, so don't design around them. run()on a virtual thread does nothing when you call it directly (Java 21 Javadoc). One more reason never to callrun()yourself.- Pool threads are non-daemon by default, so a forgotten
newFixedThreadPoolkeeps the JVM alive aftermainends. Try-with-resources orshutdown()fixes it. - Java 19 added
Future.state(),resultNow()andexceptionNow(), handy for inspecting finished futures without the checkedget()dance. getId()is deprecated since Java 19; usethreadId().
Thread vs Runnable vs Callable vs Thread.Builder vs ExecutorService
Thread |
Runnable |
Callable<V> |
Thread.Builder (21) |
ExecutorService |
|
|---|---|---|---|---|---|
| What it is | The worker (class) | A task, void run() |
A task, V call() throws Exception |
Configures and creates threads | Manages workers, queues tasks |
| Returns a value | No | No | Yes, via Future<V> |
No | Yes, submit() returns a Future |
| Checked exceptions | No | No | Yes | n/a | Captured in the Future |
| Creates OS threads | Yes, on start() |
No | No | Yes (platform) or no (virtual) | Yes, reused in a pool, or one virtual per task |
| Reusable | No, start once | Yes | Yes | Yes, builds many threads | Yes, until shut down |
| Use it for | Demos, custom thread types | Fire-and-forget work | Work with a result | Named, configured threads; virtual threads | Production default |
10How to say it in the interview
When I ask this, I'm listening for three things. Do you keep task and thread separate and know why Runnable wins over extending Thread? Do you know that only start() creates a thread, with a one-way NEW to started transition? And do you know where exceptions and results go once an executor is involved?
Three questions come up almost every time; here's how I'd answer each in two sentences.
- "Runnable vs Callable?"
Runnable.run()returns nothing and can't throw checked exceptions;Callable<V>.call()returns aVand may throwException. You run aCallablethroughExecutorService.submit()or aFutureTask, and read the result or the wrappedExecutionExceptionwithFuture.get(). - "What happens if you call run() instead of start()?" The task runs as a normal method call on the current thread, so
Thread.currentThread().getName()prints the caller's name and nothing runs in parallel. No OS thread is created and theThreadstaysNEW. - "Can you start a thread twice?" No. The second
start()throwsIllegalThreadStateException, even after the first run finished; create a newThreador use a pool.
In the interview
"I define the work as a Runnable, or a Callable if I need a result or checked exceptions, and keep it separate from the thread that runs it. That keeps my one superclass free and lets the same task run on a plain thread, a pool or a virtual thread. Only start() creates a thread: the JVM asks the OS for a native thread, pthread_create on Linux, with a stack sized by -Xss, about 1 MB on x64, and that thread calls run(). Calling run() directly runs on my own thread, and a second start() throws IllegalThreadStateException. In production I don't create a thread per request: platform threads cost tens of microseconds and a megabyte of stack each, so I use a named, bounded pool or, on Java 21, a virtual thread per task. And I watch exceptions: submit() stores them in the Future, so if nobody calls get() they vanish, while execute() sends them to the uncaught exception handler."
Here's how this might look if you sketch it on the whiteboard:
On the whiteboard
Creating threads
- task = Runnable (void) | Callable
(returns V, throws Exception) - worker = Thread; prefer passing a task over extends Thread
- start(): NEW → native thread (pthread_create, -Xss 1 MB x64) → run()
- run() directly = same thread, no new stack
- start() twice → IllegalThreadStateException
- Callable → FutureTask or pool.submit → Future.get() / ExecutionException
- Java 21: Thread.ofPlatform().name("w-", 0), ofVirtual(), factory()
- submit() hides exceptions in Future; execute() → uncaught handler
- prod: named pool or virtual thread per task, never thread per request
Further reading (14)
- Thread (Java SE 21 API): start(), run(), IllegalThreadStateException, constructors with stackSize, default Thread-N names, virtual thread run() does nothing
- Thread.Builder (Java SE 21 API): ofPlatform/ofVirtual, name(prefix, start), unstarted(), factory()
- FutureTask (Java SE 21 API): a RunnableFuture that wraps a Callable
- ExecutorService (Java SE 21 API): submit vs execute, close() and AutoCloseable since 19
- Executors (Java SE 21 API): newFixedThreadPool, defaultThreadFactory naming, newVirtualThreadPerTaskExecutor
- Java Language Specification (Java SE 21) Chapter 17: Threads and Locks: §17.4.4 thread termination and join, §17.4.5 start() happens-before
- JEP 444: Virtual Threads: Thread.Builder API, virtual threads final in Java 21
- The java command (Java SE 21): -Xss: default stack 1024 KB Linux/x64, 2048 KB Linux/AArch64 and macOS/AArch64
- OpenJDK 21 HotSpot source: jvm.cpp (JVM_StartThread): IllegalThreadStateException check and OutOfMemoryError when no native thread can be created
- OpenJDK 21 HotSpot source: os_linux.cpp (os::create_thread): pthread_attr_setstacksize and pthread_create
- Java Concurrency in Practice (Goetz et al., 2006): ch. 6 Task Execution (tasks vs threads, Executor), ch. 7.3 Handling Abnormal Thread Termination
- Tomasz Nurkiewicz: ExecutorService, 10 tips and tricks: name pool threads; exceptions from submit() are swallowed
- Spring Framework issue SPR-8995 (#13635): @Async should have an UncaughtExceptionHandler: @Async void methods failed silently until Spring 4.1
- Jetty reactive HttpClient issue #22: thread leak: leaked threads ended in OutOfMemoryError: unable to create native thread