Java容器类总结与对比

1,814 阅读9分钟

一、概览:

Java容器类分为大体上分为Collection与Map两类,其中Collection主要用于存储对象的集合,而Map主要用于存储映射关系;

二、Collection:

Collection接口继承了 Iterable接口,所以实现了该接口的所有容器都可以通过迭代器的方式进行遍历(foreach为典型的一种迭代器遍历方式,并且使用迭代器遍历时如果出现多线程结构性修改一个非安全的容器会导致java.util.ConcurrentModificationException异常,比单纯的for循环多了报警机制)

2.1 List接口:

2.1.1 ArrayList(基于动态数组实现,数据结构中的顺序表):

  1. 默认初始化容量:10 (在新建List时给予适当的初始化容量,可以避免频繁的数组扩容);扩容是通过Arrays.copyof()方法进行扩容,每次扩容为旧容量的1.5倍,但扩容时需要将原数组整个复制到新数组中,成本很高;
  2. 查询,插入与删除元素:因为ArrayList是implments RandomAccess 接口,所以ArrayList支持快速随机访问,即可以通过get(int index)方法获取指定位置的元素;删除元素时,因为ArrayList底层是通过数组来实现的,所以当删除一个第i个元素后,要把该元素之后的所有元素向前移动,故时间复杂度为o(n-i),插入同理;即ArrayList的插入与删除元素的时间复杂度与元素位置相关,如果在最后add元素则时间复杂度为o(1),并且ArrayList的add方法会默认将元素添加至末尾

2.1.2 LinkedList(基于双向链表实现, 数据结构中的双向链表):

  1. 查询,插入与删除元素:因为双向链表的实现机理,所以每个节点都需要包括前节点的指针,后节点的指针和自己的data域;LinkedList不支持高效的快速随机访问;插入与删除元素时,如果目标元素是通过addFirst() 或 add()方法插入,则不需要进行寻找相应index的过程,直接插入即可;如果时指定位置的插入或删除,则需要通过判断index与(head + last) / 2的关系,决定是从尾节点查找还是头节点开始查找,此时的时间复杂度近似o(n);

2.1.3 适用场景的比较:

参考文章:

juejin.cn/post/684490…

该文章通过事实说话,使我摆脱了多插入删除用LinkedList,多查找用ArrayList的固定思维;因为LinkedList的插入删除的复杂度在于找到对应的index,这里只能遍历;ArrayList的性能瓶顶在于插入或删除元素后所有的元素都需要调用System.arraycopy 方法将后面的元素进行相应的前移或后移;并且如果没有设定好初始容量的情况下,需要不断调用native方法arraycopy()进行扩容,但由于是本地方法,并且数据的移动是在连续空间下进行的,所以花费的时间并没有想象中的那么多; 结论: 数据量破万,头插时,LinkedList效率高,而在中间插或尾插都是ArrayList的效率高一些 原因:尾插时,ArrayList每次需要移动的数据量为0,并且数据量大时,每次扩容为原先的1.5倍导致新增的空间很客观,而此时的LinkedList每插入一个节点,都需要建立建立一个Node对象,所以相比之下,前者的效率更高;而在中间插入更是如此,LinkedList的随机查询能力很弱,要不断遍历,所以此时还是ArrayList更快; (ps:使用迭代器时,可在一定程度上弥补LinkedList的这一弱点)

2.1.4 CopyOnWriteArrayList(底层基于Object[] 数组实现):

  1. 线程安全:使用迭代器遍历时,多线程修改也不会抛出java.util.ConcurrentModificationException异常,因为迭代器遍历的是原数组(即使在遍历的过程中另一个线程修改了容器,但迭代器遍历的仍然是之前的数组)
  2. 写时复制:其实可以看出,HashTable->ConcurrentHashMap,vector->CopyOnWriteArrayList,其实本质上都是锁粒度的改变(可以类比Mysql的锁粒度,锁住的如果是页,则并发的级别限制在页;而如果是行锁,则并发的级别限制在行);COW在线程调用set(), remove(), add()方法时会通过Arrays.copyof()方法新建一个数组,并且在修改完成后将数组的引用重新赋给COW,而此时原数组仍然在对外提供get()服务,保证了并发性;
  3. 适用场景:多读少写,因为每次写都需要两倍的存储空间,并且写操作需要加上ReentrantLock(),存在一定的消耗;并且COW还有一个缺点,无法保证数据的一致性,因为原数组与新数组之间存在一定的时间差,所以不适用与对实时性很高的场景;

2.1.5 Vector与HashTable:

  1. Vector与ArrayList()类似,只是大部分方法都通过synchronized进行修饰,所以容器的访问速度更慢,现在已经几乎不用;HashTable同理;

2.2 Set接口:

2.2.1 HashSet:

  1. 主要特性(场景):在实践中主要通过HashSet进行去重,因为HashSet在每个元素插入时会通过元素的HashCode和equals方法来进行判别当前Set中是否已经有相同的元素,而为了使得相同对象的hashCode值相同,所以当我们覆盖默认的equals方法时(该方法默认是==,即判断两个对象是否引用的相同对象),必须重新覆盖hashCode方法(因为默认的散列方法可能会导致即使相同的元素也会产生不一样的hashCode,而这样就会导致不会进入到equals()方法去判断,所以我们需要去复写散列方法);
  2. 实现原理:主要是在HashMap的基础上进行的修改,并利用了HashMap中Key的唯一性,在HashSet中是使用HashMap的Key来存储add()到Set中的对象的,而Value值则为一个默认的PRESENT常量;

三、 Map接口:

3.1 HashMap(数据结构中查找表的实现):

  1. 初始容量及扩容:HashMap 默认的初始化大小为16;之后每次扩充,容量变为原来的2倍,并且每次扩容后都需要重新计算HashCode & (n - 1) //n为数组的长度,因为容量为2的n次方,所以可以用位操作代替原有的HashCode % capacity操作,然后对原有的Entry进行重新put,所以HashTable的resize()是很费时的,如果需要自定义初始化大小,则大小必须是2的次方;
  2. 底层数据结构:一个Entry类型的数组,Entry为一个链表,其包含了K,V,HashCode值以及指向下一个Entry<K,V>的next指针;
  3. 解决Hash冲突:算法橙皮书中提到了两种方法,Java采用了拉链法解决(头插法,因为如果尾插效率会慢很多),在JDK1.8之后如果链表长度大于8,并且总Entry数大于64时,会将链表转换成红黑树来提高查询效率;
  4. 多线程不安全:HashMap是线程不安全的,主要原因是在两个线程同时put时发现扩容的阈值到达了,两个线程便同时进行resize,这时会出现环形链接的问题(Infinite Loop),这不是HashMap的问题,虽然在JDK1.8时通过使用尾插法进行扩容解决了这个问题,但如果需要线程安全则使用ConcurrentHashMap; 具体文章可参考 coolshell.cn/articles/96…

3.2 ConcurrentHashMap:

  1. 与HashMap的实现基本相同,主要解决HashMap线程不安全以及HashTable效率低下的问题,HashTable为整个容器共用一把synchronized锁,所以当一个线程进行put操作时,另一个线程不可以进行任何被synchroniezd修饰的方法,如get().put()等;ConcurrentHashMap在JDK1.7时最主要是采用分段锁(Segment),分段锁继承了ReentrantLock,每个分段上维护了一定量的桶,多个线程可以同时访问不同分段锁上的桶,从而使其并发度更高,其并发度就是分段锁的个数;JDK1.8时摒弃了1.7的做法,而采用了CAS + synchronized来保证线程安全性,put操作时,首先利用 CAS 尝试写入,失败则自旋保证成功;如果还是不可以则使用内置锁synchronized,并且synchronized只锁table中的首节点,我个人理解为相当于每个分段的大小为1,并且不使用分段锁去维护,进一步降低了锁粒度,提高了并发性;

3.3 LinkedHashMap

  1. 为什么有了HashMap还需要LinkedHashMap?因为当我们用迭代器通过KeySet()去遍历HashMap时得到的结果并不是我们放置的顺序,而在一些我们对数据的顺序性要求高的时候,HashMap无法解决这个问题,所以LinkedList应运而生,其内部通过HashMap存储数据,通过LinkedList维护插入顺序;也可以在构造时传入accessOrder参数,使得其遍历顺序按照访问的顺序输出;
  2. LinkedHashMap与HashMap一样,是线程不安全的;
  3. 实现方法,其绝大部分方法与HashMap无异,其多出来的操作主要是在Entry插入HashMap时,同时维护一个双向链表的顺序,所以需要复写HashMap的put()和get()方法,而get()方法主要是为了当LinkedHashMap初始化时如果指定了accessOrder为true时需要调整双向链表的顺序;
  4. 应用场景:LRU(将accessOrder参数设置为true)

3.4 WeakedHashMap

  1. WeakedHashMap中的Entry继承自WeakReference,而被其关联的对象会在下一次垃圾回收时被回收(此处可以联想到GC的可达性回收算法,引用的四个等级为强软弱虚,此处即为弱引用,而被强引用关联的对象是几乎不会被回收的)
  2. 应用场景:Tomcat中的ConcurrentCache(ConcurrentCache的设计在一定程度上借鉴了Java虚拟机的垃圾回收算法,按照分代进行缓存,一开始将缓存加入eden区,而将一段时间没有被使用的缓存对象放入老年代,并用WeakedHashMap进行关联,从而利用虚拟机垃圾回收的特性对这些可用性较低的缓存进行清理)

3.5 总结:

  1. 我们可以发现,HashMap衍生的几种Map其实都是按照自己的特性修改了Entry(比如ConcurrentHashMap,为了多线程下的可见性,将Entry的V值设置为volatile),也在一定程度上按照原有的put(),get()等方法上利用Java的多态重写了其中的一些子方法从而达到特殊化的目的,其实这种设计在一定程度上也可以给予我们一些启迪。