MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java锁机制与死锁预防

2023-07-056.6k 阅读

Java 锁机制基础

在 Java 多线程编程中,锁机制是保证线程安全的关键手段。锁能够控制对共享资源的访问,确保同一时间只有一个线程可以访问共享资源,从而避免数据竞争和不一致问题。

1. 内置锁(Monitor 锁)

Java 中的每个对象都可以作为一个锁,这种锁被称为内置锁或 Monitor 锁。当一个线程进入一个同步方法或同步代码块时,它会自动获取该对象的锁。

public class SynchronizedExample {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}

在上述代码中,increment 方法和 getCount 方法都被声明为 synchronized,这意味着当一个线程调用这些方法时,它会获取 SynchronizedExample 实例对象的锁。其他线程在该锁被释放之前,无法调用这两个同步方法。

同步代码块的形式如下:

public class SynchronizedBlockExample {
    private int value = 0;
    private final Object lock = new Object();

    public void updateValue() {
        synchronized (lock) {
            value++;
        }
    }
}

这里使用了一个单独的 Object 实例 lock 作为锁对象,同步代码块通过 synchronized (lock) 来获取锁。这样做的好处是可以更细粒度地控制锁的范围,只对需要同步的代码进行加锁,提高程序的并发性能。

2. 锁的可重入性

Java 的内置锁是可重入的。这意味着同一个线程可以多次获取同一个锁而不会发生死锁。例如:

public class ReentrantLockExample {
    public synchronized void outerMethod() {
        System.out.println("Entered outerMethod");
        innerMethod();
    }

    public synchronized void innerMethod() {
        System.out.println("Entered innerMethod");
    }
}

当一个线程调用 outerMethod 时,它获取了 ReentrantLockExample 实例的锁。在 outerMethod 中调用 innerMethod 时,由于锁的可重入性,该线程可以再次获取同一把锁,顺利执行 innerMethod。如果锁不可重入,线程在调用 innerMethod 时会因为无法再次获取锁而死锁。

显示锁(Lock 接口)

除了内置锁,Java 5.0 引入了 java.util.concurrent.locks.Lock 接口及其实现类,提供了更灵活和强大的锁机制。

1. ReentrantLock

ReentrantLockLock 接口的一个实现类,它具有与内置锁相似的可重入特性,但提供了更多的功能。

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockUsage {
    private int counter = 0;
    private final Lock lock = new ReentrantLock();

    public void increment() {
        lock.lock();
        try {
            counter++;
        } finally {
            lock.unlock();
        }
    }

    public int getCounter() {
        lock.lock();
        try {
            return counter;
        } finally {
            lock.unlock();
        }
    }
}

在上述代码中,ReentrantLock 通过 lock() 方法获取锁,通过 unlock() 方法释放锁。注意,释放锁的操作必须放在 finally 块中,以确保无论代码执行过程中是否发生异常,锁都能被正确释放,避免死锁。

ReentrantLock 还支持公平锁和非公平锁。默认情况下,ReentrantLock 使用非公平锁,这种锁在锁可用时,允许新的线程立即尝试获取锁,而不考虑等待队列中的其他线程。公平锁则会按照线程等待的顺序分配锁,保证先来先得。创建公平锁的方式如下:

Lock fairLock = new ReentrantLock(true);

2. ReadWriteLock

ReadWriteLock 接口提供了一种读写锁机制,允许多个线程同时进行读操作,但只允许一个线程进行写操作。这种锁机制在读取操作频繁而写入操作较少的场景下能显著提高并发性能。

ReentrantReadWriteLockReadWriteLock 接口的实现类。示例代码如下:

import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockExample {
    private int data = 0;
    private final ReadWriteLock lock = new ReentrantReadWriteLock();

    public void write(int value) {
        lock.writeLock().lock();
        try {
            data = value;
        } finally {
            lock.writeLock().unlock();
        }
    }

    public int read() {
        lock.readLock().lock();
        try {
            return data;
        } finally {
            lock.readLock().unlock();
        }
    }
}

