共计 6976 个字符,预计需要花费 18 分钟才能阅读完成。
并发编程学习
[TOC]
并发理论基础
可见性、原子性、有序性问题。并发编程 BUG 源头
可见性
一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
在单核的时代,不会出现问题。
多核时代,就会出现问题了。
线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU- 2 上的缓存。线程 A 对变量 V 的操作对线程 B 不具备可见性了。这个就属于硬件程序猿给软件程序猿挖的坑。
以下代码 calc 得到的结果不会是 20000。验证了多核场景下的可见性问题。
public class Test {
private long count = 0;
private void add10K() {
int idx = 0;
while(idx++ < 10000) {count += 1;}
}
public static long calc() {final Test test = new Test();
// 创建两个线程,执行 add() 操作
Thread th1 = new Thread(()->{test.add10K();
});
Thread th2 = new Thread(()->{test.add10K();
});
// 启动两个线程
th1.start();
th2.start();
// 等待两个线程执行结束
th1.join();
th2.join();
return count;
}
}
原子性
我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性
count+1, 这个简单的指令至少需要三条 CPU 指令。
- 需要将变量 count 从内存加载到 CPU 寄存器中
- 在寄存器中执行 + 1 操作
- 将结果写入内存(缓存的机制导致可能写入的是 CPU 缓存而不是内存)
操作系统在任务切换,是可以发生在任何一条 CPU 指令中间的,而不是在高级语言中的一句语句。
如下图,线程 A 和线程 B 从 CPU 寄存器中读出来的值都是 0,并都是将 1 写入到内存中。所以不会是我们期待的 2。
有序性
编译器为了优化性能,有时候会改变程序中语句的先后顺序。
一个经典的案例,案例的双重检查锁。
public class Singleton {
static Singleton instance;
static Singleton getInstance(){if (instance == null) {synchronized(Singleton.class) {if (instance == null)
instance = new Singleton();}
}
return instance;
}
}
当两个线程同时执行这段代码时,其中一个线程是有可能获取到一个 null 的对象,访问 instance 成员变量就会触发空指针异常了。
问题出现在 new 这个动作上
我们认为的 new 动作应该是
- 分配一块内存 M
- 在内存 M 上初始化 Singleton 对象
- 然后 M 的地址赋值给 instance 变量
实际上经过编译后的优化,顺序变成
- 分配一块 M
- 将 M 的地址赋值给 instance 变量
- 最后在内存 M 上初始化 Singleton 对象。
思考
在 32 位的机器上对 long 型变量进行加减操作存在并发。
确实会的,以为 long 类型变量占 8 位字节,也就是 64 位的,32 位机器需要把变量拆分成两个 32 位操作。官方推荐把 long/double 变量声明为 volatile 或者使用同步锁。
Java 是如何解决可见性和原子性问题的
Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。
具体来说,这些方法包括 volatile、synchronized、final 三个关键字。以及六项 Happens-before 规则。
Happens-before 规则
程序的顺序性规则
程序前面对某个变量的修改一定是对后续操作可见的。
volatile 变量规则
对一个 volatile 变量的写操作,Happens-before 于后续对这个 volatile 变量的读操作。
传递性
如果 A Happens-before B,且 B happens-before C,那么 A Happens-before C。
管程中的锁规则
指对一个锁的解锁 Happens before 于后续对这个锁的加锁。
也就是说一定要有解锁动作,才能对这个锁进行再次加锁。
synchronized 是 java 对管程的实现。
线程 start()规则
它指的是主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。
Thread B = new Thread(()->{// 主线程调用 B.start() 之前
// 所有对共享变量的修改,此处皆可见
// 此例中,var==77
});
// 此处对共享变量 var 修改
var = 77;
// 主线程启动子线程
B.start();
线程 join()原则
它指的是主线程 A 等待子线程 B 完成,当子线程 B 完成后,主线程能够看到子线程的操作。
Thread B = new Thread(()->{
// 此处对共享变量 var 修改
var = 66;
});
// 例如此处对共享变量修改,// 则这个修改结果对线程 B 可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用 B.join() 之后皆可见
// 此例中,var==66
1,2,3 规则举例说明
使用以下例子说明一下顺序性、volatile 变量规则,传递性。
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {if (v == true) {// 这里 x 会是多少呢?}
}
}
- x=42 Happens-before 写变量 v=true, 这属于顺序规则
- 写变量 v =true Happens-before 读变量 v =true, 这个是 volatile 变量规则
- 那么根据传递性规则,x=42 Happens-before 读变量 v =true。
不可忽视的 final
final 修饰变量时,初衷是告诉编译器:这个变量生而不变,可以使劲儿优化。
使用 final 的时候,只要提供正确的构造函数没有“逸出”,就不会出问题。
一个关于逸出的例子,例子中通过 global.obj 读取变量 x 是有可能读取到 0 的。
final int x;
// 错误的构造函数
public FinalFieldExample() {
x = 3;
y = 4;
// 此处就是讲 this 逸出,global.obj = this;
}
解决原子性问题
简易锁模型
改进后的锁机制
synchronized 关键字使用
class X {
// 修饰非静态方法
synchronized void foo() {// 临界区}
// 修饰静态方法
synchronized static void bar() {// 临界区}
// 修饰代码块
Object obj = new Object();void baz() {synchronized(obj) {// 临界区}
}
}
如何用一把锁保护多个资源
保护没有关联关系的多个资源
不同的资源用不同的锁保护,各自管各自的。
用不同的锁对受保护资源进行精细化管理,能够提升系能,这种锁还有个名字,叫细粒度锁。
class Account {
// 锁:保护账户余额
private final Object balLock
= new Object();
// 账户余额
private Integer balance;
// 锁:保护账户密码
private final Object pwLock
= new Object();
// 账户密码
private String password;
// 取款
void withdraw(Integer amt) {synchronized(balLock) {if (this.balance > amt){this.balance -= amt;}
}
}
// 查看余额
Integer getBalance() {synchronized(balLock) {return balance;}
}
// 更改密码
void updatePassword(String pw){synchronized(pwLock) {this.password = pw;}
}
// 查看密码
String getPassword() {synchronized(pwLock) {return password;}
}
}
保护有关联关系的多个资源
如果多个资源是有关联关系的,那这个问题就比较复杂了。
class Account {
private int balance;
// 转账
synchronized void transfer(Account target, int amt){if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
上面的代码是错误的示例。
因为 this 这把锁根本锁不住 target,也就是别人的账户。
会造成什么问题呢?假设这样的一个场景,ABC 三个账户均为 200,一个线程执行 A 转账 100 给 B,一个线程执行 B 转账 100 给 C。最终导致的结果可能是 B 为 300,或者 B 为 100。而我们期望的结果 B 的余额应该是 200 才是正确的。
那么其实解决方案也非常简单,就是把锁的对象 this 改成 Account.class
class Account {
private int balance;
// 转账
void transfer(Account target, int amt){synchronized(Account.class) {if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
死锁
在上面一章中,我们使用了 Account.class 锁住了转账的动作,也就是说每笔转账动作,都是串行的动作了,性能下降严重,如何提升性能呢?
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;
}
}
}
}
}
相比较于 Account.class 这个大锁,我们使用了两个细粒度的锁。
看起来很完美,但是很可能造成 死锁。
死锁的一个比较专业定义是:一组互相竞争资源的线程因互相等待,导致永久阻塞的现象。
怎么解决死锁的问题
要解决死锁,就要了解死锁发生的条件。
Coffman 牛人总结了为四个条件:
1. 互斥,共享资源 X 和 Y 只能被一个线程占用;2. 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;3. 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;4. 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源。
也就是说,我们只要破坏掉其中一个条件,死锁就迎刃而解了。
其中互斥性无法破坏,因为我们使用的就是互斥锁。
破坏其余三个条件:
1. 破坏占有等待,可以一次性申请所有的资源
2. 破快不可抢占,占用部分资源的线程进一步申请其他资源时,如果申请不到,主动释放它占有的资源
3. 破坏循环等待,可以按序申请资源来预防。
破坏占用且等待
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)
}
}
}
破坏不可抢占条件
使用 Lock,synchronzied 无法解决,后续讨论。
破坏循环等待条件
增加 id 属性,作为排序属性,锁定顺序按照从小到大的顺序锁定。
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;
}
}
}
}
}
选择合适的方案
既然解决死锁有三个方法,那么从三个方法中选择出一个好的解决方法也显得至关重要。
比如上面的例子,破坏占用且等待条件成本就比破坏循环等待的成本来得高。
用 等待 – 通知 机制优化循环等待
用 synchronized 实现等待 – 通知
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();}
}
安全性、活跃性、性能问题
安全性
存在共享数据并且该数据会发生变化,通俗地说,就是多个线程会同时读写同一个数据。
如果多个线程同时写同一个数据,这种情况就称之为数据竞争。
活跃性
除了死锁,还有两种情况,分别是 活锁和饥饿。
活锁,线程虽然没有发生阻塞,但仍然执行不下去的情况。好比现实中的礼让问题。
活锁解决,加入尝试等待一个随机时间。
饥饿,线程因无法访问所需资源而无法执行下去。
饥饿解决,一般使用公平锁。
性能问题
从方案层面,解决性能问题:
- 使用无锁算法和数据结构,线程本地存储(Thread Local)、写入时复制(Copy on write)、乐观锁等;java 的原子类;无锁的内存队列 Disruptor
- 减少锁的持有时间,使用细粒度锁、读写锁
性能的度量指标有很多,一般三个非常重要,吞吐量、延迟、并发量。
管程:并发编程的万能钥匙
public class BlockedQueue<T>{
final Lock lock =
new ReentrantLock();
// 条件变量:队列不满
final Condition notFull =
lock.newCondition();
// 条件变量:队列不空
final Condition notEmpty =
lock.newCondition();
// 入队
void enq(T x) {lock.lock();
try {while (队列已满){
// 等待队列不满
notFull.await();}
// 省略入队操作...
// 入队后, 通知可出队
notEmpty.signal();}finally {lock.unlock();
}
}
// 出队
void deq(){lock.lock();
try {while (队列已空){
// 等待队列不空
notEmpty.await();}
// 省略出队操作...
// 出队后,通知可入队
notFull.signal();}finally {lock.unlock();
}
}
}
java 线程
线程的生命周期
Java 语言中线程共有六种状态,分别是:
- NEW
- RUNNABLE
- BLOCKED
- WAITING
- TIMED_WAITING
- TERMINATED
看着比图中多了几个状态,其实 java 中的 BLOCED WAITING TIMED_WAITING 都属于休眠状态,这个状态下的线程没有 CPU 的使用权。
创建多少个线程才是合适的?
最佳线程数 =CPU 核数 * [1 +(I/O 耗时 / CPU 耗时)
为什么局部变量是安全的?
每个线程都有自己的调用栈,局部变量保存在线程各自的调用栈里面,不会共享,所以局部变量不会有并发问题。
如何用面向对象思想写好并发程序
- 封装共享变量
- 识别共享变量间的约束条件
- 制定并发访问策略