一、并发问题的源头
1.1 可见性
可见性是指:一个线程对共享变量修改,另一个线程会立马看到。
缓存导致的可见性问题。
1.2 原子性
原子性:一个或者多个操作在CPU执行过程中不被中断的特性
线程切换带来的原子性问题。
1.3 有序性
编译优化带来的有序性问题
以双重校验的单例模式为例
pulic class Singleton{
static Singleton instance;
static Singleton getInstance(){
if(instance == null){
synchronized(Singleton.class){
if(instance==null){
instance = new Singleton();
}
}
}
}
}
new 操作我们期望的流程是:
- 分开内存M
- 在内存M中初始化Singleton对象
- 然后将Singleton对象的地址M赋值给instance变量
但是实际优化后的路径是
- 分开内存M
- 然后将Singleton对象的地址M赋值给instance变量
- 在内存M中初始化Singleton对象
流程如下图所示:
1.4 小结
主要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发bug都是可以理解、可以诊断的。
缓存、线程、编译优化的目的和我们写并发程序的目的相同,都是提高程序的性能。但是技术在解决一个问题的同时,比如会带来另一个问题,所以我们在采用一项技术的同事,一定要清楚它带来的问题是什么,以及如何规避。
1.5 问题
在32位机器上对long型变量进行加减操作有并发隐患
二、JMM
Java如何解决有序性和可见性问题?TODO 置原子性与何地呢?
2.1 介绍
解决可见性和有序性的直接办法就是禁用缓存和编译优化,但是这样问题虽然解决了,但是性能堪忧。因此合理的方案是 按需禁用缓存和编译优化,那么何时禁止编译优化缓存和编译优化呢?只有程序员知道。因此,为了解决可见性和有序性问题,只需要提供给程序员按需禁用缓存和编译优化的方法即可。
JMM是一个很复杂的规范,可以从不同角度解读,站在程序员角度,本质可以理解为:JMM规范了JVM如何按需提供禁用缓存和编译优化的方法,这些方法包括三个关键字volatile、synchronized和final和6个Happens-before规则。
2.2 volatile
volatile关键字并不是Java语言的特产,古老的C语言也有,最原始的意义是禁用CPU缓存。它表达的是告诉编译器,对这个变量的读写不能使用CPU缓存,必须从内存读取或者写入。从这个语义看上去相当明确,但是在实际使用的时候却会带来困惑。
class VolatileExample{
int x = 0;
volatile boolean v = false;
public void writer(){
x = 42;
v = true;
}
public void reader(){
if(v == true){
//TODO 这里的X会是多少
}
}
}
如果A线程调用writer()方法,将v为true写入内存中,如果B线程调用reader,那么B线程中X的值会是多少? 直觉上来说,应该是42.这个要看JDK版本,如果是1.5之前版本,可能是42,也可能是0。但是如果是1.5之后的版本,x就是42。
分析一下,为什么1.5之前版本可能会出现x=0的情况?变量x可能被CPU缓存而导致可见性问题。在Java1.5版本 对volatile语义进行了增强。通过volatile的hanpens-before。
2.3 Happens-before
Happens-before 并不是说一个操作发生在后续操作的前面,它真正要表达的是前面一个操作的结果对后续操作是可见的。Happens-before规则就是保证线程间操作结果的可见性。
Happens-before约束了编译器的优化行为,虽然允许编译器优化,但是要求编译器优化一定遵循Happens-before规则
Happens-before规则应该是JMM最晦涩的内容,和程序员相关的有如下六项,都是关于可见性的。
1)程序的顺序性原则
在一个线程中,按照程序顺序,前面的操作Happens-Before后续的任意操作
2)volatile原则
volatile变量的写操作Happens-before与后续对这个变量的读操作
3)传递性
如果 A happens-before B , B happens-before C , 则A happens-before C.
4) 管程中的锁规则
对一个锁的解锁happens-before于后续对这个锁的加锁
5)线程start()原则
Thread B = new Thread(()->{
//主线程调用B.start()之前,所有对共享变量的修改,此处
//都是可见的
//此例中,var = 77
});
//此处对共享变量var进行了修改
var = 77;
//主线程启动子线程
B.start();
6 ) 线程join()原则
Thread B = new Thread(()->{
//主线程调用B.start()之前,所有对共享变量的修改,此处
//都是可见的
//此例中,var = 77
var = 100
});
//此处对共享变量var进行了修改
var = 77;
//主线程启动子线程
B.start();
B.join();
//子线程所有对共享变量的修改,在线程调用B.join()之后都是可见
//此例中,var == 66
2.4 final关键字
volatile为的是禁用缓存和编译优化,那有没有办法告诉编译器优化得更好一点呢?那就是final。它修饰变量的初衷就是:这个变量生而不变,可以尽可能的优化
2.5 总结
JMM主要分为两部分,一部分面向写并发程序的应用开发人员,另一部分是面向JVM实现人员的。我们更需要关注和应用开发相关的部分,这部分主要的就是Happens-before规则
2.6 问题
有一个共享变量abc,在一个线程设置了abc=3,有哪些办法可以让其他线程看到"abc == 3"
三、互斥锁(上)
解决原子性问题
原子性:一个或多个操作在CPU执行过程中不被中断的特性。
在32位的机器上,long类型变量的读写,是分为两个步骤的。
3.1 原子性问题该如何解决
原子性问题的源头就是线程切换,如果能够禁止线程切换,就能够解决该问题。操作系统做线程切换依赖的是CPU中断的,所以禁止CPU中断就能够禁止线程切换,在单核CPU时代,这种方案是可行的,但是不合适多核使用场景。我们还是long类型在32位机器上的读写为例:
在单核CPU上,同一时刻只有一个线程执行,如果禁止CPU中断,这两个写操作能够做到都被执行或者都不被执行,具有原子性,但是在多长CPU下,一个线程CPU-1,禁止了中断,但是不能保证其他核CPU-X执行该段代码。
同一时刻只有一个线程执行,我们称之为”互斥“。也就是说,如果我们能保证对共享变量的修改是互斥的,那么无论在但单核CPU还是多核CPU,就都能够保证原子性了。
3.2 锁模型
3.2.1 简易模型
互斥的解决方案,你肯定能够想到–锁。我们把一段需要互斥访问代码成为临界区。在进入临界区之前要进行加锁,访问临界区代码需要持有锁,当执行完临界区代码,则需要释放锁。如果想要执行临界区代码,但是没有锁,只能进行等待。
3.2.2 改进后的模型
改进点在于,我们可以通过针对特定的受保护的资源创建特定的锁。这些很符合现实生活中的场景。
3.3 java中的synchronized
锁是一种通用解决方案,java语言为我们提供了synchronized关键字。
常见的使用方式如下:
class Hello{
public synchronized void world(){
...
}
public static synchronized void doStatic(){
...
}
public void workHard(){
Object o = new Object();
synchronized(o){
...
}
}
}
synchronized既可以声明在方法上,也可以使用在代码块中。Java编辑器将synchronized修饰代码中添加上 加锁和释放锁相关的代码。从而避免开发者出现错误(没有成对出现)
3.4 用synchronized解决count+1相关的问题
class SafetyCal{
long value = 0L;
long getValue(){
return value;
}
void synchronized addValue(){
value += 1;
}
}
通过synchronized关键字,保证了addValue的操作原子性。并且通过锁的Happens-before原则,保证了addValue操作对后续的addValue()操作都是可见的。从而保证了1000次addValue调用,其结果肯定是1000.
但是addValue的结果没有对针对getValue的可见性。如果想实现这种可见性,还得需要Happens-before原则,即:
class SafetyCal{
...
long synchronized getValue(){
return value;
}
...
}
其模型图,如下所示:
3.5 锁与被保护资源的关系
一个合理的关系是:受保护资源和锁时N:1
如果是多把锁保护同一个资源呢?
上图中 两个临界区之间没有互斥关系,因此会存在安全性问题。
3.6 总结
互斥锁是并发中核心关注点,但存在并发话题,大家首先都想到加锁。但是,我们必须要深入分析锁对象和受保护资源的关系,综合考虑受保护资源的访问路径,多方面考量,才能用好互斥锁。
四、互斥锁(下)
解决一个锁保护多个资源的问题
4.1 保护没有关联关系的多个资源
class Acount{
//余额锁
private Object baLock = new Object();
//余额
private Integer balance;
//密码锁
private Object pwLock = new Object();
private String password;
public void withDraw(Integer amt){
synchronized(baLock){
if(this.balance >= amt){
this.balance -= amt;
}
}
}
public Integer getBalance(){
synchronized(baLock){
return this.balance;
}
}
public void updatePwd(String newPwd){
synchronized(pwLock){
this.password = newPwd;
}
}
public String getPwd(){
synchronized(pwLock){
return this.password;
}
}
如上述代码中,我们分别针对密码(password)、余额(balance)使用了不同的锁。这种用不同的锁对资源进行精细化管理能够提升性能,这种锁称为细粒度锁
我们也可以使用一把锁同时保护 密码(password)、余额(balance),这是没有问题的,但是这样做,会使得密码相关操作与余额相关操作存在了互斥性,从而影响效率。
4.2 保护有关联关系的多个资源
那么我们该如何用锁去保护相互关联的资源呢?我们还是以转账操作(账户A转给账户B100元)为例。
class Acount {
private Integer balance;
public void transfer(Acount target,Integer amt){
synchronized(this){
if(this.balance>=amt){
this.balance -=amt;
target.balance += amt; //这里是安全的吗?答案当然是否定的
}
}
}
}
在上述代码中,锁对象是要转账的对象。但是还对象不能保证target对象的准确性。
4.3 锁的正确使用方式
锁的正确使用姿势:它能够覆盖所有受保护的资源就可以了
根据该原则,4.2节中的正确代码应该是把Account.class对象当做锁即可
class Acount {
private Integer balance;
public void transfer(Acount target,Integer amt){
synchronized(Acount.class){
if(this.balance>=amt){
this.balance -=amt;
target.balance += amt; //这里是安全的,但是性能不会很理想,那该如何优化呢
}
}
}
}
4.4 总结
针对如何保护多个资源。首先 要看这些资源是否有关系,如果没有关系,则每个资源一把锁即可;如果有关系,则要一个能够覆盖所有关联资源的锁。然后,还要梳理出有哪些访问路径,并且在访问路径上添加上合适的锁。
关联关系其实就是一种原子性特征。原子性的本质不是不可分割,而是多个资源间有一致性的要求,操作的中间状态对外不可见
五、死锁如何处理
一不小心死锁了,该如何处理?
5.1 模拟现实世界
在现实生活中,转账业务也是并行的,并不是4.3节那样使用Account.class对象作为锁对象,那样效率太差。
我们可以模拟将转账的操作模拟古代没有信息化的转账逻辑。
分成如下三种情况:
a. 转入账本或者转出账本都空闲,则可以直接进行转账操作
b. 只有转入(转出)账本,则需要等待另一个账本
c. 转入和转出账本都没有
class Account{
private int balance;
void transfer(Account target,int amt){
synchronized(this){
synchronized(target){
if(this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
5.2 细粒度的缺陷
细粒度锁可以提高并行度,是性能优化的一个重要手段。但是细粒度是有代价的,这个代价就是有可能导致死锁
死锁:一组相互竞争资源的线程因相互等待,导致‘永久’阻塞的现象
5.3 如何避免死锁
要想避免死锁,我们先来看一下产生死锁的必要条件:
- 互斥
- 占有且等待
- 不可抢占
- 循环等待
如果我们能避免其中的某一个条件,就可以避免死锁。
对于互斥,这个是没有办法破坏。针对占有且等待我们可以通过一次申请所有资源,这样就不会存在等待;针对不可抢占占有部分资源的线程可以进一步申请其他资源,如果申请不到,则需要是否自己已经占有的资源;针对循环等待,我们可以靠按序申请资源,从而破坏循环等待。
class Allocator{
private List<Object> als = new ArrayList<Object>();
synchronized boolean apply(Object from, Object to){
if(als.containes(from)||als.contains(to)){
return false;
} else {
als.add(from);
als.add(to);
}
return true;
}
synchronized void free(Object from,Object to){
als.remove(from);
als.remove(to);
}
}
class Account{
private int balance;
void transfer(Account target,int amt){
//循环监听是是否两个账户可用
while(!actr,apply(this,target));
synchronized(this){
synchronized(target){
if(this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
5.4 总结
当我们在编程世界遇到问题时,应不局限于当下,可以换个思路,向现实世界要答案,利用现实世界模型来构思解决方案,这样往往能够让我们的方案更容易理解,也能够看清问题的本质。
六、等待唤醒机制
用等待唤醒机制优化循环等待?
在上节中,为了获取“两个账户都闲置 的时间点”,采用了死循环 while(!actr,apply(this,target)); 要知道通过死循环是非常消耗性能的
其实在这种场景下,最好的方案应该是:如果线程要求的条件不满足,则线程阻塞自己,进入等待状态;当线程的条件重新满足时,再进行唤醒操作。Java也是存在等待唤醒机制的。
一个完整的等待唤醒机制,首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当线程要求的条件满足时,通知等待线程,重新获取互斥锁
6.1 用Synchronized实现等待-通知机制
在Java语言中,等待通知机制实现方式可以有很多种,比如Java语言内置的synchronized配置wait、notify、notifyAll这三个方法就能轻松实现。
6.2 优化第五节的分配器
class Allocator{
private List<Object> als = new ArrayList<Object>();
synchronized void apply(Object from, Object to){
while(als.containes(from)||als.contains(to)){
try{
wait();
}catch(Exception e){
}
}
als.add(from);
als.add(to);
}
synchronized void free(Object from,Object to){
als.remove(from);
als.remove(to);
notifyAll();
}
}
6.3 尽量使用notifyAll()
**notify()是会随机地通知等待队列中的一个线程,而notifyAll()会通知等待队列中所有的线程。**从感觉上notify应该会好一些,因为即便通知所有的线程,也只有有一个进入临界区。但是所谓的感觉往往都蕴藏着风险,实际上使用notify()也很有风险,它的风险在于可能导致某些线程用于不会被通知到。
假设有ABCD四个资源,线程1申请了AB两个资源,线程2申请了CD两个资源。线程3因无法申请AB而阻塞;线程4因无法申请CD而阻塞。当线程1释放了AB资源,进行notify操作,它有可能唤醒的是线程4,而线程四仍然会因为CD而阻塞。本该执行的线程3无法被唤醒。因此推荐使用notifyAll()
七、安全性、活跃性、及性能问题
并发编程需要注意的问题有很多,主要分为三个方面:安全性、活跃性、性能问题
7.1 安全性
存在共享数据并且该数据会变化,通俗地讲就是有多个线程会同时读写同一数据
数据竞争:当多个线程访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发bug
竞态条件:程序的结果依赖于线程的执行顺序
面对数据竞争和竞态条件,保证线程安全的方法就是通过互斥。
7.2 活跃性
活跃性问题除了死锁,还有活锁和饥饿
有时候线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是是“活锁”,解决活锁问题的就是 随机退让一个时间
线程因无法访问资源儿无法执行下去的情况。因线程优先级较低,永远无法执行到。 解决饥饿问题:1.保证资源充足 2.平均分配 3.避免持有锁的线程执行时间过长
7.3 性能问题
a. 无锁方案
TLS 、Copy on write、乐观锁、JCP中原子类、Disruptor对列
b.减少锁持有的时间
细粒度锁、读写锁
性能相关的指标
* 吞吐量
* 延迟
* 并发量