JUC笔记(二)
Java JUC 笔记(2)
锁框架
JDK5以后增加了Lock接口用来实现锁功能,其提供了与synchronized类似的同步功能,但是在使用时手动的获取和释放锁
Lock和Condition锁
这里的锁与synchronized
锁不太一样,我们可以认为是Lock一把真正意义上的锁,每个锁都是一个对应的锁对象,我们在应用时只需要对这个锁对象进行上锁或者解锁的操作即可。我们首先来看看,此接口中定义了什么:
1 | public interface Lock { |
同样按照(一)中的案例,演示如何使用Lock类来进行加锁和释放锁操作:
1 | public class Main { |
这里的线程在执行到action中的代码时,是真的回去请求获取main中定义的锁对象的,他们俩争抢唯一的一把锁,自然也不会出现交错的自增导致线程冲突的问题了。
上面的代码中出现了ReentrantLock,这里简单说一下
reetrantlock可以用来当做synchronized使用的,它比synchronized更安全,在线程tryLock()失败时不会导致死锁,它不会在获取不到锁时无限等待,因为我们必须先获取到锁,然后再进入代码块,最后在finally里释放锁,才能完成全部流程
但是这样开了两个线程去自发地争抢锁,那我们如何去控制线程等待和唤醒等待中(wait()和notify())的线程呢?这里并发包提供了Condition接口
1 | public interface Condition { |
我们可以直接通过我们创建的锁对象来lock.getCondition()
来获取Condition对象,然后调用Condition对象实现唤醒和等待
△ 同一把锁内可以存在多个Condition对象,他们的等待队列是分开计算的(所以可以实现多等待队列)
可重入锁
大家可否学过操作系统或者计组?这里跟CPU响应中断有点像。
CPU需要处理许多事,而每件事的优先级都不同。如果CPU在处理一件事时,有一件更紧急的事来了,CPU就需要先放下手上的事,优先处理更紧急的事,以此类推,不断的套娃(笑
虽然跟这篇文章主题没什么关系,但是在CPU中,它心情不好的时候是可以只干自己愿意干的事的(关中断),在那时就无法响应更高的请求了
可重入锁简单的理解其实也还是套娃(同一个线程可以反复上锁),不过这里可重入锁要锁的对象是他自己,在一个锁它负责的代码范围内可以套入一次锁,同时每次上锁和解锁操作必须一一对应,不然显然会出现问题。
1 | public static void main(String[] args) throws InterruptedException { |
我们可以在一段线程内通过锁调用getHoleCount()
方法来查询当前套娃的层数。
实际上,若有线程拿到锁了,如果有其他线程也来获取锁,他们会进入一个等待队列,我们同样可以通过锁对象调用Lock.getQueueLength()
方法来获取等待该锁的线程数量的估计值
同样的,锁也能通过lock.getWaitQueueLength(Condition condition)
方法来查询等待Condition的线程数量
这里再次强调一下关系,锁拥有不同的condition,然后通过锁来查询condition的等待队列的线程对象
锁对象还提供了hasQueuedThread(Thread t)
的方法来查询一个线程是否在等待队列中
公平锁与非公平锁
之前提到若线程间争抢同一把锁,会让他们暂时进入到等待队列中,那么线程获取锁的顺序是否是按照进入等待队列的先后顺序呢?
在ReentrantLock的构造方法中是这样写的
1 | public ReentrantLock() { |
其实锁分为公平锁和非公平锁,可以看出默认创建出来的是非公平锁
- 公平锁:多个线程按照进入等待队列的次序来获得锁
- 非公平锁:多个线程锁获取锁时,各个线程会直接去抢锁,若获取不到才回去排队
公平锁一定是公平的吗?
读写锁
读写锁并不是专门用作读写操作的的锁,但是可以以读写操作来举例理解
还是以操作系统举例
- 在没有线程在写某个文件时,同时可以有多个线程在读该文件
- 在没有线程在读某个文件时,同时只能有一个线程写该文件
这里的读写锁机制如下
- 读锁:写锁未占用,同时可以多个线程加读锁
- 写锁:读锁未占用,同时只能有一个线程加写锁
读写锁与可重入锁一样,有专门的接口
1 | public interface ReadWriteLock { |
ReadWriteLock接口有一个实现类ReentrantReadWriteLock(它本身不是锁),我们操作ReentrantReadWriteLock时不能直接上锁,而是要先获取读锁或写锁后再上锁。
并且,ReentrantReadWriteLock不仅具有读写锁的功能,还保留了可重入锁和公平/非公平机制,比如同一个线程可以重复为写锁加锁,并且必须全部解锁才真正释放锁:
1 | public static void main(String[] args) throws InterruptedException { |
锁降级和锁升级
锁降级指的是写锁降级成读锁;
当一个线程持有写锁,虽然别的线程不能申请读锁,但是持有写锁的线程可以将加一把读锁,这就是锁降级
1 | public static void main(String[] args) throws InterruptedException { |
那么,如果我们在同时加了写锁和读锁的情况下,释放写锁,是否其他的线程就可以一起加读锁了呢?
在一个线程同时持有写锁和读锁的情况下,释放了写锁,其他线程也会获取到写锁,这种情况就叫做锁降级
注意在仅持有读锁的情况下去申请写锁,属于”锁升级“,ReentrantReadWriteLock是不支持的:
1 | public static void main(String[] args) throws InterruptedException { |
可以看到线程直接卡在加写锁的那一句了。
锁降级: 有写锁 -> 可以再申请读锁; 释放写锁后其他线程也可以申请读锁了
锁升级(ReentrantReadWriteLock不支持): 有读锁 -> 无法申请写锁;
线程同步器AQS
底层实现(以公平锁为例)
锁执行lock对象时,实际上是调用的Sync对象的方法,而Sync又继承自AbstractQueuedSynchronizer(队列同步器AQS)
既然是AQS中的Q是Queued,那么它自然需要维护一个队列
对于每个节点,他有几个比较重要的字段和方法
- Prev和Next,显而易见是指向前序节点和后续节点的指针
- status:表示节点不同的状态
- thread:记录被封装到该节点内的线程
1 | //每个处于等待状态的线程都可以是一个节点,并且每个节点是有很多状态的 |
在一开始的时候,head
和tail
都是null
,state
为默认值0
继续看AQS初始化的其他内容:
1 | //直接使用Unsafe类进行操作 |
可以发现,队列同步器由于要使用到CAS算法,所以,直接使用了Unsafe工具类,Unsafe类中提供了CAS操作的方法(Java无法实现,底层由C++实现)所有对AQS类中成员字段的修改,都有对应的CAS操作封装。
CAS类提供了一些可重写的方法,同时也为独占式和非独占式锁都提供了对应的方法,已经一些已经写好的模板方法(他们会调用重写的方法)。
首先看一些可重写方法:
1 | //独占式获取同步状态,查看同步状态是否和参数一致,如果返没有问题,那么会使用CAS操作设置同步状态并返回true |
可以看到,这些需要重写的方法默认是直接抛出UnsupportedOperationException
,也就是说根据不同的锁类型,我们需要去实现对应的方法,我们可以来看一下ReentrantLock(此类是全局独占式的)中的公平锁是如何借助AQS实现的:
1 | static final class FairSync extends Sync { |
我们先看看加锁操作干了什么事情,这里直接调用了AQS提供的模板方法acquire()
,我们来看看它在AQS类中的实现细节:
1 | //这个是JEP 270添加的新注解,它会保护被注解的方法,通过添加一些额外的空间,防止在多线程运行的时候出现栈溢出,下同 |
其中的tryAcquire()方法
1 | static final class FairSync extends Sync { |
在了解了公平锁的实现之后,是不是感觉有点恍然大悟的感觉,虽然整个过程非常复杂,但是只要理清思路,还是比较简单的。
接着我们看addWaiter(Node.EXCLUSIVE)
1 | private Node addWaiter(Node mode) { |
再看acquireQueued(addWaiter(Node.EXCLUSIVE), arg)
1 |
|
上面是获取锁
释放锁其实也是类似的,在释放的过程中,需要唤醒队列中下一个结点中的线程,然后还要维护AQS中的状态(删除挂起的队列,减少等待队列中节点数量)
还记得JVM的垃圾回收器吗,这里将结点设置为空,然后把指向他的指针知道别的地方,他稍后就会被垃圾回收器回收
具体的代码也贴一下吧
unlock()
方法是在AQS中实现的:
1
2
3 public void unlock() {
sync.release(1); //直接调用了AQS中的release方法,参数为1表示解锁一次state值-1
}
1
2
3
4
5
6
7
8
9
10
public final boolean release(int arg) {
if (tryRelease(arg)) { //和tryAcquire一样,也得子类去重写,释放锁操作
Node h = head; //释放锁成功后,获取新的头结点
if (h != null && h.waitStatus != 0) //如果新的头结点不为空并且不是刚刚建立的结点(初始状态下status为默认值0,而上面在进行了shouldParkAfterFailedAcquire之后,会被设定为SIGNAL状态,值为-1)
unparkSuccessor(h); //唤醒头节点下一个节点中的线程
return true;
}
return false;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 private void unparkSuccessor(Node node) {
// 将等待状态waitStatus设置为初始值0
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
//获取下一个结点
Node s = node.next;
if (s == null || s.waitStatus > 0) { //如果下一个结点为空或是等待状态是已取消,那肯定是不能通知unpark的,这时就要遍历所有节点再另外找一个符合unpark要求的节点了
s = null;
for (Node t = tail; t != null && t != node; t = t.prev) //这里是从队尾向前,因为enq()方法中的t.next = node是在CAS之后进行的,而 node.prev = t 是CAS之前进行的,所以从后往前一定能够保证遍历所有节点
if (t.waitStatus <= 0)
s = t;
}
if (s != null) //要是找到了,就直接unpark,要是还是没找到,那就算了
LockSupport.unpark(s.thread);
}那么我们来看看
tryRelease()
方法是怎么实现的,具体实现在Sync中:
1
2
3
4
5
6
7
8
9
10
11
12
13
protected final boolean tryRelease(int releases) {
int c = getState() - releases; //先计算本次解锁之后的状态值
if (Thread.currentThread() != getExclusiveOwnerThread()) //因为是独占锁,那肯定这把锁得是当前线程持有才行
throw new IllegalMonitorStateException(); //否则直接抛异常
boolean free = false;
if (c == 0) { //如果解锁之后的值为0,表示已经完全释放此锁
free = true;
setExclusiveOwnerThread(null); //将独占锁持有线程设置为null
}
setState(c); //状态值设定为c
return free; //如果不是0表示此锁还没完全释放,返回false,是0就返回true
}
这样就大概讲完了可重入锁的公平锁实现,实际上也不是无法理解的(迫真
下面是讲师总结的流程图
这是我自己总结的
公平锁真的公平吗?
在并发的情况下,公平锁是有概率变得不公平的
对于每个需要尝试获取锁的进程,他们都会先执行tryAcquire()来尝试获取锁,在尝试获取锁的过程中会先判断在队列中是否有节点处于等待队列,然后一旦发现没有,就会执行CAP操作
现在假设有线程1,线程2和线程3;线程1已经拿到锁了,这时线程2开始尝试获取锁,它发现虽然等待队列是空的,但是CAS操作失败,显然有线程在用锁,这时他开始准备排队了;
现在,突然线程3也开始尝试获取锁,恰巧在这时线程1释放锁了,线程3说:“消息队列没人在排队,CAS也没人在用,那我就不客气了”,于是线程3顺利地拿到了锁,实现了插队的目的。线程2:“我转个身怎么前面大哥换人了?”
虽然概率很低,但高并发的情况也不是遇不见这样离谱的情况。
所以,严格来讲,公平锁只有在等待队列非空的时候才是公平的,就算是公平,也不是按照发请求的先后的公平,而是按照进入等待队列的时间的公平。
Condition实现原理
之前我们看了Condition,了解了它实际上就是替代传统对象实现wait/notify操作(在Condition中是await/signal操作)的,并且同一把锁可以创建多个Condition对象,我们现在对Condition对象进行解析
在AQS中,Condition类有一个实现类ConditionObject,其同样使用链表实现了条件队列
这里的条件队列能允许线程在某些条件不满足的情况下先进入等待状态,并且等待被唤醒;在某个对象进入调用wait()后会被放入条件队列,等待notify()唤醒以争夺锁
1 | public class ConditionObject implements Condition, java.io.Serializable { |
这里条件队列直接借用了AQS的Node类,但是使用的是Node类中的nextWaiter字段来连接节点,并且Node的状态设置为CONDITION(处于条件队列中)
当一个线程调用await()方法时,会进入等待状态(进入条件队列),直到其他线程使用signal()方法将其唤醒(进入AQS的等待队列)
下面将会研究await()方法的实现,这里先明确这个方法的目标
- 仅有已经持有锁的方法才能够调用await
- 当调用await方法后,无论加了多少次锁,都会直接释放锁
- 只有其他线程调用signal或者是被中断时,才会唤醒等待中的线程
- 被唤醒的线程依然需要等待其他线程释放锁,并且等真正抢到锁以后才会继续执行,并且会恢复到await()时的状态(和await时一样的锁层数)
1 | public final void await() throws InterruptedException { |
简而言之
- 首先判断当前调用的线程是否处于中断的状态,若已在中断状态了,那么还谈什么等待状态呢,直接抛异常
- 在条件队列中加入一个新的节点,并保存当前线程的各种状态
- 循环判断当前线程是否还处于条件队列中,并且在循环里监视有没有在等待时被中断(被中断了一样会醒)
- 当跳出循环,表示当前线程一定是醒了,现在只需要拿到锁就可以开始运行了
- 在拿到锁后进行收尾工作,打扫一下等待队列,再回头看一眼有没有被中断,之后就正式开始执行自己的任务了
实际上await()
方法比较中规中矩,大部分操作也在我们的意料之中,那么我们接着来看signal()
方法是如何实现的,同样的,为了防止各位绕晕,先明确signal的目标:
- 只有持有锁的线程才能唤醒锁所属(等着这把锁)Condition的的线程
- 优先唤醒条件队列队首,若出现问题,就按顺序往下找,直到找到可以唤醒的
- 唤醒的本质是将线程从条件队列中移出至等待队列
- 拿到锁之后,线程才能恢复运行
1 | public final void signal() { |
1 | private void doSignal(Node first) { |
1 | final boolean transferForSignal(Node node) { |
这里其实思路也不是很复杂, 跟之前消费等待队列的思路大差不差
先判断调用signal的线程的状态,是不是有锁,没锁你唤醒个锤子
若持有锁,就从条件队列取队首,并进行消费,不断循环判断该节点是否为取消状态,若是则继续看下一个节点;若发现条件队列都空了,就要进行一些收尾工作
消费等待队列结点的过程:
- 先进行CAS判断,若成功了表明已经有资格进入等待队列了
- 将线程加入等待队列,并且获取前驱节点的等待状态
- 若上一个节点的状态为取消(上一个节点都被取消了还管他干啥,直接开抢), 或者尝试设置上一个节点的状态为SIGNAL时失败(想把我挂起?没门,开抢!)
其实最让人不理解的就是倒数第二行,明明上面都正常进入到AQS等待队列了,应该是可以开始走正常流程了,那么这里为什么还要提前来一次unpark呢?
这里其实是为了进行优化而编写,直接unpark会有两种情况:
- 如果插入结点前,AQS等待队列的队尾节点就已经被取消,则满足wc > 0
- 如果插入node后,AQS内部等待队列的队尾节点已经稳定,满足tail.waitStatus == 0,但在执行ws >0之后 !compareAndSetWaitStatus(p, ws,Node.SIGNAL)之前被取消,则CAS也会失败,满足compareAndSetWaitStatus(p, ws,Node.SIGNAL) == false
如果这里被提前unpark,那么在await()
方法中将可以被直接唤醒,并跳出while循环,直接开始争抢锁,因为前一个等待结点是被取消的状态,没有必要再等它了。
大致流程如下
其实不难理解吧。。就是概念比较多,讲师带着顺一遍其实基本能跑通思路
原子类
原子类,正如其名,是可以实现原子操作的类,JUC为我们提供的原子类底层采用CAS算法;所有的原子类都位于java.util.concurrent.atomic
包下。
原子类介绍
常用的基本数据类型有其对应的原子类封装:
- AutomicInteger:原子更新int
- AtomicLon:原子更新long
- AtomicBoolean:原子更新boolean
现在我们使用int类型对应的原子类:
1 | public class Main { |
我们可以将int数值封装到此类中(注意必须调用构造方法,它不像Integer那样有装箱机制),并且通过调用此类提供的方法来获取或是对封装的int值进行自增。
底层实现
1 | private volatile int value; |
AtomicInteger的底层本质是实现了一个volatile
类型的int值,这样就可以保证在CAS时的可见性,进而实现原子性
原子类底层也是采用了CAS算法来保证的原子性,包括getAndSet
、getAndAdd
等方法都是这样。原子类也直接提供了CAS操作方法,我们可以直接使用:
1 | public static void main(String[] args) throws InterruptedException { |
如果想以普通变量的方式来设定值,那么可以使用lazySet()
方法,这样就不采用volatile
的立即可见机制了。
1 | AtomicInteger integer = new AtomicInteger(1); |
除了基本类有原子类以外,基本类型的数组类型也有原子类:
- AtomicIntegerArray:原子更新int数组
- AtomicLongArray:原子更新long数组
- AtomicReferenceArray:原子更新引用数组
其实原子数组和原子类型一样的,不过我们可以对数组内的元素进行原子操作:
1 | public static void main(String[] args) throws InterruptedException { |
在JDK8之后,新增了DoubleAdder
和LongAdder
,在高并发情况下,LongAdder
的性能比AtomicLong
的性能更;
比如说在自增时普通的原子类会只对一个value进行CAS,而DoubleAdder
和LongAdder
会对一个数组进行分散的CAS操作(即不同线程可以对数组中不同的元素进行CAS自增),这样就避免了所有线程都对同一个值进行CAS
使用如下:
1 | public static void main(String[] args) throws InterruptedException { |
除了对基本数据类型支持原子操作外,对于引用类型,也是可以实现原子操作的:
1 | public static void main(String[] args) throws InterruptedException { |
JUC还提供了字段原子更新器,可以对类中的某个指定字段进行原子操作(注意字段必须添加volatile关键字):
1 | public class Main { |
ABA问题及其解决方案
之前的Redis中提过,这里不再赘述Redis 秒杀 笔记1
这里的解决方法时记录一个不会重复的版本号
1 | public static void main(String[] args) throws InterruptedException { |
并发容器
传统容器线程安全吗?
以ArrayList为例子,我们创建两个线程,同时向ArrayList中添加数据,各添加1万次,最后获得的队列长度应该是2万。但是在实际情况下,一般都会小于2万个
ArrayList的入队操作是先确认是否有足够的容量进行插入操作,在确认可以插入后就执行插入操作。在多线程的情况下,A线程执行确认和插入操作之间,B线程执行了插入,导致A线程插入时队列的长度不足,就会导致数组越界,造成最后的结果与预期不一致。
传统容器虽然在单线程情况下很好用,但是在多线程情况下就会产生安全问题,下面会介绍一些常用的线程安全的并发容器
并发容器介绍
如何让传统线程容器安全?显而易见的我们可以使用synchronized
关键字,但是这样效率太低了。
我们直接使用JUC提供的专用于并发场景下的容器。
CopyOnWriteArrayList
比如之前的ArrayList我们可以替换为CopyOnWriteArrayList,它是线程安全的,就不会造成线程之间互相冲突的问题
那么它是如何执行add()操作的呢?
1 | public boolean add(E e) { |
可以看到添加操作是直接上锁,并且会先拷贝一份当前存放元素的数组,然后对数组进行修改,再将此数组替换(CopyOnWrite)接着我们来看读操作:
1 | public E get(int index) { |
正如其名,CopyOnWriteArrayList会对写操作加上锁,而读操作不需要加锁,毕竟再多人读都不会改变数组的内容,但是有人在写就可能造成前后读取的数据不一致的问题
ConcurrentHashMap
接着我们来看对于HashMap的并发容器ConcurrentHashMap
:
1 | public static void main(String[] args) throws InterruptedException { |
在多线程的情况下, 多个线程会争抢同一把锁,我们之前的LongAdder中有一种分散出几个锁来缓解压力的思想,这里也是类似的;在JDK7以前,ConcurrentHashMap的原理是将数据分段存储,每一段都有自己的锁,这样当给一段数据加了锁也不会影响对其他段的操作了
在JDK8之后,ConcurrentHashMap又引入了红黑树和哈希表综合的机制
当插入数据时,会先计算对应的哈希值,根据哈希值找到应在数组中存放的位置,然后创建一个新的结点并添加到对应下标的链表后面,打拿个链表长度达到8以后,会自动将链表转化为红黑树,这样就能提升查询效率。
ConcurrentHashMap的put()
实现
1 | public V put(K key, V value) { |
总结一下就是:
讲师的图画的也太好!
ConcurrentHashMap的get()
实现
1 | public V get(Object key) { |
综上,ConcurrentHashMap的put操作,实际上是对哈希表上的所有头结点元素分别加锁,理论上来说哈希表的长度很大程度上决定了ConcurrentHashMap在同一时间能够处理的线程数量,这也是为什么treeifyBin()
会优先考虑为哈希表进行扩容的原因。显然,这种加锁方式比JDK7的分段锁机制性能更好。
get操作就是根据hash来找到对应的队列/红黑树,然后在里面找到对应的数据并且返回
阻塞队列
了我们常用的容器类之外,JUC还提供了各种各样的阻塞队列,用于不同的工作场景。
阻塞队列本身也是队列,但是它是适用于多线程环境下的,基于ReentrantLock实现的,它的接口定义如下:
1 | public interface BlockingQueue<E> extends Queue<E> { |
比如现在有一个容量为3的阻塞队列,这个时候一个线程put
向其添加了三个元素,第二个线程接着put
向其添加三个元素,那么这个时候由于容量已满,会直接被阻塞,而这时第三个线程从队列中取走2个元素,线程二停止阻塞,先丢两个进去,还有一个还是进不去,所以说继续阻塞。
利用阻塞队列,我们可以轻松地实现消费者和生产者模式;
一共有三种常用的阻塞队列:
- ArrayBlockingQueue:有界带缓冲阻塞队列(就是队列是有容量限制的,装满了肯定是不能再装的,只能阻塞,数组实现)
- SynchronousQueue:无缓冲阻塞队列(相当于没有容量的ArrayBlockingQueue,因此只有阻塞的情况)
- LinkedBlockingQueue:无界带缓冲阻塞队列(没有容量限制,也可以限制容量,也会阻塞,链表实现)
对SynchronousQueue,它没有容量,也就是说入队必须和出队同时出现,我们需要通过transfer
方法来对生产者和消费者之间的数据来进行操作
如果只有消费者或者只有生产者都不能完成数据传递,所以会被阻塞;只有生产者和消费者全部到齐了才能进行消费
LinkedBlockingQueue更牛逼了,它在SynchronousQueue的基础上加了容量,可以暂时让多个消费者/生产者在队列中多等着
了解一些其他的队列:
- PriorityBlockingQueue - 是一个支持优先级的阻塞队列,元素的获取顺序按优先级决定。
- DelayQueue - 它能够实现延迟获取元素,同样支持优先级。
DelayQueue可以实现代优先级的延迟出队,在这个情况下,就算优先级比较低的结点已经可以出队了,还是需要等待优先级更高的结点结束延迟出队后才能进行出队操作
参考视频:Java JUC 并发编程 已完结(IDEA 2021版本)4K蓝光画质 玩转多线程
视频教程文档:柏码-JUC笔记(二)