Key Takeaways
- Java threads share process memory, which makes communication convenient but requires coordination around shared mutable state.
- Define work with Runnable or Callable, then choose an executor. Direct Thread creation is useful for learning, not the default design for a request-handling service.
- Virtual threads suit large numbers of blocking I/O tasks; they don't increase CPU parallelism or remove resource limits.
- Bound admitted work, queued tasks, and downstream resource usage separately.
- Prevent lock cycles with consistent ordering, and never assume a thread pool makes dependent tasks deadlock-free.
Java multithreading lets a program make progress on concurrent tasks within one process. Threads can share objects, but you must coordinate access when multiple tasks read and change the same state. These examples cover thread creation, executors, a request-handling server, and deadlock prevention.
What Is Multithreading in Java?
A process normally has its own memory space. Threads within that process share its memory and resources, although each thread has its own execution stack. Sharing objects is easier than communicating across process boundaries, but one thread can interfere with another's changes. Our Java heap versus stack explanation covers the memory distinction.
Concurrency means tasks make progress during overlapping periods; parallelism means work executes simultaneously. A program can be concurrent on one CPU core by switching between tasks. Adding threads doesn't guarantee a speedup: scheduling, coordination, and contention can outweigh the useful work.
- CPU-bound work spends most of its time calculating. Limit active computation to a measured level appropriate for the available CPU capacity.
- Blocking I/O work spends substantial time waiting for sockets, databases, or files. Concurrency can let other requests progress during those waits.
Platform threads are typically backed by operating-system threads. Virtual threads are scheduled by the Java runtime onto platform threads, allowing many more mostly waiting tasks without a dedicated OS thread for every task. Neither model makes individual calculations inherently faster.
Java Thread Creation Examples: Tasks Versus Threads
Extending Thread and Implementing Runnable
Both approaches below create platform threads. Save the example as ThreadsExample.java; the helper classes are nested so the file contains only one public top-level class.
public class ThreadsExample {
static class MyThread extends Thread {
@Override
public void run() {
System.out.println("This thread is running");
}
}
static class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("This runnable is running");
}
}
public static void main(String[] args) throws InterruptedException {
Thread first = new MyThread();
Thread second = new Thread(new MyRunnable());
first.start();
second.start();
first.join();
second.join();
}
}start() schedules a thread to execute; join() waits for it to finish. Calling run() directly isn't how you start concurrent execution. The order of the two messages isn't guaranteed.
Extending Thread prevents your class from extending another class, because Java classes have only one direct superclass. Implementing Runnable keeps the task separate from its execution mechanism and leaves that inheritance choice open. You can also express a Runnable with a lambda; see our Java lambda expressions guide.
Submitting Runnable and Callable Tasks to ExecutorService
A Runnable performs work without returning a result. A Callable<T> returns a result and can throw checked exceptions. An ExecutorService manages execution, while a Future represents a submitted task's eventual outcome.
Save this as TaskExample.java. The sleep simulates a blocking wait, not a performance benchmark.
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class TaskExample {
public static void main(String[] args) {
try (ExecutorService executor =
Executors.newVirtualThreadPerTaskExecutor()) {
Runnable log = () -> System.out.println("Request started");
Callable<String> request = () -> {
Thread.sleep(100);
return "response";
};
Future<?> logged = executor.submit(log);
Future<String> response = executor.submit(request);
logged.get();
System.out.println(response.get());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (ExecutionException e) {
System.err.println("Task failed: " + e.getCause());
}
}
}newVirtualThreadPerTaskExecutor() starts a new virtual thread for each task; it isn't a reusable thread pool. Both tasks are submitted before the program waits for their results.
Future.get() waits for completion and reports task failures through ExecutionException. A timed get limits your wait, not the task's execution time, and doesn't automatically cancel the task.
The try-with-resources block closes the executor and waits for its tasks to finish. That wait has no automatic deadline. For services, plan explicit shutdown deadlines with shutdown(), awaitTermination(...), and, if necessary, shutdownNow(); the last method only attempts to stop running tasks.
Sharing State Safely
Shared memory doesn't guarantee that another thread sees your writes correctly. The Java Memory Model defines visibility and ordering rules through a happens-before relationship: a guarantee that earlier effects are visible to later operations.
- Prefer task-local state: avoid sharing mutable objects when each task can own its data.
- Use synchronized for related changes: it provides mutual exclusion and visibility when all accesses use the same monitor.
- Use volatile for suitable flags: it provides visibility and ordering, but
volatile int countdoesn't makecount++atomic. - Use atomic classes for single-variable updates:
AtomicInteger.incrementAndGet()atomically increments a counter and returns its new value. - Publish objects safely: initialization before executor submission is visible to the submitted task; task effects are visible after successful
Future.get().
These guarantees come from synchronization and concurrency APIs, not from adding sleeps or hoping tasks run in a particular order.
ConcurrentHashMap supports concurrent access, but a sequence of individually thread-safe calls isn't automatically one atomic operation. Use an appropriate atomic collection operation or coordinate the whole invariant—the condition your data must always satisfy. Compare this with older collection choices in Java HashMap versus Hashtable.
Choosing Platform Threads, Virtual Threads, or Actors
| Approach | Good starting point | Coordination responsibility |
|---|---|---|
| Bounded platform-thread executor | CPU-heavy tasks or explicitly limited worker concurrency | Size workers and queues; handle rejected tasks. |
| Virtual thread per task | Many independent requests using blocking I/O | Bound admitted work and scarce downstream resources. |
| Actor library, such as Akka | Stateful components that interact through messages | Design messages, mailbox capacity, and failure handling. |
Actors offer a different way to organize state, not simply a faster executor: an actor processes its messages one at a time while different actors can work concurrently. Choose that model when message-driven ownership helps your application; ordinary request execution doesn't require an actor framework.
Bound the Queue, Not Just the Worker Count
Executors.newFixedThreadPool(10) limits active workers but uses an unbounded work queue. Under sustained overload, waiting tasks can accumulate and retain memory. A bounded ThreadPoolExecutor makes that capacity explicit.
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
// Fragment: own this pool in a try-with-resources scope.
ThreadPoolExecutor pool = new ThreadPoolExecutor(
4, 4, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<Runnable>(100),
new ThreadPoolExecutor.AbortPolicy());This configuration allows up to four worker tasks and 100 queued tasks. When saturated or shut down, AbortPolicy throws RejectedExecutionException; the submitting code must reject, retry under a bounded policy, or otherwise handle that failure. These numbers are example capacities, not sizing recommendations.
CallerRunsPolicy can slow submission by executing work on the submitting thread, but that can be the wrong place to block an accept loop or event loop. Rejection sheds load; backpressure makes producers slow down. Choose deliberately rather than silently dropping requests.
Practical Java Multithreaded Server Example
A web server often handles requests that wait on I/O. The same request-per-task idea is easier to see in a small TCP server: this example reads one command byte, replies OK for ? or ERR otherwise, and closes the connection. It isn't an HTTP implementation.
Save it as RequestServer.java. It listens only on loopback, admits at most 64 handlers, and stops accepting after roughly 30 seconds before waiting for admitted tasks to finish. The capacities and timeouts are demonstration settings.
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
public class RequestServer {
public static void main(String[] args) {
Semaphore slots = new Semaphore(64);
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
try (ExecutorService workers =
Executors.newVirtualThreadPerTaskExecutor();
ServerSocket server = new ServerSocket(
8080, 50, InetAddress.getByName("127.0.0.1"))) {
server.setSoTimeout(500);
while (System.nanoTime() - deadline < 0) {
Socket client;
try {
client = server.accept();
} catch (SocketTimeoutException e) {
continue; // Recheck the admission deadline.
}
if (!slots.tryAcquire()) {
close(client); // Shed excess load without waiting.
continue;
}
try {
workers.execute(() -> {
try (client) {
client.setSoTimeout(2_000);
int command = client.getInputStream().read();
if (command != -1) {
String reply = command == '?' ? "OK\n" : "ERR\n";
client.getOutputStream().write(
reply.getBytes(StandardCharsets.US_ASCII));
}
} catch (IOException e) {
System.err.println("Request failed: " + e);
} finally {
slots.release();
}
});
} catch (RejectedExecutionException e) {
slots.release();
close(client);
System.err.println("Request rejected: " + e);
}
}
} catch (IOException e) {
System.err.println("Server failed: " + e);
}
}
private static void close(Socket socket) {
try {
socket.close();
} catch (IOException e) {
System.err.println("Socket close failed: " + e);
}
}
}A waiting client doesn't stop the accept loop from assigning another client to a different task. Semaphore.tryAcquire() rejects excess admitted work without building a queue of waiting virtual threads. The handler releases its permit even when reading, writing, or closing fails.
Platform pool: requests -> bounded queue -> limited workers
Virtual tasks: requests -> admission gate -> virtual thread per task
|
separate database limitThe 64 permits limit admitted handlers, not database connections or the OS connection backlog. If a request later needs a database connection, use the database pool's own capacity and acquisition deadline rather than assuming virtual threads make that resource unlimited.
How to Avoid Deadlock in Java
Understanding the Lock Cycle
Deadlock occurs when tasks cannot progress because each is waiting for a resource another holds. Two threads can create a cycle by acquiring the same two locks in opposite orders; unrelated threads may continue running.
Thread 1 holds A -> waits for B
Thread 2 holds B -> waits for AThis DeadlockExample.java deliberately hangs. The latch makes both threads acquire their first lock before either attempts its second; unlike a sleep-based example, it doesn't depend on a lucky delay. Run it separately and terminate the process afterward.
import java.util.concurrent.CountDownLatch;
public class DeadlockExample {
private static final Object A = new Object();
private static final Object B = new Object();
private static final CountDownLatch HELD = new CountDownLatch(2);
private static void lockBoth(Object first, Object second) {
synchronized (first) {
HELD.countDown();
try {
HELD.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
synchronized (second) {
throw new AssertionError("Unreachable without interruption");
}
}
}
public static void main(String[] args) {
new Thread(() -> lockBoth(A, B)).start();
new Thread(() -> lockBoth(B, A)).start();
}
}Dedicated Object instances make lock ownership explicit. Don't synchronize on boxed integers: Integer is value-based, and synchronization on value-based instances is strongly discouraged.
Strategies to Avoid Deadlock
1. Reduce Shared State and Explicit Locking
Use task ownership, concurrent collections, or atomic variables where they fit. These reduce some manual coordination; they don't eliminate application-level cycles or make arbitrary multi-step operations atomic. Avoid holding locks during network calls or other unpredictable waits.
2. Use One Consistent Lock Order
If nested locks are necessary, every path must acquire them in the same global order. Here both callers enter through update() and acquire A before B, removing the opposite-order cycle.
// Fragment: all access to these counters uses this locking protocol.
final class OrderedState {
private final Object a = new Object();
private final Object b = new Object();
private int left;
private int right;
void update() {
synchronized (a) {
synchronized (b) {
left++;
right++;
}
}
}
}3. Check Thread-Pool Task Dependencies
A pool doesn't inherently reduce deadlock risk. In a single-worker pool, a parent task that submits a child to the same pool and waits on its Future can occupy the only worker forever: the queued child never gets a worker. Larger pools can suffer the same thread-starvation deadlock when all workers wait this way.
Don't block workers waiting for dependent work that requires those same exhausted workers. Restructure the dependency or use an execution design that guarantees the child work can progress.
4. Use Timed Lock Acquisition With Cleanup
ReentrantLock.tryLock(timeout, unit) returns false if its waiting time expires and can throw InterruptedException. Release every acquired lock in a finally block.
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
// Fragment: callers handle false by aborting or scheduling a bounded retry.
final class TimedState {
private final ReentrantLock a = new ReentrantLock();
private final ReentrantLock b = new ReentrantLock();
private int updates;
boolean update() throws InterruptedException {
if (!a.tryLock(100, TimeUnit.MILLISECONDS)) {
return false;
}
try {
if (!b.tryLock(100, TimeUnit.MILLISECONDS)) {
return false;
}
try {
updates++;
return true;
} finally {
b.unlock();
}
} finally {
a.unlock();
}
}
}Each acquisition has its own timeout; this isn't a 100-millisecond deadline for the entire method. A timeout provides an escape path, not guaranteed eventual success. Avoid immediate, unlimited retries that merely turn blocking into repeated contention.
How Many Threads Can Run at Once in Java?
Separate the number of existing threads from the number doing useful CPU work simultaneously. CPU capacity limits parallel computation; thousands of waiting requests don't imply thousands of calculations running in parallel.
Platform-thread capacity depends on OS limits, native memory, stack configuration, JVM implementation, and workload. Virtual threads allow many more waiting tasks, but still consume memory and can retain sockets, request data, or downstream resources. There isn't a portable thread count that guarantees good performance.
Measure throughput, tail latency, CPU utilization, memory, queue depth, rejections, and downstream saturation. Increase concurrency only while useful throughput improves without unacceptable latency or resource pressure.
Testing and Diagnosing Concurrency Failures
Use latches or barriers to arrange important task interactions in tests instead of relying on sleeps. Give tests bounded waits, repeat contention scenarios, and test overload and shutdown—not just successful requests. A passing test run doesn't prove every possible interleaving is safe.
For a stuck JVM, collect thread stacks and inspect lock ownership and waiting tasks. The JDK's jcmd supports traditional thread output, a file dump that includes virtual threads, and Java Flight Recorder recordings. Replace the sample PID before running these commands.
PID=12345 # Replace with the target JVM process ID.
jcmd "$PID" Thread.print -l
jcmd "$PID" Thread.dump_to_file -format=json threads.json
jcmd "$PID" JFR.start name=concurrency settings=profile duration=30s filename=concurrency.jfrThread.print -l includes information about java.util.concurrent locks; use Thread.dump_to_file when you need virtual-thread stacks. The recording command captures a short profiling window for further investigation.
FAQ
What's the difference between a thread and a process?
A process normally has an isolated memory space. Threads within a process share memory and resources, making communication convenient but requiring coordination around shared mutable state.
Should I use Runnable, Callable, or Thread in Java?
Use Runnable for work without a returned result and Callable for work that returns a value or throws checked exceptions. Submit tasks to an appropriate executor; reserve direct Thread creation for cases where you deliberately manage the thread lifecycle.
When should I use virtual threads in Java?
Use virtual threads for many concurrent tasks that spend substantial time waiting on blocking I/O. They don't make CPU-bound calculations faster, and you must still limit admitted work and scarce resources such as database connections.
Can a Java thread pool cause a deadlock?
Yes. A task can occupy a worker while waiting for another task queued to the same saturated pool, creating thread-starvation deadlock. A thread pool also doesn't prevent ordinary lock-order cycles.
How many platform or virtual threads can Java run?
There is no portable useful maximum: platform-thread capacity depends on OS and JVM constraints, while virtual-thread capacity also depends on retained memory and workload resources. CPU capacity limits simultaneous computation regardless of how many tasks are waiting.
Further Reading
- Oracle's virtual-thread guide — adoption patterns and virtual-thread diagnostics.
- ExecutorService API — submission, memory guarantees, and shutdown.
- ThreadPoolExecutor API — worker sizing, bounded queues, and rejection policies.
- Java Language Specification: Threads and Locks — synchronization and the memory model.
- ReentrantLock API — interruptible and timed lock acquisition.
- jcmd command reference — thread dumps and recording commands.
- Akka's actor-model introduction — message processing and state ownership.
