【集合】HashMap 的底层原理(附部分源码 + 面试题)

1. 知识回顾

HashMap

  • HashMap 是一个用于存储 <key, value> 键值对的集合。
  • 底层采用的数据结构:
    • JDK7 及以前:数组 + 链表
    • JDK8 及之后:数组 + 链表 + 红黑树
  • 底层采用的算法:
    • 哈希算法
  • 使用方式:
    • 添加键值对:put(key, value)
    • 获取值:get(key)

数组

  • 数组:采用一段连续的存储单元来存储数据。
  • 特点:查询 O ( 1 ) O(1) O(1),插入和删除 O ( N ) O(N) O(N),也就是,查询快,插入删除慢。
  • ArrayList 底层就是采用数组。

链表

  • 链表:是一种物理上非连续、非顺序的存储结构。
  • 特点:插入和删除 O ( 1 ) O(1) O(1),查找 O ( N ) O(N) O(N),也就是,插入删除快,查找慢。
  • LinkedList 底层就是采用双向链表。

哈希算法 / 摘要算法

作用:对任意一组输入数据进行计算,得到一个固定长度的输出摘要。

常见的哈希算法有:

  • MD4
  • MD5
  • SHA-1
  • 其他

哈希算法最重要的特点就是:

  • 相同的输入一定得到相同的输出;
  • 不同的输入大概率得到不同的输出。

当两个不同的输入得到了相同的输出,即为哈希碰撞(冲突)。常见的冲突处理方法:

  • 链地址法
  • 开放定址法
  • 再散列法

2. HashMap 源码中的几个重要字段

HashMap 底层采用的数据结构:

  • JDK7 及以前:数组 + 链表
  • JDK8 及之后:数组 + 链表 + 红黑树

为了解决哈希冲突而采用的处理方式:链地址法。

下图形象地展示了 HashMap 的底层数据结构实现:
在这里插入图片描述


在了解 HashMap 的功能是如何实现之前,我们先来看下源码中比较重要的几个字段。

transient Node<K,V>[] table;
final float loadFactor; // 负载因⼦
int threshold; // 所能容纳 Node 节点的最大数量
transient int modCount;
transient int size;

1、Node

Node 是 HashMap 的一个静态内部类,它实现了 Map.Entry 接口,本质就是一个映射(键值对)。上图中的每个黑色圆点就是一个 Node 对象。具体源码如下:

//JDK 1.8
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash; //用来定位数组索引位置
    final K key;
    V value;
    Node<K,V> next; //链表的下一个node

    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;
    }
}

2、table

transient Node<K,V>[] table;

HashMap 类中有一个非常重要的字段,就是 Node[] table ,即哈希桶数组。明显它是一个存储 Node 的数组,初始化长度为length(默认值是16)。

length 大小必须为 2 的 n 次方(一定是合数),这是一种非常规的设计。常规的设计是把桶的大小设计为素数,相对来说素数导致冲突的概率要小于合数。HashTable初始化桶大小为11,就是桶大小设计为素数的应用(HashTable扩容后不能保证还是素数)。HashMap采用这种非常规设计,主要是为了在取模和扩容时做优化。同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。

3、loadFactor

final float loadFactor;

loadFactor为负载因子(默认值是 0.75)。

默认的负载因子 0.75 是对空间和时间效率的一个平衡选择,建议大家不要修改,除非在时间和空间比较特殊的情况下。如果内存空间很多而又对时间效率要求很高,可以降低负载因子的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子的值,这个值可以大于1。

这里存在一个问题,即使负载因子和Hash算法设计得再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap的性能。于是,在JDK8 版本中,对数据结构做了进一步的优化,引入了红黑树。而当链表长度太长(默认超过8)时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能,其中会用到红黑树的插入、删除、查找等算法。

loadFactor 表示 HashMap 的拥挤程度,影响hash操作到同一个数组位置的概率。默认 loadFactor 等于0.75,当 HashMap 里面容纳的元素已经达到HashMap 数组长度的75%时,表示HashMap太挤了,需要扩容,在 HashMap 的构造器中可以定制 loadFactor。

4、threshold

int threshold; // 所能容纳 Node 节点的最大数量

