一、指令重排问题


你写的代码有可能,根本没有按照你期望的顺序执行,因为编译器和 CPU 会尝试指令重排来让代码运行更高效,这就是指令重排。

1.1 虚拟机层面

我们都知道CPU执行指令的时候,访问内存的速度远慢于 CPU 速度。

为了尽可能减少内存操作带来的 CPU 空置的影响,虚拟机会按照自己的一些规则将程序编写顺序打乱:即写在后面的代码在时间顺序上可能会先执行,而写在前面的代码会后执行。

当然这样的前提是不会产生错误。不管谁先开始,总之后面的代码在一些情况下存在先结束的可能。

1.2 硬件层面

在硬件层面,CPU会将接收到的一批指令按照其规则重排序,同样是基于 CPU 速度比缓存速度快的原因。

和上一点的目的类似,只是硬件处理的话,每次只能在接收到的有限指令范围内重排序,而虚拟机可以在更大层面、更多指令范围内重排序。

1.3 数据依赖

如果两个操作访问的是同一个变量,且其中有一个是写操作,那么这两个操作之间就存在数据依赖。

数据依赖分为读后写、写后写、写后读。

读后写:a = b;b = 1;
写后读:a = 1;b = a;
写后写:a = 1;a = 2;

上面这几种情况,存在数据以来,当存在数据依赖的时候,重排指令就会造成程序结果错误,所以编译器和处理器会遵循数据依赖,不改变顺序。

1.4 As-If-Serial语义

基于上面的重排序原则,不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。

编译器,runtime 和处理器都必须遵守 as-if-serial 语义。为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。

1.5 Happens-Before语义

直接翻译就是,在之前发生,本质上和 as-if-serial 一样的。

定义:如果一个操作 happens-before 另一个操作,那么意味着第一个操作的结果对第二个操作可见,而且第一个操作的执行顺序将排在第二个操作的前面。

如果重排序之后的结果,与按照happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。

具体规则如下:

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  • 线程启动规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  • 线程终结规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
  • 传递性规则:如果A happens-before B,且B happens-before C,那么A happens-before C。

总结:

这么些规则总的来说就在体现一件事,也就是单线程里,程序有关的各个层面都会做指令重排的优化,而且会用这些规则来保证结果正确。


二、多线程的指令重排


如果是多线程,无法保证正确性,指令重排可能会造成结果错误。

我个人是这样理解的:多线程最大问题就是一个 先来后到 可能破坏正确性的问题,因此有同步、加锁等等机制来保障先来后到,可是指令重排让线程本身的指令乱了,就可能让整体的结果功亏一篑。

一个简单的例子:

/**
* 指令重排问题
*/
public class HappenBefore {
private static int i = 0, j = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
while (true){
i = 0;
j = 0;
a = 0;
b = 0;
Thread t1 = new Thread(()->{
a = 1;
i = b;
});
Thread t2 = new Thread(()->{
b = 1;
j = a;
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("i = " + i +" ; j = " + j);
if (i == 0 && j == 0){
break;
}
}
}
}

上面程序里开了两个线程,交替赋值;join保障这两个线程都在 main 线程之前执行完,确保最后在执行完后输出。(注意并不是保证 t1 在 t2 之前执行)

线程t1 线程t2
a = 1; b = 1;
i = b; j = a;

如果没有指令重排,按照我们的分析,多线程情况下,t1 和 t2 分别执行完,或者经过cpu的调度,t1 和 t2 线程经过了切换,那么可能出现的情况是,最终的 i 和 j 为:

  • i = 0,j = 1;(t1先执行完)
  • i = 1,j = 0;(t2先执行完)
  • i = 1,j = 1;(中间线程切换了)

但是结果应该是不会出现 i = 0,j = 0 的情况的。

可是代码跑起来就会发现,会出现 i = 0,j = 0 的情况,这就是指令重排在多线程情况下带来的问题。在各自单线程里,因为没有依赖关系,所以编译器、虚拟机以及 cpu 可以进行乱序重排,最后导致了这种结果。


三、volatile 关键字


Volatile 英文翻译:易变的、可变的、不稳定的。

  1. 在之前的示例中,线程不安全的问题,我们使用线程同步,也就是通过 synchronized 关键字,对操作的对象加锁,屏蔽了其他线程对这块代码的访问,从而保证安全。
  2. 这里的 volatile 尝试从另一个角度解决这个问题,那就是保证变量可见,有说法将其称之为轻量级的synchronized。

volatile保证变量可见:简单来说就是,当线程 A 对变量 X 进行了修改后,在线程 A 后面执行的其他线程能够看到 X 的变动,就是保证了 X 永远是最新的。

更详细的说,就是要符合以下两个规则:

