并发编程学习笔记之Java存储模型(十三)

概述

Java存储模型(JMM),安全发布、规约,同步策略等等的安全性得益于JMM,在你理解了为何这些机制会如此工做后,能够更容易有效地使用它们.java

1. 什么是存储模型,要它何用.

若是缺乏同步,就会有不少因素会致使线程没法当即,甚至永远没法看到另外一个线程的操做所产生的结果:数组

  • 编译器生成指令的次序,能够不一样于源代码书写的顺序,并且编译器还会把变量存储在寄存器,而不是内存中.
  • 处理器能够乱序或者并行地执行指令.
  • 缓存会改变写入提交到主内存的变量的次序.
  • 存储在处理器本地缓存中的值,对于其余处理器并不可见.

这些因素都会阻碍一个线程看到另外一个变量的最新值,并且会引发内存活动在不一样的线程中表现出不一样的发生次序---若是你没有适当同步的话.缓存

在单线程环境中,上述状况的发生,咱们是没法感知到的,它除了可以提升程序执行速度外,不会产生其余的影响.安全

Java语言规范规定了JVM要维护内部线程相似顺序话语义(within-thread as-if-serial semantics):只要程序的最终结果等同于它在严格的顺序化环境中执行的结果,那么上述全部的行为都是容许的.架构

从新排序后的指令使程序在计算性能上获得了很大的提高.对性能的提高作出贡献的,除了愈来愈高的时钟频率(它是评定CPU性能的重要指标。通常来讲主频数字值越大越好。),还有不断提高的并行性.如今时钟频率正变得难以经济地得到提升,能够提高的只有硬件并行性.app

JMM规定了JVM的一种最小保证:何时写入一个变量会对其余线程可见.ide

1.1 平台的存储模型

在可共享内存的多核处理器体系架构中,每一个处理器都有它本身的缓存,而且周期性的与主内存协调一致.函数

处理器架构提供了不一样级别的缓存一致性(cache coherence);有的只提供最小的保证,几乎在任什么时候间内,都容许不一样的处理器在相同的存储位置上看到不一样的值.不管是操做系统、编译器、运行时(有时甚至包括应用程序),都要将就这些硬件与线程安全需求之间的不一样.性能

想要保证每一个处理器都能在任意时间获知其余处理器正在进行的工做,其代价很是高昂,并且大多数时间里这些信息没什么用,因此处理器会牺牲存储一致性的保证,来换取性能的提高.优化

一种架构的存储模型告诉了应用程序能够从它的存储系统中得到何种担保,同时详细定义了一些特殊的指令被称为存储关卡(memory barriers)栅栏(fences),用以在须要共享数据时,获得额外的存储协调保证.

为了帮助Java开发者屏蔽这些跨架构的存储模型之间的不一样,Java提供了本身的存储模型,JVM会经过在适当的位置上插入存储关卡,来解决JMM与底层平台存储模型之间的差别化.

有一个理想地模型,叫顺序化一致性模型说的是:操做执行的顺序是惟一的,那就是它们出如今程序中的顺序,这与执行他们的处理器无关;另外,变量每一次读操做,都能获得执行序列上这个变量最新的写入值,不管这个值是哪一个处理器写入的.

这是一个理想,没有哪一个现代的多处理器会提供这一点,JMM也不行.这个模型又叫冯·诺依曼模型,这个经典的顺序化计算模型,仅仅是现代多处理器行为的模糊近似而已.

最后的结论就是: 在Java中,跨线程共享数据时,只须要正确的使用同步就能够保证线程安全,不须要在程序中指明存储关卡的放置位置..

1.2 重排序

各类可以引发操做延迟或者错序执行的不一样缘由,均可以归结为一类重排序(reordering).

public class PossibleReordering {

    static int x = 0 , y =0;
    static int a = 0 , b = 0;
    public static void main(String [] args) throws InterruptedException {
                Thread one = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        a = 1 ;
                        x = b ;
                    }
                });

                Thread other = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        b = 1;
                        y = a;
                    }
                });
                one.start();
                other.start();

                one.join();
                other.join();
                System.out.println("x:"+x);
                System.out.println("y:"+y);
    }

}

PossibleReordering可能由于重排序打印输出 0,0 1,1 1,0.

这是一个简单的程序,可是由于重排序的存在它列出的结果仍然让人惊讶.

内存级的重排序会让程序的行为变得不可预期.没有同步,推断执行次序的难度使人望而却步;只要确保你的程序已正确同步,事情就会变得简单些.

同步抑制了编译器、运行时和硬件对存储操做的各式各样的重排序,不然这些重排序会破坏JMM提供的可见性保证.

