数据结构之Map
# HashMap
# 简介
在Java中,HashMap是Map的最常用的。HashMap主要用来存放键值对,它基于哈希表的Map接口实现,是常用的Java集合之一。与HashTable主要区别为不支持同步和允许null作为key和value,所以如果你想要保证线程安全,可以使用ConcurrentHashMap代替而不是线程安全的HashTable,因为HashTable基本已经被淘汰
# 版本差异
# JDK1.8之前
JDK1.8之前,HashMap使用了拉链法解决冲突。也就是在Hash冲突的时候,直接将冲突的加入链表即可 缺陷:如果散列分布不均匀,导致大量不同数据都有同一个Hash地址,这样拉链法的这个链表就会很长了,如果过于长,会降低HashMap的性能。
# JDK1.8之后
为了解决当链表过长出现的性能问题,HashMap采用拉链法+红黑树的方式解决冲突。也就是首先使用拉链法的方式,当链表长度达到一定长度之后(默认为8),就将链表转换成红黑树。
# 源码探究
以下都来自JDK1.8
# 核心成员
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
// 序列号
private static final long serialVersionUID = 362498820763181265L;
// 默认的初始容量是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的填充因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
// 当桶(bucket)上的结点数小于这个值时树转链表
static final int UNTREEIFY_THRESHOLD = 6;
// 桶中结构转化为红黑树对应的table的最小大小
static final int MIN_TREEIFY_CAPACITY = 64;
// 存储元素的数组,总是2的幂次倍
transient Node<k,v>[] table;
// 存放具体元素的集
transient Set<map.entry<k,v>> entrySet;
// 存放元素的个数,注意这个不等于数组的长度。
transient int size;
// 每次扩容和更改map结构的计数器
transient int modCount;
// 临界值 当实际大小(容量*填充因子)超过临界值时,会进行扩容
int threshold;
// 装填因子
final float loadFactor;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
- 其中的MAXIMUM_CAPACITY = 1 << 30; 相当于是2的30次方。int一共32位,为什么不左移31位呢,也是2的幂次。原因就是因为31位为符号位,如果左移31位了就会是一个负数了,所以只能左移31位。
- loadFactor就是装填因子,具体可以查看哈希表的那篇博文,HashMap默认设置为0.75f。
- threshold,也就是容量阈值,就是装到了多少比例了再扩容,当作变量是不用每次都计算。只在容量变化时候计算。
# 树节点与链表节点
- 链表节点
static class Node<K,V> implements Map.Entry<K,V> {
// 哈希值,存放元素到hashmap中时用来与其他元素hash值比较
final int hash;
final K key;
V value;
// 指向下一个节点
Node<K,V> next;
}
2
3
4
5
6
7
8
- 红黑树节点
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent;
TreeNode<K,V> left;
TreeNode<K,V> right;
// needed to unlink next upon deletion
TreeNode<K,V> prev;
// 是否为红色节点
boolean red;
}
2
3
4
5
6
7
8
9
# 增删查方法
1.put与putVal
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
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)
n = (tab = resize()).length;
//这里对hash值进行取余,保证hash的地址在数组长度以内,并且确定存放在哪个桶中,如果桶为空,则新的节点就放入桶中(在数组中的)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//证明此时桶不为空
else {
Node<K,V> e; K k;
//桶的第一个元素hash值和key值和新的节点一样
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//hash值不相等,即key不相等;
//为红黑树结点 时候
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//为链表结点 时候
else {
//遍历到最后一个节点,将新的节点插入尾部
for (int binCount = 0; ; ++binCount) {
//没有下一个节点了则到尾部了
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//如果链表长度到达了阈值,则转化成红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//遍历到的链表中的这个节点hash值和key值和新的节点一样,发现存在了,跳出循环。当前e为这个重复的节点。p为上一个节点
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
//切换父节点为当前节点,做遍历
p = e;
}
}
//e的值存在
if (e != null) { // existing mapping for key
V oldValue = e.value;
//只有在值不存(onlyIfAbsent=false)在的或者为Null的时候才更新
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 访问后回调
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//判断是否需要扩容
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
可以看到put方法只是对key进行了hash,然后调用了putVal方法,核心的添加逻辑还是在putVal方法里面。 因为putTreeVal有添加树节点和红黑树的转换,不是这里的重点,具体可以查看红黑树那篇。
- remove与removeNode
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
//hash表已经初始化,并且这个位置有节点,才进入逻辑
if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
//桶中第一个元素key和hash值与新来的key相等
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
node = p;
//不相等的时候,如果存下一个节点
else if ((e = p.next) != null) {
//桶中第一个节点节点为树节点时,拿到这个要删除的节点
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
//为链表时
else {
//遍历链表,直到找到
do {
if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
//这个key对应的节点存在,并且不匹配值,或者值与期望的值相等,则进行删除。
if (node != null && (!matchValue || (v = node.value) == value ||(value != null && value.equals(v)))) {
//如果是树节点,就调用删除树节点的方法
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
//如果是第一个节点,那就把节点前移 ,桶中第一个节点变为下一个节点
else if (node == p)
tab[index] = node.next;
//否则 直接把节点前移
else
p.next = node.next;
++modCount;
--size;
//一处后的回调
afterNodeRemoval(node);
return node;
}
}
//没有找到这个key则直接返回null
return null;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
- get与getNode
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//hash表已经初始化,并且这个位置有节点,才进入逻辑
if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
//如果桶中第一个就是了,直接返回
if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
return first;
//如果存在下一个节点
if ((e = first.next) != null) {
//桶中第一个如果是树节点。直接查找树
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//不是的话就遍历链表,知道找到为止
do {
if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
//没有找到返回null
return null;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# hash表扩容
当hashmap中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对hashmap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,所以这是一个通用的操作,很多人对它的性能表示过怀疑,不过想想我们的“均摊”原理,就释然了,而在hashmap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。
扩容条件就是当threshold=loadFactory*capacity大于等于hash表当前的节点个数
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) {
//如果旧的容量大于了最大的容量 2的30次方,装不下了,不再扩容了
if (oldCap >= MAXIMUM_CAPACITY) {
//设置为最大,后续不再进入此方法了
threshold = Integer.MAX_VALUE;
return oldTab;
}
//两倍扩容
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//未初始化的时候
//初始阈值大于0则将新的容量设置为旧的阈值
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//否则使用默认的容量进行初始化新的容量
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
//计算新的阈值
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//计算新的阈值,如果新的阈值为0的话
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 = newTab;
if (oldTab != null) {
//遍历每个桶,拷贝到新的hash数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//如果桶不为空
if ((e = oldTab[j]) != null) {
//释放以前节点的空间
oldTab[j] = null;
//这个桶只有这一个节点
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;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
对于以上的为链表时候的扩容源码解读,因为一两句说不清楚,推荐阅读深入理解HashMap(四): 关键源码逐行分析之resize扩容 (opens new window)
# 线程不安全可能引发的链表成环问题(JDk1.7)
推荐阅读大多数人不知道的:HashMap链表成环的原因和解决方案 (opens new window)
# HashTable
HashTable和HashMap的实现原理几乎一样, 差别无非是
- HashTable不允许key和value为null;
- HashTable是线程安全的。
但是HashTable线程安全的策略实现代价却太大了,简单粗暴,get/put所有相关操作都是synchronized的,这相当于给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。
# CurrentHashMap
# 介绍
JDK1.7 HashTable性能差主要是由于所有操作需要竞争同一把锁,而如果容器中有多把锁,每一把锁锁一段数据,这样在多线程访问时不同段的数据时,就不会存在锁竞争了,这样便可以有效地提高并发效率。这就是ConcurrentHashMap所采用的"分段锁"思想。
JDK1.8 取消segments字段,直接采用transient volatile HashEntry<K,V>[] table保存数据,采用table数组元素作为锁,从而实现了对每一行数据进行加锁,并发控制使用Synchronized和CAS来操作
# 区别
从JDK1.7版本的ReentrantLock+Segment+HashEntry,到JDK1.8版本中synchronized+CAS+HashEntry+红黑树,总结如下:
- JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8实现降低锁的粒度就是HashEntry(首节点)
- JDK1.8版本的数据结构变得更加简单,去掉了Segment这种数据结构,使用synchronized来进行同步锁粒度降低,所以不需要分段锁的概念,实现的复杂度也增加了
- JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,代替一定阈值的链表,这样形成一个最佳拍档
- JDK1.8为什么使用内置锁synchronized来代替重入锁ReentrantLock:
- 低粒度加锁方式,synchronized并不比ReentrantLock差,粗粒度加锁中ReentrantLock可能通过Condition来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了
- JVM的开发团队从来都没有放弃synchronized,而且基于JVM的synchronized优化空间更大,使用内嵌的关键字比使用API更加自然
- 在大量的数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多的内存
# TreeMap
TreeMap底层为红黑树,线程不安全的,如果需要使用请使用线程安全的,可以使用
Collections.synchronizedSortedMap();
Collections.synchronizedMap(new TreeMap());
2
3
4