在面试的过程中, 经常会有面试官问道, ArrayList是什么,ArrayList和LinkedList有什么区别? 相信每个人都知道,ArrayList的底层是数组,LinkedList的底层是链表, LinkedList增删快,ArrayList查询快;它们两个都是线程不安全的; 而随着学习的深入,我们不能只知其然而不知其所以然; 我们要常常要问自己为什么. 为什么ArrayList查询快但是增删慢, 为什么ArrayList线程不安全?
带着这些问题, 我们从源码中一个一个的寻找出答案;
先从ArrayList的构造方法看起:
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);
}
}
transient Object[] elementData;
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
可以看到elementData 是一个Object类型的数组,它就是ArrayList中实际保存元素的地方;
当我们调用默认的空参构造方法时, 会将elementData 初始化成一个空数组,当传入一个数值型参数时,会初始化一个长度等于该值的数组, 这个地方就有人会有疑问了, 空数组怎么保存数据?不急, 我们接着往下看;
创建完ArrayList对象之后,我们自然是要调用add()方法添加参数了;因此, 我们来看add()方法:
ArrayList.add()
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
//设置元素到数组中
elementData[size++] = e;
return true;
}
private void ensureCapacityInternal(int minCapacity) {
//首次添加元素时,默认集合容量为10
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
private static final int DEFAULT_CAPACITY = 10;
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
//如果元素数量大于集合长度,则扩容
if (minCapacity - elementData.length > 0)
grow(minCapacity);
//扩容
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;//旧数组长度
int newCapacity = oldCapacity + (oldCapacity >> 1);//容量扩大为原来的1.5倍; 右移一位相当于缩小一半
//如果元素数量大于新容量,则将新容量设为元素数量
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
//将旧数组的元素复制到新数组中
elementData = Arrays.copyOf(elementData, newCapacity);
}
}
可以看到,在第一次添加元素时,如果数组为空, 则会默认初始化一个长度为10的数组; 当要新增的元素数量要大于数组长度时, ArrayList会重新创建一个长度是旧数组1.5倍的新数组,然后调用Arrays.copyOf(elementData, newCapacity)方法将旧数组的数据复制到新数组中;该过程通俗点讲,就是扩容; 扩容成功后,再将要添加的元素添加到新数组中; 这就是add()方法的整体流程; 从这里我们也能看出ArrayList能够动态扩容的原理了, 其实它就是在元素数量即将大于数组长度时,再创建新的数组来实现扩容的;
ArrayList.get(i)
public E get(int index) {
rangeCheck(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];
}
get()方法很简单, 先判断一下下标是否越界, 然后直接通过数组的索引获取对应的值
ArrayList.remove(int index)
public E remove(int index) {
rangeCheck(index);
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
//将index+1位置开始到末尾的元素向前移动一位,最后一位赋值为null,GC进行回收
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}
从代码中可以看到,remove中最重要的一部分就是System.arraycopy(elementData, index+1, elementData, index,numMoved);
该方法就是将index位置的后面的元素都向前移一位,从而达到了删除的作用;
如下一个数组, 假如我们要把元素c删掉:

则会将c元素后面的d,e,f元素都向前移一位,最终会达到这样的效果:

ArrayList.remove(Object o)
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)
//将index+1位置开始到末尾的元素向前移动一位,最后一位赋值为null,GC进行回收
System.arraycopy(elementData, index+1, elementData, index, numMoved);
elementData[--size] = null; // clear to let GC do its work
}
remove(Object o)也是同理, 先判断元素是否存在,根据元素获取到对应的索引, 然后执行"删除"操作;
看了两个remove方法的源码,咱们来看看一道关于remove方法的面试题;

该程序输出多少呢?
有些人可能有疑问,调的是哪个remove方法呢?两个方法的执行结果是不一样的. 是[1,3]还是[2,3]呢?
答案是[1,3];
因为我们传入的参数1是一个int类型的参数, 因此实际调的是remove(int index)方法, 所以答案是[1,3];
那如果将题目改成这样呢?

