游侠网云服务,免实名免备案服务器 游侠云域名,免实名免备案域名

统一声明:

1.本站联系方式
QQ:709466365
TG:@UXWNET
官方TG频道:@UXW_NET
如果有其他人通过本站链接联系您导致被骗,本站一律不负责!

2.需要付费搭建请联系站长QQ:709466365 TG:@UXWNET
3.免实名域名注册购买- 游侠云域名
4.免实名国外服务器购买- 游侠网云服务
Java源码解读:ArrayList与HashMap底层逻辑超详细拆解,搞懂这些就够了

这篇文章把两个集合的源码“拆碎了”讲:从ArrayList的数组初始化、grow()方法的扩容计算(到底是1.5倍还是2倍?扩容时怎么拷贝数据?),到HashMap的哈希函数设计、putVal()的冲突处理流程(链表转红黑树的阈值为什么是8?树化还要满足什么条件?),每一步都结合源码片段超详细拆解。不用啃晦涩的原生代码,跟着思路走就能搞懂底层机制。

不管你是想解决实际开发的性能问题,还是想在面试中把集合问题答得“有理有据”,这篇源码解读都能帮你把基础夯得更实—— 懂底层,才能用得更“稳”。

做Java开发的朋友,你有没有过这种崩溃瞬间?用ArrayList存订单数据,明明没存多少,程序突然卡死;用HashMap存用户信息,查询速度突然变慢,查半天找不到原因?我去年做电商项目时,就因为没搞懂ArrayList的扩容机制,让订单系统卡了10分钟——后来翻源码才发现,原来我踩的坑,全是底层逻辑没吃透的锅。今天咱们把ArrayList和HashMap的源码“拆碎了”讲,从初始化到扩容,从哈希冲突到红黑树,每一步都给你讲清楚,你以后再也不用踩这些坑。

ArrayList:从数组到扩容的每一步,我踩过的坑你别再踩

先说说ArrayList的本质——其实就是个动态数组,底层用Object[] elementData存数据。你是不是以为它默认初始容量是0?不对,我之前也这么以为,直到翻了源码才发现:ArrayList的无参构造方法里,elementDataDEFAULTCAPACITY_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.copyOfSystem.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的幂,这样计算更快)。
  • 桶为空:直接把新节点放进桶里,结束。
  • 桶不为空
  • 先看桶里第一个节点的key是不是和当前key相等(用equals()判断),如果相等,直接替换value。
  • 如果不等,判断节点类型:是红黑树节点(TreeNode)还是链表节点(Node)?
  • 红黑树节点:用树的插入逻辑(复杂度O(logn))。
  • 链表节点:遍历链表,找到相等的key(替换)或末尾(插入新节点)。
  • 插入后,如果链表长度≥8,调用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实现,适合多线程场景。