JDK 1.8 HashMap解析


一、Map常用实现类

Map在Java里边是一个接口,常见的实现类有HashMap、LinkedHashMap、TreeMap、hashTable和ConcurrentHashMap

二、底层原理

在Java中,哈希表的结构是数组+链表的方式
HashMap底层数据结构是数组+链表/红黑树
LinkedHashMap底层数据结构是数组+链表/红黑树+双向链表
TreeMap底层数据结构是红黑树
HashTable底层数据结构是数组+链表
而ConcurrentHashMap底层数据结构也是数组+链表/红黑树

在这里插入图片描述

三、HashMap详解

思考:

  • 默认大小、负载因子以及扩容倍数是多少
  • 底层数据结构
  • 如何处理 hash 冲突的
  • 如何计算一个 key 的 hash 值
  • 数组长度为什么是 2 的幂次方
  • 扩容、查找等过程

1. 基本属性值

源码基本属性:

//默认初始容量为16  
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;  
//默认负载因子为0.75  
static final float DEFAULT_LOAD_FACTOR = 0.75f;  
//Hash数组(在resize()中初始化)  
transient Node[] table;  
//元素个数  
transient int size;  
//容量阈值(元素个数超过该值会自动扩容)  
int threshold;

table 数组里面存放的是 Node 对象,Node 是 HashMap 的一个内部类,用来表示一个 key-value

总结:

  • 默认初始容量为 16,默认负载因子为 0.75
  • threshold = 数组长度 * loadFactor,当元素个数超过threshold(容量阈值)时,HashMap 会进行扩容操作
  • table 数组中存放指向链表的引用
    这里需要注意的一点是 table 数组并不是在构造方法里面初始化的,它是在 resize(扩容)方法里进行初始化的。
  • 默认值选择为0.75是在时间和空间成本上做的一个折中方案,一般不建议自己更改。这个值越高,就意味着数组中能存更多的值,减少空间开销,但是会增加hash冲突的概率,增加查找的成本;这个值越低,就会减少hash冲突的概率,但是会比较费空间。

2.数据结构

  • 在 JDK1.8 中,HashMap 是由数组+链表+红黑树构成
  • 当一个值中要存储到 HashMap 中的时候会根据 Key 的值来计算出他的 hash,通过 hash 值来确认存放到数组中的位置,如果发生 hash 冲突就以链表的形式存储,当链表过长的话,HashMap 会把这个链表转换成红黑树来存储。

3.数组长度为什么总是 2 的幂次方

3.1 为什么是为 2 的次幂?

  • (n - 1) & hash,当n为2次幂时,会满足一个公式:(n - 1) & hash = hash % n

  • &运算速度快,至少比%取余运算快

  • 能保证 索引值 肯定在 capacity 中,不会超出数组长度

  1. key具体应该在哪个桶中,肯定要和key挂钩的,HashMap顾名思义就是通过hash算法高效的把存储的数据查询出来,所以HashMap的所有get 和 set 的操作都和hash相关。

  2. 既然是通过hash的方式,那么不可避免的会出现hash冲突的场景。hash冲突就是指 2个key 通过hash算法得出的哈希值是相等的。hash冲突是不可避免的,所以如何尽量避免hash冲突,或者在hash冲突时如何高效定位到数据的真实存储位置就是HashMap中最核心的部分。

  3. HashMap中计算hash时,通过(h = key.hashCode()) ^ (h >>> 16) 先获得key的hashCode的值 h,然后 h 和 h右移16位 做异或运算,使高16位也参与到hash的运算能减少冲突。后面获取下标时 (n - 1) & hash(当数组长度为 2 的幂次方时,可以使用位运算来计算元素在数组中的下标,(n - 1) & hash = hash % n,使用位运算可以提高效率) 的计算中,因为 n 永远是2的次幂,所以 n-1 通过 二进制表示,永远都是尾端以连续1的形式表示(00001111,00000011),这样才能保证其均匀散列在数组中,如果是如果 length 不是 2 的次幂,比如 length 为 15,则 length-1 为 14,对应的二进制为 1110,在和 hash 做与运算时,最后一位永远都为 0,另外一半始终获取不到 ,浪费空间。

3.2 如何实现呢,每次都是2 的幂次方

可以从源码来看,通过下面方法等到保证,结果一定是2的整数次幂,结果是大于等于initialCapacity的最小2的整数次幂。

/**
 * Returns a power of two size for the given target capacity.
 */
