目录
1、回顾JMM
1.1、可见性(Visibility)
1.2、原子性(Atomicity)
1.3、有序性(Ordering)
2、volatile
2.1、保证可见性
2.2、不保证原子性
2.3、防止指令重排
2.4、什么时候使用volatile
3、小结
1、回顾JMM
JMM(Java Memory Model)是Java内存模型的缩写,它定义了Java程序在多线程环境下内存访问的规则和语义。JMM的几个主要特性包括:可见性、原子性、有序性、顺序一致性。在我的《JVM内存模型》文章中,已经初步介绍了JMM相关特性,现在我们就来详细说说这些特性。
1.1、可见性(Visibility)
串行程序来说,可见性问题是不存在的。因为你在任何一个操作步骤中修改了某个变量,在后续的步骤中读取这个变量的值时,读取的一定是修改后的新值。
可见性是指当一个线程修改了某一个共享变量的值时,其他线程是否能够立即知道这个修改。但是如果一个线程修改了某一个全局变量,那么其他线程未必可以马上知道这个改动。如图:
如果有一个静态(共享)变量t,在 CPU1和CPU2 上各运行了一个线程,CPU1线程要读取变量t,CPU2线程要修改变量t。由于编译器优化或者硬件优化的缘故,在CPU1 上的线程将变量 t 进行了优化,将其缓存在 cache 中或者存器里。在这种情况下,如果在 CPU2 上的某个线程修改了变量t的实际值,那么CPU1 上的线程可能无法意识到这个改动,依然会读取 cache 中或者寄存器里的数据。因此,就产生了可见性问题。外在表现为:变量t的值被修改,但是 CPU1 上的线程依然会读到一个旧值。可见性问题也是并行程序开发中需要重点关注的问题之一。
例如如下代码:
public class VisibilityExample {
private boolean t = false;
public void updateFlag() {
t = true; // 修改共享变量t
}
public void printFlag() {
while (!t) {
// 空循环,等待t变为true
}
System.out.println("t is true");
}
}
在上面的代码中,两个线程分别调用updateFlag和printFlag方法。由于没有同步机制,线程之间对于t的修改可能不可见,导致printFlag方法陷入死循环。
1.2、原子性(Atomicity)
原子性是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
比如,对于一个静态全局变量 int i,两个线程同时对它赋值,线程A 给它赋值 i = 1,线程B 给它赋值为i = 2。那么不管这两个线程以何种方式、何种步调工作,i 的值要么是 1,要么是2。线程A和线程B之间是没有干扰的。这就是原子性的一个特点,不可被中断。但如果我们不使用int型数据而使用 long 型数据,可能就没有那么幸运了。
package jmm;
/**
* @author Shamee loop
* @date 2023/6/17
*/
public class AtomicityDemo {
private static long i;
public static void main(String[] args) {
// 线程赋值 1
new Thread(() -> {
while (true) {
i = 111L;
Thread.yield();
}
}, "thread-write-a").start();
// 线程赋值 2
new Thread(() -> {
while (true) {
i = -222L;
Thread.yield();
}
}, "thread-write-b").start();
// 线程赋值 3
new Thread(() -> {
while (true) {
i = 333L;
Thread.yield();
}
}, "thread-write-c").start();
// 线程读取
new Thread(() -> {
while (true) {
long tmp = i;
if(tmp != 111L && tmp != -222L && tmp != 333L){
System.out.println("读取到的值:" + tmp);
}
Thread.yield();
}
}, "thread-read").start();
}
}
上述代码中3个线程对long数据i进行赋值,分别赋值为111,-222,333。然后有一个线程进行读取i的值。通常来说,由于代码40行我们做了判断,那么41行代码是不会有内容输出的,也就是说i的是肯定是111,-222,333中的一个。事实上,当我们用64位的JDK运行时,并不会有任何问题。
注意控制台第一行是我的JDK版本信息。
而当我们使用32位JDK运行时:
我们看到读取到了相当多根本不存在的值。很多人可能应该想到了。
对于32位系统来说,long型数据的读写不是原子性的(因为 long 型数据有 64 位)。也就是说,如果两个线程同时对long数据进行写入或读取,则对线程之间的结果是会产生干扰的。
因为计算组存储的数据是二进制,因此这些数字都会转化成二进制数据。我们可以将上面的几个相关数字算出他们的补码。就会发现,4294967074等数字是111的前32位和-222的后32位数字合并而成的。也就是说,由于线程并行的关系,数字被乱写了。 而long类型64位,这就导致了读的时候也串了。
这个例子便是我们所说的原子性。
1.3、有序性(Ordering)
对于一个线程的执行代码而言,我们总是习惯性地认为代码是从前往后依次执行的。这么理解也不能说完全错误,因为就一个线程内而言,确实会表现成这样。但是,在并发时,程序的执行可能就会出现乱序。给人的直观感觉就是: 写在前面的代码,会在后面执行。听起来有些不可思议,是吗? 有序性问题的原因是程序在执行时,可能会进行指令重排,重排后的指令与原指令的顺序未必一致。
package jmm;
/**
* @author Shamee loop
* @date 2023/6/17
*/
public class OrderingDemo {
int a = 0;
boolean flag = false;
public void writer(){
a = 1;
flag = true;
}
public void reader(){
if(flag){
int i = a + 1;
// ...
}
}
}
假设线程A先执行了writer() 方法,接着线程B执行了reader()方法。如果发生指令重排,线程B在19行读取的时候,不一定能看到a被成功赋值了。当然,这里说的不是绝对的,前提是如果有发生指令重排的情况下。
因此,对于一个线程来说,他看到的指令执行顺序一定是一致的,也就是说指令重排有一个基本前提,就是保证穿行语义的一致性。
但是并发编程中,就没有义务保证多线程间的语义一致性。
那么,为什么要指令重排?
为了提高程序的性能和优化执行效率。在现代处理器中,存在多级缓存和乱序执行等优化技术,指令重排是其中的一种。指令重排是指编译器或处理器在保持程序执行结果不变的前提下,重新排序指令的执行顺序。它可以通过优化指令的执行顺序,减少处理器的空闲时间,提高指令级并行性和性能。
2、volatile
前面已经简单介绍了JMM,java内存模型都是围绕着原子性,可见性,有序性展开的。而前面我们也介绍到了,不遵循这些特性,以及发生指令重排情况下,可能会有超出期望的情况发生。
为了在适当的场合,确保线程间的有序性、可见性和原子性。Java 使用了一些特殊的操作或者关键字来声明、告诉虚拟机,在这个地方,要尤其注意,不能随意变动优化目标指令。关键字 volatile 就是其中之一。volatile英译:不稳定的,顾名思义。
当你用关键字 volatile 声明一个变量时,就等于告诉了虚拟机,这个变量极有可能会被某些程序或者线程修改。为了确保这个变量被修改后,应用程序范围内的所有线程都能够“看到”这个改动,虚拟机就必须采用一些特殊的手段,保证这个变量的可见性等特点。
比如,根据编译器的优化规则,如果不使用关键字 volatile 声明变量,那么这个变量被修改后,其他线程可能并不会被通知到,甚至在别的线程中,看到变量的修改顺序都会是反的。一旦使用关键字 volatile,虚拟机就会特别小心地处理这种情况。
2.1、保证可见性
- volatile关键字保证了被修饰变量的可见性,即当一个线程修改了该变量的值,其他线程能够立即看到最新的值。
- 普通的变量在多线程环境下存在线程间的不可见性问题,即一个线程修改了变量的值,其他线程无法立即看到修改后的值,而需要通过同步机制(如锁)来保证可见性。
- 使用volatile修饰的变量可以确保对它的写操作对其他线程可见,从而避免了线程间的数据不一致性问题。
针对上面原子性中的代码示例,使用关键字 volatile 进行调整:
// 只需要在变量前声明volatile关键字
private volatile static long i;
执行结果:
2.2、不保证原子性
volatile不能保证原子性,也不能代替锁,它也无法保证一些复合操作的原子性。比如下面的例子,通过关键字 volatile 是无法保证 i++的原子性操作的。
/**
* @author Shamee loop
* @date 2023/3/24
*/
public class VolatileDemo {
public static volatile int num = 0;
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(new AddThread());
threads[i].start();
}
for (int i = 0; i < 10; i++) {
threads[i].join();
}
System.out.println("sum:" + num);
}
public static class AddThread implements Runnable {
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
num++;
}
}
}
}
上述代码中,累计10个线程对i进行累加;每个线程累加10000次,如果是原子性的,那么应该输出是100000。但是看下输出结果:
每次都小于100000。
2.3、防止指令重排
什么是内存屏障?
内存屏障(Memory Barrier),也称为内存栅栏或屏障指令,是一种硬件或软件机制,用于控制指令的执行顺序和内存访问的顺序,保证内存操作的有序性和可见性。内存屏障在多线程编程中起到重要的作用,确保程序的正确性和一致性。
- 保证指令的顺序性:内存屏障可以限制指令的执行顺序,确保指令按照程序的顺序进行执行。在内存屏障之前的指令会先于内存屏障之后的指令执行,不会发生乱序执行的情况。这样可以避免指令重排带来的问题,保证程序的正确性。
- 保证内存操作的有序性:内存屏障可以控制对内存的读写操作顺序,确保内存操作按照程序的顺序进行。在内存屏障之前的内存操作会先于内存屏障之后的内存操作执行,不会发生乱序访问内存的情况。这样可以避免由于内存操作顺序不一致而引起的数据不一致性问题。
- 保证内存可见性:内存屏障可以确保对共享变量的修改对其他线程立即可见。当一个线程在内存屏障之前对共享变量进行写操作时,会将修改后的值刷新到主内存中;而其他线程在内存屏障之后读取该共享变量时,会从主内存中获取最新的值。这样可以保证多线程环境下的共享变量的可见性,避免了数据的不一致性问题。
在volatile变量的读写操作前后会插入内存屏障,确保写入操作先于读取操作,从而避免了指令重排带来的问题。这样可以保证多线程环境下的可见性和一致性,确保变量的修改对其他线程立即可见。
因此:
- volatile关键字禁止了编译器和处理器对被修饰变量操作的重排序,保证了指令执行的有序性。
- 在多线程环境下,指令重排序可能导致线程间的结果不一致性。使用volatile修饰的变量能够确保变量的读写操作按照程序的顺序执行,避免了潜在的线程安全问题。
2.4、什么时候使用volatile
当一个变量被多个线程并发访问和修改时,应该使用Java中的volatile关键字,并且它的值需要对所有线程实时可见。以下是一些适合使用 volatile 关键字的场景:
- 状态标志:如果你有指示进程状态的标志,例如当前正在运行的任务,你可能希望将其标记为易变的。这将确保所有线程都可以看到进程的更新状态。
- 计数器:如果你有一个由多个线程递增的计数器,你可能希望将其标记为易变的。这将确保所有线程都可以看到计数器的当前值并避免竞争条件。
- 配置变量:如果你有一个用于配置系统行为的变量,例如超时值或最大连接数,你可能希望将其标记为易变的。Java 中 volatile 关键字的使用将确保所有线程都可以看到配置变量的当前值并相应地调整它们的行为。
3、小结
volatile尤其要注意的是,他能保证可见性和防止指令重排,但是并不能保证原子性。如果需要保证原子性操作,可以使用原子类(AtomicInteger)或加锁机制来代替volatile。通常我们在创建单例的时候,会使用volatile+双重检查锁来确保线程安全,便是这个道理。