Map接口常用的实现类有:ConcurrentHashMap、Hashtable、TreeMap、LinkedHashMap、HashMap。
Map是key-value对的集合。
它们之间的关系如下图,有些中间类或接口在图中没有画出:
1、HashMap
HashMap是我们最常用的Map实现类。默认装填因此是0.75,默认初始容量是16,其中装填因子只能通过构造函数设置。在构造函数中也可以设置初始容量,如果初始容量不是2的次幂,HashMap会将其修改为大于且最接近当前设定值的2的次幂值。当元素比较少时,HashMap的结构如下图:
在HashMap中,上图的table是一个数组,其定义如下:
transient Node<K,V>[] table;
table也称作桶,在构造函数中设置的初始容量指的就是桶的个数,也就是数组的大小。每个键值对使用Node表示,每新增一个键值对,就增加一个Node节点,通过单链表将这些节点连接起来。
那么Node节点如何加入到HashMap中?
当调用put()方法向HashMap中增加键值对时,先对键执行哈希操作:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
大家看到最后异或了h >>> 16
,这里异或高16位,是为了降低哈希碰撞,利用 hash 值高位与低位的异或处理来降低冲突的可能性,使得元素的位置不单单取决于低位。
之后调用putVal()方法:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果table为null,那么调用resize()创建数组,初始数组长度为16
//如果使用默认的构造方法,HashMap不会对table做初始化,此时table=null
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//使用(n - 1) & hash计算出桶位
//如果当前桶位没有Node节点,那么创建一个节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
//如果当前节点为树节点,则将元素插入红黑树中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//遍历桶位后面的链表
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//遍历到队列尾,将元素插入到队列尾
p.next = newNode(hash, key, value, null);
//如果链表长度大于等于8,则调用treeifyBin()检查是否将链表转换为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1)
//treeifyBin()方法里面会判断桶的个数,如果桶的个数
//小于64,则将桶扩容,否则转换为红黑树
treeifyBin(tab, hash);
break;
}
//切换到链表的下一个元素,继续遍历
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//如果key相等,将value替换为新的值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//检查是否超出threshold限制,如果超过则扩容
//threshold = 桶数 * 负载因子
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
相比于java8之前的版本,HashMap有三点改动非常大:
- 一是如果链表的长度小于8,则每次都将元素插入到链表尾,之前的版本是插入到链表头,之所以把元素插入到链表尾,其中一个原因就是在并发的情况下因为插入而进行扩容时可能会出现链表环而发生死循环,不过,HashMap本身是不用于处理并发情况的。
- 二是如果链表长度大于等于8且桶个数大于等于64,则将链表转换为红黑树,这个是之前的版本没有实现的,修改为红黑树之后,可以加快查询速度,红黑树的时间复杂度是O(nlogn);
- 当加入的键值对过多,超过了负载因子,那么HashMap启动再哈希,再哈希将键值对重新放入每个桶中,如果原来的Node节点是树,再哈希后发现树的元素个数小于等于6,那么HashMap会将树转换为链表。
在putVal()方法最后,如果元素个数超过了threshold(threshold = 桶数 * 负载因子),那么需要扩充桶,扩充桶是将table的长度扩充到原来的2倍,然后将每个键值对重新计算哈希值放入桶中。java8对每个键值对重新放入桶中也做了优化,想了解请参见文末的参考文章。
2、Hashtable
Hashtable将每个方法都使用synchronized关键字修饰。Hashtable与HashMap的原理类似,下面只介绍两者不同的地方:
- Hashtable的默认初始容量为11,它不像HashMap一样,容量必须是2的次幂,Hashtable使用
(hash & 0x7FFFFFFF) % tab.length
计算桶的位置; - 新增加的键值对放到链表头,无论链表多长,都不会转换为红黑树;
- 如果元素个数超过了阈值,Hashtable会扩容,扩容长度为
(oldCapacity << 1) + 1
,接下里对每个键值对重新计算哈希值,然后放入桶中。
HashMap做的很多优化在Hashtable上都没有,因此性能上,HashMap要优于Hashtable。
3、LinkedHashMap
LinkedHashMap是HashMap的子类。
HashMap有一个缺点,就是HashMap中的元素是无序的,无法按照插入顺序遍历元素,这时LinkedHashMap便提供了这个功能,可以按照插入顺序遍历。
为了实现这个功能,LinkedHashMap在内部维护了一个双链表,使用head和tail属性记录链表的头和尾:
transient LinkedHashMap.Entry<K,V> head;
transient LinkedHashMap.Entry<K,V> tail;
链表节点定义为:
static class Entry<K,V> extends HashMap.Node<K,V> {
//before指向当前节点的前一个节点
//after指向当前节点的后一个节点
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
每当插入新元素时都会创建一个Entry对象,并将这个Entry对象加入到链表的尾部,这样链表上节点的顺序就是元素的插入顺序,下面是创建Entry对象的代码:
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
//创建节点对象
LinkedHashMap.Entry<K,V> p =
new LinkedHashMap.Entry<K,V>(hash, key, value, e);
linkNodeLast(p);
return p;
}
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
//找到链表尾
LinkedHashMap.Entry<K,V> last = tail;
tail = p;
//将新节点插入到链表尾部
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
}
这样当我们使用Iterator或者调用forEach()方法遍历元素时,直接遍历双向链表即可。
LinkedHashMap相对于HashMap来说,插入一个元素既要插入到桶中,也要插入到链表中。
LinkedHashMap还提供了属性accessOrder,如果accessOrder=true,那么链表除了按照插入顺序排序之外,如果调用get()方法访问了该元素,那么该元素也会被移动到链表尾部。默认accessOrder=false。下面来看一下get()方法代码:
public V get(Object key) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) == null)
return null;
if (accessOrder)
afterNodeAccess(e);//调整当前元素到链表尾部
return e.value;
}
基于这个特性,可以使用LinkedHashMap实现LRU算法,使用越频繁的元素越靠后,当缓存满的时候,可以将链表头部的元素删除。
4、TreeMap
TreeMap是使用红黑树实现的,它没有桶的概念,使用属性root记录红黑树的树根:
//红黑树的树根
private transient Entry<K,V> root;
因为是基于树实现的,所以TreeMap没有初始容量,也没有负载因子的概念,而且还是无界的。它要求key不能为null,因为红黑树要比较元素大小,所以要么key实现接口Comparable,要么比较器属性comparator不能为null,如果属性comparator不为null,那么优先使用comparator比较元素大小。
根据key查询时直接在红黑树上搜索,时间复杂度是O(nlogn)的。同样的,插入、删除操作也都是O(nlogn)的。
TreeMap实现了NavigableMap接口,NavigableMap继承自SortedMap接口,前者在后者的基础上做了扩展,提供了大量的与顺序相关的方法,比如创建按照升序遍历的迭代器。因为TreeMap是有序的,所以可以很方便的创建按照升序或者降序遍历元素的迭代器,也可以查找大于/小于某个key的所有元素,以及TreeMap中的最大元素或者最小元素。
5、ConcurrentHashMap
ConcurrentHashMap是这些类中,唯一一个线程安全的Map实现类,它是基于CAS使用类Unsafe操作来操作元素。
ConcurrentHashMap的总体实现原理与HashMap是类似的,比如:
- ConcurrentHashMap的默认初始容量是16,默认负载因子是0.75;
- 与HashMap一样,也使用了数组来表示桶,当元素key的hash值一样时,会放到桶后面的链表尾上,如果链表长度达到了8,且总的元素个数大于等于64,则将链表改造成红黑树;
- 计算key的哈希值使用的算法与HashMap一样;
- 每次扩充桶时,都是扩充为原来的2倍,桶的个数永远都是2的次幂。
两个类计算key的哈希规则是不同,ConcurrentHashMap的计算规则是:
//入参h=key.hashCode()
static final int spread(int h) {
//int HASH_BITS = 0x7fffffff
return (h ^ (h >>> 16)) & HASH_BITS;
}
spread()方法里面使用0x7fffffff进行了与操作,这样得到的哈希结果一定是正的。那么为什么要哈希结果一定是正的呢?原因是负值在ConcurrentHashMap中有特殊含义,比如如果节点的哈希值是-1,表示正在进行再哈希,如果是-2,表示当前节点是红黑树的树根,我们可以在代码中看到key.hash>=0
的判断,大于0表示桶后面是一个链表。
除了使用CAS之外,当插入和删除元素、扩容操作时,还会使用synchronized关键字将槽位锁住,防止不同线程同时操作同一个链表或者红黑树。
当一个线程插入元素前后,发现另一个线程也在扩容,那么当前线程会去帮助扩容,这样可以加快扩容速度。
参考文章:
Java 程序员都该懂的 Java8 HashMap
Java8 中 ConcurrentHashMap工作原理的要点分析