Java基础之hashmap,面试必问,重点中的重点
HashMap底层源码和数据结构分析
1.底层数据结构
在JDK1.7中,hashmap是以链表加数组的形式存在的,而在JDK1.8中,hashmap是以链表加(数组/红黑树)的数据结构表现的。HashMap的底层主要是基于数组和链表来实现的,它之所以有相当快的查询速度主要是因为它是通过计算散列码来决定存储的位置。HashMap中主要是通过key的hashCode来计算hash值的,只要hashCode相同,计算出来的hash值就一样。如果存储的对象对多了,就有可能不同的对象所算出来的hash值是相同的,这就出现了所谓的hash冲突。学过数据结构的同学都知道,解决hash冲突的方法有很多,HashMap底层是通过链表来解决hash冲突的。
JDK1.7->1.8转变的原因: 因为1.7中是以链表存储计算完的哈希值一样的元素,所以链表的长度是以线性增加的,而链表的插入和搜索都是需要从头节点开始依次向下遍历,时间复杂度为n。因此当链表的长度增加时,IO次数也会增加,大大影响了插入和搜索的效率,因此在1.8中,当链表深度到达8时会将链表转变为红黑树的存储结构,时间复杂度为log2(n)。而且在1.7中,当多线程put的情况下会发生死锁的问题,具体原因我会在下面文章中解释 。
hash散列函数:
Hash (散列函数)是把任意长度的输入通过散列算法变换成固定长度的输出。Hash 函数的返回值也称为 哈希值 哈希码 摘要或哈希。Hash作用如下图所示:
Hash 函数可以通过选取适当的函数,可以在时间和空间上取得较好平衡。
解决 Hash 的两种方式:拉链法和线性探测法
2.源码分析
HashMap的内部类
//这个是链表形式的数据结构,每个Node中包含了他的key,value,他的hash值,以及他的下一个指向的Node
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;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
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;
}
}
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> { //这个是红黑树的数据结构,当链表元素数量大于8时,链表会转化成红黑树
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
HashMap的put方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i; //tab是指整个table数组,p是下标为i数组的链表头节点或者红黑树根节点
if ((tab = table) == null || (n = tab.length) == 0) //如果数组为空的话,先将数组进行初始化
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null) //如果i下标数组位置为空的话,新建一个Node结点
tab[i] = newNode(hash, key, value, null);
else { //如果i下标有元素
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) // 判断链表长度是否大于8,是的话进行树化
treeifyBin(tab, hash);
break;
}
if (e.hash == hash && //如果搜索到了key相同,进行跳过,因为这个判断里只进行插入操作,更新操作在下个循环体
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // 将旧的value值进行替换
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e); //回调函数,可以根据用户需求进行回调
return oldValue;
}
}
++modCount; //这个modCount是用来计算这个hashmap中修改的次数,用来进行fail-fast机制,即进行修改时,modCount就加1
if (++size > threshold) //如果当前map中包含的k-v键值数超过了阈值threshold则扩容
resize();
afterNodeInsertion(evict);
return null;
}
modCount以及fail-fast机制
fail-fast 机制是java集合(Collection)中的一种错误机制。当多个线程对同一个集合的内容进行操作时,就可能会产生fail-fast事件。例如:当某一个线程A通过iterator去遍历某集合的过程中,若该集合的内容被其他线程所改变了;那么线程A访问集合时,就会抛出ConcurrentModificationException异常,产生fail-fast事件。一种多线程错误检查的方式,减少异常的发生。
一般情况下,多线程环境 我们使用 ConcurrentHashMap
来代替 HashMap。
HashMap的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 && //如果原容量*2小于最大值 , 就把原数组容量扩大一倍,返回这个原数组
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // //oldCap=0或oldThr=0时,即初始化时的设置
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) { //修正 threshold,例如上面的 else if (oldThr > 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 { // preserve order
Node<K,V> loHead = null, loTail = null; //定义两个链表 low和high
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next; //将Node结点的next赋值给next
if ((e.hash & oldCap) == 0) { //如果结点e的hash值与原数组的长度作与运算为0,则将它放到新链表lower中
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else { //放到high链表中
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) { //把low链表插到新节点j下标位置
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) { //把high链表插到新数组j + 原来数组容量位置的新数组下标位置
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab; //将新数组进行返回
}
HashMap的树化treefyBin和treeify方法
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) //如果数组元素数量小于64的话,只进行resize,不进行树化
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab); //开始真正转变成红黑树
}
}
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null; //创建根节点
for (TreeNode<K,V> x = this, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next;
x.left = x.right = null;
if (root == null) { //第一次转化过程,将链表的头节点作为根节点
x.parent = null;
= false;
root = x;
}
else {
K k = x.key;
int h = x.hash;
Class<?> kc = null;
for (TreeNode<K,V> p = root;;) {
int dir, ph;
K pk = p.key;
if ((ph = p.hash) > h) //通过hash的大小决定插入顺序
dir = -1; //dir是插入顺序的标识
else if (ph < h)
dir = 1;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) //首先通过二者的类类型进行比较,如果相等的话,使用 (System.identityHashCode(a) <= System.identityHashCode(b) 使用原始的 hashcode,不是重写的在对比。
dir = tieBreakOrder(k, pk);
TreeNode<K,V> xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
root = balanceInsertion(root, x); //进行二叉树的平衡
break;
}
}
}
}
moveRootToFront(tab, root);
}
HashMap的get方法
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
* 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;
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;
}
3HashMap常见问题
Hashmap在1.7中死锁问题
在hashmap1.7中,在扩容时,使用的是头插法,比如说在原数组某个链表中,1—>2->3,但是在转移完之后会变成3-》2-》1
我们就以1-》2在并发环境下进行分析,下图是两个线程同时进行扩容,线程一已经转移完毕。这时候线程而也获得运行状态开始执行
当执行e.next = newTable[i]后
newTable[i] = e;
在把2也插入进来后,变成了下面的结构
当next下移指向null,然后执行e.next = newTable[i],就会发生下面的死锁
Hashmap对于hash冲突的解决
对于解决hash冲突,一般有两种,一种是拉链法,就是将进行hash计算后hash值相同的元素以链表形式加到尾部。另一种是开放定址法,并没有体现在hashmap中,但是在ThreadLocal中有体现,我们也来顺便讲一下这个ThreadLocal
ThreadLocal(线程局部变量)
数据结构
在并发编程的时候,成员变量如果不做任何处理其实是线程不安全的,各个线程都在操作同一个变量,显然是不行的,并且我们也知道volatile这个关键字也是不能保证线程安全的。那么在有一种情况之下,我们需要满足这样一个条件:变量是同一个,但是每个线程都使用同一个初始值,也就是使用同一个变量的一个新的副本。这种情况之下ThreadLocal就非常使用,比如说DAO的数据库连接,我们知道DAO是单例的,那么他的属性Connection就不是一个线程安全的变量。而我们每个线程都需要使用他,并且各自使用各自的。这种情况,ThreadLocal就比较好的解决了这个问题。
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;
在每一个Thread线程中,维护了一个threadLocals的map
ThreadLocal存在的问题和隐患
学过ThreadLocal 的人应该都知道,ThreadLocal其实是存在内存泄漏的问题,一个是key的内存泄漏,一个是value的内存泄漏,面试官接着问,那你说说ThreadLocal是如何解决这些问题的,我答:
- 对于key 的内存泄漏:ThreadLocalMap 的key实际上是一个弱引用,当ThreaLocal对象为null时,这个key就会被回收,就不会造成内存泄漏
- 对于value的内存泄漏:每次使用完ThreadLocal中存的变量都要记得remove,这样就不会造成value 的内存泄漏
对于value的内存泄漏,之所以没有采用弱引用的方式,是因为不清楚这个Value
除了map
的引用还是否还存在其他引用,如果不存在其他引用,当GC
的时候就会直接将这个value
干掉了,而此时我们的ThreadLocal
还处于使用期间,就会造成Value为null的错误
Hashmap常见面试题
1.谈一下HashMap的特性?
1.HashMap存储键值对实现快速存取,允许为null。key值不可重复,若key值重复则覆盖。
2.非同步,线程不安全。
3.底层是hash表,不保证有序(比如插入的顺序)
2.谈一下HashMap的底层原理是什么?
基于hashing的原理,jdk8后采用数组+链表+红黑树的数据结构。我们通过put和get存储和获取对象。当我们给put()方法传递键和值时,先对键做一个hashCode()的计算来得到它在bucket数组中的位置来存储Entry对象。当获取对象时,通过get获取到bucket的位置,再通过键对象的equals()方法找到正确的键值对,然后在返回值对象。
3.谈一下hashMap中put是如何实现的?
1.计算关于key的hashcode值(与Key.hashCode的高16位做异或运算)
2.如果散列表为空时,调用resize()初始化散列表
3.如果没有发生碰撞,直接添加元素到散列表中去
4.如果发生了碰撞(hashCode值相同),进行三种判断
4.1:若key地址相同或者equals后内容相同,则替换旧值
4.2:如果是红黑树结构,就调用树的插入方法
4.3:链表结构,循环遍历直到链表中某个节点为空,尾插法进行插入,插入之后判断链表个数是否到达变成红黑树的阙值8;也可以遍历到有节点与插入元素的哈希值和内容相同,进行覆盖。
5.如果桶满了大于阀值,则resize进行扩容
4.谈一下hashMap中什么时候需要进行扩容,扩容resize()又是如何实现的?
调用场景:
1.初始化数组table
2.当数组table的size达到阙值时即++size > load factor * capacity 时,也是在putVal函数中
实现过程:(细讲)
1.通过判断旧数组的容量是否大于0来判断数组是否初始化过
否:进行初始化
- 判断是否调用无参构造器,
- 是:使用默认的大小和阙值
- 否:使用构造函数中初始化的容量,当然这个容量是经过tableSizefor计算后的2的次幂数
是,进行扩容,扩容成两倍(小于最大值的情况下),之后在进行将元素重新进行与运算复制到新的散列表中
概括的讲:扩容需要重新分配一个新数组,新数组是老数组的2倍长,然后遍历整个老结构,把所有的元素挨个重新hash分配到新结构中去。
PS:可见底层数据结构用到了数组,到最后会因为容量问题都需要进行扩容操作
5.谈一下hashMap中get是如何实现的?
对key的hashCode进行hashing,与运算计算下标获取bucket位置,如果在桶的首位上就可以找到就直接返回,否则在树中找或者链表中遍历找,如果有hash冲突,则利用equals方法去遍历链表查找节点。
6.谈一下HashMap中hash函数是怎么实现的?还有哪些hash函数的实现方式?
对key的hashCode做hash操作,与高16位做异或运算
还有平方取中法,除留余数法,伪随机数法
7.为什么不直接将key作为哈希值而是与高16位做异或运算?
因为数组位置的确定用的是与运算,仅仅最后四位有效,设计者将key的哈希值与高16为做异或运算使得在做&运算确定数组的插入位置时,此时的低位实际是高位与低位的结合,增加了随机性,减少了哈希碰撞的次数。
HashMap默认初始化长度为16,并且每次自动扩展或者是手动初始化容量时,必须是2的幂。
8.为什么是16?为什么必须是2的幂?如果输入值不是2的幂比如10会怎么样?
1.为了数据的均匀分布,减少哈希碰撞。因为确定数组位置是用的位运算,若数据不是2的次幂则会增加哈希碰撞的次数和浪费数组空间。(PS:其实若不考虑效率,求余也可以就不用位运算了也不用长度必需为2的幂次)
2.输入数据若不是2的幂,HashMap通过一通位移运算和或运算得到的肯定是2的幂次数,并且是离那个数最近的数字
9.谈一下当两个对象的hashCode相等时会怎么样?
会产生哈希碰撞,若key值相同则替换旧值,不然链接到链表后面,链表长度超过阙值8就转为红黑树存储
10.如果两个键的hashcode相同,你如何获取值对象?
HashCode相同,通过equals比较内容获取值对象
11."如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?
超过阙值会进行扩容操作,概括的讲就是扩容后的数组大小是原数组的2倍,将原来的元素重新hashing放入到新的散列表中去。
12.HashMap和HashTable的区别
相同点:都是存储key-value键值对的
不同点:
- HashMap允许Key-value为null,hashTable不允许;
- hashMap没有考虑同步,是线程不安全的。hashTable是线程安全的,给api套上了一层synchronized修饰;
- HashMap继承于AbstractMap类,hashTable继承与Dictionary类。
- 迭代器(Iterator)。HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException。
- 容量的初始值和增加方式都不一样:HashMap默认的容量大小是16;增加容量时,每次将容量变为"原始容量x2"。Hashtable默认的容量大小是11;增加容量时,每次将容量变为"原始容量x2 + 1";
- 添加key-value时的hash值算法不同:HashMap添加元素时,是使用自定义的哈希算法。Hashtable没有自定义哈希算法,而直接采用的key的hashCode()。
13.请解释一下HashMap的参数loadFactor,它的作用是什么?
loadFactor表示HashMap的拥挤程度,影响hash操作到同一个数组位置的概率。默认loadFactor等于0.75,当HashMap里面容纳的元素已经达到HashMap数组长度的75%时,表示HashMap太挤了,需要扩容,在HashMap的构造器中可以定制loadFactor。
14.传统hashMap的缺点(为什么引入红黑树?):
JDK 1.8 以前 HashMap 的实现是 数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有 n 个元素,遍历的时间复杂度就是 O(n),完全失去了它的优势。针对这种情况,JDK 1.8 中引入了 红黑树(查找时间复杂度为 O(logn))来优化这个问题。
15. 平时在使用HashMap时一般使用什么类型的元素作为Key?
选择Integer,String这种不可变的类型,像对String的一切操作都是新建一个String对象,对新的对象进行拼接分割等,这些类已经很规范的覆写了hashCode()以及equals()方法。作为不可变类天生是线程安全的,