这篇文章比较长,请耐心看完,相信会让你对并发三大特性有一个较深的理解。
1.原子性(Atomicity)
1.1 原子性定义以及理解
即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
一个很经典的例子就是银行账户转账问题:
比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。
所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。
1.2 java的原子性
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作。
a = true;//1
a = 5;//2
a = b;//3
a = b + 2;//4
a ++;//5
上面的5个基本数据类型的操作,只有1和2是原子性的。
a = true:包含一个操作,1.将true的赋值给a。
a = 5:包含一个操作,1.将5的赋值给a。
a = b:包含两个操作,1.读取b的值;2.将b的值赋值给a。
a = b + 2:包含三个操作,1.读取b的值;2.计算b+2;3.将b+2的计算结果赋值给a。
a ++:即a = a + 1,包含三个操作,读取a的值;2计算a+1;3.将a+1的计算结果赋值给a
1.3 原子性问题
i ++是安全的吗?
在多线程的并发下, 此操作是不安全的, 它不是一个原子操作, i ++ 可分为三步三条 CPU 指令 :1.需要把变量 count 从内存加载到工作内存; 2.在工作内存执行 +1 操作;3.将结果写入内存;
线程A 读入count之后, 线程B也读入了count, 也就是说, 线程A的三条指令本应是不可分割的,但在这三步操作间又插入了线程B的操作, 致使我们最终得到的结果有误
/**
* <p>原子性示例:不是原子性</p>
*
* @author lbq 2024/6/05 10:58
**/
static class Increment {
private int count = 1;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
下面的代码展示了在多线程环境中,调用此自增器进行自增操作。
int type = 0;//类型
int num = 50000;//自增次数
int sleepTime = 5000;//等待计算时间
int begin;//开始的值
Increment increment;
//不进行原子性保护的大范围操作
increment = new Increment();
begin = increment.getCount();
LOGGER.info("Java中普通的自增操作不是原子性操作。");
LOGGER.info("当前运行类:" +increment.getClass().getSimpleName() + ",count的初始值是:" + increment.getCount());
for (int i = 0; i < num; i++) {
new Thread(() -> {
increment.increment();
}).start();
}
//等待足够长的时间,以便所有的线程都能够运行完
Thread.sleep(sleepTime);
LOGGER.info("进过" + num + "次自增,count应该 = " + (begin + num) + ",实际count = " + increment.getCount());
某次运行结果:
Java中普通的自增操作不是原子性操作。
当前运行类:Increment,count的初始值是:1
进过50000次自增,count应该 = 50001,实际count = 49999
通过观察结果,发现程序确实存在原子性问题。
1.4 原子性保障技术
在Java中提供了多种原子性保障措施,这里主要涉及三种:
- 通过synchronized关键字定义同步代码块或者同步方法保障原子性。
- 通过Lock接口保障原子性。
- 通过Atomic类型保障原子性。
首先第一种方式就是加锁, Java提供的 synchronized 关键字,就是锁的一种实现 , 我们提供一把锁, 只有某一线程拿到这把锁才能对此变量进行操作, 其他线程进来后发现没有获得锁,只能在外等待, 所以synchronized锁能够很好的保持原子性 ,同样它还可以保持可见性和有序性(因为被锁住只能由一个线程进行操作)
因为篇幅有限,其他俩种不做过多解释,想要了解可以继续查阅资料学习
2.可见性(Visibility)
2.1 可见性定义
可见性:当一个线程修改了共享变量的值,其他线程能够看到修改的值。
2.2 java的可见性
场景说明:
- 存在两个线程A、线程B和一个共享变量stop。
- 如果stop变量的值是false,则线程A会一直运行。如果stop变量的值是true,则线程A会停止运行。
- 线程B能够将共享变量stop的值修改为ture。
//普通情况下,多线程不能保证可见性
private static boolean stop;
new Thread(() -> {
System.out.println("Ordinary A is running...");
while (!stop) ;
System.out.println("Ordinary A is terminated.");
}).start();
Thread.sleep(10);
new Thread(() -> {
System.out.println("Ordinary B is running...");
stop = true;
System.out.println("Ordinary B is terminated.");
}).start();
运行结果:
Ordinary A is running...
Ordinary B is running...
Ordinary B is terminated.
从结果观察,发现线程B运行结束了,也就是说已经修改了共享变量stop的值。但是线程A还在运行,也就是说线程A并没有用接收到stop=true这个修改。
2.3 可见性问题
在如今的多核CPU中 , 由于CPU与内存存在速度差异, 所以每个核都有自己的缓存, 缓存是私有的, 这就导致CPU缓存与内存中的数据可能会出现不一致的情况。CPU在自己的缓冲区拿到数据进行写操作之后, 并不会立刻将这个数据更新回内存中, 如果此时来了另一个线程, 它此时应该读入的是上一个线程修改后的值, 但由于不是即时更新的,此时第二个线程拿到的还是原本的值, 也就是说, 这两个线程之间对此变量操作是不可见的, 这就是可见性问题。
如上图, 内存中有一个共享变量a=0, 此时线程1 先进入, 操作之后 a=1, 但此时还没来及将这个值更新回内存, 线程2就将 a =0又读入了它里面, 此时两边最终更新的值都为1(原本应为2)。
2.4 可见性保障技术
在Java中提供了多种可见性保障措施,这里主要涉及四种:
- 通过volatile关键字标记内存屏障保证可见性。
- 通过synchronized关键字定义同步代码块或者同步方法保障可见性。
- 通过Lock接口保障可见性。
- 通过Atomic类型保障可见性。
3. 有序性(orderly)
3.1 有序性定义
有序性:即程序执行的顺序按照代码的先后顺序执行。
3.2 Java自带的有序性
在Java中,由于happens-before原则,单线程内的代码是有序的,可以看做是串行(as-if-serial)执行的。但是在多线程环境下,多个线程的代码是交替的串行执行的,这就产生了有序性问题。
先来看 happens-before 关系的定义:
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果就会对第二个操作可见
- 两个操作之间如果存在 happens-before 关系,并不意味着 Java 平台的具体实现就必须按照 happens-before 关系指定的顺序来执行.如果重排序之后的执行结果,与按照 happens-before 关系来执行的结果一直,那么 JMM 也允许这样的重排序。
备注:
as-if-serial 语义保证的是单线程内重排序之后的执行结果和程序代码本身应该出现的结果是一致的, happens-before 关系保证的是正确同步的多线程程序的执行结果不会被重排序改变.
(什么是as-if-serial ,别着急,最后会介绍重排序,再回来看就行)
一句话来总结就是:如果操作 A happens-before 操作 B ,那么操作 A 在内存上所做的操作对操作 B 都是可见的,不管它们在不在一个线程.
Java提供了happens-before原则保证程序基本的有序性,主要规则如下:
线程内部规则:在同一个线程内,前面操作的执行结果对后面的操作是可见的。
同步规则:如果一个操作x与另一个操作y在同步代码块/方法中,那么操作x的执行结果对操作y可见。
传递规则:如果操作x的执行结果对操作y可见,操作y的执行结果对操作z可见,则操作x的执行结果对操作z可见。
对象锁规则:如果线程1解锁了对象锁a,接着线程2锁定了a,那么,线程1解锁a之前的写操作的执行结果都对线程2可见。
volatile变量规则:如果线程1写入了volatile变量v,接着线程2读取了v,那么,线程1写入v及之前的写操作的执行结果都对线程2可见。
线程start原则:如果线程t在start()之前进行了一系列操作,接着进行了start()操作,那么线程t在start()之前的所有操作的执行结果对start()之后的所有操作都是可见的。
线程join规则:线程t1写入的所有变量,在任意其它线程t2调用t1.join()成功返回后,都对t2可见。
而有序性问题,都是发生在happens-before原则之外的状况。
3.3 有序性问题
编译器为了优化性能,有时候会改变程序中语句的先后顺序。
例如程序中:“a=6;b=7;”
编译器优化后可能变成 “b=7;a=6;”
。
在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的Bug。
这句红色字体的话在下面重排序会具体解释
3.4 有序性问题的解决
在Java中提供了多种有序性保障措施,这里主要涉及四种:
- 通过volatile关键字标记内存屏障保证可见性。
- 通过synchronized关键字定义同步代码块或者同步方法保障可见性。
- 通过Lock接口保障可见性。
补充扩展-重排序问题
1. 重排序概念
“缓存不能及时刷新“(2.3 可见性问题)和“编译器为了优化性能而改变程序中语句的先后顺序”(3.3 有序性问题 )都是重排序的一种。
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。
从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
1.编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2.指令级并行的重排序。处理器将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3.内存系统的重排序。处理器使用缓存和读/写缓冲区,使得加载和存储操作看上去可能是在乱序执行。
举例:如下代码执行过程中,程序不一定按照先A后B的顺序执行,经重排序之后可能按照先B后A的顺序执行。
int a = 1;// A
int b = 2;// B
2. 重排序规则
重排序需要遵守一定规则,以保证程序正确执行。
数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。
存在数据依赖性的三种情况:
① 写后读:a = 1;b = a; 写一个变量之后,再读这个位置。
② 写后写:a = 1;a = 2; 写一个变量之后,再写这个变量。
③ 读后写:a = b;b = 1;读一个变量之后,再写这个变量。
存在数据依赖关系的两个操作,不可以重排序。
数据依赖性只针对单个处理器中执行的指令序列和单个线程中执行的操作。
举例:
同一个线程中执行a=1;b=1; 不存在数据依赖性,可能重排序。
同一个线程中执行a=1;b=a; 存在数据依赖性,不可以重排序。
重排序遵守as-if-serial 语义
as-if-serial 语义:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。
举例,以计算圆的面积为例:
double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C
A和B重排序之后,程序的执行结果不会改变,所以允许A、B重排序。A和C重排序之后,程序的执行结果会改变,所以不允许A、C重排序。
所以在这里,我看来,遵守数据依赖性和as-if-serial 语义实质上是一回事。为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。
3. 重排序带来的问题
重排序可以提高程序执行的性能,但是代码的执行顺序改变,可能会导致多线程程序出现可见性问题和有序性问题。
备注:指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。
例如,在两个并发线程A和B中,如果线程A有三个操作A1、A2、A3,线程B有三个操作B1、B2、B3,并且线程A正确释放锁后线程B获取同一把锁,那么在顺序一致性模型下,两个线程会观察到一个整体有序的操作序列,如 A1->A2->A3->B1->B2->B3。
然而,实际硬件和编译器并不遵循如此严格的顺序一致性模型,而是允许一定的指令重排序以提升性能。
就可能出现下面的情况:
4.volatile 关键字
Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。
当一个变量定义为 volatile 之后,将具备两种特性:
1.保证此变量对所有的线程的可见性,这里的“可见性”,当一个线程修改了这个变量的值,volatile 保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。但普通变量做不到这点,普通变量的值在线程间传递均需要通过主内存来完成。
2.禁止指令重排序优化。有volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置)。