Java技术总结之——Java集合框架

一、综述

Java最初版本职位最常用的数据结构提供了很少的一组类:Vector、Stack、Hashtable、BitSet 与Enumeration 接口, 其中的Enumeration 接口提供了一种用于访问任意容器中各个元素的抽象机制。随着Java SE 1.2 的问世,设计人员感到是推出一组功能完善的数据结构的时机了。于是就有了现在的集合框架。需要注意的是,之前的那些容器类库并没有被弃用而是进行了保留,主要是为了向下兼容的目的,但我们在平时使用中还是应该尽量少用。

从上面的集合框架图可以看到,Java集合框架主要包括两种类型的容器,一种是集合(Collection),存储一个元素集合,另一种是图(Map),存储键/值对映射。对于Collection接口,使用add()方法添加元素,可以使用迭代器iterator访问其中的元素;而Map接口,使用put()方法添加元素,可以使用get()方法访问其中的元素。Collection接口又有3种子类型,List、Set和Queue,再下面是一些抽象类,最后是具体实现类,常用的三大接口分别是List、Set、Map。它们常用的具体实现为ArrayList、LinkedList、HashSet、LinkedHashSet、HashMap、LinkedHashMap等等。

具体的集合

 

 二、Collection接口

Collection接口是处理对象集合的根接口,其中定义了很多对元素进行操作的方法,AbstractCollection是提供Collection部分实现的抽象类。下图展示了Collection接口中的全部方法。

 其中,有几个比较常用的方法,比如方法add()添加一个元素到集合中,addAll()将指定集合中的所有元素添加到集合中,contains()方法检测集合中是否包含指定的元素,toArray()方法返回一个表示集合的数组。Collection接口有三个子接口,下面详细介绍。

1、List

List接口扩展自Collection,它可以定义一个允许重复有序集合,从List接口中的方法来看,List接口主要是增加了面向位置的操作,允许在指定位置上操作元素,同时增加了一个能够双向遍历线性表的新列表迭代器ListIterator。AbstractList类提供了List接口的部分实现,AbstractSequentialList扩展自AbstractList,主要是提供对链表的支持。下面介绍List接口的两个重要的具体实现类,也是我们可能最常用的类,ArrayList和LinkedList。

ArrayList

通过阅读ArrayList的源码,我们可以很清楚地看到里面的逻辑,它是用数组存储元素的,这个数组可以动态创建,如果元素个数超过了数组的容量,那么就创建一个更大的新数组,并将当前数组中的所有元素都复制到新数组中。假设第一次是集合没有任何元素,下面以插入一个元素为例看看源码的实现。

1)内部数据结构

//用来存放数据元素的数组
transient Object[] elementData;
//当前存储元素的个数
private int size;

2)添加指定元素

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  //扩容处理
    elementData[size++] = e;//添加元素
    return true;
}

//此方法主要是确定将要创建的数组大小。
private void ensureCapacityInternal(int minCapacity) {
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    ensureExplicitCapacity(minCapacity);
}

private void ensureExplicitCapacity(int minCapacity) {
    modCount++;
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}

//确定了添加元素后的大小之后将元素复制到新数组中
private void grow(int minCapacity) {
    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);
}

3)移除指定元素

public boolean remove(Object o) {
    if (o == null) {
        for (int index = 0; index < size; index++)
            if (elementData[index] == null) {
                fastRemove(index);
                return true;
            }
    } else {
        for (int index = 0; index < size; index++)
            if (o.equals(elementData[index])) {
                fastRemove(index);
                return true;
            }
    }
    return false;
}
    
private void fastRemove(int index) {
    modCount++;
    int numMoved = size - index - 1;
    if (numMoved > 0) // 删除某个元素时需要移动其它数组元素
        System.arraycopy(elementData, index+1, elementData, index, numMoved);
    elementData[--size] = null; // clear to let GC do its work
}

LinkedList

