HashMap实现原理通俗详解

HashMap实现原理通俗详解

前言

JDK版本:1.8
IDE:IntelliJ IDEA

概述

  • HashMap的实现方式是数组+链表,主体是数组,链表只是用来做辅助的
  • HashMap的主干是一个Node数组,每一个Node包含一个Key-Value键值对
  • HashMap通过计算Key的哈希值来确实Key-Value在HashMap中的插入、查询位置
  • HashMap最重要和复杂的方法put方法,扩容方法,get相对来说比较简单
  • HashMap是基于哈希表的Map接口的非同步实现(简单说多线程下使用它会出事儿的)

原理

先说下数组,数组的特性是通过给定数组下标进行操作(查找,插入,删除)速度非常快,怎样把这一特性应用到Map呢?
于是有聪明的coder想到了这样一种方法:通过一种方式由Map的Key生成一个随机数(哈希),
这个随机数再通过一种方式生成数组的下标(对数组长度取余),然后我们就可以非常迅速地找到它的位置了
ok基本思路确实了,下面自然就是解决这种方式存在的问题了

问题1:生成的随机数重复(哈希冲突, 碰撞)

通过Key很难生成生成一个全球唯一的随机数,而且就算生成全球唯一的随机数,需要花费的性能代价也非常高,不可取,怎么办呢?
答案就是我们只需要让生成的这个随机数足够均匀地分散到数组的每一个位置就可以了,然后如果有重复的,只需要让这个元素挂到前一个元素的后面就可以了
于是就有了数组加链表的形式,但是链表只是做辅助,最终还是以数组为主的。通过这种实现方式,HashMap查找容易,插入删除也容易

问题2:数组的长度是固定的,而Map的大小是不确定的,怎么办呢?

还能怎么办呢?Map的大小超出数组的长度总不能不插吧?只能扩容了!创建一个新的更大的数据,然后把原来数组里的所有元素重新计算放到这个新的数组里,
当然Map扩容是非常耗性能的,这个没有办法,只能你在初始化Map的时候指定数组的大小了

问题3:什么时候扩容呢?

JDK1.8的解决方案是HashMap的大小超过了数组长度的75%(loadFactor=0.75), 就会对数组进行扩容(创建一个新数组容量是原来的2倍)

问题4:怎么查找呢?

查找的时候通过Key以同样的方式生成一个随机数,然后就可以定位到目标在数组中的标下了,如果该下标下有一串好几个元素(单向链表),那就没有办法了,只能一个一个遍历,
如果这个链表超过了8个元素就把它转为一个平衡红黑树,这样查找起来更快,不过最好是一个数组下标一个元素

然后就有了HashMap如下的结构图
HashMap实现原理

要点:

1. HashMap的基本组成单元:Node

HashMap的基本组成单元是Node,它主要的实现属性如下:

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
    ...
}

取自HashMap源码,后面的构造方法啊,get,set,toString,equals,hashCode方法啊什么的这里就不列出来了,可以支JDK源码自己看
Node类在JDK1.7的时候是叫Entry,Oracle把它定义成静态内部类了,
每个Node包含一个Key-Value对,
key和value就是HashMap的key, value
next:指向下一个Node,就通过这么一个属性就实现了意向链表啦
hash:通过key的哈希值,存起来后面进行扩容的时候就不用再计算了
HashMap的主干就是一个Node数组:

