阻塞队列--线程安全问题

之前的队列在很多场景下都不能很好地工作,例如

  1. 大部分场景要求分离向队列放入(生产者:主要调用offer方法)、从队列拿出(消费者:主要调用poll方法)两个角色、它们得由不同的线程来担当,而之前的实现根本没有考虑线程安全问题

  2. 队列为空,那么在之前的实现里会返回 null,如果就是硬要拿到一个元素呢?只能不断循环尝试

  3. 队列为满,那么再之前的实现里会返回 false,如果就是硬要塞入一个元素呢?只能不断循环尝试

因此我们需要解决的问题有

  1. 用锁保证线程安全

  2. 用条件变量让等待非空线程等待不满线程进入等待状态,而不是不断循环尝试,让 CPU 空转

有同学对线程安全还没有足够的认识,下面举一个反例,两个线程都要执行入队操作(几乎在同一时刻)

public class TestThreadUnsafe {
    private final String[] array = new String[10];
    private int tail = 0;

    public void offer(String e) {
        array[tail] = e;
        tail++;
    }

    @Override
    public String toString() {
        return Arrays.toString(array);
    }

    public static void main(String[] args) {
        TestThreadUnsafe queue = new TestThreadUnsafe();
        new Thread(()-> queue.offer("e1"), "t1").start();
        new Thread(()-> queue.offer("e2"), "t2").start();
    }
}

注意IDEA调试设置:

执行的时间序列如下,假设初始状态 tail = 0,在执行过程中由于 CPU 在两个线程之间切换,造成了指令交错

糟糕的是,由于指令交错的顺序不同,得到的结果不止以上一种,宏观上造成混乱的效果

单锁实现

Java 中要防止代码段交错执行,需要使用锁,有两种选择

  • synchronized 代码块,属于关键字级别提供锁保护,功能少

  • ReentrantLock(可重入锁) 类,功能丰富

以 ReentrantLock 为例

ReentrantLock lock = new ReentrantLock();//锁对象

public void offer(String e) {
    //lock.lock();  //加锁
    lock.lockInterruptibly();//加锁 记得要抛异常
    try {
        array[tail] = e;
        tail++;
    } finally {  
        //利用try-finally:为了防止当我们try{}内有异常,那么我们的lock.unlock()解锁操作就没办法执行,所以我们要保证即使出现异常也要执行解锁操作
        lock.unlock();//解锁
    }
}

lock()与lockInterruptibly()的区别:

使用lock():当t1加锁状态,未解锁时,t2必须等待

使用lockInterruptibly():当t1加锁状态,未解锁时,但t1处于异常导致无法解锁的时候,t2可以在阻塞状态下随时打断t1的锁

只要两个线程执行上段代码时,锁对象是同一个,就能保证 try 块内的代码的执行不会出现指令交错现象,即执行顺序只可能是下面两种情况之一

  • 另一种情况是线程2 先获得锁,线程1 被挡在外面

  • 要明白保护的本质,本例中是保护的是 tail 位置读写的安全

