HashMap深度分析详解

1.HashMap的实现原理?

HashMap采用Entry数组来存储key-value对,每一个键值对组成了一个Entry实体,Entry类实际上是一个单向的链表结构,它具有Next指针,可以连接下一个Entry实体。只是在JDK1.8中,链表长度大于8的时候,链表会转成红黑树!

hashmap实现原理图

1.1 为什么用数组+链表?
数组是用来确定桶的位置,利用元素的key的hash值对数组长度取模得到
链表是用来解决hash冲突问题,当出现hash值一样的情形,就在数组上的对应位置形成一条链表。
这里的hash值并不是指hashcode,而是将hashcode高低十六位异或过的。

1.2 我用LinkedList代替数组结构可以么?

源码中是这样的

Entry[] table = new Entry[capacity];

Entry就是一个链表节点。
那我用下面这样表示

List<Entry> table = new LinkedList<Entry>();  

答案很明显,必须是可以的。

既然是可以的,为什么HashMap不用LinkedList,而选用数组?
因为用数组效率最高!

在HashMap中,定位桶的位置是利用元素的key的哈希值对数组3长度取模得到。此时,我们已得到桶的位置。显然数组的查找效率比LinkedList大。

那ArrayList,底层也是数组,查找也快啊,为啥不用ArrayList?

因为采用基本数组结构,扩容机制可以自己定义,HashMap中数组扩容刚好是2的次幂,在做取模运算的效率高。

2. HashMap的扩容?

HashMap的默认大小空间是16,负载因子是0.75

在HashMap中,临界值(threshold) = 负载因子(loadFactor) * 容量(capacity)。

扩容机制(即 resize()函数方法)

扩容机制即 resize 流程图

2.1 为什么要扩容

HashMap在扩容到过程中不仅要对其容量进行扩充,还需要进行rehash!所以,这个过程其实是很耗时的,并且Map中元素越多越耗时。

rehash的过程相当于对其中所有的元素重新做一遍hash,重新计算要分配到那个桶中。

那么,有没有人想过一个问题,既然这么麻烦,为啥要扩容?HashMap不是一个数组链表吗?不扩容的话,也是可以无限存储的呀。为啥要扩容?

这其实和哈希碰撞有关。

哈希碰撞

HashMap其实是底层基于哈希函数实现的,哈希函数都有如下一个基本特性:根据同一哈希函数计算出的哈希值如果不同,那么输入值肯定也不同。但是,根据同一哈希函数计算出的哈希值如果相同,输入值不一定相同。

两个不同的输入值,根据同一哈希函数计算出的哈希值相同的现象叫做碰撞。
衡量一个哈希函数的好坏的重要指标就是发生碰撞的概率以及发生碰撞的解决方案。

如果一个HashMap中冲突太高,那么数组的链表就会退化为链表。这时候查询速度会大大降低。 所以,为了保证HashMap的读取的速度,我们需要想办法尽量保证HashMap的冲突不要太高。

那么如何能有效的避免哈希碰撞呢?

在合适的时候扩大数组容量,再通过一个合适的hash算法计算元素分配到哪个数组中,就可以大大的减少冲突的概率。就能避免查询效率低下的问题。

2.2 为什么默认loadFactor是0.75

这个值现在在JDK的源码中是0.75:

/**
 * The load factor used when none specified in constructor.
 */

static final float DEFAULT_LOAD_FACTOR = 0.75f;

那么,为什么选择0.75呢?背后有什么考虑?为什么不是1,不是0.8?不是0.5,而是0.75呢?

在JDK的官方文档中,有这样一段描述描述:

As a general rule, the default load factor (.75) offers a good tradeoff between time and space costs. Higher values decrease the space overhead but increase the lookup cost (reflected in most of the operations of the HashMap class, including get and put).

一般来说,默认的负载因子(0.75)在时间和空间成本之间提供了很好的权衡。更高的值减少了空间开销,但增加了查找成本(反映在HashMap类的大多数操作中,包括get和put)。

0.75的数学依据

我们假设一个bucket空和非空的概率为0.5,我们用s表示容量,n表示已添加元素个数。
用s表示添加的键的大小和n个键的数目。根据二项式定理,桶为空的概率为:

P(0) = C(n, 0) * (1/s)^0 * (1 - 1/s)^(n - 0)

因此,如果桶中元素个数小于以下数值,则桶可能是空的:

log(2)/log(s/(s - 1))

当s趋于无穷大时,如果增加的键的数量使P(0) = 0.5,那么n/s很快趋近于log(2):

log(2) ~ 0.693...

所以,合理值大概在0.7左右。