static final int tableSizeFor(int cap) {
    //cap-1后,n的二进制最右一位肯定和cap的最右一位不同,即一个为0,一个为1,例如cap=17(00010001),n=cap-1=16(00010000)
    int n = cap - 1;
    //n = (00010000 | 00001000) = 00011000
    n |= n >>> 1;
    //n = (00011000 | 00000110) = 00011110
    n |= n >>> 2;
    //n = (00011110 | 00000001) = 00011111
    n |= n >>> 4;
    //n = (00011111 | 00000000) = 00011111
    n |= n >>> 8;
    //n = (00011111 | 00000000) = 00011111
    n |= n >>> 16;
    //n = 00011111 = 31
    //n = 31 + 1 = 32, 即最终的cap = 32 = 2 的 (n=5)次方
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

tableSizeFor 的功能(不考虑大于最大容量的情况)是返回大于等于输入参数且最近的 2 的整数次幂的数。比如 10,则返回 16。

该算法让最高位的 1 后面的位全变为 1。最后再让结果 n+1,即得到了 2 的整数次幂的值了。

让 cap-1 再赋值给 n 的目的是另找到的目标值大于或等于原值。例如二进制 1000,十进制数值为 8。如果不对它减1而直接操作,将得到答案 10000,即 16。显然不是结果。减 1 后二进制为 111,再进行操作则会得到原来的数值 1000,即 8。通过一系列位运算大大提高效率。
在构造方法里面调用该方法来设置 threshold,也就是容量阈值。

4.扩容

HashMap 每次扩容都是建立一个新的 table 数组,长度和容量阈值都变为原来的两倍,然后把原数组元素重新映射到新数组上,具体步骤如下:
阈值threshold此时的值

  • 在调用有参构造器时,此时等于大于等于数组长度最小2次幂;
  • 在调用无参构造器时,此时等于0;
  • 之前数组不为空,现在是扩容,此时等于旧数组容量*负载因子
  1. 首先会判断 table 数组长度,如果大于 0 说明已被初始化过,那么按当前 table 数组长度的 2 倍进行扩容,阈值也变为原来的 2 倍,当然不能超过最大值(容量230 = 1 << 30, 阈值231-1=0x7fffffff)
    这里有个判断原数组长度是否大于等于默认长度16(如果不加上此条件判断,而是直接将旧的阈值2倍,那可能会导致阈值≠容量* 负载因子),如果是则正常将阈值扩充2倍,否则就会进入下面的阈值等于0逻辑中,重新计算阈值=容量* 负载因子

  2. 若 table 数组未被初始化过,且旧的 threshold(阈值)大于 0 说明调用了 HashMap(initialCapacity) 或HashMap(initialCapacity, loadFactor) 有参构造方法,那么就把数组大小设为旧的 threshold,此时新的阈值为0,也会进入下面的阈值等于0逻辑中,重新计算阈值=容量*负载因子

  3. 若 table 数组未被初始化,且 旧的threshold 为 0 说明调用 HashMap() 构造方法,那么就把数组大小设为 16,threshold 设为 16*0.75

  4. 接着需要判断如果不是第一次初始化,那么扩容之后,要重新计算键值对的位置,并把它们移动到合适的位置上去,如果节点是红黑树类型的话则需要进行红黑树的拆分。

这里有一个需要注意的点就是在 JDK1.8 HashMap 扩容阶段重新映射元素时不需要像 1.7 版本那样重新去一个个计算元素的 hash 值,而是通过 hash & oldCap(这种是用于有多个节点时,如果只有一个节点则还是hash & (cap-1)计算,其实最终的位置是一样的) 的值来判断,若为 0 则索引位置不变,不为 0 则新索引=原索引+旧数组长度,为什么呢?具体原因如下:

因为我们使用的是 2 次幂的扩展(指长度扩为原来 2 倍),所以,元素的位置要么是在原位置,要么是在原位置再移动 2 次幂的位置。因此,我们在扩充 HashMap 的时候,不需要像 JDK1.7 的实现那样重新计算 hash,只需要看看原来的 hash 值新增的那个 bit 是 1 还是 0 就好了,是 0 的话索引没变,是 1 的话索引变成“原索引 +oldCap

5.链表和红黑树

  1. 链表树华指的就是把链表转换成红黑树(时间复杂度:查找和插入都是O(log(n))),树化需要满足以下两个条件:
  • 链表长度大于 等于8
  • table 数组长度大于等于 64
  1. 红黑树退化成链表(时间复杂度:插入是O(1),查找是O(n))
  • 红黑树大小小于等于6

5.1 Hashmap链表长度为什么为8时转换成红黑树

最开始的 Map 是空的,因为里面没有任何元素,往里放元素时会计算 hash 值,计算之后,第 1 个 value 会首先占用一个桶(也称为槽点)位置,后续如果经过计算发现需要落到同一个桶中,那么便会使用链表的形式往后延长,俗称 “拉链法”。

5.1.1 为什么要转换

每次遍历一个链表,平均查找的时间复杂度是 O(n),n 是链表的长度。红黑树有和链表不一样的查找性能,由于红黑树有自平衡的特点,可以防止不平衡情况的发生,所以可以始终将查找的时间复杂度控制在 O(log(n))

最初链表还不是很长,所以可能 O(n) 和 O(log(n)) 的区别不大,但是如果链表越来越长,那么这种区别便会有所体现。所以为了提升查找性能,需要把链表转化为红黑树的形式。

HashMap 查询的复杂度为O(1) 是如何实现的
HashMap的主干是连续的存储单元来存储数据的数组;对于指定下标的查找,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1)。
HashMap容器O(1)的查找时间复杂度只是其理想的状态,而这种理想状态需要由java设计者去保证
在由设计者保证了链表长度尽可能短的前提下,由于利用了数组结构,使得key的查找在O(1)时间内完成。

