HashMap

Java7 HashMap

在这里插入图片描述
大方向上,HashMap 里面是一个数组,然后数组中每个元素是一个单向链表。
上图中,每个绿色的实体是嵌套类 Entry 的实例,Entry 包含四个属性:key, value, hash 值和用于单向链表的 next。
capacity:当前数组容量,始终保持 2^n,可以扩容,扩容后数组大小为当前的 2 倍。
loadFactor:负载因子,默认为 0.75。
threshold:扩容的阈值,等于 capacity * loadFactor

//内部数组的默认初始容量,作为hashmap的初始容量,是2的4次方,2的n次方的作用是减少hash冲突
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    //默认的最大容量
    static final int MAXIMUM_CAPACITY = 1 << 30;
    //默认负载因子,当容器使用率达到这个75%的时候就扩容
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    /**
     *当数组表还没扩容的时候,一个共享的空表对象
     */
    static final Entry<?,?>[] EMPTY_TABLE = {};
    //内部数组表,用来装entry,大小只能是2的n次方。
    transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
    //存储的键值对的个数
    transient int size;
    /**
     * 扩容的临界点,如果当前容量达到该值,则需要扩容了。
     * 如果当前数组容量为0时(空数组),则该值作为初始化内部数组的初始容量
     */
    int threshold;
    //由构造函数传入的指定负载因子
    final float loadFactor;
    //Hash的修改次数
    transient int modCount;
    //threshold的最大值
    static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
    //计算hash值时候用,初始是0
    transient int hashSeed = 0;
    //含有所有entry节点的一个set集合
    private transient Set<Map.Entry<K,V>> entrySet = null;
    private static final long serialVersionUID = 362498820763181265L;

构造方法

/**
     * 生成一个空HashMap,传入容量与负载因子
     * @param initialCapacity 初始容量
     * @param loadFactor 负载因子
     */
    public HashMap(int initialCapacity, float loadFactor) {
        //初始容量不能小于0
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                    initialCapacity);
        //初始容量不能大于默认的最大容量
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;

        //负载因子不能小于0,且不能为“NaN”(NaN(“不是一个数字(Not a Number)”的缩写))
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                    loadFactor);
        //将传入的负载因子赋值给属性
        this.loadFactor = loadFactor;

        //此时并不会创建容器,因为没有 传具体值
        // 没下次扩容大小
        /**
         * 此时并不会创建容器,因为没有传具体值。
         * 当下次传具体值的时候,才会“根据这次的初始容量”,创建一个内部数组。
         * 所以此次的初始容量只是作为下一次扩容(新建)的容量。
         */
        threshold = initialCapacity;

        //该方法只在LinkedHashMap中有实现,主要在构造函数初始化和clone、readObject中有调用。
        init();
    }

    /**
     * 生成一个空hashmap,传入初始容量,负载因子使用默认值(0.75)
     * @param initialCapacity 初始容量
     */
    public HashMap(int initialCapacity) {
        //生成空数组,并指定扩容值
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    /**
     * 生成一个空hashmap,初始容量和负载因子全部使用默认值。
     */
    public HashMap() {
        //生成空数组,并指定扩容值
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }

    /**
     * 根据已有map对象生成一个hashmap,初始容量与传入的map相关,负载因子使用默认值
     * @param m 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);

        //将传入map的键值对添加到初始数组中
        putAllForCreate(m);
    }

put 过程分析

/**
     * 存入一个键值对,如果key重复,则更新value
     * @param key 键值名
     * @param value 键值
     * @return 如果存的是新key则返回null,如果覆盖了旧键值对,则返回旧value
     */
    public V put(K key, V value) {
        //如果数组为空,则新建数组
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }

        //如果key为null,则把value放在table[0]中
        if (key == null)
            return putForNullKey(value);

        //生成key所对应的hash值
        int hash = hash(key);

        //根据hash值和数组的长度找到:该key所属entry在table中的位置i
        int i = indexFor(hash, table.length);

        /**
         * 数组中每一项存的都是一个链表,
         * 先找到i位置,然后循环该位置上的每一个entry,
         * 如果发现存在key与传入key相等,则替换其value。然后结束侧方法。
         * 如果没有找到相同的key,则继续执行下一条指令,将此键值对存入链表头
         */
        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;
            }
        }

        //map操作次数加一
        modCount++;

        //查看是否需要扩容,并将该键值对存入指定下标的链表头中
        addEntry(hash, key, value, i);

        //如果是新存入的键值对,则返回null
        return null;
    }

    /**
     * 将传入map的所有键值对存入本map
     * @param m 传入map
     */
    public void putAll(Map<? extends K, ? extends V> m) {
        //传入数组的键值对数
        int numKeysToBeAdded = m.size();
        if (numKeysToBeAdded == 0)
            return;

        //如果本地数组为空,则新建本地数组
        if (table == EMPTY_TABLE) {
            //从当前扩容临界值和传入数组的容量中选择大的一方作为初始数组容量
            inflateTable((int) Math.max(numKeysToBeAdded * loadFactor, threshold));
        }

        //如果传入map的键值对数比“下一次扩容后的内部数组大小”还大,则对数组进行扩容。(因为当前数组即使扩容后也装不下它)
        if (numKeysToBeAdded > threshold) {
            //确定新内部数组所需容量
            int targetCapacity = (int)(numKeysToBeAdded / loadFactor + 1);
            //不能大于最大容量
            if (targetCapacity > MAXIMUM_CAPACITY)
                targetCapacity = MAXIMUM_CAPACITY;
            //当前数组长度
            int newCapacity = table.length;
            //从当前数组长度开始增加,每次增加一个“2次方”,直到大于所需容量为止
            while (newCapacity < targetCapacity)
                newCapacity <<= 1;

            //如果发现内部数组长度需要增加,则扩容内部数组
            if (newCapacity > table.length)
                resize(newCapacity);
        }

        //遍历传入map,将键值对存入内部数组
        for (Map.Entry<? extends K, ? extends V> e : m.entrySet())
            put(e.getKey(), e.getValue());
    }

