java2集合框架的一些我的分析和理解

Java2中的集合框架是广为人知的,本文打算从几个方面来讲说本身对这个框架的理解。css

下图是java.util.Collection的类图(基本完整,有些接口如集合类均实现的Cloneable、Serializable没有包含进去)html

QQ20140415173213_thumb5

 

 

咱们常说要继承的话,究竟是写个抽象类仍是接口,它们区别在于:若是子类确实是父类的一种,应该使用抽象类,描述是“is-a”的关系,而接口则表示一种行为,描述的是“like-a”的关系。但在Java类库里,其实许多原则因为各类缘由被打破了,好比在Collection框架里,List/Set都是Collection的一种,为何不把Collection定义为抽象类呢?而ArrayList/LinkedList也都是List的一种,为何不把List定义为抽象类呢?这就是原则和实际的折衷。做为Java类库而言,不只要考虑面向对象的一些原则,也要考虑扩展性和语言自己的限制。能不能把Collection接口去掉,用AbstractCollection做为顶层?做为类库而言是不能够的,由于Java是单继承的,若是把AbstractCollection做为顶层,那么当用户自定义的类既要继承本身的父类,又要具有集合的属性,那么就作不到了(能够自定义集合接口,但就没法与Collection相互转化)。所以,Java集合框架采起的是类库普遍使用的接口+抽象类的形式,以同时得到接口和抽象类的好处,因此咱们看到ArrayList extends AbstractList implements List(AbstractList自己就是实现List的,这里再写出implements List是为了使ArrayList的类结构更为清晰)。java

另外咱们再看Set接口,它的方法基本和Collection方法如出一辙,为何要再写一遍?一方面是做为类库而言要增长详细注释,虽然是同名的方法但实现的约束不一样,好比Set的add方法是不会保存重复值的,另外一方面是为了从Set接口自己能很清楚地看到它所提供的功能(好比size()方法,和Collection是彻底一个含义,也从新定义了一遍),这是从类库易读性来考虑,对于咱们本身编写的类,基本就不须要这样。算法

说多了,回到集合框架自己。数据库

Iterable基本是个标识接口,同时约定了全部线性集合(数组、队列、栈这种一维的都属于线性集合,Map就属于二维,不要求遍历)必须是能够遍历的(集合要给出遍历结构),同时提供了配套的Iterator顶级接口,实现hasNext()、next()和remove()方法来完成遍历功能。为何这里要定义remove接口方法却不定义add/set方法?我的以为这多是考虑在类库的使用过程当中remove的频率更高,而add的方法频率要低,set的使用场景就更少了。数组

ListIterator相比Iterator就多提供了不少功能,包括上面提到的add/set,还有得到索引的nextIndex、previousIndex、以及往回迭代的hasPrevious()/previous()。给针对线性表的操做者更多的便利,事实上在AbstractList里就提供了iterator()和listIterator()两种方法来提供给开发者更多选择。相应的,在HashMap里头,也提供了实现Iterator接口的HashIterator内部抽象类,而在Apache Commons Collections下甚至单独写出MapIterator extends Iterator,因而可知,做为类库的设计者,在Iterator和ListIterator/HashMapIterator上是作了便捷性/易用性以及使用场景上的权衡的。安全

ArrayList内部结构是个数组,默认是10,在建立ArrayList对象时此数组是空的(Object[] EMPTY_ELEMENTDATA = {})只有当add的时候才扩容(若是扩容容量小于DEFAULT_CAPACITY,也就是10,就一次性扩容到10)。其扩容的机制是:当前数组容量已经没法放入更多元素的时候,增长原有数组的一半,数据结构

 int oldCapacity = elementData.length;
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        // minCapacity is usually close to size, so this is a win:
        elementData = Arrays.copyOf(elementData, newCapacity);

此数组的最大值是Integer.MAX_VALUE,也就是2^31-1。数组扩容的时候要考虑到当容量再度扩容一半的时候会越界,因此单独作了判断处理。数组扩容是在调用了本地方法去分配新的空间区域(下面是Arrays.CopyOf的代码)并发

public static int[] copyOf(int[] original, int newLength) {
        int[] copy = new int[newLength];
        System.arraycopy(original, 0, copy, 0,
                         Math.min(original.length, newLength));
        return copy;
    }
System.arraycopy的代码以下
public static native void arraycopy(Object src,  int  srcPos,
                                        Object dest, int destPos,
                                        int length);

按照理论上来讲,对于能预先知道数组大小的,应该在定义ArrayList的时候指定其容量以减小扩容次数,可是通过如下代码试验(虚拟机64位Client模式,JDK1.7.0_45)app

