首页 > 技术文章 > 内存屏障

hangzhi 2019-07-31 22:17 原文

锁的使用场景

  • check-then-act:一个线程基于读取共享变量后的结果,决定下一个操作
  • read-modify-write:一个线程基于读取共享变量读取后的结果,更新该数据
  • 多个线程更新多个共享变量:如这多个共享变量是有关联关系的,那么使用锁保证这些共享变量的更新操作是原子性的
todo 区分synchronized和volatile使用内存屏障的不同用法

内存屏障

内存屏障是保证锁实现线程同步机制的一个主力。

锁保证可见性的过程:获得锁后刷新处理器缓存,保证当前线程读取到上个线程对共享变量做的跟新,释放锁后冲刷处理器缓存,保证当前对共享变量做的更新能被下个执行线程读取到。而这两个动作是Java虚拟机借助底层的内存屏障来实现的。

内存屏障仅针对内存读写操作指令,是被插到两个指令之间使用的,作用是禁止编译器,处理器重排序从而保证有序性,因为保证有序性的同时会刷新处理器缓存/冲刷处理器缓存,所以同时保证了可见性。

内存屏障按照所起作用分类

作用内存屏障动作插入位置
可见性(读写线程成对使用) 加载屏障 刷新处理器缓存 MonitorEnter(申请锁)后临界区前
  存储屏障 冲刷处理器缓存 临界区后MonitorExit(释放锁)后
有序性 获取屏障 禁止后续读写操作的重排序,先拿到共享数据的所有权,保证临界区中读写操作不会重排到临界区前 MonitorEnter(申请锁)后临界区前(读操作之后)
  释放屏障 禁止当前和之前读写操作重排序,释放所有权,保证临界区中读写操作不会重排到临界区外 临界区后MonitorExit(释放锁)前(写操作之前)

编译器/处理器为保证锁执行过程中,既能保证作用实现也尽量避免性能的损伤,遵循了一些基本的重排序规则:禁止部分重排序,允许部分重排序

  • 保证可见性和原子性:临界区内的代码不能重排序到临界区外
  • 减少性能伤害:
    • 临界内的代码可以重排序,发挥计算机性能,因为锁的排他性能,所以临界区内的代码是原子性操作,所以内部的执行即使不是顺序的,在外界看来也是一起发生的TODO
    • 临界区前和后的代码可以重排序
  • 锁申请与释放是配对的:锁申请MonitorEnter和锁释放MonitorExit操作不能重排序
  • 避免死锁:多个同步块内,多个锁的申请/多个锁的释放不能被重排序TODO
  • 动态编译时,临界区外的代码可以被重排序到临界区内,但是基于内存屏障的作用,这些进入临界区的代码将不能再出去了TODO

volatile

volatile关键字用于修饰变量容易变化,而这种易变的变量都应是内存访问(高速缓存或主内存),而不是被编译器分配到寄存器进行存储,所以其访问高效性没有读取普通变量高。

写操作:虚拟机会在写线程对volatile的写操作前插入释放屏障,而释放屏障禁止了重排序,从而保证这个更新操作对读线程是可见的,这也保证了读线程对共享变量执行的更新操作的感知顺序与源代码顺序一致,这个操作之后插入存储屏障,写操作后插入存储屏障,存储屏障保证所有操作结果对其他处理器是同步的,因此保证可见性和有序性。

读操作:虚拟机会在读操作之前插入加载屏障,加载屏障通过冲刷处理器缓存,从其他处理器同步更新结果到当前处理器的高速缓存中。读操作之后插入获取屏障,获取屏障禁止读操作之后任何读写操作与当前读操作重排序,保证volatile读操作之后的任何操作开始执行之前,任何写线程对共享变量和普通变量的更新对当前线程可见。

写操作的释放屏障结合读操作的加载屏障使得写线程对volatile变量的写操作包括之前执行的所有其他内存操作的结果对读线程可见,但因为volatile不具备排他性,所以它只能保证读线程读到的较新值而不是最新值

volatile也称轻量锁,volatile只保证写操作的原子性,且因为没有锁的排他性,所以不会产生上下文切换。

volatile相当于是编译器的一个提示,告知相应变量会被其他处理器更改,编译器不要做一些对可见性产生影响的优化。同时,volatile禁止以下重排序:写volatile变量操作不会和该操作之前的任何读写操作重排序,读volatile变量操作不会和该操作之后的任何读写操作重排序。

  1. 类似于被volatile修饰的变量的a,而a=a+2时,这个操作是不具备原子性的,因为这是个可分割的操作,所以为保证原子性,这个操作还是得用到锁,如果用volatile修饰,那么右边的表达式就不应该有共享变量

  2. 赋值操作中,如volatile A a = new A();总共有三个操作,因为只有操作3涉及到共享变量,其他都只涉及局部变量,所以对a的赋值操作还是个原子操作

1.objRef = allocate(A.class)    //分配对象所需的存储空间 

2.invokeConstructor(objRef)	   //初始化objRef的引用对象 

3.a = objRef			       //将对象引用写入a
  1. 如果volatile修饰的变量是数组或引用型变量,那么volatile关键字只能对数组引用本身有用(读取引用+更新引用),而对内部的元素或对象内部字段无用,可以使用juc包下的AtomicArray和AtomicRefference保证
int i = array[0];			   //读数组元素:读取数组引用(即内存地址)+在指定的数组引用(内存地址)计算偏移量来读取数组元素
array[0] = 1;				   //写数组元素
volatile int[] other = array;  //读取数组引用:更新另一个数组的引用,触发volatile作用

