写在开头

在之前的学习我们了解到,为了充分利用缓存,提高程序的执行速度,编译器在底层执行的时候,会进行指令重排序的优化操作,但这种优化,在有些时候会带来 有序性 的问题。

那何为有序性呢?我们可以通俗理解为:程序执行的顺序要按照代码的先后顺序。 当然,之前我们还说过发生有序性问题时,我们可以通过给变量添加volatile修饰符进行解决。那么今天,我们继续学习,一起探讨一下volatile与指令重排之间的冤家路窄!

有序性问题

首先,我们来回顾一下之前写的一个关于有序性问题的测试类。

【代码示例1】

int a = 1;(1)
int b = 2;(2)
int c = a + b;(3)

上面的这段代码中,c变量依赖a,b的值,因此,在编译器优化重排时,c肯定会在a,b赋值以后执行,但a,b之间没有依赖关系,可能会发生重排序,但这种重排序即便到了多线程中依旧不会存在问题,因为即便重排对执行结果也无影响。

但有些时候,指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致,我们继续看下面这段代码:

【代码示例2】

public class Test {

    private static int num = 0;
private static boolean ready = false;
//禁止指令重排,解决顺序性问题
//private static volatile boolean ready = false; public static class ReadThread extends Thread { @Override
public void run() { while (!Thread.currentThread().isInterrupted()) {
if (ready) {//(1)
System.out.println(num + num);//(2)
}
System.out.println("读取线程...");
}
}
} public static class WriteRead extends Thread { @Override
public void run() {
num = 2;//(3)
ready = true;//(4)
System.out.println("赋值线程...");
}
} public static void main(String[] args) throws InterruptedException {
ReadThread rt = new ReadThread();
rt.start(); WriteRead wr = new WriteRead();
wr.start(); Thread.sleep(10);
rt.interrupt();
System.out.println("rt stop...");
}
}

我们定义了2个线程,一个用来求和操作,一个用来赋值操作,因为定义的是成员变量,所以代码(1)(2)(3)(4)之间不存在依赖关系,在运行时极可能发生指令重排序,如将(4)在(3)前执行,顺序为(4)(1)(3)(2),这时输出的就是0而不是4,但在很多性能比较好的电脑上,这种重排序情况不易复现。

这时,我们给ready 变量添加一个volatile关键字,就成功的解决问题了。

原因解析

volatile关键字可以禁止指令重排的原因主要有两个!

一、3 个 happens-before 规则的实现

  1. 对一个 volatile 变量的写 happens-before 任意后续对这个 volatile 变量的读;
  2. 一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作;
  3. happens-before 传递性,A happens-before B,B happens-before C,则 A happens-before C。

二、内存屏障

变量声明为 volatile 后,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。

内存屏障(Memory Barrier 又称内存栅栏,是一个 CPU 指令),为了实现volatile 内存语义,volatile 变量的写操作,在变量的前面和后面分别插入内存屏障;volatile 变量的读操作是在后面插入两个内存屏障。

具体屏障规则:

  1. 在每个 volatile 写操作的前面插入一个 StoreStore 屏障;
  2. 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障;
  3. 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障;
  4. 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

屏障说明:

  1. StoreStore:禁止之前的普通写和之后的 volatile 写重排序;
  2. StoreLoad:禁止之前的 volatile 写与之后的 volatile 读/写重排序;
  3. LoadLoad:禁止之后所有的普通读操作和之前的 volatile 读重排序;
  4. LoadStore:禁止之后所有的普通写操作和之前的 volatile 读重排序。

OK,知道了这些内容之后,我们再回头看代码示例2中,增加了volatile关键字后的执行顺序,在赋值线程启动后,执行顺序会变成(3)(4)(1)(2),这时打印的结果就为4啦!

volatile为什么不能保证原子性?

我们讲完了volatile修饰符保证可见性与有序性的内容,接下来我们思考另外一个问题,它能够保证原子性吗?为什么?我们依旧通过一段代码去证明一下!

