从0学习java并发编程实战-读书笔记-java内存模型(14)

什么是java内存模型

假设一个线程为变量aVariable赋值:

aVariable = 3;

内存模型需要解决的问题:在什么条件下,读取aVariable的线程将看到这个值为3?

  • 如果缺少同步,那么将会有许多因素使得线程无法立即甚至永远,看到另一个线程的操作结果。
  • 在编译器中生成的指令顺序,可以与源代码中的顺序不同,
  • 编译器还会把变量保存在寄存器而不是内存中。
  • 处理器可以采用乱序或并行等方式来执行指令。
  • 缓存可能会改变将写入变量提交到主内存的次序。
  • 保存在处理器本地缓存的值,对于其他处理器是不可见的。

以上这些因素都可能会使一个线程无法看到变量的最新值,并且会导致其他线程中的内存操作似乎在乱序执行(如果没有使用正确的同步)。

在单线程环境下,我们无法看到所有这些底层技术,它们除了提高程序的执行速度外,不会产生其他影响。java语言规范要求JVM在线程中维护一种类似串行的语义:只要程序最终结果与在严格串行中执行的结果相同,那么上诉条件都是允许的。
在多线程环境中,维护程序的串行性将带来很大的性能开销。对于并发应用程序中的线程来说,它们在大多数时间里都在执行各自的任务,因此在线程之间的协调操作只会降低应用程序的运行速度,不会带来任何好处。只有当多个线程需要共享数据时,才必须协调它们之间的操作,并且JVM依赖程序通过同步来找出这些协调将在什么时候发生。

平台的内存模型

在共享内存的多处理器体系架构中,每个处理器都拥有自己的缓存,并且定期地与主内存进行协调。在不同的处理器架构中提供了不同级别的缓存一致性(Cache Coherence),其中一部分只提供最小保证,即允许不同的处理器在任意时刻从同一位置上看到不同的值
要想确保每个处理器都能在任意时刻知道其他正在进行的工作,将需要非常大的开销。在大多数时间里,这种信息是不必要的,因此处理器适当的放宽一致性保证,以换取性能的提升。
在架构定义的内存模型中,将告诉应用程序可以从内存系统中获得怎样的保证,此外还定义了一些特殊的指令(称为内存栅栏栅栏),当需要共享数据时,这些指令就能实现额外的存储协调保证。为了使java开发人员无需关心不同架构上内存模型的差异,java提供了自己的内存模型,并且JVM通过在适当位置插入内存栅栏来屏蔽在JMM与底层平台内存模型之间的差异。

重排序

在没有充分同步的程序中,如果调度器采用不恰当的方式来交替执行不同线程的操作,那么将导致不正确的结果。JMM还使得不同线程看到的操作执行顺序是不同的,从而导致在缺乏同步的情况下,要推断操作的执行顺序变得更加复杂。各自使操作延迟或看似混乱执行的不同原因,都可以归为重排序。

java内存模型简介

java内存模型是通过各种操作来定义的,包括对变量的读/写操作,监视器的加锁和释放,以及线程的启动和合并。JMM为程序中所有的操作定义了一个偏序关系,称为Happens-Before。要想保证操作B的线程看到操作A的结果(无论AB是否在一个线程上执行),那么AB之间必须满足偏序关系,否则,JVM可以对它们进行任意的重排序。
偏序关系(Happens-Before)的规则包括:

  • 程序顺序规则:如果程序中操作A在操作B之前,那么在线程中A操作将在B操作之前执行。
  • 监视器锁规则:在监视器锁上的解锁操作必须在同一个监视器锁的加锁操作之前执行(显示锁和内置锁在加锁和解锁等操作上有着相同的内存语义)。
  • volatile变量规则:对volatile变量的写入操作必须在对该变量的读操作之前执行(原子变量volatile变量在读写操作上有着相同的内存语义)。
  • 线程启动规则:在线程上对Thread.start的调用必须在该线程中执行任何操作之前执行。
  • 线程结束规则:线程中的任何操作必须在其他检测到该线程已经结束之前执行,或者从Thread.join中返回,或者在调用Thread.isAlive时返回false。
  • 中断规则:当一个线程在另一个线程上调用interrupt时,必须在被中断线程检测到interrupt调用之前执行(通过抛InterruptedException,或者调用isInterrupted和interrupted)。
  • 终结器规则:对象的构造函数必须在启动该对象的终结器之前执行完成。
  • 传递性:如果操作A 在操作B之前执行,操作B在操作C之前执行,那么操作A必须在操作C之前执行。

借助同步

