当前位置: 首页 > 科技观察

说说可重入锁这个特别重要的话题

时间:2023-03-22 13:08:04 科技观察

本文转载自微信公众号《三太子敖丙》,作者三太子敖丙。转载本文请联系三太子敖丙公众号。在Java中进行多线程开发,使用锁几乎是一个无法回避的问题。今天,就让我们来谈谈这个基础却非常重要的话题。首先我们来看看什么是锁?而且,为什么要使用锁?如果有2个线程,他们需要访问同一个对象User。一个读线程和一个写线程。User对象有2个字段,一个是姓名,一个是手机号。刚刚创建User对象时,姓名和电话号码都是空的。然后,写入线程开始填充数据。最后就是下面这个心酸的一幕:可见虽然写线程先于读线程工作,但是写名字和写电话号码这两个操作并不是原子的。这导致读取线程只读取了一半的数据。从读取线程的角度来看,User对象的电话号码不存在。为了避免类似的问题,我们需要使用锁。让写入线程在修改对象之前先锁定对象,然后完成姓名和电话号码的赋值,然后释放锁。读线程也是如此,先获取锁,再读,再释放锁。这样你就可以避免这种情况发生。如下图所示:什么是可重入锁?好了,现在大家知道我们为什么要用锁了吧。那么什么是可重入锁呢?一般来说,锁可以用来控制多个线程的访问行为。那么对于同一个线程,如果同一个锁连续两次被加锁会怎样呢?对于一般的锁,线程会一直卡在那里,例如:voidhandle(){lock();lock();//和前面的lock()操作同一个锁对象,所以这里会一直等待unlock();unlock();}这个特性是比较难用的,因为在实际开发过程中,函数之间的调用关系可能错综复杂,一不小心,可能会在多个不同的函数中重复调用lock().在这种情况下,线程本身和自身就会被卡住。所以,对于我们这些想傻傻编程的人来说,可重入锁就是用来解决这个问题的。可重入锁允许同一个线程重复锁定同一个锁而不释放它,而不会导致线程卡住。所以,如果我们使用可重入锁,上面的代码就可以正常工作。你唯一需要保证的是unlock()的次数和lock()的次数一样多。这不是方便多了?Java中的可重入锁Java中的锁都是来自于Lock接口,如下图红框所示,就是可重入锁。可重入锁提供的最重要的方法是lock()voidlock():加锁,如果锁已经被别人占用,则无限等待。这个lock()方法提供了锁最基本的功能,获取到锁就返回,没有获取到锁就等待。所以在复杂的场景中大范围使用是有可能出现死锁的。因此,请非常小心地使用此方法。如果想防止可能出现的死锁,可以试试下面的方法:booleantryLock(longtimeout,TimeUnitunit)throwsInterruptedException:尝试获取锁,等待超时时间。同时可以响应中断。这是一种比简单的lock()更具工程价值的方法。如果你看过JDK的一些内部代码,不难发现tryLock()在JDK内部被广泛使用。与lock()相比,tryLock()至少有以下优点之一:不需要无限等待。直接打破形成僵局的条件。如果一段时间内等不到锁,可以直接放弃,释放已经获得的资源。这样就可以在很大程度上避免死锁。因为在应用层可以自旋的线程之间有谦逊机制,你可以决定多试几次,或者放弃。在等待锁的过程中,可以响应中断。如果此时程序刚好接收到关机信号,就会触发中断。进入中断异常后,线程可以做一些清理工作,防止数据被破坏,数据丢失等悲惨情况。当然,当锁用完后,别忘了释放。否则程序可能会崩溃~voidunlock():释放锁另外,可重入锁还有一个不带任何参数的tryLock()。publicbooleantryLock(),这个没有任何参数的tryLock(),不会执行任何等待。如果能拿到锁,则直接返回true。如果获取失败,则返回false。特别适合在应用层管理锁。层旋转等待。可重入锁的实现原理可重入锁内部实现的主要类如下:可重入锁的核心功能委托给内部类Sync实现,根据是否公平有FairSync和NonfairSync两种实现锁。这是一个典型的策略模式。实现可重入锁的方法很简单,就是基于一个状态变量state。该变量保存在AbstractQueuedSynchronizer对象中的privatevolatileintstate中;当这个state==0时,表示锁是空闲的,如果大于0,表示锁已经被占用,它的值表示当前线程重复占用这个锁的次数。所以lock()最简单的实现是:finalvoidlock(){//compareAndSetState就是对state进行CAS操作,如果修改成功就会占用锁if(compareAndSetState(0,1))setExclusiveOwnerThread(Thread.currentThread());else//如果修改不成功,说明其他线程已经使用了锁,那么可能需要等待acquire(1);下面是acquire()的实现:publicfinalvoidacquire(intarg){//tryAcquire()再次尝试获取锁,//如果发现锁被当前线程占用,更新状态为表示重复占用的次数,//同时宣告获取成功,这是重入的关键if(!tryAcquire(arg)&&//如果获取失败,则在这里加入队列等待acquireQueued(addWaiter(Node.EXCLUSIVE),arg))//如果在等待过程中被打断,则再次设置中断标志为selfInterrupt();公平重入锁默认情况下,重入锁是不公平的。什么不公平?也就是说,如果有1、2、3、4四个线程,它们依次申请锁。当锁可用时,谁先拿到锁?在不公平的情况下,答案是随机的。如下图,线程3可能先拿到锁。如果你是公平主义者,强烈坚持先到先得,那么你需要在构造可重入锁的时候指定这是一个公平锁:ReentrantLockfairLock=newReentrantLock(true);这样,每个请求锁线程都会乖乖的把自己放到请求队列中,而不是上来就争抢。但请务必注意,公平锁是有代价的。维持公平的竞争环境是以牺牲系统性能为代价的。如果你愿意承担这个损失,公平锁至少提供了一种普世价值的实现!公平锁和非公平锁的核心区别是什么?看一下lock()的这段代码://非公平锁finalvoidlock(){//上来也无所谓,直接抢if(compareAndSetState(0,1))setExclusiveOwnerThread(Thread.currentThread());else//抢不到就慢慢入队等待acquire(1);}//公平锁finalvoidlock(){//直接入队等待acquire(1);}从上面的代码不难看出,如果第一次尝试非公平锁失败,后面的处理和公平锁一样,进入等待队列,慢慢等待。和tryLock()很像://非公平锁finalbooleannonfairTryAcquire(intacquires){finalThreadcurrent=Thread.currentThread();intc=getState();if(c==0){//不管上来,直接抢过来sayif(compareAndSetState(0,acquires)){setExclusiveOwnerThread(current);returntrue;}}//如果当前线程占用了锁,则更新状态表示锁被重复占用的次数//这是“重入”的关键是elseif(current==getExclusiveOwnerThread()){//我又来了~~~intnextc=c+acquires;if(nextc<0)//overflowthrownewError("Maximumlockcountexceeded");setState(nextc);returntrue;}returnfalse;}//公平锁保护finalbooleantryAcquire(intacquires){finalThreadcurrent=Thread.currentThread();intc=getState();if(c==0){//首先检查是否有其他人在等待,没人会等我去抢,有人在我前面,我就不去抢if(!hasQueuedPredecessors()&&compareAndSetState(0,acquires)){setExclusiveOwnerThread(current);returntrue;}}elseif(current==getExclusiveOwnerThread()){intnextc=c+acquires;if(nextc<0)thrownewError("Maximumlockcountexceeded");setState(nextc);returntrue;}returnfalse;}ConditionCondition可以理解为可重入锁的伴随对象。它提供了一种基于可重入锁的等待和通知机制。可以使用newCondition()方法生成条件对象,如下所示。privatefinalLocklock=newReentrantLock();privatefinalConditioncondition=lock.newCondition();如何使用Condition对象。JDK中有一个很好的例子。我们来看看ArrayBlockingQueue。ArrayBlockingQueue是一个队列。可以将元素放入队列(enqueue),也可以取出。但是有个小条件,就是如果队列是空的,那么take()需要等到有元素了才返回。那么如何实现这个功能呢?这可以使用Condition对象。在ArrayBlockingQueue中实现,它维护了一个Condition对象lock=newReentrantLock(fair);notEmpty=lock.newCondition();这个notEmpty是一个Condition对象。用于通知其他线程ArrayBlockingQueue是否为空。当我们需要取出一个元素时:publicEtake()throwsInterruptedException{finalReentrantLocklock=this.lock;lock.lockInterruptibly();try{while(count==0)//如果队列长度为0,则等待notEmptycondition,waituntilanelementcomein.//注意await()方法必须先获取条件关联的锁才能使用。notEmpty.await();//一旦有人通知我队列中有元素,我就在队列中有元素时弹出一个returnreturndequeue();}finally{lock.unlock();}}:publicbooleanooffer(Ee){checkNotNull(e);finalReentrantLocklock=this.lock;//先获取锁,take只有达到锁才能对应的Condition对象lock.lock();try{if(count==items.length)returnfalse;else{//入队,在这个函数中,会做一个notEmpty通知,通知相关线程,有数据就绪enqueue(e);returntrue;}}finally{//锁被释放,等待线程现在可以弹出一个元素并尝试lock.unlock();}}privatevoidenqueue(Ex){finalObject[]items=this.items;items[putIndex]=x;if(++putIndex==items.length)putIndex=0;count++;//元素已放置,通知等待拿东西的人NotEmpty.signal();}所以整个过程如图图:使用可重入锁的例子,让大家更好的理解如何使用可重入锁。现在我们使用可重入锁来实现一个简单的计数器。该计数器在多线程环境下可以保证统计数据的准确性,请看下面的示例代码:lock.lock();try{count++;}finally{lock.unlock();}}publicintgetCount(){//读取数据也需要加锁,保证数据可见性lock.lock();try{returncount;}finally{lock.unlock();}}}总结可重入锁是多线程的入门级知识点,所以我把它作为多线程系列的第一篇。对于可重入锁,我们需要特别了解几点:对于同一个线程,可重入锁允许你重复获取一个锁,但是你申请和释放锁的次数必须是一样的。默认情况下,可重入锁是不公平的,公平可重入锁的性能比不公平锁差。可重入锁的内部实现是基于CAS操作。可重入锁的伴生对象Condition提供了await()和singal()函数,可用于线程间消息通信。