Skip to main content
Pixel art: two chefs on opposite sides of a stove both reach for the same frying pan, hands about to collide over the handle. Surprised expressions. On the wall, an unused key hook labeled 'Stove Key' glows teal — the mutex neither chef grabbed. Tagline: Shared State. Shared Problems.

CS 3100: Program Design and Implementation II

Lecture 31: Concurrency I — Threads and Synchronization

©2026 Jonathan Bell, CC-BY-SA

Learning Objectives

After this lecture, you will be able to:

  1. Describe threads as a concurrency mechanism
  2. Recognize the need for synchronization and atomicity
  3. Utilize locks and concurrent collections to implement basic thread-safe code
  4. Understand deadlocks and race conditions

50 Devices, 3 Users, 1 Hub

SceneItAll's hub manages 50 smart devices — lights, fans, shades, thermostats — across a whole house.

Three family members walk in and activate scenes simultaneously from different rooms:

UserSceneDevicesSequential time
Alice"Evening"15 devices (dim lights, close shades)~5 sec
Bob"Movie Night"8 devices (lights off, shades closed)~3 sec
Carol"Study"6 devices (desk lamp on, overhead dim)~2 sec

Sequential: 10 seconds of delay — Alice waits, Bob waits longer, Carol waits longest.
Concurrent: All three scenes activate within ~5 seconds.

Remember L29: "long handlers freeze the UI." Same problem — now the hub is the UI.

The Concurrency Roadmap: This Week and GA1

LectureTopicThe question it answers
L31 (today)Threads, shared state, synchronization, deadlock"Why does my GUI freeze when I load data?"
L32 (Wed)Async programming, CompletableFuture, Platform.runLater()"How do I load data in the background and update my GUI when it's ready?"
L33 (Thu)Event-driven architecture, consistency, resilience"What happens when the service I depend on is slow or down?"

GA1 connection: These concepts are the foundation for GA1's BackgroundTaskRunner — more in the Looking Ahead slide.

Concurrency Is Not Parallelism

Concurrency = managing overlapping tasks (structure)

Parallelism = executing simultaneously (execution)

Three-row diagram: row 1 shows concurrency (1 core, interleaved threads), row 2 shows parallelism (3 cores, 3 threads), row 3 shows both (8 cores, 100+ threads interleaved across cores — the real world).

Your laptop has ~8 cores but hundreds of threads running right now. You get both: parallelism across cores, concurrency within each core. The bugs we'll see today apply to all of it.

A Thread Is an Independent Path of Execution

Every Java program has a main thread. You can create additional threads that run simultaneously. Threads share the heap (device state, scene definitions) but have their own stack (local variables).

Creating Threads in Java

// Option 1: Extend Thread (not recommended — limits inheritance)
public class DeviceCommandSender extends Thread {
private final DeviceCommand command;
public DeviceCommandSender(DeviceCommand command) { this.command = command; }

@Override
public void run() {
DeviceResponse response = sendViaZigbee(command);
log.info("Command sent: " + response.getStatus());
}
}
new DeviceCommandSender(command).start();

// Option 2: Implement Runnable (preferred — doesn't burn your one superclass)
Thread thread = new Thread(new DeviceCommandTask(command));
thread.start();

// Option 3: Lambda (most concise — use for simple tasks)
new Thread(() -> {
DeviceResponse response = sendViaZigbee(command);
log.info("Light dimmed: " + response.getStatus());
}).start();

Prefer Runnable / lambda. Java has single inheritance — extending Thread means you can't extend anything else.

Thread Pools: Don't Create a Thread Per Device

public class HubCommandService {
// A pool of 10 command worker threads
private final ExecutorService executor = Executors.newFixedThreadPool(10);

public void activateScene(Scene scene, Area room) {
for (Device device : room.getDevices()) {
executor.submit(() -> {
DeviceState target = scene.getTargetState(device);
DeviceResponse response = sendViaZigbee(
new DeviceCommand(device, target)
);
device.updateStatus(parseResponse(response));
});
}
}

public void shutdown() {
executor.shutdown();
}
}

