Java集合框架之实现类

文章目录

  • 一、Collection接口
    • 1.1 List
      •   1.1.1 ArrayList
      •   1.1.2 LinkedList
    • 1.2 Queue和Deque
      •  1.2.1 ArrayDeque
    • 1.3 Set
      •  1.3.1 EnumSet
      •  1.3.2 HashSet
      •  1.3.3 LinkedHashSet
      •  1.3.3 TreeSet
  • 二、Map接口
    • 2.1 EnumMap
    • 2.2 HashMap
    • 2.3 LinkedHashMap
    • 2.4 TreeMap
  • 三、集合框架总结

  在之前学习了接口和抽象类,这次开始学实际的实现,首先先整理一下各个接口,抽象类的子类具体有哪些实现。然后逐一分析他们的源码,分析出他们的共性和特性,从整体上对集合框架有个较为全面的认识。

一、Collection接口

  首先从Collection接口下来,它包含List和Set以及Queue三大接口。首先从之前分析的经验可以总结出List和Set以及Queue接口的异同有:

  1. List和Set以及Queue都是继承自Collection接口的,都是单列存储,都可以转换为原始数组。
  2. List和Queue的元素可重复且有序,Set的元素不可重复且无序。
  3. Queue不可直接索引,只能从两端取值。而List可以直接索引,可以从任意位置取值。(Queue可以通过迭代器完成类似索引的功能)

1.1 List

  List通过有序性这个特性,新增了排序功能以及根据元素索引操作元素的功能,并且有了新的迭代器ListIterator可以用于双向迭代。它的具体实现类包括:ArrayList,LinkedList,Vector,Stack。需要注意的是Vector和Stack是同步容器,所以分别被ArrayList和ArrayDeque替代掉了。我会在集合框架的最后一章整理废弃容器以及集合框架的设计问题。所以这里只会分析ArrayList和LinkedList。

  1.1.1 ArrayList

  1. 底层存储方面:
      ArrayList底层使用数组存储元素,默认大小为10,使用elementData数组引用指向具体的存储空间EMPTY_ELEMENTDATADEFAULTCAPACITY_EMPTY_ELEMENTDATA都是大小为0的常量静态数组由空元素数组或者默认大小的空元素数组共同指向,避免了多个零大小/默认大小的空元素数组占用多余的空间。
      这里可能会有个疑问,为什么默认大小的空ArrayList和0大小的空ArrayList的底层存储要指向两个不同的数组呢?通过观察源码,从add()->ensureCapacityInternal()找到了原因:
      初始化0大小的ArrayList(0)第一次增加元素只会增加单个容量,而默认初始化大小的ArrayList()第一次增加元素则会增长10个容量。之后每次容量超出后就会自动增长至oldCapacity + (oldCapacity >> 1),也可以手动增长,但每次增长最少1.5倍。

  2. 数据操作方面:
    2.1 为了维护视图以及保证元素被正常操作,ArrayList中照样使用了快速失败机制,每次结构性修改都会使modCount+1,并且结构性修改都是调用System.arraycopy(src,srcpos,dest,destpos,size)该本地函数进行修改。在不改变数组结构的情况下删除元素只需要将该数组索引处设置为null即可。
    2.2 为了保证ArrayList所有函数式接口执行的完整性,在传入函数执行前后都会验证其modCount,只有传入函数执行前后数组结构没变的情况下才能继续后面的操作,否则抛出并发修改异常。

  1.1.2 LinkedList

  LinkedList实现了List接口和Deque接口,所以它同时包含两种接口的特性,同时,由于它继承于AbstractSequentialList,所以我将之归纳在了List中。
首先先讨论它的底层元素存储:
  LinkedList正如其名,是由结点链接组成的,结点内部类的代码为:

    private static class Node<E> {
        E item;
        Node<E> next;
        Node<E> prev;

        Node(Node<E> prev, E element, Node<E> next) {
            this.item = element;
            this.next = next;
            this.prev = prev;
        }
    }

可以看出,该类中包含元素,下一个结点,上一个结点三个字段,完整的描述了一个结点所应该包含的内容。并且说明了这是一个双向链表然后再观察LinkedList类的字段:

 transient int size = 0;  
 transient Node<E> first;
 transient Node<E> last;

