JMM内存可见性与顺序一致性模型

让我们从CPU的缓存结果说起,CPU的速度比磁盘快,为了处理这种快慢之间的矛盾,现代处理器在CPU和主存之间设计了多层的缓存,越靠近CPU的缓存越快也越小。缓存结构如下图中所示:


图中的L1和L2两级缓存都只能被一个单独的CPU核使用。L3能够被单个插槽上的所有CPU核共享。最后,主存由全部插槽上的所有CPU核共享。CPU读取数据时先从L1中读取,L1中miss后逐级向上查找。以java为例,一个i++方法编译成字节码后在jvm中是分成了以下三个步骤运行的:1、从主存中复制i的值并复制到CPU的缓存中,2、CPU取缓存中的值执行i++,刷新到缓存,3、将缓存中的值更新到主存。

如是上述的变量i是定义在共享内存中的变量,当多个线程同时访问该共享变量时,每个线程都会将变量i复制到本地缓存中进行修改,如果线程A读取变量i的值时,线程B正在修改i的值,那么线程B对变量i的修改对线程A而言就是不可见的。如下就是多线程并发访问共享变量造成的结果不一致问题。

public class ThreadTest {
    public static class Test {
        private int i = 0;
        private final CountDownLatch mainLatch = new CountDownLatch(1);

        private class myThread extends Thread {
            private CountDownLatch threadLatch;

            public myThread(CountDownLatch latch) {
                threadLatch = latch;
            }

            @Override
            public void run() {
                try {
                    mainLatch.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                for (int j=0; j < 1000; j++) {
                    i++;
                }
                threadLatch.countDown();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        for(int k=0; k<10; k++){
            Test test = new Test();
            CountDownLatch threadLatch = new CountDownLatch(10);
            for (int i=0; i<10; i++) {
                test.new myThread(threadLatch).start();
            }
            test.mainLatch.countDown();
            threadLatch.await();
            System.out.println(test.i);
        }
    }
}

上述的代码来自参考资料,根据程序设想i的输出应该是10000,但是由于这里多个线程同时写未做同步处理的共享变量i,造成了结果的不一致,变量i并未每次都准确输出10000。类似这种多线程访问主存中的同一份变量时,如果存在至少一个线程是写入该存储单元,则称这些线程之间存在数据竞争。

与此同时,为了提高程序执行时的性能,编译器和处理器还常常对指令做重排序。但是不管怎么做重排序,程序的执行结果不能被改变,这就要求存在数据依赖关系的操作不会发生重排序,因为这种重排序会改变程序的执行结果。上面的语义称为as-if-serial语义,为了实现该语义,编译器和处理器定义了一系列重排序规则,并通过内存屏障指令实现。内存屏障相当于告诉CPU和编译器先于这个指令的必须先执行,后于这个指令的必须后执行。此外,内存屏障还有另外一个作用是强制更新一次主存,结合文章前面的叙述,更新主存就意味着当前线程对变量的修改对所有线程都可见。

常用的内存屏障包括Store屏障和Load屏障。Store屏障,对应x86上的“sfence”指令,该指令会保证所有写操作都在该指令执行之前被完成,并把store缓冲区的数据都刷新到主存中,使得该程序修改的状态对其它CPU可见。Load屏障,对应着x86上的“lfence”指令,将强制所有在load屏障指令之后的load指令,都在该load屏障指令执行之后被执行,并且强制本地load缓冲区的值全部失效,以便从主存中重新读取共享变量的值,使得当前CPU对共享变量的更改对所有CPU可见。

刘未鹏在博客里为内存模型给出了清晰的定义,对于语言实现方而言,内存模型规定了哪些优化是被允许的,而对于语言的使用房内存模型制定规则,使得程序员只要遵循该规则,就能保证程序具有正确的语义。同样地,JMM(java内存模型)对内存屏障做一层抽象,使得程序员无需直接处理底层复杂的内存屏障指令,并向程序员提供了这样的保证:

1、编译器和处理器的as-if-serial语义确保单线程程序不会出现内存可见性问题;

2、正确同步的多线程程序的执行将具有顺序一致性。JMM通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。

什么是顺序一致性,它具有这样的特点,首先一个线程中的所有操作都必须按照程序的顺序来执行,再次所有线程都只能看到一个单一的操作执行顺序,也就是说线程内每个操作的结果都必须立即对其它线程可见。还是以上面的写操作为例,10个线程会把执行完自增的变量i首先缓存在本地内存L1中,在没刷新到主内存之前,这个写操作仅对当前的线程可见,其它线程读取变量i时,仍然是自增前的结果,仿佛前面线程的写操作没有被执行一样。只有当线程将本地缓存中的数据刷新到主内后,这个写操作的结果才会对其他线程可见。如此就可以解释上述程序多次执行之后不能每次都累加到10000.

总而言之,顺序一致性描述了这样的场景:多个线程的所有操作能够被穿插交错执行,但各个线程内部操作之间的相对顺序被遵守。然而,编译器的优化会破坏顺序一致性,导致多线程程序的执行结果与语义发生背离,那么除了禁止优化有没有别的方法可以兼顾两者,这就是data-race-free模型,该模型保证了只要通过同步原语保证了程序中没有data-race(所谓data-race,是指计算结果取决于多线程的执行顺序),那么编译器就能够向你保证程序能够满足顺序一致性,关于data-race-free模型的讲解,推荐刘未鹏的《C++罗浮宫》(里面对并发的解释非常精彩)

JSR133弱化了连续一致的要求,提出了先行发生模型,这也就是我们前面提高的JMM(Java内存模型)模型,主要包括以下几个方面:

1、在同一个线程里面,按照代码语义的顺序,前一个操作先于后面一个操作发生;

2、对一个monitor(监视器)对象的解锁操作先于后续对同一个monitor对象的锁操作;

3、对volatile字段的写操作先于后面对此字段的读操作;

4、对线程的start操作先于这个线程内部的其它任何操作;

5、一个线程中所有的操作先于其他任何线程在此线程上调用join()方法;

6、一个对象构造函数的结束操作先于该对象的finalizer的开始操作;

6、如果A操作优先于B,B操作优先于C,那么A操作优先于C;

上述就是JMM提供的happen-before语义,根据上述语义,不能保证任意时刻数据一致性的代码,可以通过使用synchronized、volatile和final这几个语义被增强的关键字,做到数据一致性。下面简单介绍JMM模型对上述几个关键字的语义保证。

Synchronized关键字


当线程A释放锁M时,它所写过的变量(x,y)都会同步到主存中,而当线程B申请同一个锁M(synchrozied)时,线程B的工作内存被视为无效,此时线程B会重新从主存中加载它所需要的变量到它的工作内存中(也就是上例中的x=1,y=1)。通过这种方式来实现线程A和线程B间的线程通信。

finally关键字,包含以下几层内存语义规则:

1、在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。该语义保证了,在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了;

2、在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序;

3、初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。该语义保证了,在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。

volatile关键字

1、当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存;

2、当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量;

理解了data-race-free模型,就可以更深刻地认识线程安全的概念,以线程安全类的概念为例,是指在被多线程同时访问的情况下,不管运行时的调度如何,都不需要调用方进行同步,仍然能保证正确运行的类称为线程安全的类。

线程安全的类或者类本身已经做好了同步,或者类全部由不可变对象和线程局部变量组成,这样的类无内部状态,和函数式编程中无状态方法的概念是类似的,因为类本身不可变,所以也就不会发生data-race。


参考资料:

http://www.blogjava.net/bolo/archive/2014/06/10/414582.html

http://blog.csdn.net/pongba/article/details/1659952


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值