Java并发编程之并发集合

一、ConcurrentHashMap(是线程高效并安全的hashMap)

1.hashMap的底层原理

HashMap在JDK1.8之前的实现方式 数组+链表,

但是在JDK1.8后对HashMap进行了底层优化,改为了由 数组+链表或者数值+红黑树实现,主要的目的是提高查找效率

Hashcode 他是根据数组的长度进行一个按位与运算和亦或运算另外通过平方取中法取余法 伪随机数法 都可以得到hashcode

二次哈希:

Java并发编程之并发集合_第1张图片

  1. Jdk8数组+链表或者数组+红黑树实现,当链表中的元素超过了 8 个以后, 会将链表转换为红黑树,当红黑树节点 小于 等于6 时又会退化为链表。
  2. 当new HashMap():底层没有创建数组,首次调用put()方法示时,底层创建长度为16的数组,jdk8底层的数组是:Node[],而非Entry[],用数组容量大小乘以加载因子得到一个值,一旦数组中存储的元素个数超过该值就会调用rehash方法将数组容量增加到原来的两倍,专业术语叫做扩容,在做扩容的时候会生成一个新的数组,原来的所有数据需要重新计算哈希码值重新分配到新的数组,所以扩容的操作非常消耗性能.

默认的负载因子大小为0.75,数组大小为16。也就是说,默认情况下,那么当HashMap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍。

  1. 在我们Java中任何对象都有hashcode,hash算法就是通过hashcode与自己进行向右位移16的异或运算。这样做是为了计算出来的hash值足够随机,足够分散,还有产生的数组下标足够随机,

map.put(k,v)实现原理

(1)首先将k,v封装到Node对象当中(节点)。

(2)先调用k的hashCode()方法得出哈希值,并通过哈希算法转换成数组的下标。

(3)下标位置上如果没有任何元素,就把Node添加到这个位置上。如果说下标对应的位置上有链表。此时,就会拿着k和链表上每个节点的k进行equal。如果所有的equals方法返回都是false,那么这个新的节点将被添加到链表的末尾。如其中有一个equals返回了true,那么这个节点的value将会被覆盖。

map.get(k)实现原理

(1)、先调用k的hashCode()方法得出哈希值,并通过哈希算法转换成数组的下标。

(2)、在通过数组下标快速定位到某个位置上。重点理解如果这个位置上什么都没有,则返回null。如果这个位置上有单向链表,那么它就会拿着参数K和单向链表上的每一个节点的K进行equals,如果所有equals方法都返回false,则get方法返回null。如果其中一个节点的K和参数K进行equals返回true,那么此时该节点的value就是我们要找的value了,get方法最终返回这个要找的value。

 2.高并发下的hashMap

因为在多线程环境下,使用HashMap进行put操作会引起死循环,导致CPU利用率接近100%,所以在并发情况下不能使用HashMap

1.Hashmap在插入元素过多的时候需要进行Resize,Resize的条件是

HashMap.Size   >=  Capacity * LoadFactor。

2.Hashmap的Resize包含扩容和ReHash两个步骤,ReHash在并发的情况下可能会形成链表环。

3.什么是ConcurrentHashMap

首先我们了解一下什么是Segment

Segment是什么呢?Segment本身就相当于一个HashMap对象。

同HashMap一样,Segment包含一个HashEntry数组,数组中的每一个HashEntry既是一个键值对,也是一个链表的头节点。

单一的Segment结构如下:

 Java并发编程之并发集合_第2张图片

像这样的Segment对象,在ConcurrentHashMap集合中有多少个呢?有2的N次方个,共同保存在一个名为segments的数组当中。

因此整个ConcurrentHashMap的结构如下: 

Java并发编程之并发集合_第3张图片

 

可以说,ConcurrentHashMap是一个二级哈希表。在一个总的哈希表下面,有若干个子哈希表。 

Java7 ConcurrentHashMap基于ReentrantLock实现分段锁

Java8中 ConcurrentHashMap基于分段锁+CAS保证线程安全,分段锁基于synchronized
关键字实现;
Java并发编程之并发集合_第4张图片

 4.ConcurrentHashMap初始化