由于Happens-Before的排序功能很强大,因此有时候可以借助(Piggyback)现有同步机制的可见性属性。这需要将Happens-Before的程序顺序规则与其他某个顺序规则(通常是指监视器锁规则或者volatile变量规则)结合起来,从而对某个未被锁保护的变量访问操作进行排序。
这项技术由于对语句顺序非常敏感,因此很容易出错,它是一项高级技术,并且只有当需要最大限度的提升某些类(如ReentrantLock)的性能时候,才应该使用这个技术。
在FutureTask的保护方法AQS中说明了如何使用这种借助技术。AQS维护了一个表示同步器状态的整数,FutureTask用这个整数来保存任务的状态:正在运行、已完成、已取消。但FutureTask还维护了其他一些变量,例如计算的结果。当一个线程调用set来保存结果并且另一个线程调用get来获取该结果时,这两个线程最好按照Happens-Before进行排序。这可以通过将执行结果的引用声明为volatile类型来实现,但利用现有的同步机制可以更容易的实现相同功能。

说明如何借助同步的FutureTask内部类

private final class Sync extends AbstractQueuedSynchronizer {
    private static final int RUNNING = 1, RAN = 2, CANCELED = 4;
    private V result;
    private Exception excetpion;

    void innerSet(V v){
        while(true){
            int s = getState();
            if(ranOrCanceled(s)){
                return;
            }
            if(compareAndSetState(s, RAN)){
                break;
            }
            result = v;
            releaseShared(0);
            done();
        }

        V innerGet() throws InterruptedException ,ExcutionExcption{
            acquireSharedInterruptibly(0);
            if(getState() == CANCELED){
                throw new CancellationException();
            }
            if(exception != null){
                throw new ExecutionException(exception);
            }
            return result;
        }
    }
}

FutureTask在设计时能确保,在调用tryAcquireShared之前总能成功调用tryReleaseShared。tryReleasedShared会写入一个volatile类型的变量,而tryAcquireShared将读取这个变量。innerSet和innerGet,在保存和获取result时将调用这些方法。
innerSet在调用releaseShared(这个方法又将调用tryReleaseShared)之前写入result,并且innerGet将在调用acquireShared之后读取result,因此将程序顺序与volatile变量规则结合在一起,就可以确保innerSet中的写入操作在innerGet的读取操作之前执行。

之所以这项技术称为借助,是因为它使用了一种现有的Happens-Before顺序来确保对象X的可见性,而不是专门为了发布X而创建的一种Happens—before顺序。
在FutureTask中使用的借助技术很容易出错,因此要谨慎使用。在某些情况下,这种借助技术是非常合理的。
在类库中提供的其他Happens-before技术包括:

  • 将一个元素放入一个线程安全容器的操作将在另一个线程从该容器中获得这个元素的操作之前执行。
  • 在CountDownLatch上的倒数操作将在线程从闭锁上的await方法中返回之前执行。
  • 释放Smaphore许可的操作将在从该Semaphore上获得一个许可之前执行。
  • Future表示的任务的所有是操作将在Future.get中返回之前执行。
  • 向Executor提交一个Runnable或Callable的操作将在任务开始执行之前执行。
  • 一个线程到达CyclicBarrier或Exchanger的操作将在其他到达该栅栏或交换点的线程被释放之前执行,而栅栏操作又会在线程从栅栏中释放之前执行。

发布

不安全的发布

当缺少Happens-Before关系时,就可能出现重排序问题,这就解释了为什么在没有充分同步的情况下发布一个对象会导致另一个线程看到一个只被部分构造的对象。在初始化一个新的对象时需要写入多个变量,即新对象中的各个域。同样,在发布一个引用时也需要写入一个变量,即新对象的引用。如果无法确保发布共享引用的操作在另一个线程加载该共享引用之前执行,那么对新对象引用的写入操作将与对象中各个域的写入操作重排序。这时候另一个线程可能看到对象的某些或者全部状态中包含的是无效值。

