超详细的HashMap解析(jdk1.8)

本文首发于cdream的我的博客,点击得到更好的阅读体验!html

欢迎转载,转载请注明出处。前端

本文是我在学习 java集合过程当中,针对HashMap的一篇总结文章。因为博主是非科班出身程序员,在学习HashMap原理时遇到了不少困难,因此若是你和博主同样,数据结构基础也不扎实甚至是没有基础,这篇文章可能也很是适合你!java

注:本文基于jdk1.8,不包括红黑树部分分析node

[TOC]git

如下是本文的结构,可根据须要跳转到相应位置,没必要按顺序阅读!程序员

image-20181121193553067

1、预备知识

时间复杂度

时间复杂度用来度量算法的运行时间,记做: T(n) = O(f(n))。它表示随着输入 n 的增大,算法执行须要的时间的增加速度能够用 f(n) 来描述。渐进时间复杂度用大写O来表示,因此也被称为大 O表示法。github

经常使用的时间复杂度比较:算法

O(1)<O(log n)<O(n)<O(nlog n)<O(n^2)后端

从左到右,算法执行效率逐渐降低,数组

了解更多:

  1. 十分钟搞定时间复杂度(算法的时间复杂度)
  2. 一套图 完全明白了“时间复杂度”

基本数据结构

HashMap主干是哈希表,这以前先了解一下其余几种数据结构的性能。

数组

采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为 O(1);经过给定值进行查找(顺序查找),须要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为 O(n);对于有序数组,可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提升为 O(logn);对于指定位置的插入删除操做,涉及到数组元素的移动,其平均复杂度为 O(n);另外修改和查找实现复杂度相同。

链表

不是按线性的顺序存储数据,而是在每个节点里存到下一个节点的指针(Pointer)。对于链表的新增,删除等操做(在找到指定操做位置后),仅需处理结点间的引用便可,时间复杂度为 O(1),而查找操做须要遍历链表逐一进行比对,复杂度为 O(n)。

<div class="note info"><p style="color: #EE82EE">数组与链表的根据指定值查询时间复杂度都是O(1),但数组更快</p>1. <i>链表须要在遍历时,须要比数组更多的寻址操做</i> 2. <i>CPU缓存会把一片连续的内存空间读入,由于数组结构是连续的内存地址,因此数组所有或者部分元素被连续存在CPU缓存里面,而链表则不会</i></div>

红黑树

是一种自平衡二叉查找树,能够在O(log n)时间内作查找,插入和删除,jdk8以后HashMap桶内链表长度超过树化阀值且总长度超过最小树化容量后会将链表转换为红黑树。

散列表

散列表(Hash table,也叫哈希表)是一种根据 key-value进行访问的数据结构。在散列表中进行添加,删除,查找等操做,性能十分之高,不考虑哈希冲突的状况下,仅需一次定位便可完成,时间复杂度为O(1)。哈希表是 HashMap主干,因此在分析 HashMap前要先详细了解一下哈希表。

散列表(Hash table),是根据键(Key)而直接访问在内存存储位置的数据结构。也就是说,它经过计算一个关于键值的函数f(x),将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数f(x)称作散列函数,存放记录的数组称作散列表。

散列函数(Hash function),经散列函数映象到地址集合中任何一个地址的几率是相等的,则称此类散列函数为均匀散列函数(Uniform Hash function),散列函数的设计相当重要,好的散列函数会尽量地保证 计算简单散列地址分布均匀。散列函数构造方法包括直接定址法,随机数法,除留余数法等。

冲突(Collision):对不一样的Key可能获得同一散列地址,即k1≠ k2,而 f(k1)=f(k2),再好的散列函数也没法避免冲突。产生冲突就要进行处理,一般处理冲突的方法有开发定址法,单独链表法,双散列,再散列等。在java中使用的是单独链表法

举个例子:

假设有个数组长度为4数组(每个位置都叫桶bucket),这里有3我的赵四,钱五,孙六要装进去,咱们定义一个规则,按姓氏在百家姓中的顺序除以四获得的余数做为索引放入四个位置。

image-20181118161709369

当前存放三我的记录的数组就是散列表,咱们定义的规则就是散列函数,根据散列函数进行映射的过程叫作散列过程。

image-20181118174832759

