(Java)笔记篇---HashMap底层原理解析及HashMap常考面试题

目录

一. 实现的接口

二. 默认初始值

1. 默认初始容量

2. 默认最大容量

3. 默认负载因子

三. 链表与红黑树的相互转换

四. 哈希桶中链表的结构

五. 哈希函数

六. 扩容

七. HashMap中常用的方法

1. 构造方法

2. 查找,根据key获取value

3. 检测key是否存在 

4. 插入

5. 删除

八. HashMap常考问题


一. 实现的接口

底层实现了Map,克隆,序列化接口

二. 默认初始值

1. 默认初始容量

2^4 = 16,当不给初始容量时,容量默认为16

2. 默认最大容量

默认最大容量为 2^30 

3. 默认负载因子

默认的负载因子为0.75,有效元素个数 / 表容量 = 负载因子

三. 链表与红黑树的相互转换

哈希桶中存放的是链表节点,但是在一定条件下,链表会和红黑树相互转化

每个桶的链表节点个数超过8,链表会转化为红黑树

当红黑树中的节点个数小于6时,红黑树会退化为链表

如果哈希桶中某条链表中节点超过8,并且桶的个数超过64,链表才会转化为红黑树,否则直接扩容 

四. 哈希桶中链表的结构

//HashMap将其底层链表中的节点封装为静态内部类
//节点中带有key,value键值对以及key所对应的哈希值
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; }
        
        //重写Object类中hashcode()方法
        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }
        
        //重写Object类中equals方法
        public final boolean equals(Object o) {
            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;
        }
    }

五. 哈希函数

将key转化为一个整型数字,再用这个数字进行除留余数法计算桶的位置 

解析:

1. 如果key为null,返回0号桶。

2. 如果key不为null,返回key所对应的哈希码,如果key为自定义类型,必须重写Object类中的hashcode()方法。

3. ( h = key . hashCode() ) ^ ( h >>> 16 ),是为了让高16bit不变,低16bit与高16bit进行异或,主要用于当hashmap数组比较小的时候所有bit都参与运算,目的是减小碰撞。

4. 获取到哈希地址后,计算桶号的方式为:index = (table.length - 1) & hash。

5. 通过除留余数法方式获得桶号,因为hash表的大小始终为2的n次幂因此可以将取模转为位运算,提高效率,这也是为什么要按照2倍方式扩容的一个原因。

这里画图来说明一下原因:

总结:通过上述方式可知,实际上hashcode的很多位是用不上的,因此在hashMap的hash函数中,才使用了移位运算,只取了前16位来做映射,另一方面&运算比取模效率更高。 

六. 扩容

每次都是将cap扩大到与cap最近的2的n次幂,int n = cap - 1;是为了防止cap已经是2的幂次方,如果cap已经是2的幂次方,则执行完后面的几条无符号右移操作之后,返回的capacity将是这个cap的2倍。

假设现在cap的初始值为10,具体方式如下:

七. HashMap中常用的方法

1. 构造方法

 // 构造方法一:带有初始容量的构造,负载因子使用默认值0.75
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    // 构造方法二:
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
    // 构造方法一:带有初始容量和初始负载因子的构造
    public HashMap(int initialCapacity, float loadFactor) {
        // 如果容量小于0,抛出非法参数异常
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                    initialCapacity);
        // 如果初始容量大于最大值,用2^30代替
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;

        // 检测负载因子是否非法,如果负载因子小于0,或者负载因子不是浮点数,抛出非法参数异常
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                    loadFactor);
        // 给负载因子和容量赋值,并将容量提升到2的整数次幂
        // 注意:构造函数中并没有给
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }
/*
注意:
不同于Java7中的构造方法,Java8对于数组table的初始化,并没有直接放在构造器中完成,而是将table数组的构
造延迟到了resize中完成
*/

2. 查找,根据key获取value

/*
     1. 通过key计算出其哈希地址,然后借助哈希地址在哈希桶中找到与key对应的节点
     2. 如果节点为null,返回null,说明HashMap中节点是可以为空的
     3. 如果节点不为空,返回该节点中的value
    */
    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;
        // 1. 先检测哈希桶是否为空
        // 2. 检测哈希桶的个数是否大于零,如果桶不空,桶的个数肯定不为0
        // 3. n-1&hash-->计算桶号
        // 4. 当前桶是否为空桶
        // 如果1 2 3 4均不成立,说明当前桶中有节点,拿到当前桶中第一个节点
        if ((tab = table) != null && (n = tab.length) > 0 &&
                (first = tab[(n - 1) & hash]) != null) {

            // 如果节点的哈希值与key的哈希值相等,然后再检测key是否相等
            // 如果相等,则返回该节点
            // 此处也进一步证明了:HashMap必须要重写hashCode和equals方法
            if (first.hash == hash && // always check first node
                    ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            // 如果第一个节点后还有节点,检测first是否为treeNode类型的
            // 因为如果哈希桶中某条链节点大于8个,为了提高性能,HashMap会将链表替换为红黑树
            // 此时再红黑树中找与key对应的节点
            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;
    }

3. 检测key是否存在 

    /*
1. 先通过getNode()获取与key对应的节点
2. 如果节点不为空,说明存在返回true,否则返回false
3. 时间复杂度:平均为O(1),如果当天key所对应的桶中挂接的链表则顺序查找,挂接的是红黑树按照红黑树性质找
*/
    public boolean containsKey(Object key) {
        return getNode(hash(key), key) != null;
    }

4. 插入

    /*
1. 先使用key借助hash函数计算key的哈希地址
2. 将key-value键值对,结合计算出的hash地址插入到哈希桶中
3. 从以下代码中可以看到,HashMap在插入时,并没有处理线程安全问题,因此HashMap不是线程安全的
4. 红黑树优化链表过长是java8新引进,是基于性能的考虑,在冲突大时,红黑树算法会比链表综合表现更好
*/
    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;

        // 1. 桶如果是空的,则进行扩容
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;

        // 2. (n-1)&hash-->计算桶号,如果当前桶中没有节点,直接插入
        // p来记录桶中的第一个节点
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;

            // 3. 如果key已经是和桶中第一个节点相等,不进行插入
            if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode) // 4. 如果该桶中挂接的是红黑树,向红黑树中插入
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                // 5. key不同,也不是红黑树,说明当前桶中挂的是一个链表
                // a. 在当前链表中找key
                // b. 如果找到,则不插入
                // c. 如果没有找到,先构建新节点,然后将该节点尾插到链表中
                // d. 检测bitCount的计数,binCount记录的是在未插入新节点前原链表的节点个数
                // e. 新节点插入后,链表长度是否超过TREEIFY_THRESHOLD,如果超过将链表转换为红黑树
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        // p已经是最后一个节点,说明在链表中未找到key对应的节点
                       // 进行尾插
                        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;
                }
            }

            // 如果key已经存在,将key所对节点中的value替换为参数指定value,返回旧value
            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;
    }
    /*
注意:afterNodeAccess和afterNodeInsertion主要是LinkedHashMap实现的,HashMap中给出了该方法,但是
并没有实现
*/
    // Callbacks to allow LinkedHashMap post-actions
    // 访问、插入、删除节点之后进行一些处理,
    // LinkedHashMap正是通过重写这三个方法来保证链表的插入、删除的有序性
    void afterNodeAccess(Node<K,V> p) { }
    void afterNodeInsertion(boolean evict) { }
    void afterNodeRemoval(Node<K,V> p) { }
