【Java集合框架源码分析(JDK1.7)】-HashMap源码分析

Map简介

  • Map用于保存具有映射关系的数据,因此Map集合里保存着两组值,一组值用于保存Map里的key,另外一组值用于保存Map里的value。key和value都可以是任何引用类型的数据。Map的key不允许重复,即同一个Map对象的任何两个key通过equals方法比较结果总是返回false。 关于Map,从代码复用的角度去理解,java是先实现了Map,然后通过包装了一个所有value都为null的Map就实现了Set集合。Map的这些实现类和子接口中key集的存储形式和Set集合完全相同(即key不能重复) 。Map的这些实现类和子接口中value集的存储形式和List非常类似(即value可以重复、根据索引来查找)。

  • java.util.Map接口有4个常用的实现类,分别为HashMap,HashTable,LinkedHashMap和TreeMap,类的继承关系如下图所示:

各个类的特点分别如下:

  1. HashMap:根据key的hash值存储value,有较快的访问速度,遍历的顺序是不确定的。允许使用null值null键非线程安全,任一时刻可以有多个线程同时写HashMap,可能导致数据不一致。Collections.synchronizedMap()和ConcurrentHashMap可以满足线程安全的需求。
  2. HashTable:常用功能与HashMap类似,不过它继承自Dictionary类,是线程安全的。HashTable是遗留类,不建议再使用,线程安全的场景下可以使用ConcurrentHashMap,任一时刻只能有一个线程写HashTable,并发性不如ConcurrentHashMap。
  3. LinkedHashMap:是HashMap的子类,与HashMap有着同样的存储结构,但它加入了双向链表的头结点,将所有put到LinkedHashMap的节点串成双向循环链表,因此它保存了记录的插入顺序,可以使用Iterator顺序遍历,先得到的记录是先插入的
  4. TreeMap:实现SortedMap接口,能将它保存的记录按key排序(默认升序),也可指定排序的比较器,当使用Iterator遍历时,得到的记录是经过排序的。使用TreeMap时,key必须实现Comparable接口或在构造TreeMap时传入自定义的Comparator。
    《【Java集合框架源码分析(JDK1.7)】-HashMap源码分析》

HashMap讲解

介绍

HashMap基于哈希表的 Map 接口的实现。此实现提供所有可选的映射操作,并允许使用 null 值和 null 键。(除了非同步和允许使用 null 之外,HashMap 类与 Hashtable 大致相同。)此类不保证映射的顺序,特别是它不保证该顺序恒久不变。 此实现假定哈希函数将元素适当地分布在各桶之间,可为基本操作(get 和 put)提供稳定的性能。迭代 collection 视图所需的时间与 HashMap 实例的“容量”(桶的数量)及其大小(键-值映射关系数)成比例。所以,如果迭代性能很重要,则不要将初始容量设置得太高(或将加载因子设置得太低)。

定义

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

HashMap 实现了Serializable接口,因此它支持序列化,实现了Cloneable接口,能被克隆。

重要属性

    /** * 默认的初始容量(容量为HashMap中槽的数目)是16,且实际容量必须是2的整数次幂。 */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    /** * 最大容量为2^30(必须是2的幂且小于2的30次方,传入容量过大将被这个值替换) */
    static final int MAXIMUM_CAPACITY = 1 << 30;

    /** * 默认因子为0.75 */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    /** * 初始化存储数据的Entry数组。 */
    static final Entry<?,?>[] EMPTY_TABLE = {};

    /** * 装载键值对的内部容器Entry数组,长度一定得是2的幂 */
    transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

    /** * HashMap中包含的键值对的个数 */
    transient int size;

    /** * HashMap的阈值,用于判断是否需要调整HashMap的容量(threshold = 容量*负载因子) */
    int threshold;

    /** * 负载因子实际大小 */
    final float loadFactor;

    /** * HashMap被修改的次数 */
    transient int modCount;

构造函数

