Map集合之HashMap(一)

从中可以看出哈希表的更底层还是数组,和ArrayList一样。但是哈希表之所以不直接叫成数组,是因为它在插入元素选定位置的策略上和普通数组不一样。

普通数组可以随机插入到数组的任一索引位置。

ArrayList的底层数组都是从0索引位置开始顺序插入。

ArrayList的插入算法可以称为顺序插入。是高效低能的插入。这种插入是导致ArrayList根据元素查索引很慢的根本原因。

而HashMap的底层哈希表,在插入元素前,需要根据元素的关键内容 通过 “哈希函数” 计算出 插入位置,即对应数组索引。 这样就保证插入的每个元素都可以和插入位置 通过哈希函数算法 一一对应起来。

这样HashMap就可以说不是通过元素"查"索引,而是通过元素“算”索引。自然比ArrayList和LinkedList要快。

HashMap有何劣势?


HashMap的所有劣势可以说归于一个原因:哈希冲突(哈希碰撞)

HashMap的底层哈希表,带来了快速查询能力的同时,带来了另一个大问题,如果两个插入元素通过 哈希函数 算出的 插入位置相同,怎么办?

我们将HashMap的插入元素 通过 哈希函数 算出的 插入位置 相同的现象 称为 哈希冲突,或者叫哈希碰撞。

此时为了保证保留高效查询,就必须将哈希冲突的元素的继续存储在同一个插入位置。此时引入了链式存储,即以单向链表的数据结构将冲突的元素挂载在对应数组索引位置元素的下面。

这不仅导致了元素设计的复杂性(数组元素内容不仅要有key-value,还要保存下一个单向链表节点的引用),还影响了查询效率(比如,我们就要查询单向链表上的元素,那么要先定位到数组索引,然后通过单向链表顺序遍历查询)。

当单向链表的节点过多时,哈希表的查询优势将不复存在,完全被单向链表的查询所影响。

那么为了保持HashMap的查询优势,大佬们提出了将 单向链表 转为 红黑树。即将哈希冲突元素从单向链表转成红黑树挂载到对应数组索引元素下面。关于红黑树的查询效率高的解释如下

我们知道二叉树的查询效率总体是优于链表的。通过二叉树的左子树元素小,右子树元素大的特点,可以快速查询到想要的元素。

而为了防止出现链表形式的二叉树,即全左倾或右倾二叉树,就提出了AVL树,即高度自平衡树,AVL树的特点是左子树和右子树的高度差不能超过1。

高度平衡的二叉树效率最高。但是维持高度自平衡是有代价的,在插入删除元素操作中,AVL树需要经过不断地左旋右旋来保持自平衡。

而为了避免多次调整,就提出了红黑树,红黑树也是自平衡二叉树,但是相较于AVL树,它地自平衡要求没有那么高。

红黑树的特点是:

红黑树的节点有颜色,且只能是黑色和红色。

红黑树的根节点和叶节点被标为黑色,插入的节点被标为红色。

红黑树的红色节点的子节点只能是黑色节点,黑色节点的子节点即可以是黑色也可以是红色。

红黑树的任一一个节点到达叶节点途中所经过的黑色节点个数相同。

红黑树的引入,解决了单向链表查询效率低的问题,保持了HashMap底层哈希表的查询效率。

但是更可怕的问题来了,插入删除冲突元素,导致的红黑树的节点变色,左旋右旋这些复杂操作所耽误的时间是否划算?

何时确定单向链表转为红黑树,红黑树转为单向链表,挂载的元素过多,是进行链式树式存储,还是哈希表的数组扩容?

正所谓引入一个优点,带来一堆问题。但是这些问题都是可以解决的,就算解决不了也可以找到一个最优点。

HashMap的存储流程简析

==============

在深入研究HashMap的底层源码前,我们需要对HashMap的存储流程有个大致了解,否则在看下面源码时,没有一个全局观,就会导致理解不全面。

当我们创建了一个HashMap对象时,由于它的底层是数组,所以我们要指定数组的长度后才能完成初始化。

当我们向HashMap集合中插入kv时,我们需要先计算k的hashcode值,然后通过哈希函数计算得到插入位置。

但是此时不能直接插入,需要判断该插入位置上有没有对象,

如果没对象,说明是该位置是首次被插入,则可以直接插入桶中(即数组索引位置处)。

如果有对象,就需要对比已存在对象的old_k是否和当前插入的对象的k是否相同,

若相同,则可以直接将v覆盖old_v

若不同,则说明哈希冲突,需要继续判断已存在对象的下面是否挂载了东西

如果没有挂载东西,则将当前插入对象以单向链表节点的形式,挂载到桶元素(即数组对应索引位置的元素)下面

如果挂载了东西,则判断挂载的东西是红黑树节点还是单向链表节点

若是红黑树节点,则直接挂载(挂载即插入红黑树节点过程,插入会自动寻找位置,若发现相同k的节点,则自动覆盖v)

若是单向链表节点,则需要遍历单向链表

如果遍历过程中发现相同k的节点,则直接覆盖其v

如果遍历过程中发现没有相同k的节点,则挂载到单向链表的最后一个节点上

计算单向链表的长度是否大于8

若单向链表长度大于8,则检查数组长度是否也大于64,若是,则将单向链表进行红黑树化

若单向链表节点大于8,且数组长度小于64,则数组扩容2倍

插入操作完成后,检查容器元素个数,若大于某个限度则数组扩容2倍

HashMap成员变量

===========

private static final long serialVersionUID = 362498820763181265L;


序列化标识,序列化时需要用到,和HashMap底层存储逻辑无关。

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;


默认初始化容量,即HashMap底层数组的默认初始化长度,默认值是16。

这个成员变量是用在创建HashMap对象时,未指定初始化容量,如HashMap无参构造器创建对象时,未指定初始化容量,就默认使用16.

需要注意的是,该成员的变量的注释The default initial capacity - MUST be a power of two。中特别说明该默认初始化容量必须是2的幂。

思考题:

1.为什么一定需要是2的幂

2.非默认的由外部指定的初始化容量,是否可以不是2的幂,若指定不是2的幂,那么有何影响?

static final int MAXIMUM_CAPACITY = 1 << 30;


最大容量,,即HashMap底层数组的最大长度,默认值为 1 << 30。

当我们创建对象指定的初始化容量大于该值,或者HashMap对象扩容时计算的容量大于该值,则使用该值作为最终容量值。

思考题:

该变量是int类型,我们知道int类型的最大值是2^31-1,即1<<31 -1,那么这里为什么只定义到了1<<30?