事情还没有完,上面的例子是队列还没有放满的情况,考虑下面的代码(这回锁同时保护了 tail 和 size 的读写安全

ReentrantLock lock = new ReentrantLock();
int size = 0;//判断队列是否满了

public void offer(String e) {
    lock.lockInterruptibly();
    try {
        if(isFull()) {
            // 满了怎么办?
        }
        array[tail] = e;
        //tail++;
        //防止tail长度溢出
        if (++tail == array.length) {
                tail = 0;
        }
        size++;
    } finally {
        lock.unlock();
    }
}

private boolean isFull() {
    return size == array.length;
}

之前是返回 false 表示添加失败,前面分析过想达到这么一种效果:

  • 在队列满时,不是立刻返回,而是当前线程进入等待

  • 什么时候队列不满了,再唤醒这个等待的线程,从上次的代码处继续向下运行

ReentrantLock 可以配合条件变量来实现,代码进化为

ReentrantLock lock = new ReentrantLock();
Condition tailWaits = lock.newCondition(); // 条件变量
int size = 0;

public void offer(String e) {
    lock.lockInterruptibly();
    try {
        while (isFull()) {
            tailWaits.await();	// 当队列满时, 当前线程进入 tailWaits 等待,当前线程加入tailWaits,并且让此线程阻塞
        }
        array[tail] = e;
        //tail++;
        //防止tail长度溢出
        if (++tail == array.length) {
                tail = 0;
        }
        size++;
    } finally {
         //利用try-finally:为了防止当我们try{}内有异常,那么我们的lock.unlock()解锁操作就没办法执行,所以我们要保证即使出现异常也要执行解锁操作
        lock.unlock();
    }
}

private boolean isFull() {
    return size == array.length;
}
  • 条件变量底层也是个队列,用来存储这些需要等待的线程,当队列满了,就会将 offer 线程加入条件队列,并暂时释放锁

  • 将来我们的队列如果不满了(由 poll 线程那边得知)可以调用 tailWaits.signal() 来唤醒 tailWaits 中首个等待的线程,被唤醒的线程会再次抢到锁,从上次 await 处继续向下运行

思考为何要用 while 而不是 if,设队列容量是 3

关键点:

  • 从 tailWaits 中唤醒的线程,会与新来的 offer 的线程争抢锁,谁能抢到是不一定的,如果后者先抢到,就会导致条件又发生变化

  • 这种情况称之为虚假唤醒,唤醒后应该重新检查条件,看是不是得重新进入等待

最后的实现代码:

接口:

/**
 目前队列存在的问题
 <ol>
    <li>很多场景要求<b>分离</b>生产者、消费者两个<b>角色</b>、它们得由不同的线程来担当,而之前的实现根本没有考虑线程安全问题</li>
    <li>队列为空,那么在之前的实现里会返回 null,如果就是硬要拿到一个元素呢?只能不断循环尝试</li>
    <li>队列为满,那么再之前的实现里会返回 false,如果就是硬要塞入一个元素呢?只能不断循环尝试</li>
 </ol>

 解决方法
 <ol>
     <li>用锁保证线程安全</li>
     <li>用条件变量让 poll 或 offer 线程进入<b>等待</b>状态,而不是不断循环尝试,让 CPU 空转</li>
 </ol>
 */

public interface BlockingQueue<E> { // 阻塞队列

    void offer(E e) throws InterruptedException;

    boolean offer(E e, long timeout) throws InterruptedException;

    E poll() throws InterruptedException;
}

实现:

/**
 * 单锁实现
 * @param <E> 元素类型
 */
public class BlockingQueue1<E> implements BlockingQueue<E> {
    private final E[] array;
    private int head = 0;
    private int tail = 0;
    private int size = 0; // 元素个数 判断队列是否满了

    @SuppressWarnings("all")
    public BlockingQueue1(int capacity) {
        array = (E[]) new Object[capacity];
    }

    ReentrantLock lock = new ReentrantLock();
    Condition tailWaits = lock.newCondition();//用于poll方法中
    Condition headWaits = lock.newCondition();//用于offer方法中

    @Override
    public void offer(E e) throws InterruptedException {
        lock.lockInterruptibly();
        try {
            while (isFull()) {
                tailWaits.await();
            }
            array[tail] = e;
            //tail++;
        	//防止tail长度溢出
            if (++tail == array.length) {
                tail = 0;
            }
            size++;
            //poll方法那边等待队列非空,那么我们就要唤醒poll方法的等待
            headWaits.signal();
        } finally {
             //利用try-finally:为了防止当我们try{}内有异常,那么我们的lock.unlock()解锁操作就没办法执行,所以我们要保证即使出现异常也要执行解锁操作
            lock.unlock();
        }
    }

    @Override
    //对于上面的Offer方法,我们又多了个选择:
    //我们确实可以等待该线程被唤醒,但是要等多久呢?如果我们不需要等那么久,那就可以给这个等待加一个时间上限
    //在规定时间内,我可以等待,但是超过了这个时间,还没有人来唤醒我,那我就不等了,直接提取结束这个等待,可以返回一个false,表示我添加失败
    public void offer(E e, long timeout) throws InterruptedException {
        lock.lockInterruptibly();
        try {
            long t = TimeUnit.MILLISECONDS.toNanos(timeout);
            
            while (isFull()) {
                //在规定时间内,我可以等待,但是超过了这个时间,还没有人来唤醒我,那我就不等了,直接提取结束这个等待,可以返回一个false,表示我添加失败
                if (t <= 0) {
                    return;
                }
                t = tailWaits.awaitNanos(t);//最多等待多少纳秒
            }
            array[tail] = e;
            //tail++;
            //防止tail长度溢出
            if (++tail == array.length) {
                tail = 0;
            }
            size++;
            headWaits.signal();
        } finally {
             //利用try-finally:为了防止当我们try{}内有异常,那么我们的lock.unlock()解锁操作就没办法执行,所以我们要保证即使出现异常也要执行解锁操作
            lock.unlock();
        }
    }

    @Override
    public E poll() throws InterruptedException {
        lock.lockInterruptibly();
        try {
            //如果队列为空,我们就要让该线程等待,直到offer方法中headWaits.signal();将其唤醒
            while (isEmpty()) {
                headWaits.await();
            }
            E e = array[head];
            array[head] = null; // help GC
            if (++head == array.length) {
                head = 0;
            }
            size--;
            //offer方法那边等待队列非空,那么我们就要唤醒offer方法的等待
            tailWaits.signal();
            return e;
        } finally {
             //利用try-finally:为了防止当我们try{}内有异常,那么我们的lock.unlock()解锁操作就没办法执行,所以我们要保证即使出现异常也要执行解锁操作
            lock.unlock();
        }
    }

    private boolean isEmpty() {
        return size == 0;
    }

    private boolean isFull() {
        return size == array.length;
    }
}

注意

  • JDK 中 BlockingQueue 接口的方法命名与我的示例有些差异

    • 方法 offer(E e) 是非阻塞的实现,阻塞实现方法为 put(E e)

    • 方法 poll() 是非阻塞的实现,阻塞实现方法为 take()

双锁实现

单锁的缺点在于:

  • 生产和消费几乎是不冲突的,唯一冲突的是生产者和消费者它们有可能同时修改 size

  • 冲突的主要是生产者之间:多个 offer 线程修改 tail

  • 冲突的还有消费者之间:多个 poll 线程修改 head

如果希望进一步提高性能,可以用两把锁

  • 一把锁保护 tail

  • 另一把锁保护 head

ReentrantLock headLock = new ReentrantLock();  // 保护 head 的锁
Condition headWaits = headLock.newCondition(); // 队列空时,需要等待的线程集合

ReentrantLock tailLock = new ReentrantLock();  // 保护 tail 的锁
Condition tailWaits = tailLock.newCondition(); // 队列满时,需要等待的线程集合

先看看 offer 方法的初步实现

@Override
public void offer(E e) throws InterruptedException {
    tailLock.lockInterruptibly();
    try {
        // 队列满则等待
        while (isFull()) {
            tailWaits.await();
        }
        
        // 不满则入队
        array[tail] = e;
        if (++tail == array.length) {
            tail = 0;
        }
        
        // 修改 size (有问题)
        size++;
        
    } finally {
        tailLock.unlock();
    }
}

上面代码的缺点是 size 并不受 tailLock 保护,tailLock 与 headLock 是两把不同的锁,并不能实现互斥的效果。因此,size 需要用下面的代码保证原子性

此时,你会有疑问:size++,它不就是写在一个锁对象的加锁与解锁操作之间吗?那它怎么就是线程不安全的呢?

答:注意:我们这个size++,它确实能够受到我们tailLock这把锁的保护,但是对所有使用tailLock锁的线程让他们串行执行,也就是将来调用所有offer方法的线程,它们是不是都在使用tailLock这把锁,也就是说,只有各种线程在调用offer方法时,size++会受到保护,但是如果是headLock 这个锁,这个锁是由poll方法调用,这就和“只有各种线程在调用offer方法时,size++会受到保护”同一个道理,但是如果这两个锁串着用size++就不受保护,因为现在是使用了两把锁。

AtomicInteger size = new AtomicInteger(0);	   // 保护 size 的原子变量

size.getAndIncrement(); // 自增
size.getAndDecrement(); // 自减

代码修改为

@Override
public void offer(E e) throws InterruptedException {
    tailLock.lockInterruptibly();
    try {
        // 队列满等待
        while (isFull()) {
            tailWaits.await();
        }
        
        // 不满则入队
        array[tail] = e;
        if (++tail == array.length) {
            tail = 0;
        }
        
        // 修改 size
        size.getAndIncrement();//相当于size++  可以防止不会与其他线程产生这种指令交错的问题
        
    } finally {
        tailLock.unlock();
    }
}

对称地,可以写出 poll 方法

@Override
public E poll() throws InterruptedException {
    E e;
    headLock.lockInterruptibly();
    try {
        // 队列空等待
        while (isEmpty()) {
            headWaits.await();
        }
        
        // 不空则出队
        e = array[head];
        if (++head == array.length) {
            head = 0;
        }
        
        // 修改 size
        size.getAndDecrement();//相当于size++  可以防止不会与其他线程产生这种指令交错的问题
        
    } finally {
        headLock.unlock();
    }
    return e;
}

下面来看一个难题,就是如何通知 headWaits 和 tailWaits 中等待的线程,比如 poll 方法拿走一个元素,通知 tailWaits:我拿走一个,不满了噢,你们可以放了,因此代码改为

@Override
public E poll() throws InterruptedException {
    E e;
    headLock.lockInterruptibly();
    try {
        // 队列空等待
        while (isEmpty()) {
            headWaits.await();
        }
        
        // 不空则出队
        e = array[head];
        if (++head == array.length) {
            head = 0;
        }
        
        // 修改 size
        size.getAndDecrement();
        
        // 通知 tailWaits 不满(有问题)
        tailWaits.signal();
        
    } finally {
        headLock.unlock();
    }
    return e;
}

那有同学说,加上锁不就行了吗,于是写出了下面的代码

发现什么问题了?两把锁这么嵌套使用,非常容易出现死锁,如下所示

因此得避免嵌套,两段加锁的代码变成了下面平级的样子

性能还可以进一步提升

  1. 代码调整后 offer 并没有同时获取 tailLock 和 headLock 两把锁,因此两次加锁之间会有空隙,这个空隙内可能有其它的 offer 线程添加了更多的元素,那么这些线程都要执行 signal(),通知 poll 线程队列非空吗?

  • 每次调用 signal() 都需要这些 offer 线程先获得 headLock 锁,成本较高,要想法减少 offer 线程获得 headLock 锁的次数

  • 可以加一个条件:当 offer 增加前队列为空,即从 0 变化到不空,才由此 offer 线程来通知 headWaits,其它情况不归它管

  • 队列从 0 变化到不空,会唤醒一个等待的 poll 线程,这个线程被唤醒后,肯定能拿到 headLock 锁,因此它具备了唤醒 headWaits 上其它 poll 线程的先决条件。如果检查出此时有其它 offer 线程新增了元素(不空,但不是从0变化而来),那么不妨由此 poll 线程来唤醒其它 poll 线程

这个技巧被称之为级联通知(cascading notifies),类似的原因:

     在 poll 时队列从满变化到不满,才由此 poll 线程来唤醒一个等待的 offer 线程,目的也是为了减少 poll 线程对 tailLock 上锁次数,剩下等待的 offer 线程由这个 offer 线程间接唤醒

最终的代码为

public class BlockingQueue2<E> implements BlockingQueue<E> {

    private final E[] array;
    private int head = 0;
    private int tail = 0;
    private final AtomicInteger size = new AtomicInteger(0);
    ReentrantLock headLock = new ReentrantLock();
    Condition headWaits = headLock.newCondition();
    ReentrantLock tailLock = new ReentrantLock();
    Condition tailWaits = tailLock.newCondition();

    public BlockingQueue2(int capacity) {
        this.array = (E[]) new Object[capacity];
    }

    @Override
    public void offer(E e) throws InterruptedException {
        int c;
        tailLock.lockInterruptibly();
        try {
            while (isFull()) {
                tailWaits.await();
            }
            array[tail] = e;
            if (++tail == array.length) {
                tail = 0;
            }            
            c = size.getAndIncrement();//相当于size++  可以防止不会与其他线程产生这种指令交错的问题
            // a. 队列不满, 但不是从满->不满, 由此offer线程唤醒其它offer线程
            if (c + 1 < array.length) {
                tailWaits.signal();
            }
        } finally {
            tailLock.unlock();
        }
        // b. 从0->不空, 由此offer线程唤醒等待的poll线程
        if (c == 0) {
            headLock.lock();
            try {
                headWaits.signal();
            } finally {
                headLock.unlock();
            }
        }
    }

    @Override
    public E poll() throws InterruptedException {
        E e;
        int c;
        headLock.lockInterruptibly();
        try {
            while (isEmpty()) {
                headWaits.await(); 
            }
            e = array[head]; 
            if (++head == array.length) {
                head = 0;
            }
            c = size.getAndDecrement();//相当于size++  可以防止不会与其他线程产生这种指令交错的问题
            // b. 队列不空, 但不是从0变化到不空,由此poll线程通知其它poll线程
            if (c > 1) {
                headWaits.signal();
            }
        } finally {
            headLock.unlock();
        }
        // a. 从满->不满, 由此poll线程唤醒等待的offer线程
        if (c == array.length) {
            tailLock.lock();
            try {
                tailWaits.signal();
            } finally {
                tailLock.unlock();
            }
        }
        return e;
    }

    private boolean isEmpty() {
        return size.get() == 0;
    }

    private boolean isFull() {
        return size.get() == array.length;
    }

}

双锁实现的非常精巧,据说作者 Doug Lea 花了一年的时间才完善了此段代码

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值