大家有在面试中有遇过这个问题吗?
什么是读(共享)锁?什么是写(独占/互斥/排他)锁?
我们先看看读锁(Read Lock)和写锁(Write Lock)的概念:
读锁(Read Lock),共享锁(Shared Lock),S 锁,指的是允许多个线程同时读取共享资源的并发控制机制,读锁在读操作之间是共享的,一旦涉及到写操作就会发生互斥。
写锁(Write Lock),互斥锁(Mutex Lock),排他锁(Mutex Lock),X 锁,指的是无论读写同一时间只允许一个线程访问共享资源的并发控制机制。
模型不难理解,获取锁的线程进入临界区执行程序,访问共享资源,它描述了一种最简单的互斥锁模型。
临界区:源自于操作系统进程调度的概念,是访问共享资源的程序片段
事实上,我们很多时候用的是读写锁,读写锁中只有两种情况多读或一写。
- 允许多个线程申请读锁;
- 如果读锁已经被申请,需要等待读锁释放后才能申请写锁;
- 如果写锁已经被申请,需要等待写锁释放后才能申请读锁。
在使用的时候我们要注意: - 开发过程中容易犯在持有read lock时修改数据的错误;
- 读写锁的实现比互斥锁复杂,如果控制粒度极小,互斥锁可能更快;
- 如果读锁不允许升级为写锁,会和non-recursive mutex一样,造成死锁;
- 读写锁会引起写饥饿。
public class Cache {
private final Map<String, Object> cache = new HashMap<>();
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private final Lock readLock = lock.readLock();
private final Lock writeLock = lock.writeLock();
public Object get(String key) {
readLock.lock();
try {
return cache.get(key);
} finally {
readLock.unlock();
}
}
public void put(String key, Object value) {
writeLock.lock();
try {
cache.put(key, value);
} finally {
writeLock.unlock();
}
}
}
上述示例中,Cache 类使用读写锁实现对缓存的读写操作。多个线程可以同时读取缓存(共享资源),但只有一个线程能够执行写操作。这样可以提高读取的并发性,同时保证写操作的原子性和一致性。
此外,锁还分公平锁和非公平锁:
公平锁维护等待队列,当线程尝试获取锁时,如果等待队列为空,或当前线程位于队首,那么线程就持有锁,否则添加到队尾,按照FIFO的顺序出队。
示例使用 ReentrantLock 类来实现。
public class Task implements Runnable {
private static ReentrantLock lock = new ReentrantLock(true);
@Override
public void run() {
lock.lock();
try {
// 执行任务
} finally {
lock.unlock();
}
}
}
非公平锁是一种不按照线程请求顺序来获取锁的机制,它允许新来的线程插队获取锁,可能导致已经在等待的线程长时间等待。在Java中,默认情况下,ReentrantLock和synchronized关键字都是非公平锁。
public class Task implements Runnable {
private static ReentrantLock lock = new ReentrantLock(false); // 非公平锁
@Override
public void run() {
lock.lock();
try {
// 执行任务
} finally {
lock.unlock();
}
}
}
在上述示例中,Task类使用非公平锁来保证多个线程获取锁的顺序是不确定的。通过创建ReentrantLock对象时,将fair参数设置为false来创建非公平锁,而公平锁使用true、
乐观锁和悲观锁:
悲观锁:认为并发访问共享资源总是会发生修改,因此在进入临界区前进行加锁操作,退出临界区后进行解锁。
乐观锁:认为并发访问共享资源不会发生修改,因此无需加锁操作,真正发生修改准备提交数据前,会检查该数据是否被修改。
乐观锁与悲观锁是两种并发控制的思想。乐观锁认为并发冲突较少,因此不主动加锁,而是在更新数据时检查是否有冲突。悲观锁则认为并发冲突较多,因此主动加锁保证数据的一致性。
乐观锁示例:使用 AtomicInteger 来实现乐观锁机制。
public class Counter {
private AtomicInteger value = new AtomicInteger(0);
public int incrementAndGet() {
return value.incrementAndGet();
}
}
悲观锁示例:使用 synchronized 关键字实现悲观锁机制。
public class Counter {
private int value = 0;
public synchronized int incrementAndGet() {
return ++value;
}
}
在乐观锁示例中,通过 AtomicInteger 类的 incrementAndGet 方法实现原子性的自增操作,无需显式加锁。而在悲观锁示例中,通过使用 synchronized 关键字来保证线程安全,确保每次调用 incrementAndGet 方法时只有一个线程能够执行。
总结:
本文详细介绍了 Java 中常用的锁类型,包括读写锁、公平锁、乐观锁和悲观锁,并结合具体示例进行了解析。读写锁允许多个线程同时读取共享资源,公平锁按照线程请求的顺序来获取锁,乐观锁和悲观锁分别适用于并发冲突较少和较多的情况。
通过深入理解和运用 Java 锁机制,开发人员能够更好地编写多线程程序,保证数据的一致性和线程安全。根据具体需求选择合适的锁类型,能够提高程序的健壮性和性能。