/*
LinkedHashMap: 继承了HashMap,在LinkedHashMap中会对以上方法进行重写,以保证存入到LinkedHashMap中
的key是有序的,注意这里的有序是不自然序列,指的是插入元素的先后次序
LinkedHashMap底层的哈希桶使用的是双向链表
*/

5. 删除

public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
                null : e.value;
    }
    final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
        Node<K,V>[] tab; Node<K,V> p; int n, index;

        // 1. 检测哈希表是否存在
        // 2. index = (n - 1) & hash: 获取桶号
        // 3. p记录当前桶中第一个节点,如果桶中没有节点,直接返回null
        if ((tab = table) != null && (n = tab.length) > 0 &&
                (p = tab[index = (n - 1) & hash]) != null) {
            Node<K,V> node = null, e; K k; V v;

            // 如果第一个节点就是key,用node记录
            if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                node = p;
            else if ((e = p.next) != null) {
                // 如果当前桶下是红黑树,在红黑树中查找,结果用node记录
                if (p instanceof TreeNode)
                    node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
                else {
                    // 当前桶下是链表,遍历链表,在链表中检测是否存在为key的节点
                    do {
                        if (e.hash == hash &&
                                ((k = e.key) == key ||
                                        (key != null && key.equals(k)))) {
                            node = e;
                            break;
                        }
                        p = e;
                    } while ((e = e.next) != null);
                }
            }
            // node不为空,在HashMap中找到了
            if (node != null && (!matchValue || (v = node.value) == value ||
                    (value != null && value.equals(v)))) {
                // 如果节点在红黑树中,将其删除
                if (node instanceof TreeNode)
                    ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
                    // 如果节点是链表中第一个节点,将当前链表中下一个节点地址放在桶中
                else if (node == p)
                    tab[index] = node.next;
                else
                    p.next = node.next; // 非第一个节点
                ++modCount;
                --size;

                // LinkedHashMap使用
                afterNodeRemoval(node);

                // 删除成功,返回原节点
                return node;
            }
        }

        // 删除失败返回空
        return null;
    }

八. HashMap常考问题

1. 如果new HashMap(19),bucket数组多大?

在Java1.8中,new的时候并没有给数组开辟空间,而是在第一次插入的时候才开辟空间,开辟的空间为比19大且最接近19的幂次方,2^4=16,2^5=32,故bucket数组的大小为32

2. HashMap什么时候开辟bucket数组占用内存?

这个问题上面答案中已经回答过了,在第一次插入的时候才开辟空间内存

3. hashMap何时扩容?

当表中有效元素的个数 >= 负载因子 * 表格容量的时候需要扩容,扩容也是按照2的幂次方来进行扩容的

4. 当两个对象的hashcode相同会发生什么?

在get()时:如果hashcode相同,先通过equal方法比较key是否一样,如果key也相同将value直接返回,否则返回空

在插入时:如果hashcode相同,再判断key是否存在,如果key已经存在,将key对应的value进行替换,如果key不存在则插入

在删除时:如果hashcode相同,则key可能是我们要删除的,通过equals对比,如果是则删除,如果不是则返回

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

遍历与hashCode值相等时相连的链表,直到相等或者 null

6. 你了解重新调整HashMap大小存在什么问题吗? 

如果将HashMap的容量进行改变,就必须将原来表中的节点重新哈希,扩容的目的就是将节点重新哈希,将链表变短

7. 为什么要重写hashcode()与equals()方法?

重写hashcode:底层原理是通过key来计算hashcode,通过hashcode来计算hash,hash返回的是一个整型数字,再通过这个数来进行除留余数,计算的结果为桶的位置。但是对于自定义类型,key不能转化为整型数字,必须重写hashcode方法来使自定义类型的key转化为整型数字以此来得到桶的位置。

重写equals:当发生哈希冲突时,得比较key是否相同,而比较需要用到equals方法,对于自定义类型比较key的时候得重写equals方法来比较key的内容是否相同。 

猜你喜欢

转载自blog.csdn.net/qq_58710208/article/details/122154321