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

相关推荐
儿时可乖了3 分钟前
使用 Java 操作 SQLite 数据库
java·数据库·sqlite
ruleslol4 分钟前
java基础概念37:正则表达式2-爬虫
java
xmh-sxh-131421 分钟前
jdk各个版本介绍
java
天天扭码40 分钟前
五天SpringCloud计划——DAY2之单体架构和微服务架构的选择和转换原则
java·spring cloud·微服务·架构
程序猿进阶41 分钟前
堆外内存泄露排查经历
java·jvm·后端·面试·性能优化·oom·内存泄露
FIN技术铺1 小时前
Spring Boot框架Starter组件整理
java·spring boot·后端
小曲程序1 小时前
vue3 封装request请求
java·前端·typescript·vue
陈王卜1 小时前
django+boostrap实现发布博客权限控制
java·前端·django
小码的头发丝、1 小时前
Spring Boot 注解
java·spring boot
java亮小白19971 小时前
Spring循环依赖如何解决的?
java·后端·spring