同样,我们打开LinkedList的源文件,不难看到LinkedList是在一个链表中存储元素。所以,LinkedList的元素添加和删除其实就对应着链表节点的添加和移除。

 

//存储的元素个数
transient int size = 0;
//头节点
transient Node<E> first;
//尾节点
transient Node<E> last;

1)添加元素

LinkedList.add方法将对象添加到链表尾部。

public boolean add(E e) {
    linkLast(e);
    return true;
}

void linkLast(E e) {
    final Node<E> l = last;
    final Node<E> newNode = new Node<>(l, e, null);
    last = newNode;
    if (l == null)
        first = newNode;
    else
        l.next = newNode;
    size++;
    modCount++;
}

2)删除元素

E unlink(Node<E> x) {
    final E element = x.item;
    final Node<E> next = x.next;
    final Node<E> prev = x.prev;

    if (prev == null) {// 当前节点为头节点,重置头节点
        first = next;
    } else {//当前节点非头节点,将前驱节点和后继节点连接
        prev.next = next;
        x.prev = null;
    }

    if (next == null) {//当前节点为尾节点,重置尾节点
        last = prev;
    } else {// 当前节点非尾节点
        next.prev = prev;
        x.next = null;
    }

    x.item = null;
    size--;
    modCount++;
    return element;
}

小结

1) ArrayList
基于数组实现,非线程安全,效率高,增删慢,查找快; 

2)LinkedList
基于链表(在Java程序设计语言中,所有的链表都是双向链表)实现,非线程安全,链表内存是散列的,增删快,查找慢; 

3)Vector

基于数组实现,线程安全,效率低,增删慢,查找慢;

 

由于ArrayList,采用数组实现,所以可以通过get()方法通过索引来随机访问;而LinkedList也提供了一个get()方法,但是它还是通过遍历链表来实现的,不过在get()方法上做了微小的优化:如果索引大于size()/2就总列表尾端开始搜索元素。实际使用中我们需要根据特定的需求选用合适的类,如果除了在末尾外不能在其他位置插入或者删除元素,那么ArrayList效率更高,如果需要经常插入或者删除元素,就选择LinkedList。

2、Set

Set接口扩展自Collection,它与List的不同之处在于,Set中存储的数据是不允许重复的,但元素在集合中的位置是由元素的hashcode决定,即位置是固定的(Set集合是根据hashcode来进行数据存储的,所以位置是固定的,但是这个位置不是用户可以控制的,所以对于用户来说set中的元素还是无序的)。AbstractSet是一个实现Set接口的抽象类,Set接口有三个具体实现类,分别是散列集HashSet、链式散列集LinkedHashSet和树形集TreeSet。

HashSet

散列集HashSet是一个用于实现Set接口的具体类,可以使用它的无参构造方法来创建空的散列集,也可以由一个现有的集合创建散列集。散列集一般采用数组链表的形式实现,每个列表称为桶(bucket)。当然,有时候会遇到桶被占满的情况,这也是不可避免的。这种现象被称为散列冲突(hash colision)。HashMap使用的是链地址法来解决冲突。有关如何散列冲突的问题可以参考:哈希表及处理冲突的常用方法

在散列集中,有两个名词需要关注,初始容量(initialCapacity)即桶数和装填因子(loadFactor)。实际上HashSet就是基于后面介绍的HashMap而实现的,装填因子是确定在增加规则集之前,该规则集的饱满程度,当元素个数超过了容量与装填因子的乘积时,容量就会自动增加。

下面看一个HashSet的例子。

public class TestHashSet {
    public static void main(String[] args) {
        Set<String> set = new HashSet<>();
        set.add("11111");
        set.add("22222");
        set.add("33333");
        set.add("44444");
        set.add("22222");
        System.out.println(set.size());
        for (String e : set) {
            System.out.println(e);
        }
    }
}

从输出结果我们可以看到,规则集里最后有4个元素,而且在输出时元素还是无序的。

