区别简述
HashMap是JAVA中最常见的键值对存储对象,但是存在线程安全问题,如果有多个线程同时操作HashMap对象,有可能出现锁问题。早期解决这个问题是使用HashTable,后来又推出性能更高的ConcurrentHashMap,HashTable和ConcurrentHashMap都是线程安全的类。
HashMap为啥会出现死锁
HashMap使用【数组+链表】的方式实现,其中有个功能是自动扩容。在操作HashMap的put方法时,如果发现table容量超过阀值时,会自动扩大数组长度(即自动扩容)。这样的设计出发点是合理利用内存空间,但也是导致死循环的问题所在。
扩容并不是简单的增加原有数组大小,HashMap是根据Key的Hash值计算出key应该在数组中的下标,不同的key值计算出数组下标相同时,叫做Hash碰撞,这种情况下会形成一个链表。在【自动扩容】时,由于数组容量变动,所以原来根据key的hash计算出来的数组下标会变化,相对来说原来数组中的数据成了散乱放置。必须新建一个数组把原来数组中的值转移到新的数组中,问题就在转移的这段代码。如下:
void transfer(Entry[] newTable)
{
Entry[] src = table;
int newCapacity = newTable.length;
//从OldTable将元素一个个拿出来,然后放到NewTable中
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next;
//计算节点在新的Map中的位置
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
在链表中的每一个元素都会记录它的下一个值,转移数据的过程是把链表中的值从头部取出(取出的值叫做e),计算出e在新的数组中新的下标,e的next指向链表的原有的头部,成为链表新的头部,原来链表的头部就成了链表的第二个元素。这个逻辑产生的现象是,原先链表A->B ->C 的顺序,转移完成之后可能是C->B->A,链表顺序完全颠倒。顺序可以颠倒是多线程情况下,出现死锁的漏洞所在。
还原死循环的场景,假如有两个线程T1,T2都将操作同一个HashMap对象,T1在执行get方法,运行到已经查询到A的下一个值是B,将要查B的下一个值时挂起。T2运行执行扩容,扩容之后B的下一个值是A。这时T2挂起T1继续运行,查找B的下一个值,这时发现B的下一个值是A,就出现A-B-A-B…死循环。
HashMap 在table中下标的计算
【取模】【按位与】两种数学算法在计算机中【按位与】效率远远高于【取模】,为了提高效率table的大小都设置的是2的N次方。JDK1.8用【Hash值高16位与hash本身异或的值】与【table数组大小减1后的值】按位与,实现与取模一样的效果。相比JDK1.7做了4次扰动函数,JDK1.8只用了一次干扰函数,效率得到了提高。降低了干扰次数会不会导致获取的数组小标不够均匀?由于JDK1.8使用的是【Hash值高16位】与【hash本身】异或,最大程度保留了高位和低位的特性,使得数组下标值的获取分布更加均匀。
JDK1.8
// 代码1
static final int hash(Object key) { // 计算key的hash值
int h;
// 1.先拿到key的hashCode值; 2.将hashCode的高16位参与运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 代码2
int n = tab.length;
// 将(tab.length - 1) 与 hash值进行&运算
int index = (n - 1) & hash;
ava中有三种移位运算符
<< : 左移运算符,num << 1,相当于num乘以2
>> : 右移运算符,num >> 1,相当于num除以2
>>> : 无符号右移,忽略符号位,空位都以0补齐
& (按位与) :同为1则为1,否则为0
| (按位或) :任意一个为1则为1,否则为0
^ (按位异或) :相同则为1,否则为0
JDK1.7
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
// 这里是扰动函数
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
static int indexFor(int h, int length) {
return h & (length-1);
}
HashTable如何解决死锁问题
HashTable在put和get方法都加了sychonized关键字。
sychonized用在方法上时,作用于方法,锁住的是方法所在的对象,因此put方法和get方法不能被同时调用。加锁之后解决了死锁问题,但是在并发的情况下这种设计效率比较低。
HashTable与HashMap的区别
HashTable的Key和Value都不能为null,HashMap的key可以有一个null,value可以有多个null.
ConcurrentHashMap如何解决死锁问题
ConcurrentHashMap的实现在JDK1.7和JDK1.8中有所区别。
JDK1.8
ConcurrentHashMap是,在保证线程安全前提下,最高效的Map实现类,因此它的实现也最复杂。关于死锁问题的解决方式,简单来说就是灰度生效,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());
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;
}
//eh<0表示该节点是树节点或正在扩容
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;
}
JDK1.7
相比JDK1.7的ConcurrentHashMap, JDK1.8做了非常大的改进。JDK1.7使用分数组(Segment) + 数组(HashEntry) + 链表(HashEntry节点)的结构,而JDK1.8是Node数组+链表 / 红黑树,所以1.8的性能更高。JDK1.8使用CAS+volatile 来控制并发, 锁的粒度在Node,对数据的影响很小。而JDK1.7的分段锁所的对象是Segment,颗粒度更大。
总结:
HashMap在扩容过程中会出现死锁,JDK1.8ConcurrentHashMap通过ForwordingNode解决查询时的数据源问题,同时volatile关键字的是使用保证CAS后的数据可及时同步到其他线程。