int times=1000000;
long startTime=new Date().getTime();
List<Integer> arrayList=new ArrayList<Integer>(times);
for(int i=0;i<times;i++) {
    arrayList.add(i);
}
long endTime=new Date().getTime();
System.out.println("ArrayList增长"+times+"次,耗费时间="+(endTime-startTime));

对于1百万次增长,使用new ArrayList<Integer>(times)时耗费时间是90ms,而使用new ArrayList<Integer>()的耗费时间居然要短一些,只要81ms;把times扩大到1千万的时候,差距更明显,指定容量的要5秒多,而不指定容量的只要3秒多。具体是哪慢了很差下结论,推测应该是当实际元素较少的时候,大数组在寻址、计算等方面要慢一些,反过来讲System.arraycopy的效率并无传说中的那么低,这也是为何用的本地方法的缘由。

LinkedList咱们知道内部结构是个线性链表,首先看它继承的不是AbstractList,而是继承自AbstractSequentialList,这是AbstractList的子类,实现了线性链表的骨架方法,如get/set,均是经过ListIterator迭代器来遍历实现。为何要创造出AbstractSequentialList这个类?由于线性的不仅有链表,但线性的都只有经过迭代器才能找到元素,与之对应的是随机读取——也就是数组,所以在AbstractSequentialList的类注释里明确说明:若是是随机读取的,则使用AbstractList更合适(AbstractList并无提供随机读取的实现,类注释的意思只是说如要随机读取,则AbstractSequentialList没有任何帮助,不如实现AbstractList更准确)。事实上,为了代表集合是否能够根据索引随机读取,Collection框架专门定义了一个空接口RandomAccess,以标识该类是否可随机读,ArrayList、Vector都实现了这个接口,而没有实现这个接口的,则是不能够经过下标索引来寻址的。

LinkedList有比ArrayList在接口上有更丰富的功能,好比addFirst()、addLast()、push()、pop(),、indexOf(),同时它的listIterator()也要比iterator()更经常使用一些。咱们之前常说对于常常删除、增长的集合,使用LinkedList比ArrayList效率要高,这是容易被误解的,LinkedList的寻址相比数组来讲很是地慢,若是在频繁增/删以前须要寻址定位,那么仍然比ArrayList要慢不少,数十倍地慢,因此使用它的时候要谨慎,不能耍小聪明。LinkedList根据索引寻址的get(int index)方法,使用的是简单的“二分法”,即若是index小于size的一半,则从前日后迭代;大于size一半则从后往前迭代。这也是没有办法的事情,LinkedList是须要保证插入顺序的,因此不能作任何排序,也就不能使用任何如冒泡、快速排序之类的算法。有没有不须要保证插入顺序从而可以快速寻址的集合呢?TreeSet/HashSet能够快速寻址,但不能有重复值;TreeMap/HashMap一样是不能有重复值;Collection框架并无给出能有重复值同时又能容许排序的List,应该是他们认为ArrayList就能够知足这种场景了,但类库中有个类IdentityHashMap,它的hash()方法用的是System.identityHashCode()而不是HashMap所用的key.hashCode。System.identityHashCode()意思是无论对象是否实现了hashCode,都取Object的hashCode也就是对象的内存地址来做为key,这样即便两个对象hashCode相等,也会被重复插入(在该类的注释中说到了它的一些使用场景,有兴趣的能够仔细看下)。

咱们知道一般的集合都是非线程安全的,表如今多个线程同时增/删时,集合大小会不可预测,同时Iterator尽可能保证在迭代过程当中操做是安全的(不保证准确,但尽可能保证不会有越界问题),即当某线程迭代读取集合时,若有其余线程修改此集合的结构(扩大/缩小),则会抛出ConcurrentModificationException。那么它是如何实现的呢?在集合中都会维护一个内部计数器modCount,若是有影响集合结构的操做(增长、删除、合并等,而修改不是),modCount都会自增1。在对集合迭代时,都会检查当前迭代时的操做计数器副本expectedModCount(迭代前初始化为和modCount相等)和modCount是否相等

int expectedModCount = modCount;
final void checkForComodification() {
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
        }
一样,Set、Map(得到Entry的HashIterator迭代器)中都有modCount这样的操做计数器。
 
