HashMap

目录

一、HashMap定义

二、HashMap底层结构示意图

三、HashMap底层代码解析

四、HashMap 常见面试问题


一、HashMap定义

HashMap实现了Map接口,继承AbstractMap。其中Map接口定义了键映射到值的规则,而AbstractMap类提供 Map 接口的骨干实现,以最大限度地减少实现此接口所需的工作,其实AbstractMap类已经实现了Map。

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {

    // 序列号

    private static final long serialVersionUID = 362498820763181265L;    

    // 默认的初始容量是16

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;   

    // 最大容量

    static final int MAXIMUM_CAPACITY = 1 << 30;

    // 默认的负载因子

    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    // 当桶(bucket)上的结点数大于这个值时会转成红黑树

    static final int TREEIFY_THRESHOLD = 8; (jdk1.8以后才有)

    // 当桶(bucket)上的结点数小于这个值时树转链表

    static final int UNTREEIFY_THRESHOLD = 6;

    // 桶中结构转化为红黑树对应的table的最小大小

    static final int MIN_TREEIFY_CAPACITY = 64;

    // 存储元素的数组,总是2的幂次倍

    transient Node<k,v>[] table;

    // 存放具体元素的集

    transient Set<map.entry<k,v>> entrySet;

    // 存放元素的个数,注意这个不等于数组的长度。

    transient int size;

    // 每次扩容和更改map结构的计数器

    transient int modCount;   

    // 临界值 当实际大小(容量*填充因子)超过临界值时,会进行扩容

    int threshold;

    // 负载因子

    final float loadFactor;

}

注:

transient:java语言的关键字,变量修饰符,如果用transient声明一个实例变量,当对象存储时,它的值不需要维持。换句话来说就是,用transient关键字标记的成员变量不参与序列化过程。

二、HashMap底层结构示意图

红黑树:

   红黑树(Red Black Tree) 是一种自平衡二叉查找树,是在计算机科学中用到的一种数据结构,典型的用途是实现关联数组。

特点:

  1. 每个节点要么是红色,要么是黑色。
  2. 根节点必须是黑色
  3. 红色节点不能连续(也即是,红色节点的孩子和父亲都不能是红色)。
  4. 对于每个节点,从该点至null(树尾端)的任何路径,都含有相同个数的黑色节点。

在树的结构发生改变时(插入或者删除操作),往往会破坏上述条件3或条件4,需要通过调整使得查找树重新满足红黑树的条件。如果有删除或者插入节点,使用左旋和右旋;

三、HashMap底层代码解析

HashMap提供了4个构造函数:

      HashMap():构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。

      HashMap(int initialCapacity):构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。

      HashMap(int initialCapacity, float loadFactor):构造一个带指定初始容量和加载因子的空 HashMap。

      HashMap(Map<? extends K, ? extends V> m):传入一个map以构造一个新的map,使用默认加载因子(0.75)。

      在这里提到了两个参数:初始容量,加载因子。这两个参数是影响HashMap性能的重要参数,其中容量表示哈希表中桶的数量,初始容量是创建哈希表时的容量,加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度,它衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。系统默认负载因子为0.75,一般情况下我们是无需修改的。

1.存值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;

//检测table是否为空,如果为空,则使用扩容函数进行初始化

        if ((tab = table) == null || (n = tab.length) == 0)

            n = (tab = resize()).length;

//如果通过hash值取模得到的桶为空,则直接把新生成的节点放入该桶

        if ((p = tab[i = (n - 1) & hash]) == null)

            tab[i] = newNode(hash, key, value, null);

        else {//以下为该桶不为空的逻辑

            Node<K,V> e; K k;

//判断桶的第一个元素的key值是否相同(hash值相同,且能equals)

//如果相同,则返回当前元素(函数末尾进行统一处理)

            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);

//插入之后,检查是否达到了转成红黑树结构的标准

                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st

                            treeifyBin(tab, hash);

                        break;

                    }

//如果在遍历过程中,发现了key值相同,则返回当前元素(函数末尾进行统一处理)

                    if (e.hash == hash &&

                        ((k = e.key) == key || (key != null && key.equals(k))))

                        break;

                    p = e;

                }

            }

