由于 Synchronized 本身的局限性,除 Synchronized 关键字之外,自 JDK 1.5 起,Java 的类库中提供了 java.util.concurrent 包(后面简称 J.U.C),其中的 java.util.concurrent 接口便成了 Java 的另一种全新的互斥同步手段。基于 Lock 接口,我们能以 非块结构 来实现互斥同步,摆脱语言特性的束缚,改为在类库层面实现同步,为以后拓展出各种同步锁提供了广阔的空间
重入锁(ReentrantLock)是 Lock 接口最常见的一种实现,顾名思义,它也是可重入锁。在基本用法上,ReentrantLock 也与 Synchronized 很相似,只是代码写法上稍有区别而已
ReentrantLock 并不是用来替代 Synchronized 的,而是内置加锁不适用时,作为一种可选择的高级功能
以下代码的解释:
public class ReentrantLock1 {
//执行方法 m1 和 m2 需要的都是实例对象锁
synchronized void m1() {
for(int i=0; i<10; i++) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(i);
//由于是同一个线程调用两个方法,执行第一个方法的时候,线程就拿到了这个对象的锁,在执行第二个方法的时候
//第二个方法同样要尝试拿到对象的锁,一检查发现就是这个线程拿到的锁,直接拿来用即可,无需再次尝试加锁
//这就是可重入锁的概念,synchronized必然是可重入锁。
if(i == 2) m2();
}
}
synchronized void m2() {
System.out.println("m2 方法运行...");
}
public static void main(String[] args) {
ReentrantLock1 rl = new ReentrantLock1();
new Thread(rl::m1).start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
//另起一个线程,也是尝试对这个对象加锁,则它必须要等到第一个线程执行完释放锁之后,才可以获得锁并运行;所以期间第一个线程 sleep,第二个线程也不能获得锁并执行
new Thread(rl::m2).start();
}
}
API:
lock
public void lock()
获取锁。
如果该锁没有被另一个线程保持,则获取该锁并立即返回,将锁的保持计数设置为 1。
如果当前线程已经保持该锁,则将保持计数加 1,并且该方法立即返回。
如果该锁被另一个线程保持,则出于线程调度的目的,禁用当前线程,并且在获得锁之前,该线程将一
直处于休眠状态,此时锁保持计数被设置为 1
可以认为 ReentrantLock 是可以替代 Synchronized 的,只需要在原来使用 Synchronized 的地方替换成 lock.lock();**但是要注意隐式锁 Synchronized 的加锁和解锁是由操作系统来调度的,使用显式锁 ReentrantLock 的解锁操作需要手动完成,所以必须要在 finally 语句块中执行 unlock 方法,保证无论发生任何异常的情况下锁都能够被释放 **
具体代码参考如下:
/**
* 使用reentrantlock可以完成同样的功能
* 需要注意的是,必须要必须要必须要手动释放锁(重要的事情说三遍)
* 使用syn锁定的话如果遇到异常,jvm会自动释放锁,但是lock必须手动释放锁,因此经常在finally中进行锁的释放
*/
public class ReentrantLock2 {
ReentrantLock lock = new ReentrantLock();
void m1() {
try {
lock.lock(); //等同于synchronized(this)
for (int i = 0; i < 10; i++) {
TimeUnit.SECONDS.sleep(1);
System.out.println(i);
if ( i == 2) m2();
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//必须要在 finally 语句块中执行 unlock 方法,保证任何情况下锁都能够被释放
lock.unlock();
}
}
void m2() {
try {
lock.lock();
System.out.println("m2 ...");
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
ReentrantLock2 rl = new ReentrantLock2();
new Thread(rl::m1).start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(rl::m2).start();
}
}
注意:解锁 unlock 操作一定要写在 finall 语句块里面,保证执行期间不管发生任何意外情况都一定会解锁
以下三个特点是 ReentrantLock 相较于 Synchronized 中比较有优势的特性
API:
tryLock
public boolean tryLock()
仅在调用时锁未被另一个线程保持的情况下,才获取该锁。
1)如果该锁没有被另一个线程保持,并且立即返回 true 值,则将锁的保持计数设置为 1。
即使已将此锁设置为使用公平排序策略,但是调用 tryLock() 仍将 立即获取锁(如果有可用的),
而不管其他线程当前是否正在等待该锁。在某些情况下,此“闯入”行为可能很有用,即使它会打破公
平性也如此。如果希望遵守此锁的公平设置,则使用 tryLock(0, TimeUnit.SECONDS)
,它几乎是等效的(也检测中断)。
2)如果当前线程已经保持此锁,则将保持计数加 1,该方法将返回 true。
3)如果锁被另一个线程保持,则此方法将立即返回 false 值。
线程执行 Synchronized 同步代码,如果无法获得锁,就会阻塞在那里,处在 Blocked 状态;但是如果使用 ReentrantLock ,我们可以通过 tryLock() 方法来进行尝试加锁,此方法返回值是 boolean 类型,可以根据方法的返回值来确定当前线程是否获得锁
使用 tryLock() 尝试获得锁,不管锁定与否,方法都能将继续执行;不过可以根据是否能够获得锁,来决定方法的不同的执行逻辑
public class TestTryLock {
ReentrantLock lock = new ReentrantLock();
void m1() {
try {
lock.lock();
//线程 1 在此处循环调整释放锁的时机,循环是否超过 5 次决定了线程 2 是否能够获得锁
for (int i = 0; i < 3; i++) {
TimeUnit.SECONDS.sleep(1);
System.out.println(i);
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
/**
* 使用tryLock进行尝试锁定,不管锁定与否,方法都将继续执行
* 可以根据tryLock的返回值来判定是否锁定
* 也可以指定tryLock的时间,由于tryLock(time)抛出异常,所以要注意unclock的处理,必须放到finally中
*/
void m2() {
boolean locked = false;
try {
// tryLock 方法的返回值是 boolean 类型:如果 5 秒种之内能够获得锁,就可以返回 true,否则返回 false
locked = lock.tryLock(5, TimeUnit.SECONDS);
System.out.println("m2 是否获得了锁?-- " + locked);
//使用 tryLock 可以根据方法返回值,来决定方法的允许逻辑,哪怕未获得锁,方法仍然可以向下运行
if (locked == true){
//....省略若干逻辑代码
System.out.println("m2 获得锁的执行逻辑");
}else if (locked == false){
//未获得锁,方法仍然得以运行
System.out.println("m2 未获得锁的执行逻辑");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//如果获得锁,则必须手动释放锁
if(locked) lock.unlock();
}
}
public static void main(String[] args) {
TestTryLock rl = new TestTryLock();
new Thread(rl::m1).start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(rl::m2).start();
}
}
等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。可中断特性对处理执行时间非常长的同步块很有帮助
API:
lockInterruptibly
public void lockInterruptibly() throws InterruptedException
1)如果当前线程未被中断,则获取锁。
2)如果该锁没有被另一个线程保持,则获取该锁并立即返回,将锁的保持计数设置为 1。
3)如果当前线程已经保持此锁,则将保持计数加 1,并且该方法立即返回。
4)如果锁被另一个线程保持,则出于线程调度目的,禁用当前线程,并且在发生以下两种情况之一以
前,该线程将一直处于休眠状态:
1)锁由当前线程获得;或者
2)其他某个线程中断当前线程。
5)如果当前线程获得该锁,则将锁保持计数设置为 1。
如果当前线程:
1)在进入此方法时已经设置了该线程的中断状态;或者
2)在等待获取锁的同时被中断。
则抛出 InterruptedException,并且清除当前线程的已中断状态。
6)在此实现中,因为此方法是一个显式中断点,所以要优先考虑响应中断,而不是响应锁的普通获取或
重入获取
lock()与lockInterruptibly()的区别:
简单总结就是:
Synchronized 和 lock() 方法优先考虑获得锁,待获取锁成功后,才响应中断
lockInterruptibly() 方法优先考虑响应中断,而不是响应锁的普通获或重入获取
示例代码如下:
public class TestInterrupt {
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
Thread t1 = new Thread(()->{
System.out.println("t1 start");
try {
reentrantLock.lock();
System.out.println("t1 获得锁并执行");
//线程 t1 获得锁,但是调用 sleep 方法休眠一天,进入 Blocked 状态,而且并不会释放锁
TimeUnit.SECONDS.sleep(60 * 60 * 24);
} catch (InterruptedException e) {
System.out.println("线程 t1 被中断运行,锁资源被释放");
} finally {
reentrantLock.unlock();
}
});
t1.start();
Thread t2 = new Thread(()->{
System.out.println("t2 start");
try {
//通过 lock 方法加锁,无法对interrupt方法作出响应
// reentrantLock.lock();
//通过 lockInterruptibly() 方法进行加锁,可以对 interrupt 方法作出响应
reentrantLock.lockInterruptibly();
System.out.println("t2 获得锁并正常执行需要加锁的工作");
} catch (InterruptedException e) {
System.out.println("t2 未获得锁,但是可以对interrupt方法作出响应,阻塞状态被打断");
System.out.println("t2 去执行一些不需要加锁的工作");
} finally {
try {
reentrantLock.unlock();
} catch (Exception e) {
}
}
});
t2.start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
//t2 未获得锁的情况下打断其阻塞状态,并通过lockInterruptibly() 方法做出响应
//执行不需要加锁的执行逻辑
t2.interrupt();
}
}
运行结果:
t1 start
t1 获得锁并执行
t2 start
t2 未获得锁,但是可以对interrupt方法作出响应,阻塞状态被打断
t2 去执行一些不需要加锁的工作
公平锁:是指当多个线程在等待同一个锁的时候,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都会抢夺锁,都有机会获得锁,甚至刚释放锁的线程仍然可以再次抢夺锁
Synchronized 中的锁是非公平的,ReentrantLock 默认情况下和 Synchronized 一样是非公平锁,但是 ReentrantLock 可以通过带 boolean 值的构造函数要求使用公平锁
ReentrantLock 设置公平锁示例如下:
public class TestFireLock extends Thread {
//参数为 true 表示为公平锁,请对比输出结果
private static ReentrantLock lock=new ReentrantLock(true);
public void run() {
for(int i=0; i<100; i++) {
lock.lock();
try{
Thread.sleep(1);
System.out.println(Thread.currentThread().getName()+"获得锁");
} catch (InterruptedException e) {
e.printStackTrace();
} finally{
lock.unlock();
}
}
}
public static void main(String[] args) {
TestFireLock fireLock = new TestFireLock();
Thread th1 = new Thread(fireLock);
Thread th2 = new Thread(fireLock);
th1.start();
th2.start();
}
}
当设置为公平锁时,运行结果必然是如下这种公平交替运行的情况:
Thread-1获得锁
Thread-2获得锁
Thread-1获得锁
Thread-2获得锁
Thread-1获得锁
Thread-2获得锁
...
如果不进行公平锁的设置,两条线程将会抢夺锁资源,可能会出现任何情况,比如一个线程连续若干次抢夺到锁,另一个线程很少抢到锁:
Thread-2获得锁
Thread-2获得锁
Thread-2获得锁
Thread-1获得锁
Thread-1获得锁
...
不过一旦使用公平锁,会导致 ReentrantLock 的性能急剧下降,明显影响吞吐量,而且大多数情况下我们并不需要维护公平性;只有在一些特殊的、需要保证公平性的情况下,才会使用公平锁
锁绑定多个条件:是指一个 ReentantLock 对象可以同时绑定多个 Condition 对象。
在 Synchronized 中,锁对象的 wait() 和它的 notify() 方法配合可以实现一个隐含的条件,如果要和多于 1 个的条件关联的时候,就不得不额外添加一个锁;而 ReentantLock 则无需这样做,多次调用 newCondition() 方法即可
经典的生产者消费者实现是通过 Synchronized 来完成——当生产者线程把容器生产满了的时候,会调用 notifyAll() 方法,唤醒所有生产者、消费者线程抢夺容器的锁资源,此时如果还是生产者抢到了容器的锁,它是没有办法继续生产的,只会再次调用 notifyAll() 方法,直到有消费者线程把容器消费出空闲容量;消费者线程也是同理
当生产者线程 wait 的时候,是没有必然唤醒其他的生产者线程的(消费者线程同理),有没有一种方法能够唤醒指定的线程呢?此时可以使用 ReentantLock 中的 Condition ,使线程分为不同的条件队列
Condition的本质就是不同的等待队列
- 使用Sync的时候,只有一个等待队列,所有的生产者消费者都在一个等待队列,所有线程一拥而上抢锁
- ReentrantLock 的 Condition 可以创建多个等待队列,这样在唤醒线程的时候,就可以只唤醒指定等待队列的线程
ReentantLock 实现生产者–消费者模型代码示例:
public class TestCondition<T> {
final private LinkedList<T> lists = new LinkedList<>();
final private int MAX = 10; //最多10个元素
private int count = 0;
private Lock lock = new ReentrantLock();
//生产者线程队列
private Condition producer = lock.newCondition();
//消费者线程队列
private Condition consumer = lock.newCondition();
public void put(T t) {
try {
lock.lock();
while(lists.size() == MAX) {
producer.await();
}
lists.add(t);
++count;
//通知消费者线程进行消费,这唤醒消费者
consumer.signalAll();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public T get() {
T t = null;
try {
lock.lock();
while(lists.size() == 0) {
consumer.await();
}
t = lists.removeFirst();
count --;
//通知生产者进行生产,只唤醒生产者
producer.signalAll();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
return t;
}
public static void main(String[] args) {
TestCondition<String> c = new TestCondition<>();
//启动消费者线程
for(int i=0; i<10; i++) {
new Thread(()->{
for(int j=0; j<5; j++) System.out.println(c.get());
}, "c" + i).start();
}
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
//启动生产者线程
for(int i=0; i<2; i++) {
new Thread(()->{
for(int j=0; j<25; j++) c.put(Thread.currentThread().getName() + " " + j);
}, "p" + i).start();
}
}
}
用到的方法的补充说明:
await() :阻塞当前队列的所有线程
signal() :唤醒指定的等待队列
signalAll() :唤醒所有的等待队列
AQS 即 AbstractQueuedSynchronized,底层数据结构是 FIFO 双向队列,AQS是很多同步工具锁的底层实现,比如ReentrantLock、CountDownLatch、CyclicBarrier等,所以理解 AQS 是非常有必要的
AQS 的核心是 State——标志锁的状态
如果对于ReentrantLock,state是0就表示没有加锁,从0变成1就表示有一个线程加了锁,此后如果还是这个线程重入,值就会递增,什么时候这个值不断递减至0,就说明这个锁被释放了;如果对于CountDownLatch,state就可能代表门闩latch的默认限制数量,对于CyclicBarrier中可能代表栅栏的限制等等
State后面跟着一个队列,这个队列是双向链表,由AQS维护,里面包含一堆的线程节点node,每个node里面封装了一个线程,这么多的线程节点去争用state,谁先抢到state,谁就拿到了锁。示意图如下:
理解 AQS 的核心,就是理解 State 的意义以及双向队列如何通过 CAS 操作来完成双向队列中节点正确的入队列、出队列实现
由于 AQS 很重要,是很多并发工具类的底层实现核心,而且其实现很复杂,在这个 ReentrantLock 的专题来讲有点喧宾夺主,所以我们后面单独用一篇文章来讲 AQS,这里简单了解 ReentrantLock 的底层实现核心是 AQS 即可
Synchronized与 ReentantLock 的相同点:
Synchronized 是 JVM 自动加锁,加锁解锁操作都映射到操作系统来自动完成;ReentantLock 是手动加锁,加锁方式和时机可以自己选择
Synchronized 遇到异常情况,会自动释放锁;ReentantLock 遇到异常不会释放锁,所以必须要在 finall 块中手动释放锁
详细区别:
简单总结区别就是:
根据上面的讨论,ReentantLock 在功能上是 Synchronized 的超集,在性能上也不会弱于 Synchronized ,那 Synchronized 是否就应该被抛弃,不被使用了呢?当然不是,实际上基于以下情况我们大多数情况下,还是会有限使用 Synchronized,而不是 ReentantLock
参考:《深入理解JAVA虚拟机》
关联文章: