Java并发编程 - 可见性、原子性、有序性 & Java内存模型如何解决可见性、有序性

1,846 阅读12分钟

并发问题产生的根源:可见性、原子性、有序性

Java内存模型的主要目标是定义程序中变量的访问规则。即在虚拟机中将变量存储到主内存或者将变量从主内存取出这样的底层细节。

  • 主内存:java虚拟机规定所有的变量(不是程序中的变量)都必须在主内存中产生,为了方便理解,可以认为是堆区。可以与前面说的物理机的主内存相比,只不过物理机的主内存是整个机器的内存,而虚拟机的主内存是虚拟机内存中的一部分。
  • 工作内存:java虚拟机中每个线程都有自己的工作内存,该内存是线程私有的为了方便理解,可以认为是虚拟机栈。线程的工作内存保存了线程需要的变量在主内存中的副本。虚拟机规定,线程对主内存变量的修改必须在线程的工作内存中进行,不能直接读写主内存中的变量。不同的线程之间也不能相互访问对方的工作内存。如果线程之间需要传递变量的值,必须通过主内存来作为中介进行传递。

线程工作内存的可见性

下面我们再用一段代码来验证一下多线程工作内存的可见性。下面的代码,每执行一次 add() 方法,都会循环num次 count++ 操作。在主方法中我们创建了两个线程,每个线程调用一次 add(100000) 方法,我们来想一想执行完得到的结果应该是多少呢?

public class Test {
 
    private int count = 0;
 
    public void add(int num){
        for(int i=0; i<num; i++){
            count++;
            System.out.println(Thread.currentThread().getName() + "current count : "+count);
        }
    }
 
    public static void main(String[] args) {
        Test test = new Test();
        Thread t1 = new Thread(() -> {
            test.add(100000);
        });
        Thread t2 = new Thread(() -> {
            test.add(100000);
        });
        t1.start();
        t2.start();
    }
}

可能会有很多人认为会输出200000,如果在单线程中调用2次add(100000),那么结果确实应该是200000;但是当开启多个线程执行时,如上面代码的线程t1和线程t2,那么结果会有所不同,上面最终输出的count值会在100000到200000之间的一个随机数,结果如下图:

为什么会出现这种结果呢?

我们假设线程 1 和线程 2 同时开始执行,那么第一次都会将 count=0 读到各自的线程工作内存里,执行完 count++ 之后,各自的线程工作内存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2。之后由于各自的线程工作内存里都有了 count 的值,两个线程都是基于线程工作内存里的 count 值来计算,所以导致最终 count 的值都是小于 200000 的。这就是线程工作的可见性问题。

物理硬件层面的可见性

在单核电脑中,所有的线程都在一个处理器上执行,处理器的高速缓存与内存的一致性很容易解决。但是在多核处理器时代,每个处理器都有自己的高速缓存,如下图中,当线程1执行的时候访问的是处理器1的高速缓存1,线程2执行访问的是处理器2的高速缓存2,这个时候如果线程1和线程2修改同一个变量val,那么线程1和线程2的操作彼此是不具备可见性的。

目前基于高速缓存的存储交互很好的解决了cpu和内存等其他硬件之间的速度矛盾,多核情况下各个处理器(核)都要遵循一定的诸如MSI、MESI等协议来保证内存的各个处理器高速缓存和主内存的数据的一致性。

原子性

当前的操作系统执行基本是基于“时间片”来执行,即当有多个进程需要获取CPU的执行操作时,CPU采用时间片轮转法并发的执行,即CPU会允许某个进程执行一小段时间,例如 100 毫秒,过了 100 毫秒操作系统就会重新选择一个进程来执行(我们称为“任务切换”),如下图:

  • 原子的意思代表着——“不可分”;
  • 在整个操作过程中不会被线程调度器中断的操作,都可认为是原子性。原子性是拒绝多线程交叉操作的,不论是多核还是单核,具有原子性的量,同一时刻只能有一个线程来对它进行操作。例如 a=1是原子性操作,但是i++和i+=1就不是原子性操作。

i++和i+=1不能保证原子操作的原因如下原子性:

有序性

有序性指的是程序按照代码的先后顺序执行。

有序性从不同的角度来看是不同的。在本线程内观察,所有操作都是有序的(即指令重排不会导致单线程程序执行结果与排序前有任何差别);在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序。前半句说的就是“线程内表现为串行的语义”,后半句指得是“指令重排序”现象和主内存与工作内存之间同步存在延迟的现象。
在 Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

我们来看个简单的例子,单例的dubbo check的实现,我们在用单例的dubbo check方式时会在实例变量Singleton s 前面用volatile修饰变量,volatile关键字就是为了解决重排序的优化带来的并发问题:

public class Singleton {
 