  1. 线程对变量进行修改后,要立刻写回主内存;
  2. 线程对变量读取的时候,要从主内存读,而不是缓存。

另一个角度,结合指令重排序,volatile 修饰的内存空间,在这上面执行的指令是禁止乱序的。因此,在单例模式的 DCL 写法中,volatile 也是必须的元素。

3.1 volatile使用示例1

private static int num = 0;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
while (num == 0){ }
}).start(); Thread.sleep(1000);
num = 1;
}

代码死循环,因为主线程里的 num = 1,不能及时将数据变化更新到主存,因此上面的代码 while 条件持续为真。

因此可以给变量加上 volatile:

private volatile static int num = 0;

这样就在执行几秒后就会停止运行。

3.2 单例模式Double Checked Locking

在设计模式里的单例模式,如果在多线程的情况下,仍然要保证始终只有一个对象,就要进行同步和锁。

class DCL{
private static volatile DCL instance;
private DCL(){ } public static DCL getInstance(){
if (instance == null){//check1
synchronized (DCL.class){
if (instance == null){//check2
instance = new DCL();
}
}
}
return instance;
}
}

双重校验锁,实现线程安全的单例锁。

关于单例模式的内容,指路:单例模式讲解及8种写法

但是:volatile不能保证原子性。

3.3 原子性问题

原子操作就是这个操作要么执行完,要么不执行,不可能卡在中间。

比如在 Java 里, i = 2,这个指令是具有原子性的,而 i++ 则不是,事实上 i++ 也是先拿 i,再修改,再重新赋值给 i 。

例如你让一个volatile的integer自增(i++),其实要分成3步:

1)读取volatile变量值到local;

2)增加变量的值;

3)把local的值写回,让其它的线程可见。

这 3 步的jvm指令为:

mov    0xc(%r10),%r8d ; Load
inc %r8d ; Increment
mov %r8d,0xc(%r10) ; Store
lock addl $0x0,(%rsp) ; StoreLoad Barrier

最后一步是内存屏障。

什么是内存屏障?内存屏障告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行,同时强制更新一次不同CPU的缓存,也就是通过这个操作,使得 volatile 关键字达到了所谓的变量可见性。

这个时候我们就知道,如果一个操作有好几步,如果其他的线程修改了值,将会都产生覆盖,还是会出现不安全的情况,所以, volatile 关键字本身无法保证原子性。

volatile 无法保证原子性。我们还是用两数之和来示例:

public class NoAtomic {
private static volatile int num = 0;
public static void main(String[] args) throws InterruptedException {
for (int i=0; i<100; i++){
new Thread(()->{
for (int j=0; j < 100; j++){
num++;
}
}).start();
}
Thread.sleep(3000);
System.out.println(num);
}
}

输出结果会小于预期,虽然 volatile 保证了可见性,但是却不能保证操作的原子性。因此想要保证原子性,还是得回去找 synchronized 或者使用juc下的原子数据类型。

3.4 volatile 和 synchronized 的区别

  • volatile 本质是在告诉 jvm 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
  • volatile 仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的。
  • volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量的修改可见性和原子性。
  • volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。

不过:由于硬件层面,从工作内存到主存的更新速度已经提升的很快,加上 synchronized 的改进,也已经不用考虑太过重量的问题,所以 volatile 很少使用。