1.3 Java存储模型的简介

Java存储模型的定义是经过动做(actions)的形式进行描述的,所谓动做,包括变量的读和写、监视器加锁和释放锁、线程的启动和拼接(join).

JMM为全部程序内部的动做定义了一个偏序关系(happens-before),要想保证执行动做B的线程看到动做A的结果(不管A和B是否发生在同一个线程),A和B之间就必须知足happens-before关系.若是两个操做之间并未按照happens-before关系排序,JVM能够对它们随意地重排序.

偏序关系≼: 是集合上的一种反对称的,自反的和传递的关系,不过并非任意两个元素x,y都必须知足 x≼y或者y≼x.咱们天天都在应用偏序关系来表达咱们的喜爱;咱们能够喜欢寿司赛过三明治,能够喜欢莫扎特赛过马勒,可是咱们没必要在三明治和莫扎特之间作出一个明确的喜爱选择.

当一个变量被多个线程读取,且至少被一个线程写入时,若是读写操做并未按照happens-before排序,就会产生数据竞争(data race).一个正确同步的程序(correctly synchronized program)是没有数据竞争的程序;正确同步的程序会表现顺序的一致性,这就是说全部程序内部的动做会以固定的、全局的顺序发生.

数据竞争: 若是在访问共享的非final类型的域时没有采用同步来进行协同,那么就会出现数据竞争.(数据竞争主要会引起过时数据的问题)

happens-before的法则包括:

  • 程序次序法则:线程中的每一个动做A都happens-before于该线程中的每个动做B,其中,在程序中,全部的动做B都出如今动做A以后.
  • 监视器锁法则:对一个监视器锁的解锁happens-before于每个后续对同一监视器锁定加锁.(同显示锁)
  • volatile变量法则:对volatile域的写入操做happens-before于每个后续对同一域的读操做.
  • 线程启动法则:在一个线程里,对Thread.start的调用会happens-before于每个启动线程中的动做.
  • 线程终结法则:线程中的任何动做都happens-before于其余线程检测到这个线程已终结、或者从Thread.join调用中成功返回,或者Thread.isAlive返回false.
  • 中断法则:一个线程调用另外一个线程的interrupt happens-before于被中断的线程发现中断(经过抛出InterruptedException,或者调用isInterrupted和interrupted)
  • 终结法则:一个对象的构造函数的结束 happens-before于B,且B happens-before于C,则A happens-before 于C.

虽然动做仅仅须要知足偏序关系,可是同步动做--锁的获取与释放,以及volatile变量的读取与写入--倒是知足全序关系(当偏序集中的任意两个元素均可比时,称该偏序集知足全序关系).

1.4 由类库担保的其余happens-before排序

包括:

  • 将一个条目置入线程安全容器happens-before于另外一个线程从容器中获取条目.
  • 执行CountDownLatch中的倒计时happens-before于线程从闭锁(latch)的await中返回.
  • 释放一个许可给Semaphore happens-before 于从同一个Semaphore里得到一个许可.
  • Future表现的任务所发生的动做 happens-before 于另外一个线程成功地从Future.get
  • 向Executor提交一个Runnable或Callable happens-before 与开始执行任务.
  • 最后,一个线程到达CyclicBarrier或者Exchanger happens-before于相同关卡(barrier)或Exchanger点中的其余线程被释放.若是CyclicBarrier使用一个关卡(barrier)动做,到达关卡happens-before于关卡动做,依照次序,关卡动做happens-before于线程从关卡中释放.

2. 发布

安全发布技术之因此是安全的,正是得益于JMM提供的保证.

而不正确发布带来风险的真正缘由,是在"发布共享对象"与从"另外一个线程访问它"之间,缺乏happens-before.

2.1 不安全的发布

在缺乏happens-before的状况下,存在重排序的可能性.因此没有充分同步的状况下发布一个对象会致使看到对象的过时值(在赋值的状况下可能看到对象是null或者对象的引用是null).

局部建立对象:

public class UnsafeLazyInitialization {
    private static  Resource resource;

    public static  Resource getInstance(){
        if(resource == null){
            resource = new Resource();
        }
        return resource;
    }

}

这是非线程安全的,一个线程调用getInstance, 当== null成真时,为resource赋值,可是不能保证对另外一个线程可见,会有过时值的问题.

类中只有一种方式得到resource对象的实例就是经过getInstance方法,可是由于调用这个方法的线程之间没有同步,因此即便代码的书写顺序是在 == null的时候先赋值再返回引用,可是另外一个线程获得resource实例的时候可能由于重排序致使获得的是一个resouce是new 出来的实例,可是对象的域为null的状况.

