2024年Android最新Android面试必备的集合源码详解,看完之后简历上多一个技能!,面试资料分享

自学编程路线、面试题集合/面经、及系列技术文章等,资源持续更新中…

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

熟悉 Java/Android 中常见的集合源码,包括 List、Set、Map、Queue/Deque 等…

下面就是正文了,欢迎讨论~:

前言

起初想看集合源码是因为,一次偶然的机会,一位同事跟我说 ArrayList 的初始容量为空,第一次 add 时才会扩容至 10。我当时就觉得我的知识体系有点落后了,就决定去看一遍集合源码。在看的过程中呢,可以用惊喜+收获满满来形容了。惊喜是指对于 Stack、HashSet、LinkedHashMap 等等的实现方式感觉很震惊,看完这些集合源码有种那种你感觉很难但是其实很简单的感觉,不仅如此,熟悉集合源码对实际开发也是有帮助的,主要体现在 ArrayList remove 时可能抛出的 ConcurrentModificationException、Collections 使用不当造成的 UnsupportedOperationException、ArrayMap 的缓存机制等等。

在看集合源码时,Debug 源码有时并不直接,可以使用 OpenJDK 开源的 JOL 工具查看对象的内存布局,这对分析集合扩容、HashMap 树化非常有用。

目录

  1. ArrayList

  2. Vector

  3. Stack

  4. LinkedList

  5. HashMap

  6. Hashtable

  7. TreeMap

  8. LinkedHashMap

  9. HashSet

  10. TreeSet

  11. CopyOnWriteArrayList

  12. ConcurrentHashMap

  13. LinkedBlockingQueue

  14. SynchronousQueue

  15. SparseArray

  16. ArrayMap

ArrayList

ArrayList 实现了 List 接口,RandomAccess 接口,可以插入空数据以及支持随机访问。它相当于一个动态数组,初始化时是一个空数组,在第一次 add 时设置初始容量为 10,每次扩容都增加到原来的 1.5 倍。简单的 add 就是在 elementData 数组末尾添加一个数据,size++;指定 index 添加数据,就需要拷贝 index 后面的数据后移一位。在删除的时,如果是删除 null,就遍历数组找到第一个 null 值删除,否则就遍历比较 equals 删除指定 index 的数据,其实也就是拷贝 index 后面的数据前移一位。删除数据时,最好使用迭代器来做,避免 ConcurrentModificationException,它并不只是在并发时才会抛出的,单线程也可能抛出,其实内部是比较 expectedModCount 和 modCount 是否相等来判断的。ArrayList 的性能损耗就来源于数组拷贝,在适当情况下,可以初始化时指定容量大小,避免不必要的扩容操作。其实呢,ArrayList 还有一个缺点,就是不能自动缩容,但是我们可以手动调用 trimToSize 来缩容至当前 size 大小。

还有一点是 elementData 是用 transient 修饰的,也就是拒绝数据被自动序列化,因为 ArrayList 并不是所有位置都有数据,所以没必要全部序列化,应该只序列化有数据的部分,所以它重写了 writeObject/readObjet 方法。

Vector

Vector 感觉是一个被人抛弃的类,它在初始化时直接设置了数组初始容量为 10,在它的 get/add 等方法都加了 synchronized,完全可以看成是一个加锁的 ArrayList。

不同的是,它在扩容时默认是直接加倍的,当然这个是可以控制的,在构造方法中可以传一个增长数,这个数是需要大于 1 的,不然就按 1 处理。比如是 0.75,每次扩容就加 1,是 5 呢每次扩容就加 5。

当然,想让 ArrayList 变成线程安全的,还可以使用 Collections.synchronizedList 来做,或者呢,使用 CopyOnWriteArrayList。

Vector 基本上没人用过,但是它的一个子类大家可能会用过,那就是 Stack。

Stack

Stack 是继承于 Vector 的,所以它也是线程安全的,它总共代码就二三十行。所有实现都是在父类 Vector 中,在我们调用 push 时就是往数组末尾 add 一个数据,pop 时就是获取数组的最后一个元素。和 Vector 不同的是,它的初始容量为空。还有需要注意的是,在调用 peek/pop 时,如果栈为空,是会抛 EmptyStackException 的。

LinkedList

LinkedList 实现了 Deque 接口,说明它是一个双向链表,每一个 Node 节点都有 prev 和 next 指针,每次 add 或者 remove 时都需要更新前驱和后指针,在指定 index 位置删除时,会区分 index 如果是靠头部比较近,就从头 first 节点遍历删,否则从尾部 last 节点删。使用迭代器时,也是可以使用 ListIterator 从头或从尾遍历。

它在新增和删除时,时间复杂度 O(1),而在查询时时间复杂度 O(n)。

HashMap

