闲谈
HashMap是一个用来保存<Key,Value>数据的类,凭借其极其优秀的效率,一直是编程中最常用的基础类。一直以来就是面试的热点,感觉不问问HashMap的原理, 都不好意思是Java面试了。不好好看下HashMap,怎么能说准备好了面试呢。
概述
我们都知道HashMap是基于数组+链表的数据结构,可以高效的插入和查找数据,克服数组插入数据需要移位,而链表查找数据需要遍历的缺点,结构如下图。
通过存储的Key的哈希值来计算<Key,Value>存储的槽位(数组上的位置),槽位上已经有其他<Key,Value>,则称为是哈希冲突。通过链表连接起来。那么问题来了,极端情况下,所有的key计算结果全都在一个槽位上,那会是什么情况?实际上就是弱化成一个链表,这时候查询的时间复杂度就为O(n)
为避免出现这种情况,链表长度超过一定长度(默认为8),就直接生成红黑树(一种近似平衡二叉树的结构)这样的话最差的情况下,查找的时间复杂度是O(logn)
常见面试题
1、HashMap的容量为什么会是2的幂次方?
通过哈希值运算存储的槽位时取余可以通过位运算来实现,效率高。
2、HashMap中负载因子为什么是0.75?
关于这个问题,源码里有段注释“As a general rule, the default load factor (.75) offers a good * tradeoff between time and space costs.”意思是说负载因子0.75能在时间和空间上去得很好平衡。负载因子太高了,容易造成冲突。太小了,空间利用率又不高,还得频繁扩容。那么为什么不是0.6,0.8呢?我在网上看到一个解释觉得有一定道理,因为容量是2幂次方,容量*负载因子(扩容的临界值)刚好会是整数。
3、HashMap链表转化为数为什么是8?
还是从源码中的注释找答案,“Because TreeNodes are about twice the size of regular nodes”,树节点占用的空间是常规节点的两倍,“Ideally, under random hashCodes, the frequency of nodes in bins follows a Poisson distribution with a parameter of about 0.5 on average for the default resizing threshold of 0.75。在随机hashCode下,节点在槽中的分布符合泊松分布,在负载因子为0.75下,泊松分布参数为0.5。槽中节点的个数及对应的概率如下
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
可以看到,但链表长度为8个的时候概率是非常非常低的,所以取树化为8是为了降低树化的概率,同时当链表太长时有可以通过转换为树,提高查找效率(空间换时间)。
4、HashMap的Key或Value是否可以为null
可以,HashMap的Key或Value都可以为null,key为null的<key,value>键值对存储在槽位为0的位置。
源码解析
直接打开代码,来看看hashmap是怎么实现的。⚠️注意:本文的分析基于JDK1.8
一、常量及成员变量
//默认初始化的容量16 二进制"10000",即数组的默认长度,或者叫做“槽”的长度
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
/**
"泊松分布",负载因子,存储元素超过该比例即扩容,默认0.75
即存储的元素个数大于槽容量的0.75倍就扩容槽位
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//最大容量 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
//链表长度大于该值时转成黑树
static final int TREEIFY_THRESHOLD = 8;
//红黑树原生个数小于该值时转成链表
static final int UNTREEIFY_THRESHOLD = 6;
//存储元素的数组,或者叫槽
transient Node<K,V>[] table;
//目前存放元素的个数
transient int size;
//修改的次数,可认为当前的版本
transient int modCount;
/*
扩容的阀值=容量*因子,当存储的<key,value>数超过该变量,则扩容.
未初始化前,这个值会存储首次初始化时的容量,构造函数中赋值*/
int threshold;
//因子
final float loadFactor;复制代码
以上各变量暂时不知道,可以先不理,先混个眼熟,一会代码里用到会做说明。
二、构造函数
/**
构造函数,传入初始容量,及因子
*/
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的幂次方’”,
为什么是2的幂次方的,你先不要管,只要知道hashmap的容量是2的幂次方即可...
是不是有点绕?
例子 参数14,返回:2^4=16
参数 16<cap<=32,则返回2^5=32
********************************************************************/
static final int tableSizeFor(int cap) {
/**
这么一大串位移看起来很头疼?作用就是让参数二进制最高为1的位后面的所有位数变为1
举个例子cap为 14则二进制为
cap为14 0000 0000 0000 0000 0000 0000 0000 1110
n为cap-1 0000 0000 0000 0000 0000 0000 0000 1101
*/
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
//上面位移结果 0000 0000 0000 0000 0000 0000 0000 1111
//这个结果+1是 0000 0000 0000 0000 0000 0000 0001 0000 就是2^4=16
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
/*******************************************************************************
位移解析
为什么这里要-1?这里-1其实是作用在传进来的数已经是2^n
如16 0001 0000 减1 会是0000 1111,可以与传16以下统一,位移最后的结果都是 0000 1111
*********************************************************************************/
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
还有点懵?再给个例子,自己去意会吧
0010 0000 0000 0000 0000 0000 0000 0001 参数2^29+1
0010 0000 0000 0000 0000 0000 0000 0000 -1后结果
0011 0000 0000 0000 0000 0000 0000 0000 移1位再|原来数据
0011 1100 0000 0000 0000 0000 0000 0000 移2位再|原来数据
0011 1111 1100 0000 0000 0000 0000 0000 移4位再|原来数据
0011 1111 1111 1111 1100 0000 0000 0000 移8位再|原来数据
0011 1111 1111 1111 1111 1111 1111 1111 移16位后再|原来数据
0100 0000 0000 0000 0000 0000 0000 0000 +1后得到的结果2^30
最后的结果是否就是大于参数的最小幂次方?2^30 就是大于2^29+1的最小幂次方
复制代码
传初始容量构造函数:调用了上面的构造函数,因子为默认的0.75
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}复制代码
无参数构造函数,所有参数使用默认值
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}复制代码
通过传进来一个Map来初始化HashMap
public HashMap(Map<? extends K, ? extends V> m) {
//因子使用默认值
this.loadFactor = DEFAULT_LOAD_FACTOR;
//将map复制到当前map中
putMapEntries(m, false);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
//1、槽还未初始化,计算要初始化的槽大小
//2、槽已经初始化且容量不够,则通过resize()扩容
if (table == null) {
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)
resize();
//将源Map中的key、value存储到当前Map中
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);
}
}
}
/********************************扩容函数********************************************/
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) {
/*
当前容量已经是最大容量,直接返回
否则直接容量扩展为原来的2倍
*/
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
}
//未初始化过,且初始化容量已经计算过,则容量设为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;
//定义新容量的槽,重新计算<key,value>的位置
@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;
/**
如果槽位没有链表(红黑树),则直接计算槽位存储的<key,value>新位置,
否则是链表则遍历链表,重新计算链表上所有<key,value>的位置,
红黑树同样做遍历及计算。
这里可以看到计算位置只需要(key的哈希值)按位与(容量-1),
其实就是hash值对容量取余,这就是容量是2的幂次方的好处,计算取余非常方便。
*/
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;
/**
这里的位置计算有一个小技巧,只“按位与”容量,
为0则槽位不变,为1则新槽位=(旧槽位位置+旧容量)*/
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总共有4个构造函数,分别可以传入容量、因子、Map。1、默认情况下,初始化容量会是16,因子会是0.75。2、如果传入容量,则初始化的容量会是大于传入的容量的 最小“2的幂次方”。
往HashMap中增删查数据
一)、添加
添加一个<key,value>
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
这里有个点需要说明以下,
1、hashmap不是直接使用对象的哈希值,
而是会让高16为按位异或低16位,这是为了尽量让32位的hash值都能在计算槽位时起作用,减少冲突。
2、如果key是null,则hash值会取0,也就是key位null,则会存储在槽位0的位置。
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果还未进行初始化,则进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//通过hash计算存储位置,如果位置上没有节点,则直接将节点放入该位置,槽位个数n为2的幂次方
//的好处就在这里,这里哈希值对槽大小计算余数,即要存储的槽位置,通过按为与即可实现。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//如果计算出的位置有节点,且节点的key等于要添加节点的key,先不做什么操作,只把已有节点保存到e
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。
如果遍历到链表尾部,则新增新节点,如果key已经在链表中,则停止遍历*/
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;
}
}
//key已经存在于Map中,直接替换其value值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//移动节点到链表尾部
afterNodeAccess(e);
return oldValue;
}
}
//Map版本号+1
++modCount;
//超过阀值,直接扩容
if (++size > threshold)
resize();
//这个方法,HashMap没做操作,主要是给扩展子类来使用
afterNodeInsertion(evict);
return null;
}复制代码
二)、HashMap中通过key获取value
/**通过key,获取value*/
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) {
//槽位上节点first,如果key等于first的key,直接返回first
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);
//链表结构,链表结构,直接往后遍历链表,直到找到节点key等于传进来的key或者链表结束
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}复制代码
三、删除HashMap中的节点
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;
//通过hashcode找到存储的槽位
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;
//如果槽位上的节点key等于要删除的key,则将节点记录到node
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
//否则,槽上面的节点还有下一个节点
else if ((e = p.next) != null) {
//如果是树结构
if (p instanceof TreeNode)
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;
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);
/**
node为要删除的节点,p为要删除节点的删一个节点,
如果要删的是槽位置,则p跟node都指向槽位上的节点*/
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
//版本更改
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}复制代码
阶段总结
至此,增删查都已经解析完毕,代码相对也比较简单,就是通过hash计算出槽位,再顺槽位上的链表或者树查找元素。注意点:1、HashMap中key跟vlue是可以位null的,key为null会存储在槽位为0的位置。2、可以看到,增删都没有做任何数据同步或者锁,所以HashMap是非线程安全的。
版本号用武之地
通过HashMap对象,可以获取到key的集合还有Value的集合,或者节点的集合,通过集合Iterator迭代器可以遍历HashMap中所有的key、value或者节点。
//获取key的集合、value的集合,还有节点的集合方法如下
hashMap.keySet();
hashMap.values();
hashMap.entrySet();
//再来看看这些集合共用的迭代器
abstract class HashIterator {
Node<K,V> next; // next entry to return
Node<K,V> current; // current entry
int expectedModCount; // for fast-fail
int index; // current slot
HashIterator() {
expectedModCount = modCount;
//.....省略不重要代码
}
public final boolean hasNext() {
return next != null;
}
final Node<K,V> nextNode() {
Node<K,V>[] t;
Node<K,V> e = next;
/*************关键代码*****************
迭代器创建的时候会保存HashMap的版本,一旦发现当前的HashMap版本
与创建迭代器时候的HashMap版本不一致,则说明遍历期间,
HashMap被其他线程修改了,直接抛出一个ConcurrentModificationException异常
**/
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
if ((next = (current = e).next) == null && (t = table) != null) {
do {} while (index < t.length && (next = t[index++]) == null);
}
return e;
}
//......省略不重要代码
}复制代码
阶段总结:HashMap中有个变量,用来记录HashMap的版本号。HashMap迭代器对于多线程修改的反应是“快速失败”的方法。迭代过程,一旦发现HashMap被修改了,迭代元素就直接抛出异常。