0.75的必然因素:为了保证负载因子(loadFactor) * 容量(capacity)的结果是一个整数,这个值是0.75(3/4)比较合理,因为这个数和任何2的幂乘积结果都是整数

所以负载因子是1的话那么就会有很高的哈希冲突的概率,会大大降低查询速度。
如果是0.5的话那么频繁扩容没,就会大大浪费空间。
所以,这个值需要介于0.5和1之间。根据数学公式推算。这个值在log(2)的时候比较合理。
另外,为了提升扩容效率,HashMap的容量(capacity)有一个固定的要求,那就是一定是
2的幂
。所以,如果loadFactor是3/4的话,那么和capacity的乘积结果就可以是一个整数

2.3 为什么扩容是2的次幂?

HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法;这个算法实际就是取模,hash%length。

我们来看一下indexFor方法

static int indexFor(int h, int length) {

    return h & (length-1);

}

indexFor方法其实主要是将hashcode换成链表数组中的下标。其中的两个参数h表示元素的hashcode值,length表示HashMap的容量。那么return h & (length-1) 是什么意思呢?

其实,他就是取模。Java之所有使用位运算(&)来代替取模运算(%),最主要的考虑就是效率

位运算(&)效率要比代替取模运算(%)高很多,主要原因是位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。

如果不太清楚位运算的可以看看,可以看看位运算 这篇文章,里面有详细的介绍。

实现的原理如下:

X % 2^n = X & (2^n – 1)

举个例子

6 % 8 = 6 ,6 & 7 = 6
10 & 8 = 2 ,10 & 7 = 2

运算过程如下如:

与运算图

所以,因为位运算直接对内存数据进行操作,不需要转成十进制,所以位运算要比取模运算的效率更高,所以HashMap在计算元素要存放在数组中的index的时候,使用位运算代替了取模运算。之所以可以做等价代替,前提是要求HashMap的容量一定要是2^n 。

那么,既然是2^n ,为啥一定要是16呢?为什么不能是4、8或者32呢?

太小了就有可能频繁发生扩容,影响效率。太大了又浪费空间,不划算。
所以,16就作为一个经验值被采用了。

在JDK 8中,关于默认容量的定义为:static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 ,其故意把16写成1<<4,就是提醒开发者,这个地方要是2的幂。值得玩味的是:注释中的 aka 16 也是1.8中新增的

2.4 怎么指定容量初始化的

	/**
     * 分析1:tableSizeFor(initialCapacity)
     * 作用:将传入的容量大小转化为:>传入容量大小的最小的2的幂
     * 与JDK 1.7对比:类似于JDK 1.7 中 inflateTable()里的 roundUpToPowerOf2(toSize)
     */
  static final int tableSizeFor(int cap) {
		//传进来的大小 先减去1,在去做下面的操作,可以避免如果是2^n的数
		int n = cap - 1;
		// 1+2+4+8+16 = 31  而1 << 30 最高位也就30,我左移31位
		// 它的二进制必定都会是1(因为或运算+右移),最后再加上一个1就都变成了0(除了最高项)
		n |= n >>> 1;
		n |= n >>> 2;
		n |= n >>> 4;
		n |= n >>> 8;
		n |= n >>> 16;
		// 如果 (n < 0) ? 1  ===> n < 0 则是1
		// 否则如果 (n >= 1 << 30) ? 1 << 30  ===> n 大于  1 << 30  就左移 1 << 30
		// 否则 n+1 因为除了0和大于1<<31的数
		// 那么这个数肯定在0到1<<31之间,而在这中间的数都被右移变成了1,最后加上1,就都变成了0
		// 所以,不管怎么样他都会变成是2的n次方,这样在后面的很多操作中就变得既简便又快速(完美的衔接)
//		if(n < 0){
//			return 1;
//		}
//		else if(n >= 1 << 30){
//			return 1 << 30;
//		}else{
//			return n + 1;
//		}
		return (n < 0) ? 1 : (n >= 1 << 30) ? 1 << 30 : n + 1;
	}

其实是对一个二进制数依次向右移位,然后与原值取或。其目的对于一个数字的二进制,从第一个不为0的位开始,把后面的所有位都设置成1。

随便拿一个二进制数,套一遍上面的公式就发现其目的了:

1100 1100 1100 >>>1 = 0110 0110 0110

1100 1100 1100 | 0110 0110 0110 = 1110 1110 1110

1110 1110 1110 >>>2 = 0011 1011 1011

1110 1110 1110 | 0011 1011 1011 = 1111 1111 1111

1111 1111 1111 >>>4 = 1111 1111 1111

