【数据结构和算法】HashMap从结构到源码

HashMap从结构到源码

一、哈希表结构

1、常用数据结构

1) 数组

数组 有序的元素序列,特点:寻址容易,插入和删除困难。
对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),当然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn);对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)

2) 链表

链表 存储区间是非连续、非顺序的,特点:寻址困难,插入和删除容易。
对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)

3) 哈希表

所谓的哈希表,简单的说就是散列,即将输入的数据通过hash函数得到一个key值,输入的数据存储到数组中下标为key值的数组单元中去。

在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1)

2、哈希表常用知识

1) 桶
  • 哈希表:在将键值对存入数组之前,将key通过哈希算法计算出哈希值,把哈希值作为数组下标,把该下标对应的位置作为键值对的存储位置,通过该方法建立的数组
  • 桶:哈希表数组的存储位置

数组是通过整数下标直接访问元素,哈希表是通过字符串key直接访问元素,也就说哈希表是一种特殊的数组(关联数组),哈希表广泛应用于实现数据的快速查找

2) 哈希冲突

如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞

3) 哈希冲突的解决方法

哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法

二、HashMap的结构

1、HashMap在JDK1.7和JDK1.8结构比较

JDK 1.7中HashMap采用了链地址法,也就是数组+链表的方式
HashMap图

JDK 1.8对HashMap进行了比较大的优化,底层实现由之前的“数组+链表”改为“数组+链表+红黑树”

HashMap 1.8图

2、关于红黑树的问题

1)JDK1.8为什么引入红黑树

JDK1.7中数组+链表的结构,即使哈希函数取得再好,也很难实现元素百分百均匀分布。
当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有 n 个元素,遍历的时间复杂度就是 O(n),完全失去了它的优势。

针对这种情况,JDK 1.8中引入了红黑树(查找时间复杂度为O(logn))来优化这个问题。

2)链表什么时候转换成红黑树

HashMap在jdk1.8之后引入了红黑树的概念,表示若桶中链表元素超过8时,会自动转化成红黑树;若桶中元素小于等于6时,树结构还原成链表形式。

原因

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

3)生成红黑树长度为8,退化长度为6的原因

中间有个差值7可以防止链表和树之间频繁的转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。

三、HashMap源码解析

1、HashMap基本信息

HashMap中的基本参数

//全局常量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认容量16
static final int MAXIMUM_CAPACITY = 1 << 30; //最大容量
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默认负载因子0.75

static final int TREEIFY_THRESHOLD = 8; // 链表节点转换红黑树节点的阈值, 9个节点转
static final int UNTREEIFY_THRESHOLD = 6;   // 红黑树节点转换链表节点的阈值, 6个节点转
static final int MIN_TREEIFY_CAPACITY = 64; // 转红黑树时, table的最小长度
transient Node<K,V>[] table; //Node<K,V>类的数组,里面的元素是链表,用于存放HashMap元素的实体
transient Set<Map.Entry<K,V>> entrySet;
transient int size;     // Map中KV对的个数
transient int modCount; // 用来记录HashMap内部结构发生变化的次数
int threshold;          // 阈值,决定了HashMap何时扩容,以及扩容后的大小,一般等于table大小乘以loadFactor 默认为12
final float loadFactor; // 负载因子 默认为0.75
1.1、常见问题
1) 默认阀值threshold为什么是12?

答:threshold = 16 * 0.75 = 12

//resize()方法中这一段代码是计算默认阈值的。
……
  else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;//设置默认容量
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//设置默认阈值
    }
……

threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];

……

可见以上代码是当初始化的时候设置默认容量=16、默认阈值=12。

2) 每次扩容容器的大小是怎么变化的?

答:每次扩容:newCap = oldCap << 1 ,就是扩大2倍,首次扩容是当table数组个数等于默认阀值12的时候。
容量:16、32、64……
阀值:12、24、48……
其中size是table.length(数组的长度),而不是元素的个数。
详情见:HashMap中的算法

3) HashMap的加载因子为什么是0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;

加载因子是表示Hsah表中元素的填满的程度。
加载因子越大,填满的元素越多,空间利用率越高,但冲突的机会加大了。
反之,加载因子越小,填满的元素越少,冲突的机会减小,但空间浪费多了。

冲突的机会越大,则查找的成本越高。反之,查找的成本越小。

因此,必须在 "冲突的机会"与"空间利用率"之间寻找一种平衡与折衷。

1.2、Node<K,V>类