【代码示例3】

public class Test {
//计数变量
static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
//线程 1 给 count 加 10000
Thread t1 = new Thread(() -> {
for (int j = 0; j <10000; j++) {
count++;
}
System.out.println("thread t1 count 加 10000 结束");
});
//线程 2 给 count 加 10000
Thread t2 = new Thread(() -> {
for (int j = 0; j <10000; j++) {
count++;
}
System.out.println("thread t2 count 加 10000 结束");
});
//启动线程 1
t1.start();
//启动线程 2
t2.start();
//等待线程 1 执行完成
t1.join();
//等待线程 2 执行完成
t2.join();
//打印 count 变量
System.out.println(count);
}
}

我们创建了2个线程,分别对count进行加10000操作,理论上最终输出的结果应该是20000万对吧,但实际并不是,我们看一下真实输出。

输出:

thread t1 count 加 10000 结束
thread t2 count 加 10000 结束
14281

原因:

Java 代码中 的 count++并非原子的,而是一个复合性操作,至少需要三条CPU指令:

  • 指令 1:把变量 count 从内存加载到CPU的寄存器
  • 指令 2:在寄存器中执行 count + 1 操作
  • 指令 3:+1 后的结果写入CPU缓存或内存

即使是单核的 CPU,当线程 1 执行到指令 1 时发生线程切换,线程 2 从内存中读取 count 变量,此时线程 1 和线程 2 中的 count 变量值是相等,都执行完指令 2 和指令 3,写入的 count 的值是相同的。从结果上看,两个线程都进行了 count++,但是 count 的值只增加了 1。这种情况多发生在cpu占用时间较长的线程中,若单线程对count仅增加100,那我们就很难遇到线程的切换,得出的结果也就是200啦。

要想解决也很简单,利用 synchronized、Lock或者AtomicInteger都可以,我们在后面的文章中会聊到的,请继续保持关注哦!

结尾彩蛋

如果本篇博客对您有一定的帮助,大家记得留言+点赞+收藏呀。原创不易,转载请联系Build哥!

如果您想与Build哥的关系更近一步,还可以关注“JavaBuild888”,在这里除了看到《Java成长计划》系列博文,还有提升工作效率的小笔记、读书心得、大厂面经、人生感悟等等,欢迎您的加入!

关于volatile与指令重排序的探讨的更多相关文章

  1. Java的多线程机制系列:不得不提的volatile及指令重排序(happen-before)

    一.不得不提的volatile volatile是个很老的关键字,几乎伴随着JDK的诞生而诞生,我们都知道这个关键字,但又不太清楚什么时候会使用它:我们在JDK及开源框架中随处可见这个关键字,但并发专 ...

  2. Java的多线程机制系列:(四)不得不提的volatile及指令重排序(happen-before)

    一.不得不提的volatile volatile是个很老的关键字,几乎伴随着JDK的诞生而诞生,我们都知道这个关键字,但又不太清楚什么时候会使用它:我们在JDK及开源框架中随处可见这个关键字,但并发专 ...

  3. 不得不提的volatile及指令重排序(happen-before)

    微信公众号[程序员江湖] 作者黄小斜,斜杠青年,某985硕士,阿里 Java 研发工程师,于 2018 年秋招拿到 BAT 头条.网易.滴滴等 8 个大厂 offer,目前致力于分享这几年的学习经验. ...

  4. 单例模式+volatile禁止指令重排序

    单例模式: 单例,顾名思义就是只能有一个.不能再出现第二个.就如同地球上没有两片一模一样的树叶一样. 在这里就是说:一个类只能有一个实例,并且整个项目系统都能访问该实例. 单例模式共分为两大类: 懒汉 ...

  5. volatile和指令重排序

    volatile 的作用 1 精致指令重排序 2 多线程访问同一个变量的时候,每次都是取最新的,而不会使用当前cpu缓存的那一份.

  6. Volatile禁止指令重排序(三)

    Volatile禁止指令重排 计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令重排,一般分为以下三种: 源代码 -> 编译器优化的重排 -> 指令并行的重排 -> 内存系 ...

  7. 使用 volatile 关键字保证变量可见性和禁止指令重排序

    volatile 概述 volatile 是 Java 提供的一种轻量级的同步机制.相比于传统的 synchronize,虽然 volatile 能实现的同步性要差一些,但开销更低,因为它不会引起频繁 ...

  8. JVM并发机制的探讨——内存模型、内存可见性和指令重排序

    并发本来就是个有意思的问题,尤其是现在又流行这么一句话:“高帅富加机器,穷矮搓搞优化”. 从这句话可以看到,无论是高帅富还是穷矮搓都需要深入理解并发编程,高帅富加多了机器,需要协调多台机器或者多个CP ...

  9. 关于volatile的可见性和禁止指令重排序的疑惑

    在学习volatile语义的可见性和禁止指令重排序的相关测试中,发现并不能体现出禁止指令重排序的特性 实验代码如下 package com.aaron.beginner.multithread.vol ...

  10. 指令重排序所带来的问题及使用volatile关键字解决问题

    首先看下如下代码: 指令重排序和优化后代码如下:if(!stop)while(true){}volatile最适合使用的是一个线程写.其他线程读的场合,如果有多个线程并发写操作,仍然需要使用锁或者线程 ...

