并发编程笔记(4)

AQS 原理

1、概述

全称是 AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架

2、特点

1.用 state 属性来表示资源的状态(分独占模式和共享模式),子类需要定义如何维护这个状态,控制如何获取锁和释放锁

    1)getState - 获取 state 状态

    2)setState - 设置 state 状态

    3)compareAndSetState - cas 机制设置 state 状态

    4)独占模式是只有一个线程能够访问资源,而共享模式可以允许多个线程访问资源

2.提供了基于 FIFO 的等待队列,类似于 Monitor 的 EntryList

3.条件变量来实现等待、唤醒机制,支持多个条件变量,类似于 Monitor 的 WaitSet

ReentrantLock 原理

可以看到 ReentrantLock 提供了两个同步器,实现公平锁和非公平锁,默认是非公平锁!

1)加锁解锁流程

先从构造器开始看,默认为非公平锁实现

当前线程进入 acquire方法的 acquireQueued 逻辑

    1.acquireQueued 会在一个死循环中不断尝试获得锁,失败后进入 park 阻塞

    2.如果自己是紧邻着 head(排第二位),那么再次 tryAcquire 尝试获取锁,我们这里设置这时 state 仍为 1,失败

    3.进入 shouldParkAfterFailedAcquire 逻辑,将前驱 node,即 head 的 waitStatus 改为 -1,这次返回 false

waitStatus = -1 表示有义务去唤醒后继节点

4.shouldParkAfterFailedAcquire 执行完毕回到 acquireQueued ,再次 tryAcquire 尝试获取锁,当然这时 state 仍为 1,失败

5.当再次进入 shouldParkAfterFailedAcquire 时,这时因为其前驱 node 的 waitStatus 已经是 -1,这次返回 true

6.进入 parkAndCheckInterrupt, Thread-1 park(灰色表示已经阻塞)

再从次有多个线程经历上述过程竞争失败,变成这个样子

Thread-0 释放锁,进入 tryRelease 流程,如果成功

    设置 exclusiveOwnerThread 为 null

    state = 0

如果当前队列不为 null,并且 head 的 waitStatus = -1,进入 unparkSuccessor 流程:

1.unparkSuccessor 中会找到队列中离 head 最近的一个 Node(没取消的),unpark 恢复其运行,本例中即为 Thread-1

2.回到 Thread-1 的 acquireQueued 流程

如果加锁成功(没有竞争),会设置 (acquireQueued 方法中)

    1.exclusiveOwnerThread 为 Thread-1,state = 1

    2.head 指向刚刚 Thread-1 所在的 Node,该 Node 清空 Thread

3.原本的 head 因为从链表断开,而可被垃圾回收

如果这时候有其它线程来竞争(非公平的体现),例如这时有 Thread-4 来了

如果不巧又被 Thread-4 占了先

    1.Thread-4 被设置为 exclusiveOwnerThread,state = 1

2.Thread-1 再次进入 acquireQueued 流程,获取锁失败,重新进入 park 阻塞(之前unpark时会将waitState设置为0,竞争失败后重新park)

是否需要 unpark 是由当前节点的前驱节点的 waitStatus == Node.SIGNAL 来决定,而不是本节点的waitStatus 决定

2、锁重入原理

3、可打断原理

不可打断模式:

在此模式下,即使它被打断,仍会驻留在 AQS 队列中,一直要等到获得锁后方能得知自己被打断了。

在线程获取锁后才会去检测打断标记,若为真则打断当前线程

可打断模式:

4、公平锁原理

5、条件变量实现原理

每个条件变量其实就对应着一个等待队列,其实现类是 ConditionObject

await 流程:

1.开始 Thread-0 持有锁调用 await,进入 ConditionObject 的 addConditionWaiter 流程

2.创建新的 Node 状态为 -2(Node.CONDITION),关联 Thread-0,加入等待队列尾部

接下来进入 AQS 的 fullyRelease 流程(解决锁重入的情况),释放同步器上的锁

unpark AQS 队列中的下一个节点,竞争锁,假设没有其他竞争线程,那么 Thread-1 竞争成功

park 阻塞 Thread-0

signal 流程

假设 Thread-1 要来唤醒 Thread-0

进入 ConditionObject 的 doSignal 流程,取得等待队列中第一个 Node,即 Thread-0 所在 Node

执行 transferForSignal 流程,将该 Node 加入 AQS 队列尾部,将 Thread-0 的 waitStatus 改为 0,Thread-3 的waitStatus 改为 -1

读写锁原理

1、ReentrantReadWriteLock

当读操作远远高于写操作时,这时候使用读写锁让读-读可以并发,提高性能。读-写,写-写都是互斥的!

提供一个数据容器类内部分别使用读锁保护数据的read()方法,写锁保护数据的write()方法 。

注意事项

    1.读锁不支持条件变量

2.重入时升级不支持:即持有读锁的情况下去获取写锁,会导致获取写锁永久等待

