简单谈谈HashMap

Briefly talk about HashMap

Posted by if on 2021-10-31
Estimated Reading Time 41 Minutes
Words 9.9k In Total

简单谈谈HashMap

@[TOC]

前言

HashMap 基于哈希表的 Map 接口实现,是以 key-value 存储形式存在,即主要用来存放键值对。HashMap 的实现不是同步的,这意味着它不是线程安全的。它的 key、value 都可以为 null,此外,HashMap 中的映射不是有序的

hash算法

hash算法又叫摘要算法(Digest),是将给定数据转化为固定长度的不规则值的函数

  • 输出值的数据长度固定(同一hash函数下)
  • 相同的输入必定得到相同的输出值
  • 相似的数据得到的输出值的差距也许会有点大
  • 输入不同的数据有可能得到一致的输出值(哈希碰撞,也叫哈希冲突)

在java中,hashCode()定义在 JDK 的 Object 类中,这就意味着 Java 中的任何类都包含有 hashCode() 函数

1
2
    //Object类的hashCode方法,返回的是对象的32位jvm内存地址
public native int hashCode();

使用native关键字说明这个方法是原生函数,也就是这个方法是用C/C++语言实现的,并且被编译成了DLL,由java去调用

String类重写了hashCode()方法

1
2
3
4
5
6
7
8
9
10
11
12
public int hashCode() {
int h = hash; //Default to 0
if (h == 0 && value.length > 0) { //private final char value[];
char val[] = value;

for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}

既然是hash算法,那么碰撞就不可能完全避免的

因为碰撞的概率关系到了算法的安全性,所以一个安全的哈希算法必须满足

  • 碰撞概率低
  • 不能通过哈希值算出原值

常用的哈希算法

算法 输出长度(字节) 输出长度(位)
MD5 16 bytes 128 bits
SHA-1 20 bytes 160 bits
RipeMD-160 20 bytes 160 bits
SHA-256 32 bytes 256 bits
SHA-512 64 bytes 512 bits

根据碰撞概率,哈希算法的输出长度越长,就越难产生碰撞,也就越安全

hashmap的数据结构

jdk1.8之前:数组+链表

jdk1.8之后:数组+链表+红黑树

jdk1.7和jdk1.8的区别

简单讲解过程

大致过程大家应该都知道,简单讲就是hashcode结合数组长度,得到数组索引,空数组则放入,有元素则比对hash值,hash值相同则发生哈希碰撞,用equals比对key是否一致,一致则取到对应的值(put则覆盖),不一致就继续比对,到尾结点了就补链,链长度过长影响效率,默认链表长度大于为8且数组长度大于64时转红黑树

1.8之后为什么要引入红黑树?

哈希算法再好也无法避免哈希碰撞,当碰撞过多,接入链表时,前期数据少,效率还行

但是后期的不断碰撞,导致链表过长,读取需要遍历,时间消耗O(n)

而引入红黑树后,查找时间O(logn),提高了效率

什么时候扩容?

1
2
3
4
5
6
7
8
9
10
11
12
13
  /**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;


//扩容条件1:table为null 或 tab长度为0
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;

//扩容条件2:size是否大于了边界值
if (++size > threshold)
resize();

第一个条件没啥好说的,空的就扩呗

第二个条件就需要多注意一下了

  1. size表示hashmap中k-v的实际数量,并不是数组的长度

  2. 边界值threshold = 容量DEFAULT_INITIAL_CAPACITY * 负载因子DEFAULT_LOAD_FACTOR

    所以达到扩容的阈值点就是16*0.75 = 12

扩容源码

hashmap扩容是扩容一倍newThr = oldThr << 1; // double threshold(二进制左移一位就是数字翻倍)

cap容量给了老边界值newCap = oldThr;

如果老边界值为0则给初始值

1
2
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);

高频集合扩容的信息

集合类 初始容量 负载因子 扩容增量
ArrayList 10 1 0.5倍
Vector 10 1 1倍
HashSet 16 0.75 1 倍
HashMap 16 0.75 1 倍

什么时候转红黑树?

1
2
3
4
5
6
7
8
9
10
11
12
13
	/**
* The value must be greater than 2 and should be at least 8 to mesh with assumptions in
* tree removal about conversion back to plain bins upon shrinkage.
*/
static final int TREEIFY_THRESHOLD = 8;

/**
* The smallest table capacity for which bins may be treeified.
* (Otherwise the table is resized if too many nodes in a bin.)
* Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
* between resizing and treeification thresholds.
*/
static final int MIN_TREEIFY_CAPACITY = 64;

链表长度阈值8,数组长度大小64

链表阈值至少大于2且至少为8,才能符合红黑树在收缩的时候能转为普通链表

数组长度至少为4倍的链表阈值以避免冲突

1
2
3
4
5
if (binCount >= TREEIFY_THRESHOLD - 1) //先判断是否达到链表阈值
treeifyBin(tab, hash);

if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)//再判断是否达到数组阈值
resize();

