Concurrency in Java – Threads, Synchronization, and the Java Memory Model

Introduction

Concurrency lets programs do multiple things at once, whether it’s handling web requests, processing data, or updating a user interface. In Java, concurrency revolves around threads, synchronization, and the Java Memory Model (JMM). In this post, we’ll cover how to start threads, coordinate actions between them, avoid common pitfalls like race conditions and deadlocks, and understand the JMM’s happens-before guarantees.


1. Creating and Starting Threads

In Java, you can create threads by extending Thread or implementing Runnable:

// Extending Thread
class MyThread extends Thread {
    public void run() {
        System.out.println("Hello from MyThread");
    }
}

// Implementing Runnable
class MyTask implements Runnable {
    public void run() {
        System.out.println("Hello from MyTask");
    }
}

// Starting threads
public class Main {
    public static void main(String[] args) {
        new MyThread().start();
        new Thread(new MyTask()).start();
    }
}

Teaching Tip:

Show students that calling run() directly does not start a new thread, only start() does.


2. Synchronization and Locks

When multiple threads access shared data, you need synchronization to avoid race conditions:

class Counter {
    private int count = 0;
    public synchronized void increment() {
        count++;
    }
    public synchronized int getCount() {
        return count;
    }
}
  • The synchronized keyword ensures that only one thread at a time enters the method on the same object.
  • You can also synchronize on blocks:
public void update() {
    synchronized(this) {
        // critical section
    }
}

Quirk:

Holding locks for too long can lead to thread contention and poor performance.


3. The volatile Keyword

volatile ensures that reads and writes to a variable go straight to main memory, preventing threads from caching stale values:

class Flag {
    private volatile boolean running = true;

    public void stop() {
        running = false;
    }

    public void work() {
        while (running) {
            // do work
        }
    }
}

Use volatile for simple flags or state variables, but for compound actions (e.g., count++) you still need synchronization.


4. Deadlocks and How to Avoid Them

A deadlock happens when two threads wait on each other:

synchronized(lockA) {
    synchronized(lockB) {
        // ...
    }
}

// In another thread:
synchronized(lockB) {
    synchronized(lockA) {
        // ...
    }
}

Teaching Tip:

Illustrate deadlock by demonstrating a simple two-lock scenario, then show how imposing a lock-ordering discipline prevents it.


5. Higher-Level Concurrency APIs

Since Java 5, the java.util.concurrent package offers powerful tools:

  • Executors: manage thread pools instead of creating threads manually.
  • Locks: ReentrantLock, ReadWriteLock for advanced locking behavior.
  • Concurrent collections: ConcurrentHashMap, BlockingQueue.
  • CountDownLatch, CyclicBarrier, Semaphore for thread coordination.

Example – ExecutorService:

ExecutorService exec = Executors.newFixedThreadPool(4);
for (int i = 0; i < 10; i++) {
    exec.submit(() -> {
        System.out.println(Thread.currentThread().getName() + " working");
    });
}
exec.shutdown();

Teaching Tip:

Compare manual thread creation vs. using an ExecutorService for performance and resource management.


6. Java Memory Model and Happens-Before

The JMM defines how and when changes made by one thread become visible to others. happens-before is a key concept:

  • Program order: Each action in a thread happens-before its subsequent actions.
  • Monitor lock: unlock happens-before subsequent lock on the same monitor.
  • Volatile: A write to a volatile field happens-before each subsequent read of that field.
  • Thread start/join: A call to Thread.start() happens-before any actions in the started thread; all actions in a thread happen-before another thread successfully returns from Thread.join().

These rules guarantee memory visibility and ordering, so you don’t see stale or out-of-order state.


7. Common Concurrency Pitfalls

  • Ignoring exceptions in threads can silently kill them, always handle or log exceptions.
  • Busy-waiting wastes CPU; prefer blocking queues or wait/notify.
  • Over-synchronization hurts scalability.

Best Practices:

  1. Use higher-level constructs from java.util.concurrent.
  2. Limit synchronized blocks to the smallest possible scope.
  3. Favor immutable objects and thread-local variables when possible.

Key Takeaways

  1. Start threads with start(), not run().
  2. Use synchronized and volatile to manage shared state safely.
  3. Avoid deadlocks by consistent lock ordering.
  4. Leverage java.util.concurrent for scalable concurrency.
  5. Understand the JMM’s happens-before rules to guarantee visibility.

Up next: I/O and NIO, working with files, streams, and non-blocking I/O in Java.