[Java并发] 2. 并发容器
文章目录
一、线程安全的单例模式
单例模式就是说系统中对于某类只能有一个实例对象,不能出现第二个!面试中常常会被问到或者手写一个线程安全的单例模式,主要考察多线程情况下的线程安全问题。
1. 不使用同步锁
//直接加载。缺点:在该类加载的时候就会直接new一个静态对象出来
//当系统中这样的类较多时,就使得启动速度变慢。
public class SingletonDirectlyNew {
//直接初始化一个对象
private static SingletonDirectlyNew single = new SingletonDirectlyNew();
//构造方法私有,保证其他类对象不能直接new一个该对象的实例
private SingletonDirectlyNew() {
}
public static SingletonDirectlyNew getSingle(){ //该类唯一的一个public方法
return single;
}
}
上述代码中的一个缺点是该类加载的时候就会直接new一个静态对象出来,当系统中这样的类较多时,会使得启动速度变慢 。现在流行的设计都是讲延迟加载,我们可以在第一次使用的时候才初始化第一个该类对象。所以这种适合在小系统。
2. 延迟/懒加载 使用同步方法
/*锁住了一个方法,锁的力度有点大*/
public class SingletonSynMethod {
private static SingletonSynMethod instance;
private SingletonSynMethod() { //构造方法私有
}
public static synchronized SingletonSynMethod getInstance() { //对获取实例的方法进行同步
if (instance == null) {
instance = new SingletonSynMethod();
}
return instance;
}
}
上述代码中的一次锁住了一个方法, 这个粒度有点大 ,改进就是只锁住其中的new语句就OK。就是所谓的“双重锁”机制。
3. 延迟/懒加载 使用双重同步锁
public class SingletonDoubleSyn {
private static SingletonDoubleSyn instance;
private SingletonDoubleSyn () { //构造方法私有
}
public static SingletonDoubleSyn getInstance() {
if (instance == null) {
synchronized (SingletonDoubleSyn.class) { //锁定new语句
if (instance == null) {
instance = new SingletonDoubleSyn();
}
}
}
return instance;
}
}
4. 延迟/懒加载 使用静态内部类
/*不用加锁 也能实现懒加载*/
public class SingletonInner {
private SingletonInner() {
}
private static class Inner { //静态内部类
private static SingletonInner s = new SingletonInner();
}
private static SingletonInner getSingle() {
return Inner.s;
}
}
既不用加锁,也能实现懒加载,使用内部类,只加载一次。
二、售票问题
写一个程序模拟:有n张火车票,每张票都有一编号,同时有10个窗口在对外售票。
1. List
直接使用List存储票会发生重复销售和超量销售,因为List的所有操作都是非原子性的。
/*下面程序模拟卖票可能会出现两个问题:①票卖重了 ②还剩最后一张票时,好几个线程同时抢,出现-1张票
*出现上面两个问题主要是因为:①remove()方法不是原子性的 ②判断+操作不是原子性的*/
import java.util.ArrayList;
import java.util.List;
public class TicketSeller1 {
static List<String> tickets = new ArrayList<String>();
static {
for (int i=0; i<10000; i++) tickets.add("票编号:" + i);//共一万张票
}
public static void main(String[] args) {
for (int i=0; i<10; i++) {//共10个线程卖票
new Thread(() -> {
while(tickets.size() > 0) {//判断余票
System.out.println("销售了--" + tickets.remove(0));//操作减票
}
}).start();
}
}
}
2. Vector
Vector本身就是同步容器,所有的方法都加锁,所有操作均为原子性。但仍会出现问题,因为判断与操作是分离的,形成的复合操作不能保证原子性。
/*本程序虽然用了Vector作为容器,Vector中的方法都是原子性的,但是在判断size和减票的中间还是可能被打断的,即被减到-1张*/
import java.util.Vector;
import java.util.concurrent.TimeUnit;
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
使用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. 并发队列
使用并发队列ConcurrentLinkedQueue
存储元素,其底层使用CAS
实现而非加锁实现的,其效率较高。
并发队列ConcurrentLinkedQueue
的poll()
方法会尝试从队列头中取出一个元素,若获取不到,则返回null
,对其返回值做判断可以实现先取票后判断,可以避免加锁。
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
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();//poll方法是同步的
if (t == null) break;
System.out.println("销售了:" + t);
}
}).start();
}
}
}
poll()
是原子性的,但是后面的if
与println
会打破原子性,但是这里没有问题,因为poll()
后我们没有对队列进行修改操作(比如前面的remove
),不会出现重复和超额销售。
三、并发容器
1. Map
主要的非并发容器有HashMap
、TreeMap
、LinkedHashMap
。
主要的并发容器有HashTable
、SynchronizedMap
、ConcurrentMap
。
HashTable
和SynchronizedMap
的效率较低,其同步的实现原理类似,都是给容器的所有方法都加锁。其中SynchronizedMap
使用装饰器模式,调用其构造方法并传入一个Map
实现类,返回一个同步的Map
容器。ConcurrentMap
的效率较高,有两个实现类ConcurrentHashMap
: 使用哈希表实现,key是无序的。ConcurrentSkipListMap
: 使用跳表实现,key是有序的。
关于跳表的实现:跳表(SkipList)及ConcurrentSkipListMap源码解析,ConcurrentSkipListMap和Treemap插入时效率比较低,需要排好顺序。但是查的时候效率很高。
ConcurrentMap同步的实现原理在JDK1.8前后不同,
- 在JDK1.8以前,其实现同步使用的是分段锁,将整个容器分为16段(Segment),每次操作只锁住操作的那一段,是一种细粒度更高的锁.
- 在JDK1.8及以后,其实现同步用的是
Node+CAS
,关于CAS的实现,可以看这篇文章Java:CAS(乐观锁)
import java.util.Arrays;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.CountDownLatch;
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<>(); // 使用跳表,高并发且有序
// Map<String,String> map = new TreeMap<>(); //插入时要排序,所以插入可能会比较慢
Random r = new Random();
Thread[] ths = new Thread[100];
CountDownLatch latch = new CountDownLatch(ths.length); // 启动了一个门闩,每有一个线程退出,门闩就减1,直到所有线程结束,门闩打开,主线程结束
long start = System.currentTimeMillis();
// 创建100个线程,锁100个门闩,每个线程都往map中加10000个随机字符串,每个完成后打开一个门闩。当所有线程执行完毕,记录所花费的时间。
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());
}
Map和Set本质上是一样的,只是Set只有key,没有value,所以下面谈到的Map可以替换成Set。
- 在不加锁的情况下,可以用:HashMap、TreeMap、LinkedHashMap。想加锁可以用Hashtable(用的非常少)。
- 在并发量不是很高的情况下,可以用Collections.synchronizedXxx()方法,在该方法中传一个不加锁的容器(如Map),它返回一个加了锁的容器(容器中的所有方法加锁)
- 在并发性比较高的情况下,用ConcurrentHashMap ,如果并发性高且要排序的情况下,用ConcurrentSkipListMap。
2. 写时复制CopyOnWrite
CopyOnWriteArrayList
位于java.util.concurrent
包下,它实现同步的方式是: 当发生写操作(添加,删除,修改)时,就会复制原有容器然后对新复制出的容器进行写操作,操作完成后将引用指向新的容器。其写效率非常低,读效率非常高。
- 优点: 读写分离,使得读操作不需要加锁,效率极高。
- 缺点: 写操作效率极低
- 应用场合: 应用在读多写少的情况,如事件监听器
3. 队列Queue
低并发队列有Vector
和SynchronizedList
,其中vector
类似HashTable
;SynchronizedList
类似SynchronizedMap
使用装饰器模式,其构造函数接受一个List
实现类并返回同步List
,在java.util.Collections
包下。它们实现同步的原理都是将所有方法用同步代码块包裹起来.
高并发队列分为阻塞队列BlockingQueue
和非阻塞队列ConcurrentLinkedQueue
,其中阻塞队列的常用实现类有LinkedBlockingQueue
、ArrayBlockingQueue
、DelayedQueue
、TransferQueue
、SynchronousQueue
;非阻塞队列使用CAS
保证操作的原子性,不会因为加锁而阻塞线程.类似于ConcurrentMap
。
3.1 高并发队列的方法
方法 | 抛出异常 | 返回特殊值 | 一直阻塞 (非阻塞队列不可用) | 阻塞一段时间 (非阻塞队列不可用) |
---|---|---|---|---|
插入元素 | 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
,并放弃当前错误操作,不抛出异常。
3.2 经典阻塞队列LinkedBlockingQueue和ArrayBlockingQueue
LinkedBlockingQueue
和ArrayBlockingQueue
是阻塞队列的最常用实现类,用来更容易地实现生产者/消费者模式.
import java.util.Random;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
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();
}
}
3.3 延迟队列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}
3.4 阻塞消费队列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"被消费者线程消费.
适用场景:消费者先启动,生产者生产一个东西的时候,不扔在队列里,而是直接去找有没有消费者,有的话直接扔给消费者,若没有消费者线程,调用transfer()方法就会阻塞,调用add()、offer()、put()方法不会阻塞。TransferQueue适用于更高的并发情况
3.5 零容量的阻塞消费队列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()
请求.因此若玩家登陆但未准备好 或只有一个玩家准备好时游戏线程都会阻塞,直到两个人都准备好了,游戏线程才会被唤醒,游戏继续.
整理自马士兵并发编程视频:地址