并发编程艺术笔记:Java中的锁(重入锁、读写锁、LockSupport、Condition接口)

目录

3、重入锁

实现分析:

构造函数

实现重进入

公平与非公平获取锁的区别

非公平锁为什么造成线程“饥饿”

为什么非公平锁性能好

4、读写锁

实现分析:

1.读写状态的设计

2.构造函数

3.写锁的获取与释放

4.读锁的获取与释放

5.锁降级

5、LockSupport

6、Condition接口

实现分析:

1.等待队列

2.等待

3.通知


3、重入锁

可重入互斥锁 ReentrantLock 具有与使用 synchronized 方法和语句访问的隐式监视器锁相同的基本行为和语义,但具有扩展功能。

ReentrantLock 在调用lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞。

这里提到一个锁获取的公平性问题,如果在绝对时间上,先对锁进行获取的请求一定先被满足,那么这个锁是公平的,反之,是不公平的。公平的获取锁,也就是等待时间最长的线程最优先获取锁,也可以说锁获取是顺序的。ReentrantLock提供了一个构造函数,能够控制锁是否是公平的。

使用由许多线程访问的公平锁的程序可以显示比使用默认设置的程序更低的总吞吐量(即更慢,通常慢得多),但是获得锁的时间变化较小并且保证缺乏饥饿。 但请注意,锁的公平性并不能保证线程调度的公平性。 因此,使用公平锁定的许多线程中的一个可以连续多次获得它,而其他活动线程没有进展并且当前没有保持锁定。

公平的锁机制往往没有非公平的效率高,但是,并不是任何场景都是以TPS(吞吐量)作为唯一的指标,公平锁能够减少“饥饿”发生的概率,等待越久的请求越是能够得到优先满足。

实现分析:

构造函数

实现重进入

ReentrantLock是通过组合自定义同步器来实现锁的获取与释放,以非公平性(默认的)实现为例,获取同步状态的代码如下:

成功获取锁的线程再次获取锁,只是增加了同步状态值,这也就要求ReentrantLock在释放同步状态时减少同步状态值:

公平与非公平获取锁的区别

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

对于非公平锁,只要CAS设置同步状态成功,则表示当前线程获取了锁,而公平锁则不同:

公平性锁与非公平性锁相比,公平性锁保证了锁的获取按照FIFO原则,而代价是进行大量的线程切换。

非公平性锁虽然可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量。

非公平锁为什么造成线程“饥饿”

在非公平锁的加锁流程中,线程在进入同步队列等待之前有两次抢占锁的机会:

  • 第一次是非重入式的获取锁,只有在当前锁未被任何线程占有(包括自身)时才能成功
  • 第二次是在进入同步队列前,包含所有情况的获取锁的方式

只有这两次获取锁都失败后,线程才会构造结点并加入同步队列等待。而线程释放锁时是先释放锁,然后才唤醒后继结点线程。如果线程A已经释放锁,但还没来得及唤醒后继线程C,而这时另一个线程B刚好尝试获取锁,此时锁恰好不被任何线程持有,它将成功获取锁而不用加入队列等待。线程C被唤醒尝试获取锁,而此时锁已经被线程B抢占,故而其获取失败并继续在队列中等待。整个过程如下图所示:

在锁竞争激烈的情况下,在队列中等待的线程可能迟迟竞争不到锁。这也就是非公平锁在高并发情况下会出现的饥饿问题。

为什么非公平锁性能好

线程不必加入等待队列就可以获得锁,不仅免去了构造结点并加入队列的繁琐操作,同时也节省了线程阻塞唤醒的开销,线程阻塞和唤醒涉及到线程上下文的切换和操作系统的系统调用,是非常耗时的。在高并发情况下,如果线程持有锁的时间非常短,短到线程入队阻塞的过程超过线程持有并释放锁的时间开销,那么这种抢占式特性对并发性能的提升会更加明显。

减少CAS竞争。如果线程必须要加入阻塞队列才能获取锁,那入队时CAS竞争将变得异常激烈,CAS操作失败虽然不会导致线程挂起,但不断失败重试导致的对CPU的浪费也不能忽视。

 参考:从源码角度彻底理解ReentrantLock(重入锁)

4、读写锁

如 Mutex 和 ReentrantLock 基本都是排他锁,这些锁在同一时刻只允许一个线程进行访问,而读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比排他锁有了很大提升。因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量。

除了保证写操作对读操作的可见性以及并发性的提升之外,读写锁能够简化读写交互场景的编程方式。假设在程序中定义一个共享的用作缓存数据结构,它大部分时间提供读服务(例如查询和搜索),而写操作占有的时间很少,但是写操作完成之后的更新需要对后续的读服务可见。

在没有读写锁支持时(Java 5之前),如果需要完成上述工作就要使用Java的等待通知机制,就是当写操作开始时,所有晚于写操作的读操作均会进入等待状态,只有写操作完成并进行通知之后,所有等待的读操作才能继续执行(写操作之间依靠synchronized关键进行同步),这样做的目的是使读操作能读取到正确的数据,不会出现脏读。改用读写锁实现上述功能,只需要在读操作时获取读锁,写操作时获取写锁即可。当写锁被获取到时,后续读写操作都会被阻塞,写锁释放之后,所有操作继续执行,编程方式相对于使用等待通知机制的实现方式而言,变得简单明了。

ReentrantReadWriteLock的特性如下:

实现分析:

1.读写状态的设计

读写锁的自定义同步器在同步状态(一个32位整型变量)上维护多个读线程和一个写线程的状态,即按位切割使用这个变量,以便实现在一个整型变量上维护多种状态。读写锁将变量切分成了两个部分,高16位表示读状态,低16位表示写状态。

