Java容器
三、Map
Map主要用于存储健值对,根据键得到值,因此不允许键重复(重复则覆盖),但允许值重复。
3.1 HashMap
源码基于 JDK1.7
3.1.1 类结构
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {}
- HashMap 实现了Map接口,继承AbstractMap。
- 其中Map接口定义了键映射到值的规则,而AbstractMap类提供 Map 接口的骨干实现,以最大限度地减少实现此接口所需的工作,其实AbstractMap类已经实现了Map。
- HashMap 实现了 Cloneable接口,即实现克隆功能。
- HashMap 实现了 Serializable接口,表示支持序列化。
3.1.2 属性
//table 的容量大小,默认为 16。需要注意的是 capacity 必须保证为 2 的 n 次方。
static final int DEFAULT_INITIAL_CAPACITY = 16;
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认装载因子为0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//底层数据结构:table数组
transient Entry[] table;
//键值对的数量
transient int size;
//size 的临界值,当 size 大于等于 threshold 就必须进行扩容操作。
int threshold;
//装载因子,table 能够使用的比例,threshold = capacity * loadFactor。
final float loadFactor;
//修改次数
transient int modCount;
3.1.3 构造方法
-
构造一个带指定初始容量和装载因子的空 HashMap。
public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); }
- 若传入的初始容量小于0,则抛出异常
- 若传入的初始容量大于最大容量,则初始容量设置为最大容量
- 若传入的装载因子小于0或者为NaN,则抛出异常
- 装载因子和size临界值赋值
-
构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。
public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); }
-
构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。
public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted }
-
构造一个带指定初始Map的 HashMap。
public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }
- 设置默认装载因子
- 将指定的Map放入table中
3.1.4 数据结构
内部包含了一个 Entry 类型的数组 table。
transient Entry[] table;
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
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();
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();
}
}
- Entry 存储着键值对。
- 包含了四个字段,从 next 字段可以看出 Entry 是一个链表。
- 即数组中的每个位置被当成一个桶,一个桶存放一个链表。
- HashMap 使用拉链法来解决冲突,同一个链表中存放哈希值和散列桶取模运算结果相同的 Entry。
3.1.5 拉链法
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 为 134,使用除留余数法得到所在的桶下标 134%16=6,插在 <K2,V2> 前面。
应该注意到链表的插入是以头插法方式进行的,例如上面的 <K3,V3> 不是插在 <K2,V2> 后面,而是插入在链表头部。
查找键值对,需要分成两步进行:
- 计算键值对所在的桶;
- 在链表上顺序查找,时间复杂度显然和链表的长度成正比。
3.1.6 put(key,value)
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// 键为 null 单独处理,调用putForNullKey()方法
if (key == null)
return putForNullKey(value);
//计算键的hash值
int hash = hash(key);
// 确定桶下标,计算键的hash值在table数组中的位置
int i = indexFor(hash, table.length);
// 先找出是否已经存在键为 key 的键值对,如果存在的话就更新这个键值对的值为 value
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//判断该链上是否有hash值相同的 和 键相同
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
//若存在相同,则直接覆盖值,返回旧值
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
//修改次数+1
modCount++;
// 插入新键值对,添加至table表中i处
addEntry(hash, key, value, i);
return null;
}
- 首先判断key是否为null,若为null,则直接调用putForNullKey方法。
- 若不为空则先计算key的hash值,然后根据hash值搜索在table数组中的索引位置。
- 如果table数组在该位置处有元素,则通过比较是否存在相同的key,若存在则覆盖原来key的value,否则将该元素添加到链表。
private V putForNullKey(V value) {
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++;
addEntry(0, null, value, 0);
return null;
}
- HashMap 允许插入键为 null 的键值对。
- 但是因为无法调用 null 的 hashCode() 方法,也就无法确定该键值对的桶下标,只能通过强制指定一个桶下标(table[0])来存放。
- HashMap 使用第 0 个桶存放键为 null 的键值对。
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;
}
- 将该元素保存在链头(最先保存的元素放在链尾)
3.1.7 确定桶的下标
//计算键的hash值
int hash = hash(key);
// 确定桶下标,计算键的hash值在table数组中的位置
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 次方,那么就可以将这个操作转换为与运算。
采用二进制位操作 &,相比于 % 而言,能够提高运算效率。
即:将模运算(%)转换为与运算(&)的前提是,容量(capacity)为 2 的 n 次方,因此 HashMap 的长度是 2 的幂次方。
static int indexFor(int h, int length) {
return h & (length-1);
}
3.1.8 扩容
设 HashMap 的 table 长度为 M,需要存储的键值对数量为 N,如果哈希函数满足均匀性的要求,那么每条链表的长度大约为 N/M,因此平均查找次数的复杂度为 O(N/M)。
为了让查找的成本降低,应该尽可能使得 N/M 尽可能小,因此需要保证 M 尽可能大,也就是说 table 要尽可能大。HashMap 采用动态扩容来根据当前的 N 值来调整 M 值,使得空间效率和时间效率都能得到保证。
添加元素的源码:
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);
}
- 当需要扩容时,令 capacity 为原来的两倍。
resize()
方法实现扩容:
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);
}
}
}
- 扩容操作需要把 oldTable 的所有键值对重新插入 newTable 中,这一步是很费时的。
重新计算桶下标:
在进行扩容时,需要把键值对重新放到对应的桶上。HashMap 使用了一个特殊的机制,可以降低重新计算桶下标的操作。
假设原数组长度 capacity 为 16,扩容之后 new capacity 为 32:
capacity : 00010000
new capacity : 00100000
对于一个 Key,其哈希值在第 5 位上的值
- 如果为 0,那么取模得到的结果和之前一样;
- 如果为 1,那么得到的结果为原来的结果 +16。
3.1.9 计算数组容量
HashMap 构造函数允许用户传入的容量不是 2 的 n 次方,因为它可以自动地将传入的容量转换为 2 的 n 次方。
推导:
先考虑如何求一个数的掩码,对于 num = 10010000,掩码 mask = 11111111
mask |= num >> 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;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
3.1.10 链表转红黑树
从 JDK 1.8 开始,一个桶存储的链表长度大于 8 时会将链表转换为红黑树。
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
//如果节点数变小小于红黑树的节点数阈值时,调整空间
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
//该方法直接返回一个红黑树结点。
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
//从链表头开始依次插入红黑树
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
// For treeifyBin
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}
- HashMap在JDK1.8的版本中引入了红黑树结构做优化,当链表元素个数大于等于8时,链表转换成树结构;
- 若桶中链表元素个数小于等于6时,树结构还原成链表。
- 因为红黑树的平均查找长度是log(n),长度为8的时候,平均查找长度为3,如果继续使用链表,平均查找长度为8/2=4,这才有转换为树的必要。
- 链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。
- 还有选择6和8,中间有个差值7可以有效防止链表和树频繁转换。
- 假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
3.1.11 get(key)读取
public V get(Object key) {
// 若为null,调用getForNullKey方法返回相对应的value
if (key == null)
return getForNullKey();
// 根据该 key 的 hashCode 值计算它的 hash 码
int hash = hash(key.hashCode());
// 取出 table 数组中指定索引处的值
for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
Object k;
//若搜索的key与查找的key相同,则返回相对应的value
if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
return e.value;
}
return null;
}
- 通过key的hash值找到在table数组中的索引处的Entry,然后返回该key对应的value即可。
3.1.12 与 HashTable 的比较
- HashMap线程不安全,HashTable是线程安全的。
- HashMap内部实现没有任何线程同步相关的代码,所以相对而言性能要好一点。
- 如果在多线程中使用HashMap需要自己管理线程同步。
- HashTable大部分对外接口都使用synchronized包裹,所以是线程安全的,但是性能会相对差一些。
- 二者的基类不一样。
- HashMap派生于AbstractMap,HashTable派生于Dictionary。
- 都实现Map, Cloneable, Serializable接口。
- AbstractMap中提供的基础方法更多,并且实现了多个通用的方法。
- 而在Dictionary中只有少量的接口,并且都是abstract类型。
- key和value的取值范围不同。
- HashMap的key和value都可以为null,但是HashTable的key和value都不能为null。
- 对于HashMap如果get返回null,并不能表明HashMap不存在这个key,如果需要判断HashMap中是否包含某个key,就需要使用containsKey这个方法来判断。
- 算法不一样。
- HashMap的初始容量值 initialCapacity为16,而HashTable的初始容量值 initialCapacity为11。
- HashMap中初始容量必须是2的幂,如果初始化传入的initialCapacity不是2的幂,将会自动调整为大于出入的initialCapacity最小的2的幂。HashTable 直接使用指定的容量,之后每次扩容,容量变为原来的 2n+1.
- HashMap使用自己的计算hash的方法(会依赖key的hashCode方法),HashTable则使用key的hashCode方法得到。
- HashMap 的迭代器是 fail-fast 迭代器。
- HashMap 不能保证随着时间的推移 Map 中的元素次序是不变的。
- JDK 1.8之后的 HashMap,当链表长度大于阈值(默认为8),将链表转化为红黑树,减少搜索时间。