3.重入时降级支持:即持有写锁的情况下去获取读锁

2、应用之缓存

缓存更新策略:

更新时,是先清缓存还是先更新数据库?

先清除缓存操作如下:

先更新数据库操作如下:

3、读写锁原理

读写锁用的是同一个 Sync 同步器,因此等待队列、state 等也是同一个

下面执行:t1 w.lock,t2 r.lock 情况

1)t1 成功上锁,流程与 ReentrantLock 加锁相比没有特殊之处,不同是写锁状态占了 state 的低 16 位,而读锁使用的是 state 的高 16 位

2)t2 执行 r.lock,这时进入读锁的 sync.acquireShared(1) 流程,首先会进入 tryAcquireShared 流程。如果有写锁占据,那么 tryAcquireShared 返回 -1 表示失败。

tryAcquireShared 返回值表示

    1.-1 表示失败

    2. 0 表示成功,但后继节点不会继续唤醒

    3. 正数表示成功,而且数值是还有几个后继节点需要唤醒,我们这里的读写锁返回 1

3)这时会进入 sync.doAcquireShared(1) 流程,首先也是调用 addWaiter 添加节点,不同之处在于节点被设置为 Node.SHARED 模式而非 Node.EXCLUSIVE 模式,注意此时 t2 仍处于活跃状态

4)t2 会看看自己的节点是不是老二,如果是,还会再次调用 tryAcquireShared(1) 来尝试获取锁

