Java集合之总集篇


整体框架图

本文主要从整体上介绍Java集合框架的结构,以及划分依据,不涉及太多细节。所有内容不包括java.util.concurrent,这个包的相关笔记会单独整理。
首先给出java.util包的整体继承图。
java集合.png

整体框架的划分及其逻辑

首先集合框架分为Collection和Map两个顶层接口,通常来说接口是用来定义协议的,说明这个接口及其实现类需要提供的功能。Collection和Map的区别在于Collection存单个的Object,而Map存键值对。根据这一点,顶层划分了两个接口。插一句,我们不讨论Dictionary的原因是因为该类实现的太早,已经不符合现在java的设计规范。这个类还没有被废弃的原因是因为其子类HashTable和Properties在项目中使用的太多了。同时,虽然HashTable是线程安全的,但是其实现线程安全的方式是使用synchronized关键字,其效率完全依赖于JVM层面的锁的相关优化,通常表现不是太好,所以不应该再使用HashTable而应该使用ConcurrentHashMap代替。

Map分支

需要注意的是,Map顶层接口提供的功能概况来说为键值对存储。而不包含提供便捷的Hash查找方式,很多情况下我们会将Map等同于HashMap,这是不对的。比如TreeMap,其功能实现则不依赖于Hash。
这里有一个小插曲,我们在JDK源码中经常可以看到这样的代码:

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

其中,AbstractMap是Map接口的抽象实现,而HashMap继承了AbstractMap,按道理不应该在实现Map接口。集合类的设计者是这样解释这个问题的,他们说这是一个小失误,但是这个失误并不会带来什么后果,所以他们也没有对其进行修正。所以我们在分析继承关系的时候,对于因为这个原因形成的额外的关系不加讨论。

Map的子接口或者抽象子类主要包括SortedMap,AbstractMap。产生这两个分支的原因主要在于是否堆Map中元素有顺序要求。当我们对键值对的顺序有要求的时候,就应该使用SortedMap的具体实现类,比如TreeMap,或者实现SortedMap接口,而当我们没有顺序要求时,直接继承AbstractMap是一个很好的选择。AbstractMap实现了Map接口规定的一部分通用功能。

Map.SortedMap分支

SortedMap为Map添加了顺序规则,当我们不指定Comparator时,其为元素自然顺序。当元素有顺序的时候,我们就可以根据条件寻找符合要求的元素,比如最大,最小等等。而NavigabelMap则继承了SortedMap并扩展了上述功能。

这一分支的最终实现类是TreeMap,这是JDK中实现的红黑树。TreeMap会根据默认或者指定的比较规则将元素组织成一棵红黑树进行存储,其目的是为了在满足元素有序的情况下,插入和查询都有不错的效率。所以以后不要再问怎么让HashMap中的元素有序这个问题了,别人压根就不是这样设计的,要维持元素有序应该使用TreeMap或者自己实现NavigableMap接口。

Map.AbstractMap分支

AbstractMap实现了Map接口中规定的一些通用功能。TreeMap继承这个类是为了复用这些功能。除了TreeMap之外,其实现类还有EnumMap,IdentityHashMap,WeakHashMap,HashMap。

其中EnumMap是为key为Enum值设计的专用Map,其可以实现该条件下K,V的高效映射。这里详细说下这个类,后面不会专门在讨论这个了。我们知道,对于Enum对象,其枚举值数量提前可知且每个枚举值都有一个固定的顺序,而EnumMap则利用了这个特点,其底层采用了双数组的形式。

// 核心成员变量
private final Class<K> keyType;
private transient K[] keyUniverse;
private transient Object[] vals;

// 核心构造函数
public EnumMap(Class<K> keyType) {
        this.keyType = keyType;
        keyUniverse = getKeyUniverse(keyType);
        vals = new Object[keyUniverse.length];
    }

在创建EnumMap的时候必须显示或者隐式得指明枚举类型,构造函数会将该枚举类型存储在keyType中,然后根据该枚举类型获取其枚举值并保存在keyUniverse中,然后根据枚举类的枚举类型数量初始化vals数组。之后的put和get操作则是以枚举值的ordinal作为索引直接操作vals数组。从而获得近似数组操作的性能。

