Java并发编程笔记[1]——并发理论基础(上)
并发编程Bug源头
解决CPU、内存、I/O设备三者速度差异问题
为了合理利用CPU的高性能,平衡三者之间的差异,计算机体系结构、操作系统、编译程序都做出了贡献(简记:硬件、操作系统、应用软件三个层面的优化):
- CPU增加缓存,均衡与内存的差异;
- 操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异;
- 编译程序优化指令执行次序,使得缓存能够得到更加合理的应用。
副作用
由此带来的副作用:
-
缓存导致可见性问题
一个线程对共享变量的修改,另一个线程能够立刻看到,称为「可见性」
CPU多核时代 -
线程切换带来原子性问题
早期操作系统基于进程切换CPU,不同进程间不共享内存空间 ==> 任务切换需要切换内存映射地址
现代操作系统基于线程切换CPU,一个进程创建的所有线程共享内存空间 ==> 切换线程很『轻量』
一个或者多个操作在CPU执行的过程汇总不被中断的特性,称为「原子性」 -
编译优化带来有序性问题
「有序性」指程序按照代码的先后顺序执行
编译器为了优化性能,有时会改变程序中语句的先后顺序以上副作用可以简记为:缓存导致的可见性问题、线程切换带来的原子性问题、便以优化带来的有序性问题。
解决可见性&有序性问题:Java内存模型
解决可见性&有序性的基本思路
导致可见性&有序性问题的根本原因是缓存和编译优化,那么直接的办法就是:禁用缓存、禁用编译优化。但如此就没法享受高性能了。这是典型的因噎废食。
合理的方案是:按需禁用缓存及编译优化。『按需』其实就是程序员来控制何时禁用。
Java内存模型
Java内存模型 规范了JVM如何提供按需禁用缓存和编译优化的方法。
具体地说,这些方法包括:
- volatile
- synchronized
- final
- Happens-Before规则
注意:这些方法并不是互斥的关系。
Happens-Before 7个规则
- 程序次序规则:在一个线程内一段代码的执行结果是有序的。指令还会重排,但是随便它怎么排,结果是按照我们代码的顺序生成的不会变。
- 管程锁定规则:无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果。(管程是一种通用的同步原语,synchronized就是管程的实现)
- volatile变量规则:如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见。
- 线程启动规则:在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。
- 线程终止规则:在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见。
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通过Thread.interrupted()检测到是否发生中断。
- 传递规则:happens-before原则具有传递性,即A happens-before B , B happens-before C,那么A happens-before C。
- 对象终结规则:一个对象的初始化的完成,也就是构造函数执行的结束一定 happens-before它的finalize()方法。
上面的理解来自网络文章面试官:谈谈happens-before?,有删改。
互斥锁:解决原子性问题 & 保护临界资源
互斥锁模型
这个模型更像现实世界里面球赛门票的管理,一个座位只允许一个人使用,这个座位就是“受保护资源”,球场的入口就是 Java 类里的方法,而门票就是用来保护资源的“锁”,Java 里的检票工作是由 synchronized 解决的。
synchronized关键字
当修饰静态方法的时候,锁定的是当前类的 Class 对象;当修饰非静态方法的时候,锁定的是当前实例对象 this。
如果一个synchronized修饰的静态方法和一个synchronized修饰的普通方法,都对一个变量(资源)进行了保护(多把锁保护一个资源),这两个临界区并没有互斥的关系,即使加了锁(其实是两把),可能就出现并发问题了。
受保护资源和锁之间合理的关联关系应该是 N:1 的关系,也就是说可以用一把锁来保护多个资源,但是不能用多把锁来保护一个资源。
保护多个资源
保护没有关联关系的多个资源
用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁。
使用细粒度锁可以提高并行度,是性能优化的一个重要手段。
使用细粒度锁是有代价的,这个代价就是可能会导致死锁。
保护有关联关系的多个资源
用同一把锁来保护多个资源,也就是现实世界的“包场”,那在编程领域应该怎么“包场”呢?很简单,只要我们的锁能覆盖所有受保护资源就可以了。
『原子性』本质
『原子性』的本质是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。例如,在 32 位的机器上写 long 型变量有中间状态(只写了 64 位中的 32 位),在银行转账的操作中也有中间状态(账户 A 减少了 100,账户 B 还没来得及发生变化)。所以解决原子性问题,是要保证中间状态对外不可见。
死锁
有个叫 Coffman 的牛人早就总结过了,只有以下这四个条件都发生时才会出现死锁:
- 互斥,共享资源 X 和 Y 只能被一个线程占用;
- 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
- 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
- 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
反过来分析,也就是说只要我们破坏其中一个,就可以成功避免死锁的发生。
其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?
- 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
- 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
- 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
破坏占用且等待条件
Allocator。它有两个重要功能,分别是:同时申请资源 apply() 和同时释放资源 free()。账户 Account 类里面持有一个 Allocator 的单例(必须是单例,只能由一个人来分配资源)。当账户 Account 在执行转账操作的时候,首先向 Allocator 同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;当转账操作执行完,释放锁之后,我们需通知 Allocator 同时释放转出账户和转入账户这两个资源。
class Allocator {
private List<Object> als = new ArrayList<>();
// 一次性申请所有资源
synchronized boolean apply(
Object from, Object to){
if(als.contains(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 {
// actr应该为单例
private Allocator actr;
private int balance;
// 转账
void transfer(Account target, int amt){
// 一次性申请转出账户和转入账户,直到成功
while(!actr.apply(this, target))
;
try{
// 锁定转出账户
synchronized(this){
// 锁定转入账户
synchronized(target){
if (this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
} finally {
actr.free(this, target)
}
}
}
破坏不可抢占条件
核心是要能够主动释放它占有的资源,这一点 synchronized 是做不到的。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。
java.util.concurrent
这个包下面提供的 Lock 是可以轻松解决这个问题的。
破坏循环等待条件
破坏这个条件,需要对资源进行排序,然后按序申请资源。这个实现非常简单,我们假设每个账户都有不同的属性 id,这个 id 可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请。比如下面代码中,①~⑥处的代码对转出账户(this)和转入账户(target)排序,然后按照序号从小到大的顺序锁定账户。这样就不存在“循环”等待了。
class Account {
private int id;
private int balance;
// 转账
void transfer(Account target, int amt){
Account left = this ①
Account right = target; ②
if (this.id > target.id) { ③
left = target; ④
right = this; ⑤
} ⑥
// 锁定序号小的账户
synchronized(left){
// 锁定序号大的账户
synchronized(right){
if (this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
使用「等待-通知」机制优化循环等待
「等待-通知」机制
一个完整的「等待 - 通知」机制:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁。
Java中「等待-通知」实现
在 Java 语言里,等待 - 通知机制可以有多种实现方式,比如 Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法就能轻松实现。
class Allocator {
private List<Object> als;
// 一次性申请所有资源
synchronized void apply(
Object from, Object to){
// 经典写法
while(als.contains(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();
}
}
致谢
系列文章都是来自极客时间专栏《Java并发编程实战》,感谢作者王宝令老师。