API要点汇总
- 允许空值与空键
- 与Hashtable大致相同,但是不同步
- 不保证映射顺序,特别是不能保证order(不知道翻译成什么)在其中不随时间变化
- HashMap实例有两个影响其性能的参数:初始容量和负载因子(load factor)。容量是哈希表中的桶数,初始容量就是创建哈希表时的容量。负载因子是衡量在哈希表的容量被自动增加之前,哈希表被允许获得多少满的度量。当哈希表中的条目数超过负载因子和当前容量的乘积时,哈希表将被重新哈希(即重新构建内部数据结构),这样哈希表的桶数大约扩展两倍。
不理解的要点:
- 在hashmap需要存储许多映射时,需要创建更大容量的Hashmap(比它自己扩展效率高),但使用具有相同hashCode()的多个键肯定会降低任何散列表的性能。它建议当键是可比较的时,该类可以使用键之间的比较顺序实现(?),但这个是不同步的,于是又有Map m = Collections.synchronizedMap(new HashMap(…));(?)。
- 这个类的所有“collection view methods”返回的迭代器都是快速失败的,如果在创建迭代器之后的任何时候对映射进行结构上的修改,除了通过迭代器自己的remove方法之外,迭代器将抛出ConcurrentModificationException。(不是很了解啊)
关系图
函数概要
功能 | 描述 |
---|---|
构造函数 | 能够用容量、 负载因子、其它hashmap构造 |
删除 | 删除所有、依据某个键值、键对值删除 |
克隆 | 返回浅克隆(键对值不被克隆) |
set | 转换为set) |
获取 | 获取值 |
添加 | 以键对值、map等数据形式添加 |
替换 | 替换键值对、替换某一键对应的值 |
有些函数没有功能没有列出,例如compute,这个是java 8新增,不熟悉,等以后用到再更新吧。
源码解析
其函数挺多的,我其实只是挑部分感兴趣的代码看,说实话我也不可能每个函数都看一遍,对于没有实践体会的看起来实在是枯燥,也就不看了,等以后遇到使用问题再扒出来看吧。
hashmap
如图,它的实现方式是一种数组(API指的是桶)加链表的组合,HashMap 则使用了拉链式的散列算法,并在 JDK 1.8 中引入了红黑树优化过长的链表,也就是将链表部分换成红黑树,优化查询等操作效率,在进行增删查等操作时,首先要定位到元素的所在桶的位置,之后再从链表中定位该元素。
构造方法
提供如下四种构造函数:
在这里,我只列出两种,另外两种都十分简单:
HashMap(int initialCapacity, float loadFactor):
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);
}
构造函数前面的部分是十分容易理解,关键是最后一个tableSizeFor函数,它设置了阈值,我们接着看:
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;
}
以上是tableSizeFor的具体实现,位右移运算,而且,是不是很懵逼?试着在纸上画画,你会发现,在经历过这些操作之后,原始数据所在位数的低位数将全部变为1,最后n + 1显然得到的是2的整数幂,比如你输入一个5,最后会得到一个8。是不是很神奇!
下图是我在网上找的分析图,供大家理解(实在懒,不想画):
HashMap(Map<? extends K, ? extends V> m)
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
由图上的源码可以看出,这种构造函数将负载因子设置为默认值(0.75),随后调用了另一个函数,我们来看这个函数:
putMapEntries:
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
//数组还是空,初始化参数
if (table == null) { // pre-size
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold)
threshold = tableSizeFor(t);
}
//数组不为空,超过阈值就扩容
else if (s > threshold)
resize();
//遍历数据,插入
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
有关evict参数,其注释这样解释:evict false when initially constructing this map, else true (relayed to method afterNodeInsertion).
在进行数据的填充时,它使用到了putVal函数,实际上,Hashmap的put函数就是直接调用这个putVal函数,接下来看看这个函数:
插入
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果当前 哈希表内容为空,新建,n 指向最后一个桶的位置,tab 为哈希表另一个引用,将插入动作延期进行
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;
//如果第一个就是想要找的数据时,就将e指向此节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果是TreeNode类型,就调用红黑树的插入方法
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;
}
}
//最终决定是否更新数据,首先判断要插入的键值对是否存在 HashMap 中
if (e != null) { // existing mapping for key
V oldValue = e.value;
//onlyIfAbsent 表示是否仅在 oldValue 为 null 的情况下更新键值对的值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//统计修改次数,方便fail-fast的判断
++modCount;
//判断是否超过阈值,进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
首先,我们可以明确的是这个函数的final属性,传入的参数hash(key)是对于key进行hash计算:(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);接下来我们看到了HashMap 的底层数据结构之一的链表数组:Node<K,V>[] table;它被声明为transient类型(Java中transient关键字的作用,简单地说,就是让某些被修饰的成员属性变量不被序列化,能够节省存储空间)
查找
查找函数的核心算法封装在如下函数内:
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//判断整个数组存在,并且能够访问到键值对应桶的位置(第三个判断条件不太明白,直至参考网上解析)
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//若当前节点就是查询节点,返回节点
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//否则查询下一节点
if ((e = first.next) != null) {
//若后续为 TreeNode,按照红黑树类型进行查找
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//否则遍历链表,返回查询结果
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
查询的整体思路并不复杂,但在第一个条件语句中的tab[(n - 1) & hash]使我困惑,虽然大致知道是找桶所在的位置,但不清楚实现原理,查找网上相关解析,得到如下解释:
这里通过(n - 1)& hash即可算出桶的在桶数组中的位置,可能有的朋友不太明白这里为什么这么做,这里简单解释一下。HashMap 中桶数组的大小 length 总是2的幂,此时,(n - 1) & hash 等价于对 length 取余。但取余的计算效率没有位运算高,所以(n - 1) & hash也是一个小的优化。举个例子说明一下吧,假设 hash = 185,n = 16。计算过程示意图如下:
删除
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
//判断是否存在以及桶的位置情况
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
//当前节点就是查询节点,返回节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
//判断节点的下一个节点是否存在
else if ((e = p.next) != null) {
//判断是否属于TreeNode,是就采用红黑树的函数得到节点
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
//遍历链表,获取节点
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// 删除节点,并修复链表或红黑树
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
总的来看,基本思路跟查询操作类似。
替换
public V replace(K key, V value) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) != null) {
V oldValue = e.value;
e.value = value;
afterNodeAccess(e);
return oldValue;
}
return null;
}
上面一些代码很简单,就是查询所在节点是否存在,然后进行替换擦作就行,所以就不再赘述。
总结
首先对这篇文章也不是很满意,但总归比上篇好些,有一下几点:
- 读起来思维比较混乱,虽然我也是从构造函数看起,遇见陌生函数就直接穿插其代码分析,显得抓不住重点
- 每个代码进行分析之后,没有相关的函数思路总结
- 有些重点没有阐述清楚,比如阈值与容量和负载因子的关系
抛开这些之外,阅读源码真是让我收获良多,特别印象深刻的有关位运算的应用,我们都知道位运算能极大提高效率,但实际应用上却远远不如,其中有关利用位运算实现求一个数的最接近的幂、实现求桶所在的位置等都让我惊呼神仙操作,还有许多地方的思维逻辑十分严谨,总之这是一个大工程,许多东西我还没有详细去看,比如有关红黑树的操作,有关扩容机制的详细内容,自己还有很长一段路要走,看源码是一件很枯燥的事,但与此同时,看大佬的代码真的让人受益匪浅!
共勉!下次的文章自己还会改进有关文章表达方面的问题的。
推荐几篇文章,我认为写的非常详细,至少比我写的不知道好到哪去了,有一些有关位运算的思路我都是从中才明白的:
HashMap源码详细解析(JDK1.8) (推荐!)
Java 集合深入理解(16):HashMap 主要特点和关键方法源码解读