"Evening" scene sends 15 device commands. Pool processes 10 at once, 5 queue up. Like a kitchen with fixed staff — orders go on a queue, cooks grab the next one when they're free.

Interrupts: What If a Device Stops Responding?

public class TimedCommandSender {
private final ExecutorService executor = Executors.newSingleThreadExecutor();

public DeviceResponse sendWithTimeout(DeviceCommand command,
Duration timeout) {
Future<DeviceResponse> future = executor.submit(
() -> sendViaZigbee(command)
);
try {
return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
future.cancel(true); // Interrupts the worker thread
return DeviceResponse.timeout(
"Device exceeded " + timeout.toSeconds() + "s"
);
} catch (InterruptedException | ExecutionException e) {
future.cancel(true); // Clean up if still running
return DeviceResponse.error("Command failed: " + e.getMessage());
}
}
}
// The other side: the worker must CHECK for interrupts
public void applyFirmwareChunks(Device device, List<byte[]> chunks) {
for (byte[] chunk : chunks) {
if (Thread.currentThread().isInterrupted()) { return; } // cooperative!
device.writeChunk(chunk);
}
}

Interrupts are cooperativecancel(true) sets the interrupt flag and interrupts blocking calls (like Thread.sleep() or I/O), but the worker must check it or handle the exception. Thread.stop() was deprecated in 1997 — forcibly killing threads corrupts shared state.

Two Users Activate Different Scenes at the Same Time

Alice activates "Evening" (lights to 30%, shades closed). Bob activates "Movie Night" (lights off, shades to 80%). Same room, same instant. This code handles it:

public class SceneService {
public boolean activateScene(Scene scene, Area room) {
for (Device device : room.getDevices()) {
DeviceState targetState = scene.getTargetState(device);
if (targetState != null) {
device.setState(targetState); // sets brightness, shadePosition, etc.
}
}
return true;
}
}

Looks correct. What could go wrong?

The Interleaving (1/3): Alice Goes First

Alice reads the light at 100%, sets it to 30%. So far so good...

The Interleaving (2/3): Bob Reads Stale Data

Bob reads 100% — not Alice's 30%. His write overwrites hers. The light is now 0%.

The Interleaving (3/3): Nobody Got What They Wanted

Light = 0% (Bob wins). Shades = 80% (Bob wins). Both got true. Neither scene actually applied.

Race Conditions: When Correctness Depends on Luck

A race condition occurs when the program's behavior depends on the relative timing of operations, which is unpredictable. The core issue: operations that need to be atomic (indivisible — no other thread can see them mid-execution) are actually multiple steps that can be interleaved.

Common patterns:

PatternExampleWhy it's dangerous
Check-then-actif (!scenes.containsKey(name)) scenes.put(name, scene)Another thread puts first
Read-modify-writedeviceCount++Two threads read same value
Compound actionRead state + write new state across multiple devicesInterleaving between reads and writes

"These bugs are timing-dependent — your tests pass 99 times, fail once, and you can't reproduce it."

You Can't Even Trust deviceCount++ (1/2)

deviceCount++ is three operations: read, add, write. Two threads registering devices simultaneously:

Both threads read 0. Both compute 1. What happens when they write?

You Can't Even Trust deviceCount++ (2/2)

Lost update: Thread A writes 1. Thread B writes 1 — overwrites A's result with the same stale computation. One increment is silently lost. If you can't trust deviceCount++, how do you trust anything?

The Java Memory Model: It Gets Worse

Beyond interleaving, there's a visibility problem. Modern CPUs have caches — writes by one thread may not be visible to another thread, even in the same process.

Diagram showing two CPU cores in the same hub with separate caches. Thread A's cache has updated brightness, but Thread B's cache still has stale data.

Thread A writes brightness = 30. Thread B reads brightness — and gets 0. Same object, same heap. Different CPU caches.

Visibility in Code

public class DeviceStateHolder {
private boolean ready = false;
private int brightness = 0;

// Thread A (scene activation worker) writes:
public void updateBrightness() {
brightness = 30;
ready = true;
}

// Thread B (UI refresh worker) reads:
public void checkBrightness() {
while (!ready) { /* spin-wait — burns CPU; real code uses wait/notify */ }
System.out.println(brightness); // Might print 0!
}
}