//处理相同元素的情况

            if (e != null) { // existing mapping for key

                V oldValue = e.value;

//如果onlyIfAbsent为ture,则在oldValue为空时才替换

//否则直接替换

                if (!onlyIfAbsent || oldValue == null)

                    e.value = value;

                afterNodeAccess(e);

                return oldValue;

            }

        }

        ++modCount;//修改次数+1

//map的size加1,然后判断是否达到了threshold,否则进行扩容

//threshold由Node[] table的长度及loadFactor控制

        if (++size > threshold)

            resize();

//执行回调函数

        afterNodeInsertion(evict);

        return null;

}

put过程总结

1、根据key生成hashcode

2、判断当前HashMap对象中的数组是否为空,如果为空则初始化该数组

3、1.7的时候会进行4次无符号右移,5个与运算,1.8会进行高16位和低16位进行逻辑与运算,算出hashcode基于当前数组对应的数组下标i

4.判断数组的第i个位置的元素(tab[i])是否为空

   a. 如果为空,则将key,value封装为Node对象赋值给tab[i]

   b. 如果不为空:

i. 如果put⽅法传⼊进来的key等于tab[i].key,那么证明存在相同的key

ii. 如果不等于tab[i].key,则:

          1. 如果tab[i]的类型是TreeNode,则表示数组的第i位置上是⼀颗红⿊树,那么将key和value插⼊到红⿊树中,并且在插⼊之前会判断在红⿊树中是否存在相同的key

          2. 如果tab[i]的类型不是TreeNode,则表示数组的第i位置上是⼀个链表,那么遍历链表寻找是否存在相同的key,并且在遍历的过程中会对链表中的结点数进⾏计数,当遍历到最后⼀个结点时,会将key,value封装为Node插⼊到链表的尾部,同时判断在插⼊新结点之前的链表结点个数是不是⼤于等于8,且数组的长度大于64,如果是,则将链表改为红⿊树。

iii. 如果上述步骤中发现存在相同的key,则根据onlyIfAbsent标记来判断是否需要更新value值,然后返回oldValue

5. modCount++

6. HashMap的元素个数size加1

7. 如果size⼤于扩容的阈值,则进⾏扩容

取值 get(Object key)

 

public V get(Object key) {

        Node<K,V> e;

        return (e = getNode(hash(key), key)) == null ? null : e.value;

}

final Node<K,V> getNode(int hash, Object key) {

        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;

//如果table不为空,则再进行查询操作

        if ((tab = table) != null && (n = tab.length) > 0 &&

            (first = tab[(n - 1) & hash]) != null) {

//先检查第一个元素是否key相同

            if (first.hash == hash && // always check first node

                ((k = first.key) == key || (key != null && key.equals(k))))

                return first;

            if ((e = first.next) != null) {

//如果为红黑树结构,则走红黑树的查询逻辑

                if (first instanceof TreeNode)

                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);

                do {//否则遍历链表

                    if (e.hash == hash &&

                        ((k = e.key) == key || (key != null && key.equals(k))))

                        return e;

                } while ((e = e.next) != null);

            }

        }

        return null;

}

get过程总结

1、根据key⽣成hashcode

2、如果数组为空,则直接返回空

3、如果数组不为空,则利⽤hashcode和数组⻓度通过逻辑与操作算出key所对应的数组下标i

4、如果数组的第i个位置上没有元素,则直接返回空

5、如果数组的第1个位上的元素的key等于get⽅法所传进来的key,则返回该元素,并获取该元素的value

6、如果不等于则判断该元素还有没有下⼀个元素,如果没有,返回空

7、如果有则判断该元素的类型是链表结点还是红⿊树结点 a. 如果是链表则遍历链表 b. 如果是红⿊树则遍历红⿊树

8、找到即返回元素,没找到的则返回空

四、HashMap 常见面试问题

1、1.7和1.8的不同点

