Java Interview Preparation - Deadlocks
Published on

Java Interview Preparation - Deadlocks

Authors

Challenges with Thead and Deadlocks

  1. Deadlocks: This occurs when two or more threads are unable to proceed because each is waiting for the other to release a resource.

  2. Race conditions: This happens when the behavior of a system depends on the relative timing or interleaving of its individual threads.

  3. Thread synchronization: Ensuring that threads run in the correct order or at the correct time can be difficult.

  4. Starvation: This occurs when a thread is perpetually denied necessary resources, preventing it from making progress.

  5. Thread safety: Writing thread-safe code is challenging, especially in languages that don't have built-in support for threads.

  6. Resource management: Threads consume system resources, so creating too many can degrade performance or cause the system to run out of resources.

  7. Debugging: Debugging multithreaded code is more complex than debugging single-threaded code because bugs can be timing-dependent and non-deterministic.

How does Deadlock occur in Java?

In Java, a deadlock can occur in a situation where two or more threads are blocked forever, each waiting for the other to release a resource. Here's a simple scenario:

  1. Thread 1 acquires lock A and starts to perform some work.
  2. Concurrently, Thread 2 acquires lock B and starts to perform some work.
  3. At some point, Thread 1 needs to acquire lock B to proceed, but it can't because Thread 2 holds it. So, Thread 1 is blocked, waiting for Thread 2 to release lock B.
  4. Similarly, Thread 2 needs to acquire lock A to proceed, but it can't because Thread 1 holds it. So, Thread 2 is blocked, waiting for Thread 1 to release lock A.

Neither thread can proceed because each is waiting for the other to release a lock. This is a deadlock situation. Deadlocks can involve more than two threads and more than two resources, making them more complex to avoid and resolve.

Ways to prevent or resolve deadlocks in Java

  1. Avoid Nested Locks: Avoid holding more than one lock at a time. If multiple locks are necessary, try to acquire all of them upfront, and release them at once when you're done.

  2. Avoid Unnecessary Locks: Only lock the sections of code that need to be synchronized. The smaller the synchronized block, the smaller the chance of deadlock.

  3. Use Lock Ordering: If multiple locks must be acquired, always acquire the locks in the same order. This can prevent circular wait conditions.

  4. Use a Lock Timeout: Try to acquire a lock, but if it's not available within a certain period of time, release all locks, wait for a random amount of time, and then try again.

  5. Use Deadlock Detection Tools: Java has built-in tools for detecting deadlocks, such as the ThreadMXBean class, which can find deadlocked threads.

  6. Use Higher-Level Concurrency Constructs: Java provides higher-level concurrency constructs like java.util.concurrent.locks.Lock and java.util.concurrent.locks.Condition that offer more flexible lock acquisition and release semantics than synchronized blocks.

Remember, the best way to handle deadlocks is to design your code to avoid them in the first place. Deadlock prevention is generally easier and more efficient than trying to recover from them.

Synchronized Instance Methods vs. Synchronized Static Methods

In Java, synchronization can be achieved using synchronized methods and synchronized static methods. Here are the advantages and disadvantages of each:

Synchronized Instance Methods:

Advantages:

  1. They allow only one thread to execute at a time on a given instance, providing a simple way to prevent race conditions when accessing instance variables.
  2. They are easy to use and understand.

Disadvantages:

  1. They can cause performance issues because they lock the entire object, even if different threads need to access different variables or methods.
  2. They can lead to deadlocks if not used carefully.

Synchronized Static Methods:

Advantages:

  1. They allow only one thread to execute at a time across all instances of the class, which is useful when modifying static variables.
  2. They provide a way to synchronize access to shared resources, such as files or databases.

Disadvantages:

  1. They can cause performance issues because they lock the entire class, even if different threads need to access different static variables or methods.
  2. They can lead to deadlocks if not used carefully.

In general, it's often better to use higher-level concurrency constructs provided by Java, such as the classes in the java.util.concurrent package, which offer more control and flexibility.

Synchronized Methods vs. Synchronized Blocks

