面试必备之volatile

概述

多线程编程的三个问题:可见性、有序性及原子性。volatile关键字:

  1. 保证多线程环境下的可见性
  2. 通过防止重排序解决有序性
  3. 对volatile变量的单次读/写操作可保证原子性,如long和double类型变量,但不能保证i++这种操作的原子性,因i++是读、写两次操作

volatile变量可以用于提供线程安全,但必须同时满足两个条件:

  • 对变量的写操作不依赖于当前的值
  • 该变量没有包含在具有其他变量的不变式之中

一个原则:只有在状态真正独立于程序内其他内容时才能使用 volatile。

可见性

定义:一个线程对共享变量做修改之后,其他的线程立即能够看到(感知到)该变量这种修改(变化)。

JMM规定所有的变量都是存在主内存中,每个线程都有自己的工作内存。每个线程对共享数据的读、写操作都只在各自的工作内存中,不能直接在主内存中进行;在各自工作内存中对数据操作完成后,同步到主内存中;线程间不能访问各自工作内存中的数据,只能通过主内存来完成。

happens-before原则禁止处理器和编译器的某些重排序,也可以保证可见性。

实现可见性的内存语义:

  • 当写一个volatile变量时,JMM会把线程对应的本地内存中的共享变量值刷新到主内存
  • 当读一个volatile变量时,JMM会把线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量

主内存:所有线程共享的区域,存储线程共享的数据,包括实例变量、静态变量和构成数组的对象的元素,不包括局部变量和方法参数。
工作内存:每个线程独享的区域,存储主内存中的数据拷贝。

实现可见性的方法:volatile、synchronized、Lock、final。

volatile可被认为是一种轻量级的synchronized机制,访问volatile变量时并不会执行加锁操作,不会导致线程阻塞,同时它又和锁机制一样,都具备可见性(并不具备原子性)。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会把这个变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者其他处理器不可见的地方。在读取volatile变量时总是会返回最新写入的值。

有序性

定义

重排序:JVM或JIT为了获得更好的性能,在不影响语句执行结果(即as-if-serial语义)的前提下会对语句重排序。但volatile类型变量因内存屏障和happens-before规则,则不会参与重排序。

volatile禁止指令重排序是通过lock前缀指令实现,相当于一个内存屏障,指令重排序时不能把后面的指令重排序到lock前缀指令之前,强制将对工作内存的修改操作立即写入主内存中。只有一个cpu时,这种内存屏障是多余的。多个cpu访问同一块内存时,需要内存屏障。

单例模式的一种实践,DLC,double lock check,双重检查加锁,便是利用这个特性。

为什么双重校验单例模式要加volatile关键词?
参考why-is-volatile-used-in-double-checked-locking
因为new这个指令是非原子操作,底层是分成几条指令来执行的,加上volatile 是禁止指令重排,保证别的线程读到的一定是状态和引用正常的、一个完整的对象,防止其他线程看到的是类似引用有了,内存资源却还没分配的对象。

javap -v编译出来的字节码指令还不是全部指令,它里面的 new 指令还是能更细分的,因为 volatile 的指令还要深入到汇编的层次插入的

原子性

定义:指一个操作不能被打断,要么全部执行完毕,要么不执行。也叫互斥性,即一次只允许一个线程能够持有某个特定的锁,并访问其代码块。因此原子性可以用于实现对共享数据对协调访问,一次只有一个线程可以访问其共享对象。

Java中读取long/double类型变量不是原子的,需要分成两步,高32位和低32位。对一个volatile型的 long或double变量的读写是原子。对于++整个操作不是原子性的。++操作,可以用AtomicInteger.incrementAndGet()来代替;AtomicInteger,AtomicLong等API采用基于CAS的无锁技术。

实例

开销较低的读-写锁策略

如果对于某个变量的值的读操作远远超过写操作,可以通过将内置锁(帮助实现操作的原子性)和volatile关键字相结合,实现开销较低的读-写锁。

