banner
bladedragon

bladedragon

《并发编程的艺术》-阅读笔记05:java中的锁

《并发编程的艺术》阅读笔记第五章,图文绝配

你锁我,我锁你,两者互不相让,然后就进入了死局,这像极了爱情。

image

一、Lock 接口#

提供了 synchronized 不具有的特性:

  1. 尝试非阻塞地获取锁:tryLock(),调用方法后立刻返回
  2. 能被中断地获取锁:lockInterruptibly(): 在锁的获取中可以中断当前线程
  3. 超时获取锁:tryLock(time,unit),超时返回

Lock 接口的实现基本都是通过 == 聚合了一个同步器的子类来完成线程访问控制的。==

使用注意事项

  • unlock方法要在finally中使用,目的保证在获取到锁之后,最终能被释放
  • lock方法不能放在try块中,因为如果try catch抛出异常,会导致锁无故释放

image

二、队列同步器#

队列同步器AbstractQueuedSynchronizer是用来构建锁或其他同步组件的基础框架。
它使用一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。

同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态。同步器既可以支持独占式地获取同步状态,也可以支持共享式地获取同步状态,这样就可以方便实现不同类型的同步组件(ReentrantLock
ReentrantReadWriteLockCountDownLatch等)

==同步器== 是实现锁的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义。
理解两者的关系:

  • 锁是面向使用者的,它定义了使用者与锁交互的接口,隐藏了实现细节;
  • 同步器是面向锁的实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待和唤醒等底层操作。

队列同步器的接口与示例#

同步器的设计是基于模板方法模式的,也就是说,使用者需要继承同步器并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用同步器提供的模板方法,而这些模板方法将会调用使用者重写的方法。

重写同步器指定的方法时,需要使用同步器提供的如下 3 个方法来访问或修改同步状态

  • getState():获取当前同步状态。
  • setState(int newState):设置当前同步状态。
  • compareAndSetState(int expect,int update):使用 CAS 设置当前状态,该方法能够保证状态设置的原子性。

image

image

同步器提供的模板方法基本上分为 3 类:独占式获取与释放同步状态共享式获取与释放同步状态查询同步队列中的等待线程情况。自定义同步组件将使用同步器提供的模板方法来实现自己的同步语义。

工作原理#

MUtux源代码略

队列同步器的实现分析#

1. 同步队列#

通过一个FIFO双向队列来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成一个Node并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。

首节点是获取同步状态成功的节点,首节点在释放同步状态时,会唤醒后继节点,而后继节点在获取同步状态成功时将自己设置为首节点。

节点的属性类型与名称以及描述

image

image