static final float DEFAULT_LOAD_FACTOR = 0.75f;


默认负载因子, 默认值为0.75。

该变量定义HashMap底层数组的扩容时机,即当HashMap底层数组元素个数达到 (当前数组长度默认负载因子)个时,就进行数组扩容。

该变量是定义的默认值,有的构造器可以由外部指定特定的负载因子。

static final int TREEIFY_THRESHOLD = 8;


树化阈值,默认为8。

我们需要知道当发生哈希冲突时,可能会在一个桶位置以链表形式挂载多个节点,当链表节点个数大于8时,链表的查询效率会下降。

为了不影响查询效率,以空间换时间,就要对这个超长链表进行树化,即将链表转为红黑树存储。

红黑树的优点是查询快,因为它是自平衡二叉树,查询的时间复杂度为O(logN),而单向链表的查询效率是O(N)

红黑树的缺点是占用内存多,插入删除动作复杂。一个红黑树节点需要保存父节点引用,两个子节点引用,颜色属性,且插入删除时会涉及节点变色,左旋,右旋来保持自身树结构平衡

思考题:

1.当前一个数组很短时,如数组长度16,那么一个桶位置下面挂了超过8个的链表节点,那进行树化就一定能提高效率吗?

2.或者换一种说法:我们知道当容器元素达到一定个数时,数组会进行扩容,那么树化和扩容是不是会产生冲突呢?

3.TREEIFY_THRESHOLD该变量能作为判断树化的唯一标准吗?

static final int UNTREEIFY_THRESHOLD = 6;


解树化阈值,默认为6.

HashMap不仅可以插入键值对,还可以删除键值对。当删除的键值对刚好是红黑树的节点时,且将红黑树的节点删除到某个很小值时

此时红黑树还就显得不是很划算了。因为HashMap引进红黑树是为了解决单向链表的节点多时,查询效率低的问题。那么当节点少的时候

,单向链表也能有很高的查询效率,红黑树反而因为占用内存多,操作复杂而不具备优势。所以解树化就势在必行。

该变量定义的就是解树化发生的时机,即红黑树元素个数小于6时,将红黑树转为单向链表。

思考题:

之前树化可能和扩容冲突,那么解树化会和缩容冲突吗?HashMap有缩容操作吗?

static final int MIN_TREEIFY_CAPACITY = 64;


树化最小容量,默认64.

该变量和TREEIFY_THRESHOLD树化阈值,相辅相成,解答了树化和扩容的冲突问题。

即单向链表树化的条件有两个:1.单向链表节点个数大于TREEIFY_THRESHOLD 2.底层数组长度大于MIN_TREEIFY_CAPACITY

如果单向链表的节点个数大于树化阈值了,但是底层数组的长度没有大于64,则进行数组扩容操作

如果单向链表的节点个数大于树化阈值了,且底层数组的长度大于64,则进行树化操作。

transient Node<K,V>[] table;


该变量就是HashMap底层数组。

transient Set<Map.Entry<K,V>> entrySet;


该变量用于缓存HashMap的Entry对象,即键值对对象

transient int size;


该变量用于记录HashMap对象的元素个数

transient int modCount;


该变量用于记录HashMap对象被结构化修改的次数。用于给HashMap的迭代器对象检测并发修改异常。

int threshold;


The next size value at which to resize (capacity * load factor).

该值的含义时扩容阈值,即当集合元素个数大于该值时,集合底层数组进行扩容。

threshold = capacity * load factor 即为 当前底层数组容量 * 负载因子

当HashMap的底层数组还没有被分配内存时,即为null时,该值表示数组首次初始化时的长度,

如果当该值为0表示首次初始化的数组长度为16。

final float loadFactor;


该值是HashMap对象的负载因子,默认是DEFAULT_LOAD_FACTOR,即0.75。也可以有外部指定。如public HashMap(int initialCapacity, float loadFactor)。

该值的标志着 HashMap底层数组的使用率。当loadFactor越接近1,则数组的使用率越高,越接近0,则使用率越低。

思考题:

为什么loadFactor的默认值是0.75?而不是1,为1时,说明要等到数组长度全部用完了才扩容,这样不是更加节省容量吗?

HashMap内部类

==========

Node


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;

}

HashMap插入元素发生哈希冲突时,会以单向链表的形式将插入的元素挂载到当前桶元素的下面。

所以桶元素需要具备挂载能力,即指向下一个链表节点的能力。而链表节点虽然被挂载到了桶元素下面,但是它本身还是需要具备桶元素该有的特性(如key,value,hash)

而就算桶元素挂载的是树节点,桶元素本身只需要保留下一个节点的引用,保持连接即可。和单向链表保持连接实现一致。

所以综合考虑:可以将桶元素类型设计成单向链表节点类型。

即Node既是桶元素类型,也是单向链表节点类型。

该类中key,value是基础数据,保存实际元素信息。hash用于标记元素的唯一性,因为不同对象的hash值都不一样,只有两个相同对象的hash才一样。next用于保存桶元素下挂载的哈希冲突节点。

TreeNode