这里说明了一个LinkedList实例需要保存链表的头尾结点以及长度信息。
这里列举几个能直接操作链表结点的添加元素方法名:

private void linkFirst(E e)		//将元素e链接到链表头部
void linkLast(E e)				//将元素e链接到链表尾部
void linkBefore(E e, Node<E> succ)	//将元素e链接到succ结点前面
public boolean addAll(int index, Collection<? extends E> c)

这些方法并没有什么特别的地方,在将新增结点正确链接后更新了size值和modCount值而已。
再列举几个能直接操作链表结点的删除元素的方法名:

private E unlinkFirst(Node<E> f)  //删除头部结点 f
private E unlinkLast(Node<E> l)	  //删除尾部结点 l
E unlink(Node<E> x)				  //删除结点 x
public void clear()				  //清空链表,这里的实现是循环遍历链表设置结点属性为null

这里我十分不解为什么需要三个方法,照理来说第三个方法不就能实现前两个方法吗?first和last结点都存在实例字段中,为什么还需要传参呢?希望能得到解惑。。。
在查找方法中也有类似的情况,明明get(int index)方法就可以查找到所有结点了,但还有多余的getLast()和getFirst()方法。

这些设计问题先不考虑了,最后看看LinkedList的迭代器部分:
LinkedList除了最基本的iterator和listIterator以外还有descendingIterator迭代器。
当然,功能上并没有太大区别,descendingIterator迭代器只不过将next当作previous用了而已。功能性最强的迭代器还是listIterator,所以只需要研究这一个迭代器的代码就足够了。
然而,listIterator迭代器的实现和ArrayList中的实现差不多,只不过将数组的操作方式变成链表的操作方式了。

1.2 Queue和Deque

  Queue和Deque接口分别表示队列和双端队列,继承路径是Collection->Queue->Deque。在Queue中仅仅新增了队列必要的方法名:

boolean offer(E e);   //向队列头部插入元素e,添加成功返回true。
E remove();			  //删除队列头部元素,如果队列为空,则抛出异常
E poll();			  //删除队列头部元素,如果队列为空,则返回null
E element();		  //取出队列头部元素,如果队列为空,则抛出异常
E peek();			  //取出队列头部元素,如果队列为空,则返回null

Deque则继承自Queue,并且新增了双端队列主要的方法有:

void addFirst(E e);			//向队列头端插入元素,如果失败则抛出异常
void addLast(E e);			//向队列尾端插入元素,如果失败则抛出异常
boolean offerFirst(E e);	//向队列头端插入元素,如果成功则返回true
boolean offerLast(E e);		//向队列尾端插入元素,如果成功则返回true
E getFirst();		//获取队列头端元素,如果队列为空则抛出异常
E getLast();		//获取队列尾端元素,如果队列为空则抛出异常
E peekFirst();		//获取队列头端元素,如果队列为空则返回null
E peekLast();		//获取队列尾端元素,如果队列为空则返回null
E removeFirst();	//删除队列头端元素,如果队列为空则抛出异常
E removeLast();		//删除队列尾端元素,如果队列为空则抛出异常
E pollFirst();		//删除队列头端元素,如果队列为空则返回null
E pollLast();		//删除队列尾端元素,如果队列为空则返回null

该接口目前只有两个具体实现,分别是LinkedList和ArrayDeque。从名字就能得知,LinkedList底层是使用链表存储的,而ArrayDeque是使用的数组存储的。双端队列刚好又只在两端进行操作。根据数组和链表的特性,如果只需要在一端进行操作使用数组效率较高,否则使用链表效率较高。这里需要注意的是ArrayDeque不能存储null元素,而LinkedList可以存储null元素。因为之前学过了LinkedList,所以这里只看ArrayDeque。

 1.2.1 ArrayDeque

  该数据结构的实现和之前比就不是那样直接了,由于双端队列的特性和数组的存储,在效率的考虑上,该类实际上是以循环数组实现的。head始终指向队列头部元素,tail始终指向队列尾部元素的后一个元素。