5.1.2 为什么不直接用红黑树

其实在 JDK 的源码注释中已经对这个问题作了解释:

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. 

大致意思就是因为树节点的大小大约是普通节点的两倍,我们只有当容器包含足够的节点时才使用它们,当他们变得太小的时候(由于移除或调整大小)它们被转换回普通节点。

毕竟当链表长度不大时,链表的空间占用是比较少的,查询时间也没有太大的问题。

5.1.3 为什么为8时转换成红黑树

当链表越来越长,需要用红黑树的形式来保证查询的效率。对于何时应该从链表转化为红黑树,需要确定一个阈值,这个阈值默认为 8。
如果选择6和8(如果链表小于等于6树还原转为链表,大于等于8转为树),中间有个差值7可以有效防止链表和树频繁转换。源码如下:

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

大致意思就是如果 hashCode 分布良好,也就是 hash 计算的结果离散好的话,那么红黑树这种形式是很少会被用到的,因为各个值都均匀分布,很少出现链表很长的情况。在理想情况下,链表长度符合泊(po)松分布,各个长度的命中概率依次递减,当长度为 8 的时候,概率仅为 0.00000006。这是一个小于千万分之一的概率,通常我们的 Map 里面是不会存储这么多的数据的,所以通常情况下,并不会发生从链表向红黑树的转换。

5.2 为什么 table 数组容量大于等于 64 才树化?

因为当 table 数组容量比较小时,键值对节点 hash 的碰撞率可能会比较高,进而导致链表长度较长。这个时候应该优先扩容,而不是立马树化。

5.3 什么是红黑树(RBT)

红黑树是一种特化的AVL树(平衡二叉树),几大原则:

  • (颜色属性)性质1:节点非黑即红

  • (根属性)性质2:根节点一定是黑色

  • (叶子属性)性质3:叶子节点(NIL)一定是黑色

  • (红色属性)性质4:每个红色节点的两个子节点,都为黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)

  • (黑色属性)性质5:从任一节点到其每个叶子的所有路径,都包含相同数目的黑色节点。

黑色属性,可以理解为平衡特征, 如果满足不了平衡特征,就要进行平衡操作。

RBT有点属于一种空间换时间类型的优化,

在AVL(平衡二叉树)的节点上,增加了 颜色属性的 数据,相当于 增加了空间的消耗。 通过颜色属性的增加, 换取,后面平衡操作的次数 减少。

红黑树是实际应用中最常用的平衡二叉查找树,并不是一颗AVL平衡二叉搜索树,红黑树的平衡条件,不是以整体的高度来约束的,而是以黑色节点的数目来约束的。

5.4 红黑树的恢复平衡过程的三个操作

三种操作:变色、左旋、右旋。

5.5 有了二叉搜索树,为什么还需要平衡二叉树?

二叉搜索树容易退化成一条链
这时,查找的时间复杂度从O ( log n)也将退化成O ( N )
引入对左右子树高度差有限制的平衡二叉树 AVL,保证查找操作的最坏时间复杂度也为O ( log n)