HashMap 底层数据结构是数组 + 链表 + 红黑树。数组的主要作用是方便快速查找,时间复杂度是 O(1),默认大小是 16,数组的下表索引是通过 key 的 hashCode 计算出来的,数组元素叫做 Node,当多个 key 的 hashCode 一致,但 key 值不相同时,即发生了 hash 冲突时,单个 Node 就会转化为链表,链表的查询复杂度是 O(n),当链表的长度大于等于 8 并且数组的大小超过 64 时,链表就会转化为红黑树,红黑树的查询复杂度是 O(log(n)),简单来说,最坏的查询次数相当于红黑树的最大深度。

HashMap 非线程安全,如果需要满足线程安全,可以用 Collections.synchronizedMap 使得 HashMap 具有线程安全的能力,或者使用 ConcurrentHashMap。

上面已经说清楚 HashMap 的大致实现原理了,下面就说一些细节的东西。

在 HashMap 中,哈希桶数组的长度大小必须是 2 的 n 次方,这是一种非常规的设计,常规的做法是把桶大小设计为素数。相对来说,素数导致冲突的概率要小于合数。HashTable 初始化桶的大小为 11 就是把桶大小设计为素数的典型应用。HashMap 采用这种非常规的设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap 在定位哈希桶索引位置时,也加入了高位参与运算的过程。

在链表长度大于等于 8 并且数组长度大于等于 64 时,才会进行树化。如果数组长度小于 64,则只会扩容而不会树化。为什么是 8 呢?这个在源码注释中说的比较清楚,大致意思是,在链表数据不多的时候,使用链表进行遍历也比较快,只有当链表数据比较多才会转化为红黑树,但红黑树的占用空间是链表的两倍,考虑到转化时间和空间消耗,所以我们需要定义出转化的边界值。在考虑设计 8 这个值的时候,参考了泊松分布概率函数,得出的结论就是当链表长度为 8 的时候,出现的概率不到千万分之一,所以说,正常情况下链表的长度不可能到达 8。

接下来讲确定哈希桶索引位置的做法。

简单的做法就是通过 hash 对数组长度取模运算得到索引,这样元素分布相对来说也是比较均匀的,但是取模运算不及位运算,HashMap 采用的是 (n-1)&hash,当 n 为 2 的次方时,(n-1)&hash 等价于取模运算,但是位运算执行效率显然是高于取模运算的。

同时,取 hash 的时候是通过 hashCode 的高十六位和低十六位异或得到,这样做在数组长度比较小的时候也能保证高位 bit 都能参与到 hash 计算中,同时不会有太大开销。

然后再讲一下扩容机制,扩容的时候是容量翻倍,也就是 x2,这也同时保证了长度依旧是 2 的次方,所以前面基于位运算取索引的优化得以保留。既然数组长度改变了,那么肯定需要重新计算索引位置呀?这里又有一个优化点,当 n 为 2 次方时,x2 不过是在高位补 1,然后在进行与运算,与 1 进行与运算就是它本身嘛。所以这时只需要看 hash 的高位是 1 还是 0,如果是 0,索引不变,如果是 1,新索引就是原索引加旧桶值。这也就避免了重新计算索引,只需要看 hash 的高位是 1 还是 0 即可。但是你可能会说,这必须得保证数组长度为 n 次方呀,我们可以在初始化 HashMap 时传一个非 2 的 n 次方的数,这就炸了。其实呢,HashMap 会根据你数组容量,自动调节到 2 的 n 次方上,比如传 15 就是 16,传 17 呢就是 32,向上转一个最接近的 2 次幂数。

最后,在这里面我并没有将 HashMap 的具体 put/remove/get 的实现,这些其实就是数组或链表或红黑树的操作,数组和链表大家都很熟悉了,红黑树我也不是很懂,只需要记得每次 put/remove 时,都会进行着色和旋转,使得红黑树更加平衡。再不济就把红黑树看成一颗二叉搜索树也行趴。

Hashtable

首先这个 Hashtable 的命名就有点离谱,没有遵循驼峰命名法。它的实现是通过一个 Entry 数组来做的,put/remove/get 都加了 synchronized,是线程安全的,它的取 index 是 (hash & 0x7FFFFFFF) % tab.length,前面和 0x7FFFFFF 是为了让 hash 值变为正数,那你可能会问,为啥不用 Math.abs 呢,其实在数值溢出时,abs 也是可能会得到负值的;HashMap 的可以只有一个 key 为 null,多个 value 为 null 的,而 Hashtable 是不允许 key/value 为 null 的,不然直接抛空指针。

Hashtable 在扩容时,是 x2 + 1 的。Hashtable 在处理 hash冲突时,是插入到链表的头部,即头插法。

TreeMap

TreeMap 底层的数据结构就是红黑树,和 HashMap 的红黑树结构一样。不同的是,TreeMap 通过 compare 来比较 key 的大小,然后利用红黑树左小右大的特性,为每个 key 找到自己的位置,维护了 key 的大小关系,适用于 key 需要排序的场景。

因为底层使用的是红黑树的结构,所以它的 put/remove/get 等方法时间复杂度都是 log(n) 的。

LinkedHashMap

