面试官:说说你对volatile关键字的理解

250 阅读6分钟

本文首发于公众号:托尼学长,立个写 1024 篇原创技术面试文章的flag,欢迎过来视察监督~

Java中的volatile关键字这个问题,最近在面试过程中经常会被问到,候选人往往可以回答出来其具备可见性,但不具备原子性。

若要保证其原子性,可使用Synchronized关键字或ReentrantLock可重入锁,以及JUC包中的AtomicInteger和AtomicLong工具类。

但对于其可见性是如何实现的,以及禁止指令重排序的相关内容,很少有候选人可以很详细地说出来。

本文就来继续往下进行延伸,将volatile关键字所涉及到的知识点一一说透。

内存可见性

我们来看下面这段代码:

public class TestVolatile {

    private static boolean isRunning = true;

    public static void main(String[] args) {
        int i = 0;
        Thread thread = new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
            }
            isRunning = false;
        });
        thread.start();
        while (isRunning) {
            i++;
        }
        System.out.println("master thread is currently here");
    }
}

上面的代码并不会把主线程最后的这段英文打印输出,程序会一直运行下去。

接下来我们将isRunning全局变量加上volatile关键字进行修饰,再试一次。

public class TestVolatile {

    private static volatile boolean isRunning = true;

    public static void main(String[] args) {
        int i = 0;
        Thread thread = new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
            }
            isRunning = false;
        });
        thread.start();
        while (isRunning) {
            i++;
        }
        System.out.println("master thread is currently here");
    }
}

这次结果则完全不同,主线程最后的这段英文打印输出后,程序终止运行。

这就是所谓的内存可见性问题,第一段代码中被子线程修改的全局变量新值没有被主线程可见,而在第二段代码中则恰好相反。

JMM(Java内存模型)中有两个区域:工作内存和主内存,主内存是所有线程共享的内存区域,工作内存则是各个线程私有的高速缓存。

这里所说的高速缓存包括CPU寄存器和L1/L2缓存。

如上图所示,Java中的全局变量不仅仅存储在主内存中,基于性能原因,各个线程的工作内存也都各自存储一份副本。

对于内存可见性,JMM通过将被某线程修改后的全局变量新值立即刷新到主内存,后续其他线程对该全局变量的读操作直接从主内存读取来实现的。

继续往下深挖,被某线程修改后的全局变量新值立即刷新到主内存,以及后续其他线程对该全局变量的读操作直接从主内存读取,这是如何实现的呢?

答案是通过硬件指令内存屏障(Memory Barrier),共包括四种:StoreStore 、LoadLoad、StoreLoad和LoadStore。

对于volatile变量的写操作,在写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障。

这样可以保证volatile变量的修改对其他线程立即可见,并禁止volatile写与前后操作的重排序。

对于volatile变量的读操作,在读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。

这样可以保证读取到volatile变量的最新值,并禁止volatile读与后续操作的重排序。

如此一来,也就解决了内存可见性的问题。

指令重排序

我们先来看一段代码:

public class TestVolatile1 {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        int count = 0;
        while (true) {
            count++;
            x = 0; y = 0;
            a = 0; b = 0;

            Thread thread1 = new Thread(() -> {
                a = 1;          // 操作1
                x = b;          // 操作2
            });

            Thread thread2 = new Thread(() -> {
                b = 1;          // 操作3
                y = a;          // 操作4
            });

            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();

            // 可能出现的意外结果:x=0且y=0
            // 这意味着:
            // 1. thread1看到thread2对b的修改发生在thread2看到thread1对a的修改之前
            // 2. 或者发生了其他重排序
            if (x == 0 && y == 0) {
                System.out.println("第" + count + "次尝试," +
                        "发生重排序: x=" + x + ", y=" + y);
                break;
            }
        }
    }
}

代码在执行一段时间后,打印出了这样的日志,随即程序停止运行。

如果代码在顺序执行的情况下,x == 0 && y == 0的判定是不应该成立的,之所以发生这样的现象,这就是指令重排序所导致的。

public class TestVolatile1 {
    private static int x = 0, y = 0;
    private static volatile int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        int count = 0;
        while (true) {
            count++;
            x = 0; y = 0;
            a = 0; b = 0;

            Thread thread1 = new Thread(() -> {
                a = 1;          // 操作1
                x = b;          // 操作2
            });

            Thread thread2 = new Thread(() -> {
                b = 1;          // 操作3
                y = a;          // 操作4
            });

            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();

            // 可能出现的意外结果:x=0且y=0
            // 这意味着:
            // 1. thread1看到thread2对b的修改发生在thread2看到thread1对a的修改之前
            // 2. 或者发生了其他重排序
            if (x == 0 && y == 0) {
                System.out.println("第" + count + "次尝试," +
                        "发生重排序: x=" + x + ", y=" + y);
                break;
            }
        }
    }
}

我们为a、b全局变量都加上volatile关键字修饰后,程序再也没有打印出来日志,而是一直在运行中,这证明已经规避了指令重排序的问题。

有的同学会有疑问,Java中为什么会有这么奇怪的问题,连代码的执行顺序都不能保证了呢?

这就涉及到了JMM模型中的核心原则——先行发生原则(Happens-Before Principle)。

上述八种先行发生原则中,我们拿出来两个讲讲,那就是程序次序原则和Volatile变量原则。

程序次序原则:在一个线程内,按照控制流顺序(考虑分支、循环等结构),前面的操作先行发生于后面的操作。

int a = 1; // 操作A
int b = 2; // 操作B

在这段代码中,哪怕发生了指令重排序,JMM仍然要从执行结果上保证操作A一定先行发生于操作B。

当然,程序次序原则只能保证一个线程中的效果,并不能在多线程中保证一样的效果,这也就是在第一个TestVolatile1类中出现这种执行结果的原因。

volatile变量原则:对volatile变量的写操作先行发生于后续的读操作。

volatile boolean flag=false;
// 线程1
flag = true; // 操作A
// 线程2
boolean f = flag; // 操作B(能读取到true)

操作A先行发生于操作B,保证写入的flag值对后续读取可见。

也就是说,单纯的程序次序原则是无法保证多线程执行顺序的,需要通过volatile变量原则进行保证。

Java中的指令重排序有两种情况,一个是处理器指令重排序,另一个是编译器指令重排序,都可以通过volatile关键字的内存屏障进行规避。

对于volatile变量的写操作,在写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障。

这样可以保证volatile变量的修改对其他线程立即可见,并禁止volatile写与前后操作的重排序。

对于volatile变量的读操作,在读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。

这样可以保证读取到volatile变量的最新值,并禁止volatile读与后续操作的重排序。

如此一来,也就解决了指令重排序的问题。