目录

前言:

前置知识:

什么是公平锁与非公平锁?

尝试自己构造一把锁:

ReentrantLock源码:

加锁:

解锁:

总结:


前言:

在并发编程中,线程安全是一个重要的问题。为了保证多个线程之间的互斥访问和正确的同步操作,Java提供了一种强大的锁机制——ReentrantLock(可重入锁)。

与synchronized相比,ReentrantLock提供了更加灵活和强大的功能。它支持公平锁和非公平锁两种模式,可以通过lock()和unlock()方法手动控制锁的获取和释放,并且可以实现可重入特性,即同一个线程可以多次获得同一个锁而不会发生死锁。

在本文中,我们将详细讲解ReentrantLock的使用方法,包括基本的锁操作、可重入特性、公平性设置、条件变量和中断等。我们还将对比ReentrantLock和synchronized的性能差异,并给出一些适用场景的建议。

在正式介绍ReentrantLock之前,我们要先来学习一些前置知识,方便大家更好的理解ReentrantLock源码。

前置知识:

什么是公平锁与非公平锁?

公平锁:

公平锁是指多个线程按照申请锁的顺序依次获取锁,即先到先得的原则。当一个线程释放锁后,等待时间最长的那个线程将获得锁的访问权。公平锁可以避免线程饥饿,保证每个线程都有机会获得锁,但可能会降低系统的整体吞吐量。

非公平锁:

非公平锁是指多个线程竞争锁时,不考虑线程的等待时间,直接尝试获取锁。如果当前锁没有被其他线程持有,则该线程立即获得锁的访问权,即先到先得。非公平锁的性能相对较好,因为它减少了线程切换和调度的开销,但可能会导致某些线程一直无法获取到锁,产生线程饥饿的问题。

简而言之:公平锁在线程获取锁的时候,等待时间最长的线程获取锁的访问权。非公平锁是线程自由竞争,不存在按照等待时间排序。

而ReentrantLock既可以是公平锁,也可以是非公平锁。无参构造情况下是非公平锁

尝试自己构造一把锁:

锁的基本原理就是:加锁就是放行或者阻塞当前线程。放锁就是唤醒被阻塞的线程。

基于这个思想,我们很快就可以设计出一个最简单的锁:

public class LiLocks {int statue =0;public void lock(){while ( statue!=0){System.out.println(Thread.currentThread().getName()+"等待锁");}statue=1;}public void unlock(){statue=0;}}

但是这个锁存在一个最基本的问题:并不能处理全部情况

理想状态下,A线程进入lock之后,先判断当前statue==0,之后做statue自增操作。这样当下一个线程进入的时候就会被阻塞到while循环中,直到A线程进入unlock方法对statue做递减操作。如果statue==0的话,就相当于释放锁。

而在有些特殊情况下,当A线程比较完之后,还没来得及给statue赋值,另外一个B线程就抢到了资源调度,由于此时statue还没变值,所以此时B线程仍然可以通过比较不进入while循环。就造成了加锁失败的情况

也就是说:如果我们想要优化这个锁,那么就需要使得判断statue==0和赋值statue=1具有原子性。

这两步就是CAS(Compare And Swap),只不过我们的CAS是自己写的,所以不具备原子性。那么我们调用Java为我们提供的具有原子性的CAS操作就好了。

而Java为我们提供的具有原子性的cas操作来自Unsafe类,那么我们使用Unsafe类来改造代码:

public class LiLock {private static Unsafe unsafe;volatileint statue =0;static {try {Field field = Unsafe.class.getDeclaredField("theUnsafe");field.setAccessible(true);unsafe = (Unsafe) field.get(null);} catch (Exception e) {e.printStackTrace();}}long objectOffset = unsafe.objectFieldOffset(LiLock.class.getDeclaredField("statue"));public LiLock() throws NoSuchFieldException {}public void lock() throws NoSuchFieldException {while (!unsafe.compareAndSwapInt(this, objectOffset, 0, 1)){System.out.println(Thread.currentThread().getName()+" is waiting");}}public void unlock() {statue=0;}}

在这里,我们简单实现了一个自旋锁。而我们如果想要把这个锁改为公平锁,要怎么做呢?