1111 1111 1111 | 1111 1111 1111 = 1111 1111 1111

通过几次无符号右移和按位或运算,我们把1100 1100 1100转换成了1111 1111 1111 ,再把1111 1111 1111加1,就得到了1 0000 0000 0000,这就是大于1100 1100 1100的第一个2的幂。

总之,HashMap根据用户传入的初始化容量,利用无符号右移和按位或运算等方式计算出第一个大于或等于该数的2的幂。

2.5 为什么要先高16位异或低16位再取模运算?

HashMap之所以这样做是为了降低hash冲突的几率

在JDK8中,由于使用了红黑树来处理大的链表开销,所以hash这边可以更加省力了,只用计算hashCode并移动到低位就可以了。

	static final int hash(Object key) {
	    int h;
	    //计算hashCode,并无符号移动到低位
	    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

举个例子: 363771819^(363771819 >>> 16)

0001 0101 1010 1110 1011 0111 1010 1011(363771819)
0000 0000 0000 0000 0001 0101 1010 1110(5550) XOR
--------------------------------------- =
0001 0101 1010 1110 1010 0010 0000 0101(363766277)

可以看到,无符号右移之后在异或,碰撞的几率就大大的减少了。

3. HashMap的get/put的过程?

3.1 HashMap中put元素的过程是怎么样的?

put(K key, V value)方法是将指定的 key, value 对添加到 map 里。该方法首先会对 map 做一次查找,看是否包含该 K,如果已经包含则直接返回;如果没有找到,则将元素插入容器
HashMap之put图

这个是在jdk1.8之后做的修改,当链表过长的时候就转换为红黑树。

具体执行步骤:

  1. 判断键值对数组 table[i]是否为空或为 null,否则执行 resize()进行扩容
  2. 根据键值 key 计算 hash 值得到插入的数组索引 i,如果 table[i]==null,直接新建节点添加
  3. 当 table[i]不为空,判断 table[i]的首个元素是否和传入的 key 一样,如果相同直接覆盖 value;
  4. 判断 table[i] 是否为 treeNode,即 table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对;
  5. 遍历 table[i],判断链表长度是否大于 8,大于 8 的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;当红黑树的个数小于6的时候就转换为链表;这样可以提高性能;遍历过程中若发现 key 已经存在直接覆盖 value 即可;
  6. 插入成功后,判断实际存在的键值对数量 size 是否超多了最大容量 threshold,如果超过,进行扩容操作

3.2 HashMap中get元素的过程是怎么样的?

get(Object key)方法根据指定的 key 值返回对应的 value,getNode(hash(key), key) 得到相应的 Node 对象 e,然后返回 e.value。因此 getNode()是算法的核心。

jdk1.8get方法图
get 方法,首先通过 hash()函数得到对应数组下标,然后依次判断。

  1. 判断第一个元素与 key 是否匹配,如果匹配就返回参数值;
  2. 判断链表是否红黑树,如果是红黑树,就进入红黑树方法获取参数值;
  3. 如果不是红黑树结构,直接循环判断,直到获取参数为止;

3.3 说说String中hashcode的实现?

public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;

        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

String类中的hashCode计算方法还是比较简单的,就是以31为权,每一位为字符的ASCII值进行运算,用自然溢出来等效取模。

哈希计算公式可以计为s[0]31^(n-1) + s[1]31^(n-2) + … + s[n-1]

那为什么以31为质数呢?

主要是因为31是一个奇质数,所以31i=32i-i=(i<<5)-i,这种位移与减法结合的计算相比一般的运算快很多。

4. HashMap1.7和1.8比较

4.1 jdk1.8之后改动了什么?

  • 数组+链表的结构改为数组+链表+红黑树
  • 1.7前是用的头插法,1.8后用的是尾插法,1.7之前是一个Entry节点,在1.8变成了一个Node节点
  • 优化了高位运算的hash算法:h^(h>>>16)
  • 扩容后,元素要么是在原位置,要么是在原位置再移动2次幂的位置,且链表顺序不变。

最后一条是重点,因为最后一条的变动,HasMap在1.8中,不会在出现死循环问题。因为在1.7之前用的是头插法,在扩容的时候回调用一个resize的方法,在resize方法中有调了transfer的方法,将里面的Entry进行了一个rehash的方法,在这当中可能会造成一个链表的循环。所以可能在get的时候会出现死循环。另外可能没有加锁,在多并发的情况下,数据可能是不准确的

1.7和1.8的比较图

5. 你一般用什么作为HashMap的key?
  1. 你一般用什么作为HashMap的key?

    一般用Integer、String这种不可变类当HashMap当key,而且String最为常用

    1. 因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
    2. 因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的覆写了hashCode()以及equals()方法。
  2. 健可以为Null值么?

    必须可以,key为null的时候,hash算法最后的值以0来计算,也就是放在数组的第一个位置。

  3. 我用可变类当HashMap的key有什么问题?

    hashcode可能发生改变,导致put进去的值,无法get出,如下所示

HashMap<List<String>, Object> changeMap = new HashMap<>();
List<String> list = new ArrayList<>();
list.add("hello");
Object objectValue = new Object();
changeMap.put(list, objectValue);
System.out.println(changeMap.get(list));
list.add("hello world");//hashcode发生了改变
System.out.println(changeMap.get(list));

输出值如下

java.lang.Object@74a14482
null
  1. 如果让你实现一个自定义的class作为HashMap的key该如何实现?

此题考察两个知识点

  • 重写hashcode和equals方法注意什么?

  • 如何设计一个不变类

针对问题一,记住下面四个原则即可

  1. 两个对象相等,hashcode一定相等
  2. 两个对象不等,hashcode不一定不等
  3. hashcode相等,两个对象不一定相等
  4. hashcode不等,两个对象一定不等

针对问题二,记住如何写一个不可变类

  1. 类添加final修饰符,保证类不被继承。
    如果类可以被继承会破坏类的不可变性机制,只要继承类覆盖父类的方法并且继承类可以改变成员变量值,那么一旦子类以父类的形式出现时,不能保证当前类是否可变。

  2. 保证所有成员变量必须私有,并且加上final修饰
    通过这种方式保证成员变量不可改变。但只做到这一步还不够,因为如果是对象成员变量有可能再外部改变其值。所以第4点弥补这个不足。

  3. 不提供改变成员变量的方法,包括setter
    避免通过其他接口改变成员变量的值,破坏不可变特性。

  4. 通过构造器初始化所有成员,进行深拷贝(deep copy)
    如果构造器传入的对象直接赋值给成员变量,还是可以通过对传入对象的修改进而导致改变内部变量的值。例如:

public final class ImmutableDemo {  
    private final int[] myArray;  
    public ImmutableDemo(int[] array) {  
        this.myArray = array; // wrong  
    }  
}

这种方式不能保证不可变性,myArray和array指向同一块内存地址,用户可以在ImmutableDemo之外通过修改array对象的值来改变myArray内部的值。

为了保证内部的值不被修改,可以采用深度copy来创建一个新内存保存传入的值。正确做法:

public final class MyImmutableDemo {  
    private final int[] myArray;  
    public MyImmutableDemo(int[] array) {  
        this.myArray = array.clone();   
    }   
}
  1. 在getter方法中,不要直接返回对象本身,而是克隆对象,并返回对象的拷贝
    这种做法也是防止对象外泄,防止通过getter获得内部可变成员对象后对成员变量直接操作,导致成员变量发生改变。
6. HashMap的并发问题?

此题可以组成如下连环炮来问

  • HashMap在并发编程环境下有什么问题啊?
  • 在jdk1.8中还有这些问题么?
  • 你一般怎么解决这些问题的?
  1. HashMap在并发编程环境下有什么问题啊?
    (1)多线程扩容,引起的死循环问题
    (2)多线程put的时候可能导致元素丢失
    (3)put非null元素后get出来的却是null

  2. 在jdk1.8中还有这些问题么?

在jdk1.8中,死循环问题已经解决。其他两个问题还是存在。

  1. 你一般怎么解决这些问题的?

比如ConcurrentHashmap,Hashtable等线程安全等集合类。

  1. 为什么选择ConcurrentHashMap

因为ConcurrentHashMap它的并发度是更高的。就普通的HashTable是直接对里面的方法进行了一个Synchronized的对象锁,但是在1.8之后ConcurrentHashMap同样是变成了一个数组+链表+红黑树。他只会锁住我目前所在的那个Entry节点的一个值。在上锁的时候是使用了 CAS Synchronized ,在jdk1.6之后对Synchronized的一个优化升级的过程,所以他的效率是更高的,所支持的并发度是最高的。

  1. 简单介绍一下锁升级的过程吧

在最开始的时候,是无锁的状态,会先判断一下这个当前的锁。锁是有支持偏向锁的,当前获得锁资源的这个线程,会让他获得这个锁,如果没有获取到这个锁,就升级成一个轻量级的乐观锁,如果这个锁没有设置成功的话会进行一个自旋的过程,自旋到一定的次数就会升级为一个Synchronized重量级的锁,这样就保证了一个性能的问题。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值