You are currently viewing Java Multithreading Basics: Tasks, Shared State, and Waiting

Java Multithreading Basics: Tasks, Shared State, and Waiting

Two Java threads can each run counter++, yet the counter may rise by only one. Each thread reads the value, adds one, and writes the result back. If both read the same starting value, one write overwrites the other. That is the catch with shared data: tasks can run at the same time, but their updates need coordination.

First, decide whether concurrency helps

A thread is a path of execution within a process. Threads share the process's heap, so they can exchange data easily—and interfere with one another just as easily. Concurrency is useful for independent tasks, such as processing several files or handling requests while waiting for input. For a tiny calculation, though, starting extra tasks may cost more than doing the work directly.

I/O-bound work spends much of its time waiting for files or network responses, so overlapping those waits can help. CPU-bound work spends its time calculating; running it in parallel helps only if the machine has processing capacity available and the work can be split without too much coordination. Measure the outcome rather than assuming more threads will make a program faster.

Independent tasks running in a Java program

Start with tasks, not manually managed threads

For most beginner projects, define the work as a Runnable or Callable<T> and submit it to an executor. A Runnable does not return a result; a Callable returns a value and can throw an exception. The executor manages the worker threads while your code describes each task.

ExecutorService pool = Executors.newFixedThreadPool(2);
try {
    Future<Integer> result = pool.submit(() -> 6 * 7);
    System.out.println(result.get());
} finally {
    pool.shutdown();
}

The example needs imports from java.util.concurrent. The call to get() waits for the result and can throw InterruptedException or ExecutionException, so the surrounding method must handle or declare them. ExecutionException wraps a failure in the submitted task. The finally block starts an orderly shutdown even if waiting fails, but shutdown() does not wait for every task to finish.

A fixed pool puts an explicit limit on workers, not necessarily on pending work. If tasks arrive faster than they finish, they may pile up in a queue. For a service that accepts work continuously, choose a queue or admission policy that bounds pending tasks appropriately. For a small batch exercise, submit a known number of tasks, collect their results, and then shut down the executor.

Keep ownership of task results

It is easy to call submit() and ignore the returned Future. Then the code that started a failed task may never notice the failure. Keep the futures, call get() when you need the results, and decide what a failure means for the rest of the batch. If a result is no longer useful, cancel(true) requests interruption; it does not guarantee the task will stop.

Share less state

The easiest data race to avoid is one with no shared mutable data. Give each task its own input, calculate locally, and combine the results after the tasks finish. Immutable values such as strings are simpler to pass around because one thread cannot change the value another is reading.

When tasks must share state, choose a tool that fits the operation:

  • AtomicInteger: useful for an independent counter; incrementAndGet() makes the increment atomic.
  • synchronized: useful when several operations must act as one protected unit, such as checking a balance before changing it. Every access to that state must follow the same locking rule.
  • ConcurrentHashMap: supports concurrent map operations, but separate calls do not automatically form one atomic transaction. Use an operation such as compute() when an update must be coordinated per key.
  • BlockingQueue: lets producers hand items to consumers without making you manage the locking for a shared list.

volatile has a narrower job: it helps threads observe updates to a field. It does not make counter++ atomic. For that race, use an atomic counter or a lock—or avoid sharing the counter in the first place.

Visibility and synchronization are governed by Java's memory model. The practical lesson is that one thread's write is not necessarily a safe basis for another thread's action without proper coordination. Locks and concurrent utilities establish that coordination; guesses about timing do not.

Treat waiting and cancellation as design decisions

A thread can pause while waiting for a future, acquiring a lock, or taking an item from a blocking queue. Decide whether a wait needs a limit. For example, future.get(2, TimeUnit.SECONDS) throws TimeoutException if the result is not available after two seconds. The task may still be running, so handling the timeout may also mean requesting cancellation and deciding what to do with partial work.

Interruption is Java's cooperative stop signal. Many blocking methods throw InterruptedException when a task is interrupted. Do not silently discard it. If the current method cannot finish its work, propagate the exception or restore the interrupt status with Thread.currentThread().interrupt() before returning. A calculation that does not call interruptible methods can periodically check Thread.currentThread().isInterrupted() and exit cleanly when appropriate.

Do not use Thread.sleep() to make one task finish before another. It pauses the current thread for at least the requested interval; it says nothing about where another thread has reached. Use a Future to wait for a result, or a coordination utility such as CountDownLatch when tasks need to meet at a defined milestone.

Make lock problems easier to avoid

A deadlock occurs when tasks wait indefinitely for resources held by one another. If you need multiple locks, acquire them in the same order throughout the program. Keep locked sections short, and avoid holding a lock during slow I/O or while calling unfamiliar code. Task-local state or a suitable concurrent collection can sometimes remove the need to reason about several locks at all.

Reviewing a task boundary before sharing data

Test behavior, not a lucky schedule

A concurrency bug can seem to vanish when you add a print statement or run under a debugger. In tests, start multiple tasks, wait for their results, and assert something concrete: 100 submitted increments should produce exactly 100. Run the test repeatedly, but do not mistake repeated success for proof that there is no race. Check which fields are shared and how every access is coordinated.

If a program appears stuck, capture a thread dump with your development tools and look for threads waiting on locks or futures. Waiting is not necessarily a bug; ask which task must make progress before the waiting one can continue. If every worker in a small pool waits for another task queued to that same pool, a longer timeout will not fix the dependency.

For a first exercise, process ten independent integers with a two-worker executor. Submit one Callable<Integer> per integer, keep all ten futures, and collect the results in submission order. Then make one task throw an exception and check that its Future.get() reports the failure rather than leaving you with an unexplained missing result.