前言
大家开发中用得最多的工具类就是 JAVA 的汇合框架了,明天咱们来从源码的角度分析 Map 汇合的实现之一HashMap
。
HashMap 简介
这里我就间接翻译 JDK 源码正文了,其实正文讲得很具体了。
基于哈希表的 Map 接口的实现。此实现提供所有可选的映射操作,并容许空值和空键。(HashMap 类与 Hashtable 大抵等效,不同之处在于它是不同步的,并且容许为 null。)此类不保障映射的程序。特地是,它不能保障程序会随着工夫的推移放弃恒定。
假如哈希函数将元素正确扩散在存储桶中,则此实现为基本操作(get 和 put)提供恒定工夫 (这里指工夫复杂度为 o1) 的性能。汇合视图上的迭代所需的工夫与 HashMap 实例的“容量”(存储桶数)及其大小(键 - 值映射数)成正比。因而,如果迭代性能很重要,则不要将初始容量设置得过高(或负载因数过低),这一点十分重要。
HashMap 的实例具备两个影响其性能的参数:初始容量和负载因子。容量是哈希表中存储桶的数量,初始容量只是创立哈希表时的容量。负载因子是在主动减少其哈希表容量之前容许哈希表取得的满度的度量。当哈希表中的条目数超过负载因子和以后容量的乘积时,哈希表将被从新哈希(即,外部数据结构将被重建),因而哈希表的存储桶数约为两倍。
通常,默认负载因子(.75)在工夫和空间老本之间提供了一个很好的衡量。较高的值会缩小空间开销,但会减少查找老本(在 HashMap 类的大多数操作中都失去体现,包含 get 和 put)。设置其初始容量时,应思考映射中的预期条目数及其负载因子,以最大水平地缩小从新哈希操作的次数。如果初始容量大于最大条目数除以负载因子,则将不会产生任何哈希操作。
如果将许多映射存储在 HashMap 实例中,则创立具备足够大容量的映射将比让其依据须要增长表的主动从新哈希解决更无效地存储映射。请留神,应用具备雷同 hashCode()许多键是升高任何哈希表性能的必定办法。为了改善影响,当键为 Comparable,此类能够应用键之间的比拟程序来帮忙突破平局。
请留神,此实现未同步。如果多个线程同时拜访哈希映射,并且至多有一个线程在结构上批改该映射,则必须在内部进行同步。(构造批改是增加或删除一个或多个映射的任何操作;仅更改与实例曾经蕴含的键相关联的值不是构造批改。)通常通过在天然封装了 Map 的某个对象上进行同步来实现。。如果不存在这样的对象,则应应用 Collections.synchronizedMap 办法“包装”Map。最好在创立时实现此操作,以避免意外不同步地拜访 Map:
Map m = Collections.synchronizedMap(new HashMap(...));
该类的所有“汇合视图办法”返回的迭代器都是疾速失败的:如果在创立迭代器后的任何 ff 工夫以任何形式对 Map 进行构造批改,则除了通过迭代器本人的 remove 办法之外,迭代器都会抛出 ConcurrentModificationException。因而,面对并发批改,迭代器会疾速洁净地失败,而不会在将来的不确定工夫冒着任意,不确定的行为的危险。
请留神,迭代器的疾速失败行为无奈失去保障,因为通常来说,在存在不同步的并发批改的状况下,不可能做出任何严格的保障。疾速失败的迭代器会尽最大致力抛出 ConcurrentModificationException。因而,编写依赖于此异样的程序的正确性是谬误的:迭代器的疾速失败行为应仅用于检测谬误。
此类是 Java Collections Framework 的成员
几个重要的成员变量
Node<K,V>[] table
外围数据结构 数组 + 链表int size
汇合的大小int modCount
被批改的次数int threshold
下一个要调整大小的大小值(容量 * 负载因子)float loadFactor
负载因子Set<Map.Entry<K,V>> entrySet
KV 集
办法
构造方法
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);
}
有两个参数
- initialCapacity 初始容量
- loadFactor 负载因子
HashMap
就是对 散列表
这种数据结构的实现,所以须要这个两个参数去定义 散列表
tableSizeFor
咱们从下面的构造方法能够看出,HashMap
在初始化的时候,会调用这个办法去计算理论初始化的容量并暂存为threshold
。
/**
* Returns a power of two size for the given target capacity.
*/
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;
}
这个办法返回 大于输出参数且最近的 2 的整数次幂的数。比方 10,则返回 16。
先来剖析无关 n 位操作局部:先来假如 n 的二进制为 01xxx…xxx。接着
- 对 n 右移 1 位:001xx…xxx,再位或:011xx…xxx
- 对 n 右移 2 为:00011…xxx,再位或:01111…xxx
- 此时后面曾经有四个 1 了,再右移 4 位且位或可得 8 个 1
- 同理,有 8 个 1,右移 8 位必定会让后八位也为 1。
综上可得,该算法让最高位的 1 前面的位全变为 1。
最初再让后果 n +1,即失去了 2 的整数次幂的值了。
当初回来看看第一条语句:
int n = cap - 1;
让 cap - 1
再赋值给 n 的目标是另找到的目标值大于或 等于 原值。例如二进制 1000,十进制数值为 8。如果不对它减 1 而间接操作,将失去答案 10000,即 16。显然不是后果。减 1 后二进制为 111,再进行操作则会失去原来的数值 1000,即 8。
这种办法的效率十分高,可见 Java8 对容器优化了很多
hash
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这里是取 hash 的高 16 位与 hash 值进行异或运算,因为在进行槽位计算的时候容易失落高位的特色,所以采取低位与高位进行运算取得一个后果,保留了高位与低位的特色。如果采纳 |
将会使位偏差 1,如果采纳 &
将会偏差 0,而采纳 ^
则没有显著的偏差性。
槽位计算源码
tab[i = (n - 1) & hash]
PUT
public V put(K key, V value) {return putVal(hash(key), key, value, false, true);
}
这个办法将元素退出散列表,具体实现是上面这个 putVal
办法
/**
* Implements Map.put and related methods.
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
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;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
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;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
明确几个局部变量的意义
Node<K,V>[] tab
散列表Node<K,V> p
新增的节点int n
散列表数组的长度int i
计算出的槽位
咱们追随源码的流程进行剖析
首先判断散列表是否有被创立进去,如果没有创立,则进行散列表的初始化。这是一种懒加载策略。初始化的过程咱们上面再剖析。
而后就是计算散列地位,判断该地位上是否有元素。若没有则间接把元素放入。如果该节点上有元素了,则产生了 hash 碰撞,须要另外的解决。
上面剖析一下槽点计算的源码
i = (n - 1) & hash
将容量 - 1 与 hash 值进行与运算。因为散列表在初始化的时候,容量是 2 的次幂,所以在减一之后,二进制位都变为了 1,再与 hash 值进行与运算其实就是取余数,这个就相当于hash % n
。然而效率确比取模运算高出很多。
本文由博客群发一文多发等经营工具平台 OpenWrite 公布