Skip to content

面试官出难题:并发环境下单例怎么写性能最高

「最好」类问题是永无止境的,只有想办法做到更好。

要回答「怎样写单例最好」,我们要重新审视并发编程,并发编程有3个要素:

  • 原子(atomic)
  • 顺序(ordering)
  • 可见(visibility)

我们平时讨论一致性实际上是有序的体现,讨论A发生在B前,实际上讨论的是对B,A的可见性。「原子」已学,这节课我们结合Java的happens-before关系和volatile讨论顺序和可见。

这节课我们关联的面试题有:

  1. 如何解释内存一致性模型?
  2. 什么是happens-before关系?
  3. 为什么要设计volatile关键字?
  4. volatile关键字原理是什么?
  5. 并发环境下单例怎么写?
  6. volatile的性能怎么样?

顺序体现:什么是内存一致性?

计算机程序就是一个读写内存+计算的模型——所有程序皆是如此,不断读取内存中的指令,不断执行指令,不断读写内存。指令无非就3中,读、写、计算。能影响内存的,就是读和写。从绝对的时间观来看,指令的读和写是有顺序的,并行是不存在的。好比两条并行写一个内存地址的指令,最后一定会分出先后,只有一条指令的结果可以留下来。这是因为我们今天的计算机,是基于经典物理模型制造的。如果是量子计算机,可以允许一个量子比特同时有多个状态,就好像平行宇宙一样。所以从经典的时空观去看指令的执行,一定会有顺序:

因此,并行是在竞争执行顺序,而不是真的并行(产生分歧的结果,像平行宇宙那样)。这样,从时间维度,内存就可以看做一个个版本,而触发版本变化的是写指令。比如2个线程并行的写一个内存地址,实际会给内存的历史增加两个版本。只是因为其中一个版本存在的时间过于短暂,最后人们看到的是竞争后的结果。

既然内存中存在大量短暂的版本,在某个时刻,如果线程1、线程2观察内存,会不会得到不同的版本呢?比如图中,线程2认为版本3的写操作已经发生;线程1认为版本3的写操作还没有发生。因此,产生分歧。事实上,我们的系统中存在大量的这类问题——我们称为内存不一致。如果在一个观察平面(某个时刻),线程对内存的历史版本理解一致,就们就称为内存一致。任何时刻,多个线程,对内存的理解总是一致的,我们就认为他们拥有共同的历史,称为线性一致(Sequential consistency)。如果只有其中部分确定的时刻是一致的,那么我们称为**弱一致(weak consistency)。**如果总是不一致,或者无规律可查,就称为没有一致性。

从编程模型上说,线性一致是最强的保证,程序员可以不用在意并发产生的一致性问题。弱一致性环境下,程序员要使用一些同步元语(primitives)——比如锁、信号量、阻塞队列、屏障等等,还有这节课要学习的happens-before和volatile关键字。总之,弱一致性下,程序员需要工具。 在不使用工具的情况下,程序语言一般只支持到弱一致性。

我们在解决并发问题的时候,期望有序:任意时刻,不同线程观察到的历史是一致的(操作顺序是一致的——操作是相同的)。这个性质也称作有序性,更多的时候我们称为线性一致(Sequential consistency),代表历史没有产生分支,历史一致。

并发导致不一致示例

下面这段程序可以直接导致内存不一致。即便执行顺序是:

a=1;
println(b);
b=1;
println(a);

image-20201205232953005

结果仍然可能是两个0 ,这是因为指令重排和分级缓存策略的存在。

分级缓存策略

现代计算机的设计中,每个CPU都有自己的缓存(L1,L2两层)。共享的L3缓存和内存。 这是因为在成本不变:速度越快的存储发热越大,容量越小。L1缓存读写可以在少数几个CPU周期内完成,L2读写也可以在个位数,L3缓存读写在2位数CPU周期内完成,内存读写会比L3慢10-100被,相当于100~1000个CPU周期。

读取指定内存地址的值,可以先从L1开始,然后L2,然后L3,都没有找到再去内存中读取。考虑到其中每一级都有80%的命中率,因此整体来说读取内存99%的操作都是在缓存中执行的。像这样,缓存根据读写速度不同分级,速度越快造价越高容量越小,速度越小造价越低容量越大,对数据的访问进行分级加速的模型,我们称为分级缓存策略

下图中,我们演示了写入a=1 的过程,需要先写L1、L2,再写L3,然后写内存。

但是这也产生了一致性问题:在事件1完成后,同核心的线程就可以读到最新版本的a了,从L1中。但是跨CPU的线程,得从内存或者L3中读取,这个时候有可能读不到最新版本。所以尽管synchronized解决了两个线程不可能穿插执行,但是仍然会出现一致性问题。

观察到的一种现象:a=1执行完成后,线程B可能会print出a=0(不同CPU,并发),也可能print出a=1(同CPU)。

这样,同一时刻,多个线程对内存版本的理解就不一致了。

指令重排问题

面试题:举例一个指令重排的具体例子?

还有一个影响一致性的问题是指令重排,CPU是可以重排指令的。 比如:

read(a) -> Register1
read(b) -> Register2
Register1 + 1
Register2 + 1

读取a 如果没有命中缓存,读取b 命中了缓存。 可以考虑先执行Register2+1。

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

举个具体的Java例子,比如:

if(a == 0) {
  // some code ...
}
return a;

如果some code 中没有明确看到对a 的修改,那么对Java编译器而言a=0a 的读取, 和returna 的读取顺序是可以重排的。但是如果考虑到并发环境,其他线程在some code 执行的过程中修改了a ,那么会导致两次读取到a 的值不一致。比如其他地方设置了a=1,可能有a=0 没执行,但是return a 返回0 的情况。