Three things that can go wrong without synchronization:

  1. Thread B never sees ready = true — spins forever (write cached, never flushed)
  2. Thread B sees ready = true but brightness = 0CPU may execute the write to ready before the write to brightness reaches memory
  3. Thread B sees both correctly — works today, breaks tomorrow on different hardware

The fix: synchronized, volatile, or atomic classes — all establish a "happens-before" relationship that guarantees visibility.

Caution: volatile fixes visibility but does NOT fix compound operations — volatile int count; count++ is still a race condition.

synchronized: One Scene at a Time

public class SceneService {
public synchronized boolean
activateScene(
Scene scene, Area room) {
// Only one thread at a time
for (Device d : room.getDevices()) {
DeviceState target =
scene.getTargetState(d);
if (target != null) {
d.setState(target);
}
}
return true;
}
}

Now the scene activation is atomic. The room goes "Evening" then "Movie Night", never a mix.

What synchronized Actually Does

synchronized provides two guarantees:

1. Mutual Exclusion

Only one thread can execute the synchronized block at a time. Bob's scene activation waits until Alice's finishes.

Fixes the race condition from slide 9.

2. Visibility

When a thread releases a lock, all its writes are flushed to main memory. When another thread acquires the same lock, it sees all those writes.

Fixes the memory visibility problem from slide 12.

synchronized fixes both the interleaving problem and the caching problem. That's why it's the fundamental building block.

Fine-Grained Locking: Don't Lock the Whole House

public class SceneService {
// One lock per room (ConcurrentHashMap because multiple threads may add rooms simultaneously)
private final Map<String, Object> roomLocks = new ConcurrentHashMap<>();

public boolean activateScene(Scene scene, Area room) {
Object roomLock = roomLocks.computeIfAbsent(
room.getId(), k -> new Object() // lock objects just serve as monitors
);
synchronized (roomLock) {
for (Device device : room.getDevices()) {
DeviceState targetState = scene.getTargetState(device);
if (targetState != null) {
device.setState(targetState);
}
}
return true;
}
}
}

Alice activates "Evening" in the living room, Bob activates "Movie Night" in the bedroom — both proceed in parallel because they lock different rooms.

Use dedicated lock objects: synchronized(roomLock) not synchronized(this).

Concurrent Collections and AtomicInteger

public class DeviceRegistry {
// Thread-safe map — no external synchronization needed
private final ConcurrentHashMap<String, Device> devices =
new ConcurrentHashMap<>();

// Thread-safe counter — fixes the deviceCount++ bug
private final AtomicInteger deviceCount = new AtomicInteger(0);

public void registerDevice(Device device) {
// putIfAbsent: atomic check-and-put — fixes the check-then-act race from earlier
Device existing = devices.putIfAbsent(device.getId(), device);
if (existing == null) {
deviceCount.incrementAndGet(); // Atomic increment
}
}

public int getDeviceCount() {
return deviceCount.get();
}
}
CollectionUse case
ConcurrentHashMapDevice registry — many readers, occasional writers
AtomicIntegerCounters — deviceCount, commandsSent
BlockingQueueCommand queue — producers (users) and consumers (workers)
CopyOnWriteArrayListListener lists — read-heavy, rarely modified

Use these — the experts already debugged them.

Deadlock: When Everyone Waits Forever (1/2)

Two methods, opposite lock ordering. What happens when they run simultaneously?

public class SmartHomeService {
public void activateScene(Scene scene, Area room) {
synchronized (room) { // Lock room FIRST
for (Device device : room.getDevices()) {
synchronized (device) { // Then lock device
device.setState(scene.getTargetState(device));
}
}
}
}

public void firmwareUpdate(Device device, Area room,
FirmwarePackage firmware) {
synchronized (device) { // Lock device FIRST
synchronized (room) { // Then lock room
device.applyFirmware(firmware);
room.updateDeviceManifest(device); // update firmware version in room's manifest
}
}
}
}

