目录
前言:
前置知识:
什么是公平锁与非公平锁?
尝试自己构造一把锁:
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;
volatile int 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源码。
最外层:
这段代码的基本逻辑为:如果当前线程不能获取锁,则会进入等待队列中等待,并在等待过程中不断尝试获取锁。如果当前线程被中断,则会重新设置中断标志。
我们在这里主要聊的是加锁,因此我们就先看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 还提供了公平性选择的机制。通过构造函数传入的参数可以选择公平模式或非公平模式,默认是非公平模式。在公平模式下,锁会按照请求的顺序分配给等待线程,避免了线程饥饿的情况。在非公平模式下,锁的获取是无序的,可能会导致某些线程一直获取不到锁。
如果我的内容对你有帮助,请点赞,评论,收藏。创作不易,大家的支持就是我坚持下去的动力!