数组和集合
60、性能考虑,数组是首选
数组在实际的系统开发中用得越来越少了,我们通常只有在阅读一些开源项目时才会看到它们的身影,在Java中它确实没有List、Set、Map这些集合类用起来方便,但是在基本类型处理方面,数组还是占优势的,而且集合类的底层也都是通过数组实现的,比如对一个数据集求和这样的计算:
java
//对数组求和
public static int sum(int[] datas){
int sum =0;
for(int i=0;i<datas.length;i++){
sum +=datas[i];
}
return sum;
}
对一个int类型的数组求和,取出所有的数组元素并相加,此算法中如果是基本类型则使用数组效率是最高的,使用集合则效率次之。再看使用List求和:
java
//对列表求和计算
public static int sum(List<Integer> datas){
int sum =0;
for(int i=0;i<datas.size();i++){
sum +=datas.get(i);
}
return sum;
}
注意看加粗字体,这里其实已经做了一个拆箱动作,Integer对象通过intValue方法自动转换成了一个int基本类型,对于性能濒于临界的系统来说该方案是比较危险的,特别是大数量的时候,首先,在初始化List数组时要进行装箱动作,把一个int类型包装成一个Integer对象,虽然有整型池在,但不在整型池范围内的都会产生一个新的Integer对象,而且众所周知,基本类型是在栈内存中操作的,而对象则是在堆内存中操作的,栈内存的特点是速度快,容量小,堆内存的特点是速度慢,容量大(从性能上来讲,基本类型的处理占优势)。其次,在进行求和计算(或者其他遍历计算)时要做拆箱动作,因此无谓的性能消耗也就产生了。
在实际测试中发现:对基本类型进行求和计算时,数组的效率是集合的10倍。
注意性能要求较高的场景中使用数组替代集合。
61、若有必要,使用变长数组
Java中的数组是定长的,一旦经过初始化声明就不可改变长度,这在实际使用中非常不方便,比如要对班级学生的信息进行统计,因为我们不知道一个班级会有多少学生(随时都可能会有学生入学、退学或转学),所以需要有一个足够大的数组来容纳所有的学生,但问题是多大才算足够大?10年前一台台式机64MB的内存已经很牛了,现在要是没有2GB的内存你都不好意思跟别人交流计算机的配置,所以呀,这个足够大是相对于当时的场景而言的。随着环境的变化,"足够大"也可能会转变成"足够小",然后就会出现超出数组最大容量的情况,那该如何解决呢?事实上,可以通过对数组扩容"婉转"地解决该问题,代码如下:
java
public static <T> T[] expandCapacity(T[] datas, int newLen) {
// 不能是负值
newLen = newLen<0?0:newLen;
// 生成一个新数组,并拷贝原值
return Arrays.copyOf(datas, newLen);
}
上述代码中采用的是Arrays数组工具类的copyOf方法,产生了一个newLen长度的新数组,并把原有的值拷贝了进去,之后就可以对超长的元素进行赋值了(依据类型的不同分别赋值为0、false或null),使用方法如下:
java
public static void main(String[] args) {
//一个班级最多容量60个学生
Stu[] classes = new Stu[60];
/*classes初始化 ......*/
//偶尔一个班级可以容纳80人,数组加长
classes = expandCapacity(classes,80);
/*重新初始化超过限额的20人......*/
}
通过这样的处理方式,曲折地解决了数组的变长问题。其实,集合的长度自动维护功能的原理与此类似。在实际开发中,如果确实需要变长的数据集,数组也是在考虑范围之内的,不能因固定长度而将其否定之。
62、警惕数组的浅拷贝
有这样一个例子,第一个箱子里有赤橙黄绿青蓝紫7色气球,现在希望在第二个箱子中也放入7个气球,其中最后一个气球改为蓝色,也就是赤橙黄绿青蓝蓝7个气球,那我们很容易就会想到第二个箱子中的气球可以通过拷贝第一个箱子中的气球来实现,毕竟有6个气球是一样的嘛,来看实现代码:
java
public class Client {
public static void main(String[] args) {
//气球数量
int ballonNum = 7;
// 第一个箱子
Balloon[] box1 = new Balloon[ballonNum];
// 初始化第一个箱子中的气球
for (int i = 0; i < ballonNum; i++) {
box1[i] = new Balloon(Color.values()[i], i);
}
// 第二个箱子的气球是拷贝的第一个箱子里的
Balloon[] box2 = Arrays.copyOf(box1, box1.length);
// 修改最后一个气球颜色
box2[6].setColor(Color.Blue);
// 打印出第一个箱子中的气球颜色
for (Balloon b:box1) {
System.out.println(b);
}
}
}
// 气球颜色
enum Color {
Red, Orange, Yellow, Green, Indigo, Blue, Violet;
}
// 气球
class Balloon {
// 编号
private int id;
// 颜色
private Color color;
public Balloon(Color _color, int _id) {
color = _color;
id = _id;
}
/*id、color的getter/setter方法省略*/
//apache-common包下的ToStringBuilder重写toString方法
public String toString() {
return new ToStringBuilder(this)
.append("编号", id)
.append("颜色", color)
.toString();
}
}
第二个箱子里最后一个气球的颜色毫无疑问是被修改成蓝色了,不过我们是通过拷贝第一个箱子里的气球然后再修改的方式来实现的,那会对第一个箱子的气球颜色有影响吗?我们看输出:
java
Balloon@b2fd8f[编号=0,颜色=Red]
Balloon@a20892[编号=1,颜色=Orange]
Balloon@158b649[编号=2,颜色=Yellow]
Balloon@1037c71[编号=3,颜色=Green]
Balloon@1546e25[编号=4,颜色=Indigo]
Balloon@8a0d5d[编号=5,颜色=Blue]
Balloon@a470b8[编号=6,颜色=Blue]
最后一个气球颜色竟然也被修改了,我们只是希望修改第二个箱子的气球啊,这是为何?这是很典型的浅拷贝(Shallow Clone)问题,前面第1章的序列化中也介绍过,但是这里与之有一点不同:数组中的元素没有实现Serializable接口。
确实如此,通过copyOf方法产生的数组是一个浅拷贝,这与序列化的浅拷贝完全相同:基本类型是直接拷贝值,其他都是拷贝引用地址。需要说明的是,数组的clone方法也是与此相同的,同样是浅拷贝,而且集合的clone方法也都是浅拷贝,这就需要大家在拷贝时多留心了。
问题找到了,解决方案也很简单,遍历box1的每个元素,重新生成一个气球(Ballon)对象,并放置到box2数组中,代码较简单,不再赘述。
该方法用得最多的地方是在使用集合(如List)进行业务处理时,比如发觉需要拷贝集合中的元素,可集合没有提供拷贝方法,如果自己写会很麻烦,所以干脆使用List.toArray方法转换成数组,然后通过Arrays.copyOf拷贝,再转换回集合,简单便捷!但是,非常遗憾的是,这里我们又撞到浅拷贝的枪口上了,虽然很多时候浅拷贝可以解决业务问题,但更多时候会留下隐患,需要我们提防又提防。
63、在明确的场景下,为集合指定初始容量
我们经常使用ArrayList、Vector、HashMap等集合,一般都是直接用new跟上类名声明出一个集合来,然后使用add、remove等方法进行操作,而且因为它是自动管理长度的,所以不用我们特别费心超长的问题,这确实是一个非常好的优点,但也有我们必须要注意的事项。
下面以ArrayList为例深入了解一下Java是如何实现长度的动态管理的,先从add方法的阅读开始,代码如下。
java
public boolean add(E e) {
//扩展长度
ensureCapacity(size + 1);
//追加元素
elementData[size++] = e;
return true;
}
我们知道ArrayList是一个大小可变的数组,但它在底层使用的是数组存储(也就是elementData变量),而且数组是定长的,要实现动态长度必然要进行长度的扩展,ensureCapacity方法提供了此功能,代码如下:
java
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;
//数组拷贝,生成新数组
elementData = Arrays.copyOf(elementData, newCapacity);
}
}
注意看新数组的长度计算方法,并不是增加一个元素,elementData的长度就加1,而是在达到elementData长度的临界点时,才将elementData扩容1.5倍,这样实现有什么好处呢?好处是避免了多次调用copyOf方法的性能开销,否则每加一个元素都要扩容一次,那性能岂不是非常糟糕?!
可能有读者要问了,这里为什么是1.5倍,而不是2.5倍、3.5倍?这是一个好问题,原因是一次扩容太大(比如扩容2.5倍),占用的内存也就越大,浪费的内存也就越多(1.5倍扩容,最多浪费33%的数组空间,而2.5倍则最多可能浪费60%的内存);而一次扩容太小(比如每次扩容1.1倍),则需要多次对数组重新分配内存,性能消耗严重。经过测试验证,扩容1.5倍即满足了性能要求,也减少了内存消耗。
现在我们知道了ArrayList的扩容原则,那还有一个问题:elementData的默认长度是多少呢?答案是10,如果我们使用默认方式声明ArrayList,如new ArrayList(),则elementData的初始长度就是10。我们来看ArrayList的无参构造:
java
//无参构造,我们通常用得最多的就是这个
public ArrayList() {
//默认是长度为10的数组
this(10);
}
//指定数组长度的有参构造
public ArrayList(int initialCapacity) {
super();
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal Capacity: "+initialCapacity);
//声明指定长度的数组,容纳element
this.elementData = new Object[initialCapacity];
}
默认初始化时声明了一个长度为10的数组,在通过add方法增加第11个元素时,ArrayList类就自动扩展了,新的elementData数组长度是(10×3)/2+1,也就是16,当增加到第17个元素时再次扩容为(16×3)/2+1,也就是25,依此类推,实现了ArrayList的动态数组管理。
从这里我们可以看出,如果不设置初始容量,系统就按照1.5倍的规则扩容,每次扩容都是一次数组的拷贝,如果数据量很大,这样的拷贝会非常耗费资源,而且效率非常低下。如果我们已经知道一个ArrayList的可能长度,然后对ArrayList设置一个初始容量则可以显著提高系统性能。比如一个班级的学生,通常也就是50人左右,我们就声明ArrayList的默认容量为50的1.5倍(元素数量小,直接计算,避免数组拷贝),即new ArrayList<Studeng>(75),这样在使用add方法增加元素时,只要在75以内都不用做数组拷贝,超过了75才会按照默认规则扩容(也就是1.5倍扩容)。如此处理,对我们的开发逻辑并不会有任何影响,而且还可以提高运行效率(在大数据量下,是否指定容量会使性能相差5倍以上)。
弄明白了ArrayList的长度处理方式,那其他集合类型呢?我们先来看Vector,它的处理方式与ArrayList相似,只是数组的长度计算方式不同而已,代码如下:
java
private void ensureCapacityHelper(int minCapacity) {
int oldCapacity = elementData.length;
if (minCapacity > oldCapacity) {
Object[] oldData = elementData;
//若有递增步长,则按照步长增长;否则,扩容2倍
int newCapacity = (capacityIncrement > 0) ?(oldCapacity + capacityIncrement)
: (oldCapacity * 2);
//越界检查,否则超过int最大值
if (newCapacity < minCapacity) {
newCapacity = minCapacity;
}
elementData = Arrays.copyOf(elementData, newCapacity);
}
}
Vector与ArrayList不同的地方是它提供了递增步长(capacityIncrement变量),其值代表的是每次数组拓长时要增加的长度,不设置此值则是容量翻倍(默认是不设置递增步长的,可以通过构造函数来设置递增步长)。其他集合类的扩容方式与此相似,如HashMap是按照倍数增加的,Stack继承自Vector,所采用的也是与其相同的扩容原则等,读者有兴趣可以自行研读一下JDK的源码。
注意非常有必要在集合初始化时声明容量。
64、多种最值算法,适时选择
对一批数据进行排序,然后找出其中的最大值或最小值,这是基本的数据结构知识。在Java中我们可以通过编写算法的方式,也可以通过数组先排序再取值的方式来实现。下面以求最大值为例,解释一下多种算法。
- (1)自行实现,快速查找最大值
先来看用快速查找法取最大值的算法,其代码如下:
java
public static int max(int[] data) {
int max = data[0];
for (int i:data) {
max = max > i ? max : i;
}
return max;
}
这是我们经常使用的最大值算法,也是速度最快的算法。它不要求排序,只要遍历一遍数组即可找出最大值。
- (2)先排序,后取值
对于求最大值,也可以采用先排序后取值的方式,同样比较简单,代码如下:
java
public static int max(int[] data) {
//先排序
Arrays.sort(data.clone());
//然后取值
return data[data.length -1];
}
从效率上来讲,当然是自己写快速查找法更快一些了,只用遍历一遍就可以计算出最大值。但在实际测试中我们发现,如果数组数量少于1万,两者基本上没有差别,在同一个毫秒级别里,此时就可以不用自己写算法了,直接使用数组先排序后取值的方式。
如果数组元素超过1万,就需要依据实际情况来考虑:自己实现,可以提升性能;先排序后取值,简单,通俗易懂。排除性能上的差异,两者都可以选择,甚至后者更方便一些,也更容易想到。
现在问题来了,在代码中为什么要先使用data.clone拷贝再排序呢?那是因为数组也是一个对象,不拷贝不就改变了原有数组元素的顺序吗?除非数组元素的顺序无关紧要。
接着往下思考,如果要查找仅次于最大值的元素(也就是老二),该如何处理呢?要注意,数组的元素是可以重复的,最大值可能是多个,所以单单一个排序然后取倒数第二个元素是解决不了问题的。
此时,就需要一个特殊的排序算法了,先要剔除重复数据,然后再排序。当然,自己写算法也可以实现,但是集合类已经提供了非常好的方法,要是再使用数组自己写算法就显得有点过时了。数组不能剔除重复数据,但Set集合却是可以的,而且Set的子类TreeSet还能自动排序。代码如下:
java
public static int getSecond(Integer[] data){
//转换为列表
List<Integer> dataList = Arrays.asList(data);
//转换为TreeSet,删除重复元素并升序排列
TreeSet<Integer> ts = new TreeSet<Integer>(dataList);
//取得比最大值小的最大值,也就是老二了
return ts.lower(ts.last());
}
剔除重复元素并升序排列,这都由TreeSet类实现的,然后可再使用lower方法寻找小于最大值的值。大家看,上面的程序非常简单吧?那如果是我们自己编写代码会怎么样?那至少要遍历数组两遍才能计算出老二的值,代码的复杂度将大大提升。
也许你会说,这个要求有点变态,怎么会有这样的需求?不,有这样的需求很正常,比如在学校按成绩排名时,如果一个年级有1200人,只要找出最高的三个分数(可不一定就是3个人,也可能是多人),是不是就是这种情况呢?因此在实际应用中求最值,包括最大值、最小值、第二大值、倒数第二小值等,使用集合是最简单的方式,当然若从性能方面来考虑,数组是最好的选择。
注意最值计算时使用集合最简单,使用数组性能最优。
65、避开基本类型数组转换列表陷阱
我们在开发过程中经常会使用Arrays和Collections这两个工具类在数组和列表之间转换,非常方便,但也有时候会出现一些奇怪的问题,来看如下代码:
java
public static void main(String[] args) {
int[] data = {1,2,3,4,5};
List list = Arrays.asList(data);
System.out.println("列表中的元素数量是:" + list.size());
}
也许你会说,这很简单,list变量的元素数量当然是5了。但是运行后打印出来的列表数量却是1。
事实上data确实是一个有5个元素的int类型数组,只是通过asList转换成列表后就只有1个元素了,这是为什么呢?其他4个元素到什么地方去了呢?
我们仔细看一下Arrays.asList的方法说明:输入一个变长参数,返回一个固定长度的列表。注意这里是一个变长参数,看源代码:
java
public static <T> List<T> asList(T... a) {
return new ArrayList<T>(a);
}
asList方法输入的是一个泛型变长参数,我们知道基本类型是不能泛型化的,也就是说8个基本类型不能作为泛型参数,要想作为泛型参数就必须使用其所对应的包装类型。那前面的例子传递了一个int类型的数组,为什么程序没有报编译错呢?
在Java中,数组是一个对象,它是可以泛型化的,也就是说我们的例子是把一个int类型的数组作为了T的类型,所以转换后在List中就只有一个类型为int数组的元素了,我们打印出来看看,代码如下:
java
public static void main(String[] args) {
int[] data = {1,2,3,4,5};
List list = Arrays.asList(data);
System.out.println("元素类型:" + list.get(0).getClass());
System.out.println("前后是否相等:"+data.equals(list.get(0)));
}
输出的结果是:
java
元素类型:class [I
前后是否相等:true
很明显,放在列表中的元素是一个int数组,可能有读者要问了,为什么"元素类型:"后的class 是"[I"?我们并没有指明是数组(Array)类型呀!这是因为JVM不可能输出Array类型,因为Array是属于java.lang.reflect包的,它是通过反射访问数组元素的工具类。在Java中任何一个数组的类都是"[I",究其原因就是Java并没有定义数组这一个类,它是在编译器编译的时候生成的,是一个特殊的类,在JDK的帮助中也没有任何数组类的信息。
弄清楚了问题,修改方案也就诞生了,直接使用包装类即可,代码如下:
java
public static void main(String[] args) {
Integer[] data = {1,2,3,4,5};
List list = Arrays.asList(data);
System.out.println("列表中的元素数量是:" + list.size());
}
仅仅修改了加粗字体部分,把int替换为Integer即可让输出元素数量为5。需要说明的是,不仅仅是int类型的数组有这个问题,其他7个基本类型的数组也存在相似的问题,这就需要读者注意了,在把基本类型数组转换成列表时,要特别小心asList方法的陷阱,避免出现程序逻辑混乱的情况。
注意原始类型数组不能作为asList的输入参数,否则会引起程序逻辑混乱。
66、asList方法产生的List对象不可更改
上一个建议指出了asList方法在转换基本类型数组时存在的问题,接着我们看一下asList方法返回的列表有何特殊的地方,代码如下所示:
java
enum Week{Sun,Mon, Tue, Wed,Thu,Fri,Sat}
public static void main(String[] args) {
//五天工作制
Week[] workDays = {Week.Mon, Week.Tue, Week.Wed,Week.Thu,Week.Fri};
//转换为列表
List<Week> list = Arrays.asList(workDays);
//增加周六也为工作日
list.add(Week.Sat);
/*工作日开始干活了*/
}
很简单的程序呀,默认声明的工作日(workDays)是从周一到周五,偶尔周六也会算作工作日加入到工作日列表中。不过,这段程序执行时会不会有什么问题呢?
编译没有任何问题,但是一运行,却出现了如下结果:
java
Exception in thread "main" java.lang.UnsupportedOperationException
at java.util.AbstractList.add(AbstractList.java:131)
at java.util.AbstractList.add(AbstractList.java:91)
UnsupportedOperationException,不支持的操作?居然不支持List的add方法,这真是奇怪了!还是来追根寻源,看看asList方法的源代码:
java
public static <T> List<T> asList(T... a) {
return new ArrayList<T>(a);
}
直接new了一个ArrayList对象返回,难道ArrayList不支持add方法?不可能呀!可能,问题就出在这个ArrayList类上,此ArrayList非java.util.ArrayList,而是Arrays工具类的一个内置类,其构造函数如下所示:
java
//这是一个静态私有内部类
private static class ArrayList<E> extends AbstractList<E>
implements RandomAccess, java.io.Serializable {
//存储列表元素的数组
private final E[] a;
//唯一的构造函数
ArrayList(E[] array) {
if (array==null)
throw new NullPointerException();
a = array;
}
/*其他方法省略*/
}
这里的ArrayList是一个静态私有内部类,除了Arrays能访问外,其他类都不能访问。仔细看这个类,它没有提供add方法,那肯定是父类AbstractList提供了,来看代码:
java
public boolean add(E e) {
throw new UnsupportedOperationException();
}
父类确实提供了,但没有提供具体的实现(源代码上是通过add方法调用add(int,E)方法来实现的,为了便于讲解,此处缩减了代码),所以每个子类都需要自己覆写add方法,而Arrays的内部类ArrayList没有覆写,因此add一个元素就会报错了。
我们再深入地看看这个ArrayList静态内部类,它仅仅实现了5个方法:
- size:元素数量。
- toArray:转化为数组,实现了数组的浅拷贝。
- get:获得指定元素。
- set:重置某一元素值。
- contains:是否包含某元素。
对于我们经常使用的List.add和List.remove方法它都没有实现,也就是说asList返回的是一个长度不可变的列表,数组是多长,转换成的列表也就是多长,换句话说此处的列表只是数组的一个外壳,不再保持列表动态变长的特性,这才是我们要关注的重点(虽然此处JDK的设计有悖OO设计原则,但这不在我们讨论的范围内,而且我们也无力回天)。
有些开发者特别喜欢通过如下方式定义和初始化列表:
java
List<String> names = Arrays.asList("张三","李四","王五");
一句话完成了列表的定义和初始化,看似很便捷,却深藏着重大隐患---列表长度无法修改。想想看,如果这样一个List传递到一个允许add操作的方法中,那将会产生何种结果?如果读者有这种习惯,请慎之戒之,除非非常自信该Lis只用于读操作。
67、不同的列表选择不同的遍历方法
我们来思考这样一个案例:统计一个省的各科高考平均值,比如数学平均分是多少,语文平均分是多少等,这是每年招生办都会公布的数据,我们来想想看该算法应如何实现。当然使用数据库中的一个SQL语句就能求出平均值,不过这不再我们的考虑之列,这里还是使用纯Java的算法来解决之,看代码:
java
public static void main(String[] args) {
//学生数量, 80万
int stuNum = 80*10000;
//List集合,记录所有学生的分数
List<Integer> scores = new ArrayList<Integer>(stuNum);
//写入分数
for(int i=0;i<stuNum;i++){
scores.add(new Random().nextInt(150));
}
//记录开始计算时间
long start = System.currentTimeMillis();
System.out.println("平均分是:" + average(scores));
System.out.println("执行时间:" + (System.currentTimeMillis() -start) + "ms");
}
//计算平均数
public static int average(List<Integer> list){
int sum = 0;
//遍历求和
for(int i:list){
sum +=i;
}
//除以人数,计算平均值
return sum/list.size();
}
把80万名学生的成绩放到一个ArrayList数组中,然后通过foreach方式遍历求和,再计算平均值,程序非常简单,输出的结果是:
java
平均分是:74
执行时间:47ms
仅仅求一个算术平均值就花费了47毫秒,不要说考虑其他诸如加权平均值、补充平均值等算法,那花的时间肯定更长。我们仔细分析一下arverage方法,加号操作是最基本操作,没有什么可以优化的,剩下的就是一个遍历了,问题是List的遍历可以优化吗?
我们可以尝试一下,List的遍历还有另外一种方式,即通过下标方式来访问,代码如下:
java
// 计算平均数
public static int average(List<Integer> list) {
int sum = 0;
// 遍历求和
for (int i = 0, size = list.size(); i < size; i++) {
sum += list.get(i);
}
// 除以人数,计算平均值
return sum / list.size();
}
不再使用foreach方式遍历列表,而是采用下标方式遍历,我们看看输出结果如何:
java
平均分是:74
执行时间:16ms
执行时间已经大幅度下降,性能提升了65%,这是一个飞速提升!那为什么我们使用下标方式遍历数组会有这么高的性能提升呢?
这是因为ArrayList数组实现了RandomAccess接口(随机存取接口),这也就标志着ArrayList是一个可以随机存取的列表。在Java中,RandomAccess和Cloneable、Serializable一样,都是标志性接口,不需要任何实现,只是用来表明其实现类具有某种特质的,实现了Cloneable表明可以被拷贝,实现了Serializable接口表明被序列化了,实现了RandomAccess则表明这个类可以随机存取,对我们的ArrayList来说也就标志着其数据元素之间没有关联,即两个位置相邻的元素之间没有相互依赖和索引关系,可以随机访问和存储。
我们知道,Java中的foreach语法是iterator(迭代器)的变形用法,也就是说上面的foreach与下面的代码等价:
java
for(Iterator<Integer> i=list.iterator(); i.hasNext(); ){
sum +=i.next();
}
那我们再想想什么是迭代器,迭代器是23个设计模式中的一种,"提供一种方法访问一个容器对象中的各个元素,同时又无须暴露该对象的内部细节",也就是说对于ArrayList,需要先创建一个迭代器容器,然后屏蔽内部遍历细节,对外提供hasNext、next等方法。问题是ArrayList实现了RandomAccess接口,已表明元素之间本来没有关系,可是,为了使用迭代器就需要强制建立一种互相"知晓"的关系,比如上一个元素可以判断是否有下一个元素,以及下一个元素是什么等关系,这也就是通过foreach遍历耗时的原因。
Java为ArrayList类加上了RandomAccess接口,就是在告诉我们,"嘿,ArrayList是随机存取的,采用下标方式遍历列表速度会更快",接着又有一个问题了:为什么不把RandomAccess加到所有的List实现类上呢?
那是因为有些List实现类不是随机存取的,而是有序存取的,比如LinkedList类,LinkedList也是一个列表,但它实现了双向链表,每个数据结点中都有三个数据项:前节点的引用(Previous Node)、本节点元素(NodeElement)、后继节点的引用(Next Node),这是数据结构的基本知识,不多讲了,也就是说在LinkedList中的 两个元素本来就是有关联的,我知道你的存在,你也知道我的存在。那大家想想看,元素之间已经有关联关系了,使用foreach也就是迭代器方式是不是效率更高呢?我们修改一下例子,代码如下:
java
public static void main(String[] args) {
// 学生数量, 80万
int stuNum = 80 * 10000;
// List集合,记录所有学生的分数
List<Integer> scores = new LinkedList<Integer>();
/*其他代码没有改变,不再赘述*/
}
public static int average(List<Integer> list) {
int sum = 0;
// foreach遍历求和
for (int i : list) {
sum += i;
}
// 除以人数,计算平均值
return sum / list.size();
}
运行的结果如下:
java
平均分是:74
执行时间:16ms
确实如此,也是16毫秒,效率非常高。可能大家还想要测试一下下标方式(也就是采用get方法访问元素)遍历LinkedList元素的情况,其实不用测试,效率真的非常低,我们直接看源码:
java
public E get(int index) {
return entry(index).element;
}
由entry方法查找指定下标的节点,然后返回其包含的元素,看entry方法:
java
private Entry<E> entry(int index) {
/*检查下标是否越界,代码不再拷贝*/
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;
}
看懂了吗?程序会先判断输入的下标与中间值(size右移一位,也就是除以2了)的关系,小于中间值则从头开始正向搜索,大于中间值则从尾节点反向搜索,想想看,每一次的get方法都是一个遍历,"性能"两字从何说起呢!
明白了随机存取列表和有序存取列表的区别,我们的average方法就必须重构了,以便实现不同的列表采用不同的遍历方式,代码如下:
java
public static int average(List<Integer> list) {
int sum = 0;
if (list instanceof RandomAccess) {
//可以随机存取,则使用下标遍历
for (int i = 0, size = list.size(); i < size; i++) {
sum += list.get(i);
}
} else {
//有序存取,使用foreach方式
for (int i : list) {
sum += i;
}
}
// 除以人数,计算平均值
return sum / list.size();
}
如此一来,列表的遍历就可以"以不变应万变"了,无论是随机存取列表还是有序列表,它都可以提供快速的遍历。
注意列表遍历不是那么简单的,其中很有"学问",适时选择最优的遍历方式,不要固化为一种。
68、频繁插入和删除时使用LinkedList
上一个建议介绍了列表的遍历方式,也就是"读"操作,本建议将介绍列表的"写"操作:即插入、删除、修改动作。
- (1)插入元素
列表中我们使用最多的是ArrayList,下面来看看它的插入(add方法)算法,源代码如下:
java
public void add(int index, E element) {
/*检查下标是否越界,代码不再拷贝*/
//若需要扩容,则增大底层数组的长度
ensureCapacity(size+1);
//给index下标之后的元素(包括当前元素)的下标加1,空出index位置
System.arraycopy(elementData, index, elementData, index + 1, size - index);
//赋值index位置元素
elementData[index] = element;
//列表长度+1
size++;
}
注意看arraycopy方法,只要是插入一个元素,其后的元素就会向后移动一位,虽然arraycopy是一个本地方法,效率非常高,但频繁的插入,每次后面的元素都要拷贝一遍,效率就变低了,特别是在头位置插入元素时。现在的问题是,开发中确实会遇到要插入元素的情况,那有什么更好的方法解决此效率问题吗?
有,使用LinkedList类即可。我们知道LinkedList是一个双向链表,它的插入只是修改相邻元素的next和previous引用,其插入算法(add方法)如下:
java
public void add(int index, E element) {
addBefore(element, (index==size ? header : entry(index)));
}
这里调用了私有addBefore方法,该方法实现了在一个元素之前插于元素的算法,代码如下:
java
private Entry<E> addBefore(E e, Entry<E> entry) {
//组装一个新节点,previous指向原节点的前节点,next指向原节点
Entry<E> newEntry = new Entry<E>(e, entry, entry.previous);
//前节点的next指向自己
newEntry.previous.next = newEntry;
//后节点的previous指向自己
newEntry.next.previous = newEntry;
//长度+1
size++;
//修改计数器+1
modCount++;
return newEntry;
}
这是一个典型的双向链表插入算法,把自己插入到链表,然后再把前节点的next和后节点的previous指向自己。想想看,这样一个插入元素(也就是Entry对象)的过程中,没有任何元素会有拷贝过程,只是引用地址改变了,那效率当然就高了。
经过实际测试得知,LinkedList的插入效率比ArrayList快50倍以上。
- (2)删除元素
插入了解清楚了,我们再来看删除动作。ArrayList提供了删除指定位置上的元素、删除指定值元素、删除一个下标范围内的元素集等删除动作,三者的实现原理基本相似,都是找到索引位置,然后删除。我们以最常用的删除指定下标的方法(remove方法)为例来看看删除动作的性能到底如何,源码如下:
java
public E remove(int index) {
//下标校验
RangeCheck(index);
//修改计数器+1
modCount++;
//记录要删除的元素值
E oldValue = (E) elementData[index];
//有多少个元素向前移动
int numMoved = size - index - 1;
if (numMoved > 0)
//index后的元素向前移动一位
System.arraycopy(elementData, index+1, elementData, index,numMoved);
//列表长度减1,并且最后一位设为null
elementData[--size] = null;
//返回删除的值
return oldValue;
}
注意看,index位置后的元素都向前移动了一位,最后一个位置空出来了,这又是一次数组拷贝,和插入一样,如果数据量大,删除动作必然会暴露出性能和效率方面的问题。ArrayList其他的两个删除方法与此相似,不再赘述。
我们再来看看LinkedList的删除动作。LinkedList提供了非常多的删除操作,比如删除指定位置元素、删除头元素等,与之相关的poll方法也会执行删除动作,下面来看最基本的删除指定位置元素的方法remove,源代码如下:
java
private E remove(Entry<E> e) {
//取得原始值
E result = e.element;
//前节点next指向当前节点的next
e.previous.next = e.next;
//后节点的previouse指向当前节点的previous
e.next.previous = e.previous;
//置空当前节点的next和previous
e.next = e.previous = null;
//当前元素置空
e.element = null;
//列表长度减1
size--;
//修改计数器+1
modCount++;
return result;
}
这也是双向链表的标准删除算法,没有任何耗时的操作,全部是引用指针的变更,效率自然高了。
在实际测试中得知,处理大批量的删除动作,LinkedList比ArrayList快40倍以上。
- (3)修改元素
写操作还有一个动作:修改元素值,在这一点上LinkedList输给了ArrayList,这是因为LinkedList是顺序存取的,因此定位元素必然是一个遍历过程,效率大打折扣,我们来看set方法的代码:
java
public E set(int index, E element) {
//定位节点
Entry<E> e = entry(index);
E oldVal = e.element;
//节点的元素替换
e.element = element;
return oldVal;
}
看似很简洁,但是这里使用了entry方法定位元素,在上一个建议中我们已经说明了LinkedList这种顺序存取列表的元素定位方式会折半遍历,这是一个极耗时的操作。而ArrayList的修改动作则是数组元素的直接替换,简单高效。
在修改动作上,LinkedList比ArrayList慢很多,特别是要进行大量的修改时,两者完全不在一个数量级上。
上面通过分析源码完成了LinkedList 与ArrayList之间的PK,其中LinkedList胜两局:删除和插入效率高;ArrayList胜一局:修改元素效率高。总体上来说,在"写"方面,LinkedList占优势,而且在实际使用中,修改是一个比较少的动作。因此,如果有大量的写操作(更多的是插入和删除动作),推荐使用LinkedList。不过何为少量,何为大量呢?
这就要依赖诸位正在开发的系统了,一个实时交易的系统,即使写作操再少,使用LinkedList也比ArrayList合适,因为此类系统是争分夺秒的,多N个毫秒可能就会造成交易数据不准确;而对于一个批量系统来说,几十毫秒、几百毫秒,甚至是几千毫秒的差别意义都不大,这时是使用LinkedList还是ArrayList就看个人爱好了,当然,如果系统已经处于性能临界点了那就必须使用LinkedList。
且慢,"写"操作还有一个增加(add方法)操作,为什么这里没有PK呢?那是因为两者在增加元素时性能上基本没有什么差别,区别只是在增加时LinkedList生成了一个Entry元素,其previous指向倒数第二个Entry,next置空;而ArrayList则是把元素追加到了数组中而已,两者的性能差别非常微小,不再讨论。
69、列表相等只需关心元素数据
我们来看一个判断列表相等的例子,代码如下:
java
public static void main(String[] args) {
ArrayList<String> strs = new ArrayList<String>();
strs.add("A");
Vector<String> strs2 = new Vector<String>();
strs2.add("A");
System.out.println(strs.equals(strs2));
}
两个类都不相同,一个是ArrayList,一个是Vectory,那结果肯定不相等了!真是这样吗?其实结果是两者相等!
我们来详细分析一下为什么两者是相等的。两者都是列表(List),都实现了List接口,也都继承了AbastractList抽象类,其equals方法是在AbstractList中定义的,我们来看源代码:
java
public boolean equals(Object o) {
if (o == this)
return true;
//是否是List列表,注意这里:只要实现list接口即可
if (!(o instanceof List))
return false;
//通过迭代器访问list的所有元素
ListIterator<E> e1 = listIterator();
ListIterator e2 = ((List) o).listIterator();
//遍历两个list的元素
while(e1.hasNext() && e2.hasNext()) {
E o1 = e1.next();
Object o2 = e2.next();
//只要存在着不相等就退出
if (!(o1==null ? o2==null : o1.equals(o2)))
return false;
}
//长度是否也相等
return !(e1.hasNext() || e2.hasNext());
}
看到没?这里只是要求实现了List接口就成,它不关心List的具体实现类。只要所有的元素相等,并且长度也相等就表明两个List是相等的,与具体的容量类型无关。也就是说,上面的例子中虽然一个是ArrayList,一个是Vector,只要里面的元素相等,那结果就是相等。
Java如此处理也确实是在为开发者考虑,列表只是一个容器,只要是同一种类型的容器(如List),不用关心容器的细节差别(如ArrayList与LinkedList),只要确定所有的元素数据相等,那这两个列表就是相等的。如此一来,我们在开发中就不用太关注容器细节了,可以把注意力更多地放在数据元素上,而且即使在中途重构容器类型,也不会对相等的判断产生太大的影响。
其他的集合类型,如Set、Map等与此相同,也是只关心集合元素,不用考虑集合类型。
注意判断集合是否相等时只须关注元素是否相等即可。
70、子列表只是原列表的一个视图
List接口提供了subList方法,其作用是返回一个列表的子列表,这与String类的subString有点类似,但它们的功能是否相同呢?我们来看如下代码:
java
public static void main(String[] args) {
//定义一个包含两个字符串的列表
List<String> c = new ArrayList<String>();
c.add("A");
c.add("B");
//构造一个包含c列表的字符串列表
List<String> c1 = new ArrayList<String>(c);
//subList生成与c相同的列表
List<String> c2 = c.subList(0, c.size());
//c2增加一个元素
c2.add("C");
System.out.println("c == c1? " + c.equals(c1));
System.out.println("c == c2? " + c.equals(c2));
}
c1是通过ArrayList的构造函数创建的,c2是通过列表的subList方法创建的,然后c2又增加了一个元素C,现在的问题是输出的结果是什么呢?列表c与c1、c2之间是什么关系呢?别忙着回答这个问题,我们先来回想一下String类的subString方法,看看它是如何工作的,代码如下:
java
public static void main(String[] args) {
String str = "AB";
String str1 = new String(str);
String str2 = str.substring(0) + "C";
System.out.println("str == str1? " + str1.equals(str1));
System.out.println("str == str2? " + str.equals(str2));
}
很明显,str与str1是相等的(虽然不是同一个对象,但用equals方法判断是相等的),但它们与str2不相等,这毋庸置疑,因为str2在对象池中重新生成了一个新的对象,其表面值是ABC,那当然与str和str1不相等了。
说完了subString的小插曲,现在回到List是否相等的判断上来。subList与subString的输出结果是一样的吗?让事实说话,运行结果如下:
java
c == c1? false
c == c2? true
很遗憾,与String类刚好相反,同样是一个sub类型的操作,为什么会相反呢?仅仅回答"为什么"似不足以平复我们的惊讶,下面就从最底层的源代码来进行分析。
c2是通过subList方法从c列表中生成的一个子列表,然后c2又增加了一个元素,可为什么增加了一个元素还会相等呢?我们来看subList源码:
java
public List<E> subList(int fromIndex, int toIndex) {
return (this instanceof RandomAccess ?
new RandomAccessSubList<E>(this, fromIndex, toIndex) :
new SubList<E>(this, fromIndex, toIndex));
}
subList方法是由AbstractList实现的,它会根据是不是可以随机存取来提供不同的SubList实现方式,不过,随机存储的使用频率比较高,而且RandomAccessSubList也是SubList子类,所以所有的操作都是由SubList类实现的(除了自身的SubList方法外),那么,我们就直接来看SubList类的代码:
java
class SubList<E> extends AbstractList<E> {
//原始列表
private AbstractList<E> l;
//偏移量
private int offset;
//构造函数,注意list参数就是我们的原始列表
SubList(AbstractList<E> list, int fromIndex, int toIndex) {
/*下标校验,省略*/
//传递原始列表
l = list;
offset = fromIndex;
//子列表的长度
size = toIndex - fromIndex;
}
//获得指定位置的元素
public E get(int index) {
/*校验部分,省略*/
//从原始字符串中获得指定位置的元素
return l.get(index+offset);
}
//增加或插入
public void add(int index, E element) {
/*校验部分,省略*/
//直接增加到原始字符串上
l.add(index+offset, element);
/*处理长度和修改计数器*/
}
/*其他方法省略*/
}
通过阅读这段代码,我们就非常清楚subList方法的实现原理了:它返回的SubList类也是AbstractList的子类,其所有的方法如get、set、add、remove等都是在原始列表上的操作,它自身并没有生成一个数组或是链表,也就是子列表只是原列表的一个视图(View),所有的修改动作都反映在了原列表上。
我们例子中的c2增加了一个元素C,不过增加的元素C到了c列表上,两个变量的元素仍保持完全一致,相等也就很自然了。
解释完相等的问题,再回过头来看看为什么变量c与c1不相等。很简单,因为通过ArrayList构造函数创建的List对象c1实际上是新列表,它是通过数组的copyOf动作生成的,所生成的列表c1与原列表c之间没有任何关系(虽然是浅拷贝,但元素类型是String,也就是说元素是深拷贝的),然后c又增加了元素,因为c1与c之间已经没有一毛钱的关系了,那自然是不相等了。
注意subList产生的列表只是一个视图,所有的修改动作直接作用于原列表。
71、推荐使用subList处理局部列表
我们来看这样一个简单的需求:一个列表有100个元素,现在要删除索引位置为20~30的元素。这很简单,一个遍历很快就可以完成,代码如下:
java
public static void main(String[] args) {
//初始化一个固定长度,不可变列表
List<Integer> initData= Collections.nCopies(100, 0);
//转换为可变列表
List<Integer> list = new ArrayList<Integer>(initData);
//遍历,删除符合条件的元素
for(int i=0,size=list.size();i<size;i++){
if(i>=20 && i<30){
list.remove(i);
}
}
}
或者
java
for (int i = 20; i < 30; i++) {
if(i<list.size()){
list.remove(i);
}
}
相信首先出现在大家脑海中的实现就是此算法了,遍历一遍,符合条件的就删除,简单而又实用。不过,还有没有其他方式呢?有没有"one-lining"一行代码就解决问题的方式呢?
有,直接使用ArrayList的removeRange方法不就可以了吗?等等,好像不可能呀,虽然JDK上有此方法,但是它有protected关键字修饰着,不能直接使用,那怎么办?看看如下代码。
java
public static void main(String[] args) {
//初始化一个固定长度,不可变列表
List<Integer> initData= Collections.nCopies(100, 0);
//转换为可变列表
ArrayList<Integer> list = new ArrayList<Integer>(initData);
//删除指定范围的元素
list.subList(20, 30).clear();
}
上一个建议讲解了subList方法的具体实现方式,所有的操作都是在原始列表上进行的,那我们就用subList先取出一个子列表,然后清空。因为subList返回的List是原始列表的一个视图,删除这个视图中的所有元素,最终就会反映到原始字符串上,那么一行代码即解决问题了。