文章目录
1.同步容器
为了解决线程安全问题 java在Jdk1.5之前提供了同步容器,但是性能很差。
在Java中同步容器主要包括 2 类:
- Vector、 Stack、 HashTable
Vector
实现了List
接口,Vector
实际上就是一个数组,和ArrayList
类似,但是Vector中的方法都是synchronized
方法,即进行了同步措施。
Stack
也是一个同步容器,它的方法也用synchronized
进行了同步,它实际上是继承于Vector
类。
HashTable
实现了Map
接口,它和HashMap
很相似,但是HashTable
进行了同步处理,而HashMap
没有。HashTable 容器使用 synchronized 来保证线程安全,但在线程竞争激烈的情况下 HashTable 的效率非常低下。因为当一个线程访问 HashTable 的同步方法,其他线程也访问 HashTable 的同步方法时,会进入阻塞或轮询状态。如线程 1 使用 put 进行元素添加,线程 2 不但不能使用 put 方法添加元素,也不能使用 get方法来获取元素,所以竞争越激烈效率越低。 - Collections类中提供的静态工厂方法创建的类
在 Collections 这个类中提供了一套完备的包装类,分别把 ArrayList、HashSet 和 HashMap 包装成了线程安全的 List、Set 和 Map。List list = Collections.synchronizedList(new ArrayList()); Set set = Collections.synchronizedSet(new HashSet()); Map map = Collections.synchronizedMap(new HashMap());
同步容器的同步原理就是在方法上用 synchronized 修饰。那么,这些方法每次只允许一个线程调用执行。
由于被 synchronized 修饰的方法,每次只允许一个线程执行,其他试图访问这个方法的线程只能等待。显然,这种方式比没有使用 synchronized 的容器性能要差。
同步容器未必真的安全,在做迭代、跳转、条件运算等复合操作时,仍然需要加锁来保护。
2.并发容器
结合上面同步容器的各种缺点, Java 1.5 版本之后提供的并发容器,在性能方面则做了很多优化,并且容器的类型也更加丰富了。
并发容器主要分为四类 List
、Map
、Set
和 Queue
,一共14个
List
CopyOnWriteArrayList
:并发版ArrayList
Set
CopyOnWriteArraySet
:并发Set
ConcurrentSkipListSet
:基于跳表的并发Set
Map
ConcurrentSkipListMap
:基于跳表的并发Map
ConcurrentHashMap
:并发版HashMap
Queue
ConcurrentLinkedQueue
:并发队列(基于链表)
ConcurrentLinkedDeque
:并发队列(基于双向链表)
ArrayBlockingQueue
:由数组结构组成的有界阻塞队列
LinkedBlockingQueue
:由链表结构组成的有界阻塞队列
PriorityBlockingQueue
:支持优先级排序的无界阻塞队列
DelayQueue
:支持延时获取元素的无界阻塞队列
SynchronousQueue
:不存储元素的阻塞队列
LinkedTransferQueue
:由链表结构组成的无界阻塞队列
LinkedBlockingDeque
:由链表结构组成的双向阻塞队列
3.CopyOnWriteArrayList 和 CopyOnWriteArraySet
CopyOnWrite容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。如果读的时候有多个线程正在向CopyOnWriteArrayList添加数据,读还是会读到旧的数据,因为写的时候不会锁住旧的CopyOnWriteArrayList。
CopyOnWrite并发容器用于对于绝大部分访问都是读,且只是偶尔写的并发场景。比如白名单,黑名单,商品类目的访问和更新场景,假如我们有一个搜索网站,用户在这个网站的搜索框中,输入关键字搜索内容,但是某些关键字不允许被搜索。这些不能被搜索的关键字会被放在一个黑名单当中,黑名单每天晚上更新一次。当用户搜索时,会检查当前关键字在不在黑名单当中,如果在,则提示不能搜索。
写时复制容器注意的一些问题:
- 减少扩容开销。根据实际需要,初始化 CopyOnWriteMap 的大小,避免写时 CopyOnWriteMap 扩容的开销。
- 使用批量添加。因为每次添加,容器每次都会进行复制,所以减少添加次数,可以减少容器的复制次数。
- 写时复制容器的问题性能问题。每次修改都创建一个新数组,然后复制所有内容,如果数组比较大,修改操作又比较频繁,可以想象,性能是很低的,而且内存开销会很大。
- 数据一致性问题。CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,不要使用 CopyOnWrite 容器。
4.ConcurrentSkipListSet和ConcurrentSkipListMap
ConcurrentSkipListSet和ConcurrentSkipListMap可以看做是TreeSet和TreeMap的并发版本。TreeMap和TreeSet使用红黑树按照key的顺序(自然顺序、自定义顺序)来使得键值对有序存储,但是只能在单线程下安全使用;多线程下想要使键值对按照key的顺序来存储,则需要使用ConcurrentSkipListMap和ConcurrentSkipListSet,分别用以代替TreeMap和TreeSet,存入的数据按key排序。在实现上,ConcurrentSkipListSet本质上就是ConcurrentSkipListMap。
了解什么是 SkipList
首先我们来了解一下二分查找
和 AVL 树查找
二分查找要求元素可以随机访问,所以决定了需要把元素存储在连续内存。这样查找确实很快,但是插入和删除元素的时候,为了保证元素的有序性,就需要大量的移动元素了。
如果需要的是一个能够进行二分查找,又能快速添加和删除元素的数据结构,首先就是二叉查找树,二叉查找树在最坏情况下可能变成一个链表。于是,就出现了平衡二叉树,根据平衡算法的不同有 AVL 树,B-Tree,B+Tree,红黑树等,但是 AVL 树实现起来比较复杂,平衡操作较难理解,这时候就可以用SkipList 跳跃表结构。
什么是跳表
传统意义的单链表是一个线性结构,向有序的链表中插入一个节点需要 O(n)的时间,查找操作需要 O(n)的时间。如果我们使用上图所示的跳跃表,就可以减少查找所需时间为O(n/2),因为我们可以先通过每个节点的最上面的指针先进行查找,这样子就能跳过一半的节点。
比如我们想查找 50,首先和 20 比较,大于 20 之后,在和 40 进行比较,然后在和 70 进行比较,发现 70 大于 50,说明查找的点在 40 和 50 之间,从这个过程中,我们可以看出,查找的时候跳过了 30。
跳跃表其实也是一种通过“空间来换取时间”的一个算法,令链表的每个结点不仅记录 next 结点位置,还可以按照 level 层级分别记录后继第 level 个结点。此法使用的就是“ 先大步查找确定范围,再逐渐缩小迫近”的思想进行的查找。跳跃表在算法效率上很接近红黑树。
跳跃表又被称为概率,或者说是随机化的数据结构,目前开源软件 Redis
和
lucence
都有用到它。
都是线程安全的 Map 实现,ConcurrentHashMap 的性能和存储空间要优于ConcurrentSkipListMap,但是 ConcurrentSkipListMap 有一个功能: 它会按照键的顺序进行排序。
5.ConcurrentHashMap
ConcurrentHashMap是Java中的一个线程安全且高效的HashMap实现。平时涉及高并发如果要用map结构,那第一时间想到的就是它。除了 Map 系列应该有的线程安全的 get,put 等方法外,ConcurrentHashMap还提供了一个在并发下比较有用的方法 putIfAbsent,如果传入 key 对应的 value已经存在,就返回存在的 value,不进行替换。如果不存在,就添加 key 和 value,返回 null。
HashMap、Hashtable、ConccurentHashMap三者的区别
HashMap线程不安全,数组+链表+红黑树
Hashtable线程安全,锁住整个对象,数组+链表
ConccurentHashMap线程安全,CAS+同步锁,数组+链表+红黑树
HashMap的key,value均可为null,其他两个不行。
HashTable 是使用 synchronize 关键字加锁的原理(就是对对象加锁);
而针对 ConcurrentHashMap,在 JDK 1.7 中采用分段锁的方式;JDK 1.8 中直接采用了 CAS(无锁算法)+ synchronized,也采用分段锁的方式并大大缩小了锁的粒度。
为什么 ConcurrentHashMap 比 HashTable 效率要高?
HashTable 使用一把锁(锁住整个链表结构)处理并发问题,多个线程竞争一把锁,容易阻塞;ConcurrentHashMapJDK 1.7 中使用分段锁(ReentrantLock + Segment + HashEntry),相当于把一个 HashMap 分成多个段,每段分配一把锁,这样支持多线程访问。锁粒度:基于 Segment,包含多个 HashEntry。JDK 1.8 中使用 CAS + synchronized + Node + 红黑树。锁粒度:Node(首结点)(实现 Map.Entry<K,V>)。锁粒度降低了。
ConcurrentHashMap 在 JDK 1.8 中,为什么要使用内置锁 synchronized来代替重入锁 ReentrantLock ?
JVM 开发团队在 1.8 中对 synchronized 做了大量性能上的优化,而且基于 JVM 的 synchronized 优化空间更大,更加自然。
在大量的数据操作下,对于 JVM 的内存压力,基于 API 的ReentrantLock 会开销更多的内存。
ConcurrentHashMap 简单介绍
①重要的常量:
private transient volatile int sizeCtl;
当为负数时,-1 表示正在初始化,-N 表示 N - 1 个线程正在进行扩容;
当为 0 时,表示 table 还没有初始化;
当为其他正数时,表示初始化或者下一次进行扩容的大小。
②数据结构:
Node 是存储结构的基本单元,继承 HashMap 中的 Entry,用于存储数据;
TreeNode 继承 Node,但是数据结构换成了二叉树结构,是红黑树的存储结构,用于红黑树中存储数据;
TreeBin 是封装 TreeNode 的容器,提供转换红黑树的一些条件和锁的控制。
③存储对象时(put() 方法):
1.如果没有初始化,就调用 initTable() 方法来进行初始化;
2.如果没有 hash 冲突就直接 CAS 无锁插入;
3.如果需要扩容,就先进行扩容;
4.如果存在 hash 冲突,就加锁来保证线程安全,两种情况:一种是链表形
式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入;
5.如果该链表的数量大于阀值 8,就要先转换成红黑树的结构,break 再一
次进入循环
6.如果添加成功就调用 addCount() 方法统计 size,并且检查是否需要扩容。
④扩容方法 transfer():默认容量为 16,扩容时,容量变为原来的两倍。
helpTransfer():调用多个工作线程一起帮助进行扩容,这样的效率就会更高。
⑤获取对象时(get()方法):
1.计算 hash 值,定位到该 table 索引位置,如果是首结点符合就返回;
2.如果遇到扩容时,会调用标记正在扩容结点 ForwardingNode.find()方法,
查找该结点,匹配就返回;
3.以上都不符合的话,就往下遍历结点,匹配就返回,否则最后就返回 null。
ConcurrentHashMap 的并发度是什么?
A:1.7 中程序运行时能够同时更新 ConccurentHashMap 且不产生锁竞争的最大线程数。默认为 16,且可以在构造函数中设置。当用户设置并发度时,ConcurrentHashMap 会使用大于等于该值的最小 2 幂指数作为实际并发度(假如用户设置并发度为 17,实际并发度则为 32)。
1.8 中并发度则无太大的实际意义了,主要用处就是当设置的初始容量小于并发度,将初始容量提升至并发度大小。
6.ConcurrentLinkedQueue和ConcurrentLinkedDeque
ConcurrentLinkedQueue 是单向链表结构的无界并发队列。元素操作按照 FIFO (first-in-first-out 先入先出) 的顺序。适合“单生产,多消费”的场景。内存一致性遵循对ConcurrentLinkedQueue的插入操作先行发生于(happen-before)访问或移除操作。
ConcurrentLinkedDeque 是双向链表结构的无界并发队列。与 ConcurrentLinkedQueue 的区别是该阻塞队列同时支持FIFO和FILO两种操作方式,即可以从队列的头和尾同时操作(插入/删除)。适合“多生产,多消费”的场景。内存一致性遵循对ConcurrentLinkedDeque 的插入操作先行发生于(happen-before)访问或移除操作。
7.阻塞队列 BlockingQueue
队列是一种特殊的线性表,特殊之处在于它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作,和栈一样,队列是一种操作受限制的线性表。进行插入操作的端称为队尾,进行删除操作的端称为队头。在队列中插入一个队列元素称为入队,从队列中删除一个队列元素称为出队。因为队列只允许在一端插入,在另一端删除,所以只有最早进入队列的元素才能最先从队列中删除,故队列又称为先进先出(FIFO—first in first out)线性表。
什么是阻塞队列
支持阻塞的插入方法:当队列满时,队列会阻塞插入元素的线程,直到队列不满。
支持阻塞的移除方法:在队列为空时,获取元素的线程会等待队列变为非空。
阻塞队列常用于生产者和消费者的场景,生产者是向队列里添加元素的线程,消费者是从队列里取元素的线程。阻塞队列就是生产者用来存放元素、消费者用来获取元素的容器。
什么是有界无界
有限队列就是长度有限,满了以后生产者会阻塞,无界队列就是里面能放
无数的东西而不会因为队列长度限制被阻塞,当然空间限制来源于系统资源的限
制,如果处理不及时,导致队列越来越大越来越大,超出一定的限制致使内存超
限,操作系统或者 JVM 帮你解决烦恼,直接把你 OOM kill 省事了。
无界也会阻塞,为何?因为阻塞不仅仅体现在生产者放入元素时会阻塞,
消费者拿取元素时,如果没有元素,同样也会阻塞。
常用阻塞队列
1.ArrayBlockingQueue
是一个用数组实现的有界阻塞队列。此队列按照先进先出(FIFO)的原则对元素进行排序。默认情况下不保证线程公平的访问队列,所谓公平访问队列是指阻塞的线程,可以按照阻塞的先后顺序访问队列,即先阻塞线程先访问队列。非公平性是对先等待的线程是非公平的,当队列可用时,阻塞的线程都可以争夺访问队列的资格,有可能先阻塞的线程最后才访问队列。初始化时有参数可以设置
2.LinkedBlockingQueue
是一个用链表实现的有界阻塞队列。此队列的默认和最大长度为Integer.MAX_VALUE。此队列按照先进先出的原则对元素进行排序。
Array 实现和 Linked 实现的区别
- 队列中锁的实现不同
ArrayBlockingQueue 实现的队列中的锁是没有分离的,即生产和消费用的是同一个锁;
LinkedBlockingQueue 实现的队列中的锁是分离的,即生产用的是 putLock,消费是 takeLock - 在生产或消费时操作不同
ArrayBlockingQueue 实现的队列中在生产和消费的时候,是直接将枚举对象插入或移除的;
LinkedBlockingQueue 实现的队列中在生产和消费的时候,需要把枚举对象转换为 Node进行插入或移除,会影响性能 - 队列大小初始化方式不同
ArrayBlockingQueue 实现的队列中必须指定队列的大小;
LinkedBlockingQueue 实现的队列中可以不指定队列的大小,但是默认是Integer.MAX_VALUE
3.PriorityBlockingQueue
PriorityBlockingQueue 是一个支持优先级的无界阻塞队列。默认情况下元素采取自然顺序升序排列。也可以自定义类实现 compareTo()方法来指定元素排序规则,或者初始化 PriorityBlockingQueue 时,指定构造参数 Comparator 来对元素进行排序。需要注意的是不能保证同优先级元素的顺序。
4.DelayQueue
是一个支持延时获取元素的无界阻塞队列。队列使用 PriorityQueue 来实现。队列中的元素必须实现 Delayed 接口,在创建元素时可以指定多久才能从队列中获取当前元素。只有在延迟期满时才能从队列中提取元素。
DelayQueue 非常有用,可以将 DelayQueue 运用在以下应用场景。
缓存系统的设计:可以用 DelayQueue 保存缓存元素的有效期,使用一个线程循环查询 DelayQueue,一旦能从 DelayQueue 中获取元素时,表示缓存有效期到了。
5.SynchronousQueue
是一个不存储元素的阻塞队列。每一个 put 操作必须等待一个 take 操作,否则不能继续添加元素。SynchronousQueue 可以看成是一个传球手,负责把生产者线程处理的数据直接传递给消费者线程。队列本身并不存储任何元素,非常适合传递性场景。SynchronousQueue 的吞吐量高于 LinkedBlockingQueue 和ArrayBlockingQueue。
6.LinkedTransferQueue
多了 tryTransfer 和 transfer 方法,
(1)transfer 方法
如果当前有消费者正在等待接收元素(消费者使用 take()方法或带时间限制的 poll()方法时),transfer 方法可以把生产者传入的元素立刻 transfer(传输)给消费者。如果没有消费者在等待接收元素,transfer 方法会将元素存放在队列的tail 节点,并等到该元素被消费者消费了才返回。
(2)tryTransfer 方法
tryTransfer 方法是用来试探生产者传入的元素是否能直接传给消费者。如果没有消费者等待接收元素,则返回 false。和 transfer 方法的区别是 tryTransfer 方法无论消费者是否接收,方法立即返回,而 transfer 方法是必须等到消费者消费了才返回。
7.LinkedBlockingDeque
LinkedBlockingDeque 是一个由链表结构组成的双向阻塞队列。所谓双向队列指的是可以从队列的两端插入和移出元素。双向队列因为多了一个操作队列的入口,在多线程同时入队时,也就减少了一半的竞争。多了 addFirst、addLast、offerFirst、offerLast、peekFirst 和 peekLast 等方法,以 First 单词结尾的方法,表示插入、获取(peek)或移除双端队列的第一个元素。以 Last 单词结尾的方法,表示插入、获取或移除双端队列的最后一个元素。另外,插入方法 add 等同于 addLast,移除方法 remove 等效于 removeFirst。但是
take 方法却等同于 takeFirst,不知道是不是 JDK 的 bug,使用时还是用带有 First和 Last 后缀的方法更清楚。在初始化 LinkedBlockingDeque 时可以设置容量防止其过度膨胀。另外,双向阻塞队列可以运用在“工作窃取”模式中。
了解阻塞队列的实现原理
使用了等待通知模式实现。所谓通知模式,就是当生产者往满的队列里添加元素时会阻塞住生产者,当消费者消费了一个队列中的元素后,会通知生产者当前队列可用。通过查看JDK 源码发现 ArrayBlockingQueue 使用了 Condition 来实现。