threshold 表示 HashMap 最多能容纳多少个 Node 节点。

threshold = length × loadFactor,也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。

结合负载因子的定义公式可知,threshold 就是在此 loadFactor (负载因子)和 length(数组长度)对应下允许的最大键值对数目,超过这个数目就重新 resize(扩容),扩容后的 HashMap 容量是之前容量的两倍。

5、modCount

transient int modCount;

modCount字段主要用来记录 HashMap 内部结构发生变化的次数,主要用于迭代的快速失败。强调一点,内部结构发生变化指的是结构发生变化,例如增加或者删除键值对,但是某个key对应的value值被覆盖不属于结构变化。

6、size

transient int size;

size这个字段其实很好理解,就是HashMap中实际存在的键值对数量。注意和 table 的长度length、容纳最大键值对数量threshold的区别。

3. 功能实现

HashMap 的内部功能实现很多,本文主要从以下三个具有代表性的点深入展开讲解:

  • 根据 key 获取哈希桶数组索引位置(哈希算法)
  • put 方法的详细执行
  • 扩容过程

好的 Hash算法扩容机制 可以使得Hash碰撞的概率小,数组 table 占用空间又少。

3.1 根据 key 获取数组索引位置

不管增加、删除、查找键值对,定位到哈希桶数组的位置都是很关键的第一步。前面说过 HashMap 的数据结构是数组和链表的结合,所以我们当然希望这个 HashMap 里面的元素位置尽量分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用遍历表,大大优化了查询的效率。HashMap 定位数组索引位置,直接决定了hash方法的离散性能。先看看源码的实现(方法一 + 方法二):

//方法一: JDK1.8
static final int hash(Object key) {
	int h;
	return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
	// h = key.hashCode() 为第一步 取 hashCode 值
	// h ^ (h >>> 16) 为第二步 高位参与运算
}

//方法二: JDK 1.7的源码, JDK 1.8 没有这个方法,但是实现原理一样的
static int indexFor(int h, int length) {
	return h & (length-1); //第三步 取模运算
}

// JDK1.7
final int hash(Object k) {
    int h = hashSeed;   // hashSeed默认为0, 为了让hash算法计算出的hash值更散列一点
    if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((String) k);
    }
    h ^= k.hashCode();  // 异或操作:相同为0,不同为1
	
	// 无符号右移、异或运算。令其在indexFor中计算数组下标时,使高位与结果有关,提高散列性。
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
    
    /* hash为什么要进行右移异或运算
            h:    0110 0101
            15:   0000 1111

            h:    1110 0101
            15:   0000 1111

            h:    0000 0101
            15:   0000 1111
            
       如上3组&运算,其结果都一样,这样与高位无关,若无下面的右移异或运算,这样很多key值最后计
       算出的下标可能都是一样的,这样造成链表过长。
           
     */ 
}

这里的Hash算法本质上就是三步:取key的hashCode值、高位运算、取模运算。

对于任意给定的对象,只要它的 hashCode() 返回值相同,那么程序调用方法一所计算得到的 hash值 总是相同的。我们首先想到的就是把 hash值 对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,模运算的消耗还是比较大的,在 HashMap 中是这样做的:调用方法二来计算该对象应该保存在table数组的哪个索引处。

这个方法非常巧妙,它通过h & (table.length - 1)来得到该对象的保存位,而 HashMap 底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h & (length-1)运算等价于对length取模,也就是h % length,但是 &比%具有更高的效率