Node<K,V>类是HashMap内部类,继承自 Map.Entry这个内部接口,它就是存储一对映射关系的最小单元,也就是说key,value实际存储在Node中。

transient Node<K,V>[] table

HashMap的主干是一个Node数组。Node是HashMap的基本组成单元,每一个Node包含一个key-value键值对。

//Node类 
 static class Node<K,V> implements Map.Entry<K,V> {
        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);
            //按位异或^不同为真,数a两次异或同一个数b(a=a^b^b)仍然为原值a
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {//改为调用Object的equals
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }
1.3、TreeNode<K,V>类

TreeNode<K,V>类是JDK1.8新增的红黑树,继承LinkedHashMap.Entry<K,V>

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    //节点的前一个节点
        boolean red;//true表示红节点,false表示黑节点
        TreeNode(int hash, K key, V val, Node<K,V> next) {
            super(hash, key, val, next);
        }

        /**
         * 获取红黑树的根节点
         */
        final TreeNode<K,V> root() {
            for (TreeNode<K,V> r = this, p;;) {
                if ((p = r.parent) == null)
                    return r;
                r = p;
            }
        }
         /**
         * 确保root是桶中的第一个元素,将root移到桶中的第一个
         */
        static <K,V> void moveRootToFront(Node<K,V>[] tab, TreeNode<K,V> root){} 
         /**
         * 查找hash为h,key为k的节点  
         */
        final TreeNode<K,V> find(int h, Object k, Class<?> kc) { // 详见get相关
              TreeNode<K,V> p = this;   ……   }
        /**
         * Calls find for root node.
         */ //获取树节点,通过根节点查找 
        final TreeNode<K,V> getTreeNode(int h, Object k) { // 详见get相关
            return ((parent != null) ? root() : this).find(h, k, null);
        }
        /**
         * 比较2个对象的大小 
         */ 
        static int tieBreakOrder(Object a, Object b) {}
        /**
         * 将链表转为二叉树
         */ 
        finalvoid treeify(Node<K,V>[] tab) {} //根节点设置为黑色 
        /**
         * 将二叉树转为链表 
         */ 
        final Node<K,V> untreeify(HashMap<K,V> map) {}
        /**
         * 添加一个键值对 
         */ 
        final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,int h, K k, V v) {}
         /**
         * 移除一个键值对 
         */ 
        final void removeTreeNode(HashMap<K,V> map, Node<K,V>[] tab,boolean movable) {}
        /**
         * 将结点太多的桶分割  
         */ 
        finalvoid split(HashMap<K,V> map, Node<K,V>[] tab, intindex, intbit) {}
        /* --------------------------------------------------*/
        // Red-black tree methods, all adapted from CLR
        //左旋转
        static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root,TreeNode<K,V> p) {}
        //右旋转
        static <K,V> TreeNode<K,V> rotateRight(TreeNode<K,V> root,TreeNode<K,V> p) {}
         //保证插入后平衡,共5种插入情况
        static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,TreeNode<K,V> x) {}
         //删除后调整平衡 ,共6种删除情况
        static <K,V> TreeNode<K,V> balanceDeletion(TreeNode<K,V> root,TreeNode<K,V> x) {}
        /**
         * 检测是否符合红黑树 
         */ 
        static <K,V> boolean checkInvariants(TreeNode<K,V> t) {}

2、HashMap核心方法

2.1、计算哈希桶数组索引位置

整个过程本质上就是三步:

  1. 拿到key的hashCode值
  2. 将hashCode的高位参与运算,重新计算hash值
  3. 将计算出来的hash值与(table.length - 1)进行&运算

hash(key):重新计算key的hash值

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

根据hash值对(数组长度-1)取模运算:

table的大小:n = tab.length
桶的位置:(n - 1) & hash
2.2、put(K key, V value)
 public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