数组初始化
在第一个元素插入 HashMap 的时候做一次数组的初始化,就是先确定初始的数组大小,并计算数组扩容的阈值

/**
     * 新建一个空的内部数组
     * @param toSize 新数组容量
     */
    private void inflateTable(int toSize) {
        //内部数组的大小必须是2的n次方,所以要找到“大于”toSize的“最小的2的n次方”。
        int capacity = roundUpToPowerOf2(toSize);

        //下次扩容临界值
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);

        table = new Entry[capacity];

        //根据数组长度初始化hashseed
        initHashSeedAsNeeded(capacity);
    }

计算具体数组位置

/**
     * 根据传入的key生成hash值
     * @param k  键值名
     * @return hash值
     */
    final int hash(Object k) {
        int h = hashSeed;

        //如果key是字符串类型,就使用stringHash32来生成hash值
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }

        //一次散列
        h ^= k.hashCode();

        //二次散列
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

    /**
     * 返回hash值的索引,采用除模取余法,h & (length-1)操作 等价于 hash % length操作, 但&操作性能更优
     */
    /**
     * 根据key的hash值与数组长度,找到该key在table数组中的下标
     * @param h hash值
     * @param length 数组长度
     * @return 下标
     */
    static int indexFor(int h, int length) {
        //除模取余,相当于hash % length,&速度更快
        return h & (length-1);
    }

找到数组下标后,会先进行 key 判重,如果没有重复,就准备将新值放入到链表的表头

/**
     * 查看是否需要扩容,然后添加新节点
     * @param hash key的hash值
     * @param key 结点内key
     * @param value 结点内value
     * @param bucketIndex 结点所在的table下标
     */
    void addEntry(int hash, K key, V value, int bucketIndex) {
        //如果当前键值对数量达到了临界值,或目标table下标不存在,则扩容table
        if ((size >= threshold) && (null != table[bucketIndex])) {
            //容量扩容一倍
            resize(2 * table.length);
            //由于数组扩容了,重新计算hash值
            hash = (null != key) ? hash(key) : 0;
            //重新计算存储位置
            bucketIndex = indexFor(hash, table.length);
        }

        //将键值对与他的hash值作为一个entry,插入table的指定下标中的链表头中
        createEntry(hash, key, value, bucketIndex);
    }
    /**
     * 将键值对与他的hash值作为一个entry,插入table的指定下标中的链表头中
     * @param hash hash值
     * @param key 键值名
     * @param value 键值
     * @param bucketIndex 被插入的下标
     */
    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++;
    }