在数组容量方面,该类的最小容量为8,默认初始化容量为16。指定容量初始化最小初始化为8,否则将容量初始化为大于指定容量最小的2的幂。
实现上是这样的:

  if (numElements >= initialCapacity) {
            initialCapacity = numElements;
            initialCapacity |= (initialCapacity >>>  1);
            initialCapacity |= (initialCapacity >>>  2);
            initialCapacity |= (initialCapacity >>>  4);
            initialCapacity |= (initialCapacity >>>  8);
            initialCapacity |= (initialCapacity >>> 16);
            initialCapacity++;
            if (initialCapacity < 0)   // Too many elements, must back off
                initialCapacity >>>= 1;// Good luck allocating 2 ^ 30 elements
        }

可以看出该部分可以将任意一个大于8的int值,比如0…01xxxxx经过或移位处理后将会变成0…0111111这样的形式,最后经过自增变成xxxx1000000这样的形式。也就刚好是2的幂了。很奇妙的位运算方法。
初始化一个容量后,在什么情况下会产生扩容动作呢?通过源码发现,只有两种情况会产生扩容动作:

 adding
 if (head == tail)  //addFirst,head-1
 if ( (tail = (tail + 1) & (elements.length - 1)) == head) //addLast,tail+1

在添加元素时,如果head等于tail。或者(tail+1)&(length-1)等于head时,会进行扩容。扩容是直接进行双倍扩容,扩容失败则抛出异常。
第二个if利用了2的幂的巧妙,通过使 tail 和 (elements.length - 1) 进行与运算,使之等效于tail % length。相信大家都会进行验证。关于扩容的函数:

    private void doubleCapacity() {
        assert head == tail;   //扩容条件,tail索引位置存在元素
        int p = head;		   //队列头部位置
        int n = elements.length;  //队列总长度
        int r = n - p; 			 //队列头部右边的元素数量
        int newCapacity = n << 1;  //新容量,也就是队列总长度的两倍
        if (newCapacity < 0)
            throw new IllegalStateException("Sorry, deque too big");
        Object[] a = new Object[newCapacity];
        System.arraycopy(elements, p, a, 0, r);  //复制队列head右部部分
        System.arraycopy(elements, 0, a, r, p);  //复制队列head左部部分
        elements = a;  
        head = 0;       //新头部
        tail = n;       //新尾部
    }

从添加方法中我们得知,从头部增长队列head会不断循环减小,从尾部增长队列tail会不断循环增大。虽然他们的增长方向是固定的,但由于循环这一特性,队列的头尾部分只能由head~fail确定,而不能由原数组确定。所以需要两次复制来确保完整正确的复制整个队列。
最后扩容后的队列将队列头部的元素重新从数组0索引处开始,所以head归零,tail为最后一个元素的后一个元素位置,也就是原数组长度。

照常理来说,双端队列应该只能操作队列两端的元素才对。但在ArrayDeque的实现中有操作中间元素的方法,那就是:

boolean contains(Object o)  //遍历队列,返回元素o是否存在
boolean removeFirstOccurrence(Object o) //从前向后遍历,删除第一个对象o返回true或返回false
boolean removeLastOccurrence(Object o)//从后向前遍历,删除第一个对象o返回true或返回false

这里的从前遍历并不是从索引0处往后遍历,而是从head开始,遍历到tail。这是一个逻辑性的前后关系。当然从后遍历也是这样,是从tail-1向前到head。
需要注意的是,从中间删除元素需要大规模的移动数组元素,还需要更新索引值。所以消耗比较大。

还有一点需要注意的就是迭代器了,ArrayDeque有两种迭代器,分别是DeqIterator和DescendingIterator。由于该容器存储元素的机制和之前的容器相比差距较大,所以该迭代器与之前的都有所不同。当然,从功能上分,也是分为正向迭代器和反向迭代器这两种。
在该迭代器中也有快速失败机制,但是不需要modCount计数修改版本。只需要比较lastRet索引和原tail索引是否相同以及elements[cursor]是否为空即可。因为队列中tail等索引的移动是单向的,所以能这样判定是否产生了修改。

1.3 Set

  Set表示集合,特征是存储的元素集合拥有互异性,唯一性,无序性的特征。Set直接继承于Collection,并且在该接口中,没有任意新增方法。所以该接口的方法签名比较简单。在它的直接子接口中,又包含SortedSet,表示有序集合,因为有序,所以多了子视图和比较器相关的方法。
