“Volatile”的版本间的差异
Jihongchang(讨论 | 贡献)  | 
				Jihongchang(讨论 | 贡献)   | 
				||
| (未显示同一用户的5个中间版本) | |||
| 第38行: | 第38行: | ||
并不一定,CPU 在运行期间会对指令进行优化,没有依赖关系的指令,它们的顺序可能会被重排。  | 并不一定,CPU 在运行期间会对指令进行优化,没有依赖关系的指令,它们的顺序可能会被重排。  | ||
| − | 在单线程执行的情况下,发生重排是没有问题的,CPU   | + | 在单线程执行的情况下,发生重排是没有问题的,CPU 保证了顺序不一定一致,但结果一定一致(重排前和重排后指令执行得到的结果一致)。  | 
但在多线程环境下,重排序则会引起很大的问题,这又涉及了线程安全的要素:'''<big>有序性</big>'''  | 但在多线程环境下,重排序则会引起很大的问题,这又涉及了线程安全的要素:'''<big>有序性</big>'''  | ||
| 第44行: | 第44行: | ||
有序性是指程序执行的顺序应当按照代码的先后顺序执行。  | 有序性是指程序执行的顺序应当按照代码的先后顺序执行。  | ||
| − | + | 为了更好地理解有序性,下面通过一个例子来分析:<syntaxhighlight lang="java">  | |
| + | //成员变量 i  | ||
| + | int i = 0;  | ||
| + | |||
| + | //线程一的执行代码  | ||
| + | Thread.sleep(10);  | ||
| + | i++;  | ||
| + | f = true;  | ||
| + | //线程二的执行代码  | ||
| + | while(!f){  | ||
| + |     System.out.println(i);  | ||
| + | }  | ||
| + | |||
| + | </syntaxhighlight>理想的结果应该是:线程二不停地打印 0,最后打印一个1,终止。  | ||
| + | |||
| + | 在线程一里,f 和 i 没有依赖性,如果发生了指令重排,那么 f = true 发生在 i++ 之前,就有可能导致线程二在终止循环前输出的全部是 0。  | ||
| + | |||
| + | 需要注意的是,这种情况并不常见,再次运行并不一定能重现,正因为如此,很可能会导致出现一些莫名的问题。  | ||
| + | |||
| + | 如果修改上方代码中 i 的定义为使用 volatile 关键字来修饰,那么就可以保证最后的输出结果符合预期。  | ||
| + | |||
| + | 这是因为,被 volatile 修饰的变量,CPU 不会对它做重排序优化,所以也就保证了有序性。  | ||
| + | |||
| + | |||
| + | |||
| + | |||
| + | ==== volatile 不保证操作的原子性 ====  | ||
| + | 原子性:一个或多个操作,要么全部连续执行且不会被任何因素中断,要么就都不执行。  | ||
| + | |||
| + | 一眼看上去,这个概念和数据库概念里的事务(Transaction)很类似,没错,事务就是一种原子性操作。  | ||
| + | |||
| + | 原子性、可见性和有序性,是线程安全的三要素。  | ||
| + | |||
| + | 需要特别注意的是,volatile 保证可见性和有序性,但是不保证操作的原子性,下面的代码将会证明这一点:<syntaxhighlight lang="java">  | ||
| + | import java.util.HashMap;  | ||
| + | import java.util.Map;  | ||
| + | |||
| + | public class TestVolatile3 {  | ||
| + | |||
| + |     static volatile int intVal = 0;  | ||
| + | |||
| + |     public static void main(String[] args) {  | ||
| + | |||
| + |         Map<Long, Long> map = new HashMap<>();  | ||
| + | |||
| + |         //创建10个线程,执行简单的自加操作  | ||
| + |         for (int i = 0; i < 10; i++) {  | ||
| + |             new Thread(() -> {  | ||
| + |                 for (int j = 0; j < 1000; j++) {  | ||
| + |                     intVal++;  | ||
| + |                     if (j == 999) {  | ||
| + |                         System.out.printf("id:%d 已经循环到999\n", Thread.currentThread().getId());  | ||
| + |                     }  | ||
| + |                 }  | ||
| + |             }).start();  | ||
| + |         }  | ||
| + | |||
| + |         // 保证之前启动的全部线程执行完毕  | ||
| + |         while(Thread.activeCount() > 1) {  | ||
| + |             Thread.yield();  | ||
| + |         }  | ||
| + | |||
| + |         System.out.println(intVal);  | ||
| + | |||
| + |     }  | ||
| + | |||
| + | }  | ||
| + | |||
| + | </syntaxhighlight>注意:上面代码在 Windows 命令行中运行没有问题:<syntaxhighlight lang="powershell">  | ||
| + | E:\record\2023\3\2>javac TestVolatile3.java -encoding utf-8  | ||
| + | |||
| + | E:\record\2023\3\2>java TestVolatile3  | ||
| + | id:13 已经循环到 999  | ||
| + | id:17 已经循环到 999  | ||
| + | id:16 已经循环到 999  | ||
| + | id:19 已经循环到 999  | ||
| + | id:21 已经循环到 999  | ||
| + | id:20 已经循环到 999  | ||
| + | id:22 已经循环到 999  | ||
| + | id:14 已经循环到 999  | ||
| + | id:18 已经循环到 999  | ||
| + | id:15 已经循环到 999  | ||
| + | 7759  | ||
| + | </syntaxhighlight>可以进行测试  | ||
| + | |||
| + | '''但在 IDEA 中运行的话会出问题:IDEA 启动程序后出了有“main”线程,还会多出一个“Monitor Ctrl-Break”的线程,所以会因 <code>while(Thread.activeCount() > 1)</code>  陷入死循环'''  | ||
| + | |||
| + | 在之前的内容有提及,volatile 能保证修改后的数据对所有线程可见,那么,这一段对 intVal 自增的代码,最终执行完毕的时候,intVal 应该为 10000。  | ||
| + | |||
| + | 但事实上,结果是不确定的,大部分情况下会小于 10000。这是因为,无论是 volatile 还是自增操作,都不具备原子性。  | ||
| + | |||
| + | 假设 intVal 初始值为 100,自增操作的指令执行顺序如下所示:  | ||
| + | |||
| + | # 获取 intVal,此时主存内 intVal 值为 100;  | ||
| + | # intVal 执行 +1, 得到 101,此时主存内 intVal 值仍然为 100;  | ||
| + | # 将 101 写回给 intVal,此时主存内 intVal 值从 100 变化为 101。  | ||
| + | |||
| + | 具体执行流程如图:  | ||
| + | [[文件:自增操作实现原理.png|无|缩略图|362x362像素]]这个过程很容易理解,如果这段指令发生在多线程环境下呢?  | ||
| + | |||
| + | 以下面这段会发生错误的指令顺序为例:  | ||
| + | |||
| + | # 线程一获得了 intVal 值为 100;  | ||
| + | # 线程一执行+1,得到 101,此时值没有写回给主存;  | ||
| + | # 线程二在主存内获得了 intVal 值为 100;  | ||
| + | # 线程二执行+1,得到 101;  | ||
| + | # 线程一写回 101;  | ||
| + | # 线程二写回 101;  | ||
| + | |||
| + | 于是,最终主存内的 intVal 值,还是 101。具体执行流程如图:  | ||
| + | [[文件:多线程执行自增操作的结果.png|无|缩略图|512x512像素]]  | ||
| + | 为什么 volatile 的可见性保证在这里没有生效?  | ||
| + | |||
| + | 根据 volatile 保证可见性的原理(内存屏障),当一个线程执行写的时候,才会改变“数据修改”的标量,在上述过程中,线程一在执行加法操作发生后,写回操作发生前,CPU 开始处理线程二的时间片,执行了另外一次读取 intVal,此时 intVal 值为 100,且由于写回操作尚未发生,这一次读取是成功的。  | ||
| + | |||
| + | 因此,出现了最后计算结果不符合预期的情况。  | ||
| + | |||
| + | synchronized 关键字确实可以解决多线程的原子操作问题,可以修改上面的代码为:<syntaxhighlight lang="java">  | ||
| + |         Object lock = new Object();  | ||
| + |         for (int i = 0; i < 10; i++) {  | ||
| + |             new Thread(() -> {  | ||
| + |                 synchronized (lock) {  | ||
| + |                     for (int j = 0; j < 1000; j++) {  | ||
| + |                         intVal++;  | ||
| + |                     }  | ||
| + |                 }  | ||
| + |             }).start();  | ||
| + |         }  | ||
| + | </syntaxhighlight>但是,这种方式明显效率不高,10个线程都在争抢同一个代码块的使用权。(其实可以通过 CAS 来保证原子性可以获得更高的效率)  | ||
| + | |||
| + | 由此可见,volatile 只能提供线程安全的两个必要条件:可见性和有序性。  | ||
2023年3月2日 (四) 10:54的最新版本
volatile 的使用是为了线程安全,但 volatile 不保证线程安全。
线程安全有三个要素:可见性、有序性和原子性。
线程安全是指在多线程情况下,对共享内存的使用,不会因为不同线程的访问和修改发生与预期不符的情况。
volatile 的作用
volatile 有以下三个作用:
volatile 用于解决多核 CPU cache(高速缓存)导致的变量不同步
这本质上是个硬件问题,其根源在于:CPU 的高速缓存的读取速度远远快于主存(物理内存)。
所以,CPU 在读取一个变量的时候,会把数据先读取到缓存,这样下次再访问同一个数据的时候就可以直接从缓存读取了,显然提高了读取的性能。
而多核 CPU 有多个这样的缓存。这就带来了问题,当某个 CPU(例如 CPU1)修改了这个变量(比如把 a 的值从 1 修改为 2 ),但是其他的 CPU(例如 CPU2)在修改前已经把 a=1 读取到自己的缓存了,当 CPU2 再次读取数据的时候,它仍然会去自己的缓存区中读取,此时读取到的值仍然是 1,但是实际上这个值已经变成 2 了。
这里,就涉及了线程安全的要素:可见性。
可见性是指当多个线程在访问同一个变量时,如果其中一个线程修改了变量的值,那么其他线程应该能立即看到修改后的值。
volatile 的实现原理是内存屏障(Memory Barrier),其原理为:当 CPU 写数据时,如果发现一个变量在其他 CPU 中存有副本,那么会发出信号通知其他 CPU 将该副本对应的缓存行置为无效状态,当其他 CPU 读取到 变量副本的时候,会发现该缓存行是无效的,然后,它会从主存重新读取变量。
volatile 可以解决指令重排序的问题
一般情况下,程序是按照顺序执行的,例如下面的代码:
1        int i = 0;
2        i++;
3        boolean f = false;
4        f = true;
如果 i++ 发生在 int i = 0 之前,那么会不可避免地出错,CPU 在执行代码对应指令的时候,会认为 1、2 两行是具备依赖性的,因此,CPU 一定会安排行 1 早于行 2 执行。
那么,int i = 0 一定会早于 boolean f = false 吗?
并不一定,CPU 在运行期间会对指令进行优化,没有依赖关系的指令,它们的顺序可能会被重排。
在单线程执行的情况下,发生重排是没有问题的,CPU 保证了顺序不一定一致,但结果一定一致(重排前和重排后指令执行得到的结果一致)。
但在多线程环境下,重排序则会引起很大的问题,这又涉及了线程安全的要素:有序性
有序性是指程序执行的顺序应当按照代码的先后顺序执行。
为了更好地理解有序性,下面通过一个例子来分析:
//成员变量 i
int i = 0;
//线程一的执行代码
Thread.sleep(10);
i++;
f = true;
//线程二的执行代码
while(!f){
    System.out.println(i);
}
理想的结果应该是:线程二不停地打印 0,最后打印一个1,终止。
在线程一里,f 和 i 没有依赖性,如果发生了指令重排,那么 f = true 发生在 i++ 之前,就有可能导致线程二在终止循环前输出的全部是 0。
需要注意的是,这种情况并不常见,再次运行并不一定能重现,正因为如此,很可能会导致出现一些莫名的问题。
如果修改上方代码中 i 的定义为使用 volatile 关键字来修饰,那么就可以保证最后的输出结果符合预期。
这是因为,被 volatile 修饰的变量,CPU 不会对它做重排序优化,所以也就保证了有序性。
volatile 不保证操作的原子性
原子性:一个或多个操作,要么全部连续执行且不会被任何因素中断,要么就都不执行。
一眼看上去,这个概念和数据库概念里的事务(Transaction)很类似,没错,事务就是一种原子性操作。
原子性、可见性和有序性,是线程安全的三要素。
需要特别注意的是,volatile 保证可见性和有序性,但是不保证操作的原子性,下面的代码将会证明这一点:
import java.util.HashMap;
import java.util.Map;
public class TestVolatile3 {
    static volatile int intVal = 0;
    public static void main(String[] args) {
        Map<Long, Long> map = new HashMap<>();
        //创建10个线程,执行简单的自加操作
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    intVal++;
                    if (j == 999) {
                        System.out.printf("id:%d 已经循环到999\n", Thread.currentThread().getId());
                    }
                }
            }).start();
        }
        // 保证之前启动的全部线程执行完毕
        while(Thread.activeCount() > 1) {
            Thread.yield();
        }
        System.out.println(intVal);
    }
}
注意:上面代码在 Windows 命令行中运行没有问题:
E:\record\2023\3\2>javac TestVolatile3.java -encoding utf-8
E:\record\2023\3\2>java TestVolatile3
id:13 已经循环到 999
id:17 已经循环到 999
id:16 已经循环到 999
id:19 已经循环到 999
id:21 已经循环到 999
id:20 已经循环到 999
id:22 已经循环到 999
id:14 已经循环到 999
id:18 已经循环到 999
id:15 已经循环到 999
7759
可以进行测试
但在 IDEA 中运行的话会出问题:IDEA 启动程序后出了有“main”线程,还会多出一个“Monitor Ctrl-Break”的线程,所以会因 while(Thread.activeCount() > 1)  陷入死循环
在之前的内容有提及,volatile 能保证修改后的数据对所有线程可见,那么,这一段对 intVal 自增的代码,最终执行完毕的时候,intVal 应该为 10000。
但事实上,结果是不确定的,大部分情况下会小于 10000。这是因为,无论是 volatile 还是自增操作,都不具备原子性。
假设 intVal 初始值为 100,自增操作的指令执行顺序如下所示:
- 获取 intVal,此时主存内 intVal 值为 100;
 - intVal 执行 +1, 得到 101,此时主存内 intVal 值仍然为 100;
 - 将 101 写回给 intVal,此时主存内 intVal 值从 100 变化为 101。
 