查看散列集HashSet的源码实现可以看到它内部是使用一个HashMap来存放元素的,因为HashSet的元素就是其内部HashMap的键集合,所以所以HashSet可以做到元素不重复。

LinkedHashSet

LinkedHashSet是继承自HashSet的,支持对规则集内的元素排序。HashSet中的元素是没有被排序的,而LinkedHashSet中的元素可以按照它们插入规则集的顺序提取。

其实现将在LinkHashMap中详细介绍。

TreeSet

TreeSet扩展自AbstractSet,并实现了NavigableSet,AbstractSet扩展自AbstractCollection,树形集是一个有序的Set,其底层是一颗树(当期实现使用的是红黑树),这样就能从Set里面提取一个有序序列了。在实例化TreeSet时,我们可以给TreeSet指定一个比较器Comparator来指定树形集中的元素顺序。树形集中提供了很多便捷的方法。(Java集合,TreeSet底层实现和原理

下面是一个TreeSet的例子。

/**
 * @author JackalTsc
 */
public class TestSet {

    public static void main(String[] args) {

        TreeSet<Integer> set = new TreeSet<>();

        set.add(1111);
        set.add(2222);
        set.add(3333);
        set.add(4444);
        set.add(5555);

        System.out.println(set.first()); // 输出第一个元素
        System.out.println(set.lower(3333)); //小于3333的最大元素,若没有则返回null
        System.out.println(set.higher(2222)); //大于2222的最小元素,若没有则返回null
        System.out.println(set.floor(3333)); //小于等于3333的最大元素,若没有则返回null
        System.out.println(set.ceiling(3333)); //大于于等于3333的最小元素,若没有则返回null

        System.out.println(set.pollFirst()); //删除并返回第一个元素
        System.out.println(set.pollLast()); //删除并返回最后一个元素
        System.out.println(set);
    }
}

小结

1) HashSet 
底层是由HashMap实现不允许重复的值,使用该方式时需要重写 equals()和 hash Code()方法; 
2)LinkedHashSet 
继承于HashSet,同时又基于LinkedHashMap 来进行实现,底层使用的是 LinkedHashMap

 

Set不允许有重复的值,其add()方法,如果Set集合中不包含要添加的对象,则添加对象并返回true;否则返回false。

3、Queue

队列是一种先进先出的数据结构,元素在队列末尾添加,在队列头部删除。Queue接口扩展自Collection,并提供插入、提取、检验等操作。

上图中,方法offer表示向队列添加一个元素,poll()与remove()方法都是移除队列头部的元素,两者的区别在于如果队列为空,那么poll()返回的是null,而remove()会抛出一个异常。方法element()与peek()主要是获取头部元素,不删除。

接口Deque,是一个扩展自Queue的双端队列,它支持在两端插入和删除元素,因为LinkedList类实现了Deque接口,所以通常我们可以使用LinkedList来创建一个队列。PriorityQueue类实现了一个优先队列,优先队列中元素被赋予优先级,拥有高优先级的先被删除。

/**
 * @author JackalTsc
 */
public class TestQueue {

    public static void main(String[] args) {

        Queue<String> queue = new LinkedList<>();

        queue.offer("aaaa");
        queue.offer("bbbb");
        queue.offer("cccc");
        queue.offer("dddd");

        while (queue.size() > 0) {
            System.out.println(queue.remove() + "");
        }
    }
}

三、Map接口

Map,图(映射),是一种存储键值对映射的容器类,在Map中键可以是任意类型的对象,但不能有重复的键,每个键都对应一个值,真正存储在图中的是键值构成的条目。下面是接口Map的类结构。

从上面这张图中我们可以看到接口Map提供了很多查询、更新和获取存储的键值对的方法,更新包括方法clear()、put()、putAll()、remove()等等,查询方法包括containsKey、containsValue等等。Map接口常用的有三个具体实现类,分别是HashMap、LinkedHashMap、TreeMap。

HashMap

