1、HashMap结构
JDK7与JDK8及以后的HashMap结构与存储原理有所不同: Jdk1.7:数组 + 链表 ( 当数组下标相同,则会在该下标下使用链表) Jdk1.8:数组 + 链表 + 红黑树 (预值为8 如果链表长度 >=8则会把链表变成红黑树 ) Jdk1.7中链表新元素添加到链表的头结点,先加到链表的头节点,再移到数组下标位置 Jdk1.8中链表新元素添加到链表的尾结点 (数组通过下标索引查询,所以查询效率非常高,链表只能挨个遍历,效率非常低。jdk1.8及以 上版本引入了红黑树,当链表的长度大于或等于8的时候则会把链表变成红黑树,以提高查询效率)
HashMap中的数组通常被称为桶数组
如下图所示:
数组存放了一个key-value实例,在jdk7中叫Entry在jdk8中叫Node,红黑树中的结点是TreeNode
Node继承与Map.Entry,TreeNode继承于Node
2、HashMap中的常量
static final int DEFAULT_INITIAL_CAPACITY = 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;
transient Node<K,V>[] table;
transient Set<Map.Entry<K,V>> entrySet;
transient int size;
transient int modCount;
int threshold;
final float loadFactor;
static class Node<K,V> implements Map.Entry<K,V> {
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;
}
}
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent;
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev;
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
}
这里就引申而来几个问题:
问题一:为什么HashMap的默认负载因子是0.75,而不是0.5或者是整数1呢?
- 阈值(threshold) = 负载因子(loadFactor) x 容量(capacity) 根据HashMap的扩容机制,他会保证容量(capacity)的值永远都是2的幂 为了保证负载因子x容量的结果是一个整数,这个值是0.75(4/3)比较合理,因为这个数和任何2的次幂乘积结果都是整数。
- 理论上来讲,负载因子越大,导致哈希冲突的概率也就越大,负载因子越小,费的空间也就越大,这是一个无法避免的利弊关系,所以通过一个简单的数学推理,可以测算出这个数值在0.75左右是比较合理的
问题二:为什么HashMap初始化大小是16呢?
首先需要明确:HashMap的容量只能是2的幂次方,扩容的策略也是原来的容量 * 2,就算构造函数传入初始容量不是2的幂次方,构造函数中也会将传入的初始容量转为第一个 > = 的2的幂作为容量。
为什么容量要求是一个2的幂呢?
这是为了方便计算!当获取到一个key的hash值后,我们需要将hash值映射到数组中,常规的思路就是将 hash%n 获取桶数组的索引,但是取余操作需要消耗大量的时间,如果能够使用& 操作替代% 操作就能加快计算。因此下标计算使用以下式子:
int index=key.hashCode()&(n-1) 获取桶中的下标。而只有当n为2的幂,(n-1)&key.hashCode() 才能使hash值在数组下标中均匀分配,获得的index就能减少重复,这样就能减少冲突和提高HashMap的查找效率。
那么为什么是16呢?32、8就不行吗?
因为是8的话很容易导致map扩容影响性能,如果分配的太大的话又会浪费资源,所以就使用16作为初始大小。
3、HashMap的构造函数
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
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);
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
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();
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);
}
}
}
需要注意的是:
- 使用默认的构造器,阈值被默认设置为了0,也就是在第一次添加元素时,就会进行扩容。
- 无论如何创建一个HashMap实例,在resize扩容之前,其capacity值一定是0!!!
- 给定初始容量、初始载入因子的构造方法,返回的容量值没有直接给capacity,而是赋值给了
threshold ,在第一次resize时根据该值修改capacity
tableSizeFor()
上边的第三个构造函数中,调用了 tableSizeFor 方法,这个方法是怎么实现的呢?
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;
}
我们以传入参数为14 来举例,计算这个过程。i
首先,14传进去之后先减1,n此时为13。然后是一系列的无符号右移运算>>> 。
0000 0000 0000 0000 0000 0000 0000 1101
0000 0000 0000 0000 0000 0000 0000 0110
0000 0000 0000 0000 0000 0000 0000 1111
0000 0000 0000 0000 0000 0000 0000 0011
0000 0000 0000 0000 0000 0000 0000 1111
...
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
0000 0000 0000 0000 0000 0000 0000 1111
+ 1
0000 0000 0000 0000 0000 0000 0001 0000
将它转为十进制,就是 2^4 = 16 。我们会发现一个规律,以上的右移运算使得从值为1的最高位开始后所有位的值设置为1。计算结束后一定再加1,就是1 0000 这样的结构,它一定是 2的n次幂。因此,这个方法返回的就是大于当前传入值的最小(最接近当前值)的一个2的n次幂的值。
4、put方法详解
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
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;
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;
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) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
- 首先将k,v封装到Node对象当中(节点)。
- 然后它的底层会调用K的hashCode()方法得出hash值。
- 通过哈希表函数/哈希算法,将hash值转换成数组的下标(使用
hashcode&(n-1) ),下标位置上如果没有任何元素,就把Node添加到这个位置上。如果说下标对应的位置上有链表。此时,就会拿着k和链表上每个节点的k进行equals 。如果所有的equals方法返回都是false,那么这个新的节点将被添加到链表的末尾。如其中有一个equals返回了true,那么这个节点的value将会被覆盖。
5、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 &&
((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;
}
- 先调用k的hashCode()方法得出哈希值,并通过哈希算法转换成数组的下标。
- 通过上一步哈希算法转换成数组的下标之后,在通过数组下标快速定位到某个位置上。重点理解如果这个位置上什么都没有,则返回null。如果这个位置上有单向链表,那么它就会拿着参数K和单向链表上的每一个节点的K进行
equals ,如果所有equals方法都返回false,则get方法返回null。如果其中一个节点的K和参数K进行equals返回true,那么此时该节点的value就是我们要找的value了,get方法最终返回这个要找的value。
6、hash()计算原理
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这里,会先判断key是否为空,若为空则返回0。这也说明了hashMap是支持key传 null 的。若非空,则先计算key的hashCode值,赋值给h,然后把h右移16位,并与原来的h进行异或处理。这里就引申出下面几个问题:
为什么不直接调用key.hashCode() 而还需让高16位与低16位执行异或操作?
答:为了增加随机性。上面式子其实是将高16位与低16位进行了一个异或操作,因为根据哈希值确定数组位置的方法时hash&(n-1) ,n-1的大小不会很大,也就是说只有低位的hash值才影响数组的位置,而hash值原本有32位,高位的哈希特征全部都消失了,出现哈希冲突的概率就增加了,因此使用异或操作使得低位和高位均能够影响数组的位置。所以,异或运算之后,可以让结果的随机性更大,而随机性大了之后,哈希碰撞的概率当然就更小了。
为什么一定是异或操作?
能够确保结果足够分散:
7、resize()扩容机制
在添加一个新的元素后,会判断当前数组的容量是否超过阈值,如果容量>=阈值则会进行扩容。同样当开始数组为空时也同样会进行扩容。
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) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
else if (oldThr > 0)
newCap = oldThr;
else {
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) {
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 {
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;
}
这里还有一个重要的运算需要注意,使用这个判断用于将原来的链表分为两个链表,分别位于原来位置和一个新的位置
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
这里拿原容量为16,扩容后容量为32为例,判断数组具体位置的(n-1)&key.hashCode()
1111 & key.hashCode()
11111 & key.hashCode()
16容量时是不考虑第5位的值,无论其实1或0都是放在这个位置上,但是现在容量被扩充为32位,需要考虑哈希值的第5位值,如果为0也跟16容量时获得的位置相同,如果为1得到一个新的位置,因此e.hash & oldCap 直接判断第5位的值即可知道其是否能留在原位置。其他情况也类似
8、常见的一些面试问题
问题1:jdk1.7的HashMap扩容时为什么会出现死循环?
先看看jdk1.7的HashMap的扩容代码,核心思路是遍历所有的桶元素并将其插入到适合的位置,插入时使用**头插法**插入。
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);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
出现问题的核心位置就是这句:Entry<K,V>next=e.next ,当然正常情况下是不会出现问题的,问题只会在多线程操作Map时出现。
举例说明,假设桶的大小为2,同种的元素如下所示:
假设现在有两个线程:线程1与线程2,这两个线程均需要对HashMap进行扩容操作。
假设线程1先执行扩容方法,在执行完Entry<K,V>next=e.next 之后,因为时间片用完被操作系统挂起,轮线程2执行,此时线程1中的e 与next 分别如上面图所示。
线程1被挂起后并没有完成扩容,因此线程2进入后同样会执行扩容方法,并且线程2将扩容方法执行完成,HashMap已经成功被扩容!但是线程1中e 、next 所指向的元素没有改变,线程2扩容后两个指向如图所示:
线程1中还没有开始扩容,所以newTable 还只是一个长度为4的空数组,如上图左侧所示。table是已经被线程2扩容后的桶数组,如上图右侧图所示。
- 随后开始执行
e.next=newTable[i]、newTable[i]=e ,因为newTable 此时是空数组,因此e.next==null ,此时newTable[i]=e ,随后执行e=next 让指针e 与指针next 都指向同一个元素key(7),如下图所示: - 第二次循环执行
Entry<K,V>next=e.next ,next指向key(3): - 执行
e.next=newTable[i] 、newTable[i]=e ,将key(7)用头插法插入到第三号桶中:继续执行e=next ,此时e 再一次指向了key(3) - 第三次循环执行
Entry<K,V>next=e.next ,此时next == null ,执行e.next=newTable[i] 也就是将左侧线程1中的key(3)指向线程1中的头结点key(7),这样形成了循环链表!继续执行newTable[i]=e 并没有产生变化。随后e=next 让e为空结束循环。 - 此时扩容已经结束,可以看到桶数组中存放的链表已经变成了循环链表,任何的操作都可能导致死循环。这也就是为什么在jdk8.0之后HashMap的扩容方式改为了尾插法。
问题2:HashMap与HashTable有什么区别?
1、继承的类不同
HashMap继承自abstractHashMap 而HashTable继承自Dictionary ,两者均实现了Map接口。Dictionary是一个相当古老的类,官方已经废弃了这个类的使用。
2、HashMap线程不安全,HashTable线程安全
HashMap不是一个线程安全的数据结构,在前面也说到多线程情况下jdk1.7 的HashMap可能出现循环链表导致查询时出现死循环。同时HashMap因为没有数据上锁机制因此put 方法可能覆盖其他线程的修改。总体来说HashMap线程不安全。
HashTable通过synchornized 关键字实现线程安全,但是这种实现方法需要大量的上锁、解锁工作,性能较差,因此也不推荐使用HashTable保证线程安全。
3、HashTable不允许存放空值
HashMap允许存放一个null的key值,value值没有null值限制,但是HashTable键值对均不能为空。
4、计算Hash的方式不同
HashMap计算Hash值的方法:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
HashTable计算Hash值的方法:
int index = (e.hash & 0x7FFFFFFF) % newCapacity;
5、扩容方式不同
- HashMap的扩容是原始容量的两倍
- HashTable的扩容方式是原始容量的两倍+1
6、解决Hash冲突的方式不同
- HashMap中如果出现Hash冲突时,如果冲突数量小于8 则会以尾部添加的链表方式存储,冲突数量大于8且桶数组数量大于64则会将链表转为红黑树存储,当数量又小于6时就会将红黑树转为链表存储,平均查询时间复杂度:
O
(
l
g
n
)
O(lgn)
O(lgn)
- HashTable中出现Hash冲突只有链表解决方式,将冲突的结点添加到链表,解决Hash冲突,时间复杂度较高。
|