面试重灾区之HashMap

前言

本人曾在刚学习JAVA时写过一篇关于HashMap的文章,时隔数年,又一次写起了HashMap,这次是给初学者做讲解。

HashMap概述

HashMap是实现Map接口的用于映射key-value键值对的双列集合,在JDK1.8中其底层是基于数组+链表+红黑树实现的,是非线程安全的集合类。
以下该图可以帮助大家更好地理解HashMap的结构和其中的一些内部类
在这里插入图片描述

阅读须知

什么是“哈希碰撞”:

在HashMap有一个Node<K,V>类型的数组table(也称哈希桶)用来存储Node对象,在哈希桶中每一个位置只能存放一个Node对象,当哈希桶table指定位置index已经存储了一个Node对象,此时又有新的Node对象需要存储到index位置,就会出现“哈希碰撞”

红黑树

红黑树是一种特殊的AVL树(平衡二叉树),都是在进行插入和删除操作时通过特定操作保持二叉查找树的平衡,从而获得较高的查找性能,红黑树除了满足一般的平衡二叉树的要求外,红黑树还需满足以下要求:

每个结点必须带有颜色,要么黑色,要么红色。
根节点一定是黑色的。
每个叶子结点都带有两个空的黑色孩子结点。
每个红色结点的左右孩子结点都是黑色结点(即从根节点到叶子结点的所有路径上,不存在两个连续的红色结点
从任意结点到其所能到达的结点的所有路径含有相同数量的黑色结点。

HashMap属性

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
   
    //默认初始容量
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
    
    //HashMap最大容量
    static final int MAXIMUM_CAPACITY = 1 << 30;
    
    //默认加载因子,HashMap扩容用到
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
    //树化阈值,HashMap中链表树化的先决条件之一,链表长度要>=8
    static final int TREEIFY_THRESHOLD = 8;
    
    //取消树化阈值(当红黑树的节点<=6时,取消树化,红黑树转换回链表
    static final int UNTREEIFY_THRESHOLD = 6;
    
    //最小树化容量,Hash桶的数量(即table数组长度)要大于64,链表树化先决条件之一
    static final int MIN_TREEIFY_CAPACITY = 64;
    
    //Node数组,`也称为哈希桶数组`,首次使用时会进行初始化
    //需要时会进行扩容,长度总是2的整数次幂
    transient Node<K,V>[] table;
    
    //映射用于保存键值对的集合,静态内部类Node实现了Map.Entry<K,V>接口
    transient Set<Map.Entry<K,V>> entrySet;
    
    //此映射中键值对的数量
    transient int size;
    
    //此映射发生结构性变化的次数
    transient int modCount;
    
    //size的阈值,threshold = size * 负载因子(DEFAULT_LOAD_FACTOR)
    //当HashMap中的键值对数量大于threshold时会触发扩容
    //如当默认size=16、负载因子为0.75
    //那么当HashMap中键值对的数量>=12时,就会触发扩容
    //因此。threshold就是HashMap的扩容阈值
    int threshold;
    
    //哈希表的负载因子,主要用于HashMap的扩容
    final float loadFactor;
}

HashMap的静态内部类Node

static class Node<K,V> implements Map.Entry<K,V> {
    //哈希值
    final int hash;
    //保存key值
    final K key;
    //保存value值
    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;
    }
    //返回键值对中的key
    public final K getKey()        { return key; }
    
    //返回键值对中的value
    public final V getValue()      { return value; }
    
    //重写toString方法
    public final String toString() { return key + "=" + value; }
    
    //计算哈希值
    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }
    
    //设置键值对的值
    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }
    
    //重写键值对的equals()方法
    public final boolean equals(Object o) {
        //如果是同一个对象
        if (o == this)
            //返回true
            return true;
        //如果o是Map.Entry类型的对象
        if (o instanceof Map.Entry) {
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;
            //用Object的equals()方法判断对应键是否相等,对应值是否相等
            if (Objects.equals(key, e.getKey()) &&
                Objects.equals(value, e.getValue()))
                //键、值相等则返回true
                return true;
        }
        //o不是Map.Entry返回false
        return false;
    }

}    

