写这篇文章还是下了一定决心的,因为这个源码看的头疼得很。

  老规矩,源码来源于JRE1.8,java.util.HashMap,不讨论I/O及序列化相关内容。

  该数据结构简介:使用了散列码来进行快速搜索。(摘自Java编程思想)

  那么,文章的核心就探讨一下,内部是如何对搜索操作进行优化的。

  先来一张帅气的图片总览:

  预备知识:

1、Map没有迭代器,但是可以通过Map.entry()生成一个Set容器,然后通过Set的迭代器遍历map元素。

2、HashMap是乱序的。

3、HashMap元素根据散列码分散在一个数组的不同索引中,利用了数组的快速搜索特性对get操作进行了优化。

4、HashMap元素的保存形式为单向链表,是一个静态内部类。

  先过一遍这个内部类:

    static class Node<K,V> implements Map.Entry<K,V> {
// hash值、key、value、后指针
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) {
// ...
} public final boolean equals(Object o) {
// ...
}
}

  代码非常简单,常规的get/set/equals,构造函数仅有一个指向下一个节点的指针,属于单向链表。

  还有一个新建Node的方法:

    Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
return new Node<>(hash, key, value, next);
}

  总览一下类的声明:

public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
// code...
}

  其中AbstractMap类实现了大部分常规方法,诸如get、contain、remove、size等方法,但是put方法是一个没有实现的方法,仅抛出一个错误。

  至于Map接口,下载的源码包没有这个class的,所以暂时不知道内部的代码,不过影响不大。

  这里比较奇怪的是,类AbstractMap中实现了Map接口,这里HashMap又重新声明实现Map接口,不太懂为啥。

  

变量

  HashMap中的变量比较多,如下:

    // 容器默认容量 必须为2的次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 容器最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认负载参数
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 容器参数
final float loadFactor;
// 一个节点数组 HashMap的容器
transient Node<K,V>[] table;
// 保存所有map的Set容器 可以用来遍历、查询等
transient Set<Map.Entry<K,V>> entrySet;
// map对象数量
transient int size;
// 容量临界值 触发resize
int threshold;
// 将红黑树转换回链表的临界值
static final int UNTREEIFY_THRESHOLD = 6;
// 链表转树的临界值
static final int TREEIFY_THRESHOLD = 8;
// (感谢指正)当某一个数组索引处的Node数量大于此值时 触发resize并重新分配Node
static final int MIN_TREEIFY_CAPACITY = 64;

  所有的容量与参数都是table相关,table就是开篇所讲的数组。

构造函数

  

1、无参构造函数

    public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}

  简单的将默认负载参数赋值给负载参数。

2、int单参数构造函数

    public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

  调用另外一个构造函数,第二个参数为默认的负载参数。

3、int、float双参数构造函数

    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 + 1;
}

  第一次看没搞懂,后面也没太看懂,于是尝试用个测试代码看一下输入值从0-100会输出什么。

  测试代码:

public class suv {
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 + 1;
}
public static void main(String[] args){
for(int i=1;i<100;i++){
System.out.print(tableSizeFor(i) + ",");
if(i%20 == 0){System.out.println();}
}
}
}

  输出如下:

  有非常明显的规律:

1、输出均为2的次方

2、输入值为大于该值的最小2次方数

  例如:输入5,大于5的最小2次方数为2的三次方8,所以输出为8。

  如果还不懂,可以看我自己写的方法,输出跟上面一样:

    static final int diyFn(int cap){
int start = 1;
for(;;){
if(start >= cap){
return start;
}
start = start << 1;
}
}

  这里暂时不需要知道原因,只需要知道容量必须是2的次方。

4、带有初始化集合的构造函数

    public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}

  这里负载参数设置为默认的,然后调用putMapEntries方法初始化HashMap。

  这个方法会初始化一些参数,稍微看一下:

    final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