之后是IdentityHashMap,这个类也要详细介绍下,后面也不会专门介绍了。为了说明这个类实现的功能,我们先来讨论一下"==",hashcode和equals之间的关系。首先需要说明的是"==“的语义就是比较内存地址,只是java早期因为种种原因保留了基本数据类型,虽然之后推出了包装类,但是基本数据类型还是被广泛使用,基本数据类型不是对象,所以使用”==“比较其内存地址不在合适,于使”==“有了第二个语义,即当比较对象是基本数据类型的时候,比较的是它的值。然后是hashcode方法,我们知道这个方法是直接定义在Object中的,而且它是一个native方法。其实现的功能大家也知道,获取保存在对象头中的该对象的HashCode。这个值是在对象实例化的时候根据对象的内存地址计算出来然后存储在对象头中的,顺便提以下,对象头中还保存了对象的垃圾回收信息,比如是新生代还是老年代等。以及锁的相关信息,这是整个j.u.c的基础。这些都会在后面文章详细介绍。而equals也是Object中的方法,而且它只是对”=="的简单包装。所以通过equals比较相等的对象其HashCode也一定相等,但是hashcode返回值相同的对象未必一定相同,这是三者的语义保证的,并不是强制规定。但是麻烦在于,大量的类重写了equals和hashcode方法,为了保持前面的特性,强制要求重写equals和hashcode以保证equals相等则hashcode返回值相等。于是给了我们一种错觉,感觉equals的语义特别多。这种情况在XXXHashMap中也存在,equals是XXXHashMap中大量功能的基础,因为Map需要维持Key的唯一性,所以大量功能是以对象比较为基础的,这里就出现了一个问题,对于equals究竟该采取那种方式,是使用Object中equals的语义还是重写equals赋予新的语义。IdentityHashMap采用了前者,而HashMap采用了后者,也就是说在IdentityHashMap中,即使两个对象的所有特征都相同(成员变量相同),也是可以共存的。这在HashMap中表现则不相同,关于HashMap中的情况将在后文进行分析。

WeakHashMap也是针对特殊情况实现的HashMap,解释这个类之前,需要了解在Java中持有对象的几种情况。持有对象是指持有对象的引用,持有对象的引用本质上是在影响JVM的GC行为。简单的讲,GC的回收行为是以对象引用关系为基础的,当对象不在与任何StrongReference相关联的时候,垃圾回收器会认为这个对象是可以被回收的。在很早之前,我们影响GC行为的手段很少,通常我们想要释放一些对象,只能显示得设置为null,如果此时需要及时的进行GC行为,还需要通过GC相关API通知GC进行处理。但是这样操作的代价是非常大的。虽然操作的代价很大,但好在我们还能通过现有API实现我们的需求,那么有没有需求合理但是不能基于这些前提实现的呢?答案是存在的,老生常谈的一个场景就是缓存,它代表这样一种情况,我们希望在内存资源充足的时候多保存一些额外的信息,提高程序的整体性能,但是在内存资源不怎么充足的情况下,释放这一部分数据,保证程序正常运行。这个需求的难点在于我们不知道什么时候将这些引用设置为null以支持GC的回收行为。为了解决这个问题,Java对引用的类型进行了扩展,让我们能够通过引用的类型直接影响GC行为。Java的引用类型主要分为强引用(StrongReference),软引用(SoftReference),弱引用(WeakReference)和虚引用(PhantomReference)。简单来说,强引用就是我们通常使用的引用类型,与这种引用关联的对象绝对不会被GC回收,而软引用则表示当内存不足的时候,只存在软引用的对象是可以被回收的,而弱引用则表示只要进行GC行为,只存在弱引用的对象就可以被回收,至于虚引用,基本等于对象和这个引用是没有关系的,甚至能不能通过这个引用拿到对象也是不确定的。有了这些了解之后,理解WeakHashMap就比较容易了,这个类就是在HashMap得基础上将对象容器持有对象得方式改成了弱引用,那么也很容易理解这个类为什么在缓存场景中大量被使用了。

最后是HashMap,这是我们平时接触和使用最多的一个类。它提供了经典的基于键值对存储的数据结构,并且在底层对于查找和插入进行了大量的优化,并实现了自适应容量调整的功能,这个类会在后面文章中详细进行分析。同时提一下LinkedHashMap,这是HashMap的一个子类,它维持了元素的插入顺序,简单臆想一下,它是通过链表的方式实现这个功能的,实际上也确实如此。需要说明的是,插入顺序并不是我们程序意义上的顺序,原因在于对于程序上的顺序是指实现了Comparable接口,可以通过Comparator进行再排序的数据集。而插入顺序当这个顺序被打乱之后如果没有其他手段辅助是没办法恢复顺序的,所以LinkedHashMap并不在SortedMap的继承树当中。所以要再次提醒,对于需要维护顺序的Map,请不要在HashMap这个分支上找解决方案。当然,你要自己扩展也是可以的。比如通过代理模式,在代理类中维护一个需要的顺序,当然这种思路也适用于实现固定大小Stack,Stack继承于Vector,Vector是一个自适应大小的List,可以通过在代理类中拦截超出容量的相关操作以实现一个固定大小的Stack。

Collection分支