@ThreadSafe
public class CheesyCounter {
    // Employs the cheap read-write lock trick
    // All mutative operations MUST be done with the 'this' lock held
    @GuardedBy("this");
    private volatile int value;

    public int getValue() {
        return value;

    public synchronized int increment() {
        return value++;
    }
}

拓展

happens-before

保证正确同步的多线程程序的执行结果不被改变。
规则:

  1. 程序顺序规则(Program Order Rule):一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  2. 监视器锁规则(Monitor Lock Rule):解锁,happens-before于加锁,强调的是同一个锁。
  3. volatile变量规则(Volatile Variable Rule):对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  4. 传递性(Transitivity):如果A happens-before B, 且B happens-before C, 那么A happens-before C。
  5. 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。如果线程A执行操作ThreadB.start(),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  6. 线程终于规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,可以通过Thread.join()方法结束,Thread.isAlive()的返回值等作段检测到线程已经终止执行。如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
  7. 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否有中断发生。
  8. 对象终结规则(Finalizer Rule):一个对象初始化完成(构造方法执行完成)先行发生于它的finalize()方法的开始

as-if-serial

保证单线程内程序的执行结果不被改变;不管怎么重排序,单线程程序的执行结果不能改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序。
as-if-serial语义把单线程程序保护起来,遵守as-if-serial语义的编译器、runtime和处理器共同为编写单线程程序的程序员创建一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

重排序

指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。重排序分3种类型:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义(as-if-serial )的前提下,可以重新安排语句的执行顺序
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对机器指令的执行顺序
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序:
在这里插入图片描述
上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。

重排序的意义:JVM能根据处理器的特性,充分利用多级缓存,多核等进行适当的指令重排序,使程序在保证业务运行的同时,充分利用CPU的执行特点,最大的发挥机器的性能!

内存屏障

Memory Barrier,也叫内存栅栏,Memory Fence,是一组CPU指令,用于控制特定条件下的重排序和内存可见性问题。特殊指令,如ARM中的dmb、dsb和isb指令,x86中的sfence、lfence和mfence指令。CPU遇到这些特殊指令后,要等待前面的指令执行完成才执行后面的指令。

Java编译器也会根据内存屏障的规则禁止重排序。
分为以下几种类型:

  • LoadLoad屏障:对于语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:对于语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • LoadStore屏障:对于语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreLoad屏障:对于语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

有的处理器的重排序规则较严,无需内存屏障也能很好的工作,Java编译器会在这种情况下不放置内存屏障。

Java编译器使用内存屏障:
在这里插入图片描述
内存屏障阻碍CPU采用优化技术来降低内存操作延迟,必须考虑因此带来的性能损失。为了达到最佳性能,最好是把要解决的问题模块化,这样处理器可以按单元执行任务,然后在任务单元的边界放上所有需要的内存屏障。采用这个方法可以让处理器不受限的执行一个任务单元。合理的内存屏障组合的好处:缓冲区在第一次被刷后开销会减少,因为再填充改缓冲区不需要额外工作。

注意

使用volatile修饰的变量必须满足以下两个条件:

  1. 对变量的写操作不依赖于当前值,或确保只有一个线程修改变量的值
  2. 该变量没有包含在具有其他变量的不变式中。

这两个条件也是并发环境的线程安全的保证。

volatile vs synchronzied

volatile作为一种轻量级的同步工具,一般而言比synchronzied拥有更少的资源消耗。因为JDK6以后的版本对synchronzied进行锁消除和优化。

加volatile与不加volatile比较,性能消耗读操作时没有任何区别,写操作因为需要插入内存屏障指令来保证多个cpu下不会发生乱序操作,性能会略有降低,是值得的。

其他

Java 中能创建 Volatile 数组吗?
能,不过只是一个指向数组的引用,而不是整个数组,如果改变引用指向的数组,将会受到 volatile 的保护,但是如果多个线程同时改变数组的元素,volatile 标示符就不能起到之前的保护作用。

posted @ 2020-05-05 14:09  johnny233  阅读(10)  评论(0编辑  收藏  举报  来源