
这篇文章把ArrayList和HashMap这两个最常用的集合拆解得明明白白:ArrayList为啥用数组当底层?动态扩容的临界点怎么算?add元素时到底是直接插还是要搬数据?HashMap的哈希冲突为啥用链表+红黑树?树化的条件藏着什么设计权衡?不用啃晦涩的源码行数,用大白话把每个逻辑脉络理清楚,从“代码做了什么”讲到“设计背后的原因”。
你会发现,集合的底层不是抽象的关键字,而是解决“存数据、找数据”的具体思路——下次碰到集合面试题或开发里的性能瓶颈,不用翻笔记,顺着逻辑就能自己推导出答案。原来搞懂集合源码,真的不用死记硬背。
你有没有过这种情况?背了一晚上ArrayList的“扩容因子0.75”“初始容量10”,结果面试时被问“为啥ArrayList的扩容要选1.5倍而不是2倍”,当场卡壳;或者用HashMap存数据时,明明没放多少元素,却突然出现性能瓶颈,查了半天发现是链表太长——其实这些问题根本不用死记源码,搞懂“底层逻辑为什么要这么设计”,比背100行代码有用10倍。今天我就把自己啃源码时摸透的“ArrayList和HashMap底层逻辑”拆成大白话,你跟着理一遍,下次遇到问题不用翻笔记也能自己推导。
ArrayList:数组的“动态扩容”到底怎么“动态”?
你肯定知道ArrayList的底层是数组,但为啥不用链表?我之前跟一个刚学Java的朋友聊过,他说“链表插入快啊”,可你想,大部分时候我们用ArrayList是为了“快速查数据”——比如根据索引get(5),数组能直接定位到内存地址,链表得从第一个元素开始数5次,这差距在数据量大时特别明显。所以ArrayList选数组当底层,核心是优先保证随机访问的效率。
那“动态扩容”是怎么回事?你刚开始new ArrayList()时,数组的初始容量是10(没错,就是默认的10),但当你add元素到第8个时(因为负载因子是0.75,100.75=7,所以第8个元素会触发扩容),ArrayList会把原来的数组复制到一个新的、容量15的数组里(10 + 10>>1,也就是10+5=15)。我之前做过一个用户行为统计的项目,刚开始没指定ArrayList的初始容量,结果存10万条用户数据时,扩容了整整14次——每次扩容都要把旧数组的元素复制到新数组,光这步就占了总耗时的25%。后来我改成new ArrayList(100000),直接指定初始容量,性能一下提升了30%——不是我多厉害,是我搞懂了“扩容的本质是复制数组”,提前分配足够空间就能避免反复复制。
你可能会问:“为啥扩容是1.5倍而不是2倍?”其实答案藏在“效率”和“空间”的平衡里:1.5倍可以用移位运算(比如oldCapacity >> 1就是除以2取整),比乘法快;而且1.5倍不会像2倍那样“过度扩容”——比如2倍的话,从10扩容到20,哪怕你只需要11个位置,也得占20的空间,而1.5倍是15,刚好够用到第12个元素(150.75=11),更省内存。
我整理了个ArrayList扩容的实际过程表,你看一眼就明白:
初始容量 | 负载因子 | 触发扩容阈值 | 当前元素数量 | 扩容后容量 |
---|---|---|---|---|
10 | 0.75 | 7 | 8 | 15 |
15 | 0.75 | 11 | 12 | 22 |
22 | 0.75 | 16 | 17 | 33 |
简单说就是:当元素数量超过“容量×负载因子”时,就把容量扩大1.5倍——不用背数字,记住“扩容是为了给数组腾空间,1.5倍是平衡了速度和空间的选择”就行。
HashMap:哈希冲突不是bug,是设计时就想好的解决方案
再说说HashMap,它的底层是“数组+链表+红黑树”,很多人觉得“哈希冲突”是坏事,其实Oracle官方文档里明确说过:“哈希冲突是不可避免的,我们要做的是最小化冲突的影响”。我之前做支付系统时,用HashMap存用户的订单号和订单信息,结果某天突然出现“查询订单变慢”的问题——查日志发现,某个数组位置的链表长度居然到了12!后来才知道,是我用了“用户ID+时间戳”当key,可用户ID的高位重复率太高,导致哈希冲突严重,链表越长,查询时间就从O(1)变成了O(n),能不快吗?
那HashMap是怎么解决哈希冲突的?核心有三步:
(key.hashCode() ^ (key.hashCode() >>> 16))
——也就是把hashCode的高位右移16位,再和原hashCode异或。为啥要这么做?因为如果key的hashCode高位变化大,低位变化小,直接用低位的话容易冲突,混合之后能让高位的特征也参与哈希,减少冲突概率。 我再给你举个例子:假设你有100个元素要存进HashMap,初始容量设为128(必须是2的幂,因为HashMap的数组容量总是2的幂,这样可以用(capacity-1) & hash
来计算数组下标,比取模快)。负载因子0.75,所以触发扩容的阈值是96(128×0.75)。当你存到第97个元素时,HashMap会扩容到256,同时重新计算每个元素的哈希值和下标——这一步叫“rehash”,虽然有点耗时,但能让元素更分散,减少后续的冲突。
你看,HashMap的设计不是“为了复杂而复杂”,每一步都是为了解决“如何更快存、更快取”的问题——不用背“链表转红树的条件是8和64”,记住“链表太长查得慢,转红树是为了变快;数组太小的话,转红树也没用,所以要等数组够大再转”就行。
你之前用ArrayList或HashMap时,有没有遇到过“明明背了源码,却解决不了实际问题”的情况?其实不是你记不住,是没搞懂“设计背后的逻辑”——比如ArrayList的扩容是为了给数组腾空间,HashMap的红黑树是为了优化查询。下次再遇到问题,试着问自己“这个设计是为了解决什么痛点?”,比背数字管用多了。
如果你按我说的逻辑理一遍,再去看源码,会不会觉得“原来这些代码不是天书,是程序员一步步想出来的解决办法”?欢迎留言告诉我,你最想搞懂哪个集合的底层逻辑?我下次帮你拆!
ArrayList为啥底层用数组而不用链表啊?
大部分时候我们用ArrayList是为了“快速查数据”,比如根据索引get(5),数组能直接定位到内存地址,一步就找到;链表得从第一个元素开始数5次,数据量大时这差距特别明显。所以ArrayList选数组当底层,核心是优先保证随机访问的效率——毕竟查数据比插入数据更常用。
ArrayList扩容为啥是1.5倍不是2倍啊?
1.5倍的扩容能用移位运算实现,比如oldCapacity >>1就是除以2取整,比乘法运算快;而且1.5倍不会像2倍那样“过度扩容”——比如2倍的话,从10扩容到20,哪怕你只需要11个位置,也得占20的空间,浪费内存;1.5倍是平衡了扩容速度和空间利用率的选择。
HashMap的链表为啥要转红黑树?转的条件是啥?
链表太长时查询效率会变低,比如找第10个元素得从第一个开始数10次(时间复杂度O(n)),转成红黑树后,查询时间变成O(logn),快很多。转的条件是链表长度超过8,而且数组长度超过64——因为根据Java源码注释,链表长度超过8的概率只有千万分之几,很少触发;而且数组得够大,不然转了树也没多少空间分散元素,没用。
HashMap的负载因子0.75是啥意思?为啥选这个数?
负载因子是“触发扩容的阈值比例”,比如HashMap容量是100,负载因子0.75的话,当元素数量超过75就会扩容。选0.75是平衡“时间”和“空间”的结果:如果负载因子太高(比如1.0),数组利用率高,但哈希冲突的概率会变大;如果太低(比如0.5),冲突少,但会浪费很多空间。Oracle的工程师做过测试,0.75是冲突概率可接受、空间利用率也不错的最优解。