2.构造函数

3.写锁的获取与释放

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

写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放,从而等待的读写线程能够继续访问读写锁,同时前次写线程的修改对后续读写线程可见。

4.读锁的获取与释放

读状态是所有线程获取读锁次数的总和,而每个线程各自获取读锁的次数只能选择保存在ThreadLocal中,由线程自身维护,这使获取读锁的实现变得复杂。

读锁的获取过程:

在tryAcquireShared函数中,如果下列三个条件不满足(读线程是否应该被阻塞、小于最大值、比较设置成功)则会进行fullTryAcquireShared函数中,它用来保证相关操作可以成功。

fullTryAcquireShared(Thread)会判断是否要锁降级,然后根据“是否需要阻塞等待”,“读锁状态是否超过限制”等进行处理。如果不需要阻塞等待,并且读锁没有超过限制,则通过CAS尝试获取锁,并返回1。

下流程图转载自https://www.cnblogs.com/xiaoxi/p/9140541.html

读锁的释放过程:

下流程图转载自https://www.cnblogs.com/xiaoxi/p/9140541.html

5.锁降级

一个线程要想同时持有写锁和读锁,必须先获取写锁再获取读锁;写锁可以“降级”为读锁;读锁不能“升级”为写锁。

5、LockSupport

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

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

LockSupport通过调用本地方法实现:

阻塞和唤醒方法:

JDK1.6引入这三个方法对应的拥有Blocker版本:

6、Condition接口

任意一个Java对象,都拥有一组监视器方法(定义在java.lang.Object上),主要包括wait()、 wait(long timeout)、notify()以及notifyAll()方法,这些方法与synchronized同步关键字配合,可以实现等待/通知模式。

Condition接口也提供了类似Object的监视器方法,与Lock配合可以实现等待/通知模式。对比:

实现分析:

1.等待队列

等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用,该线程就是在Condition对象上等待的线程,如果一个线程调用了Condition.await()方法,那么该线程将会释放锁、构造成节点加入等待队列并进入等待状态。事实上,节点的定义复用了同步器中节点定义,也就是说,同步队列和等待队列中节点类型都是同步器的静态内部类AbstractQueuedSynchronizer.Node。

一个Condition包含一个等待队列,Condition拥有首节点(firstWaiter)和尾节点(lastWaiter)。当前线程调用Condition.await()方法,将会以当前线程构造节点,并将节点从尾部加入等待队列,等待队列的基本结构如图:

新增节点只需要将原有的尾节点nextWaiter指向它,并且更新尾节点即可。上述节点引用更新的过程并没有使用CAS保证,原因在于调用await()方法的线程必定是获取了锁的线程,也就是说该过程是由锁来保证线程安全的。

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

2.等待

调用Condition的await()方法(或者以await开头的方法),会使当前线程进入等待队列并释放锁,同时线程状态变为等待状态。当从await()方法返回时,当前线程一定获取了Condition相关联的锁。

await()方法:

总结:

  • 在 Condition 中, 维护着一个队列,每当执行 await 方法,都会根据当前线程创建一个节点,并添加到尾部.
  • 然后释放锁,并唤醒阻塞在锁的 AQS 队列中的一个线程.
  • 然后,将自己阻塞.
  • 在被别的线程唤醒后, 将刚刚这个节点放到同步队列中.接下来就是那个节点的事情了,比如抢锁.
  • 紧接着就会尝试抢锁.接下来的逻辑就和普通的锁一样了,抢不到就阻塞,抢到了就继续执行.

以下三图为线程释放锁的代码:

释放锁后,线程接下来阻塞自己。

  • 第一次调用该方法总是会返回 fasle,从而进入 while 块调用 park 方法阻塞自己
  • 线程判断自己在等待过程中是否被中断了,如果没有中断,则再次循环,会在 isOnSyncQueue 中判断自己是否在队列上。
  • isOnSyncQueue 判断当前 node 状态,如果是 CONDITION 状态,或者不在队列上了,就继续阻塞
  •  isOnSyncQueue 判断当前 node 还在队列上且不是 CONDITION 状态了,就结束循环和阻塞.

至此,Condition 成功的释放了所在的 Lock 锁,并将自己阻塞。

虽然阻塞了,但总有线程会调用 signal 方法唤醒它,唤醒之后走下面的 if 逻辑,调用 checkInterruptWhileWaiting() 方法

 

其实是尝试将自己放入到同步队列中。如果无法放入,就自旋等待 signal 方法放入。

回到 await 方法,接下来是if逻辑,第一个if语句尝试拿锁,如果成功拿到锁,返回false:

拿锁的方法tryAcquire如下:优先那些等待时间久的线程拿锁,即重入锁的公平锁获取方法

如果拿不到锁,则阻塞在parkAndCheckInterrupt中;

之后就是两个if逻辑,如果 Condition 中还有节点,那么就尝试清理无效的节点。

最后,判断是否中断,执行 reportInterruptAfterWait 方法,这个方法可能会抛出异常,也可能会对当前线程打一个中断标记。

3.通知

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

通过调用同步器的enq(Node node)方法,等待队列中的头节点线程安全地移动到同步队列。当节点移动到同步队列后,当前线程再使用LockSupport唤醒该节点的线程。

被唤醒后的线程,将从await()方法中的while循环中退出(isOnSyncQueue(Node node)方法返回true,节点已经在同步队列中),进而调用同步器的acquireQueued()方法加入到获取同步状态的竞争中。

Condition的signalAll()方法,相当于对等待队列中的每个节点均执行一次transferForSignal()方法,效果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程。

参考 https://juejin.im/post/5ae75505518825673027eddf#heading-2

      《 Java并发编程的艺术》

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值