欢迎关注专栏【JAVA并发】

前语

开篇一个比方,我看看都有谁会?假如不会的,或许不知道原理的,仍是老老实实看完这篇文章吧。

@Slf4j(topic = "c.VolatileTest")
public class VolatileTest {
    static boolean run = true;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (run) {
                // do other things
            }
            // ?????? 这行会打印吗?
            log.info("done .....");
        });
        t.start();
        Thread.sleep(1000);
       // 设置run = false
        run = false;
    }
}

main函数中新开个线程依据标位run循环,主线程中sleep一秒,然后设置run=false,咱们以为会打印”done .......“吗?

答案便是不会打印,为什么呢?

JAVA并发三大特性

咱们先来解释下上面问题的原因,如下图所示,

看完这篇,还不懂JAVA内存模型(JMM)算我输

现代的CPU架构基本有多级缓存机制,t线程会将run加载到高速缓存中,然后主线程修正了主内存的值为false,导致缓存不一致,可是t线程依然是从作业内存中的高速缓存读取run的值,终究无法跳出循环。

可见性

正如上面的比方,由于不做任何处理,一个线程能否立刻看到另外一个线程修正的同享变量值,咱们称为”可见性“。

假如在并发程序中,不做任何处理,那么就会带来可见性问题,详细怎么处理,见后文。

有序性

有序性是指程序依照代码的先后次序履行。可是编译器或许处理器出于性能原因,改变程序句子的先后次序,比方代码次序”a=1; b=2;“,可是指令重排序后,有或许会变成”b=2;a=1“, 那么这样在并发情况下,会有问题吗?

在单线程情况下,指令重排序不会有任何影响。可是在并发情况下,或许会导致一些意想不到的bug。比方下面的比方:

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

假定有两个线程 A、B 同时调用 getInstance() 办法,正常情况下,他们都可以拿到instance实例。

但往往bug就在一些极点的异常情况,比方new Singleton() 这个操作,实际会有下面3个步骤:

  1. 分配一块内存 M;

  2. 在内存 M 上初始化 Singleton 目标;

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

现在产生指令重排序,次序变为下面的方式:

  1. 分配一块内存 M;

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

  3. 最后在内存 M 上初始化 Singleton 目标。

优化后会导致什么问题呢?咱们假定线程 A 先履行 getInstance() 办法,当履行完指令 2 时恰好产生了线程切换,切换到了线程 B 上;假如此刻线程 B 也履行 getInstance() 办法,那么线程 B 在履行第一个判别时会发现 instance != null ,所以直接回来 instance,而此刻的 instance 是没有初始化过的,假如咱们这个时候拜访 instance 的成员变量就或许触发空指针异常。

这便是并发情况下,有序性带来的一个问题,这种情况又该怎么处理呢?

当然,指令重排序并不会瞎排序,处理器在进行重排序时,有必要要考虑指令之间的数据依赖性。

原子性

看完这篇,还不懂JAVA内存模型(JMM)算我输

如上图所示,在多线程的情况下,CPU资源会在不同的线程间切换。那么这样也会导致意向不到的问题。

比方你以为的一行代码:count += 1,实际上触及了多条CPU指令:

  • 指令 1:首要,需求把变量 count 从内存加载到 CPU 的寄存器;
  • 指令 2:之后,在寄存器中履行 +1 操作;
  • 指令 3:最后,将结果写入内存(缓存机制导致或许写入的是 CPU 缓存而不是内存)。

操作体系做任务切换,可以产生在任何一条CPU 指令履行完。假定 count=0,假如线程 A 在指令 1 履行完后做线程切换,线程 A 和线程 B 依照下图的序列履行,那么咱们会发现两个线程都履行了 count+=1 的操作,可是得到的结果不是咱们期望的 2,而是 1。

看完这篇,还不懂JAVA内存模型(JMM)算我输
咱们潜意识以为的这个count+=1操作是一个不可分割的全体,就像一个原子一样,咱们把一个或许多个操作在 CPU 履行的过程中不被中止的特性称为原子性。但实际情况便是不做任何处理的话,在并发情况下CPU进行切换,导致出现原子性的问题,咱们一般经过加锁处理,这个不是本文的重点。

Java内存模型真面目

前面解说并发的三大特性,其间原子性问题可以经过加锁的方式处理,那么可见性和有序性有什么处理的方案呢?其实也很简单想到,可见性是由于缓存导致,有序性是由于编译优化指令重排序导致,那么是不是可以让程序员按需禁用缓存以及编译优化, 由于只要程序员知道什么情况下会出现问题 顺着这个思路,就提出了JAVA内存模型(JMM)标准

Java 内存模型是 Java Memory Model(JMM),本身是一种笼统的概念,实际上并不存在,描述的是一组规矩标准,经过这组标准界说了程序中各个变量(包含实例字段,静态字段和构成数组目标的元素)的拜访方式。

看完这篇,还不懂JAVA内存模型(JMM)算我输