所以,总体来说,在Set接口下的具体实现类,可分为有序集合和无序集合两大类别,其中有序集合的代表是TreeSet,无序集合的代表是EnumSet和HashSet以及LinkedHashSet。

 1.3.1 EnumSet

  EnumSet的底层存储实现为

final Enum<?>[] universe;

该类是用于枚举类型的专用Set实现。性能高效。很不同的是,该类是以抽象类的形式展现的。也就是说不能创建该类的实例。所以该类的对象是以静态工厂方法提供的。
首先得先了解枚举类的特征,特别是getDeclaringClass()方法,由于枚举类内部允许一层匿名内部类,所以使用该方法才能获取正确的枚举类。
产生对象的静态工厂类有:

public static <E extends Enum<E>> EnumSet<E> of(E e);
public static <E extends Enum<E>> EnumSet<E> of(E e1, E e2)
public static <E extends Enum<E>> EnumSet<E> of(E e1, E e2, E e3, E e4)
public static <E extends Enum<E>> EnumSet<E> of(E e1, E e2, E e3, E e4,E e5)
public static <E extends Enum<E>> EnumSet<E> of(E first, E... rest)
public static <E extends Enum<E>> EnumSet<E> range(E from, E to)
public static <E extends Enum<E>> EnumSet<E> allOf(Class<E> elementType)
public static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType)
public static <E extends Enum<E>> EnumSet<E> copyOf(EnumSet<E> s)
public static <E extends Enum<E>> EnumSet<E> copyOf(Collection<E> c)
public static <E extends Enum<E>> EnumSet<E> complementOf(EnumSet<E> s)

通过noneOf方法发现,若枚举元素数量小于等于64则具体类是RegularEnumSet<>,大于64则是JumboEnumSet<>。所以,具体的枚举集合实现只需要查看这两个类即可。

首先看RegularEnumSet类,它的底层存储结构是一个长整型数字:

private long elements = 0L;

构造器调用的是父类的构造器,存储着枚举类类型以及所有可能的枚举值。

	final Class<E> elementType;   //枚举类类型
    final Enum<?>[] universe;     //枚举类的所有值
    EnumSet(Class<E>elementType, Enum<?>[] universe) {
        this.elementType = elementType;
        this.universe    = universe;
    }

因为枚举类型元素个数是有限的,并且Set具有唯一性的特性,使得这里使用了位域的思想来存储枚举集合。每个枚举集合都存储了当前枚举类型的所有元素,也就是universe,然后使用整型中的位域一一对应universe枚举数组的索引,若某一枚举元素存在于集合则对应索引的位域就为1,否则为0。所以在枚举元素数量小于64时使用long类型存储该集合,枚举元素数量大于64时使用long数组存储该枚举集合。
关于位运算,源码中处理的比较巧妙,这里暂时跳过。

 1.3.2 HashSet

  HashSet听名字就能知道它是依赖HashCode来区分对象的,并且该类集合中的元素具有确定性、互异性,无序性。底层存储结构实际上就是HashMap:

private transient HashMap<E,Object> map;

具体的HashMap在后面说,先讲讲HashSet的初始化:
HashSet有三种初始化方式,第一种是直接初始化一个默认的HashMap:

    public HashSet() {
        map = new HashMap<>();
    }

第二种初始化是利用Collection类型参数进行初始化,最小空间为16,否则为(c.size()/.75f)单位大小:

    public HashSet(Collection<? extends E> c) {
        map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
        addAll(c);
    }

第三种初始化是手动指定容量或指定容量和负载因子的初始化:

    public HashSet(int initialCapacity) {
        map = new HashMap<>(initialCapacity);
    }
	public HashSet(int initialCapacity, float loadFactor) {
        map = new HashMap<>(initialCapacity, loadFactor);
   	}

HashSet的所有操作方法都来自于HashMap,但仅仅只使用了Key,Value统一使用无意义的填充值PRESENT。

 1.3.3 LinkedHashSet

  LinkedHashSet继承于HashSet,只额外实现了构造方法。实际上构造方法也都是HashSet中该方法的包装:

    HashSet(int initialCapacity, float loadFactor, boolean dummy) {
        map = new LinkedHashMap<>(initialCapacity, loadFactor);
    }

