目录
- 一、什么是HashMap
- 二、HashMap的链表与红黑树
- 1、数据结构
- 2、链表转为红黑树
- 3、红黑树退化为链表
- 三、存储(put)操作
- 四、读取(get)操作
- 五、扩容(resize)操作
- 六、HashMap的线程安全与顺序
- 1、线程安全
- 2、有序的Map
一、什么是HashMap
HashMap 是 Java 中的一个关键数据结构,属于 java.util 包。它基于哈希表的实现,提供了快速查找、插入和删除操作。以下是 HashMap 的一些主要特性:
-
键值对存储:HashMap 存储键值对(key-value pairs),其中键(key)是唯一的。
-
非同步:HashMap 不是线程安全的。在多线程环境中,如果多个线程同时修改 HashMap,而没有适当的同步措施,可能会导致不可预知的行为。
-
允许空键和空值:HashMap 允许键或值为 null。
-
非有序:HashMap 不保证元素的顺序,特别是它不保证该顺序恒久不变。
-
初始容量和负载因子:可以通过构造函数设置 HashMap 的初始容量和负载因子。初始容量是哈希表中桶的数量,负载因子是一个影响哈希表性能的参数,它定义了哈希表在其容量自动增加之前可以达到多满。
-
哈希冲突解决:当两个对象具有相同的哈希码时,会发生哈希冲突。HashMap 使用链表(在 Java 8 之前)或链表和红黑树(Java 8 及之后)来解决冲突。
-
性能:HashMap 提供了常数时间的性能(即 O(1) 时间复杂度)对于 get 和 put 操作,假设哈希函数良好且哈希表没有过载。
-
迭代器:HashMap 提供了键集(key set)、值集(values)和键值对集(entry set)的视图,它们都可以被迭代。
-
默认构造方法:如果使用无参构造方法创建 HashMap,它会使用默认的初始容量(16)和默认的负载因子(0.75)。
主要特性总结:key-value形式的键值对;无序不重复;线程不安全。
二、HashMap的链表与红黑树
1、数据结构
HashMap是一种存取高效但不保证有序的集合,它的数据结构在1.7里面是 数组+链表,在1.8之后是数组+链表+红黑树
默认初始容量16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
将链表转为红黑树的阈值
static final int TREEIFY_THRESHOLD = 8;
将红黑树转化为链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
将链表转化为红黑树时,数组的大小必须大于等于这个值。否则如果 TREEIFY_THRESHOLD 大于8,将扩容,而不是转为红黑树
static final int MIN_TREEIFY_CAPACITY = 64;
2、链表转为红黑树
在 Java 8 及以后的版本中,HashMap 在某些情况下会将链表转换为红黑树,以提高搜索效率。这种转换发生在以下两个条件同时满足时:
-
树化阈值:HashMap 有一个树化阈值(treeify threshold),当链表的长度超过这个值时,链表会被转换为红黑树。在 Java 8 中,这个值默认是 8。
-
桶(bucket)的数量:如果桶的数量小于 64,即使链表长度达到 8,也不会立即转换为红黑树。这是为了避免在哈希表较小时过度优化,因为小规模的哈希表中,链表的性能已经足够好。
转换过程
当 HashMap 进行扩容时,如果桶的数量增加到 64 或更多,那么在重新计算哈希值并重新插入元素的过程中,任何长度大于等于 8 的链表都会被转换为红黑树。这个转换是在扩容过程中自动完成的。
为什么有这个限制
- 性能考虑:在哈希表较小时,链表的性能通常已经足够好,不需要额外的复杂性来维护红黑树。
- 内存考虑:红黑树比链表占用更多的内存,因此在小规模的哈希表中使用链表可以节省内存。
在实际应用中,这意味着如果你的 HashMap 初始容量设置得较小,或者在插入数据时没有触发扩容,那么即使某些链表的长度超过了 8,它们也可能不会立即转换为红黑树。只有当哈希表的容量增加到一定大小,且链表长度满足条件时,才会进行转换。
3、红黑树退化为链表
当一个桶中的红黑树的节点数量减少到一定阈值以下时,HashMap 会将这个红黑树转换回链表。这个阈值默认是 6。这意味着,如果一个桶中的红黑树的节点数量降到 6 个或更少,那么这个红黑树会被转换回链表。
为什么需要转换
- 性能优化:链表在节点数量较少时,其操作(如搜索、插入、删除)的性能通常优于红黑树,因为链表的结构更简单,没有红黑树的复杂性。
- 内存使用:红黑树比链表占用更多的内存,因为它需要存储额外的指针(用于维护树的结构)。当节点数量较少时,使用链表可以减少内存的使用。
转换过程
转换过程通常发生在以下情况下:
1. 删除操作:当从 HashMap 中删除元素时,如果某个桶中的红黑树节点数量减少到 6 个或更少,这个红黑树会被转换回链表。
2. 扩容操作:在 HashMap 进行扩容时,如果桶的数量增加,那么在重新计算哈希值并重新插入元素的过程中,如果某个桶中的红黑树节点数量减少到 6 个或更少,这个红黑树会被转换回链表。
三、存储(put)操作
jdk1.7采用的是头插法 ,因头插法在扩容时导致的死循环,jdk1.8中链表插入采用的是尾插法。
put操作的步骤如下:
- 待存储的key进行hash计算
- 如果hash数组为空或者数组长度为0,则进行初始化
- 数组根据key计算后的hash值进行索引数组元素:
-
如果索引出来的元素为空,则新创建一个Node节点并添加到数组中
-
如果索引出来的元素不为空,则比较索引出来元素的key与传入的key进行"=="或equals的比较
① 如果一致则用新的value值替换旧的value值
②如果不一致就判断索引出来节点是不是树形节点,如果是则按照树形节点进行更新
③如果不是树形节点,则判断索引出来的节点的next节点是否为空,如果下一个节点为空,则新建下一个节点,此时需要判断链表的长度是否大于等于8,如果等于8,则要进行树化转为红黑树。
④ 如果下一个节点不为空,就一直循环找到节点的hash值与传入key的hash值一致,key通过"=="或equals比较是一致的。然后用新的value值替换旧的value值
-
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab;
Node<K,V> p;
int n, i;
//如果table为空,则初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//key对应的桶不存在,则创建一个新节点并添加到桶中
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//key对应的桶存在,则遍历桶,找到key对应的节点,如果key相同,则更新value,否则创建一个新节点
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);
//节点key不相同也不是树形节点
else {
for (int binCount = 0; ; ++binCount) {
//节点的next节点为空,则新建节点并判断是否需要树化
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 节点的next节点不为空,且key相同
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;
}
四、读取(get)操作
获取数据步骤如下:
- 如果数组不为空,则根据hash值找到对应的桶,对应的桶为空,就返回null
- 对应的桶不为空,如果桶的第一个元素的key和key相等,则返回该元素
- 如果桶的第一个元素的key和key不相等
- 如果桶的第一个元素的next节点不是树节点,则遍历桶,找到key对应的节点,如果key相同,则返回该节点
- 如果桶的第一个元素是树节点,则调用树节点的getTreeNode方法
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;
// 如果数组不为空,则根据hash值找到对应的桶,对应的桶也不为空
if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
// 如果桶的第一个元素的key和key相等,则返回该元素
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 如果桶的第一个元素的next节点不是树节点,则遍历桶,找到key对应的节点,如果key相同,则返回该节点
if ((e = first.next) != null) {
// 如果桶的第一个元素是树节点,则调用树节点的getTreeNode方法
if (first instanceof HashMap.TreeNode)
return ((HashMap.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;
}
五、扩容(resize)操作
函数resize()用于实现哈希表的扩容操作。具体步骤如下:
-
获取旧表信息
- 获取当前哈希表table的引用。
- 获取当前哈希表的容量oldCap。
- 获取当前哈希表的阈值oldThr。
-
判断是否需要扩容:
- 如果当前容量大于等于最大容量MAXIMUM_CAPACITY,设置阈值为整型最大值并返回旧表。
- 如果当前容量大于0且小于最大容量,计算新容量newCap为旧容量的两倍,并更新阈值newThr为旧阈值的两倍。
- 如果当前容量为0但阈值大于0,将新容量设为阈值。
- 如果当前容量和阈值都为0,使用默认初始容量DEFAULT_INITIAL_CAPACITY并计算阈值。
-
计算新阈值
- 如果新阈值仍为0,根据负载因子计算新阈值。
- 如果新阈值仍为0,根据负载因子计算新阈值。
-
创建新表
- 更新阈值threshold为newThr。
- 创建新的哈希表newTab,大小为newCap。
-
迁移旧表数据:
- 遍历旧表oldTab中的每个桶。
- 对于非空的桶,将其元素迁移到新表中。
- 如果桶中的元素只有一个,直接插入新表对应位置。
- 如果桶中的元素是红黑树节点,调用split方法进行拆分。
- 如果桶中的元素是链表,遍历链表并根据哈希值分成两部分,分别插入新表的不同位置。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 计算新的容量和阈值
if (oldCap > 0) {
// 如果容量大于最大容量,则将阈值设置为最大值
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
} // 如果新容量等于旧容量的2倍且小于最大容量且旧的容量大于等于默认容量,新阈值设置为旧的容量的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold 新的阈值为旧的阈值的2倍
}
// 如果当前容量为0但阈值大于0,将阈值设为新容量。
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 如果当前容量和阈值都为0,使用默认初始容量DEFAULT_INITIAL_CAPACITY并计算阈值。
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果新阈值仍为0,根据负载因子计算新阈值。
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//创建新的哈希表newTab,大小为newCap。
Node<K,V>[] newTab = (Node<K,V>[])new HashMap.Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof HashMap.TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
六、HashMap的线程安全与顺序
1、线程安全
HashMap 是非线程安全的,这意味着多个线程同时修改 HashMap 可能会导致不可预知的行为。如果需要在多线程环境中使用 HashMap,有几种方法可以使其线程安全:
-
使用 Collections.synchronizedMap 方法:
Java 提供了一个 Collections.synchronizedMap 方法,可以将 HashMap 包装为线程安全的 Map。
Map<K, V> map = Collections.synchronizedMap(new HashMap<K, V>());
-
使用 ConcurrentHashMap:
ConcurrentHashMap 是 HashMap 的线程安全版本,它提供了更好的并发性能。通常,它是实现线程安全的首选方式,因为它允许多个线程同时读写而不需要外部同步。
Map<K, V> map = new ConcurrentHashMap<K, V>();
-
使用 synchronized 块或方法:
在访问 HashMap 的方法上使用 synchronized 关键字,确保在修改 HashMap 时只有一个线程可以执行。
public void put(K key, V value) { synchronized (this) { this.map.put(key, value); } }
2、有序的Map
HashMap 本身不保证有序,即它不保证元素的顺序,无论是插入顺序还是自然顺序。如果需要一个有序的映射,你可以使用以下几种替代方案:
-
LinkedHashMap:
LinkedHashMap 是 HashMap 的一个子类,它维护了元素的插入顺序或者访问顺序。如果你想要元素按照插入顺序存储,可以使用 LinkedHashMap
-
TreeMap:
TreeMap 是一个基于红黑树实现的 NavigableMap 接口的类,它可以按照元素的自然顺序或者自定义比较器(Comparator)定义的顺序来存储元素。TreeMap 保证了元素的有序性,并且提供了一些导航方法,如 firstEntry()、lastEntry()、higherEntry() 等。
Map<K, V> map = new TreeMap<K, V>();
或者,如果你想要自定义排序,可以提供一个比较器:
Comparator<K> comparator = ...; Map<K, V> map = new TreeMap<K, V>(comparator);
-
自定义有序 HashMap:
如果你需要 HashMap 的某些特性,并且想要保持顺序,你可以自己实现一个有序的 HashMap。这通常涉及到在内部维护一个列表来跟踪插入顺序。
-
Hashtable:
虽然 Hashtable 是一个遗留类,但它是同步的,并且它按照插入顺序维护键的顺序。然而,由于 Hashtable 的性能通常不如 HashMap 和 LinkedHashMap,并且它不是线程安全的,所以通常不推荐使用。