java.util.Map
框架:
HashMap
1 | public class HashMap<K,V> extends AbstractMap<K,V> |
预备知识
- 红黑树是一种特殊的二叉查找树,由平衡二叉查找树进化而来(在AVL中,保持全树的平衡开销太大),红黑树只是保持局部平衡,即从每个节点向下直到叶子节点的路径中包含的黑色节点数量相同,达到一种”弱平衡”。它可以在 logn 时间内做查找,插入和删除,n是树中节点的数目。特性:
- 根节点是黑色
- 从任一节点到其每个叶子节点的所有简单路径都包含相同数目的黑色节点
- 叶子节点是不放数据的黑色节点
- 红色节点不能连续(红色节点的孩子和父亲节点颜色都不能是红色)
介绍
- 继承自
AbstractMap
,实现了Cloneable
接口但是是浅拷贝,实现了Seriable
可以进行序列化,实现了Map
。 - 基于数组(
Node
) + 链表(Node
) + 红黑树(TreeNode
)实现,当链表长度达到8时,链表会变为红黑树。在resize()
对红黑树进行切割split()
时,如果切割后的红黑树大小减少到6就变回链表。 - 非线程安全,如果需要线程安全需要使用
ConcurrentHashMap
、Collections.synchronizedMap(new HashMap())
、HashTable
- 可以存一个null key,数组下标默认是0,之后的null key会覆盖原来的。
- 数据不保证有序(放进去的顺序和拿出来的顺序不一样),如果需要有序可以使用
TreeMap
、LinkedHashMap
- 数组长度是2的幂,初始是16,最大值是32。
- 数组长度是2的幂,这样可以通过位操作(&)代替%提高计算效率,数组.length – 1 使得低位数字全为1 使得 & 数组.length – 1得到的分布比不是2的幂的情况要均匀。
- 通过用key的
hashcode()
再次求hash然后通过位运算得出要存的数组下标。 - 默认加载因子是0.75,当数组中元素的数量超过数组长度的0.75倍会进行扩容。
- 通过拉链法解决hash冲突,除此之外解决hash冲突的方法还有开放地址法(再散列法)、再哈希法、建立公共溢出区。
部分源码分析
变量/常量
1 | // 数组默认容量,2^4 |
get(Object key)
1 | /** |
put(K key, V value)
1 | /** |
public V remove(Object key)
删除key对应的节点
1 | public V remove(Object key) { |
resize()
1 | /** |
Q&A
- JDK8在
resize()
中,将原先的链表通过if((e.hash & oldCap) == 0)
分割成两个子链表,这两个子链表在新数组中的下标为什么是j
和j + oldCap
?这样在新数组中通过hash & newTab.length - 1
能对应上?- 对于
(e.hash & oldCap) == 0
的元素(后面的分析都是基于这个前提) => 在原数组中的下标等于要转移到新数组中的下标。原因:(e.hash & oldCap) == 0?
就是判断oldCap
高位对应的hash
位是否为0。未扩容之前,hash
只和oldCap - 1
低位做&
运算来求在原始数组中的下标,扩容之后本质上还是和oldCap - 1
低位做&
运算来求在新数组中的下标。- 二倍扩容后,新数组的长度
newCap
为oldCap << 1
,之后求元素在新数组中的下标(hash & (newCap - 1))
。因为newCap - 1
高位为0,与此同时高位后一个位(也就是oldCap
高位)对应的hash
位也为0,所以(hash & (newCap - 1))
等价于(hash & (oldCap - 1))
。举个例子:
- 对于
假设oldCap
值是2^4 = 16
,某个元素e的hash
是0111 0101
。这个元素在原始数组中的下标应该是hash & oldCap - 1
= 5。此时(e.hash & oldCap) == 0
,根据结论:如果扩容以后,元素e所在新数组的下标应该还是hash & oldCap - 1
= 5。 事实是这样:hash & newCap - 1
= 5。
变量 | 二进制值 | 备注 |
---|---|---|
oldCap | 0000 1000 | 因为是2的幂所以高位为1,低位全为0。 |
hash | 0111 0101 | 随意选的 |
oldCap - 1 | 0000 0111 | oldCap - 1的高位为0,低位全为1 |
newCap | 0001 0000 | 等于2*oldCap ,将oldCap左移一位即可 |
此时hash & oldCap
= 0推出oldCap
高位对应的hash
位一定为0。否则求出的值绝对不是0。
如下图:当通过hash & (newCap - 1)
求元素e在新数组中的下标,实际是和最后三位进行&
运算,计算结果和hash & (oldCap - 1)
一样
变量 | 二进制值 | 备注 |
---|---|---|
hash | 0111 0101 | oldCap高位对应的hash位为0 |
newCap -1 | 0000 1111 | |
oldCap - 1 | 0000 0111 |
- 对于
(e.hash & oldCap) != 0
的元素(后面的分析都是基于这个前提) => 扩容后元素e在新数组中的下标等于元素e在原数组中的下标+原数组长度。 原因:(新数组下标计算过程)- 新数组下标
- hash & (newCap - 1)👇
- hash & (2 * oldCap - 1)👇
- hash & (oldCap + oldCap -1)👇
- (hash & oldCap - hash & 1) + hash & oldCap👇
- 由
(e.hash & oldCap) != 0
可以推出oldCap
高位对应的hash
位不为0可以推出hash & oldCap == oldCap
- 由
- hash & (oldCap - 1) + oldCap👇
- 原数组下标 + 原数组长度数组
- 新数组下标
变量 | 二进制值 | 备注 |
---|---|---|
hash | 0111 1101 | oldCap高位对应的hash位不为0 |
newCap -1 | 0000 1111 | |
oldCap - 1 | 0000 0111 |
为什么长度为6时用链表,为8时用红黑树?中间的7是干嘛的?
- 因为红黑树查找时间复杂度为O(LogN),链表查找时间复杂度为O(N),当链表中节点数量较多时,为了提高查找效率会采用红黑树。又因为红黑树的节点大约是链表节点的两倍,所以为了节省空间,链表转红黑树的阈值不能太小。
- 对于分布均匀的hash函数来说,桶中冲突元素的数量服从泊松分布,冲突元素数量是8的概率为千万分之一,是一个小概率事件,如果这个小概率事件发生了,说明冲突比较严重,这时就会将链表转为红黑树提高查询效率。
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.
- 中间隔一个7为了防止链表和红黑树频繁转换影响效率。如果不隔7的话,元素个数频繁从6变到7或者从8变到7,会造成红黑树和链表频繁的进行转换。(个人理解)
为什么不是线程安全的?具体体现在哪儿?
- 在多线程的情况下,并发执行
resize()
可能会产生环形链表,从而在get()
时可能差生inflate loop。
- 在多线程的情况下,并发执行
- 比如并发插入元素时,会并发修改size。
为什么产生环形链表?
主要是由于转移链表时在新数组中的顺序和原数组顺序不一致导致的。
为什么求出hashCode()之后要二次hash?
- 二次hash为了使得哈希码低位元素更加具有随机性。
为什么数组长度是2的幂?
- 可以通过位操作
&
代替%
进行取模运算提高效率。 - 可以使得求出的数组下标充分依赖hash码,因为2的幂 - 1保证了低位全为1,做&运算可以完全依赖hash,而hash已经通过二次hash随机性很强,从而使得分布会相对均匀。
- 可以通过位操作
为什么加载因子loadFactor默认是0.75?
- 加载因子太大:空间利用率高,查询效率变低,容易发生冲突。
- 加载因子太小:不容易产生冲突,查询效率高,空间利用率低,频繁扩容会产生性能影响
As a general rule, the default load factor (.75) offers a good tradeoff between time and space costs. Higher values decrease the space overhead but increase the lookup cost (reflected in most of the operations of the HashMap class, including get and put). The expected number of entries in
the map and its load factor should be taken into account when setting its initial capacity, so as to minimize the number of rehash operations. If the initial capacity is greater than the maximum number of entries divided by the load factor, no rehash operations will ever occur.
JDK8和JDK7关于HashMap的区别?
- JDK7基于数组加链表实现,JDK8引入了红黑树解决了发生hash冲突后,链表过长导致查询效率变低的问题。
- JDK7采用单链表头插法解决hash冲突,JDK8采用尾插法
- JDK8 resize()后的链表中元素的相对顺序不变。不会产生环形链表(个人看法,没证明)
- JDK7数组类型是Entry类型,JDK8改为Node类型都是实现的Map.Entry接口。只是改了个名字
HashMap和HashTable的区别?
- HashMap相对于HashTable可以存nul key 和 null value。 HashTable相对于HashMap是线程安全的。
- HashMap相对于HashTable可以存nul key 和 null value。 HashTable相对于HashMap是线程安全的。
为什么key一般用不变对象,比如String、Integer?
- 如果对象可变,比如User对象的name属性变了则计算出来的hash会变化,导致求出的数组下标会变从而找不到之前User对应的value。建议key尽量是String或者Integer,不应该是可变的对象。
解决hash冲突的方法?
- 开放地址法(再散列法)
- 线性探测:如果冲突了以后,继续向后查找直到找到一个空位
- 二次探测:相对于线性探测,它是两步两步的跳着找空位,比较灵活
开放地址法适合存放较少数量的键值对,比如
Thread
中维护的ThreadLocalMap
就是用的开放地址法解决的哈希冲突,因为一般通过ThreadLocal
设置的键值对数量比较少。如果存放的数量过多,最坏的时间复杂度可能会达到O(n)
- 链地址法:将冲突的元素,构造成一个单链表
- 再哈希法:提前构造多个hash函数,如果某个hash函数冲突以后,再换别的hash函数,直到找到不冲突的为止
- 建立公共溢出区:将冲突的元素放到溢出区中,和不冲突的元素分开。
参考
LinkedHashMap
1 | public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V> |
介绍
LinkedHashMap
继承自HashMap
,在此基础上,将所有键值对用双向链表链了起来,这样可以实现元素的迭代顺序和元素的插入顺序一致。在HashMap基础上加了钩子函数,可以实现元素的迭代顺序和元素的访问顺序一致(访问一个元素以后就会将元素放到双链表的尾部),通过这个特性可以实现LRU cacheLinkedHashMap
不是线程安全的,如果需要线程安全需要使用Collections.synchronizedMap(new LinkedHashMap())
LinkedHashMap
继承了HashMap
,用到了模板模式。一般的get
、put
、remove
、都用到了HashMap
中的操作
部分源码解析
变量
1 | // 双向链表头指针,在LRU中可以理解为最【老】的节点(如果缓存不够最先剔除的节点) |
void afterNodeInsertion(boolean evict)
插入元素e后会根据removeEldestEntry(first)
这个方法设定的阈值来决定是否要移除最老的元素
1 | // 当向map中插入元素后执行的操作 |
public V get(Object key)
访问元素e后,如果提前设置了accessOrder
为true
,就会调用afterNodeAccess(Node<K,V> e)将元素e放到链表的末尾(tail),来实现LRU Cache。
1 | public V get(Object key) { |
afterNodeRemoval(Node<K,V> e)
当执行HashMap
中的remove(Object key)
删除节点key
对应的元素e后,会执行双向链表中删除节点的操作
1 | void afterNodeRemoval(Node<K,V> e) { // unlink |
Q&A
- 实现一个LRU Cache?
- 设置
accessOrder
为true,保证最近访问过的元素是最新元素 - 重写
removeEldestEntry(Map.Entry eldest)
设置一个缓存大小
- 设置
1 | public class LRUCache<K, V> extends LinkedHashMap<K, V> { |
WeakHashMap
1 | public class WeakHashMap<K,V> extends AbstractMap<K,V> implements Map<K,V> |
预备知识
Java中的四种引用
不同的引用类型体现了对垃圾回收的影响,并且可以在一定程度上允许我们干涉自动垃圾回收器。在Java中主要有四种引用类型:
强引用(StrongReference)
强引用是通过赋值运算符=
将所引用的对象obj
关联起来。比如Object obj = new Object()
。如果一个对象的强引用还在,该对象就不会被JVM回收。
软引用(SoftReference)
软引用通过SoftReference
将所引用的对象obj
👆关联起来。比如SoftReference sr = new SoftReference(obj)
。在内存不足要发生内存溢出OOM之前,会被JVM回收。
- 如果
SoftReference
注册了引用队列ReferenceQueue
,当回收软引用关联的对象之后,会将该软引用加入到ReferenceQueue
。 - 和软引用关联的对象有用但不是必需的,所以软引用可以做缓存。当JVM内存不足时,会将这部分缓存回收掉。比如用户打开了多个图片,就需要加载多个图片内容到内存中,每个图片内容(字节数组)可以和软引用关联当作缓存,如果内存不足时就会清除内存中缓存的图片内容(字节数组)。不过要注意:当要再次查看图片时每次都要判断图片内容是否已经被回收(判断引用队列中是否有引用了),如果被回收需要重新加载到内存。
1 | // 一个Image相当于一个图片,如果打开多个图片等价于创建多个Image对象 |
弱引用(WeakReference)
- 弱引用通过
WeakReference
将所引用的对象obj
关联起来。比如WeakReference sr = new WeakReference(obj)
,和弱引用关联的对象在垃圾回收时,会被JVM回收。- 如果
WeakReference
注册了引用队列ReferenceQueue
,当回收弱引用关联的对象之后,会将该弱引用加入到ReferenceQueue
。 - 和弱引用关联的对象不是必需的,弱引用多用在哈希表中,比如WeakHashMap通过
WeakReference
可以回收每一个被回收的key对象所关联的Entry(详细见下面源码分析👇)
- 如果
软引用、弱引用基本上没有本质上的区别,仅仅是软引用对象比弱引用对象的命更长一些罢了。(参考链接)
因此,软引用、弱引用都适合用来实现内存敏感的高速缓存,具体使用哪种引用,这里给几条参考:
1) 如果只是想避免 OutOfMemory 的发生,则可以使用软引用;如果对于性能更在意,想尽快回收一些占用内存比较大的对象,则可以使用弱引用。
2) 如果对象可能会经常使用的,就尽量用软引用;如果对象不被使用的可能性更大些,就可以用弱引用。
幽灵引用(PhantomReference)
- 幽灵引用通过
PhantomReference
将所引用的对象obj
关联起来。比如PhantomReference sr = new PhantomReference(obj)
,和幽灵引用关联的对象等价于没有被引用,随时可能被回收。所以如果不给幽灵引用注册引用队列,那这个幽灵引用就没有意义。- 如果
PhantomReference
注册了引用队列ReferenceQueue
,当回收幽灵引用关联的对象之前(finalize()之后),会将该幽灵引用放到引用队列中。 - 因为每个对象在回收之前,还会执行继承自
Object
的finalize()
用来做一些资源清理的操作,但是JVM什么时间执行finalize()
不是确定的。考虑一个场景,在要分配内存创建新的对象时,要确定某个对象要被回收才能分配。此时可以通过幽灵引用结合引用队列实现,当执行了finalize()
会将要回收的对象关联的幽灵引用放到引用队列中。此时可以确定这个对象马上要被回收,可以分配内存。
- 如果
1 | public class PhantomBuffer { |
介绍
WeakHashMap
中的Entry
内部的key
指向的对象可能会被GC回收,即使没有手动调用remove()
或者clear()
方法。因为WeakHashMap
中的Entry
继承自WeakReference
,把key
引用的对象和弱引用关联起来(Entry
继承了WeakReference
,也就是将key
和Entry
关联起来)在JVM进行垃圾回收时会将key
引用的对象回收。每次执行get、put、resize()等操作时会根据引用队列提前判断key
所引用的对象是否被回收来决定是否清除该key
关联的Entry
以及Entry
中的value
指向的对象,来尽量避免内存泄漏。
部分源码分析
Entry结构
Entry
继承了WeakReference
,此时Entry
也是一个虚引用。每次创建新的Entry
时会将key和Entry关联,并且中。当回收了key
所引用的对象以后,会将Entry
放到引用队列queue
中。所以在每次操作时都会从引用队列中取一个Entry
释放掉。
1 | private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V> { |
public V get(Object key)
根据key获取value,当调用gettable()时会调用expungeStaleEntries(),然后从引用队列中取出Entry,释放掉Entry以及Entry中的value。
在WeakHashMap定义的增、删、改、查方法中,都要调用gettable()。
1 | public V get(Object key) { |
参考
TreeMap
1 | public class TreeMap<K,V> extends AbstractMap<K,V> implements NavigableMap<K,V>, Cloneable, Serializable |
介绍
TreeMap
是基于红黑树实现的一种Map
,对于get、put、remove时间复杂度是log(n)。元素是有序的,顺序是按照key的自然顺序或者是定义的Comparator
。TreeMap
不是线程安全的。如果需要线程安全的TreeMap
需要使用SortedMap m = Collections.synchronizedSortedMap(new TreeMap(...));
TreeMap
实现了NavigableMap
间接实现了SortedMap
。注意的是SortedMap
比较两个key
是否相等不是按Map
中规定的equals()
,而是用自己定义的Comparator
中的compareTo()
。 参考Java doc:This is so because the Map interface is defined in terms of the equals operation, but a sorted map performs all key comparisons using its compareTo (or compare) method, so two keys that are deemed equal by this method are, from the standpoint of the sorted map, equal. The behavior of a sorted map is well-defined even if its ordering is inconsistent with equals; it just fails to obey the general contract of the Map interface.
使用注意
因为是通过compareTo()比较两个key是否相等,如果compareTo()写错了会出现TreeMap中明明存在该key,并且equals()返回true,但是get()返回null。如下:(下面代码转自廖雪峰大神)
1 | public class Student implements Comparable<Student> { |
用HashMap
测试能正常输出 99 88 77。
1 | Map<Student, Integer> map = new HashMap<>(); |
用TreeMap
测试输出 null null null,因为通过hash确定下标后,通过compareTo()返回值是否为0找到key,但是现在compareTo()不会返回0…..,所以会找不到key,然后返回null。
重写compareTo()
,保证当key
相等时返回0
1 | public int compareTo(Student o) { |
应用场景
一致性哈希 代码实现思路参照链接
实现负载均衡,因为TreeMap提供了获取第一个大于当前节点的API,ceilingEntry()
- 建环
- 构造虚拟节点
- 接受请求,根据请求定位
1
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
76public class ConsitentHash {
// 虚拟节点数量
private static final int VIRTUAL_NODE_SIZE = 1000;
// 每个物理服务器中虚拟节点的分隔符
private static final String DILEMMA = "-";
// 哈希策略
private HashStrategy hashStrategy = new JDKHashCodeStrategy();
public Server select(List<Server> servers, Request request) {
TreeMap<Integer, Server> hashRing = buildRing(servers);
return locate(request, hashRing);
}
private TreeMap<Integer, Server> buildRing(List<Server> servers) {
TreeMap<Integer, Server> hashRing = new TreeMap<>();
servers.forEach(server -> {
for (int i = 0; i < VIRTUAL_NODE_SIZE; i++)
hashRing.put(hashStrategy.getHashCode(server.getUrl() + DILEMMA + i), server);
});
return hashRing;
}
private Server locate(Request request, TreeMap<Integer, Server> hashRing) {
Server server;
int key = hashStrategy.getHashCode(request.getHashKey());
Map.Entry<Integer, Server> serverEntry = hashRing.ceilingEntry(key);
return serverEntry == null ? hashRing.firstEntry().getValue() : serverEntry.getValue();
}
class Server {
private String url;
public void setUrl(String url) {
this.url = url;
}
public String getUrl() {
return this.url;
}
}
abstract class HashStrategy {
public abstract int getHashCode(String origin);
}
class JDKHashCodeStrategy extends HashStrategy {
public int getHashCode(String origin) {
return origin.hashCode();
}
}
class Request {
private String hashKey;
public String getHashKey() {
return hashKey;
}
public void setHashKey(String hashKey) {
this.hashKey = hashKey;
}
}
}