这个方法的主要逻辑就是先判断是否需要扩容,需要的话先扩容,然后再将这个新的数据插入到扩容后的数组的相应位置处的链表的表头。
数组扩容
前面我们看到,在插入新值的时候,如果当前的 size 已经达到了阈值,并且要插入的数组位置上已经有元素,那么就会触发扩容,扩容后,数组大小为原来的 2 倍。

/**
     * 对数组扩容,即创建一个新数组,并将旧数组里的东西重新存入新数组
     * @param newCapacity 新数组容量
     */
    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;

        //如果当前数组容量已经达到最大值了,则将扩容的临界值设置为Integer.MAX_VALUE(Integer.MAX_VALUE是容量的临界点)
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        //创建一个扩容后的新数组
        Entry[] newTable = new Entry[newCapacity];

        //将当前数组中的键值对存入新数组
        transfer(newTable, initHashSeedAsNeeded(newCapacity));

        //用新数组替换旧数组
        table = newTable;

        //计算下一个扩容临界点
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

扩容就是用一个新的大数组替换原来的小数组,并将原来数组中的值迁移到新的数组中。
由于是双倍扩容,迁移过程中,会将原来 table[i] 中的链表的所有节点,分拆到新的数组的 newTable[i] 和 newTable[i + oldLength] 位置上。如原来数组长度是 16,那么扩容后,原来 table[0] 处的链表中的所有元素会被分配到新数组中 newTable[0] 和 newTable[16] 这两个位置。
get 过程分析
相对于 put 过程,get 过程是非常简单的。
1、根据 key 计算 hash 值。
2、找到相应的数组下标:hash & (length - 1)。
3、遍历该数组位置处的链表,直到找到相等(==或equals)的 key。

/**
     * 根据key找到对应value
     * @param key 键值名
     * @return 键值value
     */
    public V get(Object key) {
        //如果key为null,则从table[0]中取value
        if (key == null)
            return getForNullKey();

        //如果key不为null,则先根据key,找到其entry
        Entry<K,V> entry = getEntry(key);

        //返回entry节点里的value值
        return null == entry ? null : entry.getValue();
    }
    /**
     * 查找key为null的value
     * (如果key为null,则hash值为0,并被保存在table[0]中)
     * @return 对应value
     */
    private V getForNullKey() {
        if (size == 0) {
            return null;
        }
        //查找table[0]处的链表,如果找到entry的key为null,就返回其value
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null)
                return e.value;
        }
        return null;
    }
    /**
     * 根据key值查找所属entry节点
     * @param key 键值名
     * @return entry节点
     */
    final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }

        //如果key为null,则其hash值为0,否则计算hash值
        int hash = (key == null) ? 0 : hash(key);

        //根据hash值找到table下标,然后迭代该下标中的链表里的每一个entry节点
        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;
        }
        return null;
    }

Java8 HashMap