HashMap是基于哈希表的Map接口的非同步实现,继承自AbstractMap,AbstractMap是部分实现Map接口的抽象类。在平时的开发中,HashMap的使用还是比较多的。我们知道ArrayList主要是用数组来存储元素的,LinkedList是用链表来存储的,那么HashMap的实现原理是什么呢?先看下面这张图:

 

在之前的版本中,HashMap采用数组+链表实现,即使用链表处理冲突,同一hash值的链表都存储在一个链表里。但是当链表中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。而JDK1.8中,HashMap采用数组+链表+红黑树实现,当链表长度超过阈值(8)时,将链表转换为红黑树,这样大大减少了查找时间。

下面主要通过源码介绍一下它的实现原理。

1)HashMap存储元素的数组

 transient Node<K,V>[] table;

 2)数组的元素类型是Node<K,V>,Node<K,V>继承自Map.Entry<K,V>,表示键值对映射。

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

        //构造函数 ( Hash值键值下一个节点 )
        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

3)HashMap的put操作。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length; //如果没有初始化则初始化table
    if ((p = tab[i = (n - 1) & hash]) == null)
        //根据hash值得到这个元素在数组中的位置 如果此位置还没别的元素 直接存放
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        //应该放在首节点的位置
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        //红黑树插入新节点
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            //链表插入新节点
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    //超过阈值 将链表转化为红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        //更新hash值和key值均相同的节点Value值
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

4)HashMap的get操作。

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    //如果当前Map里还没元素或者hash值对应的链表头节点为空 直接返回null
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        //是头节点 直接返回
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        //非头节点 遍历找到对应key的值
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

到这里HashMap的大致实现原理应该很清楚了,有几个需要关注的重点是:HashMap存储元素的方式以及根据Hash值确定映射在数组中的位置还有JDK 1.8之后加入的红黑树的。

在HashMap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。对于任意给定的对象,只要它的hashCode()返回值相同,那么程序调用hash(int h)方法所计算得到的hash码值总是相同的。我们首先想到的就是把hash值对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,“模”运算的消耗还是比较大的,在HashMap中,(n - 1) & hash用于计算对象应该保存在table数组的哪个索引处。HashMap底层数组的长度总是2的n次方,当数组长度为2的n次幂的时候,(n - 1) & hash 算得的index相同的几率较小,数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。

推荐一篇文章,HashMap源码分析比较详细:JDK1.8 HashMap源码分析

LinkedHashMap

LinkedHashMap继承自HashMap,它主要是用链表实现来扩展HashMap类,HashMap中条目是没有顺序的,但是在LinkedHashMap中元素既可以按照它们插入图的顺序排序,也可以按它们最后一次被访问的顺序排序。

 LinkedHashMap将用访问顺序,而不是插入顺序,对映射条目进行迭代。每次条用get或put,受到影响的条目将从当前的位置删除,并放到条目链表的尾部(只有条目在链表中的位置会受到影响,而散列表中的桶不会受影响。一个条目总位于与建散列码对应的桶中)。

TreeMap

TreeMap基于红黑树数据结构的实现,键值可以使用Comparable或Comparator接口来排序。TreeMap继承自AbstractMap,同时实现了接口NavigableMap,而接口NavigableMap则继承自SortedMap。SortedMap是Map的子接口,使用它可以确保图中的条目是排好序的。(Java集合,TreeMap底层实现和原理

TreeMap中的元素默认按照keys的自然排序排列。(对Integer来说,其自然排序就是数字的升序;对String来说,其自然排序就是按照字母表排序)

WeakHashMap

WeakHashMap与HashMap类似,二者的不同之处在于WeakHashMap中key采用的是“弱引用”的方式(Java的四种引用方式)。只要WeakHashMap中的key不再被外部引用,它就可以被垃圾回收器回收。而HashMap中key采用的是“强引用”的方式,当HashMap中的key不再被外部引用,只有在这个key从HashMap中删除后,才可以被垃圾回收器回收。

小结

1)HashMap 
基于 hash 表的 Map 接口实现,非线程安全,高效,支持 null 值和 null 键;  
2)LinkedHashMap 
是 HashMap 的一个子类,保存了记录的插入顺序; 
3)TreeMap
能够把它保存的记录根据键排序,默认是键值的升序排序。

 

