1. .为什么要使用ConcurrentHashMap?
在并发编程中 使用HashMap容易造成死循环的 (在多线程环境中,使用HashMap进行put操作会引起死循环,导致CPU的利用率低下)
而使用线程安全的HashTable效率低下 ConcurrentHashMap是线程安全而且高效的HashMap
HashMap在并发执行put的时候会引起死循环,是因为多线程会导致 ConcurrentHashMap的Entry链表形成环形数据结构,一旦形成环形数据结构,Entry的next接点永远不为空,就会产生死循环获取Entry
import java.util.HashMap; import java.util.UUID; public class Demo02 { public static void main(String[] args) throws InterruptedException { final HashMap<String ,String> map=new HashMap<String, String>(2); Thread t=new Thread(new Runnable() { @Override public void run() { for(int i=0; i<10000;i++){ new Thread(new Runnable() { @Override public void run() { map.put(UUID.randomUUID().toString(),""); } },"ftf"+i).start(); } } },"ftf"); t.start(); t.join(); } }
2. 为什么HashTable的效率低下?而ConcurrentHashMap是怎么解决的?(锁分段技术)
2.HashTable容器在激烈的并发环境中表现低下的原因: * HashTable使用synchronize来保证线程安全,所有访问HashTable的线程都必须竞争同一把锁,线程1在使用put * 添加元素时,线程2只能等待线程1操作完,不能put元素 也不能get * * 如果容器里面有多把锁,每一把锁用于锁容器其中一部分数据 * 当多线程访问容器里面不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率 * 这就是ConcurrentHashMap的锁分段技术 * 首先将数据分成一段一段的,为每段数据加锁,当一个线程占用锁访问其他一个段数据的时候, * 其他段的数据也能被其他线程访问
3. ConcurrentHashMap的结构
(1)ConcurrentHashMap类里面有 Segment<K,V> HashEntry<K,V>类
初始化方法 是通过initialCapacity loadFactor=0.75 concurrencyLevel等几个参数初始化segment数组 segmentShift
segmentMask和每个Segment里的HashEntry数组来实现的
@SuppressWarnings("unchecked") public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) { if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0) throw new IllegalArgumentException(); if (concurrencyLevel > MAX_SEGMENTS) concurrencyLevel = MAX_SEGMENTS; int sshift = 0; int ssize = 1; while (ssize < concurrencyLevel) { ++sshift; ssize <<= 1; } this.segmentShift = 32 - sshift; this.segmentMask = ssize - 1; if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; int c = initialCapacity / ssize; if (c * ssize < initialCapacity) ++c; int cap = MIN_SEGMENT_TABLE_CAPACITY; while (cap < c) cap <<= 1; Segment<K,V> s0 = new Segment<K,V>(loadFactor, (int)(cap * loadFactor), (HashEntry<K,V>[])new HashEntry[cap]); Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize]; UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0] this.segments = ss; }
1. static final class Segment<K,V> extends ReentrantLock implements Serializable 2. static final class HashEntry<K,V> { final int hash; final K key; volatile V value; volatile HashEntry<K,V> next; HashEntry(int hash, K key, V value, HashEntry<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; }
public class ConcurrentHashMap<K, V> extends AbstractMap<K, V> implements ConcurrentMap<K, V>, Serializable { static final int DEFAULT_INITIAL_CAPACITY = 16; static final float DEFAULT_LOAD_FACTOR = 0.75f; static final int DEFAULT_CONCURRENCY_LEVEL = 16; static final int MAXIMUM_CAPACITY = 1 << 30; static final int MIN_SEGMENT_TABLE_CAPACITY = 2; static final int MAX_SEGMENTS = 1 << 16; // slightly conservative static final int RETRIES_BEFORE_LOCK = 2;
(2)ConcurrentHashMap的操作
get: 整个get过程不需要加锁,除非是读到空值才加锁重读
原因是它的get方法里将要使用的共享变量都定义为volatile类型
public V get(Object key) { Segment<K,V> s; // manually integrate access methods to reduce overhead HashEntry<K,V>[] tab; int h = hash(key); long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE; if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null && (tab = s.table) != null) { for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE); e != null; e = e.next) { K k; if ((k = e.key) == key || (e.hash == h && key.equals(k))) return e.value; } } return null; }
put操作: 首先定位segment 然后插入 插入操作经过两步
(1)是否需要扩容: 在插入前先判断 HashEntry数组是否超过容量threshold,超过对其扩容
(注意 HashMap是在插入元素后 判断是否到达容量的,到达了就进行扩容,但是很有可能扩容后没有新的元素插入,这是HashMap就进行了一次无效的扩容)
(2)如何扩容
在扩容时,首先会创建一个容量是原来的2倍的数组,再将原数组的元素进行再散列后插入到新的数组里面,而且ConcurrentHashMap只对某个segment进行扩容(segment的数组长度ssize是通过concurrentLevel计算出来的,散列算法是按位与的算法 所以segments的长度只能是2的N次方)
size操作:如果要统计ConcurrentHashMap里的元素大小,就需要统计Segment里的大小后求和,Segement里的全局变量count是一个volatile, 在多线程环境,count容易变化
ConcurrentHashMap的做法: 先尝试两次不通过锁的方法统计count 如果统计过程count变化了,在采用加锁的方式
如何判断count是否变化了?采用motcount 在进行put remove clean 将modcount+1, 统计size前比较modcount是否变化,从而得知容器是否发生变化
上面可以去看java并发编程的艺术一书
下面转自https://github.com/crossoverJie/Java-Interview/blob/master/MD/ConcurrentHashMap.md
(2)1.8 中的 ConcurrentHashMap 数据结构和实现与 1.7 还是有着明显的差异。
其中抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized
来保证并发安全性。
也将 1.7 中存放数据的 HashEntry 改为 Node,但作用都是相同的。
其中的 val next
都用了 volatile 修饰,保证了可见性。
put 方法
重点来看看 put 函数:
- 根据 key 计算出 hashcode 。
- 判断是否需要进行初始化。
f
即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。- 如果当前位置的
hashcode == MOVED == -1
,则需要进行扩容。 - 如果都不满足,则利用 synchronized 锁写入数据。
- 如果数量大于
TREEIFY_THRESHOLD
则要转换为红黑树。
get 方法
- 根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。
- 如果是红黑树那就按照树的方式获取值。
- 就不满足那就按照链表的方式遍历获取值。