static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {

TreeNode<K,V> parent; // red-black tree links

TreeNode<K,V> left;

TreeNode<K,V> right;

TreeNode<K,V> prev; // needed to unlink next upon deletion

boolean red;

TreeNode(int hash, K key, V val, Node<K,V> next) {

super(hash, key, val, next);

}

从继承关系上看TreeNode,它继承了LinkedHashMap.Entry,而LinkedHashMap.Entry又继承了HashMap.Node,所以TreeNode可以当成Node来使用。

自平衡二叉红黑树的节点又如下特性:

每个节点都只能有一个父节点,且可以有两个子节点,分别是左子节点,右子节点。

每个节点都有颜色,且只能是红色或者黑色。

这里TreeNode设计:parent是父接点,left,right分别是左右子节点,red用于标记颜色,true是红色,false是黑色。

prev是指删除时需要断开连接的前一个节点

HashMap构造器

==========

无参构造器


/**

  • Constructs an empty HashMap with the default initial capacity

  • (16) and the default load factor (0.75).

*/

public HashMap() {

this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted

}

无参构造器创建对象时,对象的成员变量

table = null

entrySet = null

size = 0

modCount = 0

threshold = 0

loadFactor = 0.75f

注意此时HashMap对象的底层数组table还没有分配内存。

扩容阈值threshold为0,表示底层数组table首次初始化的长度为16。

指定初始容量构造器


/**

  • Constructs an empty HashMap with the specified initial

  • capacity and the default load factor (0.75).

  • @param initialCapacity the initial capacity.

  • @throws IllegalArgumentException if the initial capacity is negative.

*/

public HashMap(int initialCapacity) {

this(initialCapacity, DEFAULT_LOAD_FACTOR);

}

指定初始容量的构造器,内部实现是调用的带两个参数的重载构造器,其中初始容量为外部指定,负载因子为默认值即0.75f 。

table = null

entrySet = null

size = 0

modCount = 0

threshold = tableSizeFor(initialCapacity)

loadFactor = 0.75f

注意此时HashMap的底层数组table还没有分配内存。

扩容阈值threshold不是0,则底层数组table的首次初始化长度为threshold值。

指定初始容量和负载因子构造器


/**

  • Constructs an empty HashMap with the specified initial

  • capacity and load factor.

  • @param initialCapacity the initial capacity

  • @param loadFactor the load factor

  • @throws IllegalArgumentException if the initial capacity is negative

  •     or the load factor is nonpositive
    

*/

public HashMap(int initialCapacity, float loadFactor) {

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;

this.threshold = tableSizeFor(initialCapacity);

}

/**

  • Returns a power of two size for the given target capacity.

  • 该方法是基于cap获取一个2的幂作为最终的数组容量。其中cap是外部指定的初始容量。

  • 该方法说明了,HashMap不会直接将外部指定的初始容量作为底层数组容量,而是需要转化为一个2的

  • 幂的数。这个2的幂的数要大于等于cap,才能满足外部要求。

*/

static final int tableSizeFor(int cap) {

int n = cap - 1;// 减一操作是为了保证cap本身就为2的幂时,也可以适配该方法的转换规则

// 下面是无符号右移移位后按位或操作,即将cap第一个位值为1的位的后面位都变成1

n |= n >>> 1;// 该步操作后 前2位为1

n |= n >>> 2;// 该步操作后 前4位为1

n |= n >>> 4;// 该步操作后 前8位为1

n |= n >>> 8;// 该步操作后 前16位为1

n |= n >>> 16;// 该步操作后 前32位为1

// 之所以最多移位16,是因为移位16后,该数的前32位已经可能都为1了。而cap本身是int类型,就是32为

return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;

// 这里n<0是为了保证外部传入cap=1时,也能返回正确的值,由于1也是2的幂,所以直接返回该值。由于上面移位操作后,该数的已经变成了有值位都是1的数了,所以加1就可以得到大于等于它的离他最近的2的幂了。

}

该构造器由外部指定了初始容量和负载因子,则

创建对象后,对象的状态为

table = null

entrySet = null

size = 0

modCount = 0

threshold = tableSizeFor(initialCapacity)

loadFactor = loadFactor

指定了初始容量和负载因子的构造器,对外部指定的初始容量和负载因子做了校验。

其中初始容量范围是[0,MAXIUM_CAPACITY],小于0则报错非法参数,大于MAX则初始容量就为MAX。

负载因子必须大于0,且必须是一个存在的数字。

另外该构造器还指定了扩容阈值,计算方法是tableSizeFor(initailCapacity)

这个方法的作用是将外部指定的初始容量 转化为 大于它的 最小的 2的幂,或者外部指定的初始容量本身就是2的幂,则经过该方法转化后无变化。

举个例子:

外部指定初始容量为 10 ,则 方法转化后 为 16

外部指定初始容量为 12,则 方法转化后 为 16

外部指定初始容量为 2,则 方法转化后 为 2

我们来研究下该方法是如何将一个普通数转化为离他最近的2的幂?

tableSizeFor方法解析

我们假设外部指定初始容量为10,

static final int tableSizeFor(int cap) {// cap = 10
    int n = cap - 1;// n = 9
    n |= n >>> 1; // n = n | n>>>1
    /**
     * 1001 n
     * 0100 n>>>1
     * ----------- 按位或,对应位上有一个为1,则结果为1
     * 1101        则n的值为1101
     */
    n |= n >>> 2;// n = n | n>>>2
    /**
     * 1101 n
     * 0011 n>>>2
     * ----------- 按位或,对应位上有一个为1,则结果为1
     * 1111         则n的值为1111
     */
    n |= n >>> 4;// n = n | n>>>4
    /**
     * 1111 n
     * 0000 n>>>4
     * ----------- 按位或,对应位上有一个为1,则结果为1
     * 1111 n      则n的值为1111
     */
    n |= n >>> 8;// n右移八位后,还是0000,所以最终结果还是n=1111=2^4-1=15
    n |= n >>> 16;// n右移16位后,还是0000,所以最终结果还是n=1111=2^4-1=15
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;//得到结果16
}

首先从tableSizeFor方法目的看:该方法就是为了将一个数转化为 大于等于它的,离他最近的,2的幂的数

即:1010 最终转化为 10000

我们知道2的幂有:20,21,22,23,…这些数的二进制表现是:0000,0001,0010,0100,…,即二进制形式中只能有一个位的值为1,该数才是2的幂

那么比1010大的2的幂,则就是只能是10000

其他诸如1100,1011,1110,1001,1111经过tableSizeFor后都会转化为10000。

另外如果是1000,0010,0001,0100只有一个位是1的,那么本身就是2的幂,就不需要转化。

以上都是人脑找规律的逻辑,但是计算机是无法模拟人脑这样的想法的。

如果将二进制转化位字符串,判断字符串中有几个1,如果是1个就直接返回该数,如果是多个,则输出当前二进制位数加一位长度的二进制,且新二进制的最高位是1,其他位是0。

这样虽然也可以实现,且符合人脑逻辑,但是在计算机底层运算就显得不是很高效。

我们知道计算机底层都是二进制位运算,使用位运算才是最高效的。那么有没有一种基于位运算的逻辑来实现上面的规律呢?

我们可以继续基于二进制位找规律:

1010 最终转化位 10000 的方式可以是:

将1010的为0的位都变成1,即1010先变成1111,然后+1,就得到了10000

那么对于本身就是2的幂的数是否适用呢?即1000先变成1111,然后+1,就得到了10000,显然不适用。

那么如果让本身是2的幂的数也能并入该逻辑呢?那么就将他变得不是2的幂不就行了?

最简单的方式就是加1或减1,如果是加1的话:1000 -> 1001 -> 1111 -> 1111+1 = 10000,没有变化。。

如果是减1的话:1000 -> 减一操作后为111 -> 将其他位变为1操作后为 111 - >加一操作后为 1000,这样是符合要求的。那么这套逻辑是否符合非2的幂的数呢?

如 1010 -> 1001 -> 1111 -> 1111+1 = 10000 也是符合的。

所以最终该方法的代码逻辑应该是:

intitalCapacity 先 减一

再 将第一个是1的位的后面的位都变成1

再 将结果+1

就得到了最终的大于等于该数,且离他最近的2的幂了。

那么如何将第一个是1的位的后面的位的值都变成1呢?最简但的操作就是将第一个位1往后移位覆盖,

移位操作可以选择无符号右移,这样不会引入新的1

覆盖操作:即保留移位后的1,那么选择按位或运算,即位上有一个1就可以让位值为1

最终该操作为:

n = n | n>>>1 //这一步是将第一个位1后面的位覆盖为1,即该数两位已经变为1了

n = n | n>>>2 //这一步是将前两个位1后面的两位覆盖为1,即该数前四位已经变成1了

n = n | n>>>4 // 即该数前八位已经变成1了

n = n | n>>>8 // 即该数前16位已经变成1了

n = n | n>>>16 // 即该数前32位已经变成1了

外部指定的初始容量是int类型,所以一共是4字节,32位。所以移位覆盖操作到此为止。

这也解答了之前一个思考题:

不管外部指定的初始容量是否是2的幂,都会经过tableSizeFor方法转换为大于等于该数的,离他最近的2的幂。

所以外部可以指定非2的幂的数作为初始容量,但是内部不会将该数作为底层数组容量,而是经过tableSizeFor方法转换为大于该数,且离他最近的2的幂的数作为底层数组容量。

如果外部指定2的幂作为初始容量,经过tableSizeFor方法后,该数没有变化,可以当成直接作为底层数组容量。

参数是Map对象的构造器


/**

  • Constructs a new HashMap with the same mappings as the

  • specified Map. The HashMap is created with

  • default load factor (0.75) and an initial capacity sufficient to

  • hold the mappings in the specified Map.

  • @param m the map whose mappings are to be placed in this map

  • @throws NullPointerException if the specified map is null

*/

public HashMap(Map<? extends K, ? extends V> m) {

this.loadFactor = DEFAULT_LOAD_FACTOR;

putMapEntries(m, false);

}

该构造器带了一个Map对象,该构造器依赖于putVal方法,所以等putVal解析完再说

HashMap重要方法

===========

public V put(K key, V value)


/**

  • Associates the specified value with the specified key in this map.

  • If the map previously contained a mapping for the key, the old

  • value is replaced.

  • @param key key with which the specified value is to be associated

  • @param value value to be associated with the specified key

  • @return the previous value associated with key, or

  •     <tt>null</tt> if there was no mapping for <tt>key</tt>.
    
  •     (A <tt>null</tt> return can also indicate that the map
    
  •     previously associated <tt>null</tt> with <tt>key</tt>.)
    

*/

public V put(K key, V value) {

return putVal(hash(key), key, value, false, true);

}

/**

  • Computes key.hashCode() and spreads (XORs) higher bits of hash

  • to lower. Because the table uses power-of-two masking, sets of

  • hashes that vary only in bits above the current mask will

  • always collide. (Among known examples are sets of Float keys

  • holding consecutive whole numbers in small tables.) So we

  • apply a transform that spreads the impact of higher bits

  • downward. There is a tradeoff between speed, utility, and

  • quality of bit-spreading. Because many common sets of hashes

  • are already reasonably distributed (so don’t benefit from

  • spreading), and because we use trees to handle large sets of

  • collisions in bins, we just XOR some shifted bits in the

  • cheapest possible way to reduce systematic lossage, as well as

  • to incorporate impact of the highest bits that would otherwise

  • never be used in index calculations because of table bounds.

*/

static final int hash(Object key) {

int h;

return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

}

HashMap的put方法内部调用的putVal方法来实现插入键值对逻辑。

而putVal方法需要五个参数。

第一个参数是 要插入的键值对的key的hash值。这里计算key的hash值,调用了hash(Object key),它不是将 h = key.hashCode() 作为key的hash值,而将 h ^ (h>>>16) 作为key的hash值。

思考题:

h ^ (h>>>16)这样得到的key的hash值有什么作用?

第二个和第三个是 要插入的键值对的key和value

第四个参数,如果为true,则不修改已存在的值

第五个参数,如果为false,则说明HashMap的底层数组是创建模式。

putVal方法

/**

  • Implements Map.put and related methods.

  • @param hash hash for key

  • @param key the key

  • @param value the value to put

  • @param onlyIfAbsent if true, don’t change existing value

  • @param evict if false, the table is in creation mode.

  • @return previous value, or null if none

*/

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,

boolean evict) {

Node<K,V>[] tab; Node<K,V> p; int n, i;

/**

  • 下面是关于插入前扩容操作的逻辑

*/

if ((tab = table) == null || (n = tab.length) == 0)//当底层数组table为null或者长度为0时,此时不支持插入,因为容量不足,所以需要扩容。

n = (tab = resize()).length;//扩容调用了resize()方法,具体如何扩容请看后面关于resize()的解释。另外将扩容后的数组长度赋值给n

/**

  • 下面是关于插入的逻辑

  • (n - 1) & hash 就是HashMap底层哈希表的哈希函数实现。

  • 通过哈希函数可以得到该键值对在数组中的插入位置的索引,具体请见后面关于哈希函数的解析。

*/

if ((p = tab[i = (n - 1) & hash]) == null) //判断对应数组索引是否被插入过键值对,如果没有,则直接插入

tab[i] = newNode(hash, key, value, null);

else {//判断对应数组索引是否被插入过元素,如果有,则继续判断

Node<K,V> e; K k;

if (p.hash == hash && // p是对应数组索引位置已存在的桶元素

((k = p.key) == key || (key != null && key.equals(k))))//判断已存在的桶元素p和要插入的元素的key是否相同,如果相同的话,则p就是被插入的元素,标记为e

e = p;

else if (p instanceof TreeNode) // 如果已存在的桶元素p和要插入的元素的key不同,则判断p可能是红黑树节点,则调用putTreeVal直接插入元素到树中,则插入后树的变动节点就是被插入的元素,标记为e

e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

else {// 如果已存在的桶元素p和要插入的元素的key不同,则判断p可能是链表节点

for (int binCount = 0; ; ++binCount) {// 遍历单向链表节点

if ((e = p.next) == null) {// 如果桶元素p的下一个节点e为null

p.next = newNode(hash, key, value, null);//则直接将插入元素封装为链表节点挂载到桶元素p下面

if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st //判断桶元素下面挂载了的链表节点数是否大于等于8(不算桶元素),若是,则将单向链表转为红黑树

// 注意是第八个单向链表节点插入后,再计算链表节点个数是否大于8,然后将单向链表转为红黑树

treeifyBin(tab, hash);

break;//要么链表节点插入完成,要么单向链表插入后转为红黑树完成,则终止循环,此时e为null

}

if (e.hash == hash &&//如果e节点不为null,则比较要插入的元素的key和e节点的key是否相同

((k = e.key) == key || (key != null && key.equals(k))))

break;//若相同,则跳出循环,理论上此步还需要覆盖对应的value,但是由于putVal方法有个onlyIfAbsent参数控制是否可修改已存在的值,所以放到了后面判断做修改,此时e不为null

p = e;//若不同,则将p移动指向它的下一个节点e,继续下一次循环

}

}

if (e != null) { // existing mapping for key// 若e不为null,则说明要插入的元素的key和单向链表中e节点的key相同,要判断下是否需要覆盖e节点的value

V oldValue = e.value;

if (!onlyIfAbsent || oldValue == null)// 如果onlyIfAbsent为false,则说明可以修改已存在的值。如果onlyIfAbsent为true,但是e节点的value=null,也可以修改value值

e.value = value;

afterNodeAccess(e);//好像是给LinkedHashMap用的,在HashMap中,该方法是空的

return oldValue;// 返回被覆盖的旧value值 //注意此时直接就返回了oldValue,结束方法。这在HashMap看来不算结构化修改,即不需要增加modCount值,也不需要考虑扩容,因为size没有增加

}

}

++modCount;//插入元素导致HashMap的结构化修改次数+1

if (++size > threshold)//由于插入了元素,所以size+1,比较size+1和扩容阈值的大小,如果size+1已经超过了扩容阈值,就扩容

resize();

afterNodeInsertion(evict);//好像是给LinkedHashMap用的,在HashMap中,该方法是空的

return null;// 如果不是覆盖已存在的键值对的value值,则返回null

}

