基础回顾之HashMap
hashMap官方注释
jdk1.8之前
在jdk1.8之前,hashMap底层是基于数组+链表的形式实现的,以下是几个重要元素
//默认初始容量-必须为2的幂。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//初始化一个Entry的空数组
static final Entry<?,?>[] EMPTY_TABLE = {};
//哈希表中存储entry的数组,如果需要扩容,长度必须也是2的幂次
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
//当前键值对个数
transient int size;
/**
* 哈希表扩容阈值(当前容量*负载因子)
*/
// If table == EMPTY_TABLE then this is the initial capacity at which the
// table will be created when inflated.
int threshold;
//负载因子
final float loadFactor;
//HashMap的结构被修改的次数,用于迭代器
transient int modCount;
//静态内部类,链表
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
}
构造器
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;
threshold = initialCapacity;
init();
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
public HashMap(Map<? extends K, ? extends V> m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
inflateTable(threshold);
putAllForCreate(m);
}
put方法
public V put(K key, V value) {
//如果table引用指向成员变量EMPTY_TABLE,那么初始化HashMap(设置容量、临界值,新的Entry数组引用)
if (table == EMPTY_TABLE) {
//threshold在构造器中已被赋值,=initialCapacity
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);
return null;
}
分析源码,整理put方法步骤如下:
- 如果table引用指向成员变量EMPTY_TABLE,是则调用inflateTable方法进行初始化,代码如下
private void inflateTable(int toSize) {
//通过按位运算获取最接近的2的幂次数,这里决定了无论使用者指定的容量为多少,容量都会是2的幂次
int capacity = roundUpToPowerOf2(toSize);
//为阈值重新赋值为当前容量*负载因子
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table初始化,新建容量为按位运算的结果的Entry数组
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
- 如果key为null,则调用putForNullKey方法(说明hashMap允许key为null,与hashtable不同),且从源码可得key为null的Entery对象在数组中的下标为0,如果key已有对应的value,则会覆盖,并将原来的值返回
private V putForNullKey(V value) {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
//记录hashMap内部发生改变的次数
modCount++;
addEntry(0, null, value, 0);
return null;
}
- 如果key不为null,则调用hash()计算key的hash值,根据key的hashcode再进行异或和无符号右移操作,对key的hashCode进行扰动计算,防止不同hashCode的高位不同但低位相同导致的hash冲突。简单点说,就是为了把高位的特征和低位的特征组合起来,降低哈希冲突的概率,也就是说,尽量做到任何一位的变化都能对最终得到的结果产生影响。
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
- 通过indexFor()方法计算数组下标,由上一步计算得到的key的hash值与数组长度-1进行按位与运算。在一般的hash算法中,为了使元素均匀分布,都是用取模方法,即n%hashCode,但是由于在计算机中数据是已二进制的形式存在的,所以&的性能要强于%。 在hashMap中,数组的长度为2的幂次,保证了 (h & (length-1)) == (h % length)永远成立,即一样起到减少hash冲突的目的。
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}
- 如果计算得出的下标对应的数组中已有数据,且key相同,则替换value并返回旧值。
- 如果当前数组下标处没有键值对或者没有相同key的键值对存在,则modCount++,并调用addEntry()
void addEntry(int hash, K key, V value, int bucketIndex) {
//
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
6.1 判断size是否大于阈值,并且当前位置是否已有元素存在,如果是则调用resize()进行扩容。
void resize(int newCapacity) {
//将老数组赋值给新的entry数组
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//创建大小为原来数组长度2倍的新数组
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
6.1.1 将原来数组中的数据转移到新数组中,分析源代码可知,扩容之后链表元素发生了反转。
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
//遍历当前哈希表中所有元素
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
//rehash
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
//计算在扩容之后新数组中的下标,indexFor方法同上
int i = indexFor(e.hash, newCapacity);
//核心代码
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
6.1.2 调用createEntry(),将元素放入当前位置。可以看到,在1.7中在链表中添加元素的时候采用的时头插法,上面分析了在扩容之后进行的transfer,结合此处得出在1.7中,扩容之后链表发生了反转,在多线程的情况下,会出现链表环。
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
get方法
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
分析源码,整理get步骤如下:
- 如果key为null,调用getForNullKey(),返回。
- 调用getEntry(key),获取对应的Entey对象
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
//获取key的hash值,同put方法
int hash = (key == null) ? 0 : hash(key);
//获取key对应的下标,遍历该处链表,获取对应的Entry
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
jdk1.8之后
jdk1.8之后,底层实现为数组+链表+红黑树,下面为几个重要元素:
//树化阈值
static final int TREEIFY_THRESHOLD = 8;
//取消树化阈值
static final int UNTREEIFY_THRESHOLD = 6;
//桶转化为树形结构的最小容量
static final int MIN_TREEIFY_CAPACITY = 64;
put方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
分析源码,整理1.8中put方法步骤如下:
- hash(key),可以看出与1.7中的扰乱方式不同,但实质都是将hashcode尽量散布到较低位,减少hash冲突。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 调用putVal方法。通过源码注释已经很清晰的描述了1.8中put方法,下面对其中涉及的几个关键点进行深入。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//定义两个节点tab、p备用,变量n、i
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 2.1 如果当前数组为空或长度为0则进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//如果数组不为空,则判断下标为(n - 1) & hash处是否为空
if ((p = tab[i = (n - 1) & hash]) == 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不相同,判断当前元素类型是否为红黑树
else if (p instanceof TreeNode)
// 2.2 如果是红黑树类型,则putTreeVal()添加
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//到此说明只是哈希冲突产生的链表结构
//对该位置的链表进行循环,并记录链表个数
for (int binCount = 0; ; ++binCount) {
//判断是否为最后一个节点
if ((e = p.next) == null) {
//是则插入,与1.7中不同,使用的尾插法
p.next = newNode(hash, key, value, null);
//判断是否超过树化阈值
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 2.3 树化
treeifyBin(tab, hash);
break;
}
//二次判断key是否相同
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//p赋值为下一节点
p = e;
}
}
//如果e为null,代表上面的链表遍历到了最后面,并且是新建节点完成添加
//如果e!=null,代表上面链表中存在key相同的节点,需要替换
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;
}
2.1 resize()扩容
final Node<K,V>[] resize() {
//新变量接收老数组
Node<K,V>[] oldTab = table;
//老数组的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//原来的阈值
int oldThr = threshold;
//定义新变量(数组长度、阈值初始化为0)
int newCap, newThr = 0;
//如果老数组长度大于0,说明已经存在元素
if (oldCap > 0) {
//如果老数组长度已经最大,则阈值直接复制最大,返回数组
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//如果老数组的长度*2未超过最大容量且大于等于默认容量
// 则新数组长度和新阈值都为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//如果老数组的扩容阀值大于0,那么设置新数组的容量为该阀值
//这一步也就意味着构造该map的时候,指定了初始化容量。
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//能运行到这里的话,说明是调用无参构造函数创建的该map,并且第一次添加元素
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//如果新阈值为0,则按照公式取值
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指向新数组
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)//证明此处没有hash冲突,直接转移到新数组中
newTab[e.hash & (newCap - 1)] = e;
// 如果该节点为TreeNode类型
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;
// 以上的低位指的是新数组的 0 到 oldCap-1 、高位指定的是oldCap 到 newCap - 1
do {
next = e.next;
//因为数组长度的二进制有效最高位是1(例如16对应的二进制是10000),只有*..0**** 和 10000 进行与运算结果才为00000(*..表示不确定的多个二进制位)。
//又因为定位下标时的取模运算是以hash值和长度减1进行与运算,所以下标 = (*..0**** & 1111) 也= (*..0**** & 11111)
//所以该hash值再和新数组的长度取摸的话mod值也不会放生变化,也就是说该元素的在新数组的位置和在老数组的位置是相同的,所以该元素可以放置在低位链表中。
if ((e.hash & oldCap) == 0) {
if (loTail == null)// 如果没有尾,说明链表为空
loHead = e;
else
loTail.next = e;// 如果有尾,那么链表不为空,把该元素挂到链表的最后。
loTail = e;
}
// 如果与运算结果不为0,说明hash值大于老数组长度(例如hash值为17)
// 此时该元素应该放置到新数组的高位位置上
//比如:老数组长度16,那么新数组长度为32,hash为17的应该放置在数组的第17个位置上,也就是下标为16,那么下标为16已经属于高位了,低位是[0-15],高位是[16-31]
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;
}
get方法
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) {
//如果当前位置链表首节点的hash与key的hash相同,且首节点的键与key相同(地址相同或者equals相同)
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;
}
两种版本的区别
- 底层数据结构的不同,1.8之后引入了红黑树。
- 1.8之后将原来的Entry对象替换为Node对象,本质还是一个链表
- 对key为null的处理上,1.7中单独拎出来做处理(putForNullKey),1.8中直接在hash()中处理
- hash()方法不一样,1.7中进行了四次扰乱,1.8中只进行了一次(h = key.hashCode()) ^ (h >>> 16),因为hashCode是int类型,右移16位再与自身做异或操作,减少hash冲突
- 在链表中插入元素时,1.7使用的时头插法,每次将新元素放在链表头,在扩容的时候,transfer方法中,会将链表倒转,在多线程的情况下,会出现链表环的问题,1.8中使用尾插法,避免了该问题
红黑树
参考美团技术团队文章
(tech.meituan.com/2016/12/02/…)
常见面试题
- HashMap 的数据结构?
- HashMap 的工作原理?
- 当两个对象的 hashCode 相同会发生什么?
- 你知道 hash 的实现吗?为什么要这样实现?
- 为什么要用异或运算符?
- HashMap 的 table 的容量如何确定?loadFactor 是什么?该容量如何变化?这种变化会带来什么问题?
- HashMap中put方法的过程?
- 数组扩容的过程?
- 拉链法导致的链表过深问题为什么不用二叉查找树代替,而选择红黑树?为什么不一直使用红黑树?
①.二叉树在特殊情况下会退化成线性结构(和链表一样了),遍历查找会很慢
②.而红黑树在插入新数据后可能需要通过左旋,右旋、变色这些操作来保持平衡,引入红黑树就是为了查找数据块,解决链表查询深度的
问题,我们知道红黑树属于平衡二叉树,但是为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少,所以
当长度大于8的时候,会使用红黑树,如果链表长度很短的话,根本不需要引入红黑树,引入反而会慢。
- 红黑树是什么?
- jdk8中对HashMap做了哪些改变?
- HashMap,LinkedHashMap,TreeMap 有什么区别?
- HashMap & TreeMap & LinkedHashMap 使用场景?
- HashMap & ConcurrentHashMap 的区别?
- HashMap 和 HashTable 有什么区别?
- 常用的hash算法有哪些?
- 快速失败和安全失败?
在 java.util 包的集合类就都是快速失败的;而 java.util.concurrent 包下的类都是安全失败
通过判断modCount与expectedModCount的值。。。。