默许情况下,JMM中的内存机制如下:

  • 体系存在一个主内存(Main Memory),Java 中所有变量都存储在主存中,对于所有线程都是同享的
  • 每条线程都有自己的作业内存(Working Memory),作业内存中保存的是主存中某些变量的复制
  • 线程对所有变量的操作都是先对变量进行复制,然后在作业内存中进行,不能直接操作主内存中的变量
  • 线程之间无法彼此直接拜访,线程间的通信(传递)有必要经过主内存来完成

同时,JMM标准了 JVM 怎么提供按需禁用缓存和编译优化的办法,首要是经过volatilesynchronizedfinal 三个要害字,那详细的规矩是什么样的呢?

JMM 中的主内存、作业内存与 JVM 中的 Java 堆、栈、办法区等并不是同一个层次的内存区分,这两者基本上是没有关系的。

Happens-Before规矩

JMM本质上包含了一些规矩,那这个规矩便是咱们有所耳闻的Happens-Before规矩,咱们都了解了些规矩吗?

Happens-Before规矩,可以简略了解为假如想要A线程产生在B线程前面,也便是B线程可以看到A线程,需求遵循6个原则。假如不符合 happens-before 规矩,JMM 并不能确保一个线程的可见性和有序性。

1.程序的次序性规矩

在一个线程中,逻辑上书写在前面的操作先行产生于书写在后边的操作。

这个规矩很好了解,同一个线程中他们是用的同一个作业缓存,是可见的,而且多个操作之间有先后依赖关系,则不答应对这些操作进行重排序。

2. volatile 变量规矩

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

怎么了解呢?比方线程A对volatile变量进行写操作,那么线程B读取这个volatile变量是可见的,便是说可以读取到最新的值。

3.传递性

这条规矩是指假如 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C

这个规矩也比较简单了解,不展开讨论了。

  1. 锁的规矩

这条规矩是指对一个锁的解锁 Happens-Before于后续对这个锁的加锁,这里的锁要是同一把锁, 而且用synchronized或许ReentrantLock都可以。

如下代码的比方:

synchronized (this) { // 此处主动加锁
  // x 是同享变量, 初始值 =10
  if (this.x < 12) {
    this.x = 12; 
  }  
} // 此处主动解锁
  • 假定 x 的初始值是 8,线程 A 履行完代码块后 x 的值会变成 12(履行完主动开释锁)
  • 线程 B 进入代码块时,可以看到线程 A 对 x 的写操作,也便是线程 B 可以看到 x==12

5.线程 start() 规矩

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

这个规矩也很简单了解,线程 A 调用线程 B 的 start() 办法(即在线程 A 中启动线程 B),那么该 start() 操作 Happens-Before 于线程 B 中的恣意操作。

6.线程 join() 规矩

线程 A 中,调用线程 B 的 join() 并成功回来,那么线程 B 中的恣意操作 Happens-Before 于该 join() 操作的回来。

运用JMM规矩

咱们现在已经基本讲清楚了JAVA内存模型标准,以及里面要害的Happens-Before规矩,那有啥用呢?回到前语的问题中,咱们是不是可以运用现在学到的关于JMM的常识去处理这个问题。

方案一: 运用volatile

看完这篇,还不懂JAVA内存模型(JMM)算我输

依据JMM的第2条规矩,主线程写了volatile润饰的run变量,后边的t线程读取的时候就可以看到了。

方案二:运用锁

看完这篇,还不懂JAVA内存模型(JMM)算我输

利用synchronized锁的规矩,主线程开释锁,那么后续t线程加锁就可以看到之前的内容了。

小结:

volatile 要害字

  • 确保可见性
  • 不确保原子性
  • 确保有序性(禁止指令重排)

volatile 润饰的变量进行读操作与普通变量几乎没什么不同,可是写操作相对慢一些,由于需求在本地代码中插入许多内存屏障来确保指令不会产生乱序履行,可是开支比锁要小。volatile的性能远比加锁要好。

synchronized 要害字

  • 确保可见性
  • 不确保原子性
  • 确保有序性

加了锁之后,只能有一个线程取得到了锁,取得不到锁的线程就要阻塞,所以同一时间只要一个线程履行,相当于单线程,由于数据依赖性的存在,单线程的指令重排是没有问题的。

线程加锁前,将清空作业内存中同享变量的值,运用同享变量时需求从主内存中重新读取最新的值;线程解锁前,有必要把同享变量的最新值刷新到主内存中。

总结

本文解说了JAVA并发的3大特性,可见性、有序性和原子性。从而引出了JAVA内存模型标准,这首要是为了处理并发情况下带来的可见性和有序性问题,首要便是界说了一些规矩,需求咱们程序员懂得这些规矩,然后依据实际场景去运用,便是运用volatilesynchronizedfinal要害字,首要final要害字也会让其他线程可见,而且确保有序性。那么详细他们底层的完成是什么,是怎么确保可见和有序的,咱们后边详细解说。

假如本文对你有帮助的话,请留下一个赞吧

本文正在参加「金石计划 . 瓜分6万现金大奖」