Java8 对 HashMap 进行了一些修改,最大的不同就是利用了红黑树,所以其由 数组+链表+红黑树 组成。
根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。
为了降低这部分的开销,在 Java8 中,当链表中的元素达到了 8 个时,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。
在这里插入图片描述
Java7 中使用 Entry 来代表每个 HashMap 中的数据节点,Java8 中使用 Node,基本没有区别,都是 key,value,hash 和 next 这四个属性,不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode
先了解以下几个点,有利于更好的理解HashMap的源码
1、头节点指的是table表上索引位置的节点,也就是链表的头节点。
2、根结点(root节点)指的是红黑树最上面的那个节点,也就是没有父节点的节点。
3、红黑树的根结点不一定是索引位置的头结点。
4、转为红黑树节点后,链表的结构还存在,通过next属性维持,红黑树节点在进行操作时都会维护链表的结构,并不是转为红黑树节点,链表结构就不存在了。
5、在红黑树上,叶子节点也可能有next节点,因为红黑树的结构跟链表的结构是互不影响的,不会因为是叶子节点就说该节点已经没有next节点。
6、源码中一些变量定义:如果定义了一个节点p,则pl为p的左节点,pr为p的右节点,pp为p的父节点,ph为p的hash值,pk为p的key值,kc为key的类等等。源码中很喜欢在if/for等语句中进行赋值并判断,请注意。
7、链表中移除一个节点只需如下图操作,其他操作同理。
在这里插入图片描述
8、红黑树在维护链表结构时,移除一个节点只需如下图操作(红黑树中增加了一个prev属性),其他操作同理。注:此处只是红黑树维护链表结构的操作,红黑树还需要单独进行红黑树的移除或者其他操作。
在这里插入图片描述
9、源码中进行红黑树的查找时,会反复用到以下两条规则:1)如果目标节点的hash值小于p节点的hash值,则向p节点的左边遍历;否则向p节点的右边遍历。2)如果目标节点的key值小于p节点的key值,则向p节点的左边遍历;否则向p节点的右边遍历。这两条规则是利用了红黑树的特性(左节点<根结点<右节点)。
10、源码中进行红黑树的查找时,会用dir(direction)来表示向左还是向右查找,dir存储的值是目标节点的hash/key与p节点的hash/key的比较结果。
put 过程分析

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
// 第三个参数 onlyIfAbsent 如果是 true,那么只有在不存在该 key 时才会进行 put 操作 
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是否为空或者length等于0, 如果是则调用resize方法进行初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;    
    // 通过hash值计算索引位置, 如果table表该索引位置节点为空则新增一个
    if ((p = tab[i = (n - 1) & hash]) == null)// 将索引位置的头节点赋值给p
        tab[i] = newNode(hash, key, value, null);
    else {  // table表该索引位置不为空
        Node<K,V> e; K k;
        if (p.hash == hash && // 判断p节点的hash值和key值是否跟传入的hash值和key值相等
            ((k = p.key) == key || (key != null && key.equals(k)))) 
            e = p;  // 如果相等, 则p节点即为要查找的目标节点,赋值给e
        // 判断p节点是否为TreeNode, 如果是则调用红黑树的putTreeVal方法查找目标节点
        else if (p instanceof TreeNode) 
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {	// 走到这代表p节点为普通链表节点
            for (int binCount = 0; ; ++binCount) {  // 遍历此链表, binCount用于统计节点数
                if ((e = p.next) == null) { // p.next为空代表不存在目标节点则新增一个节点插入链表尾部
                    p.next = newNode(hash, key, value, null);
                    // 计算节点是否超过8个, 减一是因为循环是从p节点的下一个节点开始的
                    if (binCount >= TREEIFY_THRESHOLD - 1)
                        treeifyBin(tab, hash);// 如果超过8个,调用treeifyBin方法将该链表转换为红黑树
                    break;
                }
                if (e.hash == hash && // e节点的hash值和key值都与传入的相等, 则e即为目标节点,跳出循环
                    ((k = e.key) == key || (key != null && key.equals(k)))) 
                    break;
                p = e;  // 将p指向下一个节点
            }
        }
        // e不为空则代表根据传入的hash值和key值查找到了节点,将该节点的value覆盖,返回oldValue
        if (e != null) { 
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e); // 用于LinkedHashMap
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold) // 插入节点后超过阈值则进行扩容
        resize();
    afterNodeInsertion(evict);  // 用于LinkedHashMap
    return null;
}

