Java集合学习之HashMap 一(JDK1.8)

时间:2022-03-21 17:50:34

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操作的步骤:

  1. 第一次执行put操作时,会给HashMap底层的数据结构table分配空间
  2. 对key值进行hash后获得桶的下标索引
  3. 如果在该下标索引的桶中没有存放数据, 则直接把该数据放置在这个桶中
  4. 如果该下标索引的桶中有数据, 则遍历该桶的链表
  5. 如果找到相同的key值, 则更新该Node的value值, 并返回旧的value值; 如果key值不存在, 则new 一个新的Node, 返回null
  6. 当这个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操作的步骤:

  1. 根据key值的hash值判断相应下标索引的桶是否存放数据元素, 没有则返回null
  2. 根据key值判断该桶的第一个数据元素是不是就是要找的数据, 找到则返回该Node
  3. 在上一步没有找到想要的值, 则遍历该桶的链表, 根据key值来判断是否存在正在寻找的数据元素, 找到则返回该Node
  4. 没有找到相应的key, 返回null

resize()方法

和之前版本的resize()方法相比, JDK1.8中还承担了初始化table时分配空间的任务, 初始的table的容量大小是16(DEFAULT_INITIAL_CAPCITY), 初始的阈值是12(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPCITY)

resize由两个步骤组成:

  1. 扩容, 以成倍的方式(乘以2)
  2. 重新hash, 保持各个桶原有的链表的元素的顺序不变