之前的文章中我们可以看到,不管是 JDK1.7 还是 1.8 的HashMap都是线程不安全的,那么我们该如何处理这样的情况?该怎样保证线程安全呢?
通常有三种方式:
Hashtable
ConcurrentHashMap
类不过由于前两个并发度的原因,通常会选择第三个,它的效率和性能明显要比前两个高。
1.Collections.synchronizedMap(Map)是如何实现线程安全的?
我们来看SynchronizedMap的源码,如图SynchronizedMap内部维护了一个普通的Map对象和排斥锁 mutex :
我们在调用该方法时,需要传一个Map对象,可以看到有两个构造器,如果我们传入了 mutex ,那对象排斥锁就是传入的值。否则,就赋值为 this ,即调用 SynchronizedMap 的对象。
创建了 SynchronizedMap 之后,再对map集合操作的时候,就会对方法上锁,如下图:
2.Hashtable
在多线程环境下,它虽然比 HashMap 更安全,但是效率低,我们可以看下他的源码:
可以发现,它在对数据进行操作时都会用 synchronized 关键字上锁。当有多个元素存在资源竞争时,只能有一个线程可以获取到锁。更讨厌的是,读取操作互相之间又不影响,为啥不能同时进行?
所以 Hashtable 的缺点很明显,不管是get还是put,都是锁住了整个table,可见其效率低。因此,不适合高并发。
那HashMap 和 Hashtable 还有什么不同?
HashMap允许一个 null键,多个 null值,而 Hashtable不允许键和值为null。
从下图可以看到 HashMap 对 null 做了特殊处理:
但是 Hashtable 会在我们 put 空值的时候直接抛出空指针异常,如下图:
不过更详细的原因是因为 Hashtable 采用了安全失败机制(fail-safe),导致当前得到的数据不一定是集合最新的数据。(在下面会详细讲解 fail-safe)。
如果使用null值,就不能判断到底是映射的value是null,还是因为没有找到对应的key而为空,因为你无法再调用contain(key)来对key是否存在做判断,ConcurrentHashMap同理。在多线程情况下,即便此刻你能通过contains(key)知晓了是否包含null,下一步当你使用这个结果去做一些事情时可能其他并发线程已经改变了这种状态,而这对于用于单线程状态的hashmap是不可能发生的,它可以用contains(key) 去判断到底是否包含了这个null,从而做相应处理。
接着我们再继续看上面那个问题,它们还有哪些不同?
那到底什么是快速失败(fail-fast)?
快速失败(fail-fast)是java集合中的一种机制, 在用迭代器遍历一个集合对象时,如果遍历过程中对集合的内容进行了修改(增加、删除、修改),则会抛出 ConcurrentModificationException 异常。
具体效果我们看下代码:
Map map = new HashMap();
map.put("1", "我是a");
map.put("2", "它是b");
Iterator iter = map.keySet().iterator();
while (iter.hasNext()) {
Object key = iter.next();
System.out.println(map.get(key));
map.remove("1");
System.out.println("此时 map 的长度为"+map.size());
}
执行后的效果如下图:
为什么在用迭代器遍历时,修改集合就会抛异常?它的原理究竟是怎样的呢?
我们先来看下源码:
迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变 modCount 的值。
每当迭代器使用 hashNext()/next() 遍历下一个元素之前,都会检测 modCount 变量是否为 expectedModCount 值,如果是的话就返回遍历;否则抛出异常,终止遍历。
注意:这里是根据 modCount!=expectedModCount 作为抛出异常的条件,假如集合发生变化时修改 modCount 值刚好又设置为了expectedmodCount 值,则异常不会抛出。所以它还是有点靠谱的,因此只建议用于检测并发修改的bug。
fail-fast的场景有哪些?
在 java.util 包的集合类都是快速失败的,比如我们都接触过的 HashMap、ArrayList 这些集合类,都不能在多线程下发生并发修改(迭代过程中被修改),算是一种安全机制吧。
注意: java.util.concurrent 包下的类都是安全失败(比如:ConcurrentHashMap),可以在多线程下并发使用,并发修改。
安全失败机制(fail-safe)原理是啥?
采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。
由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所做的修改并不能被迭代器检测到,故不会抛出ConcurrentModificationException 异常
我们通过代码来看下是不是这样
ConcurrentHashMap concurrentHashMap = new ConcurrentHashMap();
concurrentHashMap.put("aaa", 1);
concurrentHashMap.put("bbb", 2);
concurrentHashMap.put("ccc", 3);
Set set = concurrentHashMap.entrySet();
Iterator iterator = set.iterator();
while (iterator.hasNext()) {
System.out.println(iterator.next());
concurrentHashMap.put("下次循环正常执行", 4);
}
System.out.println("程序结束");
运行效果如下,不会抛异常,程序正常执行。
再说明一下,快速失败和安全失败是对迭代器而言的。并发环境下建议使用 java.util.concurrent 包下的容器类。
说到这,我们再接着思考一下上面的问题,既然锁住了整张表,并发效率低,那我把整个表分成 N 个部分,并使元素尽量均匀分布到每个部分中,分别给他们加锁,互相之间并不影响,这样不就更好。这就是在 jdk1.7 中 ConcurrentHashMap 采用的方案,被叫做分段锁技术,每个部分就是一个Segment(段)。
3.ConcurrentHashMap
首先介绍下它的数据结构,他的底层是基于 数组+链表 组成的。不过在 jdk1.7 和 1.8 中具体实现稍有不同。
我们先来看下它在 jdk1.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 。
volatile 的特性是啥?
我在这里给大家举个例子:a从主存中读取数据,还没写入内存,b也从主存中读取了数据,这时候a自增赋值,为1,b自增赋值,也为1,在这种情况下,加上 volatile 就会变得安全。
ConcurrentHashMap 为什么并发度高?
原理上来说,ConcurrentHashMap 采用了分段锁技术,其中 Segment 继承于 ReentrantLock
。
它不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap 支持 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()
自旋获取锁。
MAX_SCAN_RETRIES
则改为阻塞锁获取,保证能获取成功。我们再接着看看他的get方法?
get时,只需要将 Key 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体的元素上。
由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。
ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁。
我们可以发现,虽然 jdk1.7 可以支持每个 Segment 并发访问,但是还是存在一些问题?
由于基本上还是数组加链表的方式,当我们去查询的时候,还得遍历链表,会导致效率很低,这个跟 jdk1.7 的HashMap是存在的一样问题,那大家肯定会有一个问题,既然这样的话,和 HashMap一样只优化红黑树不就行了?其实优化的更主要的原因是虽然 Segment 分段锁一次可以允许多个线程。其实每锁定一个 Segment 还是有很多 key 被同时锁住。但是如果使用 CAS的话,锁的颗粒度就降到了单个key,进一步提高了并发效率,这就是它在jdk1.8优化了的原因。
jdk1.8 中 ConcurrentHashMap 的数据结构是怎样的?
它抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized
来保证并发安全性。
跟HashMap很像,也把之前的HashEntry改成了Node,但是作用不变,把值和 next 采用了 volatile
去修饰,保证了可见性,并且也引入了红黑树(保证查询效率 O(logn)
),在链表大于一定值的时候会转换(默认是8)。
jdk1.8 中 ConcurrentHashMap的put操作大致可以分为以下步骤:
hashcode == MOVED == -1
,则需要进行扩容。TREEIFY_THRESHOLD
则要转换为红黑树。 final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
1. //根据 key 计算出 hashcode
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh; K fk; V fv;
2. //判断是否需要进行初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
3. //即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break; // no lock when adding to empty bin
}
4. //如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
5.//如果都不满足,则利用 synchronized 锁写入数据。
V oldVal = null;
synchronized (f) {
.....
}
if (binCount != 0) {
6. //如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
在这里解释一下 CAS 是什么?自旋又是什么?
CAS 是乐观锁的一种实现方式,它是一种轻量级锁,JUC中很多工具类都是基于 CAS 实现的,原子操作类也是基于 CAS 实现的。
下图是 CAS 的流程图,线程在读取数据时不加锁,在准备操作数据时,会比较原值是否修改,如果没有被其他线程修改就写回数据,否则重新读取。
这是一种乐观策略,认为并发操作并不总是经常发生。
比如我现在准备修改数据库里的一条数据,在修改之前我会先拿到他原来的值,然后在 SQL 里加一个判断,判断原来的值和我手上拿到的它的原值是否相同,如果相同就可以做修改了,不同就直接return 错误。
SQL伪代码如下:
update a set value = newValue where value = #{oldValue}
//oldValue就是我们修改之前查出来的值
CAS就一定可以确保数据没有被别的线程修改吗?
不是的,比如经典的 ABA 问题,它就无法保证了。
那ABA是什么?
举个例子吧,比如一个线程把值修改为 B ,另一个线程又把它改回了 A,这个时候发现值还是A,因此就无法判断值到底有没有被修改过,如果我们只追求最后的结果,而不记录整个过程的话,就没有关系。
但是实际比如我们去银行对资金进行操作的时候,都需要记录整个钱的修改过程,我们每次修改的时候都要有记录。
那ABA的问题该怎样解决?
其实很简单,比如我们可以用版本号来保证。我们在查询它原本值的时候加一个版本号,每次判断的时候就是判断值+版本号,如果成功就给版本号加1。
update a set value = newValue ,vision = vision + 1 where value = #{oldValue} and vision = #{vision} // 判断原来的值和版本号是否匹配,中间有别的线程修改,值可能相等,但是版本号100%不一样
除了版本号,还有很多类似的方法,比如可以获取时间戳,只有对的上才进行修改,而且更新的时候将时间也进行修改。
CAS 性能很高,但是 synchronized 性能却不咋行,为啥 jdk1.8 升级之后反而多了 synchronized?
synchronized之前一直都是重量级锁,但是 Java 官方对它进行了升级,它现在采用的是锁升级的方式去做的 。
针对 synchronized 获取锁的方式,Jvm 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程,然后再去获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就短暂自旋,防止线程被系统挂起,如果都失败就升级为重量级锁。
就这样一步一步升级,不过它最开始也是通过轻量级方式锁定的。从这也可以看出,新版的 JDK 中对 synchronized 优化是很到位的。
上面我们介绍完了ConcurrentHashMap的put操作,接下来看看它的get操作是如何进行的?
源码如下:
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
//根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
//如果是红黑树那就按照树的方式获取值。
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 都不满足那就按照链表的方式遍历获取值
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
了解了这么多,还有一个需要注意的点就是 ConcurrentHashMap 没有size,用 cellcounter 计数。