图1
上述程序执行情况? 单选
0
人0%
A.进入死循环
0
人0%
B.程序执行结束,并打印 i 的值
通过执行代码,我们会看到,该程序执行进入了死循环。子线程修改了共享变量的值,主线程不能直接看到子线程修改后变量的最新值。
为什么多线程下会出现共享变量的不可变性???
我们会从 JMM(Java Memory Mode) 里分析
JMM 规范:
① 所有的共享变量(实例变量和类变量,不包括局部变量,局部变量是线程私有的)都存储于【主内存】;
② 每个线程有自己的【工作内存】,保留了被线程使用的变量的【工作副本】;
③ 线程对变量的所有操作(读、写)在【工作内存】中完成,而不是直接在主内存中;
④ 不同线程之间不能直接访问对方【工作内存】中的变量,线程间变量的值的传递需要通过【主内存】中转完成。
JMM
某些情况下,线程的工作内存会刷新到主内存中。详情可移步
https://www.toutiao.com/i6822144960742031879/
变量不可见性的解决方案
如何实现在多线程下访问共享变量的可见性?
① 加锁;② 对共享变量使用 volatile 关键字
1、加锁
使用 synchronized 后,程序结束循环,输出 i 的值。
为什么加锁可以使共享变量可见?
我们这里简单说下 synchronized 代码块执行过程:
① 线程获取锁
② 清空工作内存
③ 从主内存拷贝共享变量最新值到工作内存成为新的副本
④ 执行代码
⑤ 将修改后的副本刷新回主内存中
⑥ 线程释放锁
2、使用 volatile 修饰共享变量
用 volatile 修饰的共享变量,保证了可见性。
它会保证修改的值会【立即】被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
从 JMM 中,我们可以看到多个处理器将高速缓存中的共享变量刷新到主内存中,需要遵循缓存一致性协议。
为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有 MSI、MESI、MOSI 等。最常见的就是 MESI 协议:
MESI 协议
MESI 是缓存数据的 4 中状态
① M (Modified):被修改的。处于这一状态的数据,只在本 CPU 中有缓存数据,而其他 CPU 中没有。同时其状态相对于内存中的值来说,是已经被修改的,且没有更新到内存中。
② E (Exclusive):独占的。处于这一状态的数据,只有在本 CPU 中有缓存,且其数据没有修改,即与内存中一致。
③ S (Shared):共享的。处于这一状态的数据在多个 CPU 中都有缓存,且与内存一致。
④ I (Invalid):要么已经不在缓存中,要么它的内容已经过时。为了达到缓存的目的,这种状态的段将会被忽略。一旦缓存段被标记为失效,那效果就等同于它从来没被加载到缓存中。
嗅探
通过嗅探技术
① 处于 M 状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回 CPU。
② 处于 S 状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为 I。
③ 处于 E 状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为 S。
④ 只有 E 和 M 可以进行写操作而且不需要额外操作,如果想对 S 状态的缓存字段进行写操作,那必须先发送一个 RFO (Request-For-Ownership) 广播,该广播可以让其他 CPU 的缓存中的相同数据的字段实效,即变成 I 状态。
通过以上机制可以使得处理器在每次读写操作都是原子的,并且每次读到的数据都是最新的。
总线风暴
由于 volatile 的 mesi 缓存一致性协议需要不断的从主内存嗅探和 cas 不断循环无效交互导致总线带宽达到峰值。
所以不要大量使用 Volatile,可以使用 synchronized 代替。
指令重排序
① 编译器优化的重排序,在不改变单线程语义的情况下重新安排语句的执行顺序;
② 指指令级并行重排序,处理器的指令级并行技术将多条指令重迭执行,如果不存在数据的依赖性将会改变语句对应机器指令的执行顺序;
③ 内存系统的重排序,因为使用了读写缓存区,使得看起来并不是顺序执行的。
重排序的好处:
提高处理的速度
举个例子
虽然重排序可以提高执行效率,但是在多线程并发下,可以出现问题。
在单例双重锁模式中,使用 volatile
Singleton singleton = new Singleton();
分三步指令:
① 分配内存地址;
② new 一个 Singleton 对象;
③ 将内存地址赋值给 inst;
CPU 为了提高执行效率,这三步操作的顺序可以是 ①②③,也可以是 ①③②,如果是 ①③② 顺序的话,当把内存地址赋给 inst 后,inst 对象的内存地址上面还没有 new 出来单例对象,这时候,如果就拿到 inst 的话,它其实就是空的,会报空指针异常。这就是为什么双重检查单例模式中,单例对象要加上 volatile 关键字。
volatile 如何保证不被重排序的???
内存屏障
Java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。
volatile写
volatile读
volatile 不能保证原子性
原子性:在一次操作或者多次操作中,要么所有操作全部得到执行并且不会受到任何因素的干扰而中断,要么所有的操作都不执行,volatile 不保证原子性操作。
输出结果:
i++ 操作包含 3 个步骤:
① 从主内存中读取数据到工作内存;
② 对工作内存中的数据进行 ++ 操作;
③ 将工作内存中的数据写到主内存
在多线程环境下, volatile 关键字可以保证共享数据的可见性,但是不能保证对数据操作的原子性。
使用 AtomicInteger 可以解决原子性操作
AtomicInteger 是一个支持原子操作的 Integer 类,它提供了原子自增方法、原子自减方法以及原子赋值方法等。其底层是通过 volatile 和 CAS 实现的,其中 volatile 保证了内存可见性,CAS 算法保证了原子性。
volatile 使用场景
① 适合纯赋值操作,不适合做 i++ 写操作
② 触发器。
我们可以将某个变量设置为 volatile 修饰,当其他线程一旦发现该变量修改的值后,触发获取到的该变量之前的操作都是最新且可见的。
volatile 与 synchronized(以下简称“syn”) 的区别?
① volatile 只能修饰实例变量和类变量,而 syn 可以修饰方法以及代码块;
② voliate 保证了数据的可见性,但是不保证原子性,而 syn 是一种排他(互斥)的机制;
③ voliate 进制指令重排序,可以解决单例双重检查对象初始化代码执行乱序问题;
④ voliate 可以看做轻量级的是syn ,volatile 不保证原子性。
欢迎关注 @Python大星 ,一个会点 Python 的 Java 程序员。文章如有问题,你倒是说啊,喜欢的话,一键三连。
@Python大星 | 文