怎么理解java关键字volatile(附最新阿里面题)

本文详细讲解Java中volatile关键字的作用机制,包括其可见性、有序性和原子性特点,以及如何通过内存屏障实现这些特性。同时,文章提供了具体示例,展示了volatile在防止指令重排序、实现可见性和保证原子性方面的应用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一、什么是volatile

volatile作为java中的关键词之一,用以声明变量的值可能随时会别的线程修改,使用volatile修饰的变量会强制将修改的值立即写入主存,主存中值的更新会使缓存中的值失效(非volatile变量不具备这样的特性,非volatile变量的值会被缓存,线程A更新了这个值,线程B读取这个变量的值时可能读到的并不是是线程A更新后的值)。volatile会禁止指令重排。

二、volatile的原理

volatile具有可见性、有序性,不具备原子性。
注意,volatile不具备原子性,这是volatile与java中的synchronized、java.util.concurrent.locks.Lock最大的功能差异,这一点在面试中也是非常容易问到的点。

  • 原子性:如果你了解事务,那这个概念应该好理解。原子性通常指多个操作不存在只执行一部分的情况,如果全部执行完成那没毛病,如果只执行了一部分,那对不起,你得撤销(即事务中的回滚)已经执行的部分。
  • 可见性:当多个线程访问同一个变量x时,线程1修改了变量x的值,线程1、线程2…线程n能够立即读取到线程1修改后的值。
  • 有序性:即程序执行时按照代码书写的先后顺序执行。在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。(本文不对指令重排作介绍,但不代表它不重要,它是理解JAVA并发原理时非常重要的一个概念)。

1.可见性的实现

线程本身并不直接与主内存进行数据的交互,而是通过线程的工作内存来完成相应的操作。这也是导致线程间数据不可见的本质原因。因此要实现volatile变量的可见性,直接从这方面入手即可。对volatile变量的写操作与普通变量的主要区别有两点:

(1)修改volatile变量时会强制将修改后的值刷新的主内存中。
(2)修改volatile变量后会导致其他线程工作内存中对应的变量值失效。因此,再读取该变量值的时候就需要重新从读取主内存中的值。

通过这两个操作,就可以解决volatile变量的可见性问题。

2.有序性的实现
在解释这个问题前,我们先来了解一下Java中的happen-before规则,JSR 133中对Happen-before的定义如下:

Two actions can be ordered by a happens-before relationship.If one
action happens before another, then the first is visible to and
ordered before the second.

简单一点说就是如果a happen-before b,则a所做的任何操作对b是可见的。
一起再来看看JSR 133中定义了哪些happen-before规则:

  • 同一个线程中的,前面的操作 happen-before
    后续的操作。(即单线程内按代码顺序执行。但是,在不影响在单线程环境执行结果的前提下,编译器和处理器可以进行重排序,这是合法的。换句话说,这一是规则无法保证编译重排和指令重排)。

  • 监视器上的解锁操作 happen-before 其后续的加锁操作。(Synchronized 规则)

  • 对volatile变量的写操作 happen-before 后续的读操作。(volatile 规则)

  • 线程的start() 方法 happen-before 该线程所有的后续操作。(线程启动规则)

  • 线程所有的操作 happen-before 其他线程在该线程上调用 join 返回成功后的操作。

  • 如果 a happen-before b,b happen-before c,则a happen-before c(传递性)。

3、内存屏障

为了实现volatile可见性和happen-befor的语义。JVM底层是通过一个叫做“内存屏障”的东西来完成。内存屏障,也叫做内存栅栏,是一组处理器指令,用于实现对内存操作的顺序限制。下面是完成上述规则所要求的内存屏障:

(1)LoadLoad 屏障
执行顺序:Load1—>Loadload—>Load2

确保Load2及后续Load指令加载数据之前能访问到Load1加载的数据。

(2)StoreStore 屏障
执行顺序:Store1—>StoreStore—>Store2
确保Store2以及后续Store指令执行前,Store1操作的数据对其它处理器可见。

(3)LoadStore 屏障
执行顺序: Load1—>LoadStore—>Store2
确保Store2和后续Store指令执行前,可以访问到Load1加载的数据。

(4)StoreLoad 屏障

执行顺序: Store1—> StoreLoad—>Load2

确保Load2和后续的Load指令读取之前,Store1的数据对其他处理器是可见的。
最后通过一个实例来说明一下JVM中是如何插入内存屏障的:

package com.paddx.test.concurrent;

public class MemoryBarrier {
    int a, b;
    volatile int v, u;

    void f() {
        int i, j;

        i = a;
        j = b;
        i = v;
        //LoadLoad
        j = u;
        //LoadStore
        a = i;
        b = j;
        //StoreStore
        v = i;
        //StoreStore
        u = j;
        //StoreLoad
        i = u;
        //LoadLoad
        //LoadStore
        j = b;
        a = i;
    }
}

三、volatile的使用

volatile适用场景

适用于对变量的写操作不依赖于当前值,对变量的读取操作不依赖于非volatile变量。
适用于读多写少的场景。
可用作状态标志。
JDK中volatie应用:JDK中ConcurrentHashMap的Entry的value和next被声明为volatile,AtomicLong中的value被声明为volatile。AtomicLong通过CAS原理(也可以理解为乐观锁)保证了原子性。

