HashMap 速描

之前简单的整理了Java集合的相关知识,发现HashMap并不是三言两语能够讲明白的,所以专门整理一下HashMap的相关知识。

HashMap 存储结构

HashMap是一个哈希表实现的Map类型的数据结构,内部是一个没有想象中那么长的Entry类型数组。Entry类型就是常说的键值对。

/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

在HashMap的源码中,默认的表初始化长度为16,如果想要自定义表的长度也必须为2的n次方。

static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}

在HashMap中,Node就是Entry类型的实现类,除了键值对之外,还有哈希值和一个next指针。不难得出,哈希表中的每一个位置都可以存放一个Node链表。

这样的结构缩短哈希表的长度,让数据量有限的情况下,既可以有较好的查询性能,也能减少空间的浪费。

但是如果数据量很大还使用默认的哈希表长度的话,会导致链表过长,查询性能下降。所幸HashMap是支持动态扩容的。

HashMap 插入操作的工作原理

在了解了HashMap的存储结构之后,有两个问题摆在我们面前。一是HashMap如何确定在表中的什么位置来插入元素,二是插入到链表的什么位置中去。

先来回答第一个问题,HashMap首先会获取对象的hashCode,然后将hashCode与表长做取余操作,也就是hashCode%表长,得到的值就是元素要插入到表中的位置。

第二个问题的答案是HashMap会采用头插法将新元素插入到链表头部。

这里有个比较特殊的情况,HashMap是支持将null作为键值的,但是无法获取null的hashCode,所以强制将null插入到下标为0的地方。

//HashMap哈希函数的实现
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//

好奇HashMap哈希函数工作原理及为什么哈希表长度必须是2的n次方的同学,可以参考下面的回答。

JDK 源码中 HashMap 的 hash 方法原理是什么? - 胖君的回答 - 知乎

所以HashMap在查找元素时会分两步:

  1. 算出元素在哈希表中的位置
  2. 遍历链表找到元素

HashMap 的扩容

上面我们提到如果数据量庞大还使用HashMap的默认哈希表长度,就会导致链表过长,查询效率下降,甚至退化成单向链表。幸运的是HashMap本身就支持动态扩容,HashMap会根据数据量大小来动态调整哈希表的长度,以此来让HashMap的查找效率与空间效率都能得到保障。

介绍几个重要参数:

  • capacity: 哈希表的长度,默认为16。需要注意的是必须保证为2的n次方
  • size: 键值对的数量
  • threshold: 阈值,当size大于threshold时就必须进行扩容
  • loadFactor: 装载因子,哈希表能够使用的比例,threshold=(int)(capacity*loadFactor)

哈希表的初始长度和loadFactor是可以在初始化HashMap时定义的,loadFactor的默认值为0.75f 。触发扩容的条件为插入新元素时检查size和threshold,如果size大于threshold,就会触发扩容。

/**
* Initializes or doubles table size. If null, allocates in
* accord with initial capacity target held in field threshold.
* Otherwise, because we are using power-of-two expansion, the
* elements from each bin must either stay at same index, or move
* with a power of two offset in the new table.
*
* @return the table
*/
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;//如果表长已经超过最大值,就会将threshold设置成一个很大的数来阻止HashMap继续扩容
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)//
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
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"})
Node<K,V>[] newTab = (Node<K,V>[])new 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 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时如果传入的capacity不是2的n次方,HashMap会自动将其转换成2的n次方。

链表转红黑树

从JDK 8开始,如果链表长度超过8,就会将链表转换成红黑树。

与HashTable相比

  1. HashTable使用synchronized来进行同步操作
  2. HashMap可以插入键为null的Entry
  3. 迭代HashMap时不可以添加元素和扩容
  4. 随时间推移,HashMap中的元素顺序可能会发生变化

ConcurrentHashMap

存储结构