HashMap 中 hash 函数是怎么实现的?还有哪些hash函数的实现方式?

答:对于 key 的 hashCode 做 hash 操作,无符号右移 16 位然后做异或运算。还有平方取中法,伪随机数法和取余数法。这三种效率都比较低。而无符号右移 16 位异或运算效率是最高的。

当两个对象的 hashCode 相等时会怎么样?

答:会产生哈希碰撞。若 key 值内容相同则替换旧的 value,不然连接到链表后面,链表长度超过阈值 8 就转换为红黑树存储。

什么是哈希碰撞,如何解决哈希碰撞?

答:只要两个元素的 key 计算的哈希码值相同就会发生哈希碰撞。jdk8 之前使用链表解决哈希碰撞。jdk8之后使用链表 + 红黑树解决哈希碰撞。

如果两个键的 hashCode 相同,如何存储键值对?

答:通过 equals 比较内容是否相同。相同:则新的 value 覆盖之前的 value。不相同:则将新的键值对添加到哈希表中。

HashMap集合类的成员

1
2
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable

Cloneable表示可克隆,Serializable可序列化

这两个接口都是一个声明接口

1
2
3
4
5
public interface Serializable {
}

public interface Cloneable {
}

hashmap继承了AbstractMap<K,V>,也实现了 Map<K,V>,可是AbstractMap<K,V>也实现了Map<K,V>,可能是设计的时候有点bug

1.序列化版本号

1
private static final long serialVersionUID = 362498820763181265L;

序列化的含义、意义及使用场景

  • 序列化:将对象写入到IO流中
  • 反序列化:从IO流中恢复对象
  • 意义:序列化机制允许将实现序列化的Java对象转换位字节序列,这些字节序列可以保存在磁盘上,或通过网络传输,以达到以后恢复成原来的对象。序列化机制使得对象可以脱离程序的运行而独立存在

2.集合的初始容量

1
2
3
4
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

默认初始容量 - 必须是 2 的幂,它为什么必须是2的幂呢?

大家知道hashmap为了存取高效,要尽量减少哈希碰撞,即:让数据分配均匀,数组空间都占据,链表长度接近

而hashmap的算法就是==取模==hash%length,计算机直接取余的效率不如位运算

所以源码中做了优化hash&(length-1),而hash%length = hash&(length-1)前提就是length是2的幂次

&:按位与运算,两个当且仅当都为1的时候结果才为1

扩容是非常消耗性能的,默认的容量不一定适用,所以需要创建的就指定一个适合的大小才是正确的

那我自定义给的容量不是2的N次方呢?

会给一个比自定义容量更大一些的2^n^的数,例如14时就给16,20时就给32

1
2
3
4
5
6
7
8
9
10
11
12
/**
* Returns a power of two size for the given target capacity.
*/
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;
}

3.负载因子(加载因子)

1
static final float DEFAULT_LOAD_FACTOR = 0.75f;

上文已经讲得很清楚了,这里不再赘述

4.集合最大容量

1
static final int MAXIMUM_CAPACITY = 1 << 30;

最大容量是2^30^,左移30位

5.树化阈值(转红黑树的边界值)

