Java****集合框架源码分析
本次源码分析对Java JDK中的集合框架部分展开分析,采用的是JDK 1.8.0_171版本的源码。
Java集合框架(Java Collections Framework,JCF)也称容器,即可以容纳其他Java对象的对象。JCF为开发者提供了通用的容器,数据持有对象的方式和对数据集合的操作,优点是:
1) 降低编程难度
2) 提高程序性能
3) 提高API间的互操作性
4) 降低学习难度
5) 降低设计和实现相关API的难度
6) 增加程序的可重用性
Java容器中只能存放对象,对于基本类型(int,double,float,long等),需要将其包装成对象类型后(Integer,Double,Float,Long等)才能放到容器里。很多时候装箱和拆箱都能够自动完成。这虽然会导致额外的性能和空间开销,但简化了设计和编程。
**1.**总体架构分析
为了规范容器的行为,统一设计,JCF定义了14种容器接口(Collection interface),它们的关系如下图所示:
Map接口没有继承自Collection接口,因为Map表示的是关联式的容器而不是集合,但Java提供了从Map转换到Collection的方法,可以方便地将Map切换到集合视图。上图中提供了Queue接口,但没有Stack,因为Stack的功能已被JDK 1.6版本引入的Deque取代。上述接口的通用实现如下表:
Implementations
Hash Table
Resizable Array
Balanced Tree
Linked List
Hash Table
+Linked List
Inter
faces
Set
HashSet
TreeSet
LinkedHashSet
List
ArrayList
LinkedList
Deque
ArrayDeque
LinkedList
Map
HashMap
TreeMap
LinkedHashMap
总体上来说,从下面的框架图可以看出,集合框架主要包括两种类型的容器,一种是集合(Collection),存储一个元素集合,另一种是图(Map),存储键/值对映射。Collection接口又有三种子类型,List,Set和Queue。再下面是一些抽象类,最后是一些具体实现类。
下面对部分具体实现类的实现做简单描述:
1) ArrayList:线程不同步,默认初始容量为10,当数组大小不足时容量扩大为1.5倍。为追求效率,ArrayList没有实现同步(synchronized),如果需要多个线程并发访问,用户需要手动实现同步,或者用Vector代替。
2) LinkedList:线程不同步,双向链表实现。LinkedList同时实现了List接口和Deque接口,所以既可以看成一个顺序容器,又可以看成一个队列,或者也可以当作一个栈。官方声明不建议使用Stack类,且也没有Queue的实现(只是接口),所以可以考虑用LinkedList来当作栈使用。首选的栈或队列实现,还是ArrayDeque,性能更好。
3) Vector:线程同步,默认初始容量为10,当数组大小不足时容量扩大为2倍。他的同步是通过Iterator方法加synchronized实现的。
4) TreeSet:线程不同步,内部使用NavigableMap操作。默认元素自然顺序排列,可以通过Comparator改变排序。TreeSet里面有一个TreeMap(适配器模式)。
5) HashSet:线程不同步,内部使用HashMap进行数据存储,提供的方法基本都是调用HashMap的方法,两者本质上是相同的。集合元素可以为null。
6) Set:Set是一种不包含重复元素的集合,最多只有一个null元素。且Set集合通常可以通过Map集合通过适配器模式得到。
7) PriorityQueue:PriorityQueue实现了Queue接口,不允许放入null元素,其通过堆实现,即通过完全二叉树实现小顶堆(任意一个非叶子节点的权值,都不大于其左右子节点的权值),也就意味着可以通过数组来作为其底层实现。
8) TreeMap:线程不同步,基于红黑树的NavigableMap实现,能够把它保存的记录根据键排序,默认是按照键值的升序排序,也可以指定排序的比较器。当用Iterator遍历TreeMap时,得到的记录是排过序的。
9) HashMap:线程不同步,根据key的hashcode进行存储,内部使用静态内部类Node的数组进行存储,默认初始大小为16,每次扩容一倍。当发生Hash冲突时,采用拉链法来解决。在1.8版本的JDK中,当单个桶中元素个数大于等于8时,链表改为红黑树实现;当元素个数小于6时,变回链表实现,由此来防止hashcode攻击。HashMap是HashTable的轻量级实现,可以接受null的key和value,而HashTable是不允许的。
10) LinkedHashMap:保存了记录的插入顺序,在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的。也可以在构造时带上参数,按照使用的次数排序,在遍历的时候会比HashMap慢。不过有例外情况,当HashMap容量很大,实际数据较少时,遍历起来可能会比LinkedHashMap慢,因为后者遍历速度只与实际数据量有关,和容量无关。
11) HashTable:线程安全,HashTable的迭代器是fail-fast迭代器。HashTable不能存储null的key和value。
12) Collections、Arrays:集合类的工具帮助类,提供了一系列静态方法,用于对集合中元素进行排序、搜索以及线程安全等各种操作。
13) Comparable、Comparator:一般是用于对象的比较来实现排序,略有区别。
**2.**源码分析
2.1 ArrayList
ArrayList实现了List接口,是顺序容器,即元素存放的数据与放入的顺序相同。允许放入null元素,底层通过数组实现。除未实现同步外,其余和Vector大致相同。每个ArrayList都有一个容量(capacity),表示底层数组的实际大小,容器内存储元素的个数不能多于当前容量,容量不足自动扩容1.5倍。Size(),isEmpty(),get(),set()方法均能在常数时间内完成,add()方法的时间开销和插入位置有关,addAll()方法的时间开销跟添加元素的个数成正比,其余方法大多是线性时间。
2.1.1 set()
由于底层是一个数组,set()方法也就变得非常简单,直接对数组的指定位置赋值即可。RangeCheck(index)用于检查下标是否越界,需要注意赋值语句仅仅是引用的复制。
1 public E set(int index, E element) {
2 rangeCheck(index);
3 E oldValue = elementData(index);
4 elementData[index] = element;
5 return oldValue;
6 }
2.1.2 get()
Get()方法也很简单,去对应下标位置获取元素即可,唯一要注意的是由于底层数组是Object[],得到元素后需要进行类型转换。
1 public E get(int index) {
2 rangeCheck(index);
3 return elementData(index);
4 }
2.1.3 add(int index,E element)
和C++的vector不同,ArrayList没有push_back()方法,对应的是add(E e),也没有insert()方法,对应的是add(int index,E element)。这两个方法都是向容器中添加新元素,可能会导致capacity不足,因此在添加元素之前,都需要进行剩余空间检查。通过ensureCapacityInternal()方法判断,该方法内部继续嵌套调用,计算容量时,若全局数组为空,则返回传入参数和default capacity(为10)中较大的那个,若minCapacity较大,然后根据两者的差值调用grow()方法完成扩容。由源码中看出,对数组的扩容首先通过扩展为原来的1.5倍,然后和minCapacity比较大小,之后也会判断容量是否过大,设置超大容量,最后扩展和复制。由于Java GC自动管理了内存,也就不需要考虑源数组释放的问题。空间扩容后,插入过程也就变得容易,需要先对元素进行移动,然后完成插入操作,所以该方法为线性复杂度。
1 public void add(int index, E element) {
2 rangeCheckForAdd(index);
3 ensureCapacityInternal(size + 1); // Increments modCount!!
4 System.arraycopy(elementData, index, elementData, index + 1, size - index);
5 elementData[index] = element;
6 size++;
7 }
1 private void ensureCapacityInternal(int minCapacity) {
2
3 ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
4
5 }
6
7
8 private void ensureExplicitCapacity(int minCapacity) {
9
10 modCount++;
11
12 // overflow-conscious code
13
14 if (minCapacity - elementData.length > 0)
15
16 grow(minCapacity);
17
18 }
19
20
21 private void grow(int minCapacity) {
22
23 // overflow-conscious code
24
25 int oldCapacity = elementData.length;
26
27 int newCapacity = oldCapacity + (oldCapacity >> 1);
28
29 if (newCapacity - minCapacity < 0)
30
31 newCapacity = minCapacity;
32
33 if (newCapacity - MAX_ARRAY_SIZE > 0)
34
35 newCapacity = hugeCapacity(minCapacity);
36
37 // minCapacity is usually close to size, so this is a win:
38
39 elementData = Arrays.copyOf(elementData, newCapacity);
40
41 }
2.1.4 remove()
Remove()方法有两个版本,一个如下所示,从指定位置删除元素,另一个是remove(Object o),删除第一个满足o.equals(elementData[index])的元素。删除操作是add()操作的逆过程,需要将删除节点之后的元素向前移动一个位置。需要注意的是为了让GC起作用,必须显式地为最后一个位置赋null值。
1 public E remove(int index) {
2
3 rangeCheck(index);
4
5 modCount++;
6
7 E oldValue = elementData(index);
8
9 int numMoved = size - index - 1;
10
11 if (numMoved > 0)
12
13 System.arraycopy(elementData, index+1, elementData, index,numMoved);
14
15 elementData[--size] = null; // clear to let GC do its work
16
17 return oldValue;
18
19 }
由于各个不同的集合类和结构大部分都是底层实现方式不同,而功能基本相同,所以这里仅分析ArrayList一例。
2.2 容器中的设计模式
2.2.1 迭代器模式
Collection实现了Iterable接口,其中的iterator()方法能够产生一个Iterator对象,通过这个对象就可以迭代遍历Collection中的元素。从JDK1.5之后可以使用foreach方法来遍历实现了Iterable接口的聚合对象。
1 List<String> list = new ArrayList<>();
2
3 list.add("a");
4
5 list.add("b");
6
7 for (String item : list) {
8
9 System.out.println(item);
10
11 }
2.2.2 适配器模式
Java.util.Arrays类中的asList()方法可以把数组类型转换为List类型,如果要将数组类型转换为List类型,应该注意的是asList()的参数为泛型的变长参数,因此不能使用基本类型作为参数,只能使用相应的包装类型数组。这里数组类型和List类型的转换就是一种典型的适配器模式,作为两个不兼容的接口之间的桥梁,结合了其功能。
1 public static <T> List<T> asList(T... a) {
2
3 return new ArrayList<>(a);
4
5 }
通常情况下,客户端可以通过目标类的接口访问它所提供的服务。有时,现有的类可以满足客户类的功能需要,但是它所提供的接口不一定是客户类所期望的,这可能是因为现有类中方法名与目标类中定义的方法名不一致等原因所导致的。
在这种情况下,现有的接口需要转化为客户类期望的接口,这样保证了对现有类的重用。如果不进行这样的转化,客户类就不能利用现有类所提供的功能,适配器模式可以完成这样的转化。
在适配器模式中可以定义一个包装类,包装不兼容接口的对象,这个包装类指的就是适配器(Adapter),它所包装的对象就是适配者(Adaptee),即被适配的类。
适配器提供客户类需要的接口,适配器的实现就是把客户类的请求转化为对适配者的相应接口的调用。也就是说:当客户类调用适配器的方法时,在适配器类的内部将调用适配者类的方法,而这个过程对客户类是透明的,客户类并不直接访问适配者类。因此,适配器可以使由于接口不兼容而不能交互的类可以一起工作。这就是适配器模式的模式动机。
**3.违反/**符合高质量代码案例
3.1 使用静态内部类提高封装性(符合)
LinkedList.java 970-980行
1 private static class Node<E> {
2
3 E item;
4
5 Node<E> next;
6
7 Node<E> prev;
8
9
10
11 Node(Node<E> prev, E element, Node<E> next) {
12
13 this.item = element;
14
15 this.next = next;
16
17 this.prev = prev;
18
19 }
20
21 }
静态内部类的优点是加强了类的封装和提高代码的可读性。Node类封装了链表中该节点前后节点的信息,不需要在LinkedList中全部定义所有节点,只需声明first和last Node,即可将所有节点相互连接,形成链表,提高了封装性。静态内部类体现了Node和LinkedList之间的强关联关系,增强了语意和可读性。从代码结构看,静态内部类放置在外部类内,在这里表示Node类是LinkedList类的子行为或自属性。与普通内部类相比,静态内部类不持有外部类的引用:在普通内部类中,我们可以直接访问外部类的属性,方法,即使是private类型也可以访问,这是因为持有引用,可以自由访问。而静态内部类,只可以访问外部类的静态方法和静态属性,其他则不能。其次,静态内部类不依赖外部类,内部实例可以脱离外部类实例单独存在。而内部类则与外部类共同生死,一起声明,一起被垃圾回收。第三,普通内部类不能声明static方法和变量,而静态内部类则没有任何限制。方便静态方法和变量扩展,具有优越性。
3.2 equals应该考虑null****值情况(违反)
LinkedList.java 595-610行
1 public int indexOf(Object o) {
2
3 int index = 0;
4
5 if (o == null) {
6
7 for (Node<E> x = first; x != null; x = x.next) {
8
9 if (x.item == null)
10
11 return index;
12
13 index++;
14
15 }
16
17 } else {
18
19 for (Node<E> x = first; x != null; x = x.next) {
20
21 if (o.equals(x.item))
22
23 return index;
24
25 index++;
26
27 }
28
29 }
30
31 return -1;
32
33 }
如果传入的Object对象o是null,且未经过检查判断,则在调用o.equals()方法时,就会出现空指针异常。出现这种情况的原因是因为equals()方法或者对其的覆写未遵循对称性原则,对于任何引用x和y的情形,如果x.equals(y)返回true,则y.equals(x)也应该返回true。所以最好的解决办法就是在覆写equals()方法时,加上对null值的判断,保证对称性原则,否则就需要像上述方法一样在调用equals()方法之前完成对null值的判断。
3.3 使用序列化类的私有方法解决部分属性持久化问题(符合)
ArayList.java 135行,755-800行
1 transient Object[] elementData;
2
3
4 private void writeObject(java.io.ObjectOutputStream s)
5
6 throws java.io.IOException{
7
8 // Write out element count, and any hidden stuff
9
10 int expectedModCount = modCount;
11
12 s.defaultWriteObject();
13
14
15
16 // Write out size as capacity for behavioural compatibility with clone()
17
18 s.writeInt(size);
19
20
21
22 // Write out all elements in the proper order.
23
24 for (int i=0; i<size; i++) {
25
26 s.writeObject(elementData[i]);
27
28 }
29
30
31
32 if (modCount != expectedModCount) {
33
34 throw new ConcurrentModificationException();
35
36 }
37
38 }
39
40
41 private void readObject(java.io.ObjectInputStream s)
42
43 throws java.io.IOException, ClassNotFoundException {
44
45 elementData = EMPTY_ELEMENTDATA;
46
47
48
49 // Read in size, and any hidden stuff
50
51 s.defaultReadObject();
52
53
54
55 // Read in capacity
56
57 s.readInt(); // ignored
58
59
60
61 if (size > 0) {
62
63 // be like clone(), allocate array based upon size not capacity
64
65 int capacity = calculateCapacity(elementData, size);
66
67 SharedSecrets.getJavaOISAccess().checkArray(s, Object[].class, capacity);
68
69 ensureCapacityInternal(size);
70
71
72
73 Object[] a = elementData;
74
75 // Read in all elements in the proper order.
76
77 for (int i=0; i<size; i++) {
78
79 a[i] = s.readObject();
80
81 }
82
83 }
84
85 }
序列化是将Java对象以一种形式持久化,如存放到硬盘,或者用于传输,反序列化是其逆过程。ArrayList也实现了Serializable接口来保证序列化。经过源码分析可知其数据存储都依赖于elementData数组,但注意该数组被transient关键字修饰。说明设计者认为该数组不需要持久化,通常部分持久化的原因是有一些属性为敏感信息,为了安全起见,不希望在网络操作中传输或本地序列化缓存。即elementData数组的生命周期仅存于调用者的内存中而不会写到磁盘中。
那么数组元素怎样序列化呢?即遵循序列化的流程,通过调用ObjectOutputStream对象输出流的writeObject()方法写入对象状态信息,然后就可以通过readObject()方法读取信息来反序列化。而对于elementData,defaultWriteObject()并不会去持久化被transient修饰的它,这里需要重写writeObject()和readObject()方法来手动持久化,通过循环将其中元素的值取出来,然后依次写入输出流。虽然重写的两个方法为private,看起来不能调用,但实际上ObjectOutputStream会调用这个类的writeObject()方法,read也同理,通过反射机制来完成判断一个类是否重写了方法,根据传入的ArrayList对象得到class,然后包装成ObjectStreamClass,在writeSerialData方法里,会调用ObjectStreamClass的invokeWriteObject方法。
defaultReadObject和defaultWriteObject应该是readObject(ObjectInputStream o)和writeObject(ObjectOutputStream o)内部的第一个方法调用。它分别读取和写入该类的所有非瞬态字段。这些方法还有助于向后和向前的兼容性。如果将来在类中添加一些非瞬态字段,并尝试通过旧版本的类对它进行反序列化,则defaultReadObject()方法将忽略新添加的字段,类似地,如果您使用新的序列化对旧的序列化对象进行反序列化版本,则新的非瞬态字段将采用JVM的默认值,即,如果其对象为null,则为null,否则将其从boolean设置为false,将int设置为0,等等。
那么最终也对elementData元素进行了序列化,为什么要将其设置为瞬态字段呢?因为ArrayList的自动扩容机制中,elementData数组相当于容器,容器不足时就会扩容,容量往往大于等于所存元素的个数。直接序列化会造成元素空间的浪费,特别是元素个数很多的情况,这种浪费非常不划算。由此,这样做的目的是为了只序列化实际存储的元素,节省资源。
3.4 在接口中存在实现代码(违反)
List.java 475-484行
1 @SuppressWarnings({"unchecked", "rawtypes"})
2
3 default void sort(Comparator<? super E> c) {
4
5 Object[] a = this.toArray();
6
7 Arrays.sort(a, (Comparator) c);
8
9 ListIterator<E> i = this.listIterator();
10
11 for (Object e : a) {
12
13 i.next();
14
15 i.set((E) e);
16
17 }
18
19 }
一般而言,接口是一种契约,一种框架性协议,他可以声明常量,声明抽象方法,也可以继承接口,但不能有具体实现。这表明其实现类都是同一种类型,或者是具备相似特征的一个集合体,其约束着实现者,保证提供的服务是稳定的、可靠的。如果把实现代码写到接口中,那接口就绑定了可能变化的因素,随时都有可能被抛弃,被更改,被重构。所以,接口中虽然可以有实现,但应避免使用。所以合理的修改方式是,在List的子类中分别去实现sort方法。
但Java 8的新特性中,推出了默认方法(default methods),简单来说,就是可以在接口中定义一个已实现的方法,且该接口的实现类不需要实现该方法。这么做的好处是为了方便扩展已有接口。如果没有默认方法,假如给JDK中的某个接口添加一个新的抽象方法,那么所有实现了该接口的类都得修改,影响很大。
但使用默认方法,可以给已有接口添加新方法,而不用修改该接口的实现类。当然,接口中新添加的默认方法,所有实现类也会继承。这样降低了接口与实现类之间的耦合度。
这样来看,Java 8的新特性和高质量代码规范产生了矛盾,所以还是要根据实际情况来分析,默认方法实现存在的必要性,使得工作更加高效。
3.5 警惕数组的浅拷贝(违反)
ArrayList.java 353-363行
1 public Object clone() {
2
3 try {
4
5 ArrayList<?> v = (ArrayList<?>) super.clone();
6
7 v.elementData = Arrays.copyOf(elementData, size);
8
9 v.modCount = 0;
10
11 return v;
12
13 } catch (CloneNotSupportedException e) {
14
15 // this shouldn't happen, since we are Cloneable
16
17 throw new InternalError(e);
18
19 }
20
21 }
22
23
24 class Person implements Serializable{
25
26 private int age;
27
28 private String name;
29
30
31
32 public Person(){};
33
34 public Person(int age,String name){
35
36 this.age=age;
37
38 this.name=name;
39
40 }
41
42
43
44 public String toString(){
45
46 return this.name+"-->"+this.age;
47
48 }
49
50 }
举例来说,对于上面这个JavaBean,通过new Person()构建3个对象,然后添加到第一个ArrayList,然后通过遍历循环复制,添加到另一个ArrayList,在调用add方法时,并没有new Person()操作。因此,通过set方法修改属性时,会破坏源数据,两个ArrayList都会收到影响,原因是浅拷贝;同样,使用ArrayList的构造方法来复制几个内容,同样是浅拷贝。在clone()方法中的Arrays.copy()和System.arraycopy()也是不能对集合进行深拷贝的。
1 public static <T> List<T> deepCopy(List<T> src) throws IOException, ClassNotFoundException {
2
3 ByteArrayOutputStream byteOut = new ByteArrayOutputStream();
4
5 ObjectOutputStream out = new ObjectOutputStream(byteOut);
6
7 out.writeObject(src);
8
9
10
11 ByteArrayInputStream byteIn = new ByteArrayInputStream(byteOut.toByteArray());
12
13 ObjectInputStream in = new ObjectInputStream(byteIn);
14
15 @SuppressWarnings("unchecked")
16
17 List<T> dest = (List<T>) in.readObject();
18
19 return dest;
20
21 }
那么通过实现Serializable接口后,使用序列化的方式可以实现深拷贝,如上所示。因为在序列化的过程中,我们取出了原List中的值,并将其传给了新的对象,那么两个List所含的对象指向的地址空间就不同了,所以是深拷贝,可以解决上述例子中Person()类属性修改则全部同时修改的问题。
3.6 显示声明UID(符合)
ArrayList.java 107-110行
1 public class ArrayList<E> extends AbstractList<E>
2
3 implements List<E>, RandomAccess, Cloneable, java.io.Serializable
4
5 {
6 private static final long serialVersionUID = 8683452581122892189L;7 }
类实现Serializable接口的目的是为了可持久化,比如网络传输或本地存储。需要考虑的一个问题是,如果消息的生产者和消息的消费者所参考的类有差异,比如生产者中的类增加一个年龄属性,而消费者没有增加属性。因为这是一个分布式部署的应用,你甚至都不知道这个应用部署在何处,特别是通过广播(broadcast)方式发消息的情况,漏掉一两个订阅者非常正常。此时,不一致的情况会导致InvalidClassException异常,原因是序列化和反序列化所对应的类版本发生了变化,JVM不能把数据流转换为实例对象。JVM是通过SerialVersionUID来标识类的版本定义的,显示声明后,在反序列化时,就会比较UID是否相同,来确保类没有发生改变,否则不执行反序列化过程。这是一个很好的校验机制,保证类和对象数据的一致性。
3.7 基本类型数组转换列表陷阱(违反)
Arrays.java 3799-3801行
1 public static <T> List<T> asList(T... a) {
2
3 return new ArrayList<>(a);
4
5 }
开发过程中经常会使用Arrays和Collections这两个工具类在数组和列表之间转换,但也会有一些奇怪的问题。
1 public class Client65 {
2
3 public static void main(String[] args) {
4
5 int data [] = {1,2,3,4,5};
6
7 List list= Arrays.asList(data);
8
9 System.out.println("列表中的元素数量是:"+list.size());
10
11 }
12
13 }
如上代码所示,理所当然地认为list元素数量是5,但实际打印为1。为什么通过asList方法转换后就只有一个元素了呢?从该方法源码可得,输入一个变长参数,返回一个固定长度的列表。我们知道基本类型是不能泛型化的,即8个基本类型不能作为该方法的泛型参数,必须使用其包装类型。但例子中传入一个int类型的数组却没有报错。因为Java中,数组可以作为一个对象,可以泛型化,所以这里我们把int数组作为了一个T类型,转换时就只有一个int数组类型的元素了。打印list.get(0).getClass()发现,元素类型是class[I。JVM不可能输出Array类型,因为其属于java.lang.reflect包,是通过反射访问数组元素的工具类。在java中任何一个一位数组的类型都是[I,原因是Java并没有定义数组这一个类,是编译器编译时产生的。
所以解决方案,一是通过程序员在调用asList方法前,封装基本对象类型数组,二则是在该方法内部,首先判断是否为基本类型,对基本类型进行装箱处理,然后传给new ArrayList()返回给用户。所以说asList这个方法的陷阱,不太优雅,容易导致程序逻辑混乱。
3.8 数组的真实类型必须为泛型类型的子类型
ArrayList.java 408-416行
1 public <T> T[] toArray(T[] a) {
2
3 int size = size();
4
5 if (a.length < size)
6
7 return Arrays.copyOf(this.a, size,
8
9 (Class<? extends T[]>) a.getClass());
10
11 System.arraycopy(this.a, 0, a, 0, size);
12
13 if (a.length > size)
14
15 a[size] = null;
16
17 return a;
18
19 }
List接口的toArray方法可以把一个集合转化为数组,但是使用不方便,返回的是一个Object数组,所以需要自行转变。ToArray(T[] a)虽然返回的是T类型的数组,但还需要传入一个T类型的数组,相当麻烦。我们期望输入的是一个泛型化的List,这样就可以转化为泛型数组。
1 public static <T> T[] toArray(List<T> list) {
2
3 T[] t = (T[]) new Object[list.size()];
4
5 for (int i = 0, n = list.size(); i < n; i++) {
6
7 t[i] = list.get(i);
8
9 }
10
11 return t;
12
13 }
如上所示,对输出的Object数组转型为T类型数组,之后遍历List赋值给数组的每个元素。使用List<String>作为传入参数,调用该方法时,编译可以通过,但运行异常,显示类型转换异常,也就是说不能把一个Object数组转化为String类型数组。问题在于,为什么Object数组不能向下转型为String数组,因为数组是容器,只有确保容器内元素类型和期望的类型有父子关系时才能转换,Object数组只能保证数组内的元素是Object类型,不能保证他们都是String的父类型或子类型,所以转换失败。另一个问题是,抛出异常的位置在main方法,而不是toArray方法,按理来说在toArray方法中进行类型的向下转换,而不是main方法,但却在main方法抛异常。原因是编译时泛型擦除,转化时并不是把Object转换为String类型,而是Object转Object,完全没有必要。所以在main方法中,为了能够实现对String数组的遍历,就需要类型转换,此时出现异常。
1 public static <T> T[] toArray(List<T> list,Class<T> tClass) {
2
3 //声明并初始化一个T类型的数组
4
5 T[] t = (T[])Array.newInstance(tClass, list.size());
6
7 for (int i = 0, n = list.size(); i < n; i++) {
8
9 t[i] = list.get(i);
10
11 }
12
13 return t;
14
15 }
所以为了能实现上述需求,把泛型数组声明为泛型的子类型即可。通过反射类Array声明了一个T类型的数组,由于我么你无法在运行期获得泛型类型参数,只能通过调用者主动传入T参数类型。
在这里我们看到,当一个泛型类(特别是泛型集合)转变为泛型数组时,泛型数组的真实类型不能是泛型的父类型(比如顶层类Object),只能是泛型类型的子类型(当然包括自身类型),否则就会出现类型转换异常。源码的设计给使用者造成了一定的困扰。
3.9 受检异常尽可能转化为非受检异常(遵守)
ArrayList.java 655-666行
1 private void rangeCheck(int index) {
2
3 if (index >= size)
4
5 throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
6
7 }
8
9
10
11 /**
12
13 * A version of rangeCheck used by add and addAll.
14
15 */
16
17 private void rangeCheckForAdd(int index) {
18
19 if (index > size || index < 0)
20
21 throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
22
23 }
受检异常是正常逻辑的一种补偿手段,特别是对于可靠性要求较高的系统。在某些条件下必须抛出受检异常以便由城西进行补偿处理。
但受检异常使接口声明脆弱,OOP要求尽量多地面向接口编程,可提高代码的扩展性、稳定性,但是涉及异常问题就不一样了。随着系统的开发,会有很多的实现类,他们通常需要抛出不同的异常。异常是对主逻辑的补充,但修改一个补充逻辑,会导致主逻辑也被修改,即实现类逆影响接口的情景。我们知道实现类是不稳定的,而接口是稳定的,一旦定义了异常,则增加了接口的不稳定性,实现的变更会最终影响到调用者,破坏了封装性,是违背迪米特法则的。
受检异常也使得代码的可读性降低,一个方法增加受检异常,则必须有一个调用者对异常进行处理。多个catch块捕获处理多个异常,大大降低代码的可读性。
当受检异常威胁到了系统的安全性,稳定性,可靠性、正确性时,则必须处理,不能转化为非受检异常,其它情况则可以转化为非受检异常。
3.10 合理使用显示锁Lock(遵守)
CopyOnWriteArrayList.java 434-447行
1 public boolean add(E e) {
2
3 final ReentrantLock lock = this.lock;
4
5 lock.lock();
6
7 try {
8
9 Object[] elements = getArray();
10
11 int len = elements.length;
12
13 Object[] newElements = Arrays.copyOf(elements, len + 1);
14
15 newElements[len] = e;
16
17 setArray(newElements);
18
19 return true;
20
21 } finally {
22
23 lock.unlock();
24
25 }
26
27 }
很多程序员会认为,Lock类和synchronized关键字在代码块的并发性和内存上时语义是一样的,都是保持代码块同时只有一个线程具有执行权。但实际情况是,通过一个例子模拟,发现synchronized内部锁保证了只有一个线性的运行权,其他等待执行;而Lock显示锁未出现互斥情况。在例子中同步资源是代码块,前者是类级别的锁,而后者是对象级别的锁。简单来说,把Lock定义为多线程类的私有属性是起不到资源互斥作用的,除非把Lock定义为所有线程共享变量。
所以,这样来看,Lock支持更细粒度的锁控制,假设读写锁分离,写操作不允许有读写操作同时存在,而读操作时读写可并发执行,这样的需求内部锁很难实现,而Lock可以。
为了获得线程安全的ArrayList,可以使用concurrent并发包下的CopyOnWriteArrayList类,这是一个CopyOnWrite容器,读取元素是从原数组读取的,添加元素是在复制的新数组上。读写分离,因而可以在并发条件下进行不加锁的读取,读取效率高,适用于读操作远大于写操作的场景。这里Lock显示锁的优越性就体现出来了,所以合理地使用Lock和synchronized可以提高程序性能,实现不同的需求,也可以使得代码具有高质量。
参考:
[1] https://www.cnblogs.com/CarpenterLee/p/5545987.html
[3]https://www.cnblogs.com/selene/default.html?page=3 编写高质量代码:改善Java程序的151个建议
[4]https://www.cnblogs.com/chenpi/p/5897713.html Java 8默认方法
[5]https://www.cnblogs.com/aoguren/p/4767309.html ArrayList序列化技术细节详解