并发编程实战(一)
并发编程的三个核心问题:
- 分工 : 高效的拆解任务分给线程
- 同步 : 线程之间的协作
- 互斥 : 保证同一时刻只允许一个线程访问共享资源
这个其实不难理解,做个简单的比喻,我们团队做一个项目的时候肯定是先分配任务(分工),然后等到任务完成进行合并对接(同步),在开发过程中,使用版本控制工具访问,一个代码只能被一个人修改,否则会报错,需要meger(互斥).
学习攻略:
- 跳出来,看全景
- 钻进去,看本质
核心: 分工(拆分) - 同步(一个线程执行完成如何通知后续任务的线程开始工作) - 互斥(同一时刻,只允许一个线程访问共享变量)
全景:
本质 : 知其然知其所以然,有理论做基础.技术的本质是背后的理论模型
并发编程为啥好难?
我从我的角度看,一个是并发编程的api不是很了解,第二个就是出现了问题不会解决,如果说还有,那就是是在不知道并发编程是用来干啥的?有什么用?
每一中技术的出现都有他出现的必然性,对于并发来说无疑是提高性能,那单线程为啥就不能提高性能,原因就在于cpu,内存和io设备三者的速度差异太大,举个例子来说: cpu一天,内存一年,io一百年; 而木桶理论告诉我们程序的性能是由短板决定,所以只要合理的平衡三者的速度差异,就可以提高性能.
并发编程问题的源头
- 缓存导致的可见性: 对于单cpu来说,缓存是可见的,也就是说多个线程同时操作,cpu会从内存读取数据,线程更新数据到cpu,cpu写入内存,线程和cpu进行交互,这个操作每个线程之间是可见的.
但是对于多cpu来说,多个线程操作不同的cpu,不同的cpu操作同一个内存,这会导致操作的不可见性,就出现了问题.(说下可见性的概念: 一个线程对共享变量的修改,另一个线程能够立刻看到,这就是可见性) - 线程切换带来的原子性问题: 原子性是一个或多个操作在cpu执行的过程中不被中断的特性. 那为什么会中断呢?原因就在于提高性能,就和现在的计算机一样,是分时间片来进行任务切换,同时听歌和敲代码,看似是同时发生,其实不是,知识任务之间切换的非常快,做到了看似同时进行.
在高级程序中,一个看似简单的操作可能需要多条cpu指令来完成,不如说count += 1;cpu指令至少三个,从内存中拿到count值到寄存器,在寄存器中进行加一操作,将结果写入内存,这个过程中可能会发生任务间的切换,比如说另一个线程在写入内存前有进行了一次++操作,这个时候结果就不是想要的结果了,可能例子不合适,但是这个意思就是这个. 而原子性就是保证高级语言层面保证操作的原子性. - 编译优化的有序性问题: 有序性指的是程序按照代码的先后顺序执行. 看起来没问题,本来就应该这样,其实不然,在jvm的知识中有一个叫重排序,就是编译器为了优化性能,有时会改变程序中语句的先后顺序,大部分情况下编译器调整后的顺序是不会影响程序的最终结果,不过也有特殊情况,如下:
public class singleton { static singleton instance; static singleton getinstance(){ if (instance == null) { synchronized(singleton.class) { if (instance == null) instance = new singleton(); } } return instance; } }
上面是经典的双重检查创建单例对象,在我们的印象中new的操作应该是: 分配内存,在内存上初始化对象,地址赋值. 实际上优化后是: 分配内存,地址赋值,初始化. 优化后的顺序就会出现问题,地址赋值后发生了线程切换,这时候其他线程读取到了对象不为null,但是实际上只有地址,这个时候访问成员变量就会出现空指针异常,这个就是编译优化可能会出现的问题.
也就是说,很多的并发bug是由可见性,原子性,有序性的原理造成的,从这三个方面去考虑,可以理解诊断很大部分一部分bug. 缓存导致可见性问题,线程切换带来的原子性,编译优化带来的有序性,本质都是提高程序性能,但是在带来性能的时候可能也会出现其他问题,所以在运用一项技术的时候一定要清楚它带来的问题是什么,以及如何实现.
java内存模型: 解决可见性和有序性问题
可见性的原因是缓存,有序性的原因是编译优化,那解决的最直接的办法就是禁用缓存和编译优化,但是有缓存和编译优化的目的是提高程序性能,禁用了程序的性能如何保证? 合理的方案是按需禁用缓存和编译优化,java内存模型规范了jvm如何提供按需禁用缓存和编译优化的方法,具体的,这些方法包括volatile,synchronized和final三个关键字,以及六项happens-before规则
volatile的困惑
volatile关键字用来声明变量,告诉编译器这个变量的读写不能使用cpu缓存,必须从内存中读写.
// 以下代码来源于【参考 1】 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,但是在jdk1.5之前,可能的值是0或者42,1.5之后就是42,为什么?原因是变量x可能被cpu缓存而导致可见性问题,也就是x=42可能不被v=true可见,那java的内存模型在1.5版本之后是如何解决的呢? 就是happens-before规则.
happens-before规则
happens-before指的是前一个操作的结果对后续操作是可见的,具体如下.
1. 程序的顺序性规则
这个规则说的是在一个线程中,按照程序顺序,前面的操作happens-before于后续的任意操作. 简单理解就是: 程序前面对于某个变量的修改一定是对后续操作可见的.也就是前面的代码x=42对于v=true是可见的.
2. volatile变量规则
这条规则指的是对一个volatile变量的写操作,happens-before于后续对这个volatile变量的读操作,即volatile变量的写操作对于读操作是可见的.
3. 传递性
这条规则指的是a happens-before c,且b happens-before c,那么a happens-before c,如下图:
这样就很明显了,x=42 happens-before v=true,写v=true happens-before 读v=true,那也就是说x=42 happens before 读v=true,这样下来,其他线程就可以看到x=42这个操作了.
4. 管程中锁的规则
这个规则是指对一个锁的解锁happens-before与后续对这个锁的加锁. 管程是一种通用的同步原语,在java中指的就是synchronized,synchronized是java里对管程的实现.管程中的锁在java中是隐式实现的,也就是进入同步块之前,会自动加锁,而在代码块执行完后自动释放锁,加锁以及解锁都是编译器帮我们实现的.
synchronized (this) { // 此处自动加锁 // x 是共享变量, 初始值 =10 if (this.x < 12) { this.x = 12; } } // 此处自动解锁
5. 线程start()规则
这个是线程启动的,指的是主线程a启动子线程b,子线程b能够看到主线程在启动子线程b前的操作.
thread b = new thread(()->{ // 主线程调用 b.start() 之前 // 所有对共享变量的修改,此处皆可见 // 此例中,var==77 }); // 此处对共享变量 var 修改 var = 77; // 主线程启动子线程 b.start();
6. 线程join()规则
这条规则是关于线程等待的.它是指主席爱能成a通过调用子线程b的join方法,子线程b执行完成之后,主线程可以看到子线程中的操作.这里指的是对共享变量的操作.
thread b = new thread(()->{ // 此处对共享变量 var 修改 var = 66; }); // 例如此处对共享变量修改, // 则这个修改结果对线程 b 可见 // 主线程启动子线程 b.start(); b.join() // 子线程所有对共享变量的修改 // 在主线程调用 b.join() 之后皆可见 // 此例中,var==66
final
final修饰变量是告诉编译器: 这个变量生而不变,可以可劲儿优化.在 1.5 以后 java 内存模型对 final 类型变量的重排进行了约束。现在只要我们提供正确构造函数没有“逸出”,就不会出问题了。下面的例子,在构造函数里将this赋值给全局变量global.obj,这就是逸出(逸出就是对象还没有构造完成,就被发布出去),线程global.obj读取到x有可能读到0.
// 以下代码来源于【参考 1】 final int x; // 错误的构造函数 public finalfieldexample() { x = 3; y = 4; // 此处就是讲 this 逸出, global.obj = this; }
在 java 语言里面,happens-before 的语义本质上是一种可见性,a happens-before b 意味着 a 事件对 b 事件来说是可见的,无论 a 事件和 b 事件是否发生在同一个线程里。例如 a 事件发生在线程 1 上,b 事件发生在线程 2 上,happens-before 规则保证线程 2 上也能看到 a 事件的发生。
互斥锁: 解决原子性问题
前面看了java的内存模型,解决了可见性和编译优化的重排序问题,哪还有一个原子性如何解决?答案就是使用互斥锁实现.
先探究源头,long在32位机器上操作可能出现bug,原因是线程的切换,那只要保证同一时刻只有一个线程执行,就可以了,这就是互斥.
互斥锁模型:
java中如何实现这种互斥锁呢?
java语言提供的锁技术: synchronized
java中的synchronized关键字就是锁的一种实现,synchronized关键字可以用来修饰方法,也可以用来修饰代码块,如下:
class x { // 修饰非静态方法 synchronized void foo() { // 临界区 } // 修饰静态方法 synchronized static void bar() { // 临界区 } // 修饰代码块 object obj = new object(); void baz() { synchronized(obj) { // 临界区 } } }
先说一下那个加锁和释放锁,synchronized并没有显示的进行这一操作,而是编译器会在synchronized修饰的方法或代码块前后自动加锁lock()和解锁unlock(),不需要编程人员手动加锁和释放锁(省的忘记,程序员很忙的).
synchronized锁的规则是什么: 当修饰静态方法的时候,锁定的是当前的类对象. 修饰非静态方法和代码块的时候,锁定的是当前的对象this.如下
class x { // 修饰静态方法 synchronized(x.class) static void bar() { // 临界区 } } class x { // 修饰非静态方法 synchronized(this) void foo() { // 临界区 } }
案例深入理解:
下面的代码可以解决多线程问题吗?
class safecalc { long value = 0l; long get() { return value; } synchronized void addone() { value += 1; } }
答案是并不可以,原因是虽然对addone进行了加锁操作(对一个锁的解锁happens-before于后续对这个锁的加锁),保证了后续addone的操作的共享变量是可以看到前面addone操作后的共享变量的值,但是get方法却没有,多个线程get方法可能获取到的值相同,addone()之后就会乱套,所以并不能解决.那下面的代码可以解决问题吗?
class safecalc { long value = 0l; synchronized long get() { return value; } synchronized void addone() { value += 1; } }
这种是可以解决多线程问题,也就是可以解决多个线程操作同一个对象的并发问题.那如果要解决多个线程操作不同对象的并发问题呢?
锁和受保护资源的关系
受保护资源和锁之间的关联关系是n:1的关系.也就是说一个锁可以保护多个受保护的资源,这个就是现实生活中的包场,但是我觉得这个也要分情况,多个受保护的资源和锁之间一定要有关系,不然锁不起作用就麻烦了,举个例子来说就是自己家门的锁肯定保护自己东西,不能用自己家门的锁去保护别人家的东西.
下面的例子:
class safecalc { static long value = 0l; synchronized long get() { return value; } synchronized static void addone() { value += 1; } }
分析如图:
所以说addone对value的修改对临界区get()没有可见性保证,会导致并发问题.将get方法也改为静态的就可以解决了.
synchronized 是 java 在语言层面提供的互斥原语,其实 java 里面还有很多其他类型的锁,但作为互斥锁,原理都是相通的:锁,一定有一个要锁定的对象,至于这个锁定的对象要保护的资源以及在哪里加锁 / 解锁,就属于设计层面的事情了。
互斥锁: 如何用一把锁保护多个资源
受保护的资源和锁之间合理的关联关系应该是n:1的关系.使用一把锁保护多个资源也是分情况的,在于多个资源之间存不存在关系,这是要分情况讨论的.
保护没有关联关系的多个资源
举个例子来说明,account类有两个成员变量,分别是账户余额balance和账户密码password. 取款和查看余额会访问balance,创建一个final对象ballock来作为balance的锁;更改密码和查看密码会操作password,创建一个final对象pwlock来作为password的锁.不同的资源用不同的锁保护.代码示例如下:
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; } } }
那还有没有其他的解决方案? 可以使用this来进行加锁,但是这种情况性能会很差,因为password和balance使用同一把锁,操作也就串行了,使用两把锁,password和balance的操作是可以并行的,用不同的锁对受保护资源进行精细化关系,能够提升性能.这个叫细粒度锁
保护有关联关系的多个资源
如果多个资源之间有关联关系,那就比较复杂,经典的转账问题.看下面代码可能发生并发问题吗?
class account { private int balance; // 转账 synchronized void transfer( account target, int amt){ if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } }
开起来没问题,其实不然,只对当前对象进行了加锁,那目标对象的访问呢?也就是说当前的对象是无法保护target.balance的.
上面的案例两个人之间的转账或许没有问题,但是涉及三个人呢?
这个时候b的余额可能为100,也可能为300,看哪个执行在后了.那应该如何解决这种有关联的资源呢,找公共的锁就可以,也就是要锁能覆盖所有受保护资源,解决方案其实不少,如下
class account { private object lock; private int balance; private account(); // 创建 account 时传入同一个 lock 对象 public account(object lock) { this.lock = lock; } // 转账 void transfer(account target, int amt){ // 此处检查所有对象共享的锁 synchronized(lock) { if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } } }
这个解决方案缺点在于需要传入共享的lock,还有一种方案
class account { private int balance; // 转账 void transfer(account target, int amt){ synchronized(account.class) { if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } } }
这个是不是很简单.
上图展示了如何使用共享的锁来保护不同对象的临界区.
解决原子性问题,是要保证中间状态对外不可见.