HashMap

HashMap数据结构基础是Entry数组存储key-value键值对,Entry类实际上是具有Next指针的单向链表结构,可以连接下一个Entry实体。这些具有next指针的Entry数组一起组成了一个bucket桶,而这些桶又存储在主干Table数组中,向其添加元素时通过计算key的hash值来确定具体bucket桶在table数组中的位置。添加元素过程中若bucket桶出现hash冲突,也就是N个元素key的hash值相等,处理方式为:将桶内数组元素转为链表结构存储,只是在JDK1.8中,链表长度大于8的时候(TREEIFY_THRESHOLD(树状阈值)= 8),链表会转成红黑树,此转化过程称为Hashmap 树化

HashMap 4 种构造方法

  1. 指定容量(initialCapacity) 和装载因子(loadFactor),容量大于等于0,当为0时添加容量会resize(),当超过最大值自动设置为最大值2∧30。装载因子不可小于0或非数字

  2. 创建一个指定容量的 HashMap,装载因子使用默认的 0.75;初始化大小为 大于容量k 2的n次方(例如如果传10,大小为16(2的4次方) )

  3. 创建一个默认初始值的 HashMap ,容量为16,装载因子为0.75

  4. 创建一个 Hashmap 并将 Map m内包含的所有元素存入(底层调用putMapEntries(m,false)方法)

 

HashMap.put()

put()调用 putVal() 方法

1.如果tab数组为空,先通过 resize() 初始化tab数组;
    不为空,计算 key 的 hash 值,通过 hash&(length-1)。计算应当存放在tab数组中的下标 index定位到桶所在的位置;
2.查看 tab[index] 是否存在数据,
若数组对应位置为空,就构造一个新Node节点存放在 table[index] 中;
若数组对应位置不为空,需要处理hash冲突(即存在二个节点key的hash值一样),继续判断key是否相等;
    如果相等,直接覆盖旧值value;
    如果不相等,判断当前节点类型是不是树型节点,如果是树型节点,创造树型节点插入红黑树中;
    如果不是树型节点,创建普通Node加入链表末端;添加后判断链表长度是否达到阈值8,"尝试"将链表树化为红黑树;
3.最后更新数据的同时更新修改次数,检查前节点数是否大于阈值,如果大于开始扩容为原数组的二倍。

 

HashMap.get()

get()调用getNode()方法
1 将table赋值给变量tab数组并判断非空 && tab 的长度大于0 && 通过位运算得到求模结果确定链表的首节点赋值并判断非空
1.1 判断首节点hash值 && 判断key的hash值(地址相同 || equals相等)均为true则表示first即为目标节点直接返回
1.2 若首节点非目标节点,且还有后续节点时,则继续向后寻找
1.2.1 树:判断此节点是否为树的节点,是的话遍历树结构查找节点,查找结果可能为null
1.2.2 链表:若此节点非树节点,说明它是链表,遍历链表查找节点,查找结果可能为null

 

HashMap.resize()

1.1 把当前table数组赋值给oldTab,为数据迁移工作做准备
1.2 将数组的大小赋值给oldCap
1.3 将当前阈值赋值给oldThr (公式:容量(newCap)* 负载系数(loadFactor))
1.4 定义并初始化 新数组的大小(newCap)和新阈值(newThr)
1.5 判断是初始化数组还是扩容,
    <1>如果原数组大小oldCap > 0,表示数组需要扩容,
        原数组 >= 最大容量(oldCap >= MAXIMUM_CAPACITY),如已达到将阈值设置为最大;
        原数组 < 最大容量,则新数组为原数组大小2倍,新阈为原阈值2倍
    <2>如果原数组大小oldCap <= 0,表示table数组需要初始化,
        阈值oldThr > 0,将当前阈值作为新数组大小(newCap = oldThr),说调用HashMap的有参构造函数;
        阈值oldThr <= 0,将默认初始容量作为新数组大小(newCap = DEFAULT_INITIAL_CAPACITY),默认初始容量(newCap)和默认负载系数(loadFactor)的乘积作为新阈值,说明调用的是HashMap的无参构造函数
