--- 集合源码口水话 --- 目录 1. ArrayList 2. Vector 3. LinkedList 4. HashMap 5. Hashtable 6. TreeMap 7. LinkedHashMap 8. HashSet 9. TreeSet 10. CopyOnWriteArrayList 11. ConcurrentHashMap #### 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,它是不允许 key 或 value 为 null 的。它的底层也是使用数组 + 链表 + 红黑树来实现的,