Java集合学习之HashMap 一(JDK1.8)
- 既然走上了研发这条路, 首选的语言又是Java, 吃饭的家伙就需要了解的透彻一些
- 要想深入使用Java, 集合一定是绕不过的坎儿, 从初级到高级甚至资深的水平, 反正是都要过一遍JDK中集合的源代码
- 下载JDK源码的时候已经是
Java1.8
了, 所以和网上的博客们和教程们可能会有一点冲突
HashMap概述
- 是
Map接口
基于散列表的实现, 继承自抽象类AbstractMap
- 插入和查询Key-Value对的开销是固定的
- 可以通过构造器设置
容量
和负载因子
, 以调整容器的性能 - HashMap不保证映射的顺序, 特别是它不保证该顺序恒久不变
- HashMap**非线程安全**, 如果多个线程同时访问一个 HashMap, 如果其中有一个线程需要从结构上(指添加或者删除一个或多个映射关系的任何操作)做了修改, 则需要通过外部来保持同步
HashMap的数据结构
HashMap的底层数据结构是通过数组和链表的结合来实现的, 如图所示:
通常的文章都把数组的每个元素称作一个桶
从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);
}
和之前版本的JDK不同, 在初始化HashMap的时候并不会创建存放键值对
的数组,该数组的创建在第一次使用的时候在resize()
方法中执行
HashMap维护几个重要的数据域:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认的容量
static final int MAXIMUM_CAPACITY = 1 << 30; // 最大容量, 2的30次方
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默认的负载因子
static final int TREEIFY_THRESHOLD = 8; // 每个桶的阈值, 即每个桶装多少为上?
static final int UNTREEIFY_THRESHOLD = 6;
transient Node<K,V>[] table;
JDK1.8
存储键值对
的对象不再是Entry<K,V>
, 而是改用Node<K,V>
,不过基本实现没有变化:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
……
}
暂时猜测与新增的TreeNode有关
Node是一个静态类,是存放数据元素的对象,除了包含key和value的值外,还包含了一个指向另一个Node的next指针,从而构建了拥有相同hash值的键值对
的链表
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;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length; // 初次使用该HashMap的对象时, 给table分配空间
if ((p = tab[i = (n - 1) & hash]) == null) // key值经过hash后得到其在table中桶的下标索引
/* table中在该下标索引的桶中没有元素, 则直接把该Node元素放置到该桶中 */
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; // 桶中的第一个元素的key值和要存储的元素的key值相同
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) {
/* 遍历到链表的尾部, 没有找到相同的key, 则新增一个Node */
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
/* 桶中的某个元素的key值和要存储的元素的key值相同 */
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
/* 此时的e是某个Node<K, V>,可能是原先就存在桶中的, 也可能是新增的 */
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value; // 更新value
afterNodeAccess(e);
return oldValue; // 返回"旧"的value
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
总结一下put
操作的步骤:
- 第一次执行put操作时,会给HashMap底层的数据结构table分配空间
- 对key值进行hash后获得桶的下标索引
- 如果在该下标索引的桶中没有存放数据, 则直接把该数据放置在这个桶中
- 如果该下标索引的桶中有数据, 则遍历该桶的链表
- 如果找到相同的key值, 则更新该Node的value值, 并返回旧的value值; 如果key值不存在, 则new 一个新的Node, 返回null
- 当这个HashMap的size大于阈值的时候, 执行resize()方法重新给table分配空间
简单讨论一下关于key值的hash函数
- 当系统决定存储HashMap中的键值对时, 完全没有考虑Node中的value, 仅仅只是根据key来计算并决定每个Node属于哪个桶
- 我们完全可以把Map集合中的value当成key的附属品, 当系统决定了key的存储位置之后, value 随之保存在那里即可
对与key的hash计算并决定其存放的桶, 共有两步:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
/* 桶的下标索引 */
i = (n - 1) & hash // n = table.length
- 桶的下标索引算法实际上是一种技巧性的
取模运算
, 因为HashMap的底层数组的容量总是2的n次方 - 当length总是2的n次方时, h&(length-1)运算等价于对length取模, 也就是h%length, 但是&比%具有更高的效率
读取键值对
暴露出来的方法接口依然是get()
,但是真正的实现是getNode()
方法:
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
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) { // 判断是否能通过key值的hash找到相应的链表
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k)))) // 根据key值确定链表的第一个元素就是满足的元素
return first;
/* 遍历该链, 寻找是否有该key值的value */
if ((e = first.next) != null) {
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;
}
总结一下get
操作的步骤:
- 根据key值的hash值判断相应下标索引的桶是否存放数据元素, 没有则返回null
- 根据key值判断该桶的第一个数据元素是不是就是要找的数据, 找到则返回该Node
- 在上一步没有找到想要的值, 则遍历该桶的链表, 根据key值来判断是否存在正在寻找的数据元素, 找到则返回该Node
- 没有找到相应的key, 返回null
resize()方法
和之前版本的resize()方法相比, JDK1.8中还承担了初始化table时分配空间的任务, 初始的table的容量大小是16(DEFAULT_INITIAL_CAPCITY
), 初始的阈值是12(DEFAULT_LOAD_FACTOR
* DEFAULT_INITIAL_CAPCITY
)
resize由两个步骤组成:
- 扩容, 以成倍的方式(乘以2)
- 重新hash, 保持各个桶原有的链表的元素的顺序不变