1、1.8用了红黑树
2、1.7插入的时候用了头插法,1.8插入的时候用了尾插法
3、1.7会rehash,1.8没有这份代码逻辑
4、1.8的hash算法时高低16位做异或运算,1.7的时候会进行4次无符号右移,5个与运算==(具体原因:JDK7的Hash算法⽐JDK8中的更复杂,Hash算法越复杂,⽣成的hashcode则更散列,那么hashmap中的元素则更散列,更散列则hashmap的查询性能更好,JDK7中没有红⿊树,所以只能优化Hash算法使得元素更散列,⽽JDK8中增加了红⿊树,查询性能得到了保障,所以可以简化⼀下Hash算法,毕竟Hash算法越复杂就越消耗CPU)==
5. JDK8中扩容的条件和JDK7中不⼀样,除开判断size是否⼤于阈值之外,JDK7中还判断了tab[i]是否为空,不为空的时候才会进⾏扩容,⽽JDK8中则没有该条件了
6. JDK8中还多了⼀个API:putIfAbsent(key,value)
7. JDK7和JDK8扩容过程中转移元素的逻辑不⼀样,JDK7是每次转移⼀个元素,JDK8是先算出来当前位 置上哪些元素在新数组的低位上,哪些在新数组的⾼位上,然后在⼀次性转移

2、1.7和1.8的插入方式,1.8为什么是尾插

1.7是头插法,1.8是尾插法

因为1.7是头插法,在多线程情况下会产生循环链表,而1.8改为尾插就不会出现循环链表问题了,还有一点就是链表要转换为红黑树的时候需要用到链表长度

3、JDK8中的HashMap为什么要使⽤红⿊树?

当元素个数⼩于⼀个阈值时,链表整体的插⼊查询效率要⾼于红⿊树,当元素个数⼤于此阈值时,链表整体的插⼊查询效率要低于红⿊树。此阈值在HashMap中为8

4、JDK8中的HashMap什么时候将链表转化为红⿊树?

当发现链表中的元素个数⼤于8之后,还会判断⼀下当前数组的⻓度,如果数组⻓度⼩于64时,此时并不会转化为红⿊树,⽽是进⾏扩容。只有当链表中的元素个数⼤于8,并且数组的⻓度⼤于等于64时才会将链表转为红⿊树。

5、扩容机制(resize)

举个例子:当前的容量⼤⼩为16,当你存进第13个的时候,判断发现需要进⾏resize了,也就是插入数据后的容量>=HashMap当前⻓度(6)*负载因子0.75(float
ft = (float)newCap * loadFactor),那就进⾏扩容,具体的就是:
1、创建⼀个新的Entry空数组,⻓度是原数组的2倍。 2、ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组。

6、为什么默认容量是16?

采用位运算,是因为位与运算⽐算数计算的效率⾼了很多。之所以选择16,是为了key映射到index的算法

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

7、HashMap怎么处理hash碰撞的?

链表法(HashMap),扩展一下还有个开放地址法

8、HashMap 的数据结构?

哈希表结构(链表散列:数组 + 链表)实现,结合数组和链表的优点。当链表长度超过 8 时,链表转换为红黑树。

9、你知道 hash 的实现吗?为什么要这样实现?

DK 1.8 中,是通过 hashCode() 的高 16 位异或低 16 位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度,功效和质量来考虑的,减少系统的开销,也不会造成因为高位没有参与下标的计算,从而引起的碰撞。

10、 为什么要用异或运算符?

保证了对象的 hashCode 的 32 位值只要有一位发生改变,整个 hash() 返回值就会改变。尽可能的减少碰撞。

11、数组扩容的过程?

创建一个新的数组,其容量为旧数组的两倍,并重新计算旧数组中结点的存储位置。结点在新数组中的位置只有两种,原下标位置或原下标 + 旧数组的大小。

12、拉链法导致的链表过深问题为什么不用二叉查找树代替,而选择红黑树?为什么不一直使用红黑树?

之所以选择红黑树是为了解决二叉查找树的缺陷,二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。推荐:面试问红黑树,我脸都绿了。

而红黑树在插入新数据后可能需要通过左旋,右旋、变色这些操作来保持平衡,引入红黑树就是为了查找数据快,解决链表查询深度的问题,我们知道红黑树属于平衡二叉树,但是为了保持 “平衡” 是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少,所以当长度大于 8 的时候,会使用红黑树,如果链表长度很短的话,根本不需要引入红黑树,引入反而会慢。

13、为什么容量总是为2的n次幂

  • &运算速度快,至少比%取模运算块

  • (n - 1) & hash,当n为2次幂时,会满足一个公式:(n - 1) & hash = hash % n

  • 不为2的n次幂(n - 1) & hash出现hash碰撞概率提高

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值