HashMap的创建

无参构造,默认初始容量(16)和默认负载因子(0.75)

//无参构造
public HashMap() {
       this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
   }
   /**
    * Constructs an empty <tt>HashMap</tt> with the default initial capacity
    * (16) and the default load factor (0.75).

有参构造

	//具有指定的初始容量和默认负载因子(0.75)
 public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }



 /**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and load factor.
     *
     * @param  initialCapacity the initial capacity
     * @param  loadFactor      the load factor
     * @throws IllegalArgumentException if the initial capacity is negative
     *         or the load factor is nonpositive
     */
     //指定的初始容量和负载因子
    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);
    }

注意:初始容量必须是2的次方,如果设置的不是,那么它会自己提升到离他最近的2的次方。
在源码中是进行无符号右移来完成,如下:

//初始值提升
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;
    }

HashMap的put方法(面试重点)

在put()方法中首先会调用hash(Object key)方法来计算键的哈希值,该方法会调用Object的hashCode()方法来计算哈希值h并将计算得到的哈希值h和h的高16(h>>>16,即为无符号右移16位,即得到h的高16位)做异或运算,得出的结果即为键的哈希值。

static final int hash(Object key) {
    int h;
    //如果key不为null,则返回异或操作的结果,作为键的哈希值
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

问:为什么通过key所属类型重写的hashcode()方法计算键的hash值后还要在与高16为进行异或运算呢?

为了在哈希桶数组的table的长度比较小的时候,也能够保证考虑到高低位都参与到hash的计算中,高16位参与运算可以更好的均匀散列,减少碰撞,进一步降低“哈希碰撞”的几率,同时不会有太大的开销。

再问:为什么选择异或而不是& ,| 这种运算呢?

异或运算能更好的保留各部分的特征,如果采用 & 运算计算出来的值的二进制会向0靠拢,采用 | 运算计算出来的值的二进制会向1靠拢。具体可以使用01,00,11,10这四个进行计算,使用 & 会有75%的概率为0,使用 | 则有75%为1,而使用异或则是50%为0,50%为1。
计算完键的哈希值后,put()方法会调用putVal()实现元素添加
过程描述:

计算完键的哈希值后,put()方法会调用putVal()实现元素添加 过程描述:

第一步,putVal()方法会先判断哈希桶table是否为空或者长度n是否为0,满足其中一个条件,说明是第一次添加键值对,则会调用resize()方法进行扩容,resize()方法后面单独拎出来讲。

第二步,通过键值对的键的哈希值hash与哈希桶数组的长度-1& 运算 得出键值对在哈希桶数组中的存储下标 i ,将哈希桶数组table[i]元素赋值给p,并判断p是否为空,为空则说明该位置还没有元素,走第三步;不为null,则走第四步。 

第三步,如果第二步的判断p为空,则新建一个Node对象存储在table[i]位置并让e指向i位置上的结点p,然后走第六步。
 
第四步
4.1: 如果i位置上的结点p的键的哈希值等于传入的键值对的键的哈希值相等,并且,( i 位置上的结点p的键与传入的键值对的键相等,或者( 传入的键值对的键不为空并且传入的键值对的键与 i 位置上的结点p的键通过Node内部类中重写的equals()方法比较相等)),则走第五步对原值进行覆盖,让e指向i位置上的结点p,然后走第五步。
4.2: 否则,说明key不同(key的哈希值相同,而key不同),则产生"哈希碰撞",判断i 位置上的结点p是否为TressNode类型,如果是说明i位置存储的是一棵红黑树,则调用putTreeVal()方法进行红黑树的插入操作。
4.3: 否则,说明i位置存储的是链表,则遍历链表进行链表的插入操作,在遍历过程中会判断链表是否满足树化条件,满足则进行树化,也会判断链表中是否已经存在相同的键值对,存在则跳出循环,走第五步。

第五步,如果结点e(结点e为与我们传入的键值对产生冲突的结点)不为空,则获取结点e的旧值,并将其作为putVal()方法的返回值,如果允许修改e的值或者e的值不为空,则更新结点e的value值(value值即为我们传入的键值对的value值)。 

第六步,只有当不走第五时,才会到第六步,说明原映射中不存在对应键值对,则能够将传入的新的键值对添加成功,因为添加成功,所以需要将HashMap的结构性变化次数modCount+1,然后会判断新增键值对后HashMap中的键值对数量是否大于扩容阈值threshold,是则调用resize()进行扩容(在putVal()中有可能会调用两次resize()方法,除此处外,另一处为在第一步时可能会调用),否则putVal()方法就返回null
/**
 * hash: 键的哈希值
 * key: 键
 * onlyIfAbsent: 如果为true,不修改已有键值对的值,默认为false,在第五步中使用到
 * evict: 如果为false,则表示哈希表处于创建模式
 * return: 若键值对存在,返回旧的键值对的值。若键值对不存在,则返回null。
 
 */
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为空或者,哈希桶table长度为0,则调用resize()方法扩容
    if ((tab = table) == null || (n = tab.length) == 0)
        //将扩容后的Node<K,V>类型数组赋值给tab,并将扩容后的数组长度赋值给n
        n = (tab = resize()).length;
        
    //经过上面的if语句,tab指向table数组,n为table数组的长度
    //第二步:
    //计算键值对存储下标,并判断该位置是否已经有元素
    if ((p = tab[i = (n - 1) & hash]) == null)
        //第三步:
        //哈希桶i位置上没有元素,则新建Node对象,并存储到哈希桶数组i位置上
        tab[i] = newNode(hash, key, value, null);
    else {
        //第四步:
        //产生“哈希碰撞”,两个键值对需要存储在哈希桶数组的同一个位置上。
        Node<K,V> e; K k;
        //如果i位置上的结点p的键的哈希值等于传入的键值对的键的哈希值相等
        //并且,i位置上的结点p的键与传入的键值对的键相等或者(传入的键值对的键不为空并且传入的键值对的键与i位置上的结点p的键通过equals()方法比较相等)
        //第4.1
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            //以上条件成立(key相同)
            //将i位置上的结点p赋值给e,走第五步,覆盖原始值
            e = p;
        //第4.2
        //否则,说明key不同(key的哈希值相同,而key不同),则产生"哈希碰撞"
        //如果i位置上的结点p是TressNode类型的
        else if (p instanceof TreeNode)
            //调用putTreeVal()方法进行红黑树的插入操作
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        //第4.3
        //如果i位置上的结点p不是TressNode类型的,则进行链表插入操作
        else {
            //遍历链表找到插入位置
            for (int binCount = 0; ; ++binCount) {
                //如果e=p.next为null,则说明p为链表的尾结点
                if ((e = p.next) == null) {
                    //新建一个Node,并让尾结点p的next指向我们新建的结点
                    p.next = newNode(hash, key, value, null);
                    //新结点添加到链表尾部后
                    //判断链表长度+1(+1是因为新增的结点没有计算到binCount中)是否大于等于TREEIFY_THRESHOLD(树化阈值=8)
                    if (binCount >= TREEIFY_THRESHOLD - 1) 
                        //链表长度>=树化阈值8,则调用treeifyBin()方法进行树化
                        treeifyBin(tab, hash);
                    break;
                }
                //p不为尾结点
                //如果e的key的哈希值与传入的键值对的key的哈希值相等,
                //并且 (e的key与传入的键值对的key相等 或者传入的键值对的key不等于null并且传入的键值对的key 等于e的key )
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    //说明在链表中已经存在键相同的键值对,则跳出当前for循环(链表上发送“哈希碰撞”)
                    break;
                //如果上面两个if都不满足,修改p的引用,进入下一个循环,继续链表的遍历
                p = e;
            }
        }
        
        //第五步
        //如果结点e不为空
        if (e != null) {
            //获取结点e的值value赋值给oldValue,作为putVal()方法的返回值
            V oldValue = e.value;
            //如果允许修改旧的值或者旧的值为空
            if (!onlyIfAbsent || oldValue == null)
                //更新结点e的值为新值
                e.value = value;
            //预留方法,可以设置回调
            afterNodeAccess(e);
            //返回旧的value值
            return oldValue;
        }
    }
    
    //第六步
    //由于不存在哈希碰撞,能够键新的键值对添加到HashMap中
    //所以HashMap的结构性变化次数modCount+1
    ++modCount;
    //如果新增键值对后HashMap中的键值对数量大于扩容阈值threshold,是则进行扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    //返回null
    return null;
}

