Java集合--HashMap(1.7与1.8)底层实现

Map

Map 集合是有 Key 和 value 的(键值对),Collection 集合是只有 value;Map 接口并不是 Collection 下的;

  • TreeMap:基于红黑树实现;

  • HashMap:基于哈希表(数组+链表+红黑树(1.8))实现;

  • HashTable:和 HashMap 类似,但它是线程安全的,这意味着同一时刻多个线程同时写入 HashTable 不会导致数据不一致。它是遗留类,不应该去使用它,而是使用 ConcurrentHashMap 来支持线程安全,ConcurrentHashMap 的效率会更高;

  • LinkedHashMap:使用双向链表来维护元素的顺序,顺序为插入顺序或者最近最少使用(LRU)顺序;

HashMap

HashMap 允许键/值为空对象(null),不保证数据的有序并且元素不能重复,而且它是线程不安全的


存储结构

HashMap 采用的数据结构 = 数组(主) + 单链表(副),这种数据结构也称为拉链法

在这里插入图片描述

Entry 结点

Entry 类实现了 Map.Entry 接口(Map 接口中的 Entry 接口);即,实现了 getKey() , getValue() , equals(Object o )和 hashCode() 等方法;

static class Entry<K,V> implements Map.Entry<K,V> {
    //键
    final K key;
    //值
    V value;
    //后继,从而形成用来解决 hash 冲突的单链表
    Entry<K,V> next;
    //每个结点的 hash 值
    int hash;
	/*构造方法
	 参数:哈希值 h ,键 k ,值 v,下一个结点 n 
	*/
    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;
    }
	//存放值,并获取旧值
    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }
	/*
	判断2个 Entry 结点是否相等,必须 key 和 value 都相等,才返回 true 
	*/
    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();
        if (k1 == k2 || (k1 != null && k1.equals(k2))) {
            Object v1 = getValue();
            Object v2 = e.getValue();
            if (v1 == v2 || (v1 != null && v1.equals(v2)))
                return true;
        }
        return false;
    }
	//计算 hash 值
    public final int hashCode() {
        return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
    }
	//toString
    public final String toString() {
        return getKey() + "=" + getValue();
    }
}

基本属性

//初始容量 16(1左移4位)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大容量 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认加载因子 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//实际加载因子
final float loadFactor;
//扩容阈值(threshold),当哈希表的大小size ≥ 扩容阈值threshold时,就会扩容哈希表(即扩充 HashMap 的容量) 
//扩容:对哈希表进行 resize 操作(即重建内部数据结构),从而哈希表将具有原来两倍的桶数
//threshold 扩容阈值 = 容量 * 加载因子
int threshold;
//空的 Entry 类型数组,参数未知
static final Entry<?,?>[] EMPTY_TABLE = {};
//存储数据的 Entry 类型数组,长度是 2 的幂
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
//HashMap 存储的结点数量
transient int size;

构造器

	//传入初始容量和加载因子
	public HashMap(int initialCapacity, float loadFactor) {
        //小于 0 直接抛出异常
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        //如果初始容量大于最大容量,则就直接赋值为最大容量
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        //判断加载因子
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        threshold = initialCapacity;
        init();
    }
	public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
	//空参构造器,传入默认初始容量16和默认加载因子0.75
	public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }
	//传入一个Map集合并转化为该HashMap
	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);

        putAllForCreate(m);
    }

put 方法

该方法采用的是头插法,详细的看下面代码:

	public V put(K key, V value) {
        //如果哈希表未初始化,则使用构造器设置的阈值(即初始容量) 初始化数组table 
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        //键为空时单独处理
        if (key == null)
            return putForNullKey(value);
        //计算key的 hash 值
        int hash = hash(key);
        //根据hash值和当前数组的长度确认 key 对应存放的数组 table 中位置
        int i = indexFor(hash, table.length);
        //找出是否已经存在键为 key 的键值对,如果存在的话就更新这个键值对的值为 value
        //遍历链表,以该数组索引为 i 的元素为头结点的链表
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            //若 key 对应的键值对已经存在,则就用新的 value 代替 旧的 value并返回旧的 value
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        //若键为 key 的键值对不存在,那么就插入该键值对
        addEntry(hash, key, value, i);
        return null;
    }

putForNullKey 方法