没错,对比HashSet构造方法底层结构使用的HashMap,LinkedHashSet的底层结构使用的是LinkedHashMap。该结构也会在之后进行学习。

 1.3.3 TreeSet

  TreeSet是继承于Set->SortedSet->NavigableSet,对比HashSet,它具备有序性特征。有正/反向迭代器,并且因为继承于NavigableSet,所以有一系列导航元素的方法。底层存储结构为:

 private transient NavigableMap<E,Object> m;

构造方法有:

	TreeSet(NavigableMap<E,Object> m) {     //供另外几个构造方法使用
        this.m = m;
    }
    public TreeSet() {						//默认构造方法
        this(new TreeMap<E,Object>());
    }
    public TreeSet(Comparator<? super E> comparator) {	//比较器构造方法
        this(new TreeMap<>(comparator));
    }
    public TreeSet(Collection<? extends E> c) {			//集合类构造方法
        this();
        addAll(c);
    }
    public TreeSet(SortedSet<E> s) {					//...
        this(s.comparator());
        addAll(s);
    }

另外的方法几乎都是调用TreeMap实现的,所以这里不做探讨。

二、Map接口

2.1 EnumMap

  EnumMap是用于枚举类型键的映射实现,键必须是枚举类型,值可以是任意引用类型。键拥有唯一,非空的特性,新增已有键的映射会覆盖之前的映射。其底层存储结构为:

private final Class<K> keyType;		    //键的枚举类型
private transient K[] keyUniverse;		//键可能的所有值的顺序排列
private transient Object[] vals;		//值数组
private transient int size = 0;		    //枚举映射大小
private static final Enum<?>[] ZERO_LENGTH_ENUM_ARRAY = new Enum<?>[0]; //无元素枚举映射
    
    private static final Object NULL = new Object() {  //null的替代品NULL作为对象存储
        public int hashCode() {
            return 0;
        }

        public String toString() {
            return "java.util.EnumMap.NULL";
        }
    };

需要说明的是,为什么需要使用对象NULL作为null的替代品呢?在我看来,通过参考NULL的使用位置,我认为原因是为了区分是否有该键,因为在EnumMap中每次都是添加一对键值,由于可以允许空值的存在,所以空值和无值键的映射就会混淆。这才有了NULL对象,使得空值映射实际上存的是NULL对象,无键部分的值是null。在之后大多数方法中都需要注意NULL和null的情况。

2.2 HashMap

  HashMap中的每个元素由一组键值组成,存储特征为无序性,确定性,键互异性,覆盖性,键值都可为空。底层存储结构为:

transient Node<K,V>[] table;               //Hash散列表
transient Set<Map.Entry<K,V>> entrySet;	   //保持缓存的entrySet()
transient int size;		//条目数量
transient int modCount; //修改版本,用于并发快速失败。
int threshold;		    //下一个调整大小值(capacity * load factor),当size大于此值时会发生扩容
final float loadFactor; //加载因子
/*********  配置参数*********************/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //默认初始化容量
static final int MAXIMUM_CAPACITY = 1 << 30;		//最大容量
static final float DEFAULT_LOAD_FACTOR = 0.75f;		//默认填充因子
static final int TREEIFY_THRESHOLD = 8;				//从链表转换到树的阀值
static final int UNTREEIFY_THRESHOLD = 6;			//从树转换到链表的阀值
/*
当桶中的链表被树化时最小容量,该值至少是4*TREEIFY_THRESHOLD
如果桶没有超过这个数量,桶中链表过长时会对桶进行扩容
如果桶超过了这个数量,链表过长时会进化为红黑树
*/
static final int MIN_TREEIFY_CAPACITY = 64;  
	
	//元素结点	   
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
        ..........
    }

