ConcurrentHashMap & HashTable
HashMap在多线程环境下存在线程安全问题,一般都是怎么处理这种情况的
多线程的场景,不同的方式代替:
- 使用Collections.synchronizedMap(Map)创建线程安全的map集合;
- Hashtable
- ConcurrentHashMap
不过出于线程并发度的原因,使用ConcurrentHashMap,他的性能和效果明显高于前两者。
Collections.synchronizedMap是怎么实现线程安全的
在synchronizedMap内部维护了一个普通对象Map,还有排斥所mutex,如图

Collections.synchronizedMap(new HashMap<>(16));
我们在调用这个方法的时候就需要传入一个Map,可以看到有两个构造器,如果你传入了mutex参数,则将对象排斥锁赋值为传入的对象。
如果没有,则将对象排斥锁赋值为this,即调用synchronizedMap的对象,就是上面的Map。
创建出synchronizedMap之后,再操作map的时候,就会对方法上锁,如图全是🔒

Hashtable
跟HashMap相比Hashtable是线程安全的,适合在多线程的情况下使用,但是效率可不太乐观。
他在对数据操作的时候都会上锁,所以效率比较低下。

Hashtable 跟HashMap不一样的点
- Hashtable 是不允许键或值为 null 的,HashMap 的键值则都可以为 null。
- 实现方法不同:Hashtable 继承了Dictionary类,而 HashMap 继承的是 AbstractMap 类。
- 初始化容器不同:HashMap 的 初始化容量为: 16 ,Hashtable 初始容量为:11,两者负载因子默认都是:0.75.
- 扩容机制不同:当现有容量大于总容量* 负载因子时,HashMap扩容规则为当前容量翻倍,Hashtable扩容规则为当前容量翻倍+1.
- 迭代器不同:HashMap中Iterator迭代器是fail-fast的,而Hashtable的Enumerator不是fail-fast的。
所以,当其他线程改变了HashMap 的结构,如:增加、删除元素,将会抛出ConcurrentModificationException 异常,而 Hashtable 则不会。
fail-fast是啥
快速失败(fail—fast)是java集合中的一种机制, 在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出Concurrent Modification Exception。
fail-fast 原理
迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。
集合在被遍历期间如果内容发生变化,就会改变modCount的值。
每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。
Tip:这里异常的抛出条件是检测到 modCount!=expectedmodCount 这个条件。如果集合发生变化时修改modCount值刚好又设置为了expectedmodCount值,则异常不会抛出。
因此,不能依赖于这个异常是否抛出而进行并发操作的编程,这个异常只建议用于检测并发修改的bug。
说说他的场景
java.util包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)算是一种安全机制吧。
Tip:安全失败(fail—safe)大家也可以了解下,java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改。
为啥 Hashtable 是不允许 KEY 和 VALUE 为 null, 而 HashMap 则可以呢?
因为Hashtable在我们put空值的时候会直接抛空指针异常,但是HashMap却做了特殊处理。
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
Hashtable使用的是安全失败机制(fail-safe),这种机制会使你此次读到的数据不一定是最新的数据
如果你使用null值 , 就会使得其无法判断对应的key是不存在还是为空,因为你无法再调用一次contain(key)来对key是否存在进行判断,ConcurrentHashMap 同理。
ConcurrentHashMap 的数据结构
ConcurrentHashMap 低层是基于 数组 + 链表 组成的,不过在 jdk1.7 和 1.8 中具体实现稍有不同。
在1.7中的数据结构:

如图所示,是由 Segment 数组、HashEntry 组成,和 HashMap 一样,仍然是数组加链表。
Segment 是 ConcurrentHashMap 的一个内部类,主要的组成如下:
`static final class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
// 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶
transient volatile HashEntry<K,V>[] table;
transient int count;
// 记得快速失败(fail—fast)么?
transient int modCount;
// 大小
transient int threshold;
// 负载因子
final float loadFactor;
}`
HashEntry跟HashMap差不多的,但是不同点是,他使用volatile去修饰了他的数据Value还有下一个节点next。
volataile 的特性
- 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(实现可见性)
- 禁止进行指令重排序。(实现有序性)
- volataile 只能保证对单词读/写的原子性。i++ 这种操作不能保证原子性。
ConcurrentHashMap 并发高的原因
原理上来说,ConcurrentHashMap采用了分段锁技术,其中Segment继承与ReentrantLock.
不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量)的线程并发。
每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。
就是说如果容量大小是16他的并发度就是16,可以同时允许16个线程操作16个Segment而且还是线程安全的。
public V put(K key, V value) { Segment<K,V> s; if (value == null) throw new NullPointerException();//这就是为啥他不可以put null值的原因 int hash = hash(key); int j = (hash >>> segmentShift) & segmentMask; if ((s = (Segment<K,V>)UNSAFE.getObject (segments, (j << SSHIFT) + SBASE)) == null) s = ensureSegment(j); return s.put(key, hash, value, false); }
他先定位到Segment,然后再进行put操作。
我们看看他的put源代码,你就知道他是怎么做到线程安全的了,关键句子我注释了。
final V put(K key, int hash, V value, boolean onlyIfAbsent) { // 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value); V oldValue; try { HashEntry<K,V>[] tab = table; int index = (tab.length - 1) & hash; HashEntry<K,V> first = entryAt(tab, index); for (HashEntry<K,V> e = first;;) { if (e != null) { K k; // 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。 if ((k = e.key) == key || (e.hash == hash && key.equals(k))) { oldValue = e.value; if (!onlyIfAbsent) { e.value = value; ++modCount; } break; } e = e.next; } else { // 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。 if (node != null) node.setNext(first); else node = new HashEntry<K,V>(hash, key, value, first); int c = count + 1; if (c > threshold && tab.length < MAXIMUM_CAPACITY) rehash(node); else setEntryAt(tab, index, node); ++modCount; count = c; oldValue = null; break; } } } finally { //释放锁 unlock(); } return oldValue; }
首先第一步的时候会尝试获取锁,如果获取失败肯定就是有其他线程存在竞争,则利用scanAndLockForPut()自旋获取锁。
1.尝试自旋获取锁。
2.如果重试的次数达到了MAX_SCAN_RETRIES则改为阻塞锁获取,保证能获取成功。
那他get的逻辑呢?
get逻辑比较简单,只需要将Key通过Hash之后定位到具体的Segment,再通过一次Hash定位到具体的元素上。
由于HashEntry中的value属性是用volatile关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。
ConcurrentHashMap的get方法是 非常高效的,因为整个过程都不需要加锁。
1.7虽然可以支持每个Segment并发访问,但是还是存在的一些问题
因为基本上还是数组加链表的方式,我们去查询的时候,还是便利链表,会降低效率很低,这个跟1.7的HashMap是存在一样问题,所以他在jdk1.8完全优化了。
jdk1.8 他的数据结构的变化
其中抛弃了原有的Segment分段锁,而采用了CAS + synchronization 来保证并发安全性。
跟HashMap很像,也吧之前的HashEntry改成Node,但是作用不变,把值和next采用了vilatile去修饰,保证了可见性,并且也引入了红黑树,在链表大于一定值的时候会转换(默认是8).
1.8之后的存取操作 线程怎么保证安全的
ConcurrentHashMap在进行put操作的还是比较复杂的,大致可以分为一下步骤:
1.根据 key 计算出 hashcode 。
2.判断是否需要进行初始化。
3.即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋4保证成功。
4.如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
5.如果都不满足,则利用 synchronized 锁写入数据。
6.如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。

CAS 是什么 自旋又是什么
CAS是一种乐观锁的一种实现方式,是一种轻量级锁,JUC中很多功率累的实现都是基于CAS的。
CAS 操作的流程如下图所示,线程在读取数据时不进行加锁 ,在准备写回数据时,比较原值是否修改,若未被其他线程修改则写回,若已经修改,则从新执行读取流程。
这是一种乐观策略,认为并发操作并不会总发生。

乐观锁在实际开发场景中非常常见,大家还是要去了解。
就比如我现在要修改数据库的一条数据,修改之前我先拿到他原来的值,然后再SQL里面还会加个判断,原来的值和我手上拿到的他的原来的值是否一样,一样我们就可以去修改了,不一样就证明被别的线程修改了你就return错误就好了。
SQL 伪代码大概如下:
update a set value = newValue where value = #{oldValue}//oldValue就是我们执行前查询出来的值
CAS就一定能保证数据没被别的线程修改过么
并不能,比如经典的ABA 问题,CAS 就 无法判断。
一个线程吧值改回了B,又一个线程吧值又改回了A,对于这个时候判断的线程,就发现他的值还是A,所以他就不知道这个值到底有没被人改过,其实很多场景如果只追求最后窃国正确,这是没关系的。
但实际过程中还是需要记录修改过程的,比如资金修改什么的,你每次修改都应该又记录,方便回溯。

浙公网安备 33010602011771号