显然, 答案就是[2,3]了, 因为包装类型Integer是一个对象, 所以实际调的是remove(Object o)方法, 所以答案自然就是[1,3]了
你答对了么?
ArrayList为什么线程不安全?
从add()方法以及remove()方法的源码中可以看到; 他们分别都有一个elementData[size++] = e和elementData[–size] = null的操作;
size是ArrayList类中的共享变量,代表ArrayList的长度;我们都知道size++和–size是一个非原子性的操作; 在多线程并发的时候, 会出现读到相同size值的情况; 比如原先size是0 ;有两个线程同时调用add方法给ArrayList添加元素, 他们两个读取到的size值都是0, 这时, 就会出现后面添加的元素被覆盖的情况,因为执行了两次elementData[0]=e;elementData[–size]也是同理;
看完了ArrayList,咱们接着来看看LinkedList
LinkedList()
public LinkedList() {
}
可以看到,LinkedList()默认初始化时,没有做任何操作
LinkedList.add()
public boolean add(E e) {
linkLast(e);
return true;
}
void linkLast(E e) {
final Node<E> l = last;
//新创建一个节点,并将其前置指针指向原先的尾结点last
final Node<E> newNode = new Node<>(l, e, null);
//将新创建的节点设为尾结点
last = newNode;
//初始化,将新创建的节点设为首节点
if (l == null)
first = newNode;
else
//将原先的尾节点的后置指针指向新的尾结点(即新创建的节点)
l.next = newNode;
size++;
modCount++;
}
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
从代码中可以看出, LinkedList是由一个双向链表实现的, 其内置的Node类就是双向链表的节点, 节点类中包含有指向前置节点的属性prev和指向后置节点的元素next, 还有实际保存的数据的元素item;
咱们通过图解来看LinkedList添加元素的过程;
首次添加元素, 创建一个头结点, 头结点的前置节点prev和后置节点next都指向null;

再次添加元素, 新建一个节点a, 其前置节点prev指向头结点并设为last节点, 再将头结点的next指向它

再次添加元素, 新建一个节点b,其前置节点prev指向a并设为last节点, 再将a的next指向它