在 JDK1.8 的实现中,优化了高位运算的算法,通过 hashCode() 的 高16位 异或 低16位 实现的:(h = key.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组 table 的 length 比较小的时候,也能保证考虑到高低 bit 都参与到 hash值 的计算中,同时不会有太大的开销。

举例说明如下:n 为 table 的长度。
在这里插入图片描述

3.2 HashMap 的 put 方法

HashMap 的 put 方法执行过程可以通过下图来理解:
在这里插入图片描述

  1. 判断 键值对 数组 t a b l e table table 是否为空或 l e n g t h = = 0 length==0 length==0,若是则执行 r e s i z e ( ) resize() resize() 进行扩容;否则转向第2步;
  2. 根据键值key计算hash值,得到插入的数组索引 i i i,如果 t a b l e [ i ] = = n u l l table[i]==null table[i]==null,直接新建节点添加,转向第6步,如果 t a b l e [ i ] table[i] table[i] 不为空,转向第3步;
  3. 判断 t a b l e [ i ] table[i] table[i] 的首个元素是否和key一样,如果相同直接覆盖value,否则转向第4步,这里的相同指的是 h a s h C o d e ( ) hashCode() hashCode() 以及 e q u a l s ( ) equals() equals()
  4. 判断 t a b l e [ i ] table[i] table[i] 是否为treeNode,即 t a b l e [ i ] table[i] table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向第5步,
  5. 遍历 t a b l e [ i ] table[i] table[i],判断表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
  6. 插入成功后,判断实际存在的键值对数量 s i z e size size 是否超过了最大容量 t h r e s h o l d threshold threshold,如果超过,进行扩容。

JDK1.8 中HashMap的 put 方法源码如下:

public V put(K key, V value) {
	// 对 key的 hashCode()做 hash
	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;
	
	// 步骤1:tab为空则创建
	if ((tab = table) == null || (n = tab.length) == 0)
		n = (tab = resize()).length;

	// 步骤2:计算index,并对null做处理
	if ((p = tab[i = (n - 1) & hash]) == null)
		tab[i] = newNode(hash, key, value, null);
	else {
		Node<K,V> e; 
		K k;
		// 步骤3:节点key存在,直接覆盖value
		if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
			e = p;
		// 步骤4:判断该链为红黑树
		else if (p instanceof TreeNode)
			e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
		// 步骤5:该链为链表
		else {
			for (int binCount = 0; ; ++binCount) {
				if ((e = p.next) == null) {
					p.next = newNode(hash, key,value,null);
					//链表长度大于8,转换为红黑树进行处理
					// -1 for 1st
					if (binCount >= TREEIFY_THRESHOLD - 1) treeifyBin(tab, hash); 		
					break;
				}
				// key已经存在直接覆盖value
				if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break;
				p = e;
			}//for
		}//else

		if (e != null) { // existing mapping for key
			V oldValue = e.value;
			if (!onlyIfAbsent || oldValue == null) e.value = value;
			afterNodeAccess(e);
			return oldValue;
		}
	}//else
	
	++modCount;
	
	// 步骤6:超过最大容量 就扩容
	if (++size > threshold) resize();
	afterNodeInsertion(evict);
	return null;
}

3.3 扩容机制

扩容(resize)就是重新计算容量,向 HashMap 对象里不停地添加元素,而 HashMap 对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然 Java 里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像我们用一个小桶装水,如果想装更多的水,就得换大水桶。

我们分析下 resize 的源码,鉴于 JDK8 融入了红黑树,较复杂,为了便于理解我们仍然使用 JDK7 的代码,好理解一些,本质上区别不大。

// JDK7
void resize(int newCapacity) { //传入新的容量
	Entry[] oldTable = table; //引用扩容前的Entry数组
	int oldCapacity = oldTable.length;
	//扩容前的数组大⼩如果已经达到最大(2^30)了
	if (oldCapacity == MAXIMUM_CAPACITY) { 
		threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
		return;
	}

	Entry[] newTable = new Entry[newCapacity]; //初始化⼀个新的Entry数组
	transfer(newTable); //!!将数据转移到新的Entry数组里
	table = newTable; //HashMap的table属性引⽤用新的Entry数组
	threshold = (int)(newCapacity * loadFactor);//修改阈值
}

这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer() 方法将原有Entry数组的元素拷贝到新的Entry数组里。

// JDK7
void transfer(Entry[] newTable) {
	Entry[] src = table; //src引用了旧的Entry数组
	int newCapacity = newTable.length;
	for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
		Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素
		if (e != null) {
			src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
			do {
				Entry<K,V> next = e.next;
				int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
				e.next = newTable[i]; //标记[i]
				newTable[i] = e; //将元素放在数组上
				e = next; //访问下一个Entry链上的元素
			} while (e != null);
		}//if
	}//for
}

