java多线程12.内存模型

 2019-12-10 15:42  阅读(714)
文章分类:Java 并发编程

假设一个线程为变量赋值:variable = 3;

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

这看上去理所当然,但是如果缺少内存同步,那么将会有许多因素使得线程无法立即甚至永远,看到另一个线程的操作结果。

如:

  • 1.在编译器中生成的指令顺序,可以与源代码中的顺序不同,此外编译器还会将变量保存在寄存器而不是内存中;
  • 2.处理器可以采用乱序或并行等方式来执行指令;
  • 3.缓存可能会改变将写入变量提交到主内存的次序;
  • 4.而且保存在处理器本地缓存中的值,对于其他处理器是不可见的。

这些因素都会使得一个线程无法看到变量的最新值,并且会导致其他线程中的内存操作似乎在乱序执行。

Java语言规范要求JVM在线程中维护一种类似串行的语义:只要程序的最终结果与严格串行环境中执行的结果相同,那么上述所有的操作都是允许的。

这确实是一件好事,因为计算机近年来在性能上的提升很大程度要归功于这些重新排序措施。

在单线程环境中,我们无法看到所有这些底层技术,它们除了提高程序的执行速度外,不会产生其他影响。

在多线程环境中,要维护程序的串行性将导致很大的性能开销。对于并发应用程序中的线程来说,它们在大部分时间里都执行各自的任务,因此在线程之间的协调操作只会降低应用程序的运行速度,而不会带来任何好处。只有当多个线程要共享数据时,才必须协调它们之间的操作,并且JVM依赖程序通过同步操作来找出这些协调操作将在何时发生。

JVM规定了一组最小保证,这组保证规定了对变量的写入操作将在何时对于其他线程可见。JVM在设计时就在可预测性和程序的易于开发性之间进行了权衡,从而在各种主流的处理器体系架构上都能实现高性能的JVM。

平台的内存模型

在共享内存的多处理器体系架构中,每个处理器都拥有自己的缓存,并且定期地与主内存进行协调。

在不同的处理器架构中提供了不同级别的缓存一致性,其中一部分只提供最小的保证,即允许不同的处理器在任意时刻从同一个存储位置上看到不同的值。

要想确保每个处理器都能在任意时刻知道其他处理器正在进行的工作,将需要非常大的开销。在大多数时间里,这种信息是不必要的,因此处理器会适当放宽存储一致性保证,以换取性能的提升。

在架构定义的内存模型中将告诉应用程序可以从内存系统中获得怎样的保证,此外还定义了一些特殊的指令(称为内存栅栏),当需要共享数据时,这些指令就能实现额外的存储协调保证。为了使Java开发人员无须关心不同架构上内存模型之间的差异,Java还提供了自己的内存模型,并且JVM通过在适当的位置插上内存栅栏来屏蔽在JVM与底层平台内存模型之间的差异。

假设:想象在程序中只存在唯一的操作执行顺序,而不考虑这些操作在何种处理器上执行,并且在每次读取变量时,都能获得在执行序列中(任何处理器)最近一次写入该变量的值。

这种乐观的模型被称为串行一致性,开发人员经常会错误的假设存在串行一致性,但在任何一款现代多处理器架构中都不会提供这种串行一致性,JVM也如此。

在支持共享内存的多处理器和编译器中,当跨线程共享数据时,会出现一些奇怪的情况,除非通过使用内存栅栏来防止这些情况的发生。不过在Java程序中不需要指定内存栅栏的位置,而只需要通过正确地使用同步来找出何时将访问共享状态。

重排序

/**
     * 在没有正确同步的情况下,即使要推断最简单的并发程序的行为也很困难。
     * 示例中:很容易想象如何输出(1,0),(0,1)或(1,1),T1可以在T2开始之前完成,T2也可以在T1开始之前完成,或者二者交替执行。
     * 但还可以输出(0,0),由于每个线程中的各个操作之间不存在数据流依赖性,因此这些操作可以乱序执行(即使这些操作按照顺序执行,但在将
     * 缓存刷新到主内存的不同时序中也可能出现这种情况,在T2的角度看,T1的赋值操作可能以相反的次序执行)。
     * 可以想象在T2看来的执行顺序[ x=b, b=1, y=a, a=1 ]
     * 要列举出这个简单示例的所有可能执行结果非常困难,内存级的重排序会使程序的行为变得不可预测。
     * 而要确保在程序中正确地使用同步却非常容易,同步将限制 编译器、运行时和硬件对内存操作重排序的方式,从而在重排序时不会破坏JVM提供的可见性保证。
     */
    public class Demo{

        static int x = 0, y = 0;
        static int a = 0, b = 0;

        public static void main(String[] args) throws InterruptedException {

            Thread T1  = new Thread(new Runnable(){
                public void run() {
                    a = 1;
                    x = b;
                }
            });

            Thread T2 = new Thread(new Runnable(){
                public void run() {
                    b = 1;
                    y = a;
                }
            });

            T1.start();
            T2.start();

            T1.join();
            T2.join();

            System.out.println(x +"--"+y);
        }
    }

