Java Lock之ReentrantLock
Java Lock之ReentrantLock
Java 锁
通常,在多线程环境时,为了确保线程安全,我们会使用synchronized
关键字来保证线程安全。在大多数情况下,synchronized
关键字是解决之道,但它由一些缺点,导致我们放弃使用它。Java 1.5 Concurrency API附带了带有接口和一些实现类的java.util.concurrent.locks
软件包,Lock
以改进对象锁定机制。
Java Lock 结构
1 | public interface Lock { |
Java Lock API 中的一些重要接口和类:
Lock:这是Lock API的基本接口。它提供了
synchronized
关键字的所有功能,并提供了其他方式来创建不同的锁定条件,从而为线程等待锁定提供了超时。一些重要的方法是:lock() 获取锁,unlock() 释放锁,tryLock() 等待锁一定时间,newCondition() 创建Condition 阻塞队列接口等。Condition:Condition对象类似于
Object wait-notify
模型,具有附加功能以创建不同的wait集。Condition对象始终由Lock对象创建。一些重要的方法是类似于wait() 和signal() 的await() ,类似于notify() 和notifyAll() 方法的signalAll()。ReadWriteLock:它包含一对关联的锁,一个用于只读操作,另一个用于写入。只要没有写程序线程,读锁就可以同时由多个读程序线程持有。写锁是排他的。
ReentrantLock:这是Lock接口使用最广泛的实现类。此类以与synchronized关键字相似的方式实现Lock接口。除了Lock接口的实现之外,ReentrantLock还包含一些实用方法来获取持有锁的线程,等待获取锁的线程等。
synchronized
同步方法本质上是可重入的,即,如果一个线程在监视对象上具有锁,并且如果另一个同步块需要在同一监视对象上具有锁,则线程可以输入该代码块。我认为这是类名称为ReentrantLock的原因。让我们通过一个简单的示例来了解此功能。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15/**
* 线程安全的类,使用synchronized关键字来保证线程安全
* @author Mr.zxb
* @date 2020-09-12 11:53:37
*/
public class SafeThreadTest {
public synchronized void foo() {
// do something
bar();
}
public synchronized void bar() {
// do some more
}
}如果线程输入foo(),则它具有
SafeThreadTest
对象的锁定,因此,当它尝试执行bar()方法时,由于该线程已经持有SafeThreadTest
对象的锁定,因此允许该线程执行bar()方法。
Java 中的 ReentrantLock
ReentrantLock
是一个可重入的互斥锁,所谓可重入是线程可以重复获取已经持有的锁。锁基本上都是要支持可重入性,否则很容易出现死锁问题。
ReentrantLock
内部实现主要通过AbstractQueuedSynchronizer
类实现的,AbstractQueuedSynchronizer
是抽象类,在ReentrantLock
类中有两个实现类:NonfairSync
和FairSync
,分别对应非公平锁和公平锁的实现。
ReentrantLock类内部持有一个Sync类型的变量,主要实现基本上都是调用Sync的实现机制,默认构建的是NonfairSync,即非公平锁,也可以通过带Boolean类型的构造函数构建公平锁,源码如下:
1 | /** |
现在,让我们看一个简单的示例,其中将用Java Lock API替换synchronized
关键字。
假设我们有一个Resource类,其中包含一些我们希望它是线程安全的操作,以及一些不需要线程安全的方法。
1 | public class Resource { |
现在,我们有一个Runnable
类,在其中我们将使用Resouce
方法。
1 | /** |
请注意,我们正在使用同步代码块来获取对Resource对象的锁定,我们可以在类中创建一个虚拟对象,并将其用于锁定目的。
现在让我们看看如何使用Java Lock API
并在不使用synchronized
关键字的情况下重写上述程序。我们将在类中使用ReentrantLock
。
1 | /** |
如您所见,我正在使用tryLock() 方法来确保我的线程仅等待一定的时间,并且如果它没有获得对象的锁,那么它只是在记录并退出。需要注意的另一个重要点是,即使doSomething() 方法调用引发任何异常,也要使用try-finally
块来确保释放锁。
Java Lock 与 synchronized的区别
基于以上程序,我们可以轻松得出Java Lock与synchronized
之间的差异:
- Java Lock API提供了更多的锁定可见性和选项,与同步机制不同,同步机制可能导致线程无限期地等待锁定,因此我们可以使用tryLock() 来确保线程仅在特定时间等待。
- 同步代码更简洁,易于维护,而使用Lock时,即使在lock() 和unlock() 方法调用之间引发了某些异常,我们也不得不尝试进行最后锁定,以确保释放Lock。
- 同步块或方法只能覆盖一个方法,而我们可以使用Lock API在一个方法中获取锁并在另一方法中释放锁。
synchronized
关键字不提供公平性,而我们在创建ReentrantLock
对象时可以将公平性设置为true,以便等待时间最长的线程首先获得该锁。- 我们可以为Lock创建不同的条件,并且不同的线程可以为不同的条件使用await() 。
总结
Lock机制的核心就是通过cas原子操作AQS中的state属性,state=0表示锁资源可用,获取锁就是通过cas原子操作将state从0设置成1,成功就表示获取锁成功,如果state>0,cas操作将会失败,即表示锁已被占用,当前获取锁失败。获取锁失败,根据是否是可中断、可超时等特性,处理的逻辑不太一致,但大致为:
- 将获取锁失败的线程封装成Node,封装成Node一方面是要构建双向队列,另一方面是Node中额外添加状态信息对节点进行控制。
- 在一个for无线循环中通过Lock.park()让线程休眠,当有锁资源被释放发生时,会从队列头到尾的顺序依次唤醒线程(会跳过CANCELLED标记的节点,因为这些节点代表的线程已经无效了),注意这里只会唤醒一个线程,唤醒的线程只表示该线程具有竞争锁资源的资格,还需要和新申请但还没有放入到Queue中的线程进行竞争该锁资源,这就是非公平锁的特性,这样设计主要是从性能方面考虑,如果竞争成功则退出for循环返回,否则继续进入休眠状态。