1、课程内容
详情可参考“极客时间”上的《数据结构与算法之美》课程:09 | 队列:队列在线程池等有限资源池中的应用 (geekbang.org)
2、课后练习
代码
数组队列
package dataStruct;
/**
* @ClassName ArrayQueue
* @Version 1.0
* @Author Wulc
* @Date 2022-02-12 11:54
* @Description 数组队列
*/
public class MyArrayQueue {
// 数组:items,数组大小:n
public Object[] items;
public int n = 0;
// head表示队头下标,tail表示队尾下标
public int head = 0;
public int tail = 0;
// 申请一个大小为capacity的数组
public MyArrayQueue(int capacity) {
items = new Object[capacity];
n = capacity;
}
// 出队
public Object dequeue() {
// 如果head == tail 表示队列为空
if (head == tail) {
return null;
}
Object ret = items[head];
++head;
return ret;
}
// 入队操作,将item放入队尾
public boolean enqueue(Object item) {
// tail == n表示队列末尾没有空间了
if (tail == n) {
// tail ==n && head==0,表示整个队列都占满了
if (head == 0) {
return false;
}
// 数据搬移,因为之前的出队操作可能导致数组左侧部分会有多余的空间,因此入队的时候,如果数组左侧有多余的空间,则把数组整体左移
for (int i = head; i < tail; ++i) {
items[i - head] = items[i];
}
// 搬移完之后重新更新head和tail
tail -= head;
head = 0;
}
items[tail] = item;
++tail;
return true;
}
}
链表队列
package dataStruct;
/**
* @ClassName LinkedListQueue
* @Version 1.0
* @Author Wulc
* @Date 2022-02-12 12:19
* @Description
*/
public class MyLinkedListQueue {
//链表
public ListNode<Object> listNode;
//队头
public Node<Object> head;
//队尾
public Node<Object> tail;
//队列大小
public int n;
public MyLinkedListQueue() {
this.listNode = new ListNode<>();
//初始化时,队头和队尾指针都指向链表的头结点
this.head = this.listNode.head;
this.tail = this.listNode.head;
}
//入队列
public boolean enqueue(Object item) {
this.listNode.addNode(item);
//队尾指针向后进一
this.tail = this.tail.next;
n++;
return true;
}
//出队列
public Object dequeue() {
// 如果head == tail 表示队列为空
if (this.head == this.tail) {
return null;
}
this.head = this.head.next;
this.listNode.removeLastN(n);
n--;
return this.head.data;
}
}
循环队列(用数组实现)
package dataStruct;
/**
* @ClassName MyCircularArrayQueue
* @Version 1.0
* @Author Wulc
* @Date 2022-02-12 21:40
* @Description 数组循环队列
*/
public class MyCircularArrayQueue {
// 数组:items,数组大小:n
private String[] items;
private int n = 0;
// head表示队头下标,tail表示队尾下标
private int head = 0;
private int tail = 0;
// 申请一个大小为capacity的数组
public MyCircularArrayQueue(int capacity) {
items = new String[capacity];
n = capacity;
}
// 入队
public boolean enqueue(String item) {
// 队列满了
if ((tail + 1) % n == head) {
return false;
}
items[tail] = item;
tail = (tail + 1) % n;
return true;
}
// 出队
public String dequeue() {
// 如果head == tail 表示队列为空
if (head == tail) {
return null;
}
String ret = items[head];
head = (head + 1) % n;
return ret;
}
}
注意:循环队列一般都是用数组实现的,其实就是为了预防普通数组队列可能出现的“假溢出”现象。循环队列不要用链表去实现,因为链表队列本身就不会存在“假溢出”现象。而且,用链表去构造循环队列,会出现“栈溢出”。
这是因为,如果用链表去实现循环队列,那么就是将尾指针指向头结点,那么就会出现死循环的现象。导致“栈溢出”。
3、课后思考
Q1、除了线程池这种池结构会用到队列排队请求,你还知道有哪些类似的池结构或者场景中会用到队列的排队请求呢?
A1、操作系统中,进程调度(FCFS)、页面置换算法(FIFO)
Q2、今天讲到并发队列,关于如何实现无锁并发队列,网上有非常多的讨论。对这个问题,你怎么看呢?
A2、其实所谓的无锁并发队列,就是指通过不加锁的方式实现原子化操作。在java中由一个类ConcurrentLinkedQueue实现了基于链表的无锁并发队列。ConcurrentLinkedQueue是线程安全的,因为ConcurrentLinkedQueue不是通过加锁实现线程安全的,因此ConcurrentLinkedQueue是“非阻碍队列”。与之像对应的是ArrayBlockingQueue,因为使用了锁,所以当出现一些异常情况,比如当前队列为空却依然有出队的请求,此时就会把队列锁住,只要其他线程往当前队列里面添加了数据,那么出队请求的锁才会被解锁,继续完成出队操作。
如上图所示:ArrayBlockingQueue类里面新增了put和take方法。put就相当于带锁的enqueue,take就相当于带锁的dequeue。
可以看一个生产者/消费者的例子
package Practise;
import dataStruct.concurrent.MyConcurrentArrayQueueWithLock;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.LinkedBlockingQueue;
/**
* @ClassName ThreadQueueConcurrentTest
* @Version 1.0
* @Author Wulc
* @Date 2022-02-14 15:28
* @Description
*/
public class TestArrayBlockingQueue {
static ArrayBlockingQueue<String> arrayBlockingQueue = new ArrayBlockingQueue(10);
public static void main(String[] args) throws InterruptedException {
Runnable productor = (new Runnable() {
@Override
public void run() {
try {
arrayBlockingQueue.put("商品");
System.out.println("向队列插入一个商品");
Thread.sleep(30);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Runnable consumer = (new Runnable() {
@Override
public void run() {
try {
arrayBlockingQueue.take();
System.out.println("从队列领取一个商品,剩余队列长度" + (arrayBlockingQueue.size() - 1));
Thread.sleep(30);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
new Thread(productor).start();
new Thread(productor).start();
new Thread(consumer).start();
new Thread(consumer).start();
new Thread(consumer).start();
new Thread(consumer).start();
new Thread(consumer).start();
new Thread(consumer).start();
}
}
生产者productor是负责往队列里面添加“商品”,消费者consumer是负责从队列里面拿“商品”。因为是使用了ArrayBlockingQueue的take和put方法,因此当队列里面没有“商品”时,消费者线程就会等待(把调用的消费者线程锁住,使其他线程在该时刻无法调用消费者线程,从而达到解决冲突)直到生产者向队列中添加了“商品”,调用的消费者线程才会被解锁。
阻塞队列的好处:多线程操作共同的队列时不需要额外的同步,另外就是队列会自动平衡负载,即那边(生产与消费两边)处理快了就会被阻塞掉,从而减少两边的处理速度差距。
ConcurrentLinkedQueue是非阻塞队列,解决线程冲突是靠“乐观锁”即CAS进行了一个版本比较来解决可能存在的冲突问题。
大致梳理了一下,应该是这样子的一个流程。
ConcurrentLinkedQueue类我看下来就是通过volatile+Unsafe类的compareAndSwap相关算法来实现链表队列头尾指针的线程安全的。
volatile关键字的作用是防止编译的时候出现“指令重排”,其实我也不是很懂为什么“指令重排”会影响线程安全。我看了一篇文章,说是在并发的情况下,CPU为了执行方便可能会对一些既有的指令顺序进行重新排列指令的执行先后顺序。
Java volatile关键字最全总结:原理剖析与实例讲解(简单易懂)_夏日清风-CSDN博客_java volatile我看文章中有提到“分配内存”、“初始化对象”,“设置地址”这些。
我的理解是:当new一个对象时,会先在内存中开辟一块空间,有了这个空间才会去初始化这个对象,然后在把这个对象指向内存中新开辟的空间。然后在高并发的情况下,会在极短的时间内需要new大量的对象。CPU为了处理方便,可能就不会对于每个要new的对象单独分别去做“分配内存”->“初始化对象”->“设置地址”这样的操作。有可能比如CPU先申请一块内存空间,然后初始化对象,发现之前申请的内存空间不够,因此部分对象无法初始化,即便初始化了也暂时没有多余的空间分配,CPU只能重新再申请一块新的空间,因此执行顺序从原来的“先分配内存再初始化对象”到现在的“先初始化对象,结果发现没内存空间了,因此CPU再去分配内存”。如此往复。
Unsafe类的compareAndSwap,又简称“CAS算法”。
compareAndSwap相关的方法都是用“native”修饰的,CAS本身并不是由java去实现的,java只是使用而已。CAS算法是用C语言实现的,更接近操作系统。是通过C语言的指针,直接指向内存地址。因为是指针直接操作内存所以无论并发有多少,每个地址空间的指针总是唯一的。使用指针的唯一性以达到线程安全的作用。
注:本文关于volatile和CAS的理解并不一定准确,是我根据查到的资料以及自己的知识储备理解的,会有一些不对的地方,等我学完了“java内存模型”后再做补充修正。
另外就是提到线程安全,也让我想到了一道非常流行的面试题:
两个线程同时执行i++100次_qq_35925750的博客-CSDN博客_两个线程同时调用i++10次循环
其实说白了,就是内存中的结果没有实时同步导致的脏数据引起的。
解决方法:一是加锁(悲观锁)。二是使用AtomicInteger(乐观锁),确保变量的原子化操作。
参考代码:
package Practise;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @ClassName Thread
* @Version 1.0
* @Author Wulc
* @Date 2022-02-12 15:30
* @Description
*/
public class MyThread {
static int count01 = 0;
static int count02 = 0;
static AtomicInteger count03 = new AtomicInteger(0);
static volatile int count04 = 0;
public static void main(String[] args) {
Runnable runnable01 = (new Runnable() {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
count01++;
try {
Thread.sleep(30);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("count01=" + count01);
}
});
Runnable runnable02 = (new Runnable() {
@Override
public synchronized void run() {
for (int i = 0; i < 100; i++) {
count02++;
try {
Thread.sleep(30);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("count02=" + count02);
}
});
Runnable runnable03 = (new Runnable() {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
count03.getAndAdd(1);
try {
Thread.sleep(30);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("count03=" + count03);
}
});
Runnable runnable04 = (new Runnable() {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
count04++;
try {
Thread.sleep(30);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("count04=" + count04);
}
});
new Thread(runnable01).start();
new Thread(runnable01).start();
new Thread(runnable02).start();
new Thread(runnable02).start();
new Thread(runnable03).start();
new Thread(runnable03).start();
new Thread(runnable04).start();
new Thread(runnable04).start();
}
}
4、总结
提到“高并发”,我首先会想到的就是:“缓存”+“队列”+“锁”。但是如何将三者有机结合起来使其功效最大化是个很大的难题,有待我以后慢慢探索研究。
5、参考资料
- 两个线程同时执行i++100次_qq_35925750的博客-CSDN博客_两个线程同时调用i++10次循环
- Java中volatile的作用以及用法 - Mars、少年 - 博客园
- Java volatile关键字最全总结:原理剖析与实例讲解(简单易懂)_夏日清风-CSDN博客_java volatile
- https://www.iteye.com/blog/flychao88-2269438
- java Unsafe类中compareAndSwap相关介绍_sherld的专栏-CSDN博客_compareandswap
- Java多线程总结之线程安全队列Queue_bieleyang的博客-CSDN博客_java 线程安全队列
- 自己实现无锁高并发队列 - 简书
- 阻塞队列和非阻塞队列_大鸡腿的博客-CSDN博客_阻塞队列和非阻塞队列的区别