多线程的指令重排问题:as-if-serial语义,happens-before语义;volatile关键字,volatile和synchronized的区别的更多相关文章

  1. 轻量级的同步机制——volatile语义详解(可见性保证+禁止指令重排)

    目录 1.关于volatile 2.语义一:内存可见性 2.1 一个例子 2.2 java的内存模型(JMM) 2.3 happens-before规则 2.4 volatile解决内存可见性问题的原 ...

  2. volatile可见性和指令重排

    volatile关键字的2个作用 1.线程的可见性 2.防止指令重排 什么是线程的可见性? 线程的可见性 就是一个线程对一个变量进行更改操作 其他线程获取会获得最新的值. 线程在执行的行 操作主线程的 ...

  3. volatile关键字及编译器指令乱序总结

    本文简单介绍volatile关键字的使用,进而引出编译期间内存乱序的问题,并介绍了有效防止编译器内存乱序所带来的问题的解决方法,文中简单提了下CPU指令乱序的现象,但并没有深入讨论. 以下是我搭建的博 ...

  4. java多线程(4)---volatile关键字

    volatile关键字 一旦一个共享变量(类的成员变量.类的静态成员变量)被volatile修饰之后,那么就具备了两层语义: 1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的 ...

  5. 多线程-volatile关键字和ThreadLocal

    1.并发编程中的三个概念 原子性:一个或多个操作.要么全部执行完成并且执行过程不会被打断,要么不执行.最常见的例子:i++/i--操作.不是原子性操作,如果不做好同步性就容易造成线程安全问题. 可见性 ...

  6. Java多线程-----volatile关键字详解

       volatile原理     Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程.当把变量声明为volatile类型后, 编译器与运行时都会注意 ...

  7. 【java多线程】volatile 关键字

    在java线程并发处理中,有一个关键字volatile的使用目前存在很大的混淆,以为使用这个关键字,在进行多线程并发处理的时候就可以万事大吉. Java语言是支持多线程的,为了解决线程并发的问题,在语 ...

  8. Java 多线程 -- 指令重排(HappenBefore)

    指令重排是指:代码执行顺序和预期不一致. 代码运行一般步骤为: 1.从内存中获取指令解码 2.计算值 3.执行代码操作 4.把结果写回内存 而写回内存的操作比较耗时,CPU为了性能,可能不会等它完成, ...

  9. JVM内存模型、指令重排、内存屏障概念解析

    在高并发模型中,无是面对物理机SMP系统模型,还是面对像JVM的虚拟机多线程并发内存模型,指令重排(编译器.运行时)和内存屏障都是非常重要的概念,因此,搞清楚这些概念和原理很重要.否则,你很难搞清楚哪 ...

随机推荐

  1. jenkins集群(三) -- master和slave配置git

    一.Linux(master)上安装git 1.运行命令:yum -y install git 2.git的默认安装目录是:  二.给Linux下Git配置好秘钥(公钥 + 私钥) 1.添加用户和密码 ...

  2. 解决react使用antd table组件固定表头后,表头和表体列不对齐以及配置fixed固定左右侧后行高度不对齐

    一.固定表头后表体列和表头不对齐 此问题可能在antd3.24.0版本之前都存在,反正3.16.2版本是存在这个问题的,如果是3.24.0之前的版本估计只能通过修改css样式解决. 按照官网说的: 1 ...

  3. Python网络数据采集PDF高清完整版免费下载|百度云盘

    百度云盘:Python网络数据采集PDF高清完整版免费下载 提取码:1vc5   内容简介 本书采用简洁强大的Python语言,介绍了网络数据采集,并为采集新式网络中的各种数据类型提供了全面的指导.第 ...

  4. 21天学通PythonPDF高清完整版免费下载|百度云盘

    百度云盘:21天学通PythonPDF高清完整版免费下载 提取码:nqa9 豆瓣评分: 书籍封面: 内容简介  · · · · · · <21天学通Python>全面.系统.深入地讲解了P ...

  5. SQL语法分类

    数据查询 语法格式 : select [ , ...] from table_reference [ , ...] 去重复值 distinct关键字 , 从select结果集中删除所有重复的行,使结果 ...

  6. Js数组对象的属性值升序排序,并指定数组中的某个对象移动到数组的最前面

    需求整理: 本篇文章主要实现的是将一个数组的中对象的属性值通过升序的方式排序,然后能够让程序可以指定对应的数组对象移动到程序的最前面. 数组如下所示: var arrayData= [{name: & ...

  7. 集合的一些实例的demo实现

    按照斗地主的规则,完成洗牌发牌的动作. 具体规则: 使用54张牌打乱顺序,三个玩家参与游戏,三人交替摸牌,每人17张牌,最后三张留作底牌. 准备牌: 牌可以设计为一个ArrayList,每个字符串为一 ...

  8. 环境篇:DolphinScheduler-1.3.1安装部署及使用技巧

    环境篇:DolphinScheduler-1.3.1安装部署 1 配置jdk JDK百度网盘:https://pan.baidu.com/s/1og3mfefJrwl1QGZGZDZ8Sw 提取码:t ...

  9. Mybatis开启二级缓存(全局缓存)的方法

    Mybatis开启二级缓存的方法 开启步骤 1.在 mybatis-config.xml 的配置文件中进行显示配置,开启二级缓存(全局缓存) 2.在 Mapper.xml 文件中添加cache标签 一 ...

  10. Javascript 模块化概述

    模块化的目的 当网站开发得越来越复杂,会经常遇到以下问题: 命名冲突 文件依赖 Sea.js 一个适合web前端的模块加载器,遵守 CMD (Common Module Definition)模块定义 ...