1
2
static final int TREEIFY_THRESHOLD = 8;
static final int MIN_TREEIFY_CAPACITY = 64;

当链表长度大于8时会转为红黑树

(其实也需要数组长度大于64,上文也提到了)

1
2
3
4
5
6
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
......
}

源码中的讲解是:到8转为红黑树  到6转为链表

红黑树的占用空间比链表要大很多,所以一开始还是链表会更好 ->空间和时间的权衡

为什么转化为红黑树的阈值8和转化为链表的阈值6不一样,是为了避免频繁来回转化

红黑树的平均查找长度是log(n),如果长度为8,平均查找长度为log(8)=3,链表的平均查找长度为n/2,当长为8时,平均查找长度为8/2=4,这才有转换成树的必要;链表长度如果是小于等于6,6/2=3, 而log(6)=2. 6,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。

6.取消数化阈值(树转链表)

1
static final int UNTREEIFY_THRESHOLD = 6;

到8转为红黑树  到6转为链表

The value must be greater than 2 and should be at least 8 to mesh with assumptions in tree removal about conversion back to plain bins upon shrinkage.

该值必须大于 2 并且应该至少为 8 以符合树移除中关于在收缩时转换回普通 bin 的假设。

我们可以看见收缩转回普通bin,证明hashmap会从红黑树收缩为链表

And when they become too small (due to removal or resizing) they are converted back to plain bins.
当它们变得太小时(由于移除或调整大小),它们会被转换回桶。

HashMap的构造方法

1.默认

1
2
3
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

用的最多的,值都是默认值,也许并不是最好的选择

2.指定容量

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

因为扩容是非常消耗性能的,所以我们可以自定义容量

可以看到其实指定容量,就是把自定义的容量和默认的负载因子传给的下面的方法↓

3.指定容量和负载因子

1
2
3
4
5
6
7
8
9
10
11
12
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);
}

this.threshold = tableSizeFor(initialCapacity);

指定的容量必须为2^n^,上文也提到了那我自定义给的容量不是2的N次方呢?的问题,这里不再赘述

4.指定集合构造器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}

final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
if (table == null) { // pre-size
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
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);
}
}
}

为什么float ft = ((float)s / loadFactor) + 1.0F;里要加这个1.0F呢?

一句话:==为了得到更大的容量来减少扩容,提高效率==

s/loadFactor的结果是小数,加1.0F与(int)ft相当于是对小数做一 个向上取整以尽可能的保证更大容量,更大的容量能够减少resize的调用次数

所以+1.0F是为了获取更大的容量

例如:原来集合的元素个数是6个,那么6/0.75是8, 是2的n次幂,那么新的数组大小就是8了。然后原来数组的数据就会存储到长度是8的新的数组中了,这样会导致在存储元素的时候,容量不够,还得继续扩容,那么性能降低了

而如果+1呢,数组长度直接变为16了,这样可以减少数组的扩容

HashMap的成员方法

1.增加put

大概步骤如下

  • 先通过hash值计算出key映射到哪个桶(数组索引)
  • 比对hash,如果桶上没有碰撞冲突,则直接插入
  • 如果出现碰撞冲突了,则需要处理冲突
    • 如果该桶使用红黑树处理冲突,则调用红黑树的方法插入数据
    • 否则采用传统的链式方法插入。如果链的长度达到临界值,则把链转变为红黑树
  • 比对key,如果桶中存在重复的键,则为该键替换新值value
  • 如果size大于阈值threshold,则进行扩容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
   //HashMap
public V put(K key, V value) {
//计算key的hash值传给puval方法
return putVal(hash(key), key, value, false, true);
}