也就是说:当此时有1,2,3 总共三个线程的时候,如果1线程获取到了锁,那么我们如何让2,3线程按照顺序获取锁呢?

假设线程2此时已经被while循环阻塞了,如果此时线程3来的话,那么我们是不能让线程3进入while阻塞的。如果3也被阻塞的话,那么不就是说2和3又在同一起跑线了。无法保证2先来就先获取到锁。

所以此时的问题就在于:如何不使用相同的while来阻塞3线程,让2线程优先获得到锁?

我们回顾一下我们之前学过的阻塞一个线程的方式:

1.wait():使用不了,他需要和synchronized 关键字一起使用,而我们是在自己写锁,当然不能使用synchronized

2.sleep():使用不了,我们不知道线程1需要多长时间才会释放锁,因此无法确定让线程2,3睡眠多长时间。

3.while(ture):使用不了,使用这个还是将线程2,3放到一个起跑线了,无法确保先来的线程2一定能过比线程3获取到锁

基于这三种方法都使用不了,我们只能采用第四种:LockSupport类中的park()方法,它可以阻塞一个线程,使其不消耗cpu资源,直至被其他线程唤醒或者中断

那么我们直接对线程3采用park方法就可以了。通过这种机制,我们就实现了公平锁的机制。

通过上述知识,我们引入了公平锁的基本实现方式,理解如何构造一个公平锁有利于我们阅读ReentrantLock源码。

ReentrantLock源码:

加锁:

最外层:

这段代码的基本逻辑为:如果当前线程不能获取锁,则会进入等待队列中等待,并在等待过程中不断尝试获取锁。如果当前线程被中断,则会重新设置中断标志。

我们在这里主要聊的是加锁,因此我们就先看tryAcquire这个方法:

这个方法分为两个实现:公平锁非公平锁,因此我们逐个来看:

公平锁:

我们来先看一下这个方法中调用的一些比较重要的方法:

1.setExclusiveOwnerThread():将锁的拥有线程修改为当前线程。

2.compareAndSetState():具有原子性的CAS操作。

3.getExclusiveOwnerThread():获取当前持有锁的线程对象。

4.setState:设置当前锁的状态。

5.hasQueuedPredecessors():判断当前线程是否需要排队

在了解完这些方法之后,其实了解公平锁的代码逻辑就很简单了。

公平锁的基本代码逻辑为:

1.获取当前线程和锁状态。

2.如果当前锁没有被线程获取(c==0),进一步判断:当前线程是否需要排队(!hasQueuedPredecessors()),如果不需要就进行CAS操作,将状态从0变为acquirs。并且设置锁的持有线程为当前线程。返回结果为true。

3.如果当前锁已经被线程获取,则再次判断获取锁的线程是不是当前线程(current == getExclusiveOwnerThread())如果是的话,更新状态值nextc为c+acquires(setStates(sextc)),返回ture。

4.如果当前锁已经被获取,且获取线程不是当前线程,则返回false

这里的整体逻辑其实和我们自己构造的公平锁逻辑很相似了。主要的区别在于:我们为什么在这里把获取锁的状态值设置为了acquire,而不是简单的1?

其实道理很简单,设置为acquire之后,我们就可以根据状态值判断锁的重入次数。这样在释放的时候才不会释放错误


非公平锁:

非公平锁的代码逻辑几乎和公平锁一样,只不过在判断当前锁没有线程获取的时候,我们不再调用hasQueuedPredecessors()方法来检测当前线程是否需要排队。也就是自由竞争。

让我们回到加锁的最外层:

如何把一个线程加入到等待队列中也是热门面试题,因此我们在这里也顺带讲一下:

在ReentrantLock的加锁外层中,为了把一个线程放到等待队列中,我们使用了addWaiter方法:

addWaiter是AQS提供的代码:

整体的代码逻辑也很简单:

先接收传递过来的节点,再用一个for构成的死循环抱住核心逻辑代码。这样是确保进入该方法的线程都可以被添加到队列当中。因为for死循环了。

让我们解读一下源代码:

1.先获取到同步队列的尾节点,之后对尾节点进行非空判断,如果为空,就先初始化队列。

2.如果尾节点不为空,先设置待加入节点的前驱节点为尾节点。

