概述
Java的集合框架十分强大,它将常用的数据结构和算法进行封装,使得Java开发者无需精通底层数据结构和算法就可以轻松使用集合框架的API。例如最常见的数组、链表以及队列,栈再到二叉树,红黑树,JDK都将其进行不同程度的封装,极大提升了开发人员的开发效率。不过虽然我们在项目中无需直接接触此类底层逻辑,但我们还是需要熟练掌握其内容。因为在极端的业务场景下,我们可能需要对其底层数据结构进行调试甚至优化,所以熟练掌握底层结构也是必备技能。
以下是一张Collection接口图。
从图中我们可以看到,Collection接口拥有三个主要的子接口,分别是:Set
、List
、Queue
。
详解-List
我们首先来讲解List接口。
List的特点
- 存储的元素是有序(存储和取出的顺序)的。
- 同一元素可重复存储(可重复)。
- 可通过索引值操作元素。
List接口下包含有ArrayList
、Vector
、LinkedList
三个类分别实现了List接口。
接下来我们来详细讨论其的区别。
我们根据底层数据结构的不同来将其分为两大类:
- 底层是数组。数组查询快,增删慢。
- 底层是链表。链表查询慢,增删快。
ArrayList和Vector属于底层是数组的分类。
ArrayList和Vector的区别?
ArrayList
线程不安全,但效率高,适用于频繁的查找工作。Vector
线程安全,但效率低。其也是Java早期提供线程安全的动态数组,不推荐在实际生产环境中使用。
源码分析
ArrayList.java
transient Object[] elementData; // non-private to simplify nested class access
private int size;
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
}
}
从源码可以看出,ArrayList
中使用的是Object[]
数组,并且查阅ArrayList
源码,发现类中没有使用到synchronized
和其他任何锁的关键字,故ArrayList
是线程不安全。
public synchronized void copyInto(Object[] anArray) {
System.arraycopy(elementData, 0, anArray, 0, elementCount);
}
public synchronized void trimToSize() {
modCount++;
int oldCapacity = elementData.length;
if (elementCount < oldCapacity) {
elementData = Arrays.copyOf(elementData, elementCount);
}
}
public synchronized void ensureCapacity(int minCapacity) {
if (minCapacity > 0) {
modCount++;
ensureCapacityHelper(minCapacity);
}
}
在Vector
类中,同样使用了数组的结构,方法中都带有synchronized
关键字,这就使得Vector
天生线程安全。但大量使用同步锁造成在高并发场景下,将会有大量阻塞的线程等待,效率地下。
LinkedList
我们再来看看LinkedList。LinkedList底层是链表结构,线程不安全,效率高。
接下来我们来探讨ArrayList和LinkedList的区别。
ArrayList和LinkedList的区别
ArrayList
底层采用的是Object
数组,而LinkedList底层采用的是双向链表的结构。
注:JDK1.6(包含)和之前采用的是双向循环链表,JDK1.7开始取消了循环。
引用 - 双向链表:包含两个指针,一个 prev 指向前一个节点,一个 next 指向后一个节点。
引用 - 双向循环链表:最后一个节点的 next 指向 head,而 head 的 prev 指向最后一个节点,构成一个环。
ArrayList
插入和删除的时间复杂度受元素位置的影响。原因如下:ArrayList
会将元素追加到末尾,此时,时间复杂度为O(1)。这时我们通过索引在指定的位置i
插入或删除元素,那么时间复杂度则为O(n-i) (n为数组的长度)。这是因为数组中第i个和第i个之后的(n-i)个元素都要跟着向后或向前移动一位。LinkedList
插入和删除的时间复杂度不受元素位置影响。因为其数据结构为双向链表,所以在add(E e)
、addFirst(E e)
、addLast(E e)
、removeFirst()
、removeLast()
操作中,其时间复杂度为O(1),而通过索引下标i
进行插入或删除操作时间复杂度为O(n)。这是因为链表指针需要移动到指定位置后插入。ArrayList
支持RandomAccess
(即高效的随机访问),而LinkedList
则不支持。
源码分析
/**
* Pointer to first node.
* Invariant: (first == null && last == null) ||
* (first.prev == null && first.item != null)
*/
transient Node<E> first;
/**
* Pointer to last node.
* Invariant: (first == null && last == null) ||
* (last.next == null && last.item != null)
*/
transient Node<E> last;
上图为LinkedList的部分源码。LinkedList
类定义了first
和last
两个Node
结点,分别指向头元素和尾元素,是典型的链表结构。
LinkedList
类没有用到锁,故其也是线程不安全的。
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{...}
ArrayList
实现了RandomAccess
,这个接口与Cloneable一样,只是作为一个标识。说明ArrayList
支持高效的随机访问,实现Cloneable
说明该类支持克隆。同时,实现了Serializable接口说明该类可被序列化。
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{...}
可以看到LinkedList并没有支持高效的随机访问。其实现了一个双端队列Deque
,其余部分与ArrayList
同理。
ArrayList的扩容机制
ArrayList
是动态数组,与传统数组相比,它的容量能够动态增长。在源码中,扩容的核心方法是grow()
,此处我们详细分析grow()
方法,若要了解详细的扩容机制,可自行查阅源码或搜索相关资料。
源码分析
// minCapacity: 最小需要容量
private void grow(int minCapacity) {
// oldCapacity: 旧容量,newCapacity: 新容量
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
// 此处MAX_ARRAY_SIZE常量值为:Integer.MAX_VALUE - 8
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);
}
解释:int newCapacity = oldCapacity + (oldCapacity >> 1);
此语句的意思是将oldCapacity
做右移一位的运算,其效果相当于oldCapacity / 2
。此处使用为位运算而不直接使用除法运算目的在于:位运算速度比除法运算快得多,有助于提升效率,节省资源。
grow
方法的逻辑:先将oldCapacity
右移一位,然后判断新容量是否小于最小需要容量,若条件满足(即newCapacity < minCapacity),则将新容量设为传入的最小需要容量。
若新容量大于MAX_ARRAY_SIZE
,则调用hugeCapacity()
方法来比较minCapacity
和MAX_ARRAY_SIZE
。
hugeCapacity
代码如下:
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
return (minCapacity > MAX_ARRAY_SIZE) ?
Integer.MAX_VALUE :
MAX_ARRAY_SIZE;
}
即:如果minCapacity大于最大容量,则新容量则为Integer.MAX_VALUE
,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 Integer.MAX_VALUE - 8
。
最后,使用Arrays.copyOf();
方法生成一个新的扩容好的数组,并将其赋值给原始数组即elementData
。这就实现了数组的扩容操作。
此外,ArrayList还提供了可供外部调用的扩容方法ensureCapacity
。
public void ensureCapacity(int minCapacity) {
int minExpand = (elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
// any size if not default element table
? 0
// larger than default for default empty table. It's already
// supposed to be at default size.
: DEFAULT_CAPACITY;
if (minCapacity > minExpand) {
ensureExplicitCapacity(minCapacity);
}
}
通过源码可以看出,ensureCapacity
方法使用的是public
修饰符,可供开发者进行调用。在添加大量元素前,开发者可以通过ensureCapacity
动态地为数组扩展容量,这样可以避免频繁地扩容带来的性能损失。此处不是此篇的重点,具体感兴趣可自行查看源码。
详解-Set
Set
也是Collection
的重要子接口之一。从文章开头的框架图可以看出,Set
下包含HashSet
、LinkedHashSet
、TreeSet
等子接口。
Set的特点
- 无序性:即存储的元素是无序(存储和取出的顺序)的。
- 唯一性:所有的元素都是唯一的。
Set
适合应用在需要去重的场景中。
将Set
进行分类,我们同样能够将其分为两大类:
- 底层是哈希表,即HashMap,在下文源码中可以看出。具体HashMap的实现,我将于下一篇进行介绍。
- 底层是二叉树。
HashSet
和LinkedHashSet
属于第一种分类,即底层是哈希表(LinkedHashSet
底层是哈希表+链表)。TreeSet
则属于底层是二叉树的分类。
HashSet
使用hashCode()
和equals()
来保证元素的唯一性。
源码分析
我们来看HashSet
的两个源码片段。
public HashSet(int initialCapacity) {
map = new HashMap<>(initialCapacity);
}
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
从源码可以看出HashSet
在初始化时创建了一个HashMap
对象。在添加元素的时候,我们可以看到,元素其实是以键的形式存储在HashMap
中,对应的值为一个PRESENT
的常量。PRESENT
的值实际上是一个final
的Object
对象。这就解释了HashSet如何保证元素的唯一性。
HashSet
常用于保证元素唯一性的场景,而TreeSet
常用于对排序有要求的场景。在不需要排序的场景下使用HashSet
可以获得更好的性能。
谈起排序,我们来具体讨论一下Comparable
和Comparator
接口。
Comparable
接口在java.lang
包,它有一个compareTo(Object obj)
方法用于排序。Comparator
接口在java.util
包,它有一个compare(Object obj1, Object obj2)
方法用于排序。
Comparable和Comparator接口的区别
Comparable
接口是一个自然排序接口,它用于实现自然排序,compareTo
也称为自然比较方法。当开发者调用Collections
中的sort
方法时,对象可按照自身实现的Comparable
进行自然排序。这个对象必须实现Comparable
接口。Comparator
接口可以理解为定制化排序接口,开发者可对没有实现Comparable
接口的类进行定制化排序,而无需修改类的源码。
TreeSet源码分析
TreeSet
类就同时提供了Comparable
和Comparator
两种排序的方式。具体我们追溯到源码。
public TreeSet() {
this(new TreeMap<E,Object>());
}
public TreeSet(Comparator<? super E> comparator) {
this(new TreeMap<>(comparator));
}
通过源码可以看出,TreeSet
底层是通过TreeMap
实现的,所以我们需要进一步进入到TreeMap
中查看具体排序的逻辑。TreeSet
的构造函数提供了一个无参构造函数和一个有参构造函数。无参构造函数在TreeMap
中将会使用Comparable
的自然排序,而有参构造函数传入了一个Comparator
对象。
现在我们进入到TreeMap
中进行查看。
final Entry<K,V> getEntry(Object key) {
// Offload comparator-based version for sake of performance
if (comparator != null)
return getEntryUsingComparator(key);
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key;
Entry<K,V> p = root;
while (p != null) {
int cmp = k.compareTo(p.key);
if (cmp < 0)
p = p.left;
else if (cmp > 0)
p = p.right;
else
return p;
}
return null;
}
通过源码可以看出,在comparator
不为空的时候,getEntry
方法会调用getEntryUsingComparator
,进而就调用了我们自定义的排序方法。默认则是通过Comparable
接口进行自然排序。
HashSet、LinkedHashSet、TreeSet比较
HashSet
、LinkedHashSet
、TreeSet
都是Set
接口的实现类,都能保证元素唯一,都不是线程安全的。HashSet
底层数据结构为哈希表,基于HashMap
进行实现。LinkedHashSet
的底层数据结构是链表+哈希表。元素的插入和取出顺序是先进先出。TreeSet
的底层数据结构是红黑树(自平衡二叉查找树)。HashSet
适用于不需要保证元素插入和取出顺序和不关心排序的场景,LinkedHashSet
适用于保证元素的插入和取出顺序满足先进先出的场景,TreeSet
适用于需要自定义排序规则的场景。
补充-Queue
Queue
是单端队列,即元素只能从一段插入,另一端删除,一般遵循FIFO即先进先出原则。它扩展了Collection
接口。
Deque
是双端队列,即队列两队均可插入或删除元素。它扩展了Queue
接口,并且提供push
和pop
等特殊方法,可用于模拟栈结构。
Deque
接口下用得较多的就是LinkedList
类,具体可查看前文内容。
总结
在本文的末尾,我总结了一个表格对比了上文讲述的对象的特点。
对象 | 底层数据结构 | 是否线程安全 |
---|---|---|
ArrayList | 数组 | 否 |
Vector | 数组 | 是 |
LinkedList | 链表 | 否 |
HashSet | 哈希表 | 否 |
LinkedHashSet | 哈希表+链表 | 否 |
TreeSet | 红黑树 | 否 |
Java容器中的Collection
是一个面试常考/必考的知识点,并且在开发中也十分常用。熟练掌握Collection
体系内容是极其必要的。