HashMap 允许插入键为 null 的键值对,但是因为无法调用 null 的 hashCode() 方法,也就无法确定该键值对的桶下标,只能通过强制指定一个桶下标来存放HashMap 使用第 0 个桶存放键为 null 的键值对。

	private V putForNullKey(V value) {
        //遍历以数组索引为 0 的链表,寻找是否存在 key == null 对应的键值对
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            //若存在则用新 value 替换旧 value ,并返回旧的 value
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        //否则,就直接插入数组 table[0] 的位置,key 值为空并且 hash 值为0
        addEntry(0, null, value, 0);
        return null;
    }

addEntry 和 createEntry 方法
	void addEntry(int hash, K key, V value, int bucketIndex) {
        //判断当前的数据个数,如果大于等于阈值(开始的时候为 16 * 0.75 = 12)
        //并且数组table的索引为bucketIndex的位置不为空时,就会进行扩容
        if ((size >= threshold) && (null != table[bucketIndex])) {
            //扩容为原来的2倍
            resize(2 * table.length);
            //获取传入的key键值的hash值
            hash = (null != key) ? hash(key) : 0;
            //计算扩容后的存放位置
            bucketIndex = indexFor(hash, table.length);
        }
		//创建存储结点
        createEntry(hash, key, value, bucketIndex);
    }
	 void createEntry(int hash, K key, V value, int bucketIndex) {
        //获取出当前数组的索引为bucketIndex的结点元素赋值给e
        Entry<K,V> e = table[bucketIndex];
        //将上一步获取的结点e作为新插入结点的后继结点(创建Entry结点在上面的内容中)
        //也就是头插法
        table[bucketIndex] = new Entry<>(hash, key, value, e);
         //长度+1
        size++;
    }

inflateTable 和 initHashSeedAsNeeded 方法分别是第一次添加元素时进行数组的初始化(阈值 threshold 开始时其实为16,经过该方法后变才成为 12 )和 判断是否需要进行rehash(重新计算 hash 值,用于扩容);


确定桶下标(重点)

HashMap 中很多操作都会先确定一个键值对所在的桶下标;(1.8和1.7虽然代码不一样,但是思想是一致的)

//根据键值 key 计算 hash 值
int hash = hash(key);
//根据 hash 值,获得 key 对应存储的数组 table 中的位置
int i = indexFor(hash, table.length);
计算 hash 值
	final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }
        //键值key使用hashCode()计算出值再与h进行异或运算
        h ^= k.hashCode();
        //哈希码(hash值)操作  = 使用hashCode() + 4次位运算 + 5次异或运算(9次扰动)
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

上述代码是求出键值 key 的 hashCode 值,然后会进行 4 次位运算和 5 次异或运算(该处理也被称为扰动处理);最后会将运算的结果返回并参与确定桶下标的处理;

确定下标(为什么这样处理)

确定桶下标的最后一步是将 key 的 hash 值对桶个数取模:hash%capacity,如果能保证 capacity 为 2 的 n 次方,那么就可以将这个操作转换为位运算,如下:

	static int indexFor(int h, int length) {
       //根据上一步计算出的数值
        return h & (length-1);
    }

确定下标的“为什么”

  1. 为什么不直接采用经过 hashCode() 处理的哈希码作为存储结点元素的数组 table 的下标位置?
  2. 为什么采用哈希码和 数组长度-1 的与运算(&) 计算数组下标?
  3. 为什么在计算数组下标前,需对哈希码进行二次处理?

所有处理的根本目的,都是为了提高 存储key-value的数组下标位置的随机性 & 分布均匀性,尽量避免出现hash值冲突。即:对于不同key,存储的数组下标位置要尽可能不一样。


问题1,为什么不直接采用经过 hashCode() 处理的哈希码作为存储结点元素的数组 table 的下标位置?

容易出现 哈希码(hash值) 与 数组大小范围不匹配的情况,即,计算出来的哈希码可能不在数组大小范围内,从而导致无法匹配存储位置;

为了解决“哈希码与数组大小范围不匹配”的问题,HashMap 给出了解决方案:哈希码 &(数组长度-1)


问题2,为什么采用哈希码和 数组长度-1 的与运算(&) 计算数组下标?

在此之前先要了解,为什么 HashMap 的底层数组的长度被要求为 2 的幂?

如果长度设计为素数(素数导致的 hash 冲突会降低)时,如 HashTable 初始数组大小为 11 ,但扩容后不能够保证长度还是素数;

原因:

保证了哈希码的均匀性(实现均匀分布),同时减少了哈希碰撞,如:

数组长度 = 2 的幂 = 100…00 的形式(二进制),其首位是 1 ,最后一位是 0

1.算出的下标值就会集中于某几位,这样增大了 hash 冲突的可能性;
2.数组长度为偶数,最后一位是 0,& 出结果肯定为偶数,这样浪费了一半空间,而且也增大了hash冲突的可能性;

数组长度-1 = 0111…11 的形式,其首位是 0 ,最后一位是 1

这样 & 出的结果,就会由 hash 值的后几位来决定,并且最后一位为 1 ,& 出的结果是奇数还是偶数,由 hash 值的最后来决定;

并且使用 & 运算可以提高运算效率

令 x = 1<<4,即 x 为 2 的 4 次方,它具有以下性质:

x   : 00010000
x-1 : 00001111

令一个数 y 与 x-1 做与运算,可以去除 y 位级表示的第 4 位以上数:

y       : 10110010
x-1     : 00001111
y&(x-1) : 00000010

这个性质和 y 对 x 取模效果是一样的:

y   : 10110010
x   : 00010000
y%x : 00000010

位运算的代价比求模运算小的多,因此在进行这种计算时用位运算的话能带来更高的性能;

引用 CyC2018


问题3为什么在计算数组下标前,需对哈希码进行二次处理?

因为一般数组长度只会对应 hash 码的后几位,这样求出的结果也会易造成 hash 冲突,说白了就是,经过移位运算,得到的 hash 码更加均匀,提高了数组索引的随机性和均匀性;


总结上述问题

问:数组长度为什么是 2 的幂(初始为 16)并且为什么要进行位与运算?

答:长度为 2 的幂时,length-1 的值的所有二进制位都是 1 ,该情况下,函数 indexFor() 的结果等同于 hash 值后几位值,只要输入的 hash 值本身分布均匀,那么该 Hash 算法的结果就是均匀的,这就是为了 实现均匀分布使用位于运算是为了提高运算效率


扩容以及环形链表问题(重点)

多线程下可能也会出现数据丢失的问题。
与扩容有关的参数如下:

参数含义
capacitytable 的容量大小,默认为 16。需要注意的是 capacity 必须保证为 2 的 n 次方
size键值对数量
thresholdsize 的临界值,当 size 大于等于 threshold 就必须进行扩容操作
loadFactor加载因子,table 能够使用的比例,threshold = (int)(capacity * loadFactor)。(容量*阈值,初始后为12 = 16 * 0.75)

	void resize(int newCapacity) {
        //保存原来的数组 table
        Entry[] oldTable = table;
        //保存原来数组的长度
        int oldCapacity = oldTable.length;
        //如果长度等于最大容量那么阈值直接赋值为整数最大值
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }
		//否则,创建一个长度为newCapacity(2的容量)的数组 newTable
        Entry[] newTable = new Entry[newCapacity];
        //将原来数组上的数据(键值对)转移到新 table 中完成扩容
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        //扩容后,新数组 table 引用到 HashMap 的table属性上
        table = newTable;
        //确定新的阈值
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }
	
	void transfer(Entry[] newTable, boolean rehash) {
        //获取新的数组长度
        int newCapacity = newTable.length;
        //开始遍历原来的数组,将原数组中的键值对移动到新数组中
        for (Entry<K,V> e : table) {
            /*
            如果数组当前位置的元素 e 不为空时,就继续向下遍历
            也就是如果存在链表就系统链表中的元素
            e 也就是用来进行维护遍历的指针
            */
            while(null != e) {
                //保存元素 e 的下一个结点
                Entry<K,V> next = e.next;
                //判断是否对 hash 值重新进行计算(效率稍低)
                //rehash的值是根据方法initHashSeedAsNeeded计算出的
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                //计算新的存放元素的数组索引 i
                int i = indexFor(e.hash, newCapacity);
/*
采用头插法进行移动:先将新数组的newTable[i] 赋值给e的后继(e.next指向newTable[i],第一次时newTable[i]为null),
接着将元素e赋值给newTbale[i](进行了元素的转移,因此新数组中的元素e的后继就为null),最后将next元素结点赋值给e(即,e指向了next的结点元素)
*/
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

在扩容时直接按原来的方法计算 hash 值并且计算扩容后需要插入的位置(不计算添加的元素数据),因此效率较低;而且扩容完成后链表中的元素结点是逆序状态(头插法导致);
在方法 transfer 中进行元素的移动时采用的是头插法,并且在多线程并发下可能会产生环形链表的问题;

如下图所示,e其实也就是维护链表的指针,目前指向如下;

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dnJAZHoC-1592360675747)(F:\我的复习\集合\Map.assets\image-20200617090554452.png)]

接着会计算新的 hash 值 int i = indexFor(e.hash, newCapacity) ,然后将newTable[i] 引用赋值给 e.next(目前e 的 next 为 7,被赋值后 e 的 next 就为 null):

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2UXS67wj-1592360675751)(F:\我的复习\集合\Map.assets\image-20200617090932783.png)]