在上述代码中,write 方法使用写锁,保证在写入数据时的线程安全。read 方法使用读锁,允许多个线程同时读取数据,提高了读操作的并发性能。

锁的优化与性能考量

在使用锁机制时,性能是一个重要的考量因素。不合理的锁使用可能会导致性能瓶颈,降低程序的并发能力。

1. 减小锁的粒度

通过将大的锁范围分解为多个小的锁,可以减少线程竞争,提高并发性能。例如,在一个包含多个独立数据项的对象中,可以为每个数据项使用单独的锁。

public class FineGrainedLocking {
    private int value1 = 0;
    private int value2 = 0;
    private final Object lock1 = new Object();
    private final Object lock2 = new Object();

    public void updateValue1(int val) {
        synchronized (lock1) {
            value1 = val;
        }
    }

    public void updateValue2(int val) {
        synchronized (lock2) {
            value2 = val;
        }
    }
}

在这个例子中,updateValue1updateValue2 方法分别使用不同的锁,使得对 value1value2 的更新操作可以并发执行,提高了并发性能。

2. 锁粗化

与减小锁粒度相反,锁粗化是将多次连续的、对同一锁的请求合并为一次请求,减少获取和释放锁的开销。例如:

public class CoarseLocking {
    private StringBuilder builder = new StringBuilder();
    private final Object lock = new Object();

    public void appendStrings(String... strings) {
        synchronized (lock) {
            for (String s : strings) {
                builder.append(s);
            }
        }
    }
}

如果不进行锁粗化,每次调用 builder.append(s) 都可能涉及获取和释放锁的操作,而将整个循环放在同步块中,只进行一次锁的获取和释放,减少了锁的开销。

3. 锁消除

现代 JVM 具有锁消除的优化机制。当 JVM 检测到某些加锁操作不会存在线程竞争时,会自动消除这些锁操作。例如:

public class LockElimination {
    public void nonThreadSafeMethod() {
        StringBuffer buffer = new StringBuffer();
        buffer.append("Hello");
        buffer.append(" World");
    }
}

在上述代码中,StringBufferappend 方法是同步的,但由于 buffer 变量是局部变量,不存在线程共享,JVM 会自动消除这些同步操作,提高性能。

死锁问题与预防

死锁是多线程编程中一个严重的问题,当两个或多个线程相互等待对方释放锁,从而导致所有线程都无法继续执行时,就会发生死锁。

1. 死锁的产生条件

死锁的产生需要同时满足以下四个条件:

  • 互斥条件:资源不能被共享,只能被一个线程占用。
  • 占有并等待条件:线程已经持有了至少一个资源,但又请求其他资源,并且在等待获取其他资源的过程中,不会释放已持有的资源。
  • 不可剥夺条件:线程已获得的资源,在未使用完之前,不能被其他线程强行剥夺,只能由该线程自己释放。
  • 循环等待条件:存在一个线程链,其中每个线程都在等待下一个线程释放资源,形成一个循环等待的关系。

2. 死锁示例

以下是一个简单的死锁示例:

public class DeadlockExample {
    private static final Object resource1 = new Object();
    private static final Object resource2 = new Object();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (resource1) {
                System.out.println("Thread 1 acquired resource1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (resource2) {
                    System.out.println("Thread 1 acquired resource2");
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (resource2) {
                System.out.println("Thread 2 acquired resource2");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (resource1) {
                    System.out.println("Thread 2 acquired resource1");
                }
            }
        });

        thread1.start();
        thread2.start();
    }
}

在这个示例中,thread1 先获取 resource1,然后尝试获取 resource2,而 thread2 先获取 resource2,然后尝试获取 resource1。如果 thread1 获取 resource1 后,thread2 获取 resource2,此时两个线程都在等待对方释放锁,从而导致死锁。