static final int hash(Object key) {
int h;
//可以看到,key为null时,hash值为0
//key的hashCode()无符号右移16位 再与 原值 异或^ -> 得到hash值
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

//之前提到了hash和数组长度进行 按位与,得到索引
//(tab.length-1)&hash的结果与hash%tab.length结果一致(length是2的幂次方情况下)
n = tab.length;
tab[i = (n - 1) & hash]

有点意思的是,hashmap此处key为null时返回索引为0

而HashTable中并没有判空操作,所以hashtable的key不能为null

1
2
3
4
5
//HashTable put()
if (value == null) {
throw new NullPointerException();
}
int hash = key.hashCode();

2.核心增加putVal

1
2
3
4
5
6
7
8
9
10
11
12
   /**
* Implements Map.put and related methods.
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent 如果为true,请不要更改现有值
* @param evict 如果为false,则表处于创建模式
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict)

数组table的创建

hashmap的数组是随着new HashMap就被创建了嘛?

并不是,而是随着第一次putVal进行了判空而创建的(调用了扩容方法)

1
2
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;

数组的索引的算法

1
2
n = tab.length
tab[i = (n - 1) & hash]

上文提到了,这里再复制一遍

大家知道hashmap为了存取高效,要尽量减少哈希碰撞,即:让数据分配均匀,数组空间都占据,链表长度接近

而hashmap的算法就是==取模==hash%length,计算机直接取余的效率不如位运算

所以源码中做了优化hash&(length-1),而hash%length = hash&(length-1)前提就是length是2的幂次

3.转化红黑树treeifyBin

除非表太小,否则替换给定哈希索引处bin中的所有链接节点,在这种情况下改为调整大小

1
2
3
4
5
6
7
8
9
10
11
12
13
//putVal方法中,循环链表,判断count>=链表阈值-1时,进行转化
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
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;
}

在讲解转化方法之前,先讲一讲什么是2-3-4树和红黑树

这里推荐一个数据结构学习网站

什么是2-3-4树?

2-3-4树是四阶的B树(Balance Tree),他属于一种多路查找树,它的结构有以下限制:
所有叶子节点都拥有相同的深度
节点只能是2-节点、3-节点、4-节点

  • 2-节点:包含1个元素的节点,有2个子节点
  • 3-节点:包含2个元素的节点,有3个子节点
  • 4-节点:包含3个元素的节点,有4个子节点

所有节点必须至少包含1个元素

元素始终保持排序顺序,整体上保持二叉查找树的性质,即父结点大于左子结点,小于右子结点

而且结点有多个元素时,每个元素必须大于它左边的和它的左子树中元素

在这里插入图片描述

2-3-4树的查询操作像普通的二叉搜索树一-样,非常简单,但由于其结点元素数不确定,在一些编程语言中实现起来并不方便, 实现一般使用它的等同——红黑树

2-3-4树和红黑树的关系

  • 2节点 - 黑节点
  • 3节点 - 左倾/右倾
  • 4节点- 中间上移

在这里插入图片描述

什么是红黑树?

红黑树Red-Black-Tree [RBT] ,是一个自平衡二叉查找树,每个节点都遵守下面的原则

  • 每个节点要么是黑色,要么是红色

  • 根节点是黑色

  • 每个叶子节点是黑色

    • 一棵树当中没有子结点(即度为0)的结点称为叶子结点

  • 每个红色节点的两个子节点一定都是黑色

    • 图中红色节点没有子节点是因为默认隐藏掉了为null的黑色节点
  • 任意一个节点到每个叶子节点的路径都包含数量相等的黑节点

红黑树能自平衡靠的是什么?左旋、右旋、变色

左旋:子左给父右,父子交换

以某个结点作为支点(旋转结点),其右子结点变为旋转结点的父结点,

右子结点的左子结点变为旋转结点的右子结点,左子结点保持不变。

在这里插入图片描述

右旋:子右给父左,父子交换

以某个结点作为支点(旋转结点),其左子结点变为旋转结点的父结点,

左子结点的右子结点变为旋转结点的左子结点,右子结点保持不变。

在这里插入图片描述

变色

节点颜色由黑边红或者由红变黑

红黑树对于插入节点的分析

对于一颗普通的二叉搜索树来说,就是左小右大

红黑树的插入也是这样的,只是插入完可能是需要做平衡处理(旋转、变色)

红黑树平衡的调整处理

  • 2-3-4树2节点新增一个元素直接合并为3节点
    • 红黑树:新增一个红色节点这个红色节点会添加在黑色节点下:不需要调整
  • 2-3-4树3节点新增个元素 合并为一个4节点
    • 红黑树:就会有6种情况两种(左中右)的不需要调整
    • 根左左 根右右 旋转1次
    • 根左右 根右左 旋转2次
  • 2-3-4树4节点新增一个元素4节点中间元素升级为父节点新增元素和剩下节点合并
    • 红黑树:新增节点是红色+爷爷节点是黑色父 节点和叔叔节点为红色
    • 调整为爷爷节点变为红色,父亲和叔叔节点变为黑色,如果爷爷节点为root节点则调整为黑色

红黑树对于删除节点的分析

我们先看一颗普通的二叉树

前驱节点:对一棵二叉树进行中序遍历,遍历后的顺序,当前节点的前一个节点为该节点的前驱节点

后继节点:对一棵二叉树进行中序遍历,遍历后的顺序,当前节点的后一个节点为该节点的后继节点

例如一颗完全二叉树(1,2,3,4,5,6,7),按照中序遍历后的顺序为:(4,2,5,1,6,3,7)

即:1节点的前驱节点为:5,后继节点为:6

而删除操作就是有3种情况

  1. 删除叶子节点,直接删除
  2. 删除的节点有一个子节点,那么用子节点来替代
  3. 如果删除的节点右两个子节点,此时需要找到前驱节点或者后继节点替代可以转换为1、2的情况

转换方法treeifyBin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
   //传入数组tab和hash值用于确定下标
final void treeifyBin(Node<K,V>[] tab, int hash) {
//n是数组长度,index是下标,e是根据hash值从数组中取的节点
int n, index; Node<K,V> e;
//判断数组还有没有容量及是否大于64的数组阈值,没则扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
//桶中节点不为空则循环转换红黑树节点
else if ((e = tab[index = (n - 1) & hash]) != null) {
//hd链表头结点,tl尾结点
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);
//链表处理变treeNode后放置回桶中
if ((tab[index] = hd) != null)
//将红黑树节点链表进行平衡操作,才是最终变为红黑树
hd.treeify(tab);
}
}

将节点转为树节点

1
2
3
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}

创建红黑树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null; // 定义树的根节点
for (TreeNode<K,V> x = this, next; x != null; x = next) { // 遍历链表,x指向当前节点、next指向下一个节点
next = (TreeNode<K,V>)x.next; // 下一个节点
x.left = x.right = null; // 设置当前节点的左右节点为空
if (root == null) { // 如果还没有根节点
x.parent = null; // 当前节点的父节点设为空
x.red = false; // 当前节点的红色属性设为false(把当前节点设为黑色)
root = x; // 根节点指向到当前节点
}
else { // 如果已经存在根节点了
K k = x.key; // 取得当前链表节点的key
int h = x.hash; // 取得当前链表节点的hash值
Class<?> kc = null; // 定义key所属的Class
for (TreeNode<K,V> p = root;;) { // 从根节点开始遍历,此遍历没有设置边界,只能从内部跳出
// GOTO1
int dir, ph; // dir 标识方向(左右)、ph标识当前树节点的hash值
K pk = p.key; // 当前树节点的key
if ((ph = p.hash) > h) // 如果当前树节点hash值 大于 当前链表节点的hash值
dir = -1; // 标识当前链表节点会放到当前树节点的左侧
else if (ph < h)
dir = 1; // 右侧

/*
* 如果两个节点的key的hash值相等,那么还要通过其他方式再进行比较
* 如果当前链表节点的key实现了comparable接口,并且当前树节点和链表节点是相同Class的实例,那么通过comparable的方式再比较两者。
* 如果还是相等,最后再通过tieBreakOrder比较一次
*/
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk);

