HashMap源码分析

news/2024/9/28 19:28:09

HashMap源码分析

image-20240901220247606

在jdk1.8中,HashMap的数据结构如上图所示,是由Node数组+链表/红黑树组成的,每个K-V对保存在一个Node结点中,看一下Node结点的定义,其实就是一个Map.Entry<K,V>的实现类,包括key的hash值,key,value和一个next指针。

static class Node<K,V> implements Map.Entry<K,V> {final int hash;final K key;V value;Node<K,V> next;...
}//树节点
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {TreeNode<K,V> parent;  // red-black tree linksTreeNode<K,V> left;TreeNode<K,V> right;TreeNode<K,V> prev;    // needed to unlink next upon deletionboolean red;TreeNode(int hash, K key, V val, Node<K,V> next) {super(hash, key, val, next);}...
}

HashMap中定义的几个静态变量:

public class HashMap<K,V> extends AbstractMap<K,V>implements Map<K,V>, Cloneable, Serializable {private static final long serialVersionUID = 362498820763181265L;/*** HashMap的初始容量为2^4=16,容量表示table[]的长度,也是哈希桶的个数*/static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16/*** HashMap的最大容量为2^30*/static final int MAXIMUM_CAPACITY = 1 << 30;/*** 负载因子,当元素数量大于当前容量的0.75倍时,HashMap进行自动扩容*/static final float DEFAULT_LOAD_FACTOR = 0.75f;/*** 树化阈值,当哈希桶上的链表长度大于等于8时进行扩容或转为红黑树*/static final int TREEIFY_THRESHOLD = 8;/*** 和树化阈值相反,树节点数量小于6时红黑树转为链表*/static final int UNTREEIFY_THRESHOLD = 6;/*** 最小树化容量,当前容量大于等于64时,桶中链表才会转换为红黑树,否则只是扩容*/static final int MIN_TREEIFY_CAPACITY = 64;...
}

看一下HashMap的put()方法是怎样插入结点的:

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[]为空,初始化一个table[]if ((tab = table) == null || (n = tab.length) == 0)n = (tab = resize()).length;//根据key的hash值定义key在table[]中的下标i,p作为结点指针判断下标i位置是否为空//如果为空,直接在该位置创建一个Node结点就好了,否则进入p指向下标为i的结点进入elseif ((p = tab[i = (n - 1) & hash]) == null)tab[i] = newNode(hash, key, value, null);else {Node<K,V> e; K k;//进入else,说明要插入的k的hash值已存在,如果有相同的key,则替换,否则,插入链表或红黑树//判断key是否相同,相同的话用e表示旧结点,后面进行替换if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))e = p;//如果p是树节点,则将新结点插入红黑树中,p指向树的根节点else if (p instanceof TreeNode)e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);//这个else表示将新结点插入链表,此时p指向链表头节点else {//遍历链表for (int binCount = 0; ; ++binCount) {//尾插法插入新节点if ((e = p.next) == null) {p.next = newNode(hash, key, value, null);//如果链表长度大于等于8,进行扩容,或者树化if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1sttreeifyBin(tab, hash);break;}//如果在链表中找到相同的key,则新value替换旧valueif (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))break;p = e;}}//新值替换旧值if (e != null) { // existing mapping for keyV oldValue = e.value;if (!onlyIfAbsent || oldValue == null)e.value = value;afterNodeAccess(e);return oldValue;}}++modCount;//节点数量大于阈值进行扩容if (++size > threshold)resize();afterNodeInsertion(evict);return null;
}    

总结一下put的流程:首先,判断插入节点的哈希地址是否存在元素,没有的话直接插入节点。有的话,首先判断插入节点和已有节点的哈希值和key值,如果相同,覆盖掉之间的节点。如果不相同,说明应该将这个节点插入红黑树或者链表。如果是插入链表,当链表长度超过阈值8时,先判断数组长度是否大于等于64时,是的话会将链表转换为红黑树结构,否则只是进行扩容。另外,当HashMap中的元素个数大于阈值时,也要进行扩容。

