类图结构
ArrayList
是对List
列表数据结构的一种具体实现,先放一张源码类图结构,有个直观的印象,该图是Java
集合Collection
类图的一个子集:
存储结构
transient Object[] elementData;
ArrayList
实例本身只是一个普通的Java
对象,它的内部封装了一个数组,添加到ArrayList
里面的对象元素都是存储在这个数组当中。
数组存储结构的最大特点就是内存空间具有连续性,随机访问数组任何位置,时间复杂度都是O(1)
。
因此,单从数组存储结构,就能看出ArrayList
的特点:
1、优秀的对象查找速度,时间复杂度永远是O(1)
2、增删对象的时候,涉及数组其它对象的前后移动,因此效率较低
3、在列表尾部的增删效率高于在头部的增删效率,因为尾部增删需要移动的其它对象较少
数组还有一个特点,就是一旦创建,数组长度就是固定的。当数组存储空间用完,还要继续向列表添加元素的时候,就需要开辟新的存储空间,这就是ArrayList
的数组扩容机制,后文再讲。
ArrayList初始化
ArrayList
有三个构造方法:
// 对象存储数组
transient Object[] elementData;
// 两个空集合标识,一个表示“人为指定”,一个表示“系统默认”
private static final Object[] EMPTY_ELEMENTDATA = {};
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
// 无参构造函数
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
// 构造的时候,指定ArrayList的初始容量
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity);
}
}
// 构造的时候,添加一些初始化元素
public ArrayList(Collection<? extends E> c) {
elementData = c.toArray();
if ((size = elementData.length) != 0) {
// c.toArray might (incorrectly) not return Object[] (see 6260652)
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, size, Object[].class);
} else {
// replace with empty array.
this.elementData = EMPTY_ELEMENTDATA;
}
}
从源码可以看出,构造ArrayList
的时候,最关心的是就数组elementData
存储空间的初始化。
通过以下方式构造ArrayList
时,数组暂不分配存储空间:
ArrayList list = new ArrayList();
ArrayList list = new ArrayList(0);
ArrayList list = new ArrayList(list);// list是空的
ArrayList
构造完成以后,数组变量elementData
会指向一个预定义的空数组对象,要么是EMPTY_ELEMENTDATA
,要么是DEFAULTCAPACITY_EMPTY_ELEMENTDATA
。
为什么要预定义两个空数组对象呢?
这是在为后面新增元素时数组扩容作准备,数组第一次扩容时,需要知道指向空对象的原因是“人为指定”还是“系统默认”。暂时先记住这一点,后面讲扩容时再具体分析。
通过以下方式构造ArrayList
时,数组立即分配存储空间:
ArrayList list = new ArrayList(128);
ArrayList list = new ArrayList(list);// list里面有对象元素
小结:构建ArrayList
对象时,为了优化性能,非必要的情况下,不会分配数组存储空间,如果明确知道后续操作需要多大的数组空间,指定一个合适的初始容量也是极好的。
新增对象与数组扩容
新增元素的方法有四个,实现上大同小异,顺着其中任何一个方法追踪下去,很快就可以看到新增逻辑和数组扩容机制,数组扩容只在新增的时候才会有。
以add(E e)
方法为例,查看完整方法调用链如下:
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
private void ensureCapacityInternal(int minCapacity) {
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
// 计算数组存储空间的最小长度
private static int calculateCapacity(Object[] elementData, int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
return minCapacity;
}
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
// 数组扩容的核心逻辑
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
这段代码逻辑包含两个方面内容:添加元素、数组扩容。
添加元素的逻辑是这样的:
- 判断当前的数组空间够不够用
- 如果够用,将元素添加到数组当中
- 如果不够用,先触发数组扩容机制,再将元素添加到数组当中
数组扩容的逻辑是这样的:
- 数组空间不足时才会触发扩容机制,创建新的内存数组,长度是原来的1.5倍,将原数组对象复制到新数组,
elementData
对象引用指向新数组 - 第一次扩容时,数组分配长度取决于构造
ArrayList
时的参数,还记得那两个空数组对象么? - 如果初始化
ArrayList
时,空数组对象是“系统默认”的,那么,数组扩容第一次得到的内存空间就是10个对象长度 - 如果初始化
ArrayList
时,空数组对象是“人为指定”的,那么,数组扩容第一次得到的内存空间就是1个对象长度 - 数组扩容的最大值是
Integer.MAX_VALUE
,再继续扩容就会抛出OutOfMemoryError
异常
扩容机制的好处是可以保证对象元素存储空间的动态增加,避开了数组固定长度的限制,但这也是降低列表性能的操作。
因此,在实际应用场景下,如何降低扩容次数也是ArrayList
一个可以考虑的优化方向。
删除对象
删除方法有多个,实现也是大同小异,最常用的删除操作是:
public E remove(int index) {
rangeCheck(index);
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index, numMoved);
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}
删除逻辑非常简单:
- 检查删除对象索引是否有效,索引就对应数组下标
- 拿到将要删除的对象
- 将数组删除位置之后的所有对象前移一位
- 返回删除对象
需要注意的是,ArrayList
只有数组的扩容机制,没有“减容机制”!删除元素的时候不会动态减少数组空间。
面试的时候,不止一次的有面试者告诉我:ArrayList
数组空间是动态分配的,新增对象时,空间不够就增加,删除对象时,空间多了就减少。这完全是错误的理解!
再强调一次:ArrayList
底层数组只会在新增元素且数组空间不足时扩容,数组空间没有动态变小的途径!!!
查找对象
从ArrayList
里面查找对象非常的快,因为数组具有时间复杂度为O(1)
的随机查找能力。
查找源码如下:
public E get(int index) {
rangeCheck(index);
return elementData(index);
}
逻辑也简单,没啥可分析的:
- 检查查找索引是否有效
- 从数组中获取对象并返回
线程安全性
ArrayList
是线程不安全的,因为源码里面没有涉及到任何的锁操作,也没有任何的数据同步保障。
所以,多线程场景下使用ArrayList
存在线程安全问题。
如何解决这个问题呢?提供三个方案。
Vector
Vector
实现逻辑与ArrayList
很像,最大的区别在于Vector
会在方法上使用synchronzied
关键字保证线程安全:
public synchronized boolean add(E e) {...}
public synchronized E remove(int index) {...}
public synchronized E get(int index) {...}
可以看到,新增、删除、查找,这三类方法都加上了synchronized
关键字。
Vector
保证了线程安全,但牺牲了增删查的效率,尤其是查找效率大打折扣,这是非常致命的一点。
再提一个它们的区别,默认情况下,ArrayList
的扩容因子是1.5
,Vector
的扩容因子是2
。也就是它们各自的数组扩容速度,相同数据量下,Vector
扩容次数不会高于ArrayList
。
因此,小数据量的场景下,即使Vector
有同步操作,它的新增速度通常也会优于ArrayList
,大数据量的场景下,Vector
通常又会比ArrayList
浪费更多的数组存储空间。
总有人跟我说,不要使用Vector
,因为它的同步性能低下。
我不否认这一点,但是我想说的是,Vector
并非一无是处,它也有优于ArrayList
的场景,合理的选择利用它们,扬长避短,才是编程取舍之道。
SynchronizedList
SynchronizedList
是集合工具类Collections
里面的一个静态内部类,通常,用法如下:
ArrayList<String> list = new ArrayList<>();
List<String> synchronizedList = Collections.synchronizedList(list);
synchronizedList.add("string");
String str = synchronizedList.get(0);
SynchronizedList
是保证线程安全的方法也是利用的synchronized
同步机制:
public void add(int index, E element) {
synchronized (mutex) {list.add(index, element);}
}
public E remove(int index) {
synchronized (mutex) {return list.remove(index);}
}
public E get(int index) {
synchronized (mutex) {return list.get(index);}
}
这种方案是运用了代理模式,对List
实现类进行了代理,在增删查操作之前添加同步操作,效率也不高。
CopyOnWriteArrayList
使用List
集合的业务场景,通常情况下是读多写少,CopyOnWriteArrayList
就是专门为这种业务场景设计的。
它的特点是:
- 读操作支持并发,写操作保证同步
- 写操作进行时,不会阻塞读操作
- 它能保证数据不出错,但是并非严格意义上的线程安全
看看新增和查找的源码,从中可以看出它的实现原理:
// 数组存储结构
private transient volatile Object[] array;
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
public E get(int index) {
return get(getArray(), index);
}
它的写操作逻辑是这样的:
- 底层还是数组存储结构
- 进行写操作前,将原数组复制一份,新数组空间长度加1
- 在新数组中进行写操作
- 写操作完成后,将原数组引用指向新数组即可
- 并发写操作时加锁,保证同步
从写操作逻辑中,可以看出CopyOnWriteArrayList
为什么会对读操作有很好的并发支持。
读操作包括新增和删除,每一次写操作,都需要复制一次数组,对内存空间有一定程度的浪费。
而且,因为读写之间没有同步机制,所以写操作成功后,不一定能及时反馈给读操作,可能就会出现两种现象:
- 对象新增后不能及时读到
- 对象删除后还能读到
这就是上面说的,从严格意义上讲,CopyOnWriteArrayList
并不是线程安全的,但是宏观上,它又能保证数据的正确性,很有特点的一个类!