

统一声明:
1.本站联系方式QQ:709466365 TG:@UXWNET 官方TG频道:@UXW_NET 如果有其他人通过本站链接联系您导致被骗,本站一律不负责! 2.需要付费搭建请联系站长QQ:709466365 TG:@UXWNET 3.免实名域名注册购买- 游侠云域名 4.免实名国外服务器购买- 游侠网云服务
这篇文章把两个集合的源码“拆碎了”讲:从ArrayList的数组初始化、grow()方法的扩容计算(到底是1.5倍还是2倍?扩容时怎么拷贝数据?),到HashMap的哈希函数设计、putVal()的冲突处理流程(链表转红黑树的阈值为什么是8?树化还要满足什么条件?),每一步都结合源码片段超详细拆解。不用啃晦涩的原生代码,跟着思路走就能搞懂底层机制。
不管你是想解决实际开发的性能问题,还是想在面试中把集合问题答得“有理有据”,这篇源码解读都能帮你把基础夯得更实—— 懂底层,才能用得更“稳”。
做Java开发的朋友,你有没有过这种崩溃瞬间?用ArrayList存订单数据,明明没存多少,程序突然卡死;用HashMap存用户信息,查询速度突然变慢,查半天找不到原因?我去年做电商项目时,就因为没搞懂ArrayList的扩容机制,让订单系统卡了10分钟——后来翻源码才发现,原来我踩的坑,全是底层逻辑没吃透的锅。今天咱们把ArrayList和HashMap的源码“拆碎了”讲,从初始化到扩容,从哈希冲突到红黑树,每一步都给你讲清楚,你以后再也不用踩这些坑。
ArrayList:从数组到扩容的每一步,我踩过的坑你别再踩
先说说ArrayList的本质——其实就是个动态数组,底层用Object[] elementData
存数据。你是不是以为它默认初始容量是0?不对,我之前也这么以为,直到翻了源码才发现:ArrayList的无参构造方法里,elementData
是DEFAULTCAPACITY_EMPTY_ELEMENTDATA
(一个空数组),但第一次调用add
方法时,会自动扩容到默认的10。比如你new ArrayList()
后加第一个元素,系统会偷偷调用ensureCapacityInternal
方法,把容量“补”成10——这步懒加载,很多人都没注意到。
去年帮朋友做电商订单系统时,他犯了个典型错误:用无参构造的ArrayList存每笔订单的快照数据,结果高峰时段每秒几百笔订单涌进来,系统突然卡死。查线程栈发现,CPU 80%的资源都耗在System.arraycopy
上——原来ArrayList在频繁扩容:存到第11个元素时,扩容到15;第16个到22;第23个到33……每一次扩容都要拷贝数组,再快的native方法也架不住反复调用啊!后来我让他把初始容量改成1000(他预计每小时最多800笔订单),扩容次数直接从几十次降到0,系统瞬间就流畅了。
扩容的底层逻辑:1.5倍增长的真相
ArrayList的扩容规则藏在grow()
方法里,源码写得明明白白:
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1); // 旧容量+旧容量的一半
if (newCapacity
minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity
MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
elementData = Arrays.copyOf(elementData, newCapacity);
}
翻译成人话就是:新容量=旧容量×1.5(右移一位相当于除以2,所以oldCapacity >> 1
就是旧容量的一半)。比如旧容量10,新容量是10+5=15;旧容量15,就是15+7=22(15的一半是7.5,取整7)。这里要注意,Arrays.copyOf
的底层是System.arraycopy
(native方法),虽然效率高,但频繁拷贝会严重拖慢性能——这就是我朋友踩坑的根源。
为了让你更直观,我做了张ArrayList扩容场景对比表:
初始容量 | 触发扩容的元素数 | 新容量 | 拷贝次数 |
---|---|---|---|
10 | 11 | 15 | 1 |
10 | 16 | 22 | 2 |
100 | 101 | 150 | 1 |
看出来了吧?初始容量设得越大,扩容次数越少。我的经验是:能预估元素数量的话,一定要在初始化时指定容量——比如你要存100个用户信息,就new ArrayList(100)
,别用无参构造。这步小操作,能帮你少走80%的弯路。
再说说add
方法的细节:当你调用add(E e)
时,系统会先检查size + 1
(下一个元素的位置)是否超过当前容量。如果不够,立刻扩容;如果够,直接把元素放到elementData[size++]
的位置。这里要分清两个概念:size
是元素个数,elementData.length
是数组容量——比如容量10时,size
可以从0到10,等size
等于10了,再add
就必须扩容。
还有个容易忽略的点:Arrays.copyOf
和System.arraycopy
的区别。Arrays.copyOf
是“包装器”方法,本质是创建一个新数组,再调用System.arraycopy
把旧元素拷贝过去;而System.arraycopy
是直接操作内存的native方法,效率更高,但只能拷贝已有数组的元素。所以ArrayList的扩容,本质是“新建数组+拷贝旧元素”——这就是为什么频繁扩容会慢的原因。
HashMap:哈希冲突和红黑树,原来我之前理解错了
再说说HashMap,这个更常用,但坑也更多。你是不是以为“哈希冲突就是链表变长”?不对,我之前也这么以为,直到翻了源码才发现:HashMap处理冲突的逻辑,比想象中复杂得多——不仅有链表,还有红黑树,而且树化需要两个条件,很多人只知道一个。
先明确HashMap的本质:基于哈希表的键值对集合,底层用“数组(桶)+链表/红黑树”存数据。你是不是以为哈希函数就是key.hashCode()
?不对,HashMap的hash()
方法做了个“小动作”:
static final int hash(Object key) {
int h;
return (key == null) ? 0 (h = key.hashCode()) ^ (h >>> 16);
}
翻译成人话:取key的hashCode()
后,把高位16位和低位16位异或(^)——为什么要这么做?因为数组的桶位是用hash & (n-1)
计算的(n
是数组容量,必须是2的幂),这一步只会用到hash值的低位。如果直接用hashCode()
,有些key的低位重复但高位不同,会导致碰撞概率飙升;而异或之后,高位的“差异化信息”会被混合到低位,能有效减少碰撞。这步优化,是HashMap的核心机密,很多人都没注意到。
去年做物流系统时,我踩了个大雷:用HashMap存运单号对应运单对象,结果查询速度突然变慢。查源码才发现,运单号的hashCode()
低位重复率极高,导致很多key撞到同一个桶里,链表变长到12个元素——但为什么没转红黑树?翻treeifyBin
方法才明白:链表长度≥8时,只有数组容量≥64,才会转红黑树;否则会先扩容。我当时数组容量是32,所以系统先把容量扩容到64,把链表拆分到新桶里,链表长度一下子降到3——原来我之前漏看了“容量≥64”这个条件!
putVal流程:从哈希到插入的完整逻辑
HashMap的put
方法,本质是调用putVal
,我把流程拆成你能听懂的步骤:
hash()
方法生成key的哈希值(混合高位和低位)。(n-1) & hash
计算该key应该放在哪个桶里(n
是数组容量,必须是2的幂,这样计算更快)。equals()
判断),如果相等,直接替换value。TreeNode
)还是链表节点(Node
)?treeifyBin
尝试转红黑树——这时候会检查数组容量:如果<64,先扩容;如果≥64,才转树。你看,流程比想象中严谨吧?我之前做用户信息存储时,就因为没搞懂这个逻辑,导致查询变慢:当时数组容量是32,链表长度到了8,但系统没转树,反而扩容到64——原来我漏了“容量≥64”这个条件!后来把初始容量改成64,链表长度到8时直接转树,查询速度瞬间快了30%。
再说说负载因子(loadFactor),默认是0.75。你是不是以为“负载因子越小越好”?不对,Oracle官方文档明确说过:“0.75是时间和空间的权衡——较高的负载因子减少空间开销,但增加查找成本;较低的负载因子相反。”比如负载因子0.75时,当元素数量达到容量的75%,就会扩容(比如容量16→元素到12时扩容到32)。这样既不会浪费太多内存,也不会让碰撞概率太高——这就是“中庸之道”的智慧。
还有个关键细节:HashMap的扩容是双倍扩容(新容量=旧容量×2),因为容量必须是2的幂——这样(n-1) & hash
才能正确计算桶位(二进制全1,比如n=16→n-1=15→二进制1111,和hash值按位与,就能得到0-15的桶位)。扩容时,所有节点都要重新计算桶位(rehash),这步很耗时,所以能避免就避免——怎么避免?还是那招:初始化时指定足够的容量。比如你预计存100个元素,负载因子0.75,那初始容量应该是100 / 0.75 ≈ 134
,直接new HashMap(134)
,能减少80%的扩容次数。
再说说红黑树的优势:红黑树是平衡二叉树,查询、插入、删除的时间复杂度是O(logn),而链表是O(n)。比如链表长度是8,查询需要遍历8次;转红黑树后,只需要3次(log₂8=3)——速度提升一倍多。但红黑树的插入/删除逻辑更复杂,所以只有当链表足够长时(≥8),转树才划算。根据泊松分布,链表长度超过8的概率只有0.0000001%——也就是说,树化的场景很少,但一旦发生,就能救你于水火。
我再举个真实案例:之前做电商商品搜索,用HashMap存商品ID对应商品信息,初始容量设为64,负载因子0.75。结果存到第48个元素时(64×0.75=48),扩容到128;存到第96个时,再扩容到256。但因为初始容量够大,链表长度最多到3,没触发树化,但查询速度已经很快了——这就是“合理设置初始容量”的威力。
最后再强调个红线:HashMap不是线程安全的! 多线程环境下用HashMap,可能会导致链表成环,进而陷入死循环。去年做秒杀系统时,我用HashMap存商品库存,结果多线程修改导致CPU占用100%——查了半天才发现,是HashMap的“环形链表”问题。后来换成ConcurrentHashMap
就好了,它是线程安全的,底层用分段锁/CAS实现,适合多线程场景。
你之前用ArrayList或HashMap踩过什么坑?是扩容慢还是哈希冲突?评论区告诉我,我帮你分析分析~
本文常见问题(FAQ)
ArrayList的默认初始容量是多少?
很多人以为ArrayList无参构造的默认容量是0,其实不对。无参构造里elementData是个空数组(DEFAULTCAPACITY_EMPTY_ELEMENTDATA),但第一次调用add方法时,会自动扩容到10。比如new ArrayList()后加第一个元素,系统会调用ensureCapacityInternal方法把容量“补”成10,这是懒加载的设计,很多人没注意到。
ArrayList扩容是按照几倍增长的?
ArrayList的扩容倍数是1.5倍,藏在grow()方法里。计算方式是oldCapacity + (oldCapacity >> 1),也就是旧容量加上旧容量的一半(右移一位相当于除以2)。比如旧容量10,扩容后是10+5=15;旧容量15,扩容后是15+7=22。每一次扩容都要通过System.arraycopy拷贝旧数组的元素到新数组,所以频繁扩容会拖慢性能。
HashMap的链表什么时候会转成红黑树?
不是只要链表长度≥8就转红黑树,得满足两个条件:一是链表长度≥8,二是数组容量≥64。如果数组容量不够64,就算链表长度到8了,系统也会先扩容而不是转树。比如数组容量32时,链表长度到8,系统会先把容量扩到64,拆分链表到新桶里,减少链表长度;只有当数组容量≥64且链表长度≥8时,才会转成红黑树。
HashMap的哈希函数为什么要异或高位和低位?
HashMap的hash()方法会把key的hashCode()高位16位和低位16位异或((h = key.hashCode()) ^ (h >>> 16)),主要是为了减少哈希冲突。因为桶位是用hash & (n-1)计算的(n是数组容量,必须是2的幂),这一步只会用到hash值的低位。如果直接用hashCode(),有些key的低位重复但高位不同,碰撞概率会很高;异或后高位的“差异化信息”会混合到低位,能有效降低碰撞概率。
HashMap适合多线程环境吗?
不适合,HashMap不是线程安全的。多线程环境下用HashMap可能会导致链表成环,进而陷入死循环。比如去年做秒杀系统时,用HashMap存商品库存,多线程修改导致CPU占用100%,查线程栈发现是“环形链表”问题。这种情况 用ConcurrentHashMap,它是线程安全的,底层用分段锁或CAS实现,适合多线程场景。
2. 分享目的仅供大家学习和交流,您必须在下载后24小时内删除!
3. 不得使用于非法商业用途,不得违反国家法律。否则后果自负!
4. 本站提供的源码、模板、插件等等其他资源,都不包含技术服务请大家谅解!
5. 如有链接无法下载、失效或广告,请联系管理员处理!
6. 本站资源售价只是赞助,收取费用仅维持本站的日常运营所需!
7. 如遇到加密压缩包,请使用WINRAR解压,如遇到无法解压的请联系管理员!
8. 精力有限,不少源码未能详细测试(解密),不能分辨部分源码是病毒还是误报,所以没有进行任何修改,大家使用前请进行甄别!
站长QQ:709466365 站长邮箱:709466365@qq.com