然后就会执行 newTable[i] = e 语句,也就是将 e 的引用赋值 给 newTable[i] ,接着就会执行 e = next ,将 next 的引用赋值给 e(e 指向了原表中的 7,但 e 的值也就是 3 已经赋值给了 newTable[i]了):[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FzAqU2O8-1592360675753)(F:\我的复习\集合\Map.assets\image-20200617091520102.png)]

接着就继续移动元素 7,当移动 7 完成后,next 就会指向 null ,然后再次循环时就不满足条件 while(null != e) 最终退出循环,移动完成;


但是,它在多线程的情况下是如何产生环形链表的呢?

示例:两个线程 A 和 B ,都要执行 put 操作,即向表中添加元素,即线程 A 和线程 B 都会进行扩容(目前容量为 2 ,那么扩容后为 4);

如果是线程 A 执行并且执行到 transfer 方法的 1 处(Entry<K,V> next = e.next)然后被 A 被挂起(每个线程有自己的方法栈,目前这个都在 A 线程的方法栈中保存着):

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LCFJXv3s-1592360675755)(F:\我的复习\集合\Map.assets\image-20200617093745211.png)]

A 被挂起后,接着 B 来继续执行,并且 B 线程将会全部执行完毕并写入内存中(A 和 B 两个线程有各自的方法栈,方法栈是私有的。但是注意!!这里的移动都是通过引用完成的而不是复制元素本身的值),B 线程全部执行完毕:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1HrloxD2-1592360675756)(F:\我的复习\集合\Map.assets\image-20200617094128243.png)]

这时,A 线程的 e 指针还是指向元素 3 ,next 还是指向 7 ;

这时,A 再次进行移动元素,先处理元素 3 , 将 3 放入线程 A 自己栈的新 table 中,但由于 B 线程已经修改了 7 的 next ( B 线程在移动完后,7 元素的 next ,也就是 7.next 指向的是元素 3 ,而不再是旧表中的 null 了(虽然方法栈都是私有的,但它们修改的是引用)),因此, 7.next 就指向 3 :

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RCl99YEy-1592360675758)(F:\我的复习\集合\Map.assets\image-20200617095435187.png)]

当 A 线程再次对 7 元素进行移动时,会执行代码 Entry<K,V> next = e.next ,那么会发现,这时的 next 指向的就不是 null 了,而是元素 3 ,接着继续执行代码 e.next = newTable[i] ,就会看见如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uH11RYQL-1592360675759)(F:\我的复习\集合\Map.assets\image-20200617100314421.png)]

然后,继续执行代码 newTable[i] = e 和 e = next (此时e.next = 7,也就是3.next = 7):

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dxXSsfpV-1592360675759)(F:\我的复习\集合\Map.assets\image-20200617101940182.png)]

现在 e 指向的是 3 ,然后进入下一轮 while 循环,会有 Entry<K,V> next = e.next ,那么,3.next = next = 7 ,就成为如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-c7m8AxO5-1592360675760)(F:\我的复习\集合\Map.assets\image-20200617102335134.png)]

最终,陷入死循环中。


get 方法

上述的 put() 方法的原理和 get() 方法两者的原理几乎相同;

	public V get(Object key) {
        //如果 key 值为空,就和put 类似
        //以数组中的第 1 个元素(即table[0])为头结点的链表去寻找对应 key == null 的键
        if (key == null)
            return getForNullKey();
        //否则就另作处理
        Entry<K,V> entry = getEntry(key);
        return null == entry ? null : entry.getValue();
    }
	private V getForNullKey() {
        if (size == 0) {
            return null;
        }
        //遍历以 table[0] 为头结点的链表,寻找 key==null 对应的值
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null)
                return e.value;
        }
        return null;
    }
	final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }	
		//根据 key 值,通过 hash() 计算出对应的 hash 值
        int hash = (key == null) ? 0 : hash(key);
        //根据 has h值计算出对应的数组下标
        //遍历以该数组下标的数组元素为头结点的链表所有结点,寻找该 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;
        }
        return null;
    }


