HashMap可能是面试中问到的最多的数据结构,因为它良好的性能,所以深受广大程序员的喜爱。
一问HashMap都知道数组加链表,实际上呢?就让我们解读一下它的源码。
首先我们看看它的类结构:
public class HashMap extends AbstractMap
implements Map, Cloneable, Serializable {
//...
}
扩展于抽象Map实现了Map接口、Cloneable接口,Serializable接口,后两个不说了,通过ArrayList那一节我们已经知道他就是个标识符。
所以我们首先看看Map接口里面的方法。这里面的基本定义我以注释的形式给出
int size();//获取大小
boolean isEmpty();//是否为空
boolean containsKey(Object key);//是否包含某个键
boolean containsValue(Object value);//是否包含某个值
V get(Object key);//通过键获取值
V put(K key, V value);//放置键值对
V remove(Object key);//移除键值对
void putAll(Map extends K, ? extends V> m);//把一个Map里的键值对全部插入
void clear();//清空
Set keySet();//获得键的集合
Collection values();//获取值的集合
Set> entrySet();//获取键值对的集合
这些是我们用到的主要的方法。
还有一个接口Entry是存放键值对的。
interface Entry {
K getKey();
V getValue();
V setValue(V value);
boolean equals(Object o);
int hashCode();
public static , V> Comparator> comparingByKey() {
return (Comparator> & Serializable)
(c1, c2) -> c1.getKey().compareTo(c2.getKey());
}
public static > Comparator> comparingByValue() {
return (Comparator> & Serializable)
(c1, c2) -> c1.getValue().compareTo(c2.getValue());
}
public static Comparator> comparingByKey(Comparator super K> cmp) {
Objects.requireNonNull(cmp);
return (Comparator> & Serializable)
(c1, c2) -> cmp.compare(c1.getKey(), c2.getKey());
}
public static Comparator> comparingByValue(Comparator super V> cmp) {
Objects.requireNonNull(cmp);
return (Comparator> & Serializable)
(c1, c2) -> cmp.compare(c1.getValue(), c2.getValue());
}
}
对于Entry的接口除了几个读写key-value的方法,剩下的就是一些比较器,决定最后插入位置的,所以暂且不看。
然后是AbstractMap,有意思的是这个抽象类实现了Map接口,也就是说,它实现了一部分(不是全部的)Map方法。
我们怀着好奇的心思观察一下:
public abstract Set> entrySet();
public V put(K key, V value) {
throw new UnsupportedOperationException();
}
public V get(Object key) {
Iterator> i = entrySet().iterator();
if (key==null) {
while (i.hasNext()) {
Entry e = i.next();
if (e.getKey()==null)
return e.getValue();
}
} else {
while (i.hasNext()) {
Entry e = i.next();
if (key.equals(e.getKey()))
return e.getValue();
}
}
return null;
}
public boolean containsValue(Object value) {
Iterator> i = entrySet().iterator();
if (value==null) {
while (i.hasNext()) {
Entry e = i.next();
if (e.getValue()==null)
return true;
}
} else {
while (i.hasNext()) {
Entry e = i.next();
if (value.equals(e.getValue()))
return true;
}
}
return false;
}
public boolean containsKey(Object key) {
Iterator> i = entrySet().iterator();
if (key==null) {
while (i.hasNext()) {
Entry e = i.next();
if (e.getKey()==null)
return true;
}
} else {
while (i.hasNext()) {
Entry e = i.next();
if (key.equals(e.getKey()))
return true;
}
}
return false;
}
我们简直要喊666了。
这里面的put方法是不支持操作的,然后contain/get方法是线性搜索entrySet;然后获取entrySet的方法又是虚拟方法!
所以要不人家叫抽象map呢,说白了还是得你自己来,要不线性搜索也不和hashmap对路啊。对吧?
所以接下来我们把眼光秒向真正的HashMap类。
首先观察几个常量值。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;//默认初始化容量是16
static final int MAXIMUM_CAPACITY = 1 << 30;//最大容量是2^30
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//默认填充因子是0.75也就是超过3/4的数组被占用就扩容
static final int TREEIFY_THRESHOLD = 8;//树化阀值,当一个数组位置对应的链表长超过8,就转化为树
static final int UNTREEIFY_THRESHOLD = 6;//解除树结构的最小阀值
static final int MIN_TREEIFY_CAPACITY = 64;//最小树化容量
以上的常量看起来应该和某些操作的界限有关,但是这里我们还不知道,然后我们接着往后看。
但是这里面涉及了一个空间大小的哲学问题——当我们正在rehash的时候,相当于同时存在两个超大型数组所以为了不越界
最大的容量是2^30而不是2^31!!!
首先,查看构造器:
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;
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
我们可以选择初始化的时候设置它的初始化容量或者装填因子。
看起来我们不能随意设置初始化容量,最多只能达到它的最大值,但是看起来装填因子只要不小于等于0的值都是可以的喽。
实际上啊,装填因子大于1.0是一个非常不好的数字——那就装满为止咯。
既然要存储数据,就需要链表头的数组,我们看看对象变量。
transient Node[] table;
transient Set> entrySet;
transient int size;
transient int modCount;
int threshold;
final float loadFactor;
啊,从这些字段,对比之前已经接触过的List数据结构里的字段,我们似乎意识到了什么——transient的关键字表示依然数据与数据结构信息是分开存储的,而且modCount也能保证它不允许多个线程并发写入对象。然后装填因子是一个final值,一旦设定好了就不会再改。还有一个entrySet,是用来保存键值对的吗?我们无从而知,下面要看他的方法了。
按照增删改查的顺序,我们先看看put方面的方法。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node[] tab;
Node p;
int n, i;
//按照短路的原则,tab是被table赋值
//下面的if左边情况是新建的hashmap
//右边的情况的clear之类的清除动作之后的tab
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;//对tab扩容(也就是table),返回新容量n
if ((p = tab[i = (n - 1) & hash]) == null)//如果插入位为空,新建一个头结点
//这里要注意插入位是(n-1)&hash,所以hash不一定就是相等的
//当2的幂,(n-1)&hash == hash%n
tab[i] = newNode(hash, key, value, null);
else {
Node e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;//如果新节点的key不是null且键、hash都相等,让节点等于新节点
else if (p instanceof TreeNode)
//如果这个位置已经被树化了,那么就按照树的方法插入一个树节点
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {//binCount是节点数
if ((e = p.next) == null) {
//这说明是尾插法,是先扫描了整段链表
//然后用binCount是否超过树化阀值,如果超过就树化
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果发现一模一样的节点hash/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;
afterNodeAccess(e);//这个是给LinkedHashMap调用的
return oldValue;
}
}
++modCount;//所以更新value不算修改,增加节点才算修改一次
if (++size > threshold)
resize();//如果大于阈值,就扩张容量
afterNodeInsertion(evict);//也是给LinkedHashMap调用的,我们过几节再说
return null;//如果增加了节点就返回了null
}
通过这段源码我们知道了几个事情,而且从了解程度来看都非常重要:
1、如果插入了一个不存在的键值对就修改modCount,否则不修改
2、如果仅仅更新键值对(k=v1变为k=v2),那么返回旧值v1,否则返回null
3、当超过树化阈值就变为树
4、并不进行节点key检查,所以可以为null,value也是
5、插入节点采用尾插法便于寻找hash/key都相等的节点来更新
6、插入过程因为n总是2的x次方,所以用(n-1)&hash 代替hash%n;
为了说明第四点,我还做了一个小实验
public static void main(String[] args){
HashMap hashMap=new HashMap<>();
hashMap.put(null,"value1");
hashMap.put(2,null);
System.out.println(hashMap);//{null=value1, 2=null}
hashMap.put(null,null);
System.out.println(hashMap);//{null=null, 2=null}
}
以上代码表明,HashMap键值均可以为null;
但是
public static void main(String[] args){
Hashtable hashtable=new Hashtable<>();
hashtable.put(null,1);//NullPointerException
hashtable.put(2,null);//NullPointerException
System.out.println(hashtable);
}
对于Hashtable就完全不行(谁他娘的告诉我Hashtable是可以放null的?),有些面试题以讹传讹罢了。
首先,我们先观察他的扩容规则,从第一个if可以知道,最开始的table数组是一个null,什么都没有。
仅当开始放置第一个元素时,才开始新建(resize)。
那么我们看看它的resize函数
final Node[] resize() {//capacity*lordfactor=threshold
Node[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;//旧容量
int oldThr = threshold;//旧阈值
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
//如果旧容量已经超过了最大容量,把阈值设为最大正整数
//不进行rehash操作,直接返回旧的table
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//新的容量cap是2*旧容量,如果大于初始化容量(16),那么阈值也乘以2
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0)
//考虑当oldCap==0并且threshold是在初始化时给出容量的情况
newCap = oldThr;
else {
//new HashMap<>()的情况,使用默认初始化容量16和阈值12
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
//当阈值给0的情况,重新设定装填因子
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;//阈值被重新设定
@SuppressWarnings({"rawtypes","unchecked"})
Node[] newTab = (Node[])new Node[newCap];//创建新数组
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;//这一步是解除旧数组对链表的引用,使得能够GC
if (e.next == null)//旧数组就一个链表节点时,把这一个插到新数组
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
//如果是树形式,那么就分割(这个函数一会再说)
((TreeNode)e).split(this, newTab, j, oldCap);
else { // preserve order
Node loHead = null, loTail = null;
Node hiHead = null, hiTail = null;
Node next;
do {//rehash的过程
next = e.next;
if ((e.hash & oldCap) == 0) {
//考虑一下cap都是0b10...0的形式,所以
//按照hash的最高位是不是1分成了两段链表
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {//插入新数组
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
在这个函数里,我们可以的得到以下的事实:
1、无参数new的HashMap初始化容量为16,阈值12。
2、每次新容量是原来的2倍。
3、当容量已经无法再扩大,就把阈值直接设为最大正整数。
4、rehash过程采用了最高位1分割的算法,具体过程如下:
因为capacity总是2的倍数,所以最高位是1,其他位都是0,不妨假设是1...n个0(暂时不考虑二进制补码)
然后扩容后的新数组是1...n+1个0,这一个位置的链节点的hash有的对应位是1,有的对应位为0,然后是原数组的第j个节点。
考虑原来的插入位置是hash%n,新的位置是hash%(2*n)。那么hash=b*n+j (因为j是插入位置),
所以hash%(2n)只有两种结果!那就是n+j和j,为什么呢?因为当b为一个偶数的时候hash%(2n)=0+j ,如果b是一个奇数的时候,就是1*n+j由此完全可以确定两段新链表的插入位置!!!(我的妈啊写库的程序员实在厉害)
然后我们考虑一个奇数是以1结尾(二进制),偶数以0结尾所以只要“&”一下cap的最高位1就知道b是奇数还是偶数了。就这样,用更节省时间的方式rehash了一个就链表(考虑如果直接用类似add的过程,那就每次扫描新链表,十分花时间)。
现在我们要看看树化(add时候的treefify)和rehash过程的split(应该是重新变换成链表的过程)。
final void treeifyBin(Node[] tab, int hash) {
int n, index; Node e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
//e是要插入位置的头结点
TreeNode hd = null, tl = null;
do {
TreeNode p = replacementTreeNode(e, null);//链表节点转树节点
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);//这个循环把链表转换成了一串的树节点(双向)
if ((tab[index] = hd) != null)
hd.treeify(tab);//树化函数,现在hd是这一串的头结点
}
}
TreeNode replacementTreeNode(Node p, Node next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}
//从这看出来是转成了红黑树
static final class TreeNode extends LinkedHashMap.Entry {
TreeNode parent; // red-black tree links
TreeNode left;
TreeNode right;
TreeNode prev; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node next) {
super(hash, key, val, next);
}
和之前想的很有差异啊。
在之前看的博客上我还以为直接就把链表节点通过一系列措施(比如原地旋转之类的)就成了一颗红黑树。
但是实际上:
树化过程是先用prev和next指针指向树的前驱和后续,再进行树化的转换的。
那么,treeify函数应该就是真正的把一串树节点转化为树的了,我们看看是一个怎样的过程。
final void treeify(Node[] tab) {
//可以知道treeify是TreeNode的方法
TreeNode root = null;
for (TreeNode x = this, next; x != null; x = next) {
next = (TreeNode)x.next;
x.left = x.right = null;
if (root == null) {
x.parent = null;
x.red = false;
root = x;
}
else {
K k = x.key;
int h = x.hash;
Class> kc = null;
for (TreeNode p = root;;) {
int dir, ph;
K pk = p.key;
//这一段主要是用来比较节点的相对位置:
//先比较hash再比较key
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk);
TreeNode xp = p;
//从左到右一个个的查看,判断是插到右边还是插到左边
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
root = balanceInsertion(root, x);//按红黑树方式插入
break;
}
}
}
}
moveRootToFront(tab, root);//把根节点换到数组位置
}
红黑树的转化过程十分复杂,我们只要知道它是按照双向树链表的顺序一个个向根节点的前或者后插入。
因为经过一系列插入之后,树的根并不一定就是数组对应的头,所以moveRootToFront要把根节点换到数组槽的位置。
final void split(HashMap map, Node[] tab, int index, int bit) {
TreeNode b = this;
// Relink into lo and hi lists, preserving order
TreeNode loHead = null, loTail = null;
TreeNode hiHead = null, hiTail = null;
int lc = 0, hc = 0;
for (TreeNode e = b, next; e != null; e = next) {
next = (TreeNode)e.next;
e.next = null;
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
}
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
if (loHead != null) {
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
}
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
以上那段代码是红黑树分割为两段链表的rehash方法。
我们不禁好奇,不是应该用中序遍历递归神马的吗?
但是你忘记了吗,它是有prev和next指针的啊!所以:
只要像一段链表分割的方法(按1分割算法)就可以了!
然后如果分割完了还是大于树化阈值,那么依然转化成树;
如果分割完了小于反树化阈值,就以链表的形式插入!
至此,一个键值对如何插入过程已经完结(真麻烦)。
为了方便起见,接下来看看get方法,也是超级实用的按key找value:
public V get(Object key) {
Node e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node getNode(int hash, Object key) {
Node[] tab; Node first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {//找到数组对应头结点
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;//如果头结点是要找的key那么就返回
if ((e = first.next) != null) {
if (first instanceof TreeNode)
//如果是树结构,按二叉树搜索方法寻找
return ((TreeNode)first).getTreeNode(hash, key);
do {//如果是链表结构,线性搜索
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;//如果头结点是null只能返回null了
}
final TreeNode getTreeNode(int h, Object k) {
return ((parent != null) ? root() : this).find(h, k, null);
}
final TreeNode find(int h, Object k, Class> kc) {//树搜索
TreeNode p = this;
do {
int ph, dir; K pk;
TreeNode pl = p.left, pr = p.right, q;
if ((ph = p.hash) > h)
p = pl;
else if (ph < h)
p = pr;
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
else if (pl == null)
p = pr;
else if (pr == null)
p = pl;
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) &&
(dir = compareComparables(kc, k, pk)) != 0)
p = (dir < 0) ? pl : pr;
else if ((q = pr.find(h, k, kc)) != null)
return q;
else
p = pl;
} while (p != null);
return null;
}
这也是符合了那么麻烦的put方法的初衷了,过程是:
先计算hash对应的位置,如果这个位置目前是树结构,按树的logN方法搜索,否则是链表的线性搜索。
然后与上面所描述的同样过程可以得到containsKey的代码(不贴了)。
还有一个containValue方法呢?
public boolean containsValue(Object value) {
Node[] tab; V v;
if ((tab = table) != null && size > 0) {
for (Node e : tab) {
for (; e != null; e = e.next) {
if ((v = e.value) == value ||
(value != null && value.equals(v)))
return true;
}
}
}
return false;
}
我看到了浓浓的暴力搜索的味道,扫描数组然后扫描链表(或者树),看来没有hash的优化是十分费时的。
而且:
对于keySet和valueSet,都是事先不初始化(懒惰加载),然后依靠迭代器完成遍历的,也就是说并不是新建了一个Set,而是生成了一个迭代器。
然后看看“删”方法,删就是里面的remove:
public V remove(Object key) {
Node e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node[] tab; Node p; int n, index;
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node node = null, e; K k; V v;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
if (p instanceof TreeNode)//红黑树删除法
node = ((TreeNode)p).getTreeNode(hash, key);
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}//链表删除法
p = e;
} while ((e = e.next) != null);
}
}
//删除节点可能改变数组内节点,所以要判断是不是删除了头,是不是把根删除了
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode)node).removeTreeNode(this, tab, movable);
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
删除依然是按照两种方法删除。
然后清空:
public void clear() {
Node[] tab;
modCount++;
if ((tab = table) != null && size > 0) {
size = 0;
for (int i = 0; i < tab.length; ++i)
tab[i] = null;
}
}
清空就好理解多了,就是把数组置null了而已。
然后是简单到不能再简单的替换方法:
public boolean replace(K key, V oldValue, V newValue) {
Node e; V v;
if ((e = getNode(hash(key), key)) != null &&
((v = e.value) == oldValue || (v != null && v.equals(oldValue)))) {
e.value = newValue;
afterNodeAccess(e);
return true;
}
return false;
}
@Override
public V replace(K key, V value) {
Node e;
if ((e = getNode(hash(key), key)) != null) {
V oldValue = e.value;
e.value = value;
afterNodeAccess(e);
return oldValue;
}
return null;
}
就是先按之前的两种搜索方法获取节点然后检查是否需要改变。
至此,面试“高频”问题HashMap就告一段落。