volatile 关键字特性解析及单例模式下的使用

47 阅读4分钟

一、什么是 volatile ?

volatile 是 Java 中的一个关键字,Java 虚拟机提供的轻量级同步机制。

二、JMM(Java Memory Model)

为了更好的理解 volatile 关键字,应该了解了解 JMM。

JMM(Java内存模型Java Memory Model,简称 JMM)本身是一种抽象的概念并不真实存在,它描述的是一组规则或规范通过规范定制了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

JMM 的特点: 1、可见性 2、原子性 3、有序性

JMM关于同步规定:
1、线程解锁前,必须把共享变量的值刷新回主内存
2、线程加锁前,必须读取主内存的最新值到自己的工作内存
3、加锁解锁是同一把锁

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方成为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作空间,然后对变量进行操作,操作完成再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存储存着主内存中的变量副本拷贝,因此不同的线程无法访问对方的工作内存,此案成间的通讯(传值) 必须通过主内存来完成,其简要访问过程如下图:

image.png

三、volatile 关键字的三大特性

3.1 保证可见性

通过前面对 JMM 的介绍,我们知道各个线程对主内存中共享变量的操作都是各个线程各自拷贝到自己的工作内存操作后再写回主内存中的。这就可能存在一个线程 A 修改了共享变量 X 的值还未写回主内存中时,另外一个线程 B 又对内存中的一个共享变量 X 进行操作,但此时 A 线程工作内存中的共享变量 X 对线程 B 来说并不不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题。

3.1.1 代码演示 → 未添加 volatile 关键字时:

class MyData {

    int number = 0;

    public void add() {
        this.number = 60;
    }
}

// volatile 可以保证可见性,及时通知其他线程,主物理内存的值已经被修改
private static void main(String[] args) {
    MyData myData = new MyData();

    new Thread(() -> {
        System.out.println(Thread.currentThread().getName() + " come in");

        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        myData.add();
        System.out.println(Thread.currentThread().getName() + " update the value to " + myData.number);
    }, "Thread A").start();

    while (myData.number == 0) {
        // main 线程一直循环等待直到 number 的值不再是 0
    }
    System.out.println(Thread.currentThread().getName() + " mission is over, main get the value " + myData.number);
}

image.png

线程启动后,首先打印出“Thread A come in”,表示线程 A 已启动,等待 3 秒后,修改 number 变量的值为 60。但程序一直处于运行状态。因为主线程拿到的 number 的值是 0,不知道变量 number 的值已经被修改为了 60,因此 main 线程一直在循环等待。由此就可以看出,此时 number 变量的值不具有可见性。

3.1.2 代码演示 → 添加 volatile 关键字后:

代码同上,只是在声明 number 变量时,添加 volatile 关键字即可。

image.png

线程 A 修改变量 number 的值为 60 后,主线程立刻得到了通知,跳出循环,在控制台上打印出“main mission is over, main get the value 60”。

3.2 不保证原子性

3.2.1 代码演示:(开启 20 个线程对 number 变量执行自增操作 1000 次)

class MyData {

    volatile int number = 0;

    public void addPlusPlus() {
        number++;
    }
}

public static void main(String[] args) {
    MyData myData = new MyData();

    for (int i = 1; i <= 20; i++) {
        new Thread(() -> {
            for (int j = 1; j <= 1000; j++) {
                myData.addPlusPlus();
            }
        }, String.valueOf(i)).start();
    }

    while (Thread.activeCount() > 2) {
        Thread.yield();
    }

    System.out.println(Thread.currentThread().getName() + " finally number value are " + myData.number);
}

image.png

测试的结果不是 20000,每次运行结果都不一样。

3.2.2 原因分析:

image.png

3.2.3 解决办法:

  1. 在方法上添加 synchronized 关键字
  2. 使用 java.util.concurrnent.atomic 包下的 AtomicInteger
class MyData {
  AtomicInteger atomicInteger = new AtomicInteger();
  public void addMyAtomic() {
      atomicInteger.getAndIncrement();
  }
}

image.png

3.3 禁止指令重排

计算机在执行程序时,为了提高性能,编译器和处理器常常会做指令重排,一把分为以下3种:

  1. 编译器优化的重排
  2. 指令并行的重排
  3. 内存系统的重排

单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。处理器在进行重新排序是必须要考虑指令之间的数据依赖性。多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程使用的变量能否保持一致性是无法确定的,结果无法预测。

四、volatile 关键字的使用

/**
 * 双重检查机制的单例模式
 */
public class SingletonDemo {

    private static volatile SingletonDemo instance = null;

    private SingletonDemo() {
        System.out.println(Thread.currentThread().getName() + " 构造方法");
    }

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

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(SingletonDemo::getInstance).start();
        }
    }
}