1.6 当新阈值newThr == 0时,需重新计算新阈值,
1.7 初始化newTab数组,判断原数组oldTab内是否有存储数据,有的话循环迁移数据,
    若节点是单个节点,直接在newTab中进行重定位
    若节点是TreeNode节点,要进行 红黑树的 rehash 操作
    若是链表,进行链表的 rehash 操作
        根据算法 e.hash & oldCap 判断节点位置rehash 后是否发生改变
            最高位==0,这是索引不变的链表。
            最高位==1 (这是索引发生改变的链表)
​

 

 


为什么用数组+链表?

数组是用来确定桶的位置,利用元素的key的hash值对数组长度取模得到.

链表是用来解决hash冲突问题,当出现hash值一样的情形,就在数组上的对应位置形成一条链表。

ps:这里的hash值并不是指hashcode,而是将hashcode高低十六位异或过的。

ps:可以用LinkedList代替Entry数组结构(Entry就是一个链表节点),

为什么HashMap选用数组,而不用LinkedList

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

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

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


hash冲突你还知道哪些解决办法?

先说一下hash算法干嘛的,Hash函数是指把一个大范围映射到一个小范围。把大范围映射到一个小范围的目的往往是为了节省空间,使得数据容易保存。比较出名的有MurmurHash、MD4、MD5等等

比较出名的有四种(1)开放定址法(2)链地址法(3)再哈希法(4)公共溢出区域法


HashMap的hash怎么设计的吗?

hash函数是先拿到通过key 的hashcode,是32位的int值,然后让hashcode的高16位和低16位进行异或操作。

 

补充:String中hashcode的实现

以奇质数31为权,乘以hash(初始化为0),加上每一位为字符的ASCII值,用自然溢出来等效取模。

源码:
​
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;
}

假设字符串msg =“ abcd”; //此时value [] = {'a','b','c','d'}因此

for循环会执行4次

第一次:h = 31 * 0 + a = 97

第二次:h = 31 * 97 + b = 3105

第三次:h = 31 * 3105 + c = 96354

第四次:h = 31 * 96354 + d = 2987074

由以上代码计算可以算出msg的hashcode = 2987074

在源码的hashcode的注释中还提供了一个多个式计算方式:

s [0] * 31 ^(n-1)+ s [1] * 31 ^(n-2)+ ... + s [n-1]

s [0]:表示字符串中指定下标的字符

n:表示字符串中字符长度

a * 31 ^ 3 + b * 31 ^ 2 + c * 31 ^ 1 + d = 2987074 + 94178 + 3069 + 100 = 2987074;


为什么采用hashcode的高16位和低16位 异或 能降低hash碰撞?hash函数能不能直接用key的hashcode?

将hashcode高位和低位的值进行混合做异或运算,而且混合后,低位的信息中加入了高位的信息,这样高位的信息被变相的保留了下来。掺杂的元素多了,那么生成的hash值的随机性会增大。同时不会有太大的开销

因为key.hashCode()函数调用的是key键值类型自带的哈希函数,返回int型散列值。而int值范围为-2147483648~2147483647,前后加起来大概40亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。你想,如果HashMap数组的初始大小才16,用之前需要对数组hash与长度取模运算(hash%length),得到的余数才能用来访问数组下标。源码中模运算就是把 散列值和 数组长度-1 进行&运算,位运算比%运算要快。

bucketIndex = indexFor(hash, table.length); static int indexFor(int h, int length) { return h & (length-1); }

 

与运算符(&) 两个操作数中位都为1,结果才为1,否则结果为0

或运算符(|) 两个位只要有一个为1,那么结果就是1,否则就为0

异或运算符(^) 相同则结果为0,不同则结果为1

非运算符(~) 如果位为0,结果是1,如果位为1,结果是0

0为false,1为true

 

HashMap在什么条件下扩容?

如果桶bucket满了(超过load factor<负载因子0.75> * current capacity<当前数组大小>),就要resize。

