zoukankan      html  css  js  c++  java
  • Java集合之HashMap

    前面讲过ArrayList和LinkedList,它们都是List类型,对于List集合来说,它存储的元素除了有先后顺序关系外,不会在这个集合中表示出其他的联系。本文要讲的HashMap是Map类型,它同时存储key和value两个元素,并且key和value之间是一一对应的。换句话说,Map不光存储数据,并且存储数据的映射关系。

    对于HashMap来说,其基本特性如下:

    基本特性 结论
    元素是否允许为null key和value可以为null
    元素是否允许重复 key重复会覆盖,value可以重复
    是否有序
    是否线程安全

    源码分析

    本文使用的是JDK 1.8.0_201的源码。

    成员变量

    HashMap在JDK 1.7时基于数组+链表实现,在JDK 1.8时作了优化,变成了数组+链表+红黑树实现。我们来看下JDK 1.8下HashMap的成员变量:

    成员变量 作用
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 底层数组的默认大小为16,这个数字必须为2的次方
    static final int MAXIMUM_CAPACITY = 1 << 30 底层数组最大长度
    static final float DEFAULT_LOAD_FACTOR = 0.75f; 默认的负载因子
    static final int TREEIFY_THRESHOLD = 8; 桶的实现由链表转为红黑树的阈值
    static final int UNTREEIFY_THRESHOLD = 6; 桶的实现由红黑树转为链表的阈值
    static final int MIN_TREEIFY_CAPACITY = 64; 由链表转为红黑树,底层数组的最小长度
    transient Node<K,V>[] table; 底层数组
    transient Set<Map.Entry<K,V>> entrySet; entrySet缓存
    transient int size; 实际存储的元素个数
    int threshold; 需要进行resiz时size的大小,即capacity * load factor
    final float loadFactor; 负载因子,默认为0.75

    HashMap的源码明显比ArrayList和LinkedList要复杂。

    put()操作

    put操作的大致思路为:

    1. 对key的hashCode()做hash处理,然后再通过求模计算bucket的下标;
    2. 如果没有产生hash碰撞,直接放入bucket中;
    3. 如果产生碰撞,以链表的形式追加到桶后面;
    4. 如果链表的长度过长(即大于等于TREEIFY_THRESHOLD),就把链表转为红黑树;
    5. 如果节点已经存在就替换old value(保证key的唯一性);
    6. 如果该map中的元素个数超过阈值threshold(即capacity * load factor),就resize
    public V put(K key, V value) {
        // 对key的hashCode()做hash处理
        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;
        // 计算桶的下标(等同于%求模运算),并判断该桶是否为null,即判断是否产生hash碰撞,如果该桶为null则创建一个桶
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        // 如果该桶不为null,即hash碰撞,则需要根据情况进行处理
        else {
            Node<K,V> e; K k;
            // 第一种情况,该桶的第一个节点与添加的元素相同
            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 {
                // 遍历桶中的元素,为添加的元素寻找位置,注意这里的时间复杂度为O(n)
                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;
                    }
                    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;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        // 超过负载阈值,resize
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
    

    get()操作

    有了前面put操作的基础,再看get操作就容易多了。大致思路如下:

    1. 先检查map以及根据hash求模的桶是否有数据,如果没有数据,直接返回null;
    2. 判断是否命中该桶第一个元素,是则直接返回第一个元素value;
    3. 判断该桶的元素是否为红黑树,如果是则调用红黑树的方法获取value;
    4. 如果是普通节点,遍历节点元素,匹配就返回value,否则返回null。
    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;
        // 先检查该map是否有数据,以及根据hash求模的桶是否有数据,如果没有数据则直接返回null
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            // 若命中桶的第一个元素,直接返回第一个元素value
            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;
    }
    

    hash()方法的实现

    在put()和get()操作时,都对key做了hash操作。先看下源码:

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

    这个方法的作用是:对key的hashCode值做计算,让其高16位不变,低16位与高16位进行异或计算。代码非常简单,但是其背后的设计思想并不是那么简单。

    HashMap其底层数组table[]的长度一定是2的幂次方,在根据hash值计算table下标时,可以用 (n - 1) & hash 的方式代替求模 % 运算。这么做提高了计算table下标的效率,却容易导致hash碰撞。比如,n - 1为15(0x1111)时,hash真正有效的只是低4位,当然容易发生碰撞。

    为了解决上面的碰撞,设计者将hashCode的低16位异或高16位。那么为什么不多异或几次呢?这里考虑到了综合性能,因为现在很多类的hashCode实现得很好,原本就很分散,多几次异或操作作用不大。另一方面,对于冲突较多的情形,使用树来解决频繁的碰撞。仅异或一次,既减少了系统的开销,也不会造成的因为高位没有参与下标的计算(table长度比较小时),从而引起的碰撞。

    在JDK 1.8 之前,HashMap是用链表实现的,在产生碰撞的情况下,get操作的时间复杂度为O(n),因此当碰撞很厉害,n非常大的时候,O(n)的效率是不够好的。

    因此JDK 1.8 采用红黑树代替链表,这样get操作的时间复杂度变为了O(log n) ,在n非常大的时候,能够明显的提高效率。在 《Java 8:HashMap的性能提升》 一文中有相关的性能测试结果。

    resize()操作

    当添加元素时,比如put操作,如果map的元素个数大于阈值(即size > threshold),就会触发resize。resize操作会将map的容量扩大为原先的2倍,而每个元素的桶下标要是不变,要么移动2的幂次方。

    原理在于,计算桶下标的方法是:

    (n - 1) & hash
    

    现在n为原先的2倍,比如原先n为16,那么(n - 1)即15(0x1111),现在n为32,(n - 1)即为31(0x11111)。原先有效的hash位是4位,现在变成了5位。所以,hash位第5位是0的元素,仍然在原先的桶中,而hash位第5位是1的元素,将移动2的幂次方。

    总结

    为了加深对HashMap的理解,总结了以下几个问题:

    1. 什么时候会使用HashMap?他有什么特点?

    HashMap是基于Map接口的实现,存储键值对时,它可以接收null的键值,是非同步的,HashMap存储着Entry(hash, key, value, next)对象。

    2. 你知道HashMap的工作原理吗?

    HashMap通过put和get存储和获取对象。存储对象时,我们将K/V传给put方法时,它调用hashCode计算hash从而得到bucket位置,进一步存储,HashMap会根据当前size的大小自动调整容量(超过Load Facotr则resize为原来的2倍)。获取对象时,我们将K传给get,它调用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键值对。如果发生碰撞的时候,Hashmap通过链表将产生碰撞冲突的元素组织起来,在Java 8中,如果一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度。

    3. 你知道get和put的原理吗?equals()和hashCode()的都有什么作用?

    通过对key的hashCode()进行hashing,并通过( n-1 & hash)求模的方式计算下标,从而获得buckets的位置。如果产生碰撞,则利用key.equals()方法去链表或树中去查找对应的节点。

    4. 你知道hash的实现吗?为什么要这样实现?

    在Java 1.8的实现中,是通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在bucket的n比较小的时候,也能保证考虑到高低bit都参与到hash的计算中,同时不会有太大的开销。

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

    如果超过了负载因子(默认0.75),则会重新resize一个原来长度两倍的HashMap。元素要么仍然在之前的bucket中,要么移动2的幂次方。

    参考资料

    《Java HashMap工作原理及实现》
    《Java 8:HashMap的性能提升》

  • 相关阅读:
    kill命令
    linux grep命令
    ps命令详解
    Linux chmod命令详解
    Linux netstat命令详解
    多线程同步机制的几种方法
    C++_运算符重载 总结
    malloc/free与new/delete的区别
    python发送邮件
    linux之nfs
  • 原文地址:https://www.cnblogs.com/bluemilk/p/10713442.html
Copyright © 2011-2022 走看看