并发编程的问题来源:原子性,可见性,有序性。Happens-Before原则的总结

并发问题的三大源头

并发问题的三大源头:

  1. 可见性
  2. 原子性
  3. 有序性

可见性

可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到,这称之为可见性,对应到程序中,应该是一个线程对共享变量进行修改以后,另外一个线程读取这个共享变量的值,能读到最新的值。考虑两个线程A和B,以及一个共同的变量shareValue,假设其初值为0。当线程A已经对shareValue进行了加一操作时,由于CPU缓存的作用,shareValue的值并没有立即写入内存中,而是保存在CPU的缓存中。现在的电脑,基本上都是多核CPU了,能并行执行的线程的数量约等于CPU的核心数。但是每一个CPU的都有自己的独立的缓存,它们的缓存是不共享的。因此,A线程将更新后的shareValue的值放入自己的CPU缓存还未写入内存中的时候,B线程此时读shareValue的值,只能读到内存中的值,B线程读不了A线程对应的CPU的缓存中的值。因此B线程此次读shareValue的值依然还是0,并不是更新后的1。这就导是所谓的不可见。

原子性

原子性:我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。CPU 能保证的原子操作是 CPU 指令级别的,我们编写的代码属于高级语言,我们写的是一句代码,但是不代表CPU就是一口气执行完毕的,中途可能会因为线程切换而被打断。

在Java中基本类型的大部分赋值操作是原子性的,但是long和double除外,因为Jvm将long和double会产生字撕裂的情况,Jvm将long和double读取和写入当作分离的两次32位操作来执行,这样多线程可能产生不一致的情况出现,解决办法就是加上volatile。

比如我们写的一句代码:a++;(假设a的初始值为0)
就会被分为三个步骤:

  1. 将a的旧值(对应到这里,也就是0)从内存读到寄存器中
  2. 在寄存器将a的值加1
  3. 将a更新后的值写回内存中

如果我们只是在一个单线程中运行程序,不会有其它线程来打断这些操作,就不用考虑这些问题。因此我们在当线程中执行一万次a++,a的值一定变为了10000,执行20000次,a的值一定变为了20000。然而在多线程中,执行20000次a++,a最终的值却不等于20000,而是趋近于10000。

有序性

考虑这个程序,按照我们写的这个程序,初始时a, b, c, d, f, g的值均为0。我们先对a加1,然后对b加1,依次到e。在这个过程中,e有可能大于a吗,直觉告诉我们不可能,a要比e先进行加1操作,e等于a还有可能,e大于a,怎么看都不可能。

public class DataHolder {
    int a, b, c, d, f, g;
    long e;

    public void operateData() {
        // TODO 按照这个顺序执行,g 的值是肯定小于等于 e 的。但是实际执行在执行的时候,可能会为了优化的目的重排
        a += 1;
        b += 1;
        c += 1;
        d += 1;
        e += 1;
    }

    int counter;//counter用来统计e > a发生的次数

    public void check() {
        // TODO 看似不可能的条件,实际可能被触发到
        if (e > a) {
            System.out.println("got it " + (counter++));
        }
    }
}

在单线程中我们这样执行

public static void main(String[] args) {
   int loopCount = Integer.MAX_VALUE / 30;
   
   DataHolder dataHolder = new DataHolder();
   
   for (int i = 0; i < loopCount; ++i) {
     dataHolder.operateData();
     dataHolder.check();
   }
 }

我们发现check函数并没有打印出任何内容,也就是说并没有出现e > a的情况。
在这里插入图片描述

但是在多线程中得到的结果令我们有些诧异。我们建立了两个线程,operator线程用来执行operateData()操作,也就是执行上面代码中的加一操作。checker线程用来执行check()操作,检查是否存在e > a的情况。
我们再两个线程中都执行了Integer.MAX_VALUE / 30次循环操作。

public class Main {
    public static void main(String[] args) {
        int loopCount = Integer.MAX_VALUE / 30;
        DataHolder dataHolder = new DataHolder();

        Thread operator = new Thread(() -> {
            for (int i = 0; i < loopCount; ++i) {
                dataHolder.operateData();
            }
        });
        operator.start();

        Thread checker = new Thread(() -> {
            for (int i = 0; i < loopCount; ++i) {
                dataHolder.check();
            }
        });
        checker.start();
    }
}

打印结果很出人意料:e > a的情况不仅出现了,还出现了很多次。
在这里插入图片描述
原因在于:
编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,在一般情况下,编译器这样优化不会给我们的程序带来什么影响,正如我们在单线程中执行上面那个程序一样,并不会有什么莫名其妙地问题。但是在多线程的情况下,编译优化导致的顺序性 + 非原子性 + 不可见性这三者联合起来就可能带来一大堆莫名其妙地Bug。而这些Bug可能我们在单线程的程序中根本就不可能会考虑到的。
那上面那个bug该怎么解决,我们只需要用volatile关键字就可以解决。如果我们对变量e用了volatile关键字,volatile long e,那么就能抑制指令重排,强制CPU从内存读写变量从而抑制了程序的不可见性,同时运用一些列Happens-Before原则来保持同步。【Happens-Before原则见下文】
然后当我们再次执行那个多线程程序时,会发现就没有诡异的e > a的情况发生了。
在这里插入图片描述
不过用volatile修饰的变量读写时间会远远慢于普通变量的读写时间


public class AccessMemoryVolatile {

    public volatile long counterV = 0;
    public long counter = 0;