若是又来一我的叫周日,根据咱们的规则,周日也要落在1的位置上,此时就产生了冲突。这里咱们使用单独链表法处理,把周天放在索引为一的位置和赵四构成链表(新元素是放在链表前端仍是后端彻底是取决于怎么方便)。

散列表查找是就是先找到桶的位置,再遍历查找须要的数据。若是散列函数设计的很差,全部的元素都落在一个桶里那效率就特别低,和单链表随机访问没什么区别。

基本位运算

运算符 计算方式
与 & 只有两个数同一位都是1才会返回1
或 l 两个数同一位只要存在一个1就是1
异或 ^ 两个数同一位不能相同才为1
左移 << 全部位置左移,低位补0
右移 >> 正数:全部位置右移,高位补0</br>负数:写出补码(符号位不变,其他位置取反,而后加1),全部位置右移高位补1,而后再获取原码(符号位不变,其他位置取反,而后加1)
无符号右移 >> 不管正负高位补0

了解更多:

  1. 负数的带符号和不带符号的右移运算
  2. 位运算符详解

2、HashMap实现原理

结构

Node是 HashMap的静态内部,HashMap主干是一个Node数组,Node是HashMap的最基本组成单位。

// HashMap的主干数组
transient Node<K,V>[] table;

Node

static class Node<K,V> implements Map.Entry<K,V> {
    // 这个节点所在位置的hash值
    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;
        }
    
    …………(其余get(),set()等方法省略)
        
}

在jdk8以前HashMap是数组加链表的形式实现,可是在1.8以后为提升哈希冲突后链表的查询速度,当桶内链表长度超过树化阀值且总长度超过最小树化容量后会将链表转换为红黑树。

jdk7

image-20181118180344514

jdk8

image-20181118180418238

速度

查询与修改

先用散列函数对键进行散列,没有冲突的状况下查询是下标查询,时间复杂度是 O(1),速度很快。

存在哈希冲突的状况,须要对链表/红黑树进行遍历,equals比对查询。

性能上,考虑是链表/红黑树上的元素越是越好,越均匀越好;此外HashMap主干未必越长越好,会有用不到的桶浪费空间。

增长与删除

因为查询速度快,而桶里用链表/红黑树实现,因此添加和删除效率也很高。HashMap会在size超过阀值后进行调整大下(resize),因此根据具体状况提早给HashMap一个合适的初始长度是个不错的习惯。


3、源码分析

基本常量

// 默认初始长度,即主干数组的长度,若是建立对象时没有给长度,默认是16
// 在明确知道元素个数的状况下,初始化时建议能够把容量设置成expectedSize / 0.75F + 1.0F (guava)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

// 最大容量,HashMap最大容量是2^30
// 由于int范围是-2^31——2^31-1,但32位2进制最高位是符号位,因此最大是2^30
static final int MAXIMUM_CAPACITY = 1 << 30;

// 默认负载因子,默认是0.75,建立对象时能够自定义
// 能够用于计算扩容阀值transient Node<K,V>[] table;
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 树化阀值
// 当某一个桶中链表长度超过8时会转化为红黑树
static final int TREEIFY_THRESHOLD = 8;

// 去树化阀值
// 当一个桶里红黑树总结点数小于6时,会转化为链表
static final int UNTREEIFY_THRESHOLD = 6;

// 最小树化容量
// 树化的另外一个条件,只有主干数组长度大于64才进行树化
static final int MIN_TREEIFY_CAPACITY = 64;

<div class="note warning"><p>CAPACITY是 HashMap容量(主干数组长度),size是键值对个数</p></div>

基本成员变量

// 主干数组
transient Node<K,V>[] table;

// 遍历时常常用到
transient Set<Map.Entry<K,V>> entrySet;

// HashMap中Node的总个数
transient int size;

// 用于快速失败,HashMap是非线程安全的,在迭代过程当中,若是结构发生改变,会抛出ConcurrentModificationException
transient int modCount;

// 阀值,是否调整主干数组长度的指标
// 通常由capacity(主干数组长度) * loadFactor计算,超过范围会取最大容量MAXIMUM_CAPACITY
int threshold;

//负载因子,数组的填充量,计算阀值使用,上面有个默认的0.75F
final float loadFactor;

构造方法

主要构造方法

HashMap有四种构造方法,这里只说最核心的一个,只说传入初始容量和负载因子这种。

