hashmap分析

3.HashMap

组成关系
image

3.1在哪个包下

package java.util;

3.2类的继承关系

image

  • Cloneable空接口,表示可以克隆,创建并返回HashMap对象的一个副本;
  • Serializable序列化接口,属于标记性接口,HashMap对象可以被序列化和反序列化;
  • AbstractMap父类提供了Map实现接口,以最大限度地减少实现此接口所需的工作;

3.2成员变量

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 //默认初始容量为16 (1左移4位)。
static final int MAXIMUM_CAPACITY = 1 << 30; //最大容量。
static final float DEFAULT_LOAD_FACTOR = 0.75f; //构造函数中未进行指定的使用的一个负载因子。
static final int TREEIFY_THRESHOLD = 8; //链表节点转换为红黑树节点的一个阈值。
static final int UNTREEIFY_THRESHOLD = 6; //红黑树节点转为链表节点的阈值。
static final int MIN_TREEIFY_CAPACITY = 64; //树化的最小容量为64,也就是哈希表的长度。

3.3为什么默认加载因子为0.75f

为了提高空间利用率和增加查询效率的折中。主要是泊松分布,0.75的话碰撞最小。
如果负载因子比较小,因为阈值等于哈希表的长度*负载因子。(阈值是哈希表中可以存储元素的个数)。哈希表能存的元素个数就更少,那么相应的发生哈希冲突的概率也就降低了,所以查询效率也就提高。但是由于增加相同数量的元素,需要的哈希桶的个数就要进行增加。于是就多次调用扩容方法,扩容会重构哈希结构,这个过程很慢,空间利用率就降低了。为了在时间和空间上有一个折中的结果,满足泊松分布取0.75是一个比较理想的取值。

3.4为什么哈希桶中的节点超过8才转为红黑树?

将链表转为红黑树是为了提高查询的效率,但是这样也带来了一些问题,树节点的大小是普通节点的2倍。
在节点数量较低时,维护红黑树结构的成本是不低于查询成本的,所以此时不值得进行转换。
源码中也有解释 :
在使用分布良好的hashcode时,很少使用红黑树结构,理想情况下,在随机哈希码下,箱子中节点的频率服从泊松分布,从下面源码的概率值可以看出,一个哈希桶中链表长度达到8个元素的概率为0.00000006,这几乎是一个不可能事件。因此选择8,不是随便就决定的,而是根据概率统计决定的。还是得学好数学啊。
* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. In
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million

3.5构造方法

3.5.1指定初始容量和指定负载因子

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);
}
//返回给定目标容量的 2 次方。
static final int tableSizeFor(int cap) {
int n = cap - 1; //对传入的cap进行减1操作,是为了防止cap已经是2的幂次方。
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
假设输入的值为cap=10
int n = cap - 1;//cap=10 n=9
n |= n >>> 1;
00000000 00000000 00000000 00001001 //9
00000000 00000000 00000000 00000100 //9 >>> 1 = 4
-------------------------------------------------
00000000 00000000 00000000 00001101 //9 | (9 >>> 1) = 13
n |= n >>> 2;//n = 13
00000000 00000000 00000000 00001101 //13
00000000 00000000 00000000 00000011 //13 >>> 2 = 3
-------------------------------------------------
00000000 00000000 00000000 00001111 //13 | (13 >>> 2) = 15
//接下去的移位也是类似,移4位、8位、16位,保证在2 ^ 30内能达到目标即可
//此时已经得到我们想要的结果,所以就不继续下去了。这里我做下补充,如果在继续15 | 15>>>4 得到的结果还是15,最后的几步就相当于是没有变化,最后按下面的巧妙的方法得到的值就是16了。不得不说真的很巧妙。
//判断最终结果,总之就是一个很巧妙的方法
(n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;

3.5.2指定一个初始容量和默认加载因子【推荐使用】

public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//这里默认会去调用3.5.3中的构造方法

3.5.3空参构造方法【经常使用】

public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // 默认16 和 0.75f
}

3.5.4构造参数是map的构造方法

public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR; //默认为0.75f
putMapEntries(m, false);
}
//----------------------------------------------------------------------------
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
if (table == null) { // pre-size 如果原来的同还没有初始化
float ft = ((float)s / loadFactor) + 1.0F; //计算需要的桶的大小
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold) //如果计算出来的大于原来的计算出来的扩容大小,则把新计算出来的大小进行赋值,其实就是重新赋值
threshold = tableSizeFor(t);
}
else if (s > threshold) //判断当前的桶的大小可不可以存下m的所有的元素
resize(); //不能存下则从新扩容
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) { //遍历拷贝元素
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}