TreeNode<K,V> xp = p; // 保存当前树节点

/*
* 如果dir 小于等于0 : 当前链表节点一定放置在当前树节点的左侧,但不一定是该树节点的左孩子,也可能是左孩子的右孩子 或者 更深层次的节点。
* 如果dir 大于0 : 当前链表节点一定放置在当前树节点的右侧,但不一定是该树节点的右孩子,也可能是右孩子的左孩子 或者 更深层次的节点。
* 如果当前树节点不是叶子节点,那么最终会以当前树节点的左孩子或者右孩子 为 起始节点 再从GOTO1 处开始 重新寻找自己(当前链表节点)的位置
* 如果当前树节点就是叶子节点,那么根据dir的值,就可以把当前链表节点挂载到当前树节点的左或者右侧了。
* 挂载之后,还需要重新把树进行平衡。平衡之后,就可以针对下一个链表节点进行处理了。
*/
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;
}
}
}
}

// 把所有的链表节点都遍历完之后,最终构造出来的树可能经历多个平衡操作,根节点目前到底是链表的哪一个节点是不确定的
// 因为我们要基于树来做查找,所以就应该把 tab[N] 得到的对象一定根是节点对象,而目前只是链表的第一个节点对象,所以要做相应的处理。
//把红黑树的根节点设为 其所在的数组槽 的第一个元素
//首先明确:TreeNode既是一个红黑树结构,也是一个双链表结构
//这个方法里做的事情,就是保证树的根节点一定也要成为链表的首节点
moveRootToFront(tab, root);
}