ConcurrentHashMap初始化方法是通过initialCapacityloadFactorconcurrencyLevel等几个
参数来初始化segment数组、段偏移量segmentShift、段掩码segmentMas和每个segment里的
HashEntry数组来实现的。

5.允许多个读操作并发进行

ConcurrentHashMap完全允许多个读操作并发进行,读操作并不需要加锁。如果使用传统的技术,如HashMap中的实现,如果允许可以在hash链的中间添加或删除元素,读操作不加锁将得到不一致的数据。ConcurrentHashMap实现技术是保证HashEntry几乎是不可变的。HashEntry代表每个hash链中的一个节点,其结构如下所示:

 static final class HashEntry {  
     final K key;  
     final int hash;  
     volatile V value;  
     final HashEntry next;  
 } 

可以看到除了value不是final的,其它值都是final的,这意味着不能从hash链的中间或尾部添加或删除节点,因为这需要修改next 引用值,所有的节点的修改只能从头部开始。对于put操作,可以一律添加到Hash链的头部。但是对于remove操作,可能需要从中间删除一个节点,这就需要将要删除节点的前面所有节点整个复制一遍,最后一个节点指向要删除结点的下一个结点。这在讲解删除操作时还会详述。为了确保读操作能够看到最新的值,将value设置成volatile,这避免了加锁。

 6.ConcurrentHashMap的size

如果我们要统计整个ConcurrentHashMap里元素的大小,就必须统计所有Segment里元素的大小后求和。Segment里的全局变量count是一个volatile变量,那么在多线程场景下,我们是不是直接把所有Segmentcount相加就可以得到整个ConcurrentHashMap大小了呢?不是的,虽然相加时可以获取每个Segmentcount的最新值,但是拿到之后可能累加前使用的count发生了变化,那么统计结果就不准了。所以最安全的做法,是在统计size的时候把所有Segment的put,remove和clean方法全部锁住,但是这种做法显然非常低效。
  因为在累加count操作过程中,之前累加过的count发生变化的几率非常小,所以ConcurrentHashMap的做法是先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小。
  那么ConcurrentHashMap是如何判断在统计的时候容器是否发生了变化呢?使用modCount变量,在put , remove和clean方法里操作元素前都会将变量modCount进行加1,那么在统计size前后比较modCount是否发生变化,从而得知容器的大小是否发生变化。

7.ConcurrentHashMap的get

Segmentget操作实现非常简单和高效。先经过一次再散列,然后使用这个散列值通过散列运算定位到Segment,再通过散列算法定位到元素,代码如下。

public V get(Object key) {
   int hash =hash(key.hashCode());
return segmentFor(hash).get(key, hash);
}

get操作的高效之处在于整个get过程不需要加锁,除非读到的值是空才会加锁重读。

那么ConcurrentHashMap的get操作是如何做到不加锁的呢?

