HashMap的原理解析

在这里插入图片描述

hashmap作为面试的热门,也是哈希表这种数据结构的工业级应用和范例。非常值得我们花大力气搞懂。如果有所收获或者疑惑,希望大家能够积极反馈,共同学习进步!

本文借鉴了王争、杨晓峰的专栏以及
https://blog.csdn.net/daerzei/article/details/79855507 https://blog.csdn.net/hefenglian/article/details/79763634
https://blog.csdn.net/qq_38182963/article/details/78940047

一、底层数据结构

在JDK1.8后,HashMap采用位桶+链表+红黑树实现,当链表长度超过阈值(8),时,将链表转换为红黑树,这样大大减少了查找时间。

二、HashMap的实现原理

HashMap把table里面的每个元素形象化为桶bucket

1. 基本组成单元:Node

为什么说底层是数组+链表?

查看源码

1.7部分的初始化的源码
// 初始为空table,由entry组成的数组
static final Entry<?,?>[] EMPTY_TABLE = {};
// resize的时候必须为2的次方
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE

而entry是什么呢?

static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next; // 证明entry是单链表
        int hash;
1.8解释初始化部分的源码
/*第一次初始化的时候使用,扩容的时候使用,扩容大小总是2的N次方
以Node<K,V>为元素的数组,也就是HashMap的纵向的长链数组,起长度必须为2的n次方*/
transient Node<k,v>[] table;//存储(位桶)的数组</k,v>  

node是什么呢?

Node的具体代码

//Node是单向链表,它实现了Map.Entry接口  
static class Node<k,v> implements Map.Entry<k,v> {  
    final int hash;  
    final K key;  
    V value;  
    Node<k,v> next;//next节点的指针,证明单向链表  
  

2. 初始化

    public HashMap(int initialCapacity, float loadFactor) {
       // ...
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }

该hashmap采用懒加载,最开始只是设置些初始值。在首次使用时被初始化。

3. HashMap的put方法

put方法只有一个putVal的调用,直接看putVal

put方法主要实现以下步骤:
第一步:如果数组(table)为空,则调用resize函数扩容创建一个数组
第二步:计算元素所要存储的数组下标,如果此下标没有元素则直接插入
第三步:否则说明要添加的位置已经有元素了,也就是发生了hash冲突,这个时候分以下几种情况
第一种情况: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;
            }
        }
        ...
}

这里面需要注意的是:HashMap的hash函数是如何写的。

将hash值的计算和index的计算摘出来,非常精彩!

hash值的计算,并不是key本身的hashcode,而是另一种hash函数!

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

在插入或查找时,计算key被映射到桶的位置。

int index = hash(key) & (capacity - 1)
为什么必须是右移16位

首先hashcode本身是个32位整型值(int是32位)。获取对象的hashcode之后,先进行移位运算,再和自己做异或运算,非常巧妙,将高16位移到低16位,这样计算得到的整型值将“具有”高位和低位的性质。

因为需要考虑这样的情况:有些数据计算出的hash值差异主要在高位,而hashmap里的hash寻址(也就是计算放置到数组的索引位置)是忽略容量(初始16)以上的高位的,这种处理可以有效避免类似情况的哈希碰撞。

举个例子:我们假设有一种情况,对象A的hashCode 为 1000010001110001000001111000000,对象 B 的 hashCode 为 0111011100111000101000010100000。

如果容量是16,16-1=15,二进制1111,对 与运算这两个数, 你会发现高位都未参与运算,结果都是0。这样的散列结果太让人失望了。很明显不是一个好的hash算法。

但是如果我们将 hashCode 值右移 16 位,也就是取 int 类型的一半,刚好将该二进制数对半切开。并且使用位异或运算(如果两个数对应的位置相反,则结果为1,反之为0),这样的话,就能避免我们上面的情况的发生。

为什么要容量减1

最后,用hash表当前的容量减1,再和刚计算出来的整型值做位与运算,为什么要容量减1呢?

因为A%B = A & (B-1),该式子在B是2的指数时成立,转换为取模运算,结果只取决于hash值。这也是为什么容量建议2的幂次方,这样保证&中的二进制位全是1,最大限度利用hash值,更好的散列,让hash值均匀的分布在桶中

4. 数组的索引位置

通过对元素的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是没有顺序的

这就是为什么说扩容是非常消耗性能的

5. 容量和装载因子

Capacity很容易理解,load factor的话涉及到了哈希表的数据结构的底层,可以理解为数组中空闲槽位的比例,计算公式是:哈希表的装载因子=填入表中的元素个数/哈希表的长度

装载因子越大,空闲位置越少,哈希冲突越多,哈希表的性能下降。