put方法流程图

走读完代码,看完流程图,请思考如下问题:

put过程中,方法内部可能有哪几个地方调用resize()?

有三个地方:

1.putVal一开始,就会检查底层哈希表是否已经初始化,如果没有初始化则调用resize()进行初始化

2.putVal快要结束时会判断size>threshold?如果true,则调用resize()进行扩容

3.在桶位置挂载的单向链表插入新节点时,插入完,会检测单向链表节点数(不包括桶位置节点)是否大于8,如果大于,再检查底层哈希表table的长度是否大于等于64,如果true,则调用resize()方法扩容

put可以插入key==null的键值对吗,如果可以,那么该键值存储在底层哈希表哪个桶位置?

put可以插入key==null的键值对。由于put插入元素是根据哈希函数计算所有,哈希函数hash & (n-1)依赖于hash值,而hash值的计算由是put方法中的hash()方法完成的。

hash()方法实现

return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

所以当key==null时,对应hash值为0,而0和任何数按位与操作结果都为0。

所以key==null的键值对,被存在底层哈希表的索引0处。

put操作一定会导致HashMap集合发生结构化修改吗?若不是,则请列出所有不会发生结构化修改场景?

我们知道集合结构化修改就是指集合的modCount成员变量发生变化。

而通过流程图我们可以知道,不是所有的put操作都会导致modCount++