HashMap1.7 总结

HashMap1.7 底层主要是 数组+链表 的存储方式,初始化时默认数组长度为 16加载因子为 0.75阈值 = 数组长度 * 加载因子 = 12

put 元素

  • 如果是第一次添加元素,那么先会去初始化数组 16,并且计算出初始的阈值 12 ;
  • 如果添加的元素的 key 值为 null ,那么就会对 key 值做单独的处理:将其存储在数组的第一个位置 table[0] ,如果过该位置有元素,那么就向下遍历链表;并且每天添加元素都会判断是否需要扩容,用来保证 HashMap 的性能;
  • 否则,计算 hash 值进行对应的存储;
  • 存储时如果该位置已经存在元素,那么就先去判断:先用 hash 值判断,如果 hash 值相等再使用 equals 去判断,如果两个都是相等的,说明存入的元素和该元素就是正真的相等

扩容

  • 如果添加元素时,HashMap 中的元素的个数 大于等于 阈值 并且目前添加元素的位置也不为空,那么就进行扩容,扩容为原来的 2 倍;
  • 然后先创建新的数组并调用 transfer 方法进行元素的移动,而在这之前也会先去判断是否需要重新计算 hash 值;
  • 扩容时采用的是头插法,而这样的方法在多线程环境下容易出现环形链表;

问:数组长度为什么是 2 的幂(初始为 16)并且为什么要进行位与运算?

答:长度为 2 的幂时,length-1 的值的所有二进制位都是 1 ,该情况下,函数 indexFor() 的结果等同于 hash 值后几位值,只要输入的 hash 值本身分布均匀,那么该 Hash 算法的结果就是均匀的,这就是为了 实现均匀分布使用位于运算是为了提高运算效率

问:加载因子为什么会是 0.75 而不是其它的?(主要是在1.8时做出判断,决定是否要转变为红黑树)

答:这是考虑到 “哈希冲突” 和 “空间利用率” 矛盾的一个折衷选择;

  • 加载因子越大,填满的元素越多,空间利用率越高,但与此同时冲突的机会加大(阈值 = 长度 * 加载因子);
  • 反之,加载因子越小,填满的元素越少,冲突的机会减小,但空间浪费又会增多;

HashMap(1.8重点)

在这里插入图片描述


存储结构

HashMap1.8 采用的数据结构 = 数组(主) + 单链表(副) + 红黑树

在这里插入图片描述

这种结构,提高了 HashMap 的性能(解决了发生哈希冲突后,链表过长从而导致索引效率变慢的问题),时间复杂度从 O(n) 降低到了 O(logn);


Node 结点

之前 1.7 是 Entry 结点,1.8 则是 Node 结点,其实相差不大,因为都是实现了 Map.Entry (Map 接口中的 Entry 接口)接口,即,实现了 getKey() , getValue() , equals(Object o )和 hashCode() 等方法;

	static class Node<K,V> implements Map.Entry<K,V> {
        //hash 值
        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; }
		//hash 值
        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }
        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }
		//判断2个Entry是否相等,必须key和value都相等,才返回true  
        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;
        }
    }

红黑树结点

	static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent; //父节点
        TreeNode<K,V> left;//左子树
        TreeNode<K,V> right;//右子树
        TreeNode<K,V> prev;//删除辅助结点(删除后需要取消链接)
        boolean red;//颜色
        //构造函数
        TreeNode(int hash, K key, V val, Node<K,V> next) {
            super(hash, key, val, next);
        }
        //返回当前节点的根节点
        final TreeNode<K,V> root() {
            for (TreeNode<K,V> r = this, p;;) {
                if ((p = r.parent) == null)
                    return r;
                r = p;
            }
        }
//     ......后面是红黑树的方法
    }

基本属性

主要是存储结构的变化导致核心属性的改变,属性中多了关于链表到红黑树转化的阈值,红黑树又转化到链表的阈值等;

