写这篇文章前,首先感谢MrBird的(https://mrbird.cc/Java-HashMap底层实现原理.html)这篇文章,里面讲解的HashMap源码内容非常棒,本文仅作为自身学习的记录,如果还能帮助其他同样在看HashMap源码的朋友,那也是极好的。
本文提到的HashMap源码内容,均来自于Jdk1.8版本。虽然HashMap可以通过直接进入HashMap class的方式进行阅读,但为了更好的写注释和提交自己的修改记录,还是建议下载源码阅读更为方便,源码下载地址:https://hg.openjdk.java.net/jdk8/jdk8/jdk/file/687fd7c7986d
好,话不多说,下面开始!
HashMap采用哈希表结构,也就是数组table+链表Node实现,因此结合了二者的优点:数组的高访问效率和链表方便的插入、删除(不需要移动元素)。实际上HashMap不仅使用了链表,为提升查询效率,当链表达到一定长度,会转换为红黑树。
图片来自: Java HashMap底层实现原理 | MrBird
说到这个数据结构,第一个冒出来的问题大概率是:HashMap是如何分配什么时候使用数组,什么时候使用链表?
答案是通过hash计算的方式,由 (n-1) & hash 来决定元素在数组中的位置(计算中的n为数组长度),当该位置存在元素时,即发生哈希碰撞,则将新放入的元素挂在已存在的元素下形成链表。前面提到,数组由于在内存中的位置是连续的,查询效率高,当HashMap中的链表越来越长,查询的效率就会越来越低,链表查询复杂度为O(N),此时会进行判定(如何判定之后会提到)是否需要转为红黑树,花费时间进行转换后将复杂度降为O(logN)。
数组大家都再熟悉不过,而链表也是大家多接触过的单向链表结构,包含了hash、key、value以及next:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;//下一个节点
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
红黑树的结构如下:
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;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
...
}
我不知道大家在实例化一个HashMap时会不会对它的参数进行设置,我个人很少会进行设置(这个习惯大概率是不太好的),那么当我们不进行设置时,HashMap的默认配置是如何的呢?请看以下几个常量:
//数组的默认长度16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//数组的最大容量,2^30,即1073741824
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;
//链表转换为红黑树,数组容量必须超过的阈值,和TREEIFY_THRESHOLD配套使用
static final int MIN_TREEIFY_CAPACITY = 64;
我当时看到这几个参数,一下子被两个地方迷惑住,第一个是你16就16,为啥弄个1<<4,还在后面注释一个aka 16,有一种想“羞辱”我“没文化”又怕我真看不懂的感觉;第二个问题就是什么是加载因子?
第一个问题一看就更有吸引力,所以这里先回答第二个问题。
加载因子也叫扩容因子,用于决定HashMap数组何时进行扩容,公式为扩容阈值=数组容量*加载因子,默认为DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR = 16*0.75 = 12,即HashMap数组的数据数量大于等于12时,数组进行扩容。
我们都知道创建数组时就要确认其长度,扩容的操作实际上是创建一个新的更大的数组,然后把数据复制过去(像极了人类改善住房)。搬过家的都懂,这个过程非常耗时。那么就会发现:
加载因子越大,越不容易发生扩容,容量小了哈希碰撞的概率就增加,链表也就变长,查询效率降低;
加载因子越小,越容易发生扩容,扩容的耗时增加了,但哈希碰撞概率降低,查询效率提高。
世间安得两全法,不负如来不负卿。容量占用、扩容耗时、查询耗时等因素互相影响,无法都达到最好,因此需要一个平衡点,那么这个平衡点就由加载因子决定,而这个0.75就是JDK开发人员设置的经验之值(当然里面的门道可能不仅仅是“经验”二字这般简单)。
解释完加载因子,让我们回到第一个问题,1<<4 why?其实对这个问题的答案我反复查找了几次资料,有许多人的回答是“位运算直接操作内存,不需要进行‘进制’的转换,方便后面进行二进制运算”,于是我用最简单粗暴的方式试了试,看看是不是会输出一个二进制的结果:
public class main {
public static void main(String[] arg) {
System.out.println(1<<4);
}
}
不知道是不是我的方式不对,确实也只是输出一个“冰冷”的16.
那这直接=16,连位运算都省了不是更好吗?后来由于好奇心作祟,我又去查了查,有一位网友的答案让我觉得较为有说服力:
如果哪位大佬有更棒的答案,也希望能告诉我。那既然提到了碰撞几率低这个原因,就聊一聊为什么2^N会降低碰撞几率。
前文提到由HashMap通过 (n-1) & hash 计算来决定元素在数组中的位置,这个计算在源码中会频频出现,如下:
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;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {...}
&符号表示与运算,也就是两个操作数第n位都为1则为1,否则为0。那么=2^N的数都有什么特点呢,我们找几个转为二进制来看一看。
2 10
4 100
8 1000
16 10000
找一下规律:都是最高位1,其余位都为0。
那么n-1呢?
1 1
3 11
7 111
15 1111
全为1,这样的数有什么特点呢。比如字符“学习” ,对应的hashCode为745402,对应的二进制10110101111110111010。n为16,(n-1) & hash=1111&10110101111110111010=1010,也就是说,hash计算的结果完全取决于key的hashCode最后几位。
那么不使用2^N的数组长度呢,比如长度10,10-1= 9的二进制为1001,那么不管key是多少,&运算的四位结果中,最多只能出现两个1,而其余两位永远为0,共4种可能性(0000、1000、1001、0001)对应数组里的4个下标,而n为16的四位结果在0000-1111共16种,对应数组的16个下标,分布均匀,冲突几率最低。
不得不感慨,哪怕一个默认长度,里面也蕴藏了JDK开发人员的智慧。
到这里,算是初步探索了一下HashMap,记录了一些我一开始阅读好奇的地方,下一篇文章让我们继续探索内部具体的方法。