首先从Collection接口下来,它包含List和Set以及Queue三大接口。首先从之前分析的经验可以总结出List和Set以及Queue接口的异同有:
List通过有序性这个特性,新增了排序功能以及根据元素索引操作元素的功能,并且有了新的迭代器ListIterator可以用于双向迭代。它的具体实现类包括:ArrayList,LinkedList,Vector,Stack。需要注意的是Vector和Stack是同步容器,所以分别被ArrayList和ArrayDeque替代掉了。我会在集合框架的最后一章整理废弃容器以及集合框架的设计问题。所以这里只会分析ArrayList和LinkedList。
底层存储方面:
ArrayList底层使用数组存储元素,默认大小为10,使用elementData数组引用指向具体的存储空间EMPTY_ELEMENTDATA和DEFAULTCAPACITY_EMPTY_ELEMENTDATA都是大小为0的常量静态数组由空元素数组或者默认大小的空元素数组共同指向,避免了多个零大小/默认大小的空元素数组占用多余的空间。
这里可能会有个疑问,为什么默认大小的空ArrayList和0大小的空ArrayList的底层存储要指向两个不同的数组呢?通过观察源码,从add()->ensureCapacityInternal()找到了原因:
初始化0大小的ArrayList(0)第一次增加元素只会增加单个容量,而默认初始化大小的ArrayList()第一次增加元素则会增长10个容量。之后每次容量超出后就会自动增长至oldCapacity + (oldCapacity >> 1),也可以手动增长,但每次增长最少1.5倍。
数据操作方面:
2.1 为了维护视图以及保证元素被正常操作,ArrayList中照样使用了快速失败机制,每次结构性修改都会使modCount+1,并且结构性修改都是调用System.arraycopy(src,srcpos,dest,destpos,size)该本地函数进行修改。在不改变数组结构的情况下删除元素只需要将该数组索引处设置为null即可。
2.2 为了保证ArrayList所有函数式接口执行的完整性,在传入函数执行前后都会验证其modCount,只有传入函数执行前后数组结构没变的情况下才能继续后面的操作,否则抛出并发修改异常。
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中的实现差不多,只不过将数组的操作方式变成链表的操作方式了。
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。
该数据结构的实现和之前比就不是那样直接了,由于双端队列的特性和数组的存储,在效率的考虑上,该类实际上是以循环数组实现的。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等索引的移动是单向的,所以能这样判定是否产生了修改。
Set表示集合,特征是存储的元素集合拥有互异性,唯一性,无序性的特征。Set直接继承于Collection,并且在该接口中,没有任意新增方法。所以该接口的方法签名比较简单。在它的直接子接口中,又包含SortedSet,表示有序集合,因为有序,所以多了子视图和比较器相关的方法。
所以,总体来说,在Set接口下的具体实现类,可分为有序集合和无序集合两大类别,其中有序集合的代表是TreeSet,无序集合的代表是EnumSet和HashSet以及LinkedHashSet。
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数组存储该枚举集合。
关于位运算,源码中处理的比较巧妙,这里暂时跳过。
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。
LinkedHashSet继承于HashSet,只额外实现了构造方法。实际上构造方法也都是HashSet中该方法的包装:
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
}
没错,对比HashSet构造方法底层结构使用的HashMap,LinkedHashSet的底层结构使用的是LinkedHashMap。该结构也会在之后进行学习。
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实现的,所以这里不做探讨。
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的情况。
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值的地方有:
(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
HashMap是个比较复杂的类,所以我想将哈希散列表所有的可能的变化情况都演化一次:
当创建哈希散列表时,构造函数只会初始化加载因子,扩展阀值等配置参数:
当添加一个元素后,根据put方法会计算元素的哈希,并将之存入hash散列表正确的桶中,然后更新size值和threshold(扩容阀值)。
put方法执行图:
之后再添加结点,除了计算哈希存放到哈希表以及更新size以外,有几个特殊事件触发点:
扩容:为了保持桶中的链表/树的长/深度不大于某个临界点,当总条目数超出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的维护。
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)
Map的有序实现,键值均不能为空,完全依赖红黑树的实现。由于该类是独立实现的,不是像LinkedHashMap那样借用了已有的代码,所以该类有3000多行…
暂时没有研究其源码。
不过还是粗略知道该类使用比较器进行大小判别。元素存储顺序依赖于比较器的定义。
想得到详细了解可以参考这篇文章Java 集合系列12之 TreeMap详细介绍(源码解析)和使用示例
列表是十分易懂直接的数据结构,实现也比较容易,使用更是广泛。特性是可变长,线性有序的存储,可以循秩访问/修改元素。在集合框架中,列表有链式的和顺序的两种实现,分别是LinkedList和ArrayList。在使用时最重要的便是认清链表和数组的底层操作特性了。虽说实现容易,在不同环境的使用中还是可以根据其结构特性简单优化下的。比如:
在队列和栈方面,队列是先入先出的结构,栈是先入后出的结构,所以特性是可变长,线性无序(底层是有序的,但存取顺序不一致),只能访问/修改末端元素。由于历史原因,Stack不被推荐使用,实现队列和栈接口的具体类有LinkedList和ArrayDeque。和列表类似,队列/栈的实现也分为链式和顺序两种实现,分别对应不同的使用情景。好在队列/栈在一般情况下只需要在两端进行操作。所以顺序实现只在扩容时才会产生较大的消耗。链式实现上除了需要额外空间存储连接链以外,在对队列和栈的实现上没有太大缺点。实现也很直接易懂。而数组的栈/队列实现就没有这样容易了。需要两个变量分别索引队头和队尾。一般来说还需要长度变量来标记元素个数,以便于动态扩展数组。由于队头,队尾的位置是单向变化的,为了尽可能不浪费空间,采用了循环数组的技巧。在使用方面,基础方面上可以参考列表,因为队列/栈本身就是一个特殊的列表。
最后就是树/散列方面了,树/散列属于高级数据结构,是基础数据结构的组合,比如散列即可以采用哈希函数和数组实现(开放定址法),也可以采用数组+链表实现(链地址法)实现。
而在树结构中,集合框架实现了平衡二叉搜索树(具体实现包含AVL树,伸展树,B树,红黑树)的红黑树实现。树是一种半线性结构,同时他结合了数组和链表的优点,在查找和插入效率中进行了折中。因此在很多复杂场合都有树结构的应用,比如文件系统,数据库系统,压缩编码等等。同时,也广泛使用在Java集合框架中Map和Set中。
首先从Map和Set的接口和使用开始探究:Map和Set都是半线性结构,所以查询/插入/删除效能比较平均,需要额外的空间开销,不可以循秩访问,但可以使用迭代器遍历。
在具体的HashMap以及HashSet中,它们使用的是同样的数据结构,只不过使用了不同的接口,都是链地址法的哈希散列表。也就是这个样子:
这是链地址法最基本的应用,在之后可以看到,LinkedHash系列将单链表换成了双链表,新版本中的链在长度超过阈值后会进化为红黑树。在Tree系列中,存储元素的底层结构是一颗直接的红黑树,它不仅保证元素有序,还能使查询/插入/删除效能都维持在O(logN)。
需要注意的一点是,根据哈希容器的特性,涉及到hash操作的部分必须是不可变的,比如HashMap/HashSet中的键,如果键对象发生变化,则hashCode一般也会发生变化,而存储在Hash容器中所计算的hash值却不会变化。这样一来便检索不到该对象了。
参考:
Java8系列之重新认识HashMap
HashMap 源码分析