5.6 有了平衡二叉树,为什么还需要红黑树?

AVL的左右子树高度差不能超过1,每次进行插入/删除操作时,几乎都需要通过旋转操作保持平衡
在频繁进行插入/删除的场景中,频繁的旋转操作使得AVL的性能大打折扣
红黑树通过牺牲严格的平衡,换取插入/删除时少量的旋转操作,

整体性能优于AVL

  • 红黑树插入时的不平衡,不超过两次旋转就可以解决;删除时的不平衡,不超过三次旋转就能解决

  • 红黑树的红黑规则,保证最坏的情况下,也能在O ( log n)时间内完成查找操作。

5.7 红黑树写入操作 ,是如何找到它的父节点的?

首先是找到一个合适的插入点,就是找到插入节点的父节点,
由于红黑树 它又满足BST二叉查找树的 有序特性,这个找父节点的操作和二叉查找树是完全一致的。
二叉查找树,左子节点小于当前节点,右子节点大于当前节点,

然后每一次向下查找一层就可以排除掉一半的数据,查找的效率在log(N)

最终查找到nil节点或者 key一样的节点。

如果最终查找到 key一样的节点,进行更新操作。这个TreeNode.key 与当前 put.key 完全一致。这就不需要插入,替换value就可以了,父节点就是当前节点的父节点

如果最终查找到nil节点,进行插入操作。nil节点的父节点,就是当前节点的父节点,把插入的节点替换nil节点。然后进行红黑树的 平衡处理。

5.8 红黑树与AVL树区别

  1. 调整平衡的实现机制不同

    红黑树根据路径上黑色节点数目一致,来确定是否失衡,如果失衡,就通过变色和旋转来恢复

    AVL根据树的平衡因子(所有节点的左右子树高度差的绝对值不超过1),来确定是否失衡,如果失衡,就通过旋转来恢复

  2. 黑树的插入效率更高

    红黑树是用非严格的平衡来换取增删节点时候旋转次数的降低,任何不平衡都会在三次旋转之内解决,
    红黑树并不追求“完全平衡”,它只要求部分地达到平衡要求,降低了对旋转的要求,从而提高了性能

    而AVL是严格平衡树(高度平衡的二叉搜索树),因此在增加或者删除节点的时候,根据不同情况,旋转的次数比红黑树要多。
    所以红黑树的插入效率更高

  3. 红黑树统计性能比AVL树更高

    红黑树能够以O(log n) 的时间复杂度进行查询、插入、删除操作。

    AVL树查找、插入和删除在平均和最坏情况下都是O(log n)。

    红黑树的算法时间复杂度和AVL相同,但统计性能比AVL树更高,

  4. 适用性:AVL查找效率高

    如果你的应用中,查询的次数远远大于插入和删除,那么选择AVL树,如果查询和插入删除次数几乎差不多,应选择红黑树。
    即,有时仅为了排序(建立-遍历-删除),不查找或查找次数很少,R-B树合算一些。

6.查找、插入、删除等操作

6.1 查找

HashMap 的查找是非常快的,要查找一个元素首先得知道 key 的 hash 值,在 HashMap 中并不是直接通过 key 的 hashcode 方法获取哈希值,而是通过内部自定义的 hash 方法计算哈希值,我们来看看其实现:

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

(h = key.hashCode()) ^ (h >>> 16) 是为了让高位数据与低位数据进行异或,变相的让高位数据参与到计算中,int 有 32 位,右移 16 位就能让低 16 位和高 16 位进行异或,也是为了增加 hash 值的随机性。

6.2 插入

  1. 当 table 数组为空时,通过扩容的方式初始化 table( 由此可以看出table 数组是在第一次调用 put 方法后才进行初始化的);

  2. 通过计算键的 hash 值求出下标后,若该位置上没有元素(没有发生 hash 冲突),则新建 Node 节点插入;

  3. 若发生了 hash 冲突,遍历链表查找要插入的 key 是否已经存在,存在的话根据条件判断是否用新值替换旧值;

  4. 如果不存在,则将元素插入链表尾部,并根据链表长度决定是否将链表转为红黑树;

  5. 判断键值对数量是否大于阈值,大于的话则进行扩容操作。

6.3 删除

  1. 定位桶位置;

  2. 遍历链表找到相等的节点;

  3. 第三步删除节点,删除节点后可能破坏了红黑树的平衡性质,removeTreeNode 方法会对红黑树进行变色、旋转等操作来保持红黑树的平衡结构。