3.使用cas操作,使得tail更新为node。(tail为始终指向尾节点的一个变量

4.将实际尾节点的next指针指向node。

5.返回node。

这样我们就完成了一次添加尾节点。其实就是一个很简单的尾插法。而我们要讲一下:为什么在更新Tail变量的时候,要使用cas操作?

这是因为在多线程竞争的条件下,我们要保证始终只有一个节点获取到tail,成为真正的尾节点,如果不使用cas算法来对tail进行维护的话,那么多个线程同时获取到tail之后,就会引发线程竞争

目前我们已经知道了addwaiter的作用就是把线程加入到阻塞队列中,那么下一步很明显就应该是阻塞在队列中的线程了,但是我们想一想逻辑:阻塞队列是先进先出的,那么在阻塞和唤醒的时候,就应该是:当前线程自己阻塞后,由前一个线程唤醒。由图可看:

而我们的新加节点也不是直接阻塞的,他需要告知前一个节点,而node节点中就为我们维护了这样一个变量:

某一个node对象的waitStatus的值为0的时候,意味着他在释放锁之后不需要unpark其他对象,反之如果它的值为-1的话,就需要在释放锁之后unpark其他对象

那么很显然,这段代码逻辑应该就是在acquireQueued这个方法中执行的;

让我们来看一看这个方法:

这段方法的逻辑为:先获取到这个节点的前一个节点:如果前一个节点为头节点的话,说明当前线程是一个进行排队的,那么就调用tryAcquire方法进行加锁。

如果加锁成功,就调用setHead方法,把线程变为头节点。

这种方式使得加锁成功后,线程不会再留到这个队列当中。

如果不是头节点,就调用shouldParkAfterFailedAcquire()方法:

这个其实就是我们之前讲到的设置waitStatus的逻辑。

我们分别来看这几个代码的逻辑:

1.如果前驱节点的waitStatus等于-1。就返回ture。

2.如果前驱节点的waitStatus>0.则说明此前驱节点不可用,我们就一直往前找,直到找到前驱节点小于等于0的节点。

3.如果前驱节点的waitStatus=0,就使用CAS操作,将其值更新为-1。

我们来看看外围方法:

也就是说:当shouldParkAfterFailedAcquire方法返回ture(前驱节点的waitstatus=-1)的时候,就调用parkandCheckInterrupt方法

解锁:

这是最外层的方法:基本逻辑为:

先调用tryRelease(arg)来尝试释放锁,如果锁释放成功,则肇东当前头节点,如果头节点不为空且waitstatus的值不为0,则证明有节点需要头节点unpark,那么就调用unparkSuccessor

否则tryRelease(arg)返回false,标识锁释放失败。

因此我们来看一看tryRelease方法:

整体的逻辑为:先用getstate-release(锁支持重入)。之后判断当前线程是否是持有锁的线程。如果不是就抛出异常。之后创建锁的标志量free,false标识释放失败,true标识释放成功。那么当c==0的时候,意味着锁释放完全了,就更新标识量free为true。并且同步设置锁的拥有者为null。再最外层更新锁的状态量state

这就是解锁的全部过程。

总结:

ReentrantLock 是 Java 并发包中的一个重要类,它提供了一种可重入的互斥锁实现。在多线程环境下,ReentrantLock 提供了更灵活和强大的同步控制机制,相比于 synchronized 关键字,它具有更多的特性和扩展能力。

首先,ReentrantLock 采用了独占模式,即同一时刻只允许一个线程持有锁。这保证了被锁保护的临界区只能被一个线程访问,从而避免了多个线程同时修改共享资源导致的数据竞争和不一致性。

其次,ReentrantLock 支持可重入,也就是说同一个线程可以多次获取同一个锁。这种特性使得线程可以在持有锁的情况下重复进入同一个临界区,并且在退出时释放锁。这样的设计简化了编程模型,并且避免了死锁的可能。

此外,ReentrantLock 还提供了公平性选择的机制。通过构造函数传入的参数可以选择公平模式或非公平模式,默认是非公平模式。在公平模式下,锁会按照请求的顺序分配给等待线程,避免了线程饥饿的情况。在非公平模式下,锁的获取是无序的,可能会导致某些线程一直获取不到锁。

如果我的内容对你有帮助,请点赞,评论,收藏。创作不易,大家的支持就是我坚持下去的动力!