文章目录
- 一、Map常用实现类
- 二、底层原理
- 三、HashMap详解
- 四、问答
- 五、LinkedHashMap简介
- 六、TreeMap简介
- 七、线程安全的Map
一、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 中,不会超出数组长度
-
key具体应该在哪个桶中,肯定要和key挂钩的,HashMap顾名思义就是通过hash算法高效的把存储的数据查询出来,所以HashMap的所有get 和 set 的操作都和hash相关。
-
既然是通过hash的方式,那么不可避免的会出现hash冲突的场景。hash冲突就是指 2个key 通过hash算法得出的哈希值是相等的。hash冲突是不可避免的,所以如何尽量避免hash冲突,或者在hash冲突时如何高效定位到数据的真实存储位置就是HashMap中最核心的部分。
-
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;
- 之前数组不为空,现在是扩容,此时等于旧数组容量*负载因子
-
首先会判断 table 数组长度,如果大于 0 说明已被初始化过,那么按当前 table 数组长度的 2 倍进行扩容,阈值也变为原来的 2 倍,当然不能超过最大值(容量230 = 1 << 30, 阈值231-1=0x7fffffff)
这里有个判断原数组长度是否大于等于默认长度16(如果不加上此条件判断,而是直接将旧的阈值2倍,那可能会导致阈值≠容量* 负载因子),如果是则正常将阈值扩充2倍,否则就会进入下面的阈值等于0逻辑中,重新计算阈值=容量* 负载因子 -
若 table 数组未被初始化过,且旧的 threshold(阈值)大于 0 说明调用了 HashMap(initialCapacity) 或HashMap(initialCapacity, loadFactor) 有参构造方法,那么就把数组大小设为旧的 threshold,此时新的阈值为0,也会进入下面的阈值等于0逻辑中,重新计算阈值=容量*负载因子
-
若 table 数组未被初始化,且 旧的threshold 为 0 说明调用 HashMap() 构造方法,那么就把数组大小设为 16,threshold 设为 16*0.75
-
接着需要判断如果不是第一次初始化,那么扩容之后,要重新计算键值对的位置,并把它们移动到合适的位置上去,如果节点是红黑树类型的话则需要进行红黑树的拆分。
这里有一个需要注意的点就是在 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.链表和红黑树
- 链表树华指的就是把链表转换成红黑树(时间复杂度:查找和插入都是O(log(n))),树化需要满足以下两个条件:
- 链表长度大于 等于8
- table 数组长度大于等于 64
- 红黑树退化成链表(时间复杂度:插入是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树区别
-
调整平衡的实现机制不同
红黑树根据路径上黑色节点数目一致,来确定是否失衡,如果失衡,就通过变色和旋转来恢复
AVL根据树的平衡因子(所有节点的左右子树高度差的绝对值不超过1),来确定是否失衡,如果失衡,就通过旋转来恢复
-
黑树的插入效率更高
红黑树是用非严格的平衡来换取增删节点时候旋转次数的降低,任何不平衡都会在三次旋转之内解决,
红黑树并不追求“完全平衡”,它只要求部分地达到平衡要求,降低了对旋转的要求,从而提高了性能而AVL是严格平衡树(高度平衡的二叉搜索树),因此在增加或者删除节点的时候,根据不同情况,旋转的次数比红黑树要多。
所以红黑树的插入效率更高 -
红黑树统计性能比AVL树更高
红黑树能够以O(log n) 的时间复杂度进行查询、插入、删除操作。
AVL树查找、插入和删除在平均和最坏情况下都是O(log n)。
红黑树的算法时间复杂度和AVL相同,但统计性能比AVL树更高,
-
适用性: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 插入
-
当 table 数组为空时,通过扩容的方式初始化 table( 由此可以看出table 数组是在第一次调用 put 方法后才进行初始化的);
-
通过计算键的 hash 值求出下标后,若该位置上没有元素(没有发生 hash 冲突),则新建 Node 节点插入;
-
若发生了 hash 冲突,遍历链表查找要插入的 key 是否已经存在,存在的话根据条件判断是否用新值替换旧值;
-
如果不存在,则将元素插入链表尾部,并根据链表长度决定是否将链表转为红黑树;
-
判断键值对数量是否大于阈值,大于的话则进行扩容操作。
6.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有哪些区别
-
JDK1.7的时候使用的是数组+ 单链表的数据结构。但是在JDK1.8及之后时,使用的是数组+链表+红黑树的数据结构(当链表的深度达到8的时候,也就是默认阈值,就会自动扩容把链表转成红黑树的数据结构来把时间复杂度从O(n)变成O(logN)提高了效率)
-
JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法。
那么他们为什么要这样做呢?因为JDK1.7是用单链表进行的纵向延伸,当采用头插法时会容易出现逆 序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。 -
扩容后数据存储位置的计算方式也不一样:
在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区别
- 需要注意的是Hashtable的默认初始容量大小是11,而HashMap 是16,但是他们的加载因子都是0.75f
- HashTable的初始容量可以使任何非负整数,但是HashMap会采用第一个大于等于该数值的2的幂作为初始化容量(0 1 除外,都是 1)
- HashTable的线程安全是完全借助synchronized 的加持
- HashTable 的元素是头插法,也就是插入到链表的头部,因为HashTable 是线程安全的,在这个前提下,使用头查法性能更好,否则还有遍历到链表的尾部插入
- HashTable 是没有红黑树支持的,就是不论链表的长度有多长,都不会转化成红黑树
- 哈希值的计算不同,HashTable直接使用对象的hashCode。而HashMap重新计算hash值(高16位异或低16位),并且HashMap 支持key 为null 就是在这里的,HashTable key value 都不支持null
- Hashtable扩容时,将容量变为原来的2倍加1,而HashMap扩容时,将容量变为原来的2倍
- 对 Null key 和 Null value 的⽀持: HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有⼀个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出NullPointerException
4.2 HashMap 和 HashSet 的区别有什么?
- 两者实现的接口不一样,HashMap 实现的是 Map 接口、HashSet 则实现的是 Set 接口。
- HashMap 存储的是键值对、HashSet 存储的是对象。
- HashSet 的速度比 HashMap 的要慢一些。
- 计算 hashcode 值的方式不一样,HashMap 使用键对象来计算、HashSet 使用它本身的对象元素来计算。
- 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,逐行分析每一个关键点