文章目录
JMM&volatile
- 现代计算机理论模型与工作原理
- 什么是线程
- 为什么用并发,并发产生的问题
- JMM
- volatile
计算机理论模型
重点是运算器,(内)存储器,控制器。CPU->存储器
缓存的必要性:
- IO总线太难了,每一个设备都需要用,靠不住
- 寄存器>L1>L2>L3>内存条
- 缓存绑定在CPU上,不用IO总线,其中L1甚至内嵌在CPU中
CPU和内存其实操作的都是缓存。且L3不会立即同步到内存中,而是当L3满了之后,用FIFO的原则进行同步。
想要强制将L3的计算完的数据同步到内存,用到了汇编中的#Lock信号。
而#Lock又是基于Mesi(缓存一致性协议)
CPU内部结构
- 控制单元(寄存器位于控制单元中,分为指令寄存器)
- 运算单元
- 内存单元(缓存L1)
CPU多核缓存架构
多个CPU,多个CPU缓存,一个RAM。此时Mesi十分重要。否则多CPU时就会有很多问题。多个CPU给主内存中的变量操作
解决:
- 总线加锁:读写都没办法进行
- 缓存一致性协议
两个线程跑在同一个CPU上,也要进行总线交互,只不过是CPU的内部总线
MESI
- M:Modify,修改。
- E:Exclusive,独享。
- S:Share。分享
- I:Invalid
CPU通过bus来监听其他CPU的操作:总线嗅探机制
问题1:为什么有了MESI,但还是会有多线程问题?
- MESI只是涉及到了存储数怎么去操作。只解决了缓存一致性问题
- 但还有问题:有序性问题(指令重排)
问题2:什么情况下,缓存一致性协议会失效?
- 如果变量x的存储长度大于一个缓存行。数据不能横跨两个缓存行(其实可以,只是MESI不生效了)
解决:换效率更低的总线锁 - CPU本身并不支持缓存一致性协议(早期奔腾)
问题3:MESI缺点
- 占总线资源,太多的话会总线风暴
线程
在以前的面向进程的操作系统中,进程是操作系统调度的基本单位;如今面向线程的操作系统中,进程是线程的容器
什么是真正的线程?
线程寄生在进程之中
用户空间划分:(因为安全性)
- 用户空间(JVM):不能操作内核空间,只能操作内核提供的接口
- 内核空间(操作系统的核心)
CPU的执行分两个特权级别:
- Ring0:只有内核空间才有Ring0级别。才能创建线程
- ring3:太低了,需要ring0(用户空间)
线程的分类:
-
用户级线程:ULT(User Level Thread)跑在用户空间中。没有CPU使用权限。线程表被所在进程维护。依托于主进程执行。操作系统甚至不知道用户级线程的存在,因为他的线程表没有在内核维护,而是被进程所维护。
-
内核级线程:KLT(Kernel Level Thread)跑在内核空间中。才有CPU使用权限。线程表被内核维护,所以操作系统才知道他,并给他功能。
-
内核空间跑内核级线程,内核级线程去抢CPU
-
用户空间跑用户线程,由JVM自己创建的线程,需要JVM进程维护自己的线程,需要JVM调内核空间提供的接口创建内核级线程,这个过程需要JVM从用户态转内核态。(这也是为什么阻塞线程需要的开销很大,因为需要JVM进程从用户态到内核态)
一般不会用用户级线程:
- 进程创建的线程只是伪线程,没办法独立调度CPU,只能依附于主进程。且运行时,当进程中的某一个线程发生阻塞,则主进程阻塞。
- 好处是避免过度创建线程,导致大量的上下文切换。
- 真正的做法是用Java提供的API创建线程,JVM调用OS内核接口,将其映射到OS底层的线程。
一般用内核级线程:
- 每个线程都会在内核空间维护线程表。此时每个线程被称为轻量级进程,此时他可以调用CPU
JVM使用的线程:
- JVM在1.2版本之前用的是ULT
- 之后用的是KLT
- Java中的线程调用接口(如Linux的pthread)后,与内核级线程一一映射
问题:线程上下文切换会涉及到用户态到内核态的切换原因
- 因为线程是映射到操作系统底层的所支持的线程的。而上下文切换这个操作,同样需要调用操作系统内核提供的接口,所以需要进程从用户态进入内核态来调用这些接口
- 线程的时间片用完后,而程序还没有运行完,则需要将程序的指令,程序指针,中间数据放到主内存的内核空间(Tss,任务状态段)
为什么需要用到并发
- 充分利用多核CPU的计算能力
- 方便进行业务拆分,提升应用性能。比如JVM里面就有专门垃圾回收的线程和主线程之分
使用并发所带来的问题:
- 高并发的场景下,导致频繁的上下文切换,多个线程争抢CPU时间片。
- 临界区线程安全问题,容易出现死锁
问题的原因:
- 线程间无法感知
- 需要MESI协议
JMM
Java Memory Model。JMM的出现,解决了硬件多样化的痛点,为内存管理提供了一个统一的抽象概念,使其更好得管理内存。
目的:
- 一次编译,到处运行
- 屏蔽底层操作系统设计
- 屏蔽硬件架构的不同
就是硬件架构的抽象。只是一种规范,它的来源就是硬件。作用是屏蔽硬件的不同并不实际存在
JMM=CPU+缓存+主存 的抽象。
基于CPU多重缓存架构的抽象:
- 线程的工作内存可以理解为CPU的缓存
- 线程的程序栈本质是CPU在执行指令
- 主内存本质是内存条
- 工作内存和主内存之间的JMM控制本质上是基于总线的MESI缓存一致性协议
成员:
- 主内存
- 工作区
JVM与JMM内存划分没有一毛钱关系:
- JVM的内存划分是为了更好得管理内存
- JMM是为了映射解决底层硬件架构设计的不同
三大特性:
- 原子性
- 有序性
- 可见性
JMM控制内存交互操作:用这8大操作来解决三大特性
- lock
- unlock
- read
- load
- use
- assign赋值
- store
- write
lock->read->load->use->assign->store->write
可见性
解决方案:
- synchronized
- volatile
原子性:
count++不是原子操作,volatile也无法解决原子性。
有volatile之后,会多一个 lock addl $0x0,(%rsp)
,也就是lock的前缀指令。 相当于内存屏障。lock指令会触发MESI
他能保证三点:
- 将本处理器的缓存写入内存
- 重排序时不能把后面的指令重排序到内存屏障之前的位置
- 如果是写入动作,会导致其他处理器中对应的内存无效
- 其中1和3保证了可见性
问题:
- 为什么能解决可见性却不能解决原子性
因为volatile在解决线程之间的可见性时的原理触发MESI协议,然后触探总线。当触探到别的线程将volatile修饰的遍历进行改变时,他会清空自己的工作内存,但不会再执行一遍。
第一轮循环无效后,第二次循环不一定是在写会主存之后,所以第一次失效后,第二次会自动延迟,延迟到值写入内存。但不可能一直等,而是指令重排,将count++下面的代码先执行。从而引出有序性和指令重排
总结1
为了了解JMM,而JMM是屏蔽了操作系统和硬件架构的。先举例最常见的x86架构。它是基于冯诺依曼体系结构的,最重要的就是运算器和存储器。而设计之中,为了解决速度不匹配的问题,又采用了多重缓存L1,L2,L3的架构设计。CPU唯一能直接存取的是CPU当中内嵌的寄存器,如果寄存器没有,则去找L1要,依次去要。
为了解决CPU层次的可见性,设计了基于BUS总线的MESI协议。它包括了Modify修改有效独有,Exclusive独享读,Share分享读,Invalid修改无效分享。他的底层是嗅探总线上的#Lock汇编指令。他解决了跑在CPU的进程层次的可见性,但现在的线程可能跑在多核心CPU上,所以线程的可见性依然没有解决。
了解了硬件架构之后,就能很好地理解JMM了。JMM就是为了实现一次编译,到处运行。因为可以到处运行,所以大可将其和x86进行映射。JMM中的主内存就是主存储器,工作内存就是CPU缓存,JMM控制就是MESI。JMM提供的同步8种操作(lock,read,load,use,assign,store,write,unlock他们是原子性的操作),用这八种操作来解决三大特性。就是脱胎于MESI。而且底层同样是用到了汇编指令中的#Lock,来完成嗅探其他线程。他的原理是锁缓存行,而当数据超出缓存行时,锁总线。
原子性操作基于cmp-chxg指令,原子比较与交换的支持。除了原子性外,read和load,store和write必须成对出现执行。read后马上load。store后马上write
JMM需要按可见性,原子性,一致性展开。先来说可见性。
有一个例子。两个线程,一个线程处于while死循环,跳出循环的条件是一个flag变量。他先启动。而另一个线程启动后,修改这个变量,但前一个线程依然处于死循环。
问题就在于线程用到的是自己工作空间中的变量副本,而非从主存中读取被改后的数据。
但当while循环中加synchronized出现了阻塞,则情况不一样。
出现阻塞后,其CPU使用权降低,增加其时区使用权的几率。其CPU时间片很快就会用完,进行了线程上下文的切换。换出时,将环境读到TSS(任务状态段)中,而切换回来时,会再次从主存中拿数据。
这里再提一下上下文的切换。线程分为用户级线程和内核级线程。其中只有内核级线程才能抢CPU,所以JVM中维护的线程发生上下文切换时需要进行内核的权限。
将flag变量前加volatile也可以解决可见性。他修饰的变量的read,load,use和assign,store,write存在原子性,是一次性执行的。其效果就是每次的读和写都会从主存中拿,而非自己的工作空间(其实是将自己的工作空间的无效值废掉,然后去主内存拿)。他的底层是通过汇编#lock,会触发MESI。出现lock时,必须要继续顺序执行,直到unlock。且当多个写时,一个写有效,其他写的结果被舍弃。
原子性
原子性的例子就是经典的用10000个线程来进行i++。如果加上volatile,他会用lock汇编指令,触发了MESI协议,将某个线程的状态从S共享读变成M,从而使其他的线程无效,实现通信。但其他线程无效,无效就是其他线程i++的结果被扔了,但没有机会再来一次。
而然还有一个扩展的问题,就是虽然被通知自己的i++结果被Invalid的了,但下一个进程再来读时,并不代表前前个进程的内容已经写入了主存(read->load->use->asign时已经开始通知了,但没有写入),所以当前进行会延迟从内存中取数据。但不能一直等啊,所以会执行i++后面的操作
补充诸葛
Java内存模型应该叫做Java线程模型:Java Memory Model
Java线程内存模型跟cpu缓存模型类型,是基于cpu缓存模型来建立的
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cULjeBR2-1607355643903)(D:\文件_Typora\1607218091608.png)]
volatile
volatile:不能只是简单说他解决了变量副本的相对可见性
JMM处理工作内存和主内存外,还提供了数据原子操作:
- read
- load
- use
- assign
- store
- write
- lock
- unlock
- 解决了共享变量怎样到工作内存?
- 工作内存修改后又是怎样同步回主内存/其他线程?
- 这些原子操作就是线程之间相互交互
没有volatile时:
- 线程一经过read,load,use,assign,store,write就完了,虽然写入了主内存,但其他线程不知道
- 线程之间没法传递变量
解决:
- 早期是总线加锁,在read前加lock,write后unlock
- 之后别的请求才能read
- 相当于串联执行
现代jdk对volatile是借用的MESI协议:
- 修改后就触发MESI协议
- 当store后,数据就会经过总线,此时触发总线嗅探机制
- 其他线程嗅探到后,就会把自己工作内存的值失效,再读的时候,发现失效,所以就回去主内存要值
加入volatile后:
- 就会开启MESI协议和总线嗅探机制
底层
他的实现,需要汇编语言:
-
底层实现主要通过汇编lock前缀指令,他会锁定这块内存区域的缓存(缓存行)锁定,并回写到主内存
-
不加前:initFlag=true;
add dword ptr [rsp],0h//=assign
-
加后:initFlag=true;
lock add dword ptr [rsp],0h//=assign
lock指令:软件开发手册对lock指令的解释:再往底层,就没有了,他已经是汇编了。再就是0101机器码了:
- 会将当前处理器缓存行的数据立即写回到系统内存,同步入主内存
- 这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效(MESI协议和总线嗅探机制)
- 锁住这块内存区域的缓存
- 这个lock相当于把assign的功能升级了。lock汇编指令和lock原子操作不是同一个东西
对比:
- 之前是在read之前lock,导致串行
- 现在也有lock,但是实在store之前lock住主内存
问题:
- 不用lock,有MESI似乎就够了?
- 为啥要把lock从之前的read前,移到现在的store前
解决:
- 若两个线程都要assign,都回写主内存,会有并发问题。若没有这个lock,当store之后到write之前,还有时间,此时store是发生在总线的,已经让其他线程的工作内存失效了,有可能在write成功之前,又马上跑到主内存去要数据了,而这个数据是旧数据
- 和之前是有本质区别的,之前的lock的粒度太大了,粒度横跨了线程的执行过程(read->…->write)。现在加锁只锁了store和write之间,保证这个操作的执行过程,对性能的影响很小。锁必须要加,但是粒度必须小
三大特性
volatile保证可见性和有序性
有序性&指令重排序
有序性
分两种:
- 多条代码的有序性
- 一条代码的有序性
Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得 到保证的有序性,这个通常也称为happens-before 原则。如果两个操作的执行次序无法从 happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。(多条代码的有序)意思是JMM根据happens-before原则,在一些有依赖关系的操作上不会进行指令重排序。
指令重排序
原则是不影响单线程下结果的改变,as-if-serial(串行化语义)
问题:指令重排发生在什么阶段
- 在字节码指令被翻译成机器码阶段,不是在java翻译成字节码
- 发生在CPU执行汇编时,酌情指令重排
例子:
-
private volatile static a=0,b=0; Thread t1=new Thread(new Runnable(){ public void run(){ shortWait(10000); a=1;//是读是写?store,volatile写 //storeload屏障,不允许volatile写与第二步的volatile读发生重排 x=b;//是读是写?先读后写 //先读volatile // //再写普通变量 } }); Thread t2=new Thread(new Runnable(){ public void run(){ b=1; y=a; } });
t1.start();
t2.start();
2. 本来只会出现10 11 01,但是出现了00。说明出现了指令重排序。cpu或者jit
内存屏障分类:
1. storestore
2. storeload
3. loadload
4. loadstore
规则表:(JMM针对编译器重排序指定的规则表)
| 是否能重排 | | 第二个操作 | |
| ---------- | --------- | ---------- | ---------- |
| 第一个操作 | 普通读/写 | volatile读 | volatile写 |
| 普通读/写 | | | NO |
| volatile读 | NO | NO | NO |
| volatile写 | | NO | NO |
为了实现这个表的规则,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。而最优策略是不存在的,所以采用保守策略
内存屏障的工作原理:会禁止屏障前后的指令顺序发生重排序。volatile的前后都加,体现保守原则
1. **普通读->普通写->StoreStore屏障->volatile写->StoreLoad屏障**
2. 在每个volatile写操作的前面插入一个StoreStore屏障
禁止上面的普通写和下面的volatile写重排序。
同时将保障上面所有的普通写在volatile写之前刷新到主内存
3. 在每个volatile写操作的后面插入一个StoreLoad屏障
防止上面的volatile写与下面可能有的volatile读/写重排序
因为编译器无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障(比如写后立即return)
1. **volatile读->LoadLoad屏障->LoadStore屏障->普通读->普通写**
2. 在每个volatile读操作的前面插入一个LoadLoad屏障
禁止下面所有的普通读操作和上面的volatile读重排序
3. 在每个volatile读操作的后面插入一个LoadStore屏障
禁止下面所有的普通写操作和上面的volatile读重排序
问题:不用volatile,如何阻止指令重排?(不用锁,也不用final)
1. 手动加内存屏障:Unsafe(提供了很多越过虚拟机去操作底层的方法)的loadFence(),storeFence(),fullFence()
UnsafeInstance.reflectGetUnsafe().storeFence()
## 扩展
1. cas和volatile关键字的使用会产生什么问题?
2. 总线风暴?什么情况下将导致总线风暴?
大量的cpu会通过bus缓存一致性协议对主内存产生交互。交互过多,且是无效的交互,大量的volatile和cas(compare and swap)会造成大量其他线程有无效工作内存变量。大量其他内存到bus中,而bus的带宽是有限的。
所以volatile不能太多,太多后就该考虑用synchronized,lock,AQS
双重检查锁:
```java
//如果没有volatile,就算两个if也没有
private volatile static Singleton myinstance;
public static Singleton getInstance(){
//if效率比抢锁高,相当于提前终止不必要的抢锁
if(myinstance==null){
synchronized(Singleton.class){
//避免多个线程阻塞在第一个if当中
if(myinstance==null){
myinstance=new Singleton();
}
}
}
return myinstance;
}
- volatile和if()和synchronized和if()
- new=申请内存空间+实例化对象(对象头,实例数据,对其填充位)+给对象分配内存空间
- 这三个指令可能指令重排。加volatile防止其重排
- 如果没有volatile,则会重排:当new发生了重排后,其实还没有new完,instance就已经不为空了,而另一个线程可能此时进入第一个if,误以为对象已经实例化好了,导致return了一个没有实例化好的对象,发生了异常
总结2
在之前的i++例子中,我知道了原来CPU也会出现指令重排,那么现在来说一下什么是指令重排。场景是两个线程错开进行赋值。
CPU执行指令时,有一个时空图,里面分为取指令,执行指令,写入内存。如果而他们之间当用到相同的资源,如同时读内存,则效率变低。
JMM遵循happens-before原则来推导两个操作的执行顺序,如果不满足原则,则可以随意地重排序。
as-if-serial来指令重排序,但他只保证了单线程下的结果不影响,但无法保证多线程。所以,需要volatile。即volatile不仅解决了可见性,还解决指令重排的问题。
带有volatile关键字的变量后的操作被称为volatile读或者volatile写。而JMM为其定义了一个规则表,比如,第一个操作是volatile读,则无论第二个操作数是什么,都不会重排序。而当第二个操作是volatile写,则无论第一个操作数是什么,都不会重排序。他是采用保守策略,即前后都加内存屏障。
volatile的底层是用到了内存屏障。所谓的内存屏障是一个CPU指令,作用是保证有序性和可见性,禁止在内存屏障前后的指令执行重排序
内存屏障分为:StoreStore,StoreLoad,LoadLoad,LoadStore屏障。JMM内存屏障采用保守策略,前后都加
在双重检查锁的单例模式中,就必须要将单例的实例对象用volatile关键字修饰,不然的话可能会发生new关键字的重排序,导致还没有new完时,实例对象就被下一个线程误以为已经new完,而return了空,导致空异常。
其实看似有了volatile和cas如此轻量级,不会阻塞这种涉及上下切换。看似完美,但其实不是。volatile不能有太多,因为他底层是通过BUS总线上的MESI来实现通信的,即他会占用总线,而总线的带宽是有限的,volatile的大量使用会导致总线风暴。
补充
volatile 的底层原理是如何实现的呢?
volatile只能保证基本类型的修改的可见性,不能保证引用类型的修改可见性。如果想要保证内部字段的可见性最好使用CAS的数据结构。
volatile的底层是:如果没有volatile,线程修改volatile变量后,不会立刻写入主内存,而是只改变了工作内存。有了volatile之后,修改volatile时会立刻写入工作内存,而且会把其他的工作工具置为无效。
有volatile时,编译后的汇编指令,多出一个lock的前缀指令。lock指令相当于内存屏障,保证以下三点:
- 将本处理器的缓存写入内存(可见性)
- 重排序时不能讲后面的指令重排序到内存屏障之前的位置
- 如果是写入动作,会导致其他处理器中对应的内存无效。之所以能这样,是因为触发了MESI协议,将其他线程的缓存(工作空间)从S状态变成I状态
没有volatile不是一定不可见:没有volatile修饰时,JVM也会尽量保证可见性,有volatile修饰的时候,一定保证可见性
synchronized&MarkWord
- synchronized:CAS
- ReentrantLock
- AbstractQueuedSynchronizer:JUC
场景:多个线程访问共享,可变资源(被称为临界资源)
有序性
synchronized保证的是代码段的有序性,而无法保证具体代码的有序性,即依然指令重排
synchronized和Lock和AQS,其实现都是串行化执行
为什么要有同步:因为JMM的工作内存,硬件的缓存
volatile是轻量级的synchronized锁
Java中锁的分类:
- 显式锁:就是ReentrantLock,实现JUC里的Lock接口,实现是基于AQS实现,他需要手动加锁和解锁lock(),unlock()。
- 隐式锁:就是synchronized这种加锁机制,即JVM内置锁,特性是不需要手动地去加锁解锁,而是JVM自动加锁解锁
- 显示锁更灵活。因为synchronized不能跨方法区加锁解锁,因为是靠的JVM
synchronized
主要内容:
- synchronized在JVM中的工作机制
- JVM各个版本之间对这个关键字的机制和优化
加锁方式:
- 加在某个object上
- 加载方法前面
- 加载this
- 其中的2和3,在Spring托管的情况下,需要将Bean的作用域设置为单例才能生效
synchronized如何跨方法加锁?:
- unsafe类手动加内存屏障
- 不推荐使用,因为跨过了虚拟机,也就失去了虚拟机的优化
synchronized底层:
- 翻译为monitorEnter和monitorExit指令:对应JMM8大操作中的lock和unlock。保证了同步块的进和出,并发时遇到monitor时会竞争
面试题:谈谈synchronized,希望得到的回答:
- 不能只回答Monitor,怎么进入退出,翻译成字节码后怎么执行。真正想知道的是:JVM内置锁原理,膨胀升级的过程,怎么记录锁
- 基于monitorEnter方式进入同步
- 锁的优化,锁的膨胀升级
- object怎么标记锁
- 锁粗化,锁消除
monitor是什么?
- 也叫管程
- 实现依赖于操作系统底层的Mutex Lock(互斥锁)
- Mutex Lock依赖于互斥量,由操作系统维护,阻塞,性能低
MarkWord
32位和64位差别不大,64位会空一些位
synchronized可重入的原因:
-
JVM给每个object都维护ObjectMonitor对象
-
monitor对象中有成员属性_waitSet,作用是处于wait的线程会被加到waitSet中(竞争失败,失去锁,处于等待)
-
还有_entryList,处于等待加锁blocked状态的线程,会被加到该队列
-
更重要的是有一个_count记录加锁的次数,当count为0是,owner为null
-
ObjectMonitor(){ _header=null;//对象头 _count=0;//记录加锁次数 _waiters=0;//当前有多少处于wait状态的thread _recursions=0; _object=null; _owner=null;//指向持有ObjectMonitor对象的线程 _WaitSet=null;//处于wait状态的线程,会被加入到_WaitSet _WaitSetLock=0; _Responsible=null; _succ=null; _cxq=null; FreeNext=null; _EntryList=null;//处于等待加锁block状态的线程,会被加入到该队列 _SpinFreq=0; OwnerIsThread=0; }
锁在哪里存:对象需要一块区域记录加锁和解锁
- oop中:
- 存储锁状态和锁信息
- 靠对象的内存结构中的对象头MarkWord(剩下的是实例数据和对其填充位->使其必须是8字节的整数倍)
- 对象头存了:hashCode,锁状态标志位,偏向状态,数组长度(若是数组),元数据指针,年龄
HashCode,Epoch,ThreadID,age,偏向状态,锁状态标志,数组长度,MetaData(指向实例对象的Class对象,所以可以getClass()拿到Class对象) - oopClass中:
- 跟oop相同,几乎没区别
对象逃逸
面试题:实例对象内存中存储在哪?
- 若实例对象存储在堆区,则实例的引用存在栈上,实例的元数据class存在方法区或元空间
面试题:实例对象一定存放在堆区吗?
- 不一定,如果实例对象没有线程逃逸行为,则直接存在线程栈上
- JIT(用来翻译class文件)会指令优化,进行逃逸分析,必须不被其他线程引用到,无return对象,则在线程栈上。1.7后默认开启逃逸分析的优化
- 关闭逃逸分析的话,则失去JIT的优化,导致50W个实例对象都在堆区
- 开启逃逸分析的话,堆上只要8W个实例对象。并不是所有对象存放在堆区
- 目的是便于锁消除和锁粗化
逃逸行为:
- 同步省略(锁消除):如果一个对象被发现只能被一个线程访问到,则不考虑对这个对象的操作同步
- 将堆分配转化为栈分配。好处是堆上会垃圾回收,操作复杂,且在堆上需要连续空间
- 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器
JVM对内置锁的优化
锁消除:
-
对于那些不可能被其他线程用到,却加了锁的锁进行忽略。原理是逃逸分析
-
public void test(){ synchronized(new Object()){ //其实并不会加锁,因为逃逸分析,其他线程访问不到,所以加锁没意义,不会加锁 } }
锁粗化:
-
多个锁换成是粒度更大的锁
-
/* StringBuffer是线程安全的。多个append会触发多个锁? 并不会,因为会锁粗化,粗化成一个更大的锁 */ StringBUffer stb=new StringBuffer(); public void test(){ stb.append("1"); stb.append("2"); stb.append("3"); } ///此处的锁加载this上 @Override public synchronized StringBuffer append(String str){ toStringCache=null; supper.append(str); return this; }
锁的升级
锁的分类:
- 线程是否锁住同步资源?
- 锁住:悲观锁
- 不锁住:乐观锁
- 锁住资源失败,是否阻塞?
- 阻塞
- 不阻塞:自旋锁/自适应自旋锁
- 多个线程竞争同步资源的流程细节区别?
- 不锁住资源,多个线程只有一个能改成,其他重试:无锁
- 同一个线程执行同步资源时自动获取资源:偏向锁(1.6后才有)
- 多个线程竞争同步资源时,没有获取到时进行自旋操作:轻量级锁
- 多个线程竞争同步资源时,没有获取到时会阻塞等待唤醒:重量级锁
- 多个线程竞争锁时是否排队?
- 排队:公平锁
- 先插队,失败再排队:非公平锁
- 一个线程的多个流程能不能获取同一把锁?
- 能:可重入锁
- 不能:不可重入锁
- 多个线程能否共享一把锁?
- 能:共享锁(读锁)
- 不能:排他锁(写锁)
- 是否手动加锁解锁?
- 是:显示锁
- 不是:隐式锁
升级过程:
- 无锁->偏向锁->轻量级锁->重量级锁
- 偏向锁是在1.6之后才有,开启后性能提升10%。好处是省去一些CAS操作,互斥量操作
为什么需要锁升级:
- 直接重量级的话,需要JVM的线程空间,从用户空间跑的线程空间
- 消耗大
总结3
- 逃逸分析,对象的内存结构,锁消除和锁粗化,锁的膨胀升级
从解决有序性来入手,synchronized是一个锁,是隐式锁,基于JVM内置锁实现的。他利用了JMM中8大操作中的lock和unlock操作。他的加锁方式有三种:1. 实例方法2. 方法3. 代码块。其更底层是JVM中的Monitor对象,通过进入与退出Monitor来实现同步,Monitor的实现依赖于OS层面的Mutex Lock(互斥锁)。再回到Monitor层次,JVM会对所有的实例对象和Class对象维护一个ObjectMonitor(这也是为什么所有的对象都能当锁),在ObjectMonitor对象中有一个WaitSet这样一个集合来存放wait状态下的线程,即争夺资源失败后的线程,还有一个enterList,用来存放打算抢资源的线程,还有个owner存放锁所归属的线程,还有一个count来存放加锁的次数所以synchronized是一个可重入的锁。这就是锁的实现。
Synchronized加锁加载对象上,而对象怎么来记录锁的状态呢?那就要走到实例对象的存储模型中去看。实例对象的存储模型有对象头(MarkWord),实例数据,对其填充位(保证数据是8字节的整数倍,方便JVM内存管理)。其中对象头存放了锁状态标志位。锁的状态和锁的类型(其实还有hashCode,Class的指针故每个oop都能getClass,垃圾回收中的年龄,数组的长度,ThreadID)。这就是锁的状态存储。而且oop和oopClass的存储结构相同。
synchronized在1.6之前是一个重量级锁,在1.6之后有了锁升级和锁优化。先说锁优化吧,锁的优化是因为在1.6时JVM引入了逃逸分析,能够分析哪些锁是没有必要的,是完全不会被别的线程进行访问的,比如synchronized锁的对象是一个刚刚后面的括号中刚刚new出来的,这就叫锁消除。还有一种锁优化是锁粗化,当有多个锁时会将其变成一个粒度更大的锁。很经典的例子是StringBuffer的append函数的连续使用,StringBuffer是线程安全的,按道理来说,他的append被调用几次,就要被加几次锁,但是JVM会将其优化为一个锁。
对于锁的升级,下次再总结。再说说逃逸分析。逃逸分析出了锁消除外,还能优化对象实例的存储空间的位置,如果一个对象在一个线程中被创建,而不会被其他线程引用,则这个实例开辟的内存空间是在线程栈中,而非堆中(违背常理)。
补充
那不用 volatile,只用 synchronized 修饰方式,能保证可见性吗?
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SmodOCod-1607355643905)(D:\文件_Typora\synchronized.png)]
对象存储
对象头:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-24fVmApI-1607355643906)(D:\文件_Typora\对象头.png)]
Monitor:
在HotSpot虚拟机中,monitor是有C++中ObjectMonitor实现。
synchronized的运行机制就是:当JVM监测到对象在不同的竞争状况时,会自动切换到合适的锁实现,这种切换就是锁的升级,降级。
三种不同的Monitor实现,也就是常说的三种不同的锁:偏向锁,轻量级锁和重量级锁。当一个Monitor被某个线程持有后,它便处于锁定状态
ObjectMonitor() {
_header = NULL;
_count = 0; // 记录个数
_waiters = 0,
_recursions = 0; // 线程重入次数
_object = NULL; // 存储 Monitor 对象
_owner = NULL; // 持有当前线程的 owner
_WaitSet = NULL; // 处于wait状态的线程,会被加入到 _WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; // 单向列表
FreeNext = NULL ;
_EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
特性
原子性:
- 通过monitorenter,计数器+1
- 通过monitorexit,计数器-1
可见性:
- synchronized为啥能解决可见性?
- 线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值
- 线程解锁前,必须将共享变量的最新值刷新到主内存中
- volatile的可见性都是通过内存屏障(Memory Barrier)来实现的
- synchronized的可见性是靠操作系统内核互斥锁MutexLock来实现的,相当于JMM中的lock,unlock。退出代码块时刷新变量到主内存
有序性:
- 双重检查锁:synchronized也有可见性的特点,还需要volatile关键字?
- 因为,synchronized的有序性,不是volatile的防止指令重排序
可重入性:
- 靠锁对象中的计数器
AQS
锁升级细节
无锁->偏向锁->轻量级锁->重量级锁
偏向锁:单独的线程访问
轻量级锁:竞争不激烈,交替执行。时间线上允许不长的重叠,会竞争,但不能长,重叠的部分进行自旋。1.7后自适应:分析上一次自旋,来决定这一次自旋的次数
无锁->轻量级锁
- 线程1访问同步块,判断标志位最后两位是否是偏向(01),再判断倒是第三位(0或1)确定是无锁还是偏向。当是无偏向时,则CAS修改MarkWord,把前23bit位的hashCode替换成线程1的ID。倒数第三位从无锁改成偏向锁。(偏向锁不会自动释放)
- 修改后,进入同步逻辑,进入MonitorEnter。
- 线程2装裱访问同步块,检查偏向线程ID是否是自己。不是,但还是会尝试CAS准备修改MarkWord。其实就是尝试修改线程ID为自己,但很显然修改失败,说明已经被占有过了。这次尝试的意义就在于赌线程1刚好用完了,释放锁。
- 失败后,想向虚拟机发出申请,让其撤销偏向锁。但此时线程1还没执行完,JVM就要去线程1达到安全点(时间片走完,不能强行杀死线程,需要等待)。达到安全点不以为这线程结束
- 线程1暂停后,检查线程1是否退出了同步块。若线程1退出同步块,则将ThreadID置为空,同时将偏向由1改0,即不偏向任何线程。这样做的好处是,此时不用升级。此时线程2再一次CAS即可,相当于抹去了线程1的痕迹。
- 线程1暂停后,检查线程1是否退出了同步块。若线程1没有退出同步块。锁升级为轻量级锁
无锁->重量级锁
- 轻量级锁膨胀失败,升级重量级锁
- 升级前,需要调底层操作系统(Linux)的PThread,要求互斥量(从用户到内核)线程挂起。
- ObjectMarkWord变成重量级锁。只有重量级锁才真正用到了ObjectMonitor
AQS
锁的不同特性(排他,公平,重入),不同的性质源自于AQS框架带来的特性。
Lock,Latch,Barrier等都是基于AQL
AQS定义:
- AQS是大多数同步器的基础行为的抽象:等待队列,条件队列,独占获取,共享获取等等。
- AQS定义了一套多线程访问共享资源的同步器框架,是一个依赖状态(state)的同步器
- state记录了加锁的次数和当前是否会加锁等等。
锁的特性由来:
- 基于独占获取,共享获取。抽象出了排他/共享锁
- 基于等待队列,条件队列。抽象出了公平/非公平锁
- 基于state。抽象出了重入/非重入锁。
AQS具备特性:
- 阻塞等待队列
- 共享/独占
- 公平/非公平
- 可重入
- 允许中断
怎么实现特性?
- 大量使用死循环,用循环暂定线程
拿到锁,为什么要重复拿?:
- 完成多件事后,才能释放锁
具体类实现(ReentrantLock):
- 一般通过定义内部类Sync继承AQS
- 将同步器所有调用都映射到Sync对应的方法
Sync&Node
内部类,继承AQS
AQS属性:
- 完成重入和非重入特性:在AbstractQueuedSynchronizer中定义一个state变量,负责记录锁被加了多少次。
private volatile int state;
-
完成独占和共享的特性:定义模式(独占,共享)(ReentrantLock是悲观锁,凡是Lock,都是悲观,基于独占的方式,除非锁被是否,其他线程才能拿到使用权)所以需要知道锁的拥有者owner。而state并不能记录。所以AQS还继承于AbstractOwnableSynchronizer。
AbstractOwnableSynchronizer定义了一个非常重要的属性——private transient Thread exclusiveOwnerThread;记录独占线程是谁。private transient Thread exclusiveOwnerThread;
-
完成公平和非公平特性:涉及到获取锁失败时,要去怎么处理?AQS中的Q以为着涉及到队列。设计了一种数据结构,CLH队列变种——同步等待队列
CLH队列本质是双向链表+信号量。原本设计的是自旋等待,不失去CPU。而Java中CLH不会让其自旋,而是阻塞,所以称之为变种。其数据结构定义在AQS的内部类Node中static final class Node { //定义了共享还是独占 static final Node SHARED = new Node(); static final Node EXCLUSIVE = null; //节点所处于的状态(信号量) static final int CANCELLED = 1;//结束状态,死节点需移除 static final int SIGNAL = -1;//表明当前节点可被唤醒 static final int CONDITION = -2;//意味着当前节点存在另外的队列(PPT中的条件队列) static final int PROPAGATE = -3;//可传播,用在共享模式中 //上述的常量值记录在waitState,即信号量 volatile int waitStatus; volatile Node prev;//前驱节点指针 volatile Node next;//后继节点指针 volatile Thread thread;//记录当前阻塞线程 Node nextWaiter;//此指针用在条件队列中 final boolean isShared() { return nextWaiter == SHARED; } final Node predecessor() throws NullPointerException { Node p = prev; if (p == null) throw new NullPointerException(); else return p; } Node() { // Used to establish initial head or SHARED marker } Node(Thread thread, Node mode) { // Used by addWaiter this.nextWaiter = mode; this.thread = thread; } Node(Thread thread, int waitStatus) { // Used by Condition this.waitStatus = waitStatus; this.thread = thread; } }
- 面试题:条件队列:如果节点Node存放在条件队列中,则当前锁的模式必须是独占模式,而不能是共享模式。结合BlockingQueue来问。
- 不管是条件队列还是CLH队列,都是基于Node来构造
-
条件队列:是一个单向链表+信号量,用到nextWaiter指针,而不用prev和next指针,一律为null。
-
等待队列和条件队列的节点,用信号量来区分
同步队列
公平/非公平:
- 非公平锁:上来就尝试插队,当前用完就轮到我。但是还是有可能失败,因为还要后面的排队的线程进行竞争
- 公平锁:上来就排队,即使用完了我也要排队。
怎么用?:
- new ReentrantLock(true);true表示创建一个公平锁,false表示非公平
- 创建锁后初始化(未加锁),断点发现sync下的重要属性(忽略了tail和head)
- state=0 exclusiveOwnerThread=null
- 第一次加锁后
- state=1 exclusiveOwnerThread=“Thread杨过”
- 第二次加锁后 state=2
- 第一次释放锁 state=1
- 第二次释放锁
- state=0 exclusiveOwnerThread=null
并发状态修改state似乎会脏数据?即使volatile修饰state也会原子性。如何原子性呢?
-
本质是靠CAS操作
static final class FairSync extends Sync { protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { //第一步:问队列是否有人在排队 //公平锁的原因,如若空闲,先去问队列 if (!hasQueuedPredecessors() && //第二部:CAS比较,更改state状态 //原子操作进行更新cass,他的本质底层依赖于unSafe,unSafecas(cas)底层依赖于cmpchxg汇编指令 compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; } }
多个线程竞争资源时:(杨过在小龙女前面获取锁)
- head节点不再为null,其中的next指针指向小龙女
- tail节点不再为null,其中的next指针指向小龙女,并且小龙女的前驱指针指向head节点
notify的劣势:
- 不能指定唤醒哪个线程
- 而AQS可以指定唤醒头节点的线程
AQS怎么实现阻塞?:
-
本质上通过unSafe魔术类的park()和unPark()去实现阻塞
-
park()和unPark()本质是调用OS底层的库Pthread_mutex_lock,用他进行阻塞
-
放在for循环中,由他进行park和unPark操作。两个操作可以颠倒使用
final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { boolean interrupted = false; for (;;) { final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; // help GC failed = false; return interrupted; } //park操作,阻塞 if (shouldParkAfterFailedAcquire(p, node) && //进入后用UNSAFE.park(false,0L); parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } }
总结4
先讲的是synchronized锁的升级,主要是在概率上进行考虑。大多数情况下,往往是一个线程多次访问同一个同步资源,此时就引出了偏向锁,偏向锁的对象头中会记录上次的访问同步资源的线程ID,这样的好处是用if判断线程id的操作来避免了多次申请锁资源。但如果有另外的线程来抢呢?引出轻量级锁。先考虑特殊情况,就是多个线程交替执行,如果时间线上没有重叠的部分那再好不过了,还是不用加锁,而此时做的事情就是将之前偏向锁记录的ThreadID清空掉,以便于另外的线程来抢。再考虑不这么特殊,就是时间线上有重叠。如果有重叠的话,还是不想加锁,就用while死循环来进行占有CPU式得等待,避免阻塞导致上下文切换开销大。而且有个自适应算法,来根据上一次的等待时间,决定这一次等几次循环。最后实在不行了,才会升级到重量级锁,也就是阻塞,等待,进行上下文切换。
然后是AQS。AQS是DL写的同步框架,大多数的Lock类都继承了这个框架。AQS定义了同步器的基础行为:等待队列,条件队列,独占获取,共享获取。而且为他们提供了特性,比如公平与非公平,独占还是共享,可重入还是不可重入。
为了完成重入和不可重入特性,AQS定义了volatile修饰的state,用来记录被锁了几次。但是volatile修饰没啥用,volatile只是解决了可见性和指令重排,没有解决原子性。其实是用到了CAS操作来对其进行修改的。所谓的CAS操作就是一个算法,参数有三个,分别是内存地址,预期值,目标值。只有的内存地址的变量的值与预期值相同是,才会将其改变成目标值。
为了完成独占和共享特性,AQS继承了一个AbstractOwnableSynchronizer。他里面记录了ownerThread。还有state也是为了实现独占和共享的
为了完成公平和非公平特性,AQS引入了等待队列FIFO,即CLH队列的变种,为啥说是变种呢?因为最初的CLH队列使用的是自旋,而Java中用的是阻塞。而所谓的等待队列其实就是一个双向链表+信号量,ASQ会弄一个内部类Node来作为这个链表的节点,这个Node有前驱和后继指针,而节点的内容则是Thread变量,值得一提的是Node还有next指针,因为Node不仅用于等待队列这个双向链表,还用于条件队列这个单向链表。当是等待队列节点时pre指针和next指针都用到,当是条件队列时pre指针和next指针为null,而用到nextWaiter指针。而且Node里面也是信号量的的,用waitStatus信号量来区分当前节点是处来等待队列还是条件队列。信号量除了区分功能外,还用来标志这个节点是否是结束状态,即死节点。是否是共享状态,即生效于共享状态。
关于公平与非公平,需要明白当是公平锁时,即便当前持有锁的线程结束了,新来的线程也不会抢,而是排队。当是非公平锁时,即便当前持有锁的线程结束了,新来的线程也不一定能获取锁,而是要和后面的线程发生竞争,并非一定是新来的获取锁。AQS源码中可以看到,公平锁时,在if当中直接进入队列,而问都不问state是否是0。非公平锁时,如果当前state为0,还是会在if中问队列中是否有线程在等待,只有没线程在等待,才会进行拿锁。
用DEBUG可以看到,随着多个线程的参数,AQS中的tail,head,state都在发生变化。即等待队列在起作用。
AQS其实就是重写synchronized这个隐式锁,使其更加可控。AQS和synchronized其实很像,比如ObjectMonitor中的_count对应着AQS中的state,都是记录可重入锁的。而AQS是怎么实现synchronized的阻塞的呢?用的是unSafe魔术类的park和unPark来进行阻塞的,而unSate又是调用的OS提供的库Pthread线程库来实现的。至此AQS已经基本实现了synchronized的功能。而他的优势中lock和unlock可以跨方法,更加灵活等等就不说了。结合本次的同步队列我们可以看到,AQS还有一个优势就是,下一次唤醒的线程是可预知的,会唤醒等待队列中排在前面的线程(公平锁的情况下),而synchronized中的notify是随机的。
补充1
你了解公平锁吗,怎么实现的
ReentrantLock
ReentrantLock是可重入且独占式锁,具有和synchronized监视器锁基本相同的行为和语义(monitorenter,monitorexit)。但他更灵活,增加了轮询,超时,中断等高级功能以及可以创建公平锁和非公平锁。
他的共享和互斥是基于对state的操作,他的可重入是因为实现了同步器Sync,在sync的两个实现类中,包括了公平锁和非公平锁。(一般情况下并并不需要公平锁,除非场景需要保证顺序性)
ReentrantLock在finally中关闭。在构造函数中传入boolean值来决定公平或非公平。
公平与非公平的实现区别:
- 主要是在方法tryAcquire中,是否有!hasQueuedPredecessors()判断
- 这个方法就是看当前线程是不是同步队列的首位,此处引出同步队列CLH
CLH
CLH是一种基于单向链表的高性能,公平的自旋锁。
AQS中的队列是CLH变体的虚拟双向队列(FIFO),AQS是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配。
public class CLHLock implements Lock {
private final ThreadLocal<CLHLock.Node> prev;
private final ThreadLocal<CLHLock.Node> node;
private final AtomicReference<CLHLock.Node> tail = new AtomicReference<>(new CLHLock.Node());
private static class Node {
private volatile boolean locked;
}
public CLHLock() {
this.prev = ThreadLocal.withInitial(() -> null);
this.node = ThreadLocal.withInitial(CLHLock.Node::new);
}
@Override
public void lock() {
final Node node = this.node.get();
node.locked = true;
Node pred_node = this.tail.getAndSet(node);
this.prev.set(pred_node);
// 自旋
while (pred_node.locked);
}
@Override
public void unlock() {
final Node node = this.node.get();
node.locked = false;
this.node.set(this.prev.get());
}
}
CLH队列锁的优点是空间复杂度低,在SMP对称多处理器架构效果不过,但是在NUMA下效果不好。
同CLH一样的,还有MCSLock。他是一种基于链表的可扩展,高性能,公平的自旋锁。但和CLH不同,它是真的有下一个节点next,添加这个真是节点后,他就可以只在本地变量上自旋,而CLH是前驱节点的属性上自旋。
因为自旋节点的不同,导致CLH更适合于SMP架构,而MCS可以适合NUMA非一致存储访问架构。可以想象成CLH更需要线程数据在同一块内存上效果才更好,MCS因为是在本地变量自旋,所以无论数据是否分散在不同的CPU模块都没有影响
公平锁的实现依据不同场景和SMP,NUMA的使用,会有不同的优劣效果。在实际的使用中一般默认会选择非公平锁,即使是自旋也是耗费性能的,一般会用在较少等待的线程中,避免自旋时过长
补充2
AQS 你了解吗,ReentrantLock 获取锁的过程是什么样的?什么是 CAS?…
AQS
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lLVrrgNH-1607355643910)(D:\文件_Typora\ReentrantLock.jpg)]
AQS是AbstractQueuedSynchronized的缩写,几乎所有Lock都是基于AQS来实现的,其底层大量使用CAS提供乐观锁服务,在冲突时,采用自旋方式进行重试,以此来实现轻量级和高效的获取锁。
另外,AbstractQueuedSynchronized是一个抽象类,但并没有定义相应的抽象方法,而是提供了可以被子类继承时覆盖的protected方法,这样就可以非常便利的支持继承类的使用。
ReentrantLock简版,包括:
- Sync类继承AQS,并重写方法:tryAcquire,tryRelease,isHeldExclusively
- 这三个方法是必须重写的,如果不重写,在使用的时候就会抛出异常UnsupportedOperationException
- 重写的过程也比较简单,主要是使用AQS提供的CAS方法。以预期值为0,写入更新值1,写入成功则获取锁成功。其实这个过程就是对state使用unsafe本地方法,传递偏移量stateOffset等参数,进行值交换操作。unsafe.compareAndSwapInt(this,stateOffset,expect,update)
- 最后提供lock,unlock两个方法,实际的类中会实现Lock接口中的相应方法,这里为了简化,直接定义了这两个方法
public class SyncLock {
private final Sync sync;
public SyncLock() {
sync = new Sync();
}
public void lock() {
sync.acquire(1);
}
public void unlock() {
sync.release(1);
}
private static class Sync extends AbstractQueuedSynchronizer {
@Override
protected boolean tryAcquire(int arg) {
return compareAndSetState(0, 1);
}
@Override
protected boolean tryRelease(int arg) {
setState(0);
return true;
}
// 该线程是否正在独占资源,只有用到 Condition 才需要去实现
@Override
protected boolean isHeldExclusively() {
return getState() == 1;
}
}
}
测试:
- 结果是Thread顺序输出完成
@Test
public void test_SyncLock() throws InterruptedException {
final SyncLock lock = new SyncLock();
for (int i = 0; i < 10; i++) {
Thread.sleep(200);
new Thread(new TestLock(lock), String.valueOf(i)).start();
}
Thread.sleep(100000);
}
static class TestLock implements Runnable {
private SyncLock lock;
public TestLock(SyncLock lock) throws InterruptedException {
this.lock = lock;
}
@Override
public void run() {
try {
lock.lock();
Thread.sleep(1000);
System.out.println(String.format("Thread %s Completed", Thread.currentThread().getName()));
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
CAS
CAS是compareAndSet的缩写,他的应用场景就是对一个变量进行值变更,在变更时会传入两个参数:一个是预期值,另一个是更新值。如果被更新的变量预期值与传入值一致,则可以变更。
CAS的具体操作使用到了unsafe类,底层用到了本地方法unsafe.compareAndSwapInt比较交换方法。
CAS是一种无锁操作,这种操作是CPU指令集操作,只有一步原子操作,速度非常快。而且CAS避免了请求操作系统来裁定锁问题,直接由CPU搞定,但也不是没有开销,比如CacheMiss
AQS源码分析
lock():
- ReentrantLock实现了公平锁和非公平锁。所以在调用lock.lock();时会有不同的实现类
- 非公平锁,会直接使用CAS进行抢占,修改变量state值。如果成功则直接把自己的线程设置到exclusiveOwnerThread,也就是获取锁成功,调用compareAndSetState()。
- 公平锁,则不会进行抢占,而是规规矩矩排队。调用AQSz中的acquire()。
非公平锁的lock()的compareAndSetState():
- 里面的操作:return unsafe.compareAndSwapInt(this,stateOffset,stateOffset,except,update);
- 其中两个参数变成了四个参数:
- stateOffset=unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeckaredField(“state”));
- 这个stateOffset是偏移量,是一个固定值
公平锁的lock()的acquire()
-
代码块中调用了四个方法:
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt();
-
tryAcquire():分别有继承AQS的公平锁和非公平锁实现
-
addWaiter():是AQS的私有方法,作用是当tryAcquire返回false后,即获取锁失败后,把请求锁的线程添加到队列中,并返回Node节点
-
acquireQueued():负责把addWaiter返回的Node节点添加到队列尾部,并执行获取锁操作以及判断是否把当前线程挂起
-
selfInterrupt:是AQS中的Thread.currentThread().interrupt()方法调用,作用是在执行完acquire之前,自己执行中断操作
公平锁的lock()的acquire()的tryAcquire()
- 作用:开始尝试获取锁
- 当c==0,说明锁没有被占有,尝试使用CAS方式获取锁,并返回true
- 当current==getExclusiveOwnerThread(),说明当前线程持有锁,则需要调用setState,进行锁重入操作。setState不需要加锁,因为是在自己的当前线程下
- 当都不满足,则返回false
公平锁的lock()的acquire()的addWaiter()
- 作用:当tryAcquire返回false后,即获取锁失败后,把请求锁的线程添加到队列中,并返回Node节点
- 当执行到这,说明获取锁失败了,即tryAcquire=false
- 接下来就是把当前线程封装到Node节点中,加入到FIFO队列中。即加到队尾
- compareAndSetTail(pred,node)并发场景不一定成功,如果不成功,则调用enq方法,在其中自旋,用for死循环+CAS入队
公平锁的lock()的acquire()的acquireQueued()
- 作用:走到这里,说明节点已经加入队列完成。该方法再次尝试获取锁,如果获取失败就会判断是否把线程挂起
- shouldParkAfterFailedAcquire判断的依据就是waitStatus定义的四种状态。在这个方法中用到了两种
- CANCELED:取消排队,放弃获取锁
- SIGNAL:标识当前节点的下一节点状态已经被挂起,意思是 大家一起排队上厕所,队伍太长了,后面的谢飞机说,我去买个油条哈,一会到我了,你微信我哈。其实就是当前线程执行完毕后,需要额外执行唤醒后继节点操作。
- 总的来说:
- 如果前一个节点状态是
SIGNAL
,则返回 true。安心睡觉等着被叫醒 - 如果前一个节点状态是
CANCELLED
,就是它放弃了,则继续向前寻找其他节点。 - 最后如果什么都没找到,就给前一个节点设置个闹钟
SIGNAL
,等着被通知。
- 当方法
shouldParkAfterFailedAcquire
返回 false 时,则执行 parkAndCheckInterrupt() 方法。 - 那么,这一段代码就是对线程的挂起操作,
LockSupport.park(this);
。 Thread.interrupted()
检查当前线程的中断标识。
共享Tools(缺)
上次是AQS的独占模式ReentrantLock,这次是共享模式
其中Semaphore可以用在限流,hexrix(两种模式,线程池模式,信号量模式即Semaphore)
内容:
- Tools工具类简单介绍
- Semaphore
- CountDownLatch
- CyclicBarrier
包含5个工具类:Executors,Semaphore,Exchanger,CyclicBarrier,CountDownLatch
- Executors:线程池讲
- Exchanger:线程间互换变量,没有太多应用场景
- Semaphore:重点
- CyclicBarrier:重点
- CountDownLatch:重点
Semaphore
可以用来限流,通过AQS里面的条件队列
和ReentrantLock一样,有个Sync类继承AQS。
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(2);
for (int i=0;i<5;i++){
new Thread(new Task(semaphore,"yangguo+"+i)).start();
}
}
static class Task extends Thread{
Semaphore semaphore;
public Task(Semaphore semaphore,String tname){
this.semaphore = semaphore;
this.setName(tname);
}
public void run() {
try {
semaphore.acquire();//获取公共资源
System.out.println(Thread.currentThread().getName()+":aquire() at time:"+System.currentTimeMillis());
Thread.sleep(1000);
semaphore.release();//释放公共资源
System.out.println(Thread.currentThread().getName()+":aquire() at time:"+System.currentTimeMillis());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
线程访问后端时,需要先去公共资源池里取拿票据。
- 当票据不够时,需要等拿到票据的线程释放票据。从而达到限流的效果
Semaphore的构造函数,默认创建非公平锁:
-
public Semaphore(int permits){ sync=new NonfairSync(permits); }
-
NonfairSync继承extends自Sync,而Sync继承自AQS
-
创建之初就会设置setState,之后的semaphore.acquire()尝试获取state。
-
而acquire()是调用了sync的acquireSharedInterruptibly(1)方法他是AQS提供的,他尝试去获取资源tryAcquiredShared,这个是AQS抽象定义的却没有实现的方法。
-
当state不是1或者0时,就可以多个线程拿到资源,体现了共享。
-
当state是0时,自旋不断,因为有大量失败的行为
-
死循环是因为cas会失败,但不意味着不将节点插入了
CountDownLatch
启动两个线程同时干两件事,等这两件都干完,主线程才走下一步
游戏等所有人准备,才能继续
CyclicBarrier
栅栏
总结5(缺)
补充
基于 AQS 实现的锁都有哪些? 不止ReentrantLock
大部分锁的实现是基于AQS:
- ReentrantLock,可重入锁。最常用的锁,通常会与synchronized做比较使用
- ReentrantLockReadWriteLock,读写锁。读锁是共享锁,写锁是独占锁
- Semaphore,信号量锁。主要用于控制流量,比如:数据库连接池给你分配10个连接,那么让你来一个连一个,连到10个还没有人释放,那你就等等
- CountDownLatch,闭锁。四个人一个漂流艇,坐满了就推下水
Semaphore
使用:
-
Semaphore semaphore = new Semaphore(2, false); // 构造函数入参,permits:信号量、fair:公平锁/非公平锁 for (int i = 0; i < 8; i++) { new Thread(() -> { try { semaphore.acquire(); System.out.println(Thread.currentThread().getName() + "蹲坑"); Thread.sleep(1000L); } catch (InterruptedException ignore) { } finally { semaphore.release(); } }, "蹲坑编号:" + i).start(); }
-
为了避免造成拥挤,一次释放两个进去,一直到都释放。
-
Semaphore 的构造函数可以传递是公平锁还是非公平锁,最终的测试结果也不同,可以自行尝试。
-
测试运行时,会先输出
0坑、1坑
,之后2坑、3坑
…,每次都是这样两个,两个的释放。这就是 Semaphore 信号量锁的作用。
源码分析
构造函数:
- 传入permits,许可证数量。会根据permits设置AQS中state的值
- 传入boolean,选择公平/非公平
CountDownLatch
他和Semaphore共享锁,既有相似又有不同。CountDownLatch 更多体现的组团一波的思想,同样是控制人数,但是需要够一窝。
使用:
-
public static void main(String[] args) throws InterruptedException { CountDownLatch latch = new CountDownLatch(10); ExecutorService exec = Executors.newFixedThreadPool(10); for (int i = 0; i < 10; i++) { exec.execute(() -> { try { int millis = new Random().nextInt(10000); System.out.println("等待游客上船,耗时:" + millis + "(millis)"); Thread.sleep(millis); } catch (Exception ignore) { } finally { latch.countDown(); // 完事一个扣减一个名额 } }); } // 等待游客 latch.await(); System.out.println("船长急躁了,开船!"); // 关闭线程池 exec.shutdown(); }
-
这一个公园游船的场景案例,等待10个乘客上传,他们比较墨迹。
-
上一个扣减一个
latch.countDown()
-
等待游客都上船
latch.await()
-
最后船长开船!!急躁了
源码和Semaphore差不多
Atomic&Unsafe
AQS精华:for+CAS替换synchronized
前面提过的魔术类的应用:内存屏障,对象锁加锁解锁,线程的阻塞Park,CAS
- 什么是原子操作
- CPU原子操作的实现方式
- Atomic
- Unsafe魔术类
原子操作
不可被进一步分割的最小粒子,不可打断,要么全执行,要么全不执行。
原子性分类:
- 不可中断的一个操作(synchronized)
- 不可中断的一系列操作(CAS)
术语解释:
- 缓存行:Cache
- 缓存的最小操作单位
- 比较并交换:CompareAndSwap
- CAS操作需要输入两个数值,旧值对得上后,才会交换成新值
- 原子操作依靠汇编的cmpchxg原子指令来实现
- 这个指令也是依赖于锁住缓存行,基于缓存行去实现
- 当多个CPU想要去CAS同一个地址空间:把变量复制到缓存行中去,然后执行cmpchxg指令,依赖MESI协议,一个M,其他嗅探后是I。然后等待写回主存。
- CPU流水线:CPU pipeline
- CPU流水线的工作方式就像工业生产上的装配流水线,在CPU中由56个不同功能的电路单元组成一条指令处理流水线,然后将一条X86指令分成56步后再由这些电路单元分别执行,这样就能实现在一个CPU时钟周期完成一条指令,因此提高CPU的运算速度
- 内存顺序冲突:MemoryOrderViolation
- 内存顺序冲突一般是由假共享引起的,假共享是指多个CPU同时修改同一个缓存行的不同部分而引起其中一个CPU的操作无效,当出现这个内存顺序冲突时,CPU必须清空流水线
什么能原子性,什么不能原子性:
- 处理器自动保证基本内存操作的原子性,如对同一个缓存行里进行16/32/64为的操作是原子的。
- 复杂的内存操作处理器不能自动保证其原子性,比如跨总线宽度,跨多个缓存行,跨页表的访问。
- 跨多个缓存行,会引起总线锁
- 没跨多个缓存行,没跨页表的访问,引起缓存行锁,MESI搞定
Atomic
在Atomic包里一共有12个类,四种:原子更新方式,原子更新基本类型,原子更新数组,原子更新引用,原子更新字段,Atomic包里的类基本都是使用Unsafe实现的包装类
- 基本类:AtomicInteger,AtomicLong,AtomicBoolean
- 引用类型:AtomicReference,AtomicReference的ABA实例,AtomicStampedRerence,AtomicMarkableReference
- 数组类型:AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray
- 属性原子修改器(Updater):AtomicIntegerFieldUpdater,AtomicLongFieldUpdater,AtomicReferenceFieldUpdater
源码中,Atomic都是依赖unSafe魔术类,他提供了越过虚拟机的接口调用
-
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5); public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5); public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);
-
所有原子类操作,都依托于上述三个native方法,去实现底层的CAS无锁操作
使用:
-
static AtomicInteger atomicInteger=new AtomicInteger(); public static void main(String[] args){ for(int i=0;i<10;i++){ new Thread(new Runnable(){ @Override public void run(){ atomicInteger.incrementAndGet(); } }).start(); } sout(atomicInteger.get()); }
-
BUG:加出来的不是10
-
原因:没跑完,需要join或者主线程sleep等子线程跑完。还可以用CountDownLatch等其全部执行。
ABA
问题演示:
-
static AtomicInteger atomicInteger=new AtomicInteger(1); public static void main(String[] args){ Thread main=new Thread(new Runnable(){ @Override public void run(){ System.out.println("修改前:操作线程"+Thread.currentThread().getName()+"操作数值"+atomicInteger.get()); try{ Thread.sleep(1000); }catch(InterruptedException e){ e.printStackTrace(); } boolean isCasSuccess=atomicInteger.compareAndSet(1,2); System.out.println("修改后:操作线程"+Thread.currentThread().getName()+"操作数值"+atomicInteger.get()); } },"主线程"); Thread other=new Thread(new Runnable(){ atomicInteger.incrementAndGet(); atomicInteger.decrementAndGet(); } },"干扰线程"); }
-
解决:加version版本号
解决ABA
-
public class AtomicStampedRerenceTest { //其中"0"为初始版本号 private static AtomicStampedReference<Integer> atomicStampedRef = new AtomicStampedReference<>(1, 0); public static void main(String[] args){ Thread main = new Thread(() -> { int stamp = atomicStampedRef.getStamp(); //获取当前标识别,版本号 System.out.println("操作线程" + Thread.currentThread()+ "stamp="+stamp + ",初始值 a = " + atomicStampedRef.getReference()); try { Thread.sleep(1000); //等待1秒 ,以便让干扰线程执行 } catch (InterruptedException e) { e.printStackTrace(); } boolean isCASSuccess = atomicStampedRef.compareAndSet(1,2,stamp,stamp +1); //此时expectedReference未发生改变,但是stamp已经被修改了,所以CAS失败 System.out.println("操作线程" + Thread.currentThread() + "stamp="+stamp + ",CAS操作结果: " + isCASSuccess); },"主操作线程"); Thread other = new Thread(() -> { //取版本号 int stamp = atomicStampedRef.getStamp(); //多传俩参数,进行版本号叠加 atomicStampedRef.compareAndSet(1,2,stamp,stamp+1); System.out.println("操作线程" + Thread.currentThread() + "stamp="+atomicStampedRef.getStamp() +",【increment】 ,值 = "+ atomicStampedRef.getReference()); stamp = atomicStampedRef.getStamp(); atomicStampedRef.compareAndSet(2,1,stamp,stamp+1); System.out.println("操作线程" + Thread.currentThread() + "stamp="+atomicStampedRef.getStamp() +",【decrement】 ,值 = "+ atomicStampedRef.getReference()); },"干扰线程"); main.start(); other.start(); } }
数组类型
- aiArray。getAndSet(0,1);
- 将下标为0的元素的值改为1
坑:
- if(aiArray.get(0)!=value[0])
- CAS操作的其实是克隆后的副本,所以不相等
属性原子修改器
修改类的某个属性,是原子性的
-
static AtomicIntegerFieldUpdater aifu = AtomicIntegerFieldUpdater.newUpdater(Student.class,"old"); public static void main(String[] args) { Student stu = new Student("杨过",18); System.out.println(aifu.getAndIncrement(stu)); System.out.println(aifu.get(stu)); }
-
需要改的属性,如age,必须加volatile
-
所有需要CAS的属性,都有用volatile修饰:
- 为了保证可见性
Unsafe
Atomic的cas原子操作都是依赖于UnSafe类的比较与交换的方法,JNI方法,由底层C++实现。
Unsafe实例的获取方法:只有两种,单例的
-
需要启动类加载器加载这个UnSafe类,需要参数配置到启动类加载器中。之后才能getUnsafe
java -Xbootclasspath/a:${patj} //其中path为调用UnSafe相关类的包
-
反射
偏移量:
- 和对象内存结构相关
- 对象有一个内存大小,有一个起始位置和结束位置
- 偏移量就是计算age属性从起始位置开始,离他多远。这叫做偏移量
01:22 一小时后听不懂了…
用Unsafe取代Synchronized,进行跨方法:
-
public class ObjectMonitorTest { static Object object = new Object(); /* public void method1(){ unsafe.monitorEnter(object); } public void method2(){ unsafe.monitorExit(object); }*/ public static void main(String[] args) { /*synchronized (object){ }*/ Unsafe unsafe = UnsafeInstance.reflectGetUnsafe(); unsafe.monitorEnter(object); //业务逻辑写在此处之间 unsafe.monitorExit(object); } }
用Unsafe防止指令重排,做内存屏障:
-
public class FenceTest { public static void main(String[] args) { UnsafeInstance.reflectGetUnsafe().loadFence();//读屏障 UnsafeInstance.reflectGetUnsafe().storeFence();//写屏障 UnsafeInstance.reflectGetUnsafe().fullFence();//读写屏障 } }
用Unsafe进行线程操作:
-
public class ThreadParkerTest { public static void main(String[] args) { /*Thread t = new Thread(new Runnable() { @Override public void run() { System.out.println("thread - is running----"); LockSupport.park();//阻塞当前线程 System.out.println("thread is over-----"); } }); t.start(); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } LockSupport.unpark(t);//唤醒指定的线程*/ //可颠倒,放到上面会被阻塞,就无法执行 //LockSupport.park(); System.out.println("main thread is over"); //相当于先往池子里放了一张票据,唤醒指定的线程 LockSupport.unpark(Thread.currentThread());//底层是调用Pthread_mutex //拿出票据使用 //在自旋被使用过 //可颠倒,放到上面会被阻塞,就无法执行 LockSupport.park(); System.out.println("im running step 1"); } }
Unsafe功能
- 数组相关:
- 返回数组元素内存大小
- 返回数组首元素偏移地址
- 内存屏障
- 禁止load,store重排序
- 系统相关:
- 返回内存页大小
- 返回系统指针大小
- 线程调度:
- 线程挂起,恢复
- 获取,释放锁
- 内存操作
- 分配,拷贝,扩充,释放堆外内存
- 设置,获得给定地址中的值
- 在堆外开辟空间,把数据丢入,做缓冲池时使用,在Netty常用
- 他只存在很短的时间,放堆中会增加GC压力
- CAS
- Class相关:
- 动态创建类(普通类&匿名类)
- 获取field的内存地址偏移量
- 检查,确保类初始化
- 对象操作:
- 获取对象成员属性在内存偏移量
- 非常规对象实例化
- 存储,获取指定偏移量的变量值(包含延迟生效,volatile语义)
- 除了内存屏障,线程调度和CAS,其他别用,很容易玩坏
总结6(缺)
Collection&Queue(缺)
- BlockingQueue阻塞队列
- Java7HashMap死锁与Java8HashMap优化
- ConcurrentHashMap线程安全与分段锁
- 并发List-ArrayList与CopyOnWriteArrayList
之前讲的AQS里面有同步等待队列和条件对象,之前没有涉及到。这次来说条件队列的应用场景
- BlockingQueue
- 原理是基于AQS和里面的条件队列Condition,去实现阻塞
- 大量使用AQS的ReentrantLock独占锁和Condition
- HashMap
- 分析死锁成因和优化
- ConcurrentHashMap
- HashMap+CAS+同步块
tansient:
- 表示当前属性不需要序列化
BlockingQueue
分类:
- ArrayBlockingQueue:由数组支持的有界队列,线程池用到这个队列,基于数组结构,重点,将AQS中的Condition的使用
- LinkedBlockingQueue:由链接节点支持的可选有界队列,也是有界,和1只是数据结构的不同,没太大区别
- PriorityBlockingQueue:由优先级堆支持的无界优先队列,优先级本质上是比较器接口,可以进行确定优先级
- DelayQueue:由优先级支持的,基于时间的调度队列。延时队列,支持优先级,需要实现Delay,Compare。本质上是重写了Collection中的offer方法,用到了ReentrantLock
贯穿:ReentrantLock,Condition(只能在独占模式使用)(AQS里实现)
ArrayBlockingQueue:
- 基于数组,不可扩容。在new时确定容量,超过后,不满足条件,会阻塞等
- 生产者消费者模式可以使用,当消费者看到没资源时(不满足条件),会阻塞,唤醒另外线程
Collection(缺)
并发编程之Collection&Queue
HashMap
HashMap 在1.7会产生死锁。1.8不会,不会死锁
基础数据模型:
- 数组+链表
- 当达到数组的阈值(threshold=16)加载因子0.75。此时扩容,把原数组移动到新的数组,
死锁的原因:
- 在多线程下,hashMap在扩容期间,存在节点位置互换,指针引用的问题,有可能导致
在TL_集合框架.md中
总结7(缺)
Executor
线程池
在web开发中,服务器需要接受并处理请求,所以会为一个请求来分配一个线程来进行处理。
- 是一个线程缓存,线程是稀缺资源,如果被无限制的创建,不仅不会消耗系统资源,还会降低系统的稳定性,因此Java中提供线程池对线程进行统一分配,调优和监控
- Java本身是没有调度CPU,申请时间片的权利的。本质上是映射,操作内核空间提供的操作系统调度库。依赖于操作系统对线程的支持。所以线程的创建和销毁开销大。
- 最开始,用户线程是在用户空间的,当要再起线城时,需要用户线程进入内核空间,涉及到状态转换,CPU权限级别的转换,费时。
- 可能创建和销毁比web中请求执行的时间还长
- 线程在申请完后,在内核空间会开辟线程栈,TSS等等额外消耗。内核空间很宝贵,空间不大
什么时候使用线程池:
- 单个任务处理时间比较短
- 需要处理的任务数量大
好处:
- 重用存在的线程,减少线程创建,销毁的开销,提高性能
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就立即执行
- 当没有空余的线程,就把任务放到队列中去排队
- 提高线程的可管理性,统一分配,调优,监控
Executor
顶级接口,Executor。只有一个方法execute
每一个线程池,有两种提交任务的方式:
- execute()
- submit():在ExecutorService中,他继承自Executor
ExecutorService
定义了大量关于线程池的操作方法
- shutdownNow:马上关闭当前线程池
- isTerminated:判断是否中断
- submit:提交任务,重载提交Callable,Runnable
线程池重要方法:
- execute(Runnable command):履行Runable类型的任务
- submit(task):可用来提交Callable或Runnable任务,并返回代表此任务的Future对象
- shutdown():在完成已提交的任务后封闭办事,不再接管新任务
- shutdownNow():停止所有正在履行的任务并封闭办事
- isTerminated():测试是否所有任务都履行完毕了(本质是判断CTL)
- isShutdown():测试是否该ExecutorServic已被关闭
线程池的使用
-
public static void main(String[] args){ ExecutorService executor=Executors.newFixedThreadPool(5); for(int i=0;i<20;i++){ executor.submit(new RunTask()); } executor.shutdownNow(); }
-
不自己写,而是用Executors工具类,他提供很多静态方法
-
在newFixedThreadPool的内部代码中,还会new一个LinkedBlockingQueue();
-
用来放当线程全部在工作时,多余的任务会放到这个阻塞队列中
-
同样的线程池还有newSingleThreadExecutor()将线程池再包装 ,还有newCacheThreadPool,无限创建
-
还可以定时线程池,ScheduledThreadPoolExecutor
-
他们的本质上都是new ThreadPoolExecutor
线程池重要属性
private final AtomicInteger ctl=new AtomicInteger(cltOf(RUNNING,9));
private static final int COUNT_BIT=Integer.SIZE-3;
private static final int CAPACITY=(1<<COUNT_BITS)-1;
- ctl 是对线程池的运行状态和线程池中有效线程的数量进行控制的一个字段, 它包含两部分的信息: 线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount),这里可以看到,使用了Integer类型来保存,他共有32位,其中高3位保存runState,低29位保存workerCount。COUNT_BITS 就是29,CAPACITY就是1左移29位减1(29个1),这个常量表示workerCount的上限值,大约是5亿。
当使用execute提交任务时:
- 分类:BlockingQueue阻塞队列任务列表,线程池容量大小(两部分:核心线程(core),非核心线程)
- 会先把任务提交给corePool核心线程,马上创建一个对应的线程(前5个)
- 但后15个任务(核心线程不够用)会放到BlockingQueue,在其中排列等待
- 但队列长度也是有限制的(假设是5),剩下的10个任务就取决于参数maximumPoolSize
- 线程池线程的大小maximumPoolSize=核心线程(5)+非核心线程(假设55)
- 所以剩下的10个中的5个任务分派给非核心线程,非核心线程在使用完后会被回收
- 还剩5个实在不行了,execute会执行拒绝策略(RejectedExecutionHandler)
- AbortPolicy(默认)会抛出异常
- CallerRunPolicy
- …
- 总:先放核心线程,再放队列,再放非核心线程,最后执行拒绝策略
线程池的五种状态
线程池存在5种状态
-
RUNNING = 1 << COUNT_BITS; //高3位为111
-
SHUTDOWN = 0 << COUNT_BITS; //高3位为000
-
STOP = 1 << COUNT_BITS; //高3位为001
-
TIDYING = 2 << COUNT_BITS; //高3位为010
-
TERMINATED = 3 << COUNT_BITS; //高3位为011
-
RUNNING:当executor.shutdown()后,线程池就从RUNNING到了SHUTDOWN。此时可以接收新任务(当然不能大于最大线程池数和队列初始长度),以及对新添加的任务进行处理。线程池只要被创建,就会处于RUNNING状态
-
SHUTDOWN:调用shutdown方法后,会改变状态。不再接收新的任务,但是会继续处理已经添加的任务
-
STOP:不接收新任务,不处理已添加任务(队列中的),中断正在处理的任务。当调用shutdownNow()接口时,会转到此状态。
interrupt不等于杀死线程。需要在死循环中进入中断标志位的判断才能够跳出死循环 -
TIDYING:当线程池处于SHUTDOWN,且所有任务已终止,阻塞队列为空,ctl记录的"任务数量"为0,线程池变成TIDYING状态。此时会执行用户可以重装的terminated()钩子函数
-
TERMINATED:线程池彻底终止,当线程处于TIDYING,且执行完terminated()之后
线程池的具体实现
- ThreadPoolExecutor默认线程池
- ScheduledThreadPool
总结8
简单说一下线程池吧,先来说说他的必要性,在web开发中,服务器需要接受并处理请求,每一个请求会起一个线程。而创建线程和销毁线程这个动作,Java本身是没办法做的,因为他没有调度CPU,申请时间片的权利,只有操作系统才有,所以需要Java线程从用户空间去到内核空间,然后在内核空间调度操作系统的线程库,并且线程在申请完后,在内核空间还要开辟线程栈用来记录这一线程,还有TSS等额外消耗。而线程池就是为了节约线程这一宝贵资源,不然的话,很有可能他的创建和销毁比web中请求的执行时间还长。所以,显然,线程池还可以提高请求的响应时间。除此之外,将所有的线程进行统一管理,分配和监控。
对于线程池的实现,他的顶层接口是Executor,他只有一个方法execute方法,用来提交任务,除此之外,还有submit也可以提交任务,他是在ExecuteService中定义的,而ExecutorService还定义了很多其他操作线程池的方法,比如shutdown,isTerminated等等。再来说说他的使用以及他的运转流程。我们在使用是,可以调用Executors这个工具类,用他的静态方法来进行创建线程池,他提供了很多种特性的线程池,除了最基本的newFixedThreadPool之外,还有容量为一的newSingleThreadOll,还有定时线程池ScheduledThreadPoolExecutro。但他们本质是都是newThreadPoolExecutor。来说最基本的newFixedThreadPool吧。他需要传入线程池的线程最大容量,而在方法内部还会自己创建一个LinkedBlockingQueue,他的作用是当所有的核心线程都在工作时,没有多的核心线程来执行新提交的任务时,就会将任务放入这个LinkedBlockingQueue中,但BlockingQueue的大小也是有限的,所以还是会有任务装不下,此时就会把多的任务放到非核心线程中,非核心线程也满了的时候,就会执行拒绝策略,默认的拒绝策略是抛出异常。
说完了任务提交到线程池的流程后,再来说说线程池的状态,他分为5个状态,running,shutdown,stop,tidying(整理),terminated。从running到shutdown需要调用shutdown方法,从running和shutdown到stop需要调用shutdownNow方法,从stop到tidying需要线程全部执行完且阻塞队列为空,从tidying到terminated需要钩子函数terminated执行完。
回顾:
线程通过执行execute或者submit方法,把任务提交,先进入核心线程,当核心线程达到最大容量时,再execute会把任务放到阻塞队列中,阻塞队列有很多种(ArrayBlockingQueue有界,LinkedBlockingQueue,无界队列,延迟队列)。若阻塞队列也放满了,此时execute会再去判断线程池允许创建的最大线程数是多少,如果说最大线程数和核心线程数是一样的,那么就不能再创建线程了,若是最大线程数大于核心线程数,那么就还可以创建新的非核心线程,去处理后面新execute()的任务,而不是处理阻塞队列中的任务。
线程池的五种状态,running,shutdown,stop,tidying,terminated
ThreadPoolExecutor
execute()
submit:
-
submit允许返回值
public <T> Future<T> submit(Callable<T> task) { if (task == null) throw new NullPointerException(); RunnableFuture<T> ftask = newTaskFor(task); execute(ftask); return ftask; }
-
允许提交Callable
-
submit的本质是execute()
execute:
-
public void execute(Runnable command) { //ctl记录当前线程池的工作线程数,以及当前线程池的运行状态 //高3位记录运行状态,低29位记录工作线程数 int c = ctl.get(); //workCountOf得出当前正在工作线程数 if (workerCountOf(c) < corePoolSize) { //小于就创建一个Worker对应线程,Worker中有个Thread属性 //true代表是否创建核心线程 //command是firsttask if (addWorker(command, true)) return; c = ctl.get(); } //判断当前线程池的状态,不Running,就不让入队列 if (isRunning(c) && workQueue.offer(command)) { //重复检查,因为线程池可能进入if后被中断 int recheck = ctl.get(); if (! isRunning(recheck) && remove(command)) //不处于Running,就移除这个任务,反正还没执行 reject(command); //线程池一个线程都没有 else if (workerCountOf(recheck) == 0) addWorker(null, false); } else if (!addWorker(command, false)) reject(command); }
2. ctl记录当前线程池的工作线程数,以及当前线程池的运行状态
3. 高3位记录运行状态,低29位记录工作现场数
4. workCountOf得出当前正在工作线程数
5. 小于就创建一个Worker对应线程
6. Worker中有个Thread属性记录工作线程,还有一个关键属性时firstTask记录传进来的执行的第一个任务
7. Work一定有Thread,但不一定有firsttask
8. 由第一个任务firsttask触发线程创建的,创建完又是由他触发线程启动
9. 反复复用这个线程
10.