HashMap面试相关知识

注:本文只是简单的对hashMap部分常用源码解析,并未深入算法等方面

HashMap数据结构

java7:数组+链表(头插法)
java8:数组+链表+红黑树(尾插法)

加入红黑树是为了提升在 hash 冲突严重时(链表过长)的查找性能,使用链表的查找性能是 O(n),而使用红黑树是 O(logn)

HashMap基本字段属性

// 默认初始化容量16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;    
// 默认负载因子0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f; 
// 链表节点转换红黑树节点的阈值, 9个节点转
static final int TREEIFY_THRESHOLD = 8; 
// 红黑树节点转换链表节点的阈值, 6个节点转
static final int UNTREEIFY_THRESHOLD = 6;   
// 负载因子
final float loadFactor;
// HashMap的链表数组
transient Node<K,V>[] table;
 
// 链表节点, 继承自Entry
static class Node<K,V> implements Map.Entry<K,V> {  
    final int hash;
    final K key;
    V value;
    Node<K,V> next; 
    // ... ...
}
 
// 红黑树节点
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    // ...
}

HashMap初始化

//无参数创建HashMap
Map<String,Object> map = new HashMap<>();

//HashMap无参数构造器
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

//创建HashMap,传入初始化容量大小
Map<String,Object> map = new HashMap<>(12);

