集合框架概述
以Java来说,我们日常所做的编写代码的工作,其实基本上往往就是在和对象打交道。
但显然有一个情况是,一个应用程序里往往不会仅仅只包含数量固定且生命周期都是已知的对象。
所以,就需要通过一些方式来对对象进行持有,那么通常是通过怎么样的方式来持有对象呢?
通过数组是最简单的一种方式,但其缺陷在于:数组的尺寸是固定的,即数组在初始化时就必须被定义长度,且无法改变。
也就说,通过数组来持有对象虽然能解决对象生命周期的问题,但仍然没有解决对象数量未知的问题。
这也是集合框架出现的核心原因,因为大多数时候,对象需要的数量都是在程序运行期根据实际情况而定的。
实际上集合框架就是Java的设计者:对常用的数据结构和算法做了一些规范(接口)和实现(具体实现接口的类),而用以对对象进行持有的。
也就是说,最简单的来说,我们可以将集合框架理解为:数据结构(算法)+ 对象持有。与数组相比,集合框架的特点在于:
- 集合框架下的容器类只能存放对象类型数据;而数组支持对基本类型数据的存放。
- 任何集合框架下的容器类,其长度都是可变的。所以不必担心其长度的指定问题。
- 集合框架下的不同容器类底层采用了不同的数据结构实现,而因不同的数据结构也有自身各自的特性与优劣。
而既然被称为框架,就自然证明它由一个个不同的“容器”结构集体而构成的一个体系。
所以,在进一步的深入了解之前,我们先通过一张老图来了解一下框架结构,再分而进之。
这张图能体现最基本的“容器”分类结构,从中其实不难看到,所谓的集合框架:
主要是分为了两个大的体系:Collection与Map;而所有的容器类都实现了Iterator,用以进行迭代。
总的来说,集合框架的使用对于开发工作来说是很重要的一块使用部分。
所以在本篇文章里,我们将对各个体系的容器类的使用做常用的使用总结。
然后对ArrayList,LinkList之类的常用的容器类通过源码解析来加深对集合框架的理解。
Collection体系
Java容器类的作用是“保存对象”,而从我们前面的结构图也不难看到,集合框架将容器分为了两个体系。
体系之一就是“Collection”,其特点在于:一条存放独立元素的序列,而其中的元素都遵循一条或多条规则。
相信你一定熟悉ArrayList的使用,而当你通过ArrayList一层层的去查看源码的时候,就会发现:
它经历了AbstractList → AbstractCollection → Collection这样的一个继承结构。
由此,我们也看见Collection接口正是位于这个体系之中的众多容器类的根接口。这也为什么我们称该体系为“Collection体系”。
既然Collection为根,了解继承特性的你,就不难想象,Collection代表了位于该体系之下的所有类的最通性表现。
那我们自然有必要首先来查看一下,定义在Collection接口当中的方法声明:
- boolean add(E e) 确保此 collection 包含指定的元素(可选操作)。
- boolean addAll(Collection< ? extends E> c) 将指定 collection 中的所有元素都添加到此 collection 中(可选操作)。
- void clear() 移除此 collection 中的所有元素(可选操作)。
- boolean contains(Object o) 如果此 collection 包含指定的元素,则返回 true。
- boolean containsAll(Collection< ? > c) 如果此 collection 包含指定 collection 中的所有元素,则返回 true。
- boolean equals(Object o) 比较此 collection 与指定对象是否相等。
- int hashCode() 返回此 collection 的哈希码值。
- boolean isEmpty() 如果此 collection 不包含元素,则返回 true。
- Iterator< E > iterator() 返回在此 collection 的元素上进行迭代的迭代器。
- boolean remove(Object o) 从此 collection 中移除指定元素的单个实例,如果存在的话(可选操作)。
- boolean removeAll(Collection< ? > c) 移除此 collection 中那些也包含在指定 collection 中的所有元素(可选操作)。
- boolean retainAll(Collection< ? > c) 仅保留此 collection 中那些也包含在指定 collection 的元素(可选操作)。
- int size() 返回此 collection 中的元素数。
- Object[] toArray() 返回包含此 collection 中所有元素的数组。
- < T > T[] toArray(T[] a) 返回包含此 collection 中所有元素的数组;返回数组的运行时类型与指定数组的运行时类型相同。
上述的方法也代表将被所有Collection体系之下的容器类所实现,所以不难想象它们也就代表着使用Collection体系时最常使用和最基本的方法。
所以,我们当然有必要熟练掌握它们的使用,下面我们通过一个例子来小试身手:
public class CollectionTest {
public static void main(String[] args) {
Collection<String> c = new ArrayList<String>();
c.add("abc"); // 添加单个元素
Collection<String> sub = new ArrayList<String>();
sub.add("123");
sub.add("456");
c.addAll(sub); // 添加集合
c.addAll(Arrays.asList("111", "222"));
System.out.println("1==>" + c);
System.out.println("2==>" + c.contains("123")); // 查看容器内是否包含元素"123"
System.out.println("3==>" + c.containsAll(sub));// 查看容器c内是否包含容器sub内的所有元素
System.out.println("4==>" + c.isEmpty()); // 查看容器是否为空
c.retainAll(sub);// 取容器c与sub的交集
System.out.println("5==>" + c);
c.remove("123"); // 移除单个元素
c.removeAll(sub);// 从容器c当中移除sub内所有包含的元素
System.out.println("6==>" + c);
c.add("666");
Object[] oArray = c.toArray();
String[] sArray = c.toArray(new String[] {});
System.out.println("7==>" + c.size() + "//" + oArray.length + "//"
+ sArray.length);
c.clear();
System.out.println("8==>"+c.size());
}
}
上面演示代码的输出结果为:
1==>[abc, 123, 456, 111, 222]
2==>true
3==>true
4==>false
5==>[123, 456]
6==>[]
7==>1//1//1
8==>0
到此,我们尝试了Collection体系下的容器类的基本使用。其中还有一个很重要的方法“iterator”。
但这个方法并不是声明在Collection接口当中,而是继承自另一个接口Iterable。
所以,我们将它放在之后的迭代器的部分,再来看它的相关使用。
List 体系
事实上,通过对于Colleciton接口内的方法了解。我们已经发现,对于Collection来说:
实际上已经提供了对于对象进行添加,删除,访问(通过迭代器)等等一些列的基本操作。
那么,为什么还要在其之下,继续划出一个List体系呢?通过查看源码,你可以发现List接口同样继承自Colleciton接口。
由此也就不难想到,List接口是在Collection接口的基础上,又添加了一些额外的操作方法。
而这些额外的操作方法,其核心的用途概括来说都是:在容器的中间插入和移除元素(即操作角标)。
查看Java的API说明文档,你会发现对于List接口的说明当中,会发现类似下面的几段话:
- List 接口在 iterator、add、remove、equals 和 hashCode 方法的协定上加了一些其他约定,超过了 Collection 接口中指定的约定。。
- List 接口提供了特殊的迭代器,称为 ListIterator,除了允许 Iterator 接口提供的正常操作外,该迭代器还允许元素插入和替换,以及双向访问。
上面的话中,很清楚的描述了List体系与Collection接口表现出的最共性特征之外的,自身额外的特点。
那么,我们也可以来看一下,在List接口当中额外添加的方法:
- void add(int index, E element) 在列表的指定位置插入指定元素(可选操作)。
- boolean addAll(int index, Collection< ? extends E > c) 将指定 collection 中的所有元素都插入到列表中的指定位置(可选操作)。
- E get(int index) 返回列表中指定位置的元素。
- int indexOf(Object o) 返回此列表中第一次出现的指定元素的索引;如果此列表不包含该元素,则返回 -1。
- int lastIndexOf(Object o) 返回此列表中最后出现的指定元素的索引;如果列表不包含此元素,则返回 -1
- ListIterator< E > listIterator() 返回此列表元素的列表迭代器(按适当顺序)。
- ListIterator< E > listIterator(int index) 返回列表中元素的列表迭代器(按适当顺序),从列表的指定位置开始。
- E remove(int index) 移除列表中指定位置的元素(可选操作)。
- E set(int index, E element) 用指定元素替换列表中指定位置的元素(可选操作)。
- List< E > subList(int fromIndex, int toIndex) 返回列表中指定的 fromIndex(包括 )和 toIndex(不包括)之间的部分视图。
由此,我们看见,List接口相对于Collction接口,进行了添加或针对某些方法进行重载得工作, 从而得到了10个新的方法。
而从方法的说明当中,我们很容易发现它们存在一个共性,就是都存在着针对于角标进行操作的特点。
这也就是为什么我们前面说,List出现的核心用途就是:在容器的中间插入和移除元素。
从源码解析ArrayList
前面我们已经说了不少,我们应该掌握了不少关于集合框架的内容的使用,至少了解了Collection与List体系。
但严格的来说,前面我们所做的都还停留在“纸上谈兵”的阶段。之所这么说,是因为我们前面说到的都是两个接口内的东西,即没有具体实现。
那么,ArrayList可能是我们实际开发中绝逼会经常用到的容器类了,我们就通过这个类为切入点,通过研究它的源码来真正的一展拳脚。
为了将思路尽量的理的比较清晰,我们先从该容器类的继承结构说起,打开ArrayList的源码,首先看到这样的类声明:
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
我们对该容器类的声明进行分析,其中:
- Cloneable接口是用于实现对象的复制;Serializable接口用于让对象支持实现序列化。它们在这里并非我们关心的重点,故不多加赘述。
- RandomAccess接口是一个比较有意思的东西,因为打开源码你会发现这就是一个空接口,那么它的用意何在?
通过注释你其实可以很容易推断出,这个接口的出现是为了用来对容器类进行类型判断,从而选择合适的算法提高集合的循环效率的。
通常在对List特别是Huge size的List的遍历算法中,我们要尽量来判断是属于RandomAccess(如ArrayList)还是Sequence List (如LinkedList)。
这样做的原因在于,因为底层不同的数据结构,造成适合RandomAccess List的遍历算法,用在Sequence List上就差别很大。
我们当然仍旧通过代码来进行验证,因为实践是检验理论的唯一标准:
public class CollectionTest {
public static void main(String[] args) {
ArrayList<Integer> arrayList = new ArrayList<Integer>();
LinkedList<Integer> linkedList = new LinkedList<Integer>();
initList(arrayList);
initList(linkedList);
loopSpeed(ArrayList.class.getSimpleName(), arrayList);
iteratorSpeed(ArrayList.class.getSimpleName(), arrayList);
loopSpeed(LinkedList.class.getSimpleName(), linkedList);
iteratorSpeed(LinkedList.class.getSimpleName(), linkedList);
}
private static void initList(List<Integer> list) {
for (int i = 1; i <= 100000; i++) {
list.add(i);
}
}
private static void loopSpeed(String prefix, List<Integer> list) {
long startTime = System.currentTimeMillis();
for (int i = 0; i < list.size(); i++) {
list.get(i);
}
long endTime = System.currentTimeMillis();
System.out.println(prefix + "通过循环的方式,共花费时间:" + (endTime - startTime)
+ "ms");
}
private static void iteratorSpeed(String prefix, List<Integer> list) {
long startTime = System.currentTimeMillis();
Iterator<Integer> itr = list.iterator();
while (itr.hasNext()) {
itr.next();
}
long endTime = System.currentTimeMillis();
System.out.println(prefix + "通过迭代器的方式,共花费时间:" + (endTime - startTime)
+ "ms");
}
}
在我的机器上,这段程序运行的结果为:
ArrayList通过循环的方式,共花费时间:0ms
ArrayList通过迭代器的方式,共花费时间:15ms
LinkedList通过循环的方式,共花费时间:7861ms
LinkedList通过迭代器的方式,共花费时间:16ms
由此,你可以发现:
- 对于ArrayList来说,使用loop进行遍历相对于迭代器速度要更加快,但这个差距相对还稍微能够接受一点。
- 对于LinkedList来说,使用loop与迭代器进行遍历的速度,相比之下简直天差地别,迭代器要快上几个世纪。
所以,如果在你的代码中想要针对于遍历这个功能来提供一个足够通用的方法。
我们就可以以上面的代码为例,对其加以修改,得到类似下面的代码:
private static <E> void loop(List<E> list) {
if (list instanceof RandomAccess) {
for (int i = 0; i < list.size(); i++) {
list.get(i);
}
} else {
Iterator<E> itr = list.iterator();
while (itr.hasNext()) {
itr.next();
}
}
}
是不是很给力呢?好了,废话少说,我们接着看:
3.然后,至于List接口来说的话,我们在之前已经分析过了。它做的工作正是:
在Collection的基础上,根据List(即列表结构)的自身特点,添加了一些额外的方法声明。
4.同时可以看到,ArrayList继承自AbstractList,而打开AbstractList类的源码,又可以看到如下声明:
public abstract class AbstractList<E> extends AbstractCollection<E> implements List<E>
根据代码,我们又可以分析得到:
- AbstractList自身是一个抽象类。而其自身继承自AbstractCollection,也就是说它又继承自另一个抽象类。
- 而“public abstract class AbstractCollection< E > implements Collection< E >”则是AbstractCollection类的申明。
- 到此我们已经明确了我们之前说的“List → AbstractList → AbstractCollection → Collection”的继承结构。
- 对于AbstractCollection来说,它与Collection接口的方法列表几乎是完全一样,因为它做的工作仅仅是:
覆写了从Object类继承来的toString方法用以打印容器;以及对Collection接口提供部分骨干默认实现。- 而与AbstractCollection的工作相同,但AbstractList则负责提供List接口的部分骨干默认实现。不难想象它们有一个共同的出发点则是:
提供接口的骨干实现,为那些想要通过实现该接口来完成自己的容器的开发者以最大限度地减少实现此接口所需的工作。- 最后,AbstractList还额外提供了一个新的方法:
protected void removeRange(int fromIndex, int toIndex) 从此列表中移除索引在 fromIndex(包括)和 toIndex(不包括)之间的所有元素。
到这个时候,我们对于ArrayList类自身的继承结构已经有了很清晰的认识。至少你知道了你平常用到的ArrayList的各种方法,分别都来自哪里。
相信这对于我们使用Collection体系的容器类会有不小的帮助,下面我们就正式开始来分析ArrayList的源码。
- 构造器源码解析
首先,就像我们使用ArrayList时一样,我们首先会做什么?当然是构造一个容器对象,就像下面这样:
ArrayList<Integer> arrayList = new ArrayList<Integer>();
所以,我们首先从源码来看一看ArrayList类的构造器的定义,ArrayList提供了三种构造器,分别是:
//第一种
public ArrayList(int initialCapacity) {
super();
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal Capacity: "
+ initialCapacity);
this.elementData = new Object[initialCapacity];
}
//第二种
public ArrayList() {
this(10);
}
//第三种
public ArrayList(Collection<? extends E> c) {
elementData = c.toArray();
size = elementData.length;
// c.toArray might (incorrectly) not return Object[] (see 6260652)
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, size, Object[].class);
}
我们以第一个构造器作为切入点,你会发现:等等?似乎什么东西有点眼熟?
this.elementData = new Object[initialCapacity];
没错,就是它。这时你有话要说了:我靠?如果我没看错,这不是。。。数组。。。吗?
其实没错,ArrayList的底层就是采用了数组的结构实现,只不过它维护的这个数组其长度是可变的。就是下面这个东西:
private transient Object[] elementData;
于是,构造器的内容,你已经很容器能够弄清楚了:
- 第一种构造器,可以接受一个int型的参数,它使用来指定ArrayList内部的数组elementData的初始范围的。
如果该参数传入的值小于0,则会抛出一个IllegalArgumentException异常。 - 第二种构造器,就更简单了,它就在内部调用第一种构造器,并将参数值指定为10。
也就是说,当我们使用默认的构造器,内部就会默认初始化一个长度为10的数组。 - 第三种构造器,接收Collection接口类型的参数。然后通过调用其toArray方法,将其转换为数组,赋值给内部的elementData。
完成数组的初始化赋值工作后,紧接着做的工作就是:将代表容器当前存放数量的变量size设置为数组的实际长度。
正常来说,第三种构造器所作的工作就是这么简单,但你肯定在意在这之后的两行代码。在此先不谈,我们马上会讲到。 - 插入元素 源码解析
当我们初始化完成,得到一个可以使用的ArrayList容器对象后。最常做的操作是什么?
答案显而易见:通常我们都是对该容器内做元素添加、删除、访问等工作。
那么,首先,我们就以添加元素的方法“add(E e)“为起点,来看看源码中它是怎么做实现的?
public boolean add(E e) {
ensureCapacity(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
这就是ArrayList源码当中add方法的定义,看上去并不复杂,我们来加以分析:
1.首先,就可以看到其调用了另一个成员方法ensureCapacity(int minCapacity)。
2.该方法的注释说明是这样的:如有必要,增加此ArrayList实例的容量,以确保它至少能够容纳最小容量参数所指定的元素数。
3.也就是说,简单来讲该方法就是用来修改容器的长度的。我们来看一下该方法的源码:
public void ensureCapacity(int minCapacity) {
modCount++;
int oldCapacity = elementData.length;
if (minCapacity > oldCapacity) {
Object oldData[] = elementData;
int newCapacity = (oldCapacity * 3) / 2 + 1;
if (newCapacity < minCapacity)
newCapacity = minCapacity;
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
}
- 首先看到了“modCount++”这行代码。这个变量其实是继承自AbstractList类当中的一个成员变量。
而它的作用则是:记录已从结构上修改此列表的次数。从结构上修改是指更改列表的大小,或者打乱列表。 - 所以,很自然的,因为在这里我们往容器内添加了元素,自然也就会改变容器的现有结构。故让该变量自增。
- 代码“int oldCapacity = elementData.length;”则是通过内部数组的现有长度得到容器的现有容量。
- 接下来的工作就是判断 我们传入的“新容量(最小容量)”是否大于“旧容量”。这样做的目的是:
如果新容量小于旧容量,则代表现有的容器已经足够容纳指定的数量,不必进行扩充工作;反之才需要对容器进行扩充。 - 当判断后,发现需要对容器进行扩充后。首先,会声明一个新的数组引用来拷贝出原本elementData数组里存放的元素。
- 然后,会通过“int newCapacity = (oldCapacity * 3) / 2 + 1;“来计算初步得到一个新的容量值。
- 如果计算得到的容量值小于我们传入的指定的新的容量值,那么就使用我们传入的容量值。否则就使用计算得到的值作为新的容量值。
这两步工作可能也值得说一下,为什么有一个传入的指定值“minCapacity”了,还额外做了这个“newCapacity”的运算。
其实不难想象到,这样做是为了提高程序效率。假设我们通过默认构造器构建了一个ArrayList,那么容器内部就有了一个大小为10的初始数组了。
这个时候,我们开始循环的对容器进行“add”操作。不难想象当执行到第11次add的时候,就需要扩充数组长度了。
那么根据add方法自身的定义,这里传入的“minCapacity”值就是11。而通过计算得到的“newCapacity ”= (10 * 3)/2 +1 =16。
到这里就很容易看到好处了,因为如果不进行上面的运算:那么当超过数组的初始长度后,每次add都需要执行数组扩充的工作。
而因为newCapacity的出现,程序得以确保当执行第11次添加时,数组扩充后,直到执行到第16次添加,都不需要进行扩充了。 - 最后,就是最关键的一步,也就是根据得到的新的容量值,来对容器进行扩充工作。我们有必要好好看一看。
我们发现对于容器的扩充工作,是通过调用Arrays类当中的copyOf方法进行的。
当你点击源码进入后,你会发现,实际上,在该方法里又调用了其自身的另一个重载方法:
public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
T[] copy = ((Object)newType == (Object)Object[].class)
? (T[]) new Object[newLength]
: (T[]) Array.newInstance(newType.getComponentType(), newLength);
System.arraycopy(original, 0, copy, 0,
Math.min(original.length, newLength));
return copy;
}
由此,你可能也回忆起来,在我们上面说到的第三种构造器里实际也用到了这个方法。所以,我们有必要来好好看一看:
- 首先,会通过一个三元运算符的表达式来计算传入的newType是不是”Objcet[]”类型。
- 如果是,则会直接构造一个Objcet[]类型的数组。如果不是,则会根据具体类型来进行构造。
- 具体构造的方式是通过调用Array类里的newInstance方法,这个方法的说明是:
static Object newInstance(Class< ? > componentType, int length) 创建一个具有指定的组件类型和长度的新数组。 - 其中参数componentType就是指数组的组件类型。而在上面的源码中它是通过Class类里的getComponentType()方法得到的。
该方法很简单,就是来获取表示数组组件类型的 Class,如果组件不为数组,则会返回“null”。
通过一段简单的代码,我们能够更形象的理解它的用法:
public static void main(String[] args) {
System.out.println(String.class.getComponentType()); //输出结果为 null
System.out.println(String [].class.getComponentType());//输出结果为 class java.lang.String
}
- 接着,当执行完三元运算表达式的运算工作后,就会得到一个长度为”newLength”的全新的数组“copy”了。
- 问题在于,此时的数组”copy”内仍然没有任何元素。所以我们还要完成最后一步动作,将源数组当中的元素拷贝新的数组当中。
- 拷贝的工作正是通过调用System类当中的navtie方法“arraycopy”完成的,该方法的说明为:
- public static void arraycopy(Object src, int srcPos, Object dest,int destPos, int length)
- 从指定源数组中复制一个数组,复制从指定的位置开始,到目标数组的指定位置结束。
- 从 src 引用的源数组到 dest 引用的目标数组,数组组件的一个子序列被复制下来。被复制的组件的编号等于 length 参数。
- 源数组中位置在 srcPos 到 srcPos+length-1 之间的组件被分别复制到目标数组中的 destPos 到 destPos+length-1 位置。
- 到了这里“ensureCapacity”方法就已经执行完毕了,内部的elmentData成功得以扩充。接下只要进行元素的存放工作就搞定了。
- 但这时候,不知道你还记不记得我们前面说到的一个东西:那就是第三种构造器中,在执行完toArray获取数组后,还进行了一个有趣的判断如下:
// c.toArray might (incorrectly) not return Object[] (see 6260652)
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, size, Object[].class);
- 思考一下为什么会做这样的判断,实际上这样做正是为了避免某种运行时异常。从代码的注释得到的信息是:
通过调用Collection.toArray()方法得到的数组并不能保证绝对会返回Object[]类型的数组。
通过下面的测试代码,我们就能验证这种情况的发生:
public static void main(String[] args) {
Collection<String> c1 = new ArrayList<String>();
Collection<String> c2 = Arrays.asList("123");
System.out.println(c1.toArray().getClass()==Object[].class);//true
System.out.println(c2.toArray().getClass()==Object[].class);//false
System.out.println(c2.toArray().getClass());//class [Ljava.lang.String;
}
从输出结果我们不难发现,例如通过Arrays.asList方式得到的Collection对象,其调用toArray方法转换为数组后:
得到的就并非一个Object[]类型的数组,而是String[]类型的数组。也就说:如果我们使用c2来构造ArrayList,之前的数组拷贝语句就变为了:
elementData = c.toArray();
//等同于:
Object [] elementData = new String[x];
虽然说这样做看上去并没有什么问题,因为有“向上转型”的关系。进一步来说,上面的代码原理就等同于:
Object [] elementData = new Object[10];
String [] copy = new String [12];
elementData = copy;
但这个时候如果在上面的代码的基础上,再加上一句代码,实际上这的确也就是add方法在完成数组扩充之后做的工作,就是:
elementData [11] = new Object();
然后,运行代码,你会发现将得到一个运行时异常“java.lang.ArrayStoreException”。
如果想了解异常的原因可以参见:JDK1.6集合框架bug:c.toArray might (incorrectly) not return Object[] (see 6260652)
所以就像我们之前说的那样,第三种构造器内添加这样的额外判断,正是出于程序健壮性的考虑。
这样的原因,正是因为避免出现上述的情况,导致在数组需要扩充之后,向扩充后的数组内添加新的元素出现运行时异常的情况。
- 到了这时,我们终于可以回到“add”方法内了。之后的代码是简单的“elementData[size++] = e;”
实际这行代码所做的工作就正如我们上面说到的一样,数组完成扩充后,此时则进行元素插入就行了。
同时在完成添加过后,将代表容器内当前存放的元素量的变量“size”的值进行一次自增。
到此,我们就完成了对添加元素的方法”add(E e)”的源码进行了一次完整的剖析。有没有一丢丢成就感?
革命还得继续,我们趁热打铁,马上来看一看另一个添加元素方法的实现,即”add(int index, E element)“:
public void add(int index, E element) {
if (index > size || index < 0)
throw new IndexOutOfBoundsException("Index: " + index + ", Size: "
+ size);
ensureCapacity(size + 1); // Increments modCount!!
System.arraycopy(elementData, index, elementData, index + 1, size
- index);
elementData[index] = element;
size++;
}
我们前面花了那么多的功夫,在这里应该有所见效。相信对于上面的代码,你已经很容易理解了。
- 首先,既然是通过角标来插入元素。那么自然会有一个健壮性判断,index大于容器容量或者小于0都将抛出越界异常。
在这里,额外说明一下,特别注意一个使用误区,尤其是在我们刚刚分析完了前面的源码,信心十足的情况下。
我们一定要注意这里index是与代表容器实际容量的变量size进行比较的,而不是与elmentData.length!!!
我们仍然通过实际操作,来更深刻的加深印象,来看这样的一段代码:
ArrayList<String> list = new ArrayList<String>();
list.add(2, "123");
我们可能会觉得这是可行的,因为在list完成构造后,内部的elmentData就会默认初始化为长度为10的数组。
这时,通过”list.add(2, “123”);”来向容器插入元素,我们可能就会下意识的联想到这样的东西”elmentData[2] = “123”;”,觉得可行。
但很显然的,实际上这样做等待你的就是数组越界的运行时异常。
- 接着,与add(E e)相同,这里仍然会调用“ensureCapacity”来判断是否进行数组的扩充工作。有了之前的分析,我们不再废话了。
- 接下来的代码,就是该添加方法能够针对角标在容器中间进行元素插入工作的重点了,就是这两句小东西:
System.arraycopy(elementData, index, elementData, index + 1, size
- index);
elementData[index] = element;
System.arraycopy方法我们在之前也已经有过解释。对应于上面的代码,它做的工作你是否已经看穿了?
没错,其实最基本的来说,我们可以理解为:“对elementData数组从下标index开始进行一次向右位移”。
还是老办法,通过代码我们能够更形象直接的体会到其用处,就像下面做的:
public static void main(String[] args) {
String[] elmentData = new String[] { "1", "2", "3", "4", null };
int index = 2, size = 4;
System.arraycopy(elmentData, index, elmentData, index + 1, size - index);
System.out.print("[");
for (int i = 0; i < elmentData.length; i++) {
System.out.print(elmentData[i]);
if (i < elmentData.length - 1)
System.out.print(",");
}
System.out.print("]");
}
以上程序的输入结果为:”[1,2,3,3,4]“。也就是说,假设一个原本为”[1,2,3,4]”的数组经过扩充后,
再调用源码当中的“System.arraycopy(elementData, index,elementData, index + 1, size - index);”,
最终得到的结果就是[1,2,3,3,4]也就是说,将指定角标开始的元素都向后进行了一次位移。
- 这个时候,再通过”elementData[index] = e;”来将指定角标的元素更改为新存放的值,不就达到在中间插入元素的效果了吗?
所以说,对于向ArrayList容器中间插入元素的工作,我们归纳一下,发现实际上需要做的工作很简单,不过就是:
将原本数组中角标index开始的元素按指定位数(根据要插入的元素个数决定)进行位移 + 替换index角标的元素 = 在容器中间插入元素。
而这其实也解释了:为什么相对于LinkedList来说,ArrayList在执行元素的增删操作时,效率低很多。
因为在数组结构下,每当涉及到在容器中间增删元素,就会产生蝴蝶效应波及到大量的元素发生位移。
OK,又有进一步的收获。到了这里,对于插入元素的方法来说,还有另外两个它们分别是:
addAll(Collection< ? extends E > c) 以及addAll(int index, Collection< ? extends E > c)。
在这里,我们就不一一再分析它们的源码了,因为有了之前的基础,你会发现,它们的核心思想都是一样的:
都是先判断是否需要对现有的数组进行扩充;然后根据具体情况(插入单个元素还是多个,在中间插入还是在微端插入)进行元素的插入保存工作。
有兴趣可以自己看一下另外两个方法的源码,相信对加深理解有不错的帮助。
- 删除元素 源码解析
看完了向容器添加元素的方法源码,接着,我们来看一看与之对应的删除元素的方法的实现。
在这里,我们首先选择删除方法”remove(int index)“作为切入点,来对源码加以分析:
public E remove(int index) {
RangeCheck(index);
modCount++;
E oldValue = (E) elementData[index];
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index + 1, elementData, index,
numMoved);
elementData[--size] = null; // Let gc do its work
return oldValue;
}
- 首先就看到一个名为rangeCheck的方法调用,从命名就不难看出,这应该是做容器范围检查的工作的。查看它的源码:
private void RangeCheck(int index) {
if (index >= size)
throw new IndexOutOfBoundsException("Index: " + index + ", Size: "
+ size);
}
- 由此,RangeCheck所做的工作很简单:对传入的角标进行判断,如果它大于或等于容器的实际存放量,则报告越界异常。
- 接下来的一行代码,你已经很熟悉了。删除元素自然也会改变容器的现有结构,所以让该变量自增。
- 然后是根据该角标从内部维护的elementData数组中,将该角标对应的元素取出。
- 之后的两行代码就是删除元素的关键,你可能会觉得有点熟悉。没错,因为其中心思想与插入元素时是一致的。
这个时候我们发现,其实在对于ArrayList来说,每当需要改变内部数组的结构的时候,都是通过arrayCopy执行位移拷贝。不同在于:
- 删除元素是将源数组index+1开始的元素复制到目标数组的index处。也就是说,与添加相反,是在做元素左移拷贝。
- 删除元素时,用于指定数组拷贝长度的变量numMoved不再是size - index而变为了size - index -1。
造成差异的原因就在于,在实现左移效果的时候,源数组的拷贝起始坐标是使用index+1而不再是index了。
- 接下来的一行代码则是“elementData[–size]”,它的效果一目了然,既是将数组最后的一个元素设置为null。
注释“// Let gc do its work”则说明,我们将元素值设为null。之后就由gc堆负责废弃对象的清理。
到此你不得不说别人的代码确实写的牛逼,remove里的代码短短几行,却思路清晰,且完全达到了以下效果:
- 要remove,首先进行rangeCheck,如果你指定要删除的元素的index超过了容器实际容量size,则报告越界异常。
- 经过rangeCheck后,index就只会小于size。那么通过numMoved就能判断你指定删除的元素是否位于数组末端。
这是因为数组的特点在于:它的下标是从0而非1开始的,也就是如果长度为x,最末端元素的下标就为x-1。
也就是说,如果我们传入的index值如果恰好等于size-1,则证明我们要删除的恰好是末端元素,
如果这样则不必进行数组位移,反之则需要调用System.arrayCopy进行数组拷贝达到左移删除元素的效果。
- 到这里我们就能确保,无论如何我们要做的就是删除数组末端的元素。所以,最后就将该元素设置为null,让size自减就搞定了。
接下来,我们再看看另一个删除方法”remove(Object o)“:
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;
}
是不是觉得这个方法看上去也太轻松了,没错,实际上就是对数组做了遍历。
当发现有与我们传入的对象参数相符的元素时,就调用fastRemove方法进行删除。
所以,我们再来点开fastRemove的源码来看一下:
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; // Let gc do its work
}
这个时候,我们已经胸有成竹了。因为你发现已经没上面新鲜的了,上面的代码相信不用再多做一次解释了。
你可能会提起,还有另外一个删除方法removeAll。但查看源码,你就会发现:
这个方法是直接从AbstractCollection类当中继承来的,也就是说在ArrayList里并没有做任何额外实现。
- 访问元素 源码解析
其实对于数据来说,所做的操作无非就是“增删查改”。我们前面已经分析了增删,接下来就看看“查”和“改”。
ArrayList针对于这两种操作,提供了”get(int index)“和”set(int index, E element)“方法。
其中”get(int index)“方法的源代码如下:
public E get(int index) {
RangeCheck(index);
return (E) elementData[index];
}
。。。。。简单到我们甚至懒得解释。首先仍然是熟悉的RangeCheck,判断是否越界。然后就是根据下标从数组中查找并返回元素。
是的,就是这么容易,就是这么任性。事实上这也是为什么ArrayList对于随机访问元素的执行速度快的原因,因为基于数组就是这么轻松。
那么再来看一看”set(int index, E element)“的源码吧:
public E set(int index, E element) {
RangeCheck(index);
E oldValue = (E) elementData[index];
elementData[index] = element;
return oldValue;
}
不知道你是不是已经觉得越来越没难度了。的确,因为硬骨头我们在之前都啃的差不多了。。。
上面的代码归根结底就是一个通过角标对数组元素进行赋值的操作而已。老话,基于数组就是这么任性。。
- 其它的常用方法 源码解析
事实上到了这里,关于容器最核心部分的源码(增删改查)我们都了解过了。
但我们也知道除此之外,还有许多其它的常用方法,它们在ArrayList类里是怎么实现的,我们就简单来一一了解一下。
- size();
public int size() {
return size; //似乎都没什么好说的!! - -!
}
- isEmpty();
public boolean isEmpty() {
return size == 0; //。。依旧。。没什么好说的。。。
}
- contains
public boolean contains(Object o) {
//内部调用indexOf方法,indexOf是查询对象在数组中的位置(下标)。如果不存在,则会返回-1.所以如果该方法返回结果>=0,自然容器就包含该元素
return indexOf(o) >= 0;
}
- indexOf(Object o)
// 核心思想也就是对数组进行遍历,当遍历到有元素符合我们传入的对象时,就返回该元素的角标值。如果没有符合的元素,则返回-1。
public int indexOf(Object o) {
if (o == null) {
for (int i = 0; i < size; i++)
if (elementData[i] == null)
return i;
} else {
for (int i = 0; i < size; i++)
if (o.equals(elementData[i]))
return i;
}
return -1;
}
- lastIndexOf(Object o)
// 与indexOf方法唯一的不同在于,这里选择将数组从后向前进行遍历。所以返回的值就将是元素在数组里最后出现的角标。同样,如果没有遍历到,则返回-1。
public int lastIndexOf(Object o) {
if (o == null) {
for (int i = size - 1; i >= 0; i--)
if (elementData[i] == null)
return i;
} else {
for (int i = size - 1; i >= 0; i--)
if (o.equals(elementData[i]))
return i;
}
return -1;
}
- Object[] toArray() 与 public T[] toArray(T[] a)
public Object[] toArray() {
//也就是说,底层仍然是通过Arrays.copyOf进行转换。
//另外,这行代码在底层等同于:Arrays.copyOf(elementData, size,Object[].class);
return Arrays.copyOf(elementData, size);
}
public <T> T[] toArray(T[] a) {
if (a.length < size)
// Make a new array of a‘s runtime type, but my contents:
/*
* 上面的英文注释是源码自身的注释,从源码中的注释就可以发现,
* 这里是在程序运行时根据实际类型返回对应类型的数组对象。
* 例如传入的a是String[],就将返回String类型的数组。
* 这也是与Object[] toArray()方法的不同。*/
return (T[]) Arrays.copyOf(elementData, size, a.getClass());
// 熟悉的数组拷贝的工作,注意这里的目标数组是“a”。
// 也就是说,这里做的工作是将elementData作为源数组,将其中的元素拷贝到a当中,然后返回。
System.arraycopy(elementData, 0, a, 0, size);
if (a.length > size)
a[size] = null;
return a;
}
- clear()
public void clear() {
modCount++;
// Let gc do its work
// 遍历数组,将元素全部设置为null
for (int i = 0; i < size; i++)
elementData[i] = null;
// 将实际容量还原为0
size = 0;
}
- removeRange(int fromIndex, int toIndex)
protected void removeRange(int fromIndex, int toIndex) {
modCount++;
/*
* 相信有了之前remove(int index)的基础,这个方法你理解起来也应该很轻松。
* 因为原理完全相同,唯一的区别在于,之前的方法是针对一个元素位置的位移拷贝。
* 而这里则是针对一个区间的元素。但要注意的是toIndex自身实际是不被包括的。
* 举例来说,现在有“[1,2,3,4,5]”。我们希望将下标"1-3"之间的元素(即[2,3])移除。
* 那么,通过位移拷贝,我们首先需要实现的效果就是得到[1,4,5,4,5]这样的数组,然后将之后的两个个元素设置为null。
* 而这样的操作体现在代码上,就正如下面的表达方式。
*/
int numMoved = size - toIndex;
System.arraycopy(elementData, toIndex, elementData, fromIndex, numMoved);
// Let gc do its work
int newSize = size - (toIndex - fromIndex);
while (size != newSize)
elementData[--size] = null;
}
到这里,我们终于完成了对于ArrayList容器类的源码解析。相信一定有不少收获。
我们只需要记住,它是基于维护一个可变数组来实现的容器结构。至于它自身的种种特点:
例如在容器中间插入/删除元素效率较低,随机访问元素速度快之类的特点。
相信经过这一番解析,你已经不仅仅是知道它们,甚至已经更进一步,了解造成这些特点的原因了。
LinkedList用法见析
其实,我最初的想法是像ArrayList一样,把主流的这几个容器类的源码都过一遍,写一个较为详细的解析。
但写完ArrayList我发现,这。。。实在是。。。太累心了。。。
所以,在这里,我们就只看一些比较关键部分的源码,重点就放在明白其底层实现原理就可以了!
与ArrayList的套路一样,我们首先仍然选择查看构造器部分:
private transient Entry<E> header = new Entry<E>(null, null, null);
private transient int size = 0;
public LinkedList() {
header.next = header.previous = header;
}
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);
}
我们发现,与ArrayList不同,LinkedList内部维护的不再是数组,而是一个叫做“Entry”的东西。
点开这个东西的源码,你发现这是定义在LinkedList之中的一个静态内部类,我们看这个类的定义:
private static class Entry<E> {
E element;
Entry<E> next;
Entry<E> previous;
Entry(E element, Entry<E> next, Entry<E> previous) {
this.element = element;
this.next = next;
this.previous = previous;
}
}
根据最直观的翻译,我们可以把这个东西叫做“记录”。这也能体现它自身的作用。
可以看到,该类型的内部首先有一个泛型E,这实际上就是用来保存我们的插入的元素。
然后又有另外两个“记录(Entry)”分别是“next”,“previous”。正如同它们的名字一样:
它们正是用来记录和保存:位于自身“记录”其之前和之后的“记录”的信息的。
这样的一种容器结构,实际上也就符合所谓的“链表”数据结构的特征。
那么,对应于这样的一种数据结构来说,如果我们执行add添加元素时,其内部是怎么做的呢?
public boolean add(E e) {
addBefore(e, header);
return true;
}
我们发现:add(E e)方法又调用了私有的一个静态方法addBefore,我们再查看这个方法的源码定义:
private Entry<E> addBefore(E e, Entry<E> entry) {
Entry<E> newEntry = new Entry<E>(e, entry, entry.previous);
newEntry.previous.next = newEntry;
newEntry.next.previous = newEntry;
size++;
modCount++;
return newEntry;
}
初看之下,可能会觉得有点发蒙。。。至少我是这样,搞什么呢?玩绕口令呢?而弄明白过后,你会发现写的确实牛逼!
我们首先要明确一个行为:当我们调用add(E e)方法添加元素,所做的工作是将元素添加到列表末尾。
那么,想象一下,ArrayList因为内部维护的一个数组,所以如果要向尾部添加元素是很容易的。
然而,LinkedList自身其实只通过一个Entry型的变量header来完成这种数据结构,它要怎么保证依次向表位插入元素呢?
不知道你注意到没有,add(E e)方法在调用addBefore的时候传入的第二个参数固定为“header”。
由此,我们也不难想象,这个header肯定在用来确保以上的功能得以完成起到了关键的作用。
从源码中,我们可以看到,假设我们调用无参的构造器创建LinkedList对象,那么就会创建一个空的列表。
创建空的列表的代码很简单,就是:“header.next = header.previous = header;”。
也就是说,在刚刚构造完成的时候,现有的空列表中,没有存放任何元素,只维持一个表头(header)。
现在重头戏要登场了,我们来看看别人的源码到底写的有多叼,首先我们来看addBefore的第一行代码:
Entry<E> newEntry = new Entry<E>(e, entry, entry.previous);
- 我们说了add(E e)方法穿入的entry值恒定为header,于是这行代码就代表着:
newEntry.next永远等于header,而newEntry.previous永远为header.previous。
- 最初一看可能会觉得很困惑,因为我们不知道这样做的意义在哪?但其实精髓就在这。
在这之前,我们先来研究一个神功:“九九归一”大法。虽然带点玩笑的成分,但我们这里使用的确实就是类似的思想。
想象一下,假设现在的情况是:有数字“1”到“9”按顺序依次排列。同时有一个指针在它们头上移动。
那么,我们可以说”1.previous = 9”或者”9.next = 1”吗?通常来说不行,因为你说9.next当然是10啊大兄弟。
但是,如果我们将“1-9”视为一个轮回,没有其它元素的干扰。那么,在这个轮回移动至9的时候,
再下一步的时候,它就应该“返璞归真”,再次回归到1了。
好了,九九归一大法修炼完毕。我们接着来看我们的源代码,不知道现在你是不是已经能理解之前我们说的代码了。
分析一下,之前的代码做的工作实际上就是说:
- 我们将“header”作为链表世界的“原点”。
- 那么,”header.previous”就是世界的终点(列表末端元素);
- 同理,”末端元素.next”就意味着世界的”原点”(header);
现在我们再来分析源码做的工作就很清晰了:声明一个新的Entry变量”newEntry”,这个Entry保存我们插入的元素e。
并且将其的next节点信息记录为“header”;将其previous节点信息记录为”header.previous”(即在newEntry节点进入之前的末端元素)”。
由此,我们不难发现,其实源码的作者通过一行代码,实现了将元素依次从列表末端插入的工作,确实牛逼。
接着,我们来看之后的两行代码:
newEntry.previous.next = newEntry;
newEntry.next.previous = newEntry;
有了第一行代码理解的基础,这两行代码我们理解起来就相对容易多了。这步工作就好比:
在newEntry出现之前,链表的末端是另一个元素。所以当newEntry出现后:
我们自然不能忘记,将因为newEntry的插入而发生位置改变的节点记录(Entry)里的next与previous的值做更新。
之后的两行代码“size++”和“modCount++”自然就不需要我们多费口舌了。
那么,我们再来看一下如果是要在列表的中间插入元素,LinkedList又是怎么做的呢?
public void add(int index, E element) {
addBefore(element, (index == size ? header : entry(index)));
}
OK,我们看到该方法在底层,实际仍然是通过调用addBefore方法来实现的。
同时用一个三元运算表达式对index做了判断,如果index==size,实际就等同于在列表末端插入元素,也就与add(E e)没有区别。
而如果index位于列表中间位置,那么传入addBefore方法的entry参数与就不再是header节点了。
而是会先通过entry(index)方法查询到当前在该位置(index)存放的节点信息,作为参数传入。
想象一下,这样做与传入header节点有什么不同,在回顾一下那行经典的代码:
Entry<E> newEntry = new Entry<E>(e, entry, entry.previous);
对应到这里说,假设我们选择在index为5的地方插入元素e,那么所做的工作就是:
- 新建节点newEntry,保存插入的元素e。
- 将newEntry的next节点,设置为之前存放在index=5位置上的节点。
- 将newEntry的previous节点,设置为之前存放在index=5位置上的节点之前的节点。
由此,也就完美实现了在某个指定index的位置插入元素的需求。
然后同样是通过该行代码之后的两行代码,更新相关的节点信息的改变。
我们看一下上面的代码出现的另一个比较关键的方法“entry(int index)”。
因为我们在调用LinkedList的元素访问方法get(index)时,底层就是通过该方法实现的:
public E get(int index) {
return entry(index).element;
}
而entry(int index)方法的源码实现则是下面这样:
private Entry<E> entry(int index) {
if (index < 0 || index >= size)
throw new IndexOutOfBoundsException("Index: " + index + ", Size: "
+ size);
Entry<E> e = header;
if (index < (size >> 1)) {
for (int i = 0; i <= index; i++)
e = e.next;
} else {
for (int i = size; i > index; i--)
e = e.previous;
}
return e;
}
由此可以看到,与ArrayList不同,LinkedList在实现随机访问元素的时候,是通过循环的进行e.next来进行的。
为了提高该方法的效率,这里使用了“二分查找法”的方式来减少循环工作量。
但不幸的是,如果要进行频繁的元素访问工作,尤其是对于huge zize的LinkedList来说,效率仍然是十分低的。
因为就像代码表现的那样,假设我们在LinkedList中存放了一百万个元素,我们要查找index为50万位置的元素。
那么,虽然做了一个二分式的判断,但是仍然要循环的从header节点开始做50万次“e = e.next”的操作。
到此,我们相信我们应该了解了为什么说:
- 对于频繁的元素随机访问工作,ArrayList的工作效率高于LinkedList。
(因为ArrayList可以直接通过数组下标访问。而LinkedList则需要循环的进行e.next)
- 而对于在列表中间进行插入和删除元素的操作,LinkedList则高于ArrayList。
(ArrayList底层的数组结构发生改变,则会涉及到大量的相关元素发生拷贝位移;
而LinkedList则只需要找到该index的位置进行对应操作,并修改相关的节点信息)
再来看看一看addAll()方法的源码:
public boolean addAll(Collection<? extends E> c) {
return addAll(size, c);
}
public boolean addAll(int index, Collection<? extends E> c) {
if (index < 0 || index > size)
throw new IndexOutOfBoundsException("Index: " + index + ", Size: "
+ size);
Object[] a = c.toArray();
int numNew = a.length;
if (numNew == 0)
return false;
modCount++;
Entry<E> successor = (index == size ? header : entry(index));
Entry<E> predecessor = successor.previous;
for (int i = 0; i < numNew; i++) {
Entry<E> e = new Entry<E>((E) a[i], successor, predecessor);
predecessor.next = e;
predecessor = e;
}
successor.previous = predecessor;
size += numNew;
return true;
}
有了之前的基础,相信上面的代码已经不再陌生了,我们不再花过多的精力去进行分析。主要看一下以下的代码:
//获取后端节点。如果index=size则代表末端插入,则后端节点为header,否则为当前位于index位置上的节点元素。
Entry<E> successor = (index == size ? header : entry(index));
//获取前端节点。此时的前端节点自然就是之前的“successor”的“previous”节点
Entry<E> predecessor = successor.previous;
//根据插入的元素的数量进行循环
for (int i = 0; i < numNew; i++) {
// 新建Entry保存插入元素。并设置next,previous节点信息
Entry<E> e = new Entry<E>((E) a[i], successor, predecessor);
// 因为新的e节点的插入,predecessor.next就应该由successor变为e了。
predecessor.next = e;
// 因为还要在此次加入的节点后面继续添加节点,所以这个时候predecessor就应该变为此时新加入的节点e了。
predecessor = e;
}
// 当循环完成。当然不要忘记更新successor的previous节点信息。
successor.previous = predecessor;
最后看一看移除元素的方法。你会发现对于LinkedList来说,几乎所有的删除方法最后都会回到下面:
private E remove(Entry<E> e) {
if (e == header)
throw new NoSuchElementException();
// 取出元素
E result = e.element;
// 这个节点被删除 = 该节点的前一个节点的后一个节点的值应该更新为此时被删除的节点的后一个节点。
e.previous.next = e.next;
// 道理同上
e.next.previous = e.previous;
// 接下来的两行代码都是将e中保存的对象设置为空,方便gc在适当时候回收。
e.next = e.previous = null;
e.element = null;
size--;
modCount++;
return result;
}
到这里为止,实际我们就已经了解了LinkedList源码当中最核心的部分。
但你肯定听说过关于LinkedList还有一个很重要的使用方式:即可以借助其实现另外两种数据结构:“栈”以及“队列”。
而LinkedList针对于实现这些数据结构,也封装提供了一些额外的元素操作方法。
但你可能也发现了,这些方法例如在ArrayList当中是不存在的,由此你不难想象,它们肯定是来自于另外一个接口。
打开源码后发现,事实和我们想象的一样,LinkedList实现了Deque接口,而Deque又继承自Queue接口。
但实际上,查看LinkedList源码中这些接口的实现,你会发现,基本上对我们前面讲到的核心部分的代码做一个封装:
public E getFirst() {
if (size == 0)
throw new NoSuchElementException();
return header.next.element;
}
public E getLast() {
if (size == 0)
throw new NoSuchElementException();
return header.previous.element;
}
public E removeFirst() {
return remove(header.next);
}
public E removeLast() {
return remove(header.previous);
}
public void addFirst(E e) {
addBefore(e, header.next);
}
public void addLast(E e) {
addBefore(e, header);
}
public E peek() {
if (size == 0)
return null;
return getFirst();
}
public E element() {
return getFirst();
}
public E poll() {
if (size == 0)
return null;
return removeFirst();
public boolean offer(E e) {
return add(e);
}
public boolean offerFirst(E e) {
addFirst(e);
return true;
}
public boolean offerLast(E e) {
addLast(e);
return true;
}
public E peekFirst() {
if (size == 0)
return null;
return getFirst();
}
public E peekLast() {
if (size == 0)
return null;
return getLast();
}
public E pollFirst() {
if (size == 0)
return null;
return removeFirst();
}
public E pollLast() {
if (size == 0)
return null;
return removeLast();
}
public void push(E e) {
addFirst(e);
}
public E pop() {
return removeFirst();
}
同时,你还会发现,在这一系列的方法中。很多方法只是名称有些差异,或者实现由细微不同。
所以,之所以提供这么多的方法接口,实际上是为了这些名字在特定的上下文环境中(例如不同的数据结构)更加适用。
而接下来,我们就来看一看,怎么样调用LinkedList来实现我们说到的”栈“和”队列“的数据结构。
- 用LinkedList实现栈结构
”栈“结构的特点在于,存放在其中的元素总是会保持“先进后出”的特点。借助LinkedList就可以轻松实现:
public class Stack<E> {
private LinkedList<E> mList = new LinkedList<E>();
public void push(E e) {
mList.addFirst(e);
}
public E peek() {
return mList.getFirst();
}
public E pop() {
return mList.removeFirst();
}
public boolean isEmpty() {
return mList.isEmpty();
}
public String toString() {
return mList.toString();
}
}
- 用LinkedList实现队列结构
与栈结构的特点恰好相反,队列结构则有着元素“先进先出”的特点。所以我们同样很容易实现:
public class Queue<E> {
private LinkedList<E> mList = new LinkedList<E>();
public void offer(E e) {
mList.addLast(e);
}
public E peek() {
return mList.getFirst();
}
public boolean isEmpty() {
return mList.isEmpty();
}
public String toString() {
return mList.toString();
}
}
Set体系
Set是位于Collection体系下的另一个常用的容器类体系。与List体系内的容器的特点不同的是:
Set容器不允许存放重复的元素,如果你试图将相同对象的多个实体添加到容器中,就会被阻止。
Set最常被使用的情况就是用来测试归属性,你可以很容易的查询到某个对象是否在某个Set当中。
另外,值得注意的是,虽然新声明出了Set接口,但是Set具有和Collection完全相同的方法声明。
所以Set的作用更多的是用以声明类型区别,它虽然与Collection具有完全相同的方法入口,但具体表现行为不同。
同时,Set体系最常被使用的容器类有两个,分别是HashSet与TreeSet。但对于这两个容器类来说:
通过查看源码你就可以看见,虽然他们隶属于Set体系当中,但实际内部还是通过HashMap与TreeMap的原理来实现的。
所以在这里,我们先简单的来看一下它们的源码有个印象,而其实现原理,我们之后在Map的章节再通过源码来分析。
-
HashSet源码浅析
public class HashSet<E>
extends AbstractSet<E>
implements Set<E>, Cloneable, java.io.Serializable
{
static final long serialVersionUID = -5024744406713321676L;
private transient HashMap<E,Object> map;
private static final Object PRESENT = new Object();
public HashSet() {
map = new HashMap<E,Object>();
}
public HashSet(Collection<? extends E> c) {
map = new HashMap<E,Object>(Math.max((int) (c.size()/.75f) + 1, 16));
addAll(c);
}
public HashSet(int initialCapacity, float loadFactor) {
map = new HashMap<E,Object>(initialCapacity, loadFactor);
}
public HashSet(int initialCapacity) {
map = new HashMap<E,Object>(initialCapacity);
}
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<E,Object>(initialCapacity, loadFactor);
}
public Iterator<E> iterator() {
return map.keySet().iterator();
}
public int size() {
return map.size();
}
public boolean isEmpty() {
return map.isEmpty();
}
public boolean contains(Object o) {
return map.containsKey(o);
}
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
public boolean remove(Object o) {
return map.remove(o)==PRESENT;
}
public void clear() {
map.clear();
}
public Object clone() {
try {
HashSet<E> newSet = (HashSet<E>) super.clone();
newSet.map = (HashMap<E, Object>) map.clone();
return newSet;
} catch (CloneNotSupportedException e) {
throw new InternalError();
}
}
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException {
s.defaultWriteObject();
s.writeInt(map.capacity());
s.writeFloat(map.loadFactor());
s.writeInt(map.size());
for (Iterator i=map.keySet().iterator(); i.hasNext(); )
s.writeObject(i.next());
}
private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
s.defaultReadObject();
int capacity = s.readInt();
float loadFactor = s.readFloat();
map = (((HashSet)this) instanceof LinkedHashSet ?
new LinkedHashMap<E,Object>(capacity, loadFactor) :
new HashMap<E,Object>(capacity, loadFactor));
int size = s.readInt();
for (int i=0; i<size; i++) {
E e = (E) s.readObject();
map.put(e, PRESENT);
}
}
}
由此,我们发现其实HashSet类的源码很简单。同时也印证了我们前面说的:
HashSet的接口是与Collection完全一样的,但其内部实际就是维护了一个HashMap对象“map”。
我们在源码中也可以看到,基本上HashSet所有的接口内部的实现都是在调用HashMap的方法来实现的。
-
TreeSet源码浅析
由于文章的篇幅原因,对于TreeSet我们只截取最具代表性的一段源码,如下:
public class TreeSet<E> extends AbstractSet<E>
implements NavigableSet<E>, Cloneable, java.io.Serializable
{
private transient NavigableMap<E,Object> m;
private static final Object PRESENT = new Object();
TreeSet(NavigableMap<E,Object> m) {
this.m = m;
}
public TreeSet() {
this(new TreeMap<E,Object>());
}
public TreeSet(Comparator<? super E> comparator) {
this(new TreeMap<E,Object>(comparator));
}
public TreeSet(Collection<? extends E> c) {
this();
addAll(c);
}
public TreeSet(SortedSet<E> s) {
this(s.comparator());
addAll(s);
}
}
通过源码我们可以知道,TreeSet如果说与HashSet的原理有所不同的话,就是:
它内部不是那么直接的维护一个“TreeMap”的对象,而是维护了一个“NavigableMap”类型的对象。
打开源码我们可以知道“NavigableMap”是一个接口,而TreeMap正是实现了该接口。
TreeSet的其中一种构造器“TreeSet(NavigableMap< E,Object > m) ”允许我们自定义传入该类型的对象。
而除此之外,其它的构造器内部实际上仍然就是直接在构造一个“TreeMap”类型的容器对象。
-
Set容器使用小结
总的来说,我们把对于HashSet与TreeSet的容器的使用可以简单的归纳总结为:
- 由于具有相同的方法接口,我们可以像使用Collection一样的使用它们,不同之处在于Set不允许重复元素。
- HashSet是基于哈希(即散列)结构来实现的;而TreeSet则是通过二叉树结构来实现的(在后面的Map我们具体分析)。
- 判断元素是否的一句来说:HashSet通过覆写hashcode()与equals()方法实现;TreeSet通过内部维护的比较器(Comparator)实现。
Map体系
接下来,我们就来到了Java集合框架的另一个重点“Map”。从命名我们就不难看出:
与“Collection体系”单值存储的特点不同,Map体系下的容器除了允许我们存储值(value)之外,
还允许我们对该值设置一个对应的索引(key)方便我们查找,就像我们查询字典一样。
与Colleciton一样,Map也是该体系下的容器类的根接口,打开其接口声明:
- void clear() 从此映射中移除所有映射关系(可选操作)。
- boolean containsKey(Object key) 如果此映射包含指定键的映射关系,则返回 true。
- boolean containsValue(Object value) 如果此映射将一个或多个键映射到指定值,则返回 true。
- Set< Map.Entry< K,V > > entrySet() 返回此映射中包含的映射关系的 Set 视图。
- boolean equals(Object o) 比较指定的对象与此映射是否相等。
- V get(Object key) 返回指定键所映射的值;如果此映射不包含该键的映射关系,则返回 null。
- int hashCode() 返回此映射的哈希码值。
- boolean isEmpty() 如果此映射未包含键-值映射关系,则返回 true。
- Set< K > keySet() 返回此映射中包含的键的 Set 视图。
- V put(K key, V value) 将指定的值与此映射中的指定键关联(可选操作)。
- void putAll(Map< ? extends K,? extends V > m) 从指定映射中将所有映射关系复制到此映射中(可选操作)。
- V remove(Object key) 如果存在一个键的映射关系,则将其从此映射中移除(可选操作)。
- int size() 返回此映射中的键-值映射关系数。
- Collection< V > values() 返回此映射中包含的值的 Collection 视图。
由此,其实我们不难发现,Map与Colleciton相比,其实大部分的接口声明都是差不多的。
因为归根结底它们都是容器,根本来说它们都是做元素的存取等操作。不同之处就是在于,Map的存取开始针对于key。
-
HashMap源码剖析
相对来说的话,HashMap数据结构的实现要更为复杂一点。但实际上,如果我们了解之后,也就会觉得其实这种结构很清晰。
所以在分析HashMap的源码之前,我们首先要弄清一种数据结构的特点。因为顾名思义,HashMap正是基于这种数据结构来实现的。
- 散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。
- 也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。
- 这个映射函数叫做散列函数,存放记录的数组就叫做散列表。
- 给定表M,存在函数f(key),对任意给定的关键字值key,代入函数后若能得到包含该关键字的记录在表中的地址
我们就称表M为哈希(Hash)表,函数f(key)为哈希(Hash) 函数。
也就是说,我们归纳来说,虽然被命名为哈希表。但实际上,内部的数据结构仍然是基于数组来维护的。
只是该数组与List为例而言,对比来说,它不再直接存储值。而是通过将我们存储的对象计算出一个值,
这个值就是所谓的哈希值(散列码),也就是上面的概念提到的Key Value(关键码值)。
然后数组通过维护该关键码值,从而避免了速度缓慢的线性查询,从而提高数据的访问速度。
概念性的东西说了一大堆,我们现在就正式开始,通过分析HashMap的源码来对它进行一次深入的了解。
与之前对于ArrayList与LinkedList的源码分析时做的一样,我们首先来看HashMap的类声明:
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
与之前一样,从以上的类声明我们可以得到主要的如下信息:
- HashMap继承自AbstractMap,AbstractMap是对Map接口提供了骨干部分默认实现。
- HashMap同样实现了Map接口,除此之外实现了Cloneable, Serializable接口,确保对象能被复制,以及实现序列化。
构造器部分源码解析
接下来,我们仍然首先来看HashMap的构造器部分:
// 默认的初始容量
static final int DEFAULT_INITIAL_CAPACITY = 16;
// 容器的最大容量 (2的30次方)
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 一个存储Entry类型的对象的数组,实际上这个数组就是所谓的散列表(即散列数组)
transient Entry[] table;
// 当前容器内的数据量
transient int size;
// 当容量达到该值,就需要进一步散列进行扩容
int threshold;
// 加载因子
final float loadFactor;
// 修改容器结构的次数
transient volatile int modCount;
public HashMap(int initialCapacity, float loadFactor) {
// 如果初始容量小于0,则报告异常(道理很简单,实际这样就是把“table”初始化为长度0的数组)
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 确保设置的初始容量不能超过最大容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 确保加载因子必须大于0,否则报告异常
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
// 容器容量必须是2的n次幂
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
// 设置加载因子
this.loadFactor = loadFactor;
// 计算需要进行扩容的界限,实际就是 初始容量 * 加载因子。
threshold = (int)(capacity * loadFactor);
// 设置哈希表大小
table = new Entry[capacity];
init();
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
table = new Entry[DEFAULT_INITIAL_CAPACITY];
init();
}
public HashMap(Map<? extends K, ? extends V> m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
putAllForCreate(m);
}
我们对最关键的代码部分都做了注释,并且我们可以总结得到:
- HashMap内部维护了一个Entry类型的数组对象“table”,这实际上就是所谓的哈希表。至于Entry我们紧接着就会说到。
- HashMap的初始容量由“initialCapacity”决定,默认的初始容量为16,即我们不传入initialCapacity,“table”的长度就默认为16。
- loadFactor即“加载因子”,它的作用是决定容器在什么时候需要进行扩充,默认的加载因子为0.75f.
- 而容器具体在什么时候就会进行扩充工作呢?即达到容量极限“threshold ”时,threshold = capacity * loadFactor。
了解了以上信息,我们对于HashMap的基本结构就有了一个初步的认识。而另外值得一提的是:
我们注意到容器的容量值总是被确保为2的N次方,也就是说,如果我们传入的容量值为7。那么:
构造器内部就会初始化一个capacity =1,然后循环做左位移操作,直到它的值变为8(即2的3次方,且大于我们传入的6)。
至于究竟为什么这样做,我们不难猜想多半是为了提高之后的存取效率,我看到一边文章中的解释是:
- HashMap的底层数组长度总是2的n次方,因为在构造函数中存在:capacity <<= 1;
- 而当length为2的n次方时,h&(length – 1)就相当于对length取模,而且速度比直接取模快得多。
原理就在这了,但因为在算法方面,我是一个巨大的菜鸟。所以看了我也没能十分明白。
那总之,我就记住它这样做,归根结底是为了提高之后对于容器的存取操作的效率。
内部类元素类型”Entry”
紧接着,我们已经说到,对于HashMap,它内部维护的数组“table”的类型是”Entry”。
我们肯定还记得LinkedList当中就有这样的东西,但这里的“Entry”是定义在HashMap当中的内部类,一个全新的类型:
static class Entry<K,V> implements Map.Entry<K,V> {
final K key; // 键
V value; // 值
Entry<K,V> next;// 链表的下一个键值对元素
final int hash; // 哈希值
/*
*构造器
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
public final K getKey() {
return key;
}
public final V getValue() {
return value;
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
/*
* equals判断
*/
public final boolean equals(Object o) {
// 首先通过类型判断
if (!(o instanceof Map.Entry))
return false;
// 类型转换
Map.Entry e = (Map.Entry)o;
// 获取key
Object k1 = getKey();
Object k2 = e.getKey();
/*
* 判断结果分为几种情况:
* 1.如果k1==k2,即k1和k2在内存中指向同一个对象,则返回true。
* 2.如果k1不为null,且满足k1.equals(k2),即两个Entry对象的key满足equals。则再判断它们的value是否满足equals,如果都满足则返回true.
* 3.否则其他情况则将返回false
*/
if (k1 == k2 || (k1 != null && k1.equals(k2))) {
Object v1 = getValue();
Object v2 = e.getValue();
if (v1 == v2 || (v1 != null && v1.equals(v2)))
return true;
}
return false;
}
/*
* Entry对象的哈希值计算
*/
public final int hashCode() {
// 即key的哈希值与value的哈希值做亦或运算
return (key==null ? 0 : key.hashCode()) ^
(value==null ? 0 : value.hashCode());
}
public final String toString() {
return getKey() + "=" + getValue();
}
/**
* This method is invoked whenever the value in an entry is
* overwritten by an invocation of put(k,v) for a key k that‘s already
* in the HashMap.
*/
void recordAccess(HashMap<K,V> m) {
}
/**
* This method is invoked whenever the entry is
* removed from the table.
*/
void recordRemoval(HashMap<K,V> m) {
}
}
我们注意到,在这个Entry内部类当中,我们可以得到比较重要的信息有:
- 两个关键的字段:key和value,即是我们存放的键值对(对应于map.put(key,value))。
- 一个Entry类型的变量next,即代表当前Entry的下一个节点元素信息。
- 另一个关键的int型变量“hash”即代表当前Entry对象的哈希值(散列码)
在上面的信息当中,容器引起我们注意的当然是变量“next”。为什么会持有这样一个变量?
这是因为,我们说过哈希表当中的元素,是通过计算散列码来决定其在数组中的存放位置的。
但是,无论是怎么样的哈希函数,都不能绝对保证不会出现两个不同元素但哈希值计算相同的情况。
也就是说,也就是说,很可能在同一个哈希值代表的索引处,会存放多个不同的元素。
那么,这种情况是怎么样得以实现的呢?答案还是链表。不知道你否对LinkedList当中的Entry有印象没。
在LinkedList当中的Entry除了有next节点之外,还有previous节点,所以又称为双向链表结构。
而在HashMap内部定义的Entry只保留next节点,所以是单向链表结构。我们借助一张图可以更清楚的了解这种结构:
上图中橙色部分代表的就是内部的”table”数组,蓝色的则是代表:
如果之后在相同哈希值代表的索引处存放进元素,则通过链表形式进行链接。
put/get 元素的存取剖析
到此为止,我们已经了解了关于构造一个HashMap容器对象,所做的工作及其原理。
老样子,当我们有容器对象过后,我们所做的就是元素的存取工作,所以put和get方法当然是我们关心的重点。
自然而然,先有存才有取,所以我们就先来看一下put方法的源码是怎么样子的吧:
public V put(K key, V value) {
//当key为null,调用putForNullKey方法,保存null于table第一个位置中,这也是HashMap允许为null的原因
if (key == null)
return putForNullKey(value);
// 计算key的哈希值(散列码)
// key.hashCode()的调用说明了为什么使用HashMap存储自定义类型时,要求覆写hashCode()方法
int hash = hash(key.hashCode());
// 通过散列码计算出其应该在散列表(table)中存放的索引位置
int i = indexFor(hash, table.length);
// 从结算得到的索引处取出头结点元素e,然后依次通过e.next遍历链表
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//判断该条链上是否有相同的key存在
//即"hash"相等,且key1=key2或者key1.equals(key2)(这也是为什么在用)
//key.equals(k)的调用说明了为什么使用HashMap存储自定义类型时,要求覆写equals方法
//若存在相同,则直接覆盖value,返回旧value
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
// 增加结构修改次数
modCount++;
// 将key、value添加至i位置处
addEntry(hash, key, value, i);
return null;
}
private V putForNullKey(V value) {
/* 这段代码你很熟悉,因为在put方法内部见过,不同的是:
* 这里是直接使用table[0]索引处,这也是为什么说key为null,将被直接保存在哈希表的第一个位置。
*/
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
/*
* 这就是一个纯粹的数学运算,用以计算对象的哈希值。
* 好像之所以额外定义这样一个方法,是因为我们自定义的hashCode方法有时可能写的很不好。
* 所以作者额外提供这样一个计算方法,并且配合indexFor方法提升运行效率。
*/
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
// 调用该方法得到哈希值对应的存放索引,可以看到计算方法是计算得到的哈希值与table的长度-1做与运算。
static int indexFor(int h, int length) {
return h & (length-1);
}
void addEntry(int hash, K key, V value, int bucketIndex) {
//获取bucketIndex处的Entry
Entry<K,V> e = table[bucketIndex];
//将新创建的 Entry 放入 bucketIndex 索引处,并让新的 Entry 指向原来的 Entry
//另外注意到,新的Entry的next被设置为原来的Entry“e”,也就是说新放入的元素将放置在链头而不是链尾。
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
// 如果超过容量极限,则将容量扩大为2倍
if (size++ >= threshold)
resize(2 * table.length);
}
事实上,上面的代码我们已经做了较为详细的代码注释,我们可以由此总结put的工作流程:
- 首先判断传入的key是否为null,如果是则直接调用putForNullKey将元素存放在table[0]处。
- 通过“hash(key.hashCode())”计算出key的哈希值;接着通过indexFor计算得到的哈希值在table中对应存放的索引。
- 对该索引位置进行遍历,查看该位置是否已经存有元素。如果没有,则直接将则直接插入。
- 否则迭代该处元素链表并依此比较其key的hash值,此时又分为两种情况:
1、如果通过hash()方法计算得到的”hash”值相等,且key也相同,则代表是对同一个key对应的值做更新,所做的操作也就是用新值覆盖旧值。
2、而如果“hash”值相等,但是key不相同。则代表虽然具有相同的散列码,应该在同一索引位置存放,但实际是两个不同的键值对。则将新的键值对添加至该索引处的链表表头。
由此,我们了解了put方法的工作原理。接着我们就看与之对应的get方法:
public V get(Object key) {
// 先判断key是否为null,若为null则直接通过getForNullKey返回对应的value
if (key == null)
return getForNullKey();
// 计算哈希值
int hash = hash(key.hashCode());
// 遍历该哈希值所处索引处的值,直到满足hash值相等,且key相同的条件,则代表找到key所对应的值,从而返回。
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
return e.value;
}
// 为查找到则返回null
return null;
}
private V getForNullKey() {
// 不再赘述,唯一的不同就是不用计算hash值,在计算得到索引。直接使用索引“0”
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
到此,我们对于HashMap源码的核心部分就都进行了分析。而其他部分的源码来说,
实际上基本都是在我们上面分析过的部分加上对应的逻辑处理,我们也就不一一再去分析了。
对于HashMap来说,我们可以总结出并记住以下一些有趣的关键点:
- HashMap有一个叫做Entry的内部类,它用来存储key-value对。
- 上面的Entry对象是存储在一个叫做table的Entry数组中。
- table的索引在逻辑上叫做“桶”(bucket),即我们所说的具有相同散列码但实际不同的对象我们可以理解为它们都存放在同一个桶。
- HashMap.hash(key.hashCode())方法用来找到Entry对象所在的桶。
- key的equals()方法,被用来进一步确保key的唯一性。
- value对象的equals()和hashcode()方法根本一点用也没有。
-
TreeMap源码浅析
TreeMap顾名思义,其内部就是根据”红黑树“的数据结构来实现的。红黑树又称红-黑二叉树,它首先是一颗二叉树,它具体二叉树所有的特性。同时红黑树更是一颗自平衡的排序二叉树。
我们知道一颗基本的二叉树他们都需要满足一个基本性质–即树中的任何节点的值大于它的左子节点,且小于它的右子节点。
而TreeMap之所以能让内部存放的元素按照指定的顺序来存放,也是基于这个原因。
了解了基本的概念,我们仍然从其构造器部分的源码看起:
// 比较器对象
private final Comparator<? super K> comparator;
// 树的根节点
private transient Entry<K,V> root = null;
// 当前存放数
private transient int size = 0;
// 从结构上修改容器的次数
private transient int modCount = 0;
public TreeMap() {
comparator = null;
}
public TreeMap(Comparator<? super K> comparator) {
this.comparator = comparator;
}
public TreeMap(Map<? extends K, ? extends V> m) {
comparator = null;
putAll(m);
}
public TreeMap(SortedMap<K, ? extends V> m) {
comparator = m.comparator();
try {
buildFromSorted(m.size(), m.entrySet().iterator(), null, null);
} catch (java.io.IOException cannotHappen) {
} catch (ClassNotFoundException cannotHappen) {
}
}
TreeMap的构造器部分,比较简单,值得我们关注的就是两个东西:
- Comparator - 也就是所谓的比较器,我们说了二叉树的原理就是比较排序,而在TreeMap里,元素的比较久是通过这个东西完成的。
Comparator比较后将返回一个int型的值,小于0则代表元素应该放置在父节点左边,大于0则放在右边,等于0则代表同一个节点。
- 另一个则是我们熟悉的“Entry”,当然,这仍是定义在TreeMap当中的全新的类型。打开其源码:
static final class Entry<K,V> implements Map.Entry<K,V> {
K key; // 传入的key
V value; // 传入的value
Entry<K,V> left = null; // 左子节点元素
Entry<K,V> right = null; // 右子节点元素
Entry<K,V> parent; // 父节点元素
boolean color = BLACK; // 颜色(红/黑)
/**
* Make a new cell with given key, value, and parent, and with
* <tt>null</tt> child links, and BLACK color.
*/
Entry(K key, V value, Entry<K,V> parent) {
this.key = key;
this.value = value;
this.parent = parent;
}
我们发现,正如我们所了解的二叉树的结构特点,与链表不同,这里的Entry内部保存是其父节点及左、右子节点的信息。
而实际上,我们已经了解二叉树对于元素的比较排序而存放的原理,而它反应在Java代码上是如何的?
public V put(K key, V value) {
//用t表示二叉树的当前节点
Entry<K,V> t = root;
//t为null表示一个空树,即TreeMap中没有任何元素,直接插入
if (t == null) {
//比较key值,个人觉得这个比较好像是多余的?
compare(key, key);
//将新的key-value键值对创建为一个Entry节点,并将该节点赋予给root
root = new Entry<>(key, value, null);
//修改size
size = 1;
//修改次数 + 1
modCount++;
return null;
}
int cmp; //key经过排序的返回结果
Entry<K,V> parent; //父节点
Comparator<? super K> cpr = comparator; //指定的排序算法
//如果cpr不为空,则采用既定的排序算法进行创建TreeMap集合
if (cpr != null) {
do {
parent = t; //parent指向上次循环后的t
//比较新增节点的key和当前节点key的大小
cmp = cpr.compare(key, t.key);
//cmp返回值小于0,表示新增节点的key小于当前节点的key,则以当前节点的左子节点作为新的当前节点
if (cmp < 0)
t = t.left;
//cmp返回值大于0,表示新增节点的key大于当前节点的key,则以当前节点的右子节点作为新的当前节点
else if (cmp > 0)
t = t.right;
//cmp返回值等于0,表示两个key值相等,则新值覆盖旧值,并返回新值
else
return t.setValue(value);
} while (t != null);
}
//如果cpr为空,则采用默认的排序算法进行创建TreeMap集合
else {
if (key == null) //key值为空抛出异常
throw new NullPointerException();
/* 下面处理过程和上面一样 */
Comparable<? super K> k = (Comparable<? super K>) key;
do {
parent = t;
cmp = k.compareTo(t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
}
//将新增节点当做parent的子节点
Entry<K,V> e = new Entry<>(key, value, parent);
//如果新增节点的key小于parent的key,则当做左子节点
if (cmp < 0)
parent.left = e;
//如果新增节点的key大于parent的key,则当做右子节点
else
parent.right = e;
/*
* 上面已经完成了排序二叉树的的构建,将新增节点插入该树中的合适位置
* 下面fixAfterInsertion()方法就是对这棵树进行调整、平衡,具体过程参考上面的五种情况
*/
fixAfterInsertion(e);
//TreeMap元素数量 + 1
size++;
//TreeMap容器修改次数 + 1
modCount++;
return null;
}
注意了,对于上面的代码之中do-while之间的算法过后,实际上就可以完成普通二叉树的排序。
但是,我们说过了TreeSet是基于红黑树来实现,红黑树是一种平衡排序二叉树。
也就是说,还需要对元素的存放存续进行适当调整,这个调整工作就是通过fixAfterInsertion完成的。
这个方法我也没具体研究明白,就跳过不谈了。而对应的,TreeMap的get方法又是怎么样的呢?
public V get(Object key) {
Entry<K,V> p = getEntry(key);
return (p==null ? null : p.value);
}
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();
Comparable<? super K> k = (Comparable<? super K>) key;
// 获取根节点
Entry<K,V> p = root;
// 进行遍历
while (p != null) {
// 通过compareTo进行比较
int cmp = k.compareTo(p.key);
// 如果比较结果小于0,则继续向左边的子树进行遍历
if (cmp < 0)
p = p.left;
// 如果比较结果大于0,则继续向右边的子树进行遍历
else if (cmp > 0)
p = p.right;
// 等于0,则代表找到了对应的节点
else
return p;
}
return null;
}
因为TreeMap底层是基于红黑树的,而红黑树的实现本身的确十分复杂。
因为能力有限,所以在这里我们就只对TreeMap里比较常用关键的一些源码做了分析,
主要的目的是对TreeMap的实现原理有一定的了解,并知道这种数据结构能给我们带来什么方便。
迭代器 - 统一对容器的访问方式
通过前面这么多的介绍,我们知道Java的集合框架提供了各种各样特点的容器类型。
不同的数据结构也导致了它们对于元素的访问形式的不一,而我们知道代码的通用性有多么重要?
所以,如何通过一种方式能够通用的对所有的容器类型进行访问,就很关键了。
而迭代器 (iterator),也就是这样,应运而生。
迭代器的使用
我们首先来看一下,对于迭代器来说,它的使用方式是怎么样的?
public static void main(String[] args) {
Collection<String> c = new ArrayList<String>();
c.add("1");
c.add("2");
c.add("3");
c.add("4");
Iterator<String> itr = c.iterator();
while (itr.hasNext()) {
System.out.println(itr.next());
}
}
对于迭代器的时候,相比大家都比较熟悉。我们也不多说,我们这里来关心一下,我们怎么样才可以构建自己的迭代器。
-
iterator与iteratable接口
实际上,向我们平常常用的容器类的迭代器都是通过iterator与iteratable来实现的。
举例来说,假设来定义一个属于自己的简易的容器类:
public class SimpleCollection<E>{
private Object[] array;
private final static int DEFAULT_CAPACITY = 16;
private int size = 0;
public SimpleCollection() {
this(DEFAULT_CAPACITY);
}
public SimpleCollection(int capacity) {
array = new Object[capacity];
}
public void add(E e){
if(size < 0 || size > array.length)
throw new ArrayIndexOutOfBoundsException();
array[size++] = e;
}
public E get(int index){
if(index < 0 || index > array.length)
throw new ArrayIndexOutOfBoundsException();
return (E) array[index];
}
public int size(){
return size;
}
}
我们当然可以通过最平常的方式来访问我们自己的容器:
public static void main(String[] args) {
SimpleCollection<String> sc = new SimpleCollection<String>();
sc.add("123");
sc.add("345");
sc.add("456");
for (int i = 0; i < sc.size(); i++) {
System.out.println(sc.get(i));
}
}
但是,如果我们想要通过迭代器的方式来访问又该怎么样去做呢?我们可以在我们的容器类当中添加如下的代码:
public Iterator<E> iterator(){
return new SimpleIterator<E>();
}
private class SimpleIterator<E> implements Iterator<E>{
private int index = 0;
@Override
public boolean hasNext() {
return size - index > 0 ;
}
@Override
public E next() {
return (E) array[index++];
}
@Override
public void remove() {
throw new UnsupportedOperationException();
}
}
现在我们就可以将访问容器的代码修改为下面这样使用了:
public static void main(String[] args) {
SimpleCollection<String> sc = new SimpleCollection<String>();
sc.add("123");
sc.add("345");
sc.add("456");
Iterator<String> itr = sc.iterator();
while(itr.hasNext()){
System.out.println(itr.next());
}
}
而我们打开另一个接口iteratable的源码:
public interface Iterable<T> {
/**
* Returns an iterator over a set of elements of type T.
*
* @return an Iterator.
*/
Iterator<T> iterator();
}
我们发现该接口实际上就是提供了一个返回Iterator的方法。这个接口有什么用?我们下面就将看到。
-
for each语法原理
对于对象使用for each语法,实际上原理就是基于Iterable接口。
不知道大家在使用Java的过程中,有没有写过类似下面的代码:
ArrayList<String> list = new ArrayList<String>();
list.add("1");
list.add("2");
list.add("3");
for (String string : list) {
if(string.equals("1"))
list.remove("1");
}
当我们运行上面的带的时候,就会得到一个异常:java.util.ConcurrentModificationException。
而造成这个异常的原因,我们现在也可以分析出来了,这是因为:
- 我们已经知道了,ArrayList有个modCount字段表示ArrayList被修改的次数
- 而foreach语法的原理,最终就还是调用iterator接口。对于ArrayList来说,iterator是在AbstarctList中默认实现的。
- 在获取iterator的时候ArrayList的modCount会被保存在iterator的expectedModCount中。
- 每次修改ArrayList的时候modCount就会改变,例如我们上面代码中做的remove操作。
- 而在每次调用iterator的hasNext和next方法的时候,会先检查ArrayList的modCount和iterator的expectedModCount是否相同,不同就会抛出上面的异常。
那么,如果我们想让我们上面自己定义的容器类SimpleCollection能够使用for-each语法,我们就可以让类它实现iterable接口。
而由于,我们之前的代码就已经实现了iterator方法,所以都不用做任何修改了。
最终,我们就可以用for-each的形式来访问容器了:
public static void main(String[] args) {
SimpleCollection<String> c = new SimpleCollection<String>();
c.add("1");
c.add("2");
c.add("3");
for (String str : c) {
System.out.println(str);
}
}
集合框架的使用总结
对于集合框架的使用选择,《Thinking in java》一书中实际做了很详细的总结:
- Collection保存单一的元素;Map保存相关的键值对。
- 如果要进行大量的随机访问,则应该选择ArrayList;如果要在表中间进行频繁的元素操作,则应该选择LinkedList。
- 各种Queue以及栈数据结构的操作,由LinkedList负责提供支持。
- HashMap设计用来快速访问;而TreeMap则保持“键”使用保持排序状态。
LinkedHashMap保持元素插入的顺序,同时也通过实现散列提供了快速访问能力。
- Set不接受重复元素;HashSet提供最快的元素访问速度;TreeSet让元素保持排序状态;
- 新程序中不应该使用过时的Vector,HashTable和Stack。