构造函数接受的两个参数:初始容量和负载因子。它们是hashmap最重要的指标。
初始化容量从代码中,可看出指的是链表数组的长度,负载因子是hashmap中当前元素数量/初始容量的一个上限(代码中用threshold(容量*负载因子)来衡量)。当超过整个限度时,会把链表数组的长度增加,重新计算各个元素的位置(最耗性能)。

    /** * 指定默认容量和负载因子 */
    public HashMap(int initialCapacity, float loadFactor) {
        //对initialCapacity进行参数校验,若小于0,则抛出IllegalArgumentException异常
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +                                            initialCapacity);
        //若initialCapacity大于MAXIMUM_CAPACITY(2^30),则将MAXIMUM_CAPACITY赋值给initialCapacity
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        //对参数loadFactor进行有效性校验,不能<=0,不能非数字,否则抛出IllegalArgumentException异常 
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +                                             loadFactor);
        //负载因子,默认构造时为0.75 
        this.loadFactor = loadFactor;
        threshold = initialCapacity;
        init();
    }

    //init方法是空的,如果你定制额外的初始化操作,可以继承HashMap,覆盖init()方法
    void init() {
    }

    /** * 通过指定的容量initialCapacity来构造HashMap,这里使用了默认的加载因子DEFAULT_LOAD_FACTOR 0.75 */
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    /** * 无参的构造函数 加载因子为DEFAULT_LOAD_FACTOR 0.75,容量为默认的DEFAULT_INITIAL_CAPACITY 16,极限为 16*0.75=12 */
    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }

    /** * 包含“子Map”的构造函数 */
    public HashMap(Map<? extends K, ? extends V> m) {
        this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                      DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
        inflateTable(threshold);
        // 将m中的全部元素逐个添加到HashMap中 
        putAllForCreate(m);
    }

Entry实现

Entry是一个链表节点,每个节点包含键值对、hash值和下个节点的引用。
JDK7 里 HashMap的bucket数组也不会在new 的时候分配,也是在第一次 put 的时候通过 inflateTable() 函数进行分配。

/** *Entry是HashMap里面承载键值对数据的数据结构,实现了Map接口里面的Entry接口,除了方法recordAccess(HashMap<K,V> m),recordRemoval(HashMap<K,V> m)外,其他方法均为final方法,表示即使是子类也不能覆写这些方法。 */
 static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;
        int hash;

        /** * Creates new entry. */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }

        public final K getKey() {
            return key;
        }

        public final V getValue() {
            return value;
        }

        //设置value值,返回原来的value值
        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }
        //比较键值对是否equals相等,只有键、值都相等的情况下,才equals相等
        public final boolean equals(Object o) {
            if (!(o instanceof Map.Entry))
                return false;
            Map.Entry e = (Map.Entry)o;
            Object k1 = getKey();
            Object k2 = e.getKey();
            //若k1 == k2(k1,k2引用同一个对象),或者k1.equals(k2)为true时,进而判断value值是否相等
            if (k1 == k2 || (k1 != null && k1.equals(k2))) {
                Object v1 = getValue();
                Object v2 = e.getValue();
                //若v1 == v2(v1,v2引用同一个对象),或者v1.equals(v2)为true时,此时才能说当前的键值对和指定的的对象equals相等
                if (v1 == v2 || (v1 != null && v1.equals(v2)))
                    return true;
            }
            return false;
        }

        public final int hashCode() {
            return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
        }

        public final String toString() {
            return getKey() + "=" + getValue();
        }

        /** * 此方法只有在key已存在的时候调用m.put(key,value)方法时,才会被调用,即覆盖原来key所对应的value值时被调用 */
        void recordAccess(HashMap<K,V> m) {
        }

        /** * 此方法在当前键值对被remove时调用 */
        void recordRemoval(HashMap<K,V> m) {
        }
    }

put方法源码

JDK7中 HashMap 的bucket数组大小也一定是2的幂,同样有计算下标简便的优点。如果你通过 HashMap(int initialCapacity) 构造器传入initialCapacity,会先存入 threshold,在第一次 put 时调用 inflateTable() 初始化,会计算出比initialCapacity大的2的幂作为初始化数组的大小,此后 resize 扩容也都是每次乘2。