4.扩容resize

扩容机制在上文讲述了,这里不再赘述\

容量翻倍

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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) {
//如果原来的容量已经大于了最大容量,则把threshold设置成int的最大值
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//否则,新的容量,扩充为原来的一杯oldCap << 1 向左位移1为,相当于oldCap * 2
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;

//如果走下面这个else,则说明是第一次put元素,也是第一次进行扩展
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;

//扩容 = 扩容因子 * 容量,DEFAULT_INITIAL_CAPACITY 默认是16,DEFAULT_LOAD_FACTOR默认是0.75
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}

重新计算,并旧转新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74

@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;
//如果列表里面只有一个元素,直接将e的key的hash与新容量重新计算下标

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 {
/*
这一块是处理原来的HashMap冲突的,因为容量扩容了
原来通过 & 运算出来的hash冲突的key,与新的容量 & 可能位置会发生改变
比如之前的冲突的两个key (k1,k2)的hash 分别为 1101,11101
初始化默认的容量是16 二进制就是 10000, 16-1的二进制就是 1111 与key进行& 运算结果都是 1101,但是与11111(36-1)进行 一个是1101 另一是11101
所以需要重新计算下标,通过与oldCap 进行 & 运算 分为低位和高位,因为和oldCap &运算只有两种结果,一种是0 另一种是oldCap
将结果=0的设置成低位,将等于oldCap的保存到高位,
低位的hash值肯定小于oldCap,所以下标还是在原来的位置
高位的hash一定大于oldCap,在二进制最左至少会多一个1(有可能还有前面还有其他二进制数字,但运算结果都是一样的)计算正好是原来位置加上新的容量
*/
//低位位链表头和尾
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) {
//如果loTail==null说明是第一个元素,把这个元素赋值给loHead
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;
//下标 j + oldCap 计算出来的新下标正好是原来位置索引加上新的容量
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}

扩容方法中JDK8对JDK7的优化

jdk7中,所有元素按照索引算法全部重新计算效率比较低

可以看到在1.7中,借助transfer()方法(jdk1.8中已移除),在扩容的时候会重新计算threshold,数组长度并进行rehash,这样的效率是偏低的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//jdk1.7
void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    //判断是否有超出扩容的最大值,如果达到最大值则不进行扩容操作
    if (oldCapacity == MAXIMUM_CAPACITY) {
      threshold = Integer.MAX_VALUE;
      return;
    }

    Entry[] newTable = new Entry[newCapacity];
    // transfer()方法把原数组中的值放到新数组中
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    //设置hashmap扩容后为新的数组引用
    table = newTable;
    //设置hashmap扩容新的阈值
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
  }

void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
      while(null != e) {
        Entry<K,V> next = e.next;
        if (rehash) {
          e.hash = null == e.key ? 0 : hash(e.key);
        }
        //通过key值的hash值和新数组的大小算出在当前数组中的存放位置
        int i = indexFor(e.hash, newCapacity);
        e.next = newTable[i];
        newTable[i] = e;
        e = next;
      }
    }
  }