在 JDK7 中,e.next = newTable[i],也就是使用了单链表的头插法:同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到 Entry 的尾部(如果发生了hash冲突的话),这一点和 JDK8 有区别,下文详解。

在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。

下面举个例子说明下扩容过程。假设了我们的 hash算法 就是简单的用 key mod一下表的大小〔也就是数组的长度)。其中的哈希桶数组 table 的 length 为 2,所以key:3、7、5,put顺序依次为5、7、3。在mod 2 以后都冲突在 table[1] 这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小 size 大于 table 的实际大小 length 时进行扩容。接下来的三个步骤是哈希桶数组 resize 成4,然后所有的 Node 重新 rehash 的过程。

在这里插入图片描述

下面我们讲解下 JDK8 做了哪些优化。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n 为 table 的长度,图(a)表示扩容前的 key1 和 key2 两种 key 确定索引位置的示例,图(b)表示扩容后 key1 和 key2 两种 key 确定索引位置的示例,其中 hash1 是 key1 对应的哈希与高位运算结果。
在这里插入图片描述
元素在重新计算hash之后,因为 n 变为2倍,那么 n-1 的 mask 范围在高位多1bit(红色),因此新的index就会发生这样的变化:
在这里插入图片描述
因此,JDK8 在扩充 HashMap 的时候,不需要像 JDK7 的实现那样重新计算 hash值,只需要看看原来的 hash值 新增的那个 bit 是1还是0就好了,是0的话索引没变,是1的话索引变成"原索引 + oldCap",可以看看下图为16扩充为32的 resize 示意图:
在这里插入图片描述
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此 resize 的过程,均匀的把之前的冲突的节点分散到新的bucket(桶)了。这一块就是 JDK1.8 新增的优化点。有一点注意区别,JDK7 扩容的时候,旧链表迁移新表的时候,如果在新表的数组索引位置相同,则链表元素会倒置(头插法),但是从上图可以看出,JDK8 不会倒置。(尾插法) 有兴趣的同学可以研究下JDK1.8的resize源码,写的很赞,如下:

// JDK8
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;
		}
		// 没超过最大值,就扩充为原来的2倍
		else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
			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);
 	}
		
	// 计算新的resize上限
	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) {
		// 把每个bucket都移动到新的buckets中
		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 { // 链表优化重hash的代码块
					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;
						}
						// 原索引+oldCap
						else {
							if (hiTail == null)	hiHead = e;
							else   hiTail.next = e;
							hiTail = e;
						}
					} while ((e = next) != null);
					// 原索引放到bucket里
					if (loTail != null) {
						loTail.next = null;
						newTab[j] = loHead;
					}
 					// 原索引+oldCap放到bucket里
					if (hiTail != null) {
						hiTail.next = null;
						newTab[j + oldCap] = hiHead;
					}
				}
			}
		}//for
	}//if
	
	return newTab;
}

4. 面试题

4.1 特性

1、谈一下 HashMap 的特性?

  • HashMap 存储键值对实现快速存取,允许 key 和 value 任一为 null 或者都为 null。key值不可重复,若key值重复则覆盖。
  • 非同步,线程不安全。
  • 底层是 hash 表,不保证有序(比如插入的顺序)

4.2 底层原理

2、谈一下 HashMap 的底层原理是什么?

HashMap 基于 Hash 算法实现的,我们通过 put(key,value)存储,get(key)来获取 value 值。当传入 key 时,HashMap 会根据 key,调用 hash(Object key) 方法,计算出 hash 值,根据 hash 值将 value 保存在 Node 对象里,Node 对象保存在数组里。

当计算出的 hash值 相同时,我们称之为 hash冲突,HashMap 的做法是用链表和红黑树存储相同 hash值 的 value。当hash冲突的个数小于等于 8 使用链表,大于 8 时,使用红黑树解决链表查询慢的问题。