Java内存模型

java内存模型是通过各种操作来定义的,包括对变量的读/写操作,监视器的加锁和释放操作,以及线程的启动和合并操作。

JVM为程序中所有的操作定义了一个偏序关系,称为Happens-Before。要想保证执行操作B的线程看到操作A的结果(无论A和B是否在同一个线程中执行),那么在A和B之间必须满足Happens-Before关系。如果缺乏这个关系,那么JVM可以对它们任意的重排序。

当一个变量被多个线程读取并且至少被一个线程写入时,如果在读操作和写操作之间没有依照Happens-Before来排序,那么就会产生数据竞争问题。在正确同步的程序中不存在数据竞争,并会表现出串行一致性,就是说程序中的所有操作都会按照一种固定的和全局的顺序执行。

  • Happens-Before规则:
  • 1.程序顺序规则:如果程序中操作A在操作B之前,那么线程中操作A将在操作B之前执行。
  • 2.监视器锁规则:在监视器锁上的解锁操作必须在同一个监视器锁上的加锁操作之前执行。
  • 3.volatile变量规则:对volatile变量的写入操作必须在对该变量的读操作之前执行。
  • 4.线程启动规则:在线程上对Thread.Start的调用必须在该线程中执行任何操作之前执行。
  • 5.线程结束规则:线程中的任何操作都必须在其他线程检测到该线程已经结束之前执行,或者Thread.join中成功返回,或者在调用Thread.isAlive时返回false。
  • 6.终端规则:当一个线程在另一个线程上调用interrupt时,必须在被终端线程检测到interrupt调用之前执行通过抛出(InterruptException,或者调用isInterrupted和interrupted。)
  • 7.终结器规则:对象的构造函数必须在启动该对象的终结器之前执行完成。
  • 8.传递性:如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行。

虽然这些操作只满足偏序关系,但同步操作,如锁的获取与释放,以及volatile变量的读取与写入操作,都满足全序关系。

因此,在描述Happens-Before关系时,就可以使用“后续的锁获取操作”和“后续的volatile变量读取操作”等表达术语。

当两个线程使用同一个锁进行同步时,在它们之间存在Happens-Before关系。

在线程A内部的所有操作都按照它们在源程序中的先后顺序来排序,在线程B内部的操作也是如此。在A释放了锁M,并且B随后获取了锁M,因此A中所有在释放锁之前的操作,就位于B中请求锁之后的所有操作之前。而如果两个线程是在不同的锁上进行同步的,那么就不能推断它们之间的动作顺序,因为两个线程之间并不存在Happens-Before关系。

借助同步

将Happens-Before的程序顺序规则与其他某个顺序规则(通常是监视器锁规则或volatile变量规则)结合起来,从而对某个未被锁保护的变量的访问操作进行排序。

在FutureTask的保护方法AbstractQueuedSynchronizer中说明了如何使用这种“借助”技巧。

AQS维护了一个表示同步器状态的整数,FutureTask用这个整数来保存任务的状态。但FutureTask还维护了其他一些变量,如计算结果。

当一个线程调用set来保存结果并且另一个线程调用get来获取结果时,这两个线程最好按照Happens-Before进行排序。这可以将执行结果的引用声明为volatile类型来实现,但利用现有的同步机制可以更容易地实现相同的功能。