// 初始table为null
if (table == null) { // pre-size
// 用负载参数进行计算
float ft = ((float)s / loadFactor) + 1.0F;
// 与最大容量作比较 返回对应的int类型值
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
// The next size value at which to resize (capacity * load factor).
if (t > threshold)
threshold = tableSizeFor(t);
}
// 扩容
else if (s > threshold)
resize();
// 插入处理
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}

  这里的扩容类似于ArrayList的grow函数,不同的是这里扩容的算法是每次乘以2,并且存在一个负载参数来修正初次扩容的步数。

  threshold可以看注释,这是一个扩容临界值。当容器大小大于这个值时,就会进行resize扩容操作,临界值取决于当前容器容量与负载参数。

  接下来应该要进入resize函数,参照之前的ArrayList源码,这里也是先扩容得到一个新的数组,然后将所有节点进行转移。

  函数有点长,一步一步来:

    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;
}
// 容量与临界值同时<<1
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
// 下面的else均代表旧数组为空
else if (oldThr > 0)
// 新容量设置为旧的临界值
newCap = oldThr;
else {
// 当容器为空时 初始化所有参数
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 这里的情况是初始化一个空HashMap 然后调用putAll插入大量元素触发的resize
// 新临界值为新容量与负载参数相乘
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 新临界值
threshold = newThr; // ...数组操作
}

  首先第一步是参数修正,包括临界值与容器容量。

  接下来就是数组操作,如下:

    final Node<K,V>[] resize() {
// 参数修正 @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;
// 遍历旧数组
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 { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 遍历数组对应索引的链表元素
do {
next = e.next;
// 两个分支都是执行链表的链接
// 由于数组扩容 所以对于(length-1) & hash的运算会改变 所以对原有的数组内容重新分配
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;
// oldCap为旧容量
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}

  至此,可以看出,数组保存了一系列单向链表的第一个元素。

核心讲解

  这里存在一个核心运算,即:

    newTab[e.hash & (newCap - 1)] = e;

  之前讲过扩容,每次扩容的容量都是2的次方,为什么必须是呢?这里就给出了答案。

  开篇讲过,该数据结构是通过hash值来优化搜索,这里就用到了hash值。但是hash值是不确定的,如何保证元素分配到的索引平均分配到数组的每一个索引,并且不会超过索引呢?

  答案就是这个运算,这里举一个例子:

  比如说容量为默认的16,此时的二进制表示为10000,减1后会得到01111。

  与运算应该都不陌生,两个都为1时才会返回1。

  由于高位会自动补0,所以任何数与01111做与运算时,高位都是0,范围限定在 00000 ~ 01111,十进制表示就是0 - 15,巧的是,容量为16的数组,索引恰好是[0] - [15]。

  这就解释了为什么容量必须为2的次方,而且元素是如何被平均分配到数组中的。

    (e.hash & oldCap) == 0

  这是用来区分lo、hi的运算,注释中已经解释了为什么需要做切割,这里给一个简图说明一下:

  

  首先,假设这个tab容量目前是8,而索引0中的节点太多了(这里应该是树,懒得画了),于是触发了resize,并将该索引每个节点的hash值按照上面的那个计算,判断是否需要移动。

  经过重分配,数组大概变成了这样:

  扩容后,会进行插入操作,留到下一部分解释。

  由于大体上的思想已经很明显了,下面看一下增删改查的API。

方法

  按照增删改查的顺序。

  首先看一眼

    public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

  方法需要传入键值对,返回值。这里调用了内部的添加方法,其中散列码用的是key的,这里的hash并不是直接用hashCode方法,而是内部做了二次处理。

    static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

  这个运算没啥讲的,当成返回一个随机数就行了。

  下面是putVal的完整过程:

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K, V>[] tab;
Node<K, V> p;
int n, i;
// 初始化HashMap后第一次添加会调用resize初始化
if ((tab = table) == null || (n = tab.length) == 0)
// 返回扩容后的长度 默认情况下为1<<4
n = (tab = resize()).length;
// 又是位运算 这里代表该索引位没有链表 于是新建一个Node
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K, V> e;
K k;
// 传入元素的key与链表第一个元素的key相同
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 {
for (int binCount = 0;; ++binCount) {
// 到达尾部进行插入节点
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 当链表的长度大于临界值时 调用treeifyBin
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 当中途遇到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);
return oldValue;
}
}
++modCount;
// 临界值检测
if (++size > threshold)
resize();
// 新建链表的钩子函数
afterNodeInsertion(evict);
return null;
}

  这里的过程可以简述为:通过key的hash值计算出一个值作为索引,然后对索引处的链表进行插入或者修改操作

  但是这里还是有几个特殊的点:

1、钩子函数

2、当链表长度大于某个值时,会调用treeifyBin方法将链表转换为红黑树

  钩子函数是我自己取的名字,因为让我想到了vue生命周期的钩子函数。这两个方法都是本地已定义但是没有具体内容,是用来重写的函数。

  另外一个是treeifyBin方法,该方法将链表转换为红黑树结构保存:

    final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 若小于最低树临界值 触发resize
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 该索引处有元素
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
// 将索引处第一个链表元素转为红黑树结构
TreeNode<K,V> 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);
}
}

  可以看出,当链表的长度大于某一临界值时,会将数据结构转换为红黑树。

  当然,这个链表的Node比一般的链表还是牛逼一点,采用的键值对的泛型,而TreeNode本身是一个静态内部类,目前仅需要知道继承于LinkedHashMap.Entry,元素按照插入顺序进行排序。

  关于TreeNode转换的详解可以单独分一节讲了,这里暂时跳过吧。

  下面是

    public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value;
}

  直接看removeNode的实现:

    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;
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
// 当对应索引第一个链表元素就与key相等
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);
else {
// 遍历链表对key做比较
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<K,V>)node).removeTreeNode(this, tab, movable);
// 当第一个元素被删除时 下一个被指定为索引处元素
else if (node == p)
tab[index] = node.next;
// 重新链接next
else
p.next = node.next;
++modCount;
--size;
// 钩子函数
afterNodeRemoval(node);
// 返回删除的节点
return node;
}
}
return null;
}

  这里很简答,通过hash值快速找到对应的索引处,遍历链表或者红黑树进行查询,找到就删除节点并重新执行next链接。

  同样,这里也有一个钩子函数,参数为被删除的节点。

  由于改的情况在增的情况中已经提及,所以这里就跳过。

  最后看一眼查:

    public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
public boolean containsKey(Object key) {
return getNode(hash(key), key) != null;
}

  一个获取,一个查询,都指向同一个方法,所以看getNode的实现:

    final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> 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;
// 然后遍历
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);
}
}
return null;
}

  没啥营养,常规的找索引,遍历,返回节点或者null。

  至此,HashMap的基本内部实现已经完事,红黑树转换另外开一篇单独弄。