JDK给出的默认装载因子为0.75,是在时间和空间成本上的折中,过低的话浪费内存空间。

容量和装载因子决定数组中可用的桶的数量,空桶太多浪费空间,使用太满影响性能,极端情况下,只有一个桶,退化为了链表。

根据上面公式,如果我们知道要存取的键值对数量,可用预设合适的容量大小,需要满足装载因子*容量>元素数量

预设的容量要满足大于“预估元素数量/装载因子”,同时也是2的幂数

5. 扩容机制

插入的元素太多,数组装不下了就只能扩容了,HashMap会在原来的基础上把数组的容量增加一倍
当然Java里的数组是无法自动扩容的,方法就是创建一个新的更大的数组代替已有的容量小的数组

然后Node类的hash对数组的长度重新取余,以确定数组的下标。于是乎HashMap里元素的顺序又重排了。
扩容:一是扩大table的长度,而是修改node的位置。容量n扩大一倍,新table中,node的下标要么还是原来的t,要么是t+n。

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;//老的table不用了,赋值为null,垃圾回收
                //如果e的下一个节点是null说明没有链表或树的结构,重新计算下标,赋值到新的table
                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);
                // 链表重排,注意,原table的某些key会被计算到同一个下标,但是新的table中不一定
                // 因此,链表可能会拆散,变成0-2个链表
                // 所以,定义两个node对,一个是loHead,loTail;一个是hiHead,hiTail
                else { 
                    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==0的Node会被分配到同一个位置,确切的说,和原table下标一样
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            //其余节点会被分配到另一个的同一位置,确切说是原table下标+oldCap
                            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;
}
扩容涉及到一个小问题,如何知道要将原数组的某个元素放到新数组的哪个索引位置上?

也就是说如何确定元素e在新数组的位置。之前put的时候,用的是hash(key) & (capacity - 1)确定,为什么不继续用该方法,却转而判断(e.hash & oldCap) == 0,判断原来的元素在新数组上是否移位,假设capacity是16,只需要看倒数第五位,如果为0,下标不变,如果是1,下标加上容量oldCap。

6. 线程安全性

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

三、LinkedHashMap

LinkedHashMap前面比HashMap多了个"Linked",是不是说,它是一个通过链表法解决散列冲突的散列表呢?

其实,LinkedHashMap并没有这么简单,其中的“Linked”也并不仅仅代表它是通过链表法解决散列冲突的。

先看下面的一段代码,你觉得这段代码会以什么样的顺序打印3,1,5,2这几个key呢?原因又是什么呢?

HashMap<Integer,Integer> m = new LinkedHashMap<>();
m.put(3,11);
m.put(1,12);
m.put(5,23);
m.put(2,22);
for (Map.Entry<Integer, Integer> entry : m.entrySet()) {
  System.out.println(entry.getKey());// 3,1,5,2
}

上面的代码会按照数据插入的顺序依次来打印,也就是说,打印的顺序就是3,1,5,2。你有没有觉得奇怪?散列表中数据是经过散列函数打乱之后无规律存储的,这里是如何实现按照数据的插入顺序来遍历打印的呢?

LinkedHashMap也是通过散列表和链表组合在一起实现的。实际上,它不仅支持按照插入顺序遍历数据,还支持按照访问顺序来遍历数据。你可以看下面这段代码:

 // 10是初始容量,0.75是装载因子,true表示按照访问时间排序
HashMap<Integer,Integer> m = new LinkedHashMap<>(10,0.75f,true);
m.put(3,11);
m.put(1,12);
m.put(5,23);
m.put(2,22);

m.put(3,26);
m.get(5);
for (Map.Entry<Integer, Integer> entry : m.entrySet()) {
  System.out.println(entry.getKey());// 1,2,3,5
}

为什么这段代码会按照这样顺序来打印?

每次调用put()函数,往LinkedHashMap中添加数据的时候,都会将数据添加到链表的尾部,再次将键值为3的数据放入到LinkedHashMap的时候,会先查找这个键值是否已经有了,然后,再将已经存在的(3,11)删除,并且将新的(3,26)放到链表的尾部。访问到key为5的数据的时候,我们将被访问到的数据移动到链表的尾部。

所以,最后打印出来的数据是1,2,3,5。从上面的分析,你有没有发现,按照访问时间排序的LinkedHashMap本身就是一个支持LRU缓存淘汰策略的缓存系统。

总结一下,实际上,LinkedHashMap是通过双向链表和散列表这两种数据结构组合实现的。LinkedHashMap中的“Linked”实际上是指的是双向链表,并非指用链表法解决散列冲突

