一、Lock锁
Lock接口是对锁操作方法的一个基本定义,它提供了synchronized关键字所具备的全部功能方法,另外我们可以借助于Lock创建不同的Condition对象进行多线程间的通信操作,与关键字synchronized进行方法同步代码块同步的方式不同,Lock提供了编程式的锁获取(lock())以及释放操作(unlock())等其他操作。
Lock锁的实现类:
Lock锁的接口定义:
public interface Lock {
/**
* 尝试获取锁,如果此刻该锁未被其他线程持有,则会立即返回,并且设置锁的hold计数为1;
* 如果当前线程已经持有该锁则会再次尝试申请,hold计数将会增加一个,并且立即返回;
* 如果该锁当前被另外一个线程持有,那么当前线程会进入阻塞,直到获取该锁,
* 由于调用lock方法而进入阻塞状态的线程同样不会被中断,这一点与进入synchronized同步方法或者代码块被阻塞类似。
*/
void lock();
/**
* 该方法的作用与lock()类似,但是使用该方法试图获取锁而进入阻塞操作的线程则是可被中断的,也就说线程可以获得中断信号。
*/
void lockInterruptibly() throws InterruptedException;
/**
* 调用该方法获取锁,无论成功与否都会立即返回,线程不会进入阻塞状态,若成功获取锁则返回true,若获取锁失败则返回false。
* 使用该方法时请务必注意进行结果的判断,否则会出现获取锁失败却仍旧操作共享资源而导致数据不一致等问题的出现。
*/
boolean tryLock();
/**
* 该方法与tryLock()方法类似,只不过多了单位时间设置,如果在单位时间内未获取到锁,则返回结果为false,
* 如果在单位时间内获取到了锁,则返回结果为true,同样hold计数也会被设置为1。
*/
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
/**
* 释放锁,当某个线程对锁的使用结束之后,应该确保对锁资源的释放,以便其他线程能够继续争抢。
*/
void unlock();
/**
* 创建一个与该lock相关联的Condition对象,Condition的作用在于控制锁并且判断某个条件(临界值)是否满足,
* 如果不满足,那么使用该锁的线程将会被挂起等待另外的线程将其唤醒,
* 与此同时被挂起的线程将会进入阻塞队列中并且释放对显式锁Lock的持有,这一点与对象监视器的wait()方法非常类似
*/
Condition newCondition();
}
二、ReentrantLock 可重入锁
在显式锁Lock接口的实现中,我们用得最多的就是ReentrantLock,该类不仅完全实现了显示锁Lock接口所定义的接口,也扩展了对使用显式锁Lock的一些监控方法。
- getHoldCount()方法:查询当前线程在某个Lock上的数量,如果当前线程成功获取了Lock,那么该值大于等于1;如果没有获取到Lock的线程调用该方法,则返回值为0。
- isHeldByCurrentThread()方法:判断当前线程是否持有某个Lock,由于Lock的排他性,因此在某个时刻只有一个线程调用该方法返回true。
- isLocked()方法:判断Lock是否已经被线程持有。
- isFair()方法:创建的ReentrantLock是否为公平锁。
- hasQueuedThreads()方法:在多个线程试图获取Lock的时候,只有一个线程能够正常获得,其他线程可能(如果使用tryLock()方法失败则不会进入阻塞)会进入阻塞,该方法的作用就是查询是否有线程正在等待获取锁。
- hasQueuedThread(Thread thread)方法:在等待获取锁的线程中是否包含某个指定的线程。
注意细节:
- 多个原子性方法的组合不能确保原子性
- 避免锁的交叉使用引起死锁
- 确保已获取锁的释放(因为可重入性,所以多次获取锁,就需要多次释放锁)
//阻塞方式获取锁
public static void test1() throws InterruptedException {
final ReentrantLock lock = new ReentrantLock();
//线程A
Thread t1 = new Thread(() ->{
try {
//第一次获取锁,阻塞方式获取
lock.lock();
System.out.println(Thread.currentThread().getName() + "线程,第一次获取到lock!当前holdCount:" + lock.getHoldCount());
lock.lock();
System.out.println(Thread.currentThread().getName() + "线程,第二次获取到lock!当前holdCount:" + lock.getHoldCount());
} finally {
lock.unlock();
//lock.unlock();
System.out.println(Thread.currentThread().getName() + "线程,第一次释放lock!当前holdCount:" + lock.getHoldCount());
}
});
t1.start();
//主程序休眠3秒,保证线程A创建完成,并启动
TimeUnit.SECONDS.sleep(3);
//主线程尝试获取锁,因为线程A获取锁两次,但是只释放了一次,所以线程A没有真正把锁释放掉,主线程无法获取锁,阻塞当前线程
lock.lock();
System.out.println(Thread.currentThread().getName() + "线程,是否可以执行到该位置呢?" );
lock.unlock();
}
//主线程采用非阻塞的方式,获取锁,避免主线程被阻塞
if(lock.tryLock()){
lock.lock();
System.out.println(Thread.currentThread().getName() + "线程,获取到了锁!" );
lock.unlock();
}else{
System.out.println(Thread.currentThread().getName() + "线程,未获取到锁!" );
}
ReentrantLock 与 Synchronized关键字 的区别
使用synchronized关键字进行共享资源的同步时,JVM提供了两个指令monitor enter和monitor exit来分别确保锁的获取和释放操作,这与显式锁Lock的lock和unlock方法的作用是一致的。
1、synchronized关键字的使用灵活性不如显式锁Lock
2、在单线程访问的情况下,synchronized关键字的性能要高于lock锁,这主要得益于JDK内部对于synchronized关键字的不断优化升级,另外在单线程的情况下,synchronized关键字的jvm指令在运行期间也会被优化。
3、在多线程访问情况下,显式锁Lock的性能要优于synchronized关键字。
三、ReadWriteLock 读写锁
读写锁类,旨在允许某个特定时刻多线程并发读取共享资源,提高系统性能和访问吞吐量,即对共享资源的访问一般包括两种类型的动作,读和写(修改、删除等会引起资源发生变化的动作),当多个线程同时对某个共享资源进行读取操作时,并不会引起共享资源数据不一致情况的发生,因此这个时候我们不应该让资源的访问互斥。
与ReentrantLock一样,只不过在使用的过程中需要分别派生出“读锁”和“写锁”,在进行共享资源读取操作时,需要使用读锁进行数据同步,在对共享资源进行写操作时,需要使用写锁进行数据一致性的保护。
public interface ReadWriteLock {
/**
* 获取读锁,若某个线程获取了读锁进行数据读操作,那么此时其他线程对共享资源的写操作会进入阻塞直到锁被释放,但如果是其他线程对共享资源进行读操作则不会被阻塞。
*/
Lock readLock();
/**
* 获取写锁,若某个线程获取了写锁进行数据写操作,那么此时其他线程对共享资源的读写操作都会被阻塞直到锁被释放。
*/
Lock writeLock();
}
ReentrantReadWriteLock实现自ReadWriteLock接口并且提供了一些ReadWriteLock监控查询方法。
读锁、写锁的方法:
读写锁使用示例,在main方法中,多次调用readLock()方法,发现不会被阻塞,但是如果分别调用readLock()方法和writeLock()方法,或者同时调用writeLock()方法都会被阻塞。
public class ReadWriteLockTest {
//读写锁
private static final ReadWriteLock lock = new ReentrantReadWriteLock();
//读锁
private static final Lock readLock = lock.readLock();
//写锁
private static final Lock writeLock = lock.writeLock();
//共享数据
private static List<String> list = new ArrayList<>();
public static void main(String[] args) throws InterruptedException {
ReadWriteLockTest.writeLock();
TimeUnit.SECONDS.sleep(2);//保证上句先执行
System.out.println(Thread.currentThread().getName() + "主线程继续执行!");
ReadWriteLockTest.readLock();
//ReadWriteLockTest.readLock();
}
public static void readLock(){
new Thread(() ->{
try{
readLock.lock();
System.out.println(Thread.currentThread().getName() + "读线程,执行了读操作:");
list.forEach(i -> System.out.println(i));
TimeUnit.SECONDS.sleep(5);
System.out.println(Thread.currentThread().getName() + "读线程,继续执行!");
}catch (Exception e){
e.printStackTrace();
}finally {
readLock.unlock();
}
}).start();
}
public static void writeLock(){
new Thread(() ->{
try{
writeLock.lock();
System.out.println(Thread.currentThread().getName() + "写线程,执行了写操作:");
list.add("test");
TimeUnit.SECONDS.sleep(5);
System.out.println(Thread.currentThread().getName() + "写线程,继续执行!");
}catch (Exception e){
e.printStackTrace();
}finally {
writeLock.unlock();
}
}).start();
}
}
四、Condition对象
Condition对象是由某个显式锁Lock创建的,一个显式锁Lock可以创建多个Condition对象与之关联,Condition的作用在于控制锁并且判断某个条件(临界值)是否满足,如果不满足,那么使用该锁的线程将会被挂起等待另外的线程将其唤醒,与此同时被挂起的线程将会进入阻塞队列中并且释放对显式锁Lock的持有,这一点与对象监视器的wait()方法非常类似。
Condition一经推出,就大规模地替代了传统对象监视器(Object Monitor)方式进行多个线程间的通信和数据交换,同时Condition又提供了更多的操作方法,比如用于线程监控等。相比对象监视器的方式,Condition更加高效,避免了很多无谓的线程上下文切换,从而提高了CPU的利用率。
public interface Condition {
/**
* 当前线程调用该方法会进入阻塞状态直到有其他线程对其进行唤醒,或者对当前线程执行中断操作。
* 当线程执行了await()方法进入阻塞时;当前线程会被加入到阻塞队列中,并且释放对显式锁的持有。
* Condition的wait队列则是由Java程序实现的FiFO队列。
*/
void await() throws InterruptedException;
/**
* 该方法与await()方法类似,只不过该方法比较固执,它会忽略对它的中断操作,一直等待有其他线程将它唤醒。
*/
void awaitUninterruptibly();
/**
* 调用该方法同样会使得当前线程进入阻塞状态,但是可以设定阻塞的最大等待时间,如果在设定的时间内没有其他线程将它唤醒或者被执行中断操作,那么当前线程将会等到设定的纳秒时间后退出阻塞状态。
*/
long awaitNanos(long nanosTimeout) throws InterruptedException;
/**
* 执行方法awaitNanos(),如果到达设定的纳秒数则当前线程会退出阻塞,并且返回实际等待的纳秒数,但是程序很难判断线程是否被正常唤醒,
* 因此该方法的作用除了可以指定等待的最大的单位时间,另外,还可以返回在单位时间内被正常唤醒而且还是由于超时而退出的阻塞。
*/
boolean await(long time, TimeUnit unit) throws InterruptedException;
/**
*调用该方法同样会导致当前线程进入阻塞状态直到被唤醒、被中断或者到达指定的Date
*/
boolean awaitUntil(Date deadline) throws InterruptedException;
/**
* 唤醒Condition阻塞队列中的一个线程,Condition的wait队列采用FiFO的方式,因此在wait队列中,第一个进入阻塞队列的线程将会被首先唤醒
*/
void signal();
/**
* 唤醒Condition wait队列中的所有线程。
*/
void signalAll();
}
根据Condition的用法,我们模拟一个生产者消费者模式,来验证Condition的用法,示例如下:
public class ConditionTest {
private static final Lock lock = new ReentrantLock();
//用于共享变量是否达到上限的判断
private static final Condition FULL_CONDITION = lock.newCondition();
//用于共享变量是否为空的判断
private static final Condition EMPTY_CONDITION = lock.newCondition();
//共享变量,模拟生产消费者中的池
private static final LinkedList<Long> list = new LinkedList<>();
//共享变量允许的最大值
private static final int CAPACITY = 10;
//初始值
private static long i = 0;
public static void main(String[] args) {
//启动3个生产者线程
IntStream.range(0,3).forEach(k ->{
new Thread(()->{
try {
for (;;){
produce();
TimeUnit.SECONDS.sleep(3);
}
}catch (Exception e){
e.printStackTrace();
}
},"Producer" + k).start();
});
//启动2个消费者线程
IntStream.range(0,2).forEach(k ->{
new Thread(()->{
try {
for (;;){
consume();
TimeUnit.SECONDS.sleep(3);
}
}catch (Exception e){
e.printStackTrace();
}
},"Consumer" + k).start();
});
}
private static void produce(){
lock.lock();//获取阻塞锁
try{
System.out.println("生产者线程" + Thread.currentThread().getName() + ",获取到锁,尝试产生数据!");
while (list.size() >= CAPACITY){//共享变量已经达到存储上限
System.out.println("生产者线程" + Thread.currentThread().getName() + ",当前数据达到上限,阻塞当前线程,释放锁!");
FULL_CONDITION.await();//阻塞当前线程,释放锁
}
i++;
list.addLast(i);
System.out.println("生产者线程" + Thread.currentThread().getName() + ",产生数据:" + i);
//唤醒Condition阻塞队列中的一个线程,Condition的wait队列采用FiFO的方式,因此在wait队列中,第一个进入阻塞队列的线程将会被首先唤醒
EMPTY_CONDITION.signal();
}catch (Exception e){
e.printStackTrace();
}finally {
lock.unlock();//释放锁
}
}
private static void consume(){
lock.lock();
try {
System.out.println("消费者线程" + Thread.currentThread().getName() + ",获取到锁,尝试消费数据!");
while (list.isEmpty()){//没有数据可使用
System.out.println("消费者线程" + Thread.currentThread().getName() + ",当前数据为空,阻塞当前线程,释放锁!");
EMPTY_CONDITION.await();//阻塞当前线程,释放锁
}
Long v = list.removeFirst();
System.out.println("消费者线程" + Thread.currentThread().getName() + ",消费数据:" + v);
//唤醒Condition wait队列中的所有线程。
FULL_CONDITION.signalAll();
}catch (Exception e){
e.printStackTrace();
}finally {
lock.unlock();
}
}
}
五、StampedLock锁
JDK1.8版本引入了StampedLock,该锁由一个long型的数据戳(stamp)和三种模型构成,当获取锁(比如调用readLock(),writeLock())的时候会返回一个long型的数据戳(stamp),该数据戳将被用于进行稍后的锁释放参数。如果返回的数据戳为0(比如调用tryWriteLock()),则表示获取锁失败,同时StampedLock还提供了一种乐观读的操作方式。和ReentrantLock、ReentrantReadWriteLock不一样的是:在StampedLock内部没有hold计数器,所以是不可重入的。StampedLock的引入可以解决读写锁的饥饿写问题。
读写锁的饥饿写问题
饥饿写是指在使用读写锁(非公平锁)的时候,读线程的数量远远大于写线程的数量,导致锁长期被读线程霸占,写线程无法获得对数据进行写操作的权限从而进入饥饿的状态。当然可以在构造读写锁时指定其为公平锁,读写线程获得执行权限得到的机会相对公平,但是当读线程大于写线程时,性能效率会比较低下。
StampedLock实现ReentrantLock锁的功能
在ReentrantLock锁中不存在读写分离锁,使用StampedLock锁
的writeLock()方法模拟ReentrantLock类的lock()方法,该方法会返回一个数据戳(stamp),在稍后的锁释放过程中需要用到该数据戳(stamp),即lock.unlock(stamp)。
和ReentrantLock的区别:不可重入。
StampedLock实现ReentrantReadWriteLock锁的功能
StampedLock也提供了读锁和写锁这两种模式,因此StampedLock天生就支持读写分离锁的使用方式。使用StampedLock锁不需要额外创建出不同类型的Lock(ReadLock或WriteLock)就可以很轻易地完成读写锁的分离,提高并发情况下的数据读取性能。
实现示例如下,可以验证StampedLock完全可以支持:读读不互斥,读写互斥,写写互斥。
public class StampedLockTest1 {
//共享变量,模拟生产消费者中的池
private static final LinkedList<Integer> list = new LinkedList<>();
private static int i = 0;
private static final StampedLock lock = new StampedLock();
public static void main(String[] args) {
IntStream.range(0,10).forEach(k ->{
new Thread(()->{
consume();
},"Consumer" + k).start();
});
/*IntStream.range(0,10).forEach(k ->{
new Thread(()->{
produce();
},"Producer" + k).start();
});*/
}
/**
* 写锁
*/
public static void produce(){
long stamp = lock.writeLock();
System.out.println("生产者线程" + Thread.currentThread().getName() + "获取写锁!");
try {
i++;
list.addLast(i);
System.out.println("生产者线程" + Thread.currentThread().getName() + ",获取到锁,产生数据:" + i);
}catch (Exception e){
e.printStackTrace();
}finally {
lock.unlock(stamp);
System.out.println("生产者线程" + Thread.currentThread().getName() + "释放写锁!");
}
}
/**
* 读锁
*/
public static void consume(){
long stamp = lock.readLock();
try{
System.out.println("消费者线程" + Thread.currentThread().getName() + "获取读锁!");
if(!list.isEmpty()){
Integer v = list.removeFirst();
System.out.println("消费者线程" + Thread.currentThread().getName() + ",消费数据:" + v);
}else{
System.out.println("消费者线程" + Thread.currentThread().getName() + ",当前没有数据可消费");
}
TimeUnit.SECONDS.sleep(new Random().nextInt(5));
}catch (Exception e){
e.printStackTrace();
}finally {
lock.unlock(stamp);
System.out.println("消费者线程" + Thread.currentThread().getName() + "释放读锁!");
}
}
}
StampedLock实现乐观读模式
StampedLock还提供了一个乐观读模式,使用tryOptimisticRead()方法获取一个非排他锁并且不会进入阻塞状态,与此同时该模式依然会返回一个long型的数据戳用于接下来的验证(该验证主要用来判断共享资源是否有写操作发生)。
首先通过tryOptimisticRead()获取,然后通过validate()校验,通过说明当前读锁可用,直接返回,否则,就通过readLock()方法进入阻塞,等待CPU调度,直到获取到读锁。
/**
* 读锁
*/
public static int consume(){
//乐观锁,立即返回,并不会导致当前线程进入阻塞等待,
//该方法会返回一个long型的数据戳(stamp),如果获取成功,则数据戳为非0,如果失败,则数据戳为0。
long stamp = lock.tryOptimisticRead();
if(lock.validate(stamp)){//校验通过,即当前没有写线程在操作变量
return shareData;
}else{//如果有写锁在使用,就使用readLock()获取读锁,进入阻塞,等待CPU调度,直到获取到读锁
stamp = lock.readLock();
try{
return shareData;
}finally {
//释放锁
lock.unlock(stamp);
}
}
}
六、总结
我们这篇内容主要学习了Lock锁及其实现类ReentrantLock、ReentrantReadWriteLock和StampedLock的基本用法,关于实现原理和读写锁的饥饿写问题产生的原因等,我们在后续内容中逐渐进行整理。