为什么扩容是2的次幂?

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

但是,这种运算不如位移运算快。

因此,源码中做了优化hash&(length-1)。

也就是说hash%length==hash&(length-1)

那为什么是2的n次方呢?

因为2的n次方实际就是1后面n个0,2的n次方-1,实际就是n个1。

例如长度为8时候,3&(8-1)=3 2&(8-1)=2 ,不同位置上,不碰撞。

而长度为5的时候,3&(5-1)=0 2&(5-1)=0,都在0上,出现碰撞了。

所以,保证容积是2的n次方,是为了保证在做(length-1)的时候,每一位都能&1 ,也就是和1111……1111111进行与运算。

顺便说一下,这也正好解释一下构造方法中为什么HashMap的 数组长度 要取2的n次幂。这里就要提到tableSizeFor()方法的主要功能是返回一个比给定整数大且最接近的2的幂次方整数,如给定10,返回2的4次方16.

 

因为这样(数组长度-1)正好相当于一个“低位掩码”。“&”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度16为例,16-1=15。2进制表示是00000000 00000000 00001111。和某散列值做“与”操作如下,结果就是截取了最低的四位值。

10100101 11000100 00100101

& 00000000 00000000 00001111

00000000 00000000 00000101 //高位全部归零,只保留末四位

但这时候问题就来了,这样就算我的散列值分布再松散,要是只取最后几位的话,碰撞也会很严重。更要命的是如果散列本身做得不好,分布上成等差数列的漏洞,如果正好让最后几个低位呈现规律性重复,就无比蛋疼。

时候“扰动函数”的价值就体现出来了,看下面这个图,

 

右位移16位,正好是32bit的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。


1.8相比之前还有那些主要的优化?

1.数组+链表改成了数组+链表或红黑树; 2.链表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,1.7将新元素放到数组中,原始节点作为新节点的后继节点,1.8遍历链表,将元素放置到链表的最后; 3.扩容的时候1.7需要对原数组中的每个元素进行重新hash定位在新数组的位置,1.8对此进行了一些优化组长度是通过2的次方扩充的,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置 4.在插入时,1.7先判断是否需要扩容,再插入,1.8先插入,插入完成再判断是否需要扩容;

为什么要做这几点优化?

1.防止发生hash冲突,链表长度过长,将时间复杂度由O(n)降为O(logn);

2.因为1.7头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环;A线程在插入节点B,B线程也在插入,遇到容量不够开始扩容,重新hash,放置元素,采用头插法,后遍历到的B节点放入了头部,这样形成了环,如下图所示:

3.扩容的时候为什么1.8 不用重新hash就可以直接定位原节点在新数据的位置呢?

这是由于扩容是扩大为原数组大小的2倍,那么n-1的掩码高位仅仅只是多了一个1,因此扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+原数组大小oldCap”,这个设计既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize()的过程,均匀的把之前的冲突的节点分散到新的桶bucket了。这一块就是JDK1.8新增的优化点。

有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置

4.在插入时,1.7先判断是否需要扩容,再插入,1.8先插入,插入完成再判断是否需要扩容;

扩容前长度为16,用于计算(n-1) & hash 的二进制n-1为0000 1111,扩容为32后的二进制就高位多了1,为0001 1111。

因为是& 运算,1和任何数 & 都是它本身,那就分二种情况,如下图:原数据hashcode高位第4位为0和高位为1的情况;

第四位高位为0,重新hash数值不变,第四位为1,重新hash数值比原来大16(旧数组的容量)

 


HashMap是线程安全的吗?

不是,在多线程环境下,1.7 会产生死循环、数据丢失、数据覆盖的问题,1.8 中会有数据覆盖的问题,以1.8为例,当A线程判断index位置为空后正好挂起,B线程开始往index位置的写入节点数据,这时A线程恢复现场,执行赋值操作,就把B线程的数据给覆盖了;还有++size这个地方也会造成多线程同时扩容等问题。

怎么解决这个线程不安全的问题?