/**
     * FutureTask在设计时能够确保,在调用tryAcquireShared之前总能成功地调用tryReleaseShared。
     * tryReleaseShared会写入一个volatile类型的变量,而tryAcquireShared将读取这个变量。
     * 在保存和获取result时将调用innerSet和innerGet方法。
     * 由于innerSet将在调用releaseShared(这又将调用tryReleaseShared)之前写入result,
     * 并且innerGet将在调用acquireShared(这又将调用tryAcquireShared)之后读取result,
     * 因此就可以确保innerSet的写入操作在innerGet中的读取操作之前执行。
     *
     * @param <V>
     */
    public class FutureTask<V>{

        private final class Sync extends AbstractQueuedSynchronizer{

            private static final int RUNNING = 1;

            private static final int RAN = 2;

            private static final int CANCELLED = 4;

            private V result;

            private Exception exception;

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

            V innerGet() throws InterruptedException, ExecutionException{
                acquireSharedInterruptibly(0);
                if(getState() == CANCELLED){
                    throw new CancellationException();
                }
                if(exception != null){
                    throw new ExecutionException(exception);
                }
                return result;
            }
        }
    }
  • 类库中提供的其他Happens-Before排序如:
  • 1.将一个元素放入一个线程安全容器的操作将在另一个线程从该容器中获得这个元素的操作之前执行。
  • 2.在CountDownLatch上的倒数操作将在线程从闭锁上的await方法中返回之前执行。
  • 3.在释放Semaphore许可操作将在从该Semaphore上获得一个许可之前执行。
  • 4.Future表示的任务的所有操作将在从Future.get中返回之前执行。
  • 5.向Exceutor提交一个Runnable或Callable的操作将在任务开始之前执行。
  • 6.一个线程到达CyclicBarrier或Exchanger的操作将在其他到达该栅栏或交换点的线程被释放之前执行。如果CyclicBarrier使用一个栅栏操作,那么到达栅栏的操作将在栅栏操作之前执行,而栅栏操作又会在线程从栅栏中释放之前执行。

不安全的发布

当缺少Happens-Before关系时,就可能出现重排序问题,这可以解释为什么在没有充分同步的情况下发布一个对象会导致另一个线程看到一个只被部分构造的对象。

在初始化一个新的对象时需要写入多个变量,即新对象中的各个域。同样,在发布一个引用时也需要写入一个变量,即新对象的引用。

如果无法确保发布共享引用的操作在另一个线程加载该共享引用之前执行,那么对新对象引用的写入操作与对象中各个域的写入操作重排序(从使用该对象的线程的角度来看)。

/**
     * 程序中存在的问题似乎只有竞态条件问题(当所有Resource示例都相同时可以忽略)
     * 即使不考虑这个问题,这样发布仍然是不安全的,因为在另一个线程可能看到部分构造的Resource实例的引用。
     *  
     * 假设T1是第一个调用getInstance的线程,它将看到resource为null,并且初始化一个新的Resource,然后将resource设置为这个新实例。
     * 当T2随后调用getInstance,它可能看到resource值为非空,因此使用这个已经构造好的Resource。
     * 但T1写入resource的操作与T2读取resource的操作之间并不存在Happens-Before方法。
     *  
     * 当新分配一个Resource时,Resource的构造函数将把新实例中的各个域由默认值修改为初始值。
     * 由于两线程未使用同步,因此T2看到的T1的操作顺序,可能与T1执行这些操作时的顺序不同。
     * 即T2可能看到对resource的写入操作将在Resource各个域的写入操作之前发生。 从而T2就看到一个被部分构造处于无效状态的Resource实例。
     */
    public class Resource{

        private static Resource resource;

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

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

安全发布

在上面示例中需要将getInstance改为synchronized,使用同步即可解决问题。

JVM在初始器中采用了特殊的方式来处理静态域(或者在静态初始化代码块中初始化的值),并提供了额外的线程安全性保证。

静态初始化器是由JVM在类的初始化阶段执行,即在类被加载后并且线程使用之前。

由于JVM将在初始化期间获得一个锁,并且每个线程只是获取一次这个锁以确保这个类已经加载,因此在静态初始化期间,内存的写入操作将自动对所有线程可见。

因此无论是在被构造期间还是被引用时,静态初始化的对象都不需要显示的同步。

然而这个规则仅适用于在构造时的状态,如果对象时可变的,那么在读线程和写线程之间仍然需要通过同步来确保随后的修改操作是可见的,以及避免数据破坏。

将静态初始化器这种特性和JVM的延迟加载机制结合起来,可以形成一种延迟初始化技术。

/**
     * 延迟初始化占位模式:使用一个专门的类来初始化Resource。
     * JVM将推迟ResourceHolder的初始化操作,直到开始使用这个类时才初始化,并且由于通过一个静态初始化来初始化Resource,因此不需要额外的同步。
     * 当任何一个线程第一次调用getResource时,都会使ResourceHolder被加载和初始化,此时静态初始化器将执行
     */
    public class ResourceFactory{

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

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

#笔记内容参考 《java并发编程实战》

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> java多线程12.内存模型

相关推荐