HashMap源码分析及面试题总结
源码分析
对于学习hashmap最好的方式就是读懂源码,并且独立进行设计一个hashmap的算法即手写hashMap。
数据结构
jdk1.7中hashmap的数据结构是数组+链表,在其中链表的节点存储的是Entry对象,Entry为一个链表中的一个节点,每个Entry对象都存在这属性:
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;//key值
V value;//存储的value
Entry<K,V> next;//当前节点下链表下一个Node的指向
int hash;
概念图如图:
HashMap一些参数的了解
//hashMap的初始容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//hashMap的最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//hashMap的负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
部分问题
1.hashMap初始容量为16的原因需要在效率和内存使用上做一个权衡。这个值不能太大,也不能太小。太小了就可能会频繁的发生扩容,影响效率;太大了又浪费空间,不划算。
2.hashMap的容量为什么是2的n次幂:充分利用数组空间,扩容后,数组下标重新计算。
hashMap之构造方法
构造方法较为简单,里面只存在了部分校验,但是实际并未初始化hashmap。虽然hashMap默认的初始化容量是16,但是在其构造方法中是可以自定义容量和负载因子的。
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方法并未进行初始数组,但是在hashMap的子类LinkHashMap中倒是实现此方法
init();
}
//hashMap中的实现
void init() {
}
hashMap之put方法
public V put(K key, V value) {
//判断Entry数组对象是否为空,如果是空的话进行初始化数组,使用的是懒加载
if (table == EMPTY_TABLE) {
//进行数据的初始化
inflateTable(threshold);
}
//如果key值为null的话,走另外分支
if (key == null)
return putForNullKey(value);
//对传入的key值进行hash,并不是单纯的hashCode方法,里面还存在再多个右移和异或运算,这样 的目的是为了让hash值更加散列。
int hash = hash(key);
//计算数组下标位置,利用上述的hash值和数组的长度进行与运算
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;
}
//初始化数组
private void inflateTable(int toSize) {
//传入初始化容量,进行计算出大于等于此数据的2的幂次方数据
int capacity = roundUpToPowerOf2(toSize);
//计算新的阈值
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
//生成新的数组对象
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
//放入key值为null的方法
private V putForNullKey(V value) {
//重点,如果key值是null,那么放入的就是这个数据的第一个位置。
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;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
重点代码剖析:
hashMap之扩容
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);
}
//hashMap扩容方法
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//创建一个新数组,将老数组中的数据转移至新数组
Entry[] newTable = new Entry[newCapacity];
//数据转移
transfer(newTable, initHashSeedAsNeeded(newCapacity));
//将新数组赋值给table
table = newTable;
//重新计算阈值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 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;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
//此处为断链操作,是将原有链表的next指针指向null。
e.next = newTable[i];
//将e赋值给新的数组
newTable[i] = e;
//进行循环
e = next;
}
}
}
数据转移图解:
此处省略3,4node的移动,原理相同,最终e变为null,循环停止。
此段代码是造成jdk1.7中出现线程不安全的主要因素。此段代码如果发生多线程即高并发操作的话,会将会形成环状,形成循环链表。
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;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
//此处为断链操作,是将原有链表的next指针指向null。
e.next = newTable[i];
//将e赋值给新的数组
newTable[i] = e;
//进行循环
e = next;
}
}
}
形成环状链表的原因图解如下:
数组A数据转移完成,此时数组B的元素还没有进行操作,指针跟随数组A进行移动;
最后一次转移其实转移的还是1:1元素,此时已经行程循环链表,并且丢失4:4,3:3元素,循环结束。
注:
此处链表此次链表的形成是指两个多线程同时执行到tranfers方法的循环中,线程A正常执行,而线程B执行到Entry<K,V> next = e.next;失去cpu时间,挂起,直至线程A完成循环,线程B执行。
hashMap之get方法
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
//如果key是null值得话直接去数组头以为即table[0]
int hash = (key == null) ? 0 : hash(key);
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.7使用的是头插法,这也是发生环形链表的原因所在。使用头插法,扩容后链表的位置与原位置相反。
扩容机制
初始容量(16)*负载因子(0.75L)并且发生了hash冲突(即插入的数组的位置有元素存在),在极端的情况下,jdk1.7的数组在不扩容的情况下是可以填满16个数组位置的。扩容的方式是现有数组容量*2(2倍扩容)