1、JMM内存模型

计算机的存储介质分为两大类:外存和内存,外存就是硬盘,磁盘等持久化的介质,数据存在外存中,机器下电后依然能保存数据;内存就是我们平常说的内存条,我们运行程序(CPU执行指令)时会把数据从外存读取到内存中,然后CPU再将数据从内存中读取,由于CPU执行指令的速度远远快于从内存中读取数据的速度,因此CPU从内存中加载数据的速度会成为一个性能瓶颈,因此缓存出现了。
缓存是有分级的:一级缓存、二级缓存…,缓存是CPU的一部分,自然CPU从缓存里读取数据的速度会很快。CPU读取数据时,会先从缓存中读取数据,如果缓存里找不到数据,就会从内存里读数据,从内存中读取数据后,会先在缓存里留一份数据副本,CPU就可以从缓存中读取和写入数据,CPU处理完数据后把缓存里的数据刷新,再写回内存中。
Java内存模型和计算机的内外存模型有很多类似的地方。Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存),线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作,并且每个线程不能访问其他线程的工作内存。如图所示,图摘自参考链接2。
volatile关键字 - 图1

2、原子性、可见性、有序性

通常我们说的线程安全,可以理解为多个线程访问临界变量时,需保证以下三个性质:原子性、可见性和有序性。

2.1 原子性

原子性是指:一个或者一组操作,要么全部执行,要么一个都不执行,不能出现一组操作中,有几个操作执行了,另外几个操作没执行,例子就是银行转账的例子。
java中的原子操作包括:

  • 除long和double之外的基本类型的赋值操作
  • 所有引用reference的赋值操作
  • java.concurrent.Atomic.* 包中所有类的一切操作

看似是原子操作实际不是的操作:

  1. y = x;
  2. x++;
  3. x = x + 1;

这里以x++举例说明,分为以下三步:

  1. 线程将x的值从主内存读入工作内存;
  2. 在工作内存中将x的值加1;
  3. 将加1后的x的值从工作内存写回主内存。

以上3个步骤并不能保证原子性,中途可能那个步骤线程会阻塞。前面讲的同步方法,比如synchronized关键字和Lock都是java里保证操作原子性的措施。

2.2 可见性

可见性是指:多个线程共同访问一个变量并修改变量的值,其中一个线程修改了变量的值,其他线程能感知到变量修改后的值。举个例子:两个线程,一个线程将变量的值从主存加载到工作内存,在工作内存中将变量值+10,此时该线程阻塞,并没有将+10后的值从工作线程刷回到主存中;此时另一个线程读取该变量值,由于主存中该变量值没刷新,因此第二个线程拿到的值还是+10之前的值,并没有感知到+10的变化,+10这个操作对第二个线程是不可见的。
java中保证可见性最经典的就是今天要讲的volatile关键字,此外synchronized关键字和ReentrantLock也可以保证。

2.3 有序性

有序性是指:程序是按照代码中书写的顺序执行的。在实际JVM执行代码时,并不能保证写在前面的代码一定会发生在写在后面的代码之前,因为会发生指令重排序。指令重排序是指:处理器为了提高程序运行效率,可能会对输入的代码进行优化,它不保证程序中各个语句的执行顺序与代码中的顺序一致,但是它会保证程序最终执行结果和代码中顺序执行的结果是一致的。
单个线程里发生指令重排序不会出现问题,多个线程里出现指令重排序会有问题。举例如下:
代码块1:

  1. // 语句1
  2. context = loadContext();
  3. // 语句2
  4. inited = true;

代码块2:

  1. while(!inited ){
  2. sleep()
  3. }
  4. doSomethingwithconfig(context);

代码块1:中,loadContext()方法加载上下文,加载成功后将标志位inited置为true;代码块2中,有一个循环时刻监测着标志位inited,一旦inited为true,即上下文加载成功后,代码块2跳出循环,调用doSomethingwithconfig()方法,该方法依赖加载后的上下文context。

问题场景描述:由于存在指令重排序的机制,线程1首先执行代码块1中的语句2,此时没有调用loadContext()方法加载上下文,但标志位inited已经置为true;然后线程2执行代码块2,发现inited已经为true,因为线程2继续执行代码块2中的doSomethingwithconfig()方法。问题就出现了,此时上下文context并没有被加载,而强依赖context的doSomethingwithconfig(context)方法已经执行了,会出现问题。还是要注意:只有在多个线程中的交互中,指令重排序才会出现问题。

3、volatile关键字工作机制

