多线程不安全的原因和基本的解决方案

2019独角兽企业重金招聘Python工程师标准>>> hot3.png

内容大纲

  • 共享变量在内存中的可见性
  • 什么是原子性
  • synchronized实现可见性和原子性的方式
  • volatile实现可见性的方式

Java内存模型(JMM)

Java内存模型(JMM)描述了Java程序中变量(线程公用变量)的访问规则(可以看做是一种规范),以及在JVM中将变量存储到内存内存中读取出变量这样的底层细节

  • 所有的变量都存储在主内存中
  • 每个线程都有自己独立的工作内存,里面保存该线程使用到的变量副本(主内存中该变量的一份拷贝)

并且规定:

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

举个例子(修改线程A中的变量):

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

如果满足上面两点,也就是说线程A中更新的共享变量线程B中能够及时得到更新,就称为变量是可见的,反正则是不可见。

共享变量、可见性和原子性

  • 共享变量:如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量。
  • 可见性:一个线程对共享变量值的修改,能够及时地被其他线程看到。
  • 原子性: 一段指令像原子一样不可分割,在执行结束之前,其他线程不可打断。

指令重排序和as-if-serial

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

主要有三种方式:

  1. 编译器优化的重排序(编译器优化,主要是单线程下,在保证执行结果正确的前提下,重排序代码顺序让代码更符合机器执行)
  2. 指令级并行重排序(处理器优化,主要为多核计算机同时执行做了优化)
  3. 内存系统的重排序(处理器优化,主要是运行内存,如主内存、工作内存进行重排序)

举个例子:

int a = 2;
int b = 3;
int c = a + b;

在实际运行中可能是:

int b = 3;
int a = 2;
int c = a + b;

上述例子中演示了指令重排序,那么可能会有人问,第三行代码如果重排序到前两行代码之前,岂不是会报错吗?

有一个as-if-serial,其内容如下:

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

因此在单线程的情况下你不必担心指令重排序带来什么不良后果。

但是在多线程交错执行时,重排序就可能造成内存可见性问题,详情请继续阅读下文。

多线程中的指令重排序

package cn.com.dotleo;

/**
 * Created by liufei on 2018/6/16.
 */
public class SynchronizedDemo {

    // 共享变量
    private boolean ready = false;
    private int result = 0;
    private int number = 1;

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

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

    private class ReadWriteThread extends Thread {
        private boolean flag;
        public ReadWriteThread(boolean flag) {
            this.flag = flag;
        }

        // 根据传入执行不同的读写操作
        @Override
        public void run() {
            if (flag) {
                write();
            } else {
                read();
            }
        }
    }

    public static void main(String[] args) {
        SynchronizedDemo demo = new SynchronizedDemo();
        // 启动写线程
        demo.new ReadWriteThread(true).start();
        // 启动读线程
        demo.new ReadWriteThread(false).start();
    }

}

代码参见SynchronizedDemo

对于这个程序,如果执行main方法将可能有一下几种情况:

  • 1.1 -> 2.1 -> 2.2 -> 1.2 result:3
  • 1.2 -> 2.1 -> 2.2 -> 1.1 result:0

其实,2.1和2.2也是可以重排序的:

int temp = number * 3;
if (ready) {
    result = temp;
}

因此就有了:

  • 2.2 -> 2.1 ->1.2 -> 1.1
  • ...

导致共享变量线程不安全的原因

到这里,可以总结一下为什么会出现共享变量线程不安全的主要原因了:

  1. 线程的交叉执行(原子性)
  2. 重排序结合线程的交叉执行(可见性)
  3. 共享变量更新后的值没有在工作内存中与主内存之间及时更新(可见性)

可见性的实现方式

从Java语言层面讲,主要支持一下两种方式:

  • Synchronized
  • Volatile

不包括JDK 1.5提供的Java并发包

Synchronized

JMM关于Synchronized的两条规定:

  1. 线程解锁前,必须把共享变量的最新值刷新到主内存中
  2. 线程加锁时,将清空工作内存中共享变量的值,使得使用共享变量时需要从主内存中重新获取最新的值。(加锁和解锁需要是同一把锁)

我们先来修改一下原来的代码,保证其共享变量在多线程下的可见性。

    // 写操作
    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);
    }

代码参见SafeSynchronizedDemo

为什么这个操作能保证其可见性呢?我们通过分析导致共享变量线程不可见的3个原因逐一分析:

  1. Synchronized关键词加锁后,保证了线程不会交叉执行
  2. 如果线程不交叉执行,那么无论如何重排序,都相当于单线程中的重排序,遵循as-if-serial语义
  3. 上面提到的Synchronized的两条规定能保证它及时获取并且在操作结束后及时更新主内存中的共享变量的值。

volatile

关于volatile,它有以下特性:

  • 能够保证变量的可见性
  • 不能保证变量的原子性

它的这些特性是通过内存屏障和禁止指令重排序优化来实现的。

  • 对volatile变量执行写操作时,会在写操作后加入一条store屏障指令,让主内存中的变量及时更新
  • 对volatile变量执行读操作时,会在读操作前加入一条load屏障指令,更新主内存中的变量

举一个volatile的例子说明它不具备原子性。

package cn.com.dotleo;

/**
 * Created by liufei on 2018/6/16.
 */
public class VolatileDemo {

    private volatile int num = 0;

    public int getNum() {
        return this.num;
    }

    public void increase() {
        this.num++;
    }

    public static void main(String[] args) {
        final VolatileDemo volatileDemo = new VolatileDemo();
        for (int i = 0; i < 500; i++) {
            new Thread(new Runnable() {
                public void run() {
                    volatileDemo.increase();
                }
            }).start();
        }

        // 为了让所有线程执行完毕
        // 如果还有子线程执行
        // 主线程让出cpu资源
        while (Thread.activeCount() > 1) {
            Thread.yield();
        }
        System.out.println("num" + volatileDemo.getNum());
    }
}

代码参见VolatileDemo

该程序的运行结果不总是500。因为increase()方法中的num++并非原子操作,它包括了:

  1. 从主内存中获取num的值
  2. num的值 + 1
  3. 将num + 1的值赋值给num

试分析一种情况:

  1. 线程A获取到num的值 = 0,由于它不是原子性,cpu资源被线程B抢走
  2. 线程B获取num的值 = 0
  3. 线程B对num + 1 = 1
  4. 线程B把num值更新到主内存中后结束
  5. 线程A重新获得cpu资源, 对它内存中num的副本 + 1 = 1
  6. 线程A将num = 1更新到主内存中

至此,两次循环却少加了,导致并非我们想要的num最终 = 500

volatile适用场景

要在多线程中安全的使用volatile,必须同时满足:

  1. 对变量的写入操作不依赖当前值
  2. 该变量没有包含在具有其他变量的不变式中

转载于:https://my.oschina.net/u/2930289/blog/1831411

你可能感兴趣的:(多线程不安全的原因和基本的解决方案)