原因是它的get方法里将要使用的共享变量都定义成volatile类型,如用于统计当前Segement大小的count字段和用于存储值的HashEntryvalue。定义成volatile的变量,能够在线
程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,但是只能被单线程写
(有一种情况可以被多线程写,就是写入的值不依赖于原值),在get操作里只需要读不需要写共享变量countvalue,所以可以不用加锁。之所以不会读到过期的值,是因为根据Java内存模型的happen before原则,对volatile字段的写入操作先于读操作,即使两个线程同时修改和获取volatile变量,get操作也能拿到最新的值,这是用volatile`替换锁的经典应用场景。

transient volatile int count;
volatile V value;

 8.ConcurrentHashMap的put

由于put方法里需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必须加锁。put方法首先定位到Segment然后在Segment里进行插入操作。插入操作需要经历两个
步骤:
第一步判断是否需要对Segment里的HashEntry数组进行扩容,

第二步定位添加元素的位置,然后将其放在HashEntry数组里

二、ConcurrentLinkedQueue

ConcurrentLinkedQueue是一个基于链接节点的无界线程安全队列,它采用先进先出的规则对节点进行排序,当我们添加一个元素的时候,它会添加到队列的尾部;当我们获取一个元
素时,它会返回队列头部的元素。它采用了“wait-free”算法(即CAS算法)来实现,该算法在
Michael&Scott算法上进行了一些修改

ConcurrentLinkedQueue 是 Java 中的一个线程安全的无界非阻塞队列实现,它是基于链接节点的队列,适用于高并发场景下的应用。由于它是无界的,它不会限制队列的大小,理论上可以存储无限多的元素,但实际上受到系统内存的限制。

特点

  • 线程安全ConcurrentLinkedQueue 通过使用原子操作和锁机制来保证多线程环境下的线程安全性,使得多个线程可以安全地并发访问队列进行添加和移除操作。

  • 无界队列:队列没有预设的容量限制,可以动态地增长以容纳更多的元素。

  • 非阻塞队列:插入和删除操作不会阻塞线程,即使在队列中没有元素时调用 poll 方法也不会阻塞。

  • 高并发性能:由于其内部实现考虑了并发性,因此在高并发场景下通常比其他阻塞队列(如 BlockingQueue 的实现)具有更好的性能。

内部实现

ConcurrentLinkedQueue 使用一种非阻塞链表算法,其中每个元素都是一个节点,节点包含指向下一个节点的引用。队列的头部和尾部通过原子引用来维护,这些原子引用确保了在并发访问时对队列结构的修改是安全的。

  • 头部节点(Head):指向队列中第一个有效节点的引用。
  • 尾部节点(Tail):指向队列中最后一个有效节点的下一个节点,这是为了支持并发添加操作。

主要操作

  • offer(E e):将元素插入队列的尾部,如果队列没有空间(实际上由于无界队列的特性,几乎不会发生这种情况),返回 false
  • poll():从队列头部移除并返回第一个元素,如果队列为空,则返回 null
  • peek():获取但不移除队列头部的元素,如果队列为空,则返回 null

使用场景

ConcurrentLinkedQueue 适用于以下场景:

  • 在高并发环境下,需要多个线程共享对队列的访问。
  • 需要一个无界队列来存储元素,并且不需要在队列满时阻塞生产者线程,或者队列空时阻塞消费者线程。

由于其非阻塞特性和高并发性能,ConcurrentLinkedQueue 在构建高性能并发应用程序时非常有用。

三、java中的阻塞队列

阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作支持阻塞的插入和移除操作。

  • 支持阻塞的插入方法:意思是当队列满时,队列 会阻塞插入 元素的线程,直到队列不满。

  • 支持阻塞 的移除方法:意思是在队列为空时,获取元素的线程会等待队列变为非空。

Java并发编程之并发集合_第5张图片

  • 抛出异常: 当队列满时 ,如果再往队列 里插入元素,会抛出IllegalStateException("Queue full")。当队列空时,从队列里获取元素会抛出throw new NoSuchElementException();

  • 返回特殊值:当往队列插入元素时,会返回元素是否插入成功,成功返回true。如果是移除方法,则是从队列里取出一个元素,如果没有则返回null。

  • 一直阻塞:当阻塞队列满时,如果生产者线程往队列里put元素,队列会一直阻塞生产者线程,直到队列可用或者响应中断退出。当队列空时,如果消费者线程从队列里take元素,队列会阻塞住消费者线程,直到队列不为空。

  • 超时退出:当阻塞队列满时,如果生产者线程往队列里插入元素,队列会阻塞生产者线程一段时间,如果超过了指定的时间,生产者线程就会退出。

 1.ArrayBlockingQueue 

是一个用数组实现的有界阻塞 队列,按照先进先出(FIFO)的原则对元素进行排序。

ArrayBlockingQueue fairQueue = new ArrayBlockingQueue(1000,true);

访问者的公平性是使用可重入锁实现的,代码如下。

public ArrayBlockingQueue(int capacity, boolean fair) {
if (capacity <= 0)
throw new IllegalArgumentException();
this.items = new Object[capacity];
lock = new ReentrantLock(fair);
notEmpty = lock.newCondition();
notFull = lock.newCondition();
}

2.LinkedBlockingQueue 

用链表实现的有界阻塞队列。此队列的默认和最大长度为 Integer.MAX_VALUE.此队列按照先进先出的原则对元素进行排序。

3.PriorityBlockingQueue

PriorityBlockingQueue是一个支持优先级的无界阻塞队列。默认情况下元素采取自然顺序升序排列。也可以自定义类实现compareTo()方法来指定元素排序规则,或者初始化
PriorityBlockingQueue时,指定构造参数Comparator来对元素进行排序。需要注意的是不能保证
同优先级元素的顺序。

4.DelayQueue

DelayQueue是一个支持延时获取元素的无界阻塞队列。队列使用PriorityQueue来实现。队列中的元素必须实现Delayed接口,在创建元素时可以指定多久才能从队列中获取当前元素。只有在延迟期满时才能从队列中提取元素

使用场景:

可以用DelayQueue保存缓存元素的有效期,使用一个线程循环查询DelayQueue,一旦能从DelayQueue中获取元素,表示缓存有效期到了。

使用DelayQueue保存当前将会执行的任务和执行时间,一旦从DelayQueue中取到任务就开始执行,比如TimerQueue就是使用DelayQueue实现的。

5.SynchronousQueue 

SynchronousQueue是一个不存储元素的阻塞队列。每一个put操作必须等待一个take操作,否则不能继续添加元素。

SynchronousQueue可以看成是一个传球手,负责把生产者线程处理的数据直接传递给消费者线程。队列本身并不存储任何元素,非常适合传递性场景。SynchronousQueue的吞吐量高于LinkedBlockingQueueArrayBlockingQueue

6.LinkedTransferQueue

LinkedTransferQueue是一个由链表结构组成的无界阻塞TransferQueue队列。相对于其他阻
塞队列,LinkedTransferQueue多了tryTransfertransfer方法。

7.LinkedBlockingDeque

LinkedBlockingDeque是一个由链表结构组成的双向阻塞队列。所谓双向队列指的是可以从队列的两端插入和移出元素。双向队列因为多了一个操作队列的入口,在多线程同时入队
时,也就减少了一半的竞争

8.阻塞队列的选择

选择阻塞队列时,需要考虑以下几个因素:

  1. 容量需求:根据实际应用场景,确定是否需要限制队列的容量。如果需要限制容量,可以选择 ArrayBlockingQueue;如果不需要限制容量,可以选择 LinkedBlockingQueue、PriorityBlockingQueue、DelayQueue、SynchronousQueue、LinkedTransferQueue 或 LinkedBlockingDeque。

  2. 元素排序需求:根据实际应用场景,确定是否需要对队列中的元素进行排序。如果需要排序,可以选择 PriorityBlockingQueue;如果不需要排序,可以选择 ArrayBlockingQueue、LinkedBlockingQueue、DelayQueue、SynchronousQueue、LinkedTransferQueue 或 LinkedBlockingDeque。

  3. 任务优先级需求:根据实际应用场景,确定是否需要支持任务的优先级。如果需要支持任务的优先级,可以选择 PriorityBlockingQueue;如果不需要支持任务的优先级,可以选择 ArrayBlockingQueue、LinkedBlockingQueue、DelayQueue、SynchronousQueue、LinkedTransferQueue 或 LinkedBlockingDeque。

  4. 延时获取需求:根据实际应用场景,确定是否需要支持延时获取队列中的元素。如果需要支持延时获取,可以选择 DelayQueue;如果不需要支持延时获取,可以选择 ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue、SynchronousQueue、LinkedTransferQueue 或 LinkedBlockingDeque。

  5. 生产者-消费者模式需求:根据实际应用场景,确定是否需要支持生产者-消费者模式。如果需要支持生产者-消费者模式,可以选择 SynchronousQueue;如果不需要支持生产者-消费者模式,可以选择 ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue、DelayQueue、LinkedTransferQueue 或 LinkedBlockingDeque。

  6. 性能需求:根据实际应用场景,确定是否需要考虑队列的性能。如果需要考虑性能,可以选择 LinkedBlockingQueue、PriorityBlockingQueue、DelayQueue、SynchronousQueue、LinkedTransferQueue 或 LinkedBlockingDeque,这些队列在性能上都有各自的优势。

  7. 其他需求:根据实际应用场景,确定是否需要考虑其他因素,例如队列的稳定性、可扩展性、可重用性等。在选择阻塞队列时,需要综合考虑这些因素,以选择最合适的阻塞队列实现。

你可能感兴趣的:(java,开发语言)