3. 死锁的预防

  • 破坏死锁产生条件
    • 破坏互斥条件:在某些情况下,可以通过使用资源的副本或者使用可共享的资源来避免互斥。例如,在多线程环境下,对于只读的数据,可以使用共享的副本,多个线程可以同时读取而不需要加锁。
    • 破坏占有并等待条件:可以要求线程在启动时一次性获取所有需要的资源,而不是在运行过程中逐步获取。例如,在数据库事务中,可以预先声明所有需要访问的表,一次性获取所有表的锁。
    • 破坏不可剥夺条件:当一个线程获取了部分资源但无法获取其他资源时,可以主动释放已获取的资源,然后重新尝试获取所有资源。例如,在分布式系统中,可以通过设置锁的超时时间,如果获取锁超时,则释放已获取的其他锁,重新尝试获取所有锁。
    • 破坏循环等待条件:可以对资源进行排序,要求线程按照固定的顺序获取资源。例如,为每个资源分配一个唯一的标识符,线程按照标识符从小到大的顺序获取资源。
public class DeadlockPrevention {
    private static final Object resource1 = new Object();
    private static final Object resource2 = new Object();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (resource1) {
                System.out.println("Thread 1 acquired resource1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (resource2) {
                    System.out.println("Thread 1 acquired resource2");
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (resource1) {
                System.out.println("Thread 2 acquired resource1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (resource2) {
                    System.out.println("Thread 2 acquired resource2");
                }
            }
        });

        thread1.start();
        thread2.start();
    }
}

在这个改进的示例中,thread1thread2 都按照先获取 resource1 再获取 resource2 的顺序获取资源,避免了循环等待,从而预防了死锁。

  • 使用超时机制 在获取锁时设置一个超时时间,如果在规定时间内无法获取锁,则放弃获取并进行相应的处理。ReentrantLock 提供了 tryLock(long timeout, TimeUnit unit) 方法来实现这一功能。
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class TimeoutLockExample {
    private static final ReentrantLock lock1 = new ReentrantLock();
    private static final ReentrantLock lock2 = new ReentrantLock();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            boolean gotLock1 = false;
            boolean gotLock2 = false;
            try {
                gotLock1 = lock1.tryLock(1, TimeUnit.SECONDS);
                if (gotLock1) {
                    System.out.println("Thread 1 acquired lock1");
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    gotLock2 = lock2.tryLock(1, TimeUnit.SECONDS);
                    if (gotLock2) {
                        System.out.println("Thread 1 acquired lock2");
                    } else {
                        System.out.println("Thread 1 failed to acquire lock2");
                    }
                } else {
                    System.out.println("Thread 1 failed to acquire lock1");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                if (gotLock2) {
                    lock2.unlock();
                }
                if (gotLock1) {
                    lock1.unlock();
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            boolean gotLock1 = false;
            boolean gotLock2 = false;
            try {
                gotLock2 = lock2.tryLock(1, TimeUnit.SECONDS);
                if (gotLock2) {
                    System.out.println("Thread 2 acquired lock2");
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    gotLock1 = lock1.tryLock(1, TimeUnit.SECONDS);
                    if (gotLock1) {
                        System.out.println("Thread 2 acquired lock1");
                    } else {
                        System.out.println("Thread 2 failed to acquire lock1");
                    }
                } else {
                    System.out.println("Thread 2 failed to acquire lock2");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                if (gotLock1) {
                    lock1.unlock();
                }
                if (gotLock2) {
                    lock2.unlock();
                }
            }
        });

        thread1.start();
        thread2.start();
    }
}

在这个示例中,thread1thread2 尝试获取锁时都设置了 1 秒的超时时间。如果在超时时间内无法获取锁,线程会放弃获取并进行相应的处理,避免了死锁的发生。

总结

Java 的锁机制是多线程编程中保证线程安全的重要工具。内置锁和显示锁各有特点,在实际应用中需要根据具体场景选择合适的锁机制。同时,要注意锁的优化,通过减小锁粒度、锁粗化等方式提高性能。死锁是多线程编程中需要特别关注的问题,通过破坏死锁产生条件和使用超时机制等方法,可以有效地预防死锁的发生。深入理解和掌握 Java 锁机制与死锁预防,对于编写高效、稳定的多线程程序至关重要。