ConcurrentHashMap与HashMap相比,存储结构相似,但是ConcurrentHashMap引入了分段锁,每个分段锁都维护一段表,这样不同的线程就能同时访问不同分段锁的哈希表。极大提高了访问效率。默认有16个分段锁。

size操作

每个 Segment 维护了一个 count 变量来统计该 Segment 中的键值对个数。

/**
* The number of elements. Accessed only either within locks
* or among other volatile reads that maintain visibility.
*/
transient int count;

在执行 size 操作时,需要遍历所有 Segment 然后把 count 累计起来。

ConcurrentHashMap 在执行 size 操作时先尝试不加锁,如果连续两次不加锁操作得到的结果一致,那么可以认为这个结果是正确的。

尝试次数使用 RETRIES_BEFORE_LOCK 定义,该值为 2,retries 初始值为 -1,因此尝试次数为 3。

如果尝试的次数超过 3 次,就需要对每个 Segment 加锁。


/**
* Number of unsynchronized retries in size and containsValue
* methods before resorting to locking. This is used to avoid
* unbounded retries if tables undergo continuous modification
* which would make it impossible to obtain an accurate result.
*/
static final int RETRIES_BEFORE_LOCK = 2; public int size() {
// Try a few times to get accurate count. On failure due to
// continuous async changes in table, resort to locking.
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; // true if size overflows 32 bits
long sum; // sum of modCounts
long last = 0L; // previous sum
int retries = -1; // first iteration isn't retry
try {
for (;;) {
// 超过尝试次数,则对每个 Segment 加锁
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force creation
}
sum = 0L;
size = 0;
overflow = false;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
sum += seg.modCount;
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
// 连续两次得到的结果一致,则认为这个结果是正确的
if (sum == last)
break;
last = sum;
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return overflow ? Integer.MAX_VALUE : size;
}

JDK 8的改动

JDK 1.7 使用分段锁机制来实现并发更新操作,核心类为 Segment,它继承自重入锁 ReentrantLock,并发度与 Segment 数量相等。

JDK 1.8 使用了 CAS 操作来支持更高的并发度,在 CAS 操作失败时使用内置锁 synchronized。

并且 JDK 1.8 的实现也在链表过长时会转换为红黑树。

LinkedHashMap

存储结构

LinkedHashMap继承自HashMap,因此有HashMap一样的快速查找特性。

除此之外,在LinkedHashMap内部还维护了一个双向链表来记录数据的插入顺序或者最近最少使用(LRU)顺序。

accessOrder决定了记录哪个顺序,默认为false,记录插入顺序。

/**
* The iteration ordering method for this linked hash map: {@code true}
* for access-order, {@code false} for insertion-order.
*
* @serial
*/
final boolean accessOrder;

LinkedHashMap使用下面两个方法来维护链表

  • afterNodeInsertion
  • afterNodeAccess

afterNodeInsertion

void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}

在 put 等操作之后执行,当 removeEldestEntry() 方法返回 true 时会移除最晚的节点,也就是链表首部节点 first。

evict 只有在构建 Map 的时候才为 false,在这里为 true。

protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}

removeEldestEntry() 默认为 false,如果需要让它为 true,需要继承 LinkedHashMap 并且覆盖这个方法的实现,这在实现 LRU 的缓存中特别有用,通过移除最近最久未使用的节点,从而保证缓存空间足够,并且缓存的数据都是热点数据。

afterNodeAccess

void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMap.Entry<K,V> last;
if (accessOrder && (last = tail) != e) {
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
p.after = null;
if (b == null)
head = a;
else
b.after = a;
if (a != null)
a.before = b;
else
last = b;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
tail = p;
++modCount;
}
}

如果accessOrder为true,那么在访问一个节点后会将节点移动到链表尾部,保证链表尾部是最近访问的节点,那么链表首部就是最近最久未使用的节点。

LRU 缓存

继承LinkedHashMap可以快速的实现一个LRU缓存,下面是一个LRU的例子:

class LRUCache<K, V> extends LinkedHashMap<K, V> {
private static final int MAX_ENTRIES = 3; //重写removeEldestEntry方法使元素数量大于3时将最近最久未使用的元素移除
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > MAX_ENTRIES;
} LRUCache() {
super(MAX_ENTRIES, 0.75f, true);
}
}
public static void main(String[] args) {
LRUCache<Integer, String> cache = new LRUCache<>();
cache.put(1, "a");
cache.put(2, "b");
cache.put(3, "c");
cache.get(1);//访问1使(1,"a")重新置于链表尾部
cache.put(4, "d");//加入4使数量大于3而将最近最少使用的2移除
System.out.println(cache.keySet());
}
[3, 1, 4]

WeakHashMap

存储结构

WeakHashMap 的 Entry 继承自 WeakReference,被 WeakReference 关联的对象在下一次垃圾回收时会被回收。

WeakHashMap 主要用来实现缓存,通过使用 WeakHashMap 来引用缓存对象,由 JVM 对这部分缓存进行回收。

ConcurrerntCache

Tomcat 中的 ConcurrentCache 使用了 WeakHashMap 来实现缓存功能。

ConcurrentCache 采取的是分代缓存:

  • 经常使用的对象放入 eden 中,eden 使用 ConcurrentHashMap 实现,不用担心会被回收(伊甸园);
  • 不常用的对象放入 longterm,longterm 使用 WeakHashMap 实现,这些老对象会被垃圾收集器回收。
  • 当调用 get() 方法时,会先从 eden 区获取,如果没有找到的话再到 longterm 获取,当从 longterm 获取到就把对象放入 eden 中,从而保证经常被访问的节点不容易被回收。
  • 当调用 put() 方法时,如果 eden 的大小超过了 size,那么就将 eden 中的所有对象都放入 longterm 中,利用虚拟机回收掉一部分不经常使用的对象。
public final class ConcurrentCache<K, V> {

    private final int size;

    private final Map<K, V> eden;

    private final Map<K, V> longterm;

    public ConcurrentCache(int size) {
this.size = size;
this.eden = new ConcurrentHashMap<>(size);
this.longterm = new WeakHashMap<>(size);
} public V get(K k) {
V v = this.eden.get(k);
if (v == null) {
v = this.longterm.get(k);
if (v != null)
this.eden.put(k, v);
}
return v;
} public void put(K k, V v) {
if (this.eden.size() >= size) {
this.longterm.putAll(this.eden);
this.eden.clear();
}
this.eden.put(k, v);
}
}

