呵呵呵,原理nmb。
HashTable,HashMap,ConcurrentHashMap
当你作为一个菜鸡的时候,别人就会那这个来问你。
为什么要用ConcurrentHashMap,因为HashMap不是线程安全的,这种线程不安全性体现在进行迭代的时候,也就是用Iterator进行访问。
问题来了,呵呵。
HashMap为什么是不安全的?
HashMap在put的时候,插入的元素超过了容量(由负载因子决定)的范围就会触发扩容操作,就是rehash,这个会重新将原数组的内容重新hash到新的扩容数组中,在多线程的环境下,存在同时其他的元素也在进行put操作,如果hash值相同,可能出现同时在同一数组下用链表表示,造成闭环,导致在get时会出现死循环,所以HashMap是线程不安全的。
觉得还是要用一个例子来说明这种情况。
为啥HashTable是安全的?
另一个键值存储集合HashTable,它是线程安全的,它在所有涉及到多线程操作的都加上了synchronized关键字来锁住整个table,这就意味着所有的线程都在竞争一把锁,在多线程的环境下,它是安全的,但是无疑是效率低下的。
Collections.synchronizedMap()函数返回的是线程安全的HashMap。
平常多线程真的用的比较少,只有在练习代码写写多线程。
package com.tuhooo.practice.concurrent; import java.util.*; public class TestConcurrent implements Runnable { private static HashMap<String, Object> m = new HashMap<String, Object>(); private Integer flag; static {
for(int i=0; i<10000; i++) {
m.put("hahaha" + i, "hahaha" + i);
}
} TestConcurrent(Integer flag) {
this.flag = flag;
} public void run() {
if(new Integer(1).equals(flag)) {
final Iterator<String> iterator = m.keySet().iterator();
while(iterator.hasNext()) {
System.out.println(iterator.next());
}
} else {
final Set<String> strings = m.keySet();
for(String key : strings) {
System.out.println("remove " + m.remove(key));
}
}
} public static void main(String[] args) {
new Thread(new TestConcurrent(1)).start();
new Thread(new TestConcurrent(2)).start();
}
}
下面再用Collections.synchronizedMap(),并在HashMap遍历的时候加上同步锁,可是仍然会出现并发修改异常。因为Collections.synchronizedMap(),只是简单地为HashMap的操作加了一个锁而已。
package com.tuhooo.practice.concurrent; import java.util.*; public class TestConcurrent implements Runnable { private static Map<String, Object> m;
private Integer flag;
private static Object obj = new Object(); TestConcurrent(Integer flag, Map<String, Object> map) {
this.flag = flag;
m = map;
} public void run() {
if(new Integer(1).equals(flag)) { // 这里用了同步操作, 可是还是会出错
synchronized (obj) {
final Iterator<String> iterator = m.keySet().iterator(); while(iterator.hasNext()) {
System.out.println(iterator.next());
}
}
} else {
final Set<String> strings = m.keySet();
for(String key : strings) {
System.out.println("remove " + m.remove(key));
}
}
} public static void main(String[] args) { final Map<String, Object> map = Collections.synchronizedMap(new HashMap<String, Object>()); for(int i=0; i<10000; i++) {
map.put("hahaha" + i, "hahaha" + i);
}
new Thread(new TestConcurrent(1, map)).start();
new Thread(new TestConcurrent(2, map)).start();
}
}
p.s. 这里好像写错了,锁应该加在keySet()获得key的集合上,而不是找个obj加锁。
其实HashTable有很多的优化空间,锁住整个table这么粗暴的方法可以变相的柔和点,比如在多线程的环境下,对不同的数据集进行操作时其实根本就不需要去竞争一个锁,因为他们不同hash值,不会因为rehash造成线程不安全,所以互不影响,这就是锁分离技术,将锁的粒度降低,利用多个锁来控制多个小的table。
也就是用这两种写法都会出错,那么我们看一下ConcurrentHashMap是怎么做的。
在JDK1.7版本中,ConcurrentHashMap的数据结构是由一个Segment数组和多个HashEntry组成,如下图所示:
所以Segment的大小取值都是以2的N次方,无关concurrencyLevel的取值,当然concurrencyLevel最大只能用16位的二进制来表示,即65536,换句话说,Segment的大小最多65536个,没有指定concurrencyLevel元素初始化,Segment的大小ssize默认为16
每一个Segment元素下的HashEntry的初始化也是按照位于运算来计算,用cap来表示,HashEntry大小的计算也是2的N次方(cap <<=1), cap的初始值为1,所以HashEntry最小的容量为2
HashMap中常用的四种操作有:
在多线程环境下,get,put,remove都是比较容易实现的(如果不考虑效率,简单加锁即可),迭代的操作才是真正的难点。
默认一个ConcurrentHashMap中有16个子HashMap,所以相当于一个二级哈希。对于所有的操作都是先定位到子HashMap,再作相应的操作。
public V get(Object key)
先得到 key所在的table,再像HashMap一样get,
中间并不加锁
ConcurrentHashMap的get操作跟HashMap类似,只是ConcurrentHashMap第一次需要经过一次hash定位到Segment的位置,然后再hash定位到指定的HashEntry,遍历该HashEntry下的链表进行对比,成功就返回,不成功就返回null。
public V put(K key, V value)
先得到所属的table,加锁,
判断table是否要扩容
如果table要扩容,则产生newTable,
hash值相同的slot整体移到newTable,
hash值不同的slot,把oldTable中的所有Entry都复制到newTable中。
因为有可能其它线程在历遍这个table,所以不能把原来的链表拆断。
更深入的,Segment实现了ReentrantLock,也就带有锁的功能,当执行put操作时,会进行第一次key的hash来定位Segment的位置,如果该Segment还没有初始化,即通过CAS操作进行赋值,然后进行第二次hash操作,找到相应的HashEntry的位置,这里会利用继承过来的锁的特性,在将数据插入指定的HashEntry位置时(链表的尾端),会通过继承ReentrantLock的tryLock()方法尝试去获取锁,如果获取成功就直接插入相应的位置,如果已经有线程获取该Segment的锁,那当前线程会以自旋的方式去继续的调用tryLock()方法去获取锁,超过指定次数就挂起,等待唤醒。
public V remove(Object key)
remove操作,如下图,要删除Entry3,则先复制Entry1为Entry1*,Entry1*指向Entry4,再复制Entry2为Entry2*,Entry2*指向Entry1*,最终形成一个两叉的链表。原本的Entry1,Entry2,Entry3会被GC自动回收。
迭代操作
ConcurrentHashMap的历遍是从后向前历遍的,因为如果有另一个线程B在执行clear操作时,会把table中的所有slot都置为null,这个操作是从前向后执行
如果线程A在历遍Map时也是从前向后,则有可能会出现追赶现象。
size操作
计算ConcurrentHashMap的元素大小是一个有趣的问题,因为他是并发操作的,就是在你计算size的时候,他还在并发的插入数据,可能会导致你计算出来的size和你实际的size有相差(在你return size的时候,插入了多个数据),要解决这个问题,JDK1.7版本用两种方案。
第一种方案他会使用不加锁的模式去尝试多次计算ConcurrentHashMap的size,最多三次,比较前后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的;
第二种方案是如果第一种方案不符合,他就会给每个Segment加上锁,然后计算ConcurrentHashMap的size返回。
JDK1.8的实现
JDK1.8的实现已经摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap,虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本。