1.HashTable是直接在操作方法上加synchronized关键字,锁住整个数组,粒度比较大,所有同步操作使用的是同一个锁对象。这样若有n个线程同时在get时,这n个线程要串行的等待来获取锁。

2.Collections.synchronizedMap是使用Collections集合工具的内部类,通过传入Map封装出一个SynchronizedMap对象,内部定义了一个对象锁,方法内通过对象锁实现;

3.ConcurrentHashMap使用分段锁(可重入锁ReentrantLock),降低了锁粒度,让并发度大大提高。


ConcurrentHashMap的分段锁的实现原理?

ConcurrentHashMap是Java5中新增加的一个线程安全的Map集合,可以用来替代HashTable。ConcurrentHashMap成员变量使用volatile 修饰,免除了指令重排序(Reording),同时保证内存可见性,另外使用CAS操作和synchronized结合实现赋值操作,多线程操作只会锁住当前操作索引的节点。

如下图,线程A锁住A节点所在链表,线程B锁住B节点所在链表,操作互不干涉。

 

按照并发级别(concurrentLevel)分成了若干个片段(segment);默认情况下内部按并发级别为16来创建。对于每个segment的容量,默认情况也是16。Segment继承了ReentrantLock,表明每个segment都可以当做一个锁。这样对每个segment中的数据需要同步操作的话都是使用每个segment容器对象自身的锁来实现。这种做法,就称之为“分离锁(lock striping)”

分拆锁(lock spliting)就是若原先的程序中多处逻辑都采用同一个锁,但各个逻辑之间又相互独立,就可以拆(Spliting)为使用多个锁,每个锁守护不同的逻辑。
分拆锁有时候可以被扩展,分成可大可小加锁块的集合,并且它们归属于相互独立的对象,这样的情况就是分离锁(lock striping)。(摘自《Java并发编程实践》)

补充ConcurrentHashMap知识

get方法

第一步,先判断一下 count != 0;count变量表示segment中存在entry的个数。如果为0就不用找了。count变量使用了volatile来修改。JMM实现了对volatile的保证:对volatile域的写入操作happens-before于每一个后续对同一个域的读写操作。所以,每次判断count变量的时候,即使恰好其他线程改变了segment也会体现出来。

第二步,获取到要该key所在segment中的索引地址,如果该地址有相同的hash对象,顺着链表一直比较下去找到该entry。当找到entry的时候,先做了一次比较: if(v != null) ;HashEntry类结构,除了 value,其它成员都是final修饰的

newEntry对象是通过 new HashEntry(K k , V v, HashEntry next) 来创建的。如果另一个线程刚好new 这个对象时,当前线程来get它。因为没有同步,就可能会出现当前线程得到的newEntry对象是一个没有完全构造好的对象引用。参考双重检测锁(DCL),没有锁同步的话,new 一个对象对于多线程看到这个对象的状态是没有保障的,这里同样有可能一个线程new这个对象的时候还没有执行完构造函数就被另一个线程得到这个对象引用。所以才需要判断一下:if (v != null) 如果确实是一个不完整的对象,则使用锁的方式再次get一次。

1) 在get过程之中,另一个线程新增了一个entry

因为每个HashEntry中的next也是final的,没法对链表最后一个元素增加一个后续entry,所以新增一个entry只能通过头结点来插入了。

 

2) 在get过程之中,另一个线程修改了一个entry的value

value是用volitale修饰的,可以保证读取时获取到的是修改后的值。

3.在get之后,另一个线程删除了一个entry

假设我们的链表元素是:e1-> e2 -> e3 -> e4 我们要删除 e3这个entry,因为HashEntry中next的不可变,所以我们无法直接把e2的next指向e4,而是将要删除的节点之前的所有节点复制一份,形成新的链表。

它的实现大致如下图所示:

 

如果我们get的也恰巧是e3,可能我们顺着链表刚找到e1,这时另一个线程就执行了删除e3的操作,而我们线程还会继续沿着旧的链表找到e3返回。这里没有办法实时保证了。