对于put最终结果新增了节点的,会导致modCount++

而对于put只是覆盖节点的value值,而不增加集合元素个数的操作,不会导致modCount++

例如:

覆盖桶元素的value,覆盖单向链表节点的value,覆盖红黑树节点的value

关于putVal方法,如果发现插入的键值对的key值已存在,那么插入的键值对的value值一定会覆盖旧的value值吗?另外当putVal方法的形参onlyIfAbsent 为true时,HashMap集合的已存在的value值一定不能被改动吗?

putVal方法有一个方法形参onlyIfAbsent

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)

这个形参传入false时,表示当插入键值对的key已经在集合中存在时,允许用插入键值对的value覆盖对应已存在的value。反之,则不允许。

但是,并不是onlyIfAbsenttrue,就完全不允许覆盖value。当已存在的valuenull时,是可以用插入键值对的value覆盖null的。

put操作中,单向链表转成红黑树的时机是什么?

当单向链表的节点数大于等于8时,且此时底层哈希表长度大于等于64时,单向链表会转成红黑树。

其中单向链表节点数不包括桶位置节点。

哈希函数 (n - 1) & hash


我们理解HashMap的底层为哈希表,而哈希表的关键在于哈希函数。

哈希函数: 通过插入元素的关键码值 结合 数组长度 得到 该插入元素在数组中的插入位置索引。

哈希函数需要保证计算出插入位置一定要在哈希表中分布均匀,减少哈希冲突。

所以哈希函数有两个任务 1.计算出插入位置索引  2.减少哈希冲突

如何设计计算插入位置索引

哈希函数的散列实现主要是  将一个数 根据某种逻辑 对应到 某个范围中的另一个数。

常见的哈希函数的设计有:https://www.cnblogs.com/gj-Acit/archive/2013/05/06/3062628.html

Java这里采用的是除数留余法。即hash % n。这样也能够将hash按照求余运算来对应到n中的某个值。

但是Java中的哈希函数是:(n - 1) & hash。那么这个函数真的等价于hash % n?

(注意这里的数组长度已经得到了保证,肯定是2的幂,具体原因请见tableSizeFor方法)

这里发现(n-1)& hash 并不是完全等价于 hash % n。由于hashCode()可能取到负数,所以hash % n得到的索引值肯能为负数,但是(n-1)& hash只会得到正数,因为n-1是正数。

所以想要获取正余数,(n-1)& hash 更加合适。

如何理解 (n-1) & hash 就是求余操作呢?

我们知道二进制中左移一位就相当于十进制的乘以2,右移一位就相当于除以2。而发生除法运算的过程是会产生余数的。

比如  13 ÷ 8 = 1…5

那么从二进制来看,13的二进制就是 0000 1101,现在对13除以8,则二进制数右移3位。

0000 1101              13

0000 0001 101       13 ÷ 8

则可以发现,

移出去的红色标记位对应的十进制值位5,刚好就是除法运算后的余数,

而保留的绿色标记位的值为1,刚好是除法运算后的商。

所以现在只要想一个办法将移除的101保留下来,就能获得13 ÷ 8的余数。

我们可以发现移除的101,其实就是13二进制的最后三位 0000 1101

所以只要保留这三位数即可。而位运算中:按位与,按位或,按位异或这三种操作能够最简单做到保留操作的就是按位与1:

0000 1101