6.4 遍历

当我们在遍历 HashMap 的时候,如foreach若使用 remove 方法删除元素时会抛出 ConcurrentModificationException 异常;在 HashMap 中有一个名为 modCount 的变量,它用来表示集合被修改的次数,修改指的是插入元素或删除元素,可以回去看看上面插入删除的源码,在最后都会对 modCount 进行自增。
当我们在遍历 HashMap 时,每次遍历下一个元素前都会对 modCount 进行判断,若和原来的不一致说明集合结果被修改过了,然后就会抛出异常,这是 Java 集合的一个特性。
而想要删除可以使用迭代器自带的 remove 方法

在遍历 HashMap 时,我们会发现遍历的顺序和插入的顺序不一致,这是为什么?

在 HashIterator 源码里面可以看出,它是先从桶数组中找到包含链表节点引用的桶。然后对这个桶指向的链表进行遍历。遍历完成后,再继续寻找下一个包含链表节点引用的桶,找到继续遍历。找不到,则结束遍历。这就解释了为什么遍历和插入的顺序不一致。

为什么添加到 HashMap 中的对象需要重写 equals() 和 hashcode() 方法?

  • 原生的 equals 方法是使用 == 来比较对象的
  • 原生的 hashCode 值是根据内存地址换算出来的一个值

7.Hashmap的结构,1.7和1.8有哪些区别

  1. JDK1.7的时候使用的是数组+ 单链表的数据结构。但是在JDK1.8及之后时,使用的是数组+链表+红黑树的数据结构(当链表的深度达到8的时候,也就是默认阈值,就会自动扩容把链表转成红黑树的数据结构来把时间复杂度从O(n)变成O(logN)提高了效率)

  2. JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法。
    那么他们为什么要这样做呢?因为JDK1.7是用单链表进行的纵向延伸,当采用头插法时会容易出现逆 序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。

  3. 扩容后数据存储位置的计算方式也不一样:
    在JDK1.7的时候是直接用hash值和需要扩容的二进制数进行&(hash值 & length-1)

    而在JDK1.8的时候通过 hash & oldCap 的值来判断,并不需要重新如上一样再次计算。这种方式就相当于只需要判断Hash值的新增参与运算的位是0还是1就直接迅速计算出了扩容后的储存方式。
    在计算hash值的时候,JDK1.7用了9次扰动处理=4次位运算+5次异或,而JDK1.8只用了2次扰动处理=1次位运算+1次异或。
    在这里插入图片描述

JDK 1.8  先插入后扩容
//复制老数据
.....
//扩容判断
if (++size > threshold)
            resize();
----------------------------------------
JDK 1.7 先扩容后插入
void addEntry(int hash, K key, V value, int bucketIndex) {
        //这里当钱数组如果大于等于12(假如)阈值的话,并且当前的数组的Entry数组还不能为空的时候就扩容
      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);
  }

 void createEntry(int hash, K key, V value, int bucketIndex) {
      Entry e = table[bucketIndex];
    //把新加的放在原先在的前面,原先的是e,现在的是new,next指向e
      table[bucketIndex] = new Entry<>(hash, key, value, e);//假设现在是new
      size++;
  }

8.哈希表如何解决Hash冲突?

在这里插入图片描述

9.为什么HashMap具备下述特点:键-值(key-value)都允许为空、线程不安全、不保证有序、存储位置随时间变化

在这里插入图片描述

10.为什么 HashMap 中 String、Integer 这样的包装类适合作为 key 键

在这里插入图片描述

11.HashMap 中的 key若 Object类型, 则需实现哪些方法?

在这里插入图片描述

四、问答

4.1 HashTable与HashMap区别

  1. 需要注意的是Hashtable的默认初始容量大小是11,而HashMap 是16,但是他们的加载因子都是0.75f
  2. HashTable的初始容量可以使任何非负整数,但是HashMap会采用第一个大于等于该数值的2的幂作为初始化容量(0 1 除外,都是 1)
  3. HashTable的线程安全是完全借助synchronized 的加持
  4. HashTable 的元素是头插法,也就是插入到链表的头部,因为HashTable 是线程安全的,在这个前提下,使用头查法性能更好,否则还有遍历到链表的尾部插入
  5. HashTable 是没有红黑树支持的,就是不论链表的长度有多长,都不会转化成红黑树
  6. 哈希值的计算不同,HashTable直接使用对象的hashCode。而HashMap重新计算hash值(高16位异或低16位),并且HashMap 支持key 为null 就是在这里的,HashTable key value 都不支持null
  7. Hashtable扩容时,将容量变为原来的2倍加1,而HashMap扩容时,将容量变为原来的2倍
  8. 对 Null key 和 Null value 的⽀持: HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有⼀个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出NullPointerException

