概述+内存模型+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锁,就可以保证线程间的可见性。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 221,695评论 6 515
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,569评论 3 399
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 168,130评论 0 360
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,648评论 1 297
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,655评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,268评论 1 309
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,835评论 3 421
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,740评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,286评论 1 318
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,375评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,505评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,185评论 5 350
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,873评论 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,357评论 0 24
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,466评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,921评论 3 376
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,515评论 2 359