并发编程(五):设计原理

1. happens-before

1.1 JMM的设计要求

设计JMM时需要考虑:

  • 程序员对内存模型的使用。希望内存模型易于理解。
    JMM的happens-before规则简单易懂,向程序员提供了足够强的内存可见性保证

  • 编译器和处理器对内存模型的实现。希望内存模型对编译器和处理器限制越少越好(便于优化)。
    JMM的基本原则:只要不该变程序(单线程和正确同步)的执行结果,编译器和处理器怎么优化都可以

JMM的重排序策略:

  • 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序
  • 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求,可以进行重排序

1.2 happens-before的定义

happens-before的定义如下:

  • 如果 A happens-before B ,那么 A 的执行结果将对 B 可见,而且 A 的执行顺序排在 B 之前
  • 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果与按happens-before关系执行的结果一致,这种重排序并不非法

happens-before关系和as-if-serial语义是一回事:

  • as-if-serial语义保证单线程内程序执行结果不改变,happens-before关系保证正确同步的多线程程序执行结果不被改变

  • as-if-serial语义可以让程序员认为:单线程是按程序的顺序来执行的

    happens-before关系可以让程序员认为:正确同步的多线程是按先行性规则指定的顺序来执行的

  • as-if-serial和happens-before都是为了在不改变程序执行结果的情况下,尽可能提高程序的并行度

1.3 happens-before规则(先行性规则)

序号 规则 内容
1 程序顺序规则 一个线程内的任意操作,先于该线程中任意后续操作
2 锁规则 对一个锁的解锁先于之后对这个锁的加锁
3 volatile变量规则 对一个volatile变量的写, 先于之后对该变量的读
4 传递规则 A先于B,B先于C,则A先于C
5 线程启动原则 Thread对象的start()方法先于对该线程的任何操作
6 线程中断原则 线程执行interrupt操作先于获取到中断信息
7 线程终结规则 线程的所有操作先于线程死亡
8 对象终结规则 一个对象的初始化完成先于finalize()方法
9 join规则 ThreadB.join(),B线程中任意操作先于B线程返回

2. DCL双重检查锁定

2.1 延迟初始化

只有使用该对象(高开销)才进行初始化操作:

public class DoubleCheckedLocking{
    private static Instance instance;
    
    public static Instance getInstance(){
        if(instance == null){						//第一次检查
            synchroinzed (DoubleCheckedLocking.class){		//加锁
                if(instance == null){				//第二次检查
                    instance=new Instance();		//延迟初始化(有问题)
                }
            }
        }
    }
}

大多数情况下正常初始化要优于延迟初始化。

2.2 出现问题的原因

初始化代码instance=new Instance();可被分解为下面三个操作:

memory = allocate();	//1.分配内存空间
ctorInstance(memory);	//2.初始化内存空间
instance = memory;		//3.将instance指向内存空间

操作2,3在某些编译器中会重排序,其他线程在操作3之后,操作2之前获取锁访问instance对象就会出错(未初始化)

3. DCL问题解决方案

两种解决方案:

  • 禁止重排(3.1)
  • 允许重排但是对其他线程不可见(3.2)

3.1 基于volatile解决

将instance声明为volatile型,修改为private volatile static Instance instance;就可以。

会禁止操作2,3之间的重排序(volatile写和写之前的操作)

基于volatile的方案处理可以对静态字段实现延迟初始化,还可以对实例字段实现延迟初始化。

3.2 基于类初始化解决

JVM在类的初始化阶段会执行类的初始化,在此期间,JVM会获取一个锁,同步多个线程对同一个类的初始化。

另一种线程安全的延迟初始化方案(不使用DCL),代码如下:

public class InstanceFactory{
    private static class InstanceHolder{
        public static Instance instance=new Instance();
    }
    public static Instance getInstance(){
    	 //初始化InstanceHolder类
        return InstanceHolder.instance;		
    }
}

两个线程并发访问getInstance示意图:(初始化锁)

立即初始化的五种情况:

  • T是一个类,一个T类型的实例被创建
  • T是一个类,且T中声明的一个静态方法被调用
  • T中声明的一个静态字段被赋值
  • T中声明的一个静态字段被使用,而且这个字段不是一个常量字段
  • T是一个顶级类且一个断言语句嵌套在T内部执行

3.3 类初始化处理流程

Java的每一个类和接口C,都有一个唯一的初始化锁LC与之对应,每个线程都会至少获取一次该锁确保这个类已经被初始化。类的初始化流程分为五个阶段:

1. 获取锁

线程A:获取到初始化锁

  • 读取到state=initialization,将其设置为initializing

  • 释放初始化锁

线程B:等待获取初始化锁

2. 执行初始化

线程B:获取到初始化锁,读取到state=initializing,释放初始化锁并进入对应的condition等待

线程A:执行类的静态初始化和初始化类中声明的静态字段

3. 初始化完毕

线程A:获取初始化锁,设置state=initialized,唤醒初始化锁对应的condition中等待的所有线程

  • 释放初始化锁,A线程初始化结束

线程B:被唤醒

4. 结束类初始化

线程B:获取初始化锁,读取到state=initialized,释放初始化锁,B线程初始化结束

5. 其他线程初始化

线程C:获取初始化锁,读取到state=initialized,释放初始化锁,C线程初始化结束

AB线程分别获取两次初始化锁,初始化完毕后的C线程只获取一次初始化锁

4. 内存模型总结

4.1 处理器内存模型

内存模型 写-读重排序 写-写重排序 读读和读写重排序 可以更早读取到其他处理器的写 可以更早读取到当前处理器的写 内存模型强度
TSO Y Y 4(最强)
PSO Y Y Y 3
RMO Y Y Y Y 2
PowerPC Y Y Y Y Y 1(最弱)

越是追求性能的处理器模型越弱,允许越多的重排序,对处理器的束缚越少。

JMM屏蔽了不同的处理器内存模型的差异,在不同的模拟器上为Java程序员提供了一个一致的内存模型。

4.2 各种内存模型的关系

JMM是语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性模型是理论参考模型。

内存模型强度:顺序一致性模型>JMM>处理器(TSO~PPC)

4.3 JMM内存可见性保证

  • 单线程程序:不会出现内存可见性问题

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

  • 未同步/未正确同步的多线程程序:

    最小安全性保证:线程执行时读到的值,要么是之前线程写入的值(64位long或double可能只写入一半),要么是默认值

4.4 JSR-133语义增强

  • volatile内存语义增强:严格限制volatile变量和普通变量之间的重排序

  • final内存语义增强:保证final引用不会从构造函数内溢出

posted @ 2021-03-11 20:46  菜鸟kenshine  阅读(89)  评论(0编辑  收藏  举报