关于HashMap的存储需要注意的重点就是Hash值。在一个HashMap中关于Hash值的地方有:

  1. HashMap本身的Hash值:
    HashMap本身的Hash值是每个条目的哈希值之和。
  2. HashMap结点的HashCode值:
    HashMap中结点的Hash值为键值的异或。
  3. HashMap结点的hash属性值,这个才是哈希表存取的关键
    HashMap结点的hash属性值为(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    key正是存储在table[hash & (table.length-1)]的桶中的。

HashMap是个比较复杂的类,所以我想将哈希散列表所有的可能的变化情况都演化一次:
当创建哈希散列表时,构造函数只会初始化加载因子,扩展阀值等配置参数:
Java集合框架之实现类_第1张图片
当添加一个元素后,根据put方法会计算元素的哈希,并将之存入hash散列表正确的桶中,然后更新size值和threshold(扩容阀值)。
put方法执行图:
Java集合框架之实现类_第2张图片

之后再添加结点,除了计算哈希存放到哈希表以及更新size以外,有几个特殊事件触发点:

  1. 当 size > threshold 时触发扩容(resize)函数。
  2. 当某个桶的链表长度大于8时并且容量大于64时,将链表转换为红黑树,否则进行扩容。
  3. 删除结点时,根据删除的红黑树结点位置可能将树退化为链表。

扩容:为了保持桶中的链表/树的长/深度不大于某个临界点,当总条目数超出threshold时就会调用resize()函数。resize()方法的运行分为两个阶段,
第一阶段是分配新容量
第二阶段是将元素从旧表迁移到新表。

删除:删除会先确认哈希表是否存在(table!=null,table.lenth>0),以及入参的hash(key)对应的桶是否存在。然后进入具体删除方法:
第一阶段为:从桶/链表/树种获得hash为hash(key)的结点node。
第二阶段为:如果结点在树中就将之从树中删除,并且根据条件可能将树退化为链表:

            if (root == null || root.right == null ||
                (rl = root.left) == null || rl.left == null) {
                tab[index] = first.untreeify(map);  // too small
                return;
            }

结点不在树中则将该结点从桶/链表中赋值给node.next

根据迭代器代码可知,所有的迭代器都依赖于HashIterator,而HashIterator的实现是基于next引用的,next引用又是取自Node.next()或者table[index]的。所以当链式结点进化为树形结点后怎么办呢?
没问题,因为

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V>
static class Entry<K,V> extends HashMap.Node<K,V>

所以即使HashMap的链式结点进化成红黑树的形式,next指针仍然存在,这样的好处是可以简化迭代器的遍历。坏处是在实现红黑树时需要对next的维护。

2.3 LinkedHashMap

  HashMap的子类,比起HashMap,LinkedHashMap将多维护一个双向链表,用于链接所有结点。LinkedHashMap结点以及构造函数定义如下所示:

//调用父类的构造函数以及初始化遍历顺序,遍历顺序默认为插入顺序
    public LinkedHashMap(int initialCapacity,float loadFactor,boolean accessOrder) {
        super(initialCapacity, loadFactor);
        this.accessOrder = accessOrder;
    }
    
    static class Entry<K,V> extends HashMap.Node<K,V> {
        Entry<K,V> before, after;
        Entry(int hash, K key, V value, Node<K,V> next) {
            super(hash, key, value, next);
        }
    }

在原HashMap的基础上增加了before和after属性,构造函数的定义都是一样的,但在构造LinkedHashMap时,除了原来HashMap需要做过的所有操作以外,需要额外维护befor和after引用。
那么,LinkedHashMap是怎样实现的呢?通过观察源码,发现它并没有太多的重复代码,仅仅覆盖了几个父类方法,就有了这样的效果。
视图类/迭代器类暂且不考虑,只看重要的几个覆盖方法:

/*
覆盖了父类的该方法,除了做原有父类需要做的功能以外
还需要维护结点的befor和after,以及类中的head和tail
维护的方法独立定义,在父类中作为空方法,在LinkedHashMap中得到覆盖
*/
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next)
TreeNode<K,V> newTreeNode(int hash, K key, V value, Node<K,V> next)
Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next)
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e)
void reinitialize()

//由于LinkedHashMap有双向链表,所以遍历更加方便
public boolean containsValue(Object value)
//由于是有序集,所以它通过accessOrder属性控制遍历为插入顺序还是访问顺序
public V getOrDefault(Object key, V defaultValue)/public V get(Object key)
//继承于HashMap,调用了父类的clear方法并且清除了自己的head和tail
public void clear()
//覆盖该方法可以在插入元素时进行旧元素的删除操作
//需要删除则返回true,不需要则返回false,默认实现是 return false;
protected boolean removeEldestEntry(Map.Entry<K,V> eldest){}

关于Hash散列表操作后维护双向链表的具体函数有:

