前言
无意中发现有很多对Map尤其是HashMap的线程安全性的话题讨论,在我的理解中,对HashMap的理解中也就知道它是线程不安全的,以及HashMap的底层算法采用了链地址法来解决哈希冲突的知识,但是对其线程安全性的认知有限,故写这篇博客的目的就是让和我对这块内容不熟悉的小伙伴有一个对HashMap更深的认知,为了让你更好的查看你感兴趣的内容,你也可以直接点击右边的文章目录进行阅读。
写这篇文章是在一年前,这里只是重新发表
哈希表
在数据结构中有一种称为哈希表的数据结构,它实际上是数组的推广。如果有一个数组,要最有效的查找某个元素的位置,如果存储空间足够大,那么可以对每个元素和内存中的某个地址对应起来,然后把每个元素的地址用一个数组(这个数组也称为哈希表
)存储起来,然后通过数组下标就可以直接找到某个元素了。这种方法术语叫做直接寻址法
。这种方法的关键是要把每个元素和某个地址对应起来,所以如果当一组数据的取值范围很大的时候,而地址的空间又有限,那么必然会有多个映射到同一个地址,术语上称为哈希冲突
,这时映射到同一个地址的元素称为同义词
。毕竟,存储空间有限,所以冲突是不可避免的,但是可以尽量做到减少冲突。目前有两种比较有效的方法来解决哈希冲突:
- 链地址法
- 开放地址法
这里简要说明一下开放地址法,顾名思义,就是哈希表中的每个位置要么存储了一个元素要么为NULL。当数据比较多的时候,查找一个元素挺费事的,但是可以使用探测的方法进行查找。这个话题与本主题关系不大,感兴趣的小伙伴可以自行研究。
链地址法
为什么要把链地址法单独拿出来呢?因为后面有用。
链地址法的大概思想是:对于每个关键字,使用哈希函数确定其在哈希表中位置(也就是下标),如果该位置没有元素则直接映射到该地址;如果该位置已经有元素了,就把该元素连接到已存在元素的尾部,也就是一个链表,并把该元素的next设置为null。这样的话,每个哈希表的位置都可能存在一个链表,这种方式要查找某个元素效率比较高,时间复杂度为O(1+a),a为哈希表中每个位置链表的平均长度。这里需要假设每个元素都被等可能映射到哈希表中的任意一个位置。
下面这张图展示了链地址法的过程:
HashMap
HashMap底层实现
HashMap允许使用null作为key或者value,并且HashMap不是线程安全的,除了这两点外,HashMap与Hashtable大致相同,下面是官方API对HashMap的描述:
Hash table based implementation of the Map interface. This implementation provides all of the optional map operations, and permits null values and the null key. (The HashMap class is roughly equivalent to Hashtable, except that it is unsynchronized and permits nulls.) This class makes no guarantees as to the order of the map; in particular, it does not guarantee that the order will remain constant over time.
如果有多个线程对Hash映射进行访问,那么至少有一个线程会对哈希映射进行结构的修改:
结构上的修改是指添加或删除一个或多个映射关系的任何操作;仅改变与实例已经包含的键关联的值不是结构上的修改
那么很显然,当多个线程同时(严格来说不能称为同时,因为CPU每次只能允许一个线程获取资源,只不过时间上非常短,CPU运行速度很快,所以理解为同时
)修改哈希映射,那么最终的哈希映射(就是哈希表)的最终结果是不能确定的,这只能看CPU心情了。如果要解决这个问题,官方的参考方案是保持外部同步,什么意思?看下面的代码就知道了:
Map m = Collections.synchronizedMap(new HashMap(...));
但是不建议这么使用,因为当多个并发的非同步操作修改哈希表的时候,最终结果不可预测,所以使用上面的方法创建HashMap的时候,当有多个线程并发访问哈希表的情况下,会抛出异常,所以并发修改会失败。比如下面这段代码:
for (int i = 0; i < 20; i++) {
collectionSynMap.put(i, String.valueOf(i));
}
Set<Entry<Integer,String>> keySets = collectionSynMap.entrySet();
Iterator<Entry<Integer, String>> keySetsIterator = keySets.iterator();
try {
while(keySetsIterator.hasNext()){
Entry<Integer,String> entrys = (Entry<Integer, String>) keySetsIterator.next();
System.out.println(entrys.getValue());
if(entrys.getValue().equals("1")){
System.out.println(entrys.getValue());
collectionSynMap.remove(1);
//keySetsIterator.remove();
}
}
} catch (Exception e) {
e.printStackTrace();
}
就会抛出ConcurrentModificationException异常,因为在使用迭代器遍历的时候修改映射结构,但是使用代码中注释的删除是不会抛出异常的。
通过上面的分析,我们初步了解HashMap的非线程安全的原理,下面从源码的角度分析一下,为什么HashMap不是线程安全的:
public V put(K key, V value) {
//这里省略了对重复键值的处理代码
modCount++;
addEntry(hash, key, value, i);
return null;
}
那么问题应该处在addEntry()上,下面来看看其源码:
void addEntry(int hash, K key, V value, int bucketIndex) {
//如果达到Map的阈值,那么就扩大哈希表的容量
if ((size >= threshold) && (null != table[bucketIndex])) {
//扩容
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
//创建Entry键值对,此处省略这部分代码
}
假设有线程A和线程B都调用addEntry()方法,线程A和B会得到当前哈希表位置的头结点(就是上面链地址法的第一个元素),并修改该位置的头结点,如果是线程A先获取头结点,那么B的操作就会覆盖线程A的操作,所以会有问题。
下面再看看resize方法的源码:
void resize(int newCapacity) {
//此处省略如果达到阈值扩容为原来两倍的过程代码
Entry[] newTable = new Entry[newCapacity];
//把当前的哈希表转移到新的扩容后的哈希表中
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
所以如果有多个线程执行put方法,并调用resize方法,那么就会出现多种情况,在转移的过程中丢失数据,或者扩容失败,都有可能,所以从源码的角度分析这也是线程不安全的。
HashMap测试代码
for (int i = 0; i < 40; i++) {
hashMap.put(i, String.valueOf(i));
}
Set<Entry<Integer,String>> keySets = hashMap.entrySet();
final Iterator<Entry<Integer, String>> keySetsIterator = keySets.iterator();
Thread t3 = new Thread(){
public void run(){
try {
while(keySetsIterator.hasNext()){
Entry<Integer,String> entrys = (Entry<Integer, String>) keySetsIterator.next();
System.out.println(entrys.getValue());
if(entrys.getValue().equals("1")){
System.out.println(entrys.getValue());
hashMap.remove(1);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
};
Thread t4 = new Thread(){
public void run(){
try {
while(keySetsIterator.hasNext()){
Entry<Integer,String> entrys = (Entry<Integer, String>) keySetsIterator.next();
System.out.println(entrys.getValue());
if(entrys.getValue().equals("1")){
System.out.println(entrys.getValue());
hashMap.remove(1);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
};
t3.start();
t4.start();
这段代码启动了两个线程并发修改HashMap的映射关系,所以会抛出两个ConcurrentModificationException异常,通过这段测试代码在此证明了HashMap的非线程安全。