3.1 底层原理

volatile关键字是用来修饰临界资源的,临界资源即可能出现多个线程同时进行写操作或者读操作的资源。
一个变量被volatile关键字修饰后将具备如下两个特性:
(1)保证此变量对所有线程的可见性
当一个写线程修改完volatile关键字修饰的变量后,会立刻将修改后的值刷新到主内存中,并强迫其他工作线程中的变量副本失效,这样其他线程在读取该变量时,不会从工作内存中获取而是从主内存中重新读取变量值,保证可见性;
(2)禁止指令重排序
被volatile关键字修饰的变量赋值后,在赋值语句后会插入一个内存屏障,指令重排序时不能把后面的指令重排序到内存屏障之前的位置,保证有序性。

关于内存屏障:
加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供2个功能:

  • 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  • 它会强制对临界变量的写操作立即刷新至主存中,并使其他线程的工作内存失效。

因此volatile关键字底层还是靠这个内存屏障对应的lock指令保证的。

3.2 使用举例

(1)状态标记量
比如我们设置一个开关,在一个线程完成某件事情时,其他所有线程将不再继续执行新任务。主要是利用了volatile关键字提供的可见性。
(2)double check
在单例模式的写法中,有一种经典的写法:双重检查(DCL, Double Check Lock),需要将单例对象用volatile关键字修饰,详情可参考我这篇文章:https://www.yuque.com/docs/share/f42591c9-a83c-42e4-8b60-3fd4eeb4d8ba?# 《单例模式》

4、为什么volatile关键字不保证原子性

还是举上面的例子,起10个异步线程,每个线程执行1000次临界资源的自增操作,注意让主线程休眠5秒,保证10个异步线程全部执行完毕后再打印临界资源的值,如下:

  1. package Volatile;
  2. /**
  3. * @ClassName VolatileDemo
  4. * @Description TODO
  5. * @Auther Jerry
  6. * @Date 2020/3/20 - 22:38
  7. * @Version 1.0
  8. */
  9. public class VolatileDemo {
  10. // 初始化临界资源,值为0
  11. private volatile int val = 0;
  12. public static void main(String[] args) {
  13. VolatileDemo volatileDemo = new VolatileDemo();
  14. for (int i = 0; i < 10; ++i) {
  15. new Thread(volatileDemo::increase).start();
  16. }
  17. // 让主线程休眠5秒,保证前面起的10个异步线程都执行完毕
  18. try {
  19. Thread.sleep(5000);
  20. } catch (InterruptedException e)
  21. {
  22. e.printStackTrace();
  23. }
  24. System.out.println(volatileDemo.getVal());
  25. }
  26. private void increase() {
  27. for (int i = 0; i < 1000; ++i) {
  28. this.val++;
  29. }
  30. }
  31. private int getVal() {
  32. return this.val;
  33. }
  34. }

结果每次执行都不一样,有时候出现10000,有时候出现9000等值,但是按照预期,就应该是10*1000=10000啊,这是因为volatile关键字不能保证原子性。

简单的说,修改volatile变量分为四步:

  1. 从主内存读取volatile变量到线程的工作内存;
  2. 修改变量值;
  3. 工作内存中变量的副本值写回主内存;
  4. 插入内存屏障,即lock指令,让其他线程可见。

这样就很容易看出来,前三步都是不安全的,取值和写回之间,不能保证没有其他线程修改,需要锁来保证。

回到上面的例子,变量i被volatile关键字修饰,i在主内存的值为100,起两个异步线程分别执行++i操作,线程1从主内存里将i=100读取到线程1的工作内存后被阻塞,同时线程2从主内存里将i=100读取到线程2的工作内存并完成+1操作, 但线程2并没有将+1后的结果刷新到主内存里,而是直接被阻塞,此时线程1被唤醒,由于线程2并没有来得及将+1后的结果刷新到主内存中,因此线程1里的缓存没有失效,线程1继续将缓存里的i=100+1=101执行后刷新到主内存里,然后线程2唤醒再将101刷新到主内存里,最后i的值是101而不是102。
解决上面问题的方法还是前面讲的线程同步的方法,用synchronized方法、synchronized块、lock,但是这种简单的+1操作还是推荐用JUC包下的原子类。

参考

Java并发编程:volatile关键字解析 - Matrix海子 - 博客园 www.cnblogs.com
深入理解Java内存模型 www.jianshu.com
volatile什么时候使用 www.jianshu.com
volatile为什么不能保证原子性?