4.2 HashMap 和 HashSet 的区别有什么?

  1. 两者实现的接口不一样,HashMap 实现的是 Map 接口、HashSet 则实现的是 Set 接口。
  2. HashMap 存储的是键值对、HashSet 存储的是对象。
  3. HashSet 的速度比 HashMap 的要慢一些。
  4. 计算 hashcode 值的方式不一样,HashMap 使用键对象来计算、HashSet 使用它本身的对象元素来计算。
  5. HashSet 底层就是基于 HashMap 实现的。

4.3 重写 equals() 时没有重写 hashCode() ⽅法的话,使⽤ HashMap 可能会出现什么问题?

首先明白Object中的hashCode()默认是对象的内存地址转换成的int数值,所以即使equals()相同的两个对象(内存地址不同),那么默认的hashCode()方法值也不同。

所以就会导致相同的两个键对象(未重写hashCode())同时出现在hashMap中(判断键是否相等,首先判断的就是哈希值,其次如果哈希值相同再用equal()判断,此时键判断是不同所以就会认为是两个不同的键),这与hashMap的规定不符(键不能重复)。
而对于查找时可能会导致查找不到,因为即使相同的对象,此时它的hashCode()值也不一样(内存地址不一样),所以此时的key是匹配不到的。

五、LinkedHashMap简介

LinkedHashMap是HashMap的子类,在HashMap的基础上维护了一个双向链表,并且key-value都是放在HashMap.table中存储,只是把所有key-value按照放入容器的顺序用双链表串了起来。那么HashMap的特征LinkedHashMap都有,比如不支持并发读写、允许放入key或value为空的key-value。LinkedHashMap还有一个特性就是可以按照放入容器的顺序取出来。
总的来说,一句话 LinkedHashMap = HashMap + list。

LinkedHashMap 和 HashMap 都是 Java 集合框架中的 Map 接口的实现类。它们的最大区别在于迭代元素的顺序。HashMap 迭代元素的顺序是不确定的,而 LinkedHashMap 提供了按照插入顺序或访问顺序迭代元素的功能。此外,LinkedHashMap 内部维护了一个双向链表,用于记录元素的插入顺序或访问顺序,而 HashMap 则没有这个链表。因此,LinkedHashMap 的插入性能可能会比 HashMap 略低,但它提供了更多的功能并且迭代效率相较于 HashMap 更加高效。

在这里插入图片描述

六、TreeMap简介

TreeMap的底层数据结构是红黑树,TreeMap的key不能为null(如果为null,那还怎么排序呢),TreeMap有序是通过Comparator来进行比较的,如果comparator为null,那么就使用自然顺序。
在这里插入图片描述

七、线程安全的Map

HashMap不是线程安全的;线程安全,可以使用ConcurrentHashMap(线程安全的Map实现类,它在juc包下的)、Hashtable,当然了,也可以使用Collections来包装出一个线程安全的Map。
但无论是Hashtable还是Collections包装出来的都比较低效(因为是直接在外层套synchronize),所以我们一般有线程安全问题考量的,都使用ConcurrentHashMap。
在这里插入图片描述
ConcurrentHashMap的底层数据结构是数组+链表/红黑树,它能支持高并发的访问和更新,是线程安全的;通过在部分加锁和利用CAS算法来实现同步,在get的时候没有加锁,Node都用了volatile给修饰;在扩容时,会给每个线程分配对应的区间,并且为了防止putVal导致数据不一致,会给线程的所负责的区间加锁。

参考:
Map集合
红黑树( 图解 + 秒懂 + 史上最全)
答应我面试再问HashMap,求你把这篇文章发给他!
美团面试题:Hashmap的结构,1.7和1.8有哪些区别,史上最深入的分析 转载
HashMap 容量为什么总是为 2 的次幂?
为什么HashMap的容量必须是2的次幂
Hashmap链表长度为8时转换成红黑树,你知道为什么是8吗
Java容器之LinkedHashMap源码分析(看看确定不点进来?进来真不点?)
聊聊经典数据结构HashMap,逐行分析每一个关键点

  • 2
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 6
    评论
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值