面试常问的HashMap源码分析(jdk1.8)
目录
一、面试常见问题二、基本常量属性三、构造方法四、节点结构4.1 Node类4.2.TreeNode五、put方法5.1 key的hash方法5.2 resize() 扩容方法六、get方法
一、面试常见问题
Q1: HashMap底层数据结构是什么?
jdk1.7是数组+链表;jdk1.8 采用数组+ 链表+红黑树
红黑树的引入是为了提高查询效率,哈希冲突可以减少(元素key均匀散列程度和通过扩容),不可避免;
如果冲突元素较多,会导致链表过长,而链表查询效率是O(n), 当链表长度超过8后,且数组长度(length)超过64才转化为红黑树,
这点易忽略(后源码证明),不是链表长度一超高8个就将其树化为红黑树,此时扩容解决冲突效果更好。(注:红黑树的特性需要了解掌握)
Q2:为什么要引入红黑树,有什么优势,解决什么问题?
红黑树的引入是为了提高查询效率,哈希冲突可以减少(元素key均匀散列程度和通过扩容),不可避免,
如果冲突元素较多,会导致链表过长,而链表查询效率是O(n), 当链表长度超过8后,且数组长度(length)超过64才转化为红黑树,
这点易忽略(后源码证明),不是链表长度一超高8个就将其树化为红黑树,此时扩容解决冲突效果更好。(注:红黑树的特性需要了解掌握)
Q3: 在一条链表上新增节点,什么方式插入?
1.8 是尾插法,1.7是采用头插法,为什么1.7需要头插法?头插法效率高?
Q4:放入HashMap中元素需要什么特性?
需要重写hashCode和 equals()方法 ,不重写会有什么问题?
Q5: HashMap是线程安全的吗?你列举其他线程安全的,特性?
不是, 因为它的方法没有同步锁,HashTable 是线程安全的,每个方法有加synhronized关键字,线程安全,但也会导致效率变低;
一般多线程环境使用 ConcurrentHashMap,其原理是采用了分段锁机制,每一段(Segment)中是一个HashMap,每个Segment中操作是加锁的,
即保证线程操作某一个Segment是排他的,但不同线程在不同Segment是可以同时操作的,即保证了线程安全,又提高了并发效率。
(此处不详细展开ConcurrentHashMap,面试问题是环环相套的,好的面试官会步步引导,目的是为了全面考察面试者的技术水平)。
Q6: 1.7中 HashMap存在什么问题?
在多线程环境下,1.7中Map可能会导致cpu使用率过高,是因为存在环形链表了,HashMap中 链表是单链表结构,怎么会有环?
是链表中元素指向下一个元素的指针next,指向了前面的元素,导致了环,所以在遍历链表时,程序一直死循环无法结束。在多个线程放置元素时,resize()方法中导致(后源码证明)。
Q7: 1.8是先插入新值再判断是否扩容,还是先扩容在插入新值?
1.8是先插入元素,在判断容量是否超过阈值,扩容,1.7是先扩容再插入新值
Q8: HashMap是延迟初始化?
是的,创建的Map对象,如果有指定容量大小,会记录下来;没有指定会使用默认16,在第一次put元素时才初始化数组,1.7,1.8都是如此,可以节省内存空间。
Q9: HashMap允许放置空键(null),空值(null)吗?
允许放置,null 键是放置在数组第一个位置的,因此在判断某个key是否存在时,不能通过该get() 方法获取value为null判断,
这时键值对可能是 null:null,此时是存在Node对象的,可以通过containsKey(key)判断 ,key为null,Node对象不为null,如下图所示
Q10 :简要讲述一下HashMap放置元素过程,即put()方法。
put方法流程如下图所示
二、基本常量属性
//默认初始容量 16static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16/***最大容量,如果在构造函数中指定大于该值,会使用该值作为容量大小,2^30*/static final int MAXIMUM_CAPACITY = 1 << 30;/*** 默认加载因子75%,好比地铁满载率,受疫情影响地铁满载率不超过30%* 1,加载因子设置较大,随着数组中元素装的越多,发生的冲突的概率越大,即对应的链表* 越长,影响查找效率。* 2,加载因子设置较小,元素很容易达到设定的阈值,发生扩容操作,数组空间还有很大部分* 没有利用上,造成空间浪费。* 因此在时间与空间上的权衡考虑*/static final float DEFAULT_LOAD_FACTOR = 0.75f;/*** 树化阈值:在链表元素超过8个了,将链表转化为红黑数结果,提高查找效率*/static final int TREEIFY_THRESHOLD = 8;/*** 取消树化阈值:在红黑树中节点数量小于6个,将其转化为链表结构*/static final int UNTREEIFY_THRESHOLD = 6;/*** 最小树化容量,容易忽视* 链表树化红黑树条件:* 1,链表中元素数量超过(>)8个;* 2, 满足map中元素个数(size)大于等于64否则会先扩容,扩容对解决hash冲突更有效。*/static final int MIN_TREEIFY_CAPACITY = 64;
三、构造方法
//传有初始容量和加载因子1.HashMap(int initialCapacity, float loadFactor)//有初始容量,加载因子默认2.HashMap(int initialCapacity)//初始容量, 加载因子默认3.HashMap()//传递的一个Map子集4.HashMap(Map<? extends K, ? extends V> m)
四、节点结构
1.数组和链表节点对象为HashMap内部类 Node.
2.红黑树节点 TreeNode,继承LinkedHashMap.Entry , 而 Entry继承Node,因此 TreeNode 实际是 Node孙子.
3.Node类,重写了hashCode和 equals方法,记录了当前key, value, key的hash值,以及指向后一个元素指针.
4.1 Node类
//实现Map集合的Entry类static class Node<K,V> implements Map.Entry<K,V> {//记录当前节点key的hashfinal 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; }//重写了hashcodepublic final int hashCode() {return Objects.hashCode(key) ^ Objects.hashCode(value);}public final V setValue(V newValue) {V oldValue = value;value = newValue;return oldValue;}//重写了equalspublic 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;}}
4.2.TreeNode
什么是红黑树?特点?
性质1. 节点是红色或黑色.
性质2. 根节点是黑色.
性质3.所有叶子都是黑色.(叶子是NUIL节点)
性质4. 每个红色节点的两个子节点都是黑色.(从每个叶子到根的所有路径上不能有两个连续的红色节点)
性质5. 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点.
// 继承LinkedHashMap.Entry 继承>> HashMap.Nodestatic final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {TreeNode<K,V> parent; // red-black tree linksTreeNode<K,V> left;TreeNode<K,V> right;TreeNode<K,V> prev; // needed to unlink next upon deletion//颜色是否为红色boolean red;
五、put方法
public V put(K key, V value) {return putVal(hash(key), key, value, false, true);}
5.1 key的hash方法
static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);}
hash(key)方法:
1.hashMap支持 key 为null,放在数组索引为0的位置
2.为了保证key的hash值分布均分、散列,减少冲突
(h = key.hashCode()) ^ (h 16)
等于 key的hash值 异或于 其hash值的低 16位
例:"水果"的 hashCode()
h = 11011000000111101000
h>>>16=00000000000000001101
两者异或,不同为1,相同为0
1101 10000001 111010000000 00000000 000011011101 10000001 11100101
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;if ((tab = table) == null || (n = tab.length) == 0)//1,数组为空,初始化操作,此处resize() 待解析1n = (tab = resize()).length;if ((p = tab[i = (n - 1) & hash]) == null)/**2,通过key的计算出的hash值与数组长度-1与运算,算出在数组下标位置* 若该位置为空则创建新节点封装元素值,放在该位置*/tab[i] = newNode(hash, key, value, null);else {// 若数组下标位置已经有值Node<K,V> e; K k;//3,首先判断数组位置两个key是否相同,e用来指向存在相同key的节点。if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))e = p;else if (p instanceof TreeNode)//4,key不相同的情况下,判断数组该下标位置连接的是链表还是树节点//若是树结构,就将该值放入树中(放入树中操作,也会遍历树判断是否存在相同的key的节点,若无相同会以新节点插入树中)e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);else {//数组该下标位置只有1个节点或者链表:这里统一为链表形式for (int binCount = 0; ; ++binCount) {if ((e = p.next) == null) {//5,没有找到相同key的元素,在链表后插入新节点p.next = newNode(hash, key, value, null);/**此处为什么要>=7,因为bincount从0开始递增,当bincount=7时,*此时for循环执行了7次,加上新增加的节点,以及数组下标位置节点*共7+1+1= 9了,即该数组下标位置链表长度大于8了,需要转化为红黑树*/if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1sttreeifyBin(tab, hash);break;}//遍历链表比较判断是否存在两个key相同元素if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))break;//有上面 e= p.next,这里 p有重新被赋值为e,即继续遍历链表下一个元素p = e;}}// 存在相同的key的元素,可能在数组、链表、树上if (e != null) { // existing mapping for keyV oldValue = e.value;if (!onlyIfAbsent || oldValue == null)//6,覆盖旧值,返回旧值,也可设置仅仅当旧value存在,不为null情况才覆盖原值e.value = value;afterNodeAccess(e);return oldValue;}}//修改次数+1++modCount;//7,判断整个map中元素个数是否大于阈值,大于则扩容,由此可见是先插入元素再判断容量大小是否扩容,区别1.7先扩容再插入新值if (++size > threshold)resize();//该方法为空,不用理会afterNodeInsertion(evict);return null;}
5.2 resize() 扩容方法
该方法有个比较有意思的是将旧数组的元素移到扩大为原来容量2倍的新数组中时,原来数组中链表需要进行拆链,非常巧妙,下见。
final Node<K,V>[] resize() {Node<K,V>[] oldTab = table;int oldCap = (oldTab == null) ? 0 : oldTab.length;int oldThr = threshold;int newCap, newThr = 0;//1,原来数组容量>0情况if (oldCap > 0) {//如果数组容量已经为最大值了 2^30,那就仅仅是将扩容阈值修改if (oldCap >= MAXIMUM_CAPACITY) {threshold = Integer.MAX_VALUE;return oldTab;}else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&oldCap >= DEFAULT_INITIAL_CAPACITY)//1.1 如果原来数组容量>=16,且其2倍小于最大容量(oldCap<<1 等价于 oldCap*2^1)// 阈值也扩容为原来2倍newThr = oldThr << 1; // double threshold}else if (oldThr > 0) // initial capacity was placed in threshold//1.2 什么情况数组容量=0,阈值>0呢?//创建HashMap,指定了数组初始容量cap,会将其转换为大于等于cap的最小的2的幂次方的数赋值给threshold,这里即将数组容量赋值newCap = oldThr;else { // zero initial threshold signifies using defaults//1.3原来数组容量和阈值都为0,即常用的无参构造方式,使用默认容量大小//阈值根据容量和负载因子算出newCap = DEFAULT_INITIAL_CAPACITY;newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);}//2,newThr = 0情况出现在1.1中,没满足条件扩大为原来2倍if (newThr == 0) {float ft = (float)newCap * loadFactor;//小于最大容量,则用容量和加载因子乘积,否则为 Integer最大值newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?(int)ft : Integer.MAX_VALUE);}threshold = newThr;//3,创建新数组对象({"rawtypes","unchecked"})Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];table = newTab;//4,原数组对象不为空,需要将原数组元素移到新数组中if (oldTab != null) {//遍历旧数组for (int j = 0; j < oldCap; ++j) {Node<K,V> e;//数组下标位置有元素if ((e = oldTab[j]) != null) {//直接把该下标位置链表(或红黑树)取出来,1个元素也当做链表看待,原数组该位置置空,只要我们拿着链表头节点/树根节点(e)就行oldTab[j] = null;if (e.next == null)//该位置只有1个元素,直接与新数组长度hash,确定位置放入新数组newTab[e.hash & (newCap - 1)] = e;else if (e instanceof TreeNode)//该位置是一颗红黑树,迁移到新数组((TreeNode<K,V>)e).split(this, newTab, j, oldCap);else { // preserve order//5,重点解析:这里将原数组链表拆分为2条链表放入新数组,那它是怎么拆的呢?// loHead,loTail(lo 理解为low缩写)分别记录放在新数组下标小的那一条链表的头节点和尾结点,// 同理hiHead,hiTail(hi理解为 hight缩写)放在新数组下标大的位置Node<K,V> loHead = null, loTail = null;Node<K,V> hiHead = null, hiTail = null;Node<K,V> next;do {next = e.next;// e.hash & oldCap 是什么目的呢?// e.hash & (oldCap-1)求得是数组下标,e.hash & oldCap// 是为了获取比oldCap-1更高的那位一是0还是1,是0的就留在原位,是1的话需要增加oldCap。这里不易理解,下面详解if (( e.hash & oldCap ) == 0) {//该链表记录原位置链表if (loTail == null)loHead = e;elseloTail.next = e;loTail = e;}else {//该链表记录高位置链表if (hiTail == null)hiHead = e;elsehiTail.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;}
扩容拆链过程分析:
#1,扩容前位置= 70001 0111&0000 1111---------0000 0111#2,扩容后位置= 230001 0111&0001 1111---------0001 0111/**因为每次扩容都是old数组长度的2倍,那么在要计算在扩容后新数组位置,而扩容后,15变为31,那么key的hash值影响计算结果位由4位变为5位,e.hash & oldCap的结果就是获取新增的位置,000X 0000,即X的值,0或者1;0运算结果不变,放在原位,1与运算结果会增加一个原数组长度。*/
如下图所示
7 & 7=70000 01110000 0111----------23 & 7=70001 01110000 0111----------31 & 7=70001 11110000 0111----------15 & 7=70000 11110000 0111#可见第4位(从右向左)为1的有15,31,扩容后位置:原有下标+原数组长度
拆链位运算实现的巧妙:
1,扩容迁移原有数组中的元素,不用再重复计算原有元素key的hash值,提高效率.
2,扩容后拆链,会将链表长度缩短,减少hash冲突,提高查找效率.
六、get方法
根据键值对中的键(key)获取值
public V get(Object key) {Node<K,V> e;//获取节点赋值给e,e!=null, 返回该键值对的value值return (e = getNode(hash(key), key)) == null ? null : e.value;}
下见方法:getNode(int hash, Object key)
final Node<K,V> getNode(int hash, Object key) {//传入的hash值与put方法一样的方法,相同规则计算key的hash值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) {//1,获取数组下标位置的第一个节点,firstif (first.hash == hash && // always check first node((k = first.key) == key || (key != null &&key.equals(k))))//2,检查是否为相同的key,是直接返回该节点对象;不是则遍历下一个节点return first;if ((e = first.next) != null) {//3,下一个节点存在,需判断是红黑树,还是链表if (first instanceof TreeNode)//3.1红黑树则遍历树获取是否存在与该key相同的节点return ((TreeNode<K,V>)first).getTreeNode(hash, key);do {//3.2 链表则依次遍历,查找相同的key,找到则返回if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))return e;} while ((e = e.next) != null);}}//数组为空,或者没有找到返回空return null;}
