一文详解ArrayList
概念
- 数组是线性表数据结构。它用一组连续性的内存空间,来存储一组相同类型的数据。
- 可以简单理解数组就是一个存储数据容器。
特征
因为数组连续内存空间的相同类型数据,具备:读取速度快、随机读取、尾部新增元素快等特点
例子
有一个长度为5的Long类型的数组,需要分配连续内存空间则为1000-1039,内存块首地址1000。
如果需要访问数组中的数据的时候,需要先通过一个寻址公式来找到内存地址,然后再读取其中的数据。
寻址公式为 a[i]_address = first_address + i * data_type_size
释:first_address为内存块的首地址1000(在很多语言中,都将数组的第一个下标定为0,因为在寻址的时候,可以直接得到内存地址,而不需要再将数值减一后再进行计算),data_type_size为数据类型的大小。
数组中元素为long类型,所以datatype_size为8个字节。
当我们需要读取a[0]时候,
a[0]_address = first_address + 0 * data_type_size = 1000 + 0 * 8 =1000;
所以数组适合查询,支持随机访问,在根据下标进行随机访问时时间复杂度为O(1)(注意:按照下标进行随机访问的时候时间复杂度O(1);如果使用二分查找的话,即便已经进行过排序的数组,时间复杂度也是O(logn))。
但是数组存储空间是连续的,在对数组进行增加和删除的时候(尾部操作除外),是比较低效的。
插入操作:如果插入的数据在最后一个,数组就不需要进行移动,最好时间复杂度为O(1);
如果数组数据是有序,要在第y位插入数据,则后面的每一个数据都需要往后挪一个,最坏时间复杂度为O(n);因为在每一个位置插入的概率都是一样的,所以平均时间复杂度为(1+2+…+n)/n=O(n)()。
如果数据是无序的话,要在第y位插入数据,将第y位的数据移动到整个数组的最后面,然后再将需要插入的数据插入即可,时间复杂度就降为了O(1)。
接下来说删除操作,类似插入操作。
java ArrayList
ArrayList 是jdk基于数组[]封装的一个类,具备数组的特性并且增加了动态扩展的特性。
类图
ArrayList 实现接口
java.util.List
java.util.RandomAccess
java.io.Serializable
java.lang.Cloneable
List:jdk抽象数组具备特征通用行为,定义了jdk中数组的基本操作。
主要操作:查找、新增、移除、替代、迭代遍历等
RandomAccess:空接口,作为支持快速随机访问的标志;在这里表示ArrayList支持快速随机访问。
详细说明:juejin.cn/post/684490…
Serializable:空接口,作为支持序列化的标志;在这里表示ArrayList支持序列化。
详细说明:www.jianshu.com/p/e554c787c…
Cloneable:空接口,作为支持克隆的标志(Object.clone()),在这里表示ArrayList支持克隆。
详细说明:www.zhihu.com/question/52…
ArrayList继承抽象类
java.util.AbstractList
AbstractList 提供了 List 接口的通用方法实现,大幅度的减少了实现迭代遍历相关操作的代码。例如说 #iterator()、#indexOf(Object o) 等方法。(但是ArrayList重写大部分AbstractList的方法)
属性
Object[] elementData;
元素数组。
int size;
数组大小;(大小指的是已经使用元素的数组大小)
方法
数组方法比较简单,下面主要抽取数组常用方法进行解析
构造方法
ArrayList(int initialCapacity);
ArrayList();
ArrayList(Collection<? extends E> c);
/**
* 默认初始化容量
*/
private static final int DEFAULT_CAPACITY = 10;
/**
* 共享的空数组对象
*
* 如果传入的初始化大小或者集合大小为 0 时,将 {@link #elementData} 指向它。
*
*/
private static final Object[] EMPTY_ELEMENTDATA = {};
/**
* 共享的空数组对象,用于 {@link #ArrayList()} 构造方法。
*
* 通过使用该静态变量,和 {@link #EMPTY_ELEMENTDATA} 区分开来,在第一次添加元素时。
*
*/
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
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);
}
}
/**
* 省略ArrayList(Collection<? extends E> c)
*/
注意:如果有参构造方法的数组大小为零,则创建一个空的数组,默认无参构造方法创建也是一个空的数组(不是大小为10数组),但是有参构造方法初始化为EMPTY_ELEMENTDATA这个空数组,而无参构造方法初始化为DEFAULTCAPACITY_EMPTY_ELEMENTDATA这个空数组。
问题:EMPTY_ELEMENTDATA和DEFAULTCAPACITY_EMPTY_ELEMENTDATA都是空数组,为什么声明两个呢?
答案:DEFAULTCAPACITY_EMPTY_ELEMENTDATA 首次扩容为 10 ,而 EMPTY_ELEMENTDATA 按照 1.5 倍扩容从 0 开始而不是 10
新增方法
add(E e);
add(int index, E element)
/**
* 将指定的元素追加到此列表的末尾
*/
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;
}
/**
* modCount++ 增加数组修改次数
*/
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
/**
* 默然按照1.5背进行扩容
*/
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);
}
grow(int minCapacity)流程
删除方法
remove(int index);按照下标进行删除;
remove(Object o);按照元素进行删除;
public E remove(int index) {
// 校验 index 不要超过 size
rangeCheck(index);
// 增加数组修改次数
modCount++;
//获取下标元素
E oldValue = elementData(index);
//记录需要移动下标
int numMoved = size - index - 1;
if (numMoved > 0)
//数组拷贝
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
//数组末尾null gc就可以清楚回收
elementData[--size] = null; // clear to let GC do its work
// 返回该位置的原值
return oldValue;
}
public boolean remove(Object o) {
if (o == null) {
for (int index = 0; index < size; index++)
if (elementData[index] == null) {
fastRemove(index);
return true;
}
} else {
for (int index = 0; index < size; index++)
if (o.equals(elementData[index])) {
fastRemove(index);
return true;
}
}
return false;
}
private void fastRemove(int index) {
// 增加数组修改次数
modCount++;
//记录需要移动下标
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
//数组末尾null gc就可以清楚回收
elementData[--size] = null; // clear to let GC do its work
}
获得指定位置的元素
E get(int index);
public E get(int index) {
// 校验 index 不要超过 size
rangeCheck(index);
// 获得 index 位置的原元素
return elementData(index);
}
private void rangeCheck(int index) {
if (index >= size)
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
E elementData(int index) {
return (E) elementData[index];
}
设置指定位置的元素
E set(int index, E element);
public E set(int index, E element) {
// 校验 index 不要超过 size
rangeCheck(index);
// 获得 index 位置的原元素
E oldValue = elementData(index);
// 修改 index 位置为新元素
elementData[index] = element;
return oldValue;
}
迭代器
Iterator iterator();
public Iterator<E> iterator() {
return new Itr();
}
//下次访问元素所在数组位置的下标
int cursor; // index of next element to return
/**
*上一次访问元素的位置
* 1. 初始化为 -1 ,表示无上一个访问的元素
* 2. 遍历到下一个元素时,lastRet 会指向当前元素,而 cursor 会指向下一个元素。这样,如果我们要实现 remove 方法,移除当前元素,就可以实现了。
* 3. 移除元素时,设置为 -1 ,表示最后访问的元素不存在了,都被移除咧。
*/
int lastRet = -1; // index of last element returned; -1 if no such
//创建迭代器时,数组修改次数。
int expectedModCount = modCount;
//判断是否存在下一个元素
public boolean hasNext() {
return cursor != size;
}
//获取下一个元素
public E next() {
//校验是否数组发生了变化
checkForComodification();
//i 记录当前 cursor 的位置
int i = cursor;
//判断下标是否超过数组元素范围
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
//判断是否超出数组长度,判断是否存在被修改
if (i >= elementData.length)
throw new ConcurrentModificationException();
//cursor指向下一个
cursor = i + 1;
//lastRet指向当前元素下标,并返回元素
return (E) elementData[lastRet = i];
}
public void remove() {
//如果lastRet小于0,代码当前没有指向任何元素,故抛出错误
if (lastRet < 0)
throw new IllegalStateException();
//校验数组是否发生变化
checkForComodification();
try {
//移除元素
ArrayList.this.remove(lastRet);
//cursor 指向 lastRet 位置,因为被移了,所以需要后退下
cursor = lastRet;
//lastRet 标记为 -1 ,因为当前元素被移除了
lastRet = -1;
//记录新的数组的修改次数
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
常见面试题
1、ArrayList、LinkedList、Vector的区别是什么?
考察点分析:
ArrayList、LinkedList、Vector 3个类都继承了List接口,主要考察对基础知识的了解程度,方便在合适的场景使用不同的容器。
答案:
| ArrayList | LinkedList | Vector | |
|---|---|---|---|
| 底层结构 | 数组 | 双向链表 | 数组 |
| 是否线程安全 | 否 | 否 | 是 |
| 是否可以存null | 否 | 否 | 是 |
| 特点 | 随机访问、查询快;在尾部添加效率高,其他地方插入慢 | 插入、删除快;随机访问查询慢; | 用synchronized关键字,只能单线程进行查询等 |
延伸
为什么ArrayList线程不安全,而Vector线程安全,如果实现ArrayList线程安全该怎么实现。
举例图解ArrayList线程不安全
原数组:{a} ,length=1,size=1
线程1需要插入{a,b,c,d,e,f,g} ,线程2需要插入{a,b,c}
假设线程1执行到ensureCapacityInternal时,此时的size=1,进行扩容,因为要插入7个元素,数组元素=7+1,数组按1.5倍扩容 = 3,所以数组扩容到8;此时线程2执行到ensureCapacityInternal(size+1)时,因为数组长度8还未到达扩容的条件,无需扩容;线程1进行添加元素,size+7,这时候size=8,这时线程1执行结束;线程2继续执行,由于刚才已经判断过不需要扩容,所以直接添加元素,但问题是线程1执行后size=8了,线程2再往里添加元素自然就报数组下标越界了.
Vector线程安全
vector之所以是线程安全的,是因为官方在可能涉及到线程不安全的操作都进行了synchronized操作,相当于官方帮你加了一把同步锁。
re: addElement(E obj)
ArrayList 实现线程安全:
List list = Collections.synchronizedList(new ArrayList());
CopyOnWriteArrayList copyOnWriteArrayList = new CopyOnWriteArrayList();
特点:
在线程数目增加时CopyOnWriteArrayList的写操作性能下降非常严重,而Collections.synchronizedList虽然有性能的降低,但下降并不明显。
在多线程进行读时,Collections.synchronizedList和CopyOnWriteArrayList均有性能的降低,但是Collections.synchronizedList的性能降低更加显著。
2、ArrayList为什么要进行扩容?它的扩容机制是什么样的?
考察点分析:
扩容是ArrayList一个非常重要的功能,考察对ArrayList源码熟悉程度
答案:
(1)如果数组是默认DEFAULTCAPACITY_EMPTY_ELEMENTDATA的空数组,且插入元素数小于默认首次扩容长度10,按照默认长度10进行扩容;如果插入元素数大于默认长度10,且小于最大整数,则按照元素数进行扩容;
如果插入元素大于最大整数,则按照最大整数进行扩容。
(2)如果数组不是DEFAULTCAPACITY_EMPTY_ELEMENTDATA的空数组,且插入元素数+数组原来的元素小于数组1.5倍扩容长度,按照数组1.5倍进行扩容;如果插入元素数+数组原来的元素大于数组1.5倍扩容长度,且小于最大整数,则按照插入元素数+数组原来的元素进行扩容;如果插入元素数+数组原来的元素大于最大整数,则按照最大整数进行扩容。
源码及流程查看前面add方法
3、Iterator 和 ListIterator 有什么区别?
考察点分析:
Iterator及ListIterator 特性理解(ArrayList 的 Iterator 源码前面已经解析)
答案:
Iterator只可以向前遍历,而LIstIterator可以双向遍历。
ListIterator从Iterator接口继承,然后添加了一些额外的功能,比如添加一个元素、替换一个元素、获取前面或后面元素的索引位置。
4、List如何一边遍历,一边删除?
考察点分析:
数组实战中使用注意事项及熟练程度。
答案:
(1)数组遍历方式
使用foreach循环遍历;
使用for循环遍历;
使用Iterator循环遍历;
(2)数组元素删除方式
ArrayList的remove()方法;
Iterator的remove()方法;
removeIf()方法;
错误示例:
List<String> list = new ArrayList<>();
list.add("1");
list.add("2");
list.add("3");
for (String str : list) {
if (str.equals("1")) {
list.remove(str);
}
}
直接跑异常java.util.ConcurrentModificationException,然后一脸懵;
通过查看代码块的字节码
foreach循环在实际执行时,其实使用的是Iterator,使用的核心方法是hasnext()和next()。
就沿着Iterator分析问题:
next()每次都会校验modCount != expectedModCount,
而remove()则会为modCount+1
如果使用foreach方法去一边遍历一遍删除,则会报错误ConcurrentModificationException
常用方式:
(1)iterator遍历,通过iterator.remove()方法移除
List<String> list = new ArrayList<>();
list.add("1");
list.add("2");
list.add("3");
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
String str = iterator.next();
if (str.equals("1")) {
iterator.remove();
}
}
不报错原因:iterator.remove()方法中回去主动修正expectedModCount
(2)for循环删除
List<String> list = new ArrayList<>();
list.add("1");
list.add("2");
list.add("3");
for (int i = 0; i < list.size(); i++) {
String item = list.get(i);
if (item.equals("1")) {
list.remove(i);
//这里一定要修正下标
i = i - 1;
}
}
(3)使用removeIf()方法
List<String> list = new ArrayList<>();
list.add("1");
list.add("2");
list.add("3");
list.removeIf(f -> "1".equals(f));
removeIf()方法源码,实质运用iterator遍历和删除
5、储元素的数组elementData为什么是transient修饰的
考察点分析:
ArrayList源码一些细节的了解程度,transient系列化中作用。
答案:
(1)对序列化、反序列化有了解。(blog.csdn.net/qq_62414755…)
(2)transient关键字作用:(blog.csdn.net/w139074301/…)
一旦变量被transient修饰,变量不再是对象持久化的一部分,该变量在反序列化后也无法获得;
transient关键字只能修饰变量,不能修饰方法和类;
一个静态变量不管是否被transient修饰,均不能被序列化;
(3)数组已经实现实现Serializable接口,可以序列化,为什么用transient修饰elementData,难道elementData不需要序列化?
ArrayList在序列化的时候通过调用writeObject()方法,将size和element写入ObjectOutputStream;反序列化时通过调用readObject(),从ObjectInputStream获取size和element,再恢复到elementData。而不是通过elementData来序列化。elementData是一个缓存数组,它通常会预留一些容量,等容量不足时再扩充容量,那么有些空间可能就没有实际存储元素,采用上诉的方式来实现序列化时,就可以保证只序列化实际存储的那些元素,而不是整个数组,从而节省空间和时间。
6、你知道集合中的Fail-fast机制吗?为什么要有这样的机制?
考察点分析:
考察你对集合中的一些核心设计理念的了解。
答案:
fail-fast概念:快速失败系统,通常设计用于停止有缺陷的过程,这是一种理念,在进行系统设计时优先考虑异常情况,一旦发生异常,直接停止并上报。
我们通常说的Java中的fail-fast机制,默认指的是Java集合中的一种错误检测机制。
例子:ArrayList中的get方法
public E get(int index) {
rangeCheck(index);
return elementData(index);
}
private void rangeCheck(int index) {
//这里就是数组采取的fail-fast的体现
if (index >= size)
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
数组通过下标获取元素,当下标大于等于size时,就直接抛出异常,并明确提示异常原因,这就是fail-fast的应用。为了避免执行接下来复杂的代码,另一方面可以根据错误原因进行针对性处理。
(blog.csdn.net/OYMNCHR/art…)