0000 0111 &

------------------

0000 0101

那么0000 0111如何得到呢?他不就是8-1嘛,即除数-1。

而上面的被除数13可以看成hash,除数8可以看成n(即数组长度),那么 二进制的余数计算公式就是:hash & (n-1)

这里也解释了为什么HashMap一定要把数组长度强制设置为2的幂,比如DEFAULT_INITIAL_CAPACITY为16,tableSizeFor(int initialCapacity)将外部指定的初始容量强制改为2的幂。

其实都是为了哈希函数中求余运算更加方便。

哈希函数中(n-1)&hash中的hash值并不是key.hashCode的值,而是h ^ (h>>>16)的值,这样做的用处是啥?

我们先假设没有这步操作,会出现什么问题?即 哈希函数为 (n-1) & key.hashCode()

假设有这样几个key,它们的hashCode()值分别为

A:0000 1111 1111 1110 0000 0000 0000 0101

B:0000 1111 1111 1100 0000 0000 0000 0101

C:0000 1111 1111 1000 0000 0000 0000 0101

D:0000 1111 1111 0000 0000 0000 0000 0101

可以看出这几个数的最后四位都是相同的,它们的高位值不同。

那么通过哈希函数计算这几个数在长度为16的数组中的位置索引:

0000 1111 1111 1110 0000 0000 0000 0101         A

0000 0000 0000 0000 0000 0000 0000 1111         16-1

-----------------------------------------------------------------  &

0000 0000 0000 0000 0000 0000 0000 0101

0000 1111 1111 1100 0000 0000 0000 0101          B

0000 0000 0000 0000 0000 0000 0000 1111         16-1

-----------------------------------------------------------------  &

0000 0000 0000 0000 0000 0000 0000 0101

0000 1111 1111 1000 0000 0000 0000 0101         C

0000 0000 0000 0000 0000 0000 0000 1111         16-1

-----------------------------------------------------------------  &

0000 0000 0000 0000 0000 0000 0000 0101

0000 1111 1111 0000 0000 0000 0000 0101         D

0000 0000 0000 0000 0000 0000 0000 1111         16-1

-----------------------------------------------------------------  &

0000 0000 0000 0000 0000 0000 0000 0101

可以发现A,B,C,D四个数在数组中的根据哈希函数计算得到位置索引都相同。

总结:当数组长度很小时(一般而言HashMap的数组长度二进制在高位有值1的可能性不大),

插入元素的key的hashCode()值的二进制在高位(后16位)出现值1时(这个可能性很大),

对哈希函数的运算几乎没有影响(如何A,B,C,D),对哈希函数运算结果有影响的位基本在低位(前16位)。

这将极大地加重发生哈希冲突的概率。

所以为了增加key地hash值二进制中高位数值地影响力,减少哈希冲突地发生,Java就将高位1移动到低位,并保留低位1(不能直接覆盖低位,要兼顾低位1,否则和上面问题中只有低位1对哈希函数有影响差不多)

即出现了 h ^ (h>>>16) 。

我们测试下

0000 1111 1111 1110 0000 0000 0000 0101         A

0000 0000 0000 0000 0000 1111 1111 1110          A>>>16

-----------------------------------------------------------         ^ 按位异或,即对应位不同才是1,相同则为0

0000 1111 1111 1110 0000 1111 1111 1010

0000 0000 0000 0000 0000 0000 0000 1111         16-1

-----------------------------------------------------------------  &

0000 0000 0000 0000 0000 0000 0000 1010

0000 1111 1111 1100 0000 0000 0000 0101          B

0000 0000 0000 0000 0000 1111 1111 1100          B >>> 16

-----------------------------------------------------------         ^ 按位异或,即对应位不同才是1,相同则为0

0000 1111 1111 1100 0000  1111 1111 1001

0000 0000 0000 0000 0000 0000 0000 1111         16-1

-----------------------------------------------------------------  &

0000 0000 0000 0000 0000 0000 0000 1001

0000 1111 1111 1000 0000 0000 0000 0101         C

0000 0000 0000 0000 0000 1111 1111 1000        C

-----------------------------------------------------------         ^ 按位异或,即对应位不同才是1,相同则为0

0000 1111 1111 1000  0000 1111 1111 1101

0000 0000 0000 0000 0000 0000 0000 1111         16-1

-----------------------------------------------------------------  &

0000 0000 0000 0000 0000 0000 0000 1101

可以发现,橙色部分标记地值已经受到高位1地影响,产生了变化,这样发生哈希碰撞地概率会有所下降。

扩容方法 resize()


/**

  • Initializes or doubles table size. If null, allocates in

  • accord with initial capacity target held in field threshold.

  • Otherwise, because we are using power-of-two expansion, the

  • elements from each bin must either stay at same index, or move

  • with a power of two offset in the new table.

  • @return the table

*/

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) { //oldCap>0 表示底层数组table不是null,即非首次扩容

if (oldCap >= MAXIMUM_CAPACITY) { //如果为非首次扩容,且当前数组长度已经是最大容量了,则无法再次扩容,直接返回老数组。且扩容阈值改为最大Integer值,即只有当集合元素个数等于最大的Integer值时,才来再次判断扩容。

threshold = Integer.MAX_VALUE;

return oldTab;

}

else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && // 非首次扩容时,新数组长度为老数组长度的两倍

oldCap >= DEFAULT_INITIAL_CAPACITY)

newThr = oldThr << 1; // 非首次扩容时,且扩容后容量不超过最大容量,且老的容量>=16,则新数组扩容阈值为老数组的扩容阈值的两倍,否则newThr=0

}

else if (oldThr > 0) // 该判断条件完整是 (oldCap = 0 && oldThr > 0),即表示底层数组table是null,且外部指定了初始容量initialCapacity,即oldThr

newCap = oldThr; //即底层数组首次扩容后的长度为外部指定的初始化容量,且此时newThr=0

else { // zero initial threshold signifies using defaults //

//该判断条件完整是 (oldCap = 0 && oldThr = 0),即表示底层数组table是null,且外部没有指定初始容量initialCapacity,则首次初始化数组的长度选择默认初始化容量16.且此时扩容阈值为16*0.75=12,即此时newThr =12

newCap = DEFAULT_INITIAL_CAPACITY;

newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);

}