四、相关经典面试

1. 介绍HashMap

按照特性来说明一下:储存的是键值对,线程不安全,非Synchronied,储存的比较快,能够接受null。

按照工作原理来叙述一下:Map的put(key,value)来储存元素,通过get(key)来得到value值,通过hash算法来计算hascode值,用hashCode标识Entry在bucket中存储的位置,储存结构就算哈希表。

table数组什么时候获得初始化

第一次插入元素的时候

初始化hashMap后,第一次放入元素,table的长度是多少?

16

new HashMap(19),创建的map中table数组长度多大?

初始化时实际上为null,第一次插入元素时32.

2. 你知道HashMap的工作原理吗?你知道HashMap的get()方法的工作原理吗?

HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。
当我们给put()方法传递键和值时,我们先对键调用hashCode()方法,返回的hashCode用于找到bucket位置来储存Entry对象。
这里关键点在于指出,HashMap是在bucket中储存键对象和值对象,作为Map.Entry。
这一点有助于理解获取对象的逻辑。如果你没有意识到这一点,或者错误的认为仅仅只在bucket中存储值的话,你将不会回答如何从HashMap中获取对象的逻辑。这个答案相当的正确,也显示出面试者确实知道hashing以及HashMap的工作原理。

3. 两个hashcode相同的时候会发生说明?

hashcode相同,bucket的位置会相同,也就是说会发生碰撞,哈希表中的结构其实有链表(LinkedList),这种冲突通过将元素储存到LinkedList中,解决碰撞。储存顺序是放在表头。

4. 如果两个键的hashcode相同,如何获取值对象?

如果两个键的hashcode相同,即找到bucket位置之后,我们通过key.equals()找到链表LinkedList中正确的节点,最终找到要找的值对象。
一些优秀的开发者会指出使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生,提高效率。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择。

5. 如果HashMap的大小超过了负载因子(load factor)定义的容量?怎么办?

HashMap里面默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。

6. 重新调整HashMap大小的话会出现什么问题?

多线程情况下会出现竞争问题,因为你在调节的时候,LinkedList储存是按照顺序储存,调节的时候回将原来最先储存的元素(也就是最下面的)遍历,多线程就好试图重新调整,这个时候就会出现死循环。

当多线程的情况下,可能产生条件竞争(race condition)。
当重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。

7. HashMap在并发执行put操作,会引起死循环,为什么?

hashmap本身就不是线程安全的。多线程会导致hashmap的node链表形成环形链表,一旦形成环形链表,node 的next节点永远不为空,就会产生死循环获取node。从而导致CPU利用率接近100%。

8. 为什么String, Interger这样的wrapper类适合作为键?

因为他们一般不是不可变的,源码上面final,使用不可变类,而且重写了equals和hashcode方法,避免了键值对改写。提高HashMap性能。

String, Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其他的优点如线程安全。如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,那么请这么做吧。因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。

9. 使用CocurrentHashMap代替Hashtable?

可以,但是Hashtable提供的线程更加安全。
Hashtable是synchronized的,但是ConcurrentHashMap同步性能更好,因为它仅仅根据同步级别对map的一部分进行上锁。ConcurrentHashMap当然可以代替HashTable,但是HashTable提供更强的线程安全性。

10. hashing的概念

散列法(Hashing)或哈希法是一种将字符组成的字符串转换为固定长度(一般是更短长度)的数值或索引值的方法,称为散列法,也叫哈希法。由于通过更短的哈希值比用原始值进行数据库搜索更快,这种方法一般用来在数据库中建立索引并进行搜索,同时还用在各种解密算法中。

11. 扩展:为什么equals()方法要重写?

判断两个对象在逻辑上是否相等,如根据类的成员变量来判断两个类的实例是否相等,而继承Object中的equals方法只能判断两个引用变量是否是同一个对象。这样我们往往需要重写equals()方法。

我们向一个没有重复对象的集合中添加元素时,集合中存放的往往是对象,我们需要先判断集合中是否存在已知对象,这样就必须重写equals方法。

怎样重写equals()方法?

重写equals方法的注意点:
1、自反性:对于任何非空引用x,x.equals(x)应该返回true。
2、对称性:对于任何引用x和y,如果x.equals(y)返回true,那么y.equals(x)也应该返回true。
3、传递性:对于任何引用x、y和z,如果x.equals(y)返回true,y.equals(z)返回true,那么x.equals(z)也应该返回true。
4、一致性:如果x和y引用的对象没有发生变化,那么反复调用x.equals(y)应该返回同样的结果。

5、非空性:对于任意非空引用x,x.equals(null)应该返回false。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值