对HashMap有什么了解吗?

1.HashMap的数据结构

在JDK1.7和JDK1.8有所差别:
在JDK1.7中,由“数组+链表”组成,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的。
在JDK1.8中,由“数组+链表+红黑树”组成。当链表过长,则会严重影响HashMap的性能,红黑树搜索时间复杂度是O(logn),而链表则是糟糕的O(n)。因此JDK1.8对数据结构做了进一步的优化,引入了红黑树,链表和红黑树达到一定条件会进行转换。

当链表超过8且数据量超过64才会转换红黑树。
将链表转换成红黑树前会判断,如果当前数组的长度小于64,那么会选择先进行数组扩容,而不是转换为红黑树,以减少搜索时间。

在这里插入图片描述

2.Hash冲突的解决方案是什么?

2.1插入方式

解决Hash冲突的方法有开放定址法、再哈希法、链地址法、建立公共溢出区。
HashMap中采取的是链地址法。
JDK1.7时,哈希冲突时采取的是头插法
JDK1.8是,哈希冲突时采取的是尾插法

  • 开放定址法也称为再散列法,基本思想就是,如果pH(key)出现冲突时,则以p为基础,再次hash,p1H§,如果p1再次出现冲突,则以p1为基础,以此类推,直到找到一个不冲突的哈希地址p1,所以只能在删除的节点上做标记,而不能真正删除节点。
  • 再哈希法(双重散列、多重散列),提供多个不同的hash函数,当R1=H1(key1)发生冲突时,再计算R2==H2(key1),直到没有冲突为止。这样做虽然不一产生堆集,但增加了计算的时间。
  • 链地址法(拉链法):将哈希值相同的元素构成一个同义词的单链表,并将单链表的头指针存放在哈希表的第i个单元中,查找、插入和删除主要在同义词链表中进行。链表法适用于经常进行插入和删除的情况。
  • 建立公共溢出区:将哈希表分为公共表和溢出表,当溢出发生时,将所有溢出数据统一放到溢出区。

2.2为什么在解决hash冲突时,不直接使用红黑树?而选择先用链表,再转红黑树?

因为红黑树需要进行左旋、右旋,变色这些操作来保持平衡,而单链表不需要。当元素小于8个的时候,此时做查询操作,链表已经能够保证性能。当元素大于8个的时候,红黑树搜索时间复杂度是O(logn),而链表是O(n),此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。
因此,如果一开始就使用红黑树结构,元素太少,新增效率又比较慢,无疑这是浪费性能的。

3.HashMap的扩容机制

3.1HashMap的默认加载因子是多少?为什么是0.75,不是0.6或者0.8

	int threshold;//容纳键值对的最大值
	final float loadFactor;//负载因子
	int modCount;
	int size;

threshold = length*loadFactor
默认的loadFactor是0.75,0.75是对空间和时间效率的一个平衡选择,一般不要修改,除非在时间和空间比较特殊的情况下:

  • 如果内存空间很多而对时间效率要求很高,可以降低负载因子loadfactor的值
  • 相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。

3.2HashMap中的key的存储索引是怎样计算的?

首先根据key的值计算出HashCode的值,然后根据HashCode值计算出hash值,最后通过hash&(length-1)计算得到存储的位置。

3.3HashMap的put方法流程

以JDK1.8为例:

  1. 首先根据key的值计算hash值,找出该元素在数组中存储的下标。
  2. 如果数组是空的,则调用resize进程初始化
  3. 如果没有哈希冲突直接放在对应的数组下标里
  4. 如果冲突了,且key已经存在,就覆盖掉value
  5. 如果冲突后,key不存在,并且发现该节点是红黑树,就将这个节点挂在树上、
  6. 如果冲突后是链表,判断该链表是否大于8,如果大于8并且数组容量小于64,就进行数组扩容;如果链表节点大于8并且数组的容量大于64,则将这个结构转换为红黑树;
    在这里插入图片描述

3.4HashMap的扩容方式

HashMap在容量超过负载因子所定义的容量之后,就会扩容。Java里的数组是无法自动扩容的,方法是将HashMap的大小扩大为原来数组的两倍,并将原来的对象放入新的数组中。

