集合

204 阅读12分钟

Collection:

List

Vector:

Vector的底层使用线程安全的动态数组,它的扩容是一倍扩容,所以非常适合随机访问的场景。

ArrayList:

transient Object[] elementData。 transient表示该属性不会被序列化,但ArrayList实现了Serializable接口。因为ArrayList底层数组是动态扩容的,所以并不是所有被分配的内存空间都存有数据,所以ArrayList为了避免这部分没有数据的数组长度被序列化,它自己重写了writeObject()与readObject()来完成序列化,节省了空间和时间。

对ArrayList进行无参构造时,会先初始化一个空数组。

执行add()方法,数组扩容先为10(而JDK1.7就是饿汉式加载,创建对象时直接初始化一个容量为10的数组)。在add元素之前ArrayList首先检查可用空间,若当前元素个数+1超过了数组的最大容量,则会触发扩容机制。ArryaList会创建一个1.5倍大小的新数组(a+a>>1,若a为奇数则不足1.5倍),然后把旧数组内的元素拷贝到新数组。而后,如果1.5倍还是不够,则去比较最小需求量和数组最大阙值(Integer.MAV_VALUE),则把较小的一方作为新数组的容量。

执行get()方法,就是从数组下标直接获取元素。

LinkedList:

底层使用Node双向链表,它实现了Deque接口,具有队列的特性。LinkedList只会记录他的首尾节点,所以都使用transient修饰并重写readObject()与writeObject()自实现序列化整条链表。LinkedList是线程不安全的。而且链表是离散存储的,对CPU缓存不友好。

add()方法:默认直接在链表尾节点后面插入元素。若指定了索引位置,则也是以get()方式去遍历链表并插入到指定位置。

get()方法:他会去判断要插入的索引位置和size()<<1的大小,若索引位置大于容量的一半,则从链表尾开始变量;否则从链表头开始遍历。遍历到正确位置后返回元素即可。【正因为它每次get()时都要遍历判断索引位置,所以他的插入速度并不如ArrayList快。LinkedList,狗都不用 】

CopyOnWriteArrayList(并发容器):

内部维护了一个array数组,所有读操作都是对这个数组进行的。那它进行写操作时,会将array数组复制一份,然后在新复制的数组上执行add()操作,完成后再将arr[]的指针指向这个新数组。它仅适用于写操作非常少且能够容忍短暂的读写不一致的场景。因为在源码中,它会Arrays.copyOf(),频繁的内存申请和释放对CPU消耗很大。

遍历ArrayList / LinkedList:

使用Iterator有快速失败机制,若出现并发问题则会抛出ConcurrentModificationException快速失败

使用Iterator迭代器时,在ArrayList中会创建一个内部迭代器Iterator。使用next()方法获取下一个元素时,会去拿 ArrayList中的“List修改次数”变量modCount与Iterator中的“期望的修改次数”变量expectedModCount来做比较,若不相等则会抛出异常。

我们使用ArrayList.remove()方法,只会做modCount++,而不会同步进行expectedModCount++。使用Iterator.remove()则会同步++。而foreach语法糖遍历时也是使用Iterator迭代器遍历,所以如果使用ArrayList.remove()则会触发快速失败。

Set

HashSet:

底层是HashMap,直接使用key存放元素。HashSet中的元素必须重写hashCode()与equals(),因为相同值的不同对象的hashCode不同,若不重写则会重复添加同一值。

TreeSet:底层使用红黑树,构造方法可以接收Comparator比较器,可以作为顺序表使用。如果再问,就转红黑树。

LinkedHashSet:内部构建了一个记录插入顺序的双向链表,具有按照插入顺序遍历的能力。

Queue: 各种BlockingQueue、LinkedBlockingDeque双端队列、ConcurrentLinkedQueue

Map

HashTable:

HashTable是扩展了Dictionary类,与Vector同属于早期集合,其他的Map都是AbstractMap的子类。HashTable线程安全,但不允许有null键与null值。HashTble是直接使用Synchronized修饰各个方法,在高并发的场景下会存在大量的锁竞争。但是HashTable可以保证强一致性,可以保证立即获取写入的数据。

TreeMap:

底层使用红黑树,key是有序的,它必须实现Comparable接口或者提供Conparator外部比较器,所以key不允许为null,value允许为null。

Map的遍历方法:Map.keySet()、Map.values()→Collections.iterator()、Map.entrySet()、Map.forEach((k,v)->{...})

如何评价一个hash函数的优劣

能够将key均匀地映射在哈希表上、对哈希冲突有好的解决方法(链表法、开放地址法、再哈希法)、计算哈希函数要高效。

HashMap

