<源码>HashMap、LinkedHashMap、ConCurrentHashMap
总结:
HashMap
:线程不安全,数组+链表(红黑树)LinkedHashMap
:线程不安全,继承自HashMap
,双向链表ConCurrentHashMap
:线程安全,采用桶节点锁
HashMap
1、概述
HashMap
可存null键和值HashMap
中有 两个影响其性能的参数:初始容量 和 负载系数。- HashMap是在
bucket
(Node<K,V>[] table
)中储存键对象和值对象,作为Map.Entry - 其所需的容量表示
bucket
的数量,初始容量是在创建时的容量。 - 负载系数是在自动增加哈希表容量之前允许哈希表获取的程度的度量。
- 当哈希表中的条目数超过负载系数和当前容量的乘积时,哈希表进行
rehbed
(即内部数据结构被重建),使得哈希表扩大至两倍的bucket
。 - 作为一般规则,默认负载系数(0.75)提供良好的性能时间和空间成本之间的折衷。较高的值降低了空间开销,但会增加查找成本(反映在大多数情况下)。
简单来说,HashMap有个初始容量(默认为16),当表中的条目超过这个负载容量(负载系数 x 当前容量),就会进行扩充到其两倍大小。
//默认大小
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
- 最小树容量:
static final int MIN_TREEIFY_CAPACITY = 64;
- 链表转化为红黑树,除了有阈值的限制,还有另外一个限制,需要数组容量至少达到64,才会树化。
- 这是为了避免,数组扩容和树化阈值之间的冲突。
2、原理
- 在Java1.7中,HashMap的实现方式是:数组+链表
- 在Java1.8中,HashMap的实现方式是:数组+链表(链表和红黑树会转换)
hash()
计算原理
- 为了减少哈希碰撞的概率:
- 高16位和低16位进行混合计算:为了保留高16位的特征,避免出现高16位都为0的情况
(h = key.hashCode()) ^ (h >>> 16)
- 异或运算,让结果出现的随机性更大
(n - 1) & hash
- 高16位和低16位进行混合计算:为了保留高16位的特征,避免出现高16位都为0的情况
put
方法
- 先根据table数组是否为空,进行扩容操作(2的N次方)
- 计算key的hash值,并查找在数组的位置
- 如果此位置没有元素,则将key和value 包装为
Node
节点,添加到此位置 - 如果此位置已有元素
- ① 此位置元素的hash值和传入的hash值相等,且key值也相等,处理hash碰撞(即
bucket
位置相同),即将其转化为链表形式存储(依然存的是键值对即Node节点) - ② 此位置为红黑树结构,将新节点加入到红黑树上
- ③ 此位置为普通链表,将新节点插到链表尾部,插入过程中链表长度超过8则转化为红黑树
- ① 此位置元素的hash值和传入的hash值相等,且key值也相等,处理hash碰撞(即
get
方法
- 如果查找的节点为null,则返回null,否则返回节点的value
- 根据key的hash值计算出下标位置的第一个元素
- 若hash值和key值都相等,即为第一个元素,直接返回Node节点
- 若hash值和key值不等,则遍历链表(或红黑树)
- ① 若为红黑树,则根据
hash
值和key
(key通过equals
比较)查找当前key的节点,并返回Node节点 - ② 若为普通链表,则向后遍历查找Node节点(
hash
值和key
(key通过equals
比较)都相等时),并返回Node节点
- ① 若为红黑树,则根据
remove
方法
- 如果删除的节点为null,则返回null,否则返回节点的value
- 先根据key算出hash,然后根据hash得到在table上的index
- 找到要删除的
Node<K, V>
- 若hash值和key值都相等,即要删除的是第一个元素
- 若hash值和key值不等,则遍历链表(或红黑树)
- ① 若为红黑树,则根据
hash
值和key
查找当前key的节点 - ② 若为普通链表,则向后遍历查找Node节点(
hash
值和key
(key通过equals
比较)都相等时)
- ① 若为红黑树,则根据
- 找到指定的节点Node后
- 若为红黑树,则直接从树上删除
- 若为数组上的节点(即链表表头),则直接让
tab[index] = node.next;
将下一个节点存于数组位置 - 否则将链表上的节点删除,即
p.next = node.next;
- 将删除的节点直接返回
resize
扩容数组
- 由于元素的索引是通过
hash&(n - 1)
得到的,所以扩容,并非简单的数组长度翻倍。
- JDK1.7是通过rehash操作,重新计算节点的索引值。
- JDK1.8是进行了优化,根据新增节点索引的bit高位是0还是1,是1则变为
原索引 + oldCap
,可均匀的将之前冲突的节点分散到新的bucket
了。
JDK1.7的HashMap链表会形成死循环的原因
- JDK1.7采用的是头插法,在多线程环境下可能会使链表形成环状,从而导致死循环
- JDK1.8采用的是尾插法,不会产生死循环
3、说明
- 链表的作用就是处理hash碰撞的(产生碰撞的原因就是在计算key的时候是使用
除留余数法
,可能导致不同的key值计算出来的结果是一样的,因此产生碰撞),将其插入到数组同一个位置的链表上。 - 链表中存储的是
Entry<K, V>
(Node<K, V>
),即键值对。 - 在Java1.8中,链表达到阈值时(7或8)转为红黑树结构提高性能,当长度缩小到阈值(6)时,将红黑树转化为单向链表提高性能。
hashCode()
方法是用来确定bucket
位置的equals()
方法是用来获取值对象的- 减少碰撞的发生,并提高效率,可以使用不可变的、声明final的对象,并采用合适的
equals()
和hashCode()
- 不可变性能缓存不同间的hashcode,这将提高这个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择。
- String, Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。
- 其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其他的优点如线程安全。
- 如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,那么请这么做吧。因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。
4、HashMap、HashTable、HashSet
-
1】HashMap与HashTable:
- 两者几乎可以等价,都实现了Map接口。
- 主要区别在于HashMap是非同步的,并可接收null的键和值,HashTable反之
- 单线程中推荐使用HashMap,因为HashTable是线程同步的,速度较HashMap慢
- HashMap不能保证随着时间的推移Map中的元素次序是不变的。
- 通过
Map m = Collections.synchronizeMap(hashMap);
让HashMap进行同步。
-
2】HashMap和HashSet:
- 通过看
HashSet
的源码可以看出,其内部就是依赖的一个HashMap
,将HashSet
的值作为key存储到HashMap
中(value为静态常量PRESENT = new Object()
)。 - 两者都不能保证随着时间的推移,其中的元素次序不变。
- 对比: | | | | | --------------- | ------------------------ | ------------------------- | | HashMap | HashSet | | HashMap实现了Map接口 | HashSet实现了Set接口 | | HashMap储存键值对 | HashSet仅仅存储对象 | | 使用put()方法将元素放入map中 | 使用add()方法将元素放入set中 | | 用键对象来计算hashcode值 | 用成员对象来计算hashcode值,若相等再用quals()判断对象的相等性 | | HashMap比较快,因为是使用唯一的键来获取对象 | HashSet较HashMap来说比较慢 |
- 通过看
5、相关链接
LinkedHashMap
1、概述
LinkedHashMap
是HashMap
的子类LinkedHashMap
=HashMap
+ 双链表。LinkedHashMap
类新增三个主要属性:head
(list头部节点)、tail
(list尾部节点)、accessOrder
(list节点顺序是否随get
方法的调用而改变)LinkedHashMap
中存储key-value
的内部类Entry<K, V>
继承自HashMap.Node<K, V>
,并新增了before
和after
指针
2、原理
构造器
LinkedHashMap
类一共有5个构造器,涉及initialCapacity
、loadFactor
、accessOrder
三个参数的初始化。
LinkedHashMap
调用的是父类HashMap
的构造器- 默认
accessOrder = false;
,可通过构造函数进行赋值 HashMap.table
长度默认16,HashMap
默认负载因子为0.75
LinkedHashMap操作后回调
在HashMap
中有三个方法是用于LinkedHashMap
操作后回调,在HashMap
中的各个方法中都有相应的调用,三个方法都是空实现,都要在LinkedHashMap
中进行实现
-
afterNodeRemoval
:删除某个节点时,进行的操作:- 先将需要删除的节点的前后指针赋值给两个变量
- 将删除的节点的
before
和after
置为null - 将节点的后继的
before
指向节点的前驱节点 - 将节点的前驱的
after
指向节点的后继节点
-
afterNodeInsertion
:插入某个节点时的操作:- 实现中,判断了
removeEldestEntry(first)
方法的返回值,默认为false
,也就是说并不会进入if中的逻辑,即不会执行其中的removeNode
方法
/** * 插入一个节点后的操作 */ void afterNodeInsertion(boolean evict) { // possibly remove eldest LinkedHashMap.Entry<K,V> first; // LinkedHashMap中的removeEldestEntry方法永远返回false(方法体见下文) if (evict && (first = head) != null && removeEldestEntry(first)) { K key = first.key; // 这里不会执行 removeNode(hash(key), key, null, false, true); } } /** * removeEldestEntry方法永远返回false */ protected boolean removeEldestEntry(Map.Entry<K,V> eldest) { return false; }
- 实现中,判断了
-
afterNodeAccess
:访问某个节点时的操作- 此方法是否执行依据
accessOrder == true
,当调用get
方法,会将key-value
节点移动到链表尾端 - 当
accessOrder == true
,且节点不在尾端时,执行移动操作 - ① 先将节点的前后指针赋值给两个变量
- ② 将节点的前驱的
after
指向节点的后继节点 - ③ 将节点的后继的
before
指向节点的前驱节点 - ④ 将节点指向链表的尾端
- ⑤ 更新链表的新尾端tail
- 此方法是否执行依据
链表实现的原理
-
get
方法:LinkedHashMap
中重写了get
方法- 调用
HashMap#getNode
方法,若为null,则直接返回null - 当
accessOrder == true
时,调用afterNodeAccess
方法,即将访问节点放到链表尾端
- 调用
-
getOrDefault
方法:LinkedHashMap
提供一个getOrDefault
方法,没找到就返回提供的默认值- 同
get
方法一样,只不过提供了一个默认参数值,在HashMap#getNode
方法为null时返回默认值
- 同
-
put
方法:在LinkedHashMap
中并没有重写put
相关的方法,直接调用的是HashMap#put
方法- 当节点插入成功时,调用
afterNodeAccess
方法 - 在
put
方法最后,会调用afterNodeInsertion
方法
- 当节点插入成功时,调用
-
remove
方法:在LinkedHashMap
中并没有重写remove
相关的方法,直接调用的是HashMap#remove
方法remove
方法中仅调用了removeNode
方法- 在
removeNode
方法中,在节点移除成功后,调用了afterNodeRemoval
方法
3、LRU基于LinkedHashMap的实现
- LRU,最近最少原则,若保存的数据满了,则将最近最少使用的数据删除
- 通过将
LinkedHashMap#accessOrder
设为true
时,可满足此特性 - 重写
removeEldestEntry
方法,返回size() > capacity
来决定是否要执行删除节点的操作,上面提到,在LinkedHashMap
中此方法默认返回false
,则不会执行删除的节点 - LRU的逻辑为:
- 将
accessOrder
置为true
,则调用get
方法,会将访问的节点移到链表尾端 - 根据集合大小是否超过容量,来决定是否要删除头部节点,以此来删除最久未用的节点
- 将
class LRUCache extends LinkedHashMap {
private int capacity;
public LRUCache(int capacity) {
//accessOrder为true
super(capacity, 0.75F, true);
this.capacity = capacity;
}
public int get(int key) {
return (int)super.getOrDefault(key, -1);
}
public void put(int key, int value) {
super.put(key, value);
}
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > capacity;
}
}
4、相关链接
ConCurrentHashMap
- JDK1.7和JDK1.8的实现方式有很大不同
- JDK1.7采用分段锁(为
Segment
加锁ReentrantLock
)的方式实现 - JDK1.8采用
CAS
+synchronized
,即链头加锁的方式 CAS
:Compare And Swap
,它是一种乐观锁,认为对于同一个数据的并发操作不一定会发生修改,在更新数据的时候,尝试去更新数据,如果失败就不断尝试。
一、原理
1、jdk1.7实现原理
ConcurrentHashMap
类所采用的是分段锁的思想,将HashMap
进行切割- 把 HashMap 中的哈希数组切分成小数组,每个小数组有 n 个
HashEntry
组成 - 小数组继承自
ReentrantLock(可重入锁)
,这个小数组名叫Segment
- 可以将
ConcurrentHashMap
看作一个二级哈希表。在一个总的哈希表下面,有若干个子哈希表。 - 当hash冲突的链表过长时,在查询遍历的时候依然很慢!
get
方法
- 根据key的hash值,找到对应位置的
Segment
对象 - 再通过key的hash值,定位到
Segment
对象中数组的对应位置
put
方法
- 根据key的hash值,找到对应位置的
Segment
对象 - 获取
ReentrantLock
可重入锁 - 再通过key的hash值,定位到
Segment
对象中数组的对应位置 - 插入或覆盖
HashEntry
对象 - 释放锁
2、jdk1.8实现原理
ConcurrentHashMap
采用了CAS + synchronized
来保证并发的安全性- 节点Node类中的共享变量,使用了
volatile
关键字,保证多线程操作的可见性,JDK1.7一样
get
方法
- 通过key的hash值定位到数组位置
- 判断node节点的首个元素是否为目标元素,是则返回
- 若是红黑树结构,则从树中查找
- 若是链表结构,则遍历向后查找
remove
方法
- ① 循环遍历数组,接着校验参数;
- ② 判断是否有别的线程正在扩容,如果是一起扩容;
- ③ 用 synchronized 同步锁,保证并发时元素移除安全;
- ④ 因为
check= -1
,所以不会进行扩容操作,利用CAS操作修改baseCount值。
put
方法
- key和value都不能为null,否则抛出空指针异常
- 判断节点数组容量是否为null,为null则初始化数组
initTable
- 根据hash是否查找到节点,若不存在,则通过CAS方式插入:若已有在插入的元素则进入下一次循环;若插入成功,则跳出循环,方法结束
- 判断是否有其他线程在扩容,即
f.hash == MOVE
(MOVE为-1)时,f为ForwardingNode
节点;则执行helpTransfer
,一起扩容 - 上面条件都不满足时,则在
synchronized
中进行插入赋值,即把新的Node
节点按链表或红黑树的方式插入到合适的位置 - 当在
synchronized
中插入成功后,判断是否要进行链表向红黑树的转化 - 在最后,当成功插入元素,元素个数加1,判断是否要扩容
initTable
初始化数组
- 当数组为null或容量为0时,进行初始化操作
- 先判断
sizeCtl<0
,即是否正在初始化或扩容,则Thread.yield();
让出CPU - 否则无初始化或扩容操作时,通过CAS锁控制只有一个线程进行初始化tab数组,
sizeCtl
更新为-1成功- 先检查table是否为null或容量为0,避免ABA问题
- 新建Node数组,根据
sc>0
来设定容量,默认为16;并赋值为table - 设置
sc
大小为数组长度的0.75倍 - 最后,将
sc
赋值给sizeCtl
,设定扩容门槛 - 结束
helpTransfer
协助扩容
- 当满足一定条件(传入tab数组不为null,且tab首个元素f为
ForwardingNode
类型,且f的nextTab
不为null)时,才会进行协助扩容- 说明当前tab已迁完,才会去协助迁移其他tab元素
- 在扩容时会把旧tab的首个元素置为
ForwardingNode
,并让其nextTab
指向新tab数组
- 当
sizeCtl<0
,说明正在扩容,才会协助扩容 - 扩容线程加1后,让当前线程协助迁移元素:
transfer(tab, nextTab);
addCount
扩容判断
- ① 利用CAS将方法更新baseCount的值
- ② 检查是否需要扩容,默认check = 1,需要检查;
- ③ 如果满足扩容条件,判断当前是否正在扩容,如果是正在扩容就一起扩容;
- ④ 如果不在扩容,将sizeCtl更新为负数,并进行扩容处理。
3、几种场景下使用ConcurrentHashMap
不存在(null)则插入
当多线程下同时调用如下unsafeUpdate
方法,可能会导致在get()之后if之前有其他线程进行put操作了,则当前线程的put会覆盖之前线程的put值;可使用putIfAbsent()替换:
private static final Map<Integer, Integer> map = new ConcurrentHashMap<>();
public void unsafeUpdate(Integer key, Integer value) {
Integer oldValue = map.get(key);
if (oldValue == null) {
map.put(key, value);
}
}
// 修改如下:
public void safeUpdate(Integer key, Integer value) {
map.putIfAbsent(key, value);
}
特定值则修改
当多线程下同时调用如下unsafeUpdate
方法,,可以使用replace(K key, V oldValue, V newValue)
方法,但传入的newValue
若为null,则表示删除此元素
private static final Map<Integer, Integer> map = new ConcurrentHashMap<>();
public void unsafeUpdate(Integer key, Integer value) {
Integer oldValue = map.get(key);
if (oldValue == 1) {
map.put(key, value);
}
}
// 修改如下:
public void safeUpdate(Integer key, Integer value) {
map.replace(key, 1, value);
}
处理后插入
当get
值之后,进行相关逻辑的处理后,再进行put
操作的情况下,ConcurrentHashMap
提供的方法则不一定能保证线程安全了,则可以通过加同步锁synchronized
的方法来进行处理,如:
public void unsafeUpdate(Integer key, Integer value) {
Integer oldValue = map.get(key);
if (oldValue == 1) {
System.out.println(System.currentTimeMillis());
/**
* 其它业务操作
*/
System.out.println(System.currentTimeMillis());
map.put(key, value);
}
}
// 修改后:
public void safeUpdate(Integer key, Integer value) {
synchronized (map) {
Integer oldValue = map.get(key);
if (oldValue == null) {
System.out.println(System.currentTimeMillis());
/**
* 其它业务操作
*/
System.out.println(System.currentTimeMillis());
map.put(key, value);
}
}
}
但此时使用ConcurrentHashMap
意义不大,可以换成普通的HashMap
进行同步安全的操作
4、几种实现线程安全集合的方式:
Hashtable
线程安全类
Hashtable
是一个线程安全的类Hashtable
几乎所有的添加、删除、查询方法都加了synchronized
同步锁!- 相当于给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞等待需要的锁被释放,在竞争激烈的多线程场景中性能就会非常差,所以 Hashtable 不推荐使用!
Collections.synchronizedMap
方法
Collections.synchronizedMap
里面使用对象锁来保证多线程场景下,操作安全,本质也是对 HashMap 进行全表锁- 使用
Collections.synchronizedMap
方法,在竞争激烈的多线程环境下性能依然也非常差,所以不推荐使用!
二、延伸
ConcurrentHashMap
的并发优化历程
JDK5 :分段锁,必要时加锁
若key
是整数,对于3万以下的数,其key
计算出的hash
值的高位始终为15;3万~几十万的数,都分布在高位为14或15的段中,随着数的增加,才会均匀的分布在各段中,这就会导致都会堆在一个段中,达不到均匀分布的目的,缺陷所在。
//JDK 5 小整数的 Hash 高位 15
static int hash(Object x) {
int h = x.hasCode();
h += ~(h << 9);
h ^= (h >>> 14);
h += ~(h << 4);
h ^= (h >>> 10);
return h;
}
JDK6:优化二次Hash
算法
使用一个single-word Wang/Jenkins hash
(0xfffcd7d
),多了两步计算,得到的值是均匀分布在各段中的
//JDK 6 高低位均匀分布
private static int hash(int h) {
h += ~(h << 15) ^ 0xfffcd7d;
h ^= (h >>> 10);
h += ~(h << 3);
h ^= (h >>> 6);
h += ~(h << 2) + (h << 14);
return h ^ (h >>> 16);
}
JDK7:段懒加载,volatile
& cas
和之前的初始化方式不同了:
- JDK1.6前:
Segment
直接初始化,全部先实例化出来 - JDK1.7后:
Segment
在使用时初始化,懒加载,大量的使用了对数组的volatile
的访问,保证segment在使用时的可见性。(如图:段懒加载,虚线表示未加载)
JDK8:摒弃段,基于HashMap
原理的并发实现
JDK1.8摒弃段(segment
),也没有分段锁了,直接用getObjectVolatile
来访问table[]
,而加锁只针对table
中的entry
访问,将新来的元素加入到其后面的链表里。
CHM如何计数
- JDK 5~7 基于段元素个数求和,二次不同就加锁
- JDK 8 引入
CounterCell
,本质上也是分段计数(总数是循环累加得到)
CHM是弱一致性的
- 添加元素后不一定马上能读到 可能添加的时候,已经读过去了,则读不到了
- 清空之后可能仍然会有元素 清空是一段一段清空的,已经清空的那段,可能又添加了新的元素
- 遍历之前的段元素的变化会读到 在还没遍历到时,如果后面的有变化了,当读到的时候,就会读取这个变化了的元素
- 遍历之后的段元素变化读不到 已经遍历过的元素,如果发生了变化,就读不到这个变化了
- 遍历时元素发生变化不抛出异常
由于
ConCurrentHashMap
对于会产生并发操作的node节点都会有加锁同步处理,且迭代器获取tab[index]
首个节点时都会从主存来获取(保证获取到的数据是最新的),从而保证了迭代器在迭代过程中即使有put
、remove
等操作同时发生,也可以保证迭代的安全性,不会出现ConcurrentModeificationException
不过这样的迭代没办法保证数据的完整,即tab[0]
迭代后,其又追加内容则无法遍历到\
如何进行锁优化
- 长锁不如短锁:尽可能只锁必要的部分 减少加锁代码的长度
- 大锁不如小锁:尽可能对加锁的对象拆分
- 公锁不如私锁:尽可能将锁的逻辑放到私有代码中 在逻辑层面上,尽量将锁放到私有代码中。 若对外暴露,则可能带来锁的不正当使用,甚至可能导致死锁等问题
- 嵌套锁不如扁平锁:尽可能在代码设计时避免锁嵌套
- 分离读写锁:尽可能将读锁和写锁分离
一般来说,资源的读和写的频次是不一样的,大部分是在读,少部分是在写,此时写要加一个比较重的锁,而读使用
volatile
就够了,甚至不用加锁。 - 粗化高频锁:尽可能合并处理频繁过短的锁 每加一次锁,就需要一些开销,如果加锁和释放锁的频次特比高,开销就会很大 可以考虑进行合并,减少加锁的开销
- 消除无用锁:尽可能不加锁,或用
volatile
替代锁volatile
很多时候都能保证原子性和可见性,可以替代锁
参考链接
转载自:https://juejin.cn/post/7098612614241976334