HashMap 速描的更多相关文章

  1. HashMap与TreeMap源码分析

    1. 引言     在红黑树--算法导论(15)中学习了红黑树的原理.本来打算自己来试着实现一下,然而在看了JDK(1.8.0)TreeMap的源码后恍然发现原来它就是利用红黑树实现的(很惭愧学了Ja ...

  2. HashMap的工作原理

    HashMap的工作原理   HashMap的工作原理是近年来常见的Java面试题.几乎每个Java程序员都知道HashMap,都知道哪里要用HashMap,知道HashTable和HashMap之间 ...

  3. 计算机程序的思维逻辑 (40) - 剖析HashMap

    前面两节介绍了ArrayList和LinkedList,它们的一个共同特点是,查找元素的效率都比较低,都需要逐个进行比较,本节介绍HashMap,它的查找效率则要高的多,HashMap是什么?怎么用? ...

  4. Java集合专题总结(1):HashMap 和 HashTable 源码学习和面试总结

    2017年的秋招彻底结束了,感觉Java上面的最常见的集合相关的问题就是hash--系列和一些常用并发集合和队列,堆等结合算法一起考察,不完全统计,本人经历:先后百度.唯品会.58同城.新浪微博.趣分 ...

  5. 学习Redis你必须了解的数据结构——HashMap实现

    本文版权归博客园和作者吴双本人共同所有,转载和爬虫请注明原文链接博客园蜗牛 cnblogs.com\tdws . 首先提供一种获取hashCode的方法,是一种比较受欢迎的方式,该方法参照了一位园友的 ...

  6. HashMap与HashTable的区别

    HashMap和HashSet的区别是Java面试中最常被问到的问题.如果没有涉及到Collection框架以及多线程的面试,可以说是不完整.而Collection框架的问题不涉及到HashSet和H ...

  7. JDK1.8 HashMap 源码分析

    一.概述 以键值对的形式存储,是基于Map接口的实现,可以接收null的键值,不保证有序(比如插入顺序),存储着Entry(hash, key, value, next)对象. 二.示例 public ...

  8. HashMap 源码解析

    HashMap简介: HashMap在日常的开发中应用的非常之广泛,它是基于Hash表,实现了Map接口,以键值对(key-value)形式进行数据存储,HashMap在数据结构上使用的是数组+链表. ...

  9. java面试题——HashMap和Hashtable 的区别

    一.HashMap 和Hashtable 的区别 我们先看2个类的定义 public class Hashtable extends Dictionary implements Map, Clonea ...

随机推荐

  1. iOS燃烧动画、3D视图框架、天气动画、立体相册、微信朋友圈小视频等源码

    iOS精选源码 iOS天气动画,包括太阳,云,雨,雷暴,雪动画. 较为美观的多级展开列表 3D立体相册,可以旋转的立方体 一个仪表盘Demo YGDashboardView 一个基于UIScrollV ...

  2. ValidationUtil

    package me.zhengjie.common.utils; import me.zhengjie.common.exception.BadRequestException; import ja ...

  3. deeplearning.ai 卷积神经网络 Week 1 卷积神经网络

    1. 传统的边缘检测(比如Sobel)手工设计了3*3的filter(或者叫kernel)的9个权重,在深度学习中,这9个权重都是学习出来的参数,会比手工设计的filter更好,不但可以提取90度.0 ...

  4. LINQ之路 7:子查询、创建策略和数据转换(要点笔记)

    匿名类型 上面我们自己定义了类型TempProjectionItem来存放查询的结果.通过使用匿名类型,我们可以省去这种中间类型的定义,而由编译器来帮我们完成: select item.Origina ...

  5. python运算符和常用数据类型转换

    运算符 算术运算符 运算符 描述 实例 + 加 两个对象相加 a + b 输出结果 30 - 减 得到负数或是一个数减去另一个数 a - b 输出结果 -10 * 乘 两个数相乘或是返回一个被重复若干 ...

  6. 吴裕雄--python学习笔记:os模块的使用

    在自动化测试中,经常需要查找操作文件,比如说查找配置文件(从而读取配置文件的信息),查找测试报告(从而发送测试报告邮件),经常要对大量文件和大量路径进行操作,这就依赖于os模块. 1.当前路径及路径下 ...

  7. 选拔赛 hash 字符串匹配 哈希算法(白书p374)

    hash   Description dr所在国度的有个奇怪的规定:他们的字母不是a~z,而是用1~1000表示. 利用这个奇怪的规定,dr想出了一个好玩的游戏:首先给出n个字符串(当然每个字符用1~ ...

  8. 多线程的join功能

    import threading import time def thread_job(): print("T1 start\n") for i in range(30): tim ...

  9. R (Ani Katchova) · Eric

    首先介绍一下Ani Katchova的R教程,然后再继续总结Advanced R. R introduction setwd("path")设置工作路径 mydata<-re ...

  10. hexo创建新文章的正确方法

    起因 之前我一直是通过复制以前的文章的形式来创建一个新的文档,但是这一次似乎遇到了一些问题.我将文章写完之后,准备进行预览,输入hexo s命令.在预览页面却没有显示出新的文章,还是和之前的页面是一样 ...