Java同步容器类是通过synchronized(内置锁)来实现同步的容器,比如Vector、 HashTable以及SynchronizedList等容器。 线程安全的同步容器类主要有: Vector、 Stack、 HashTable等。
Collections提供的同步包装方法
Java提供一组包装方法,将一个普通的基础容器包装成一个线程安全的同步容器。例如通过Collections.synchronizedSortedSet 包装方法能将一个普通的SortedSet容器包装成一个线程安全的SortedSet同步容器。除了提供了对SortedSet进行同步包装的方法之外, java.util.Collections还提供了一系列的对其他的基础容器进行同步包装的方法,如synchronizedList()方法将基础List包装成线程安全的列表容器, synchronizedMap()方法将基础Map容器包装成线程安全的容器, synchronizedCollection()方法将基础Collection容器包装成线程安全的Collection容器。
//创建一下基础的有序集合
SortedSet<String> elementSet = new TreeSet<String>();
//增加元素
elementSet.add("element 1");
elementSet.add("element 2");
//将elementSet包装成一个同步容器
SortedSet sorset = Collections.synchronizedSortedSet(elementSet);
同步容器面临的问题
可以通过查看Vector、 HashTable、 java.util.Collections同步包装内部类的源码,发现这些同步容器的实现线程安全的方式是:在需要同步访问的方法上加上关键字synchronized。synchronized在线程没有发生争用的场景下处于偏向锁的状态,其性能是非常高的。但是,一旦发生了线程争用, synchronized会由偏向锁膨胀成重量级锁,在抢占和释放时发生CPU内核态与用户态切换,所以削弱了并发性,降低了吞吐量,而且会严重影响性能。
JUC高并发容器
JUC高并发容器是基于非阻塞算法(或者无锁编程算法)实现的容器类,无锁编程算法主要通过CAS(Compare And Swap) +Volatile组合实现,通过CAS保障操作的原子性,通过volatile保障变量的内存可见性。无锁编程算法的主要优点如下:
- 开销较小:不需要在内核态和用户态之间切换进程。
- 读写不互斥:只有写操作需要使用基于CAS机制的乐观锁,读读操作之间可以不用互斥。
List—CopyOnWriteArrayList
JUC包中高并发List主要有CopyOnWriteArrayList,对应的基础容器为ArrayList。CopyOnWriteArrayList相当于线程安全的ArrayList,它实现了List接口。在读多写少的场景中,其性能远远高于ArrayList的同步包装容器。在很多应用场景中,读操作可能会远远大于写操作。由于读操作根本不会修改原有的数据,因此如果每次读取都进行加锁操作其实是一种资源浪费。我们应该允许多个线程同时访问List的内部数据,毕竟读操作是线程安全的。
**原理:**CopyOnWrite(写时复制)就是在修改器对一块内存进行修改时,不直接在原有内存块上进行写操作,而是将内存复制一份,在新的内存中进行写操作,写完之后,再将原来的指针(或者引用)指向新的内存,原来的内存被回收。 CopyOnWriteArrayList是写时复制思想的一种典型实现:其含有一个指向操作内存的内部指针array,而可变操作(add、 set等)是在array数组的副本上进行的。当元素需要被修改或者增加时,并不直接在array指向的原有数组上操作,而是首先对array进行一次复制,将修改的内容写入复制的副本中。写完之后,再将内部指针array指向新的副本,这样就可以确保修改操作不会影响访问器的读取操作了。 CopyOnWriteArrayList的原理如图7-2所示。从名字可以看出: CopyOnWriteArrayList是一个满足CopyOnWrite思想并使用Array数组存储数据的线程安全List。
CopyOnWriteArrayList的写入操作add()方法在执行时加了独占锁以确保只能有一个线程进行写入操作,避免多线程写的时候会复制出多个副本。 当add()操作完成后, array的引用就已经指向另一个存储空间了。 既然每次添加元素的时候都会重新复制一份新的数组,那就带来了一个问题,就是增加了内存的开销,如果容器的写操作比较频繁,那么其开销就比较大。所以,在实际应用的时候, CopyOnWriteArrayList并不适合进行添加操作。但是在并发场景下,迭代操作比较频繁, CopyOnWriteArrayList就是一个不错的选择。
CopyOnWriteArrayList有一个显著的优点,那就是读取、遍历操作不需要同步,速度会非常快。所以, CopyOnWriteArrayList适用于读操作多、写操作相对较少的场景(读多写少),比如可以在进行“黑名单”拦截时使用CopyOnWriteArrayList。
CopyOnWriteArrayList和ReentrantReadWriteLock读写锁的思想非常类似,即读读共享、写写互斥、读写互斥、写读互斥。但是前者相比后者更进一步:为了将读取的性能发挥到极致, CopyOnWriteArrayList读取是完全不用加锁的,而且写入也不会阻塞读取操作,只有写入和写入之间需要进行同步等待,于是读操作的性能得到大幅度提升。
弱一致性
数据一致性就是读到最新更新的数据:
-
强一致性:当更新操作完成之后,任何多个后续进程或者线程的访问都会返回最新的更新过的值
-
弱一致性:系统并不保证进程或者线程的访问都会返回最新的更新过的值,也不会承诺多久之后可以读到。Thread-0 读到了脏数据,不一定弱一致性就不好,并发高和一致性是矛盾的,需要权衡。
在 java.util 包的集合类就都是快速失败的,而 java.util.concurrent 包下的类都是安全失败。安全失败:采用安全失败机制的集合容器,在迭代器遍历时直接在原集合数组内容上访问,但其他线程的增删改都会新建数组进行修改,就算修改了集合底层的数组容器,迭代器依然引用着以前的数组(快照思想),所以不会出现异常
Set
JUC包中Set主要有CopyOnWriteArraySet、 ConcurrentSkipListSet。
- CopyOnWriteArraySet继承于AbstractSet类,对应的基础容器为HashSet。
- ConcurrentSkipListSet是线程安全的有序集合,对应的基础容器为TreeSet。
Map
JUC包中Map主要有ConcurrentHashMap和ConcurrentSkipListMap。
- ConcurrentHashMap对应的基础容器为HashMap。 JDK 6中的ConcurrentHashMap采用一种更加细粒度的“分段锁”加锁机制, JDK 8中采用CAS无锁算法。
- ConcurrentSkipListMap对应的基础容器为TreeMap。其内部的Skip List(跳表)结构是一种可以代替平衡树的数据结构,默认是按照Key值升序的。
ConcurrentHashMap
ConcurrentHashMap是一个常用的高并发容器类,也是一种线程安全的哈希表。 Java 7以及之前版本中的ConcurrentHashMap使用Segment(分段锁)技术将数据分成一段一段存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。 Java 8对其内部的存储结构进行了优化,使之在性能上有了更进一步的提升。
ConcurrentHashMap和同步容器HashTable的主要区别在锁的类型和粒度上: HashTable实现同步是利用synchronized关键字进行锁定的,其实是针对整张哈希表进行锁定的,即每次锁住整张表让线程独占,虽然解决了线程安全问题,但是造成了巨大的资源浪费。
HashMap 和 HashTable 的区别
- 基础容器HashMap是线程不安全的,HashTable线程安全。
- HashTable不允许key和value为null。
- HashTable使用synchronized来保证线程安全,包含get()/put()在内的所有相关需要进行同步执行的方法都加上了synchronized关键字,以锁定这个哈希表。
JDK 1.7 版本 ConcurrentHashMap 的组合结构
ConcurrentHashMap的内部结构的层次关系为ConcurrentHashMap→Segment→HashEntry。这样设计的好处在于,每次访问的时候只需要将一个Segment锁定,而不需要将整个Map类型集合都进行锁定。
JDK 1.7中的ConcurrentHashMap采用了Segment分段锁的方式实现。一个ConcurrentHashMap中包含一个Segment数组,一个Segment中包含一个HashEntry数组,每个元素是一个链表结构(一个哈希表的桶)。
HashEntry结构用于存储“ Key-Value对”(即“键-值对”)数据,以及存储了其后驱节点的指针。
Segment在ConcurrentHashMap中扮演锁的角色,每个Segment守护着一个HashEntry数组中的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。 ConcurrentHashMap中的一个段称为Segment, Segment继承了ReentrantLock,所以一个段又是一个ReentrantLock。 Segment内部拥有一个HashEntry数组类型的成员table,数组中的每个元素又是一个链表,这个由HashEntry链接起来的链表对应于一个哈希表的桶,也就是说, table的一个元素对应于哈希表的一个桶。 ConcurrentHashMap在默认并发级别时会创建包含16个Segment对象的数组。
在ConcurrentHashMap中,哈希时如果产生“碰撞”,将采用“分离链接法”来处理:把“碰撞”的HashEntry对象链接成一个链表,形成一个桶。由于HashEntry的next字段为final型,因此新节点只能在链表的表头处插入。 在一个空桶中依次插入A、 B、 C三个HashEntry对象后的结构图如图7-14所示
JDK 1.8 版本 ConcurrentHashMap 的结构
在JDK 1.8中, ConcurrentHashMap已经抛弃了Segment分段锁机制,存储结构采用数组+链表或者红黑树的组合方式,利用CAS+Synchronized来保证并发更新的安全。 JDK 1.7的ConcurrentHashMap为了进行并发热点的分离,默认情况下将一个table分裂成16个小的table(Segment表示),从而在Segment维度进行比较细粒度的并发控制。实际上,如果并发线程多,这种粒度还是没有足够细。所以, JDK 1.8的ConcurrentHashMap将并发控制的粒度进一步细化,也就是进一步进行并发热点的分离,将并发粒度细化到每一个桶。既然如此,比较粗粒度的Segment已经没有存在的必要,每一个桶已经变化成实质意义的Segment,所以该结构直接被丢弃。
JDK 1.8引入了红黑树的结构,当桶的节点数(链表长度)超过一定的阈值(默认为64)时, JDK 1.8将链表结构自动转换成红黑树的结构,可以理解为将链式桶转换成树状桶。 JDK 1.8的ConcurrentHashMap引入了红黑树的原因是:链表查询的时间复杂度为O(n),红黑树查询的时间复杂度为O(log(n)),所以在节点比较多的情况下,使用红黑树可以大大提升性能。
链式桶是一个由NODE节点组成的链表。树状桶是一棵由TreeNode节点组成的红黑树,树的根节点为TreeBin类型。当数据链表(链式桶)长度大于8时且桶的个数超过64时,会转换为TreeBin(树状桶)。 TreeBin作为根节点,可以认为是红黑树对象。在ConcurrentHashMap的table“数组”中,存放就是TreeBin对象,而不是TreeNode对象。
JDK 1.8版本的ConcurrentHashMap中通过一个Node<K,V>[]数组table来保存添加到哈希表中的桶,而在同一个Bucket位置是通过链表和红黑树的形式来保存的。
当原有的红黑树内数量小于6时,将红黑树转换成链表。 链表长度大于8 ,链式桶转成红黑树桶。
树化,当 table.length < 64 时,先尝试扩容,超过 64 时,并且 Treebin.length > 8 时,会将链表树化,树化过程会用 synchronized 锁住链表头。
-
put,如果该 bin(桶) 尚未创建,只需要使用 cas 创建 bin;如果已经有了,使用synchronized内置锁 锁住链表头进行后续 put 操作,元素添加至 bin 的尾部
-
get,无锁操作仅需要保证可见性,扩容过程中 get 操作拿到的是 ForwardingNode 会让 get 操作在新 table 进行搜索
-
扩容,扩容时以 bin 为单位进行,需要对 bin 进行 synchronized,但这时其它竞争线程也不是无事可做,它们会帮助把其它 bin 进行扩容
-
ConcurrentHashMap、HashMap 的初始容量为 16,两种 Map 扩容是当前容量翻倍:capacity * 2
总结:第一次添加元素时,默认初期长度为16,当往table中继续添加元素时,通过哈希值跟数组长度取余来决定放在数组的哪个Bucket位置,如果出现放在同一个位置时,就优先以链表的形式存放,在同一个位置的个数又达到了8个以上,如果数组的长度还小于64时,就会扩容数组。如果数组的长度大于等于64,就会将该节点的链表转换成树。 什么时候扩容?当前容量超过阈值,也就是链表中元素个数超过默认设置(8个)时,如果数组table的大小还未超过64,此时就进行数组的扩容,如果超过就将链表转化成红黑树。
扩容机制:
- 当链表中元素个数超过 8 个,数组的大小还未超过 64 时,此时进行数组的扩容,如果超过则将链表转化成红黑树
- put 数据后调用 addCount() 方法,判断当前哈希表的容量超过阈值 sizeCtl,超过进行扩容
- 增删改线程发现其他线程正在扩容,帮其扩容
尽管对同一个Map操作的线程争用会非常激烈,但是在同一个桶内 的线程争用通常不会很激烈,所以使用CAS自旋(简单轻量级锁)、 synchronized偏向锁或轻量级锁不会降低ConcurrentHashMap的性能。为什么不用ReentrantLock显式锁呢?如果为每一个桶都创建一个ReentrantLock实例,就会带来大量的内存消耗。
Queue
JUC包中Queue的实现类包括三类:单向队列、双向队列和阻塞队列。
使用循环 CAS 算法实现非阻塞队列:
- ConcurrentLinkedQueue是一个基于列表实现的单向无界线程安全队列,按照FIFO(先进先出)原则对元素进行排序。新元素从队列尾部插入,而获取队列元素则需要从队列头部获。ConcurrentLinkedQueu由 head 节点和 tail 节点组成,每个节点由节点元素和指向下一个节点的引用组成,组成一张链表结构的队列。
- ConcurrentLinkedDeque是基于链表的双向队列,但是该队列不允许null元素。作为双端队列, ConcurrentLinkedDeque可以当作“栈”来使用,并且高效地支持并发环境
除了提供普通的单向、双向队列, JUC拓展了队列,增加了可阻塞的插入和获取等操作,提供了一组加锁实现的阻塞队列,具体如下:
- ArrayBlockingQueue:基于数组实现的可阻塞的FIFO队列。
- LinkedBlockingQueue:基于链表实现的可阻塞的FIFO队列。
- PriorityBlockingQueue:按优先级排序的队列。
- DelayQueue: 按照元素的Delay时间进行排序的队列。
- SynchronousQueue:无缓冲等待队列。
BlockingQueue
在多线程环境中,通过BlockingQueue(阻塞队列)可以很容易地实现多线程之间数据共享和通信,比如在经典的“生产者-消费者”模型中,通过BlockingQueue可以完成一个高性能的实现版本。阻塞队列与普通队列( ArrayDeque等)之间的最大不同点在于阻塞队列提供了阻塞式的添加和删除方法。
ArrayBlockingQueue
ArrayBlockingQueue是一个常用的阻塞队列,是基于数组实现的,其内部使用一个定长数组存储元素。除了一个定长数组外, ArrayBlockingQueue内部还保存着两个整型变量,分别标识队列的头部和尾部在数组中的位置。 通过ReentrantLock类型的成员lock控制添加线程与删除线程的并发访问。 ArrayBlockingQueue的添加和删除操作都是共用同一个锁对象,由此意味着添加和删除无法并行运行,这一点不同于LinkedBlockingQueue。
为什么ArrayBlockingQueue比LinkedBlockingQueue更加常用?前者在添加或删除元素时不会产生或销毁任何额外的Node(节点)实例,而后者会生成一个额外的Node实例。在长时间、高并发处理大批量数据的场景中, LinkedBlockingQueue产生的额外Node实例会加大系统的GC压力。
ArrayBlockingQueue中的元素访问存在公平访问与非公平访问的两种方式,所以ArrayBlockingQueue可以分别作为公平队列和非公平队列使用:
1)对于公平队列,被阻塞的线程可以按照阻塞的先后顺序访问队列,即先阻塞的线程先访问队列。
2)对于非公平队列,当队列可用时,阻塞的线程将进入争夺访问资源的竞争中,也就是说谁先抢到谁就执行,没有固定的先后顺序。
//默认非公平阻塞队列
ArrayBlockingQueue queue = new ArrayBlockingQueue(capacity);
//公平阻塞队列
ArrayBlockingQueue queue1 = new ArrayBlockingQueue(capacity, true);
LinkedBlockingQueue
LinkedBlockingQueue是基于链表的阻塞队列,其内部也维持着一个数据缓冲队列(该队列由一个链表构成)对于添加和删除元素分别采用了独立的锁来控制数据同步,这也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。
在新建一个LinkedBlockingQueue对象时,若没有指定其容量大小,则LinkedBlockingQueue会默认一个类似无限大小的容量( Integer.MAX_VALUE),这样的话,如果生产者的速度一旦大于消费者的速度,也许还没有等到队列满阻塞产生,系统内存就有可能已被消耗殆尽了。
DelayQueue
DelayQueue中的元素只有当其指定的延迟时间到了,才能从队列中获取到该元素。 DelayQueue是一个没有大小限制的队列,因此往队列中添加数据的操作(生产者)永远不会被阻塞,而只有获取数据的操作(消费者)才会被阻塞。
PriorityBlockingQueue
基于优先级的阻塞队列和DelayQueue类似, PriorityBlockingQueue并不会阻塞数据生产者,而只会在没有可消费的数据时,阻塞数据的消费者。在使用的时候要特别注意,生产者生产数据的速度绝对不能快于消费者消费数据的速度,否则时间一长,会最终耗尽所有的可用堆内存空间。
SynchronousQueue
一种无缓冲的等待队列类似于无中介的直接交易,有点像原始社会中的生产者和消费者,生产者拿着商品去集市销售给商品的最终消费者,而消费者必须亲自去集市找到所要商品的直接生产者,如果一方没有找到合适的目标,那么大家都在集市等待。相对于有缓冲的阻塞队列(如LinkedBlockingQueue)来说, SynchronousQueue少了中间缓冲区(如仓库)的环节。
反过来说,又因为仓库的引入,使得商品从生产者到消费者中间增加了额外的交易环节,单个商品的及时响应性能可能会降低,所以对单个消息的响应要求高的场景可以使用SynchronousQueue。