How Java's Concurrency APIs Fit Together
DEV Community

How Java's Concurrency APIs Fit Together

A concurrency problem can begin with an ordinary requirement: let two independent operations make progress without forcing one to wait for the other. The requirement is easy to state. Choosing among Thread , ExecutorService , Future , CompletableFuture , locks, atomics, queues, and synchronizers is harder when they look like competing answers to the same question. These APIs answer different questions. Some describe work, some decide how it runs, some represent results, and others protect shared state or coordinate tasks. Grouping them by responsibility turns the standard library from a list of names into a map. We will build that map around a small customer-dashboard example, then use shorter examples for the problems the dashboard does not cover. The examples assume basic Java, not prior concurrency knowledge. Start with the responsibility the program needs; choose the class after that. Start with a program that waits twice Suppose an endpoint builds a customer dashboard from a profile and a list of recent orders: var profile = loadProfile(); var orders = loadOrders(); return new Dashboard(profile, orders); The code fragments below omit imports and surrounding class definitions when those details do not affect the concurrency mechanism. The second call starts after the first one returns. If the operations are independent and spend most of their time waiting for remote services, that order leaves an obvious opportunity: start both, then assemble the dashboard when both results are available. That small change already involves three separate responsibilities: describe each operation, decide how to execute it, and obtain its result. The full map uses seven questions: | Question | Representative APIs | |---|---| | What work should happen? | Runnable , Callable | | Where and how should it run? | Thread , Executor , ExecutorService , virtual threads | | How do I obtain or combine results? | Future , CompletableFuture | | How do I protect shared state? | synchronized , volatile , Lock , atomic variables | | How do I share data safely? | ConcurrentHashMap , BlockingQueue | | How do tasks coordinate? | Semaphore , CountDownLatch , CyclicBarrier | | How do I divide computation? | ForkJoinPool , parallel streams | These groups overlap. CompletableFuture , for example, represents a result and can also arrange dependent execution. Treat the groups as questions for locating a problem rather than rigid boxes for classifying every type. On a first pass, focus on tasks, executors, futures, and the shared-state sections. Learn queues and semaphores as named solutions for handoff and limits. Leave CyclicBarrier , fork/join, and continuation-scheduling rules as landmarks until a program gives you one of those problems. One vocabulary distinction will help throughout the map. Concurrent tasks make progress during overlapping periods; they do not have to execute at the same instant. Parallel work does execute at the same instant. A single CPU core can interleave concurrent tasks, while parallel execution requires hardware resources that can run work simultaneously. We will use concurrency for the waiting-heavy dashboard and reserve parallel computation for a separate data-processing example. Tasks describe work A task describes work. A thread is one possible execution mechanism. Java's basic task interfaces make that separation visible. Runnable represents an operation with no result,1 while Callable returns a value and may throw an exception.2 Neither interface chooses the thread that will run it. Their contracts only describe the operation. Runnable refreshCache = () -> cache.refresh(); Callable loadProfile = () -> profileClient.fetchProfile(customerId); Creating either value does not start the work. A caller could invoke run() or call() directly, or it could hand the task to an executor. That later choice determines the execution policy. The code that describes a cache refresh stays unchanged whether the application runs it immediately, schedules it in a pool, or starts a virtual thread for it. Use Thread directly when the program intentionally owns one thread's identity or lifecycle, such as naming it, installing its uncaught-exception handler, or joining that specific thread. An executor is the better fit when thread creation, scheduling, and lifecycle belong to an execution policy rather than the task itself.3 Executors decide how work runs An Executor receives a Runnable and applies an execution policy. Its interface separates task submission from details such as thread creation, reuse, and scheduling. The Executor contract does not promise asynchronous execution: a minimal executor is allowed to call command.run() in the submitting thread, while other implementations create threads or reuse them from a pool.4 ExecutorService adds lifecycle operations and methods such as submit , which returns a Future .5 An application should close or shut down an executor service when it no longer needs it. In modern Java, the interface is AutoCloseable , so a try-with-resources block can own that lifecycle. The executable examples target Java 21 or newer because they use virtual threads; the responsibility map and the older APIs apply more broadly. Here is the dashboard using one virtual thread per submitted task: Dashboard loadDashboard() throws Exception { try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { Future profile = executor.submit(this::loadProfile); Future > orders = executor.submit(this::loadOrders); return new Dashboard(profile.get(), orders.get()); } } Both tasks are submitted before the code waits for either result. Their executions can therefore overlap when the executor can run them concurrently. Submission alone is not a guarantee of overlap; the executor owns that policy and may be constrained by available resources. The following order prevents the two dashboard operations from overlapping: var profile = executor.submit(this::loadProfile).get(); var orders = executor.submit(this::loadOrders).get(); The first get() may block before the second line can submit loadOrders . Keep the handles first and the waits after both submissions when the operations are independent. Virtual threads became a final feature in Java 21. They are Thread instances scheduled by the JDK rather than permanent one-to-one wrappers around operating system threads. They suit thread-per-task code with many concurrent operations that often wait, which describes calls to remote services. They do not make CPU-bound code run faster or make timing-dependent access to shared mutable state safe. JEP 444 also advises against pooling virtual threads.6 Create one per task; use a separate mechanism when a scarce resource needs a concurrency limit. Results can be waited for or composed A Future is a handle to a result that may not exist yet. get() waits when necessary and then returns the value or reports the computation's failure. A Future also exposes completion status and cancellation. Under the Future contract, cancel(...) attempts cancellation; already-running work may continue, depending on timing and whether the task responds to interruption.7 The dashboard example uses two plain futures because its control flow is short: submit two operations, wait for both, and build one value. The blocking get() calls do not erase the earlier concurrency because both submissions already happened. CompletableFuture becomes useful when later work should depend on earlier results. It implements both Future and CompletionStage , which adds operations for transforming and combining completed stages. Start with one result and one transformation: CompletableFuture profile = CompletableFuture.supplyAsync(this::loadProfile, executor); CompletableFuture label = profile.thenApply(name -> "Customer: " + name); After profile completes normally, thenApply passes its result to the function and produces another stage containing the label. No get() separates the two steps.8 If we translated the earlier two-input dashboard directly, CompletableFuture would mostly look like a syntax swap around the same fixed fan-out. Add one real dependency instead: recommendations can start only after the profile is available, while orders can still load independently. profile + orders ----------> dashboard -------------\ profile -------------------> recommendations --------+--> personalized dashboard The code mirrors that dependency graph: try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { CompletableFuture profile = CompletableFuture.supplyAsync(this::loadProfile, executor); CompletableFuture > orders = CompletableFuture.supplyAsync(this::loadOrders, executor); CompletableFuture > recommendations = profile.thenCompose( loadedProfile -> loadRecommendations(loadedProfile, executor)); CompletableFuture dashboard = profile.thenCombine(orders, Dashboard::new); return dashboard.thenCombine( recommendations, (loadedDashboard, loadedRecommendations) -> new PersonalizedDashboard( loadedDashboard.profile(), loadedDashboard.orders(), loadedRecommendations)) .join(); } The asynchronous suppliers receive the executor explicitly because CompletableFuture async methods without one normally use ForkJoinPool.commonPool() .8 profile and orders start independently. After profile completes normally, thenCompose invokes the recommendation function and flattens the CompletableFuture it returns. The first thenCombine builds a dashboard after both independent inputs arrive. The second combines that dashboard with the recommendations, and join() waits once at the request boundary. Plain Future and CompletableFuture overlap, but they encourage different control flow. Plain Future remains adequate for a short, fixed set of independent tasks when the caller can submit them together and wait at one clear boundary. Completion stages help when one result starts later work or several branches must converge. The example stops at composition. Production code still needs an explicit policy for errors

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.