源码阅读(3):Java中主要的List结构——ArrayList集合(上)
置顶 2019年06月20日 23:09:08 说好不能打脸 阅读数 1491
(接上文《源码阅读(2):Java中主要的List结构——Vector集合》)
4.java.util.ArrayList结构解析
ArrayList拥有与Vector类似的接口和操作逻辑(JDK1.2+提供),不过它不支持线程安全的操作(上文已经讲到Vector的操作是线程安全的,但是基于线程安全的多线程操作性能不高)。ArrayList同样也支持随机访问操作,也就是说其单线程下对指定索引位置的数据读取操作的时间复杂度为O(1)。ArrayList的主要继承体系如下图所示:
ArrayList和Vector的差异首先就体现在他们内部不同的重要变量/常量定义上,代码片段如下所示:
public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
/**
* 该常量代表ArrayList默认的初始化容量,实际上这个值在ArrAyList中的使用非常少
* 只出现在ArrayList第一次确认容量的时候——而且还是在默认情况下
*/
private static final int DEFAULT_CAPACITY = 10;
/**
* 该常量值是在ArrayList初始化的时候使用,用来将elementData数组赋值为一个没有元素的空数组。
* 注意:只是在初始化时使用
*/
private static final Object[] EMPTY_ELEMENTDATA = {};
/**
* 该常量值在ArrayList第一次被添加元素时使用,用来作为在默认的情况下ArrayList第一次扩容大小的判定依据
* 注意:只是在第一次扩容时使用
*/
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
/**
* 该变量和Vector容器中的同名变量意义相同,都是实际用来存储ArrayList中的各个元素数据
*/
transient Object[] elementData; // non-private to simplify nested class access
/**
* 该变量用于记录当前ArrayList容器的大小,类似于Vector容器的elementCount变量
*/
private int size;
}
从上文对ArrayList容器中的重要变量介绍来看,我们至少可以“猜测”出ArrayList容器在初始化过程、和扩容过程(特别是第一次扩容)上与Vector容器可能存在较大差异,这些差异我们会在后文进行详细说明。首先本文需要说明在elementData变量定义中出现的transient关键字,官方对于该关键字的解释是:
Variables may be marked transient to indicate that they are not part of the persistent state of an object.
由于进行对象持久化操作时必须首先进行对象的序列化操作,所以读者可以将transient关键字简单理解为:该属性可以在进行对象序列化操作时被忽略。我们看以下的实际示例:
// 这里为了方便理(偷)解(懒),将异常抛出去
public static void main(String[] args) throws IOException , ClassNotFoundException {
MyObject myObject = new MyObject();
myObject.setParamOne("param1");
myObject.setParamTwo("param2");
myObject.setParamThree("param3");
// 开始进行序列化——注意:java默认提供的ObjectOutputStream只是其中一种序列化方式,很重要的默认序列化方式。
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(myObject);
byte[] objectBytes = bos.toByteArray();
// 通过序列化后的byte数组,生成另一个ArrayList
ByteArrayInputStream bis = new ByteArrayInputStream(objectBytes);
ObjectInputStream oin = new ObjectInputStream(bis);
MyObject otherObject = (MyObject)oin.readObject();
// 这样新得到的对象中将没有paramThree的属性值
System.out.println(String.format("otherObject.getParamThree() = %s ", otherObject.getParamThree()));
}
// 定义一个示例对象
class MyObject implements Serializable {
private String paramOne;
private String paramTwo;
// 其中第三个参数设置为transient
private transient String paramThree;
// ...... 省去get/set方法定义
}
和设想的结果相同,通过序列化/反序列化动作得到的新对象“otherObject ”中,没有paramThree属性的值,以下是输出结果:
otherObject.getParamThree() = null
- 1
但是读者很快会找到一个矛盾点,那就是我们在实际工作中经常需要进行ArrayList容器的序列化/反序列化操作,但为什么没有出现新的ArrayList容器短缺元素的情况呢(前提是这些元素对象本身是可序列化的)。这是因为ArrayList容器出于节约内存空间的考虑,采用了一种折中的序列化/反序列方式。这个详细内容将在后文(4.2小节)进行介绍。
4.1、ArrayList的初始化和扩容操作
本小节我们详细讲解ArrayList容器的初始化操作和扩容操作,并结合前文中已经介绍的Vector容器进行类比。首先我们来讲述一下ArrayList容器的初始化方式,先看代码片段:
/**
* 为当前初始化的ArrayList容器时,设定一个指定大小的初始容量,且这个容量中的所有元素填充为空
* @param initialCapacity 为这个容器设定的初始化大小
*/
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
// 容量不能小于0
throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity);
}
}
/**
* 设定一个初始化大小为10的元素.
*/
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
// ArrayList还有第3个构造函数,public ArrayList(Collection<? extends E> c)
// 这里不参与讨论,不然读者容易犯浑
从以上ArrayList容器实例化的代码可以看出,如果初始化时不指定ArrayList容器的大小,那么ArrayList容器都将会初始化成一个容量为0的容器。
请注意上文中那个没有带任何参数的ArrayList容器初始化方法,官方给出的初始化意义是“Constructs an empty list with an initial capacity of ten.”,但是我们所知的那个DEFAULTCAPACITY_EMPTY_ELEMENTDATA常量,是一个长度为0的Object类型的数组。这个矛盾的情况是怎么回事呢?这是因为当ArrayList容器处于这种状态时,后续无论是通过add(E e)方法还是add(int index, E element)方法向ArrayList容器添加一个新的对象(进行“写”性质的操作),ArrayList容器都会将elementData数组变量“扩容”成一个容量为10的数组——通过“private void grow(int minCapacity)”方法。
grow(int minCapacity)方法是ArrayList容器用来进行实际“扩容”的方法,由于我们已知的知识:一个给定的数组在完成初始化后其大小不能改变。所以ArrayList容器实际的“扩容”机制和Vector容器类似——通过某种算法创建一个容量更大的数组,并按照一定的逻辑将原数组中的元素一次“复制”到新的数组中。只不过ArrayList容器中计算新数组大小的方式更“动态”一些。我们来看grow(int minCapacity)方法的完整代码:
/**
* Increases the capacity to ensure that it can hold at least the
* number of elements specified by the minimum capacity argument.
* @param minCapacity 要求扩容后的最小容量。
* 从外部调用情况来看,一般是原数组大小 + 1,或者是DEFAULT_CAPACITY这个常量(大小为10)
*/
private void grow(int minCapacity) {
// 将原数组的旧大小存起来,后面要使用
int oldCapacity = elementData.length;
// 新的大小默认为:原来的大小 + 原大小的50%
// 这里特别注意,当oldCapacity 为0的情况(既是elementData数组长度为0),那么左移操作的计算值也为0
int newCapacity = oldCapacity + (oldCapacity >> 1);
// 如果计算出来的新大小 小于 "要求扩容后的最小容量" ,
// 则以“要求扩容后的最小容量”为新的容量
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
// 如果新的容量 大于 MAX_ARRAY_SIZE常量(值为2147483639),
// 则调用hugeCapacity(int)方法,为“超大容量”的数组计算一个合理的大小
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
// 这个方法在介绍Vector容器时详细说明过,这里就不需要再说明了吧
elementData = Arrays.copyOf(elementData, newCapacity);
}
hugeCapacity(int minCapacity) 方法在上文介绍Vector容器时,已经进行了详细介绍,所以这里也不赘述了。两者的hugeCapacity(int minCapacity) 方法中的内容一模一样——甚至方法中的注释、换行符都一样。 Arrays.copyOf(Object[] original, int newLength) 方法在上文中也进行了详细介绍,这里也不赘述了。
虽然我们已经详细描述了ArrayList容器中关于“扩容”的核心方法,但实际上当ArrayList容器进行第一次扩容和进行非第一次扩容的情况还是有区别的,现在我们进行详细的分析:
// 该私有方法在ArrayList容器中所有可能改变容量大小的方法中被调用,在ArrayList容器反序列化的过程中被调用
// 用于“确认”ArrayList容器要求的最小数据容量
// ArrayList容器中一般调用该方法的入参方式为 ensureCapacityInternal(size + 1) 或者 ensureCapacityInternal(size)
private void ensureCapacityInternal(int minCapacity) {
// 如果条件成立说明当前ArrayList容器还处于默认的容量为0的状态
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
// 这个时候取得DEFAULT_CAPACITY和minCapacity的最大值作为新的扩容容量
// DEFAULT_CAPACITY = 10
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
// 该私有方法用于决定是否需要进行扩容
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
// 如果当前要求的容器最小容量 大于 当前ArrayList容器中elementData数组的长度,则进行扩容操作
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
- 如果当前ArrayList容器为0:
这种情况实际上就是ArrayList容器使用默认的构造函数方式刚完成初始化动作所处的状态——但凡有一次“写”性质的操作发生,ArrayList容器都不会是这样的状态。那么处于这种状况下,通过ensureCapacityInternal(int minCapacity)方法进行的扩容操作,就会满足条件“elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA”,也就是说系统会使用DEFAULT_CAPACITY(大小为10)常量作为要求的扩容后容量。
- 如果当前ArrayList容量不为0:
这种情况下,ensureCapacityInternal(int minCapacity)方法中的判断式“elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA”就不成立。方法将会按照传入的minCapacity参数值作为要求的扩容后容量大小。那么下图可以很好表达ArrayList容器的常规扩容过程:
4.2、ArrayList的序列化和反序列化
本文不准备讨论什么叫做序列化,什么叫做反序列化,也不准备讨论为什么要进行序列化/反序列化,如果读者不清楚相关知识可自行查询资料。ArrayList容器和Vector容器都支持序列化——前提是容器中存储的对象也必须支持序列化/反序列化,否则在进行相关操作时,会抛出“java.io.NotSerializableException”异常。
但是ArrayList容器相比Vector容器,其序列化/反序列化过程进行了相关优化,相关代码片段如下所示:
public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
//......
// 该属性存在于上层的AbstractList类中——为了阅读方便才放到这里,它也使用了transient关键字进行标记
protected transient int modCount = 0;
//......
// 容器中用来存储实际数据的数组
transient Object[] elementData;
/**
* 重写了序列化方法,不要被这个private私有化定义迷惑了,当ArrayLIst容器被序列化的时候(ObjectOutputStream的writeObject方法),该方法就会被调用。
* Save the state of the <tt>ArrayList</tt> instance to a stream (that is, serialize it).
* @serialData The length of the array backing the <tt>ArrayList</tt>
* instance is emitted (int), followed by all of its elements
* (each an <tt>Object</tt>) in the proper order.
*/
private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException {
// modCount 变量表示当前ArrayList容器备操作的次数,类似add()、set()、remove()等“写”性质的操作都算在内
// 只有有一次方法调用,modCount作为计数器就会+1,这里采用了一个乐观锁检查的思路,在线程不安全的情况下,检查操作冲突
int expectedModCount = modCount;
// 这是ObjectOutputStream类的方法,当调用这个方法时,对象中非static修饰、非transient修饰的属性就会完成序列化
s.defaultWriteObject();
// 将ArrayList容器的size属性进行序列化
s.writeInt(size);
// 这个代码很重要,该方法将按照elementData数组中每个元素的顺序,依次进行序列化。
for (int i=0; i<size; i++) {
s.writeObject(elementData[i]);
}
// 这句代码同样重要,后文马上讲到
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}
// ......
/**
* 重写了反序列化方法,不要被这个private私有化定义迷惑了,当数据被反序列化成一个新的ArrayLIst容器对象时(ObjectInputStream 的readObject方法),该方法就会被调用。
* Reconstitute the <tt>ArrayList</tt> instance from a stream (that is, deserialize it).
*/
private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException {
// 反序列化时,首先将新ArrayLIst容器中的elementData数组置为长度为0的空数组。
elementData = EMPTY_ELEMENTDATA;
// 反序列调用该方法时,目标对象中非static修饰、非transient修饰的属性,就会被反序列化
s.defaultReadObject();
// 反序列化ArrayList容器中的size属性
s.readInt(); // ignored
// 如果size属性不大于0,说明序列化之前原始对象中就没有数据,那么新的ArrayList容器就已经完成反序列动作了
// 否则就按照size的值进行新ArrayLIst容器中elementData数组的第一次“扩容”
if (size > 0) {
// be like clone(), allocate array based upon size not capacity
ensureCapacityInternal(size);
Object[] a = elementData;
// Read in all elements in the proper order.
for (int i=0; i<size; i++) {
a[i] = s.readObject();
}
}
}
// ......
}
以上代码在Vector容器的定义中可没有,那么我们考虑一种情况,当Vector容器中的elementData数组容量足够大(例如超过9万的数组容量),但是elementData数组中实际已使用的容量(elementCount变量描述,类似于ArrayList容器中的size变量)还没有那么大时(如下图所示):
很明显,elementData数组中在elementCount变量所表示的索引以外的11000个位置中的元数是没有序列化的必要的,Vector容器理论上最多会浪费掉50%的计算资源进行无用的序列化过程。所以之后的ArrayList容器对序列化过程做了改进——按照以上所述的序列化过程,只对elementCount数组在size变量所表示的索引以内的元素进行序列化,而不是将整个elementCount数组全部序列化。
另外一个有意思的点是writeObject(java.io.ObjectOutputStream s)方法中的以下关联代码片段:
private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException {
//......
int expectedModCount = modCount;
//......
// 进行的序列化操作代码
//......
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
//......
}
由于ArrayLst容器不是线程安全的,也就是说在多个线程同时对一个ArrayLst容器实例进行操作写操作时,其最终操作结果可能会出现混乱,例如在第X个位置设定的新元素,可能并不是第X个位置最终元素(也就是我们所说的“脏数据”问题)。这个问题在一般情况下开发人员都可以适应——因为在ArrayList的文档和源代码中都对其线程不安全性进行了明确说明:不要在多线程情况下使用同一个ArrayList的实例,可以使用CopyOnWriteArrayList类进行替换。
但是在ArrayList容器的序列化操作时,需要保证新生成的ArrayList容器不会出现“脏数据”问题,就要保证正在进行序列化操作的ArrayList容器在这个过程中不会被其它线程进行“写”性质的操作。
那么如何解决这个问题呢?ArrayList容器的序列化操作借鉴了“乐观锁”思路(关于乐观锁的详细介绍会在本专题后续文章中机型),简单来说就是:在操作前对操作情况进行操作结果预测,然后再进行操作,最后比较实际操作结果和预计结果是否相同,如果相同则认为操作成功;如果失败则认为操作失败(回溯操作/抛出异常/重新操作都可)。那么操作过程如下:
-
在进行序列化操作前首先记录当前ArrayList容器的modCount值,这个值在之前已经提到过,其类似于一个计数器,记录了当前ArrayList容器被进行“写”性质操作的次数,在诸如add()、set()、remove()等“写”性质的操作方法中,该计数器都会进行增加。
-
记录了modCount值后,再对ArrayList容器进行实际的序列化操作。在这个过程中如果有其它线程对这个ArrayList容器进行了“写”性质的操作,那么modCount值就会增加, 且本线程进行的“序列化”操作也就没有意义。
-
最后,在完成序列化操作后,会对操作前的modCount值和当前ArrayList容器的modCount值进行比较,如果两个值不一样则抛出ConcurrentModificationException异常。
========
(接后文《源码阅读(4):Java中主要的List结构——ArrayList集合(下)》)