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

1.关于volatile

volatile是java语言中的关键字,用来修饰会被多线程访问的共享变量,是JVM提供的轻量级的同步机制,相比同步代码块或者重入锁有更好的性能。它主要有两重语义,一是保证多个线程对共享变量访问的可见性,二防止指令重排序。

2.语义一:内存可见性

2.1 一个例子

public class TestVolatile {

    public static void main(String[] args) throws InterruptedException {

        ThreadDemo threadDemo = new ThreadDemo();
        new Thread(threadDemo).start();
        threadDemo.flag = false;
        System.out.println("已将flag置为" + threadDemo.flag);

    }

    static class ThreadDemo implements Runnable {

        boolean flag = true;

        @Override
        public void run() {
            System.out.println("Flag=" + flag);
        }

    }
}

当你多次执行代码时,有一定几率会出现这种结果

在主线程将子线程实例的flag置为false后,子线程中的flag竟然还是true。这是怎么回事?这就是多线程的内存可见性问题。对于一个没有volatile修饰的的共享变量,当一个线程对其进行了修改,另一线程并不一定能马上看见这个被修改后的值。为什么会出现这种情况呢?这就要从java的内存模型谈起。

2.2 java的内存模型(JMM)

java的内存模型定义了线程和主内存之间的抽象关系,它的内容主要包括:

  • 主要由多线程共享的主内存和各线程私有的工作内存组成(工作内存是个抽象概念,并不真实存在,是对缓冲区,cpu寄存器等的抽象)
  • 变量都存储于主内存中,但是线程的工作内存中保存着要使用的变量在主内存中的副本。
  • 线程对变量的操作必须在工作内存中进行,不同的线程无法直接访问对方的工作内存,相互通信必须经过主内存。

线程,主内存,工作内存三者的交互关系如图所示

看看JMM模型会给我们在多线程环境下的读写带来什么样的问题。

  • 当一个线程(记为A)对共享变量进行修改时,修改的并不是主内存中的变量,而是该线程对应的工作内存中该变量的一个副本。
  • 当主内存中的变量值已经被修改,另一个线程读取的却还是自己工作内存中的旧值。

这时就出现了共享变量在多线程环境下的可见性问题。如果把线程的工作内存当作主内存的缓存,这个问题的本质就在于如何解决缓存失效问题。那么JMM中是如何解决可见性问题的?这就不得不提到happens-before规则。

2.3 happens-before规则

happens-before规则又叫先行发生规则。它定义了java内存模型中两项操作的偏序关系,更确切的说,它定义了操作可见性之间的偏序关系。比如A操作 happens-before B操作,并不意味这A操作一定在B操作之前,而是A操作的影响能被操作B观察到,这个影响包括改变了内存中共享变量的值,发送消息等。那么JMM定义了哪些happens-before规则?

  • 1.程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 2.监视器锁规则:对于一个锁的解锁,happens-before于随后对这个锁的加锁。
  • 3.volatile变量规则:对于一个volatile 变量的写,happens-before于任意后续对这个volatile变量的读。
    这里对于我们而言重要的是第三点。即对于一个volatile变量,写操作happens-before于读操作,也就是说,一个线程对volatile变量做了修改,另一个线程能马上读到这个被修改后的值。
    这样就能解决共享变量在多线程环境下的可见性问题了。结合JMM模型,我们可以继续探讨下volatile是如何做到这点的。

2.4 volatile解决内存可见性问题的原理

当一个变量被修饰为volatile后,对其的读写就会显得比较特别

  • 1.写一个volatile变量时,JMM首先修改工作内存中的变量值,并刷新到主内存中
    如图所示
  • 2.读一个变量时,JMM会把该线程对应的本地内存置为无效,并从主内存中读取共享变量。
    如图所示

对volatile变量的读写,可以说都是直接对主内存进行的操作,这样虽然会牺牲一些性能,但是解决了“缓存一致性问题”,使得变量的在多线程间的可见行得到了很好的保证。

3. 语义二:禁止指令重排

