volatile,原来是这么回事

先说结论,volatile修饰共享变量,那么

  • 1)这个共享变量具有可见性

  • 2)限制了编译器对这个共享变量的相关读写操作,限制对这个共享变量的读写操作进行指令重排

1.1 什么是可见性

每个线程都共享主内存,但每个线程也都分别有各自独占的内存区域 如操作栈、本地变量表等。线程本地内存保存了引用变量在堆内存中的副本。线程对变量的所有操作都在本地内存区域中进行,执行结束后再同步到堆内存中去。该线程对副本的操作对于其他线程都是不可见的。因为对于其他线程来说,自己的副本并没有发生改变,读取共享变量时读的仍是自己的副本。而CPU何时去主存中重新读取共享变量刷新缓存是一个不确定的因素(CPU有空闲时间就可以去重新读取)。

1.2 volatile是怎么实现可见性的

使用 volatile 修饰共享变量后,每个线程要操作变量时会从主内存中将变量拷贝到本地内存作为副本,当线程操作变量副本并写回主内存后,会通过 CPU 总线嗅探机制告知其他线程该变量副本已经失效,需要重新从主内存中读取。那么其他线程下次读取这个变量时,就会从主内存中重新获取。这样就保证了volatile修饰的这个变量的可见性。

补充:加锁也可以实现可见性。当一个线程进入 synchronizer 代码块后,线程获取到锁,会清空本地内存,然后从主内存中拷贝共享变量的最新值到本地内存作为副本,执行代码,又将修改后的副本值刷新到主内存中,最后线程释放锁。也就是说,每个线程一旦获取锁后,第一会做的事就是清空副本,然后再去主存中读,这样就可以确保读到的一定是最新的。保证了可见性。

2.1. 什么是指令重排

计算机并不会根据代码顺序按部就班地执行相关指令,会分析哪些取数据动作可以合并进行,哪些存数据动作可以合并进行。指令重排是编译器和处理器为了提高性能而重新安排指令执行顺序的过程。这种优化在多线程环境中可能导致不同的执行结果。

举例1:单线程中的指令重排

java 复制代码
int a = 0;
int b = 0;
​
a = 1;  // 指令1
b = 2;  // 指令2

编译器可能将这两条指令重排为:

java 复制代码
b = 2;  // 指令2
a = 1;  // 指令1

在单线程情况下,重排不会影响程序的最终结果,因为两条指令之间没有依赖关系。

举例2:多线程中的指令重排

java 复制代码
int a = 0;
int flag = 0;
​
//Thread 1:
a = 1;       // 指令1
flag = 1;    // 指令2
​
//Thread 2:
if (flag == 1) {
    // 使用a
}

正常情况下,对于Thread 1来说,是先给a赋值后,才给flag赋值。当其他线程判断flag==1后,这样获取到刚刚给a赋的值。

而如果指令重排,变成如下:

java 复制代码
//Thread 1:
flag = 1;    // 指令2
a = 1;       // 指令1

那么对于Thread 2来说,有可能获取到的是a的初始值。从而导致错误的程序行为。

2.2. volatile在单例模式中的应用

java 复制代码
class LazyinitDemo {
    private static TransactionService service = null;
    
    public static TransactionService getTransactionService() {
        if (service == null) {
            synchronized (this) {
                if (service == null) {
                    service = new TransactionService();
                }
            }
        }
​
        return service;
    }
}

实例化一个对象其实可以分为三个步骤:

(1)分配内存空间。

(2)初始化对象。

(3)将内存空间的地址赋值给对应的引用。

指令重排后:

(1)分配内存空间。

(2)将内存空间的地址赋值给对应的引用。

(3)初始化对象

如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,此时如果有其他线程getTransactionService(),由于 service!=null ,但是此时 service 对象还没有被赋予真正有效的值。从而无法取到正确的 service 单例对象。

对于此问题,一种较为简单的解决方案是用 volatile 关键字修饰目标属性。这样 service 就限制了编译器对它的相关读写操作,限制对它的读写操作进行指令重排。确定对象实例化之后才返回引用。

3.1. volatile不能保证对变量的操作是原子性的

举例:用volatile修饰共享变量inc

java 复制代码
public volatile static int inc = 0;
​
public void increase() {
    inc++;
}

我们知道inc++在底层是分为三个步骤的:

  1. 读取 inc 的值。

  2. 对 inc 加 1。

  3. 将 inc 的值写回内存。

如果此时有100个并发线程来执行inc++,最终得到的inc的值会比100小。因为可能同时两个线程读到的inc的值一样,最终的效果只给inc+1而已。由此可见,volatile不能保证对变量的操作是原子性的。

解决办法,就是给inc++加悲观锁reentrantlock、synchronized,或者使用 AtomicInteger(底层为CAS乐观锁):

java 复制代码
public synchronized void increase() {
    inc++;
}
java 复制代码
Lock lock = new ReentrantLock();
public void increase() {
    lock.lock();
    try {
        inc++;
    } finally {
        lock.unlock();
    }
}
java 复制代码
public AtomicInteger inc = new AtomicInteger();
​
public void increase() {
    inc.getAndIncrement();
}

参考

《 码出高效:Java开发手册》

volatile 关键字,你真的理解吗? - 知乎 (zhihu.com)

Java 并发编程:volatile的使用及其原理 - liuxiaopeng - 博客园 (cnblogs.com)

Java并发常见面试题总结(中) | JavaGuide

相关推荐
LCG元1 小时前
【面试问题】JIT 是什么?和 JVM 什么关系?
面试·职场和发展
xlsw_2 小时前
java全栈day20--Web后端实战(Mybatis基础2)
java·开发语言·mybatis
神仙别闹3 小时前
基于java的改良版超级玛丽小游戏
java
黄油饼卷咖喱鸡就味增汤拌孜然羊肉炒饭3 小时前
SpringBoot如何实现缓存预热?
java·spring boot·spring·缓存·程序员
暮湫3 小时前
泛型(2)
java
超爱吃士力架3 小时前
邀请逻辑
java·linux·后端
南宫生3 小时前
力扣-图论-17【算法学习day.67】
java·学习·算法·leetcode·图论
转码的小石4 小时前
12/21java基础
java
李小白664 小时前
Spring MVC(上)
java·spring·mvc
GoodStudyAndDayDayUp4 小时前
IDEA能够从mapper跳转到xml的插件
xml·java·intellij-idea