线程安全解决方案之ReentrantLock
Lock接口介绍
Lock是J.U.C包下面提供的一个接口,也是用来实现线程同步的一种解决方案,他提供了一个规范,定义了关于一个锁应该具备的能力,定义了加锁、解锁等基本的方法。实现类有ReentrantLock 、ReadLock (在ReentrantReadWriteLock 中做读锁)、WriteLock (在ReentrantReadWriteLock 中做写锁)、Segment (在ConcurrentHashMap )中做分段锁。当然我们也可以通过实现这个接口去自定义我们的锁。因此,我们在来理解一下接口的意义,其实就是定义规范,定义如果你要实现一个锁,则必须按照我的规范来。
我们一起看下Lock接口定义了那些方法要我们去实现:
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
ReentrantLock如何使用
ReentrantLock 是我们最常用的Lock 接口的一种实现,Reentrant是可重入的概念(后面展开)。他和synchroized 关键字都是悲观锁。我们一起看下如何使用:
lock() and unlock()
Lock lock = new ReentrantLock();
public void sellTicket() {
try {
lock.lock();
if (totalTicket > 0) {
System.out.println("totalTicket :" + totalTicket + " 被线程: " + Thread.currentThread().getName() + " 减1");
Thread.sleep(1000);
totalTicket--;
}
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
错误写法1:如果代码在第2行到第6行之间报了错,抛出异常,会导致无法走到第8行,锁无法释放
lock.lock();
if (totalTicket > 0) {
System.out.println("totalTicket :" + totalTicket + " 被线程: " + Thread.currentThread().getName() + " 减1");
Thread.sleep(1000);
totalTicket--;
}
lock.unlock();
错误写法2:多个线程会加载自己私有的线程栈,lock作为局部变量是每个线程栈内部私有的,不共享,会导致有多少个线程访问,就有多少锁,没办法起到互斥的作用,如果不理解那些是线程私有的,可以参考:并发编程02-什么是线程安全以及Java虚拟机中哪些数据是线程共享的,那些是线程私有的
public void sellTicket() {
Lock lock = new ReentrantLock();
try {
lock.lock();
if (totalTicket > 0) {
System.out.println("totalTicket :" + totalTicket + " 被线程: " + Thread.currentThread().getName() + " 减1");
Thread.sleep(1000);
totalTicket--;
}
lock.unlock();
} catch (Exception e) {
e.printStackTrace();
}
}
tryLock()
tryLock() 尝试获取锁,如果获取不到,线程就不进行处理。之前做一个定时任务的需求,由于项目中没有分布式任务调度框架,故用了spring自带的定时任务。这就导致如果你的应用部署在多个节点上,那么到了设定的时间,三个节点会同时触发定时任务,实际上只需要触发一次即可。当时用数据库做分布式锁实现了Lock接口,当定时任务触发的时候,先调用tryLock() 方法获取锁,获取到则触发定时任务,否则什么都不做。
public void sellTicket() {
if (lock.tryLock()) {
try {
} finally {
lock.unlock();
}
} else {
}
}
错误写法:如果有多个线程访问这个方法,同一时刻只有线程1获取到了锁,线程2没有获取到锁,那么线程2会走到第5行,尝试释放锁,而实际线程2并没有获取到锁,导致代码报错。
public void sellTicket() {
if (lock.tryLock()) {
}
lock.unlock();
}
简单说明ReentrantLock如何实现加锁/解锁
Lock 是一个接口,定义了锁的规范,提供了加锁/解锁的基本方法。而具体的核心逻辑是在J.U.C包下面有一个抽象类AbstractQueuedSynchronizer (AQS),在这个抽象类里面结合模板方法设计模式实现了lock 、unlock() 等基础功能,其他锁实现只需要继承这个抽象类,在做一些自定义的实现即可。通过这个设计可以进一步理解接口和抽象类的区别:接口定义规范(can-do),抽象类提取子类的公共逻辑进行实现,减少冗余代码(is-a)。关于AbstractQueuedSynchronizer 的细节可以参考:通过ReentrantLock和Semaphore看AQS独占锁和共享锁的源码。这里我们在简单提一下基本加锁流程:
- AQS中定义了一个全局变量:state,初始化为0,如果有线程获取锁资源,则通过CAS的原子操作对state加一,表示加锁成功
- 当线程释放锁资源的时候,将state重新设置为0,表示解锁成功
- 如果同时有多个线程争抢锁资源,同时只有一个线程获取到了锁,其他的线程如何处理?AQS提供了一个用双向链表实现的同步队列,让其他线程去这个队列里面排队,当锁被释放后,从队列里面取出一个线程获取锁
- 假设线程2来获取锁资源,此时线程1刚好释放了锁,他则直接加锁成功,而没有去上面说的队列中排队等待,那么这种锁我们叫做非公平锁,通过
new ReentrantLock(false) 定义非公平锁,默认非公平 - 假如线程2来获取锁资源没有插队,而是乖乖去队列里面排队等候,那么这种锁我们叫做公平锁,通过
new ReentrantLock(true) 定义公平锁
如何理解Reentrant的概念
Reentrant,英语是可重入的意思,ReentrantLock 即可重入锁,synchroized 也是可重入锁(用法参考:并发编程04-线程安全解决方案之如何正确使用synchroized关键字)。什么是可重入锁呢,以下面的代码为例,如果某一个线程在调用method1的时候获取到了锁,那么在调用method2的时候也会自动获取锁,即可重入。假设不会自动获取,也就是不可重入,那么下面这两段代码就会这样运行:线程1执行method方法获取了锁,接着调用method2,又需要获取锁,那么此时锁被谁占用呢?线程1,因此他会等线程1释放锁,而线程1能释放锁嘛,不能,因为他还没有执行完,他在method2中等待获取锁。这就卡住了一个bug,导致了死锁。因此我认为可重入锁在一定程度上解决了死锁问题。
Lock lock = new ReentrantLock(true);
public void method1() {
try {
lock.lock();
method2();
} finally {
lock.unlock();
}
}
public void method2() {
try {
lock.lock();
} finally {
lock.unlock();
}
}
public synchronized void method1() {
method2();
}
public synchronized void method2() {
}
Lock锁和synchroized锁的区别
相同点:
- 他们都可以实现线程同步,都是悲观锁
- 都是可重入锁
不同点:
synchroized 是JVM层面实现的,lock 是基于AQS框架实现的lock 支持公平锁和非公平锁两种,synchroized 是非公平的synchroized 加锁解锁是自动的,lock 需要手动去做,因为手动,则更加灵活,你可以在任意地方加锁解锁,但是一定要注意合理正确的释放锁,否则会造成死锁lock 提供了tryLock() 方法,这个是synchroized 做不到的synchroized 可以结合wait/notify机制实现线程通信,而lock 锁可以用Condition 实现线程通信lock 接口下有读锁、写锁的实现类,用来为读写锁实现高性能的读写分离的锁,synchroized 是做不到的
|