HashMap是根据key获取对应的value,然后将value存储到key的hash值映射到的内存地址中。HashMap的底层基础都是数组,它实际上就是利用了数组支持按照下标随机访问的特性。JDK1.7的HashMap是以Node数组+链表来存储元素的,Node节点含有next指针,实现了链表的数据结构。HashMap还有两个属性,loadFactor加载因子和threshold边界值(即数组最大长度)。

加载因子默认为0.75(加载因子越大,对空间的利用就越充分,也就意味着链表长度越长,那么查询效率就会越低。所以在查询操作频繁时,我们可以减少加载因子;在对内存利用率较高时,我们可以增加加载因子。)

threshold边界值 = capacity初始容量 * loadFactor加载因子。 HashMap在添加元素时

①首先会计算key对应的存储位置:key对应索引 = (hashCode高十六位 ^ hashCode低十六位) & arr.length-1。

为了提高计算index索引的效率,数组长度大小必须是二次幂。因为数组的长度是都是二次幂,所以arr.length-1的前置位全是0。如果直接用hashCode进行与操作,则hashCode的高十六位会用不上,导致哈希冲突严重。所以采用hashcode高低十六位与的计算方式。因为数组长度是二次幂,所以hash()&length-1得到的索引值一定位于数组索引之内,不会出现错误的情况。 ②得到key对应索引位置后,HashMap会判断该索引位置的节点是否为null。若为null,则new出第一个Node节点并赋值给此索引位置;若不为null,则JDK1.7和JDK1.8有不同的对策:

JDK1.7:遍历链表元素,

1.若key与当前节点的键相同,则覆盖此节点并返回被覆盖的节点value。

2.若没有节点的键与key相同,提前判断是否需要扩容,然后头插法入链表。

JDK1.8

1.若节点为Node类型,则遍历链表元素。

若key与当前节点的键相同,则直接break跳出遍历。

若一直遍历到链表尾,则判断此时链表长度是否到达8,则判断数组长度:若数组长度小于64则触发扩容,然后把节点插入扩容链表;若数组长度大于64,则链表转换为红黑树(链表链表的每个节点,增加prev属性变为双向链表。然后把双向链表转换为红黑树。然后用红黑树的根节点覆盖数组索引位置),然后把节点插入红黑树。

2.若节点为TreeNode类型,把节点插入红黑树。

3.插入节点后,判断是否扩容(元素数量与阙值大小)。 扩容

JDK1.7:创建一个两倍数组,重新计算每一个节点的新索引位置,将原数组中的元素转移至新数组,若有哈希冲突则头插法入新链表。(补充:头插法时若新索引位置相同,则索引元素倒置,那在变换next指针的时候如果出现并发问题,在一个线程变换next时失去CPU,另一个线程又来为已经变换的节点增加next,导致循环引用。则再调用get()方法时就会出现死循环。)

JDK1.8:生成一个两倍数组,然后遍历老数组:首先JDK1.8的HashMap在扩容时并没有重新计算节点的新索引位置,因为是两倍扩容,所以变化的仅仅是length-1比原来多了一位bit。所以HashMap只会判断计算得到的hash的新增bit位是0还是1,若为0则索引位置不变,若为1则索引位置 = 原索引 + oldCap。

①若为单Node节点,则得到新索引位置后转移。

②若为Node链表,则先得到所有节点的新索引位置,然后把新索引位置相同的节点组成链表,根据头尾统一转移到新索引位置。

③若为TreeNode节点:根据节点的新索引位置把红黑树拆分成两个TreeNode链表(hash & arr.length<<1 = 1 / 0)。若两个链表都不为空,则各自转移至新索引位置,然后转换为红黑树;若其中一个链表为空,则判断:若非空链表长度≤6,则把TreeNode变为Node生成单向链表,转移至新索引位置;若非空链表长度>6,则直接把TreeNode链表转移至新索引位置。

【HashMap为什么要在1.8用红黑树】:解决链表越来越长导致的插入/查询效率变低的问题。

【为什么在大于阈值才转换红黑树,而不是一开始就是红黑树?】:因为红黑树在插入时还需要左旋右旋递归变化,所以元素个数比较少时,直接使用链表成本相对低一些。而且HashMap的源码注释里有写,TreeNode占用的空间是普通Node的两倍,所以只有到达阈值才会转为TreeNode。

【加载因子为什么是0.75】:加载因子指的是hash表中的元素填满程度,我们链表阈值长度是8,当负载因子为0.75时,根据松柏分布得到hash桶中链表到8的概率为0.000006,所以比较理想。所以它可以按照我们的需要来变大变小。

【HashMap的并发问题】