阶段小结

内存一不一致,是相对的——需要确定观察者。我们通常以线程位观察者,比较多个线程的结果,来思考内存满不满足一致性。分级缓存策略和指令重排是触发内存不一致的两大原因,那么入门该如何来避免这个问题呢?

happens-before关系:解决内存一致性问题

为了解决内存一致性模型,Java提供了一些控制执行顺序和可见性关系的元语,我们称为happens-before

happens-before和volatile关键字

考虑下面的程序:

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();
}

上面程序中a=10000 可能不会被线程2观察到, 进入无限循环。

原因分析:第一个线程写入内存的值,只写了自己的缓存和内存,但是没有写其他CPU的缓存。第二个Runnable 观察时,总是看的自己CPU的缓存,导致两个线程对数据理解不一致,违反了内存一致性。

再思考本质一点,是因为a=10000 这个写入操作,从逻辑上是应该会早于1s以后a<100 对a的读取操作——而实际的情况,a<100 并没有看到a=10000 这次写入。要解决这个问题,Java提供了happens-before规则:如果事件A逻辑上发生早于事件B,那么事件B发生时应该可以看到事件A的结果。

具体解决这个问题,Java提供的叫做volatile happens-before规则。volatile保证如果逻辑上,volatile变量的写在读之前发生,那么能够确保观察到的结果,写也在读之前发生。

因此只需要将a 设置为volatile 就可以了。

volatile static int a = 0;

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

volatile 确保语义上对变 量的读、写操作顺序被观察到(确保可见性)。事实上实现volatile 需要做三件事:

  1. 对volatile变量的读、写不会被重排到对它后续的读写之后(阻止指令重排
  2. 保证写入的值可以马上同步到CPU缓存中(写入后要求CPU马上刷新缓存
  3. 保证读取值时能读到最新版本(比如读L3-Cache,读内存,甚至更复杂的策略,这个和平台实现都有关,超出讨论范围。如果面试官问,可以这样回答)

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

考虑一个单例程序:

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

如果考虑到并发场景:

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

// 或
class Foo {
  static DbConnection mysqlConnection;
  
  public synchronized static DbConnection getDb(){
    if(mysqlConnection == null) {
       mysqlConnection = new MysqlConnection(...);       
    }
  }    
}

如果希望减少使用锁的范围,这个称为lockless 设计。翻译成少锁设计,无锁设计后面我们会专门讨论,叫做lock-free

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

上面程序进行了判断null ,如果并发量大,减少对锁的争夺。但是上面程序会有一个问题——java 允许部分构造的类。也就是说,一个线程获得锁成功初始化了mysqlConnection 时,另一个线程可能会看到mysqlConnection 不是null 但是如果它尝试使用mysqlConnection 会报错。

这个时候java 可以用happens-before 关系进行管理。保证对类型使用发生在对类型初始化完成后。这个也是用volatile 关键字。

static volatile DbConnection mysqlConnection;

增加了volatile 的程序:

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

上面程序还有一个性能问题,就是mysqlConnection 多次读取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;
}

程序中的ref.getAcquireref.setRelease 通过底层实现保证了happens-before顺序。相比volatile 阻止指令重排,acquirerelease 允许一定范围的指令重排,比如:

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

我们也称这种行为是relaxed atomics 。因为允许一定范围的指令重排,因此acquire/release 性能更好。

面试官:还有哪些Happens-before关系?

单线程规则:单线程内,总是符合happens-before规则。

Monitor规则:synchronized对锁的释放 happens-before 对锁的获取

volatile规则:volatile变量的操作happens-before对它的后续操作(并且周围指令不会重排)

image-20201206102141452

Relaxed Atomics acquire/release规则(Java 9):getAcquire和setRelease操作 happens-before 后续的操作。

操作1
操作2
acquire/release
操作3
操作4

操作1,2可以重排,操作3,4可以重排,操作1,3不可以重排。

Thread Start规则:start()调用前的操作 happens-before 线程内的程序

Thread.join规则:线程的最后一条指令 happens-before join后的第一条指令

happens-before传递性:如果A happens-before B, B happens-before C,那么A happens-before C

总结

总结一下,happens before不是时间关系,happens before是发生顺序和观察到的结果关系。类似因果关系。happens-before是操作顺序和可见性的关系。

通常我们说解决并发问题有3个要素:

  1. 原子性
  2. 有序性
  3. 可见性

那么我们之前学习的cas 是为了解决原子性。今天我们学习的happens-before 是为了解决有序性和可见性。

最后A happens-before B,可以读做:如果A在B前发生,那么A带来的变化在B可以观察到(对B时刻在观察的线程可见)。

happens-before是partial ordering(部分有序)。参考Relaxed Atomics,重要的顺序保证,其他仍然可以重排。

我希望大家通过学习happens-before ,去思考Java语言的设计。 规则是什么?规则就是可以重复利用,来处理相似问题的方法。happens-before 这样的规则设计出来,从实现上,应该是统一的。并不是为volatile,为synchronized等去实现它们每个人的happens-before,从实现层面,我们应该通过规则去配置出volatile和synchronized,甚至更多的happens-before规则。 这样的设计,才能称为一个规则。 业务是灵活多变的,业务规则应该是统一完整的。从规则角度实现业务,应该可以做到配置化。这些,是我希望大家进一步思考的内容。处理1-2个happens-before问题,具体问题具体分析;处理更多的happens-before问题,应该思考happens-before的明确对应,并且实现规则引擎和相关算法。

好的,这节课就到这里,下一节课,我们将学习并发数据结构——Java的5种同步队列。

文章来源于自己总结和网络转载,内容如有任何问题,请大佬斧正!联系我