2. 独占式同步状态获取和释放#

 public final void acquire(int arg) {
        if (!tryAcquire(arg) 					&&acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            //如果上述操作失败,则阻塞线程
            selfInterrupt();
 }
//上述代码主要完成了同步状态获取、节点构造、加入同步队列以及在同步队列中自旋等待的相关工作,

代码分析:
首先尝试获取同步状态,如果获取失败,构造独占式同步节点 (独占式 Node.EXCLUSIVE) 并将其加入到节点的尾部,然后调用acquireQueued,使节点一死循环的方式去获取同步状态,如果获取不到就阻塞节点中的线程。

两个死循环:入队、入队后

  1. addWaiterenq方法・

    在 “死循环” 中只有通过CAS将节点设置成为尾节点之后,当前线程才能从该方法返回,否则,当前线程不断地尝试设置。可以看出,enq(final Node node)方法将并发添加节点的请求通过 CAS 变得 “串行化” 了

    addWaiter方法尝试快速添加,但是存在出现并发导致节点无法正常添加成功(获取尾节点==null),因此enq方法无限循环添加节点,将节点加入到尾部
    
  2. acquireQueued方法

    ・== 只有前驱节点是头结点才能尝试获取同步状态 ==,原因:

  3. 头结点是成功获取到同步状态的节点,而头结点的线程释放了同步状态后,将会唤醒其后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否为头节点。

  4. 维护同步队列的 FIFO 原则。节点之间互不通信,便于对过早通知的处理(过早通知是指前驱节点不是头节点的线程
    由于中断而被唤醒)

释放同步状态使用release方法

public final boolean release(int arg) {
	if (tryRelease(arg)) {
		Node h = head;
		if (h != null && h.waitStatus != 0
            //unparkSuccessor(Node node)方法使用LockSupport(在后面的章节会专门介绍)来唤醒处于等待状态的线程
			unparkSuccessor(h);
		return true;
	}
	return false;
}
总结:在获取同步状态时,同步器维护一个同步队列,获取状态失败的线程都会被加入到队列中并在队列中进行自旋,移出队列(停止自旋)的条件是前驱节点是头结点且成功获取了同步状态。在释放同步状态时,同步器调用tryRelease方法释放同步状态,然后唤醒头结点的后继节点

image

3. 共享式同步状态获取和释放#

主要区别:同一时刻是否有多个线程同时获取到同步状态

共享式访问资源时,其他共享式的访问均被允许,而独占式访问被阻塞。独占式访问资源时,同一时刻其他访问均被阻塞。
  • tryAcquireShared(int arg)方法返回值为int类型,当返回值大于等于0时,表示能够获取到同步状态
  • releaseShared・方法和独占式主要区别在于tryReleaseShared(int arg)方法必须确保同步状态(或者资源数)线程安全释放,一般是通过循环和CAS来保证的,因为释放同步状态的操作会同时来自多个线程。
源码细节还是有很多没有看懂
比如interrupt的放置时机,如何保证共享的时候线程安全性、共享获取同步状态中的传播和信号都是什么意思。。。

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纳秒,反之,表示已经超时・

独占式超时获取同步状态和独占式获取同步状态流程上非常相似

主要区别在于未获取到同步状态时的处理逻辑。acquire(int args)在未获取到同步状态时,将会使当前线程一直处于等待状态,而doAcquireNanos(int arg,long nanosTimeout)会使当前线程等待nanosTimeout纳秒,如果当前线程在nanosTimeout纳秒内没有获取到同步状态,将会从等待逻辑中自动返回

当时间小于等于一个超时自旋门限时则不再进行超时等待,而是进入快速的自旋过程

image

三、重入锁(ReentrantLock)#

synchronized 关键字隐式地支持重入

ReentrantLock 不像 synchronized 隐式支持,在调用 lock 方法时,已经获取到锁的线程,能够再次调用 lock 方法获取锁而不被阻塞。

公平的获取锁,也就是等待时间最长的线程最优先获取锁,也可以说锁获取是顺序的

事实上,公平的锁机制往往没有非公平的效率高,但是公平锁的好处在于:公平锁能够减少 “饥饿” 发生的概率,等待越久的请求越是能够得到优先满足。

1. 重入的实现#

两个问题:

  1. 再次获取锁

    锁需要识别获取锁的线程是否为当前占据锁的线程,如果时,再次成功获取

  2. 最终释放

    要求锁对于获取进行自增计数

    问题:意义何在?
    防止出现循环获取锁影响性能或者造成死锁
    
    可重入获取锁的机制,在获取的时候如果不是第一次获取,状态加一,实际上没有进行CAS操作,因此在释放锁的时候要求state为0,才能彻底释放锁
    

2. 公平锁与非公平锁的区别:#

如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是 FIFO

公平锁:CAS成功,且是队列的首节点(判断多了一层对前去前驱节点的判断)
非公平锁:CAS成功即可

重入锁的默认实现是非公平锁,原因:虽然会导致饥饿,但是非公平锁的的开销少(线程切换次数少),从而可以有更高的吞吐量。

四、读写锁(ReentrantReadWriteLock)#

前文中的锁基本都是排他锁,在同一时刻只允许一个线程访问。

读写所在同一时刻可以允许多个读线程访问,但在写线程访问时,所有读线程和其他写线程均被阻塞。(保证了写操作的可见性)

读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。

读写锁的实现分析#

1. 读写状态的设计#

依赖自定义同步器,读写锁的自定义同步器需要在同步状态(一个 int 值)上维护多个读线程和一个写线程的状态,高 16 位表示读,低 16 位表示写。

image

位运算
当前同步状态表示一个线程已经获取了写锁,且重进入了两次,同时也连续获取了两次
读锁。读写锁是如何迅速确定读和写各自的状态呢?答案是通过位运算。假设当前同步状态
值为S,写状态等于S&0x0000FFFF(将高16位全部抹去),读状态等于S>>>16(无符号补0右移16位)。当写状态增加1时,等于S+1,当读状态增加1时,等于S+(1<<16),也就是
S+0x00010000。

2. 写锁的获取与释放#

image

写锁是一个支持重入的排他锁,如果当前线程已经获取了写锁,则增加写状态。
如果当前线程在获取写锁时,读锁已经被获取或者该线程不是已经获取写锁的线程,则当前线程进入等待状态。

读锁存在,写锁不能获取:

读写锁要确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。

3. 读锁的获取与释放#

在没有其他写线程访问时,读锁总会被成功地获取。如果写锁已经被其他线程获取,则进入等待状态。

读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是(1<<16)

读状态的线程安全由 CAS 保证

4. 锁降级(写锁降级成为读锁)#

定义:把持住写锁,再获取到读锁,随后释放写锁的过程

writeLock.lock();
readLock.lock();
writeLock.unlock();

这边不是很理解。。。。

锁降级的前提是所有线程都希望对数据变化敏感,但是因为写锁只有一个,所以会发生降级。如果先释放写锁,再获取读锁,可能在获取之前,会有其他线程获取到写锁,阻塞读锁的获取,就无法感知数据变化了。所以需要先 hold 住写锁,保证数据无变化,获取读锁,然后再释放写锁。锁降级中读锁获取的必要性:

为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程获取了写锁并修改了数据,那么当前线程无法感知到数据的更新.如果当前线程获取读锁,则另一个线程会被阻塞,直到当前线程使用数据并释放锁之后,另一个线程才能获取写锁进行数据更新。

五、LockSupport 工具#

LockSupport 定义了一组的公共静态方法,这些方法提供了最基本的线程阻塞和唤醒功能,而 LockSupport 也成为构建同步组件的基础工具

image

在 Java 6 中,LockSupport 增加了 park (Object blocker)、parkNanos (Object blocker,long nanos) 和 parkUntil (Object blocker,long deadline) 3 个方法,用于实现阻塞当前线程的功能,其中参数 blocker 是用来标识当前线程在等待的对象(以下称为阻塞对象),该对象主要用于问题排查和系统监控。

六、Condition 接口#

Condition 接口也提供了类似 Object 的监视器方法,与 Lock 配合可以实现等待 / 通知模式,但是这两者在使用方式以及功能特性上还是有差别的

1.Condition 接口和示例#

image

Condition 在调用方法之前先获取锁

image

在添加和删除方法中使用while循环而非if判断,目的是防止过早或意外的通知,只有条件
符合才能够退出循环。回想之前提到的等待/通知的经典范式,二者是非常类似的。

2.Condition 的实现分析#

ConditionObject是同步器AbstractQueuedSynchronizer的内部类,因为Condition的操作需要获取相关联的锁,所以作为同步器的内部类也较为合理。每个Condition对象都包含着一个队列(以下称为等待队列),该队列是Condition对象实现等待 / 通知功能的关键。

等待队列

等待队列是一个 FIFO 的队列,在队列中的每个节点都包含了一个线程引用,该线程就是
在 Condition 对象上等待的线程,如果一个线程调用了 Condition.await () 方法,那么该线程将会释放锁、构造成节点加入等待队列并进入等待状态(和同步队列类似)

image

在 Object 的监视器模型上,一个对象拥有一个同步队列和等待队列,而并发包中的
Lock(更确切地说是同步器)拥有一个同步队列和多个等待队列

上述节点引用更新的过程并没有使用 CAS 保证,原因在于调用 await () 方法的线程必定是获取了锁的线程,也就是说该过程是由锁来保证线程安全

等待

调用 Condition 的 signal () 方法,将会唤醒在等待队列中等待时间最长的节点(首节点),在
唤醒节点之前,会将节点移到同步队列中。

源码略
1. 使用locksupport中的park()方法进入等待状态,判断是否唤醒节点的标志是查看节点是否在同步队列上,因为通知condition在唤醒节点之前后将节点转移到同步队列上
唤醒后注意还有判断唤醒方式是通知还是中断
2. 从队列角度,线程加入Condition的等待队列实质是构造了新的节点加入等待队列

通知

调用 Condition 的 signal () 方法,将会唤醒在等待队列中等待时间最长的节点(首节点),在
唤醒节点之前,会将节点移到同步队列中。

image

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。