3.1 为什么会有指令重排

为了优化程序性能,编译器和处理器会对java编译后的字节码和机器指令进行重排序,通俗的说代码的执行顺序和我们在程序中定义的顺序会有些不同,只要不改变单线程环境下的执行结果就行。但是在多线程环境下,这么做却可能出现并发问题。比如下面的例子。

3.2 线程不安全的双重检查单例模式

运行这段代码我们可能会得到一个匪夷所思的结果:我们获得的单例对象是未初始化的。为什么会出现这种情况?因为指令重排。首先要明确一点,同步代码块中的代码也是能够被指令重排的。然后来看问题的关键

 INSTANCE = new Singleton();

虽然在代码中只有一行,编译出的字节码指令可以用如下三行表示

  • 1.为对象分配内存空间
  • 2.初始化对象
  • 3.将INSTANCE变量指向刚分配的内存地址
    由于步骤2,3交换不会改变单线程环境下的执行结果,故而这种重排序是被允许的。也就是我们在初始化对象之前就把INSTANCE变量指向了该对象。而如果这时另一个线程刚好执行到代码所示的2处
if (INSTANCE == null)

那么这时候有意思的事情就发生了:虽然INSTANCE指向了一个未被初始化的对象,但是它确实不为null了,所以这个判断会返回false,之后它将return一个未被初始化的单例对象!整个过程的执行流程如下图所示

由于重排序是编译器和CPU自动进行的,那么有什么办法能禁止这种重排序操作吗?很简单,给
INSTANCE变量加个volatile关键字就行,这样编译器就会根据一定的规则禁止对volatile变量的读写操作重排序了。而编译出的字节码,也会在合适的地方插入内存屏障,比如volatile写操作之前和之后会分别插入一个StoreStore屏障和StoreLoad屏障,禁止CPU对指令的重排序越过这些屏障。

4. volatile的其他特性

对volatile变量的读写具有原子性,但是其他操作并不一定具有原子性,一个简单的例子就是i++。由于该操作并不具有原子性,故而即使该变量被volatile修饰,多线程环境下也不能保证线程安全。

5.总结

volatile是jvm提供的轻量级同步工具。被volatile修饰的共享变量在多线程环境下可以获得可见行保证。其次它还能禁止指令重排。由于对volatile的写-读与锁的释放-获取具有相同的内存语义,故某些时候可以代替锁来获得更好的性能。但是和锁不一样,它不能保证任何时候都是线程安全的。

原文地址:https://www.cnblogs.com/takumicx/p/9302398.html

时间: 2024-07-31 12:34:16

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

Java中Volatile关键字详解

一.基本概念 先补充一下概念:Java并发中的可见性与原子性 可见性: 可见性是一种复杂的属性,因为可见性中的错误总是会违背我们的直觉.通常,我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有时甚至是根本不可能的事情.为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制. 可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的.也就是一个线程修改的结果.另一个线程马上就能看到.比如:用volatile修饰的变量,就会具有可见性.volatile修饰的变量不允许线程

轻量级高可用实现工具--keepalived详解

一 .keepalived简介 keepalived:它的诞生最初是为ipvs(一些服务,内核中的一些规则)提供高可用性的,最初最主要目的是能够自主调用ipvsadm来生成规则,并且能够自动实现将用户访问的地址转移到其他节点上进行实现的. keepalived:核心包含两个ckechers和VRRP协议. ckeckers #检查服务检查reserved的健康状况的,基于脚本也可以服务本身的健康状况.这里是实现ipvs后端健康状况的检测的. VRRP # Virtual Router Redun

SQL Server 2000 复制同步配置及常见问题详解(下)

SQL Server 2000 复制同步配置及常见问题详解(下) (二)分发.发布服务器端配置及问题 1.启动配置发布.订阅服务区器和分发向导 2.指定网络位置上的快照共享文件夹 3.配置完成4.启动新建发布向导 5.选择要发布的数据库6.选择合并发布,这样无论哪一端数据发生变化都会全范围同步7.这里可以选择兼容老版本的SQL Server,默认只选20008.选择要发布的数据库中的对象9. 11.设置新建发布属性 12.勾选“允许匿名订阅”问题五:若此处没有勾选“允许匿名订阅”,则会在订阅配置