环境要求“强一致性”的话,就不能用ConcurrentHashMap了,它的get,clear方法和迭代器都是“弱一致性”的。不过真正需要“强一致性”的场景可能非常少,我们大多应用中ConcurrentHashMap是满足的。

 

链表和树转化阈值是多少?

链表转红黑树阈值是8,红黑树转链表阈值为6

 

为什么红黑树转链表的阈值是6,不是8?

在hash函数设计合理的情况下,发生hash碰撞8次的几率为百万分之6,在长度为8的时候,与其保证链表结构的查找开销,不如转换为红黑树,改为维持其平衡开销。

至于为什么转回来是6,因为如果hash碰撞次数在8附近徘徊,频繁发生链表和红黑树的转化,影响性能。

一般用什么作为HashMap的key?

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

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

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

一个自定义的class作为HashMap的key该如何实现?

(1)类添加final修饰符,保证类不被继承

(2)保证所有成员变量必须私有,并且加上final修饰

(3)不提供改变成员变量的方法,包括setter

(4)通过构造器初始化所有成员,进行深拷贝(.clone();)

(5)在getter方法中,不要直接返回对象本身,而是克隆对象,并返回对象的拷贝

面试官: HashMap内部节点是有序的吗?那有没有有序的Map?

是无序的,根据hash值随机插入; 有LinkedHashMap 和 TreeMap

思考:HashMap的单向链表有next指针,为什么不能保证有序?

答:因为单向链表只是发生hash碰撞时,存储具有多个相同hash值的数据结构。也就是说,只是桶内有序。但是HashMap的table数组是无序的,这个是按照哈希值来定位数组中的位置,并不是按照插入顺序排序的。

 

面试官: 跟我讲讲LinkedHashMap怎么实现有序的?

LinkedHashMap构造函数,主要就是调用HashMap构造函数初始化了一个Entry[] table,然后调用自身的init初始化了一个只有头结点的双向链表。

LinkedHashMap是HashMap的子类, 复用了HashMap的很多方法,LinkedHashMap内部维护了一个有头尾节点的双向单链表把元素串联了起来,同时LinkedHashMap节点Entry内部除了继承HashMap的Node属性,还有before 和 after用于标识前置节点和后置节点,next是用于维护HashMap指定table位置上连接的Entry的顺序的,before、After是用于维护Entry插入的先后顺序的。可以实现按插入的顺序或访问顺序排序。

 

总结

  1. LinkedHashMap是继承于HashMap,是基于HashMap和双向链表来实现的。

  2. HashMap无序;LinkedHashMap有序,可分为插入顺序和访问顺序两种。如果是访问顺序,那put和get操作已存在的Entry时,都会把Entry移动到双向链表的表尾(其实是先删除再插入)。

  3. LinkedHashMap存取数据,还是跟HashMap一样使用的Entry[]的方式,双向链表只是为了保证顺序。

  4. LinkedHashMap是线程不安全的。

 

LinkedHashMap中添加数据的过程

红色部分是双向链表,黑色部分是HashMap结构,header是一个Entry类型的双向链表表头,本身不存储数据。总体来看,跟HashMap的put类似,只不过多了把新增的Entry加入到双向列表中。

首先是只加入一个元素Entry1,假设index为0:

当再加入一个元素Entry2,假设index为15:

当再加入一个元素Entry3, 假设index也是0:

双向链表的重排序

并设置LinkedHashMap为访问顺序(accessOrder为true),则更新Entry1时,会先把Entry1从双向链表中删除,然后再把Entry1加入到双向链表的表尾,而Entry1在HashMap结构中的存储位置没有变化

 

LinkedHashMap的remove操作

首先把它从table中删除,即断开table或者其他对象通过next对其引用,然后也要把它从双向链表中删除,断开其他对应通过after和before对其引用。

 

TreeMap怎么实现有序的?

TreeMap是按照Key的自然顺序或者Comprator的顺序进行排序,内部是通过红黑树来实现。所以要么key所属的类实现Comparable接口,或者自定义一个实现了Comparator接口的比较器,传给TreeMap用户key的比较。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值