/**
* 第一次初始化的时候使用,扩容的时候使用,扩容大小总是2的n次方
* 以Node<K,V>为元素的数组,也就是HashMap的纵向的长链数组,起长度必须为2的n次方
* (We also tolerate(默许) length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node<K,V>[] table;

上面的中文注释是我加上去的,它是懒加载的。

2. HashMap的put方法

put方法主要实现以下步骤:
第一步:如果数组(table)为空,则调用resize函数扩容创建一个数组
第二步:计算元素所要存储的数组下标,如果此下标没有元素则直接插入
第三步:否则说明要添加的位置已经有元素了,也就是发生了碰撞,这个时候分以下几种情况
第一种情况:key值相同,直接覆盖
第二种情况:判断链表是否为红黑树
第三种情况:链表是正常的链表(直接插到最后面就可以了)
做完以上三步后判断是否需要扩容啊什么的
下面是部分代码:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    //tab[]为数组,p是每个桶
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //第一步:table为空,则调用resize()函数创建一个
    if ((tab = table) == null || (n = tab.length) == 0) 
        n = (tab = resize()).length;
    //第二步:计算元素所要储存的位置index,如果此位置没有元素则直接插入
    if ((p = tab[i = (n - 1) & hash]) == null)  
        tab[i] = newNode(hash, key, value, null);
    //否则说明要添加的位置上面已经有元素了,也就是发生了碰撞,这个时候就要具体情况具体讨论了
    else {  
        Node<K,V> e; K k;
        //第一种情况:key值相同,直接覆盖
        if (p.hash == hash &&   
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        //第二种情况:判断链表是否为红黑树
        else if (p instanceof TreeNode) 
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        //第三种情况:链表是正常的链表
        else {  
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    //链表大于8转换为红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) 
                        treeifyBin(tab, hash);
                    break;
                }
                //如果节点key存在,则覆盖原来位置上的key,同时将原来位置的元素沿着链表身后移一位。
                if (e.hash == hash &&   
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        ...
}

3. 数组索引位置

通过对元素的key生成哈希值的函数:

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

意思就是取Key的哈希值,然后对HashCode()的高16位异或低16位,
为什么要高16位异或低16位呢?
这样做是为了让最终的哈希值更加离散分布得更加均匀,更加详细的可以去网上查,讲的的话会很长
然后利用这个值对数组的长度取余就是Key在数组中的下标啦
你可能会问如果数组扩容了,它的下标不就变了吗?
对啊,确实变了,需要重新计算它的下标了,然后把它插入到新的更大的数组里
这就是为什么Node类中要存储hash值
这就是为什么HashMap是没有顺序的
这就是为什么说扩容是非常消耗性能的

4. 扩容机制

插入的元素太多,数组装不下了就只能扩容了,HashMap会在原来的基础上把数组的容量增加一倍
当然Java里的数组是无法自动扩容的,方法就是创建一个新的更大的数组代替已有的容量小的数组
然后Node类的hash对数组的长度重新取余,以确定数组的下标。于是乎HashMap里元素的顺序又重排了。
HashMap有两个成员变量:
DEFAULT_INITIAL_CAPACITY: HashMap默认的初始化数组的大小,默认为16
DEFAULT_LOAD_FACTOR: 加载因子,默认为0.75,,当HashMap的大小达到数组的0.75的时候就会扩容

final Node<K,V>[] resize() {
    //创建一个oldTab数组用于保存之前的数组
    Node<K,V>[] oldTab = table;     
    //获取原来数组的长度
    int oldCap = (oldTab == null) ? 0 : oldTab.length;  
    //原来数组扩容的临界值
    int oldThr = threshold;     
    int newCap, newThr = 0;
    if (oldCap > 0) {
        //如果原来的数组长度大于最大值(2^30)
        if (oldCap >= MAXIMUM_CAPACITY) {   
            //扩容临界值提高到正无穷
            threshold = Integer.MAX_VALUE;  
            //返回原来的数组,也就是系统已经管不了了
            return oldTab;      
        }
        //新数组(newCap)长度乘2 < 最大值(2^30) && (原来的数组长度) >= 初始长度(2^4)
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)    
            //这个else if中实际上就是咋判断新数组(此时刚创建还为空)和老数组的长度合法性,
            //同时交待了扩容是以2^1为单位扩容的。
            newThr = oldThr << 1; 
    }// newThr(新数组的扩容临界值)一样,在原有临界值的基础上扩2^1
    else if (oldThr > 0) // initial capacity was placed in threshold
        //新数组的初始容量设置 为老数组扩容的临界值
        newCap = oldThr;    
    // 否则 oldThr == 0,零初始阈值表示使用默认值
    else {               
        //新数组初始容量设置为默认值
        newCap = DEFAULT_INITIAL_CAPACITY;  
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //如果newThr ==0,说明为上面 else if(oldThr > 0)的情况(其他两种情况都对newThr的值做了改变),
    //此时newCap = oldThr;
    if (newThr == 0) {  
        //ft为临时变量,用于判断阈值的合法性,
        float ft = (float)newCap * loadFactor; 
        //计算新的阈值
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE); 
    }
    //改变threshold值为新的阈值
    threshold = newThr; 
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    //改变table全局变量为扩容后的newTable
    table = newTab; 
    if (oldTab != null) {
        //遍历数组,将老数组(或者原来的桶)迁移到新的数组(新的桶)中
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            //新建一个Node<K,V>类对象,用它来遍历整个数组。
            if ((e = oldTab[j]) != null) {  
                oldTab[j] = null;
                //将e也就是oldTab[j]放入newTab中e.hash & (newCap -1)的位置
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                //如果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;
                    ...
                    ...
                    ...
}

5. 线程安全性

HashMap是线程不安全的,在多线程的情况下,尽量不要使用HashMap(虽然它的性能很好),而使用线程安全的ConcurrentHashMap

小结

  1. 扩容是一个特别耗性能的操作,所以在使用HashMap的时候,最好估算一下Map的大小,初始化的时候给一个大致的数值,避免Map频繁扩容。
  2. JDK1.8引入红黑树大大优化了HashMap的性能。
  3. HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap
  4. 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊
  • 5
    点赞
  • 33
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值