排队买包子之ArrayBlockingQueue与LinkedBlockingQueue

    在java集合包中,我们经常用到的两个集合,一个是ArrayList,一个是LinkedList,众所周知,ArrayList是用数组实现的,便于查询,LinkedList是使用链表实现的,便于插入和删除操作,如果我们要通过ArrayList或LinkedList实现一个生产者或者消费者模式,我们需要编写下面的代码来完成:

List<Object> eggs = new ArrayList<Object>();
 
 // 定义一个拿鸡蛋的方法,返回鸡蛋(基本数据类型)
 public synchronized Object getEggs() {
  if (eggs.size() == 0) {
   try {
    wait();
   } catch (InterruptedException e) {
    e.printStackTrace();
   }
  }
 
  Object egg = eggs.get(0);// 当放入鸡蛋的时候拿到鸡蛋
  eggs.clear();// 清空盘子
  notify();// 唤醒等待的单个线程
  System.out.println("拿到鸡蛋,你该放鸡蛋了");
  return egg;
 }
 
 //定义一个放鸡蛋的方法,传入鸡蛋参数
 public synchronized void putEggs(Object egg) {
  if (eggs.size() >0 ) {
   try {
    wait();
   } catch (InterruptedException e) {
    e.printStackTrace();
   }
  }
  eggs.add(egg);// 往盘子里面放鸡蛋
  notify();// 唤醒等待的单个线程
  System.out.println("放入鸡蛋,你可以拿了!");
 
 }

上面的代码,涉及到线程之间的通信,因为ArrayList不是线程安全的,所以需要依赖Synchronized锁来保证线程之间的协作,在jdk1.5之前,Synchronized属于重量级锁,在jdk1.5之后,对它进行了优化,增加了偏向锁和轻量级锁,锁之间的升级过程如下:【摘自网络】一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将对象头中的ThreadID改成自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS在进行操作。一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象时偏向状态,这时表明在这个对象上已经存在竞争了,检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程,如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的)。如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向。轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。

自JUC出现后,Doug lea用阻塞队列实现了上面的场景,我们无需考虑Synchronized,也不要使用Object的wait和notify方法,只使用里面的API,就可以轻松实现生产者和消费者模式,下面重点来讲一下基于ArrayList和LinkedList实现的ArrayBlockingQueue和LinkedBlockingQueue。

