详解Java 容器(第④篇)——容器源码分析 - Map

272 阅读12分钟

如果没有特别说明,以下源码分析基于 JDK 1.8。

一、HashMap

为了便于理解,以下源码分析以 JDK 1.7 为主。

1. 存储结构

内部包含了一个 Entry 类型的数组 table。

transient Entry[] table;

Entry 存储着键值对。它包含了四个字段,从 next 字段我们可以看出 Entry 是一个链表。 即数组中的每个位置被当成一个桶,一个桶存放一个链表。HashMap 使用拉链法来解决冲突, 同一个链表中存放哈希值相同的 Entry。

static class Entry<K,V> implements Map.Entry<K,V> {
	//包含了四个字段
	final K key;
	V value;
	//next指向下一个节点,说明是链表结构
	Entry<K,V> next;
	int hash;
	Entry(int h, K k, V v, Entry<K,V> n) {
		value = v;
		next = n;
		key = k;
		hash = h;
	}
	public final K getKey() {
		return key;
	}
	public final V getValue() {
		return value;
	}
	public final V setValue(V newValue) {
		V oldValue = value;
		value = newValue;
		return oldValue;
	}
	public final Boolean equals(Object o) {
		if (!(o instanceof Map.Entry))
		            return false;
		Map.Entry e = (Map.Entry)o;
		Object k1 = getKey();
		Object k2 = e.getKey();
		// k1==k2 比较的是 hashcode 值,
		// k1.equals(k2)比较的是k1和k2的内容 equals 未重写,则等价于 k1 == k2
		if (k1 == k2 || (k1 != null && k1.equals(k2))) {
			Object v1 = getValue();
			Object v2 = e.getValue();
			if (v1 == v2 || (v1 != null && v1.equals(v2)))
			                return true;
		}
		return false;
	}
	public final int hashCode() {
		return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
	}
	public final String toString() {
		return getKey() + "=" + getValue();
	}
}

2. 拉链法的工作原理

HashMap<String, String> map = new HashMap<>();
map.put("K1", "V1");
map.put("K2", "V2");
map.put("K3", "V3");
  • 新建一个 HashMap,默认大小为 16;
  • 插入 <K1,V1> 键值对,先计算 K1 的 hashCode 为 115,使用除留余数法得到所在的桶下标 115%16=3。
  • 插入 <K2,V2> 键值对,先计算 K2 的 hashCode 为 118,使用除留余数法得到所在的桶下标 118%16=6。
  • 插入 <K3,V3> 键值对,先计算 K3 的 hashCode 为 118,使用除留余数法得到所在的桶下标 118%16=6,插在 <K2,V2> 前面。

应该注意到链表的插入是以头插法方式进行的,例如上面的 <K3,V3> 不是插在 <K2,V2> 后面,而是插入在链表头部。

查找需要分成两步进行:

  • 计算键值对所在的桶;
  • 在链表上顺序查找,时间复杂度显然和链表的长度成正比。

3. put 操作

public V put(K key, V value) {
	if (table == EMPTY_TABLE) {
		inflateTable(threshold);
	}
	// 键为 null 单独处理
	if (key == null)
	        return putForNullKey(value);
	int hash = hash(key);
	// 确定桶下标
	int i = indexFor(hash, table.length);
	// 先找出是否已经存在键为 key 的键值对,如果存在的话就更新这个键值对的值为 value
	// 时间复杂度显然和链表的长度成正比。
	for (Entry<K,V> e = table[i]; e != null; e = e.next) {
		Object k;
		if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
			V oldValue = e.value;
			e.value = value;
			e.recordAccess(this);
			return oldValue;
		}
	}
	modCount++;
	// 插入新键值对
	addEntry(hash, key, value, i);
	return null;
}

HashMap 允许插入键为 null 的键值对。但是因为无法调用 null 的 hashCode() 方法,也就无法确定该键值对的桶下标,只能通过强制指定一个桶下标来存放。HashMap 使用第 0 个桶存放键为 null 的键值对。

private V putForNullKey(V value) {
	//HashMap 使用第 0 个桶 table[0] 存放键为 null 的键值对。
	for (Entry<K,V> e = table[0]; e != null; e = e.next) {
		if (e.key == null) {
			V oldValue = e.value;
			e.value = value;
			// 更新值
			e.recordAccess(this);
			return oldValue;
			// 返回旧值
		}
	}
	modCount++;
	//void addEntry(int hash, K key, V value, int bucketIndex)
	addEntry(0, null, value, 0);
	return null;
}

使用链表的头插法,也就是新的键值对插在链表的头部,而不是链表的尾部。

//TODO:使用链表的头插法,也就是新的键值对插在链表的头部,而不是链表的尾部。
void addEntry(int hash, K key, V value, int bucketIndex) {
	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<K,V> e = table[bucketIndex];
	// 头插法,链表头部指向新的键值对
	table[bucketIndex] = new Entry<>(hash, key, value, e);
	size++;
}
Entry(int h, K k, V v, Entry<K,V> n) {
	value = v;
	next = n;
	key = k;
	hash = h;
}