浅析Java源码之HashMap的更多相关文章

  1. 浅析Java源码之HashMap外传-红黑树Treenode(已鸽)

    (这篇文章暂时鸽了,有点理解不能,点进来的小伙伴可以撤了) 刚开始准备在HashMap中直接把红黑树也过了的,结果发现这个类不是一般的麻烦,所以单独开一篇. 由于红黑树之前完全没接触过,所以这篇博客相 ...

  2. JAVA源码分析-HashMap源码分析(一)

    一直以来,HashMap就是Java面试过程中的常客,不管是刚毕业的,还是工作了好多年的同学,在Java面试过程中,经常会被问到HashMap相关的一些问题,而且每次面试都被问到一些自己平时没有注意的 ...

  3. Java源码学习:HashMap实现原理

    AbstractMap HashMap继承制AbstractMap,很多通用的方法,比如size().isEmpty(),都已经在这里实现了.来看一个比较简单的方法,get方法: public V g ...

  4. 浅析Java源码之ArrayList

    面试题经常会问到LinkedList与ArrayList的区别,与其背网上的废话,不如直接撸源码! 文章源码来源于JRE1.8,java.util.ArrayList 既然是浅析,就主要针对该数据结构 ...

  5. Java源码之HashMap

    一.HashMap和Hashtable的区别 (1)HashMapl的键值(key)和值(value)可以为null,而Hashtable不可以 (2)Hashtable是线程安全类,而HashMap ...

  6. Java源码阅读HashMap

    1类签名与注释 public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cl ...

  7. Java源码解析|HashMap的前世今生

    HashMap的前世今生 Java8在Java7的基础上,做了一些改进和优化. 底层数据结构和实现方法上,HashMap几乎重写了一套 所有的集合都新增了函数式的方法,比如说forEach,也新增了很 ...

  8. JAVA源码分析-HashMap源码分析(二)

    本文继续分析HashMap的源码.本文的重点是resize()方法和HashMap中其他的一些方法,希望各位提出宝贵的意见. 话不多说,咱们上源码. final Node<K,V>[] r ...

  9. 浅析Java源码之LinkedList

    可以骂人吗???辛辛苦苦写了2个多小时搞到凌晨2点,点击保存草稿退回到了登录页面???登录成功草稿没了???喵喵喵???智障!!气! 很厉害,隔了30分钟,我的登录又失效了,草稿再次回滚,不客气了,* ...

随机推荐

  1. 10.Date对象

    Date()对象 Date对象用于处理日期和时间. Math对象 ◆Math.ceil()   天花板函数    向上取整 ★如果是整数,取整之后是这个数本身 ★如果是小数,对数进行向上舍入. ◆Ma ...

  2. Windows下Node.js的安装与配置

    一.下载和安装 1. 前往官网https://nodejs.org/或https://nodejs.org/en/download/下载最新推荐版的Node.js,本文使用10.13.0版本. 对于W ...

  3. EF使用MySql DBFirst产品的问题总结

    一.实体数据模型向导->新建连接->更改数据源  找不到MySql Batabase选项. 解决:需求安装以下两个插件(mysql官网都可以找到)(注意版本,后面会讲到) 1.MySql ...

  4. [leetcode.com]算法题目 - Maximum Subarray

    Find the contiguous subarray within an array (containing at least one number) which has the largest ...

  5. 【Atcoder】 AGC032赛后总结

    比赛前 emmm,今天是场AGC,想起上次我的惨痛经历(B都不会),这次估计要凉,可能A都不会Flag1 比赛中 看场看了波\(A\),咦,这不是很呆的题目吗?倒着扫一遍就好了. 然后切了就开始看B, ...

  6. Vue.js之下拉列表及选中触发事件

    老早就听说了Vue.js是多么的简单.易学.好用等等,然而我只是粗略的看了下文档,简单的敲了几个例子,仅此而已. 最近由于项目的需要,系统的看了下文档,也学到了一些东西. 废话不多说,这里要说的是下拉 ...

  7. postgresql-查看各个数据库大小

    查看各个数据库表大小(不包含索引),以及表数据量 mysql: select table_name,concat(round((DATA_LENGTH/1024/1024),2),'M')as siz ...

  8. Stack&&Queue

    特殊的容器:容器适配器 stack     queue     priority_queue:vector+堆算法---->优先级队列 stack:     1.栈的概念:特殊的线性结构,只允许 ...

  9. odoo开发笔记 -- 异常信息处理汇总

    1 Traceback (most recent call last): File , in _handle_exception return super(JsonRequest, self)._ha ...

  10. js 时间的国际化处理

    //1 获取相对于0时区的当地时区(默认得到的是分钟,可能是负数;北京市东八+8 美国华盛顿为西五-5),中国比美国快13小时 //js默认转换的时候自带时区,只要数据库存的是时间戳,显示的时候不用刻 ...