使用场景

  1. Java中,除了long/double型变量的写操作都具有原子性,而为了保证long/double的原子性操作,可以用volatile修饰。
  2. 使用volatile共享变量作为状态标志位:可用于一个线程通知另一个线程
  3. 使用其可见性,其他线程可以在没有加锁的情况下,读取到更新的值
  4. 替代锁:使用volatile修饰一个引用型变量,这个对象合并了一组需要更新的可变状态的变量,那么对这个对象的更新操作就相当于保证了这一组变量的原子性和可见性。
  5. 实现简易版读写锁:混合锁+volatile变量的使用 TODO负载均衡过程+代码

单例模式

单例模式为实现一个类有且仅有一个实例(只有一个ClassLoader加载一个类),而简单的单例模式不能满足多线程环境下正常运行,在满足线程安全的场景下,又需要对性能损耗有要求,有以下几种方式:

方法一:混合锁+volatile关键字保证线程安全,实现较为复杂。TODO DCL是反例???

public class Singleton{
    /**
     * 4.重排序:初始化前可能产生重排序:a->b->c 实际应该是a->c->b
     * 	为对象分配好内存 				   objRef = allocate(Singleton.class)			a
     * 	就先将对象的引用实例写入变量INSTANCE a = objRef									b
     * 	最后才初始化	  				    invokeConstructor(objRef)				 	 c
     * 	这样当线程访问到第一个if时,可能看到一个未初始化的实例,即INSTANCE不为null,然后直接	   
     * 把这个对象返回了
     * volatile可以禁止写volatile变量的b操作之前的任何读写操作ac重排序,保证线程读到的实例是已经初始化	 * 完成的
     * 5.可见性:线程初始化INSTANCE后,其他线程可见
     */
	public static volatile Singleton INSTANCE = getInstance();
    private Singleton(){}
    
    /**
     * 1.使用懒加载
     * 2.check-then-act:不是一个原子操作,可能出现线程交错,所以需要加锁
     * 3.双重检查锁定DCL:但这意味着每次调用时都需要申请锁,为了避免开销,可以先检查是否为null,如果是	 	  *   null再去执行临界区
     */
    public static Singleton getInstance(){
        if(INSTANCE == null){
            synchronized (Singleton.class){
                if(INSTANCE == null){
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

方法二:基于静态内部类,但是它的可见性仅保证第一次访问,更改后读到相对新值还是需要借助volatile,锁等TODO final对可见性有序性的保证只是一时的还是一直延续的

public class Singleton{
    
    private Singleton(){}
    /**
     * 静态变量的初次访问会触发虚拟机对其初始化,INSTANCE实例创建,且类的静态变量只会被创建一次
     * final保证读到的值是这个字段的初始值而不是默认值null等,也就是说初始化前其字段的初始值就已经赋好	  	   * 了;同时final关键字保证有序性,并不保证对象引用本身对外的可见性(后续更改不管)
     * static保证线程读到相应字段的初始值而不是相对新值
     */
    private static class SingletonHolder{
        final static Singleton INSTANCE = new Singleton();
    }
    public Singleton getInstance(){
        return SingletonHolder.INSTANCE;
    }
    public void dodo(){
        //do...
    }
    public static void main(String[] args){
        Singleton.getInstance.dodo()
    }
}

方法三:基于枚举类型

public class SingletonEnumTest{
    public static void main(String[] args){
        SingletonEnum.INSTANCE.dodo();
    }
    public static enum SingletonEnum{
        INSTANCE;//相当于SingletonEnum的唯一实例,SingletonEnum.INSTANCE初次被引用时才会初始化,					//访问SingletonEnum时不会初始化
        SingletonEnum(){}
        public void dodo(){
            //do...
        }
    }
}

CAS

CAS(compareAndSwap)是一种处理器指令,由处理器保证原子性。对于一个简单的计数器自增的实现(a++),使用锁来实现可能会造成不小的开销,volatile关键字不保证原子性,所以使用CAS,CAS能将read-modify-write和check-and-act之类的操作转换为原子操作。它的本质是一个if-then-act操作,相当于线程和对象之间的代理,比较线程提供的值和变量当前的值,如果相等,则更新这个值,其他当前想要更新的线程请求将会失败,如果不同,可以再次尝试。

juc包下多个原子操作变量类AtomicXXX都是基于CAS实现,保证对共享变量进行read-modify-write这类依赖旧值更新操作的原子性和可见性,其内部借助volatile。

如果有ABA的场景(当前值是A,但准备更新前,被其他线程改为B,更新时却以A为基础改的),可以使用AtomicStampedReference类,内部维护一个修订号即时间戳,每次对变量更新就会自增,以判断这个变量是否被更新

对象逸出

对象发布的结果不是我们期望的:构造器中将this赋值给一个共享变量,构造器中将this作为方法参数传递给其他方法,构造器中启动基于匿名类的线程;因为构造器未执行结束意味着这个对象并未初始化完成,发布到其他线程,线程看到的可能是个错误的结果;所以不能在构造器中启动工作者线程,避免this逸出,并为该类创建一个工厂方法,这个工厂方法会为该类的s实例并调用该类启动工作线程的方法

要安全发布一个正确创建的对象,可以使用volatile,final,static,锁,AtomicReference来修饰或引用该对象

推荐阅读