从Java内存模型来分析volatile实现原理

Java内存模型和操作系统的内存模型非常相似,同样的,Java内存模型规定了不能直接在主内存中操作数据,而需要将数据拷贝一份放在每个线程自己的工作内存中才能够去操作数据,跟CPU的高速缓存的方式差不多

Java中规定了线程操作工作内存和主内存的操作主要有以下几种:

  • read:线程从主内存中读取数据
  • load:将主内存中读取到的数据写入到工作内存
  • use:使用工作内存中的数据进行计算
  • assign:将计算后的数据再次写入到工作内存
  • store:将工作内存中的数据写入到主内存
  • write:给主内存中的数据赋值

image.png

内存模型的三个特性在Java内存模型中的表现

原子性

和操作系统内存模型中的原子性一样,一个操作是不可分割的整体,如果在这个操作执行时会受到其他线程的影响,那么该操作就不是原子的

volatile关键字不能保证操作的原子性

可见性

在一个线程中操作完共享数据,其他线程能够立即看到该数据的最新的值

volatile关键字可以保证操作的可见性

有序性

happens-before:

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  • 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

volatile关键字可以保证操作的有序性

volatile关键字如何保证操作的可见性

当变量被volatile关键字修饰时,线程在处理该变量时,就会利用CPU的缓存锁来操作,如果变量的值被修改,会立即写入到主内存,并且通知其他线程该变量的值已经被改变,他们工作内存中的值已经失效,需要重新从主内存中读取,这样就保证了操作的可见性

volatile关键字如何保证操作的有序性

int a = 5;
String b = "hello";

volatile long longValue = 1;

System.out.println(longValue);

int c = 7;
String d = "world";

对于上面的代码,如果longValue没有加上volatile关键字,那么这6行代码的执行顺序很有可能不是按照代码的顺序来的

当我们给longValue加上了volatile关键字后,在指令层面会发生什么变化,JIT(即时编译器)会在对被volatile修饰的变量的操作前后增加内存屏障,首先看一下写操作

int a = 5;
String b = "hello";

// 内存屏障:Release Barrier(释放屏障)

volatile long longValue = 1;

// 内存屏障:Store Barrier(存储屏障)
  • Release Barrier:该屏障的作用是,保证在对volatile变量操作之前的所有读写操作都不会和该写操作进行指令重排序
  • Store Barrier:强制刷新工作内存中变量修改后的值到主内存

然后看一下读操作

// 内存屏障:Load Barrier(加载屏障)

System.out.println(longValue);

// 内存屏障:Acquire Barrier(获取屏障)

int c = 7;
String d = "world";
  • Load Barrier:在读取longValue时,不从线程的工作内存中读取,而是直接从主内存读取
  • Acquire Barrier:保证在对volatile变量读操作之后的所有读写操作都不会和该读操作进行指令重排序

为什么volatile关键字不能保证操作的原子性

image.png

  • 看一下这个场景,有线程A和线程B分别在对变量i(用volatile关键字修饰)进行操作
  • 其中线程A已经将变量i的新值计算好准备写入到工作内存
  • 此时线程B从它的工作内存中读取变量i的值到自己的代码中进行计算
  • 然后线程A将变量i的值写入到工作内存,因为变量i是被volatile关键字修饰的,所以CPU会强制将变量i的值刷新到主内存并通知其他线程该变量的值已经被修改了,你们工作内存的值已经失效了,此时线程B会将自己工作内存中变量i的值读取为线程A对变量i修改后的新值,但是其实线程B已经拿着旧值在进行计算了,此时重新读取的变量i的新值并不会被线程B用到,接着线程B走同样的计算赋值写入主内存的过程,这样就会导致对变量i的操作不是原子的

volatile可以保证long/double变量的简单操作的原子性,如赋值

image.png

文档上明确写出,对于非volatile的long/double变量的简单写入会被分为两个部分来操作,每次操作32位,这个操作在多线程并发的情况下可能导致long/double变量在某一时刻内只被写入了32位,另外的32位则没有被写入,但是如果long/double变量被volatile修饰,那么对于其简单的写入操作就是原子的

volatile的使用场景

一般可以用与线程之间进行协调,比如

public class VolatileDemo {

    public static int count = 0;
    private static volatile boolean running = true;

    public static void main(String[] args) {
        Thread changeRunningThread = new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            running = false;
        });
        Thread addThread = new Thread(new AddTask());
        changeRunningThread.start();
        addThread.start();
    }

    static class AddTask implements Runnable {

        @Override
        public void run() {
            while (running) {
                System.out.println("count: " + count);
                count++;
                try {
                    TimeUnit.MILLISECONDS.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("running: " + running);
        }
    }
}

输出结果:
count: 0
count: 1
count: 2
count: 3
count: 4
count: 5
count: 6
count: 7
count: 8
count: 9
running: false

这里的volatile变量running就是做了一个线程之间协调的作用,addThread每隔100ms对count进行一次++直到running变为false,然后输出running的状态,changeRunningThread就是做一个改变running值为false的功能