1、校验table是否为空或者length等于0,如果是则调用resize方法(见下文resize方法)进行初始化
2、通过hash值计算索引位置,将该索引位置的头节点赋值给p节点,如果该索引位置节点为空则使用传入的参数新增一个节点并放在该索引位置
3、判断p节点的key和hash值是否跟传入的相等,如果相等, 则p节点即为要查找的目标节点,将p节点赋值给e节点
4、如果p节点不是目标节点,则判断p节点是否为TreeNode,如果是则调用红黑树的putTreeVal方法(见下文代码块4)查找目标节点
5、走到这代表p节点为普通链表节点,则调用普通的链表方法进行查找,并定义变量binCount来统计该链表的节点数
6、如果p的next节点为空时,则代表找不到目标节点,则新增一个节点并插入链表尾部,并校验节点数是否超过8个,如果超过则调用treeifyBin方法(见下文代码块6)将链表节点转为红黑树节点
7、如果遍历的e节点存在hash值和key值都与传入的相同,则e节点即为目标节点,跳出循环
8、如果e节点不为空,则代表目标节点存在,使用传入的value覆盖该节点的value,并返回oldValue
9、如果插入节点后节点数超过阈值,则调用resize方法(见下文resize方法)进行扩容
数组扩容
resize() 方法用于初始化数组或数组扩容,每次扩容后,容量为原来的 2 倍,并进行数据迁移。

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {   // 老table不为空
        if (oldCap >= MAXIMUM_CAPACITY) {      // 老table的容量超过最大容量值
            threshold = Integer.MAX_VALUE;  // 设置阈值为Integer.MAX_VALUE
            return oldTab;
        }
        // 如果容量*2<最大容量并且>=16, 则将阈值设置为原来的两倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)   
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // 老表的容量为0, 老表的阈值大于0, 是因为初始容量被放入阈值
        newCap = oldThr;	// 则将新表的容量设置为老表的阈值 
    else {	// 老表的容量为0, 老表的阈值为0, 则为空表,设置默认容量和阈值
        newCap = DEFAULT_INITIAL_CAPACITY; 
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {  // 如果新表的阈值为空, 则通过新的容量*负载因子获得阈值
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr; // 将当前阈值赋值为刚计算出来的新的阈值
    @SuppressWarnings({"rawtypes","unchecked"})
    // 定义新表,容量为刚计算出来的新容量
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab; // 将当前的表赋值为新定义的表
    if (oldTab != null) {   // 如果老表不为空, 则需遍历将节点赋值给新表
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {  // 将索引值为j的老表头节点赋值给e
                oldTab[j] = null; // 将老表的节点设置为空, 以便垃圾收集器回收空间
                // 如果e.next为空, 则代表老表的该位置只有1个节点, 
                // 通过hash值计算新表的索引位置, 直接将该节点放在该位置
                if (e.next == null) 
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                	 // 调用treeNode的hash分布(跟下面最后一个else的内容几乎相同)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); 
                else { // preserve order
                    Node<K,V> loHead = null, loTail = null; // 存储跟原索引位置相同的节点
                    Node<K,V> hiHead = null, hiTail = null; // 存储索引位置为:原索引+oldCap的节点
                    Node<K,V> next;
                    do {
                        next = e.next;
                        //如果e的hash值与老表的容量进行与运算为0,则扩容后的索引位置跟老表的索引位置一样
                        if ((e.hash & oldCap) == 0) {   
                            if (loTail == null) // 如果loTail为空, 代表该节点为第一个节点
                                loHead = e; // 则将loHead赋值为第一个节点
                            else    
                                loTail.next = e;    // 否则将节点添加在loTail后面
                            loTail = e; // 并将loTail赋值为新增的节点
                        }
                        //如果e的hash值与老表的容量进行与运算为1,则扩容后的索引位置为:老表的索引位置+oldCap
                        else {  
                            if (hiTail == null) // 如果hiTail为空, 代表该节点为第一个节点
                                hiHead = e; // 则将hiHead赋值为第一个节点
                            else
                                hiTail.next = e;    // 否则将节点添加在hiTail后面
                            hiTail = e; // 并将hiTail赋值为新增的节点
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null; // 最后一个节点的next设为空
                        newTab[j] = loHead; // 将原索引位置的节点设置为对应的头结点
                    }
                    if (hiTail != null) {
                        hiTail.next = null; // 最后一个节点的next设为空
                        newTab[j + oldCap] = hiHead; // 将索引位置为原索引+oldCap的节点设置为对应的头结点
                    }
                }
            }
        }
    }
    return newTab;
}

扩容后,节点重hash为什么只可能分布在原索引位置与原索引+oldCap位置?
扩容代码中,使用e节点的hash值跟oldCap进行位与运算,以此决定将节点分布到原索引位置或者原索引+oldCap位置上,这是为什么了?

假设老表的容量为16,即oldCap=16,则新表容量为16*2=32,假设节点1的hash值为0000 0000 0000 0000 0000 1111 0000 1010,节点2的hash值为0000 0000 0000 0000 0000 1111 0001 1010,则节点1和节点2在老表的索引位置计算如下图计算1,由于老表的长度限制,节点1和节点2的索引位置只取决于节点hash值的最后4位。再看计算2,计算2为新表的索引计算,可以知道如果两个节点在老表的索引位置相同,则新表的索引位置只取决于节点hash值倒数第5位的值,而此位置的值刚好为老表的容量值16,此时节点在新表的索引位置只有两种情况:原索引位置和原索引+oldCap位置(在此例中即为10和10+16=26)。由于结果只取决于节点hash值的倒数第5位,而此位置的值刚好为老表的容量值16,因此此时新表的索引位置的计算可以替换为计算3,直接使用节点的hash值与老表的容量16进行位于运算,如果结果为0则该节点在新表的索引位置为原索引位置,否则该节点在新表的索引位置为原索引+oldCap位置。
在这里插入图片描述
死循环问题
在Jdk 1.8以前,Java语言在并发情况下使用HashMap造成Race Condition,从而导致死循环。程序经常占了100%的CPU,查看堆栈,你会发现程序都Hang在了HashMap.get()这个方法上了,重启程序后问题消失。有人将这个问题当成一个bug提给了Sun,但是Sun认为这并不是个bug,因为HashMap本来就不保证并发的线程安全性,在并发下,要用ConcurrentHashMap来代替。
那么,在Jdk 1.8的时候,这个问题解决了吗?
我们知道,Jdk 1.8以前,导致死循环的主要原因是扩容后,节点的顺序会反掉,如下图:扩容前节点A在节点C前面,而扩容后节点C在节点A前面。
在这里插入图片描述
JDK1.8 普通链表的扩容代码,如下图所示,在上文已经分析过了:主要是在一个do/while中处理同一个位置的所有节点。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
结果:可以看出,扩容后,节点A和节点C的先后顺序跟扩容前是一样的。因此,即使此时有多个线程并发扩容,也不会出现死循环的情况。当然,这仍然改变不了HashMap仍是非并发安全,在并发下,还是要使用ConcurrentHashMap来代替。
定位哈希桶数组索引位置
不管增加、删除、查找键值对,定位到哈希桶数组的位置都是很关键的第一步。前面说过HashMap的数据结构是“数组+链表+红黑树”的结合,所以我们当然希望这个HashMap里面的元素位置尽量分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用遍历链表/红黑树,大大优化了查询的效率。HashMap定位数组索引位置,直接决定了hash方法的离散性能。下面是定位哈希桶数组的源码:

// 代码1
static final int hash(Object key) { // 计算key的hash值
    int h;
    // 1.先拿到key的hashCode值; 2.将hashCode的高16位参与运算
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 代码2
int n = tab.length;
// 将(tab.length - 1) 与 hash值进行&运算
int index = (n - 1) & hash;

整个过程本质上就是三步:
1、拿到key的hashCode值
2、将hashCode的高位参与运算,重新计算hash值
3、将计算出来的hash值与(table.length - 1)进行&运算
对于任意给定的对象,只要它的hashCode()返回值相同,那么计算得到的hash值总是相同的。我们首先想到的就是把hash值对table长度取模运算,这样一来,元素的分布相对来说是比较均匀的。
但是模运算消耗还是比较大的,我们知道计算机比较快的运算为位运算,因此JDK团队对取模运算进行了优化,使用上面代码2的位与运算来代替模运算。这个方法非常巧妙,它通过 “(table.length -1) & h” 来得到该对象的索引位置,这个优化是基于以下公式:x mod 2^n = x & (2^n - 1)。我们知道HashMap底层数组的长度总是2的n次方,并且取模运算为“h mod table.length”,对应上面的公式,可以得到该运算等同于“h & (table.length - 1)”。这是HashMap在速度上的优化,因为&比%具有更高的效率。
在JDK1.8的实现中,还优化了高位运算的算法,将hashCode的高16位与hashCode进行异或运算,主要是为了在table的length较小的时候,让高位也参与运算,并且不会有太大的开销。
在这里插入图片描述
get 过程分析

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不为空 && table长度大于0 && table索引位置(根据hash值计算出)不为空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {    
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k)))) 
            return first;	// first的key等于传入的key则返回first对象
        if ((e = first.next) != null) { // 向下遍历
            if (first instanceof TreeNode)  // 判断是否为TreeNode
            	// 如果是红黑树节点,则调用红黑树的查找目标节点方法getTreeNode
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 走到这代表节点为链表节点
            do { // 向下遍历链表, 直至找到节点的key和传入的key相等时,返回该节点
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;    // 找不到符合的返回空
}

1、先对table进行校验,校验是否为空,length是否大于0
2、使用table.length - 1和hash值进行位与运算,得出在table上的索引位置,将该索引位置的节点赋值给first节点,校验该索引位置是否为空
3、检查first节点的hash值和key是否和入参的一样,如果一样则first即为目标节点,直接返回first节点
4、如果first的next节点不为空则继续遍历
5、如果first节点为TreeNode,则调用getTreeNode方法(见下文代码块1)查找目标节点
6、如果first节点不为TreeNode,则调用普通的遍历链表方法查找目标节点
7、如果查找不到目标节点则返回空

HashMap和Hashtable的区别:

HashMap允许key和value为null,Hashtable不允许。
HashMap的默认初始容量为16,Hashtable为11。
HashMap的扩容为原来的2倍,Hashtable的扩容为原来的2倍加1。
HashMap是非线程安全的,Hashtable是线程安全的。
HashMap的hash值重新计算过,Hashtable直接使用hashCode。
HashMap去掉了Hashtable中的contains方法。
HashMap继承自AbstractMap类,Hashtable继承自Dictionary类。

总结:

HashMap的底层是个Node数组(Node<K,V>[] table),在数组的具体索引位置,如果存在多个节点,则可能是以链表或红黑树的形式存在。
增加、删除、查找键值对时,定位到哈希桶数组的位置是很关键的一步,源码中是通过下面3个操作来完成这一步:1)拿到key的hashCode值;2)将hashCode的高位参与运算,重新计算hash值;3)将计算出来的hash值与(table.length - 1)进行&运算。
HashMap的默认初始容量(capacity)是16,capacity必须为2的幂次方;默认负载因子(load factor)是0.75;实际能存放的节点个数(threshold,即触发扩容的阈值)= capacity * load factor。
HashMap在触发扩容后,阈值会变为原来的2倍,并且会进行重hash,重hash后索引位置index的节点的新分布位置最多只有两个:原索引位置或原索引+oldCap位置。例如capacity为16,索引位置5的节点扩容后,只可能分布在新报索引位置5和索引位置21(5+16)。
导致HashMap扩容后,同一个索引位置的节点重hash最多分布在两个位置的根本原因是:1)table的长度始终为2的n次方;2)索引位置的计算方法为“(table.length - 1) & hash”。HashMap扩容是一个比较耗时的操作,定义HashMap时尽量给个接近的初始容量值。
HashMap有threshold属性和loadFactor属性,但是没有capacity属性。初始化时,如果传了初始化容量值,该值是存在threshold变量,并且Node数组是在第一次put时才会进行初始化,初始化时会将此时的threshold值作为新表的capacity值,然后用capacity和loadFactor计算新表的真正threshold值。
当同一个索引位置的节点在增加后达到9个时,会触发链表节点(Node)转红黑树节点(TreeNode,间接继承Node),转成红黑树节点后,其实链表的结构还存在,通过next属性维持。链表节点转红黑树节点的具体方法为源码中的treeifyBin(Node<K,V>[] tab, int hash)方法。
当同一个索引位置的节点在移除后达到6个时,并且该索引位置的节点为红黑树节点,会触发红黑树节点转链表节点。红黑树节点转链表节点的具体方法为源码中的untreeify(HashMap<K,V> map)方法。
HashMap在JDK1.8之后不再有死循环的问题,JDK1.8之前存在死循环的根本原因是在扩容后同一索引位置的节点顺序会反掉。
HashMap是非线程安全的,在并发场景下使用ConcurrentHashMap来代替。

参考:https://www.cnblogs.com/red-code/p/6686738.html
https://javadoop.com/post/hashmap
https://blog.youkuaiyun.com/v123411739/article/details/78996181

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值