|
作者:ehtan随着硬件技术的飞速发展,多核处理器已经成为计算设备的标配,这使得开发人员需要掌握并发编程的知识和技巧,以充分发挥多核处理器的潜力。然而并发编程并非易事,它涉及到许多复杂的概念和原理。为了更好地理解并发编程的内在机制,需要深入研究内存模型及其在并发编程中的应用。本文将主要以Java内存模型来探讨并发编程中BUG的源头和处理这些问题的底层实现原理,助你更好地把握并发编程的内在机制。并发编程问题-可见性和有序性 private int a, b; private int x, y; public void test() { Thread t1 = new Thread(() -> { a = 1; x = b; }); Thread t2 = new Thread(() -> { b = 2; y = a; }); // ...start启动线程,join等待线程 assert x == 2; assert y == 1; }首先我们先看一段代码,这里定义了两个共享变量x和y,在两个线程中分别对x和y赋值,当同时开启两个线程并等待线程执行完成,最终结果是否是共享变量x等于2并且y等于1呢?答案是未可知,即共享变量x和y可能存在多种执行结果。可以看到在并发编程中,常常会遇到一些与预期不符的结果,导致程序逻辑的失败。这样的异常问题,会让开发人员感到困惑。但是如果细细探究这些问题的根源,发现是有迹可循的。这个问题的原因主要是两点:一是处理器和内存对共享变量的处理的速度差异。二是编译优化和处理器优化造成代码指令重排序。前者导致可见性问题,后者导致有序性问题。处理器缓存导致的可见性问题如上图所示,由于处理器和内存的速度差距太大。为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作。基于局部性原理,处理器在读取内存数据时,是一块块地读取,每一小块数据也叫缓存行(cacheline)。当处理器操作完数据,也不直接写回内存,而且先写入缓存中,并将当前缓存标记为脏(dirty)。等到当前缓存被替换时,才将数据写回内存。这个过程叫写回策略(write-back)。同时为了提高效率,处理器使用写缓存区(storebuffer)临时保存向内存写入的数据。写缓冲区可以保证指令流水线的持续运行,同时合并写缓冲区中对同一内存地址的多次写,减少内存总线的占用。但是由于缓冲区的数据并非及时写回内存,且写缓冲区仅对自己的处理器可见,其他处理器无法感知当前共享变量已经变更。处理器的读写顺序与内存实际操作的读写顺序可能存在不一致。现在再回来看上面代码,那么可以得到四种结果:1)假设处理器A对变量a赋值,但没及时回写内存。处理器B对变量b赋值,且及时回写内存。处理器A从内存中读到变量b最新值。那么这时结果是:x等于2,y等于0。2)假设处理器A对变量a赋值,且及时回写内存。处理器B从内存中读到变量a最新值。处理器B对变量b赋值,但没及时回写内存。那么这时结果是:x等于0,y等于1。3)假设处理器A和B,都没及时回写变量a和b值到内存。那么这时结果是:x等于0,y等于0。4)假设处理器A和B,都及时回写变量a和b值到内存,且从内存中读到变量a和b的最新值。那么这时结果是:x等于2,y等于1。从上面可发现除了第四种情况,其他三种情况都存在对共享变量的操作不可见。所谓可见性,便是当一个线程对某个共享变量的操作,另外一个线程立即可见这个共享变量的变更。而从上面推论可以发现,要达到可见性,需要处理器及时回写共享变量最新值到内存,也需要其他处理器及时从内存中读取到共享变量最新值。因此也可以说只要满足上述两个条件。那么就可以保证对共享变量的操作,在并发情况下是线程安全的。在Java语言中,是通过volatile关键字实现。volatile关键字并不是Java语言的特产,古老的C语言里也有,它最原始的意义就是禁用CPU缓存。对如下共享变量: // instance是volatile变量 volatile Singlenton instance = new Singlenton();转换成汇编代码,如下:0x01a3de1d: movb 5 0 x 0, 0 x 1104800(% esi);0x01a3de24: lock addl $ 0 x 0,(% esp);可以看到volatile修饰的共享变量会多出第二行汇编变量,并且多了一个LOCK指令。LOCK前缀的指令在多核处理器会引发两件事:1)将当前处理器缓存行的数据写回到系统内存。2)这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。上述的操作是通过总线嗅探和总线仲裁来实现。而基于总线嗅探和总线仲裁,现代处理器逐渐形成了各种缓存一致性协议,例如MESI协议。总之操作系统便是基于上述实现,从底层来保证共享变量在并发情况下的线程安全。而对开发人员,只需要在恰当时候加上volatile关键字就可以。除了volatile,也可以使用synchronized关键字来保证可见性。关于volatile和synchronized的具体实现,会在下篇文章详细阐述。编译优化导致的有序性问题前面讲到通过缓存一致性协议,来保障共享变量的可见性。那么是否还有其他情况,导致对共享变量操作不符合预期结果。可以看下面的代码: private int a, b; private int x, y; public void test() { Thread t1 = new Thread(() -> { x = b; a = 1; }); Thread t2 = new Thread(() -> { y = a; b = 2; }); // ...start启动线程,join等待线程 assert x == 2; assert y == 1; }假设将线程t1的代码块从a=1;x=b;改成x=b;a=1;。将线程t2的代码块从b=2;y=a;改成y=a;b=2;。对于线程t1和t2自己来说,代码的重排序,不会影响当前线程执行。但是在多线程并发执行下,会出现如下情况:1)假设处理器A先将变量b=0赋值给x,再将变量a赋值1。处理器B先将变量a=0赋值给y,再将变量b赋值2。那么这时结果是:x等于0,y等于0。可见代码的重排序也会影响到程序最终结果。代码和指令的重排序的主要原因有三个,分别为编译器的重排序,处理器的乱序执行,以及内存系统的重排序。后面两点是处理器优化。重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。重排序需要遵守两点:1)数据依赖性:如果两个操作之间存在数据依赖,那么编译器和处理器不能调整它们的顺序。// 写后读a = 1;b = a;// 写后写a = 1;a = 2;// 读后写a = b;b = 1;上面3种情况,编译器和处理器不能调整它们的顺序,否则将会造成程序语义的改变。2)as-if-serial语义:即给程序一个顺序执行的假象。即经过重排序的执行结果要与顺序执行的结果保持一致。a = 1;b = 2;c = a * b;如上对变量a的赋值和对变量b的赋值,不存在数据依赖关系。因此对变量a和b重排序不会影响变量c的结果。但数据依赖性和as-if-serial语义只保证单个处理器中执行的指令序列和单个线程中执行的操作,并不考虑多处理器和多线程之间的数据依赖情况。因此在多线程程序中,对存在数据依赖的操作重排序,可能会改变程序的执行结果。因此要避免程序的错误的执行,便是需要禁止这种编译和处理器优化导致的重排序。这种方式叫做内存屏障(memorybarriers)。内存屏障是一组处理器指令,用户实现对内存操作的顺序限制。以我们日常接触的X86_64架构来说,读读(loadload)、读写(loadstore)以及写写(storestore)内存屏障是空操作(no-op),只有写读(storeload)内存屏障会被替换成具体指令。在Java语言中,内存屏障通过volatile关键字实现,禁止被它修饰的变量发生指令重排序操作:1)不允许volatile字段写操作之前的内存访问被重排序至其之后。2)不允许volatile字段读操作之后的内存访问被重排序至其之前。 // 变量a,b通过volatile修饰 private volatile int a, b; private int x, y; public void test() { Thread t1 = new Thread(() -> { a = 1; // 编译器插入storeload内存屏障指令 // 1)禁止代码和指令重排序 // 2)强制刷新变量a的最新值到内存 x = b; // 1)强制从内存中读取变量b的最新值 }); Thread t2 = new Thread(() -> { b = 2; // 编译器插入storeload内存屏障指令 // 1)禁止代码和指令重排序 // 2)强制刷新变量b的最新值到内存 y = a; // 1)强制从内存中读取变量a的最新值 }); // ...start启动线程,join等待线程 assert x == 2; assert y == 1; }可以看到通过volatile修饰的变量通过LOCK指令和内存屏障,实现共享变量的可见性和避免代码和指令的重排序,最终保障了程序在多线程情况下的正常执行。并发编程问题-原子性 private int count = 0; public void test() { List ts = new ArrayList(); for (int i = 0; i { for (int j = 0; j ts = new ArrayList(); for (int i = 0; i { for (int j = 0; j { // 基于程序顺序规则 // 没有数据依赖关系,可以重排序下面代码 int i = 0; x = 2; // 基于volatile变量规则 // 编译器插入storeload内存屏障指令 // 1)禁止代码和指令重排序 // 2)强制刷新变量x的最新值到内存 }); Thread b = new Thread(() -> { int i = 0; // 存在数据依赖关系,无法重排序下面代码 // 强制从主内存中读取变量x的最新值 y = x; // 基于volatile变量规则 // 编译器插入storeload内存屏障指令 // 1)禁止代码和指令重排序 // 2)强制刷新变量y的最新值到内存 //3)y=x;可能会被编译优化去除 y = 3; // 编译器插入storeload内存屏障指令 // 1)禁止代码和指令重排序 // 2)强制刷新变量y的最新值到内存 }); Thread c = new Thread(() -> { // 基于程序顺序规则 // 没有数据依赖关系,可以重排序下面代码 int i = 0; // 基于volatile变量规则 // 强制从主内存中读取变量x和y的最新值 z = x * y; // 编译器插入storeload内存屏障指令 // 1)禁止代码和指令重排序 // 2)强制刷新变量z的最新值到内存 }); // ...start启动线程,join等待线程 assert z == 6; // 可以看到a线程对变量x变更,b线程对变量y变更,最终对线程c可见 // 即满足传递性规则 } private int x, y, z; public void test() { Thread a = new Thread(() -> { // synchronized,同步原语,程序逻辑将顺序串行执行 synchronized (this){ // 基于程序顺序规则 // 没有数据依赖关系,可以重排序下面代码 int i = 0; x = 2; // 基于监视器锁规则 // 强制刷新变量x的最新值到内存 } }); Thread b = new Thread(() -> { // synchronized,同步原语,程序逻辑将顺序串行执行 synchronized (this) { int i = 0; // 存在数据依赖关系,无法重排序下面代码 // 强制从主内存中读取变量x的最新值 y = x; // 基于监视器锁规则 // 1)强制刷新变量y的最新值到内存 //2)y=x;可能会被编译优化去除 y = 3; // 强制刷新变量y的最新值到内存 } }); Thread c = new Thread(() -> { // synchronized,同步原语,程序逻辑将顺序串行执行 synchronized (this) { // 基于程序顺序规则 // 没有数据依赖关系,可以重排序下面代码 int i = 0; // 基于监视器锁规则 // 强制从主内存中读取变量x和y的最新值 z = x * y; // 强制刷新变量z的最新值到内存 } }); // ...start启动线程,join等待线程 assert z == 6; // 可以看到a线程对变量x变更,b线程对变量y变更,最终对线程c可见 // 即满足传递性规则 }内存模型综述在本文中,我们对Java内存模型进行了全面的概述。Java内存模型是Java虚拟机规范的一部分,为Java开发人员提供了一种抽象的内存模型,用于描述多线程环境下的内存访问行为。jJava内存模型关注并发编程中的原子性、可见性和有序性问题,并提供了一系列同步原语(如volatile、synchronized等)来实现这些原则。此外,还定义happens-before关系,用于描述操作之间的偏序关系,从而确保内存访问的正确性和一致性。Java内存模型的主要优势在于它为并发编程提供了基础,简化了复杂性。屏蔽不同处理器差异性,在不同的处理器平台之上呈现了一致的内存模型,并允许一定程度的性能优化。这些优势使得Java开发人员可以更容易地编写出正确、高效、可移植的并发程序。了解Java内存模型的原理和实践对于编写高质量的Java并发程序至关重要。希望本文能为您提供有关Java内存模型的有用信息,帮助您更好地理解并发编程的内在机制,以及在实际项目中选择合适的同步原语和策略。
|
|