核心方法:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)  //table为空或length=0
            n = (tab = resize()).length; //分配空间,初始化
        // 通过hash值计算索引位置, 如果table表该索引位置节点为空则新增一个
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {// table表该索引位置不为空
            Node<K,V> e; K k;
            if (p.hash == hash &&// 判断p节点的hash值和key值是否跟传入的hash值和key值相等
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;// 如果相等, 则p节点即为要查找的目标节点,赋值给e
            else if (p instanceof TreeNode)// 判断p节点是否为TreeNode, 如果是则调用红黑树的putTreeVal方法查找目标节点
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {// 走到这代表p节点为普通链表节点
                for (int binCount = 0; ; ++binCount) // 遍历此链表, binCount用于统计节点数
                    if ((e = p.next) == null) {// p.next为空代表不存在目标节点则新增一个节点插入链表尾部
                        p.next = newNode(hash, key, value, null);
                        // 计算节点是否超过8个, 减一是因为循环是从p节点的下一个节点开始的
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        //如果链表长度达到了8,且数组长度小于64,那么就重新散列,如果大于64,则创建红黑树
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            // e不为空则代表根据传入的hash值和key值查找到了节点,将该节点的value覆盖,返回oldValue
            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;
    }

put的逻辑:

  1. 校验table是否为空或者length等于0,如果是则调用resize方法进行初始化
  2. 通过hash值计算索引位置,判断索引位置p是否为空,如果tab[i] == null【没碰撞】,直接新建节点添加。否则【碰撞】执行3操作。
  3. 判断p节点的hash值和key值是否跟传入的hash值和key值相等,如果相等则更新value,否则执行4操作
  4. 判断当前数组中处理hash冲突的方式为红黑树还是链表(check第一个节点类型即可),分别处理。
    1. 是红黑树,则按红黑树逻辑插入。
    2. 是链表,则遍历链表,看是否有key相同的节点,有则更新value值,没有则新建节点,此时若链表数量大于阀值8【9个】,则调用treeifyBin方法(此方法先判断table是否为null或tab.length小于64,是则执行resize操作,否则才将链表改为红黑树)
  5. 如果++size > threshold则进行扩容resize()。
2.3、resize()

扩容机制核心方法: Node<K,V>[] resize()

HashMap扩容有三种情况

  1. 默认构造方法初始化HashMap。HashMap一开始初始化的时候会返回一个空的table,并且thershold为0。因此第一次扩容的容量为默认值DEFAULT_INITIAL_CAPACITY也就是16。同时threshold = DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR = 12。
  2. 指定初始容量的构造方法初始化HashMap。那么从下面源码可以看到初始容量会等于threshold,接着threshold = 当前的容量(threshold) * DEFAULT_LOAD_FACTOR。
  3. HashMap不是第一次扩容。如果HashMap已经扩容过的话,那么每次table的容量以及threshold量为原有的两倍
//Initializes or doubles table size,两倍扩容并初始化table
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) {//数组长度>0 扩容
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                // 新数组长度 是原来的2倍,
				// 临界值也扩大为原来2倍
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // 带参数初始化
            // 如果原来的thredshold大于0则将容量设为原来的thredshold
			// 在第一次带参数初始化时候会有这种情况
            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;
        @SuppressWarnings({"rawtypes","unchecked"})
        //新的数组,长度为新的容量newCap
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        // 如果原来的table有数据,则将数据复制到新的table中
        if (oldTab != null) {
        // 根据容量进行循环整个数组,将非空元素进行复制
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                // 获取数组的第j个元素
                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;
                          /*********************************************/
                           // (e.hash & oldCap) 得到的是 元素的在数组中的位置是否需要移动,示例如下
							// 示例1:
							// e.hash=10 0000 1010
							// oldCap=16 0001 0000
							//	 &   =0	 0000 0000       比较高位的第一位 0
							//结论:元素位置在扩容后数组中的位置没有发生改变
						
					   	    // 示例2:
							// e.hash=17 0001 0001
							// oldCap=16 0001 0000
							//	 &   =1	 0001 0000      比较高位的第一位   1
							//结论:元素位置在扩容后数组中的位置发生了改变,新的下标位置是原下标位置+原数组长度
							
							// (e.hash & (oldCap-1)) 得到的是下标位置,示例如下
							//   e.hash=10 0000 1010
							// oldCap-1=15 0000 1111
							//      &  =10 0000 1010
								
							//   e.hash=17 0001 0001
							// oldCap-1=15 0000 1111
							//      &  =1  0000 0001
							
							//新下标位置
							//   e.hash=17 0001 0001
							// newCap-1=31 0001 1111    newCap=32
							//      &  =17 0001 0001    1+oldCap = 1+16
							
							//元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
							//参考博文:[Java8的HashMap详解](https://blog.csdn.net/login_sonata/article/details/76598675)  
							// 0000 0001->0001 0001

                            /*********************************************/
                            // 注意:不是(e.hash & (oldCap-1));而是(e.hash & oldCap)
                            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;
    }

总结:1.8中 旧链表迁移新链表 链表元素相对位置没有变化; 实际是对对象的内存地址进行操作

:HashMap是先插入数据再进行扩容的,但是如果是刚刚初始化容器的时候是先扩容再插入数据。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值