HashMap 是无序的,TreeMap 可以按照 key 进行排序,那有没有 Map 是可以维护插入顺序的呢?这就是 LinkedHashMap。

LinkedHashMap 本身是继承 HashMap 的,所以它拥有 HashMap 的所有特性,在此基础上还提供了两大特性,第一个是按照插入顺序访问,第二个呢是实现了最近最少使用策略,这个需要在构造方法中传一个 accessOrder = true,默认是 false 也就是按照插入顺序访问。

在我们调用 put/remove 方法时,其实是调用的 HashMap 的 put/remove 方法,而重写了 get 方法实现了以上特性。那么它是如何基于 HashMap 实现了以上特性的呢?其实是通过重写 HashMap 里面的三个方法,这个三个方法是每当 HashMap 调用 put/get/remove 时都会调用的,只不过在 HashMap 中是一个空实现,而在 LinkedHashMap 中它被用来记录插入顺序,其实也就是扩展 HashMap 的 Node 使其具备链表结构,也就是每个数组元素增加 befor 和 after 属性。

但是呢,LinkedHashMap 只提供了单向访问,即按照插入的顺序从头到尾进行访问,不能像 LinkedList 那样可以双向访问。

在使用 LRU 策略时,可以覆写删除策略的方法 removeEldestEntry 方法,比如可以指定当节点数大于 10 就开始删除头结点(size() > 10)等等。

HashSet

HashSet 的源码还是很少的,它保证了每个元素是不会重复的。在它的构造方法中,其实是 new HashMap 来做的,一般我们只使用它的 add 和 contains 方法,其实都是调用 HashMap 来实现的。

这个类也没说可说的了,代码就那几十行。

TreeSet

TreeSet 大致的结构和 HashSet 相似,底层组合的是 TreeMap,所以继承了 TreeMap key 能够排序的功能,同时也具有 Set 不可重复的属性。

TreeSet 的 add 方法是调用 NavigableMap 的 put 方法的,它是一个接口,它的实现是在 TreeMap 中,也就是说,TreeSet 定义了接口的规范,TreeMap 负责去实现。

TreeSet 基本上不常用,一般需要不重复元素且能根据元素进行排序的时候,才会用到 TreeSet,使用时需要我们注意最好实现 Comparable 接口,这样方便底层的 TreeMap 根据 key 进行排序。

CopyOnWriteArrayList

CopyOnWriteArrayList 是一个线程安全的 ArrayList,使用了写时复制策略,它是通过 synchronized + 数组拷贝 + volatile 关键字保证了线程安全。在它 add value 时,是先 synchronized 加锁保证同一时刻只有一个线程执行 add 方法,再拷贝一份长度加一的新数组,把 value 加到新数组的末尾,最后再把新数组赋值给原数组。你可能会问了,这都加锁了,为啥不在原数组上面直接操作呢?原因主要有两个:

  1. 在新的数组上进行拷贝,对老数组没有任何影响,只有新数组完全拷贝完成之后,外部才能访问到,降低了在赋值过程中,老数组数据变动的影响

  2. volatile 关键字修饰的是数组,如果只是简单的在原数组上修改其中某几个元素的值,是无法触发可见性的,我们必须通过修改数组的内存地址才行,也就说要对数组进行重新赋值才行

数组拷贝也有一点好处,那就是没有空间损耗,元素是占满数组的。

remove 时和 add 相似,就不多说了。

CopyOnWriteArrayList 读的时候不需要加锁,适合读多写少的场景,这同时也会带来一个弱一致性的问题,即获取迭代器后迭代元素,其他线程对该 list 进行的增删改不可见,因为它们操作的是两个不同的数组,这就是弱一致性。简单来说,就是 CopyOnWriteArrayList 提供了弱一致性的迭代器,从而保证获取迭代器后,其他线程对 list 的修改是不可见的,迭代器遍历的数组是一个快照。

CopyOnWriteArraySet 的内部实现就是在其构造方法中 new CopyOnWriteArrayList 来实现的。

ConcurrentHashMap

分享读者

作者2013年java转到Android开发,在小厂待过,也去过华为,OPPO等大厂待过,18年四月份进了阿里一直到现在。

被人面试过,也面试过很多人。深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长,不成体系的学习效果低效漫长,而且极易碰到天花板技术停滞不前!

我们整理了一份阿里P7级别的Android架构师全套学习资料,特别适合有3-5年以上经验的小伙伴深入学习提升。

主要包括阿里,以及字节跳动,腾讯,华为,小米,等一线互联网公司主流架构技术。如果你有需要,尽管拿走好了。

35岁中年危机大多是因为被短期的利益牵着走,过早压榨掉了价值,如果能一开始就树立一个正确的长远的职业规划。35岁后的你只会比周围的人更值钱。

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

期的利益牵着走,过早压榨掉了价值,如果能一开始就树立一个正确的长远的职业规划。35岁后的你只会比周围的人更值钱。

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

  • 28
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值