《并发编程的艺术》阅读笔记第五章,图文绝配
你锁我,我锁你,两者互不相让,然后就进入了死局,这像极了爱情。
一、Lock接口
提供了synchronized不具有的特性:
- 尝试非阻塞地获取锁:
tryLock()
,调用方法后立刻返回 - 能被中断地获取锁:
lockInterruptibly()
:在锁的获取中可以中断当前线程 - 超时获取锁:
tryLock(time,unit)
,超时返回
Lock接口的实现基本都是通过==聚合了一个同步器的子类来完成线程访问控制的。==
使用注意事项
unlock
方法要在finally
中使用,目的保证在获取到锁之后,最终能被释放lock
方法不能放在try
块中,因为如果try catch
抛出异常,会导致锁无故释放
二、队列同步器
队列同步器
AbstractQueuedSynchronizer
是用来构建锁或其他同步组件的基础框架。
它使用一个int
成员变量表示同步状态,通过内置的FIFO
队列来完成资源获取线程的排队工作。同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态。同步器既可以支持独占式地获取同步状态,也可以支持共享式地获取同步状态,这样就可以方便实现不同类型的同步组件(
ReentrantLock
、ReentrantReadWriteLock
和CountDownLatch
等)
==同步器==是实现锁的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义。
理解两者的关系:
- 锁是面向使用者的,它定义了使用者与锁交互的接口,隐藏了实现细节;
- 同步器是面向锁的实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待和唤醒等底层操作。
队列同步器的接口与示例
同步器的设计是基于模板方法模式的,也就是说,使用者需要继承同步器并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用同步器提供的模板方法,而这些模板方法将会调用使用者重写的方法。
重写同步器指定的方法时,需要使用同步器提供的如下3个方法来访问或修改同步状态
getState
():获取当前同步状态。setState(int newState)
:设置当前同步状态。compareAndSetState(int expect,int update)
:使用CAS设置当前状态,该方法能够保证状态设置的原子性。
同步器提供的模板方法基本上分为3类:==独占式获取与释放同步状态==、==共享式获取与释放同步状态==和==查询同步队列中的等待线程情况==。自定义同步组件将使用同步器提供的模板方法来实现自己的同步语义。
工作原理
1 | MUtux源代码略 |
队列同步器的实现分析
1.同步队列
通过一个FIFO双向队列
来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成一个Node
并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。
首节点是获取同步状态成功的节点,首节点在释放同步状态时,会唤醒后继节点,而后继节点在获取同步状态成功时将自己设置为首节点。
节点的属性类型与名称以及描述
2.独占式同步状态获取和释放
1 | public final void acquire(int arg) { |
代码分析:
首先尝试获取同步状态,如果获取失败,构造独占式同步节点(独占式
Node.EXCLUSIVE
)并将其加入到节点的尾部,然后调用acquireQueued
,使节点一死循环的方式去获取同步状态,如果获取不到就阻塞节点中的线程。
两个死循环:入队、入队后
addWaiter
和enq
方法·在“死循环”中只有通过
CAS
将节点设置成为尾节点之后,当前线程才能从该方法返回,否则,当前线程不断地尝试设置。可以看出,enq(final Node node)
方法将并发添加节点的请求通过CAS变得“串行化”了1
addWaiter方法尝试快速添加,但是存在出现并发导致节点无法正常添加成功(获取尾节点==null),因此enq方法无限循环添加节点,将节点加入到尾部
acquireQueued
方法·==只有前驱节点是头结点才能尝试获取同步状态==,原因:
头结点是成功获取到同步状态的节点,而头结点的线程释放了同步状态后,将会唤醒其后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否为头节点。
维护同步队列的FIFO原则。节点之间互不通信,便于对过早通知的处理(过早通知是指前驱节点不是头节点的线程
由于中断而被唤醒)
释放同步状态使用release
方法
1 | public final boolean release(int arg) { |
总结:在获取同步状态时,同步器维护一个同步队列,获取状态失败的线程都会被加入到队列中并在队列中进行自旋,移出队列(停止自旋)的条件是前驱节点是头结点且成功获取了同步状态。在释放同步状态时,同步器调用tryRelease方法释放同步状态,然后唤醒头结点的后继节点
3.共享式同步状态获取和释放
主要区别:同一时刻是否有多个线程同时获取到同步状态
共享式访问资源时,其他共享式的访问均被允许,而独占式访问被阻塞。独占式访问资源时,同一时刻其他访问均被阻塞。
tryAcquireShared(int arg)
方法返回值为int
类型,当返回值大于等于0
时,表示能够获取到同步状态releaseShared
·方法和独占式主要区别在于tryReleaseShared(int arg)
方法必须确保同步状态(或者资源数)线程安全释放,一般是通过循环和CAS
来保证的,因为释放同步状态的操作会同时来自多个线程。
1 | 源码细节还是有很多没有看懂 |
4.独占式超时获取同步状态
通过调用同步器的
doAcquireNanos(int arg,long nanosTimeout)
方法可以超时获取同步状态,即在指定的时间段内获取同步状态,如果获取到同步状态则返回true
,否则,返回false
。该方法提供了传统Java
同步操作(比如synchronized
关键字)所不具备的特性。
响应中断的同步状态获取过程
在Java 5中,同步器提供了acquireInterruptibly(int arg)
方法,这个方法在等待获取同步状态时,如果当前线程被中断,会立刻返回,并抛出InterruptedException
。
doAcquireNanos(int arg,long nanosTimeout)
方法在支持响应中断的基础上,增加了超时获取的特性。针对超时获取,主要需要计算出需要睡眠的时间间隔nanosTimeout
,为了防止过早通知,nanosTimeout
计算公式为:nanosTimeout-=now-lastTime
,其中now
为当前唤醒时间,lastTime
为上次唤醒时间,如果nanosTimeout
大于0
则表示超时时间未到,需要继续睡眠nanosTimeout
纳秒,反之,表示已经超时·
1 | 独占式超时获取同步状态和独占式获取同步状态流程上非常相似 |
三、重入锁(ReentrantLock)
synchronized关键字隐式地支持重入
ReentrantLock不像synchronized隐式支持,在调用lock方法时,已经获取到锁的线程,能够再次调用lock方法获取锁而不被阻塞。
公平的获取锁,也就是等待时间最长的线程最优先获取锁,也可以说锁获取是顺序的
事实上,公平的锁机制往往没有非公平的效率高,但是公平锁的好处在于:公平锁能够减少“饥饿”发生的概率,等待越久的请求越是能够得到优先满足。
1.重入的实现
两个问题:
再次获取锁
锁需要识别获取锁的线程是否为当前占据锁的线程,如果时,再次成功获取
最终释放
要求锁对于获取进行自增计数
1
2问题:意义何在?
防止出现循环获取锁影响性能或者造成死锁1
可重入获取锁的机制,在获取的时候如果不是第一次获取,状态加一,实际上没有进行CAS操作,因此在释放锁的时候要求state为0,才能彻底释放锁
2.公平锁与非公平锁的区别:
如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是FIFO
公平锁:CAS成功,且是队列的首节点(判断多了一层对前去前驱节点的判断)
非公平锁:CAS成功即可
重入锁的默认实现是非公平锁,原因:虽然会导致饥饿,但是非公平锁的的开销少(线程切换次数少),从而可以有更高的吞吐量。
四、读写锁(ReentrantReadWriteLock)
前文中的锁基本都是排他锁,在同一时刻只允许一个线程访问。
读写所在同一时刻可以允许多个读线程访问,但在写线程访问时,所有读线程和其他写线程均被阻塞。(保证了写操作的可见性)
读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。
读写锁的实现分析
1.读写状态的设计
依赖自定义同步器,读写锁的自定义同步器需要在同步状态(一个int值)上维护多个读线程和一个写线程的状态,高16位表示读,低16位表示写。
位运算
当前同步状态表示一个线程已经获取了写锁,且重进入了两次,同时也连续获取了两次
读锁。读写锁是如何迅速确定读和写各自的状态呢?答案是通过位运算。假设当前同步状态
值为S,写状态等于S&0x0000FFFF(将高16位全部抹去),读状态等于S>>>16(无符号补0右移16位)。当写状态增加1时,等于S+1,当读状态增加1时,等于S+(1<<16),也就是
S+0x00010000。
2.写锁的获取与释放
写锁是一个支持重入的排他锁,如果当前线程已经获取了写锁,则增加写状态。
如果当前线程在获取写锁时,读锁已经被获取或者该线程不是已经获取写锁的线程,则当前线程进入等待状态。
==读锁存在,写锁不能获取:==
1 | 读写锁要确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。 |
3.读锁的获取与释放
在没有其他写线程访问时,读锁总会被成功地获取。如果写锁已经被其他线程获取,则进入等待状态。
读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是(1<<16)
。
==读状态的线程安全由CAS保证==
4.锁降级(写锁降级成为读锁)
定义:==把持住写锁==,再获取到读锁,随后释放写锁的过程
writeLock.lock();
readLock.lock();
writeLock.unlock();
这边不是很理解。。。。
锁降级的前提是所有线程都希望对数据变化敏感,但是因为写锁只有一个,所以会发生降级。如果先释放写锁,再获取读锁,可能在获取之前,会有其他线程获取到写锁,阻塞读锁的获取,就无法感知数据变化了。所以需要先hold住写锁,保证数据无变化,获取读锁,然后再释放写锁。锁降级中读锁获取的必要性:
为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程获取了写锁并修改了数据,那么当前线程无法感知到数据的更新.如果当前线程获取读锁,则另一个线程会被阻塞,直到当前线程使用数据并释放锁之后,另一个线程才能获取写锁进行数据更新。
五、LockSupport工具
LockSupport定义了一组的公共静态方法,这些方法提供了最基本的线程阻塞和唤醒功能,而LockSupport也成为构建同步组件的基础工具
在Java 6中,LockSupport增加了park(Object blocker)、parkNanos(Object blocker,long nanos)和parkUntil(Object blocker,long deadline)3个方法,用于实现阻塞当前线程的功能,其中参数blocker是用来标识当前线程在等待的对象(以下称为阻塞对象),该对象主要用于问题排查和系统监控。
六、Condition接口
Condition接口也提供了类似Object的监视器方法,与Lock配合可以实现等待/通知模式,但是这两者在使用方式以及功能特性上还是有差别的
1.Condition接口和示例
Condition在调用方法之前先获取锁
1 | 在添加和删除方法中使用while循环而非if判断,目的是防止过早或意外的通知,只有条件 |
2.Condition的实现分析
ConditionObject
是同步器AbstractQueuedSynchronizer
的内部类,因为Condition
的操作需要获取相关联的锁,所以作为同步器的内部类也较为合理。每个Condition
对象都包含着一个队列(以下称为等待队列),该队列是Condition
对象实现等待/通知功能的关键。
等待队列
等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用,该线程就是
在Condition对象上等待的线程,如果一个线程调用了Condition.await()方法,那么该线程将会释放锁、构造成节点加入等待队列并进入等待状态(和同步队列类似)
在Object的监视器模型上,一个对象拥有一个同步队列和等待队列,而并发包中的
Lock(更确切地说是同步器)拥有一个同步队列和多个等待队列上述节点引用更新的过程并没有使用CAS保证,原因在于调用await()方法的线程必定是获取了锁的线程,也就是说该过程是由锁来保证线程安全
等待
调用Condition的signal()方法,将会唤醒在等待队列中等待时间最长的节点(首节点),在
唤醒节点之前,会将节点移到同步队列中。
1 | 源码略 |
通知
调用Condition的signal()方法,将会唤醒在等待队列中等待时间最长的节点(首节点),在
唤醒节点之前,会将节点移到同步队列中。