分析 ArrayBlockingQueue 构造函数加锁问题

时间:2021-09-22 00:01:34

转载自:https://blog.****.net/chenssy/article/details/78681423

  ArrayBlockingQueue 中的一个构造函数使用了锁:

public ArrayBlockingQueue(int capacity, boolean fair,
                          Collection<? extends E> c) {
    this(capacity, fair);

    final ReentrantLock lock = this.lock;
    lock.lock(); // Lock only for visibility, not mutual exclusion
    try {
        int i = 0;
        try {
            for (E e : c) {
                checkNotNull(e);
                items[i++] = e;
            }
        } catch (ArrayIndexOutOfBoundsException ex) {
            throw new IllegalArgumentException();
        }
        count = i;
        putIndex = (i == capacity) ? 0 : i;
    } finally {
        lock.unlock();
    }
}

  上面的代码中获取互斥锁,锁的目的不是为了互斥,而是为了保证可见性。

  保证可见性?保证哪个可见性?我们知道 ArrayBlockingQueue 操作的其实就是一个 items 数组,这个数组是不具备线程安全的,所以保证可见性就是保证 items 的可见性。如果不加锁为什么就没法保证 items 的可见性呢?这其实是指令重排序的问题。
  为什么说指令重排序会影响 items 的可见性呢?创建一个对象要分为三个步骤:

  1. 分配内存空间;
  2. 初始化对象;
  3. 将内存空间的地址赋值给对应的引用。

  由于指令重排序的问题,步骤 2 和步骤 3 是可能发生重排序的。这个过程就会对上面产生影响。
  假如我们两个线程:线程 A负责ArrayBlockingQueue 的实例化工作,线程 B负责入队、出队操作。线程 A 优先执行,当它执行第 2 行代码,也就是this(capacity, fair);,如下:

public ArrayBlockingQueue(int capacity, boolean fair) {
    if (capacity <= 0)
        throw new IllegalArgumentException();
    this.items = new Object[capacity];
    lock = new ReentrantLock(fair);
    notEmpty = lock.newCondition();
    notFull =  lock.newCondition();
}

  注意,这里几个属性都是final修饰的,不会被重排序影响,执行完这个构造函数后,实际上实例已经完成了初始化的工作。如果之后的代码发生了重排序,即内存空间的地址赋值给对应的引用,但是c中的内容给items还没完成。
  那么对于线程B而言,ArrayBlockingQueue是已经完成初始化工作了,也就是可以使用了。其实线程A可能还正在执行构造函数中后面的代码。两个线程在不加锁的情况对一个不具备线程安全的数组同时操作,很有可能会引发线程安全问题。

  还有一种解释:缓存一致性。为了解决CPU处理速度以及读写主存速度不一致的问题,引入了 CPU 高速缓存。虽然解决了速度问题,但是也带来了缓存一致性的问题。在不加锁的前提下,线程A在构造函数中 items 进行操作,线程 B 通过入队、出队的方式对 items 进行操作,这个过程对 items 的操作结果有可能只存在各自线程的缓存中,并没有写入主存,这样肯定会造成数据不一致的情况。