并发编程的可见性、原子性及有序性问题
原子性
原子性是指一个操作是不可中断的;
在Java中,对基本数据类型变量的读取和赋值操作是原子性操作;
注意:在32位操作系统中,long及double类型数据他们的读取和赋值操作是非原子的,因为32位的缓存行只有32位,但是long和double都是64位的存储单元。
可见性
当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值;
在多线程环境中,由于JMM规定的主内存与工作内存间同步机制,读写时延会造成可见性问题;另外指令重排也会造成可见性问题;
有序性
多线程环境中可能出现指令重排。但是在一个线程中,所有操作都视为有序行为,但是一个线程中观察另外一个线程,所有的操作都是无序的。
JMM解决方案
原子性
除了JVM自身提供读取、赋值基本数据类型的原子性,可以通过synchronized和Lock实现原子性。
Synchronized以及Lock保证同一时间只有一个线程访问该代码块
可见性
Volatile关键字保证可见性,当该关键字修饰一个变量时,任何线程修改的值会立即修改回主内存,被其他线程看到。
Synchronized以及Lock保证同一时间只有一个线程访问该代码块,所以在代码块执行完毕以后,释放锁之前将值刷新到主内存中
有序性
Synchronized以及Lock保证同一时间只有一个线程访问该代码块,对于该代码块来说,就处于单线程环境,所以所有操作都是有序的。
Volatile保障一部分原子性。
Happens-before原则(JMM具备一些先天的“有序性”)
指令重排:JVM规定,只要程序的最终结果与它顺序化执行的结果相等,那么指令的执行顺序可以与代码顺序不一致以发挥CPU最大计算能力。
As-if-serial语义:程序的最终结果与它顺序化执行的结果必须相等。
具体内容(包含一系列程序不可重排序准则,以满足as-if-serial语义)
- 程序顺序原则:在一个线程内必须保证语义串行执行;
- 锁原则:解锁必须在后续同一个锁加锁之前;
- Volatile规则:volatile变量的写,先发生于读:每次被线程访问时,都会强迫从主内存中读取一遍该变量的值;在修改完变量后,又会强迫将最新的值刷新回主内存。
- 线程启动规则:线程start()先于他的每个动作:如果T1在T2.start()之前修改了变量的值,那么这个值会在T2.start()时可见。
- 传递性:A先于B,B先于C,那么A必先于C
- 线程终止规则:线程的所有操作先于线程的终结。
- 线程中断规则:interrupt()方法先发生于被中断线程的代码检测到中断事件的发生。
- 对象终结规则:对象的构造函数先于finalize()方法
Volatile内存语义
Volatile无法保障原子性
Volatile的可见性
及时可见,对volatile变量的所有写操作都是能立即反应到其他线程中。
禁止指令重排
- 内存屏障
是一个CPU指令,作用:1. 保证特定操作的执行顺序;2. 保证某些变量的内存可见性
在指令间插入一条内存屏障命令,则会告诉编译器及CPU,不管什么指令都不能和这条命令重排序。
强制输出各CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本(MESI协议)
Volatile原理
对于volatile修饰的遍历,在读、写的时候会插入不同类型的内存屏障来禁止处理器重排序。
对于volatile变量:
写操作前插入一个store-store屏障;
【store1;storestore; store2】
写操作后插入一个store-load屏障;
【store1; storeload;load2】
读操作前插入一个load-load屏障;
【load1; loadload; load2】保证load1的读取操作在load2之前执行
读操作后插入一个load-store屏障;
【store1; storeload; load2】
CPU缓存一致性协议(MESI)
计算机中变量都是Memory -> L3 –> L2 -> L1 –> CPU使用。
在多核CPU中,拥有多个L1缓存,MESI保证多个缓存内部数据一致,不让数据混乱。
MESI为缓存行提供4个状态,用2个bit表示
M(Modifie d) – 表明缓存行中数据被修改了,和内存中的不一致
E(Exclusive) – 缓存行数据和内存一致,数据只存在于本Cache中
S(Shared) - 缓存行数据和内存一致,数据只存在于很多Cache中
I(Invalid) – 改缓存行无效
如果CPU0修改了某个数据,需要广播给其他CPU,
缓存中没有这个数据的CPU丢弃这个广播消息;
缓存中有这个数据的CPU监听到这个消息后会将相应的缓存行改为invalid,这样其他CPU在下次读取这个数据时就会判定缓存行失效,转而从内存中读取;
同时设置为S状态,让其他CPU直接到CPU0的缓存行中进行读取
缓存行伪共享
主流CPU的缓存行大小一般为64Bytes,每次操作都是以一个缓存行为单位进行操作。
现在有两个long型变量a(8 bytes), b(8 bytes),如果t1修改a,t2修改b。如果此时a和b在同一个缓存行中,修改a会导致b被强制刷新,影响彼此的性能。
解决方案
添加@Contented或者加在jvm启动参数中。这样会在缓存行中把变量a后面位都补齐。
MESI引入的问题
当需要修改本地缓存中的一条信息,必须将I状态通知到其他拥有改缓存数据的CPU中,并等待确认。这样会阻塞CPU
Store Buffers
处理器把它想要写入主内存中的值写到缓存中,然后去做其他事情,直到所有失效确认都接受到时,数据才会被提交。
第三讲
同步器
设计初衷都是序列化访问临界资源(多线程同时访问一个共享、可变的资源)。即在同一个时刻只有一个线程访问临界资源,同步互斥访问。
两种方式:synchronized和Lock
当多个线程执行同一个方法时,该方法内部的局部变量不是临界资源,因为这些局部变量是在每个线程私有栈中,因此不具有共享性。
Synchronized原理
Synchronized是一种对象锁(任何sychronized都需要作用在一个对象上,与之关联)
可重入
// 作用在类对象
public static synchronized void getUtil(){
}
// 作用在实例对象
public synchronized void getUtil2(){
synchronized (this){ // 作用在括号里面的对象上
System.out.println("abc");
}
}
底层原理
- Monitor
- 保障monitor中数据在同一时刻可会有一个线程在访问。
- 底层依赖操作系统的互斥锁(mutex原语)实现,所以涉及到内核态、用户态状态切换。
- JVM中每一个实例对象都与一个monitor关联。
- 在Java中monitor也是一个对象,该对象拥有数据结构保障在在同一时间序列化被访问,HotSpot中由ObjectMonitor实现。
主要数据结构: count, WaitSet, EntryList, owner
W.S以及E.L是两个队列,用来保存ObjectWaiter对象(每个等待的线程会被封装成此类型)
WaitSet 处于wait状态的线程,会被加到WaitSet中
EntrySet 处于等待锁block状态的线程,会被加到EntrySet中
Owner用来指向当前持有当前monitor的线程。
- Monitor对象存在于Java内存对象的对象头Mark Word中(Mark Word存储对应monitor对象的指针地址)
- 原理
- Synchronized底层基于JVM内置锁(对象的monitor锁)实现
- Synchronized块在被编译后会翻译为monitorenter和monitorexit两条指令,两条指令分别位于调用逻辑的开始与结尾。
在对象的monitor对象中
monitorenter: 线程会尝试获取monitor的所有权
- 如果此时monitor的count = 0,那么该线程进入EntryList, count+1, owner设置为本线程
- 如何当前线程已经拥有该monitor,那么重新进入即可,count+1
- 该monitor所有者为另一个线程,那么本线程进入WaitSet(阻塞状态),直到当前monitor的count = 0,再次重新获取所有权
monitorexit: 必须是该monitor的所有者,其他线程无权执行
- 如果退出后count = 0,那么代表本线程退出monitor,不再是所有者,其他线程可以开始竞争
- 同步方法在编译之后会增加ACC_SYNCHRONIZED标识符
在方法调用时,JVM会检查方法的ACC_SYNCHRONIZED访问表示是否被设置,如果设置了会尝试操作monitor,方法执行完毕会释放monitor。
Java运行时对象模型
volatile原理
volatile可以保证多线程场景下临界资源的可见性以及有序性。
有序性依靠JVM添加内存屏障,当volatile变量发生读写操作前后,JVM会为其添加内存屏障(loadload, storestore, loadstore, storeload),内存屏障是一个CPU指令,通知CPU此段代码不允许发生指令重排。
可见性依赖缓存一致性协议,在volatile变量进行编译为汇编语言的时候,会增加一个lock指令,lock指令依靠MESI控制主内存与工作内存中共享资源的访问读写。比如当CPU修改了变量a的值,会将本CPU中的变量设置为E(独占),通知其他CPU,改变量为I(无效)
sychronized原理
sychronized在编译成汇编语言以后,会在sychronized开始的地方添加monitorenter,结束的地方添加monitorexit(同样同步方法在经过JVM编译后会增加ACC_SYCHRONIZED标识符,后续也会变为相同)
monitorenter/monitorexit依赖对象的monitor对象,JVM中monitor依赖ObjectMonitor实现,其中包含waitSet, EntryList, count,owner等实例对象,当相同线程再次获取到锁后,count增加(可重入)
monitorenter/monitorexit在进行操作时,依赖CPU内置的互斥锁完成。所以synchronized也是一种重型锁