5)如果没有成功,在 doAcquireShared 内 for (;😉 循环一次,把前驱节点的 waitStatus 改为 -1,再 for (;😉 循环一 次尝试 tryAcquireShared(1) 如果还不成功,那么在 parkAndCheckInterrupt() 处 park。

又继续执行:t3 r.lock,t4 w.lock
这种状态下,假设又有 t3 加读锁和 t4 加写锁,这期间 t1 仍然持有锁,就变成了下面的样子

继续执行 t1 w.unlock

这时会走到写锁的 sync.release(1) 流程,调用 sync.tryRelease(1) 成功,变成下面的样子

接下来执行唤醒流程 sync.unparkSuccessor,即让老二恢复运行,这时 t2 在 doAcquireShared 内 parkAndCheckInterrupt() 处恢复运行,图中的t2从黑色变成了蓝色(注意这里只是恢复运行而已,并没有获取到锁!) 这回再来一次 for (;; ) 执行 tryAcquireShared 成功则让读锁计数加一

这时 t2 已经恢复运行,接下来 t2 调用 setHeadAndPropagate(node, 1),它原本所在节点被置为头节点

事情还没完,在 setHeadAndPropagate 方法内还会检查下一个节点是否是 shared,如果是则调用 doReleaseShared() 将 head 的状态从 -1 改为 0 并唤醒老二,这时 t3 在 doAcquireShared 内 parkAndCheckInterrupt() 处恢复运行.

这回再来一次 for (;; ) 执行 tryAcquireShared 成功则让读锁计数加一

这时 t3 已经恢复运行,接下来 t3 调用 setHeadAndPropagate(node, 1),它原本所在节点被置为头节点

沿着AQS队列向后寻找,如果为shared则unpark,知道找到第一个不为shared的节点为止

再继续执行t2 r.unlock,t3 r.unlock t2
进入 sync.releaseShared(1) 中,调用 tryReleaseShared(1) 让计数减一,但由于计数还不为零

t3 进入 sync.releaseShared(1) 中,调用 tryReleaseShared(1) 让计数减一,这回计数为零了,进入 doReleaseShared() 将头节点从 -1 改为 0 并唤醒老二,

之后 t4 在 acquireQueued 中 parkAndCheckInterrupt 处恢复运行,再次 for (;; ) 这次自己是老二,并且没有其他 竞争,tryAcquire(1) 成功,修改头结点,流程结束

如果读锁的计数减为0,唤醒后继节点

4、StampedLock

该类自 JDK 8 加入,是为了进一步优化读性能,它的特点是在使用读锁、写锁时都必须配合【】使用

乐观读,StampedLock 支持 tryOptimisticRead() 方法(乐观读),读取完毕后需要做一次 戳校验 如果校验通 过,表示这期间确实没有写操作,数据可以安全使用,如果校验没通过,需要重新获取读锁,保证数据安全。

注意:
StampedLock 不支持条件变量
StampedLock 不支持可重入

Semaphore

1、基本使用

信号量,用来限制能同时访问共享资源的线程上限

semaphore.acquire();

semaphore.release();

使用 Semaphore 限流,在访问高峰期时,让请求线程阻塞,高峰期过去再释放许可,当然它只适合限制单机线程数量,并且仅是限制线程数,而不是限制资源数(例如连接数,请对比 Tomcat LimitLatch 的实现)用  Semaphore 实现简单连接池,对比『享元模式』下的实现(用wait notify),性能和可读性显然更好,注意下面的实现中线程数和数据库连接数是相等的

2、图解流程

Semaphore 有点像一个停车场,permits 就好像停车位数量,当线程获得了 permits 就像是获得了停车位,然后停车场显示空余车位减一刚开始,permits(state)为 3,这时 5 个线程来获取资源。

 

假设其中 Thread-1,Thread-2,Thread-4 cas 竞争成功,而 Thread-0 和 Thread-3 竞争失败,进入 AQS 队列park 阻塞(Shared)

这时 Thread-4 释放了 permits,状态如下

接下来 Thread-0 竞争成功,permits 再次设置为 0,设置自己为 head 节点,断开原来的 head 节点,unpark 接下来的 Thread-3 节点,但由于 permits 是 0,因此 Thread-3 在尝试不成功后再次进入 park 状态

CountdownLatch

用来进行线程同步协作,等待所有线程完成倒计时。其中构造参数用来初始化等待计数值,await() 用来等待计数归零,countDown() 用来让计数减一

CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 值为 count。当线程使用 countDown方法时,其实使用了 tryReleaseShared 方法以CAS 的操作来减少 state ,直至 state 为 0 就代表所有的线程都调用了countDown方法。当调用 await 方法的时候,如果 state 不为0,就代表仍然有线程没有调用 countDown 方法,此时调用await进入队列中等待,直到state==0结束阻塞恢复运行

应用之同步等待多线程准备完毕

CyclicBarrier

CyclicBarri[ˈsaɪklɪk ˈbæriɚ]

循环栅栏,用来进行线程协作,等待线程满足某个计数。构造时设置『计数个数』,每个线程执行到某个需要“同步”的时刻调用 await() 方法进行等待,当等待的线程数满足『计数个数』时,继续执行。跟 CountdownLatch 一样,但这个可以重用

当state计数变为0,再次调用await()可以使计数重置,实现重用

每次线程调用await()就会使计数-1,当计数!=0时会使线程park(),当计数减为0时,调用CyclicBarrier构造方法中传入的Runnable,同时unpark()阻塞住的线程

注意

CyclicBarrier 与 CountDownLatch 的主要区别在于 CyclicBarrier 是可以重用的 CyclicBarrier 可以被比喻为『人满发车』

线程安全集合类概述

ConcurrentHashMap

单词计数

多个线程对map进行并发的修改,存在线程安全问题

如果使用ConcurrentHashMap 对不对?   依旧存在线程安全问题,此时虽然每个方法都是线程安全的,但是多个方法的组合不一定线程安全   map.get() <-> map.put()

ConcurrentHashMap 原理

JDK 7 HashMap 并发死链   -> 发生在扩容时(线程不安全导致的)

源码分析

Table的长度大小必须为2^n

// 1. (n-1) & h 相当于取模运算

// 2.hash为负数表示该bin在扩容中或者是treebin,调用find()去扩容后的数组中查找

// 3.如果链表头结点不是要查找的key,正常遍历链表,用equals()沿着链表依次比较

put 流程

以下数组简称(table),链表简称(bin)

// 1.不允许key = null or value = null

// 2.初始化table,添加链表头时使用了cas

// 3.当头结点是forwording node时,帮忙扩容

// 出现冲突时put()锁住的是链表的表头

addCount职责

// 1.addCount()负责维护map的size计数

// 2.addCount()负责扩容操作transfer()

size 计算流程

Transfer()

JDK 7 ConcurrentHashMap

可以看到 ConcurrentHashMap 没有实现懒惰初始化,空间占用不友好

其中 this.segmentShift 和 this.segmentMask 的作用是决定将 key 的 hash 结果匹配到哪个 segment

segment 继承了可重入锁(ReentrantLock),它的 put 方法为

rehash 流程

发生在 put 中(新增且容量达到阈值),因为此时已经获得了锁,因此 rehash 时不需要考虑线程安全

//1. 将容量扩大为原来的2倍

//2.链表中只有头结点的数组直接将节点移动到新链表中

//3.将rehash()后下标没有改变的节点直接重用

//4.剩余的节点需要新建

Size计算流程

//1.计算元素个数前,先不加锁计算两次,若两次计算结果相同,认为个数正确,返回

//2.如果不一样,进行重试,重试次数超过3,将所有segment锁住,计算结果,返回

LinkedBlockingQueue 原理

用两把锁,同一时刻,可以允许两个线程同时(一个生产者与一个消费者),锁住链表的头尾

由 put 唤醒 put 是为了避免信号不足

CopyOnWriteArrayList

CopyOnWriteArraySet是它的马甲底层实现采用了写入时拷贝的思想,增删改操作会将底层数组拷贝一份,更改操作在新数组上执行,这时不影响其它线程的并发读,读写分离。以新增为例:

get 弱一致性

迭代器弱一致性(迭代器拿到的是旧数组的引用)

不要觉得弱一致性就不好

数据库的 MVCC 都是弱一致性的表现

并发高和一致性是矛盾的,需要权衡

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值