3.6 hash()方法

该方法是hashmap用来定位元素存储的位置的一个重要方法。我们希望HashMap里面的元素位置尽量分布均匀,因为如果很多元素都在某一个位置,那么我们定位到哈希桶后,还需要继续遍历该位置下的链表或者红黑树,导致效率降低,所以应尽量使得每个哈希桶内的元素只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用遍历链表或者红黑树,大大优化了查询的效率。

static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

我来继续讲解这个return里面的三目运算。这里的hashCode()是直接调用的本地方法,没有进行重写,得到的h再与h无符号右移16的结果进行异或运算,让高位参与运算,会让得到的hashcode的值更加的散列。

但是为什么进行无符号右移16呢?

// >>> 无符号右移,忽略符号位,空位都以0补齐
// 按二进制形式把所有的数字向右移动对应位数,低位移出(舍弃),高位的空位补零。对于正数来说和带符号右移相同,对于负数来说不同。

为什么要采用^,不是&或者|呢?

我们来看看这三种情况:

image

​ 使用异或就会让返回的hashCode的值更加的分散,使用与运算(&)会让hashCode的值偏向于0,或运算(|)会让hashCode的值偏向于1。我们的目的不就是获得一个比较分散的值吗? 那肯定使用亦或呀(o゚▽゚)o

3.7 put()方法

public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
//----------------------上面的主要是调用这个方法-----------------------------------------
// onlyIfAbsent:true代表不更改现有的值,否则覆盖;默认是覆盖
// evict:如果为false表示table为创建状态,默认是非创建状态
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; //存储table的引用
Node<K,V> p; int n, i; //n是桶的个数,i是桶的下标
if ((tab = table) == null || (n = tab.length) == 0) //如果是第一次插入,则一定为null或者桶的个数为0,对table进行初始化
n = (tab = resize()).length; //初始化完成后赋值n
if ((p = tab[i = (n - 1) & hash]) == null) //将目标桶下第一个元素赋值给p,若p == null,则代表目标桶还未存储过元素,直接初始化桶并插入即可
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是重复的,那么就使用value覆盖原来的值
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) { //若遇到null,直接插入节点
p.next = newNode(hash, key, value, null);
//判断此时链表长度是否达到转化红黑树的阈值
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st 从-1开始所以这里进行-1 7
treeifyBin(tab, hash); //若是则进行转化
break;
}
//若找到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) //如果此时表中元素个数大于阈值threshold,则进行扩容,
resize();
afterNodeInsertion(evict); //插入后回调
return null;
}

整个过程的流程如下图

1.先通过哈希值计算出key映射到哪个桶;
2.如果桶上没有碰撞冲突,则直接插入;
3.如果遇上了冲突,则需要冲突处理:
1)如果该桶使用红黑树处理冲突,则调用红黑树的方法插入数据;
2)否则采用传统的链式方法插入。如果链的长度达到临界值,则把链转变为红黑树;
4.如果桶中存在重复的key,则用新值替换老值并返回老值;
5.如果size大于阈值threshold,则进行扩容。

image

3.8 remove()方法

public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value;
}
//----------------------------------------------------------------------------
// matchValue 如果为true,表示只在值相等的时候进行删除,但是默认是false。
// movable 如果为false,表示删除的时候不移动其他节点,默认是true
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab;
Node<K,V> p;
int n, index;
//找目标节点
if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) { //先定位到目标桶中
Node<K,V> node = null, e;
K k;
V v;
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
node = p; //如果目标桶中第一个元素为要删除的目标元素,则赋值给node
else if ((e = p.next) != null) { //判断该桶下的其他元素
if (p instanceof TreeNode) //若元素为树节点,则调用方法去树中查询,查询结果赋值给node
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else { //否则即为链表结构
do {
if (e.hash == hash &&((k = e.key) == key ||(key != null && key.equals(k)))) {
node = e; //找到节点则赋值给node并break
break;
}
p = e;
} while ((e = e.next) != null);
}
}
//接下来对于找到的节点从哈希表中删除
if (node != null && (!matchValue || (v = node.value) == value ||(value != null && value.equals(v)))) {
if (node instanceof TreeNode) //是树节点的话,则调用树节点的删除方法
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p) //代表要删的是桶下第一个元素
tab[index] = node.next; //将下一个元素赋值给桶
else
p.next = node.next; //否则直接链表删除
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null; //无目标元素则返回null
}

流程如下

1.先找到元素的目标桶,即存储位置;
2。如果该桶下的存储结构是链表的话,则遍历找到并删除即可;
3。如果是红黑树结构,则遍历找到并删除,如果此时节点数少于红黑树转链表的阈值6时,调用untreeify方法将红黑树转化成链表

