美文网首页程序员
概述+内存模型+Happens-Before 规则

概述+内存模型+Happens-Before 规则

作者: Leo_up_up | 来源:发表于2020-05-28 18:06 被阅读0次

    如果重排序之后的结果,与按照happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)既然要学习多线程,就要知道多线程因为什么而出现,出现的意义是什么,它的出现引发了什么问题。在这里,我先理一下多线程出现带来的一堆问题。

    当CPU,内存,I/O飞速发展的时候,有一个矛盾一直存在,那就是这三者的运行速度,可以抽象的这样理解:

    CPU运行一条指令花费时间:一天

    CPU读写内存花费时间:365天 

    再来看内存和I/O设备的关系:

    CPU读写内存需要花费时间:1天

    I/O设备的花费时间:3650天

    可以看到,时间的花费差异是十分巨大的,为了弥补这份巨大的时间差异,不管是内存还是CPU,都采取了相应的策略来解决这个问题。

    1:CPU增加了缓存,以均衡和内存之间的速度差异;(从内存读取到数据后,将其存放在缓存中,直至处理结束,在将其写入到内存中)

    2:操作系统增加了进程,线程,来分时复用CPU,进而均衡CPU和I/O设备之间的速度差异。

    3:编译程序优化指令执行顺序,从而保证CPU缓存被更好的利用。

    当我们做了上述那么多事情以后,多线程带来的影响也就随之而来了。

    A:首先,第一个带来的就是可见性的问题。

    当CPU增加缓存以后,在单核时代,这不会造成什么影响,因为只要一个CPU,当CPU从内存拷贝一份数据到缓存中,多个线程是对这一个数据进行操作的,线程a处理了数据M后,其处理结果对于线程2来说是可见的。

    但是现在电脑,都是多核处理器,那么每一个CPU都有自己的缓存,这个时候问题也就来了,

    可见性

    观察上图,CPU-1和CPU-2都含有自己的缓存,线程A和线程B分别操作两个CPU上的缓存,那么这个时候,线程A对CPU-1里面缓存数据的操作对于线程B来说,就是不可见的了。而这,就会导致最终数据操作失败。

    拿一段经典代码来看:

    public class Test {

     private long count = 0;

     private void add10K() {

     int idx = 0;

     while(idx++ < 10000) {

    6 count += 1;

     }

     }

     public static long calc() {

     final Test test = new Test();

     // 创建两个线程,执行 add() 操作

     Thread th1 = new Thread(()->{

     test.add10K();

     });

     Thread th2 = new Thread(()->{

     test.add10K();

     });

     // 启动两个线程

     th1.start();

     th2.start();

     // 等待两个线程执行结束22 th1.join();

     th2.join();

     return count;

     }

     }

    上面的操作按道理应该是20000的结果,但是实际上却不是这样,为什么呢,我们假设线程 A 和线程 B 同时开始执行,那么第一次都会将 count=0 读到各自的 CPU缓存里,执行完 count+=1 之后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2。之后由于各自的 CPU 缓存里都有了 count 的值,两个线程都是基于 CPU 缓存里的 count 值来计算,所以导致最终 count 的值都是小于 20000 的。这就是缓存的可见性问题。

    B:线程切换带来了原子性问题

    因为I/O读取相较于CPU实在是太慢太慢了,于是就发明了进程,多线程来分时复用CPU。

    原子性

    线程A和线程B分时复用CPU。

    举一个例子,在一个时间片内,有一个线程正在进行文件读取操作,那么它就会把自己标记为休眠状态,然后让出CPU的使用权,等到将文件读取至内存以后,操作系统会将这个线程进行唤醒,唤醒以后,这个线程就可以争夺CPU的使用权了。

    在线程读取文件时,释放对CPU的使用权,这样CPU就可以做其他的事情,那么CPU的利用率也就上来了。当然,在一个线程进行文件读取的时候,如果这时候有另外一个线程也要进行文件读取,这个读文件的操作就会排队,当磁盘驱动发现一个读取完成以后,它就会启动排队中的其他读取操作。这样,I/O的利用率也上来了。

    理解了分时复用,任务切换,那么为什么他会带来原子性的问题呢?

    首先要说明的是:任务切换的时机大多数是在时间片结束的时候。

    在这个时候,也是BUG出现的时候,十分诡异,那么举一个例子来看看吧:

    count += 1

    在java语言中,这个语句看似只是一条指令,在CPU中却需要3条语句才可以执行完。

    指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;

    指令 2:之后,在寄存器中执行 +1 操作;

    指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

    原子性1

    可以看到,两个线程,当线程1正在对count+=1进行操作时,已经将count=0加载到了寄存器里面,这个时候突然进行任务切换,线程b也进行这个操作,线程b结束后,将结果1写入到了内存里面,这个时候线程1继续执行,但是此时count应该等于1,但是由于线程1已经加载过了,所以还是对count=0这个数据进行操作,最后也是将count=1写入到内存里面,所以,本来应该时count=2,现在因为分时复用的问题,就导致了结果的错误。

    而上述三条指令,应当是一个原子操作。

    C:最后再来看看由于编译优化带来的有序性问题。

    我们以为的 new 操作应该是:

    1. 分配一块内存 M;

    2. 在内存 M 上初始化 Singleton 对象;

    3. 然后 M 的地址赋值给 instance 变量。

    但是实际上优化后的执行路径却是这样的:

    1. 分配一块内存 M;

    2. 将 M 的地址赋值给 instance 变量;

    3. 最后在内存 M 上初始化 Singleton 对象。

    接下来看一下JAVA内存模型这一个概念以及它所引出来的一些东西。

    首先,我们已经知道了因为缓存和指令优化而带来的多线程问题,那么看由怎么避免呢,最直接有效的方法就是禁用缓存和指令优化呗,但是这样做会给我们的效率带来极大的问题。

    那么看由怎么做呢,为了同时兼顾性能和安全,我们要做的就是按需禁用缓存和指令优化,那么一个新的问题就来了,怎么拿什么来按需禁用呢?这个时候,JAVA内存模型就出来了,它规范了一些按需禁用的方法。我们直接使用它,就可以做到按需禁用。

    Java 内存模型是个很复杂的规范,可以从不同的视角来解读,站在我们这些程序员的视角,本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则。

    volatile不是JAVA的独特产物,在C语言中也存在,它的作用是什么呢,简单来说,就是禁用CPU缓存。

    Java 内存模型在 1.5 版本对 volatile 语义进行了增强。怎么增强的呢?答案是一项 Happens-Before 规则。

    Happens-Before 规则

    接下来就分析一些这个规则。

    字面理解,这个规则就是保证前一个操作的结果对后一个操作时可见可知的。那么正规的说呢,就是说,此规则约束了编译器的优化行为,虽然允许优化,但是必须要遵守它的规则。

    那下面就一个个来看它的具体规则:

    1:程序的顺序性规则:

    这个很好理解,就是说,前面的操作是优于后面的任何操作的。字面不好理解,拿代码来看:

    public void writer() { 

    x = 42;   //   1

     v = true;      /  2

    }

    在这里,x=42,就优于v=true以及它后面的任何操作,这个很好理解。

    为什么会这样呢,因为操作1和操作2没有数据依赖关系,就可能被重排序,变成

    v = true; 

    x = 42; 

    所以,这个规则就是允许重排序,优化,但是绝对不能搞乱了顺序,即操作1必须在操作2之前。

    但是虽然这是规则保证的,但是JMM其实并没有真的保证到家,也就是说,虽然说是这样,但是当真正执行的时候,JMM依然有可能对执行顺序重排序,但是却不影响这个规则的正确性,所以这只是一个基本规则,我们还需要考虑更多的规则。

    在单线程中不改变运行结果

    操作不具备数据依赖性

    如果重排序之后的结果,与按照happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)

    重排序

    所以,就是说,只要满足在1:单线程中不改变运行结果2:操作不具备数据依赖性,JMM就会重排序,这样在单线程里面确实没问题,但是拿到多线程里面,就会出现问题了。所以这是一个坑。

    2. volatile 变量规则

    这个规则就可以结合上面一个规则来看了。

    这个规则就是完全禁止重排序了。

    这个需要拿一段代码来更好的理解:

    public class ReorderExample{

          private int x = 0;

         private int y = 1;

         private volatile boolean flag = false;

        public void writer(){

                 x = 42; //1

                y = 50; //2

                flag = true; //3

          }

         public void reader(){

                    if (flag){ //4

                              System.out.println("x:" + x); //5

                              System.out.println("y:" + y); //6

                     }

    }

    }

    以上面一段代码来分析,操作3必然不会被重排序到操作1和操作2之前。操作4必然不会被重排序到操作5和操作6之后。而这个时候,有序性规则也来了,这个时候,操作1和操作2因为无论是否重排序,都不会对结果造成影响,即使在多线程情况下,因为操作5判断的是flag这个加了volatile的变量,而他被限制了,所以在多线程里面,线程1write时都可以保证操作3之前的操作,对于线程2的read,都是可见的。

    3:传递性

    这个更好理解了。这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。

    传递性

    x =42 和 y = 50 Happens-before flag = true, 这是规则 1

    写变量(代码 3) flag=true Happens-before 读变量(代码 4) if(flag),这是规则 2

    根据规则 3 传递性规则,x =42 Happens-before 读变量 if(flag)

    谜案要揭晓了: 如果线程 B 读到了 flag 是 true,那么 x =42 和 y = 50 对线程 B 就一定可见了,这就是 Java1.5 的增强 (之前版本是可以普通变量写和 volatile 变量写的重排序的)

    这就是规则的结合。

    4:监视器锁规则

    这个说白了,就是synchronized 关键字。

    要理解这个规则,就首先要了解“管程指的是什么”。管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。

    下面还是拿代码来看

    public classSynchronizedExample{

         private int x = 0;

        public void synBlock(){

             // 1.加锁

             synchronized (SynchronizedExample.class){

                                 x = 1; // 对x赋值

                       }

                       // 3.解锁

              }

          // 1.加锁

         public  synchronized void synMethod(){

                 x = 2; // 对x赋值

         }

            // 3. 解锁

    }

    先获取锁的线程,对 x 赋值之后释放锁,另外一个再获取锁,一定能看到对 x 赋值的改动,就是这么简单。

    5:start()规则

    很简单的规则,如果在线程A里面启动线程B,那么在启动线程B之前的所有操作对于线程B来说,都是可见的。

    6:join()规则

    在主线程A中启执行线程B的join()方法,那么线程B执行成功并且返回后,线程B的所有操作对于线程A来说,都可见。它与start()规则正好相反。

    问题

    有一个共享变量 abc,在一个线程里设置了 abc 的值 abc=3,你思考一下,有哪些办法可以让其他线程能够看到abc==3?

    答:依据Happens-Before 规则

    规则2:声明共享变量abc,使用volatile关键词修饰,就可以保证。

    规则4:使用synchronized锁,就可以保证线程间的可见性。

    相关文章

      网友评论

        本文标题:概述+内存模型+Happens-Before 规则

        本文链接:https://www.haomeiwen.com/subject/axdoahtx.html