如何学习并发编程
抽象为三个部分:分工,同步,互斥
分工:例如生产-消费者模型,Future本质上都是一种分工方法
同步:分工完之后,就是如何去具体的执行,这里就需要处理多线程之间的协同
- 当某个条件不满足时,线程需要等待,当某个条件满足时,线程需要被唤醒执行。
- 管程:管程时解决并发问题的万能钥匙
互斥:强调的是”线程安全“
- 所谓互斥就是同意时刻,只允许一个线程访问共享变量
可见性、原子性和有序性问题:并发编程Bug的源头
并发程序的幕后故事
- 由于CPU,内存,I/O设备的不断更新,三者之间的的速度差异愈发的明显,由于CPU和内存速度过快,I/O太慢。而根据木桶理论,程序整体的性能取决于最慢的操作——读写I/O设备,所以单方面提高CPU性能是无效的。
因此为了合理利用CPU的高性能,平衡三者的速度差异,分别从计算机体系结构,操作系统,编译程序作出如下的改善
- CPU增加缓存,一均衡与内存的速度差异
- 操作系统增加了进程,线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异
- 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用
源头之一:缓存导致的可见性问题
可见性:一个线程对共享变量的修改,另外一个线程可以立即看到
- 在多核时代,每个CPU都有自己的缓存,这时CPU缓存与内存的数据一致性就没有那么容易解决,当多个线程在不同的CPU上执行,这些线程操作的是不同CPU
源头之二:线程切换带来的原子性问题
例如:count+=1,需要三条CPU指令
- 一:将count加载到CPU的寄存器中
- 二:在寄存器中执行+1操作
- 三:将结果写会内存(缓存机制导致可能写入的是CPU缓存而不是内存)
如果A与B线程执行如下操作,会发现count最终为1,而不是2
原子性:一个或多个操作在CPU执行过程中不会被中断的特性
源头之三:编译器优化带来的有序性问题
- 有序性就是程序按照代码的先后执行顺序,而编译器优化,可能会改变程序中语句的先后顺序
例如:单例模式中Double-Checked
1 | class Singlaton{ |
假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现 instance == null ,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。
在上述例子中,看似完美无缺,但是问题就出在new操作上,new 操作执行流程如下:
- 分配内存
- 在内存中初始化Singlaton对象
- 将内存地址赋值给instance变量
经过编译器的优化后,执行顺序如下:
- 分配内存
- 将内存地址赋值给instance变量
- 在内存中初始化Singlaton对象
优化之后,线程A先执行getInstance()方法,当线程在执行第二步时恰好发生了线程切换,切换到线程B,这样B执行getInstance()方法,结果instance!=null,直接返回instance,但是此时的instance并没有初始化,如果访问instance的成员变量就会触发空指针异常
volatile:禁止指令重排,放置编译器优化而导致的问题
:thinking:在 32 位的机器上对 long 型变量进行加减操作存在并发隐患,到底是不是这样呢?
- 原子性问题:long型变量是64位,当在32位机器上进行加减操作时,需要将long型变量分为两个32位操作,这样就可能会导致,一个线程读取了某个值的高32位,而低32位已经被另一个线程所修改。因此官方推荐最好把long\double 变量声明为volatile或是同步加锁synchronize以避免并发问题。
Java内存模型:Java如何解决可见性和有序性问题
解决可见性,有序性最直接的方法就是禁止缓存和编译器优化,但是这样会性能就会降低很多,合理的方案是按需禁用缓存以及编译器优化
- volatile,synchronized和final关键字,以及六项Happens-Before规则
Happens-Before规则
- 前面一个操作的结果对后续操作是可见的
- Happens-Before约束了编译器的优化行为,虽允许便器器优化,但是要求编译器优化后一定遵守Happens-Before规则。
- 程序的顺序性规则:在一个线程中,前面的操作Happens-Before与后续的任意操作
- volatile变量规则:指对一个volatile变量的写操作,Happens-Before于后续对这个volatile变量的读操作。
- 不保证原子性,但保证可见性
- 管程(syc)中锁的规则:对一个锁的解锁Happens-Before于后续对这个锁的加锁
- 在解锁的时候,JVM需要强制刷新缓存,是的当前字段所修改的内存对其他线程可见
- 线程start()规则:Thread对象的start()方法Happens-Before于此线程的每一个动作
- 线程中断规则:对线程interrupt()方法Happens-Before于被中断线程的代码检测到中断事件的发生
- 线程终结规则:线程中的所有操作都Happens-Before线程的终止检测,可以通过Thread.join()方法结束,Thread.isAlive()的返回值手段检测到线程已经终止执行
- 对象终结规则:一个对象的初始化完成Happens-Before于它的finalize()方法的开始
- 传递性规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
- 如果两个操作的执行顺序无法从Happens-Before原则中推导出来,那么编译器就可以对其进行随意的优化
Happens-Before因果关系 现实语义:如果A事件时导致B事件的起因,那么A事件一定先于B事件发生。
在Java中,Happens-Before语义本质上是一种可见性:A Happens-Before B意味着A事件对于B事件来说是可见的,无论A事件和B事件是否发生在同一个线程里。
容易被忽视的fianl
final修饰变量时,表示该变量生而不变,可以随意优化
:thinking:有一个共享变量 abc,在一个线程里设置了 abc 的值 abc=3,思考一下,有哪些办法可以让其他线程能够看到abc==3?
- volatile变量规则:可以用volatile关键字修饰
- 管程锁规则:使用Synchronized关键字对abc的赋值代码块加锁
- 线程终结规则:可以使用join()结束线程运行,后续线程再启动,则可以看到abc==3
学习资源来源–极客时间