具体执行流程如图:
这个过程很容易理解,如果这段指令发生在多线程环境下呢?
以下面这段会发生错误的指令顺序为例:
- 线程一获得了 intVal 值为 100;
 - 线程一执行+1,得到 101,此时值没有写回给主存;
 - 线程二在主存内获得了 intVal 值为 100;
 - 线程二执行+1,得到 101;
 - 线程一写回 101;
 - 线程二写回 101;
 
于是,最终主存内的 intVal 值,还是 101。具体执行流程如图:
为什么 volatile 的可见性保证在这里没有生效?
根据 volatile 保证可见性的原理(内存屏障),当一个线程执行写的时候,才会改变“数据修改”的标量,在上述过程中,线程一在执行加法操作发生后,写回操作发生前,CPU 开始处理线程二的时间片,执行了另外一次读取 intVal,此时 intVal 值为 100,且由于写回操作尚未发生,这一次读取是成功的。
因此,出现了最后计算结果不符合预期的情况。
synchronized 关键字确实可以解决多线程的原子操作问题,可以修改上面的代码为:
        Object lock = new Object();
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                synchronized (lock) {
                    for (int j = 0; j < 1000; j++) {
                        intVal++;
                    }
                }
            }).start();
        }
但是,这种方式明显效率不高,10个线程都在争抢同一个代码块的使用权。(其实可以通过 CAS 来保证原子性可以获得更高的效率)
由此可见,volatile 只能提供线程安全的两个必要条件:可见性和有序性。