Set用的相对少一些,同时它基本是依托于Map实现的。HashSet依托于HashMap,TreeSet依托与TreeMap,因此先从Map提及。
都知道Map是不继承自Collection的,是顶级接口,为何这么设计?从根本上来讲,Collection是一维的,而Map是二维的,那么是否存在三维的?j2se并无提供这样的类库,但google的guava框架提供了这样的,如com.google.common.collect.Table<R,C,V>,其中R是行key、C是列key,而V就是对应的值,也就是说j2se类库考虑的状况会比较多,而各开源框架就能够根据本身的定位设计出更专业化的类库。
Map接口的方法和Collection差很少,不过从键的维度、值的维度以及把键值做为一个总体的维度,有了keySet()、values()、entrySet()这样的接口方法。
Map提供了抽象类AbstractMap,使用entrySet的迭代器循环,提供如get、remove、containKey这样的默认实现。为何Map不实现Iterable接口呢?我以为没有必然的缘由,Map实现Iterable接口也无不可,自己它内部就有以entrySet的Iterator来作遍历的使用,只是做为Map<K,V>这样的结构来讲,实现Iterable有些混淆,究竟是迭代K呢,仍是迭代V呢,或者是迭代总体?因此干脆Map自己就不提供迭代器,而是提供了分别按键、值、键值对三个迭代器接口。
HashMap也就是哈希表,咱们都知道它内部是一个数组链表的结构,即一个数组,
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
其中放的是Entry对象的引用,而每一个Entry内部又维持hash相同的下一个Entry引用造成链表。
   static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;
        int hash;
 
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }
同List同样,在其内部也维护叫size的变量,保存元素个数。在new HashMap()的时候,table数组是空的,一旦put则会扩容,
private void inflateTable(int toSize) {
        // Find a power of 2 >= toSize
        int capacity = roundUpToPowerOf2(toSize);
 
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }

初始扩容的容量是16

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
,在扩容的过程当中同时会计算下次扩容的阈值threshold,它=数组大小*负载因子。为何在达到threshold的时候扩容而不是在达到数组最大长度的时候?这是为了减小每一个数组元素上的Entry数,由于根据hash()方法,在把table数组占满以前,极可能在其余元素上已经有多个了(从几率角度),但负载因子又不能过小,不然会形成不少空间浪费,因此做者权衡(这里可能也是根据hash()或某些数学原理)取0.75做为负载因子,即达到table数组3/4时就扩容,而且是扩容2倍,不是ArrayList那样扩容一半
void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }
 
        createEntry(hash, key, value, bucketIndex);
    }
其缘由和hash的算法有关。为了提升效率,在HashMap里大量使用了&、>>>这样的二进制运算,好比HashMap初始化是1<<<4,在用hashCode在table数组中取模求余时用的是hashCode&table.length-1
 static int indexFor(int h, int length) {
        // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
        return h & (length-1);
    }
因此数组大小保持是2的倍数,才能使用这些快速的二进制方式。
关于上面indexFor咱们能够用两组数来算下:
假设hashCode是17,数组长度是7,那么就是10001&00110=00000,而正确结果是17%7=3;假设数组长度是8,那么就是10001&00111=00001,与正确结果17%8=1相一致。因此保持数组长度是2的倍数,就是为了提升HashMap的效率所作的一个小技巧,同时在内存分配上等都要更快一些。
在根据key来定位其hashCode的时候,并非简单调用key.hashCode(),而是再度进行了一些运算,其目的是为了使最终哈希出来的值更均匀,
final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }
 
        h ^= k.hashCode();
 
        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

至于为何这么作,比较复杂,我也没搞太清,尤为是其中为何选择20、十二、七、4这样的来右移。还有hashSeed的选择也不太清楚。

这里必需要提下HashMap扩容的效率问题。前面提到ArrayList的扩容性能并不差,而HashMap就彻底不同了,经实验,扩容至少带来性能降低1半以上,但有临界点,元素超过10万数量级差距就不明显了。下面是代码和测试结果

import java.util.Date;
import java.util.HashMap;
import java.util.Map;
 
public class ResizePerformanceTest {
    public static void main(String[] args) {
        run(1000);
        run(10000);
        run(100000);
        run(1000000);
        run(10000000);
    }
    
    public static void run(int times) {
        System.out.println("增长"+times+"次");
        long startTime=new Date().getTime();
        Map<Integer,Integer> map=new HashMap<Integer,Integer>();
        for(int i=0;i<times;i++) {
            map.put(i, i);
        }
        long endTime=new Date().getTime();
        System.out.println("HashMap自动扩容的方式,增长"+times+"次,耗费时间="+(endTime-startTime));
        
        long startTime1=new Date().getTime();
        Map<Integer,Integer> map1=new HashMap<Integer,Integer>(times);
        for(int i=0;i<times;i++) {
            map1.put(i, i);
        }
        long endTime1=new Date().getTime();
        System.out.println("HashMap预先指定空间的方式,增长"+times+"次,耗费时间="+(endTime1-startTime1));
    }
}

