JAVA 笔记 从源码深入浅出集合框架
Posted LaterEqualsNever
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JAVA 笔记 从源码深入浅出集合框架相关的知识,希望对你有一定的参考价值。
集合框架概述
以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方法唯一的不同在于,这里选择将数组从后向前进行遍历。所以返回的值就将是元素在数组里最后出现的角标。同样,如果没有遍历到,以上是关于JAVA 笔记 从源码深入浅出集合框架的主要内容,如果未能解决你的问题,请参考以下文章
jdk源码阅读笔记之java集合框架(LinkedList)