// move node to last
void afterNodeAccess(Node<K,V> e) 
// possibly remove eldest
void afterNodeInsertion(boolean evict)
// unlink
void afterNodeRemoval(Node<K,V> e)

2.4 TreeMap

  Map的有序实现,键值均不能为空,完全依赖红黑树的实现。由于该类是独立实现的,不是像LinkedHashMap那样借用了已有的代码,所以该类有3000多行…
暂时没有研究其源码。
不过还是粗略知道该类使用比较器进行大小判别。元素存储顺序依赖于比较器的定义。
想得到详细了解可以参考这篇文章Java 集合系列12之 TreeMap详细介绍(源码解析)和使用示例

三、集合框架总结

  列表是十分易懂直接的数据结构,实现也比较容易,使用更是广泛。特性是可变长,线性有序的存储,可以循秩访问/修改元素。在集合框架中,列表有链式的和顺序的两种实现,分别是LinkedList和ArrayList。在使用时最重要的便是认清链表和数组的底层操作特性了。虽说实现容易,在不同环境的使用中还是可以根据其结构特性简单优化下的。比如:

  1. 如果数组大小可预知,可以在定义时指定其初始容量。
  2. 如果对数组扩容机制需要足够细致的把握可以重载扩容函数。
  3. 在数组不需要插入元素时,可以调用方法去除其多余的位置。
  4. 需要遍历链表时,应该使用有保存迭代位置的迭代器。

  在队列和栈方面,队列是先入先出的结构,栈是先入后出的结构,所以特性是可变长,线性无序(底层是有序的,但存取顺序不一致),只能访问/修改末端元素。由于历史原因,Stack不被推荐使用,实现队列和栈接口的具体类有LinkedList和ArrayDeque。和列表类似,队列/栈的实现也分为链式和顺序两种实现,分别对应不同的使用情景。好在队列/栈在一般情况下只需要在两端进行操作。所以顺序实现只在扩容时才会产生较大的消耗。链式实现上除了需要额外空间存储连接链以外,在对队列和栈的实现上没有太大缺点。实现也很直接易懂。而数组的栈/队列实现就没有这样容易了。需要两个变量分别索引队头和队尾。一般来说还需要长度变量来标记元素个数,以便于动态扩展数组。由于队头,队尾的位置是单向变化的,为了尽可能不浪费空间,采用了循环数组的技巧。在使用方面,基础方面上可以参考列表,因为队列/栈本身就是一个特殊的列表。

  最后就是树/散列方面了,树/散列属于高级数据结构,是基础数据结构的组合,比如散列即可以采用哈希函数和数组实现(开放定址法),也可以采用数组+链表实现(链地址法)实现。

  而在树结构中,集合框架实现了平衡二叉搜索树(具体实现包含AVL树,伸展树,B树,红黑树)的红黑树实现。树是一种半线性结构,同时他结合了数组和链表的优点,在查找和插入效率中进行了折中。因此在很多复杂场合都有树结构的应用,比如文件系统,数据库系统,压缩编码等等。同时,也广泛使用在Java集合框架中Map和Set中。
  首先从Map和Set的接口和使用开始探究:Map和Set都是半线性结构,所以查询/插入/删除效能比较平均,需要额外的空间开销,不可以循秩访问,但可以使用迭代器遍历。
  在具体的HashMap以及HashSet中,它们使用的是同样的数据结构,只不过使用了不同的接口,都是链地址法的哈希散列表。也就是这个样子:
Java集合框架之实现类_第3张图片
这是链地址法最基本的应用,在之后可以看到,LinkedHash系列将单链表换成了双链表,新版本中的链在长度超过阈值后会进化为红黑树。在Tree系列中,存储元素的底层结构是一颗直接的红黑树,它不仅保证元素有序,还能使查询/插入/删除效能都维持在O(logN)。

需要注意的一点是,根据哈希容器的特性,涉及到hash操作的部分必须是不可变的,比如HashMap/HashSet中的键,如果键对象发生变化,则hashCode一般也会发生变化,而存储在Hash容器中所计算的hash值却不会变化。这样一来便检索不到该对象了。

参考:
Java8系列之重新认识HashMap
HashMap 源码分析

你可能感兴趣的:(Java)