抱歉,您的浏览器无法访问本站

本页面需要浏览器支持(启用)JavaScript


了解详情 >

内存一致性

并发编程三要素

  • 原子性(atomic): 操作不能再分割.
  • 有序性(ordering): 执行结果有序. 无序, 每次结果可能不一样. 有序, 就能在其中间建立一时间节点, 能够知道一些操作的先后顺序.
  • 可见性(visibility): 和有序关联. 因为有序所以可见. 所有都能观察到.

经典计算机 不存在同一个时刻某个变量有多种状态, 而 量子计算机 允许一个事物同时存在多种状态.

不同观测者对历史的理解不一致. 如果处理器对某个变量进行了修改, 可能只是体现在该核心的缓存里, 而运行在其它核心上的线程, 可能加载的是旧状态, 这就很可能导致一致性的问题, 数据的正确性.

在某个时刻, 线程1, 线程2观察内存, 会不会得到不同的版本? 图中, 线程2认为版本3的写操作已经发生; 线程1认为版本3的写操作还没有发生. 产生分歧, 这样称为不一致.

  • 线性一致性: 任何时刻都一致. Sequential Consistency. 最强, 程序员不需要在意并发产生的一致性问题. 单线程模型就是线性一致性的.
  • 弱一致: 部分时刻一致. Weak Consistency. 需要同步元语(primitives).
    • 锁(Lock), 信号量(Semaphore)
    • happens-before关系, volatile
    • 不使用元语(工具), Java是弱一致性的
  • 没有一致: 无法确定何时一致

有序性: 任何时刻观察到的历史是一致的.

指令重排分级缓存策略的存在导致读取不一致.

L1/L2 一般都是每个核心都有的, 而L3是公用的. 逐级读取.

分级缓存策略: CPU, 缓存 和 内存 的关系.

如果其它CPU核心去读取, 可能还是会读到a=0的, 缓存间有同步机制的, 但是有不确定性.

这样就导致了, 线程A和线程B对a, b的值理解是不一致的.

CAS解决原子性, volatile解决可见性, happens-before原则保证有序性

happens-before

static int a = 0;
public static void main(String[] argv) {
    Runnable r1 = () -> {
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        a = 10000;
        System.out.println("a="+a);
    };

    Runnable r2 = () -> {
        System.out.println("enter a =" + a);
        while(a < 100) {
        }
        System.out.println("end" + a);
    };

    new Thread(r1).start();
    new Thread(r2).start();
}

偶尔有无限循环, 原因是线程1,2执行在多个CPU时, 线程2的缓存数据没有更新.

本质: a=10000没有 happens-before a<100的判断.

happens-before: 如果事件A逻辑上发生早于事件B, 那么事件B发生时应该可以看到事件A的结果, 也就是事件A的结果对于事件B可见.

volatile static int a = 0;

这样如果写入事件a=10000 从时间上早于读取时间a<100 , 那么最终观察到的结果就是a=10000 .

happends-before的八大原则

  1. 程序次序规则 : 一个线程内, 按照代码顺序, 书写在前面的操作先行发生于书写在后面的操作.
  2. 锁定规则 : 一个unLock操作先行发生于后面对同一个锁的lock操作.
  3. volatile 变量规则 : 对一个变量的写操作先行发生于后面对这个变量的读操作.
  4. 传递规则 : 如果 操作A 先行发生于 操作B, 而 操作B 又先行发生于 操作C, 则可以得出 操作A 先行发生于 操作C.
  5. 线程启动规则 : Thread 对象的 start() 方法先行发生于此线程的每一个动作.
  6. 线程中断规则 : 对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测中断事件的发生.
  7. 线程终结规则 : 线程中所有的操作都先行发生于线程的终止检测, 可以通过 Thread.join() 方法结束, Thread.isAlive() 方法的返回值检测到已经终止执行.
  8. 对象终结规则 : 一个对象的初始化完成先行发生于他的 finalize() 方法的开始.

happens-before关系

  • 单线程规则: 单线程对内存的访问符合happens-before规则.
  • Monitor规则: synchronized 对锁的释放 happens-before对锁的获取.
  • volatile规则: volatile 变量的写 happens-before读.
  • Thread Start规则: start() 调用前的操作 happens-before 线程内的程序.
  • Thread.join规则: 线程的最后一条指令 happens-beforejoin后的第一条指令.
  • happens-before传递性: 如果 A happens-before B, B happens-before C, 那么 A happens-before C.
  • Relaxed Atomic acquire/release 规则(Java 9)