jdk8中,在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就行

e.hash & oldCap进行位运算,判断结果

  • 是0的话,索引没变:原位置 newTab[j] = loHead;
  • 是1的话,索引变成:原索引+oldCap(原位置+旧容量) newTab[j + oldCap] = hiHead;

正是因为这样巧妙的rehash方式,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认

为是随机的,在resize的过程中保证了rehash之后每个桶上的节点数一定小于等于原来桶上的节点数,保证了

rehash之后不会出现更严重的hash冲突,均匀的把之前的冲突的节点分散到新的桶中了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
   //jdk1.8

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;
}
//扩容后的位置=原位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//扩容后的位置 = 原位置 + 旧容量
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}

从上面的源码中可以分析出扩容分为三种情况:

第一种是初始化阶段,此时的newCap和newThr均设为0,从之前的源码可知,第一次扩容的时候,默认的阈值就是threshold = DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR = 12。DEFAULT_INITIAL_CAPACITY为16, DEFAULT_LOAD_FACTOR 为0.75.

第二种是如果oldCap大于0,也就是扩容过的话,每次table的容量和threshold都会扩围原来的两倍

第三种是如果指定了threshold的初始容量的话,newCap就会等于这个threshold,新的threshold会重新计算

5.删除remove

和put一样,为用户提供一个方法,实际上调用的removeNode

传递key的hash值选中位置,传递key以选中具体元素进行删除

判断删除的节点存不存在,存在则删除并返回节点的值value,反之返回null

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

6.核心删除removeNode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
/**
* 方法为final,不可被覆写,子类可以通过实现afterNodeRemoval方法来增加自己的处理逻辑(解析中有描述)
*
* @param hash key的hash值,该值是通过hash(key)获取到的
* @param key 要删除的键值对的key
* @param value 要删除的键值对的value,该值是否作为删除的条件取决于matchValue是否为true
* @param matchValue 如果为true,则当key对应的键值对的值equals(value)为true时才删除;否则不关心value的值
* @param movable 删除后是否移动节点,如果为false,则不移动
* @return 返回被删除的节点对象,如果没有删除任何节点则返回null
*/
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; // 声明节点数组、当前节点、数组长度、索引值
/*
* 如果 节点数组tab不为空、数组长度n大于0、根据hash定位到的节点对象p(该节点为 树的根节点 或 链表的首节点)不为空
* 需要从该节点p向下遍历,找到那个和key匹配的节点对象
*/
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相等,那么当前节点就是要删除的节点,赋值给node
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;

/*
* 到这一步说明首节点没有匹配上,那么检查下是否有next节点
* 如果没有next节点,就说明该节点所在位置上没有发生hash碰撞, 就一个节点并且还没匹配上,也就没得删了,最终也就返回null了
* 如果存在next节点,就说明该数组位置上发生了hash碰撞,此时可能存在一个链表,也可能是一颗红黑树
*/
else if ((e = p.next) != null) {
// 如果当前节点是TreeNode类型,说明已经是一个红黑树,那么调用getTreeNode方法从树结构中查找满足条件的节点
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
// 如果不是树节点,那么就是一个链表,只需要从头到尾逐个节点比对即可
else {
do {
// 如果e节点的键是否和key相等,e节点就是要删除的节点,赋值给node变量,调出循环
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}

// 走到这里,说明e也没有匹配上
p = e; // 把当前节点p指向e,这一步是让p存储的永远下一次循环里e的父节点,如果下一次e匹配上了,那么p就是node的父节点
} while ((e = e.next) != null); // 如果e存在下一个节点,那么继续去匹配下一个节点。直到匹配到某个节点跳出 或者 遍历完链表所有节点
}
}