    public static void main(String[] args) {
        int loopCount = Integer.MAX_VALUE / 30;
        // TODO 只是为了演示 volatile 每次访问都要直达内存,不能使用缓存,所以耗费的时间略多
        AccessMemoryVolatile accessMemoryVolatile = new AccessMemoryVolatile();
        Thread volatileAdder = new Thread(() -> {
            long start = System.currentTimeMillis();
            for (int i = 0; i < loopCount; i++) {
                accessMemoryVolatile.counterV++;
            }
            System.out.println("volatile adder takes " + (System.currentTimeMillis() - start));
        });
        volatileAdder.start();

        Thread justAdder = new Thread(() -> {
            long start = System.currentTimeMillis();
            for (int i = 0; i < loopCount; i++) {
                accessMemoryVolatile.counter++;
            }
            System.out.println("simple adder takes " + (System.currentTimeMillis() - start));
        });
        justAdder.start();
    }
}

可以看到,没有用volatile修饰的变量,从0加到Integer.MAX_VALUE / 30用了17毫秒,而用了volatile修饰的变量,则用了773毫秒。这是因为volatile强制CPU从内存读写数据,而不是从缓存,而内存的读写速度是远远慢于CPU的缓存的。
在这里插入图片描述

Happens-Before原则

上面讲述了并发编程中诡异问题的三大源头。而Java作为一门优秀的语言是怎么来解决上面那些并发问题同时也能在一定程度上保持程序的效率呢?

这就不得不提Java内存模型了。

Java 内存模型是个很复杂的规范,可以从不同的视角来解读,站在我们这些程序员的视角,本质上可以理解为,Java 内存模型规范了 JVM 如何提供 按需禁用缓存(解决不可见问题)按需编译优化(防止编译优化过度带来的问题) 的方法。具体来说,这些方法包括 volatilesynchronizedfinal 三个关键字,以及六项 Happens-Before 规则。而如下则重点阐述Happens-Before 规则。

Happens-Before 并不是说前面一个操作发生在后续操作的前面。Happens-Before原则真正要表达的是:前面一个操作的结果对后续操作是可见的
而所谓的可见性也就是说某个线程能不能读到某个变量的真实值。比如:
若:x = 1 Happens-Before y = 2,那么当线程A读到y = 2的时候,一定能读到x = 1。

1.程序的顺序性规则

程序的顺序性规则这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作

2.volatile 变量规则

对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。

3.传递性

这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。

针对如下代码:假设线程 A 执行 writer() 方法,按照 volatile 语义,会把变量 “v=true” 写入内存;假设线程 B 执行 reader() 方法,同样按照 volatile 语义,线程 B 会从内存中读取变量 v,如果线程 B 看到 “v == true” 时,那么线程 B 看到的变量 x 是多少呢?直觉上看,应该是 42,那实际应该是多少呢?这个要看 Java 的版本,如果在低于 1.5 版本上运行,x 可能是 42,也有可能是 0;如果在 1.5 以上的版本上运行,x 就是等于 42。

因为在Java1.5版本,对volatile语义进行了增强,用到了传递性,使得B线程读到 v==true时,一定能读到x = 42。

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }
  public void reader() {
    if (v == true) {
      // 这里x会是多少呢?
    }
  }
}

这里便是用到了传递性。

  1. “x=42” Happens-Before 写变量 “v=true” ,这是规则 1 的内容。
  2. 写变量“v=true” Happens-Before 读变量 “v=true”,这是规则 2 的内容 。
  3. 再根据这个传递性规则,我们得到结果:“x=42” Happens-Before 读变量“v=true”。这意味着什么呢?如果线程 B 读到了“v=true”,那么线程 A 设置的“x=42”对线程 B 是可见的。

4.管程中锁的规则

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

管程在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。

synchronized (this) { //此处自动加锁
  // x是共享变量,初始值=10
  if (this.x < 12) {
    this.x = 12; 
  }  
} //此处自动解锁

假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 12(执行完自动释放锁),线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x==12。

我对synchronized的理解:

  • 不管有多少个线程处于Runnable状态,synchronized能保证只有一个线程拿到锁,然后进入代码块执行相应的操作。
  • 执行synchronized中的代码块时也会发生线程切换,只是进入了synchronized代码块的线程不会因为线程切换就丢掉锁,该线程还是持有锁的,其它线程依然进不去,只有等到该线程再次得到CPU运行权,执行完synchronized代码块时,锁才会被该线程丢弃,其它线程又可以争夺锁从而进去。
  • A,B线程同时到达synchronized代码块,但是只能有一个线程能拿到锁进入synchronized代码块,另外一个没有拿到锁的线程则会阻塞在那里,等A线程执行完synchronized代码块并丢掉锁以后,B线程再去争夺锁。

5.线程 start() 规则

主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。

如下代码:在主线程调用了B.start()之前执行了a = 77的操作,那么当B线程开始执行时,B线程能读到a = 77

调用了B线程的start方法以后,B线程并不是立即就会启动,而是会处于Runnable状态,等待JVM的调度执行。

 public static int a = 66;

 public static void main(String[] args) {

   Thread B = new Thread(() -> {
     // 主线程调用B.start()之前
     // 所有对共享变量的修改,此处皆可见
     System.out.println(a);
   });
   // 此处对共享变量a修改
   a = 77;
   // 主线程启动子线程
   B.start();
 }

6.线程 join() 规则

指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。

参考
Java并发编程实战 作者:王宝令

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值