关于并发编程:Java并发编程从CPU缓存模型到JMM来理解volatile关键字

34次阅读

共计 5552 个字符,预计需要花费 14 分钟才能阅读完成。

并发编程三大个性

原子性

一个操作或者屡次操作,要么所有的操作全副都失去执行并且不会受到任何因素的烦扰而中断,要么所有的操作都执行,要么都不执行

对于根本数据类型的拜访,读写都是原子性的【long 和 double 可能例外】。

如果须要更大范畴的原子性保障,能够应用 synchronized 关键字满足。

可见性

当一个变量对共享变量进行了批改,另外的线程都能立刻看到批改后的最新值

volatile保障共享变量可见性,除此之外,synchronizedfinal 都能够 实现可见性。

synchronized:对一个变量执行 unclock 之前,必须先把此变量同步回主内存中。

final:被 final 润饰的字段在结构器中一旦被初始化实现,并且结构器没有把 this 的援用传递进来,其余线程中就可能看见 final 字段的值。

有序性

即程序执行的程序依照代码的先后顺序执行【因为 指令重排序 的存在,Java 在编译器以及运行期间对输出代码进行优化,代码的执行程序未必就是编写代码时候的程序】,volatile通过禁止指令重排序保障有序性,除此之外,synchronized关键字也能够保障有序性,由【一个变量在同一时刻只容许一条线程对其进行 lock 操作】这条规定取得。

CPU 缓存模型是什么

高速缓存为何呈现?

计算机在执行程序时,每条指令都是在 CPU 中执行的,而执行指令过程中,势必波及到 数据的读取和写入 。因为程序运行过程中的 长期数据是寄存在主存(物理内存)当中的,这时就存在一个问题,因为 CPU 执行速度很快,而从内存读取数据和向内存写入数据的过程跟 CPU 执行指令的速度比起来要慢的多,因而如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。

为了解决 CPU 处理速度和内存不匹配的问题,CPU Cache 呈现了。

图源:JavaGuide

缓存一致性问题

当程序在运行过程中,会 将运算须要的数据从主存复制一份到 CPU 的高速缓存当中 ,那么 CPU 进行计算时就能够 间接从它的高速缓存读取数据和向其中写入数据 ,当运算完结之后,再将高速缓存中的数据 刷新到主存 当中。

在单线程中运行是没有任何问题的,然而在多线程环境下问题就会浮现。举个简略的例子,如上面这段代码:

i = i + 1; 

依照下面剖析,次要分为如下几步:

  • 从主存读取 i 的值,复制一份到高速缓存中。
  • CPU 执行执行执行对 i 进行加 1 操作,将数据写入高速缓存。
  • 运算完结后,将高速缓存中的数据刷新到内存中。

多线程环境 下,可能呈现什么景象呢?

  • 初始时,两个线程别离读取 i 的值,存入各自所在的 CPU 高速缓存中。
  • 线程 T1 进行加 1 操作,将 i 的最新值 1 写入内存。
  • 此时线程 T2 的高速缓存中 i 的值还是 0,进行加 1 操作,并将 i 的最新值 1 写入内存。

最终的后果 i = 1 而不是 i = 2,得出结论:如果一个变量在多个 CPU 中都存在缓存(个别在多线程编程时才会呈现),那么就可能存在缓存不统一的问题。

如何解决缓存不统一

解决缓存不统一的问题,通常来说有如下两种解决方案【都是在硬件层面上提供的形式】:

通过在总线加 LOCK# 锁的形式

在晚期的 CPU 当中,是通过在总线上加 LOCK#锁的模式来解决缓存不统一的问题。因为 CPU 和其余部件进行通信都是通过总线来进行的,如果对总线加 LOCK# 锁的话,也就是说阻塞了其余 CPU 对其余部件拜访(如内存),从而使得只能有一个 CPU 能应用这个变量的内存。比方下面例子中 如果一个线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上收回了 LCOK# 锁的信号,那么只有期待这段代码齐全执行结束之后,其余 CPU 能力从变量 i 所在的内存读取变量,而后进行相应的操作。这样就解决了缓存不统一的问题。

但,有一个问题,在锁住总线期间,其余 CPU 无法访问内存,导致效率低下,于是就呈现了上面的缓存一致性协定。

通过缓存一致性协定

较驰名的就是 Intel 的 MESI 协定,MESI 协定保 S 证了每个缓存中应用的共享变量的正本是统一的。

当 CPU 写数据时,如果发现操作的变量是共享变量 ,即在其余 CPU 中也存在该变量的正本,会发出信号 告诉其余 CPU 将该变量的缓存行置为有效状态 ,因而当其余 CPU 须要读取这个变量时,发现自己缓存中缓存该变量的缓存行是有效的【嗅探机制:每个处理器通过嗅探在总线上流传的数据来查看本人的缓存的值是否过期】,那么它就 会从内存从新读取

