并发编程Bug的源头:原子性、可见性和有序性问题
原子性:保证一个线程中的一次或多次操作不会被其他线程打断
在64位的操作系统中,对基本类型的读取和赋值语句是能保证原子性的。但i++ i-- 这类操作不能保证原子性
在32位的操作系统中,是不能保证long 、double 这类64位变量的原子性;我们可以使用volatile,它可以保证单个变量赋值操作的原子性。
保证原子性的方式:
可见性:多个线程访问同一个共享变量,一个线程修改了,其他线程能立刻读取到修改后的值
下面这个案例就是有可见性问题,线程1一直跳不出循环
public class Visible {
private static boolean flag = true;
public static void readFlag(){
while (flag){
}
System.out.println("跳出循环...");
}
public static void main(String[] args) throws InterruptedException {
new Thread(() -> readFlag()).start();
Thread.sleep(100);
flag = false;
System.out.println("main thread end...");
}
}
保证可见性的方式:
在while循环中调用Thread.sleep(100)
方法也会跳出循环,原因是sleep()底层加了内存屏障操作。而Thread.sleep(0)
就等于yield()
方法,会触发cpu上下文切换
有序性:程序执行顺序是按照代码编写的顺序来执行的。
我们编写的程序最终都会转换为指令给cpu去执行,操作系统为了提高性能,一般会对我们编写的程序进行指令重排序,已达到高性能。
下面这个案例就是有 有序性问题,可能会出现x和y同时为0的情况
public class Sequence {
private static int a = 0,b = 0;
private static int x = 0,y = 0;
public static void main(String[] args) throws InterruptedException {
while (true){
a = b = x = y = 0;
Thread thread1 = new Thread(() -> {
a = 1;
x = b;
});
thread1.start();
Thread thread2 = new Thread(() -> {
b = 1;
y = a;
});
thread2.start();
thread1.join();
thread2.join();
System.out.println(x + "--" + y);
// 按照上面两个线程的赋值语句,如果没有发生重排序的情况下,那么x和y是不可能同时为0的。
if (x == 0 && y ==0){
break;
}
}
}
}
保证有序性的几种方式:
在并发编程中需要处理两个关键问题:
而多线程之间的通信一般有两种方案:共享内存、消息传递。而Java是使用的共享内存的方式。
java的线程之间通信是由java内存模型(Java Memory Model,简称JMM)控制。它来决定一个线程对共享变量的写入何时对另一个线程可见。
JMM内存模型中有一块主内存空间,各个线程还有自己的工作内存。在主内存读取变量的时候是拷贝一份变量的副本到自己的工作内存,之后的读取都是先从之间的工作内存中找。修改操作会先更新工作内存区中的值,然后在写回主内存中
线程1和线程2如果要通信的话,会经过下面两个步骤:
线程2是无法直接访问线程1的本地工作内存的。JMM就通过通知主内存与每个线程工作内存之间的交互,来为java提供内存可见性保证
主内存中的数据和线程本地工作内存之间数据流转的过程如下图所示:
JMM内存模型在执行上面的几个基本操作时,会满足下面一些规则:
在java底层中,多线程之间的可见性的实现有两种:
内存屏障
synchronized、Thread.sleep(100)、Look、volatile这些都是基于内存屏障实现的可见性
使用的lock; addl $0,0(%%rsp)
指令,x86的处理器用这个lock前缀的指令代替了内存屏障的指令,lock前缀的指令能达到内存屏障指令的功能
cpu上下文切换
时间片用完、Thread.yield()、Thread.sleep(0)
从上面JMM的内容我们就可以知道:
所以一个线程如果使用了synchronized或者是Lock显示锁,这时都会清空当前线程的本地工作内存,进而保证可见性。
为什么线程发生了上下文切换,线程A就能够读取到线程B最新更新到主内存中的值?
CPU是没有线程的概念,它只会按照内存地址去内存中取指令,然后装载到CPU的寄存器中再执行指令。JVM中每个线程都有PC寄存器,PC寄存器来保存当前线程要执行的任务执行到了哪一条指令了。
当某个线程被分配的时间片用完后cpu就会切换另一个线程去执行,这就有一次上下文切换,其中会有数据的保存、数据的恢复相关过程。
在切换之前,会把线程A工作内存中更改过的数据写回主内存中,保存上下文数据,然后清空线程本地工作内存。
切换到线程B执行时,会加载线程B的上下文数据,再根据线程B的PC寄存器中的值接着运行线程B的任务。接下来变量读取也就是读取的主内存中最新的数据到本地工作内存中。
所以,如果发生了线程上下午了切换是能保证可见性的。
我们现在知道了加锁和cpu上下文切换是如何保证可见性的了,那么volatile它又是如何实现可见性的嘞?它的禁止指令重排又是怎么回事?
volatile底层是基于内存屏障来实现的,它会在对volatile修饰的变量操作前后添加内存屏障指令。
内存屏障指令有两个功能:
volatile写的内存语义
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
volatile读的内存语义:
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,也就是清空线程本地工作内存区,线程接下来将从主内存中读取共享变量。
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
JMM内存屏障插入策略:
在每个volatile写操作的前面插入一个StoreStore屏障
在每个volatile写操作的后面插入一个StoreLoad屏障
在每个volatile读操作的后面插入一个LoadLoad屏障
在每个volatile读操作的后面插入一个LoadStore屏障
上述内存屏障的插入策略非常保守,但它可以保证在任意处理器平台,任意程序中都能得到正确的volatile内存语义。
由于不同的处理器有不同的松紧度的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以x86处理器为例,x86不会对读-读、读-写、写-写操作做重排序,因此在x86处理器中会省略这3类操作对应的内存屏障,仅会对写-读StoraLoad操作做重排序。
所以X86处理器会在volatile修饰的变量写指令后面插入一个StoreLoad屏障,实际上插入的是lock; addl $0,0(%%rsp)
指令
[JSR-133规范](file:///D:/downfile/goodle%E4%B8%8B%E8%BD%BD/the%20jsr-133%20cookbook.html)
不同硬件实现内存屏障的方式不同,所以才有JMM内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。而上图中在volatile写读前后生成的内存屏障也就是JMM层面的屏障指令。
拓展:处理器级别内存屏障指令
拿X86处理器来说,有几种主要的内存屏障:
lfence,是一种Load Barrier 读屏障
sfence, 是一种Store Barrier 写屏障
mfence, 是一种全能型的屏障,具备lfence和sfence的能力
Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。
内存屏障有两个能力:
阻止屏障两边的指令重排序
刷新处理器缓存
我们自己在java中显示插入一个内存屏障Unsafe.getUnsafe().storeFence();
,而在底层的实现其实如下所示,
inline void OrderAccess::storeload() { fence(); }
inline void OrderAccess::fence() {
if (os::is_MP()) {
// always use locked addl since mfence is sometimes expensive
#ifdef AMD64
__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
__asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
}
}
x86处理器中利用lock前缀指令实现类似内存屏障的效果。
lock前缀指令的作用
确保后续指令执行的原子性。在Pentium及之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低lock前缀指令的执行开销。
LOCK前缀指令具有类似于内存屏障的功能,禁止该指令与前面和后面的读写指令重排序。
LOCK前缀指令会等待它之前所有的指令完成、并且所有缓冲的写操作写回内存(也就是将store buffer中的内容写入内存)之后才开始执行,并且根据缓存一致性协议,刷新store buffer的操作会导致其他cache中的副本失效
JMM向程序员保证两个操作中,操作A一定是在操作B之前执行。但底层只要是不改变结果,编辑器还是想怎么优化就这么优化。详情如下:
happens-before的定义
JSR-133使用happens-before的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以在不同的线程之内。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证。
JSR-133规范对happens-before关系的定义如下:
如果一个操作happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。 这是JMM对程序员的承诺, 注意,这只是JMM向程序员做出的保证。
两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种排序并不非法,也就是说,JMM允许这种排序。这是JMM对编译器和处理器重排序的约束原则
JMM遵循一个基本原则:只要不改变程序的执行结果,编译器和处理器怎么优化都行。
这么做的目的是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
Java中的volatile关键字可以保证多线程操作共享变量的可见性以及禁止指令重排序,synchronized关键字不仅保证可见性,同时也保证了原子性(互斥性)。在更底层,JMM通过内存屏障来实现内存的可见性以及禁止重排序。
为了程序员的方便理解,提出了happens-before,它更加的简单易懂,从而避免了程序员为了理解内存可见性而去学习复杂的重排序规则以及这些规则的具体实现方法。