    private static volatile Singleton s;
    /**
     * 懒汉式
     * dubbo check:存在的问题(指令重排序)
     * 1. 申请一块内存空间s
     * 2. 在这块空间里实例化对象
     * 3. 将这块空间的地址赋值给变量 s
     *    JVM为了优化性能可能先执行1,3,后执行2,  [1,2,3]->[1,3,2]
     *    若1,3先执行,2还未执行时判断s==null,
     *    并发时就会出问题,为了防止这种情况,Singleton s 需用volatile修饰
     * @return
     */
    public static Singleton getInstance(){
        if(null == s){
            synchronized (Singleton.class){
                if(null == s){
                    s = new Singleton();
                }
            }
        }
        return s;
    }
}

正常情况下我们new一个对象应该有以下几步操作:

  1. 申请一块内存空间s
  2. 在这块空间里实例化对象
  3. 将这块空间的地址赋值给变量 s

但实际执行的顺序可能是这样的:

  1. 申请一块内存空间s
  2. 将这块空间的地址赋值给变量 s
  3. 在这块空间里实例化对象

在单线程模式下这种优化是不会有问题的,但是当多线程并发执行时有可能汇报NPE异常。我们假设线程 1 先执行 getInstance() 方法,当执行完步骤 2 时恰好发生了线程切换,切换到了线程 2 上;如果此时线程 2 也执行 getInstance() 方法,那么线程 2 在执行第一个判断时会发现 s != null ,所以直接返回 s,而此时的 s 是没有初始化过的,如果我们这个时候访问 s 的成员变量就可能触发空指针异常。

Java内存模型(如何解决可见性和有序性)

Java内存模型定义了线程和内存的交互方式,在JMM抽象模型中,分为主内存、工作内存;主内存是所有线程共享的,一般是实例对象、静态字段、数组对象等存储在堆内存中的变量。工作内存是每个线程独占的,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,线程之间的共享变量值的传递都是基于主内存来完成。 在JMM中,定义了8个原子操作来实现一个共享变量如何从主内存拷贝到工作内存,以及如何从工作内存同步到主内存。详细见图线程、工作内存、主内存之间的交互关系。

导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有 序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们程序的性能就堪忧了。

合理的方案应该是按需禁用缓存以及编译优化。那么,如何做到“按需禁用”呢?对于并发程序,何时禁用缓存以及编译优化只有程序员知道,那所谓“按需禁用”其实就是指按照程 序员的要求来禁用。所以,为了解决可见性和有序性问题,只需要提供给程序员按需禁用缓 存和编译优化的方法即可。

Java 内存模型是个很复杂的规范,可以从不同的视角来解读,从我们程序员的视角来看可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方 法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则,这也正是本期的重点内容。

volatile关键字

当一个变量定义为 volatile 之后,将具备两种特性:

  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量,并更新本地内存的值。 ------可见性

  • 禁止指令重排序优化(底层通过内存屏障解决,这里不过多介绍) ------有序性

volatile没有解决原子性的问题,原子性只能依靠我们在代码层面解决。

在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。

public class Test{
 
    private int count=2;
    private boolean flag=false;
 
    public void write1()  {
        count=10;
        flag=true;  //没有volatile修饰,实际执行顺序,有可能是flag=true先执行
    }
 
    public void read1()  {
        if(flag){
            System.out.print(count); 
        }
    }
    
    public static void main(String[] args) {
        Singleton s = new Singleton();
        Thread t1 = new Thread(() -> s.write1());
        Thread t2 = new Thread(() -> s.read1());
        t1.start();
        t2.start();
    }
 
}

上面的代码,由于指令会重排序,当线程一里面执行write1方法的flag=true的时候,同时线程2执行了read1方法,那么count的值是不确定的,可能是10,也可能是2。

public class Test{
 
    private boolean flag=false;
    private volatile boolean sync=false;
 
     public void write2() {
     count=10;
     sync=true;// 由于出现了volatile,所以这里禁止重排序
    }
 
    public void read2()  {
        if(sync){
            System.out.print(count); // 在jdk5之后,由volatile保证,count的值总是等于10
        }
 
    }
 
 
    public static void main(String[] args) {
        Singleton s = new Singleton();
        Thread t1 = new Thread(() -> s.write2());
        Thread t2 = new Thread(() -> s.read2());
        t1.start();
        t2.start();
    }
 
}

注意这里的sync变量是加了volatile修饰,意味着禁止了重排序,第一个线程调用write2方法时候,同样第二个线程在调用read2方法时候,如果sync=true,那么count的值一定是10,有人会认为count变量没有用volatile修饰啊,如何保证100%可见性呢? 确实在jdk5之前volatile关键字确实存在这种问题,必须都得加volatile修饰,但是在jdk5及以后修复了这个问题,也就是在jsr133里面增强了volatile关键字的语义,volatile变量本身可以看成是一个栅栏,能够保证在其前后的变量也具有volatile语义,同时由于volatile的出现禁止了重排序,所以在多线程下仍然可以得到正确的结果。

Happens-Before 规则

JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。具体的定义为:

  • 如果操作A happens-before 操作 B,那么操作A的执行结果将对操作可见 B,而且操作 A 的执行顺序排在操作 B 之前。
  • 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。

Happens-Before具体有以下六项规则:

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  • start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  • join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。