HashMap 速描
HashMap 速描
之前简单的整理了Java集合的相关知识,发现HashMap并不是三言两语能够讲明白的,所以专门整理一下HashMap的相关知识。
HashMap 存储结构
HashMap是一个哈希表实现的Map类型的数据结构,内部是一个没有想象中那么长的Entry类型数组。Entry类型就是常说的键值对。
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
在HashMap的源码中,默认的表初始化长度为16,如果想要自定义表的长度也必须为2的n次方。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
在HashMap中,Node就是Entry类型的实现类,除了键值对之外,还有哈希值和一个next指针。不难得出,哈希表中的每一个位置都可以存放一个Node链表。
这样的结构缩短哈希表的长度,让数据量有限的情况下,既可以有较好的查询性能,也能减少空间的浪费。
但是如果数据量很大还使用默认的哈希表长度的话,会导致链表过长,查询性能下降。所幸HashMap是支持动态扩容的。
HashMap 插入操作的工作原理
在了解了HashMap的存储结构之后,有两个问题摆在我们面前。一是HashMap如何确定在表中的什么位置来插入元素,二是插入到链表的什么位置中去。
先来回答第一个问题,HashMap首先会获取对象的hashCode,然后将hashCode与表长做取余操作,也就是hashCode%表长,得到的值就是元素要插入到表中的位置。
第二个问题的答案是HashMap会采用头插法将新元素插入到链表头部。
这里有个比较特殊的情况,HashMap是支持将null作为键值的,但是无法获取null的hashCode,所以强制将null插入到下标为0的地方。
//HashMap哈希函数的实现
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//
好奇HashMap哈希函数工作原理及为什么哈希表长度必须是2的n次方的同学,可以参考下面的回答。
JDK 源码中 HashMap 的 hash 方法原理是什么? – 胖君的回答 – 知乎
所以HashMap在查找元素时会分两步:
- 算出元素在哈希表中的位置
- 遍历链表找到元素
HashMap 的扩容
上面我们提到如果数据量庞大还使用HashMap的默认哈希表长度,就会导致链表过长,查询效率下降,甚至退化成单向链表。幸运的是HashMap本身就支持动态扩容,HashMap会根据数据量大小来动态调整哈希表的长度,以此来让HashMap的查找效率与空间效率都能得到保障。
介绍几个重要参数:
- capacity: 哈希表的长度,默认为16。需要注意的是必须保证为2的n次方
- size: 键值对的数量
- threshold: 阈值,当size大于threshold时就必须进行扩容
- loadFactor: 装载因子,哈希表能够使用的比例,threshold=(int)(capacity*loadFactor)
哈希表的初始长度和loadFactor是可以在初始化HashMap时定义的,loadFactor的默认值为0.75f 。触发扩容的条件为插入新元素时检查size和threshold,如果size大于threshold,就会触发扩容。
/**
* Initializes or doubles table size. If null, allocates in
* accord with initial capacity target held in field threshold.
* Otherwise, because we are using power-of-two expansion, the
* elements from each bin must either stay at same index, or move
* with a power of two offset in the new table.
*
* @return the table
*/
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) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;//如果表长已经超过最大值,就会将threshold设置成一个很大的数来阻止HashMap继续扩容
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)//
newThr = oldThr << 1; // double threshold
}
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);
}
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) {
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;
}
计算数组容量
在创建HashMap时如果传入的capacity不是2的n次方,HashMap会自动将其转换成2的n次方。
链表转红黑树
从JDK 8开始,如果链表长度超过8,就会将链表转换成红黑树。
与HashTable相比
- HashTable使用synchronized来进行同步操作
- HashMap可以插入键为null的Entry
- 迭代HashMap时不可以添加元素和扩容
- 随时间推移,HashMap中的元素顺序可能会发生变化
ConcurrentHashMap
存储结构
ConcurrentHashMap与HashMap相比,存储结构相似,但是ConcurrentHashMap引入了分段锁,每个分段锁都维护一段表,这样不同的线程就能同时访问不同分段锁的哈希表。极大提高了访问效率。默认有16个分段锁。
size操作
每个 Segment 维护了一个 count 变量来统计该 Segment 中的键值对个数。
/**
* The number of elements. Accessed only either within locks
* or among other volatile reads that maintain visibility.
*/
transient int count;
在执行 size 操作时,需要遍历所有 Segment 然后把 count 累计起来。
ConcurrentHashMap 在执行 size 操作时先尝试不加锁,如果连续两次不加锁操作得到的结果一致,那么可以认为这个结果是正确的。
尝试次数使用 RETRIES_BEFORE_LOCK 定义,该值为 2,retries 初始值为 -1,因此尝试次数为 3。
如果尝试的次数超过 3 次,就需要对每个 Segment 加锁。
/**
* Number of unsynchronized retries in size and containsValue
* methods before resorting to locking. This is used to avoid
* unbounded retries if tables undergo continuous modification
* which would make it impossible to obtain an accurate result.
*/
static final int RETRIES_BEFORE_LOCK = 2;
public int size() {
// Try a few times to get accurate count. On failure due to
// continuous async changes in table, resort to locking.
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; // true if size overflows 32 bits
long sum; // sum of modCounts
long last = 0L; // previous sum
int retries = -1; // first iteration isn't retry
try {
for (;;) {
// 超过尝试次数,则对每个 Segment 加锁
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force creation
}
sum = 0L;
size = 0;
overflow = false;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
sum += seg.modCount;
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
// 连续两次得到的结果一致,则认为这个结果是正确的
if (sum == last)
break;
last = sum;
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return overflow ? Integer.MAX_VALUE : size;
}
JDK 8的改动
JDK 1.7 使用分段锁机制来实现并发更新操作,核心类为 Segment,它继承自重入锁 ReentrantLock,并发度与 Segment 数量相等。
JDK 1.8 使用了 CAS 操作来支持更高的并发度,在 CAS 操作失败时使用内置锁 synchronized。
并且 JDK 1.8 的实现也在链表过长时会转换为红黑树。
LinkedHashMap
存储结构
LinkedHashMap继承自HashMap,因此有HashMap一样的快速查找特性。
除此之外,在LinkedHashMap内部还维护了一个双向链表来记录数据的插入顺序或者最近最少使用(LRU)顺序。
accessOrder决定了记录哪个顺序,默认为false,记录插入顺序。
/**
* The iteration ordering method for this linked hash map: {@code true}
* for access-order, {@code false} for insertion-order.
*
* @serial
*/
final boolean accessOrder;
LinkedHashMap使用下面两个方法来维护链表
- afterNodeInsertion
- afterNodeAccess
afterNodeInsertion
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
在 put 等操作之后执行,当 removeEldestEntry() 方法返回 true 时会移除最晚的节点,也就是链表首部节点 first。
evict 只有在构建 Map 的时候才为 false,在这里为 true。
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
removeEldestEntry() 默认为 false,如果需要让它为 true,需要继承 LinkedHashMap 并且覆盖这个方法的实现,这在实现 LRU 的缓存中特别有用,通过移除最近最久未使用的节点,从而保证缓存空间足够,并且缓存的数据都是热点数据。
afterNodeAccess
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMap.Entry<K,V> last;
if (accessOrder && (last = tail) != e) {
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
p.after = null;
if (b == null)
head = a;
else
b.after = a;
if (a != null)
a.before = b;
else
last = b;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
tail = p;
++modCount;
}
}
如果accessOrder为true,那么在访问一个节点后会将节点移动到链表尾部,保证链表尾部是最近访问的节点,那么链表首部就是最近最久未使用的节点。
LRU 缓存
继承LinkedHashMap可以快速的实现一个LRU缓存,下面是一个LRU的例子:
class LRUCache<K, V> extends LinkedHashMap<K, V> {
private static final int MAX_ENTRIES = 3;
//重写removeEldestEntry方法使元素数量大于3时将最近最久未使用的元素移除
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > MAX_ENTRIES;
}
LRUCache() {
super(MAX_ENTRIES, 0.75f, true);
}
}
public static void main(String[] args) {
LRUCache<Integer, String> cache = new LRUCache<>();
cache.put(1, "a");
cache.put(2, "b");
cache.put(3, "c");
cache.get(1);//访问1使(1,"a")重新置于链表尾部
cache.put(4, "d");//加入4使数量大于3而将最近最少使用的2移除
System.out.println(cache.keySet());
}
[3, 1, 4]
WeakHashMap
存储结构
WeakHashMap 的 Entry 继承自 WeakReference,被 WeakReference 关联的对象在下一次垃圾回收时会被回收。
WeakHashMap 主要用来实现缓存,通过使用 WeakHashMap 来引用缓存对象,由 JVM 对这部分缓存进行回收。
ConcurrerntCache
Tomcat 中的 ConcurrentCache 使用了 WeakHashMap 来实现缓存功能。
ConcurrentCache 采取的是分代缓存:
- 经常使用的对象放入 eden 中,eden 使用 ConcurrentHashMap 实现,不用担心会被回收(伊甸园);
- 不常用的对象放入 longterm,longterm 使用 WeakHashMap 实现,这些老对象会被垃圾收集器回收。
- 当调用 get() 方法时,会先从 eden 区获取,如果没有找到的话再到 longterm 获取,当从 longterm 获取到就把对象放入 eden 中,从而保证经常被访问的节点不容易被回收。
- 当调用 put() 方法时,如果 eden 的大小超过了 size,那么就将 eden 中的所有对象都放入 longterm 中,利用虚拟机回收掉一部分不经常使用的对象。
public final class ConcurrentCache<K, V> {
private final int size;
private final Map<K, V> eden;
private final Map<K, V> longterm;
public ConcurrentCache(int size) {
this.size = size;
this.eden = new ConcurrentHashMap<>(size);
this.longterm = new WeakHashMap<>(size);
}
public V get(K k) {
V v = this.eden.get(k);
if (v == null) {
v = this.longterm.get(k);
if (v != null)
this.eden.put(k, v);
}
return v;
}
public void put(K k, V v) {
if (this.eden.size() >= size) {
this.longterm.putAll(this.eden);
this.eden.clear();
}
this.eden.put(k, v);
}
}