什么是hash?
Hash,一般翻译做"散列",也有直接音译为"哈希"的,就是把任意长度的输入(又叫做预映射, pre-image),通过散列算法,变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
——360百科
map是一种存储键值对的数据结构,hashmap是我们在Java中最常用到的一种map。它是基于hash算法的map,即:根据散列算法将一个个键值对存储在hash表中,因此hashmap存储数据是不保证有序的。hashmap进行散列的依据是键而非值。hashmap允许存储键值均为null的键值对。当hashmap的键相同时,新添加的键值对会对原来的进行替换。
哈希碰撞(哈希冲突):
由于hash算法可以将任意长度的输入转化为固定长度的输出,因此必然存在不同的输入对应的定长输出相同的情况。在hashmap中表现为经过一系列算法(hash函数以及下标计算)计算出的hash表的位置相同。
数据结构:
数组(hash表)+链表(单链表)(头节点存储在hash表中)+红黑树(根节点存储在hash表中);
如下图:
说明:
hashmap根据hash算法(key的高16位与低16位的异或运算)得到hash值,再根据i=n-1 & hash的结果计算出下表将节点存储在hash表中,hash表的的一个个位置我们通常称为一个桶,桶中所装元素及其之后链接的元素称为bin。当发生hash冲突后,hashmap会将新的节点以链表的方式尾插到这个位置的节点之后。当哈希表(table数组)的长度大于等于64,且链表长度大于等于8之后,会对该链表进行树化转化为红黑树。
红黑树:
红黑树是每个节点都带有颜色属性的二叉查找树,颜色或红色或黑色。在二叉查找树强制一般要求以外,对于任何有效的红黑树我们增加了如下的额外要求:
性质1. 节点是红色或黑色。
性质2. 根节点是黑色。
性质3. 每个叶节点(NIL节点,空节点)是黑色的。
性质4. 每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)
性质5. 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。
源码解析:
了解了其数据结构后,下面我们对hashmap的源码解析:
首先看其类的定义:
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
1、HashMap继承于AbstractMap,AbstractMap实现了Map接口,其底层是一个存储Entry<K, V>的set集合,要求实现public abstract Set<Entry<K,V>> entrySet();抽象方法。
2、HashMap实现了Map接口,Map接口有一个子接口Entry<K, V>,HashMap的子类Node就是实现了这个接口生成的。
3、HashMap实现了Cloneable接口,代表其可被克隆。
4、HashMap实现了Serializable接口,代表其可序列化与反序列化。
HashMap默认常量:
//默认的map容量,16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4
//默认的最大容量,2^30,1,073,741,824
static final int MAXIMUM_CAPACITY = 1 << 30
//默认扩容因子,存储元素数量与容量之比大于扩容因子会触发扩容机制
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//默认链表树化阈值为8
static final int TREEIFY_THRESHOLD = 8;
//默认树降级为链表阈值为6
static final int UNTREEIFY_THRESHOLD = 6;
//最小树化总容量为64,结合TREEIFY_THRESHOLD = 8可知存储元素数量大于64
//且某个桶中的链表长度大于8,这个链表就会树化
static final int MIN_TREEIFY_CAPACITY = 64;
HashMap属性成员:
//JDK1.7的类为Entry,改了个名字,table是哈希表
transient Node<K,V>[] table;
//键值对的一个set集合
transient Set<Map.Entry<K,V>> entrySet;
//哈希表元素个数
transient int size;
//操作数,对哈希表的操作次数
transient int modCount;
//扩容阈值
int threshold;
//负载因子
final float loadFactor;
构造方法:
//双参构造,通过tableSize调整初始容量为2的整数倍
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;
//将扩容阈值变为大于或等于initialCapacity的最小的2的倍数的值
//如传入的值为9则计算结果为16
//如传入的值为25则计算结果为32
this.threshold = tableSizeFor(initialCapacity);
}
//单参构造,采用默认的负载因子0.75,再调用双参调整initialCapacity
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//无参构造,采用默认的负载因子0.75
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
可以看到HashMap的构造方法做的事不是很多,简单的判断+赋值,我们主要讲一下该表扩容阈值的方法tableSizeFor:
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;
}
测试代码:
public class TestForHash {
static final int MAXIMUM_CAPACITY = 1 << 30;
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;
}
public static void main(String[] args) {
System.out.println(tableSizeFor(-22));
System.out.println(tableSizeFor(0));
System.out.println(tableSizeFor(1));
System.out.println(tableSizeFor(2));
System.out.println(tableSizeFor(8));
System.out.println(tableSizeFor(9));
System.out.println(tableSizeFor(17));
System.out.println(tableSizeFor(70));
}
}
测试结果:
原理:
给cap减1的目的是为了防止特殊值造成结果不合理,比如当cap是2的整数倍时,希望得到的就是其本身。如果我们去掉这个减一,当cap为2的倍数的时候,结果就是其的2倍。
可以看到传入容量为负数或者0,1时最终得到的结果都是1,大于1以后得到的结果是大于这个数的最小2的倍数。
当然在构造方法调用的时候是已经排除了负数的。
当n>0的时候,这时候我们可以不用管符号位了,因为其怎么都为0,
假设n从左到右数第一个为1的位是从左往右数第x位
右移第一次后并作或运算,从左到右数第x+1位变为1,相与后第x位与x+1位都变成了1。
右移第二次后并作或运算,从左到右数第x+2、x+3位变为1,相与后第x位、x+1位、x+2、x+3位都变成了1。
……
以此类推,就可以保证得到第x位开始,包括x位之后所有位都为1的一个数,再给其加1,得到的就是第x-1位为1,其余均为0的数了,这个数是2的倍数且比cap大,而且最高位1仅比cap多一位,就是HashMap想要的比cap大的最小2的倍数的值。
主要方法解析:
hash方法:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
HashMap的hash方法是将按的hashCode与其高16位的值进行异或运算。(h >> 16会得到h的高16位),这是HashMap进行的一次位扰动,让其高16位也尽量参与到计算之中,使结果尽可能散列,减少hash碰撞。
put与putVal方法:
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<K,V>[] tab;//数组
Node<K,V> p; //临时的一个节点
int n, i;//n表示数组长度
if ((tab = table) == null || (n = tab.length) == 0)
//进行到这里说明tab为空或者说tab不为空但长度为0,则初始化tab
//并且对n再次赋值
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
//进行到这里说明这个桶中没bin,直接加,上面语句对p赋值了,p是这个下标处的桶里存放的值
tab[i] = newNode(hash, key, value, null);
else {
//显然进入这个else则产生了哈希碰撞了
Node<K,V> e;//临时节点
K k;//临时键
//键的对象相等或者equals结果相等,就是键相等的意思
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//进行到这里说明键相等了,那么覆盖就可以了。
e = p;
else if (p instanceof TreeNode)
//进行到这里说明桶中bin是树节点,那么调用插入树节点的方法
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);
if (binCount >= TREEIFY_THRESHOLD - 1)
//进行到这里说明链表长度大于等于树化阈值8了。
//将链表树化,当然这只是树化条件之一,另一个条件在treeifyBin方法里。
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//进行到这里说明发生键冲突了,对值进行覆盖
if (e != null) {
V oldValue = e.value;
//这个参数是一个开关,键冲突是否覆盖。默认是false,即覆盖
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
//由于覆盖不会增加长度,直接返回就可以了
return oldValue;
}
}
//modCount的值在迭代器中使用代表了总的bin个数。
++modCount;
//容量加1,判断是否触发扩容条件,扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
//添加成功未覆盖值,返回null
return null;
}
如何让bin均匀分布在每个桶中呢?或者说更加散列地存储在每个桶中?
我们很容易想到取余。
HashMap计算哈希表位置的算法:i = (n - 1) & hash。
当n-1与hash值进行与运算,由于n是2的倍数,其结果相当于hash值对(n-1)的取余。假设n是2的x次方,那么(n-1)的二进制就是后x位都为1,前面都为0的一个值,假设一个值m与其按位与,得到的结果会保留m的后x位,将前面的位置0。其结果的范围为0~(n-1),相当于取余。
HashMap不直接采用取余运算的原因是位运算更高效。
这里就能解释为什么HashMap的table长度一定是2的倍数的原因。只有这样在进行(n - 1) & hash运算时,才能让值有可能落在table的每一处,也减小了hash碰撞。同时下标运算的结果完全取决于hash值,体现了Java分而治之的思想。
由于n的大小一般不会太大,这就造成了参与计算下表的hash值经常只会用到其低位。所以HashMap的设计者在hash方法中让K的高16位的hashCode也参强行与到hash值得运算当中。
resize方法:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
//记录旧容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//记录旧的扩容阈值
int oldThr = threshold;
//新容量与阈值
int newCap, newThr = 0;
if (oldCap > 0) {
//最大边界判断
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//新容量 = 旧容量乘2,<<1等于x2
//假如旧容量大于等于16,阈值也乘2
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
//扩容阈值有值
else if (oldThr > 0)
newCap = oldThr;
else {
//到这里说明是对象是无参构造创造出来的,
//因为另外两个构造,阈值不会是0
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//上述有情况没有给新扩容阈值赋值,这里补那种情况
if (newThr == 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<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//将哈希表更新为新数组
table = newTab;
//下面进行链表的转移
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//如果旧数组的节点是否为空,不为空用e将它存起来,并赋空
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
//桶中只有一个节点。重新计算下标存储到新数组
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//桶中节点为树节点。
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
//说明桶中节点是链表
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
//这里是一个do-while循环
//具体分析在下面会说
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
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;
}
当扩容后,正常来说仍可以用i = (n-1)& hash的方式来计算下标。这里用了一个更巧妙的方法。
扩容后n-1的二进制相比旧容量-1的二进制来说,只是高位多了一位1。那么进行异或运算的话,i的值是否改变只是取决于那一位是1或是0;
举个例子:旧数组长度16,那么下标就是hash & 1111;
新数组长度为32,下标就是hash&11111。
那么只需要判断第五位(从右向左)为0还是1就可以判断出下标的值是否与原来不同。
如何判断呢,与10000相与,也就是原数组长度相与,为0则说明下标不会变。如果不是,那么新下标也很好知道,第五位多了个1,下标加上原数组长度就是新下标。
get与getNode方法:
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//判断数组是否为空。
//不为空判断长度是否大于0
//大于0判断hash所计算的对应桶的元素是否为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//判断哈希值是否相等或者说key是否相等。是则返回这个Node
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//next不为空,说明链着节点,判断类型
if ((e = first.next) != null) {
//树节点
if (first instanceof TreeNode)
return ((TreeNode<K,V>)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);
}
}
//找不到返回null
return null;
}
remove方法与removeNode方法:
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
//与getNode很像
//判断数组是否为空。
//不为空判断长度是否大于0
//大于0判断hash所计算的对应桶的元素是否为空
//p被赋值为hash值对应的桶里的节点
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
//一些临时变量
//node就是记录要删除的节点
Node<K,V> node = null, e; K k; V v;
//判断哈希值是否相等或者说key是否相等。是则将node赋值为p
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<K,V>)p).getTreeNode(hash, key);
//下面是对链表的查询,遍历对比键
//执行完后node会是p的下一节点
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);
}
}
//node为null代表没找到。会跳过这个if直接返回null
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
//进入循环说明找到了该执行删除了
//还是树节点单独处理
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
//判断node是否是桶里的元素
else if (node == p)
//是的话让node的下一个节点顶进数组里
tab[index] = node.next;
else
//完成链接
p.next = node.next;
//modCount减1,总元素格式减1
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}