本篇文章主要收集关于JavaArrayList的常见问题
前言
本文主要以一问一答的方式来讲解ArrayList的常见问题,模拟面试官的提问,以及我们自己回答问题的方式。在学习完一个知识点后,我们需要学会处理相应的问题,以及学会如何应对面试官的提问。
Q:ArrayList是什么?
ArrayList是Java集合中的数组列表,实现了List接口,是用来存储数据的容器之一,底层的数据结构是数组。
Q:ArrayList有什么特点?
有序:按照顺序添加元素
不唯一:同一元素可以存放多次
查找和访问速度较快,增删元素较慢
Q:ArrayList和LinkedList的区别有哪些?
数据机构:
ArrayList的底层数据结构是数组,而LinkedList底层数据结构是链表。
查询访问效率:
ArrayList查询和访问速度较快,LinkedList查询和访问速度较慢。
增删效率:
ArrayList增删效率较慢,LinkedList增删效率较快。
Q:为何ArrayList在平常工作中很常用,虽然不是线程安全?
工作中大部分的操作是对数据的查询,而ArrayList的数据结构是数组,可以按数组下标进行查询速度快。如果涉及到大量的增删可以使用LinkedList。
Q:如何解决ArrayList线程不安全问题?
方案一:使用Vector替代ArrayList
Vector是线程安全的,其在方法上加了锁synchronized,这样每次只能一个线程进行操作,解决了线程不安全的问题,但是加了锁,导致并发的性能降低。
方案二:使用Collections.synchronized()方法
List list =Collections.synchronizedList(new ArrayList<>());使用Collections集合工具类,在ArrayList前面增加锁(同步)机制。
方案三:使用CopyOnWriteArrayList
CopyOnWriteArrayList是一个ArrayList的线程安全变体,原理是初始化时只有一个容器,通常情况下,多个线程都是读取同一个容器中的数据(只有读取操作),所以读取的数据都是唯一、安全的,如果此时添加了一个数据,CopyOnWriteArrayList会先copy出一个容器(副本),再往新容器添加这个新的数据,最后把新容器的引用地址赋给了旧的容器地址,添加数据期间,其他线程如果读取数据,还是读的旧数据。
final CopyOnWriteArrayList<>list=new CopyOnWriteArrayList<>(a);
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
public class CopyOnArr {
public static void main(String[] args){
List<String> a=new ArrayList<String>();
a.add("A");
a.add("B");
a.add("C");
final CopyOnWriteArrayList<String> list=new CopyOnWriteArrayList<>(a);
Thread t=new Thread(new Runnable() {
int count=-1;
@Override
public void run() {
while(true){
list.add(count++ +"");
}
}
});
t.setDaemon(true);
t.start();
//Thread.currentThread().wait(3);
for(String s:list){
System.out.println(list.hashCode());
System.out.println(s);
}
}
}
Q:为何ArrayList查询比LinkedList快,而增删比LinkedList慢?
查询:
ArrayList的数据结构是数组,且数据在内存中是连续的,成块的。我们可以根据数组的首地址+偏移量(数组下标)直接计算对应位置的元素,所以查询快。
LinkedLis的数据结构是链表,在内存中不是连续的一段空间,它的结构是「元素|下一个元素地址],当我们想要查找对应位置的元素时,它只能从首元素开始,依次获取下一个元素的地址,所以查询慢。
ArrayList查询时间复杂度O(1),LinkedList查询的时间复杂度为O(n)。
增删:
ArrayList在进行增删元素时,由于数据在内存中是连续的,就要移动对应元素后面的所有元素,即,每增删一次,所有对应位置后面的元素都需要向前或向后移动,因此增删效率低。
LinkedList在进行增删时,由于是链表,在添加元素时,只要将此元素位置的前一元素和后一元素关联到此元素,删除元素时,只要把要删除元素的前一元素和后一元素的关联断掉即可,不会影响其他元素,因此增删效率快。
ArrayList增删的时间复杂度O(n),LinkedList增删的时间复杂度为O(1)。
Q:具体演示ArrayList是如何增删元素的?
1.在指定位置增加元素时,调用了数组拷贝,即arraycopy方法,先copy一个一模一样的数组
2.copy指定位置的元素和后面所有元素
3.将copy的指定位置的元素和后面所有元素放到指定元素的位置+1的位置
4.在指定位置添加需要增加的元素
假设需要在index=5的位置新增一个元素A,需要怎么做?
1.先copy一个一模一样的数组
2.copy index=5及以后的所有元素,那么copy的是以下一段数组
3.将copy index=5及以后的所有元素放到新数组的index+1处,即新数组元素为6的位置,此时红色部分为5,而新数组元素为6的位置放入了5和以后的所有元素。(上面为原数组,下面为复制的新数组)
4.新增A元素覆index=5这个位置的元素(原本的数据是5)
5.将指向原数组位置的指针指向新数组(把新数组的引用地址赋给了旧数组地址)
总结:ArrayList每次增删一个元素时,数组每次都会进行copy,移动元素,所以ArrayList的增删效率低,但是在数组的末尾进行添加元素时,速度很快。
Q:为何ArrayList线程不安全?
1.首先在ArrayList源码中,定义了一个Object数组和记录数组大小的size:
transient Object[]elementData;
private int size;
在add源码中
public boolean add(E e){
ensureCapacityInternal(size+1);
elementData[size++]=e;
return true;
}
ensureCapacityInternal()方法是判断将新新元素加到列表中,当前elementData[]数组的大小是否满足,如果size+1的大小满足了elementData[]数组的长度,则不会扩容,而大于elementData[]数组的长度,则会扩容。
所以add元素时的步骤为:
1.判断当前elementData[]数组容量是否满足
2.在elementData[]数组添加元素
此时会有两种线程不安全的异常(数组越界)
add操作:
1.假设此时列表大小size为9
2.线程a开始进行添加元素操作,使用add方法,获取size为9,调用ensureCapacityInternal()方法判断此时的数组大小是否超过设定的elementData[]数组大小
3.线程b开始进行添加元素操作,使用add方法,获取size为9,调用ensureCapacityInternal()方法判断此时的数组大小是否超过设定的elementData[]数组大小
4.a线程经过判断,发现elementData[]数组大小为10,而需求的也为10,没有超过设定数组大小,不扩容
5.b线程经过判断,发现elementData[]数组大小为10,而需求的也为10,没有超过设定数组大小,不扩容
6.线程a开始将元素放入elementData[]数组,执行elementData[9]=e;放完后size为10
7.线程b开始将元素放入elementData[]数组,执行elementData[10]=e时报错,由于没有扩容,导致数组最多放入10个元素,即放入的数组下标为9,无法放入第11个元素,所以报错ArrayIndexOutOfBoundsException数组越界
elementData[size++]=e;这步也会出现线程不安全,其执行步骤为:
1.elementData[size]=e;
2,size=size+1
多线程执行时会出现一个线程的值覆盖另一个线程的值
例如:
1.数组列表大小size=0;
2.线程a添加了元素A,执行elementData[0]=A;将A放在elementData[]数组下标为0的位置
3.线程b添加元素B,此时线程b获得的size依然是0,于是将B也放在elementData[]数组下标为0的位置,执行elementData[0]=B
4.线程a执行完将size值+1为1
5.线程b执行完将size值+1为2
概括为:
1.size=0
2.elementData[0]=A//线程a添加元素A
3.elementData[0]=B//线程a添加元素A的同时(线程A添加元素时没有结束,size没有+1)线程b添加元素B
4.size=size+1=0+1=1//线程a添加完元素A,开始size+1
5.size=size+1=1+1=2//线程b添加完元素A,开始size+1
这样线程a和b执行完后,正常的情况应该是elementData下标0的位置为A,下标1的位置为B,但实际却变成了,下标为0的位置变成了B(elementData[0]=A;elementData[0]=B,执行后,B覆盖了A),下标1的位置没有值,因为下标为0的位置执行了两次添加元素,size+1也执行了两次为2,如果没有使用set方法修改1位置的值那么永远为null,添加的元素也会从下标为2开始,既导致了元素A的被覆盖而遗失掉,又导致了数组下标为1的位置的值永远为null,非常危险。
Q:ArrayList的初始化会不会初始化数组大小,会不会初始化list大小?
会初始化数组大小,但不会初始化list集合大小
import java.util.ArrayList;
import java.util.List;
public class IIII {
public static void main(String[] args){
ArrayList<Integer>list=new ArrayList<>(10);
System.out.println(list.size());
arrayList.add(1,1);
}
}
结果:
重要!!!
我们此时初始化的是数组的大小,实际上,list大小并没有初始化,此时的数组下标10不是list集合的大小,list.size()不等于这里数组的大小10,而是等于集合元素的个数。
修正:
第一种初始化:add添加元素到指定list大小
ArrayList<Integer>list=new ArrayList<>();
list.add(3);
list.add(4);
list.add(5);
System.out.println(list);
list.set(2,1);
System.out.println(list);
结果:
以上可以进行set操作
第二种初始化:直接提供list的值初始化
public static void main(String[] args){
ArrayList<Integer>list=new ArrayList<Integer>(Arrays.asList(1,2,3,4,5));
System.out.println(list);
list.set(2,1);
System.out.println(list);
}
好了,关于ArrayList的一些常见问题就先说到这,有新的问题会继续添加。