public class UnsafeLazyInitialization{
    private static Resource resource;

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

假设线程A是第一个调用getInstance的线程,它将看到resource为null,并且初始化一个新的Resource,然后将resource设置为执行这个新实例。当线程B随后调用getInstance,它可能看到resource非空,因此使用这个已经构造好的Resource。最初这看不出任何问题。
但是线程A写入resource的数据与线程B读取resource操作之间不存在Happens-Before关系。在发布对象时存在数据竞争问题,因此B并不一定能看到Resource的正确状态。
当新分配一个对象Resource时,Resource的构造函数将把新实例中的各个域由默认值(由Object构造函数写入的)修改为它们的初始值。由于两个线程中都没有使用同步,因此线程B看到线程A中的操作顺序,可能与线程A执行这些操作的顺序并不相同。因此即使线程A初始化Resource之后再将resource的引用指向它,线程B仍然可能看到写入操作之前的resource状态,该实例可能处于无效状态。

除了不可变对象以外,使用被另一个线程初始化的对象通常是不安全的,除非对象的发布操作是在使用该对象的线程开始使用之前执行的。

安全的发布

事实上,Happens-Before比安全发布提供了更强可见性和顺序保证。如果将X从A安全地发布到B,那么这种发布可以保证X状态的可见性,但无法保证A访问的其他变量状态的可见性。然而,如果A将X置入队列的操作在线程B从队列中获取X的操作之前执行,那么B不仅能看到A留下的X状态(假设线程A在其他线程都没有对X进行修改),还能看到A在移交X之前所做的所有操作。

安全初始化模式

public class SafeLazyInitialization{
    private static Resource resource;

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

在初始器中采用了特殊方式来处理静态域,并提供了额外的线程安全性保证。静态初始化器是由JVM在类的初始化阶段执行,即在类被加载后并且被线程使用之前。由于JVM将在初始化期间获得一个锁,并且每个线程都至少获取一次这个锁以确保这个类已经加载。因此在静态初始化期间,内存写入操作将自动对所有线程可见。无论是被构造期间还是被引用时,静态初始化的对象都不需要显式的同步。然而这个规则仅适用于构造时的状态,如果对象是可变的,那么在读线程和写线程之间仍然需要通过同步来确保随后的操作是可见的,以免数据被破坏。

双重加锁检测(DCL)

在早期的JVM中,同步存在着巨大的竞争开销。因此人们相处了许多“聪明的(至少看上去聪明)”的技巧来降低同步的影响,有些技巧很好,但也有些技巧是不好的,甚至是糟糕的,DCL就是糟糕的一类。

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;
    }
}

在编写正确的延迟初始化方法中需要使用同步,但在当时,同步不仅执行速度很慢,并且更重要的是,开发人员还没有完全理解同步的含义:虽然人们能很好的理解了“独占性”的含义,但却没有很好理解可见性的含义。
DCL声称能实现两全其美——在常见的代码路径上的延迟初始化中不存在同步开销。它的工作原理是,首先检查是否在没有同步的情况下需要初始化,如果resource引用不为空,那么就直接使用它。否则,就进行同步并再次检查Resource是否被初始化,从而保证只有一个线程对共享的resource进行初始化。在常见的代码路径中——获取一个已经构造好的Resource引用并不需要同步。
DCL真正的问题是:当在没有同步的情况下读取一个共享方法时,可能发生的最糟糕情况,就是看到一个失效值(在这种情况下一般是空值)。但是如果使用DCL,线程可能看到引用的当前值,但是对象的状态却是失效的,这意味着线程可以看到对象处于无效或者错误的状态。

初始化过程中的安全性

如果能确保初始化过程的安全性,那么就可以使得被正确构造的不可变对象在没有同步的情况下也能安全地在多个线程之间共享,而不管它们怎么发布的,甚至通过某种数据竞争来发布。
如果不能确保初始化的安全性,那么当在发布或线程中没有使用同步时,一些本应为不可变对象(例如String)的值可能会发生改变,安全架构依赖于String的不可变性,如果缺少了初始化安全性,那么可能会导致一个安全漏洞,从而使恶意代码绕过安全检查。

初始化安全性将确保,对于被正确构造的对象,所有线程都能看到由构造函数为对象给各个final域设置的正确值,而不管采用何种方法来发布对象,而且,对于可以通过被正确构造对象中某个final域到达的任意变量(例如某个final数组的元素、或者由一个final域引用的hashmap里的内容等)将同样对于其他线程是可见的。
对于含有final域的对象,初始化安全性可以防止对对象的初始引用被重排序到构造过程之前。 当构造函数完成时,构造函数对final域的所有写入操作,以及对通过这些域可以到达的任何变量的写入操作,都将被“冻结”,并且任何获得该对象引用的线程都至少能确保看到被冻结的值。对于通过final域可到达的初始变量的写入操作,将不会与构造过程后的操作一起被重排序。

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

public class SafeStates{
    private final Map states;

    public SafeState(){
        states = new HashMap();
        states.put("A","a");
        states.put("B","b");
        states.put("C","c");
        states.put("D","d");
    }
    public String getAbbreviation(String s){
        return states.get("s");
    }
}

对SafeStates的细微修改都可能破坏它的线程安全性,例如如果states不是final类,或者存在构造函数以外的方法能修改states,那么初始化安全性将无法确保在缺少同步的情况下安全地访问到SafeStates。如果在SafeStates中还有其他的非final域,那么其他线程仍然可能看到这些域上的不正确的值。这也导致了对象在构造过程中逸出,从而使初始化安全性的保证无效。

初始化安全性只能保证通过final域可达的值从构造过程完成时开始的可见性。对于通过非final域可达的值,或者在构造过程完成后可能改变的值,必须采用同步来确保可见性。

小结

java内存模型说明了某个线程的内存操作在哪些情况下对于其他线程是可见的。其中包括确保这些操作是按照一种Happens-Before的偏序关系进行排序,而这种关系是基于内存操作和同步操作等的级别来定义的。如果缺乏充足的同步,那么当线程访问共享数据时,会发生一些非常奇怪的问题。

你可能感兴趣的:(java,内存,多线程,并发,并发编程)