Skip to Content
Course content

204: Common Concurrency Pitfalls and Deadlocks

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've spent a lot of time reviewing code from developers who are just starting to get comfortable with multi-threading, and there is one misconception that comes up almost every single time: the belief that "if I just synchronize every method that touches shared data, the code will be thread-safe."

It sounds logical, right? If you wrap everything in synchronized blocks, you're essentially putting a "one person at a time" sign on your data. But in reality, this approach doesn't just kill your performance—it's the fastest way to introduce a deadlock that will freeze your entire production environment.

The "More Locks Equals More Safety" Myth

Let's look at a classic example: a simple bank transfer. You have two accounts, and you want to move money from one to the other. To be "safe," you might decide to lock both account objects to ensure no other thread interferes with the balances.

public void transfer(Account from, Account to, double amount) {
    synchronized (from) {
        synchronized (to) {
            if (from.getBalance() >= amount) {
                from.withdraw(amount);
                to.deposit(amount);
            }
        }
    }
}

At first glance, this looks bulletproof. But here is where it falls apart. Imagine Thread A is transferring money from Account 1 to Account 2, and at the exact same millisecond, Thread B is transferring money from Account 2 to Account 1. Thread A grabs the lock for Account 1. Thread B grabs the lock for Account 2. Now, Thread A is waiting for Account 2, and Thread B is waiting for Account 1. Neither will ever let go. Your application has just hit a deadlock.

The Deadly Embrace: How Deadlocks Actually Happen

A deadlock isn't just "bad luck"; it's a structural failure. It happens when you have a circular dependency of locks. In the example above, the order of acquisition was dependent on the method arguments. Because the arguments were swapped between the two threads, the locking order was swapped, creating a cycle.

I usually tell my juniors to think of locks like a set of keys. If two people both need the Kitchen key and the Bedroom key to finish their chores, but one person grabs the Kitchen key and the other grabs the Bedroom key, they'll spend the rest of the afternoon staring at each other in the hallway.

When Volatile Isn't Enough

Another pitfall I see frequently is the confusion between visibility and atomicity. You'll often see someone use the volatile keyword and assume they've solved their concurrency problem. volatile is great for telling the JVM, "Don't cache this variable locally; always read it from main memory." It solves the visibility problem.

But it does nothing for atomicity. If you have a volatile int counter = 0; and you call counter++ from ten different threads, you will almost certainly end up with a final count less than 10. Why? Because counter++ is actually three separate operations: read, increment, and write. A thread can be preempted right in the middle of those steps. If you need atomicity, you need synchronized or, better yet, the java.util.concurrent.atomic package.

Breaking the Cycle with Consistent Lock Ordering

So, how do we actually fix that bank transfer deadlock? The secret is to ensure that locks are always acquired in a global, consistent order, regardless of who is sending the money.

One simple trick is to use a unique identifier (like an account ID) to determine who gets locked first. I've found this to be the most reliable way to prevent circular dependencies without over-complicating the logic.

public void transfer(Account from, Account to, double amount) {
    Account first = from.getId() < to.getId() ? from : to;
    Account second = from.getId() < to.getId() ? to : from;

    synchronized (first) {
        synchronized (second) {
            if (from.getBalance() >= amount) {
                from.withdraw(amount);
                to.deposit(amount);
            }
        }
    }
}

By sorting the locks based on the ID, both Thread A and Thread B will attempt to lock the accounts in the exact same order. If Thread A gets the lock for Account 1, Thread B will wait at the first synchronized block instead of grabbing Account 2 and causing a stalemate. It's a small change, but it's the difference between a stable system and one that crashes randomly under load.




📋 Practical Task

Exercise: Resolving the Warehouse Inventory Deadlock

You are maintaining a warehouse management system. The system has a Product class and an InventoryManager. Currently, the swapQuantities method is causing intermittent deadlocks when two different threads try to swap stock levels between the same two products in opposite directions.

Your Task: Modify the InventoryManager.java code below to eliminate the deadlock. You must ensure that no matter which product is passed as p1 or p2, the locks are always acquired in a consistent order. Assume the Product class has a public int getProductId() method.


class Product {
    private final int id;
    private int quantity;

    public Product(int id, int quantity) {
        this.id = id;
        this.quantity = quantity;
    }

    public int getProductId() { return id; }
    public int getQuantity() { return quantity; }
    public void setQuantity(int q) { this.quantity = q; }
}

class InventoryManager {
    // FIX THIS METHOD to prevent deadlocks
    public void swapQuantities(Product p1, Product p2) {
        synchronized (p1) {
            synchronized (p2) {
                int temp = p1.getQuantity();
                p1.setQuantity(p2.getQuantity());
                p2.setQuantity(temp);
                System.out.println("Swapped " + p1.getProductId() + " and " + p2.getProductId());
            }
        }
    }
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.