今天分享Java学习的第五个专题——集合。
集合可以看作是一种容器,用来存储对象信息。所有集合类都位于java.util包下,但支持多线程的集合类位于java.util.concurrent包下。
Java集合类主要由两个根接口Collection和Map派生出来的,Collection派生出了三个子接口:List、Set、Queue(Java5新增的队列,本文暂不提及),因此Java集合大致也可分成List、Set、Queue、Map四种接口体系,(注意:Map不是Collection的子接口)。
下面我们来一个一个看下这些接口,如何使用,以及源码分析。
Collection接口
所属java.util.Collection包下,常用的方法如下:
Iterator接口
不难发现,Collection接口继承了Iterable接口,而Iterable接口又包含了Iterator迭代器,这说明实现Collection接口的类都是可以迭代的。
我们遍历一个Collection接口的方式如下:
通过collection.iterator的方式声明了一个迭代器,使用这个迭代器遍历集合。
List接口
List集合代表一个有序、可重复集合,集合中每个元素都有其对应的顺序索引。List集合默认按照元素的添加顺序设置元素的索引,可以通过索引(类似数组的下标)来访问指定位置的集合元素。
实现List接口的集合主要有:ArrayList、LinkedList、Vector。
ArrayList类
ArrayList是一个动态数组,有序的容器,也是我们最常用的集合,是List类的典型实现。
它允许任何符合规则的元素插入甚至包括null。
底层通过数组实现,每一个ArrayList都有一个初始容量(10),该容量代表了数组的大小。随着容器中的元素不断增加,容器的大小也会随着增加。
在每次向容器中增加元素的同时都会进行容量检查,当快溢出时,就会进行扩容操作。所以如果我们明确所插入元素的多少,最好指定一个初始容量值,避免过多的进行扩容操作而浪费时间、效率。
ArrayList擅长于随机访问,同时ArrayList是非同步的(线程不安全的)。
ArrayList的源码分析:
ArrayList的签名
属性
构造方法
add()方法
通过上面的代码,我们知道,add()方法的原理是:
(1)检查是否需要扩容;
(2)如果elementData等于DEFAULTCAPACITY_EMPTY_ELEMENTDATA(空数组),则初始化容量大小为DEFAULT_CAPACITY;
(3)一般情况下新容量是老容量的1.5倍,如果加了这么多容量发现比需要的容量还小,则以需要的容量为准;
(4)创建新容量的数组并把老数组拷贝到新数组;
add(int index, E element)
我们在特定位置插入元素,底层原理是调用了系统方法System.arraycopy。
方法签名:
也就是将原数组elementData中位于第index位置的元素,拷贝到目标数组elementData(这里还是同数组)的第index+1位置处。移动的元素长度(个数)为size-index(因为是从第index个元素开始移动的)。
同样,remove方法的原理也大体类似,也是调用了系统方法System.arraycopy,这里就不再赘述了。
改Set:
查get:
LinkedList类
LinkedList是List接口的另一个实现,除了可以根据索引访问集合元素外,LinkedList还实现了Deque接口,可以当作双端队列来使用,也就是说,既可以当作“栈”使用,又可以当作队列使用。
LinkedList的实现机制与ArrayList的实现机制完全不同,ArrayLiat内部以数组的形式保存集合的元素,所以随机访问集合元素有较好的性能;LinkedList内部以链表的形式保存集合中的元素,所以随机访问集合中的元素性能较差,但在插入删除元素时有较好的性能。
来看LinkedList的源码。
LinkedList签名:
属性和构造方法
节点结构:可以看出,底层是一个双向链表
addAll()方法:linkedList调用了内部addAll(size, c)这个方法:
看完了增,我们再来看删:remove()
改和查比较简单,这里贴个源码,相信你自己可以看懂:
查get:
改set:
再补充一个高频API:toArray()
Vector类
Vector与ArrayList相似,但是Vector是同步的。所以说Vector是线程安全的动态数组。它的操作与ArrayList几乎一样。
虽然实现与ArrayList类似,但我们还是照例看一下Vector的源码。
Vector类属于java.util包下。
类签名:
可以看到,Vector的底层也是一个数组。
构造方法:
支持无参构造和有参构造。当我们使用空参构造来创建Vector类对象时,则elementData数组的初始容量默认为10,如需再次扩容,则将elementData数组的当前容量扩容为2倍。
如果使用带参构造来创建Vector类对象,则elementData数组的初始容量即为传入形参的指定容量,如果需要扩容,则直接将该数组当前容量扩容至2倍。
也可以指定初始容量和每次扩容自动增加的容量。
Vector是线程同步的,即线程安全的,这是因为Vector类的操作方法带有synchronized修饰符。因此,在开发中需要线程同步安全时,考虑使用Vector类;如果是单线程情况下,建议优先使用ArrayList类,因为它的效率更高。
这里可能有一个小细节:为什么ensureCapacityHelper()这个方法没有加锁,主要是为了体现锁粗化的实现,什么是锁粗化?外围方法已经加了锁,里面没有必要再加锁了。
总结一下:当我们想要添加元素到数组时:
(1)首先会检查容量是否满足,如果需要的容量比数组容量大,则需要扩容;
(2)如果给定了每次增加的扩容量,则以给定的扩容量为准进行扩容;
(3)否则,扩容为原来数组容量的2倍;
(4)如果扩容后的容量超出了最大的容量MAX_MAX_ARRAY_SIZE,则扩容为MAX_VALUE,否则扩容为MAX_ARRAY_SIZE。
indexOf()
其他方法与ArrayList实现基本一致,这里就不再赘述了。
最后总结一下,比较一下List接口的常用实现类:ArrayList,LinkedList,Vector。
按照正常的逻辑,我们应该先看Set,但是由于我们要剖析源码,而Set接口中很多实现类的底层原理都借助到了Map接口的实现类,所以我们先来学习Map。
Map接口
Map接口采用键值对Map<K,V>的存储方式,保存具有映射关系的数据,因此,Map集合里保存两组值,一组值用于保存Map里的key,另外一组值用于保存Map里的value,key和value可以是任意引用类型的数据。key值不允许重复,可以为null。如果添加key-value对时Map中已经有重复的key,则新添加的value会覆盖该key原来对应的value。常用实现类有HashMap、HashTable、LinkedHashMap、TreeMap等。
HashMap类
HashMap最早是在jdk1.2中开始出现的,一直到jdk1.7一直没有太大的变化。但是到了jdk1.8突然进行了一个很大的改动。其中一个最显著的改动就是:
之前jdk1.7的存储结构是数组+链表,到了jdk1.8变成了数组+链表+红黑树。
另外,HashMap是非线程安全的,也就是说在多个线程同时对HashMap中的某个元素进行增删改操作的时候,是不能保证数据的一致性的。
注意:不是说变成了红黑树效率就一定提高了,只有在链表的长度不小于8,而且数组的长度不小于64的时候才会将链表转化为红黑树,
红黑树是一个自平衡的二叉查找树,也就是说红黑树的查找效率是非常的高,查找效率会从链表的o(n)降低为o(logn)。如果之前没有了解过红黑树的话,也没关系,你就记住红黑树的查找效率很高就OK了。
那为什么不把整个链表变为红黑树呢?
这个问题的意思是这样的,就是说我们为什么非要等到链表的长度大于等于8的时候,才转变成红黑树?在这里可以从两方面来解释
(1)构造红黑树要比构造链表复杂,在链表的节点不多的时候,从整体的性能看来, 数组+链表+红黑树的结构可能不一定比数组+链表的结构性能高。就好比杀鸡焉用牛刀的意思。
(2)HashMap频繁的扩容,会造成底部红黑树不断的进行拆分和重组,这是非常耗时的。因此,也就是链表长度比较长的时候转变成红黑树才会显著提高效率。
定义一个HashMap要确定key和value。
来看put的源码。
hash(key)是调用hash方法计算key的hash;
第四个参数是当键相同时,不修改已存在的值;
第五个参数,当为false时,一般表示数组处于创建模式中,一般为true。
怎么扩容呢?
总结一下,HaspMap扩容就是先计算 新的hash表容量和新的容量阀值,然后初始化一个新的hash表,将旧的键值对重新映射在新的hash表里。如果在旧的hash表里涉及到红黑树,那么在映射到新的hash表中还涉及到红黑树的拆分。
hashMap中处理hash冲突的方法就是链地址法。
构造方法:
看第一个构造方法,涉及两个参数,initialCapacity和loadFactor。
(1)initialCapacity初始容量
官方要求我们要输入一个2的N次幂的值,比如说2、4、8、16等等这些,但是我们忽然一个不小心,输入了一个20怎么办?没关系,虚拟机会根据你输入的值,找一个离20最近的2的N次幂的值,比如说16离他最近,就取16为初始容量。
(2)loadFactor负载因子
负载因子,默认值是0.75。负载因子表示一个散列表的空间的使用程度,有这样一个公式:loadFactor=HashMap的容量 / initailCapacity。 所以负载因子越大则散列表的装填程度越高,也就是能容纳更多的元素,元素多了,链表大了,所以此时索引效率就会降低。反之,负载因子越小则链表中的数据量就越稀疏,此时会对空间造成烂费,但是此时索引效率高。
Hashtable类
Hashtable和HashMap的数据结构和实现方法类似,但Hashtable是线程安全的。
属性和构造方法
Entry数组如下:
不难看出,Entry为单链表节点。
put方法
如果hashtable的元素个数超过了阈值,就需要扩容rehash:
remove、get方法就不再赘述了,他们都是线程安全的,用synchronized修饰,类似put方法。
LinkedHashMap类
LinkedHashMap,使用双向链表来维护key-value对的次序(其实只需要考虑key的次序即可),该链表负责维护Map的迭代顺序,与插入顺序一致,因此性能比HashMap低,但在迭代访问Map里的全部元素时有较好的性能。
LinkedHashMap 直接继承自HashMap ,哈希表由数组和单链表构成,并且当单链表长度超过 8 的时候转化为红黑树,扩容体系,这一切都跟 HashMap 一样。但LinkedHashMap比HashMap更强大。
签名
属性
显然,它具有头节点和尾节点,节点类型为Entry:
LinkedHashMap 的 put 方法实际上调用了父类 HashMap 的方法,只是在newNode的时候重写了这个方法,按照双向链表节点的方式重写。
最后来说下,使用LinkedHashMap去构造LRU的最简单方式。(后面讲完HashSet,我还要说回LinkedHashMap的遍历方式,以及如何理解LinkedHashMap的遍历顺序和访问顺序)
代码先放这,等我们最后会把这个原理讲的非常明白。
LinkedHashMap先告一段落。
TreeMap类
TreeMap是SortedMap的实现类,是一个红黑树的数据结构,每个key-value对作为红黑树的一个节点。TreeMap存储key-value对时,需要根据key对节点进行排序。TreeMap也有两种排序方式:
♦ 自然排序:TreeMap的所有key必须实现Comparable接口,而且所有的key应该是同一个类的对象,否则会抛出ClassCastException。
♦ 定制排序:创建TreeMap时,传入一个Comparator对象,该对象负责对TreeMap中的所有key进行排序。
想要搞懂TreeMap的源码,需要先搞懂红黑树的原理,而红黑树的原理并不能简单说清楚,所以我后面会单独拿出一篇文章讲解红黑树的原理和TreeMap的源码分析。
同时,TreeMap的遍历也要等Set接口讲完再看,所以这里先停一下,看完Set再回来。
总结一下,HashMap、Hashtable、LinkedHashMap、TreeMap之间的对比:
Set接口
Set接口也是Collection接口的子接口,他最大的特点是元素无序且不可重复。
Set接口的常用实现类:HashSet、LinkedHashSet、TreeSet。
HashSet类
HashSet是Set集合最常用实现类,是其经典实现。HashSet是按照hash算法来存储元素的,因此具有很好的存取和查找性能。
HashSet具有如下特点:
(1)不能保证元素的顺序。
(2)HashSet不是线程同步的,如果多线程操作HashSet集合,则应通过代码来保证其同步。
(3)集合元素值可以是null。
(4)底层是HashMap,确保元素不重复。
底层是HashMap:
添加元素直接调用HashMap的put方法;删除元素直接调用的是HashMap的remove方法;调用Hashmap的containsKey方法检查元素是否存在。
如果想要遍历Set元素,直接调用map的KeySet的迭代器。
LinkedHashSet类
LinkedHashSet是HashSet的一个子类,具有HashSet的特性,也是根据元素的hashCode值来决定元素的存储位置。但它使用链表维护元素的次序,元素的顺序与添加顺序一致。由于LinkedHashSet需要维护元素的插入顺序,因此性能略低于HashSet,但在迭代访问Set里的全部元素时由很好的性能。
LinkedHashSet类的源码非常短小精悍:
TreeSet类
TreeSet底层是采用TreeMap实现的一种Set,它是有序的,同样也是非线程安全的。
TreeSet的底层元素也比较简单,可以直接看:
在总结一下,Set接口的几个实现类:
好了,到此为止,我们学习完了Collection接口的所有核心子接口,并且分析了他们的源码,还有两个核心问题,我们需要交代一下。
1. 以LinkedHashMap为例,我们该如何遍历?他的底层原理是怎样的?
inkedHashMap的遍历顺序可以分为两种模式:插入顺序模式和访问顺序模式。
(1)插入顺序模式
在插入顺序模式下,遍历LinkedHashMap时的顺序是元素添加的顺序。即先添加的元素先被遍历到,后添加的元素后被遍历到。这种遍历顺序是通过维护一个双向链表实现的,链表的顺序就是元素的插入顺序。
(2)访问顺序模式
在访问顺序模式下,遍历LinkedHashMap时的顺序是元素最后一次访问的顺序,即最近访问的元素被最先遍历到,最早访问的元素被最后遍历到(LRU原则)。这种遍历顺序是通过维护一个双向链表和一个访问顺序队列实现的,每次访问元素时,将元素移到链表的末尾。
需要注意的是,访问顺序模式需要在创建LinkedHashMap对象时传入参数accessOrder=true,默认为false,也就是插入顺序模式。
遍历的实现原理我们通过源码分析下:
通过将map的entry数组以Set视图展示,以遍历map。
2. modCount是做什么的?
我们在很多个子接口的实现类中都看到了这个属性,这个属性是做什么的?
以HashMap为例,我们介绍Fail-Fast 机制。
我们知道 java.util.HashMap 不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。
这一策略在源码中的实现是通过 modCount 域,modCount 顾名思义就是修改次数,对HashMap 内容的修改都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的 expectedModCount。
在迭代过程中,判断 modCount 跟 expectedModCount 是否相等,如果不相等就表示已经有其他线程修改了 Map:注意到 modCount 声明为 volatile,保证线程之间修改的可见性。