当前位置: 首页 > 科技观察

你初始化了HashMap的容量,却让性能变差了

时间:2023-03-15 01:40:10 科技观察

本文转载自微信公众号《程序新视野》,作者二哥。转载本文请联系程序新视界公众号。在前言项目中,很欣慰的看到大家已经意识到在初始化HashMap的时候指定了Map的初始容量。但仔细一看,似乎哪里不对劲。虽然指定了大小,但它使性能变差了。你可能也一样。看完《阿里巴巴Java开发手册》,感觉收获颇丰,于是开始在实践中尝试指定Map的初始大小,感觉自己写的代码有点高大上。确实,当你意识到你指定了一个初始化值时,你已经领先常人一步了,但是如果这个值没有指定好,程序的性能将不如默认值。本文将从头到尾进行分析。读者应该多关注分析方法和底层原理的实现。阿里开发规范我们来看看阿里Java开发规范是怎么描述Map初始值大小的规范的。阿里巴巴《Java开发手册》第一章编程规范第六节集合处理第十七条规定如下:【建议】集合初始化时,指定集合的??初始值。注意:HashMap是用HashMap(intinitialCapacity)初始化的。如果暂时无法确定集合大小,只需指定默认值(16)即可。正例:initialCapacity=(要存储的元素个数/加载因子)+1。注意加载因子(loaderfactor)默认为0.75。如果暂时无法确定初始值,请设置为16(默认值)。反例:HashMap需要放置1024个元素。由于容量的初始大小没有设置,随着元素的不断增加,容量被强制扩大7倍,resize需要重建哈希表。当放置的集合元素数量达到千万级时,持续扩容会严重影响性能。通过上面的规约,我们可以得到一些信息:首先,HashMap默认的容量是16;其次,扩容与负载率和存储元件数量有关;第三,设置初始值是为了减少扩容和重建哈希性能的影响。可能你看了上面的规范之后,开始在代码中使用指定集合初始值的方法,这很好。但是一不小心,中间就会出很多问题。让我们看看下面的例子。您指定的初始值是否正确?直接上示例代码想想是不是这段代码有问题:Mapma??p=newHashMap<>(4);map.put("username","Tom");map.put("address","BeiJing");map.put("age","28");map.put("phone","15800000000");System.out.println(map);类似的你熟悉吗代码,写的时候看起来棒极了。HashMap使用4个值,初始化4个大小。空间充分利用,符合阿里开发手册的规范?!上面的写法真的正确吗?真的没问题吗?直接看代码可能看不出问题,所以我们来补充一些打印信息吧。如何验证扩容很多朋友可能也想验证HashMap什么时候扩容,但是又没有思路也没有方法。这里有一个简单的方法,可以根据反射获取并打印capacity的值。还是上面的例子,我们来修改一下。在向HashMap中添加数据时,打印capacity和size这两个属性对应的值。publicclassMapTest{publicstaticvoidmain(String[]args){Mapma??p=newHashMap<>(4);map.put("username","Tom");print(map);map.put("地址""北京");print(地图);map.put("年龄","28");print(地图);map.put("电话","15800000000");print(地图);}publicstaticvoidprint(Mapma??p){try{ClassmapType=map.getClass();Methodcapacity=mapType.getDeclaredMethod("capacity");capacity.setAccessible(true);System.out.println("capacity:"+capacity.invoke(map)+"size:"+map.size());}catch(Exceptione){e.printStackTrace();}}}其中print方法通过获取Map中的capacity和capacity反射机制大小属性值,然后打印出来。在main方法中,每增加一条新数据,就打印Map的容量。打印结果如下:capacity:4size:1capacity:4size:2capacity:4size:3capacity:8size:4你发现了什么?当第四条数据放入时,HashMap扩容了一次。想想一开始指定初始容量的目的是什么?不就是为了避免扩容带来的性能损失吗?现在它导致了扩张。现在,如果去掉指定的初始值,使用newHashMap<>()方法执行程序,打印结果如下:capacity:16size:1capacity:16size:2capacity:16size:3capacity:16size:4它发现没有扩展默认值,理论上性能更高。是不是很有趣?你是否也陷入了这样的误区?上述问题在原理分析中都会出现。最重要的是我们忽略了概要规范中的第二项,即扩展机制。HashMap的扩容机制是当满足扩容条件时进行扩容。扩容条件是当HashMap中的元素个数(size)超过阈值(threshold)时,会自动扩容。在HashMap中,threshold=loadFactor*capacity。其中,默认加载因子为0.75。插入公式,让我们计算一下。loadfactor为0.75,例子中capacity的值为4。临界值为4*0.75=3。也就是说,当实际大小超过3时,就会触发扩容,扩容直接翻倍HashMap的容量。这与我们打印的一致。JDK7和JDK8的实现是一样的。本文不分析源码。基本原理和实验结果大家都知道。HashMap初始化容量设置合适。经过上面的分析,我们已经看到了隐藏的问题。这时候不禁要问,HashMap的初始容量设置多少合适呢?随便写一个比较大的数字就够了吗?这就需要我们了解传入初始容量时HashMap是如何处理的。当我们使用HashMap(intinitialCapacity)初始化容量时,HashMap不会直接使用传入的initialCapacity作为初始容量。JDK默认会帮助计算一个相对合理的值作为初始容量。所谓合理值,其实就是找到用户传入的值的第一个大于等于2次方的值。实现源码如下:staticfinalinttableSizeFor(intcap){intn=cap-1;n|=n>>>1;n|=n>>>2;n|=n>>>4;n|=n>>>8;n|=n>>>16;return(n<0)?1:(n>=MAXIMUM_CAPACITY)?MAXIMUM_CAPACITY:n+1;}也就是说,在创建HashMap时,如果7是传入,会初始化容量为8;当传入18时,初始容量为32。至此,我们得出第一个结论:设置初始容量时,使用2的n次方的值。即使你不这样设置,JDK也会帮你取下一个最接近2的n次方的值。上面的值看似合理,但是对于初始实例,我们发现初始容量并不是设置与存储的数据一样多。因为还要考虑扩展。根据扩展公式,如果初始容量设置为8,那么8乘以0.75,就是6个值。当存储小于或等于6个值时,不会触发扩展。那么是不是可以通过一个公式来反推呢?对应值的计算方法如下:return(int)((float)expectedSize/0.75F+1.0F);0.75F+1.0F计算,7/0.75+1=10,10经过JDK处理后会设置为16。那么这个时候16是一个比较合理的值,可以大大减少扩容的机会。因此可以认为,在HashMap中元素个数明确的情况下,将默认容量设置为expectedSize/0.75F+1.0F,从性能上来说是一个比较好的选择,但同时,一些内存会被牺牲。其他相关知识了解了以上知识,最后补充一些HashMap相关的知识点:HashMap在new之后不会立即分配bucket数组;HashMap的桶数组大小为2的幂;HashMapput中的元素个数大于Capacity*LoadFactor(默认大小为16*0.75时),会扩容;JDK8在hash碰撞链表长度达到TREEIFY_THRESHOLD(默认8)后会将链表转为树结构,以提高性能;JDK8通过巧妙的设计,降低了rehash的性能消耗。小结本文介绍了HashMap的一些使用误区,最大的结论可能是大家不要因为对一个知识点的了解不多而误用。同时也介绍了一些分析方法和实现原理。可能有朋友会问,要不要设置HashMap的初值,这个值设置多少,真的有这么大的影响吗?不一定有很大的影响,但是性能的优化和个人技能的积累不就是因为这一点点的提升和提升而获得的吗?