ArrayBlockingQueue和LinkedBlockingQueue都是属于阻塞队列,所谓阻塞队列,就像我们平常排队买包子一样的,包子铺还没有热腾腾包子的时候,大家都排着队流着口水等着,当包子铺的包子已经没有蒸笼去蒸了,包子铺也要等着消费者去买,这个场景中,包子铺的蒸笼就相当于是一个阻塞队列,队列为空的时候,就等着生产者放入,队列满的时候,就等着消费者去消费,它不会抛异常,也不会返回为空之类的,接下来我们来看一下使用ArrayBlockingQueue实现生产者和消费者

    LinkedBlockingQueue<Integer> eggs = new LinkedBlockingQueue<>(10);

    public static void main(String[] args) {
        ArrayBlockQueueTest test = new ArrayBlockQueueTest();
        new Thread(() -> {
            try {
                test.inc();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
        new Thread(() -> {
            try {
                test.dec();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
    }

    public void inc() throws Exception {
        for (int i = 0; i < 100; i++) {
            eggs.put(i);
            System.out.println("放入元素:" + i);
        }
    }

    public void dec() throws Exception {
        for (int i = 0; i < 100; i++) {
            System.out.println(eggs.take());
        }
    }

是不是很神奇,里面没有Synchronized,也没有wait和notify方法,就仅仅使用了ArrayBlockingQueue中的put和take方法,就实现了生产者和消费者的场景,接下来,带大家来揭秘阻塞队列神秘的面纱。

首先我们来看一下ArrayBlockingQueue,它底层还是基于数组来实现的,只是在初始化的时候,一定还要指定它的容量,为什么要指定容量,因为它是一个有界队列,不像ArrayList那样,到达容量的时候可以扩容之类的,它提供如下几种构造方法:

如上图,最后一个构造方法的参数定义为:初始化容量,进入等待队列中的锁是否公平,初始化将集合加入进来

接下来我们来看一下它的构造方法做了那些事情

/** The queued items */
final Object[] items;
final ReentrantLock lock;
/** Condition for waiting takes */
private final Condition notEmpty;
/** Condition for waiting puts */
private final Condition notFull;

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

如上代码,初始了一个指定容量的数组,根据传入的是否公平锁构造ReentranLock对象,关于对ReentranLock不理解的话,我后面会有博客专门去做介绍,这里姑且把它当做一把锁,这把锁它可以是公平锁,也可以是非公平锁,根据它的构造函数来指定,然后有两个条件对象:notEmpty和notFull,这两个条件对象都是由ReentranLock对象构造出来的,如果不理解或者不知道它是干什么用的,我下面也会举例说明的,好了,这些对象都初始化了,接下来看下一put方法

public void put(E e) throws InterruptedException {
	checkNotNull(e);
	final ReentrantLock lock = this.lock;
	lock.lockInterruptibly();
	try {
		while (count == items.length)
			notFull.await();
		enqueue(e);
	} finally {
		lock.unlock();
	}
}

private void enqueue(E x) {
	// assert lock.getHoldCount() == 1;
	// assert items[putIndex] == null;
	final Object[] items = this.items;
	items[putIndex] = x;
	if (++putIndex == items.length)
		putIndex = 0;
	count++;
	notEmpty.signal();
}

put方法也很简单,就是在数组的后面添加一个元素,这里需要注意的是,当我们添加的元素达到数组指定的长度后,它的putIndex指针又从移到了第一个位置,因为它是生产者消费者模型,如果满了并且可以放了,说明第一个位置也肯定取走了,这样就可以充分利用内存空间。接下来我们看一下take方法:

public E take() throws InterruptedException {
	final ReentrantLock lock = this.lock;
	lock.lockInterruptibly();
	try {
		while (count == 0)
			notEmpty.await();
		return dequeue();
	} finally {
		lock.unlock();
	}
}

private E dequeue() {
	// assert lock.getHoldCount() == 1;
	// assert items[takeIndex] != null;
	final Object[] items = this.items;
	@SuppressWarnings("unchecked")
	E x = (E) items[takeIndex];
	items[takeIndex] = null;
	if (++takeIndex == items.length)
		takeIndex = 0;
	count--;
	if (itrs != null)
		itrs.elementDequeued();
	notFull.signal();
	return x;
}

take方法就是从数组的头部获取数据,如果获取的元素达到数组的长度,同样滴,takeIndex也会指向起始位置。接下来就是重点了,我们可以看到put和take方法中都通过ReentranLock实现了加锁和释放锁的操作,也通过Condition使用了类似Object的wait和notify方法,来实现队列满和队列为空的线程等待,那么它究竟是怎么一回事呢?下面我就拿包子铺给大家讲述一下,模拟ArrayBlockingQueue,例如下面的场景,包子铺老板往蒸笼里面放包子,顾客往蒸笼里面取包子,蒸笼里面最多只能放5个包子,所以初始化的时候,给它指定5个位置:

好了,现在包子铺老板开始往蒸笼里放入1号包子了,因为数组是有顺序的,暂且给包子标上序号:

因为put和take方法共用一个ReentrantLock,所以包子铺老板在生产包子的时候,他拿到了一把锁,把这个蒸笼给锁住了,顾客是不能够从蒸笼里面拿包子的,如果这个时候顾客来获取包子,会是怎么样的情况呢?它会被加入到一个等待队列中,这个队列是由AbstractQueuedSynchronizer来实现的,后面也会专门对它做个讲解,。

当包子铺老板释放掉蒸笼的锁,这个时候如果加入到等待队列中的顾客拿到了锁,那么他就可以从蒸笼里拿包子了,这个时候包子铺老板制作的包子是不能放到蒸笼里面的,因为它拿不到蒸笼的锁

这就是生产者和消费者模型,下面我们来看看当蒸笼里的包子都放满,但是消费者一直在玩手机没有消费的情况是怎么样的

当放入的包子在蒸笼里面满了之后,将继续生产的包子会放到包子的等待同步队列中,然后通过notEmpty.signal唤醒顾客的等待队列加入到AQS队列中开始进行消费,而且执行的肯定会是顾客线程。同理,如果顾客把蒸笼里的包子消费完,会通过notFull.signal唤醒包子的等待同步队列加入到AQS中进行放入包子,这样就实现了生产者和消费者之间的一种协调。保证了包子线程和顾客线程能够有条有序的进行,不会出现包子一直生产没人过来买和消费者一直等着却没有包子的情况!

在源代码中,细心的读者可能会有这样的疑问:

1、为什么判断count == 0或者count == items.length使用的while循环,而不是if呢,其实就当多个消费者同时来消费,都发现没有包子可以买了,那么你们还是都乖乖的在同步队列里面去等着吧,如果使用if在高并发环境下会有问题。

2、为什么要使用notFull和notEmpty两个Condition,就是因为在唤醒线程的时候,相互之间不会干涉,例如包子放满了,我叫醒的线程肯定是消费者线程,也肯定会让蒸笼里的包子得到消费,提高线程执行的效率,像我们之前使用Synchronize方法结合notify和wait实现线程之间的同步的时候,它里面相当于是只有一个队列,将生产者和消费者线程都放在里面,在进行notify的时候,因为它是随机的,可能叫醒生产者线程,也可能会叫醒消费者线程,所以效率比较低。

接下来我们来看一下LinkedBlockingQueue的实现,它与ArrayBlockingQueue不同之处除了底层是使用链表之外以及不需要指定容量(默认为Integer.max_value),它还有一个不同之处,就是它里面使用了两个ReentrantLock对象:

/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();

/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();

/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();

/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();

然后put方法放入元素:

public void put(E e) throws InterruptedException {
	if (e == null) throw new NullPointerException();
	// Note: convention in all put/take/etc is to preset local var
	// holding count negative to indicate failure unless set.
	int c = -1;
	Node<E> node = new Node<E>(e);
	final ReentrantLock putLock = this.putLock;
	final AtomicInteger count = this.count;
	putLock.lockInterruptibly();
	try {
		/*
		 * Note that count is used in wait guard even though it is
		 * not protected by lock. This works because count can
		 * only decrease at this point (all other puts are shut
		 * out by lock), and we (or some other waiting put) are
		 * signalled if it ever changes from capacity. Similarly
		 * for all other uses of count in other wait guards.
		 */
		while (count.get() == capacity) {
			notFull.await();
		}
		enqueue(node);
		c = count.getAndIncrement();
		if (c + 1 < capacity)
			notFull.signal();
	} finally {
		putLock.unlock();
	}
	if (c == 0)
		signalNotEmpty();
}

private void enqueue(Node<E> node) {
	// assert putLock.isHeldByCurrentThread();
	// assert last.next == null;
	last = last.next = node;
}

take方法获取元素

public E take() throws InterruptedException {
	E x;
	int c = -1;
	final AtomicInteger count = this.count;
	final ReentrantLock takeLock = this.takeLock;
	takeLock.lockInterruptibly();
	try {
		while (count.get() == 0) {
			notEmpty.await();
		}
		x = dequeue();
		c = count.getAndDecrement();
		if (c > 1)
			notEmpty.signal();
	} finally {
		takeLock.unlock();
	}
	if (c == capacity)
		signalNotFull();
	return x;
}

private E dequeue() {
	// assert takeLock.isHeldByCurrentThread();
	// assert head.item == null;
	Node<E> h = head;
	Node<E> first = h.next;
	h.next = h; // help GC
	head = first;
	E x = first.item;
	first.item = null;
	return x;
}

相比上面买包子的场景,会发现生产包子和消费包子可以同时进行了,两个互不干涉,因为它里面有两把锁:

其他的原理大致类同,读者可以看着代码去想象一下排队买包子是怎么个场景。

为什么ArrayBlockingQueue只有一把ReentrantLock锁,而LinkedBlockingQueue却有两把ReentrantLock锁,这是跟他们底层的数据结构相关的,因为ArrayBlockingQueue底层是数组实现的,所以如果在一端拿一端取的话,会造成putIndex和takeIndex重叠的现象。

ArrayBlockingQueue和LinkedBlockingQueue里面也有类似List中非阻塞方法,总结如下,因为它容量有限,所以在队列满或者队列为空的时候,非阻塞方法会抛异常或者返回特殊值:

对于它们的介绍,以后会持续更新的,很久没有写博客了,有什么说的不好的地方,希望可以指正,大家还可以想一下,ArrayBlockingQueue和LinkedBlockingQueue有什么弊端?

发布了241 篇原创文章 · 获赞 305 · 访问量 54万+

猜你喜欢

转载自blog.csdn.net/HarderXin/article/details/90671283
今日推荐