注意:

  • 上述是 JDK 1.8 HashMap 的实现原理,并不是每个版本都相同,比如 JDK 1.7 的 HashMap 是基于数组 + 链表实现,所以 hash 冲突时链表的查询效率低。
  • hash(Object key)方法的具体算法是 (h = key.hashCode()) ^ (h >>> 16),经过这样的运算,让计算的 hash 值分布更均匀。
  • HashMap 的主干是一个 Node 数组(JDK 1.7及之前为Entry数组),每一个 Node 包含一个 hash 值变量、一个 key 与 value 的键值对,与一个 next 变量,next 指向下一个 node。HashMap 由多个 Node 对象组成。

4.3 put 底层实现

3、谈一下 HashMap 中 put 是如何实现的?

  • 先判断数组是否为空或长度为0,若是则执行 r e s i z e ( ) resize() resize() 进行扩容;
  • 根据 key 计算 hash 值,得到插入的数组下标(一共三步运算:取 hashCode 值、高位参与运算、取模运算);
  • 如果没有发生碰撞,直接新建节点添加到散列表中去;
  • 如果发生了碰撞(hash值相同),进行三种判断:
    • 若首个元素和 key 值相同(即 h a s h C o d e ( ) hashCode() hashCode() 以及 e q u a l s ( ) equals() equals()相同),则直接覆盖 value;
    • 如果是红黑树结构,就调用树的插入方法;
    • 如果是链表结构,循环遍历直到链表中某个节点为空,遍历过程中若发现 key 已经存在直接覆盖 value 即可;否则尾插法进行插入,插入后判断链表个数是否大于8,若是则把链表转换为红黑树。
  • 插入成功后,判断实际存在的键值对数量 s i z e size size 是否大于最大容量 t h r e s h o l d threshold threshold,如果超过,进行扩容。

4.4 get 底层实现

4、谈一下 HashMap 中 get 是如何实现的?

根据 key 来计算 hash 值,运算得到数组下标,如果是在数组下标的首个节点上就可以找到,那直接返回;否则在树中找或者遍历链表查找。

4.5 扩容

5、谈一下 HashMap 中什么时候需要进行扩容,扩容 resize() 又是如何实现的?

调用场景:

  • 初始化数组 table
  • 数组的 s i z e size size 达到阙值 t h r e s h o l d threshold threshold

HashMap 每次扩容都是建立一个新的 table 数组,长度和容量阈值都变为原来的两倍,然后把原数组元素重新映射到新数组上,具体步骤如下:

  • 首先会判断 table 数组长度,如果大于 0 说明已被初始化过,那么按当前 table 数组长度的 2 倍进行扩容,阈值也变为原来的 2 倍;如果旧数组容量已经是最大值了,那只需把 threshold 阈值设为整数的最大值即可;
  • 若 table 数组未被初始化过,且 threshold(阈值)大于 0 说明调用了 有参构造方法,那么就把数组大小设为 threshold;
  • 若 table 数组未被初始化,且 threshold 为 0 说明调用无参构造方法,那么就把数组大小设为 16,threshold 设为 16*0.75;
  • 接着需要判断如果不是第一次初始化,那么扩容之后,要重新计算键值对的位置,并把它们移动到合适的位置上去,如果节点是红黑树类型的话则需要进行红黑树的拆分。

PS:可见底层数据结构用到了数组,到最后会因为容量问题都需要进行扩容操作

4.6 hash()

6、谈一下 HashMap 中 hash() 函数是怎么实现的?还有哪些 hash() 函数的实现方式?

实现: (h = key.hashCode()) ^ (h >>> 16),调用 key 的 hashCode() ,再与高16位做异或运算。

在这里插入图片描述

7、为什么不直接将 key 作为哈希值而是与高16位做异或运算?

因为数组位置的确定用的是与运算,仅仅最后四位有效,设计者将 key 的哈希值与高16位做异或运算,使得在做 & 运算确定数组的插入位置时,此时的低位实际是高位与低位的结合,增加了随机性,减少了哈希碰撞的次数。

8、 HashMap 默认初始化长度为16,并且每次自动扩展或者是手动初始化容量时,必须是2的幂次方。

为什么要把数组长度设计为 2 的幂次方呢?

第一,当数组长度为 2 的幂次方时,可以使用位运算来计算元素在数组中的下标。