public HashMap(int initialCapacity) {
	//调用双参数构造器
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//双参数构造器
public HashMap(int initialCapacity, float loadFactor) {
	//判断传入的初始化大小是否<0
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    //判断传入的初始化大小是否>最大容量值MAXIMUM_CAPACITY                               
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    //判断负载因子是否<=0 || Float.isNaN(loadFactor)非法类型    
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    //HashMap会根据我们传入的容量计算一个大于等于该容量的最小的2的N次方,例如传 12,阀值为16.
    //未初始化容量值,在第一次put的时候,会进行resize()
    //在resize()里,会将threshold赋值给Capacity,等于这里初始化的threshold就是initialCapacity且都是2的N吃饭
    this.threshold = tableSizeFor(initialCapacity);
}
//对于 |= 运算符,我们可以看做是 +=  列如 a+=1 =====> a = a+1 同理  n |= n >>> 1  ====> n = n | n>>>1
//对于 >>>(无符号右移)运算符 
//例如 a >>> b 指的是将 a 向右移动 b 指定的位数,右移后左边空出的位用零来填充,移出右边的位被丢弃.
//假设 n=12 二进制是   0000 1100
//那么 n >>> 1则是	  0000 0110
// n | n >>> 1则是    0000 1110	
static final int tableSizeFor(int cap) {
	//为了处理 cap 本身就是 2 的N次方的情况
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    //注意上面n-1后,最后返回n+1 
    //结合下面的栗子理解为什么tableSizeFor(cap)只会返回2的n次方的值
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
假设cap=13则n = 12其n二进制为 0000 1100
	n			0000 1100
	n>>>1		0000 0110
  n|n>>>1	    0000 1110		
  					||		n = n | n>>>1
	n			0000 1110
	n>>>2		0000 0011
  n|n>>>2	    0000 1111
					||		n = n | n>>>2
	n			0000 1111
	n>>>3	    0000 0001
  n|n>>>3	    0000 1111
  
  .......................
  
最后得出n = 0000 1111 = 15  return n+1   就是16

然后我们再用20和60做测试  
规律:(栗子中的tableSizeFor(cap)是源码里最终计算出来的n,并非返回结果,返回结果是n+1)
 cap   cap-1   (cap-1)二进制		(cap-1)最高位1		tableSizeFor(cap)二进制		tableSizeFor(cap)十进制
 13	    12   	0000 1100			 4				    0000 1111					15(2的4次方-1)					
 21		20	    0001 0100			 5					0001 1111					31(2的5次方-1)
 61		60	    0011 1100			 6					0011 1111					63(2的6次方-1)
 
	 ...........................................................................................
	 结论:   
	 tableSizeFor(cap)最终会得到1个比cap大的2的n次方	 n就是cap的最高位1
	 同理可证:只要n为2的N次方,其n-1则低位全是1的值.

由此可以得出:
当我们新建 HashMap (无参数)对象时, 只初始化了负载因子loadFactor,第一次插入节点时,才会对 table 进行初始化,将threshold和Capacity赋值为默认值。
当我们新建 HashMap (一个参数:initialCapacity)对象时,第一次插入节点时,才会对 table 进行初始化,将threshold赋值给Capacity,避免不必要的空间浪费。

为什么HashMap 的容量必须是 2 的 N 次方?

计算索引位置的公式为:(n - 1) & hash 【下面会详细解释为什么是这样计算索引下标】,当 n 为 2 的 N 次方时,n - 1 为低位全是 1 的值,此时任何值跟 n - 1 进行 & 运算会等于其本身,达到了和取模同样的效果,实现了均匀分布。

如果 n 不为 2 的 n 次方时,hash 冲突的概率明显增大。
长度为9(非2 的 n 次方)
3&(9-1)=0 2&(9-1)=0 ,下标都是0,碰撞了;
长度是8(2 的 n 次方)
3&(8-1)=3 2&(8-1)=2 ,下标不一样,不碰撞;

hash冲突hashMap通过hash算法计算两个key的索引位置相同简称hash冲突,列如put 10个元素,如果hash冲突概率大,就不能实现均匀分布,会导致大多元素都在一个头结点上,查询效率低。

为什么HashMap 的默认初始容量是 16,而不是8或者32?

其实这个只要是2的n次方就行,可能是16的更符合大多数情况,最终取决于实际使用情况。

HashMap如何通过hash算法定位下标

HashMap大致数组结构
在这里插入图片描述

HashMap在调用put方法插入的数据时候会根据key的hash去计算一个index值,来确定放在哪个位置。

【hash 算法】求得元素下标index的时候,马上就可以知道对应位置的元素就是我们要的,不用遍历链表/红黑树,大大优化了查询的效率。

比如:put(“name”,“大白”);HashMap会通过哈希函数计算出插入的位置(下面会介绍如何计算数组下标index),假设计算出来index是2那结果如下图所示。
在这里插入图片描述

table就是HashMap的底层链表数组

对于put任意的对象,只要它的 hashCode() 返回值相同,那么计算得到的 hash 值总是相同的。HashMap底层采用 hash 值对 table 进行位(&)运算来得到数组下标index【tab[(table.length - 1) & hash]】,这样一来,元素的分布相对来说是比较均匀的。
因为模(%)运算消耗是比较大的,所以采用计算机比较快的运算=>位(&)运算

HashMap如何定位数组下标源码分析

//根据key通过hash算法获取key的hash值
static final int hash(Object key) {
    int h;
    // 先拿到key的hashCode值; 2.将hashCode的高16位参与运算
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

public V put(K key, V value) {
	//将key的hash的值传入putVal里
    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;
	// index = (table.length - 1) & hash 获取下标
	if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
	//..........................
}

通过上面源码分析我们能知道HashMap如何定位出元素下标的步骤

  1. 拿到 key 的 hashCode 值(int hash = key.hashCode()
  2. 将 hashCode 的高位参与运算,重新计算 hash 值((h = key.hashCode()) ^ (h >>> 16)
  3. 将计算出来的 hash 值与 (table.length - 1) 进行 & 运算
    (1):n = tab.length; //获取hashMap容量大小
    (2):i = (n - 1) & hash] //获取当前下标下的节点

出现hash冲突情况,HashMap就会通过链表来存储数据
由于数组长度有限,put进的对象key的hashcode容易重复,导致最后计算出来的index也可能出现相同的(这就是所谓的hash冲突),那么index相同的就采用链表方式进行存储(如下图)。列如我再put(“Name”,“大白2”) 假设name和Name最终计算出的index都是2
在这里插入图片描述
如果是java7结果就是
在这里插入图片描述
对于为什么java8之后就采用尾插法而不继续使用java7的头插法是有原因的(大致如下)
在多线程情况下操作HaspMap
1.Java7的HashMap可能会引起死循环,原因是resize()后前后链表顺序倒置,在扩容过程中修改了原来链表中节点的引用关系。

2.Java8的HashMap则不会出现死循环,原因是扩容转移后前后链表顺序不变,保持之前节点的引用关系。

因为每次HashMap插入元素时,都会进行判断是否要resize(),具体resize()源码详解在后面
如果执行put()时进行了resize(),
由于resize()会将旧的hashMap的所有元素重新hash定位一下,如果就定位和新定位的index相等(说明位置没变),
hashMap会将此链上的元素从头遍历
如果是java7头插法就可能出现
开始放入AB元素,由于头插法链表就是BA排列(扩容前),进行扩容后从旧链表取数,链表取数是从头遍历,重新放入新数组链表后就成了AB
扩容前BA (B.next = A、A.next = null)
扩容后AB (A.next = B、B.next = A 多线程可能出现死循环(如下图)) || (A.next = B、B.next = null 正常)
在这里插入图片描述

注:java8的HashMap多线程情况虽然不会出现链表死循环,但是并不能说明HashMap能在多线程中使用不出现问题
因为HashMap的get和put方法都没有synchronized,在多线程环境下容易出现的就是:无法保证上一秒put的值,
下一秒get的时候还是原值,所以线程安全还是无法保证。

HashMap扩容
因为数组长度是有限的,对于HashMap来说,数据多次插入的,到达一定的数量就会进行扩容,也就是会调用resize()方法
什么时候会触发resize()方法呢?
主要就是
1:HashMap当前的容量 Capacity
2:负载因子static final float DEFAULT_LOAD_FACTOR = 0.75f;
3:阀值threshold = Capacity * 负载因子
假设当前的HashMap容量大小为100,当你put第76个的时候,就会触发resize()方法
HashMap具体如何扩容如下(源码解析)主要步骤

  1. 创建一个新的Node空数组,长度是原数组的2倍。
  2. 遍历原Node数组,把所有的Node重新Hash到新数组。

resize方法源码解析

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;							//table就是旧HashMap,用oldTab替代
    int oldCap = (oldTab == null) ? 0 : oldTab.length;	//旧HashMap的容量大小
    int oldThr = threshold;								//旧HashMap的扩容阀值(超过就触发resize())
    int newCap, newThr = 0;								//初始化新HashMap的容量和阀值(新HashMap就是扩容后的HashMap)
    // 1.判断老HashMap的容量不为0
    if (oldCap > 0) {
        // 1.1 判断老HashMap的容量是否超过最大容量值:如果超过则将阈值设置为Integer.MAX_VALUE,并直接返回老HashMap,不能继续扩容
        // 1.1 此时oldCap>Integer.MAX_VALUE大,超出容量最大值,因此无法进行重新分布,只是单纯的将阈值扩容到最大
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 1.2 newCap = oldCap << 1;将旧容量值左移一位=====值*2(这里可以得出结论,每次扩容都是扩大原来的2倍)
        // 1.2 将newCap赋值为oldCap的2倍,如果newCap<最大容量并且oldCap>=16, 则将新阈值设置为原来的两倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // 2.老HashMap的容量为0, 且阈值大于0, 是因为初始容量被放入阈值
    else if (oldThr > 0)
        newCap = oldThr;	//将新HashMap的容量0设置为老表的阈值
    // 3.老HashMap的容量为0, 且阈值为0,这种情况是没有传初始容量的new方法创建的空表    
    else {
    	//将阈值和容量设置为默认值
        newCap = DEFAULT_INITIAL_CAPACITY;
        //阀值 = 容量大小 * 负载因子(解释上面为什么是容量为100,当达到76的时候就会触resize()方法)
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);	
    }
    // 4.如果新表的阈值为空, 则通过新的容量*负载因子获得阈值
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    // 5.将当前阈值设置为刚计算出来的新的阈值,定义新表,容量为刚计算出来的新容量,将table设置为新定义的表。
    threshold = newThr;
    // 初始化一个新的Node用来扩容
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    // 6.如果老HashMap不为空,则需遍历所有节点,将节点赋值给新Node
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {		// 获取旧HashMap的容量大小,进行循环
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {  	// 将老HashMap索引值为j的头节点临时赋值给e
                oldTab[j] = null; 				// 将老HashMap的节点设置为空, 以便垃圾收集器回收空间
                // 7.如果e.next为空, 则代表老HashMap的该位置只有1个节点,计算新HashMap的索引位置, 直接将该节点放在该位置
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                // 8.如果是红黑树节点,则进行红黑树的重hash分布(跟链表的hash分布基本相同)
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // 9.如果是普通的链表节点,则进行普通的重hash分布
                else { 
                    Node<K,V> loHead = null, loTail = null; // 存储索引位置为:“原索引位置”的节点
                    Node<K,V> hiHead = null, hiTail = null; // 存储索引位置为:“原索引位置+oldCap”的节点
                    Node<K,V> next;
                    do {
                        next = e.next;		//下一节点
                        // 9.1 如果e的hash值与老HashMap的容量进行与运算为0,则扩容后的索引位置跟老HashMap的索引位置一样
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null) 	// 如果loTail为空, 代表该节点为第一个节点
                                loHead = e; 		// 则将loHead赋值为第一个节点
                            else
                                loTail.next = e;    // 否则将节点添加在loTail后面
                            loTail = e; 			// 并将loTail赋值为新增的节点
                        }
                        // 9.2 如果e的hash值与老HashMap的容量进行与运算为1,则扩容后的索引位置为:老HashMap的索引位置+oldCap
                        else {
                            if (hiTail == null) // 如果hiTail为空, 代表该节点为第一个节点
                                hiHead = e; 	// 则将hiHead赋值为第一个节点
                            else
                                hiTail.next = e;// 否则将节点添加在hiTail后面
                            hiTail = e; 		// 并将hiTail赋值为新增的节点
                        }
                    } while ((e = next) != null);
                    // 10.如果loTail不为空(说明老HashMap的数据有分布到新表上“原索引位置”的节点),则将最后一个节点
                    //    的next设为空,并将新HashMap上索引位置为“原索引位置”的节点设置为对应的头节点
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 11.如果hiTail不为空(说明老HashMap的数据有分布到新HashMap上“原索引+oldCap位置”的节点),则将最后
                    // 一个节点的next设为空,并将新表上索引位置为“原索引+oldCap”的节点设置为对应的头节点
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    // 12.返回扩容后的Node
    return newTab;
}

对于为什么要将所有的元素通过hash算法重新定位一遍?
是因为长度扩大以后,Hash的规则也随之改变
还记得上文说到数组index是如何通过hash算法得到吗,int index = hash(key) & (table.length - 1)
扩容后table.length的大小不一样,得到的index的结果也不一样。

put方法源码解析

//当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) {
	// tab 可以看做 调用此方法的HashMap(原因:下面判断语句里将tab = table)
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 判断tab是否为空或者length等于0,否则调用resize方法进行初始化tab
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    
    // 判断i下标下的节点是否存在值(存在沿着该节点链表继续查找,不存在就索引位置新增一个节点(目标节点赋值进去)
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {				
        Node<K,V> e; K k;
        // 判断当前下标位置的key和hash是否和目标的key和对应的hash(key)相等
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
        	//hash和key都相等,说明HashMap的keyName相等,p节点就是目标节点(将目标节点赋值给e节点)
           	e = p;
        // 判断p节点是否为TreeNode(红黑树结构链表)
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
        	// 走到这代表目标节点在普通链表节点,则调用普通的链表方法进行查找,使用binCount统计链表的节点数    
            for (int binCount = 0; ; ++binCount) {
            	// 判断p节点的下一节点是否为null
            	// null:新增一个节点并插入(尾插法)
            	// not null: 进行下面的if判断
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 判断节点数是否超过8个,如果超过则调用treeifyBin方法将链表节点转为红黑树节点
                    // TREEIFY_THRESHOLD-1是因为循环是从p节点的下一个节点开始的
                    if (binCount >= TREEIFY_THRESHOLD - 1) 
                        treeifyBin(tab, hash);
                    break;
                }
                // 判断p的下一节点e的hash和key是否和put进来的hash(key)和key相等
                // 相等:e就是目标节点,跳出循环
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                // 将e赋值给p(为了循环获取e的下一个节点)    
                p = e;
            }
        }
        // 如果e节点不为空,则代表keyname相同,只需要将newValue覆盖oldValue,并返回oldValue
        if (e != null) { 
            V oldValue = e.value;
            // put(K key, V value) 调用 putVal(hash(key), key, value, false, true);
            // 可以看出!onlyIfAbsent 为 true
            if (!onlyIfAbsent || oldValue == null)
            	// 将新value覆盖oldValue
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 判断插入节点后节点数超过阈值,则调用resize方法进行扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

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;
    //1:判断table不为null
    //2:判断table.length > 0
    //3:通过hash计算出要查询的key对应的下标index 判断table[index]不为null
    if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
    	//1:判断头结点的hash值是否与传入key的hash值相等
    	//2:判断头结点的key是否和传入的key相等
    	// 都满足情况下说明已找到目标,就返回头结点 
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        //说明头结点不是目标节点,继续判断头结点的下一节点是否为null    
        if ((e = first.next) != null) {
        	//如果不为null,则判断头结点是否为红黑树节点
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            //循环遍历此头结点链表    
            do {
            	//以下判断与上面判断头结点是否为目标节点类似
            	//判断下一节点的hash值是否与传入key的hash值相等
            	//判断下一节点的key值是否与传入key值相等
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                	//满足返回此目标节点
                    return e;    
              //将下一节点赋给e,依次遍历链表         
            } while ((e = e.next) != null);
        }
    }
    //说明找不到符合的key
    return null;
}

HashMap遍历

//第一种遍历方式效率快
Iterator<Map.Entry<String, Object>> iterator = map.entrySet().iterator();
while(iterator.hasNext()){
    Map.Entry<String, Object> next = iterator.next();
    System.out.println("key:"+next.getKey()+"====value:"+next.getValue());
}
System.out.println("==================================================");
//第二种遍历方式效率慢不推荐使用
Iterator<String> iterator2 = map.keySet().iterator();
while (iterator2.hasNext()){
    String key = iterator2.next();
    System.out.println("key:"+key+"====value:"+map.get(key));
}
  • 后期完善ing
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值