总结: happens-before不是时间关系.

  • happens-before 是发生顺序观察到的结果关系.
  • A happens-before B. 如果 A 在 B 前发生, 那么 A 带来的变化在 B 可以观察到(对B时刻在观察的线程可见).
  • happens-before 是 parties ordering(部分有序). 参考 Relaxed Atomics, 重要的顺序保证, 其它仍然可以重排.

如果两个操作不满足上述任意一个happens-before规则, 那么这两个操作就没有顺序的保障, JVM 可以对这两个操作进行重排序; 如果 操作A happens-before 操作B, 那么 操作A 在内存上所作的操作对 操作B 都是可见的.

指令重排

是一种CPU策略, 通过交换指令执行的顺序获得最佳性能, 处理器和编译器都能执行指令重排优化. 例如, 能先读到缓存数据的指令先被计算, 一个指令读取一个数值, 在 L3 才有的, 而另外一个指令读取的值在 L1 就有了, 那么这条指令就会被放在前面, 先执行.

Java虚拟机在模拟计算机, 因此也引入了指令重排技术. 部分指令Java虚拟机明确知道性能差异的情况下可以进行优化. 要满足以下条件:

  • 在单线程环境下不能改变程序运行的结果.
  • 存在数据依赖关系的不允许重排序.

也就是说无法通过 happens-before 原则推导出来的, 才能进行指令重排序.

总结: 内存不一致是相对的, 需要观察者; 成因, 分级缓存策略指令重排.

volatile

volatile 关键字. 确保语义上对变量的读写操作顺序被观察到.

  • volatile 变量的读写不会被重排到对它后续的读写之后.(阻止指令重排)
  • 保证写入的值可以马上同步到 CPU 缓存中(写入后要求CPU马上刷新缓存)
  • 保证读取到最新版本的数据(读L3, 主存等, 甚至使用内存屏障, 不同架构方式不同)

如果逻辑上, 变量的写在读之前发生, 那么确保观察到的结果, 写也在读之前发生.

volatile 作用:

  1. volatile 变量读写时会增加内存屏障(Memory Barrier).
  2. volatile 变量读写时会禁用局部指令重排.
  3. 保证对 volatile 的操作 happens-before 另一个操作.

读屏障: 就是在读取volatile变量之前增加一条将变量的值内存 读到 CPU缓存 的指令.

写屏障: 就是在写volatile变量之后, 将变量的值从 CPU缓存 写入 内存.

happens-before 关系: 如果事件A应该在时间B之前发生, 那么观察到的结果也是如此; 时间关系的一致性.

确保可见性, 有序性.

指令重排只保证串行语义的执行一致性, 即是单线程执行的一致性. 不会关心多线程语义的一致性.

volatile应用举例: 双检查单例模型

class Foo {
  static volatile DbConnection mysqlConnection;
  public static DbConnection getDb(){
    DbConnection localRef = mysqlConnection;
    if(localRef == null) {
      synchronized(Foo.class) {
        localRef = mysqlConnection;
        if(localRef == null) {
          mysqlConnection = localRef = new MysqlConnection(...);          
        }
      }    
    }
    return mysqlConnection;
  }    
}

另外, 其实有一个更好的做法, 是利用Atomic 类. Atomic 利用cas 直接可以让进程进步, 例如这样实现:

static AtomicReference<DbConnection> ref = new AtomicReference<>();
public static DbConnection getDb(){
    // 读取当前的ref内存中的真实值
    var localRef = ref.getAcquire();
    if(localRef == null) {
        synchronized (Foo.class) {
            localRef = ref.getAcquire();
            if(localRef == null) {
                localRef = new DbConnection();
                // 设置新的ref
                ref.setRelease(localRef);
            }
        }
    }
    return localRef;
}

volatile是一个更强的排序, 影响范围更大, 是阻止指令重排; acquire/release 约束范围更小(松弛技术), 允许一定范围的指令重排.

指令1
指令2
acquire/release/volatile
指令3
指令4
  • 上面的场景 volatile 会保证顺序: 1, 2, volatile, 3, 4.
  • acquire/release 只保证自己在 1, 2 之后, 在3, 4之前.

volatile 与 synchronized

  1. volatile 本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的, 需要从主存中读取; synchronized 则是锁定当前变量, 只有当前线程可以访问该变量, 其它线程被阻塞住, 直到该线程完成变量操作为止.
  2. 作用范围不同, volatile 仅能使用在变量级别; synchronized 则可以使用在变量, 方法和类级别.
  3. volatile 仅能实现变量的修改可见性, 不能保证原子性; 而 synchronized 则可以保证变量修改的可见性原子性.
  4. volatile 不会造成线程的阻塞; synchronized 可能会造成线程阻塞.
  5. volatile 标记的变量不会被编译器优化; synchronized 标记的变量可以被编译器优化.

评论