/**
 * 核心构造方法
 * @param  initialCapacity 初始化容量
 * @param  loadFactor      负载因子
 */
public HashMap(int initialCapacity, float loadFactor) {
    // 初始容量小于0,抛异常
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    // 若是大于了最大容量,就转成最大容量
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    // 负载因子小于0或是个非法数字(除数为0这种),抛异常
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    // 这里初始容量赋值给了阀值,后面会用到
    this.threshold = tableSizeFor(initialCapacity);
}

<div class="note warning">在构造方法中,并无对table这个成员变量进行初始化,table的初始化被推迟到了put方法中,在put方法中会对threshold从新计算。</div>

tableSizeFor(initialCapacity)方法是用来计算初始容量的,HashMap容量并非传多少就是多少,而必定是2的次幂。这个方法会返回一个比给定容量大的最小2的次幂的数。

举个例子:若是你给了9,比9大的最小2的次幂是16(2^4);若是你给个27,比27大的最小的2的次幂是32(2^5)。

static final int tableSizeFor(int cap) {
    // 先是将cap减1,不然,若是cap是2的次幂,例如16,计算结果就是32,是咱们须要的容量的2倍
    int n = cap - 1;
    // 这里是先将n无符号右移,再与n进行或运算并赋值给n 
    // 这样好理解一点 =>  n = n | (n >>> 1)
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    // 若是n<0返回1
    // 若是n>最大容量返回最大容量
    // 不然返回 n+1
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

解释:

n=0

通过几回次无符号右移仍是0,最后返回n+1是1

n>0

下面这个图演示前三步移动的过程

image-20181119222138595

剩下的你们脑补,最后算出来就是32位之内最高位那个1后面跟的都是1,而后n≠1的状况下会加个1,就是咱们要的结果,这里结果是2^8,原来那个显然是大于2^7的一个数。看完这个过程是否是以为"妙啊!",我也以为这个算法好机智,哈哈。

其余构造方法

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

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

以上几个构造函数都没有直接的建立一个切实存在的数组,他们都是在为建立数组须要的一些参数作初始化,table的初始化被推迟到了put方法中,因此这几个构造函数中并无被初始化的属性都会在实际初始化数组的时候用默认值替换。

这个构造函数有put过程,table已经完成初始化

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

<div class="note info"><p>小结</p>1.<i>构造函数会建立一个容量(主干数组长度)大于等于initialCapacity的最小的2的幂长度的HashMap</i><br/></br/>2.<i>负载因子能够自定义</i><br/></br/>3.<i>多数构造方法中并无初始化table,table初始化的过程是在put方法中完成的</i></div>

put方法

put

put方法是一个重点方法,这里有 HashMap初始化,数据在 HashMap中是如何储存的,什么状况下链表会转换为红黑树等内容,须要仔细研究。

public V put(K key, V value) {
    //这里继续调用putVal方法
    return putVal(hash(key), key, value, false, true);
}

putVal

putVal是final修饰的方法,子类 LinkedHashMap也是用的这各方法,evict(看下面的的第5个参数)就是给 LinkedHashMap使用的,HashMap中并无什么用。

/**
 * 真正进行插入操做的方法,
 * hash 传入key的哈希值
 * onlyIfAbsent 若是该值是true,若是存在值就不会进行修改操做
 * evict LinekdHashMap尾操做使用,这里暂无用途
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    /**********初始化********/
    
    // 若是table长度是0或table是null会调整一次大小
    // 这时tab会指向调整大下后的Node<K,V>[](主干数组)
    // n被赋值为新数组长度
    
    // 若是没有调整大小,tab指向table
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    
    /********开始查找键的位置,并存储value*******/
    
    // i = (n - 1) & hash这个是获取key应该在哪一个桶里,下面详说
    // 这里将p指向当前key所须要的那个桶
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 若是空桶,也就是无哈希冲突的状况,直接丢个Node进去。
    	// 此时的tab就是table
        tab[i] = newNode(hash, key, value, null);
    //存在冲突,开始寻找咱们要找的节点
    else {
        Node<K,V> e; K k;
        // 判断第一个节点是否是咱们找的
        // 此时k储存了 p.key
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            // hash系值相等,key值相等,定位完成,是修改操做
            // e来储存p这个节点,一会修改
            e = p;
        // 判断是不是红黑树节点
        else if (p instanceof TreeNode)
            // 是红黑树节点,存在就返回那个节点,不存在就返回null
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 最终,是链表了,开始对链表遍历查找
        else {
           
            for (int binCount = 0; ; ++binCount) {
                // 上面知道第一个接点不是咱们要的,直接获取下一个,并储存给e
                // 下一个是空,直接丢个Node在这里,而后p.next指向这里
                // 这里下一个节点地址给了e
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // !大于树化阀值,开始树化
                    // 注意-1是由于binCount是索引而不是长度
                    // 其实此时链表长度已是7+1(索引) + 1(新进来的Node)
                    // 已经大于树化阀值8,也就是说链表长度为8时是不会树化的
                    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;
            }
        }
        
        // 上面说了,这有修改操做e才能不是null
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            // 给e新值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            // 这个是LinkedHashMap用的,HashMap里是个空实现
            afterNodeAccess(e);
            // 修改就会把旧值返回去
            return oldValue;
        }
    }
    
    /*********修改完成的后续操做**********/
    // 修改次数加1
    ++modCount;
    // 若是size大于阀值,会执行resize()方法调整大小
    if (++size > threshold)
        resize();
    // 这个是给LinkedHashMap用的,HashMap里也是个空实现
    afterNodeInsertion(evict);
    // 添加成功返回null
    return null;
}

