【死磕Java并发】—–分析 ArrayBlockingQueue 构造函数加锁问题

撸了今年阿里、腾讯和美团的面试,我有一个重要发现…….

原文出处http://cmsblogs.com/chenssy


昨天有位小伙伴问我一个 ArrayBlockingQueue 中的一个构造函数为何需要加锁,其实这个问题我还真没有注意过。主要是在看 ArrayBlockingQueue 源码时,觉得它很简单,不就是通过加锁的方式来操作一个数组 items 么,有什么难的,所以就没有关注这个问题,所以它一问我懵逼了。回家细想了下,所以产生这篇博客。我们先看构造方法:

    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    //----(1)
        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 的可见性呢?这其实是指令重排序的问题。

什么是指令重排序?编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。也就是说程序运行的顺序与我们所想的顺序是不一致的。虽然它遵循 as-if-serial 语义,但是还是无法保证多线程环境下的数据安全。更多请参考博客【死磕Java并发】—–Java内存模型之重排序

为什么说指令重排序会影响 items 的可见性呢?创建一个对象要分为三个步骤:

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

但是由于指令重排序的问题,步骤 2 和步骤 3 是可能发生重排序的,如下:

  1. 分配内存空间
  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();
    }

这个时候 items 是已经完成了初始化工作的,也就是说我们可以对其进行操作了。如果在线程 A 实例化对象过程中,步骤 2 和步骤 3 重排序了,那么对于线程 B 而言,ArrayBlockingQueue 是已经完成初始化工作了也就是可以使用了。其实线程 A 可能还正在执行构造函数中的某一个行代码。两个线程在不加锁的情况对一个不具备线程安全的数组同时操作,很有可能会引发线程安全问题。

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

推荐阅读:

赞(4) 打赏

如未加特殊说明,此网站文章均为原创,转载必须注明出处。Java 技术驿站 » 【死磕Java并发】—–分析 ArrayBlockingQueue 构造函数加锁问题
分享到: 更多 (0)

评论 10

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
  1. #3

    哭了,一开始就不能理解,为什么会出现两个线程同时执行一个构造函数的情况??

    T.K1个月前 (07-13)回复
    • 没有说两个线程调用构造函数啊

      chenssy1个月前 (07-13)回复
      • 好吧,线程A是执行构造函数,线程B去执行ArrayBlockingQueue对象的入队出队操作。

        但是我根据之前在 深入理解 Java 内存模型(六)——final 这篇文章里 “如果 final 域是引用类型” 的大段描述, 构造函数里面对final 域的写入和其成员域赋值 对于其它线程来说 都是禁止 重排序的,也就是说 至少对于items变量,构造函数里的操作(包括items的初始化和数组下标轮询赋值),对其它线程都是可见的。

        可能想法有什么问题,不吝赐教

        T.K1个月前 (07-13)回复
        • 请加微信(chen_ssy)一起探讨

          chenssy1个月前 (07-14)回复
  2. #2

    就是为了解决初始化的不可见性。线程 A 初始化 item,线程 B 可以感知到。就这一句话。

    帅飞7个月前 (01-29)回复
    • 正解

      chenssy7个月前 (01-29)回复
  3. #1

    还是没有理解,
    无论是指令重排还是缓存一致性,A线程执行构造函数,B线程读写items,为啥构造函数里面加锁了就能对让B线程可见呢???

    hj7个月前 (01-20)回复
    • 因为A、B线程都要竞争 lock 啊,happens-before 原则中有一点 一个unLock操作先行发生于后面对同一个锁额lock操作,如果 A 在执行构造函数的时候,获取了锁,B必须要等待 A 释放锁才能进行操作,A释放锁资源后,items 已经初始化完成了

      chenssy7个月前 (01-20)回复
      • 感谢回答。
        那如果B线程锁执行的代码,是没有加锁的,或者说B线程执行的代码和构造函数中的代码,不是被同一个锁对象锁住的,那是不是就GG了?

        hj7个月前 (01-21)回复
        • 这是代码实现的问题啊。本来就要确保线程安全是吧

          chenssy7个月前 (01-21)回复

关注【Java 技术驿站】公众号,每天早上 8:10 为你推送一篇技术文章

扫描二维码关注我!


关注【Java 技术驿站】公众号 回复 “VIP”,获取 VIP 地址永久关闭弹出窗口

免费获取资源

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