增长1000次
HashMap自动扩容的方式,增长1000次,耗费时间=2
HashMap预先指定空间的方式,增长1000次,耗费时间=1
增长10000次
HashMap自动扩容的方式,增长10000次,耗费时间=15
HashMap预先指定空间的方式,增长10000次,耗费时间=6
增长100000次
HashMap自动扩容的方式,增长100000次,耗费时间=25
HashMap预先指定空间的方式,增长100000次,耗费时间=21
增长1000000次
HashMap自动扩容的方式,增长1000000次,耗费时间=1707
HashMap预先指定空间的方式,增长1000000次,耗费时间=1611
增长10000000次
HashMap自动扩容的方式,增长10000000次,耗费时间=21054
HashMap预先指定空间的方式,增长10000000次,耗费时间=17820

 

HashMap大约就说这么多,再说说TreeMap。TreeMap是种红黑树的结构,可以对元素排序(红黑树、数据库的B树、B+树,还有冒泡算法、快速排序算法这些算法领域的,如今还真是不那么掌握牢固)。为了保证排序,提供了两种方式:一种是Key对象实现Comparable接口,另一种方式是单独提供Comparator实现类

public TreeMap(Comparator<? super K> comparator) {
        this.comparator = comparator;
    }

若是自己Key对象的排序是肯定的,好比Integer按大小排序,String按照字典排序,这些是无疑义的,因此它们都实现了Comparable接口,但假如说Person对象,有时须要按年龄排序,有时须要按身高排序,有时须要按薪酬排序,因此就没办法使用Comparable接口了,此时能够根据不一样排序方式建立相应的Comparator类。

既然是按照顺序排列的树,那天然就须要提供一些数据结构方面的方法,因此TreeMap有了firstKey()、lastKey()、pollFirstEntry()、lowerEntry(K)、floorEntry(K)、headMap(K)、tailMap(K)、desendintMap()这样的方便方法。

相比HashMap,TreeMap还有个很大的不一样,就是它不只是继承AbstractMap,还实现了NavigableMap,NavigableMap继承自SortedMap,SortedMap继承自Map。SortedMap定义了什么?firstkey()、lastKey()、headMap(K)、tailMap(K)、subMap(K,K),NavigableMap定义了pollFirstEntry()、lowerEntry(K)、floorEntry(K)等方法。为何这么设计?SortedMap是好理解的,针对能够排序的Map单独设一个接口,但为何要NavigableMap呢?它的lowerEntry(K)之类的方法为何不能合并到SortedMap里去?我的以为这应该是两个版本时期致使的,NavigableMap是JDK1.6时加入的,此时已经有了很多SortedMap的子类,不是颇有必要让子类也去实现这些方法,因此新加了个NavigableMap类,在须要lower的时候实现它便可,不须要时就直接实现SortedMap。也就是设计这种类库接口时的粒度问题,基本的方法在上一级接口定义,虽然另一些方法也是正常使用,但根据它的频率、约束性有所不一样能够下放,同时又要考虑不能使接口数量太多加大复杂性。

 

理解了Map,再来看Set就很简单了。HashSet内部彻底是以Set元素为key,new Object()为value的HashMap

// Dummy value to associate with an Object in the backing Map
    private static final Object PRESENT = new Object();

它的一些如size()、contain()方法都是直接调用map的方法。

public int size() {
        return map.size();
    }
 
public boolean add(E e) {
        return map.put(e, PRESENT)==null;
    }

TreeSet也是同样,且也有相配套的NavigableSet、SortedSet。

从总体来讲,感受Set设计地不是太好,其大多数功能和List很像,仅非重复这个频率并不高的场景不足以单独列这一套接口,而其实现上又基本上彻底依托于Map。若是开发者真有这种场景,彻底能够自行用HashMap来代替。

集合框架中还有两个颇有用的辅助类,分别是Collections和Arrays,这两个就很少介绍了。Collections提供了一系列synchronized集合、unmodified集合以及不多用的Checked集合(类型检查的),以及toArray(toArray(T[] a)更好用,由于能指定返回数组的元素类型)、binarySearch(快速查找算法,须要参数列表元素能排序,不然结果就不许确)。而Arrays提供了一些如sort、merge、binarySearch、copyOf、asList这样有效的方法,注意这里的asList返回的Arrays内部实现的一个ArrayList,有些方法不支持,好比add、remove,除了set以外基本上就是一个只读列表,若是须要可add/remove,仍是须要使用集合的相应构造函数或者Collections的copy方法)。

 

最后两个须要说的是虽然是在java.util根目录下,但基本是为java.util.concurrent准备的,就是Queue(队列)和Deque(双向队列)。Queue的一系列子类如DelayQueue、LinkedBlockQueue更多地是和并发有关,这放到未来的JUC框架时再讲吧。

相关文章
相关标签/搜索