Android View 事件分发机制源码详解(View篇)

前言 在Android View 事件分发机制源码详解(ViewGroup篇)一文中,主要对ViewGroup#dispatchTouchEvent的源码做了相应的解析,其中说到在ViewGroup把事件传递给子View的时候,会调用子View的dispatchTouchEvent,这时分两种情况,如果子View也是一个ViewGroup那么再执行同样的流程继续把事件分发下去,即调用ViewGroup#dispatchTouchEvent:如果子View只是单纯的一个View,那么调用的是Vie

ARC机制之__strong详解

ARC机制之__strong详解 __strong  解析: 默认情况下,一个指针都会使用 __strong 属性,表明这是一个强引用.这意味着,只要引用存在,对象就不能被销毁.这是一种所期望的行为:当所有(强)引用都去除时,对象才能被收集和释放. 不过, 有时我们却希望禁用这种行为:一些集合类不应该增加其元素的引用,因为这会引起对象无法释放.在这种情况下,我们需要使用弱引用(不用担心,内置的集合类 就是这么干的),使用 __weak 关键字.NSHashTable 就是一个例子.当被引用的对象

SQL Server 2000 复制同步配置及常见问题详解

SQL Server 2000 复制同步配置及常见问题详解(上) 最近因为要用SQL Server2000的同步复制功能,配置了一台分发.发布服务器和订阅服务器间的数据库同步,其中也碰到不少的问题,省去其中理论的说明,重点说明配置步骤和问题解决,现总结如下: 环境配置要求: SQL Server 2000需要打上SP4补丁,补丁可以去网上搜,地址很多,那么如何判断SP4补丁是否打上了呢? 打开查询分析器,键入SQL语句“select @@version”,按F5执行,如果结果显示 Microso

小编带您Volatile的详解

volatile关键字修饰的共享变量主要有两个特点:1.保证了不同线程访问的内存可见性 2.禁止重排序在说内存可见性和有序性之前,我们有必要看一下Java的内存模型(注意和JVM内存模型的区分)为什么要有java内存模型?首先我们知道内存访问和CPU指令在执行速度上相差非常大,完全不是一个数量级,为了使得java在各个平台上运行的差距减少,哪些搞处理器的大佬就在CPU上加了各种高速缓存,来减少内存操作和CPU指令的执行速度差距.而Java在java层面又进行了一波抽象,java内存模型将内存分为

MySQL5.6 数据库主从同步安装与配置详解(Master/Slave)

MySQL5.6 数据库主从同步安装与配置详解(Master/Slave)本篇文章主要介绍了MySQL5.6 数据库主从同步安装与配置详解,具有一定的参考价值,有兴趣的可以了解一下.安装环境 操作系统 :CentOS 6.5 数据库版本:MySQL 5.6.27 主机A:192.168.1.1 (Master) 主机B:192.168.1.2 (Slave) 这里强调的数据库的版本,是因为MySQL在5.6之前和之后的安装方式是不一样的. 本人在进行配置的时候,也遇到了这个坑,这里提前说明,希望

volatile为什么可以保证内存可见性及防止指令重排序?

内存 共享主存和高速缓存(工作内存).CPU高速缓存(L1,2)产生原因读写主存没有CPU执行指令快,他是某个CPU独有,只与该CPU运行的线程有关. 内存可见性 简单的说,CPU对数据的修改,对其他CPU立刻可见.下面我们详细地说. CPU修改数据,首先对工作内存修改,再同步主内存.单线程中,变量在工作内存的副本一直有效,CPU不用每次修改从主存读取变量,只是每次修改后同步主存. 对其他CPU立刻可见.当一个CPU修改变量,同步主存,如果其他CPU的工作内存也缓存这个变量,那么这个CPU的变量