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

如果重排序之后的结果,与按照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 规则)