文章目录
1. 并发编程三个重要特性
原子性
一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。
在 Java 中,可以借助synchronized
、各种 Lock
以及各种原子类
实现原子性。
synchronized
和各种 Lock
可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。各种原子类是利用 CAS (compare and swap) 操作(可能也会用到 volatile
或者final
关键字)来保证原子操作。
可见性
当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。
在 Java 中,可以借助synchronized
、volatile
以及各种 Lock
实现可见性。
如果我们将变量声明为 volatile
,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
有序性
由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。
指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。
在 Java 中,volatile
关键字可以禁止指令进行重排序优化。
2. volatile如何保证变量的可见性?
在 Java 中,volatile
关键字可以保证变量的可见性,如果我们将变量声明为 volatile
,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
volatile是通过 MESI缓存一致性协议 来保证可见性的
MESI协议
MESI协议其实是一个变量在内存中的不同状态!MESI 是指4种状态的首字母:
- M 修改 (Modified): 当一个线程要修改变量
- E 独享、互斥 (Exclusive): 当一个线程拿到了共享变量,此时为独享状态
- S 共享 (Shared) : 当多个线程都拿到了共享变量,此时为共享状态
- I 无效 (Invalid) : 线程丢弃了自己工作内存中的变量,为无效状态
MESI协议如何保证可见性?
- 首先cpu会根据共享变量是否带有volatile字段,来决定是否使用MESI协议保证缓存一致性。
- 如果有volatile,汇编层面会对变量加上
Lock
前缀,当一个线程修改变量的值后,会马上经过store、write等原子操作修改主内存的值(如果不加Lock前缀不会马上同步),为什么监听到修改会马上同步呢?就是为了触发 cpu的嗅探机制, 及时失效其他线程变量副本。
cpu总线嗅探机制
cpu总线嗅探机制监听到这个变量被修改,就会把其他线程的变量副本由共享S置为无效I,当其他线程在使用变量副本时,发现其已经无效,就回去主内存中拿一个最新的值。
写入主内存时在哪里加锁?为什么要加锁?
变量被修改后同步到主内存的过程中会在store之前加锁,写完后解锁,这个锁只有在修改的时候才会加,锁粒度非常小。
因为在store时可能已经经过了总线,但此时还没有write进主内存,总线却触发了嗅探机制,其他线程的变量已失效,当其他线程去主内存读最新数据时,新数据还未write进来,产生脏数据!
Lock前缀的作用
lock
前缀使得本线程工作内存中的volatile变量值立即写入到主内存中,并且使得其他线程共享的该volatile变量无效化,这样其他线程必须重新从主内存中读取变量值。
- 使CPU缓存数据立即写回主内存(volatile修饰的变量会带lock前缀)
- 触发总线嗅探机制和缓存一致性协议MESI来失效其他线程的变量
3. volatile如何保证有序性?
在 Java 中,volatile
关键字除了可以保证变量的可见性,还有一个重要的作用就是禁止 JVM 的指令重排优化,因而实现了有序性。 如果我们将变量声明为 volatile
,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。
内存屏障
内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性。
即内存屏障是一个这样的CPU指令:
- 确保特定操作执行的顺序。
- 影响一些数据的可见性,编译器和CPU可以保证输出结果一样的前提下对指令进行重排序,使得性能优化。当插入一个内存屏障,相当于告诉CPU和编译器,先于这个命令的必须先执行,后于这个命令的必须后执行。
- 强制更新一次不同的CPU缓存,比如一个写屏障会把这个屏障前写入的数据刷新到缓存,任何试图读取该数据的线程将得到最新的值。
系统提供的内存屏障:
- LoadLoad屏障
对于Load1; LoadLoad; Load2 ,操作系统保证在Load2及后续的读操作读取之前,Load1已经读取。 - StoreStore屏障
对于Store1; StoreStore; Store2 ,操作系统保证在Store2及后续的写操作写入之前,Store1已经写入。 - LoadStore屏障
对于Load1; LoadStore; Store2,操作系统保证在Store2及后续写入操作执行前,Load1已经读取。 - StoreLoad屏障
对于Store1; StoreLoad; Load2 ,操作系统保证在Load2及后续读取操作执行前,Store1已经写入,开销较大,但是同时具备其他三种屏障的效果。
volatile
修饰的变量,JMM将在写操作后插入一个写屏障指令,在读操作前插入一个读屏障指令,这分别代表着:
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
public void actor2(I_Result r) {
num = 2;
ready = true; // ready是被volatile修饰的 , 赋值带写屏障
// 写屏障
}
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
public void actor1(I_Result r) {
// 读屏障
// ready是被volatile修饰的 ,读取值带读屏障
if(ready) {
r.r1 = num;
} else {
r.r1 = 1;
}
}
4. volatile为什么不能保证原子性?
原因
写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其它线程的读跑到它前面去。有序性的保证只是保证了本线程内相关代码不被重排序,无法保证其他线程的读写指令交错,所以volatile无法保证原子性!!!
可以看下面这个例子:
public class Test {
public static volatile int inc = 0;
public static void increase(){
inc++;
}
public static void main(String[] args) throws InterruptedException {
//1、创建10个线程
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
//十个线程都调用普通方法
threads[i] = new Thread(()->{
for (int j = 0; j < 1000; j++) {
//inc ++ 操作执行1000次
increase();
}
});
threads[i].start();
}
// 等待1.5秒,保证上面程序执行完成
Thread.sleep(1500);
System.out.println(inc);
}
}
运行结果:
可能我们想的结果应该是:10000
,不过最终运行的结果往往达不到10000,可能我们会有疑问,不对啊,上面是对变量inc进行自增操作,由于volatile
保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值啊,所以有10个线程分别进行了1000次操作,那么最终inc的值应该是1000*10=10000
。
很多人会误认为自增操作 inc++
是原子性的,实际上,inc++
其实是一个复合操作,包括三步:
- 读取 inc 的值
- 对 inc 加 1
- 将 inc 的值写回内存
volatile
是无法保证这三个操作是具有原子性的,比如下面这种情况出现:
线程1读取到了inc
变量的值,这个时候inc
的值为10
,还没有进行自增操作时候线程1阻塞了,紧接着线程2对inc
变量进行操作,注意这个时候inc
的值还是10
,线程2对inc
进行了自增操作,这个时候inc
的值是11
,并将这个改变写到主存中,好了,现在线程1恢复了,它并不会去主存中读取inc
的值,因为inc已经在它的缓存中了,所以继续进行之前的操作,注意这个时候线程1的缓存中inc
的值是10
,线程1对inc
的值进行加1
,inc
等于11
,然后写入主存。
我们发现两个线程都对inc
进行了一轮操作,但是inc
的值只增加了1
。
可能我们还是会有疑问,不对啊,前面不是保证一个变量在修改volatile
变量时,会让缓存行无效吗?然后其他线程去读就会读到新的值,对,这个没错。这个就是上面的happens-before
规则中的volatile
变量规则。
但是要注意, 线程1对变量进行读取操作之后,被阻塞了的话,并没有对inc
值进行修改。然后虽然volatile
能保证线程2对变量inc
的值读取是从内存中读取的,但是线程1没有进行修改,所以线程2根本就不会看到修改的值。
解决方案
如果想要保证上面的代码运行正确也非常简单,利用 synchronized
、Lock
或者AtomicInteger
都可以。
(1)使用 synchronized
改进:
public synchronized void increase() {
inc++;
}
(2)使用 AtomicInteger
改进:
public AtomicInteger inc = new AtomicInteger();
public void increase() {
inc.getAndIncrement();
}
(3)使用 ReentrantLock
改进
Lock lock = new ReentrantLock();
public void increase() {
lock.lock();
try {
inc++;
} finally {
lock.unlock();
}
}
参考文献:
https://blog.csdn.net/Guyui233/article/details/125252111
https://blog.csdn.net/xiewanru/article/details/97644793
https://blog.csdn.net/qq_45783660/article/details/114661595