Collection分支下主要有三个次顶级接口,分别为Queue,List,Set。在这个层次划分三个接口的原因在于,Queue实现的是数据结构中队列的语义,List是线性表的语义,Set是集合的语义。他们的区别在于,List与Queue是允许相同元素的,而Set是严格不允许相同元素的。Queue与List的区别在于Queue中元素不能通过索引访问,也就是说Queue对元素的操作只能在队首和队尾。而List可以通过索引访问所有的元素。在这里我尽量避免对于null元素的说明,因为null元素对于每个具体的实现类都不一样,主要是在顶层接口中都没有对null元素做出强制性的规定,具体到实现类来说,List一般可以很好支持null元素,但是Queue和Set是有限制的支持null。但是在顶层接口中有建议性行为,比如Queue是不支持null的,但是确实有实现类没有遵循这些规则,原文如下:

*   `Queue` implementations generally do not allow insertion of `null` elements, although some implementations, such as [`LinkedList`](../../java/util/LinkedList.html "class in java.util"), do not prohibit insertion of `null`. Even in the implementations that permit it, `null` should not be inserted into a `Queue`, as `null` is also used as a special return value by the `poll` method to indicate that the queue contains no elements.

所以在这一层次不应该去讨论是否支持null的问题。那么为什么会出现这样的原因的,后面在涉及到具体实现的时候会说明,简单提一下就是除了逻辑上null是否合理之外,还需要考虑具体实现,比如以数组作为物理结构的实现类中,多个null就可以被很好支持,但是基于比较实现功能的类比如Map就无法支持多个null等等。顺便提一句,在使用集合类的时候,不管是否支持null,我们都不应该使用null,通常对null的情况进行包装是比较合理的,因为不仅集合会对null做限制,通常来说当我们获取到null的时候,语义也是不确定的,比如,在一些情况下,我获取到null并不能知道这个null是我获取到的元素还是因为这个元素不存在而导致集合类返回的null。

Collection.Queue

Queue是Java中队列这种数据结构的顶层接口,根据数据结构中关于队列的定义我们知道,队列是一种只能在首尾进行操作的线性表,所有一个类如果只隶属于Queue这个分支,则其不能按索引访问,比如PriorityQueue。Queue规定了其实现类必须实现两套操作数据的接口,一套接口在失败的时候需要抛出异常,它们分别为add,remove,element。另一组需要返回一个特殊值,通常是null,它们分别是:offer,poll,peek。通常来说,一个queue会按照插入的顺序或者指定的顺序来维护内部元素,加上queue只能在头尾进行操作的特性,自然的它就具有FIFO或者LIFO的特性,也就是队列最基本的性质。Queue的直接继承有Deque和AbstractQueue,其中AbstractQueue实现了Queue规定的一些通用功能,比如,将Queue中两套接口进行了统一,即抛出异常的接口只是返回特殊值接口的一个包装。

Deque的含义是指双端队列,上文中在描述Queue的时候说只能在头尾进行操作更具体的说法是,在尾插入,在头弹出,也就是说FIFO,而Deque则支持在头尾插入,在头尾弹出,即双端队列。其扩展了Queue接口,添加了实现其功能的相关函数,同时保留了Queue中的相关函数,即Deque可以被作为Queue使用并且提供与Queue中功能相同的函数。但是实际上,Deque可以实现的功能会更多,比如栈,比如双端栈,比如受限制的双端队列等等。

Deque的直接实现包括两个,第一个时ArrayDeque,第二个时LinkedList。其中LinkedList还继承了List接口,所以它具有随机访问的能力,也就是说LinkedList按索引访问的能力并不来自于Deque接口。简单分析一下ArrayDeque和LinkedList,其实从名字中就可以看出来,ArrayDeque是基于数组实现的,而LinkedList是基于链表实现的,所以各自的优缺点也不用多说了吧。

AbstractQueue的直接实现类是PriorityQueue,其含义是只优先级队列,在介绍Queue的时候我们说队列一般按照插入顺序维护元素,同时说其可以支持自定义顺序,而实现这个功能的就是优先级队列。PriorityQueue底层是基于堆来实现的,通常来说只要元素开始有顺序了,那么就可以做很多文章了,比如维护元素有序的同时要求可以快速获取,则要求查询高效,此时,我们就可以使用红黑树作为其底层结构,因为红黑树可以维持元素顺序且具有良好的插入和查找性能,你没想错,我说的就是TreeMap。但是PriorityQueue并不要求随机查询,延续Queue的特性,它只在头进行操作,所以此时使用堆来完成这个功能就很不错了,首先保存一个堆只需要一个数组就可以了,不需要记录太多额外的信息,同时插入后堆的调整消耗也远低于红黑树。所有队列的插入和删除与堆的弹出和插入相对应。那么可不可以选择其他数据结构来实现呢,当然可以,只是需要自己动手而已。

Collection.List