/** * put操作逻辑是如果当前键已经在hashmap中存在,那么覆盖之,并返回原来的值,否则返回null */
    public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        // 当key为null时,调用putForNullKey方法,将value放置在数组Entry第一个位置。
        if (key == null)
            return putForNullKey(value);
        // 根据key重新计算hash值。
        int hash = hash(key);
        // 搜索指定hash值在对应table中的索引。
        int i = indexFor(hash, table.length);
         //遍历bucket桶上面的链表元素,找出HashMap中是否有相同的key存在,若存在,则替换其value值,返回原来的value值
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        //直接将键值对插入,由于插入元素,修改了HashMap的结构,因此将modeCount+1
        modCount++;
        //进行键值对的插入
        addEntry(hash, key, value, i);
        //由于原来HashMap中不存在key,则不存在替换value值问题,因此返回null
        return null;
    }

    /** * 第一次put时,初始化table */
    private void inflateTable(int toSize) {
        // Find a power of 2 >= toSize
        int capacity = roundUpToPowerOf2(toSize);
        // threshold 在不超过限制最大值的前提下等于 capacity * loadFactor
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry[capacity];
        // 判断是否需要初始化hash
        initHashSeedAsNeeded(capacity);
    }

    /** * 开关打开(hashSeed不为0)的时候,对String类型的key采用sun.misc.Hashing.stringHash32的hash算法;对非String类型的 key,多一次和hashSeed的异或,也可以一定程度上减少碰撞的概率。 */
    final boolean initHashSeedAsNeeded(int capacity) {
        boolean currentAltHashing = hashSeed != 0;
        boolean useAltHashing = sun.misc.VM.isBooted() &&
                (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        boolean switching = currentAltHashing ^ useAltHashing;
        if (switching) {
            hashSeed = useAltHashing
                ? sun.misc.Hashing.randomHashSeed(this)
                : 0;
        }
        return switching;
    }

    //先看看HashMap中原先是否有key为null的键值对存在,若存在,则替换原来的value值;若不存在,则将key为null的键值对插入到Entry数组的第一个位置table[0]
    private V putForNullKey(V value) {
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            //说明原来之前HashMap中就已经存在key问null的键值对了,现在又插入了一个key为null的新元素,则替换掉原来的key为null的value值
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        addEntry(0, null, value, 0);
        return null;
    }

    /** * 将指定的key,value,hash,bucketIndex 插入键值对,若此时size 大于极限threshold,则将Entry数组table扩容到原来容量的两倍 */
    void addEntry(int hash, K key, V value, int bucketIndex) {
        //若此时size大于极限threshold,则将Entry数组table扩容到原来容量的两倍
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }

    /** * 在HashMap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置,如何计算这个位置就是hash算法. * HashMap的数据结构是数组和链表的结合,所以我们当然希望这个HashMap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表. * 所以我们首先想到的就是把hashcode对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的,但是,“模”运算的消耗还是比较大的,能不能找一种更快速,消耗更小的方式那?java中时这样做的 :将hash值和数组长度按照位与&来运算 * 可以这么理解 length肯定是 2的幂,如 16 转换 2禁制是 10000 ,减一为01111 ,进行&运算就可以得到h对应的低位,刚好是相当于h%length */
    static int indexFor(int h, int length) {
        // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
        return h & (length-1);
    }

    /** * 将key-value插入数组指定位置 */
    void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
    }

扩容机制