①put(k,v)方法不加锁,可能会导致并发覆盖问题,导致数据丢失。

②rehash扩容:两个线程同时进行扩容,都创建扩容数组。一个线程A假设在记录某个节点的next指针后就丢失了CPU,而另一个线程畅通无阻地完成了扩容。那对于JDK1.7是头插法,所以导致整条链表倒置,那就有可能出现,A线程所在的当前节点变成了它的next节点。而此时A线程已经记录了之前的next节点,所以就会发送a.next=b,b.next=a。后续再遍历的时候就会发生死循环。

③Map也有快速失败!(只要是集合类,都有快速失败)。所以我们可以只使用iterator的方法来修改Map,从而保证遍历时的并发问题。

ConcurrentHashMap

JDK1.7

使用Segment分段锁将内部进行分段,每个段中是一个HashEntry数组+链表。而且Segment继承了ReentrantLock,所以在并发操作时,只需要锁定相应Segment段就可以实现互斥,减少了锁粒度。

构造:Segment的数量默认是16,若传入参数会被自动调整为二次幂。

添加元素:先计算出key对应的Segment数组索引位置,判断索引位置是否为空。

若为空则会根据数组首位元素的参数和加载因子创建Segment对象,通过Unsafe调用以CAS方式加入数组中。

若不为空,则tryLock()尝试获取当前Segment的锁。若加锁失败则自旋等待;若获取成功,则遍历这个Segment中的HashEntry数组元素,将key插入HashEntry数组中。后续与JDK1.7的HashMap大致相同,但ConcurrentHashMap多了许多并发控制操作:①在插入过程中多次判断当前Node是否为null、②CAS牵扯多个变量,需要轮询、③在遍历链表完成后,会再一次判断头结点是否产生变化。若变化了则产生了锁重入,所以需要放弃此次并重新遍历。 扩容:首先需要判断元素总数。ConcurrentHashMap是通过重试机制,对比modCount的值前后两次获取有没有发生变化。如果没有发生变化则代表线程安全,直接返回元素总量;如果发生变化了,要对所以Segment加锁,然后统计元素总量并返回。若元素总量超过阙值,则进行Segment局部扩容。

JDK1.8放弃了分段锁,转而使用Synchronized + volatile的value来保证插入元素时的并发。ConcurrentHashMap在没有哈希冲突时会使用CAS进行添加,有哈希冲突时会使用Synchronized锁定链表,再进行添加。

在构造时,会使用volatile的共享变量sizeCtl作为互斥锁,通过CAS防止初始化时的并发竞争。

添加元素时:先计算出key对应的索引位置。若索引位置为null,则CAS插入数组中;若索引位置不为null但元素hash==MOVED(-1),则表示正在扩容,于是当前线程开始辅助扩容;若以上都不满足,则使用synchronized对索引位置节点加锁,然后将key插入链表或红黑树中。后续与JDK1.8的HashMap大致相同。

扩容时:首先需要判断元素总数。ConcurrentHashMap维护了一个conterCell数组,它与HashEntry数组一一对应,conterCell数组中的conterCell对象的value属性代表了HashEntry数组中对应下标的链表/红黑树长度。添加元素后,conterCell.value属性通过CAS++,若失败两次则会对全局变量baseConut通过CAS++。那么元素总数就为baseCount + 所有conterCell对象的value值总和。
四大发

LinkedHashMap:它为键值对维护了一个双向链表,提供以插入顺序为基准的顺序遍历。

ConcurrentSkipListMap:基于跳表实现,key是有序的,适用于线程安全且大数据量存取的场景。红黑树在需要保证线程安全的情况下,删除和插入后的平衡操作会牵扯到大量节点,竞争锁资源的代价相对较高,所以可以使用跳跃表代替

跳跃表由若干层链表组成,最低层包含了所有数据,然后向上冗余出若干层作为有序链表的索引,每层的相同元素通过指针相连。每层数据依次减少,查询时从顶层开始查找,就能根据间隔节点来缩小查询范围。

ComputeIfAbsent()和putIfAbsent()的区别:若value已经计算好,则用后者;若value需要耗时计算,则用前者把计算封装在函数中,只有key不存在时才会走函数。

不能使用asList()a转化基本数组:Arrays.asList()不能直接把int[]变为List,它可以自动装箱int,但不能自动装箱int[]。我们可以使用Integer[]或者Stream来操作。 Arrays.asList()得到的List不支持add与delete增删操作。 Arrays.asList()得到的只是原数组的引用,并没有在堆中new一个新集合,所以会产生影响,而且强引用导致数据不能GC从而OOM。所以我们一般要new ArrayList(Arrays.asList())