List是Java中线性表的顶层接口。线性表最大的特点就是支持随机存取,这也是List接口最大的特点。List和Queue是允许元素重复的,而Set是不可以的。这很好理解,因为只要我们可以唯一标识一个元素,那么即使这个元素有一部分是重复的也没有什么影响,比如,在List和Queue中元素签名除了元素本身,还包括元素顺序。比如在ArrayList或者ArrayQueue中,则可以允许元素重复。但是这容易给我们一个错觉,比如,ArrayQueue中,底层采用对象数组进行存储,在数组中不同位置存放相同的元素没有什么问题,于是我们就觉得只要底层数据结构支持,元素就可以重复,或者干脆认为这种特性就是数组赋予的,这种认识是有缺陷的,准确的说,底层数据结构支持是元素可以重复的必要条件而非充分条件。比如EnumMap,我们说其底层是采用双数组进行元素记录,但是他的Key是不允许重复的,而Vals数组又是可以重复的,也就是说它是两种情况共存。所以元素是否可以重复基本上可以遵循以下规则:首先确定语义上是否支持,比如Set就强制要求元素不重复,还有Map的key也要去不重复。当语义上没有规定的时候,就需要看实际实现是否支持。

还有一点需要说明的是,Collection都有一个迭代器,而List实现了一个比较特殊的迭代器,这个迭代器支持在迭代的过程当中对元素进行增删改,但是注意,这在一个线程中是ok的,你不能在多个线程中同时进行这种操作,否则会抛出一个快速失败的异常。简单提一下安全失败和快速失败,安全失败我们放在j.u.c中进行讨论,说下快速失败,它的出现场景大家应该都清楚,就是在多个线程中使用非线程安全的集合类的迭代器对集合元素进行修改时,会抛出快速失败的异常。其实现原理在后文讨论,只插一句,当涉及到多线程的时候,多想一想,是否应该使用线程安全的集合类,至于什么是线程安全的集合类,除了极个别类(HashTable,Vector),其它的都在j.u.c包下面,换句话说,我们现在讨论的都是线程不安全的。

AbstractList直接实现了List接口中的一部分功能,AbstractList的实现类包括,Vector,ArrayList,AbstractSequentialList。其中Vector分支我们不详细讨论,它的问题和HashTable的问题相同,简单提一两句,Vector和ArrayList的区别在于,Vector是线程安全的,他们的扩容策略也有一些不同。但是Vector的线程安全还是依赖于synchronized关键字,效率太低。而Vector的直接继承类Stack,从名字来说,他要实现栈的功能,这个我没有细看,但是从我的角度来想,将Stack放在Queue的分支岂不是更好,毕竟Stack是不需要随机访问的。而且Deque的功能本质上已经是Stack功能的超集了。看来有时候还是理解不了大佬们的思想啊。回过头来,我们继续介绍ArrayList,这个我没有什么好介绍的,天天在用,一些细节在后面的文章中再讨论吧。至于AbstractSequentialList,给大家翻译以下,Sequential连续的,这个我只能臆想一下,我的理解是,与ArrayList对比,数组中元素是可以不连续的,但是链表中元素一定是一个接一个的。或者直接来其实现类LinkedList,LinkedList还实现了额外的接口,Deque,也就是说LinkedList同时具有两者的特性,即队列与线性表的特征,也就是说它可以作为双端队列使用,也可以随机访问。

Collection.Set

Set是Java中集合语义的顶层接口,集合,即无序且元素不重复。

Set分支情况和Map非常相似,或者说很多Set的实现类都是依赖于Map的。Set的子类和子接口主要是AbstractSet和SortedSet。SortedSet这个接口为Set添加了顺序限制,其子接口,NavigableSet在元素有序的情况下提供了元素定位的功能,其具体实现类TreeSet底层依赖于TreeMap实现。AbstractSet直接实现类包括HashSet,EnumSet,TreeSet。HashSet及其子类LinkedHashSet都依赖于HashMap实现,所以这几个类我们不详细讨论。至于EnumSet与EnumMap的实现思想相同,只是多了两个子类RegularEnumSet和JumboEnumSet,实际上我们不必要关心这两个子类,因为它是为EnumSet服务的,优化了一些相关操作。使用时,根据我们在初始化EnumSet时指定的容量大小,分别返回这两个子类。

结语

本文的目的在于描述Java集合的整体框架,以及形成每个分支的原因,这在我们需要扩展集合类的时候很有帮助,比如我们自己的集合类有顺序要求,就去sorted开头的分支上去扩展,也不是说不是这个分支就不行,只是这会造成事倍功半的情况。另外,文中有很多东西是自己的理解,困于本人水平所限,如有错误,欢迎指正。

Java集合之HashMap–内容很多
Java集合之TreeMap–内容很多
Java集合之PriorityQueue–内容很多
Java集合之ArrayList、LinkedList–有一些内容
Java集合之TreeSet、HashSet–基本无内容
Java集合之ArrayDeque–有一些内容

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值