写在前面: 关于hashmap我目前能想出这些,希望大家看到也能提提意见,我有遗漏的或者错误的地方欢迎补充和指出,我会继续学习,哈哈。
当面试官让你说下HashMap的时候,我觉得需要从以下几个方面来进行解答
在jdk1.7的时候,它的底层数据结构数组+链表,在极端情况下,有可能会使查询复杂度变为O(n),而在jdk1.8之后,发生了变化,引入了红黑树,大大提高了查询效率。这时候面试官面试官可能会问了,那为什么要采用红黑树,而不采用二叉查找树呢?这时候,你就需要了解下红黑树这个数据结构了
红黑树是一种自平衡的二叉查找树,它具有自旋的特性,能使整个树变的平衡,而不导致极端情况下,又出现线性存储的情况
我们来看下源码
//这个字段主要是在序列化机制中的,它主要作用是在反序列化的时候,判断版本号一致性的问题,一致的话,表示正常,可以进行反序列化,不一致的话会报InvalidCastException异常
private static final long serialVersionUID = 362498820763181265L;
//HashMap的默认初始容量,必须是2的幂次方数
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//HashMap的最大容量,一般达不到这种容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//HashMap在构造器中没有给定参数指定加载因子,默认为0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//HashMap的树化值,也就是在一个桶中,链表长度大于8的时候,需要将链表转化为红黑树
static final int TREEIFY_THRESHOLD = 8;
//HashMap的非树化值,也就是从红黑树转化为链表得值,也就是当红黑树容量到6的时候,就考虑将红黑树转化为链表
static final int UNTREEIFY_THRESHOLD = 6;
//HashMap的最小树化容量,也就是说当一个链表的长度达到8的时候,它还会考虑到整个数组的容量是否达到了64,如果达到了就将该链表转化为红黑树,如果未达到,就考虑数组扩容
static final int MIN_TREEIFY_CAPACITY = 64;
先放源码
/**
* Basic hash bin node, used for most entries. (See below for
* TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
*/
//上面是jdk给我们的解释,大概就是,这个子类是Hash容器的最基本的节点,当一个entry有多个元素的时候,可以用它来存储元素
static class Node<K,V> implements Map.Entry<K,V> {
//有一个hash值,定位数组索引下标
final int hash;
//存储的键
final K key;
//存储的值
V value;
//用于指向下一节点的指针
Node<K,V> next;
//构造器
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
//实现Map.Entry的接口
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
//重写hashcode方法,减少hash碰撞率
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
//重写equals方法
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;
}
}
源码其实很简单易懂,主要作用就是,声明用于存值的链表,这里就解释下为什么要重写hashcode方法和equals方法,重写equals方法是为了比较这个对象的内容,而不仅是比较地址,但是重写了equals方法的必须要覆盖hashcode()方法
如果重写了equals方法的时候,没有覆盖hashcode方法会产生什么影响呢?
当我们有两个实例的时候,有可能在逻辑上是相等的,但是这两个是完全不相干的实例,那么默认hashcode会产生两个不同的随机值,这就违背了hashcode合同第二条
红黑树就先不带大家看了哈,因为我也还很菜哈哈
上源码,哈哈
/**
* Constructs an empty HashMap with the specified initial
* capacity and load factor.
*
* @param initialCapacity the initial capacity
* @param loadFactor the load factor
* @throws IllegalArgumentException if the initial capacity is negative
* or the load factor is nonpositive
*/
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);
}
/**
* Constructs an empty HashMap with the specified initial
* capacity and the default load factor (0.75).
*
* @param initialCapacity the initial capacity.
* @throws IllegalArgumentException if the initial capacity is negative.
*/
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
/**
* Constructs an empty HashMap with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
/**
* Constructs a new HashMap with the same mappings as the
* specified Map. The HashMap is created with
* default load factor (0.75) and an initial capacity sufficient to
* hold the mappings in the specified Map.
*
* @param m the map whose mappings are to be placed in this map
* @throws NullPointerException if the specified map is null
*/
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
HashMap提供了四种构造器
虽然在上面构造器中我们可以任意指定容量,但是最终的容量还是2的幂次方数,具体实现方法是tableSizeFor()方法
我们点进去看看它的实现逻辑:
/**
* Returns a power of two size for the given target capacity.
*/
static final int tableSizeFor(int cap) {
int n = cap - 1;
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容量大的最近的一个2的幂次方数来作为HashMap的容量
继续源码
/**
* Implements Map.put and related methods.
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//先判断是否table是否为空,如果为空的话,就先进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//根据长度和hash码进行与运算定位相应的数组索引下标,判断当前桶中是否有值,没有值的话,就直接进行插入,(这里也是hashmap在jdk1.8线程不安全的场景),如果有值进行下步操作
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//判断当前桶中的元素的key是否和待插入的key相等,相等的话进行覆盖
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);
else {
//维护一个bincount,记录链表长度
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;
}
//遍历过程中,如果找到有key和待插入的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;
}
}
//在一个新桶中插入元素,数组容量+1,然后判断是否扩容
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
我们把源码分析了一遍,接下来做个总结:
get方法的逻辑就很简单了,看下源码
/**
* Implements Map.get and related methods.
*
* @param hash hash for key
* @param key the key
* @return the node, or null if none
*/
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//根据key的hash值和数组长度定位tab的下标
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;
}
简单的做下总结:
它有两个作用,一个是对HashMap进行初始化,一个是扩容
上源码,哈哈
/**
* Initializes or doubles table size. If null, allocates in
* accord with initial capacity target held in field threshold.
* Otherwise, because we are using power-of-two expansion, the
* elements from each bin must either stay at same index, or move
* with a power of two offset in the new table.
*
* @return the table
*/
final Node<K,V>[] resize() {
//存储扩容前的表
Node<K,V>[] oldTab = table;
// 记录老表的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 记录老表的阈值
int oldThr = threshold;
// 声明两个变量,表示新表的容量的阈值
int newCap, newThr = 0;
// 判断老表的容量,来判断这个resize方法是用来扩容的还是初始化的
if (oldCap > 0) {
// 判断老表容量是否大于最大值,如果大于,就不管了,任由hash碰撞吧
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 否则将扩容为原来的2倍,并且老表容量必须大于等于默认初始容量
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//阈值也扩容为原来的2倍
newThr = oldThr << 1; // double 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;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 接下来就是扩容的过程了,哈哈,老长的代码了,总结一下就是,将老表中的元素进行一次重新hash,然后根据尾插法插入到新表中
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)
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;
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;
}
总结一下:
我们先用反例解释一下,如果容量不是2的幂次方数的话,那么在进行数组定位下标的时候,最终返回的下标一定是偶数,这极大地造成了空间资源的浪费,也增加了hash碰撞的概率,如果容量是2的幂次方的话,length-1也就是这样的形式:11111***1111,这使得与hash做与远算的时候可以充分的散列,使得元素均匀分布,减少hash碰撞的概率。所以容量需要是2的幂次方数。
那为什么扩容为原来的2倍呢?
因为要求容量是2的幂次方,所以扩容也采用2倍扩容。
小结:
采用这样的方式,可以使元素均匀的分布在hash数组上,减少hash碰撞的概率,避免形成链表的结构,导致查询效率降低。
在jdk1.7,主要体现在扩容时,它采用的是头插法,因为要改变链表的顺序,所以可能会造成链表回路的情况,造成死循环。
在jdk1.8时,主要体现在put逻辑上,检查头结点元素的时候,两个线程都认为这个桶中没有元素的时候,造成的数据覆盖。