/*
* 如果node不为空,说明根据key匹配到了要删除的节点
* 如果不需要对比value值 或者 需要对比value值但是value值也相等
* 那么就可以删除该node节点了
*/
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode) // 如果该节点是个TreeNode对象,说明此节点存在于红黑树结构中,调用removeTreeNode方法(该方法单独解析)移除该节点
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p) // 如果该节点不是TreeNode对象,node == p 的意思是该node节点就是首节点
tab[index] = node.next; // 由于删除的是首节点,那么直接将节点数组对应位置指向到第二个节点即可
else // 如果node节点不是首节点,此时p是node的父节点,由于要删除node,所有只需要把p的下一个节点指向到node的下一个节点即可把node从链表中删除了
p.next = node.next;
++modCount; // HashMap的修改次数递增
--size; // HashMap的元素个数递减
afterNodeRemoval(node); // 调用afterNodeRemoval方法,该方法HashMap没有任何实现逻辑,目的是为了让子类根据需要自行覆写
return node;
}
}
return null;
}

7.获取get

和put与remove一样,提供一个对外的方法,实际上是调用的getNode方法

如果有值返回值,无值返回null

将key的hash值与key传给了getNode

1
2
3
4
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}

8.核心获取getNode

先用hash位运算得到下标first = tab[(n - 1) & hash]

判断桶的第一个元素是不是想要的(k = first.key) == key或者key != null && key.equals(k)

也就是比对key是否一致,一致则返回

否则就先判断是不是红黑树,是则用getTreeNode获取并返回

1
2
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);

不是红黑树,则循环链表并和之前一样比对key并判断

1
2
3
4
5
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);

源码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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;
}

遍历hashmap的5种方式

1.循环遍历map自带的entrySet中的entry

map的entrySet()方法可以返回Set<Map.Entry<K, V>> entrySet();

循环遍历set可以得到Map.Entry<K,V>

而Map.Entry<K,V>中包含了entry.getKey()entry.getValue()可以获取key和value

1
2
3
4
5
6
7
8
Map<Integer, Integer> map = new HashMap<Integer, Integer>();
map.put(1, 10);
map.put(2, 20);

Set<Map.Entry<Integer, Integer>> entries = map.entrySet();
for (Map.Entry<Integer, Integer> entry : entries) {
System.out.println("key ="+entry.getKey()+" value ="+entry.getValue());
}

2.ForEach迭代键值对方式

map.keySet()map.values()包含了所有的key和value,直接遍历即可

1
2
3
4
5
6
7
8
9
10
11
12
13
Map<Integer, Integer> map = new HashMap<Integer, Integer>();
map.put(1, 10);
map.put(2, 20);

// 迭代键
for (Integer key : map.keySet()) {
System.out.println("Key = " + key);
}

// 迭代值
for (Integer value : map.values()) {
System.out.println("Value = " + value);
}

3.带泛型的map的entrySet的迭代器进行遍历

map.entrySet()返回的set可以使用iterator()方法得到迭代器

entries.next()得到entry,和第一个方法一样getKey和getValue就行

1
2
3
4
5
6
7
8
9
Map<Integer, Integer> map = new HashMap<Integer, Integer>();
map.put(1, 10);
map.put(2, 20);

Iterator<Map.Entry<Integer, Integer>> entries = map.entrySet().iterator();
while (entries.hasNext()) {
Map.Entry<Integer, Integer> entry = entries.next();
System.out.println("Key = " + entry.getKey() + ", Value = " + entry.getValue());
}

4.不带泛型的map的entrySet的迭代器进行遍历

和第3种方法差不多,区别只有不带泛型,取值需要强转

1
2
3
4
5
6
7
8
9
10
11
Map map = new HashMap();
map.put(1, 10);
map.put(2, 20);

Iterator<Map.Entry> entries = map.entrySet().iterator();
while (entries.hasNext()) {
Map.Entry entry = (Map.Entry) entries.next();
Integer key = (Integer) entry.getKey();
Integer value = (Integer) entry.getValue();
System.out.println("Key = " + key + ", Value = " + value);
}

5.通过Java8 Lambda表达式遍历

1
2
3
4
Map<Integer, Integer> map = new HashMap<Integer, Integer>();
map.put(1, 10);
map.put(2, 20);
map.forEach((k, v) -> System.out.println("key: " + k + " value:" + v));

本个人博客提供的内容仅用于个人学习,不保证内容的正确性。通过使用本站内容随之而来的风险与本站无关!