并发编程学习--并发理论基础篇
并发理论基础
可见性、原子性、有序性问题。并发编程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耗时)
为什么局部变量是安全的?
每个线程都有自己的调用栈,局部变量保存在线程各自的调用栈里面,不会共享,所以局部变量不会有并发问题。
如何用面向对象思想写好并发程序
- 封装共享变量
- 识别共享变量间的约束条件
- 制定并发访问策略
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。