当HashMap中的元素越来越多的时候,hash冲突的几率也就越来越高,因为数组的长度是固定的。所以为了提高查询的效率,就要对HashMap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,这是一个常用的操作,而在HashMap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。

  • 那么HashMap什么时候进行扩容呢?当HashMap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为 2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。
    /** * 将HashMap中Entry数组table的容量扩容至新容量newCapacity,数组的扩容不但涉及到数组元素的复制,还要将原数组中的元素rehash到新的数组中,很耗时;如果能预估到放入HashMap中元素的大小,最好使用new HashMap(size)方式创建足够容量的HashMap,避免不必要的数组扩容(rehash操作),提高效率 */
    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        Entry[] newTable = new Entry[newCapacity];
        //进行数组元素的移动和rehashing
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

    /** * 将原Entry数组table中的所有键值对迁移到新Entry数组newTable上 */
    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                //重新计算键值对e在新数组newTable中的bucketIndex位置(即rehash操作)
                int i = indexFor(e.hash, newCapacity);
                //将newTable[i]的引用赋给了e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样如果发生了hash冲突的话,先放在一个索引上的元素终会被放到Entry链的尾部
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

获取元素

HashMap 在底层将 key-value 当成一个整体进行处理,这个整体就是一个 Entry 对象。HashMap 底层采用一个 Entry[] 数组来保存所有的 key-value 对,当需要存储一个 Entry 对象时,会根据hash算法来决定其在数组中的存储位置,在根据equals方法决定其在该数组位置上的链表中的存储位置;当需要取出一个Entry时,也会根据hash算法找到其在数组中的存储位置,再根据equals方法从该位置上的链表中取出该Entry

    /** * 获取指定key所对应的value值 */
    public V get(Object key) {
        if (key == null)
            return getForNullKey();
        Entry<K,V> entry = getEntry(key);

        return null == entry ? null : entry.getValue();
    }

        /** * 返回键为key的键值对 */
    final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        } 
        // 获取哈希值,HashMap将key为null的元素存储在table[0]位置,key不为null的则调用hash()计算哈希值 
        int hash = (key == null) ? 0 : hash(key);
        // 在该hash值对应的链表上查找键值等于key的元素
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }

问题点

《阿里巴巴Java开发规约》中有提到: 【推荐】集合初始化时,指定集合初始值大小。 说明:HashMap使用如下构造方法进行初始化,如果暂时无法确定集合大小,那么指定默认值(16)即可:
public HashMap (int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); }

读完了源码再回头看这条规约涉及到的几个问题:

  • HashMap 默认bucket数组多大?
    16。
  • 如果new HashMap<>(19),bucket数组多大?
    HashMap 的 bucket 数组大小一定是2的幂,如果 new 的时候指定了容量且不是2的幂,实际容量会是最接近(大于)指定容量的2的幂,比如 new HashMap<>(19),比19大且最接近的2的幂是32,实际容量就是32。
  • HashMap 什么时候开辟bucket数组占用内存?
    HashMap 在 new 后并不会立即分配bucket数组,而是第一次 put 时初始化,类似 ArrayList 在第一次 add 时分配空间。
  • HashMap 何时扩容?
    HashMap 在 put 的元素数量大于 Capacity * LoadFactor(默认16 * 0.75) 之后会进行扩容。

总结

 1.jdk1.7的HashMap采用数组+链表的形式存储数据,当预先知道要存储在HashMap中数据量的大小时,可以使用new HashMap(int size)来指定其容量大小,避免HashMap数组扩容导致的元素复制和rehash操作带来的性能损耗。

  2.HashMap是基于Hash表、实现了Map接口的,查找元素的时候,先根据key计算器hash值,进而求得key在数组中的位置,但是要尽量避免hash冲突造成的要遍历链表操作,因此在我们手动指定HashMap容量的时候,容量capacity一定得是2的整数次幂,这样可以让数据平均的分配在数组中,减小hash冲突,提高性能。

  3.HashMap是非线程安全的,在多线程条件下不要使用HashMap,可以使用ConcurrentHashMap代替。

参考:http://www.importnew.com/7099.html
https://yq.aliyun.com/articles/225660?spm=5176.100239.bloglist.5.75T0bO
https://tech.meituan.com/java-hashmap.html
http://zhangshixi.iteye.com/blog/672697
http://www.cnblogs.com/lewis0077/p/5347061.html

    原文作者:java集合源码分析
    原文地址: https://blog.csdn.net/elricboa/article/details/78430276
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