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 都是弱一致性的表现
并发高和一致性是矛盾的,需要权衡