可以发现,HashMap的扩容时机有两个,一是当元素个数大于阈值时,二是当哈希桶的链表长度大于8,并且数组长度小于64时,接下来看看HashMap的扩容机制。

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^1倍,新容量不能超过最大容量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 thresholdnewCap = oldThr;else {               // zero initial threshold signifies using defaults//这里是初始化时的赋值newCap = DEFAULT_INITIAL_CAPACITY;newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);}//在这里计算新的threshold,新容量*负载因子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;//表示hash地址只有一个结点if (e.next == null)//计算元素新的下标,直接放入newTab[e.hash & (newCap - 1)] = e;//如果旧元素是树结点//split会将树分成两个链表,这是因为扩容后重新计算数组下标//hash值相同的结点可能会被分配到两个不同的桶中//然后对两个链表进行处理,根据元素数量转为链表或红黑树//最后放到新数组中else if (e instanceof TreeNode)((TreeNode<K,V>)e).split(this, newTab, j, oldCap);//这个else表示e后有链表,拆分成两个链表,分别处理,原因同上else { // preserve orderNode<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;elseloTail.next = e;loTail = e;}else {if (hiTail == null)hiHead = e;elsehiTail.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的扩容机制:HashMap的扩容时机有两个,一是当元素个数超过一定阈值时,二是当哈希桶的链表长度大于8,并且table[]数组长度小于64时,会触发扩容。新容量会扩充到原来容量的两倍,并根据新的容量创建一个新数组。接下来,遍历旧数组中的元素,根据元素的hash值重新计算元素在新数组中的下标,并放入对应的位置。如果碰到树或者链表,首先进行节点分裂,分裂成两个链表,这是因为原来在同一个哈希桶中的元素在扩容后可能会被分到两个不同的桶中,然后分别处理将元素放到新的数组中,另外,在这个过程中,如果桶中的元素数量超过8,链表会转换为红黑树,如果元素数量小于6,红黑树会退化成链表。

再看一下树化的方法:

final void treeify(Node<K,V>[] tab) {TreeNode<K,V> root = null;//遍历链表for (TreeNode<K,V> x = this, next; x != null; x = next) {next = (TreeNode<K,V>)x.next;x.left = x.right = null;//第一次循环将root设为第一个节点,之后循环走elseif (root == null) {x.parent = null;x.red = false;root = x;}else {K k = x.key;int h = x.hash;Class<?> kc = null;for (TreeNode<K,V> p = root;;) {//确定插入节点的方向,左侧还是右侧int dir, ph;K pk = p.key;if ((ph = p.hash) > h)dir = -1;else if (ph < h)dir = 1;//哈希值相同,比较键值else if ((kc == null &&(kc = comparableClassFor(k)) == null) ||(dir = compareComparables(kc, k, pk)) == 0)dir = tieBreakOrder(k, pk);TreeNode<K,V> xp = p;//p==null表示找到了插入的位置,将新节点x插入p的位置,否则回到上面继续遍历if ((p = (dir <= 0) ? p.left : p.right) == null) {x.parent = xp;if (dir <= 0)xp.left = x;elsexp.right = x;//红黑树的平衡调整操作root = balanceInsertion(root, x);break;}}}}//将最新的root节点放到数组的位置moveRootToFront(tab, root);
}

为什么HashMap要使用红黑树呢?先看一下红黑树的性质:

  • 每个节点非红即黑
  • 根节点是黑色的
  • 中序遍历由小到大
  • 每个叶子节点都是黑色的空节点
  • 红色节点的子节点一定是黑色的,反之不一定
  • 从任意节点到它的叶子节点的路径中,黑色节点的数量相同

红黑树的这些性质保证了红黑树的近似平衡,跟链表相比,当数据量大时,红黑树查询的时间复杂度更低,跟完全平衡树相比,红黑树节省了很多平衡调整操作。因此,红黑树在各种操作下的性能都是比较好的。

另外,注意到在HashMap中提供了这么三个方法,但是并没有实现,这些方法是提供给LinkedHashMap使用的,LinkedHashMap继承了HashMap,并重写了这三个方法,接下来分析一下LinkedHashMap。

void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }

先看一下LinkedHashMap的数据结构,它是由一个个Entry组成的存储键值对的双向链表。Entry继承了HashMap的Node,有添加了两个前后指针。

static class Entry<K,V> extends HashMap.Node<K,V> {Entry<K,V> before, after;Entry(int hash, K key, V value, Node<K,V> next) {super(hash, key, value, next);}
}

image-20240902170956867

