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

概述

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

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

如果缺少同步,就会有很多因素会导致线程无法立即,甚至永远无法看到另一个线程的操作所产生的结果:

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

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

在单线程环境中,上述情况的发生,我们是无法感知到的,它除了能够提高程序执行速度外,不会产生其他的影响.

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

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

JMM规定了JVM的一种最小保证:什么时候写入一个变量会对其他线程可见.

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的底层细节的情况下,也能确保安全性.

posted @ 2018-11-27 10:36  lbr617  阅读(477)  评论(0编辑  收藏  举报