HashMap是Map(双列集合)体系中极为重要的一个集合类,线程不安全,若需要线程安全则使用ConcurrentSkipListMap,较TreeMap拥有更好的查找、插入效率,具体效率对比请看 Java Map遍历方式的选——TreeMap、HashMap的key、value遍历与效率分析
本文只对HashMap源码进行解析
目录
底层数据结构
HashMap底层数据结构是数组+链表,即哈希表,JDK8以后引入了红黑树作为补充,大多数情况下还是以哈希表为主
如图所示,每当有元素要添加进来时,便会通过hash算法计算出来的值与数组长度-1做按位与运算所计算出来的数组索引(下面会提到)
哈希表的好处是借助哈希码(散列码)大大优化了插入及查询效率,试想,若没有哈希码,则每次添加新元素都要调用equals方法进行数据的比较,在数据量达到一定量级时所产生的性能开销是无法想象的
而通过比较哈希码则高可以省下许多比较次数,为什么说是省下许多比较次数而不是一步到位呢?因为hash算法所依赖的hashcode方法的返回值虽然是按照对象地址计算得出的,但是我们知道,两个对象相同他们的hashcode返回值一定相同,但是两个hashcode返回值相同时两个对象未必是同一个对象
因而每当计算得到相同的hashcode返回值时便会使得这个新添加的元素被放到数组的某个已有元素的索引处,这便称之为哈希碰撞,若比较equals及==后发现这不是同一个对象,便会以链表的节点的方式添加到原本已存在的元素的后面,后面再有要添加到这个链表的元素,便会迭代这张链表以检查是否与已有元素重复,因此,数组的每个元素既有可能只是一个普通的节点,也有可能是一个链表头,甚至有可能会是红黑树的根节点(当这个链表的长度>8的时候,链表转化为红黑树 下面会提到)
成员变量
- transient Node<K,V>[] table; // 底层数据结构中的数组
- final float loadFactor; // 负载因子 用于评估哈希表的使用程度
- static final float DEFAULT_LOAD_FACTOR = 0.75f; // 不指定负载因子大小时的默认值 一般不需要设置 默认就行
- static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 数组的初始容量
- int threshold; // 临界值(阀值) 临界值 = 负载因子*table数组长度 大于便扩容
值得一提的是
loadFactor设置的越大时,空间利用率便会更高,但同时也意味着发生哈希碰撞的几率也会增大,一旦哈希碰撞便会产生链表或红黑树,过长时查询效率便会降低
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;
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
构造方法都较为简单,不设置负载因子便是默认的0.75
方法解析
这里我们以put方法为起点,对HashMap的一些重要方法及疑难问题结合源码进行分析
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;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length; // 数组为空则造一个 注意真正造
// table数组是在resize方法里面
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 { // 判断是否是链表,若长度>8则转红黑树
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) { // 遍历链表 若某个节点的next
//为空则添加到那个节点后面 若发现与已有节点重复则值替换并终止遍历
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
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);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
调用put方法添加键值时,真正执行添加功能的是putVal方法,方法首先检查table是否为空,为空则创建一个初始长度为16的Node<K,V>数组(其实就是以前的Entry 只不过换了个名字),然后便开始计算新增元素应在的数组位置,即p = tab[i = (n - 1) & hash],详细计算过程看HashMap方法hash()、tableSizeFor() 这里只贴出一个例子
图源上面的链接 即HashMap方法hash()、tableSizeFor() 侵删
若这个索引处为空则直接添加进去了,若不为空,则通过hashcode和equals方法及==判断是否为同一对象,是则值替换,否则继续判断是红黑树还是链表(默认是链表)然后遍历链表/红黑树看是否已有此对象,没有则添加到链表尾部/红黑树的对应位置
一般情况下只是形成链表,当binCount >= TREEIFY_THRESHOLD - 1 即链表长度大于8(TREEIFY_THRESHOLD等于8)时,便调用 treeifyBin(tab, hash)将链表转为红黑树存储
最后if (++size >threshold){ resize(); }判断是否需要扩容,若需要扩容则调用resize()方法重新造一个长度为原数组长度两倍的table数组并将原数据重新添加到新数组,其实扩容是十分消耗性能与内存的,毕竟要造一个两倍原长度的数组,再把数组元素计算完新位置后,全部重新添加了一遍
注意看我标的注释
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;
} // 看下面那一行newCap = oldCap << 1
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
else if (oldThr > 0)
newCap = oldThr;
else {
newCap = DEFAULT_INITIAL_CAPACITY; // 默认的数组长度16就是这样来的
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];//根据上面计算的newCap造数组
table = newTab; // table此时更新为扩容后的数组
if (oldTab != null) { // 下面的操作都是把原数组元素重新分配到新数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> 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 {
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;
}
这里特别引申出一个曾经困扰过我一天的知识点Java遍历HashSet为什么输出是有序的? (HashSet底层调用的还是HashMap)
有时候当我们添加Integer类型数据进入HashMap中时,遍历values时会发现数据有时候是有序的,而Map的putVal代码显然并没有进行排序,那么这是为什么呢?
当时看完知乎高赞还是迷迷糊糊的,现在总算明白了,当hashcode计算的是Integer类型时,返回的是整型值本身,比如Integer i = 3拿去hashcode,返回值还是3,而3的hash值就是它自己 当然这还与数值本身大小有关,只有0~65535之间才是
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
因此当我们添加i时,如果i的hash值小于数组最大下标,会正好添加到这个下标处(n-1&hash),而数字的hash值就是它自己,如果i的hash值大于最大下标,也不会出现数组越界异常,而是作为节点添加到某个节点后面形成一个链表,这就是n-1&hash的巧妙之处,但此时也因此不再排序 看下面一个例子
public class HashSetDemo {
public static void main(String[] args) {
HashSet<Integer> hs = new HashSet<Integer>(4); //HashSet底层调用的还是HashMap 这里
//只是为了方便演示用了HashSet
hs.add(3);
hs.add(1);
// hs.add(9);
for (int i : hs) {
System.out.println(i);
}
}
}
此时执行
输出结果是: 1 3 有序
那么我们接着放开注释 即hs.add(9);
输出结果是: 1 9 3 无序
根据我们上面的推断 新添加的9经过hashcode后得到的返回值依然是9,然后我们模拟计算table数组的方式计算9添加到的位置
public class Test {
public static void main(String[] args) {
int tableLength = 4;
int i = 9;
System.out.println((tableLength-1)&i);
}
}
输出为1 也就是添加到了1后面,与之前添加的1形成了链表,1位链表头,9为下一节点
当table达到一定使用程度 即上面提到的if (++size > threshold) 便进行扩容 并将元素重新添加到新的数组中,此时又是有序的了
public class HashSetDemo {
public static void main(String[] args) {
HashSet<Integer> hs = new HashSet<Integer>(4);
hs.add(3);
hs.add(1);
hs.add(9);
hs.add(2);
hs.add(10);
hs.add(11);
hs.add(12);
for (int i : hs) {
System.out.println(i);
}
}
}
输出结果:1 2 3 9 10 11 12
至此,为什么HashMap有时候是有序的原因我们便明白了 其实就是因为整型值经过hashcode后返回值是整型值本身,而计算下标的算法(n -1)&hash得到的下标在数值不超过数组长度的情况下与数值相同,这就间接导致了有时候我们得到的有序的 而有时候又是无序的
值的一提的是,n-1其实还另有玄机,当容量一定是2^n时,hash & (length - 1) == hash % length,在设计源码时,通过这一点利用位运算提升了取模的效率,另外,容量为2^n还保证了哈希表的均匀性,因为当length为偶数时,二进制尾数肯定是0,即2^n的二进制肯定尾数是0,那么(2^n)-1的二进制尾数肯定是1,而我们知道,按位与运算 & 的规则是两个数据对应的二进制都为1则该为为1,否则该位为0,而(2^n)-1这种做法保证了尾数必为1,如果没有这种保证,一旦table数组长度为奇数,那么hash&(table.length-1)得到的尾数必定是0,这便会浪费近半的空间,增加了发生哈希碰撞的几率,这显然不是我们希望看到的
你可能会问,你怎么能保证容量一定是2^n,我们可以查看源码 其实别人老早就设计好了,一环扣一环
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); // 注意这一句
}
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;
}
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;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0)
newCap = oldThr; //看这一句
.....
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; // 看这一句
table = newTab;
.....
}
即使你指定的是非2^n的capacity,通过tableSizeFor方法也可以得到向上取的最接近的2^n,比如你输入5得到的就是8,11得到的就是16,(具体算法解析看上面提到的HashMap方法hash()、tableSizeFor())
当然你可能又会疑惑了。。这里接收返回值的不是临界值threshold吗,实际容量没变啊,那么请你重新回看putVal的源码 ,其中有这么一句
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
再结合上面的resize的注释处 结果显而易见,原来当我们添加元素时,table才初始化,且table长度一定是2^n
不得不感叹 设计源码的人真牛逼 至此这几天我碰到的学习难点基本都讲完了
总结
- 根据实际情况我们可以调整负载因子的大小来选择以空间换时间还是以时间换空间 一般情况下不用调
- HashMap添加整型值时的有序无序取决于数值大小及添加的时候是否超过了数组下标
- 扩容很耗费性能和内存,创建新数组后还要将原数组中的链表或红黑树重新计算位置(不重新计算哈希值)再插入到新的数组
最后特别提醒,当Map存储自定义对象时,重写了equals方法务必还要重写hashcode方法,这里引用之前学习时看到的一个例子
public class MyTest {
private static class Person{
int idCard;
String name;
public Person(int idCard, String name) {
this.idCard = idCard;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()){
return false;
}
Person person = (Person) o;
//两个对象是否等值,通过idCard来确定
return this.idCard == person.idCard;
}
}
public static void main(String []args){
HashMap<Person,String> map = new HashMap<Person, String>();
Person person = new Person(1234,"乔峰");
//put到hashmap中去
map.put(person,"天龙八部");
//get取出,从逻辑上讲应该能输出“天龙八部”
System.out.println("结果:"+map.get(new Person(1234,"萧峰")));
}
}
实际输出结果:
结果:null
如果我们已经对HashMap的原理有了一定了解,这个结果就不难理解了。尽管我们在进行get和put操作的时候,使用的key从逻辑上讲是等值的(通过equals比较是相等的),但由于没有重写hashCode方法,所以put操作时,key(hashcode1)-->hash-->indexFor-->最终索引位置 ,而通过key取出value的时候 key(hashcode1)-->hash-->indexFor-->最终索引位置,由于hashcode1不等于hashcode2,导致没有定位到一个数组位置而返回逻辑上错误的值null(也有可能碰巧定位到一个数组位置)
所以,在重写equals的方法的时候,必须注意重写hashCode方法,同时还要保证通过equals判断相等的两个对象,调用hashCode方法要返回同样的整数值。而如果equals判断不相等的两个对象,其hashCode可以相同(只不过会发生哈希冲突,应尽量避免)。
出处:HashMap实现原理及源码分析 侵删
注意源码 虽然查找的决定性依据是idCard是否相同,然而架不住在查找之前要先hash键对象,hash出来的值不一样数组位置定位就不一样,无从找起
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
那么结果当然是找不到这个key对应的值了
解决方法也很简单,在键对象的类里用编译器自动生成hashcode和equals的重写方法就好了