//JDK1.7
void resize(int newCapacity){
	//传入新的容量
	//引用扩容前的Entry数组
	Entry[] oldTable = table;
	int oldCapacity = oldTable.length;
	//扩容前的数组大小如果已经达到最大值2^30了
	if(oldCapacity==MAXIMUM_CAPACITY){
		//修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
		threshold = Integer.MAX_VALUE;
		return;
	}
	//初始化一个新的Entry数组
	Entry[]newTable = new Entry[newCapacity];
 	//将数据转移到新的Entry数组里
 	transfer(newTable);
 	//HashMap的table属性引用新的Entry数组
 	tbale = newTable;
 	//修改阈值
 	threshold = (int)(newCapacity*loadFactor);
} 
//这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里
void transfer(Entry[]newTable){
	//src引用了旧的Entry数组
	Entry[]src = table;
	int newCapacity = newTable.length;
	for(int  j = 0;j<src.length;j++){
		//遍历旧的Entry数组
		//取得旧Entry数组中的每个元素
		Entry<K,V> e = src[j];
		if(e!=null){
			//是否旧数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
			src[j] = null;
			do{
				Entry<K,V> next = e.next;
				//重新计算每个元素在数组中的位置
				int i = indexFor(e.hash,newCapacity);
				//标记[1]
				e.next = newTable[i];
				//将元素放在数组上
				newTable[i] = e;
				//访问下一个Entry链上的元素
				e = next;
			}while(e!=null);
		}
	}
}
//newTable[i]的引用赋给了e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部;这样先放在一个索引上的元素终会被放到Entry链的尾部(如果发生了hash冲突的话)。

JDK1.8做了两处优化:

  1. resize之后,元素的位置在原来的位置,或者原来的位置+oldCap(原来哈希表的长度)。不需要像JDK1.7那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,如果是0索引没变,是1的话索引变成原索引+oldCap。这个设计非常巧妙,省去了重新计算hash值的时间。
  2. JDK1.7时候出现哈希冲突的时候会采用头插法,而JDK1.8会采用尾插法。

3.5一般用什么作为HashMap的key

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

  • 因为字符串是不可变的,所以它创建的时候hashcode就被缓存了,不需要重新计算,这就是HashMap中的键往往都是用字符串的原因。
  • 因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,所以这些类已经很规范的重写了hashCode()以及equals()方法。

4.HashMap的线程安全性

  1. 多线程下扩容死循环。
    JDK1.7中的HashMap使用头插法插入元素,在多线程的环境下,扩容的时候有可能会导致环形链表的出现,形成死循环。因此JDK1.8使用尾插法插入元素,在扩容时会保持原本的顺序,不会出现环形链表的情况。
  2. 多线程的put可能导致元素的丢失。
    多线程同时执行put操作,如果计算出来的索引位置是相同的那会造成前一个key被后一个key覆盖,从而导致元素的丢失。在JDK1.7和1.8中都存在。
  3. put和get并发时,可能导致get为nul。
    线程1执行put时,因为元素个数超出threshold而导致rehash,线程2此时执行get,有可能导致这个问题。此问题JDK1.7和JDK1.8中都存在。

解决方案:
1、使用ConcurrentHashMap

2、使用Collections.synchonizedMap(Map<K,v> m)方法将HashMap变成一个线程安全的map。

5.为什么是2的幂次方

  1. 效率:
    取余操作%如果除数是2的幂次方等价于其除数减一的与&操作。
    也就是hash%length==hash&(length-1)的前提是length是2的n次方。
    并且采用二进制为操作&,相对于%能够提高运算效率。
  2. 均匀分布

6.为什么重写equals()必须重写HashCode()

equals()本质是比较两个对象的地址值。如果重写了equals()方法,那么就可以达到比较两个不同地址值的对象具有完全相同的内容。
而HashCode()本质上是对对象地址值的hash运算,相同的地址值会出现相同的hash值,而不同对象会出现不同的哈希值。
因为两个对象相等的话,他们的哈希值肯定是相同的。
所以需要对HashCode方法进行重写。

评论 13
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值