HashMap
是我们最常用的集合类型之一了。也由于其高效、使用方便,我们有必要对其内部进行梳理一番。
JDK1.8源码中,关于Map
的类图关系如下:
Map
家族的对比
从Map
的类图关系中,我们可以看出还是蛮丰富的。需要用到顺序的,可以使用TreeMap
,需要线程安全的可以使用HashTable
和ConcurrentHashMap
。其各自特点总结如下:
类 | 特点 |
---|---|
HashMap | 存储数据无序;非线程安全;key可为 ,但是其桶索引为0;效率为O(1) |
HashTable | 远古时代就具有的,存储数据无序;线程安全,但是是使用笨重的Synchronized ;key不可为
|
ConcurrentHashMap | 新贵,存储数据无序;线程安全,采用CAS ;key不可为
|
TreeMap | 存储数据有序;非线程安全 |
LinkedHashMap | 存储数据有序;非线程安全 |
让我们先从HashMap
开始。
HashMap
概述
JDK1.7中,HashMap
由数组和链表构成,当链表数据特别多的时候,很明显的其效率受到影响。于是,在JDK1.8中的HashMap
当链表数据过长时,转为红黑树的数据结构。
HashMap
源码
我们选取常用的几个方法(实例化、put)来看下源码。
HashMap
属性
//Map默认初始化的容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//Map的最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//链表转为红黑树的阈值
static final int TREEIFY_THRESHOLD = 8;
//红黑树转为链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
//链表转为红黑树表的阈值
static final int MIN_TREEIFY_CAPACITY = 64;
//桶数组
transient Node<K,V>[] table;
//对Map的entrySet结果缓存
transient Set<Map.Entry<K,V>> entrySet;
//key-value对的数量
transient int size;
//增加或者删除Map元素、rehash的次数
transient int modCount;
//HashMap需要resize的阈值
int threshold;
//负载因子
final float loadFactor;
HashMap
初始化
选取个复杂点的构造方法:
//initialCapacity表示HashMap的容量,loadFactor表示负载因子
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
//tableSizeFor是将initialCapacity转为不小于其的最小2的幂次方
this.threshold = tableSizeFor(initialCapacity);
}
//tableSizeFor的方法如下
//目前算法我看的不太明白
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
大家通过源代码可以看到,初始化时,可以说几乎没发生没什么事情。只赋值了loadFactor
和确保Map
的容量为2的幂次方。
这里就有个问题?为什么需要确保Map
的容量为2的幂次方?其实这是个非常规的设计,常规的设计是把桶的大小设计为素数(参考:https://blog.csdn.net/liuqiyao_01/article/details/14475159)。在讲完put
方法后我们来阐述。
其实,这里可以看作是Map
的延迟初始化。在首次put
元素时,会初始化属性table
。在这里顺便提下table
的类型Node
。在为链表时,其数据结构为:
//链表节点
static class Node<K,V> implements Map.Entry<K,V> {
//key的hash值
final int hash;
//Map的key值
final K key;
//Map的key对应的value
V value;
//下一个元素
Node<K,V> next;
//省略getter&setter、equals
}
当转为红黑树时,其结构为:
//树节点
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
}
HashMap put
方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//1.首次存入元素时,会在resize方法中初始化table
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//2.如果key对应的索引位置((n - 1) & hash)没有元素,则直接存入元素
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {//3.如果key对应的索引位置有元素
Node<K,V> e; K k;
//3.1 如果key相同,则直接覆盖key对应的value
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//3.2 如果key不同,判断Node是否属于红黑树类型,是则入树
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {//3.3 Node属于链表节点类型
for (int binCount = 0; ; ++binCount) {
//3.3.1 下一个节点为空,则插入链表中
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//3.3.1.1 节点数量是否超过树化的阈值,超过则进行树化。注意此处并非一定会转化为红黑树,还会判断属性table的长度,可以参考treeifyBin方法
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
//3.3.2 下一个节点不为空,判断key是否相同,相同则覆盖旧值
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//为LinkedHashMap预备
afterNodeAccess(e);
return oldValue;
}
}
//添加修改次数
++modCount;
//超过阈值则分配空间,重新rehash运算
if (++size > threshold)
resize();
//为LinkedHashMap预备
afterNodeInsertion(evict);
return null;
}
我们可以画个流程图:
其他的方法,譬如get
、remove
等,主要的都是先要确定key
对应的索引。即hash & (n - 1)
。其中hash
为key
对应的hash
值,n
为capacity
,即Map
的容量。
我们先来看下hash
的计算:
static final int hash(Object key) {
int h;
//如果为null,则索引为0;否则是其hashCode低16位与hashCode高16位的抑或结果
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
为什么hashCode
需要与其高16为抑或?简单点说就是让高位和低位都参与运算,使key
的分布尽量更均匀些。参见https://zhuanlan.zhihu.com/p/21673805
有了hash
值之后,我们可以计算索引的位置了。一般都是取模运算,但是大家都知道计算机是二进制的,位运算会比取余快多了。所以这里的hash & (n - 1)
可以看做是用空间来换取时间。因为当n
为2的幂次方时,n-1
的二进制恰恰全是1
,其与hashCode
的二进制与值正好是取模的结果。这里就回答了上面为什么需要确保Map
的容量为2的幂次方的问题。
HashMap
的源码目前就分析到这里。流程其实说起来不是很难,难的关键就是为什么设计者会是这样的设计?这恰恰是我们需要多思考的。本篇在关于这一点上还是远远不足的,大家可以多搜索几篇来看看,多思考思考。