Java并发编程04:并发容器
并发容器的引出: 售票问题
一个容器存储了一些票,要求多个线程并发从容器中取票且不出错.
实现1:使用List-非原子性操作
直接使用List存储票会发生重售和超售,因为List的所有操作都是非原子性的.
public class TicketSeller1 {
static List<String> tickets = new ArrayList<>();
static {
for (int i = 0; i < 1000; i++) {
tickets.add("票-" + i);
}
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
while (tickets.size() > 0) {
// List的remove操作不是原子性的
System.out.println("销售了:" + tickets.remove(0));
}
}).start();
}
}
}
输出如下,我们发现发生了重售:
...
销售了:票-998
销售了:票-999
销售了:票-999
实现2:使用Vector-判断与操作分离,复合操作不保证原子性
Vector的所有操作均为原子性的,但仍会出现问题,因为判断与操作是分离的,形成的复合操作不能保证原子性.
public class TicketSeller2 {
static Vector<String> tickets = new Vector<>();
static {
for (int i = 0; i < 1000; i++) {
tickets.add("票-" + i);
}
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
while (tickets.size() > 0) {
// 线程判断后睡10毫秒再执行取操作,放大复合操作的问题
try {
TimeUnit.MILLISECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("销售了:" + tickets.remove(0));
}
}).start();
}
}
}
实现3: 使用同步代码块锁住复合操作-保证正确性但效率低
我们使用synchronized代码块将判断和取票操作锁在一起执行,保证其原子性.
这样可以保证售票过程的正确性,但每次取票都要锁定整个队列,效率低.
public class TicketSeller3 {
static List<String> tickets = new ArrayList<>();
static {
for (int i = 0; i < 1000; i++) {
tickets.add("票-" + i);
}
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
while (tickets.size() > 0) {
// sychronized 保证了原子性
synchronized (tickets) {
System.out.println("销售了:" + tickets.remove(0));
}
}
}).start();
}
}
}
实现4: 使用并发队列,先取票再判断
使用JDK1.5之后提供的并发队列ConcurrentLinkedQueue
存储元素,其底层使用CAS实现而非加锁实现的,其效率较高.
并发队列ConcurrentLinkedQueue
的poll()
方法会尝试从队列头中取出一个元素,若获取不到,则返回null
,对其返回值做判断可以实现先取票后判断,可以避免加锁.
public class TicketSeller4 {
static ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
static {
for (int i = 0; i < 1000; i++) {
queue.add("票-" + i);
}
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
while (true) {
// 尝试取出队列头,若取不到则返回null
String t = queue.poll();
if (t == null) {
break;
}
System.out.println("销售了:" + t);
}
}).start();
}
}
}
并发容器
Map/Set
Map
和Set
容器类型是类似的,Set
无非就是屏蔽了Map
的value
项,只保留key
项.
非并发容器
主要的非并发容器有HashMap
,TreeMap
,LinkedHashMap
并发容器
主要的并发容器有HashTable
,SynchronizedMap
,ConcurrentMap
.
-
HashTable
和SynchronizedMap
的效率较低,其同步的实现原理类似,都是给容器的所有方法都加锁.其中
SynchronizedMap
使用装饰器模式,调用其构造方法并传入一个Map
实现类,返回一个同步的Map
容器. -
ConcurrentMap
的效率较高,有两个实现类:ConcurrentHashMap
: 使用哈希表实现,key是无序的ConcurrentSkipListMap
: 使用跳表实现,key是有序的
其同步的实现原理在JDK1.8前后不同
- 在JDK1.8以前,其实现同步使用的是
分段锁
,将整个容器分为16段(Segment
),每次操作只锁住操作的那一段,是一种细粒度更高的锁. - 在JDK1.8及以后,其实现同步用的是
Node+CAS
.关于CAS的实现,可以看这篇文章:乐观锁CAS
public static void main(String[] args) {
Map<String, String> map = Collections.synchronizedMap(new HashMap<>()); // 所有同步方法都锁住整个容器
// Map<String, String> map = new Hashtable<>(); // 所有同步方法都锁住整个容器
// Map<String, String> map = new ConcurrentHashMap<>(); // 1.8以前使用分段锁,1.8以后使用CAS
// Map<String, String> map = new ConcurrentSkipListMap<>(); // 使用跳表,并发且有序
Random r = new Random();
Thread[] ths = new Thread[100];
CountDownLatch latch = new CountDownLatch(ths.length); // 启动了一个门闩,每有一个线程退出,门闩就减1,直到所有线程结束,门闩打开,主线程结束
long start = System.currentTimeMillis();
// 创建100个线程,每个线程添加10000个元素到map,并启动这些线程
for (int i = 0; i < ths.length; i++) {
ths[i] = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
map.put("a" + r.nextInt(10000), "a" + r.nextInt(100000));
}
latch.countDown();
}, "t" + i);
}
Arrays.asList(ths).forEach(Thread::start);
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
System.out.println(end - start);
System.out.println(map.size());
}
队列
低并发队列
低并发队列有:Vector
和SynchronizedList
,其中vector
类似HashTable
,是JDK1.2就提供的类;SynchronizedList
类似SynchronizedMap
使用装饰器模式,其构造函数接受一个List
实现类并返回同步List
,在java.util.Collections
包下.
它们实现同步的原理都是将所有方法用同步代码块包裹起来.
写时复制CopyOnWriteList
CopyOnWriteArrayList
位于java.util.concurrent
包下,它实现同步的方式是: 当发生写操作(添加,删除,修改)时,就会复制原有容器然后对新复制出的容器进行写操作,操作完成后将引用指向新的容器.其写效率非常低,读效率非常高
- 优点: 读写分离,使得读操作不需要加锁,效率极高
- 缺点: 写操作效率极低
- 应用场合: 应用在读少写多的情况,如事件监听器
高并发队列
高并发队列的方法
方法 | 抛出异常 | 返回特殊值 | 一直阻塞 (非阻塞队列不可用) | 阻塞一段时间 (非阻塞队列不可用) |
---|---|---|---|---|
插入元素 | add(element) | offer(element) | put(element) | offer(element,time,unit) |
移除首个元素 | remove() | poll() | take() | poll(time,unit) |
返回首个元素 | element() | peek() | 不可用 | 不可用 |
对于高并发队列,若使用不同的方法对空队列执行查询和删除,以及对满队列执行插入,会产生不同行为
- 抛出异常: 使用
add()
,remove()
,element()
方法,若执行错误操作会直接抛出异常 - 返回特殊值: 若使用
offer()
,poll()
,peek()
方法执行错误操作会返回false
或null
,并放弃当前错误操作,不抛出异常. - 一直阻塞: 若使用
put()
,take()
方法执行错误操作,当前线程会一直阻塞直到条件允许才唤醒线程执行操作. - 阻塞一段时间: 若使用
offer()
,poll()
方法并传入时间单位,会将当前方法阻塞一段时间,若阻塞时间结束后仍不满足条件则返回false
或null
,并放弃当前错误操作,不抛出异常.
非阻塞队列ConcurrentLinkedQueue
非阻塞队列使用CAS保证操作的原子性,不会因为加锁而阻塞线程.类似于ConcurrentMap
阻塞队列BlockingQueue
阻塞队列的常用实现类有LinkedBlockingQueue
,ArrayBlockingQueue
,DelayedQueue
,TransferQueue
,SynchronousQueue
. 分别对应于不同的应用场景.
经典阻塞队列LinkedBlockingQueue
和ArrayBlockingQueue
LinkedBlockingQueue
和ArrayBlockingQueue
是阻塞队列的最常用实现类,用来更容易地实现生产者/消费者模式
.
public static void main(String[] args) {
// 阻塞队列,设置其最大容量为10
BlockingQueue<String> queue = new LinkedBlockingQueue<>(10);
// 启动生产者线程
for (int i = 0; i < 5; i++) {
new Thread(() -> {
for (int j = 0; j < 100; j++) {
try {
// 使用put()方法向队列插入元素,队列程满了则当前线程阻塞
queue.put("product" + j);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "producer" + i).start();
}
// 启用消费者线程
for (int i = 0; i < 5; i++) {
new Thread(() -> {
while (true) {
try {
// 使用take()方法从队列取出元素,队列空了则当前线程阻塞
queue.take();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "consumer" + i).start();
}
}
延迟队列DelayedQueue
延迟队列DelayedQueue
中存储的元素必须实现Delay
接口,其中定义了getDelay()
方法;而Delay
接口继承自Comparable
接口,其中定义了compareTo()
方法.各方法作用如下:
getDelay()
: 规定当前元素的延时,Delay
类型的元素必须要等到其延时过期后才能从容器中取出,提前取会取不到.compareTo()
: 规定元素在容器中的排列顺序,按照compareTo()
的结果升序排列.
Delayqueue
可以用来执行定时任务.
public class T {
// 定义任务类,实现Delay接口
static class MyTask implements Delayed {
private long runningTime; // 定义执行时间
public MyTask(long runTime) {
this.runningTime = runTime;
}
// 执行时间减去当前时间 即为延迟时间
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(runningTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
// 规定任务排序规则: 先到期的元素排在前边
@Override
public int compareTo(Delayed o) {
return (int) (this.getDelay(TimeUnit.MILLISECONDS) - o.getDelay(TimeUnit.MILLISECONDS));
}
@Override
public String toString() {
return "MyTask{" + "runningTime=" + runningTime + '}';
}
}
public static void main(String[] args) throws InterruptedException {
long now = System.currentTimeMillis();
// 初始化延迟队列,并添加五个任务,注意任务不是按照顺序加入的
DelayQueue<MyTask> tasks = new DelayQueue<>();
tasks.put(new MyTask(now + 1000));
tasks.put(new MyTask(now + 2000));
tasks.put(new MyTask(now + 1500));
tasks.put(new MyTask(now + 2500));
tasks.put(new MyTask(now + 500));
// 从输出可以看到我们规定的compareTo()排序方法进行排序的
System.out.println(tasks);
// 取出所有任务,因为要一段时间后才能取出,因此需要take()方法阻塞式地取
while (!tasks.isEmpty()) {
System.out.println(tasks.take());
// System.out.println(tasks.remove()); // 使用remove()取元素会发生异常
// System.out.println(tasks.poll()); // 使用poll()会轮询取元素,效率低
}
}
}
程序输出如下,我们发现延迟队列中的元素按照compareTo()
结果升序排列,且5个元素都被阻塞式的取出
[MyTask{runningTime=1563667111945}, MyTask{runningTime=1563667112445}, MyTask{runningTime=1563667112945}, MyTask{runningTime=1563667113945}, MyTask{runningTime=1563667113445}]
MyTask{runningTime=1563667111945}
MyTask{runningTime=1563667112445}
MyTask{runningTime=1563667112945}
MyTask{runningTime=1563667113445}
MyTask{runningTime=1563667113945}
阻塞消费队列TransferQueue
TransferQueue
继承自BlockingQueue
,向其中添加元素的方法除了BlockingQueue
的add()
,offer()
,put()
之外,还有一个transfer()
方法,该方法会使当前线程阻塞直到消费者将该线程消费为止.
transfer()
与put()
的区别: put()
方法会阻塞直到元素成功添加进队列,transfer()
方法会阻塞直到元素成功被消费.
TransferQueue
特有的方法如下:
-
transfer(E)
: 阻塞当前线程直到元素E
成功被消费者消费 -
tryTransfer(E)
: 尝试将当前元素送给消费者线程消费,若没有消费者接受则返回false
且放弃元素E
,不将其放入容器中. -
tryTransfer(E,long,TimeUnit)
: 阻塞一段时间等待消费者线程消费,超时则返回false
且放弃元素E
,不将其放入容器中. -
hasWaitingConsumer()
: 指示是否有阻塞在当前容器上的消费者线程 -
getWaitingConsumerCount()
: 返回阻塞在当前容器上的消费者线程的个数
public static void main(String[] args) {
// 创建链表实现的TransferQueue
TransferQueue transferQueue = new LinkedTransferQueue();
// 启动消费者线程,睡五秒后再来消费
new Thread(() -> {
try {
TimeUnit.SECONDS.sleep(5);
System.out.println(transferQueue.take());
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
// 启动生产者线程,使用transfer()方法添加元素 会阻塞等待元素被消费
new Thread(() -> {
try {
// transferQueue.put("product"); // 使用put()方法会阻塞等待元素成功加进容器
transferQueue.transfer("product"); // 使用transfer()方法会阻塞等待元素成功被消费
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
运行程序,我们发现生产者线程会一直阻塞直到五秒后"product2"
被消费者线程消费.
零容量的阻塞消费队列SynchronousQueue
SynchronousQueue
是一种特殊的TransferQueue
,特殊之处在于其容量为0. 因此对其调用add()
,offer()
方法都会使程序发生错误(抛出异常或阻塞线程).只能对其调用put()
方法,其内部调用transfer()
方法,将元素直接交给消费者而不存储在容器中.
public static void main(String[] args) {
BlockingQueue synchronousQueue = new SynchronousQueue();
// 启动消费者线程,睡五秒后再来消费
new Thread(() -> {
try {
TimeUnit.SECONDS.sleep(5);
System.out.println(synchronousQueue.take());
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
System.out.println(synchronousQueue.size()); // 输出0
// 启动生产者线程,使用put()方法添加元素,其内部调用transfer()方法,会阻塞等待元素被消费
new Thread(() -> {
try {
// synchronousQueue.add("product"); // SynchronousQueue容量为0,调用add()方法会报错
synchronousQueue.put("product"); // put()方法内部调用transfer()方法会阻塞等待元素成功被消费
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
该程序的输出行为与上一程序类似,生产者线程调用put()
方法后阻塞五秒直到消费者线程消费该元素.
SynchronousQueue
应用场景: 网游的玩家匹配: 若一个用户登录,相当于给服务器的消息队列发送一个take()
请求;若一个用户准备成功,相当于给服务器的消息队列发送一个put()
请求.因此若玩家登陆但未准备好 或 只有一个玩家准备好 时游戏线程都会阻塞,直到两个人都准备好了,游戏线程才会被唤醒,游戏继续.