Java并发基础之内存模型

并发三问题

  • 重排序
  • 内存可见性
  • 原子性

1. 重排序

public class Test {

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

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for(;;) {
            i++;
            x = 0; y = 0;
            a = 0; b = 0;
            CountDownLatch latch = new CountDownLatch(1);

            Thread one = new Thread(() -> {
                try {
                    latch.await();
                } catch (InterruptedException e) {
                }
                a = 1;
                x = b;
            });

            Thread other = new Thread(() -> {
                try {
                    latch.await();
                } catch (InterruptedException e) {
                }
                b = 1;
                y = a;
            });
            one.start();other.start();
            latch.countDown();
            one.join();other.join();

            String result = "第" + i + "次 (" + x + "," + y + ")";
            if(x == 0 && y == 0) {
                System.err.println(result);
                break;
            } else {
                System.out.println(result);
            }
        }
    }
}

观察代码可以发现,如果没有意外情况发生的话,在上下两个线程中,出现的结果应该下面三种情况

x= 0 ,y = 1;


x= 1 ,y = 1;


x= 1 ,y = 0;

但是在实际运行过程中,却最终有概率出现 x=0,y=0的情况。这种情况发生的原因就是出现了重排序。

重排序由一下几种机制引起:

  1. 编译器优化:对于没有数据依赖关系的操作,编译器在编译的过程中会进行一定程度的重排。


    可以看到线程1中的代码,编译器是可以将a=1和x=b换一下顺序的,因为它们之间没有数据依赖关系,同理,线程2也一样,那就不难得到x==y==0的结果了。

  2. 指令重排序:CPU优化行为,也是会对不存在数据依赖关系的指令进行一定程度的重排


    这个和编译器优化差不多,就算编译器不发生重排,CPU也可以对指令进行重排。

  3. 内存系统重排序:内存系统没有重排序,但是由于缓存的存在,使得程序整体上会表现出乱序的行为。


    假设不发生编译器重排和指令重排,线程1修改了a的值,但是修改以后,a的值可能还没写回到主内存中,那么线程2得到a==0就是很自然的事了。同理,线程2对于b的赋值操作也可能没有及时刷新到主存中。

2.内存可见性

线程间的对于共享变量的可见性问题不是直接由多核引起的,而是由多缓存引起的。如果每个核心共享同一个缓存,那么也就不存在内存可见性问题了。


现代多核CPU中每个核心拥有自己的一级缓存或一级缓存加上二级缓存等,问题就发生在每个核心的独占缓存上。每个核心都将会自己需要的数据读到独占缓存中,数据修改后也是写入到缓存中,然后等待刷入到主存中。所以会导致有些核心读取的值是一个过期的值。


在JMM中,抽象了主内存和本地内存的概念。


所有的共享变量存在于主内存中,每个线程有自己的本地内存,线程读写共享数据也是通过本地内存交换,所以可见性问题依然存在。这里说的本地缓存并不是真的是一块给每个线程分配的内存,而是JMM的一个抽象,是对于寄存器、一级缓存、二级缓存等的抽象。

3.原子性

对于long和double,它们的值都需要占用64位的内存空间,Java编程语言规范中提到,对于64位的值的写入,可以分为两个32位的操作进行写入。本来一个整体的赋值操作,将被拆分为低32位赋值和高32位赋值两个操作,中间如果发生了其他线程对于这个值的读操作,必然会读到一个奇怪的值。


这个时候我们需要使用volatile关键字进行控制了,JMM规定了对于volatile long 和volatile double,JVM需要保证写入操作的原子性。


另外,对于引用的读写操作始终是原子的,不管是32位的机器还是64位的机器。


Java编程规范同样提到,鼓励JVM的开发者能保证64位值操作的原子性,也鼓励使用尽量使用volatile或使用正确的同步方式。关键词是“鼓励”。


在64位JVM中,不加volatile也是可以的,同样能保证对于long和double写操作的原子性。

Java对于并发的规范约束

Synchronization Order

  • 对于监视器m的解锁与所有后续操作对于m的加锁同步
  • 对于volatile变量v的写入,与所有其他线程后续对v的读同步
  • 启动线程的操作与线程职工的第一个操作同步
  • 对于每个属性写入默认值(0,false,null)与每个线程对其进行的操作同步。

Happens-before Order

两个操作可以用Happens-before来确定它们的执行顺序,如果一个操作happens-before于另一个操作,那么我们说第一个操作对于第二个操作是可见的。


如果我们分别有操作X和操作Y,我们写成hb(x,y),来表示 x happens-before y 。

  • 如果操作x和操作y是同一个线程的两个操作,并且在代码上操作x先于操作y出现,那么有hb(x,y)。
  • 对象构造方法的最后一行指令happens-before于finalize()方法的第一行指令。
  • 对于操作x与随后的操作y构成同步,那么hb(x,y)
  • hb(x,y)和hb(y,z),那么可以推断出hb(x,z)

这里需要说明的是:hb(x,y),并不是说x操作一定要在y操作之前被执行,而是说x的执行结果对于y是可见的,只要满足可见性,发生了重排序也是可以的。

synchronized关键字

一个线程获取到锁以后才能进入synchronized控制的代码块,一旦进入代码块,首先,该线程对于共享变量的缓存就会失效,因此synchornized代码块中对于共享变量的读取需要从主内存中重新获取,也就能获取到最新的值。


退出代码块的时候,会将该线程写缓冲区的数据刷到主内存中。

你可能感兴趣的:(Java并发基础之内存模型)