1、防止重排序

package com.paddx.test.concurrent;

public class Singleton {
    public static volatile Singleton singleton;

    /**
     * 构造函数私有,禁止外部实例化
     */
    private Singleton() {};

    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (singleton) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

从一个最经典的例子来分析重排序问题。小伙伴应该都比较熟悉单例模式的实现,而在并发环境下的单例实现方式,我们通常可以采用双重检查加锁(DCL)的方式来实现。

现在我们分析一下为什么要在变量singleton之间加上volatile关键字。要理解这个问题,先要了解对象的构造过程,实例化一个对象其实可以分为三个步骤:

(1)分配内存空间。
(2)初始化对象。
(3)将内存空间的地址赋值给对应的引用。

但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下过程:

(1)分配内存空间。
(2)将内存空间的地址赋值给对应的引用。
(3)初始化对象

如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量。

2、实现可见性
可见性问题主要指一个线程修改了共享变量值,而另一个线程却看不到。引起可见性问题的主要原因是每个线程拥有自己的一个高速缓存区——线程工作内存。
volatile关键字能有效的解决这个问题,举个列子:

package com.paddx.test.concurrent;

public class VolatileTest {
    int a = 1;
    int b = 2;

    public void change(){
        a = 3;
        b = a;
    }

    public void print(){
        System.out.println("b="+b+";a="+a);
    }

    public static void main(String[] args) {
        while (true){
            final VolatileTest test = new VolatileTest();
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test.change();
                }
            }).start();

            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test.print();
                }
            }).start();

        }
    }
}

直观上说,这段代码的结果只可能有两种:b=3;a=3 或 b=2;a=1。不过运行上面的代码(可能时间上要长一点),你会发现除了上两种结果之外,还出现了第三种结果:

...... 
b=2;a=1
b=2;a=1
b=3;a=3
b=3;a=3
b=3;a=1
b=3;a=3
b=2;a=1
b=3;a=3
b=3;a=3
......

为什么会出现b=3;a=1这种结果呢?正常情况下,如果先执行change方法,再执行print方法,输出结果应该为b=3;a=3。相反,如果先执行的print方法,再执行change方法,结果应该是 b=2;a=1。那b=3;a=1的结果是怎么出来的?原因就是第一个线程将值a=3修改后,但是对第二个线程是不可见的,所以才出现这一结果。如果将a和b都改成volatile类型的变量再执行,则再也不会出现b=3;a=1的结果了。

3、保证原子性

关于原子性的问题,上面已经解释过。volatile只能保证对单次读/写的原子性。由于volatile变量对原子性保证,有一个问题容易被误解。现在我们就通过下列程序来演示一下这个问题:

package com.paddx.test.concurrent;

public class VolatileTest01 {
    volatile int i;

    public void addI(){
        i++;
    }

    public static void main(String[] args) throws InterruptedException {
        final  VolatileTest01 test01 = new VolatileTest01();
        for (int n = 0; n < 1000; n++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test01.addI();
                }
            }).start();
        }

        Thread.sleep(10000);//等待10秒,保证上面程序执行完成

        System.out.println(test01.i);
    }
}

大家可能会误认为对变量i加上关键字volatile后,这段程序就是线程安全的。大家可以尝试运行上面的程序。下面是我本地运行的结果:

可能每个人运行的结果不相同。不过应该能看出,volatile是无法保证原子性的(否则结果应该是1000)。原因也很简单,i++其实是一个复合操作,包括三步骤:

(1)读取i的值。
(2)对i加1。
(3)将i的值写回内存。

volatile是无法保证这三个操作是具有原子性的,我们可以通过AtomicInteger或者Synchronized来保证+1操作的原子性。

注:上面几段代码中多处执行了Thread.sleep()方法,目的是为了增加并发问题的产生几率,无其他作用。

四、最新阿里天猫3面(GC回收+Redis Hash算法+架构部署+秒杀等)

一面

自我介绍
jvm GC原理,JVM怎么回收内存
ConcurrentHashMap是怎么解决并发问题的?
HashMap怎么从链表转换为红黑树?如果存入的是null键,放在桶的哪个位置?
谈谈Synchronized与ReentrantLock的区别
再谈谈 threadLocal的实际应用
Java的内存模型,Java8做了什么修改
线程池中的几种重要的参数及流程说明
一次web请求响应中,那个部分最耗时,tcp握手?业务逻辑处理?网络延迟?数据库查询?浏览器解析?
如何进行JVM调优?有哪些方法?

二面
如何理解内存泄漏问题?有哪些情况会导致内存泄露?如何解决?
怎么理解强一致性、单调一致性和最终一致性?
分布式锁有哪些解决方案?
如何解决Redi缓存穿透的问题?
Redis hash算法用的是什么?
Redis集群方案应该怎么做?都有哪些方案?
三面
谈谈你的参与的项目
能否画出整个项目的架构设计图,尽量包含流程、部署等
分布式系统设计你会考虑哪些策略?
有遇见过服务器雪崩的场景?如何避免?
谈谈你对天猫双11秒杀的设计?
为什么想来阿里天猫?
未来3年有什么职业规划?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值