4. 确定桶下标

很多操作都需要先确定一个键值对所在的桶下标。

int hash = hash(key);
int i = indexFor(hash, table.length);

①. 计算 hash 值

final int hash(Object k) {
	int h = hashSeed;
	if (0 != h && k instanceof String) {
		return sun.misc.Hashing.stringHash32((String) k);
	}
	h ^= k.hashCode();
	// This function ensures that hashCodes that differ only by
	// constant multiples at each bit position have a bounded
	// number of collisions (approximately 8 at default load factor).
	h ^= (h >>> 20) ^ (h >>> 12);
	return h ^ (h >>> 7) ^ (h >>> 4);
}
public final int hashCode() {
	return Objects.hashCode(key) ^ Objects.hashCode(value);
}

②. 取模

令 x = 1<<4,即 x 为 2 的 4 次方,它具有以下性质:

x   : 00010000
x-1 : 00001111

令一个数 y 与 x-1 做与运算,可以去除 y 位级表示的第 4 位以上数:

y       : 10110010
x-1     : 00001111
y&(x-1) : 00000010

这个性质和 y 对 x 取模效果是一样的:

y   : 10110010
x   : 00010000
y%x : 00000010

我们知道,位运算的代价比求模运算小的多,因此在进行这种计算时用位运算的话能带来更高的性能。

确定桶下标的最后一步是将 key 的 hash 值对桶个数取模: hash%capacity,如果能保证 capacity 为 2 的 n 次方,那么就可以将这个操作转换为位运算。

static int indexFor(int h, int length) {
    return h & (length-1);
}

就等价于

static int indexFor(int h, int length) {
    return h % length;
}

但是效率会更高。

5. 扩容-基本原理

设 HashMap 的 table 长度为 M,需要存储的键值对数量为 N,如果哈希函数满足均匀性的要求,那么每条链表的长度大约为 N/M,因此平均查找次数的复杂度为 O(N/M)。

为了让查找的成本降低,应该尽可能使得 N/M 尽可能小,因此需要保证 M 尽可能大,也就是说 table 要尽可能大。 HashMap 采用动态扩容来根据当前的 N 值来调整 M 值,使得空间效率和时间效率都能得到保证。

和扩容相关的参数主要有:capacity、size、threshold 和 load_factor。

static final int DEFAULT_INITIAL_CAPACITY = 16;
//保证 capacity 为 2 的 n 次方,那么就可以将indexFor方法中操作转换为位运算
static final int MAXIMUM_CAPACITY = 1 << 30;
//保证 capacity 为 2 的 n 次方,那么就可以将 indexFor 方法中操作转换为位运算
static final float DEFAULT_LOAD_FACTOR = 0.75f;
transient Entry[] table;
transient int size;
int threshold;
final float loadFactor;
transient int modCount;

从下面的添加元素代码中可以看出,当需要扩容时,令 capacity 为原来的两倍。

void addEntry(int hash, K key, V value, int bucketIndex) {
	Entry<K,V> e = table[bucketIndex];
	table[bucketIndex] = new Entry<>(hash, key, value, e);
	if (size++ >= threshold)
	        resize(2 * table.length);
	//令 capacity 为原来的两倍
}

扩容使用 resize() 实现,需要注意的是,扩容操作同样需要把 oldTable 的所有键值对重新插入 newTable 中,因此这一步是很费时的。

void resize(int newCapacity) {
	Entry[] oldTable = table;
	int oldCapacity = oldTable.length;
	if (oldCapacity == MAXIMUM_CAPACITY) {
		threshold = Integer.MAX_VALUE;
		return;
	}
	Entry[] newTable = new Entry[newCapacity];
	transfer(newTable);
	table = newTable;
	threshold = (int)(newCapacity * loadFactor);
}
void transfer(Entry[] newTable) {
	Entry[] src = table;
	int newCapacity = newTable.length;
	for (int j = 0; j < src.length; j++) {
		Entry<K,V> e = src[j];
		if (e != null) {
			src[j] = null;
			do {
				Entry<K,V> next = e.next;
				int i = indexFor(e.hash, newCapacity);
				e.next = newTable[i];
				newTable[i] = e;
				e = next;
			}
			while (e != null);
		}
	}
}

6. 扩容-重新计算桶下标

在进行扩容时,需要把键值对重新放到对应的桶上。HashMap 使用了一个特殊的机制,可以提升重新计算桶下标的效率。

假设原数组长度 capacity 为 16,扩容之后 new capacity 为 32:

capacity     : 00010000
new capacity : 00100000

对于一个 Key,

  • 它的哈希值如果在第 5 位上为 0,那么取模得到的结果和之前一样;
  • 如果为 1,那么得到的结果为原来的结果 +16。

7. 计算数组容量

HashMap 构造函数允许用户传入的容量不是 2 的 n 次方,因为它可以自动地将传入的容量转换为 2 的 n 次方。