Choosing between a synchronized method and a synchronized block depends on the specific requirements of your code. However, in general, using a synchronized block can be more advantageous for the following reasons:

  1. Granularity: Synchronized blocks can lock on any object, not just this (for methods) or the class object (for static methods). This allows for finer-grained locking, which can improve concurrency by reducing the amount of code that's blocked at any given time.

  2. Flexibility: With a synchronized block, you can lock on different objects in different situations, which can be more flexible than locking an entire method.

  3. Performance: Because synchronized blocks typically lock for shorter periods than synchronized methods (since they don't lock the entire method), they can provide better performance in a multithreaded environment.

Remember, the choice between a synchronized method and a synchronized block should be based on the specific needs of your code. Always aim for the minimum scope of synchronization that correctly implements your program's requirements.

Real world example of Deadlock with synchronized methods

class BankAccount {
    private int balance = 100;

    synchronized void debit(int amount) {
        balance -= amount;
    }

    synchronized void credit(int amount) {
        balance += amount;
    }

    synchronized void transfer(BankAccount to, int amount) {
        this.debit(amount);
        to.credit(amount);
    }
}

public class Main {
    public static void main(String[] args) {
        BankAccount account1 = new BankAccount();
        BankAccount account2 = new BankAccount();
        BankAccount account3 = new BankAccount();

        // Thread 1
        new Thread(() -> account1.transfer(account2, 10)).start();

        // Thread 2
        new Thread(() -> account2.transfer(account3, 10)).start();

        // Thread 3
        new Thread(() -> account3.transfer(account1, 10)).start();
    }
}
  1. Thread 1 starts and locks account1 for the transfer method. It then tries to lock account2 to call the credit method on it.

  2. At the same time, Thread 2 starts and locks account2 for its transfer method. It then tries to lock account3 to call the credit method on it.

  3. Similarly, Thread 3 starts and locks account3 for its transfer method. It then tries to lock account1 to call the credit method on it.

Now, if all three threads start their transfers at the same time, they could each lock their first account and then get stuck waiting for the other threads to release their locks on the second accounts. This is a circular wait condition, which is one of the necessary conditions for a deadlock.

  • Thread 1 is waiting for Thread 2 to release account2.
  • Thread 2 is waiting for Thread 3 to release account3.
  • Thread 3 is waiting for Thread 1 to release account1.

None of the threads can proceed, and they are all blocked indefinitely, resulting in a deadlock.

To prevent this deadlock, you can use a lock ordering strategy, where you always acquire locks in the same order. For example, you could sort the accounts by their unique identifiers and always acquire locks in ascending order. This way, you avoid the circular wait condition and prevent deadlocks.

Using ThreadMXBean to detect deadlocks in Java

ThreadMXBean is a Java management interface for the thread system of the Java virtual machine (JVM). It has a method called findDeadlockedThreads(), which can be used to detect deadlocks in Java.

ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
long[] deadlockedThreads = threadMXBean.findDeadlockedThreads();

if (deadlockedThreads != null) {
    ThreadInfo[] threadInfos = threadMXBean.getThreadInfo(deadlockedThreads);

    for (ThreadInfo threadInfo : threadInfos) {
        System.out.println("Deadlocked thread: " + threadInfo.getThreadId() + " " + threadInfo.getThreadName());
    }
}
  1. We first get the ThreadMXBean from the ManagementFactory.
  2. We call findDeadlockedThreads() on the ThreadMXBean, which returns an array of IDs for any threads that are deadlocked on monitor locks. If no threads are deadlocked, this method returns null.
  3. If there are any deadlocked threads, we get their ThreadInfo objects by calling getThreadInfo(long[]) on the ThreadMXBean with the array of deadlocked thread IDs.
  4. We then loop through the ThreadInfo objects and print out the ID and name of each deadlocked thread.

This is a simple way to detect and diagnose deadlocks in a Java program. Note that findDeadlockedThreads() only detects deadlocks where at least one thread is blocked on a monitor lock. For deadlocks involving ownable synchronizers, use findMonitorDeadlockedThreads().

We can make use of this method to periodically check for deadlocks in our Java applications and take appropriate action to resolve them before they cause issues in production.

Different types of locks in Java

  1. Intrinsic Locks (Monitor Locks): These are the basic form of locks used in Java. Every object has an intrinsic lock associated with it. By default, a thread that needs exclusive and consistent access to an object's fields has to acquire the object's intrinsic lock before accessing them, and then release the intrinsic lock when it's done with them. Intrinsic locks are acquired/released using the synchronized keyword.

  2. Reentrant Locks: These are part of the java.util.concurrent.locks package. A reentrant lock has the same basic behavior and semantics as the implicit monitor lock accessed using synchronized methods and statements, but with extended capabilities. Reentrant locks allow more flexible structuring, may have completely different properties, and may support multiple associated Condition objects.

  3. Read-Write Locks: These are also part of the java.util.concurrent.locks package. The ReadWriteLock interface maintains a pair of associated locks, one for read-only operations and one for writing. The read lock may be held simultaneously by multiple reader threads, so long as there are no writers. The write lock is exclusive.

  4. Stamped Locks: Introduced in Java 8, StampedLock is another lock implementation that provides a capability of a read lock and write lock but with a new optimistic lock feature. This lock is also part of the java.util.concurrent.locks package.

  5. Fairness Policy: Both ReentrantLock and ReentrantReadWriteLock support a fairness policy. When set to true, under contention, locks favor granting access to the longest-waiting thread. Otherwise this lock does not guarantee any particular access order.

Why would you use ReentrantLock in Java?

ReentrantLocks in Java are used when more control over lock acquisition and release is needed than what is provided by synchronized blocks.

  1. Fairness: Unlike synchronized blocks, ReentrantLocks can be configured to be "fair" and maintain a queue of waiting threads. In a fair lock, the lock goes to the longest-waiting thread. This can be important in some real-time systems where task priority is important.

  2. Ability to interrupt a waiting thread: If a thread is waiting to acquire a lock, calling thread.interrupt() will not be able to interrupt it if it's waiting on a synchronized block. But if it's waiting to acquire a ReentrantLock, a call to thread.interrupt() will throw an InterruptedException.

  3. Ability to timeout while waiting for a lock: The tryLock(long timeout, TimeUnit unit) method can be used to wait for a lock for a specific amount of time. If the time expires before the lock is granted, the thread can continue without having acquired the lock. This is not possible with synchronized blocks.

  4. Ability to check if the lock is being held: The isHeldByCurrentThread() method allows a thread to check if it currently holds the lock. This can be useful for debugging or for implementing hand-over-hand (also known as chain locking) locking strategies.

  5. Flexibility with lock acquisition and release: With synchronized blocks, the lock acquisition and release is tied to the block structure, meaning the lock is automatically acquired at the start of the block and automatically released at the end. With a ReentrantLock, you have the flexibility to acquire the lock in one method and release it in another.

Remember, while ReentrantLocks can provide more flexibility and control, they also come with more responsibility. The lock must be released manually, typically in a finally block to ensure it gets released even if an exception is thrown. If not used correctly, they can lead to deadlocks or other threading issues.

How to interrupt a thread waiting on a ReentrantLock in Java?

import java.util.concurrent.locks.ReentrantLock;

public class Main {
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock lock = new ReentrantLock();

        Thread t1 = new Thread(() -> {
            try {
                // Try to acquire the lock
                lock.lockInterruptibly();
                try {
                    // Do work
                } finally {
                    lock.unlock();
                }
            } catch (InterruptedException e) {
                System.out.println("Thread was interrupted while waiting for the lock");
            }
        });

        // Acquire the lock in the main thread, preventing t1 from acquiring it
        lock.lock();

        // Start t1 - it will block because it can't acquire the lock
        t1.start();

        // Sleep for a bit, then interrupt t1
        Thread.sleep(1000);
        t1.interrupt();
    }
}