因为Map不允许重复的键值,所以当对同一个键值两次调用put方法,第二个值就会取代第一个值,并且put将返回用这个键参数存储的上一个值。在实际使用中,如果更新图时不需要保持图中元素的顺序,就使用HashMap,如果需要保持图中元素的插入顺序或者访问顺序,就使用LinkedHashMap,如果需要使图按照键值排序,就使用TreeMap。

四、其他集合类

上面主要对Java集合框架作了详细的介绍,包括Collection和Map两个接口及它们的抽象类和常用的具体实现类,下面主要介绍一下其它几个特殊的集合类,Vector、Stack、HashTable、ConcurrentHashMap以及CopyOnWriteArrayList。

Vector

前面我们已经提到,Java设计者们在对之前的容器类进行重新设计时保留了一些数据结构,其中就有Vector。用法上,Vector与ArrayList基本一致,不同之处在于Vector使用了关键字synchronized将访问和修改向量的方法都变成同步的了,所以对于不需要同步的应用程序来说,类ArrayList比类Vector更高效。

Stack

Stack,栈类,是Java2之前引入的,继承自类Vector

Hashtable

Hashtable和前面介绍的HashMap很类似,它也是一个散列表,存储的内容是键值对映射,不同之处在于,Hashtable是继承自Dictionary的,Hashtable中的函数都是同步的,这意味着它也是线程安全的,另外,Hashtable中key和value都不可以为null。值得注意的是在Hashtable中有contains方法,而HashMap中改成了containsvalue和containsKey。

上面的三个集合类都是在Java2之前推出的容器类,可以看到,尽管在使用中效率比较低,但是它们都是线程安全的。下面介绍两个特殊的集合类。

ConcurrentHashMap

Concurrent,并发,从名字就可以看出来ConcurrentHashMap是HashMap的线程安全版。同HashMap相比ConcurrentHashMap不仅保证了访问的线程安全性,而且在效率上与HashTable相比,也有较大的提高。

注意ConcurrentHashMap中不允许键有null值,因为很多方法都使用null值来指示映射中某个给定的值不存在。

而在HashMap中允许键的值为null。

CopyOnWriteArrayList

CopyOnWriteArrayList,是一个线程安全的List接口的实现,它使用了ReentrantLock锁来保证在并发情况下提供高性能的并发读取。

五、总结

1)Java集合框架主要包括Collection和Map两种类型。其中Collection又有3种子类型,分别是List、Set、Queue。Map中存储的主要是键值对映射。

2)规则集Set中存储的是不重复的元素,线性表中存储可以包括重复的元素,Queue队列描述的是先进先出的数据结构,可以用LinkedList来实现队列。

3)效率上,规则集比线性表更高效。

4)ArrayList主要是用数组来存储元素,LinkedList主要是用链表来存储元素,HashMap的底层实现主要是借助数组+链表+红黑树来实现。

5)Vector、Hashtable等集合类效率比较低但都是线程安全的。包java.util.concurrent下包含了大量线程安全的集合类,效率上有较大提升。

6)Collections是针对集合类的一个包装类,它提供了对集合一系列静态方法以实现对各种集合的搜索、排序、线程安全化(但是最好使用java.util.concurrent包中定义的集合,不使用同步包装器中的)等操作,其中大多数方法都是用来处理线性表。若使用Collections类的方法时,对应的collection对象为null,则这些方法都会抛出NullPionterExecption。

7)注意Cellection接口的数据结构要求实现了迭代器,而Map接口没有要求实现。对于Map的遍历通常获取EntrySet或KeySet来遍历,


参考资料:

Java - 集合框架完全解析@尘语凡心,简书

《Java核心技术卷一》

三大集合:List、Map、Set的区别与联系

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值