原文转自我自己的个人公众号:Java基础之刨根问底第7集——ArrayList(由于是拷贝过来的,如果排版有问题,请看公众号文章)
-
本系列不适合初学者,读者应具备一定的Java基础。
-
本系列依据Java11编写
内容简介:
-
1.ArrayList的继承关系
-
2.ArrayList的内部实现机制
-
3.核心方法实现解析
-
3.1.构造函数
-
3.2.add
-
3.3.remove
-
3.4.set和get
-
-
4.迭代器实现解析
-
4.1.iterator
-
4.2.listIterator
-
-
5.subList
-
6.下集预告
1.ArrayList的继承关系
在上一节中,我介绍了Java Collections Framework的接口继承关系
本集聚焦在ArrayList上,它的继承关系如下图所示:
左侧的三个接口与ArrayList的能力关联不大,并且他们都没有任何方法和属性,仅用于标识:
-
RandomAccess:说明实现它的类支持随机访问。
-
Cloneable:在执行Object的clone方法时,JVM会验证对象是否实现了这个接口,如无实现则抛出异常。
-
Serializable:表示可被序列化和反序列化。
除了左侧的三个接口外,其余都是跟ArrayList的能力有直接关系的接口和抽象类,但这里有一个问题:
AbstractList实现了List接口,为什么ArrayList还要实现List接口?
按理来说,AbstractList已经实现了List接口,ArrayList作为它的子类,隐含着就已经实现了List接口,为什么还要多此一举的声明自己实现了List接口呢?
经过我的多方求证,答案似乎是:为了在文档中能清晰的表达实现类与Collections Framework中接口的直接关系。
从继承关系上来看:
-
AbstractCollection:实现Collection接口的抽象类,其能力主要涉及:
-
新增:add、addAll
-
删除:remove、removeAll
-
是否包含:contains、containsAll
-
清空:clear
-
获得集合元素数量:size
-
转数组:toArray
-
取交集:retainAll
-
迭代器:iterator
-
-
AbstractList:实现List接口的抽象类,其在AbstractCollection的基础上,扩展了以下能力:
-
与下标相关的:get、indexOf、lastIndexOf、add、remove
-
更新:set
-
子集合:subList
-
List迭代器:listIterator(相比Iterator的单向访问,ListIterator可双向访问,且提供新增和修改能力)
-
-
ArrayList:基于内部数组实现了AbstractCollection和AbstractList中的抽象方法,并更具数组的特性提供了更多的重载的方法。
2.ArrayList的内部实现机制
正如名字一样,ArrayList的内部实现主要是基于一个内部数组。在ArrayList内部共有3个数组:
private static final int DEFAULT_CAPACITY = 10;
private static final Object[] EMPTY_ELEMENTDATA = {};
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
transient Object[] elementData;
private int size;
其中,EMPTY_ELEMENTDATA和DEFAULTCAPACITY_EMPTY_ELEMENTDATA都是静态的空数组,这两个的区别是,前者被所有空ArrayList共享,后者则专用于ArrayList的无参构造函数,表示需要使用默认的容量初始化数组。
elementData则是真正用来存储集合元素的数组,这里有两个点需要注意:
-
elementData并没有声明为private,这是因为ArrayList内有几个内部类也需要访问elementData,而内部类在访问private修饰的属性时,JVM实际上是需要创建一个“synthetic”的访问方法才行,为了避免额外的消耗,elementData并没有声明成private。
-
elementData使用了transient关键字,该关键字表示在序列化的时候,忽略该属性。但这并不意味这ArrayList不能序列化,只是在序列化的时候,ArrayList直接将元素进行序列化,在反序列化的时候会用元素重新填充数组。这样做避免了对数组进行序列化时的额外开销。
从上面的源码中可以看到capacity和size两个跟大小相关的属性,它们与elementData的关系如下图所示:
ArrayList的各项能力都是在elementData这个数组上进行的,因为数组的定长和空间连续等特性,因此ArrayList的大部分操作都需要对数组元素进行拷贝。接下来我们就针对几个核心方法来看看具体的实现。
3.核心方法实现解析
3.1.构造函数
ArrayList共有3个构造函数,定义如下:
public ArrayList();
public ArrayList(int initialCapacity);
public ArrayList(Collection<? extends E> c);
-
在无参构造函数中,仅仅将DEFAULTCAPACITY_EMPTY_ELEMENTDATA赋值给elementData,表示需要使用默认的容量。在上面的介绍中可以看到,默认容量是10。
-
带有initialCapacity参数的构造函数可以手动设置初始容量,如果initalCapacity设置为0,则会将EMPTY_ELEMENTDATA赋值给elementData,当前的ArrayList的容量也就是0。
-
传入集合的构造函数,首先会调用传入集合的toArray方法获得元素数组,将获得的数组长度赋值给size属性,如果获得的数组长度是0,则会使用EMPTY_ELEMENTDATA。如果传入的集合同样是ArrayList,则直接将获得的数组赋值给elementData,否则使用Arrays.copyOf将获得的数组拷贝一份赋值给elementData,如下所示:
elementData = Arrays.copyOf(a, size, Object[].class);
使用Arrays.copyOf是为了避免其他类型的集合的toArray返回的并不是Object数组,例如:
@Override
public Object[] toArray() {
String[] test = {};
return test;
}
从构造函数中可以看到,我们日常使用最多的无参构造函数并没有实质性地初始化数组,仅仅是用DEFAULTCAPACITY_EMPTY_ELEMENTDATA占了一个位置。默认容量数组的初始化延迟到了第一次添加元素的时候。
3.2.add
源码如下:
public boolean add(E e) {
modCount++;
add(e, elementData, size);
return true;
}
private void add(E e, Object[] elementData, int s) {
if (s == elementData.length)
elementData = grow();
elementData[s] = e;
size = s + 1;
}
这里又有两个点要注意:
-
modCount是一个变更计数器,所有会更改元素数量的方法都会增加这个计数器,在开始遍历元素的时候记录下这个值,每遍历一个元素对比下当前值,如果不一样,则说明遍历过程中元素有增减,就会抛出ConcurrentModificationException异常。
-
add方法调用了一个私有的add方法,而这个私有的add方法仅仅在这里被调用了一次,是否多此一举呢?答案肯定不是。这是针对HotSpot VM使用的C1-compiler的一个优化,在C1编译器中,如果方法足够的小,调用方法的时候会直接将方法体inline化,调用就不需要再通过类似“jump”指令去别的地方找方法体了。add方法的调用频率很高,因此做了这样的一个优化。
继续来看私有的add方法,首先对比size(元素数量)和element.length(容量),如果二者相等,说明容量不够了,就需要扩容,默认构造函数初始化后的第一次add就是这样的情况。扩容后将新元素添加到数组元素的最后一个。
再来看以下扩容方法,源码如下:
private Object[] grow() {
return grow(size + 1);
}
private Object[] grow(int minCapacity) {
return elementData = Arrays.copyOf(elementData,
newCapacity(minCapacity));
}
private int newCapacity(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity <= 0) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
return Math.max(DEFAULT_CAPACITY, minCapacity);
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
return minCapacity;
}
return (newCapacity - MAX_ARRAY_SIZE <= 0)
? newCapacity
: hugeCapacity(minCapacity);
}
可以看到,重点在newCapacity方法,它接收一个参数,表示最小的库容容量,add方法扩容的时候,这个参数被设置为size+1,表示至少需要扩容到size+1的容量大小。实际扩容的时候,代码中使用了右移操作符">>",右移1位相当于除以2,因此初步计算的newCapacity值为elementData.length的1.5倍。
接着对比newCapacity和minCapacity,如果前者比后者还小,说明扩容后依然不满足List对容量的最小需求,此时可能有三种情况:一种是无参构造函数后的第一次add,此时因为elementData等于DEFAULTCAPACITY_EMPTY_ELEMENTDATA,length是0,按照上面的公式计算后,newCapacity=0,而minCapacity=1,此时就返回默认容量和需求容量中的大值,在构造函数这个场景中,默认容量是10,肯定大于第一次的需求容量1;另一种情况是minCapacity本身小于0 ,这可能是由于int达到最大值后越界导致的符号位改变,此时就抛出OutOfMemoryError异常;如果上述的两种情况都不满足,那么原因就是minCapacity确实设置的比自动扩容的比例大,此时就已需求值minCapacity为准。
另一头,如果newCapacity比minCapacity大,按道理直接返回newCapacity就行了,但源码中还是进行了一些处理。这是因为,在有些JVM中,数组需要预留一些容量,这部分是不能被使用的(通常这部分容量是8),因此这里又对比了排除预留部分后是否还有足够的空间,如果有空间,就返回newCapacity,如果没有空间了,就返回hugeCapacity方法的值,该方法源码如下:
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
return (minCapacity > MAX_ARRAY_SIZE)
? Integer.MAX_VALUE
: MAX_ARRAY_SIZE;
}
如果需求的容量还没有大过MAX_ARRAY_SIZE,就返回这个最大的MAX_ARRAY_SIZE,否则说明此时不但打过了MAX_ARRAY_SIZE,而且minCapacity还没有越界变为复数,也就是说当前的JVM并没有对数组预留容量,因此就返回Integer的最大值。
得出新的容量后,就使用Arrays.copyOf方法创建一个新容量的数组,并且把原来数组的元素拷贝到新数组中。这样就完成了add操作。
3.3.remove
remove有两个重载的方法,一个是根据下标删除,另一个是根据对象删除,这两个最终都会调用fastRemove这个私有方法:
public E remove(int index) {
Objects.checkIndex(index, size);
final Object[] es = elementData;
@SuppressWarnings("unchecked") E oldValue = (E) es[index];
fastRemove(es, index);
return oldValue;
}
public boolean remove(Object o) {
final Object[] es = elementData;
final int size = this.size;
int i = 0;
found: {
if (o == null) {
for (; i < size; i++)
if (es[i] == null)
break found;
} else {
for (; i < size; i++)
if (o.equals(es[i]))
break found;
}
return false;
}
fastRemove(es, i);
return true;
}
第二个根据对象删除的remove方法中有一个不常用的语法found:和之后的break found,这是java中的根据标签break的语法,冒号前的是标签,break可以根据标签选择跳出的位置。从这个方法中也可以看出,根据对象删除的时候是需要遍历数组来确定下标的,而且对null值进行了等值判断,否则使用equals方法。这里也可以得出一个结论:根据对象删除的效率要低于下标删除。
下面看下fastRemove方法:
private void fastRemove(Object[] es, int i) {
modCount++;
final int newSize;
if ((newSize = size - 1) > i)
System.arraycopy(es, i + 1, es, i, newSize - i);
es[size = newSize] = null;
}
这个方法代码比较少,但是稍微有些不好理解,我用下面这个图来展示一下执行过程,其中“D”是要删除的对象:
3.4.set和get
set和get都特别简单,所以就放在一起来说了,源码如下:
public E set(int index, E element) {
Objects.checkIndex(index, size);
E oldValue = elementData(index);
elementData[index] = element;
return oldValue;
}
public E get(int index) {
Objects.checkIndex(index, size);
return elementData(index);
}
E elementData(int index) {
return (E) elementData[index];
}
这部分代码简单到基本上不用解释了,我就不解释了^_^!
4.迭代器实现解析
4.1.iterator
iterator方法返回了内部类Itr的实例,Itr实现了Iterator接口,可以从前向后单向访问元素,并且可以在遍历过程中删除元素。遍历是通过next方法实现的,利用了Itr内部的一个int变量记录上一次访问的下标,比较简单就不说了,这里重点说一下Itr是如何在遍历中删除元素并且不抛出ConcurrentModificationException异常的,下面是源码:
public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
checkForConmodification方法用来检查执行过程中元素有没有增减,如果此时有增减则会抛出ConcurrentModificationException异常。然后就调用ArrayList自身的remove方法删除上一个遍历的下标元素,根据上面对remove的讲解,可以想到,被删除原始后的所有元素都会向前移动一格,因此将上一个位置设置给cursor,此时的上一个位置实际上是被删除元素的下一个元素。最后一行很关键,因为ArrayList自己的remove方法会将modeCount增加1,因此将此时的modCount赋值给expectedModCount,这样后续的操作就不会因为Itr中记录的modCount与实际的modCount不一致而抛出ConcurrentModificationException了。
4.2.listIterator
与iterator方法类似,listIterator也是通过返回ListItr内部类实现的,这个内部类实现了ListIterator接口,我们上面也提到过,相比Iterator接口,ListIterator接口提供了双向访问和增加、修改的能力,因此为了复用其他能力,ListIterator继承了Itr,在Itr的基础上提供了previous方法用来向前遍历,原理和Itr的next类似,不细说了。
新增的set方法比较简单,就是调用了ArrayList自己的set方法对上一个遍历的下标元素进行重新赋值。
新增的add方法实现上和remove方法比较类似,也是通过重置expectedModCount的方式来避免出现ConcurrentModificationException异常的。
5.subList
subList是对一个完整ArrayList的切分,可以截取其中的一段,但这里需要注意的是,subList是逻辑上的分割,其中的元素都是原ArrayList中的,并不是新创建的独立元素,因此,对subList中元素的操作等同于对原始ArrayList中元素的操作。
我们从构造函数和add方法中,就可以看到其对原ArrayList的影响,以及实现原理,源码如下:
public SubList(ArrayList<E> root, int fromIndex, int toIndex) {
this.root = root;
this.parent = null;
this.offset = fromIndex;
this.size = toIndex - fromIndex;
this.modCount = root.modCount;
}
private SubList(SubList<E> parent, int fromIndex, int toIndex) {
this.root = parent.root;
this.parent = parent;
this.offset = parent.offset + fromIndex;
this.size = toIndex - fromIndex;
this.modCount = parent.modCount;
}
public void add(int index, E element) {
rangeCheckForAdd(index);
checkForComodification();
root.add(offset + index, element);
updateSizeAndModCount(1);
}
从第一个构造函数中可以看到,fromIndex被赋值给了内部的offset,然后在add方法中,会给index加上这个offset,然后调用原ArrayList的引用root来执行add方法。而第二个构造函数则说明了subList可以嵌套继续subList。
6.下集预告
下一集我们来聊聊LinkedList,这家伙还是能不用就不用,原因下集我们细聊。
插播个小广告:
本人新书发布!《企业架构与绕不开的微服务》。
-
在理论方面,介绍了企业架构标准、云原生思想和相关技术、微服务的前世今生,以及领域驱动设计等;
-
在实践方面,介绍了用于拆分微服务的“五步法”、包含4个维度的“企业云原生成熟度模型”,以及衡量企业变革成果的“效果收益评估方法”等。
本书可以帮助企业明确痛点、制定原则、规划路径、建设能力和评估成效,最终实现微服务架构在企业中的持续运营和持续演化,从而应对日益增多的业务挑战。
更多内容请关注我的个人公众号