lockInterruptibly() method, which allows the thread to be interrupted while waiting for the lock.

We first acquire the lock ourselves, then start t1. Because we hold the lock, t1 will block when it tries to acquire the lock.

We then sleep for a bit to ensure t1 has started and is waiting for the lock, then call t1.interrupt(). This causes t1 to immediately stop waiting for the lock and throw an InterruptedException, which we catch and handle by printing a message.

How is race condition different from deadlock

Race Condition: A race condition occurs when two or more threads can access shared data and they try to change it at the same time. The outcome of the program depends on the relative timing of the threads, which can lead to unpredictable results.

Deadlock: A deadlock occurs when two or more threads are blocked forever, each waiting for the other to release a resource. In a deadlock, the threads are not making progress because they are stuck waiting for each other.

Key Differences:

  • Race condition are a problem of timing and can lead to inconsistent data, while deadlocks are a problem of resource allocation and can lead to threads being blocked.
  • Fixing a race condition usually involves using some kind of lock whereas for solving deadlocks we need to reorder lock acquisition, timeouts, or use higher-level concurrency constructs.

Disruptor Pattern LMAX

The Disruptor pattern is a high-performance inter-thread messaging library developed by LMAX, a London financial company. It's designed for low-latency, high-throughput computing systems.

In the Disruptor pattern, the order of execution is guaranteed by the use of a ring buffer. The ring buffer is a circular array of fixed size. Producers add messages to the buffer and consumers process them. The position of each message in the buffer determines its processing order.

Different Thread Pools in Java

  1. Fixed Thread Pool (Executors.newFixedThreadPool(int nThreads)): This creates a thread pool with a fixed number of threads. If a thread is not available for a task, the task is put in a queue until a thread becomes available.

  2. Cached Thread Pool (Executors.newCachedThreadPool()): This creates a thread pool that creates new threads as needed, but reuses previously constructed threads when they are available. This is suitable for programs that execute many short-lived asynchronous tasks.

  3. Single Thread Executor (Executors.newSingleThreadExecutor()): This creates a single-threaded executor that uses a single worker thread operating off an unbounded queue. Tasks are guaranteed to execute sequentially, and no more than one task will be active at any given time.

  4. Scheduled Thread Pool (Executors.newScheduledThreadPool(int corePoolSize)): This creates a thread pool that can schedule commands to run after a given delay, or to execute periodically.