先考虑如何求一个数的掩码,对于 10010000,它的掩码为 11111111,可以使用以下方法得到:

mask |= mask >> 1    11011000
mask |= mask >> 2    11111110
mask |= mask >> 4    11111111

mask+1 是大于原始数字的最小的 2 的 n 次方。

num     10010000
mask+1  100000000

以下是 HashMap 中计算数组容量的代码:

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    //得到n的掩码
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

8. 链表转红黑树

从 JDK 1.8 开始,一个桶存储的链表长度大于 8 时会将链表转换为红黑树。

9. 与 HashTable 的比较

  • HashMap 是非线程安全的,HashTable 使用 synchronized 来进行同步,是线程安全的。
  • HashMap 要比 HashTable 效率高一点。Hashtable 基本被淘汰,不要在代码中使用它。
  • HashMap 可以插入键为 null 的 Entry;HashTable 中插入的键只要有一个为 null,直接抛出 NullPointerException。
  • HashMap 的迭代器是 fail-fast 迭代器。
  • HashMap 不能保证随着时间的推移 Map 中的元素次序是不变的。
  • HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间;Hashtable 没有这样的机制。
  • HashMap 默认的初始化大小为16。之后每次扩充,容量变为原来的2倍;Hashtable 容量默认的初始大小为11,之后每次扩充,容量变为原来的2n+1。 在初始化时如果给定了容量初始值,HashMap 会将其扩充为2的幂次方大小;Hashtable 会直接使用初始值。

10. 与 HashSet 的比较

HashSet 底层就是基于HashMap实现的。 (HashSet 的源码非常非常少,因为除了 clone() 方法、writeObject()方法、readObject()方法是 HashSet 自己不得不实现之外, 其他方法都是直接调用 HashMap 中的方法。)

二、LinkedHashMap

1.存储结构

继承自 HashMap,因此具有和 HashMap 一样的快速查找特性。

public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V>

内部维护了一个双向链表,用来维护插入顺序或者 LRU 顺序。

/**
 * The head (eldest) of the doubly linked list.
 */
transient LinkedHashMap.Entry<K,V> head;
/**
 * The tail (youngest) of the doubly linked list.
 */
transient LinkedHashMap.Entry<K,V> tail;

accessOrder 决定了顺序,默认为 false,此时维护的是插入顺序。

final boolean accessOrder;

LinkedHashMap 最重要的是以下用于维护顺序的函数,它们会在 put、get 等方法中调用。

void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }

2.afterNodeAccess()

当一个节点被访问时,如果 accessOrder 为 true,则会将该节点移到链表尾部。也就是说指定为 LRU 顺序之后,在每次访问一个节点时,会将这个节点移到链表尾部,保证链表尾部是最近访问的节点,那么链表首部就是最近最久未使用的节点。

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;
	}
}

3.afterNodeInsertion()

在 put 等操作之后执行,当 removeEldestEntry() 方法返回 true 时会移除最晚的节点,也就是链表首部节点 first。

evict 只有在构建 Map 的时候才为 false,在这里为 true。

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);
	}
}

removeEldestEntry() 默认为 false,如果需要让它为 true,需要继承 LinkedHashMap 并且覆盖这个方法的实现,这在实现 LRU 的缓存中特别有用,通过移除最近最久未使用的节点,从而保证缓存空间足够,并且缓存的数据都是热点数据。

protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
    return false;
}

4.LRU 缓存

以下是使用 LinkedHashMap 实现的一个 LRU 缓存:

  • 设定最大缓存空间 MAX_ENTRIES 为 3;
  • 使用 LinkedHashMap 的构造函数将 accessOrder 设置为 true,开启 LRU 顺序;
  • 覆盖 removeEldestEntry() 方法实现,在节点多于 MAX_ENTRIES 就会将最近最久未使用的数据移除。
public class LRUCache<K,V> extends LinkedHashMap<K,V>{
	private static final int MAX_ENTRIES = 3;
	LRUCache(){
		super(MAX_ENTRIES,0.75f,true);
	}
	/**
     * removeEldestEntry() 默认为 false,
     * 如果需要让它为 true,需要继承 LinkedHashMap 并且覆盖这个方法的实现,
     * 这在实现 LRU 的缓存中特别有用,通过移除最近最久未使用的节点,
     * 从而保证缓存空间足够,并且缓存的数据都是热点数据。
     */
	@Override
	    protected Boolean removeEldestEntry(Map.Entry eldest) {
		return size() > MAX_ENTRIES;
	}
	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);
		//LRU  键值1被访问过了,则最近最久未访问的就是2
		cache.put(4, "d");
		System.out.println(cache.keySet());
	}
}
[3, 1, 4]

三、WeakHashMap

1.存储结构

WeakHashMap 的 Entry 继承自 WeakReference,被 WeakReference 关联的对象在下一次垃圾回收时会被回收。

WeakHashMap 主要用来实现缓存,通过使用 WeakHashMap 来引用缓存对象,由 JVM 对这部分缓存进行回收。

private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V>

2.ConcurrentCache

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);
	}
}