-
hash概念
把关键字通过某个函数映射到得到一个固定值,然后这个固定值来确定数组中的某个位置,通过数组下标一次定位就可知道这个关键字的位置:
存储位置 = hash(关键字)
其中,这个函数就是hash算法。
-
HashMap数据结构
HashMap的底层实现是: 数组 + 链表+红黑树
2.1 结点
我们先来看看结点的结构图:
HashMap中的结点包含了四个部分:key,value,hash,指向下一个结点的引用。定义如下:
static class Node implements Map.Entry {
final K key; // key值
V value; // value值
Node next; // 链地址法解决hash冲突,单链表的每一个节点都含有指向下一个结点的引用
int hash;
Node(int hash, K key, V value, Node next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
@Override
public K getKey() {
return key;
}
@Override
public V getValue() {
return value;
}
public final String toString() {
return key + "=" + value;
}
@Override
public V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry, ?> e = (Map.Entry, ?>) o;
if (Objects.equals(key, e.getKey())
&& Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
Node类已经重写了hashCode()和equal()方法。
2.2 红黑树
讲解之前,先来看看结点的定义:
-
左旋分析
传入的是p结点,root结点。这里要求以p结点为中心的左旋这颗红黑树。
第一步:数据项为10的结点将成为这棵树的新的root结点:
第二步:r的左边结点即将成为p的右结点,同时中间分成的结点也找到了父节点。
第三步:判断新的根节点的父节点是否存在,然为将新的根节点r设置父节点。
如果p结点的父节点不存在:pp = p.parent;那就是说这个新的结点r就成为根节点了,由于不能违背根节点必须是黑色这一原则,还必须把根节点设置为黑色。如下所示:
如果p结点的父节点存在:pp = r.parent = p.parent;这个时候我们就要分为,p结点原来是父节点的左结点,还是右结点。
如果p原来是父节点的左结点,如图所示:
那么新的根节点r就会成为pp的左结点:
如果p原来是父节点的右结点,根据上面分析,新的结点r就会成为pp的右结点, pp.right = r;
第四步:现在我们看到r结点有三个分支了,但是按照红黑树结点定义,应该只有左结点,右结点的。右结点没有变化,那么主要看看左结点,左结点应该就是p分支的节点了,就相当于:r.left = p;
根据每个结点的定义,我们知道有父节点定义,左结点定义,右结点定义。现在我们已经知道了p的新的父节点就是r。直接为其设置父节点:p.parent = r;
static TreeNode rotateLeft(TreeNode root,
TreeNode p) {
TreeNode r, pp, rl;
if (p != null && (r = p.right) != null) {
if ((rl = p.right = r.left) != null)
rl.parent = p;
if ((pp = r.parent = p.parent) == null)
(root = r).red = false;
else if (pp.left == p)
pp.left = r;
else
pp.right = r;
r.left = p;
p.parent = r;
}
return root;
}
对比自己写的:
/**
* 以p结点为中心左旋,实际将以p结点右子树结点进行旋转
* 这里需要考虑到p结点,p结点的右结点rootTemp,rootTemp的左结点rootTempLeft,p的父节点四个结点的变化,考虑的时候主要考虑这些结点的哪些属性变化了。
* 比如说p结点,左旋之后,p结点的父节点变成了rootTemp,p结点的右结点发生了变化
* rootTemp成为了新的结点,左结点,父节点发生了变化
* rootTempLeft的父节点变成了p
* pParent的子节点发生了变化,这个主要是根据原来p结点是rootTemp的左结点还是右结点。
*
* @param root
* @param p
* @return
*/
static TreeNode rotateLeft(TreeNode root,TreeNode p) {
TreeNode rootTemp,pRightLeftTemp,pParentTemp;//主要设置新的根节点,p结点的右结点,p结点父节点的属性
if (p != null) {
rootTemp = p.right;
if (rootTemp != null) {
//中间结点pRightLeftTemp设置给p结点的右结点,pRightLeftTemp结点的父节点有变化,需要重新设置
pRightLeftTemp = p.right = rootTemp.left;
pRightLeftTemp.parent = p;
//重新设置新的根节点的父节点属性
pParentTemp = p.parent;
if (pParentTemp == null) {
//当前结点就是root结点,且遵循红黑树root结点必须是黑色
root = rootTemp;
root.red = false;
} else if (pParentTemp.left == p) {
//结点p是父节点的左结点
pParentTemp.left = rootTemp;
} else {
//结点p是父节点的右结点
pParentTemp.right = rootTemp;
}
rootTemp.left = p;
rootTemp.parent = pParentTemp;
p.parent = rootTemp;
}
}
return root;
}
}
- 插入结点
红黑树特点:
(1)每个结点都是黑色或者是红色。
(2)根节点是黑色。
(3)每个叶子结点是黑色。
(4)如果一个结点是红色,它的子节点必须是黑色的。(如果一个结点是黑色的,并不能代表其子节点是红色的。)
(5)从一个结点到该结点的子孙结点的所有路径上包含相同数目的黑结点。
插入的时候由于需要保证红黑树上面5条规则,因此,插入的时候我们需要根据这些规则,对红黑树做一些调整或者旋转操作。
由于要满足第5条规则,因此我们统一规定插入的结点颜色必须是红色。这样最低程度减小其他操作对红黑树的影响。
这里插入结点X有三种情况
1.插入的是根节点(root)
根据规则(2),只需要把该结点的颜色变为黑色即可。
2.X结点的父节点是黑色
满足规则(5),则不做处理。
3.X结点的父节点是红色
不满足规则(4)需要做调整,通常都是通过旋转或者改变结点颜色来达到红黑树规则的平衡。
我们知道如果x结点是红色,父节点是红色,则x祖父结点是黑色。
a.父节点是红色,叔父结点是红色
显然这里违背了父节点和子节点不能同时为红色,因此我们需要想办法把父节点XP和结点X分开。显然祖父结点是黑色的,那么我们可以通过交换父节点XP和祖父结点XPP的颜色就可以呢?这个时候又要取决于我的叔父结点了。因为如果我的叔父结点是红色,一经交换之后,如下图所示:
这又使得叔父结点和祖父结点违背了规则(4)(5),又需要调整了。我们直接将叔父结点设置为黑色就可以了,这样每个路径上黑色结点数目保持不变了。如下所示:
这样就满足了条件。 但是,我们有可能又改变了祖父结点XPP与其父节点的红黑树规则,因此尾递归的形式来对节点XPP进行调整。
===================================================================
修正方法:祖父节点改成红色,同时将父节点和叔叔节点改成黑色即可。
===================================================================
b. 叔父结点是黑色
如果叔父结点是黑色呢?如下图所示:
如果这个时候我们把父节点XP变成黑色,很显然就违背了规则(5),影响了XPP_XP_X这条路径上黑色结点的个数:
联想一下我们在上一种情况下修改了祖父结点XPP的颜色,因为XPP是辈分最高的结点,最顶点的节点的颜色可以是红色(重新调整)或者是黑色。如果我们把XP变成辈分最高的结点,就可以修改其颜色了。这个时候就需要通过旋转的方式来使得XP变成辈分最高的结点。
现在我们就可以将父节点XP设置成黑色。
但此时,我们改变了XP_XPP_XPR路径上黑色结点数目。违背了规则(5)。因此不能直接这么设置成黑色。我们考虑到交换XP和XPP的颜色即可。
================================================================
修正方法:旋转+交换颜色。
================================================================
现在我们知道了解决这种父节点为红色,叔父结点为黑色的情况。那么主要解决的是旋转问题,就是说是左旋转还是有旋转了。这种又分为四种情况:
X结点为左结点,父节点为左结点。
X结点为左结点,父节点为左结点情况如图所示:
这种情况下需要经历一次旋转,以父节点XP为中心右旋:
然后将父节点XP设置成黑色,祖父结点设置成红色即可:
修正方法:以父节点XP为中心右旋转,然后改变结点颜色。
X结点为右结点,父节点为左结点。
X结点为右结点,父节点为左结点情况如图所示:
以父节点XP为中心左旋:
然后再以X结点为中心右旋转,如图所示:
然后设置X结点颜色为黑色,祖父结点颜色为红色即可:
修正方法:要经历两次旋转,首先以父节点XP为中心左旋转,再以X结点为中心右旋,最后改变结点颜色。
X结点为左结点,父节点为右结点。
结点为右结点,父节点为左结点
先以父节点XP为中心右旋:
再以X结点为中心左旋转。
然后将X结点设置为黑色,祖父结点XPP设置为红色即可。如图所示:
修正方法:要经历两次旋转,首先以父节点XP为中心右旋转,再以X结点为中心左旋,最后改变结点颜色。
X结点为右结点,父节点为右结点。
X结点为右结点,父节点为右结点的情况如图所示:
以父节点XP为中心左旋:
父节点XP设置为黑色,祖父结点XPP设置为红色:
修正方法:首先以父节点XP为中心左旋转,然后改变结点颜色。
区分左旋还是右旋,就把这个结点当做一个天平的中心位置,如果右边重了往下沉,就左旋,相反右旋。
代码实现如下:
/**
/**
* 插入结点,必须保证红黑树的规则,所以要经历旋转或者颜色调整
* 红黑树特点:
* (1)每个结点都是黑色或者是红色。
* (2)根节点是黑色。
* (3)每个叶子结点是黑色。
* (4)如果一个结点是红色,它的子节点必须是黑色的。(如果一个结点是黑色的,并不能代表其子节点是红色的。)
* (5)从一个结点到该结点的子孙结点的所有路径上包含相同数目的黑结点
* 插入的是红色结点,这样避免违背规则(4),简化了红黑树的插入操作
* 插入需要分情况:
* 1.插入的是根节点,结点颜色设置为黑色即可。
* 2.插入结点的父节点是黑色,不做改变。
* 3.插入父节点是红色又分为两种情况:3.1 叔父结点是红色,只需要将插入结点x的父节点xp,叔父结点xpb设置为黑色,x的祖父结点xpp设置为红色。
* 3.2 叔父结点是黑色,又分为四种情况:
* 3.2.1 xp结点是左结点,x是左结点
* 3.2.2 xp结点是左结点,x是右结点
* 3.2.3 xp结点是右结点,x是左结点
* 3.2.4 xp结点是右结点,x是右结点
* @param root
* @param x
* @return
*/
static TreeNode balanceInsertion(TreeNode root,TreeNode x) {
//三种情况:1.插入的是根节点,直接插入,并保证规则(1),将结点变为黑色。
//2.插入的结点x的父节点是黑色,满足规则(5)。直接插入。
//3.插入的结点x的父节点是红色,不满足规则(4),需要经过旋转或者改变颜色进行调整。
x.red = true;//默认插入结点是红色,保证规则(5),简化红黑树操作的复杂性
for (TreeNode xp,xpp,xppl,xppr;;) {
if (x != null) {
xp = x.parent;
//第一种情况,插入根节点
if (xp == null) {
x.red = false;
return x;
} else if (!xp.red || (xpp = xp.parent) == null) {
//第二种情况,父节点是黑色结点,不做处理
return root;
}
xppl = xpp.left;
if (xp == xppl) {
//xp为左结点
xppr = xpp.right;
//叔父结点为红色,结点X的祖父结点XPP颜色设置为红色,XP和XPB设置为黑色
if (xppr != null && xppr.red) {
xp.red = false;
xpp.red = true;
xppr.red = false;
x = xpp;//继续向上迭代
}
else {
//如果x为xp的右结点,需要先进行xp为中心的左旋
if (x == xp.right) {
root = rotateLeft(root,xp);
x = xp;//xp结点与x结点位置交换
xp = x.parent;
xpp = xp == null?null : xp.parent;
}
//XP设置成黑色,XPP设置成红色,以XPP为中心右旋
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateRight(root, xpp);
}
}
}
}
else {
//xp结点为右结点
//结点X的祖父结点XPP颜色设置为红色,X和XPPL设置为黑色
if (xppl != null && xppl.red) {
xpp.red = true;
x.red = false;
xppl.red = false;
x = xpp;//继续向上迭代
}
else {
if (x == xp.left) {
//如果是左结点,先以XP为中心右旋
root = rotateRight(root, xp);
x = xp;
xp = x.parent;
xpp = xp == null?null:xp.parent;
}
//XP设置成黑色,XPP设置成红色,以XPP为中心左旋
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateLeft(root, xpp);
}
}
}
}
}
}
}
}
为了更直观的看这个源代码,我们现在画上流程图:
2.3 具体的HashMap的数据结构
具体的HashMap的数据结构如下所示:
-
如何解决hash冲突
我们知道这种通过函数得到的固定值,通过hash()算法之后,有可能存在key1和key2得到的固定值一致,这样定位到的数组地址一致了,引起了hash冲突。那么如何解决这个hash冲突呢?
解决hash冲突有四种方法:开放地址法,再hash法,链地址法,建立公共溢出区。
- 开放地址法
这种方法也称再散列法,其基本思想是:当关键字key的哈希地址p=H(key)出现冲突时,以p为基础,产生另一个哈希地址p1,如果p1仍然冲突,再以p为基础,产生另一个哈希地址p2,…,直到找出一个不冲突的哈希地址pi ,将相应元素存入其中。这种方法有一个通用的再散列函数形式:
Hi=(H(key)+di)% m i=1,2,…,n
其中H(key)为哈希函数,m 为表长,di称为增量序列。增量序列的取值方式不同,相应的再散列方式也不同。主要有以下三种:线性探测再散列,二次探测再散列,伪随机探测再散列。
线性探测再散列:这种方法是线性的,冲突发生时,在数组中查找下一个地址,一直找到一个为null的地址来存放这个元素。缺点是比较耗时。
二次探测再散列:冲突发生时,在表的左右进行跳跃式探测,比较灵活。
伪随机探测再散列:具体实现时,应建立一个伪随机数发生器,(如i=(i+p) % m),并给定一个随机数做起点。然后随机探测,一直找到不冲突的hash地址。
- 再hash法
这种方法是同时构造多个不同的哈希函数:
Hi=RH1(key) i=1,2,…,k
当哈希地址Hi=RH1(key)发生冲突时,再计算Hi=RH2(key)……,直到冲突不再产生。增加了计算时间。 - 链地址法
这种方法的基本思想是将所有哈希地址为i的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第i个单元中,因而查找、插入和删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。目前java实现HashMap,就是采用这种方法。 - 建立公共溢出区
建立了一个公共溢出区,一旦发现hash冲突,将其放入到这个公共溢出区。增加了额外空间。
-
HashMap变量以及常量分析
常量:
/*****************************************************常量*********************************************************/
//默认初始化容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //16
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//树形结构存储的阈值,就是说当node结点树超过这个阈值,则将链表转为树形结构存储,将查找元素的时间效率从O(n)降低到O(logN)
static final int TREEIFY_THRESHOLD = 8;
//由树转换成链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
//树形结构最小容量
static final int MIN_TREEIFY_CAPACITY = 64;
变量:
/*****************************************************fileds*********************************************************/
// 定义数组
Node[] table;
//实际拥有的键值对
transient Set> entrySet;
//key-value对的数量
transient int size;
//被修改次数
transient int modCount;
//HashMap的实际key-values对的数量size大于threshold(key-value对的临界值)时会执行resize(扩容)操作
int threshold;
//装载因子。装载因子用来衡量HashMap满的程度。loadFactor的默认值为0.75f.计算HashMap的实时装载因子的方法为:size/capacity,而不是占用桶的数量去除以capacity。
final float loadFactor;
-
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);//根据初始化容量界定table的key-value对的临界值,返回一个比给定整数大最接近的2的幂次方整数
}
/**
* 空构造器,默认负载因子为0.75
*/
public HashMap(){
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
/**
*
* @param initialCapacity 初始容量
*/
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
-
HashMap常用辅助方法
6.1 桶的下标计算方法
HashMap中通过hash()方法来定位key-values对在桶中的位置。如下代码所示:
tab[(n - 1) & hash]
而我们知道一个好的hash()方法,尽最大可能解决冲突问题使得key-values键值对均匀的分散在桶中。
设计者想了一个顾全大局的方法(综合考虑了速度、作用、质量),就是把高16bit和低16bit异或了一下。设计者还解释到因为现在大多数的hashCode的分布已经很不错了,就算是发生了碰撞也用O(logn)的tree去做了。仅仅异或一下,既减少了系统的开销,也不会造成的因为高位没有参与下标的计算(table长度比较小时),从而引起的碰撞。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
可以看到这个函数大概的作用就是:高16bit不变,低16bit和高16bit做了一个异或,这样使得高位也可以参与hash,更大程度上减少了碰撞率。
下面举例说明下,n为table的长度,这里假设n=16:
从上面结果我们可以看到,index=5;
这个时候再高16位参与运算下,减少碰撞的可能性。
6.2 扩容方法resize()
- 扩容条件
首先,我们必须知道hashmap在什么情况下需要扩容呢?esize方法是在hashmap中的键值对size大于阀值threshold时或者table初始化时,就调用resize方法进行扩容。 - 扩容倍数
然后我们还要了解到具体扩容之后做了些什么?每次扩容后,容量为原来的 2 倍,之后重新计算index,把节点再放到新的bucket中。
然后我们来看下源代码:
/**
* 数组扩容
* @return
*/
final Node[] resize() {
Node[] oldTab = table;//暂存原来的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;
}
//如果当前hash桶数组的长度在扩容后仍然小于最大容量 并且oldCap大于默认值16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY){
newThr = oldThr << 1; //扩充为原来的2倍
}
}
else if (oldThr > 0){
newCap = oldThr;
}
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//计算新的resie上线
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" })
//重新构造一个新的table,并重新计算index,然后将原来的oldTab迁移到新的newTab中
Node[] newTab = (Node[])new Node[newCap];
table = newTab;
if (oldTab != null) {
//重新迁移
for (int j = 0; j < oldCap; ++j) {
Node e;
if (oldTab[j] != null) {
e = oldTab[j];
oldTab[j] = null;//释放空间
if (e.next == null){
//如果桶中只有这一个元素e,重新计算index并将e赋值到newTab中
newTab[e.hash & (newCap - 1)] = e;
}
//如果是一颗红黑树或者链表结构
else if (e instanceof TreeNode) {
((TreeNode)e).split(this, newTab, j, oldCap);
}
else {
//将这个桶中的链表赋值到新table中
Node loHead = null, loTail = null;
Node hiHead = null, hiTail = null;
Node next;
do {
//扩容为原来的两倍,因此要么重新hash之后再原位置,是在原位置再移动2次幂的位置。元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit,
//因此新的index就会分为原位置和原索引+oldCap。因此只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。
next = e.next;//一个一个遍历
//原索引
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 原索引+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到bucket里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到bucket里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
扩容为原来的两倍,因此要么重新hash之后再原位置,是在原位置再移动2次幂的位置。元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit,因此新的index就会分为原位置和原索引+oldCap。因此只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。这样避免 了重新计算hash。
如图所示:
-
HashMap常用方法
常用方法无非是put()和get()方法。
-
put()方法
如上图所示,已经画好了put(K key,V value)方法的流程图,我们具体来分析一下执行过程:
- 第一次put(K key,V value)的时候,由于最开始没有初始化table,因此需要先进行初始化。
当(tab = table) == null || (n = tab.length) == 0时,表示我们的table未初始化,我们先对table进行初始化,由于table实际是数组,因此我们必须先知道数组的大小,这个时候需要第一次扩容:n = (tab = resize()).length; - 如果我们的数组已经初始化过了,我们就要找到当前key对应table中的位置,通过hash()方法来获取index,并定位到这个key对应的值:p = tab[i = (n - 1) & hash]。
- 如果定位到的这个tab[index]==null,就说明现在table里面还没有任何的key-value。此时我们直接插入即可。然后执行步骤5.如果tab[index]!=null,表示我们table中现在有值,说明这是链表或者红黑树的第一个元素,只需要先判断tab[index]这个值的key是否等于插入的key值:
p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))
如果这些条件都满足,就说明tab[index]存放的就是这个key对应的键值对,现在只需要覆盖原来的key-value对就行了。e = p;如果不相等则继续执行步骤4. - 现在我们知道tab[index]作为第一个元素,key不相等了,那么我们需要遍历红黑树或者链表来判断如何插入。如果tab[index]是红黑树结点,则我们只需要直接插入到红黑树即可。否则,表示我们需要遍历链表,如果链表的节点数超过8,则要将链表转为红黑树,再执行红黑树的插入。如果没有超过8,该结点又不存在链表中,我们需要将该结点插入到链表的头结点。如果存在,则直接覆盖即可。
- 插入执行完了,我们需要进行一次预扩容。就是说当table中key-value对数量超过阈值,我们需要进行扩容操作。
源代码实现如下:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node[] tab; Node p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node 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)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
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;
}
java1.8实现的HashMap在代码上尽量简洁了,但是可读性不太好,为了能够最大程度理解其思想,我用自己的方式实现了下,其实思路一样:
/**
*
* @param hash
* @param key
* @param value
* @param onlyIfAbsent
* @param evict
* @return
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node[] tab; Node p; int n, index;
tab = table;
n = tab.length;
//第一次插入,需要对table表初始化
if (tab == null || n == 0) {
//扩容
tab = resize();
n = tab.length;
}
//计算index
index = (n -1)& hash;
//获取inddex下标的元素
p = tab[index];
//此时index处没有key-value对,直接插入
if (p == null) {
tab[index] = new Node<>(hash, key, value, null);
}
//此时index处有key-value对,先判断作为首元素的tab[index]的hash和key是否相等来判断,该key的元素是否存在,如果存在,直接覆盖。
else {
Node e;
K k = p.key;
//tab[index]就是给定key对应的键值对,只需要改变value值即可
if (p.hash == hash && ( key == k || (key != null && key.equals(k)))) {
e = p;
}
//接下来是首元素不是该key对应的key-value对,就需要判断tab[index]对应的是链表还是树形结构
else if (p instanceof TreeNode) {
//1.是红黑树结点,直接插入到红黑树
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
}
else {
//2.是链表元素结点,从链表第二个元素开始遍历。
for (int binCount = 0; ; ++binCount){
e = p.next;//此时说明tab[index]里面只有一个元素,直接插入到头结点即可
if ((e = p.next) == null) {
p.next = new Node<>(hash, key, value, null);
//链表长度大于8转换为红黑树进行处理
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果遍历过程中发现key值对应有key-value存在,则直接覆盖
if (p.hash == hash && ( key == k || (key != null && key.equals(k)))) {
break;
}
p = e;
}
}
}
++modCount;
//如果容量超过阈值,需要扩容
if (++size > threshold){
resize();
}
afterNodeInsertion(evict);
return null;
}
- get(K key)方法
public V get(Object key) {
Node e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
* 获取key对应的key-value对
* @param hash
* @param key
* @return
*/
final Node getNode(int hash, Object key) {
Node[] tab; Node first, e; int n; K k;
tab = table;
//table为空
if (tab == null) {
return null;
}
//否则查找的时候,需要知道是红黑树查找还是链表查找
int index = (n-1) & hash;
first = tab[index];
if (first != null) {
//就是说key定位到的位置元素是存在的
k = first.key;
if (first.hash == hash && (key == k || (k != null && k.equals(key))) ) {
//如果首元素就是key对应的key-value,直接返回
return first;
}
if ((e = first.next) != null) {
//红黑树中查找
if (first instanceof TreeNode) {
return ((TreeNode)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);
}
}
//未找到key-value对,返回null
return null;
}
从我们的源码分析知道,当tab[index]下的元素超过8,则将链表转为红黑树,这样查找效率就由O(n)降低为O(logN)了。
-
特点
- HashMap允许null的value,null的key.
- HashMap大致和Hashtable实现和功能一致,除了HashMap不是线程安全的集合,允许为null的value和key.
- HahsMap不保证元素顺序,特别是它不保证该顺序恒久不变.
- HahsMap为get和put操作提供稳定的性能,前提是hash()函数能够在桶中适当的分散元素.
- HashMap有两个重要因素影响其性能:初始化容量initialCapacity和负载因子loadFactor.
- capacity代表table的桶的个数,initialCapacity仅仅在创建table的时候赋值.当hash table中键值对超过了负载因子*当前容量的时候,需要进行rehashed(即重建内部数据结构)。
- 此实现不是同步的,如果多个线程同时访问一个哈希映射,而其中至少一个线程从结构上修改了该映射,则它必须 保持外部同步,比如:Map m = Collections.synchronizedMap(new HashMap(...)); .
- 迭代器的fast-fail仅用来检测错误.
- 引入红黑树,提升了查找效率.