一.HashMap 的工作原理
1.hashing的概念
Hashing(散列法)是一种将字符组成的字符串转换为固定长度的数值的方法,称为散列法,也叫哈希法。
2.HashMap 内部结构
map 结构图:
jdk1.8中HashMap 继承了map类,且内部类Node实现了Map类中的Entry接口:
由以上结构图可以看出,HashMap是数组和链表结合组成的复合结构,数组被分为一个个桶(bucket),每个桶可存储一个或多个Entry对象,在HashMap 中Node实现了Entry接口方法,每个Node对象包含四部分,分别为hash(哈希值)、key(键)、value(值),next(指向下一个Entry),key的哈希值决定了Node对象存储在哪一个数组索引下;如果Node中key的哈希值相同,则以链表形式存储。如果链表大小超过树形转换的阈值(TREEIFY_THRESHOLD= 8),链表就会被改造为树形结构。
如下图,TREEIFY_THRESHOLD的值默认为8:
3.HashMap 工作原理
HashMap 使用了 hashing 原理,我们通过 put() 方法存储数据而通过 get() 获取数据。HashMap 允许使用null值和null键。HashMap储存的是键值对,HashMap很快。HashMap的实现是非同步的。
-
使用put()方法:
HashMap 会先给键调用hashCode()方法,根据返回的hashCode找到bucket(桶,即对应的索引)位置来储存Node对象。HashMap是在bucket中储存键对象和值对象。
根据key计算hash值,设得到的索引为x:
(1)如果table[x]为空,直接插入;
(2)如果table[x]不为空,使用equals()方法检测到待插入元素key和首元素key相同,直接覆盖该首元素value;
(3) 如果table[x]不为空,使用equals()方法检测到待插入元素key和首元素key不相同,则判断是红黑树(treeNode),是红黑树,则直接在红黑树中插入键值对;
(4)如果table[x]不为空,使用equals()方法检测到待插入元素key和首元素key不相同,则判断是红黑树(treeNode),不是红黑树,则判断table[x]中的链表长度是否大 于8,如果大于8,则把链表转换为红黑树,在红黑树中执行插入操作;
(5)如果table[x]不为空,使用equals()方法检测到待插入元素key和首元素key不相同,则判断是红黑树(treeNode),不是红黑树,则判断table[x]中的链表长度是否大 于8,没有大于8,则直接在链表中插入,遍历过程中会使用equals()方法检测是否有相同的key,如果有直接覆盖value,没有即插入;
(6)插入之后,HashMap 会判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容,扩容将会创建原来HashMap大小的两倍的bucket数组。
-
使用get()方法:
(1) 使用get(key)时key,HashMap 会给key调用hashCode()方法,根据返回的hashCode找到bucket;
(1) 通过equals()方法比较桶的内部元素是否与key相等,若都不相等,则没有找到。若找到相等的key,则取出相等key记录的value。
4.HashMap 的负载因子(load factor)
由源码可以看到,当HashMap 的构造函数中没有指定负载因子的大小时,会默认为设置为0.75。这就说明当一个 map 填满了 75% 的 bucket 时候,将会扩充HashMap 大小的两倍的 bucket 数组,并将原来的对象放入新的 bucket 数组中。这个过程叫作 rehashing,因为它调用 hash 方法找到新的 bucket 位置。
二.为什么说 String, Interger 这样的 wrapper 类适合作为键
例如 String 是final 修饰的,即是不可变的。不可变性是key的必要条件,因为存储和获取的时候都要使用hashCode()方法进行处理,如果键值是可变的,就不能保证在放入时和获取时返回相同的 hashcode ,那么就不能从 HashMap 中找到你想要的对象。
三.HashMap 与 HashTable 对比区别
HashMap 是非 synchronized 的,性能更好,HashMap 可以接受为 null 的 key-value,而 Hashtable 是线程安全的,比 HashMap 要慢,不接受 null 的 key-value。
以下是源码截图:
(1)HashTable :
由此可见HashTable 是同步的,线程安全的,且不接受value为null的值。
(2)HashMap:
由此可见HashMap 是非同步的,非线程安全的,可接受value为null的值。
四.ConcurrentHashMap
1.ConcurrentHashMap是什么:
ConcurrentHashMap最外层不是一个大的数组,而是一个 Segment 的数组。每个 Segment 包含一个与 HashMap 数据结构差不多的链表数组。
2.为什么使用ConcurrentHashMap:
在 Java 中,HashMap 是非线程安全的,在多线程下可能会导致 map 数据错乱。为保证线程安全,可使用以下三种方式:
1)使用Hashtable
2)使用Collections.synchronizedMap类
3)使用ConcurrentHashMap
类
虽然Hashtable 是一个线程安全的类,但是Hashtable 几乎所有的添加、删除、查询方法都加了synchronized
同步锁!多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞等待需要的锁被释放,在多线程场景中性能就会非常差,所以不推荐使用 Hashtable !
而使用synchronizedMap的使用是类似的,如下synchronizedMap的源码可以看到,synchronizedMap的使用实际在实例化的时候传入map实例,然后在方法中加锁,可以看出,synchronizedMap也是全表锁,和
Hashtable 的使用情况类似,虽然保证了线程安全,但是在多线程场景中性能就也会非常差,所以不推荐使用 synchronizedMap
!
因此为了解决线程安全和多线程访问性能差的问题,引进了ConcurrentHashMap
类。
3.ConcurrentHashMap结构及原理:
在jdk1.7版本中,ConcurrentHashMap结构如下:
在读写某个 Key 时,先取该 Key 的哈希值。并将哈希值的高 N 位对 Segment 个数取模从而得到该 Key 应该属于哪个Segment,接着如同操作 HashMap 一样操作这个 Segment。
Segment 继承自 ReentrantLock,可以很方便的对每一个 Segmen 上锁。
读操作:
获取 Key 所在的 Segment 时,需要保证可见性。具体实现上可以使用volatile关键字,也可使用锁。但使用锁开销太大,而使用volatile时每次写操作都会让所有CPU内缓存无效,也有一定开销。ConcurrentHashMap 使用如下方法保证可见性,取得最新的Segment:
Segment<K,V> s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)
获取 Segment 中的 HashEntry 时也使用了类似方法:
HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE)
写操作:
对于写操作,并不要求同时获取所有 Segment 的锁,只需要获取该要查找的key所在的Segment 的锁。获取成功后就可以像操作一个普通的 HashMap 一样操作该 Segment,这样可保证该 Segment 的安全性,又不需要获取全局锁,其他Segment若没有被获取锁,即可被其他线程正常操作。理论上可支持线程安全的并发读写的个数为Segment的个数。
获取锁时,并不直接使用 lock 来获取,因为该方法获取锁失败时会挂起。事实上,它使用了自旋锁,如果 tryLock 获取锁失败,说明锁被其它线程占用,此时通过循环再次以 tryLock 的方式申请锁。如果在循环过程中该 Key 所对应的链表头被修改,则重置 retry 次数。如果 retry 次数超过一定值,则使用 lock 方法申请锁。
这里使用自旋锁是因为自旋锁的效率比较高,但是它消耗 CPU 资源比较多,因此在自旋次数超过阈值时切换为互斥锁。
在jdk1.8版本中,ConcurrentHashMap结构如下:
JDK1.8 中HashMap 添加了红黑树, ConcurrentHashMap也做了相应的优化,在JDK1.8 中 ConcurrentHashMap 类取消了 Segment 分段锁,采用 CAS
+ synchronized
来保证并发安全,数据结构跟 jdk1.8 中 HashMap 结构类似,都是数组 + 链表(当链表长度大于 8 时,链表结构转为红黑二叉树)结构。
ConcurrentHashMap 中 synchronized 只锁定当前链表或红黑二叉树的首节点,只要节点 hash 不冲突,就不会产生并发,相比 JDK1.7 的 ConcurrentHashMap 效率提高了非常多。
put操作中使用CAS
+ synchronized,源码如下:
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
else if (f instanceof ReservationNode)
throw new IllegalStateException("Recursive update");
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
4.ConcurrentHashMap总结:
1) HashMap 在多线程环境下操作不安全,于是在 java.util.concurrent
包下,java 为我们提供了 ConcurrentHashMap 类,该类保证在多线程下对 HashMap 操作安全;
2)在 JDK1.7 中,ConcurrentHashMap 采用了分段锁策略,先分成一个个Segment 数组, Segment 中又有一个 HashMap, 不同点是 Segment 继承自 ReentrantLock,在操作的时候给 Segment 赋予了一个对象锁,从而保证多线程环境下并发操作安全。
3)但是 JDK1.7 中,HashMap 容易因为冲突链表过长,造成查询效率低,所以在 JDK1.8 中,HashMap 引入了红黑树特性,当冲突链表长度大于 8 时,会将链表转化成红黑二叉树结构。因此 ConcurrentHashMap对应 也采用了与 HashMap 类似的存储结构,且ConcurrentHashMap 在JDK1.8 中没有采用分段锁的策略,而是在元素的节点上采用 CAS + synchronized
操作来保证并发的安全性。