if (newThr == 0) {// 如果newThr=0,表示新数组的扩容阈值还没法指定

float ft = (float)newCap * loadFactor; // newCap * loadFactor 就是此时的扩容阈值

// 如果此时数组容量不大于最大容量,且扩容阈值不大于最大容容量,则新数组的扩容阈值就是 newCap*loadFactor,否则就是最大Integer值

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;//将table指向新容量的数组

if (oldTab != null) {//当老数组有数据时,才需要进行数据迁移

for (int j = 0; j < oldCap; ++j) {//遍历老数组

Node<K,V> e;

if ((e = oldTab[j]) != null) {//判断遍历的数组桶元素是否存在,如果不存在,则说明该桶位置没有插入过元素,就不需要管了,只管有值的桶位置

oldTab[j] = null;//上一步中已经将老数组的桶元素备份到e中,所以可以清理掉老数组的该桶位置的数据了。帮助垃圾回收。

if (e.next == null)//如果桶元素的下面没有挂载东西,则直接 通过哈希函数计算出该桶元素在新数组中的索引位置后,直接迁移

newTab[e.hash & (newCap - 1)] = e;

else if (e instanceof TreeNode) // 否则说明该桶元素下面挂载了东西,则有可能是红黑树节点

((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;

Node<K,V> next;

do {// 由于链表可能有很多节点,所以每个链表节点都通过哈希函数计算索引的话,就会非常浪费时间

next = e.next;

if ((e.hash & oldCap) == 0) {

if (loTail == null)

loHead = e;

else

loTail.next = e;

loTail = e;

}

else {

if (hiTail == null)

hiHead = e;

else

hiTail.next = e;

hiTail = e;

}

} while ((e = next) != null);

if (loTail != null) {

loTail.next = null;

newTab[j] = loHead;

}

if (hiTail != null) {

hiTail.next = null;

newTab[j + oldCap] = hiHead;

}

}

}

}

}

return newTab;

}

resize()方法流程图

HashMap的扩容就是基于resize()方法实现的。

从resize()方法可以知道,HashMap的扩容,本质是针对哈希表的扩容。

resize()方法流程

通过以上步骤分析,resize()方法实现扩容可以分为两部分:

第一部分:扩容后的容量和扩容阈值

其实就是确定新哈希表的两个重要参数:容量、扩容阈值

通过上面流程图可知,resize()扩容策略有如下

扩容操作

| -  老容量已是最大容量,不能再次扩容 【新容量=老容量,新扩容阈值 = Integer.MAX_VALUE】

| -  老容量不是最大容量,还可以再次扩容

| -  老容量扩容两倍为新容量

|-  扩容后容量不超过最大容量,且老容量不小于16【新容量=老容量*2,新扩容阈值 = 老扩容阈值*2】

|-   扩容后容量超过了最大容量,或老容量小于了16【新容量=老容量*2,新扩容阈值=新容量*负载因子】

初始化操作

| -   外部指定了初始容量,则扩容为外部指定初始容量 【新容量=外部指定初始容量,新扩容阈值=新容量*负载因子】

| -   外部未指定初始容量,则扩容为默认初始容量 【新容量=默认初始容量16,新扩容阈值=默认初始容量16*默认负载因子0.75=12】

分析一下上述步骤的几个判断条件

1.oldCap>0 为什么可以判断 哈希表是否为已经初始化?

int oldCap = (oldTab == null) ? 0 : oldTab.length;

上面代码逻辑是:当oldCap=0时,表示oldTab还没有初始化。其他情况,即可认为oldTab已经初始化了。

那么有没有可能已经初始化的oldTab的length为0呢?

这种情况只可能发生在创建HashMap实例时,外部指定初始容量为0。但实际上,HashMap确定哈希表初始化容量不会直接使用外部指定的值,而是对其进行tableSizeFor处理

从前面关于tableSizeFor方法的分析,当外部指定初始容量为0时,tableSizeFor会将实际初始容量定为1。

所以oldTab.length不可能为0。

所以oldCap==0时,就只能说明当前哈希表还没有被初始化。

2.oldThr>0 为什么可以判断 外部是否指定了初始化容量?

int oldThr = threshold;

上面代码逻辑说明:oldThr 取值于 当前HashMap实例的成员变量threshold扩容阈值。

我们知道哈希表初始化时的关键参数 诸如容量,扩容阈值,负载因子都是在构造器中确认的。

当new HashMap()时,threshold = 0;

当HashMap(int initialCapacity)或HashMap(int initialCapacity, float loadFactor)时,this.threshold = tableSizeFor(initialCapacity);

可以看出

当threshold=0时,外部没有指定初始容量。此时应该使用HashMap内部默认的初始化容量值16

当threshold !=0,即threshold>0时,threshold值就是外部指定的初始化容量对应的实际初始化容量的值,即逻辑上来说:threshold>0时,threshold就是外部指定的初始化容量值。

3.为什么oldCap>=MAXIUM_CAPACITY时,要将newThr设置为Integer.MAX_VALUE?

我们知道MAXIUM_CAPACITY是HashMap中内定的底层哈希表最大容量1<<30。

如果oldCap>=MAXIUM_CAPACITY了,那么说明老容量已经是最大容量,就不能再扩容了。

另外走进resize()方法,说明当前哈希表中的元素数量已经达到了扩容阈值的值。为了不让后面每次put元素都resize()操作一下,所以这里将新的扩容阈值定位最大Integer值。

那么后面put操作就不会触发resize()了。因为不存在一个int size值大于Integer.MAX_VALUE。

所以将newThr设置为Integer.MAX_VALUE的目的是为了 避免下次无意义的resize()调用

4.为什么newCap<MAXIUM_CAPACITY & oldCap>=16时,才允许newThr=oldThr<<1 ?否则就是newThr = newCap * loadFactor;

首先newCap如果已经是最大容量,或者超过最大容量了,那么newThr应该被设置为Integer.MAX_VALUE,原因情况上面3的分析

对于

newThr = newCap * loadFactor;

而此时newCap = oldCap * 2;

newThr = oldCap *2 * loadFactor = oldThr * 2 = oldThr << 1

即当扩容两倍时,本质上 newThr = newCap * loadFactor; 等价于 newThr=oldThr << 1

那么为什么要加以个oldCap>=16呢?

可能这里只是考虑性能原因吧,当oldCap较大时,位运算效率更高。

第二部分:重定索引和转存

将老哈希表中的元素基于新哈希表的容量重新计算索引,然后按照新索引转存到新哈希表中

重定索引后转存的 前提是:oldTab != null,即当前resize()操作是非初始化table操作。如果是初始化操作,则oldTab==null,oldTab中没有元素,即无需重定索引后转存到新表。

对于正常扩容操作:可以分为四种情况

|- 老表的桶位置无元素【无需操作】

|- 老表的桶位置有元素

|- 桶元素下面没有挂节点【newTab[e.hash & (newCap - 1)] = e; //e是老表桶元素】

|- 桶元素下面挂了节点

|- 挂的节点是红黑树节点

|- 挂的节点是单向链表节点

关于单向链表节点的重定索引和转存

如果挂的节点是单向链表节点:则需要遍历单向链表节点,并重新计算节点元素的索引。

我们知道一般扩容操作,最终都是扩容2倍,即假设老表容量16,新表容量就是32,

那么假设老表现在在索引5位置挂了这些链表节点,对应节点元素关键key的hash值如下

那么扩容2倍后,新表容量32,则重新按照哈希函数 hash & (length - 1) 计算新索引

则可以发现扩容两倍后,同一个链上的节点元素的新索引值分为两种:一种保持不变还是5,一种变成老索引值+老表长度 = 5+16 =21

可以发现扩容后重新计算索引,确实降低了哈希冲突。

但是在这里:

关于扩容两倍后,重新计算同一个链上的单向链表节点的新索引有个高效算法技巧:

即不再使用hash & (length - 1)原始哈希函数计算新索引,而是根据当前新索引呈现的规律:要么索引值不变,要么索引值加老表长度

进一步观察二进制运算可以发现,对应 32-1的最高非0位 的hash值的位值 为 0 的话,则新索引保持不变,如果位值为1的话,则新索引=老索引+老表长度。

而其中32-1的最高非0位,其实就是老表长度的最高非0位。

单向链表节点元素重定索引并转存代码分析

else { // preserve order

Node<K,V> loHead = null, loTail = null;//索引不变的新链,loHead是新链头,loTail是新链尾

Node<K,V> hiHead = null, hiTail = null;//索引+老表长度位置的新链,hiHead是新链头,hiTail是新链尾

Node<K,V> next;//next用于备份下一个节点

do {

next = e.next;//第一次,e是桶元素,e.next是桶元素下的第一个节点

if ((e.hash & oldCap) == 0) {//true时,表示当前元素的索引不会改变

if (loTail == null)//新链还没有节点

loHead = e;//则将e作为新链头节点

else//新链已经有了节点

loTail.next = e;//则将e作为新链尾节点的下一个节点

loTail = e;//不管如何,新加入的e就是新的尾节点

}

else {//false,表示当前元素的索引会改变,且是老索引+老表长度

if (hiTail == null)

hiHead = e;

else

hiTail.next = e;

hiTail = e;

}

} while ((e = next) != null);//只要老表桶位置的单向链表还有节点没有遍历,就继续遍历

if (loTail != null) {//如果新链不为空

loTail.next = null;

newTab[j] = loHead;//将新链头节点作为新表对应索引[j]位置的桶元素

}

if (hiTail != null) {//如果新链不为空

hiTail.next = null;

newTab[j + oldCap] = hiHead;//将新链头节点作为新表[j+oldCap]桶元素

}

}

关于红黑树节点的重定索引和转存

即((TreeNode<K,V>)e).split(this, newTab, j, oldCap);

e是老表桶元素

this是当前HashMap对象实例

newTab是新表

j是当前e桶元素位置

oldCap是老表容量

final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {

TreeNode<K,V> b = this;

// Relink into lo and hi lists, preserving order

TreeNode<K,V> loHead = null, loTail = null;

TreeNode<K,V> hiHead = null, hiTail = null;

int lc = 0, hc = 0;

for (TreeNode<K,V> e = b, next; e != null; e = next) {

next = (TreeNode<K,V>)e.next;

e.next = null;

if ((e.hash & bit) == 0) {

if ((e.prev = loTail) == null)

loHead = e;

### 回答1: Java中的Map接口提供了种将键映射到值的对象。其中种实现是HashMap,它使用哈希表来实现。HashMap允许 null 键和 null 值,并且没有顺序保证。HashMap 的操作复杂度为 O(1) 平均复杂度,因此在许多情况下非常高效。 ### 回答2: Java集合类中的Map用来保存键-值对,HashMap是其中的种实现方式。HashMap的内部实现是基于哈希表的,它可以将任意对象作为键,并且保证键的唯性。在HashMap中,键和值都允许为null,但是同HashMap中,键不能重复,如果重复了,新的value会覆盖旧的value。 HashMap内部是通过hashCode()和equals()方法来判断键是否相等的。当我们向HashMap中添加个键-值对时,系统会先计算出键的hashCode值,然后用该值来确定存放该键值对的位置。如果有多个键的hashCode值相同,称为哈希冲突,那么HashMap会在这些键值对所在的位置上,以链表的形式组成个链表存储。 HashMap的优点在于插入、删除和查找都比较快,时间复杂度均为O(1),对于大量的数据,它的效率优于List或Set等集合。但是,在内存使用上,HashMap会比List或Set等集合耗费更多的内存空间,因为它需要额外的空间来存储哈希值和链表指针。 值得注意的是,在多线程环境下,HashMap是不安全的,会出现并发修改导致的数据不致问题,这时可以使用ConcurrentHashMap或者加锁机制来保证线程安全。 总之,HashMapJava中非常实用的集合类,适用于大量键值对的存储和检索。我们应该了解HashMap的内部实现原理,并且在使用过程中需要注意其线程安全性等问题。 ### 回答3: Java中的Map种键值对的集合,其中每个元素都由个键和个值组成。在Map中,每个键必须是唯的,而值可以重复。 在Map中,HashMap是最常用的实现类之。它是基于哈希表实现的,可以通过键快速查找值。哈希表是种支持常数时间快速查找的数据结构,因为它可以将键映射到与其对应的值的位置,因此查找时只需要计算键的哈希码即可找到对应的值。 HashMap的实现类中使用了两个数组来存储键值对:个数组用于存储键,另个数组用于存储值。当插入键值对时,首先会将键的哈希码计算出来,然后通过哈希码将键映射到键数组的位置,将值存储在值数组的相同位置。当需要查找个键时,只需计算其哈希码并定位到键数组的位置,然后从值数组中取出对应的值即可。 与集合样,HashMap也是线程不安全的,因此在多线程环境下需要使用ConcurrentHashMap或通过synchronized关键字来保证线程安全性。此外,在使用HashMap时应该尽量避免使用null作为键,因为null的哈希码为0,可能与其他非null键的哈希码相同,导致哈希碰撞,影响HashMap的性能。 总之,HashMap种高效的键值对集合,通过哈希表实现快速的查找和插入操作。在正确使用和注意安全性的前提下,使用HashMap可以大大提升代码效率和性能。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值