以此类推;这就是LinkedList添加元素的流程
从这里有些人可能有些疑问, 为什么要用双向链表而不用单链表,单链表也能实现这样添加元素的操作吧.
没事,我们接着往下看LinkedList的get方法:
LinkedList.get(int index)
public E get(int index) {
//检查index是否溢出
checkElementIndex(index);
return node(index).item;
}
Node<E> node(int index) {
//判断index是否小于size的一半,如果小于,
// 则从头结点开始往链表后面找,如果大于则从尾结点往前面找
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
get方法很简单, 判断index是否小于size的二分之一, size>>1的意思是将size的二进制右移一位,相当于将size变为原来的二分之一;
如果小于,则从头结点开始往链表后面找,如果大于则从尾结点往前面找:

如图所示,size为5,当index=2时,小于5的二分之一, 则从first节点开始通过next往后, 直到查到b节点; 同理,当index>size/2时, 就从last节点通过prev一直往前遍历,直到找到index;
那么, 如果是单链表的时候, 是什么样子的呢?

如图,如果采用单链表的话, 要查询的时候只能通过first节点往后遍历查找数据, 当index值很大时, 例如要查找c节点,那么要遍历3次, 而双向链表遍历会从last节点开始往前找,只需要遍历1次; 从这里,你是否看出来为什么使用的是双向链表而不是单链表了么?其实就是为了加快查询的效率
LinkedList.remove(int index)
public E remove(int index) {
checkElementIndex(index);
return unlink(node(index));
}
E unlink(Node<E> x) {
// assert x != null;
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev;
//prev为null说明当前节点是头结点
if (prev == null) {
//将当前节点的后置节点设为新的头结点
first = next;
} else {
//将前置节点的后置节点指向当前线程的后置节点
prev.next = next;
//当前线程的前置节点设为null
x.prev = null;
}
//当前节点是尾结点
if (next == null) {
//将当前节点的前置节点设为新的尾结点
last = prev;
} else {
//将后置节点的前置节点指向当前线程的前置节点
next.prev = prev;
//当前线程的后置节点设为null
x.next = null;
}
//清除当前节点
x.item = null;
size--;
modCount++;
return element;
}
直接看注释的说明可能有点乱, 咱们结合图解一起说明;
比如当前,咱们要删除b节点:

第一步,先将b的prev清除, 将a的next指向c

第二步, 将b的next清除, 将c的prev指向a

第三步,清除b,删除成功

有两个特殊情况就是删除头结点和尾节点的时候;
比如现在再删除头结点first:
第一步, 将a设为新的first

第二步, next指向null, a(当前为first)的prev指向null

第三部 清除旧的first, 删除完成

删除尾结点同理
LinkedList为什么线程不安全?
咱们回到add()方法的源码:


结合图和代码我们可以看到, 现在, 线程1要插入b节点, 线程2要插入a节点,;在线程1执行到代码中的第2步之前,已经将b的prev指向了last,但是还未将b更新为新的last, 此时, 线程2插入a节点,获取到的last还是旧的last,因此,会将a的prev指向旧last; 最后, 会造成上图中的情况. 这自然是有问题的.
ArrayList和LinkedList的区别
- ArrayList底层是一个Object类型的数组, LinkedList底层是一个双向链表
- ArrayList查询效率比LinkedList快.为什么? 从两者的源码中就能看出, ArrayList可以通过索引,直接获取到索引对应的值;而 LinkedList需要从头结点或者尾结点向index进行遍历, 自然要比ArrayList查询慢了;
- LinkedList增删效率比ArrayList快. 为什么? 因为ArrayList在元素数量要大于集合容量时,会有一个扩容的操作,扩容会创建一个新的长度是原来1.5倍的数组, 再将旧数组的数据复制到新数组中; 在删除时,需要将删除的元素之后元素都向前移一位; 而LinkedList在添加元素时,只需要创建一个新的节点添加到链表的尾部, 在删除时,只需要执行一次查找的操作,再删除对应的节点; 综合起来,增删要比ArrayList快不少
ArrayList和LinkedList的相同点
- 都线程不安全
- 都属于List
- 都能存null值
有线程安全的List么
1. CopyOnWriteArrayList
CopyOnWrite: 顾名思义.写时复制;
public CopyOnWriteArrayList() {
setArray(new Object[0]);
}
public boolean add(E e) {
final ReentrantLock lock = this.lock;
//采用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 remove(int index) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
E oldValue = get(elements, index);
int numMoved = len - index - 1;
if (numMoved == 0)
setArray(Arrays.copyOf(elements, len - 1));
else {
Object[] newElements = new Object[len - 1];
System.arraycopy(elements, 0, newElements, 0, index);
System.arraycopy(elements, index + 1, newElements, index,
numMoved);
setArray(newElements);
}
return oldValue;
} finally {
lock.unlock();
}
}
public E get(int index) {
return get(getArray(), index);
}
private E get(Object[] a, int index) {
return (E) a[index];
}
从代码中也可以看到, CopyOnWriteArrayList在每次add新元素的时候,都会创建一个新的数组数组对象, 而且采用了 ReentrantLock锁加锁保证了增删元素时的线程安全; 但是我们可以看到, 其get方法是没有加锁的, 也因此, get方法并不能获取到实时add的数据;
因为get方法并没有加锁, 因此在并发环境下可能会存在以下三种情况:
1、如果写操作未完成,那么直接读取原数组的数据;
2、如果写操作完成,但是引用还未指向新数组,那么也是读取原数组数据;
3、如果写操作完成,并且引用已经指向了新的数组,那么直接从新数组中读取数据。
使用CopyOnWriteArrayList要注意的一些问题:
- 不能用于实时读的场景,像拷贝数组、新增元素都需要时间,所以调用一个set操作后,读取到数据可能还是旧的,虽然CopyOnWriteArrayList
能做到最终一致性,但是还是没法满足实时性要求 - CopyOnWriteArrayList 合适读多写少的场景,慎用 ,因为没法保证CopyOnWriteArrayList
到底要放置多少数据,万一数据稍微有点多,每次add/set都要重新复制数组,这个代价实在太高昂了。在高性能的互联网应用中,这种操作分分钟引起故障。
2. Vector
public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}
private void ensureCapacityHelper(int minCapacity) {
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
public synchronized E get(int index) {
if (index >= elementCount)
throw new ArrayIndexOutOfBoundsException(index);
return elementData(index);
public boolean remove(Object o) {
return removeElement(o);
}
public synchronized boolean removeElement(Object obj) {
modCount++;
int i = indexOf(obj);
if (i >= 0) {
removeElementAt(i);
return true;
}
return false;
}
}
可以看到, Vector的的实现方式和ArrayList基本上是一样的, 它所有的方法都采用了synchronized 关键字修饰, 从而保证了线程安全
那么, Vector除了线程安全,和ArrayList还有其他的区别么;
我们看Vector的构造方法:
public Vector(int initialCapacity, int capacityIncrement) {
super();
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
this.elementData = new Object[initialCapacity];
this.capacityIncrement = capacityIncrement;
}
可以看到,Vector比ArrayList多了一个capacityIncrement 变量, 这个变量是用来设置每次扩容的数量的, 那如果没设置该值时, 默认的扩容数量是多少呢?
我们来看Vector的扩容方法grow():
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
//扩容后的数组是原来的两倍
int newCapacity = oldCapacity + ((capacityIncrement > 0) ?
capacityIncrement : oldCapacity);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
elementData = Arrays.copyOf(elementData, newCapacity);
}
可以看到,Vector扩容后的数组是原来的两倍, 而ArrayList扩容时原来的1.5倍
3. Collections.SynchronizedList
final List<E> list;
SynchronizedList(List<E> list) {
super(list);
this.list = list;
}
SynchronizedList(List<E> list, Object mutex) {
super(list, mutex);
this.list = list;
}
public void add(int index, E element) {
synchronized (mutex) {list.add(index, element);}
}
public E get(int index) {
synchronized (mutex) {return list.get(index);}
}
public E remove(int index) {
synchronized (mutex) {return list.remove(index);}
}
可以看到, Collections.SynchronizedList在初始化时要传入一个集合对象, 而它就是在原先集合的对应方法上都加上了synchronized 修饰,从而保证了线程安全
本文深入探讨了ArrayList和LinkedList的数据结构、操作原理及线程安全性,对比了两者在查询、增删操作上的性能差异,同时介绍了几种线程安全的List实现。
1895

被折叠的 条评论
为什么被折叠?