hash

再来看一下hash()这个方法吧。

static final int hash(Object key) {
    int h;	
    // key是null就返回0,key不是null就先取hashCode()而后与这个hashCode()无符号右移进行亦或运算
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

image-20181120190453468

<div class="note primary"><p style="color:blue;font-weight: bold">可能小伙伴有疑惑,好好的hashCode()非弄个亦或运算干啥?</p><li>这是由于找key的位置时,`(n - 1) & hash`是table的索引,n的长度不够大时,只和hashCode()的低16位有关,这样发生冲突的几率就变高。</li><br/><li>为减小这种影响,设计者权衡了speed, utility, and quality,将高16位与低16位异或来减小这种影响。设计者考虑到如今的hashCode分布的已经很不错了,并且当发生较大碰撞时也用树形存储下降了冲突。仅仅异或一下,既减小了系统的开销,也不会形成的由于高16位没有参与下标的计算(table长度比较小时)而引发的碰撞。</li></div>

举个例子:下图就是table.length为16时的计算状况,若是没有亦或运算就只和低4位有关,这样就会加大冲突的几率。

image-20181120191018657

resize

这也是一个很重要的方法,主要包括两部分,第一部分是根据size是否超过阀值判断是否须要进行扩容,第二部分是扩容后将原Node[]中数据复制到扩容后的Node[]中

扩容部分

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    // 原容量,table为null返回0,不然返回table长度
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 原阀值
    int oldThr = threshold;
    // 新容量,新阀值
    int newCap, newThr = 0;
    // table已经初始化
    if (oldCap > 0) {
        // 容量已经超过最大容量,直接返回去
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 2倍扩容后小于最大容量,而且原容量大于默认初始化容量(我还没想清楚为何要大于默认初始容量)
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            // 阀值加倍
            newThr = oldThr << 1; // double threshold
    }
    // 原数组容量为0,未初始化,单阀值不为0
    // 也就是构造方法里threshold = tableSizeFor(initialCapacity)这个步骤
    else if (oldThr > 0) 
        newCap = oldThr;
    // 啥都没有,默认构造
    else {               
        newCap = DEFAULT_INITIAL_CAPACITY;
        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;

复制数据部分

看源码前,先看下面这个图

image-20181120221414380

正常来说,向新数组复制元素时须要从新计算位置,如今有了这个规律,就能够这样作:

  • x=0不改变位置
  • x≠0原位置+原数组长度获取新位置

判断x是否为0,e.hash & oldCap能够完成,返回结果是0,表明x处是0,位置不用改变,不然改变位置

image-20181120223547843

//建立新的数组
	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;
                // x获取桶的第一个节点
                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里
                            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;
    }

复制过程,a过去,假设计算后位置不边,进到i,此时i为null,a进去后便是head,又是tail

而后循环,到b,假设计算后仍是i,i中已经有a,因此b直接丢到a后面,a任是head,单tail已经变成了b

以此类推,a,b,c,d都会放在i,j中

<div class="note warning">实际上是先拼完链表才装进桶里的,这里只是方便描述,说成是一个一个过去</div>

image-20181120225308182

至此,put方法已经说完了,重点是putVal,hash和resize三个方法,若是不理解能够看本文结尾的参考文献,由于不一样的人思惟方式,表达方式都不一样,说不定换一种表述方式就能理解了。

remove

remove就是先找到节点位置,而后移除,核心方法是removeNode()

public V remove(Object key) {
    Node<K,V> e;
    // 调用removeNode,若是移除成功返回原值,不然返回null
    return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value;
}

removeNode

/**
 * value remove方法重载时使用,只有同时匹配key-value时移除该节点
 * matchValue,为true时才会同时匹配key-value进行删除
 * movable 删除节点后是否改变红黑树的结构,般都为true只有在iterator的时候才为false
 */
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;
    // 1.原数组不为null 2. 原数组长度大于0 3.key数组中的位置不为空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        // 声明两个节点node,e
        Node<K,V> node = null, e; K k; V v;
        // 第一个节点就咱们要找的节点
        
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            // 先给node,在下面删掉
            node = p;
        else if ((e = p.next) != null) {
            // 若是是红黑树,获取该接点并给node
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            // 若是是链表,循环遍历
            else {
                do {
                    // 若是是要找的节点就把这个节点给node
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    // 不是把节点给p记录,继续检查下一个节点
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        /**********删除节点的部分*********/
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            // 若是是红黑树节点,使用removeTreeNode移除
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                // 这里执行的就是上面的第一种状况,桶里的第一个节点就是要移除的
                tab[index] = node.next;
            else
                // 直接将移除的上个节点指向下一个节点
                p.next = node.next;
            // 修改次数再加1
            ++modCount;
            // 长度 -1
            --size;
            // 给LinkedList使用,这里没啥用
            afterNodeRemoval(node);
            // 删除的值返回去
            return node;
        }
    }
    // 根本没有这个键
    return null;
}

大体过程就是这个样子的~~,勉强看吧没画图天赋!

image-20181121185854156

源码分析到这里就结束,看了这几个方法,只要不是红黑树的部分,看起来就很没那么困难了。

4、平常使用注意事项

  1. 在能够明确HashMap长度的状况下,最好给HashMap一个初始容量

    看完上面原码后,会发现HashMap使用过程当中会出现resize()操做,会涉及到哈希表的重建,这是一个比较消耗资源的操做,若是在明确长度的状况下,能给定合适的容量就能够减小甚至避免扩容操做。

    阿里巴巴开发手册给出以下公式:

    {% cq %} initialCapacity = (须要存储的元素个数 / 负载因子) + 1。 {% endcq %}

    注意负载因子(即loader factor)默认为0.75, 若是暂时没法肯定初始值大小,请设置为16(即默认值)

    在guava中其实也使用这个公式,而且guava提供下面这个方法来建立HashMap:

    {% note info %}Map<String, String> map = Maps.newHashMapWithExpectedSize(10){% endnote %}

    其实这个公式是来自putAll()方法,感兴趣的小伙伴能够去看一下。

  2. 重写equals方法是必定要重写hashCode方法

    老生长谈了,这里主要针对key是对象的状况,举个例子:

    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;
        }
        @Test
        public void test(){
            Map map = new HashMap();
            Person p1 = new Person(1234,"小白");
            map.put(p1,"哈哈哈哈");
            Person p2 = new Person(1234,"小白");
            map.get(p2);
        }

    当用person来作key时,显然,若是在hashcode不重写的状况下,使用p2是没法得到须要的内容的,由于两个对象用来找桶的hashcode是不一样的,因此没法找到想同的桶啊!桶都找不到去哪里找值哈哈!

5、总结

本文记录了我学习HashMap的全过程,包括预备知识、实现原理、源码分析、注意事项等几个部分,对我这个没数据结构基础的人来讲收获真的很大,但愿对各位读者也有必定的帮助!存在问题但愿你们指正!


本文参考:

  1. HashMap实现原理及源码分析
  2. 揭秘 HashMap 实现原理(Java 8)
  3. HashMap源码注解 之 静态工具方法hash()、tableSizeFor()(四)
  4. Think in Java 第四版 第8章 集合部分
  5. Java 1.8中HashMap的resize()方法扩容部分的理解
  6. 关于HashMap容量的初始化,还有这么多学问
相关文章
相关标签/搜索