HashMap 的工作原理:
HashMap 是基于 hashing 的原理。我们使用 put(key, value) 存储对象到 HashMap 中,使用get(key) 从 HashMap 中获取对象。当我们给 put() 方法传递键和值时,我们先对键调用hashCode() 方法,计算并返回的 hashCode 是用于找到 Map 数组的 bucket 位置来储存 Node 对象。
put(K key, V value):
- 对 Key 求 Hash 值,然后再计算下标
- 如果没有碰撞,直接放入桶中
- 如果链表长度超过阀值(8),就把链表转成红黑树,链表长度低于6,就把红黑树转回链表
- 如果碰撞了,以链表的方式链接到后面
- 如果节点已经存在就替换旧值
- 如果桶满了(容量16*加载因子0.75),就需要 resize(扩容2倍后重排)
get(key):
当我们调用 get() 方法,HashMap 会使用键对象的 hashcode 找到 bucket 位置,找到 bucket 位置之后,会调用 keys.equals() 方法去找到链表中正确的节点,最终找到要找的值对象。
为何数组的长度是2的n次方
1.这个方法非常巧妙,它通过h & (table.length-1)来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,2^n -1得到的二进制数的每个位上的值都为1,那么与全部为1的一一个数进行与操作,速度会大大提升。
2.当length总是2的n次方时,h& (length-1)运 算等价王对length取模,也就是h%length,但是&比%县有更高的效率。
3.当数组长度为2的n次幂的时候,不同的key算得的index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰擁的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。
resize:
当HashMap中的结点个数超过数组大小loadEactor*(加载因子)时,就会进行数组扩容,loadFactor的默认值为0.75.世就是说,默认情况下,数组大小为16,那么当HashMap电结点个数超过160.75=12的时候,就把数组的大小和展为216=32,即扩大一倍,然后重新计算每个元素在数组中的位置,并放进去,而这是一个非常消耗性能的操作。
如何减少碰撞
1.扰动函数算法,也就是Hash 方法内部的算法实现,目的是让不同对象返回不同hashcode,促使元素位置分布均匀,减少碰撞几率。
2.使用final对象,并采用合适的equals方法和hashCode方法。
为什么 String、Integer 这样的 wrapper 类适合作为键
因为 String 是 final,而且已经重写了 equals() 和 hashCode() 方法了。不可变性是必要的,因为为了要计算 hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的 hashcode 的话,那么就不能从 HashMap 中找到你想要的对象。
hash函数是怎么实现的
- 将hashcode低16 bit 和高16 bit 做了一个异或,得到hash
- (n-1) & hash = -> 得到下标
拉链法导致的链表过深,为什么不用二叉查找树代替而选择红黑树?为什么不一直使用红黑树
二叉查找树在特殊情况下会变成一条线性结构遍历查找会非常慢。而红黑树在插入新数据后可能需要通过左旋、右旋、变色这些操作来保持平衡。引入红黑树就是为了查找数据快,解决链表查询深度的问题。我们知道红黑树属于平衡二叉树,为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少。所以当长度大于8的时候,会使用红黑树;如果链表长度很短的话,根本不需要引入红黑树,引入反而会慢。
说说你对红黑树的见解
1.每个节点非红即黑
2.根节点总是黑色的
3.如果节点是红色的,则它的子节点必须是黑色的(反之不一定)
4.每个叶子节点都是黑色的空节点(NIL节点)
5.从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)
解决 hash 碰撞还有那些办法
开放定址法
当冲突发生时,使用某种探查技术在散列表中形成一个探查(测)序列。沿此序列逐个单元地查找,直到找到给定的地址。按照形成探查序列的方法不同,可将开放定址法区分为线性探查法、二次探查法、双重散列法等。
下面给一个线性探查法的例子:
问题:已知一组关键字为 (26,36,41,38,44,15,68,12,06,51),用除余法构造散列函数,用线性探查法解决冲突构造这组关键字的散列表。
解答:为了减少冲突,通常令装填因子 α 由除余法因子是13的散列函数计算出的上述关键字序列的散列地址为 (0,10,2,12,5,2,3,12,6,12)。
前5个关键字插入时,其相应的地址均为开放地址,故将它们直接插入 T[0]、T[10)、T[2]、T[12] 和 T[5] 中。
当插入第6个关键字15时,其散列地址2(即 h(15)=15%13=2)已被关键字 41(15和41互为同义词)占用。故探查 h1=(2+1)%13=3,此地址开放,所以将 15 放入 T[3] 中。
当插入第7个关键字68时,其散列地址3已被非同义词15先占用,故将其插入到T[4]中。
当插入第8个关键字12时,散列地址12已被同义词38占用,故探查 hl=(12+1)%13=0,而 T[0] 亦被26占用,再探查 h2=(12+2)%13=1,此地址开放,可将12插入其中。
类似地,第9个关键字06直接插入 T[6] 中;而最后一个关键字51插人时,因探查的地址 12,0,1,…,6 均非空,故51插入 T[7] 中。
如果 HashMap 的大小超过了负载因子(load factor)定义的容量怎么办
HashMap 默认的负载因子大小为0.75。也就是说,当一个 Map 填满了75%的 bucket 时候,将会创建原来 HashMap 大小的两倍的 bucket 数组来重新调整 Map 大小,并将原来的对象放入新的 bucket 数组中。这个过程叫作 rehashing。
因为它调用 hash 方法找到新的 bucket 位置。这个值只可能在两个地方,一个是原下标的位置,另一种是在下标为 <原下标+原容量> 的位置。
可以使用 CocurrentHashMap 来代替 Hashtable 吗
- ConcurrentHashMap 当然可以代替 HashTable,但是 HashTable 提供更强的线程安全性
- 它们都可以用于多线程的环境,但是当 Hashtable 的大小增加到一定的时候,性能会急剧下降,因为迭代时需要被锁定很长的时间。由于 ConcurrentHashMap 引入了分割,不论它变得多么大,仅仅需要锁定 Map 的某个部分,其它的线程不需要等到迭代完成才能访问 Map。简而言之,在迭代的过程中,ConcurrentHashMap 仅仅锁定 Map 的某个部分,而 Hashtable 则会锁定整个 Map
CocurrentHashMap(JDK 1.7)
- CocurrentHashMap 是由 Segment 数组和 HashEntry 数组和链表组成
- Segment 是基于重入锁(ReentrantLock):一个数据段竞争锁。每个 HashEntry 一个链表结构的元素,利用 Hash 算法得到索引确定归属的数据段,也就是对应到在修改时需要竞争获取的锁。ConcurrentHashMap 支持 CurrencyLevel(Segment 数组数量)的线程并发。每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment
- 核心数据如 value,以及链表都是 volatile 修饰的,保证了获取时的可见性
- 首先是通过 key 定位到 Segment,之后在对应的 Segment 中进行具体的 put 操作如下:
1.将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry。
2.遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value
3.不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容
4.最后会解除在 1 中所获取当前 Segment 的锁。
首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁。
1.尝试自旋获取锁
2.如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功。最后解除当前 Segment 的锁
CocurrentHashMap(JDK 1.8)
CocurrentHashMap 抛弃了原有的 Segment 分段锁,采用了 CAS + synchronized 来保证并发安全性。其中的 val next 都用了 volatile 修饰,保证了可见性。
最大特点是引入了 CAS
借助 Unsafe 来实现 native code。CAS有3个操作数,内存值 V、旧的预期值 A、要修改的新值 B。当且仅当预期值 A 和内存值 V 相同时,将内存值V修改为 B,否则什么都不做。
CAS 会出现的问题:ABA
解决:对变量增加一个版本号,每次修改,版本号加 1,比较的时候比较版本号