可见性,原子性和有序性
CPU,内存,I/0
三者在速度上存在很大差异,大概是CPU耗时一天 内存耗时一年,内存耗时一天 /O耗时十年
- CPU 增加了缓存,以均衡与内存的速度差异;
- 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 //O 设备的速度差异;
- 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
可见性问题
- 一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
- 在单核时代,所有操作都是在一个CPU上。所有线程都是操作同一个CPU 的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。 但是在多核时代,每个CPU都会有自己的缓存,就会导致可见性问题。
比较常见的,定义一个变量初始值为0, 然后一个方法里循环执行10000次 += 操作,同时开启两个线程去执行这个方法。最终变量的值等于20000。这就是可见性问题,因为每个线程计算的变量值都是基于自己CPU缓存中的值。
原子性问题
- 支持多进程分时复用在操作系统的发展史上却具有里程碑式的意义。 Unix就是因为解决了这个问题而名噪天下的
- 早期的操作系统是基于进程来调度CPU,不同进程之间是不共享内存空间的,所以在进程切换的时候需要同时切换内存映射地址。而一个进程创建的所有线程,都共享同一个内存空间。所以用线程做任务切换的成本就很低了。 现代操作系统都是基于线程做任务切换。
- 许多并发编程的BUG就是由于线程切换导致,因为对于高级语言来说,一条语句需要多条CPU指定来完成。比如count +=1,至少需要二条CPU指令。
1.首先把变量count从内存加载到CPU寄存器
2.在寄存器中执行 +1 操作
3.最后把结果写入内存(缓存机制导致可能写入的是CPU缓存,而不是内存。)
我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性
有序性问题
有序性顾名思义就是有先后顺序。 但是高级语言编译器,在编译的过程中为了优化性能,可能会改变程序中语句的先后顺序。“a=6:b=7:”编译器优化后可能变成“b=7:a=6:”但是这个例子里 不会对程序造成影响。
看起来上述代码很完美。但是是有问题的。
问题出现在 new 操作上
我们认为的 new操作
1.分配一块内存 M;2.在内存 M 上初始化 Singleton 对象;3.然后 M 的地址赋值给 instance 变量.
优化后的new操作
1.分配一块内存 M;2.将 M 的地址赋值给 instance 变量;3.最后在内存 M 上初始化 Singleton 对象。
小结
其实对于可见性,原子性,有序性这三个问题,最初的目的都是为了提高性能,但是技术在解决一个问题的时候一定会带来另一个问题,所以在选
择的时候,一定要知道会带来什么问题,以及如何规避。
Java如解决可见性和有序性问题
问题
1.导致可见性问题是由于 CPU缓存 2.导致有序性问题是由于编译优化
解决
最直接的办法就是禁用CPU缓存和编译优化,但是这样会影响到我们程序的效率,那么就需要根据需求来禁用。
Java内存模型
- Java内存模型不是一个真的“模型”,而是一个很复杂的规范。 通俗一点说就是 Java内存模型JVM 如何提供按需禁用缓存和编译优化的方法。
- 这些方法包括了 volatile,synchronized 和 final 三个关键字,以及多项 Happens-Before 规则
- volatile
这个关键字的作用主要是 告诉编译器,这个变量的读写不能使用CPU缓存,必须从内存中读取或者写入
举例子
原因 - 在jdk 1.5以后有了-项 happen before规则
网上翻译叫 先行发生。 准确的含义是 前面一个操作的结果对后续操作是可见的。
比较正式的说法是:Happens-Before约束了编译器的优化行为,虽然云溪编译器优化,但是要求编译器优化后也一定道守 Happens-Before 规则 - Happens-Before 规则
volatile变量规则:就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见,
程序次序规则:在一个线程内一段代码的执行结果是有序的。就是还会指令重排,但是随便它怎么排,结果是按照我们代码的顺序生成的不会变。
传递性规则:这个简单的,就是happens-before原则具有传递性,即hb(A,B),hb(B,C),那么hb(A,C)。
管程锁的规则:就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果!(管程是一种通用的同步原语,synchronized就是管程的实现) --【假设x的初始值是 10,线程 A 执行完代码块后x的值会变成12(执也就是线程 B 能够看到 x==12.】
线程启动规则:在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。
线程终止规则:在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见。也称线程join(0规则。
线程中断规则:对线程interrupt0方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通过Thread.interrupted0检测到是否发生中断。
对象终结规则:这个也简单的,就是一个对象的初始化的完成,也就是构造函数执行的结束一定happens-before它的finalize0方法。
如何解决原子性问题
原子性问题的源头是线程切换。线程切换是依赖CPU中断的,所以禁止CPU发生中断就能禁止线程切换。 当然这种方式在单核时代是可行的,但是多核时代却并不适合。
举例子: 32位CPU上写Long型变量,这个操作会被拆分成两次写操作,(写高32位和 低32位)
单核CPU场景下,同一时刻只有一个线程执行,禁止CPU中断就意味着操作系统不会重新调度线程,所以这两次操作就是都被执行或者都没有被执行,具有原子性。
多核CPU场景下,同一时刻可能有两个线程在同时执行,一个CPU-1,一个咋CPU-2上,所以并不能保证同一时刻只有一个线程执行,如果同时写高32位 就会出现诡异bug了。
同一时刻只有同一个线程执行,这个条件很重要,我们一般称为 互斥。如果不管是单核还是多核CPU我们能够保证共享变量的修改是互斥的,那么就能保证原子性了。
锁
临界区
我们把一段需要互斥执行的代码称为临界区。进入临界区之前枷锁,如果成功就进入临界区,此时线程持有锁。否则就等待,直到持有锁的线程解锁。持有锁的线程执行完临界区代码后,执行解锁操作。
锁模型
在现实生活中,锁和要保护的资源是有联系的,比如你家里的锁保护你家里的东西。我家里的锁保护我家里的东西。在并发编程世界里,锁和资源的也应该有这样的关系。所以我们需要完善一下模型,
首先我们要把临界区要保护的资源标注出来。资源R,同时需要创建一个锁LR。此时LR 用来会保护R,这个关系比较重要,容易出现自己门
他家资产的事情。
synchronized
当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X;
当修饰非静态方法的时候,锁定的是当前实例对象 this。
解决问题
count+=1 问题
addOne0 方法,被关键字修饰后,无论是单核还是多核CPU只有一个线程能够执行addOne0,所以一定能保证原子操作,就不会有可见性问题。
管程中锁的规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。 – 管程,就是我们这里的synchronized(至于为什么叫管程,我们后面介绍),我们知道 synchronized 修饰的临界区是互斥的,也就是说同一时刻只有一个线程执行临界区的代码;而所谓“对一个锁解锁 Happens-Before 后续对这个锁的加锁”,指的是前一个线程的解锁操作对后一个线程的加锁操作可见,综合 Happens-Before 的传递性原则,我们就能得出前一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的。
但可能会忽略了 get0方法。执行 addOne0 方法后,value 的值可见性是没法保证的。管程中锁的规则,是只保证后续对这个锁的加锁的可见性
而 get0) 方法并没有加锁操作,所以可见性没法保证。
解决方法,给get()方法也加锁…
这两个关键字保护的资源都是 this,当前对象
锁和受保护资源的关系
受保护资源和锁之间的关联关系非常重要,他们的关系是怎样的呢?一个合理的关系是:受保护资源和锁之间的关联关系是 N:1的关系。 显示时间中可以用多把锁保护同一个资源,但是在并发领域是不行的。
上面的代码稍作改变
改动后的代码是用两个锁保护一个资源。这个受保护的资源就是静态变量 value,两个锁分别是 this 和 SafeCalc.class。我们可以用下面这幅图来形象描述这个关系。由于临界区 get0 和 addOne0) 是用两个锁保护的,因此这两个临界区没有互斥关系,临界区 addOne0) 对 value 的修改对临界区 get0 也没有可见性保证,这就导致并发问题了。
问题