主要介绍的是JDK自带数据容器在多线程环境下的知识,主要围绕三大数据容器List、Set和Map展开,为避免有重复造轮子的嫌疑,基础知识将不再介绍(类似于Set的底层是Map,HashMap初始容量为什么是16,扩容因子为什么是0.75,什么时候变成红黑树,什么时候变为二叉树等等,将不会介绍),仅仅只说在多线程的环境中,如何保证我们操作的数据容器可靠!安全!高可用!
ConcurrentModificationException
如下图:三种常用数据集合类型在多线程下的并发写异常。
code:
output
最熟悉的类型在多线程下很容易发生java.util.ConcurrentModificationException 即Java并发写异常。
看看之前
平时在操作数据库的时候,伴随着CRUD的语句通常伴随着数据库事务(Transactional),并且通常产生在删除、更新、增加的语句中,而这类操作都有一个很明显的特征就是写操作。而在代码运行过程中的数据容器,就只会有两种操作,读和写。
想想现在
对于读操作,不管是数据库,还是数据容器,或者是缓存、文件系统等等资源,都不会产生安全问题,所以在企业构建自己数据中台服务的时候才会围绕着读写分离的核心概念去设计。在云计算、云原生的时代,将读操作放在云上,写操作交给Java后台已经成为主流,并且技术也已经很成熟。
常用数据容器Collection接口
Map接口
数据容器并发读取
List
接下来会拿List这种最常用的数据容器作为案例讲解,相同思路下的另外两个常用容数组器Set、Map将会给出代码截图,不再做文字性描述。
1.1、Vector
回想Java的JUC体系,最简单粗暴的保证多线程安全的办法就是:在有可能出现多线程安全问题的代码方法上加入synchronized关键字,声明这是一段加锁的代码块或者标注在方法上,就能保证只能这段代码只能由一个线程去处理,其他线程要处理只能等待。如下是jdk中ArrayList和Vector的add()方法截图对比。
所以,使用Vector作为List的实现是能够保证线程安全的,这是List的第一种保证线程安全的办法,但Vector由于使用了synchronized关键字,这种锁机制会使得并发性下降,一般在实际中不使用这种办法。
1.2、Collections工具类
其实在工具类的底层,还是使用了同步代码块的方式,比起Vector稍微轻了一点点。源码截图如下:
1.3、写时复制
使用JUC下的CopyOnWriteArrayList
CopyOnWrite,简单翻译一下:建立副本,在,写的时候。其实就是建立副本的策略。还记得当时C语言老师布置的作业,对txt文本数据的增删改查,翻遍课本都没发现C语言没有更改的函数,最后是先把文件拷贝一份,等重新写上新数据之后,仔把原来的文件删了,再把临时文件名改成删除掉文件的名称。这一手操作在刚开始学编程的时候是不是都弄过。原理就是这么简单。
写时复制:往容器添加元素的时候,不直接往当前容器object[]添加,而是先将当前容器object[]进copy,复制出一个新的object[] newElements 然后向新容器object[] newElements里面添加元素,添加元素后,再将原容器的引用指向新的容器 setArray(newElements),这样的好处是可以copyOnWrite容器进行并发的读,而不需要加锁,因为当前容器其实不会添加任何数据。所以copyOnwrite容器也是一种读写分离的思想,读和写不同的容器。代码案例如下:
后两者对比
其实原理清楚了之后,结论也很清晰:借助Collections工具类的List读写并发性均衡,而运用写时复制的CopyOnWriteArrayList则能很大程度的保证读的并发量,而写的时候会复制数据,性能有所下降。所以在使用的时候根据具体业务来选择即可。
Set
两种:Collections.synchronizedSet(new HashSet<>()) 和 JUC包下的CopyOnWriteArraySet()
Map
两种:Collections.synchronizedMap(new HashMap<>()) 和 ConcurrentHashMap
需要注意的是:Map并不是Collection下的接口,但是却能借助Conllections工具类生成一个线程安全的Map。
BlockingQueue 阻塞队列
Queue对象也是实现了Collection接口,在多线程编程中实现了Queue接口的对象BlockingQueue也经常用到,即阻塞队列。其实在消息中间件、多线程下的生产者消费者设计模式、Netty、观察者模式等技术的底层使用的就是阻塞队列。文末将简单介绍一下BlockingQueue。
一个一个阻塞队列在数据结构中所起的作用大致是:当阻塞队列是空的时候,从队列中获取元素的操作将会被阻塞,而当队列是慢的时候,往队列里面添加元素的操作会被阻塞。所谓阻塞,在某些情况下回挂起线程(即阻塞),一旦条件满足,被挂起的线程又会自动唤醒。使用阻塞队列的好处是不需要关心什么时候需要阻塞线程,什么时候需要唤醒线程,这些事情BlockingQueue都帮助解决了。
如上图,7种常用的阻塞队列简单介绍如下:
1、ArrayBlockingQueue : 由数组结构组成的有界阻塞队列。
2、LinkedBlockingQueue : 由链表构成的有界阻塞队列(但大小默认值为Integer.MAX_VALUE 这个数字特别大21亿,基本无界) 慎用。
3、PriorityBlockingQueue : 支持优先级排序的无界阻塞队列。
4、DealyQueue : 使用优先级队列实现的延迟无界阻塞队列。
5、SynchronouseQueue : 不存储元素的阻塞队列,也即单个元素的队列。
有且仅有一个
6、LinkedTransferQueue : 由链表结构组成的无界阻塞队列。
7、LinkedBlockingDeque : 由链表结构组成的双向阻塞队列。
阻塞队列的常用方法
文末将使用阻塞队列写生产者消费者模式:
package com.learn.juc;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @author destiny
* @create 2019-12-02 21:50
*/
class MyResource{
private volatile boolean FLAG = true;
private AtomicInteger atomicInteger = new AtomicInteger(0);
//若是在sping中,则才去构造注入,传递接口
BlockingQueue<String> blockQueue = null;
public MyResource(BlockingQueue<String> blockQueue) {
this.blockQueue = blockQueue;
System.out.println(blockQueue.getClass().getName());
}
//生产
public void myProd() throws Exception{
String data = null;
boolean retValue = true;
while (FLAG){
data = atomicInteger.incrementAndGet()+"";
blockQueue.offer(data,2L,TimeUnit.SECONDS);
if(retValue){
System.out.println(Thread.currentThread().getName()+"\t 插入队列"+data + "成功");
}else {
System.out.println(Thread.currentThread().getName()+"\t 插入队列"+data + "失败");
}
TimeUnit.SECONDS.sleep(1);
}
System.out.println(Thread.currentThread().getName() +"\t FLAG = false 生产动作结束");
}
//消费
public void myConsumer() throws Exception{
String result = null;
while (FLAG){
result = blockQueue.poll(2L,TimeUnit.SECONDS);
if(null == result || result.equalsIgnoreCase("")){
FLAG = false;
System.out.println(Thread.currentThread().getName()+"\t 超过2秒钟没有可消费的数据退出");
return;
}
System.out.println(Thread.currentThread().getName()+"\t 消费队列"+result+"成功");
}
}
public void stop() throws Exception{
this.FLAG = false;
}
}
public class ProdConsumerBlockQueueDemo {
public static void main(String[] args) throws Exception {
MyResource myResource = new MyResource(new ArrayBlockingQueue<>(10));
new Thread(()->{
System.out.println(Thread.currentThread().getName()+"\t 生产线程启动");
try {
myResource.myProd();
} catch (Exception e) {
e.printStackTrace();
}
},"Provider").start();
new Thread(()->{
System.out.println(Thread.currentThread().getName()+"\t 消费线程启动");
try {
myResource.myConsumer();
} catch (Exception e) {
e.printStackTrace();
}
},"Consumer").start();
try { TimeUnit.SECONDS.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); }
myResource.stop();
}
}
代码运行结果截图:
下一章,多线程(四)将继续JUC包下的Java锁相关知识。公平锁、非公平锁、可重入锁、手写自旋锁、循环屏障、倒计时锁、信号灯等。