//初始容量 16(1左移4位)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大容量 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认加载因子 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//实际加载因子
final float loadFactor;
//扩容阈值(threshold),当哈希表的大小size ≥ 扩容阈值threshold时,就会扩容哈希表(即扩充 HashMap 的容量) 
//扩容:对哈希表进行 resize 操作(即重建内部数据结构),从而哈希表将具有原来两倍的桶数
//threshold 扩容阈值 = 容量 * 加载因子
int threshold;
//存储数据的 Node 类型数组,长度是2的幂
transient Node<K,V>[] table;
//存储数据的 Entry 类型数组,长度是 2 的幂
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
//HashMap的大小,即 HashMap 中存储的键值对的数量
transient int size;

关于红黑树的相关参数属性

//桶的树化阈值:也就是链表转成红黑树的阈值,在存储数据时当链表长度大于该值时,则将链表转换成红黑树
//需要配合下面的属性使用
static final int TREEIFY_THRESHOLD = 8;
//最小树形化容量阈值:当哈希表中的容量大于该值时,则将链表转换成红黑树
//否则,若桶内元素太多时,则会直接扩容,而不是转换为红黑树
//为了避免进行扩容,树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
static final int MIN_TREEIFY_CAPACITY = 64;
//桶的链表还原阈值:红黑树转为链表的阈值,当在扩容(resize())时
//(此时HashMap的数据存储位置会重新计算),在重新计算存储位置后,
//当原有的红黑树内数量小于6时,则将红黑树转换成链表
static final int UNTREEIFY_THRESHOLD = 6;

构造器

	//和1.7区别不大	
	//无参构造器,加载因子默认为0.75
	public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
    }
	//指定容量大小的构造器,但调用了双参的构造器,加载因子0.75
	public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
	//全参构造器
	public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        //HashMap 的最大容量只能是 MAXIMUM_CAPACITY,哪怕传入的数值大于最大容量,也按照最大容量赋值
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        //加载因子必须大于0
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        //设置扩容阈值和1.7类似,目前该阈值不是正真的阈值
        this.threshold = tableSizeFor(initialCapacity);
    }
	//将传入的子Map中的全部元素逐个添加到HashMap中
	public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

和1.7类似,真正初始化(初始化存储数组table)是在第一次添加键值对的时候,即第一次调用 put() 时;


put 方法

1.8的 put 方法,采用尾插法,并且一定要注意扩容和转换红黑树的过程;

	public V put(K key, V value) {
        //先对传入的key值进行hash值的计算,然后调用putVal方法
        return putVal(hash(key), key, value, false, true);
    }
	final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        //申明tab 和 p 用于操作原数组和结点
        Node<K,V>[] tab; Node<K,V> p; 
        int n, i;
        //如果原数组是空或者原数组的长度等于0,那么通过resize()方法进行创建初始化
        if ((tab = table) == null || (n = tab.length) == 0)
            //获取到创建后数组的长度n
            n = (tab = resize()).length;
        
        //通过key的hash值和 数组长度-1 计算出存储元素结点的数组中位置(和1.7一样)
        //并且,如果该位置为空时,则直接创建元素结点赋值给该位置,后继元素结点为null
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            //否则,说明该位置存在元素
            Node<K,V> e; K k;
            //判断table[i]的元素的key是否与添加的key相同,若相同则直接用新value覆盖旧value
            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 {
                //遍历table[i],并判断添加的key是否已经存在,和之前判断一样,hash和equals
                //遍历完毕后仍无发现上述情况,则直接在链表尾部插入数据
                for (int binCount = 0; ; ++binCount) {
                    //如果遍历的下一个结点为空,那么直接插入
                    //该方法是尾插法(与1.7不同)
                    //将p的next赋值给e进行以下判断
                    if ((e = p.next) == null) {
                        //直接创建新结点连接在上一个结点的后继上
                        p.next = newNode(hash, key, value, null);
                        //如果插入结点后,链表的结点数大于等7(8-1,即大于8)时,则进行红黑树的转换
                        //注意:不仅仅是链表大于8,并且会在treeifyBin方法中判断数组是否为空或数组长度是否小于64
                        //如果小于64则进行扩容,并且不是直接转换为红黑树
                        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指向下一个元素结点
                    p = e;
                }
            }
            //接着上面的第二个break,如果e不为空,直接用新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;
        //添加成功后,判断实际存在的键值对数量size是否大于扩容阈值threshold(第一次时为12)
        if (++size > threshold)
            //若大于,扩容
            resize();
        //添加成功时会调用的方法(默认实现为空)
        afterNodeInsertion(evict);
        return null;
    }

该方法和 1.7 的区别就是,将头插法改为尾插法,并且在是否转换为红黑树做了判断;


