HashMap
1. JDK1.7下的HashMap
1. put操作
1. 判断key是否为空,为空则插入index=0的位置
2. 根据 hash(key) & (size - 1) 计算出index,在哈希表中找到对应位置
2.1 对应位置为空,放置新节点
2.2 对应位置不为空,遍历查找key和哈希值都相同的节点,并替换value,找不到则在表头插入新节点
3. 伺机扩容
2. 生成死链的原因分析
产生死链的时机:多个线程执行扩容操作,将旧元素迁移到新元素的过程中。
1 while(null != e){ 2 Entry<K,V> next = e.next; 3 e.next = newTable[3]; 4 newTable[3] = e; 5 e = next; 6 }
假设旧数组size=2,table[1] = 3—>7。此时有两个线程进行扩容,线程1执行到行2挂起将时间片让给线程2。对于线程1来说,e指向3,next指向7。线程2同样进行扩容,并扩容完毕,新的数组size=4,table[3] = 7—>3。时间片交还线程1
因为链表元素是共用的,这个时候线程1的e还是指向3,next指向7,继续执行到行3,3—>7,此时出现了死链。
更可怕的是,如果其他线程访问table[3],无限循环,会将服务器资源耗尽。
2. JDK1.8下的HashMap
在JDK1.8中,如果链表长度大于8,会尝试转化成红黑树。这是因为相比链表查找 O(n) 复杂度,红黑树的查找复杂度为 O(logn),效率更高
如果数组大小小于64,会优先扩容。只有当数组大小大于等于64,才会将链表转化为红黑树。这是因为红黑树相比链表而言,更加的复杂,在维护方面涉及到树的旋转,所以,能通过扩容方式降低链表长度则优先使用扩容。在扩容时处理红黑树,如果发现红黑树的总大小小于等于6,那么会退化成链表,也是出于这个目的
JDK中的部分源码,用了很多条件表达式中赋值的操作,导致代码看起来不清晰明朗。小伙伴开发的时候不要学。
1. put操作
1 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { 2 HashMap.Node<K,V>[] tab; HashMap.Node<K,V> p; int n, i; 3 if ((tab = table) == null || (n = tab.length) == 0) 4 // hashMap的懒加载,这里触发扩容操作 5 n = (tab = resize()).length; 6 //i是key对应数组中的坐标 7 if ((p = tab[i = (n - 1) & hash]) == null) // case1 对应位置没有元素 8 tab[i] = newNode(hash, key, value, null); 9 else { 10 HashMap.Node<K,V> e; K k; 11 if (p.hash == hash && 12 ((k = p.key) == key || (key != null && key.equals(k)))) // case2 对应位置的key完全相同 13 e = p; 14 else if (p instanceof HashMap.TreeNode) // case3 对应位置是树节点 15 //使用红黑树的插入方法 16 e = ((HashMap.TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); 17 else { // case4 处理链表 18 for (int binCount = 0; ; ++binCount) { 19 if ((e = p.next) == null) { 20 //遍历完,没有找到完全相等的key,在链表尾插入新节点 21 p.next = newNode(hash, key, value, null); 22 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st 23 //遍历时会统计链表长度,长度大于等于8会尝试转化红黑树,因为这里最后还加了一个新节点没算,所以只需要 >= 8-1 24 treeifyBin(tab, hash); 25 break; 26 } 27 if (e.hash == hash && 28 ((k = e.key) == key || (key != null && key.equals(k)))) 29 //遍历中找到key完全相等的节点,跳出遍历 30 break; 31 p = e; 32 } 33 } 34 //找到完全相等key的情况下,准备替换value 35 if (e != null) { // existing mapping for key 36 V oldValue = e.value; 37 if (!onlyIfAbsent || oldValue == null) 38 e.value = value; 39 //这是个空方法 40 afterNodeAccess(e); 41 return oldValue; 42 } 43 } 44 ++modCount; 45 if (++size > threshold) 46 //容量大于扩容阈值,开始扩容 47 resize(); 48 afterNodeInsertion(evict); 49 return null; 50 }
3. 两个版本下的区别
1. 结构不同
在1.8版本下,引入了红黑树。并且1.8中节点的哈希值是final修饰的,在1.7中节点的哈希值在rehash时可能会变
2. 计算哈希值的方式不同
1.7版本中,对String类型有特殊的处理,在1.8中,对key为null的情况直接返回哈希值为0
3. key为null的处理
在1.7下,put对这种情况不会计算哈希值,有一段专门的处理,会在桶0的位置遍历找到key为null的元素并替换value值,没有则插入一个新节点。在1.8下,key为null的情况,计算的哈希值为0,跟一般key是一样的处理。
4. 初始化的区别
1.7版本,设定哈希表的时候会初始化一个空数组,在put元素时发现数组为空则通过inflateTable初始化
1.8版本,设置哈希表时没有指定空数组,在put元素时发现数组为空通过resize初始化
5. 扩容
扩容时机:1.7版本是在插入之前判断是否需要扩容。在1.8中,在插入时发现数组为空、插入结束发现大于扩容阈值、尝试转化红黑树时发现数组小于64,这三种情况会进行扩容。
插入顺序:1.7版本在迁移时,采用头插法。1.8版本采用尾插法,为了解决并发扩容下的死链问题,但在并发下是不会用hashmap的
计算哈希:1.7版本会为每个节点重新计算一次哈希值,1.8的不需要。
插入方式:1.8版本会现将链表分成高链和低链,低链在新数组的坐标还是原来的坐标,高链表在新数组的坐标为原来的坐标+旧数组的大小。1.7版本直接插入,没有这一步骤
4. 解决哈希冲突的方式
1.二次哈希
2.链表法
3.公共溢出区:将有冲突的元素放入溢出区,如果在哈希表中查不到,再到这个区域查找
4.开放地址:冲突后沿着数组寻找下一个没有被占用的位置。ThreadLocalMap#put中采用这种方法
5. 一致性哈希算法
跟hashmap关联不大,但是放到这里说一下。主要是解决集群缓存雪崩的问题。
我们将缓存数据存放到集群中,同时为了避免冗余数据,集群不同的节点存放不同的缓存,同时使用哈希算法保证分配均匀。将请求key的哈希值对服务器数取模得到具体存放到哪个服务器,来计算应该从哪个服务器取出缓存。
我们可以想象由N个点组成的一个环,每个点就是一个服务器,计算的结果就是服务器在圆环上的位置。
这样会有一个问题,计算结果跟服务器数是强相关的。如果集群中一个服务器下线,因为缓存位置的变动,导致整个集群的缓存都不可用,大量的请求打到数据库造成服务崩溃,这样势必需要停止整个服务,重新调整集群中缓存的分布。同样的,扩容也需要集群下线更新。
而一致性哈希算法是为了解决强相关的问题,请求key的哈希值跟一个固定值(通常是一个很大的值)来取模,规避服务器数变动带来的影响。还是用上面的模型解释,此时服务器在圆环上分布是稀疏的,这又会带来三个问题:
1. 请求计算出的位置上不一定有服务器,这个时候需要沿着圆环的一边寻找,直到找到可用的节点。
2. 服务器在圆环上分布不均匀,这时需要在圆环上生成一些虚拟的节点,去填补那些稀疏的位置。而请求到虚拟节点的数据,将会根据对应关系转发到真实服务器。
3. 服务器重新上线问题,某个服务器下线期间,请求会自动打到下一个节点,这个节点会处理请求并记录下原请求的真正位置,等服务器重新上线时,会将这部分数据重新同步回上线服务器。
我们通常会使用Redis做缓存服务,参考下Redis中的集群中请求分发的方式,集群将键分成16384个槽,每个主节点负责其中一部分槽。跟一致性哈希很像,其实一致性哈希中沿着环寻找下一个节点就相当于将这部分环分配给了下一个节点,区别是Redis集群中每个节点的槽在空间上不一定是连续的。
6. 一些小点
1. 作为Key的对象,必须是不可变对象。因为计算坐标需要根据key的哈希值,如果存入后,key的内容变了,下次取值时,根据key算出来的坐标就不是原来存入的坐标了。
2. equals和==
==:对于基本类型,比较的是具体的值。对于引用比较的是引用对象在内存中的存储地址
equlas:定义在Object中的方法,默认使用==比较,即比较对象在内存中的存储地址
3. equals和hashCode
hashCode:也是Object中定义的方法。Objetc中的默认实现,是为每一个对象生成不同的Int数值,一般跟对象的内存地址有关。Object类定义中对hashCode和equals做了如下规定:
1. equals相同,则hashCode必须相同
2. 改写了equals,必须同时改写hashCode
第二点是为了保证第一点
在比较key时,先比较key的哈希值,哈希值相同再使用equals比较。
4. 数组大小是2的整数幂
两个好处,一个是这样用哈希跟数组大小算模时,可以转化成和数组大小-1与运算。接着上面,数组大小-1是奇数,做与运算,最后一位是0和1的概率差不多,如果是偶数,最后一位只会为0。
5. 关于key和value为空
hashMap允许key和vlaue为空的情况,当key和value都为空,判断集合是否存在这种特殊的键值对,需要使用containsKey()方法, return getNode(hash(key), key) != null; ,这个方法返回的是内部存储key-value的对象Node是否为空,不为空则说明键值对一定存在。而get方法 return (e = getNode(hash(key), key)) == null ? null : e.value; ,在value也为null的情况,不能判断是不存在还是e.value的值是null。
在concurrentHashmap中,key和value都不允许null的情况,否则抛出空指针异常。查看网上的文章,我觉的可能还是出于开发习惯的考虑,就像一个列表查询没有结果时是返回null还是返回空集合。
6. 链表转成红黑树的阈值为什么是8
作者通过泊松分布,计算出,在哈希算法良好的情况下,链表长度大于等于8的情况非常小,而小于8的情况,红黑树和链表的复杂度相差不大。所以只有在大于等于8的情况下才会转化成红黑树。
参考:
1. https://blog.csdn.net/weixin_44141495/article/details/108402128?utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromMachineLearnPai2%7Edefault-1.control&dist_request_id=1330144.34821.16182103329318175&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromMachineLearnPai2%7Edefault-1.control
2.https://blog.csdn.net/a718515028/article/details/108265496