• HashMap(三)之源码分析


    通过分析HashMap来学习源码,那么通过此过程我们要带着这几个问题去一起探索

    • 为什么要学习源码
    • 怎么去学习

    0.1 为什么要学习源码

    这个问题,直接给出结论,学习源码肯定是有好处的,比如:

    • 学习优秀的编码设计
    • 学习一些有用的算法
    • 学习一些简单而又实用的方法
    • 装逼神器,面试加分

    0.2 怎么去学习

    这个问题,则仁者见仁智者见智,我这里分享下我的学习心得。并通过HashMap的源码解析来实践检测我的学习方法。

    • 先看此代码的实际使用场景
    • 主句分析
    • 遇到难点的地方,先记下啦,不要影响整体节奏
    • 笔记学到的知识点:设计模式、编码风格、算法、有用的方法。

    1. 开始分析

    • 要分析源码就要知道,hashmap是怎么定义的,干什么的,

    • 虽然平时都知道new,put,get,remove.那么我们是否曾停下来,看看官方文档,系统的了解它。

    • 这也是中国程序员的通病,严重偏科,理论考试不过关,那在面试官眼里就是基础差! -- 通过一张图来了解下 

    • 从上图我们可以看到,HashMap由链表+数组组成,他的底层结构是一个数组,而数组的元素是一个单向链表。图中是一个长度为16位的数组,每个数组储存的元素代表的是每一个链表的头结点。

    • 上面我们说了有关Hash算法的事情,通过Key进行Hash的计算,就可以获取Key对应的HashCode。好的Hash算法可以计算出几乎出独一无二的HashCode,如果出现了重复的hashCode,就称作碰撞,就算是MD5这样优秀的算法也会发生碰撞,即两个不同的key也有可能生成相同的MD5。

    • 正常情况下,我们通过hash算法,往HashMap的数组中插入元素。如果发生了碰撞事件,那么意味这数组的一个位置要插入两个或者多个元素,这个时候数组上面挂的链表起作用了,链表会将数组某个节点上多出的元素按照尾插法(jdk1.7及以前为头差法)的方式添加 。

    在补充一下,就开始源码分析吧

    • HashMap HashMap采用的是数组+链表+红黑树的形式。,它存储的内容是键值对(key-value)映射。
    • HashMap 继承于AbstractMap,实现了Map、Cloneable、java.io.Serializable接口。
    • HashMap 的实现不是同步的,这意味着它不是线程安全的。它的key、value都可以为null。此外,HashMap中的映射不是有序的

    废话说了这么多,先上一段代码

        // 1.创建hashmap
        Map<String,String> stringMap = new HashMap<>();
        //2. 插入值
        stringMap.put("key1","vaule1");
        // 3. 获取值
        String value= stringMap.get("key1");
        
        // 4. 删除key-value
        stringMap.remove("key1");
    

    以上就是我们使用HashMap的常用代码,我们就围绕此段的代码来分析。 顺便稍后整理下,我们能学到啥

    • 此分析基于JDK1.8
    • 需要了解位运算
    1.0几个重要的变量
    • initialCapacity 初始化容量16
    • loadFactor 负载因子 默认0.75

    这是在时间和空间成本上寻求一种折衷。加载因子过高虽然减少了空间开销,但同时也增加了查询成本(在大多数 HashMap 类的操作中,包括 get 和 put 操作,都反映了这一点)。在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少 rehash 操作次数。如果初始容量大于最大条目数除以加载因子,则不会发生 rehash 操作

    • threshold 阈值,用于判断是否需要调整HashMap的容量,怎么获得阈值,
    • 1.7 中 threshold = size*loadFactor
    • 看一下下面这段代码,这个值的计算算法得到了进一步改进
        // 返回的是一个size2n次方的值
        // 这是 jdk1.8 中的优化获取阈值
        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;
        }
    
    • 问题来了,为啥tableSizeFor要得到一个2的n次方

    综上 hashmap 有12个成员变量,看详细源码,有分类分析

    //默认初始容量为16,这里这个数组的容量必须为2的n次幂。
        static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    
        //最大容量为2的30次方
        static final int MAXIMUM_CAPACITY = 1 << 30;
    
        //默认加载因子
        static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
        //以Node<K,V>为元素的数组,也就是上图HashMap的纵向的长链数组,起长度必须为2的n次幂
        transient Node<K,V>[] table;
    
        //已经储存的Node<key,value>的数量,包括数组中的和链表中的
        transient int size;
    
        //扩容的临界值,或者所能容纳的key-value对的极限。当size>threshold的时候就会扩容
        int threshold;
    
        //加载因子
        final float loadFactor;
    
    1.1 创建HashMap

    我们看源码,HashMap提供了4个构造方法

     

     

    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);
        }
    
        public HashMap(int initialCapacity) {
            this(initialCapacity, DEFAULT_LOAD_FACTOR);
        }
    
        public HashMap() {
            this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
        }
    
        public HashMap(Map<? extends K, ? extends V> m) {   
            this.loadFactor = DEFAULT_LOAD_FACTOR;
            putMapEntries(m, false);
        }
    

    这里 我们有没有发现,构造方法并没有new 出一个table来作为一个hashMap,而在jdk1.7是这样的。

    //在jdk1.7中,当HashMap创建的时候,table这个数组确实会初始化;但是到了jdk1.8中则没有
    public HashMap(int initialCapacity, float loadFactor) {
        ......
        this.loadFactor = loadFactor;
        threshold = (int)(capacity * loadFactor);
        table = new Entry[capacity];        //注意这里初始化了
        init();
    }
    
    • JDK1.8 中真正table初始化的地方是在putVal()方法中,即首次向HashMap添加元素时,调用resize()创建并初始化了一个table数组
    • 那么接下来分析一下看putVal()方法
    1.2 HashMap插入值

    put()-->>putVal() 实际上是下面这个方法的调用,来分析一下此方法

    • 详见代码分析
    • 如上图,此段代码核心分5个关键地方
    1.2.0 第一个箭头的地方

    就是上面说的,jdk1.8 初始化table的地方

    • table为空,则调用resize()函数创建一个
    • 调用resize()创建并初始化了一个table数组。
    • 区别与1.7 的初始化table,这里类似于一种 懒加载的思想,可以借鉴下
    1.2.1

    如果该index位置的Node元素不存在,则直接创建一个新的Node

    • 这里创建新的node,首先需要知道hash 怎么来的
    //首先将得到key对应的哈希值:h = key.hashCode(),然后通过hashCode()的高16位异或低16位实现的
    //主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
    static final int hash(Object key) {
            int h;
            return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
        }
    
      • 为什么要先高16位异或低16位再取模运算?
    • 接着分析怎么获取这个tab当前的index = (n - 1) & hash,n是新创建的table数组的长度,取模运算.得到这个node保存的位置。
    • HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,hash&(length-1) 运算等价于对length取模,也就是hash % length,但是&比%具有更高的效率。
    1.2.2

    如果该index位置的Node元素是TreeNode类型即红黑树类型了,则直接按照红黑树的插入方式进行插入

    1.2.3

    如果该index位置的Node元素是非TreeNode类型则,则按照链表的形式进行插入操作

    • 这个要添加的位置上面已经有元素了,也就是发生了碰撞。这个时候就要具体情况分
    • 1.key值相同,直接覆盖
    • 2.链表已经超过了8位,变成了红黑树
    • 3.链表是正常的链表
    1.2.4

    链表插入操作完成后,判断是否超过阈值TREEIFY_THRESHOLD(默认是8),超过则要么数组扩容要么链表转化成红黑树

    • 这里分析一下扩容这个方法
     final Node<K,V>[] resize() {
            Node<K,V>[] oldTab = table;         //创建一个oldTab数组用于保存之前的数组
            int oldCap = (oldTab == null) ? 0 : oldTab.length;  //获取原来数组的长度
            int oldThr = threshold;             //原来数组扩容的临界值
            int newCap, newThr = 0;
            if (oldCap > 0) {
                if (oldCap >= MAXIMUM_CAPACITY) {   //如果原来的数组长度大于最大值(2^30)
                    threshold = Integer.MAX_VALUE;  //扩容临界值提高到正无穷
                    return oldTab;                  //返回原来的数组,也就是系统已经管不了了,随便你怎么玩吧
                }
                //else if((新数组newCap)长度乘2) < 最大值(2^30) && (原来的数组长度)>= 初始长度(2^4))
                //这个else if 中实际上就是咋判断新数组(此时刚创建还为空)和老数组的长度合法性,同时交代了,
                //我们扩容是以2^1为单位扩容的。下面的newThr(新数组的扩容临界值)一样,在原有临界值的基础上扩2^1
                else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                         oldCap >= DEFAULT_INITIAL_CAPACITY)
                    newThr = oldThr << 1; // double threshold
            }
            else if (oldThr > 0)
                newCap = oldThr;    //新数组的初始容量设置为老数组扩容的临界值
            else {               // 否则 oldThr == 0,零初始阈值表示使用默认值
                newCap = DEFAULT_INITIAL_CAPACITY;  //新数组初始容量设置为默认值
                newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);     //计算默认容量下的阈值
            }
            if (newThr == 0) {  //如果newThr == 0,说明为上面 else if (oldThr > 0)
            //的情况(其他两种情况都对newThr的值做了改变),此时newCap = oldThr;
                float ft = (float)newCap * loadFactor;  //ft为临时变量,用于判断阈值的合法性
                newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                          (int)ft : Integer.MAX_VALUE);     //计算新的阈值
            }
            threshold = newThr; //改变threshold值为新的阈值
            @SuppressWarnings({"rawtypes","unchecked"})
                Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
            table = newTab; //改变table全局变量为,扩容后的newTable
            if (oldTab != null) {
                for (int j = 0; j < oldCap; ++j) {  //遍历数组,将老数组(或者原来的桶)迁移到新的数组(新的桶)中
                    Node<K,V> e;
                    if ((e = oldTab[j]) != null) {  //新建一个Node<K,V>类对象,用它来遍历整个数组
                        oldTab[j] = null;
                        if (e.next == null)
                                    //将e也就是oldTab[j]放入newTab中e.hash & (newCap - 1)的位置,
                            newTab[e.hash & (newCap - 1)] = e;  //这个我们之前讲过,是一个取模操作
                        else if (e instanceof TreeNode)     //如果e已经是一个红黑树的元素,这个我们不展开讲
                            ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                        else { // 链表重排,这一段是最难理解的,也是ldk1.8做的一系列优化,我们在下面详细讲解
                            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) { //关键判断条件,即可将原桶中的数据分成2if (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);
                            //可以知道,如果(e.hash & oldCap) == 0,则  newTab[j] = loHead = e = oldTab[j],即索引位置没变。反之 (e.hash & oldCap) != 0, newTab[j + oldCap] = hiHead = e = oldTab[j],也就是说,此时把原数组[j]位置上的桶移到了新数组[j+原数组长度]的位置上了
    if (loTail != null) {
                                loTail.next = null;
                                newTab[j] = loHead;
                            }
                            if (hiTail != null) {
                                hiTail.next = null;
                                newTab[j + oldCap] = hiHead;
                            }
                        }
                    }
                }
            }
            return newTab;
        }
    
    • 扩容涉及到数组迁移问题
    • 按照2倍扩容的方式,那么就需要将之前的所有元素全部重新按照2倍桶的长度重新计算所在桶。这里为啥是2倍?也涉及到reHash
    • 先,要生成一个新的桶数组,然后要把所有元素都重新Hash落桶一次,几乎等于重新执行了一次所有元素的put。
    因为2倍的话,更加容易计算他们所在的桶,并且各自不会相互干扰。如原桶长度是4,现在桶长度是8,那么
    
    0中的元素会被分到桶0和桶41中的元素会被分到桶1和桶52中的元素会被分到桶2和桶63中的元素会被分到桶3和桶7为啥是这样呢?
    
    0中的元素的hash值后2位必然是00,这些hash值可以根据后3000或者100分成2类数据。他们分别&8-1)即&111,则后3位为000的在桶0中,后3位为100的必然在桶4中。其他同理,也就是说桶4和桶0重新瓜分了原来桶0中的元素。
    
    如果换成其他倍数,那么瓜分就比较混乱了。
    
    这样在瓜分这些数据的时候,只需要先把这些数据分类,如上述桶0中分成000100 2类,然后直接构成新的链表,分类完毕后,直接将新的链表挂在对应的桶下即可
    
    • 代码中注释的很详细了,此处分析可以参考下面提供的而参考文章,分析需要依赖于位运算
    1.3 HashMap获取值

    直接上代码

     public V get(Object key) {
     // 这里可以看到 get方法其实是拿到一个Node对象后,再取其value
     
            Node<K,V> e;
            return (e = getNode(hash(key), key)) == null ? null : e.value;
        }
    

    所以核心方法应该是 getNode()

    1.4 HashMap删除key-value

    直接上代码

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

    所以核心方法应该是 removeNode()

    总结 了解 hash 和扩容,get put 就很好理解了,这里就不展开讨论了

    2. 学习总结

    • 怎么利用位运算来让代码更加优雅
    疑点
    • 为什么要先高16位异或低16位再取模运算

    3. 参考文章

    4. 知识点补充

  • 相关阅读:
    Python 面向对象
    python Flask
    工作中的Python脚本
    python模块
    python函数
    Python小课题练习作业
    Python文件处理
    Maven 生成可执行的jar包
    Maven [ERROR] 不再支持源选项 5。请使用 6 或更高版本
    MySQL 导入导出数据
  • 原文地址:https://www.cnblogs.com/Profound/p/10879096.html
Copyright © 2020-2023  润新知