简单谈谈HashMap

简单谈谈HashMap

文章目录

前言

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

hash算法

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

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

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

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

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

String类重写了hashCode()方法

    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),提高了效率

什么时候扩容?

    /**
     * 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则给初始值

    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 倍

什么时候转红黑树?

	/**
     * 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倍的链表阈值以避免冲突

    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集合类的成员

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

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

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

public interface Serializable {
    
    
}

public interface Cloneable {
    
    
}

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

1.序列化版本号

private static final long serialVersionUID = 362498820763181265L;

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

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

2.集合的初始容量

    /**
     * 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次方呢?

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

    /**
     * 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.负载因子(加载因子)

static final float DEFAULT_LOAD_FACTOR = 0.75f;

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

4.集合最大容量

static final int MAXIMUM_CAPACITY = 1 << 30;

最大容量是230,左移30位

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

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

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

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

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.取消数化阈值(树转链表)

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.默认

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

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

2.指定容量

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

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

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

3.指定容量和负载因子

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);

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

4.指定集合构造器

    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,则进行扩容
    //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

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

2.核心增加putVal

    /**
     * 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进行了判空而创建的(调用了扩容方法)

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

数组的索引的算法

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中的所有链接节点,在这种情况下改为调整大小

	//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

    //传入数组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);
        }
    }

将节点转为树节点

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

创建红黑树

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

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

容量翻倍

	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);
        }

重新计算,并旧转新


        @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,这样的效率是偏低的

//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冲突,均匀的把之前的冲突的节点分散到新的桶中了。

    //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

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

6.核心删除removeNode

/**
* 方法为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

    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获取并返回

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

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

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

源码如下

    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

    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,直接遍历即可

    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就行

    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种方法差不多,区别只有不带泛型,取值需要强转

    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表达式遍历

    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));

猜你喜欢

转载自blog.csdn.net/Jay_Chou345/article/details/121064401