HashMap扩容

首先,先创建一个新的空的哈希桶数组,长度为原哈希桶数组table的2倍(左移一位)。然后将原来的所有键值对通过hash算法重新定位到新的哈希桶数组中(该过程中会经过一系列复杂的操作,在这里就不进行描述),最后将新的哈希桶数组赋值给table,取代原来的哈希桶数组。

知识点

关于JDK1.7和JDK1.8的HashMap的区别:

jdk7底层的数组是Entry[],jdk8底层数组是Node[]。 jdk8首次调用put方法时,才在底层创建长度为16的数组。
注:某些JDK7的版本也是在put方法后建立数组,但在面试中基本都是说在new方法的时候就进行了创建。
jdk7底层结构只有数组加链表。jdk8底层结构是数组加链表加红黑树。
jdk7添加数据是加到原来数据的前面,jdk8添加数据是加到原来数据的后面(next)。

影响rehash的原因:

初始容量过小;
加载因子过小;
注:rehash是将所有的元素都进行重新排列,这个过程是非常消耗资源的,所以初始容量提前设置为较为合适的数量是十分重要的。

HashMap为什么线程不安全?

从上面的putVal()方法中可以看出,假设在多线程下,有A、B两个线程,当A线程执行到第三步时 ( 完成if判断,但未执行元素插入
tab[i] = newNode(hash, key, value, null )
),由于时间片耗尽或其他原因导致A线程被挂起,而B线程得到时间片后在该下标处插入了元素,完成了正常的插入,然后A线程再次获得时间片,由于之前已经进行了哈希碰撞的判断,所以此时不会再执行if判断,而是直接进行插入,这就导致了B线程插入的数据被A线程覆盖了,从而导致线程不安全。

当HashMap的key为Object(或者是自定义类)时为什么要重写hashcode与equals方法?

//Object中的hashCode()方法和equals()方法
public native int hashCode();

public boolean equals(Object obj) {
    return (this == obj);
}

在Java中,Object是所有类的父类,Object类的hashCode()方法是根据对象的内存地址值来计算出一个整形的哈希值,当自定义类没有重写超类的hashCode()方法时,就会使用Object的hashCode()方法,所以即使定义两个相同含义的对象,但是他们都具有不同的内存地址,所以他们的hashCode就不可能相等。而如果只重写hashcode()不重写equals()方法,当使用equals()比较时,也是直接调用Object中的equals()方法,只是进行内存地址的比较,所以它们也不可能相等。

所以 当HashMap调用插入或获取方法时,需要将待插入或待获取的键值对的键key的哈希值与哈希桶数组中的键值对的键的哈希值比较,如果不重写hashCode()方法,那么它们的key就永远不会相等,而通过重写后的hashCode方法判断相等后,则会通过equals方法比较它们的key是否相等,同样如果不重写equals()方法,两个key值通过Object的equals()方法进行比较也永远不会相等。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值