随机推荐

  1. CF-925(已更新:D-F)

    CF 925 补题ing 待更新 后面打算更新D题和power oj上一道区间合并的题(现在才知道是一道洛谷上的原题--) D 分析 ​ 涉及到关于取模的知识,我们的答案要满足三个条件: ai-aj≡ ...

  2. react 新旧生命周期有什么区别?新增了哪些钩子?废弃了哪些钩子?为什么废弃?

    壹 ❀ 引 在日常面试中,若对于了解react的同学而言,多多少少会被问到生命周期相关的问题,比如大致阐述生命周期的运作流程,以及每个钩子函数大致的作用,而我在两位出去面试的同事那里了解到,他们都遇到 ...

  3. NEMU PA 3-3 实验报告

    一.实验目的 在上一章PA3-2中,我们实现了分段机制,将48位的虚拟地址vaddr转换成了laddr.为什么不是paddr呢?这就要说到这一章要完成的东西:**分页机制 **. 从80386开始,计 ...

  4. Moon.Orm版本维护及下载

    MoonOrm最新版及代码生成器 (2020-8-29)

  5. 【分布式】load balance 02-consistent hash algorithm 一致性哈希算法原理详解

    负载均衡系列专题 01-负载均衡基础知识 02-一致性 hash 原理 03-一致性哈希算法 java 实现 04-负载均衡算法 java 实现 概念 一致哈希是一种特殊的哈希算法. 在使用一致哈希算 ...

  6. Java8函数式接口Predicate实战

    关于函数式接口 函数式接口 Funcational Interface 是指接口范围内只允许有一个抽象方法(不包括default和static方法)的接口.Java中有一些预定义的函数接口,如Pred ...

  7. linux下使用find查找并操作文件

    介绍 最近在centos7上部署了一套环境,需要根据文件名找到程序运行路径下的文件,并进行移动文件操作,为此查阅了一番,记录下这个操作的脚本.我想很多人都会有这个需求,查找简单,但是要对对查找到的文件 ...

  8. spring boot与junit集成测试

    先创建一个REST接口 package com.laoxu.gamedog.controller; import org.springframework.web.bind.annotation.Req ...

  9. Java并发编程实例--8.在线程中处理未检查异常

    java中有两类异常: 已检查异常:这类异常编译器要求开发者必须在代码中通过throws去处理. 例如:IOException和ClassNotFoundException. 未检查异常:不必显式的在 ...

  10. SpringBoot的自动装配原理及应用

    什么是SpringBoot自动装配 所谓的"SpringBoot自动装配"就是指:通过注解和一些简单的配置就能将某些组件载入Spring容器环境中,便于使用. 比如,很多sprin ...