并发队列
Java并发编程一:并发基础必知
Java并发编程二:Java中线程
Java并发编程三:volatile使用
Java并发编程四:synchronized和lock
Java并发编程五:Atomic原子类
在多线程编程下,有时候我们需要使用现场安全的队列,其实队列大家多少肯定也知道点,比如消息中间件rabbitmq、rocketmq、kafka,都是一种生产者消费者模式,其实思想跟JDK给我们提供的并发队列相似,实际上消息队列肯定比并发队列难,要想学会跑还是得先学会走。
并发队列的类型
一个线程安全的队列有两种形式:一种是使用阻塞算法,一种是使用非阻塞算法。使用阻塞算法可以用一个锁或者两个锁来控制入队和出队的方式来实现,使用非阻塞算法可以用CAS方式来实现。
ConcurrentLinkedQueue
ConcurrentLinkedQueue是一个基于单向链表无界无阻塞线程安全的队列,采用先进先出的规则对节点进行排序,当添加一个元素的时候,会被添加到队列的尾部,当获取一个元素的时候,会返回对头的元素,采用了CAS算法来实现。其中有两个volatile类型的Node节点来存储队列的首位节点,从下面的构造函数得值。
既然是队列肯定要有生产和消费两个方法,offer是向队列尾部添加一个元素。
Node(E item) {
UNSAFE.putObject(this, itemOffset, item);
}
public boolean offer(E e) {
// 判断不为null
checkNotNull(e);
// 构造Node节点 由内部unsafe提供的
final Node<E> newNode = new Node<E>(e);
// 循环插入到队列尾部 p的初始值为队尾
for (Node<E> t = tail, p = t;;) { // (1)
// q 是 后继节点
Node<E> q = p.next;
// 判断q是否为null
if (q == null) { // (2)
// q 为null cas将入队节点添加到next
if (p.casNext(null, newNode)) { //(3)
// 判断 p是否为尾节点 如果不是 cas修改入队节点到tail
if (p != t)
casTail(t, newNode); // (4)
return true;
}
}
// 在多线程下,移除操作可能会head变为自引用,也就是head的next变成了head,
// 需要重新找到新的head
else if (p == q)
p = (t != (t = tail)) ? t : head;
else
// 有线程操作入队节点插入成功,重新修改p的值
p = (p != t && t != (t = tail)) ? t : q;
}
}
入队操作比较复杂,首先判断是为空,然后不停循环从尾部开始插入。比如线程A插入item1线程B插入item2,此时两个线程都到了(3)代码中,此时cas操作只有一个线程成功,线程A修改成功,插入队尾的后继节点返回,线程B失败,再次进入循环(1)中。
此时由于线程A修改成功,B进入(2)发现next不为null会进入else if 先去判断节点是否移除,如果移除了则将p指向队头重新开始插入新节点,如果没有移除则修改p为q(q一开始为p的后继节点)。
然后再次进入到(1)中,当执行到(2)时候q是null。
由于q是null的,此时线程B通过cas操作item2入队,假如设置成功,因为此时还有可能别的线程修改,如果别的线程修改成功,则再进入(1)循环中。由于线程A插入成功后,线程B修改了p的值,所以p!=t,进入(4)修改tail节点为item2,然后退出循环。
poll方法是从对头取出一个元素,如果当没有元素则为空。
public E poll() {
// 死循环取元素
for (;;) {
// p 指向头部元素
for (Node<E> h = head, p = h, q;;) {
E item = p.item;
// 判断对头元素不为null 设置为空
if (item != null && p.casItem(item, null)) {
// 判断p是否等于t 如果不等于,重新指向对头
if (p != h) // hop two nodes at a time
updateHead(h, ((q = p.next) != null) ? q : p);
return item;
}
// 队列为null 返回null
else if ((q = p.next) == null) {
updateHead(h, p);
return null;
}
// 有其他线程取出,重新入队
else if (p == q)
continue restartFromHead;
else
p = q;
}
}
}
相比较添加,poll取出就很简单,只是从对头开始取元素,如果不为null则取走并且CAS设置为null,如果有线程已经取走,更新头部重新获取,如果队列为null,则返回null。
注意:生产环境如果判断队列是否为null,使用isEmpty()而不使用size()>0,。
阻塞队列
相比较ConcurrentLinkedQueue无阻塞队列,Java为我们提供了7种阻塞队列,在添加和移除的时候阻塞队列。阻塞队列常用语生产者消费者。
方法说明
方法 | 抛出异常 | 返回值 | 一直阻塞 | 超时退出 |
---|---|---|---|---|
插入方法 | add(e) | offer(e) | put(e) | offer(e,time,unit) |
移除方法 | remove() | poll() | take() | poll(time,unit) |
检查方法 | element() | peek() | 不可用 | 不可用 |
抛出异常:如果队列已满,add抛出IllegalStateException(“Queue full”)异常,队列为空,remove抛出NoSuchElementException()。
返回值:offer成功返回true,失败返回false,poll时队列为null返回null。
一直阻塞:当队列满时,put则阻塞线程,等待不满;当队列为空时,take阻塞线程,等待不空,可以中断。
超时退出:offer在一定时间到时退出不添加,poll在一定时间到时退出不再获取。
如果是无界阻塞队列,队列不可能会出现满的情况,所以使用put或offer方法永远不会被阻塞,而且使用offer方法时,该方法永远返回true。
ArrayBlockingQueue
由数组结构构成的有界阻塞队列,按照FIFO先进先出原则对元素进行排序,默认情况下不保证获取元素的线程顺序,可以在初始化的时候设置,ArrayBlockingQueue queue = new ArrayBlockingQueue(5,true);
代码上由于我们在以前章节学过lock因此代码也比较简单,这里简单说下,首先的put(e)方法。
// 初始化
public ArrayBlockingQueue(int capacity, boolean fair) {
// 容量不能小于等于0
if (capacity <= 0)
throw new IllegalArgumentException();
// 创建数组
this.items = new Object[capacity];
// 生成公平还是非公平锁 如果不知道区别 可以看并发编程第四章
lock = new ReentrantLock(fair);
// 消费者 如果理解 可以看并发编程第四章
notEmpty = lock.newCondition();
// 生产者
notFull = lock.newCondition();
}
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) {
// item 存放就是元素
final Object[] items = this.items;
// 插入元素
items[putIndex] = x;
// 如果容量满了,被消费后,从数组的头部位置开始插入
if (++putIndex == items.length)
putIndex = 0;
count++;
// 唤醒消费者
notEmpty.signal();
}
接下来是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() {
// 获取当前元素
final Object[] items = this.items;
// 取出来
E x = (E) items[takeIndex];
// 赋为null
items[takeIndex] = null;
// 当下标为最后一个元素的时候,下标从头部重新开始获取
if (++takeIndex == items.length)
takeIndex = 0;
// 容量减一
count--;
if (itrs != null)
itrs.elementDequeued();
// 唤醒生产者生产
notFull.signal();
return x;
}
源码注释基本都写了,也很明确,下面阻塞队列原理基本差不多,有兴趣的可以看一下源码,如果不明白锁的可以看我以前写的文章。
LinkedBlockingQueue
由一个链表实现的有界阻塞队列,如果不设置队列大小默认为Integer.MAX_VALUE,也是按照FIFO原则。
PriorityBlockingQueue
由一个支持优先级的无界阻塞队列,默认情况下是升序,也可以自定义compareTo()方法来指定元素如何排序,需要注意的是不能保证同优先级元素的顺序。
DelayQueue
一个支持延时获取元素的无界队列,队列使用PriorityQueue来实现,插入的元素必须实现Delayedjiekou接口,在创建元素的时候指定多久后可以获取。
SynchronousQueue
一个不存储元素的阻塞队列,每一个put必须对应一个take,否则再次put,它支持线程公平访问,在初始化设置为true就可以,因此不存储元素,适合传递性场景,吞吐量高于ArrayBlockingQueue和LinkedBlockingQueue。
LinkedTransferQueue
由一个链表构成的无界阻塞队列,相比较其他阻塞队列多了transfer和tryTransfer方法。
transfer方法表示如果当前有消费者正在等待接受元素(消费者使用take方法或者poll方法延时方法),transfer可以把生产的元素立即给消费者,如果没有消费则放入队列里。
tryTransfer方法表示生产者元素能否被消费者拿到,如果能则返回true,不能返回false。
相比较SynchronousQueue内部无法存储元素,当要添加元素的时候,需要阻塞;LinkedBolckingQueue 又使用了大量的锁,性能不高,LinkedTransferQueue类似与他们的结合品。一段代码说明transfer和tryTransfer。
LinkedTransferQueue queue = new LinkedTransferQueue();
new Thread(()->{
try {
// 消费先取
System.out.println(queue.take());
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
Thread.sleep(100);
// 生产者 生产元素
queue.tryTransfer("aa");
// 没有消费者消费 返回false
System.out.println(queue.tryTransfer("bb"));
LinkedBlockingDeque
**是由一个链表组成的双向阻塞队列,它可以从队列的两端进行插入和删除,因为是双端,在多线程同时进队减少了一半的竞争。**相比较于其他阻塞队列,LinkedBlockingDeque多了addFirst、addLast、offerFirst、offerLast、peekFirst和peekLast等方法。不用过多解释了,大概都能明白,由于操作大同小异,重点的方法都一样,只是底层实现不同,后续文章会单独说明。
终极版——生产者消费者
前面写过了两次生产者消费者,发现无论那种写起来都要考虑锁的使用,既然Jdk为我们提供了这种并发队列,那我们现在就来尝试一下。
// 定义一个并发队列初始化为10
LinkedBlockingQueue<Integer> queue = new LinkedBlockingQueue(10);
// 开五个生产者
for (int i = 0; i <5 ; i++) {
new Thread(()->{
while (true){
try {
// 生产数据
queue.put(new Random().nextInt(100));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
// 开10个消费者
for (int i = 0; i <10 ; i++) {
new Thread(()->{
while (true){
try {
// 消费数据
System.out.println(queue.take());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
代码量少多了,并且不用自己来控制锁了,手写生产者消费者如此简单。如有不对,还能指出。