貌似两个多月没写博客, 不知道年前这段时间都去忙了什么.
好久以前写过一次和 volatile 相关的博客, 感觉没写的那么深入吧, 这次我们继续说我们的 volatile 关键字.
复习:
先来简单的复习一遍以前写过的东西, 上次我们说了内存一致性协议 M(修改)E(独占)S(共享)I(失效)四种状态, 还有我们并发编程的三大特性原子性, 一致性和可见性. 再就是简单的提到了我们的 volatile 关键字, 他可以保证我们的可见性, 也就是说被 volatile 关键字修饰的变量如果产生了变化, 可以马上刷到主存当中去. 我们接下来看一下我们这次博客的内容吧.
线程:
何为线程呢? 这也是我们面试当中经常问到的. 按照官方的说法是: 现代操作系统在运行一个程序时, 会为其创建一个进程. 例如, 启动一个 Java 程序, 操作 系统就会创建一个 Java 进程. 现代操作系统调度 CPU 的最小单元是线程. 比如我们启动 QQ, 就是我们启动了一个进程, 我们发起了 QQ 语音, 这个动作就是一个线程.
在这里多提一句的就是线程分为内核级线程和用户级线程, 我们在 java 虚拟机内的线程一般都为用户级线程, 也就是由我们的 jvm 虚拟机来调用我们的 CPU 来申请时间片来完成我们的线程操作的. 而我们的内核级线程是由我们的系统来调度 CPU 来完成的, 为了保证安全性, 一般的线程都是由虚拟机来控制的.
用户线程: 指不需要内核支持而在用户程序中实现的线程, 其不依赖于操作系统核心, 应用进程利用线程库提供创建, 同步, 调度和管理线程的函数来控制用户线程. 另外, 用户线程是由应用进程利用线程库创建和管理, 不依赖于操作系统核心. 不需要用户态 / 核心态切换, 速度快. 操作系统内核不知道多线程的存在, 因此一个线程阻塞将使得整个进程 (包括它的所有线程) 阻塞. 由于这里的处理器时间片分配是以进程为基本单位, 所以每个线程执行的时间相对减少.
内核线程: 线程的所有管理操作都是由操作系统内核完成的. 内核保存线程的状态和上下文信息, 当一个线程执行了引起阻塞的系统调用时, 内核可以调度该进程的其他线程执行. 在多处理器系统上, 内核可以分派属于同一进程的多个线程在多个处理器上运行, 提高进程执行的并行度. 由于需要内核完成线程的创建, 调度和管理, 所以和用户级线程相比这些操作要慢得多, 但是仍然比进程的创建和管理操作要快. 大多数市场上的操作系统, 如 Windows,Linux 等都支持内核级线程.
用户级线程就是我们常说的 ULT, 内核级线程就是我们说的 KLT. 线程从用户态切换到内核态时会消耗很大的性能和时间, 后面说 sychronized 锁的膨胀升级会说到这个过程.
上下文切换:
上面我们说过, 线程是由我们的虚拟机去 CPU 来申请时间片来完成我们的操作的, 但是不一定马上执行完成, 这时就产生了上下文切换. 大致就是这样的:
线程 A 没有运行完成, 但是时间片已经结束了, 我们需要挂起我们的线程 A,CPU 该去执行线程 B 了, 运行完线程 B, 才能继续运行我们的线程 A, 这时就涉及到一个上下文的切换, 我们把这个暂时挂起到再次运行的过程, 可以理解为上下文切换(最简单的理解方式).
可见性:
用 volatile 关键字修饰过的变量, 可以保证可见性, 也就是 volatile 变量被修改了, 会立即刷到主内存内, 让其他线程感知到变量已经修改, 我们来看一个事例
- public class VolatileVisibilitySample {
- private volatile boolean initFlag = false;
- public void refresh(){
- this.initFlag = true;
- String threadname = Thread.currentThread().getName();
- System.out.println("线程:"+threadname+": 修改共享变量 initFlag");
- }
- public void load(){
- String threadname = Thread.currentThread().getName();
- int i = 0;
- while (!initFlag){
- }
- System.out.println("线程:"+threadname+"当前线程嗅探到 initFlag 的状态的改变"+i);
- }
- public static void main(String[] args){
- VolatileVisibilitySample sample = new VolatileVisibilitySample();
- Thread threadA = new Thread(()->{
- sample.refresh();
- },"threadA");
- Thread threadB = new Thread(()->{
- sample.load();
- },"threadB");
- threadB.start();
- try {
- Thread.sleep(2000);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- threadA.start();
- }
- }
我们想创建一个全局的由 volatile 修饰的 boolean 变量, refresh 方法是修改我们的全局变量, load 方法是无限循环去检查我们全局 volatile 修饰过的变量, 我们开启两个线程, 开始运行, 我们会看到如下结果.
也就是说, 我们的变量被修改以后, 我们的另外一个线程会感知到我们的变量已经发生了改变, 也就是我们的可行性, 立即刷回主内存.
有序性:
说到有序性, 不得不提到几个知识点, 指令重排, as-if-serial 语义和 happens-before 原则.
指令重排: java 语言规范规定 JVM 线程内部维持顺序化语义. 即只要程序的最终结果与它顺序化情况的结果相等, 那么指令的执行顺序可以与代码顺序不一致, 此过程叫指令的重排序. 指令重排序的意义是什么? JVM 能根据处理器特性 (CPU 多级缓存系统, 多核处理器等) 适当的对机器指令进行重排序, 使机器指令能更符合 CPU 的执行特性, 最大限度的发挥机器性能.
指令重排一般发生在 class 翻译为字节码文件和字节码文件被 CPU 执行这两个阶段.
as-if-serial 语义的意思是: 不管怎么重排序 (编译器和处理器为了提高并行度),(单线程) 程序的执行结果不能被改变. 编译器, runtime 和处理器都必须遵守 as-if-serial 语义. 为了遵守 as-if-serial 语义, 编译器和处理器不会对存在数据依赖关系的操作做重排序, 因 为这种重排序会改变执行结果. 但是, 如果操作之间不存在数据依赖关系, 这些操作就可能被 编译器和处理器重排序.
happens-before 原则内容如下
1. 程序顺序原则, 即在一个线程内必须保证语义串行性, 也就是说按照代码顺序执行.
2. 锁规则 解锁 (unlock) 操作必然发生在后续的同一个锁的加锁 (lock) 之前, 也就是说, 如果对于一个锁解锁后, 再加锁, 那么加锁的动作必须在解锁动作之后(同一个锁).
3. volatile 规则 volatile 变量的写, 先发生于读, 这保证了 volatile 变量的可见性, 简单的理解就是, volatile 变量在每次被线程访问时, 都强迫从主内存中读该变量的值, 而当该变量发生变化时, 又会强迫将最新的值刷新到主内存, 任何时刻, 不同的线程总是能够看到该变量的最新值.
4. 线程启动规则 线程的 start()方法先于它的每一个动作, 即如果线程 A 在执行线程 B 的 start 方法之前修改了共享变量的值, 那么当线程 B 执行 start 方法时, 线程 A 对共享变量的修改对线程 B 可见
5. 传递性 A 先于 B ,B 先于 C, 那么 A 必然先于 C
6. 线程终止规则 线程的所有操作先于线程的终结, Thread.join()方法的作用是等待当前执行的线程终止. 假设在线程 B 终止之前, 修改了共享变量, 线程 A 从线程 B 的 join 方法成功返回后, 线程 B 对共享变量的修改将对线程 A 可见.
7. 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生, 可以通过 Thread.interrupted()方法检测线程是否中断.
8. 对象终结规则 对象的构造函数执行, 结束先于 finalize()方法.
上一段代码看看指令重排的问题.
- public class VolatileReOrderSample {
- private static int x = 0, y = 0;
- private static int a = 0, b = 0;
- public static void main(String[] args) throws InterruptedException {
- int i = 0;
- for (; ; ) {
- i++;
- x = 0;
- y = 0;
- a = 0;
- b = 0;
- Thread t1 = new Thread(new Runnable() {
- public void run() {
- a = 1;
- x = b;
- }
- });
- Thread t2 = new Thread(new Runnable() {
- public void run() {
- b = 1;
- y = a;
- }
- });
- t1.start();
- t2.start();
- t1.join();
- t2.join();
- String result = "第" + i + "次 (" + x + "," + y + ")";
- if (x == 0 && y == 0) {
- System.err.println(result);
- break;
- } else {
- System.out.println(result);
- }
- }
- }
- }
我们来分析一下上面的代码
情况 1: 假设我们的线程 1 开始执行, 线程 2 还没开始, 这时 a = 1 ,x = b = 0, 因为 b 的初始值是 0, 然后开始执行线程 2,b = 1,y = a = 1, 得到结论 x = 0 ,y = 1.
情况 2: 假设线程 1 开始执行, 将 a 赋值为 1, 开始执行线程 2,b 赋值为 1, 并且 y = a = 1, 这时继续运行线程 1,x = b = 1, 得到结论 x = 1,y = 1.
情况 3: 线程 2 优先执行, 这时 b = 1,y = a = 0, 然后运行线程 1,a = 1,x = b = 1, 得到结论 x = 1,y = 0.
不管怎么谁先谁后, 我们都是只有这三种答案, 不会产生 x = 0 且 y = 0 的情况, 我们在下面写出来了 x = 0 且 y = 0 跳出循环. 我们来测试一下.
运行到第 72874 次结果了 0,0 的情况产生了, 也就是说, 我们 t1 中的 a = 1;x = b; 和 t2 中的 b = 1;y = a; 代码发生了改变, 只有变为
- Thread t1 = new Thread(new Runnable() {
- public void run() {
- x = b;
- a = 1;
- }
- });
- Thread t2 = new Thread(new Runnable() {
- public void run() {
- y = a;
- b = 1;
- }
- });
这种情况才可以产生 0,0 的情况, 我们可以把代码改为
private static volatile int a = 0, b = 0;
继续来测试, 我们发现无论我们运行多久都不会发生我们的指令重排现象, 也就是说我们 volatile 关键字可以保证我们的有序性
至少我这里 570 万次还没有发生 0,0 的情况.
就是我上次博客给予的表格
Required barriers | 2nd operation | |||
1st operation | Normal Load | Normal Store | Volatile Load | Volatile Store |
Normal Load | LoadStore | |||
Normal Store | StoreStore | |||
Volatile Load | LoadLoad | LoadStore | LoadLoad | LoadStore |
Volatile Store | StoreLoad | StoreStore |
我们来分析一下代码
线程 1 的.
- public void run() {
- a = 1;
- x = b;
- }
a = 1; 是将 a 这个变量赋值为 1, 因为 a 被 volatile 修饰过了, 我们成为 volatile 写, 就是对应表格的 Volatile Store, 接下来我们来看第二步, x = b, 字面意思是将 b 的值赋值给 x, 但是这步操作不是一个原子操作, 其中包含了两个步骤, 先取得变量 b, 被 volatile 修饰过, 就成为 volatile load, 然后将 b 的值赋给 x,x 没有被 volatile 修饰, 成为普通写. 也就是说, 这两行代码做了三个动作, 分别是 Volatile Store,volatile load 和 Store 写读写, 查表格我们看到 volatile 修饰的变量 Volatile Store,volatile load 之间是给予了 StoreLoad 这样的屏障, 是不允许指令重排的, 所以达到了有序性的目的.
扩展:
我们再来看一个方法, 不用 volatile 修饰也可以防止指令重排, 因为上面我们说过, volatile 可以保证有序性, 就是增加内存屏障, 防止了指令重排, 我们可以采用手动加屏障的方式也可以阻止指令重排. 我们来看一下事例.
- public class VolatileReOrderSample {
- private static int x = 0, y = 0;
- private static int a = 0, b =0;
- public static void main(String[] args) throws InterruptedException {
- int i = 0;
- for (;;){
- i++;
- x = 0; y = 0;
- a = 0; b = 0;
- Thread t1 = new Thread(new Runnable() {
- public void run() {
- a = 1;
- UnsafeInstance.reflectGetUnsafe().storeFence();
- x = b;
- }
- });
- Thread t2 = new Thread(new Runnable() {
- public void run() {
- b = 1;
- UnsafeInstance.reflectGetUnsafe().storeFence();
- y = a;
- }
- });
- t1.start();
- t2.start();
- t1.join();
- t2.join();
- String result = "第" + i + "次 (" + x + "," + y + ")";
- if(x == 0 && y == 0) {
- System.err.println(result);
- break;
- } else {
- System.out.println(result);
- }
- }
- }
- }
storeFence 就是一个有 java 底层来提供的内存屏障, 有兴趣的可以自己去看一下 unsafe 类, 一共有三个屏障
- UnsafeInstance.reflectGetUnsafe().storeFence();// 写屏障
- UnsafeInstance.reflectGetUnsafe().loadFence();// 读屏障
- UnsafeInstance.reflectGetUnsafe().fullFence();// 读写屏障
通过 unsafe 的反射来调用, 涉及安全问题, jvm 是不允许直接调用的. 手写单例模式时在超高并发记得加 volatile 修饰, 不然产生指令重排, 会造成空对象的行为. 后面我会科普这个玩意.
最进弄了一个公众号, 小菜技术, 欢迎大家的加入
来源: https://www.cnblogs.com/cxiaocai/p/12186229.html