java并发—内存模型的几个显见例子

并发三问题:重排序,内存可见性,原子性

I、重排序代码示例

import java.util.concurrent.CountDownLatch;

public class RearrangeTest {

    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=0这种反常识结果
java并发—内存模型的几个显见例子_第1张图片

II、重排序机制

1、编译器优化:编译器编译时的代码顺序重排
如本例中编译器可能变换a = 1和x = b,以及 b = 1和y = b的代码顺序。
2、指令重排序:CPU执行指令时变换代码顺序。
3、内存系统重排序:内存系统没有重排序,但是由于有缓存的存在,使得程序整体上会表现出乱序的行为。线程1修改了a的值,但是修改以后,a的值可能还没有写回到主存中,那么线程2可能得到a = 0。同理,线程2对于b的赋值操作也可能没有及时刷新到主存中。

III、内存可见性

所有的共享变量存在于主内存中,每个线程有自己的本地内存,线程读写共享数据也是通过本地内存交换的,所以可见性问题依然是存在的。

IV、原子性

如long和double类型的值需要占用64位的内存空间,Java对于64位的值的写入,可以拆分为两个32位的操作进行写入。一个整体的赋值操作,被拆分为低32位赋值和高32位赋值两个操作,中间如果发生了其他线程对于这个值的读操作,可能会导致原子性问题。

V、Java并发约束规范

Synchronization Order
Happens-before Order

VI、synchronized

线程 a 对于进入 synchronized 块之前或在 synchronized 中对于共享变量的操作,对于后续的持有同一个监视器锁(monitor)的线程 b 可见。在进入 synchronized 的时候,并不会保证之前的写操作刷入到主内存中,synchronized 主要是保证退出的时候能将本地内存的数据刷入到主内存。

一个不太正确的单例模式双重检查,代码没有复现多线程问题,得再想想办法:

public class Singleton {
        private static Singleton instance = null;
        private int v;

        public int getV() {
            return v;
        }

        public Singleton() {
            this.v = 1;
        }

        public static Singleton getInstance() {     //单例模式双重检查
            if (instance == null) {                 //操作1:第一次检查
                synchronized (Singleton.class) {    //操作2
                    if (instance == null) {         //操作3:第二次检查
                        instance = new Singleton(); //操作4
                    }
                }
            }
            return instance;
        }
 }

如例,有两个线程 a 和 b 调用 getInstance() 方法。
假设 a 先走,一路走到 操作4 ,即 instance = new Singleton() 这行代码。这行代码首先会申请一段空间,然后将各个属性初始化为零值(0/null),执行构造方法中的属性赋值[1],将这个对象的引用赋值给 instance[2]。在这个过程中,[1] 和 [2] 可能会发生重排序。
此时,线程 b 刚好执行到 操作1,就有可能得到 instance 不为 null,然后线程 b 也就不会等待监视器锁,而是直接返回 instance。问题是这个 instance 可能还没执行完构造方法(线程 a 此时还在 4 这一步),所以线程 b 拿到的 instance 是不完整的,它里面的属性值可能是初始化的零值(0/false/null),而不是线程 a 在构造方法中指定的值。

如果所有的属性都是使用 final 修饰的,其实之前介绍的双重检查是可行的,不需要加 volatile。

VII、volatile:内存可见性和禁止指令重排序

volatile 修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值。在并发包的源码中,它使用得非常多。
volatile 属性的读写操作都是无锁的,它不能替代 synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁上,所以说它是低成本的。
volatile 只能作用于属性,我们用 volatile 修饰属性,这样 compilers 就不会对这个属性做指令重排序。
volatile 提供了可见性,任何一个线程对其的修改将立马对其他线程可见。volatile 属性不会被线程缓存,始终从主存中读取。
volatile 提供了 happens-before 保证,对 volatile 变量 v 的写入 happens-before 所有其他线程后续对 v 的读操作。
volatile 可以使得 long 和 double 的赋值是原子的。

VIII、final

1、用 final 修饰的类不可以被继承
2、用 final 修饰的方法不可以被覆写
3、用 final 修饰的属性一旦初始化以后不可以被修改。

在对象的构造方法中设置 final 属性,同时在对象初始化完成前,不要将此对象的引用写入到其他线程可以访问到的地方(不要让引用在构造函数中逸出)。

你可能感兴趣的:(java)