在并发编程中分析线程安全的问题时往往需要切入点,那就是两大核心:JMM 抽象内存模型以及 happens-before 规则,三条性质:原子性,有序性和可见性。

原子性

原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败,有着“同生共死”的感觉

我们先来看看哪些是原子操作,哪些不是原子操作,有一个直观的印象:

  1. int a = 10; //1
  2. a++; //2
  3. int b = a; //3
  4. a = a + 1; //4

上面这四个语句中只有第1个语句是原子操作。

image.png
Java 内存模型定义了8种操作是原子的:

  • lock(锁定):作用于主内存中的变量,它把一个变量标识为一个线程独占的状态;
  • unlock(解锁):作用于主内存中的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便后面的 load 动作使用;
  • load(载入):作用于工作内存中的变量,它把 read 操作从主内存中得到的变量值放入工作内存中的变量副本
  • use(使用):作用于工作内存中的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作;
  • assign(赋值):作用于工作内存中的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作;
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送给主内存中以便随后的 write 操作使用;
  • write(操作):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。

由原子性变量操作read,load,use,assign,store,write,可以大致认为基本数据类型的访问、读写 具备原子性(例外就是 long 和 double 的非原子性协定)。

Java 内存模型允许虚拟机将没有被 volatile 修饰的 64 位数据(longdouble)的读写操作划分为两次 32 位的操作来进行,也就是说基本数据类型的访问读写是原子性的,除了longdouble是非原子性的,**load****store****read****write** 操作可以不具备原子性。书上提醒我们只需要知道有这么一回事,因为这个是几乎不可能存在的例外情况。

public class VolatileExample {
    private static volatile int counter = 0;

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++)
                        counter++;
                }
            });
            thread.start();
        }
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(counter);
    }
}

开启10个线程,每个线程都自加10000次,如果不出现线程安全的问题最终的结果应该就是:1010000 = 100000;可是运行多次都是小于100000的结果,问题在于 *volatile并不能保证原子性,在前面说过 counter++ 这并不是一个原子操作,包含了三个步骤:

  1. 读取变量 counter 的值;
  2. 对 counter 加 1;
  3. 将新值赋值给变量 counter。

如果线程 A 读取 counter 到工作内存后(可能工作内存中变量的值已经传递给工作引擎了,volatile 只是让工作内存失效),其他线程对这个值已经做了自增操作后,那么线程 A 的这个值自然而然就是一个过期的值,因此,总结果必然会是小于 100000 的。

如果让 volatile 保证原子性,必须符合以下两条规则:

  1. 运算结果并不依赖于变量的当前值,或者能够确保只有一个线程修改变量的值;
  2. 变量不需要与其他的状态变量共同参与不变约束

在 Java 中,为了保证原子性,提供了两个高级的字节码指令 monitorentermonitorexit。这两个字节码对应的关键字就是synchronized。因此,在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。

可见性

Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。

Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。

除了volatile,Java中的synchronizedfinal两个关键字也可以实现可见性。只不过实现方式不同,这里不再展开了。

  • volatile
    • Java 的内存分主内存和线程工作内存,volatile 保证修改立即由当前线程工作内存同步到主内存,但其他线程仍需要从主内存取才能保证线程同步。(volatile 不能保证操作的原子性)
  • synchronized
    • 当线程获取锁时会从主内存中获取共享变量的最新值,释放锁的时候会将共享变量同步到主内存中。最多只有一个线程能持有锁。
  • final
    • 被 final 关键字修饰的字段在构造器中一旦初始化完成,并且没有发生 this 逃逸(其它线程通过 this 引用访问到初始化了一半的对象),那么其它线程就能看见 final 字段的值。

有序性

在Java中,可以使用 synchronizedvolatile来保证多线程之间操作的有序性(串行执行)。实现方式有所区别:

  • volatile关键字会禁止指令重排
  • synchronized关键字保证同一时刻只允许一条线程操作。

总结

  • synchronized: 具有原子性(锁对象,monitor),有序性(串行化)和可见性(锁释放共享变量同同步主内存)
  • volatile:具有有序性(禁止指令重排)和可见性(lock 前缀指令 + MESI 缓存一致性协议)
  • final:可见性

转自:https://www.yuque.com/volitail/obqdzb/cz1xcz