HashMap 是通过 index=hash&(table.length-1)这条公式来计算元素在 table 数组中存放的下标,就是把元素的 hash 值和数组长度减1的值做一个与运算,即可求出该元素在数组中的下标,这条公式其实等价于 hash%length,也就是对数组长度求模取余,只不过只有当数组长度为 2 的幂次方时,hash&(length-1) 才等价于 hash%length,使用位运算可以提高效率。

第二,增加 hash 值的随机性,减少 hash 冲突。

如果 length 为 2 的幂次方,则 length-1 转化为二进制必定是 000...1...11 的形式,这样的话可以使所有位置都能和元素 hash 值做与运算,如果 length 不是 2 的次幂,比如 length 为 15,则 length-1 为 14,对应的二进制为 1110,在和 hash 做与运算时,最后一位永远都为 0 ,浪费空间。

PS:其实若不考虑效率,求余也可以,就不用位运算了,也不用长度必需为2的幂次。

HashMap 如果输入值不是2的幂比如10会怎么样?

如果创建HashMap对象时,输入的数组长度是10,不是2的幂,HashMap通过一系列 位移运算或运算 得到的肯定是2的幂次数,并且是离那个数最近的数字。

//创建HashMap集合的对象,指定数组长度是10,不是2的幂
HashMap hashMap = new HashMap(10);

public HashMap(int initialCapacity) {//initialCapacity=10
   this(initialCapacity, DEFAULT_LOAD_FACTOR);
 }
 
public HashMap(int initialCapacity, float loadFactor) {//initialCapacity=10
     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);//initialCapacity=10
}


static final int tableSizeFor(int cap) {//int cap = 10
    int n = cap - 1;  // n=9
    n |= n >>> 1;     // n=13
    n |= n >>> 2;     // n=15
    n |= n >>> 4;     // n=15
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;  // n+1=16
}

4.7 HashMap和Hashtable的区别

9、HashMap 和 Hashtable 的区别

  • 相同点:都是存储 key-value 键值对的;
  • 不同点:
区别HashMapHashtable
键值对为 null允许不允许
同步,线程安全是,通过 synchronized 加锁
父类AbstractMap 类Dictionary 类
迭代器HashMap的迭代器是fail-fast迭代器,所以当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationExceptionHashtable的enumerator迭代器不是fail-fast的
容量的初始值和增加方式HashMap默认的容量大小是16,增加容量时,每次将容量变为"原始容量x2"Hashtable默认的容量大小是11,增加容量时,每次将容量变为"原始容量x2 + 1"
添加 key-value 时的hash值算法使用自定义的哈希算法没有自定义哈希算法,而直接采用的key的hashCode()

4.8 为什么引入红黑树

10、为什么引入红黑树?

JDK 1.8 以前 HashMap 的实现是 数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有 n 个元素,遍历的时间复杂度就是 O(n),完全失去了它的优势。针对这种情况,JDK 1.8 中引入了 红黑树(查找时间复杂度为 O ( l o g n ) O(logn) O(logn))来优化这个问题。简单来说,就是为了提高查找效率。

4.9 啥类型适合作为 key

11、平时在使用 HashMap 时一般使用什么类型的元素作为Key?

选择Integer、String这种不可变的类型,这些类已经很规范的覆写了 hashCode() 以及 equals() 方法,作为不可变类天生是线程安全的。

如果自定义对象做为 Map 的键,那么必须重写 hashCode 和 equals;

4.10 JDK1.8对 HashMap 的改进

  • 底层结构: JDK1.7 底层采用 entry数组+链表 的数据结构,而 JDK1.8 采用 node数组+链表或红黑树 的数据结构。
  • 插入新值: JDK1.7 的 HashMap 插入新值时使用头插法,JDK1.8 使用尾插法。使用头插法比较快,但在多线程扩容时会引起倒序和闭环的问题,所以 1.8 就采用了尾插法。
  • 扩容时计算元素在新表中的位置: JDK1.7 扩容时,是将旧表元素的所有数据重新进行哈希计算,即hashCode & (length-1)。而 JDK1.8 扩容时,只需将 hashCode老数组长度与运算,判断原来的 hashCode 新增的那个 bit 位是0还是1,是0的话索引不变,是1的话索引变为 老索引位置+老数组长度
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值