在多线程环境中,使用HashMap可能会导致程序死循环,使用线程安全的HashTable效率低效,所以便有了ConcurrentHashMap。
ConcurrentHashMap利用锁的分断技术可有效提升并发访问率,在容器里有多把锁,每一把锁用于锁容器其中一部分数据,当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效提升并发访问效率。
ConcurrentHashMap的结构
ConcurrentHashMap由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁;HashEntry用于存储键值对数据,一个ConcurrentHashMap包含一个Segment数组。Segment数组的结构和HashMap类似,是一种数组和链表结构,一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须先获得与它对应的Segment锁。
ConcurrentHashMap初始化
初始化Segment数组
segments数组的长度ssize是通过ConcurrencyLevel计算得出,为了可以通过按位与的散列算法定位Segments数组的索引,必须保证segments数组的长度是2的N次方,所以必须计算出一个大于或等于concurrencyLevel的最小的2的N次方值来作为segments数组的长度。下面是segments数组的源代码
if(concurrencyLevel > MAX_SEGMENTS){
concurrencyLevel = MAX_SEGMENTS;
int sshift = 0;
int ssize = 1;
while(ssize < concurrencyLevel){
++sshift;
ssize <<= 1;
}
segmentShift = 32 - sshift;
segmentMask = ssize -1;
this.segments = Segment.newArray(ssize);
}
concurrencyLevel的最大值是65535,Segments数组的长度最大为65536对应的二进制数组
定位Segment
ConcurrentHashMap使用分段锁Segment保护不同的数据,在插入和获取元素的时候,必须通过散列算法定位到Segment,然后在进行再散列,减少散列冲突,使元素能够均匀地分布在不同的Segment上,提高容器的存取效率。
散列算法如下:
final Segment<K,V> segmentFor(int hash){
//segmentShift默认为28,SegmentMask为15
return segments[(hash>>>segmentShift)&segmentMask];
}
初始化segmentShift和segmentMask
这两个全局变量需要定位segments的散列算法里使用,sshift等于ssize从1向左移位的次数,在默认情况下concurrencyLevel等于161需要向左移位移动4次,所以sshift等于4segmentShift用于定位参与散列运算的位数,segmentShift等于32减sshift,所以等于28。
初始化每个segment
输入参数initialCapacity是ConcurrentHashMap的初始化容量,loadfactor是每个segment的负载因子,在构造方法中需要通过这两个参数来初始化数组中的每个segment。
if(initialCapacity >MAXIMUM_CAPACITY){
initialCapacity = MAXIMUM_CAPACITY;
int c = initialCapacity/ssize;
if(c * ssize < initialCapacity){
++c;
}
int cap = 1;
while(cap < c){
cap <<= 1;
}
for(int i = 0;i<this.segments.length;i++){
this.segments[i] = new Segment<K,V>(cap,loadFactor);
}
}
ConcurrentHashMap的操作
get操作
get操作先经过一次再散列,然后使用这个散列值通过散列运算定位到Segment,在通过散列算法定位到元素。
public V get(Object key){
int hash = hash(key.hashCode());
return segmentFor(hash).get(key,hash);
}
get
操作之所以高效是因为整个get过程不需要加锁,除非读到的值是空才会加锁重读。get方法里面将要使用的共享变量都定义为volatile
,定义成volatile的变量,能够在线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,在get操作里只需要读不需要写共享变量count和value,所以不需要加锁。不会读到过期的值,是因为根据Java内存模型Happen-before原则,对volatile字段的写入优先于读操作,即使两个线程同时修改和获取volatile变量,get操作也能够拿到最新的值。
-
put操作
由于put操作需要对共享变量进行写入操作,为了线程安全,在操作共享变量是必须加锁。插入操作需经历两步:(1)判断是否需要对Segment数组里的HashEntry数组进行扩容;(2)定位添加元素的为位置,然后将其放在HashEntry数组中。根据上面的两个步骤这里又抛出两个问题:是否需要扩容、如何扩容。
是否需要扩容:在插入元素前会先判断Segment里的HashEntry数组是否超过容量,如果超过阈值,则对数组进行扩容(Segment的扩容判断比HashMap更恰当,因为HashMap是在插入元素后判断元素是否已经达到容量,如果达到了就进行扩容),
如果扩容:在扩容的时候,为了高效ConcurrentHashMap不会对整个容器进行扩容,而只是对某个Segment数组进行扩容
-
size操作
在统计size的时候会把所有Segment的put、remove、clean方法全部加锁,这种做法会变得非常低效,ConcurrentHashMap先尝试通过两次不加锁Segment的方式来统计各个Segment大小,通过过程中容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小,那么ConcurrentHashMap如何判断容器是否发生变化?使用modCount变量,在put、remove、clean方法里操作元素前都会将变量modCount进行加一操作,那么在统计size前后比较modCount是否发生变化,从而知道容器大小是否发生变化。
ConcurrentLinkedQueue
ConcurrentLinkedQueue是个基于链表节点的无界线程安全队列,采用先进先出的规则对节点进行排序,当添加一个元素时,会添加到队列的尾部;当获取一个元素时会返回队列的头元素。采用CAS算法实现。
ConcurrentLinkedQueue的入队列
入队列过程
入队列就是将入队节点添加到队列的尾部,在单线程下入队列比较简单,但在多线程同时进行入对的时候就复杂了许多,这个过程可能会有其他线程入队的情况,如果有个线程正在入队,那么这个线程就必须获取尾节点,然后设置尾结点的下一个节点为入队节点,要是这时有另一个线程插队,那么队列的尾节点就会发生变化,当前线程停止入队操作。这里就需要用到CAS算法
出队列过程
出队列就是从队列里返回一个节点元素,并清空该节点对元素的引用。首先获取头节点的元素,然后判断头节点元素是否为空,如果为空,表示另外一个线程已经进行了一次出队操作将该节点的元素取走,如果不为空,则使用CAS的方式将头节点的引用设置为null,如果CAS成功,则直接返回头节点的元素,如果不为空表示另外一个线程已经进行了一次出队操作更新过了head头节点,导致元素发生变化,需要重新获取头节点。
Java中的阻塞队列
什么是阻塞队列:阻塞队列是个支持阻塞的插入方法和阻塞的移除方法的队列;
- 支持阻塞的插入方法:当队列满时,队列会阻塞插入元素的线程直到队列不满。
- 支持阻塞的移除方法:当队列空时,获取元素的线程会等待队列变为非空
阻塞队列的实现原理
- 使用通知模式实现:通知模式就是当生产者往满的队列里添加元素时会阻塞生产者,当消费者消费了一个队列中的元素后,会通知生产者当前队列可用。