hash(key) 方法

1.8 计算 hash 的方法和 1.7 有点差别:

1.7 做了9次扰动处理 = 4次位运算 + 5次异或运算;

而 1.8 只做了2次扰动 = 1次位运算 + 1次异或运算;

为什么使用这样计算,并且为什么计算存储位置 i 时是 i = (n - 1) & hash ,这些“为什么”在1.7讲解时已经做了详细说明;

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

扩容方法

//该函数有两种使用情况:初始化哈希表或前数组容量过小,需要扩容
	final Node<K,V>[] resize() {
        //获取原数组
        Node<K,V>[] oldTab = table;
        //获取到原数组的容量oldCap
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //获取原扩容阈值
        int oldThr = threshold;
        //新的容量和阈值目前都为0
        int newCap, newThr = 0;
        if (oldCap > 0) {
            //如果原数组容量大于等于最大容量,那么不再扩容
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //而没有超过最大容量,那么扩容为原来的2倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                //扩容为原2倍
                newThr = oldThr << 1; // double threshold
        }
        //经过上面的if,那么这步为初始化容量(使用有参构造器的初始化)
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            //否则,使用的无参构造器
            //那么,容量为16,阈值为12(0.75*16)
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        //计算新的resize的上限
        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
        table = newTab;
        //如果原数组不为空,那么就进行元素的移动
        if (oldTab != null) {
            //遍历原数组中的每个位置的元素
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    //如果该位置元素不为空,那么上一步获取元素接着置为空
                    oldTab[j] = null;
                    //判断该元素上是否有链表
                    if (e.next == null)
                        //如果无链表,确定元素存放位置,
//扩容前的元素位置为 (oldCap - 1) & e.hash ,所以这里的新的位置只有两种可能:1.位置不变,
//2.变为 原来的位置+oldCap,下面会详细介绍
                        newTab[e.hash & (newCap - 1)] = e;
                    //判断是否是树结点,如果是则执行树的操作
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        //否则,说明该元素上存在链表,那么进行元素的移动
                       	//根据变化的最高位的不同,也就是0或者1,将链表拆分开
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            //最高位为0时,则将节点加入 loTail.next
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            //最高位为1,则将节点加入 hiTail.next
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
//通过loHead和hiHead来保存链表的头结点,然后将两个头结点放到newTab[j]与newTab[j+oldCap]上面去 
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }
扩容中重新计算 hash 值

对于代码 newTab[e.hash & (newCap - 1)] ,是计算扩容后的新的存放元素的索引位置,而扩容前则是 table[e.hash & (oldCap - 1)] ,因此,这里就会出现两种情况,一是地址不变,二是变为 原位置+oldCap ;

示例:

如果 oldCap = 16 ,那么二进制就是 0001 0000 ,oldCap -1 = 15 二进制是 0000 1111 ,

如果 e1.hash = 10 二进制是 0000 1010 ,e2.hash = 26 二进制是 0101 1010,
因此 e1 在扩容前的位置是: e1.hash & oldCap -1 ,结果是:0000 1010

而 e2 在扩容前是: e2.hash & oldCap-1 ,结果是 0000 1010 ,那么,e1 和 e2 在扩容前在同一个链表上;

而扩容后, newCap = 32 ,再次计算索引位置 e1.hash & newCap - 1(0000 1010 & 0001 1111)的结果是 0000 1010 ,和之前完全一致;

而 e2.hash & newCap - 1(0101 1010 & 0001 1111)结果是 0001 1010 ,该结果就是 扩容前位置 + oldCap,即 10 + 16 = 26;

引用https://blog.csdn.net/qq_37113604/article/details/81353626

因此,这样的计算就是判断倒数第五位是 0 还是 1 ,如果是 0 ,那么位置不会改变,若为 1 ,则位置改变为 扩容前的位置 + 原来数组的容量;

1.7 与 1.8 的扩容区别

左1.8 ,右 1.7

在这里插入图片描述

在 JDK1.7 中的话,是先进行扩容后进行插入的,就是当你发现你插入的桶是不是为空,如果不为空说明存在值就发生了hash冲突,那么就必须得扩容,但是如果不发生Hash冲突的话,说明当前桶是空的(后面并没有挂有链表),那就等到下一次发生Hash冲突的时候在进行扩容,但是当如果以后都没有发生hash冲突产生,那么就不会进行扩容了,减少了一次无用扩容,也减少了内存的使用;


总结

HashMap1.8 底层主要是 数组+链表+红黑树 的存储方式,使用无参构造器初始化时默认数组长度为 16 ,加载因子为 0.75 ,树化阈值为 8,最小树形化容量阈值为 64 ,阈值 = 数组长度 * 加载因子 = 12

put 元素

  • 第一次添加元素时会先计算 key 的 hash 值;
  • 如果 table 没有初始化那么调用 resize() 方法初始化,并设置默认容量 16 ,扩容阈值 12;
  • 若不是第一次添加,并且计算出的数组的索引位置为空,那么直接创建结点添加进去,否则就是 Hash 冲突,那么就会判断添加的 key 是否与当前的元素 key 相同,如果相同直接用新的 value 替换旧的 value;而如果不相同则继续判断;
  • 接上步,继续判断当前结点是树结点还是一个链表,如果是树结点,那么在红黑树中添加或替换 value 值,否则就会遍历链表进行判断;
  • 遍历 table[i] ,判断添加的 key 是否已经存在(同时判断 hash 值和 equals 是否为 true),如果不存在则使用尾插法添加,若存在则替换 value 值;
  • 若此时链表长度 > 8 ,并且数组长度大于 64 时,才会进行树化,否则只是扩容;
  • 最后添加完成,会判断当前 HashMap 中的元素个数 size 是否大于了扩容阈值(threshold),若大于,则扩容;

扩容

  • 扩容前先判断是否需要初始化,当前容量是否大于了最大容量(如果大于则不扩容),接着创建新的数组(为原来数组长度的 2 倍);
  • 保存原来的数组,并且遍历旧数组的每个元素,然后重新计算新的索引位置;
  • 然后将原数组中的元素逐个转移到新数组中(包含需要添加的元素);
  • 1.7与1.8的区别:1.7是先扩容在进行添加元素,那么不管当前插入的元素的 key 是不是空,都先进行扩容,会造成无效扩容;而1.8是先添加再扩容,这时会在 put 元素时会发现当前 key 是否为空,即是否发生 hash 冲突,如果发生了就扩容,不发生就等下一次插入冲突或大于阀值时扩容。

问题

问:那 1.8 添加时采用尾插法不会出现环形链表的问题,那它就是线程安全的吗?

答:不是。

			if ((p = tab[i = (n - 1) & hash]) == null)
            //----------------> 1
            tab[i] = newNode(hash, key, value, null);

注意 putVal 的标注的代码,如果没有 hash 冲突则会直接插入元素。如果线程 A 和线程 B 同时进行 put 操作,刚好这两条不同的数据hash 值一样,并且该位置数据为 null,所以这线程 A、B 都会进入该行代码中。假设一种情况,线程 A 进入后还未进行数据插入时挂起,而线程 B 正常执行,从而正常插入数据,然后线程 A 获取 CPU 时间片,此时线程 A 不用再进行 hash 判断了,问题出现:线程 A 会把线程 B 插入的数据给覆盖,发生线程不安全。


问:为什么在JDK1.8中进行对HashMap优化的时候,把链表转化为红黑树的阈值是 8 ,而不是其它的值呢?

答:在源码上可以看出,在理想状态下,受随机分布的 hashCode 影响,链表中的节点遵循泊松分布,而且根据统计,链表中节点数是 8 的概率已经接近千分之一,而且此时链表的性能已经很差了,所以在这种比较罕见和极端的情况下,才会把链表转变为红黑树。

	//Java中解释的原因
   * Because TreeNodes are about twice the size of regular nodes, we
     * use them only when bins contain enough nodes to warrant use
     * (see TREEIFY_THRESHOLD). And when they become too small (due to
     * removal or resizing) they are converted back to plain bins.  In
     * usages with well-distributed user hashCodes, tree bins are
     * rarely used.  Ideally, under random hashCodes, the frequency of
     * nodes in bins follows a Poisson distribution
     * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
     * parameter of about 0.5 on average for the default resizing
     * threshold of 0.75, although with a large variance because of
     * resizing granularity. Ignoring variance, the expected
     * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
     * factorial(k)). The first values are:
     *
     * 0:    0.60653066
     * 1:    0.30326533
     * 2:    0.07581633
     * 3:    0.01263606
     * 4:    0.00157952
     * 5:    0.00015795
     * 6:    0.00001316
     * 7:    0.00000094
     * 8:    0.00000006
     * more: less than 1 in ten million

  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值