You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
android-notes/blogs/Java/口水话/集合源码.md

15 KiB

集合源码口水话

目录

  1. ArrayList
  2. Vector
  3. LinkedList
  4. HashMap
  5. Hashtable
  6. TreeMap
  7. LinkedHashMap
  8. HashSet
  9. TreeSet
  10. CopyOnWriteArrayList
  11. ConcurrentHashMap
  12. SparseArray

ArrayList

ArrayList 实现了 List 接口,RandomAccess 接口,可以插入空数据以及支持随机访问。它相当于一个动态数组,初始化时是一个空数组,在第一次 add 时设置初始容量为 10,每次扩容都增加到原来的 1.5 倍。简单的 add 就是在 elementData 数组末尾添加一个数据,size++;指定 index 添加数据,就需要拷贝 index 后面的数据后移一位。在删除的时,如果是删除 null,就遍历数组找到第一个 null 值删除,否则就遍历比较 equals 删除指定 index 的数据,其实也就是拷贝 index 后面的数据前移一位。删除数据时,最好使用迭代器来做,避免 CurrentModifyException,它并不只是在并发时才会抛出的,单线程也可能抛出,其实内部是比较 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。

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. volatile 关键字修饰的是数组,如果只是简单的在原数组上修改其中某几个元素的值,是无法触发可见性的,我们必须通过修改数组的内存地址才行,也就说要对数组进行重新赋值才行
  2. 在新的数组上进行拷贝,对老数组没有任何影响,只有新数组完全拷贝完成之后,外部才能访问到,降低了在赋值过程中,老数组数据变动的影响

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

CopyOnWriteArrayList 读的时候不需要加锁,适合读多写少的场景,但是每次变动数组,都会拷贝一份新数组,可能会加重 GC。

ConcurrentHashMap

ConcurrentHashMap 和 HashMap 相比,它的底层也是使用数组 + 链表 + 红黑树来实现的,不过新增了转移节点(ForwardingNode),是为了保证扩容时的线程安全的节点。而且红黑树结构略有不同,HashMap 的红黑树节点叫做 TreeNode,TreeNode 不仅仅有属性,是维护者红黑树的结构,比如说查找、新增等;ConcurrentHashMao 中红黑树被拆分成两块,TreeNode 仅仅维护属性和查找功能,新增了 TreeBin,来维护红黑树结构,并负责根节点的加锁和解锁。

和 HashTable 相比,它也是线程安全的,但是 ConcurrentHashMap 多个线程同时进行 put、remove 等操作时并不会阻塞,可以同时进行;HashTable 在操作时,会锁住整个 Map。

ConcurrentHashMap 在 put 时,在一个 for 死循环里面,也就是一定能保证 put 成功。第一次 put 时,会先通过 CAS 保证只有一个线程扩容,如果有其他线程在执行扩容操作,就会调用 Thread.yield 释放当前 CPU 调度权,重新发起锁的竞争,这一步是在一个 while 循环里面去做的只要容量为空,就一直循环。再求出 table 索引之后,如果该槽点为空并不是直接新增,而是通过 CAS 新增。如果判断当前是转移节点(转转移节点的 hash 值固定为 MOVED 为 -1),表示该槽点正在扩容,就会一直等待扩容完成;如果当前槽点有值,就是 key 的 hash 冲突的情况,此时槽点上可能是链表或红黑树,这时候就会通过 synchronized 锁住槽点,来保证同一时刻只会有一个线程能对槽点进行修改。也就是说,put 是通过自旋 + CAS + 锁来实现线程安全的。

在进行扩容时,首先需要把老数组的值全部拷贝到新数组上,先从数组的队尾开始拷贝,拷贝数组的槽点时,会先把原数组的槽点,保证原数组槽点不能操作,成功拷贝到新数组时,把原数组槽点赋值为转移节点。如果此时有数据要 put 到此槽点就会一直等待。拷贝时也是扩容原数组两倍大小。

ConcurrentHashMap 的 get 和 HashMap 基本无差。

SparseArray

SparseArray 是 Android 中一种特有的数据结构,用来替代 HashMap 的。它里面有两个数组,一个是 int[] 数组存放 key,一个是 Object[] value 数组。也就是它的 key 只能为 int;在 put 时,会根据传入的 key 进行二分查找找到合适的插入位置,如果当前位置有值或者是 DELETED 节点,就直接覆盖,否则就需要拷贝数组后移一位,空出一个位置让其插入。如果数组满了但是还有 DELETED 节点,就需要调用 gc 方法,gc 方法所做的就是把 DELETED 节点后面的数前移,也就是真正的把 DELETED 节点删掉然后在插入,否则就只能扩容了。

调用 remove 时,并不会直接把 key 从 int[] 数组里面删掉,而是把当前 key 指向的 value 设置成 DELETED 节点,这样做是为了减少 int[] 数组的结构调整,结构调整就意味着数据拷贝。但是当我们调用 keyAt/valueAt 获取索引时,如果有 DELETED 节点就必须得调用 gc,不然获得的 index 肯定不对。

get 方法就比较简单了,二分查找获取 key 对应的索引 index,返回 values[index] 即可。

可以看到,SparseArray 比 HashMap 少了基本数据的自动装箱操作,而且不需要额外的结构体,单个元素存储成本低,在数据量小的情况下,随机访问的效率很高。但是缺点也是显而易见的,就是增删的效率比较低,在数据量比较大的时候,调用 gc 拷贝数组成本巨大。

除了 SparseArray,Android 还提供了 SparseIntArray(int:int)、SparseBooleanArray(int:boolean)、SparseLongArray(int:long) 等,其实就是把对应的 value 换成基本数据类型。