Java多线程系列---“基础篇”12之 内存可见性

一. 名词解释

原子性:是指一个操作或多个操作要么全部执行,且执行的过程不会被任何因素打断,要么就都不执行。(note: 处理器保证从系统内存中读取或写入一个字节是原子的。意思是,当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。当然,long和double类型在32位操作系统中的读写操作不是原子的,因为long和double占64位,需要分成2个步骤来处理,在读写时分别拆成2个字节进行读写。eg:银行转账:转的人和收的人要么都成功,要么都失败)

可见性:一个线程对共享变量值的修改,能够及时地被其它线程看到。

共享变量:如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量。(note:工作内存是Java内存模型JMM抽象出来的一个概念

Java内存模型(JMM):描述了Java程序种各种变量(线程共享变量)的访问规则,以及在JVM种将变量存储到内存和从内存中读取出变量这样的底层细节。(note:这里的变量一定是共享变量,如果不是,就不会牵扯到线程争用的问题)

解释:一般程序中会有一个主内存,这个主内存会用来保存所有的变量,并且每个线程都有自己的独立的工作内存,里面保存该线程使用到的变量的副本(主内存中变量的一份拷贝,拷贝到了工作内存中来)。比如如下图所示:

note:线程不能和主内存直接交互。工作内存既要负责和线程交互,还要负责和主内存交互。

 Java内存模型中的两条规定:

  • 1. 线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写。
  • 2. 不同线程之间无法直接访问其它线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成。

共享变量可见性实现的原理:

线程1对共享变量的修改要想被线程2及时看到,必须要经过如下2个步骤:

  • 1.把工作内存1中更新过的共享变量刷新到主内存中
  • 2.将主内存中最新的共享变量的值更新到工作内存2中

Java语言层面支持的可见性实现方式:

  • synchronized
  • volatile
  • jdk1.5之后的原子类的引入等等也能实现

二.synchronized实现可见性(具备原子性)

synchronized能够实现:

  • 原子性(同步)
  • 可见性

1. synchronized实现可见性原理

JMM关于synchronized的两条规定:

  • 线程解锁前,必须把共享变量的最新值刷新到主内存中
  • 线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值(Note:加锁和解锁需要时同一把锁)

因此:线程解锁前对共享变量的修改在下次加锁时对其他线程可见。

线程执行互斥代码的过程:

  • 获得互斥锁
  • 清空工作内存
  • 从主内存拷贝变量的最新副本到工作内存
  • 执行代码
  • 将更改后的共享变量的值刷新到主内存
  • 释放互斥锁

2. 执行重排序

重排序:代码书写的顺序与实际执行的顺序不同,指令重排序是编译器或处理器为了提高程序性能而做的优化。可以分成以下类型:

  • 编译器优化的重排序(编译器优化)
  • 指令级并行重排序(处理器优化)
  • 内存系统的重排序(处理器优化)

as-if-serial:无论如何重排序,程序执行的结果应该与代码顺序的执行结果一致(Java编译器、运行时和处理器都会保证Java在单线程下遵循as-if-serial语义)

(有数字依赖关系的部分禁止重排序)

3. synchronized实现可见性代码

假设有如下代码:

 1 package com.test.a;
 2 
 3 public class SynchronizedDemo {
 4     // 共享变量
 5     private boolean ready = false;
 6     private int result = 0;
 7     private int number = 1;
 8 
 9     // 写操作
10     public void write() {
11         ready = true; // 1.1
12         number = 2; // 1.2
13     }
14 
15     // 读操作
16     public void read() {
17         if (ready) { // 2.1
18             result = number * 3; // 2.2
19         }
20         System.out.println("result的值为:" + result);
21     }
22 
23     // 内部线程类
24     private class ReadWriteThread extends Thread {
25         // 根据构造方法中传入的flag参数,确定线程执行读操作还是写操作
26         private boolean flag;
27 
28         public ReadWriteThread(boolean flag) {
29             this.flag = flag;
30         }
31 
32         public void run() {
33             if (flag) {
34                 // 构造方法中传入true,执行写操作
35                 write();
36             } else {
37                 // 构造方法中传入false,执行读操作
38                 read();
39             }
40         }
41     }
42 
43     public static void main(String args[]) {
44         SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
45         // 启动线程执行写操作
46         synchronizedDemo.new ReadWriteThread(true).start();
47         // 启动线程执行读操作
48         synchronizedDemo.new ReadWriteThread(false).start();
49     }
50 }
View Code
1 result的值为:0
2 或者
3 result的值为:6
4 等等
View Code

分析可能的执行顺序:

执行顺序:
1.1-->2.1-->2.2-->1.2   输出3
1.2-->2.1-->2.2-->1.1   输出0
还有别的可能,比如2.1和2.2重排序,比如会出现以下情况:

2.1和2.2重排序后:

int mid=number*3;

if(ready){

   result=mid;

}

导致共享变量在线程间不可见的原因:

  • 线程的交叉执行
  • 重排序结合线程交叉执行
  • 共享变量更新后的值没有在工作内存与主内存间及时更新

上述问题的解决:

安全的代码:

// 写操作
    public synchronized void write() {
        ready = true; // 1.1
        number = 2; // 1.2
    }

    // 读操作
    public synchronized void read() {
        if (ready) { // 2.1
            result = number * 3; // 2.2
        }
        System.out.println("result的值为:" + result);
    }

运行后的结果要么为0或者为6

note:为了让执行结果一致为6,可以在main函数第一个线程执行完了以后加入休眠

三. volatile实现可见性(不能保证原子性)

1.volatile如何实现内存可见性

深入来说:通过加入内存屏障和禁止重排序优化来实现的。

  • 对volatile变量执行写操作时,会在写操作后加入一条store屏障指令。它会把CPU中的缓存强制刷新到主内存中去。还能禁止处理器重排序这个变量。
  • 对volatile变量执行读操作时,会在读操作前加入一条load屏障指令,

通俗来讲:volatile变量在每次被线程访问时,都强迫从主内存中重读该变量的值,而当该变量发生变化时,又会强迫线程将最新的值刷新到主内存。这样任何时刻,不同的线程总能看到该变量的最新值。

线程写volatile变量的过程:

  • 改变线程工作内存中volatile变量副本的值
  • 将改变后的副本的值从工作内存刷新到主内存

线程读volatile变量的过程:

  • 从主内存中读取volatile变量的最新值到线程的工作内存中
  • 从工作内存中读取volatile变量的副本

上面的num++被分成了3个步骤,只能被一个线程执行完以后,才能被另外一个线程执行,不能发生线程的交叉执行。不是原子操作

上面的3个步骤,volatile不能保证原子性,因此可能出现3个线程交叉执行的情况。

假设执行如下程序:

number=5;

number++;

分析上面程序:执行number=5,然后分成两个线程执行number++。B执行了加1操作后,立即刷新到了主内存,但是A来不及获取最新值,还是用的5来加1,然后刷新到了主内存。因此,这个过程种实际上A和B都是做了加1操作,但是最终结果却为6.

解决方案:

保证number自增操作的原子性:

  • 使用synchronized关键字
  • 使用ReentrantLock(java.util.concurrent.locks包下)
  • 使用AtomicInteger(java.util.concurrent.atomic包下)

 synchronized和volatile使用场合比较:

  • volatile不需要加锁,比synchronized更轻量级,不会阻塞线程;
  • 从内存可见性角度讲,volatile读相当于加锁,volatile写相当于解锁
  • synchronized既能保证可见性,又能保证原子性,而volatile只能保证可见性,无法保证原子性。

总结问题:

(1) 即使没有保证可见性的措施,很多时候共享变量依然能够在主内存和工作内存间得到及时的更新?

    答:一般只有在短时间内高并发的情况下才会出现变量得不到及时更新的情况,因为CPU在执行时会很快的刷新缓存,所以一般情况下很难看到这种问题。

(2) 对64位(long、double)变量的读写可能不是原子操作:

     JMM允许JVM将没有被volatile修饰的64位数据类型的读写操作划分位2次32位的读写操作来进行

参考文献:

https://www.imooc.com/learn/352(慕课网视频学习整理)

原文地址:https://www.cnblogs.com/Hermioner/p/9880161.html