Two threads. Opposite lock orders. What happens next?

Deadlock: When Everyone Waits Forever (2/2)

Both threads are stuck. The living room lights are frozen at 100%. Forever. Unlike a race condition (wrong results), deadlock produces no results. No error. No exception. The system just hangs.

Preventing Deadlock: Break the Cycle

Deadlock happens because of a circular wait: Thread A holds lock X and waits for lock Y, while Thread B holds lock Y and waits for lock X. Neither can proceed.

The simplest fix: always acquire locks in the same order.

Before (deadlock possible)

// activateScene: room → device
synchronized (room) {
synchronized (device) { ... }
}

// firmwareUpdate: device → room
synchronized (device) {
synchronized (room) { ... }
}

Different order → circular wait → deadlock

After (deadlock impossible)

// activateScene: room → device
synchronized (room) {
synchronized (device) { ... }
}

// firmwareUpdate: room → device
synchronized (room) {
synchronized (device) { ... }
}

Same order → no cycle → no deadlock

Concurrency Bugs Are the Hardest Bugs

Why they're hard:

  • Timing-dependent — the bug only appears under specific interleavings
  • Non-reproducible — run the same test twice, get different results
  • Load-dependent — works fine with 5 devices, glitches with 50

The family's lights work fine with 5 devices. Add a 6th and scenes start glitching. Add a firmware update and the hub deadlocks. These bugs grow with scale.

Detection strategies:

  1. Code review — look for shared mutable state accessed without synchronization
  2. Static analysis — tools like SpotBugs detect common patterns
  3. Stress testing — activate 10 scenes simultaneously, verify device states
  4. Design — prefer immutable objects, concurrent collections, minimal shared state

"The best concurrency bug is the one you avoid by not sharing mutable state."

Comprehension Check

Open Poll Everywhere and answer the three questions.

Key Takeaways

  1. Threads enable concurrency — multiple paths of execution sharing the same heap. Use thread pools, not raw threads.
  2. Shared mutable state is dangerous. Race conditions produce silently wrong results. deviceCount++ is not atomic.
  3. synchronized provides atomicity + visibility. Mutual exclusion prevents interleaving. Memory visibility ensures threads see each other's writes.
  4. Use concurrent collectionsConcurrentHashMap, AtomicInteger, BlockingQueue. The experts already debugged them.
  5. Fine-grained locking increases parallelism — lock per room, not per hub. But more locks = more deadlock risk.
  6. Prevent deadlock with consistent lock ordering. Always acquire locks in the same order across all code paths.

Looking Ahead

L32 (Wednesday): Asynchronous Programming

  • Activating "Evening" means sending commands to 15 devices — each one a 200ms network call
  • Threads are expensive for all that waiting
  • CompletableFuture and allOf for fan-out patterns
  • Platform.runLater() for updating your GA1 GUI from a background thread

Lab 12 runs Monday — GUI pair programming with Scene Builder

Your group project:

  • GA1 (due Apr 9): BackgroundTaskRunner wraps the threading concepts from today
  • Your TA will ask what happens under the hood — be ready to explain threads, shared state, and synchronization

Today: why shared state is dangerous and how locks protect it. Wednesday: how to avoid blocking threads entirely.

GA1: Where This Shows Up in CookYourBooks

Every core feature has one async operation that uses BackgroundTaskRunner:

FeatureAsync OperationWhat today's lecture explains
Library Viewrefresh() loads collectionsBackground thread does I/O; FX thread updates ObservableList
Recipe Editorsave() persists editsSave button disabled while saving — what if two saves race?
ImportOCR extraction from imageState machine (idle → processing → review) — each transition is a thread handoff
Search & FilterDebounced search queryWhat if a slow search returns after a newer one? (race condition!)
BackgroundTaskRunner.run(
() -> librarianService.listCollections(), // background thread (I/O)
collections -> { /* FX thread (update UI) */ },
error -> { /* FX thread (show error) */ }
);

Your TA will ask: what thread does the callable run on? What thread do the callbacks run on? What breaks if you skip Platform.runLater()?