3.9 get()方法

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) {
if (first.hash == hash && // always check first node检查第一个是不是
((k = first.key) == key || (key != null && key.equals(k))))
return first;
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; //无目标元素则返回null
}

3.10 treeifyBin()链表转为红黑树

这是在3.7 的put方法中的putval()的方法,如果桶里面的链表的长度大于8的时候就调用的方法。

final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) //传入的tab肯定是不是null,直接看后面的,当桶的个数不足64的时候,选择的是扩容。
resize();
//桶的数量大于64了,则进行转为红黑树
else if ((e = tab[index = (n - 1) & hash]) != null) { //使用e定位到链表的第一个节点
TreeNode<K,V> hd = null, tl = null; //构建红黑树
do {
TreeNode<K,V> p = replacementTreeNode(e, null); //将原节点转化成树节点
if (tl == null) //赋值头节点
hd = p;
else { //进行连接
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
//不难发现上述while虽然是一颗红黑树,但是是链状的
if ((tab[index] = hd) != null) //将红黑树头节点放进桶中
hd.treeify(tab); //将上述链状红黑树进行平衡处理
}
}
//----------------------------将原来的节点转为数节点----------------
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}
//-----------------------------平衡处理----------------------------
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;
if (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;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
root = balanceInsertion(root, x);
break;
}
}
}
}
moveRootToFront(tab, root);
}

总体的步骤就是:如下

1.根据哈希表中元素个数确定是扩容还是树形化;
2.如果是树形化遍历桶中的元素,创建相同个数的树形节点,复制内容,建立起联系;
3.然后让桶中的第一个元素指向新创建的树根节点,替换桶的链表内容为树形化内容;
4.最后处理红黑树,红黑树中比较大小的依据为hash值。

3.11 reseize()方法【扩容】

hashmap的扩容,每次扩容都会将表的大小扩容为原来的2倍

3.11.1扩容的时机

1.当容器中的元素数量大于阈值的时候就会触发扩容。(阈值就是表的大小乘负载因子)

2.当某一个桶中的元素数量大于8的时候,并且哈希表的大小小于64的时候,触发扩容。

final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table; //存下原来的hash表
int oldCap = (oldTab == null) ? 0 : oldTab.length; //记录原hash表的大小
int oldThr = threshold; //记录原来的阈值
int newCap, newThr = 0; //声明扩容后的大小和阈值
if (oldCap > 0) { //如果原哈希表大小大于0
if (oldCap >= MAXIMUM_CAPACITY) { //判断其是否超过了上限
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && //判断扩容为原先两倍后有没有超出上限
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
} //原哈希表大小为0,但是阈值大于0,此时便是之前提到的修正位置
else if (oldThr > 0)
newCap = oldThr; //赋值给新大小
else { //否则使用默认大小16和默认加载因子0.75计算出的阈值12
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) { //当前桶中有元素,并赋值给节点e
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; //声明两条链表l和h
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do { //用循环将所有节点取出并放入l或h中
next = e.next;
if ((e.hash & oldCap) == 0) { //重点方法,需比较一位二进制为即可判断它在新哈希表中的目标桶位置
if (loTail == null) //为0代表位置不变
loHead = e;
else
loTail.next = e;
loTail = e;
}
else { //为1则代表变为 原位置+旧容量
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;
}

这里再简单描述一下关于桶中元素为红黑树的情况:

else if (e instanceof TreeNode) //如果是红黑树结构,则调用相关方法把树分开
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);

split方法是先将当前这棵树按照上述位运算结论,分解成类似上述l、h两条链表l、h两颗红黑树,然后分别对两颗红黑树进行节点数量判断,若小于UNTREEIFY_THRESHOLD(红黑树转链表的阈值),则将其转变成链表,然后分别存储新哈希表的目标桶中。

3.12其他注意事项

hashmap初始化,推荐使用指定集合初始值的大小的构造方法,目的是为了减少扩容,扩容操作是十分耗时的。如果暂时不知道扩容的大小是多大,就设置为默认的16就好ヾ(๑╹◡╹)ノ"。

如果知道有多大,那么就设置为下面计算出来的公式的大小

image

这里的加载因子为0.75f,举个例子如果你想存100,那么就可以设置大小为100/0.75 约等于133.3,在进行+1,就是134.3,取整后为135

posted @   程序员鲜豪  阅读(24)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 25岁的心里话
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 闲置电脑爆改个人服务器(超详细) #公网映射 #Vmware虚拟网络编辑器
点击右上角即可分享
微信分享提示