除了不可变对象,使用被另外一个线程初始化的对象,是不安全的,除非对象的发布时happens-before于对象的消费线程使用它.

2.2 安全发布

安全发布之因此是安全的是由于发布的对象对于其余线程是可见的.由于它们保证发布对象是happens-before于消费线程加载已发布对象的引用.

happens-before比安全发布承诺更强的可见性与排序性.可是安全发布的操做更加贴近程序设计.

2.3 安全初始化技巧

有些对象的初始化很昂贵,这时候惰性初始化的好处就显现出来了.

能够修改一下以前的代码,使它变成线程安全的.

public class UnsafeLazyInitialization {
    private static  Resource resource;

    public static synchronized   Resource getInstance(){
        if(resource == null){
            resource = new Resource();
        }
        return resource;
    }

}

在类中,静态的初始化对象:

private static Resource resource = new Resource();

提供了额外的线程安全性保证,JVM要在初始化期间得到一个锁,这个锁每一个线程至少会用到一次来确保一个类是否已被加载:这个锁也保证了静态初始化期间,内存写入的结果自动地对全部线程是可见的.因此静态初始化的对象,不管是构造期间仍是被引用的时候,都不须要显示地进行同步.(只适用于构造当时(as-constructed)的状态,若是对象是可变的,仍是须要加锁)

public class EagerInitialization{
    private static Resource resource = new Resource();
    
    publiic static Resource getResource(){
        return resource;
    }
}

惰性初始化holder类技巧:

public class ResourceFactory {
    private static class ResourceHolder{
        public static Resource resource = new Resource();
    }

    public static Resource GetInResource(){
        return ResourceHolder.resource;
    }
}

2.4 双重检查锁

public class DoubleCheckedLocking {
    private static Resource resource;

    public static Resource getInstance(){
        //若是对象不等于空
        if(resource == null){
            //加锁,此时可能有多于一个的线程进入,因此须要再次判断
            synchronized (DoubleCheckedLocking.class){
            //再次判断
                if(resource ==null){
                    resource = new Resource();
                }
            }
        }
        return resource
    }
}

双重检查锁最大的问题在于:线程可看到引用的当前值,可是对象的状态确实过时的.这意味着对象能够被观察到,但却处于无效或错误的状态.

双重检查锁已经被废弃了---催生它的动力(缓慢的无竞争同步和缓慢的JVM启动)已经不复存在.这样的优化已经不明显了. 使用惰性初始化更好.

3. 初始化安全性

保证了初始化安全,就可让正确建立的不可变对象在没有同步的状况下被安全地跨线程共享,而不用管它是如何发布的.

若是没有初始化安全性,就会发生这样的事情:像String这样的不可变对象,没有在发布或消费线程中用到同步,可能表现出它们的值被改变.

初始化安全能够保证,对于正确建立的对象,不管它是如何发布的,全部线程都将看到构造函数设置的final域的值.更进一步,一个正确建立的对象中,任何能够经过其final域触及到的变量(好比一个final数组中的元素,或者一个final域引用的HashMap里面的内容),也能够保证对其余线程是可见的(只有经过正在被构建的对象的final域,才能触及到).

不可变对象的初始化安全性:

public class SafeStates {
    private final Map<String,String> states;

    public SafeStates() {
        states = new HashMap<>();
        states.put("a","a");
        states.put("b","b");
        states.put("c","c");
    }

    public String getAbbreviation(String s){
        return states.get(s);
    }
    
}

对于含有final域的对象,初始化安全性能够抑制重排序,不然这些重排序会发生在对象的构造期间以及内部加载对象引用的时刻.全部构造函数要写入值的final域,以及任何经过这些域获得的任何变量,都会在构造函数完成后被"冻结",能够保证任何得到该引用的线程,至少能够看到和冻结同样的新值.

因此在即便没有同步,并且依赖于非线程安全的HashSet能够被安全地发布.可是只止于安全的发布,若是有任何线程能够修改states的值仍是须要同步来保证线程安全性.

初始化安全性保证只有以经过final域触及的值,在构造函数完成时才是可见的,对于经过非final域触及的值,或者建立完成后可能改变的值,必须使用同步来确保可见性.

总结

Java存储模型明确地规定了在什么时机下,操做存储器的线程的动做能够被另外的动做看到.规范还规定了要保证操做是按照一种偏序关系进行排序.这种关系称为happens-before,它是规定在独立存储器和同步操做的级别之上的.

若是缺乏充足的同步,线程在访问共享数据时就会发生很是没法预期的事情.可是使用安全发布能够在不考虑happens-before的底层细节的状况下,也能确保安全性.

相关文章
相关标签/搜索