基于 MESI 一致性协定,每个处理器须要一直从主内存嗅探和 CAS 一直循环,有效交互会导致总线带宽达到峰值,呈现 总线风暴

图源:JavaFamily 敖丙三太子

JMM 内存模型是什么

JMM【Java Memory Model】:Java 内存模型,是 java 虚拟机标准中所定义的一种内存模型,Java 内存模型是标准化的,屏蔽掉了底层不同计算机的区别,以实现让 Java 程序在各种平台下都能达到 统一的内存拜访成果

它形容了Java 程序中各种变量【线程共享变量】的拜访规定,以及在 JVM 中将变量存储到内存和从内存中读取变量这样的底层细节。

留神,为了取得较好的执行性能,Java 内存模型并没有限度执行引擎应用处理器的寄存器或者高速缓存来晋升指令执行速度,也没有限度编译器对指令进行重排序。也就是说,在 java 内存模型中,也会存在 缓存一致性问题和指令重排序 的问题。

JMM 的规定

所有的共享变量都存储于主内存 ,这里所说的变量指的是【实例变量和类变量】,不蕴含局部变量,因为 局部变量是线程公有的,因而不存在竞争问题

每个线程都有本人的 工作内存(相似于后面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能间接对主存进行操作。

每个线程不能拜访其余线程的工作内存。

Java 对三大个性的保障

原子性

在 Java 中,对根本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

为了更好地了解下面这句话,能够看看上面这四个例子:

x = 10;      //1
y = x;       //2
x ++;        //3
x = x + 1;  //4 
  1. 只有语句 1 是原子性操作:间接将数值 10 赋值给 x,也就是说线程执行这个语句的会 间接将数值 10 写入到工作内存中
  2. 语句 2 理论蕴含两个操作:先去读取 x 的值,再将 x 的值写入工作内存,尽管两步别离都是原子操作,然而合起来就不能算作原子操作了。
  3. 语句 3 和 4 示意:先读取 x 的值,进行加 1 操作,写入新的值

须要留神的点:

  • 在 32 位平台下,对 64 位数据的读取和赋值是须要通过两个操作来实现的,不能保障其原子性。在目前 64 位 JVM 中,曾经保障对 64 位数据的读取和赋值也是原子性操作了。https://www.zhihu.com/question/38816432
  • Java 内存模型 只保障了根本读取和赋值是原子性操作,如果要实现更大范畴操作的原子性,能够通过 synchronized 和 Lock 来实现。

可见性

Java 提供了 volatile 关键字来保障可见性。

当一个共享变量被 volatile 润饰时,它会 保障批改的值会立刻被更新到主存,当有其余线程须要读取时,它会去内存中读取新值。

另外,通过 synchronized 和 Lock 也可能保障可见性 ,synchronized 和 Lock 能保 证同一时刻只有一个线程获取锁而后执行同步代码,并且在开释锁之前会将对变量的批改刷新到主存当中。因而能够保障可见性。

有序性

在 Java 内存模型中,容许编译器和处理器对指令进行 重排序,然而重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

在 Java 外面,能够通过 volatile 关键字来保障有序性,另外也能够通过 synchronized 和 Lock 来保障有序性。

Java 内存模型具备一些先天的有序性,前提是 两个操作满足 happens-before 准则,摘自《深刻了解 Java 虚拟机》:

  • 程序秩序规定:一个线程内,依照代码程序,书写在后面的操作后行产生于书写在前面的操作【让程序 看起来 像是依照代码程序执行,虚拟机 只会对不存在数据依赖性的指令进行重排序 ,只能保障单线程中 执行后果的正确性,多线程后果正确性却无奈保障】
  • 锁定规定:一个 unLock 操作后行产生于前面对同一个锁额 lock 操作
  • volatile 变量规定:对一个变量的写操作后行产生于前面对这个变量的读操作
  • 传递规定:如果操作 A 后行产生于操作 B,而操作 B 又后行产生于操作 C,则能够得出操作 A 后行产生于操作 C
  • 线程启动规定:Thread 对象的 start()办法后行产生于此线程的每个一个动作
  • 线程中断规定:对线程 interrupt()办法的调用后行产生于被中断线程的代码检测到中断事件的产生
  • 线程终结规定:线程中所有的操作都后行产生于线程的终止检测,咱们能够通过 Thread.join()办法完结、Thread.isAlive()的返回值伎俩检测到线程曾经终止执行
  • 对象终结规定:一个对象的初始化实现后行产生于他的 finalize()办法的开始

如果两个操作的执行秩序无奈从 happens-before 准则推导进去,那么它们就不能保障它们的有序性,虚拟机能够随便地对它们进行重排序。

volatile 解决的问题

  • 保障了不同线程对共享变量【类的成员变量,类的动态成员变量】进行操作是时的可见性,一个线程批改了某个变量的值,新值对其余线程来说是立刻可见的
  • 禁止指令重排序。

举个简略的例子,看上面这段代码:

// 线程 1
boolean volatile stop = false;
while(!stop){doSomething();
}
// 线程 2
stop = true; 
  1. 线程 1 和 2 各自都领有本人的工作内存,线程 1 和线程 2 首先都会将 stop 变量的值拷贝一份放到本人的工作内存中,
  2. 共享变量 stop 通过 volatile 润饰,线程 2 将 stop 的值改为 true 将会立刻写入主内存。
  3. 线程 2 写入主内存之后,导致线程 1 工作内存中缓存变量 stop 的缓存行有效。
  4. 线程 1 的工作内存中缓存变量 stop 的缓存行有效,导致线程 1 会再次从主存中读取 stop 值。

volatile 保障原子性吗?怎么解决?

volatile 无奈保障原子性,如对一个 volatile 润饰的变量进行自增操作i ++,无奈保障多线程下后果的正确性。

解决办法:

  • 应用 synchronized 关键字或者 Lock 加锁,保障某个代码块 在同一时刻只能被一个线程执行。
  • 应用 JUC 包下的原子类,如 AtomicInteger 等。【Atomic 利用 CAS 来实现原子操作】。

volatile 的实现原理

上面这段话摘自《深刻了解 Java 虚拟机》:

察看退出 volatile 关键字和没有退出 volatile 关键字时所生成的汇编代码发现,退出 volatile 关键字时,会多出一个 lock 前缀指令。

lock 前缀指令实际上相当于一个 内存屏障(也成内存栅栏),内存屏障会提供 3 个性能:

  • 它确保指令重排序时不会把其前面的指令排到内存屏障之前的地位,也不会把后面的指令排到内存屏障的前面;即在执行到内存屏障这句指令时,在它后面的操作曾经全副实现;
  • 它会强制将对缓存的批改操作立刻写入主存;
  • 如果是写操作,它会导致其余 CPU 中对应的缓存行有效。

volatile 和 synchronized 的区别

volatile 变量读操作的性能耗费与一般变量简直没有什么差异,然而写操作则会慢一些,因为它须要在本地代码中 插入许多内存屏障指令 来保障处理器不产生乱序执行。不过即便如此,大多数场景下 volatile 的总开销依然要比锁来的低

  • volatile 只能用于变量,而 synchronized 能够润饰办法以及代码块。
  • volatile 能保障可见性,然而不能保障原子性。synchronized 两者都能保障。如果只是对一个共享变量进行多个线程的赋值,而没有其余的操作,举荐应用 volatile,它更加轻量级。
  • volatile 关键字次要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间拜访资源的同步性。

volatile 的应用条件

应用 volatile 必须具备两个条件【保障原子】:

  • 对变量的写操作不依赖于以后值。
  • 该变量没有蕴含在具备其余变量的不变式中。

volatile 与双重查看锁实现单例

用双重查看锁的形式实现单例模式:

public class Singleton {
    // 留神应用 volatile 避免指令重排序
    private volatile static Singleton instance;
    // 私有化构造方法,单例模式基本操作
    private Singleton() {}
    // 动态获取单例的办法
    public  static Singleton getInstance() {
       // 先判断对象是否曾经实例过,没有实例化过才进入加锁代码
        if (instance == null) {
            // 类对象加锁
            synchronized (Singleton.class) {if (instance == null) {instance = new Singleton();
                }
            }
        }
        return instance;
    }
} 

应用 volatile 的起因:避免指令重排序。

instance= new Singleton();这一步,是一个实例化的过程,底层其实分为三部执行:

  1. 为 instance 分配内存空间:memory = allocate();
  2. 实例化 instance。ctorInstance(memory);
  3. 将 instance 指向调配的内存地址。instance = memory;

因为 JVM 具备指令重排序的个性,指令的执行程序可能会变成 1,3,2。在多线程环境下,可能某个线程可能会失去未初始化的实例。

举个例子:退出线程 A 执行了 1 和 2 之后,线程 B 调用 getInstance 的时候,会发现 instance 不为 null,会间接返回这个没有执行过指令 3 的实例。
如果感觉本文对您有帮忙,点赞珍藏反对一下

正文完
 0