LinkedHashMap插入节点时会插入到链表末尾,因此可以按照插入顺序迭代元素,LinkedHashMap直接使用HashMap的put()方法插入元素,流程是一样的,当调用newNode()方法时,LinkedHashMap进行了重写,通过linkNodeLast()将新元素插入到双向链表的表尾。

Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {LinkedHashMap.Entry<K,V> p =new LinkedHashMap.Entry<K,V>(hash, key, value, e);linkNodeLast(p);return p;
}private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {LinkedHashMap.Entry<K,V> last = tail;tail = p;if (last == null)head = p;else {p.before = last;last.after = p;}
}

另外,在put()中还调用了afterNodeAccess()和afterNodeInsertion(),看一下LinkedHashMap的实现。

先看afterNodeAccess(),它的作用是将元素移动到链表尾,触发时机是LinkedHashMap调用get()方法或者调用put()方法修改key的value时,根据这个机制可以想到利用LinkedHashMap来实现LRU缓存,将最近使用的元素移到链表末尾,注意需要设置accessOrder为true才能使用这个方法。

void afterNodeAccess(Node<K,V> e) { // move node to lastLinkedHashMap.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;elseb.after = a;if (a != null)a.before = b;elselast = b;if (last == null)head = p;else {p.before = last;last.after = p;}tail = p;++modCount;}
}

再来看一下afterNodeInsertion()方法,它的作用是移除链表头的元素,触发时机是添加完一个元素之后。

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

不过这个方法需要满足removeEldestEntry(first)这个方法为true,它默认返回false,如果我们想利用它实现LRU缓存,可以这样重写,当元素数量超过缓存的容量了,就删除表头的元素,因为表头是最久没有使用的缓存。

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

还有一个方法,afterNodeRemoval(),这个也很简单,就是删除元素之后维护一下前后指针。

void afterNodeRemoval(Node<K,V> e) { // unlinkLinkedHashMap.Entry<K,V> p =(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;p.before = p.after = null;if (b == null)head = a;elseb.after = a;if (a == null)tail = b;elsea.before = b;
}

HashMap是线程不安全的,多线程操作HashMap时可能会出现一些线程安全问题,举几个例子:

1、两个线程进行put操作,当同时执行newNode()方法时,会导致第一个线程的元素丢失。

2、一个线程put,一个线程get,当put时恰好需要扩容,扩容过程中会创建一个新数组并赋值,在这个过程中,另一个线程可能get到空值。

若想线程安全的使用HashMap,可以使用ConcurrentHashMap。

在java8之前,ConcurrentHashMap通过分段锁机制来实现线程安全,ConcurrentHashMap采用的是Segment数组加HashEntry数组的结构,每个Segment包含一个HashEntry数组,由于Segment继承了ReentrantLock,它本身就是一个锁。因此只要hash值足够分散,多线程put的时候就会put到不同的Segment中,多个线程不会互相影响,put的时候,当前的Segment会锁住,从而保证线程安全。

在java8之后,ConcurrentHashMap改成和Node数组加链表红黑树的结构,通过对Node数组加锁来实现线程安全。如果添加数据的哈希地址是空的,通过自旋和CAS操作插入,否则对Node结点加锁,然后插入到链表或者红黑树中。

看一下ConcurrentHashMap的put的源码:

final V putVal(K key, V value, boolean onlyIfAbsent) {if (key == null || value == null) throw new NullPointerException();int hash = spread(key.hashCode());int binCount = 0;for (Node<K,V>[] tab = table;;) {Node<K,V> f; int n, i, fh;//数组为空,初始化if (tab == null || (n = tab.length) == 0)tab = initTable();//f表示数组待插入位置下标的元素//如果是空的,CAS插入新元素else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {if (casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null)))break;                   // no lock when adding to empty bin}//如果正在扩容,帮忙一起扩容else if ((fh = f.hash) == MOVED)tab = helpTransfer(tab, f);//如果不是空的,说明要插入链表或红黑树else {V oldVal = null;//对Node节点加锁,也就是对要插入的桶加锁synchronized (f) {if (tabAt(tab, i) == f) {//如果是链表if (fh >= 0) {binCount = 1;//遍历链表,插入元素for (Node<K,V> e = f;; ++binCount) {K ek;//如果key相同,覆盖旧值if (e.hash == hash &&((ek = e.key) == key ||(ek != null && key.equals(ek)))) {oldVal = e.val;if (!onlyIfAbsent)e.val = value;break;}Node<K,V> pred = e;//尾插插入新元素if ((e = e.next) == null) {pred.next = new Node<K,V>(hash, key,value, null);break;}}}//新元素插入树中else if (f instanceof TreeBin) {Node<K,V> p;binCount = 2;if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,value)) != null) {oldVal = p.val;if (!onlyIfAbsent)p.val = value;}}}}if (binCount != 0) {//链表长度大于阈值8,转红黑树if (binCount >= TREEIFY_THRESHOLD)treeifyBin(tab, i);if (oldVal != null)return oldVal;break;}}}addCount(1L, binCount);return null;
}

这段代码中有一个地方,通过if (fh >= 0)来判断是链表,这是因为ConcurrentHashMap和HashMap对树的存储不太一样,ConcurrentHashMap中树的根节点是一个TreeBin类型的节点,不存储元素,TreeBin节点的Hash值是-2,而HashMap中,树的根节点是直接存储元素的。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.ryyt.cn/news/59492.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈,一经查实,立即删除!

相关文章

408存储系统大题笔记

咸鱼25计组强化课P2的笔记,有点简陋。 课后需要把第三章的课后大题(真题做一下) Cache类题目做题要注意的点!!PA的位数=Cache地址总位数 Cache总行数 Cache块大小 第2和第3相乘得Cache数据区总大小(!=Cache总大小) 映射方式 一致性问题:写策略(直写/回写) 替换算法 CPU…

idea中的docker部署配置

注意:确认本地已安装docker环境 第一步:idea安装docker插件:设置-插件-docker第二步:配置Dockerfile文件FROM harbor.chint.com/wz-build-env-public/openjdk:17 AS base# 项目的端口,内部服务端口 EXPOSE 8808# 切换到容器内部的 /workdir目录 WORKDIR /workdir# 添加要运…

第十一章 图论 Part8

dijkstra(朴素版) 拓扑排序目录单源最短路径算法dijkstra(朴素版)适用范围(权值不能为负数的单源最短路径)思路算法正确性拓扑排序思路心得 单源最短路径算法 dijkstra(朴素版) 适用范围(权值不能为负数的单源最短路径) 思路 基本类似Prim算法,只是新加入(确定)点…

Dockerfile 实战指南:轻松掌握容器化部署!

Dockerfile 非常重要,在实际工作中,使用 Docker 绝不是敲敲一些常用命令即可。Dockerfile 几乎贯穿微服务的全部内容,务必掌握。Dockerfile 非常重要,在实际工作中,使用 Docker 绝不是敲敲一些常用命令即可。Dockerfile 几乎贯穿微服务的全部内容,务必掌握。 不要求能从头…

Hodgkin-Huxley Model 完全推导

Hodgkin-Huxley Model 膜电流计算的相关公式的完全推导,从物理、数学到公式推导。Ciallo~(∠・ω< )⌒★ 我是赤川鹤鸣。本文假设您已经初步了解了 Hodgkin-Huxley Model,这里只是针对其中的公式的一些推导。不会对其优缺点、特性、应用等进行详述。物理基础知识如果已学…

PbootCMS自定义前台404错误页面

PbootCMS已经内置支持自定义内容地址错误情况下错误页面的自定义功能,只需要在站点根目录下定义404.html文件即可扫码添加技术【解决问题】专注中小企业网站建设、网站安全12年。熟悉各种CMS,精通PHP+MYSQL、HTML5、CSS3、Javascript等。承接:企业仿站、网站修改、网站改版、…

Nuxt Kit 的使用指南:从加载到构建

title: Nuxt Kit 的使用指南:从加载到构建 date: 2024/9/12 updated: 2024/9/12 author: cmdragon excerpt: 摘要:本文详细介绍了Nuxt Kit的使用方法,包括如何使用loadNuxt加载配置、buildNuxt进行项目构建、loadNuxtConfig单独加载配置以及writeTypes生成TypeScript配置,…

PbootCMS设置当前站点模板,模板子目录,黑白名单,敏感词过滤等

站点模板敏感词过滤 扫码添加技术【解决问题】专注中小企业网站建设、网站安全12年。熟悉各种CMS,精通PHP+MYSQL、HTML5、CSS3、Javascript等。承接:企业仿站、网站修改、网站改版、BUG修复、问题处理、二次开发、PSD转HTML、网站被黑、网站漏洞修复等。专业解决各种疑难杂症…