1. ArrayList和LinkedList的实现简介
1.1 从数据结构方面来说
ArrayList是通过数组实现的一个元素列表,可以通过索引访问。内部数组的默认长度是10,当不满足需求时,会自动扩充到原来的1.5倍长度。可以通过构造函数修改这个默认值。
LinkedList则是通过双向链表的方式实现的,内部是一个个Node对象,没有索引值,查找一个元素时是根据索引大小决定从头部或者尾部的方向遍历。
所以,在修改List结构时(例如插入、删除一个元素),ArrayList因为需要通过数组拷贝的方式实现,所以是比较耗时的。
而LinkedList使用链表的方式,可以很简单的修改结构,但相对来说,查询索引则比较麻烦。
例如一个size为100的list,需要在索引为50的地方插入一个元素,list.add(50, obj);
。
如果这个list是ArrayList,那么它会做两件事:
1. 判断内部数组的长度是否能再存入一个元素,否则将数组扩充(数组拷贝的方式)到原来的额1.5倍,此时数组长度可能为150。
2. 再次执行数组拷贝动作
此时,ArrayList插入一个元素的代价是比较高的,可能执行了两次数组拷贝动作。
如果这个list是LinkedList,那么它也会做两件事:
1. 从头部开始遍历,直到第50个节点
2. 在该位置插入元素
所以,Linked虽然插入一个元素的代价很低,但是它也有遍历节点产生的耗时。
1.2 从内存分配方面来说
ArrayList和LinkedList哪个更耗费内存不好分析,但是从内存分配上来说是LinkedList更占优势的。
就像上面的例子,ArrayList的内部数组长度变为150,但实际上只使用到了101个位置。并且数组的内存分配是需要一片连续的空间的。
而LinkedList,因为内部其实是一个一个Node对象,所以不需要用到连续的内存。但是每一个Node对象中需要记录三个引用(当前对象,上一个对象,下一个对象)。
2. ArrayList源码解析
2.1 成员
来看看ArrayList几个重要的成员:
// 数组默认长度
private static final int DEFAULT_CAPACITY = 10;
// 两个静态的空数组
private static final Object[] EMPTY_ELEMENTDATA = {};
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
// 实际存放元素的数组
transient Object[] elementData;
// 数组中元素的个数(注意,不是elementData的长度)
private int size;
// 父类AbstractList中的成员
protected transient int modCount = 0;
空数组:
首先EMPTY_ELEMENTDATA
和DEFAULTCAPACITY_EMPTY_ELEMENTDATA
都是静态的长度为0的Object数组,用来区分是否拥有默认长度,判断在添加第一个元素时,需要扩充多少长度。
例如,默认情况下,定义一个ArrayList时使用的是DEFAULTCAPACITY_EMPTY_ELEMENTDATA
这个数组,在第一次添加元素时,会拷贝一个长度为10的数组。
而使用new ArrayList(0)
定义时,使用的是EMPTY_ELEMENTDATA
这个数组,在第一次添加元素时,会拷贝一个长度为1的数组。
相关代码如下,add系列方法都会调用下面这个方法检测是否需要扩充长度:
private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
modCount
修改结构的次数,例如删除一个元素时,modCount会自增一次。主要作用是在使用快速迭代时,判断是否修改了List的结构,抛出ConcurrentModificationException(并发修改)异常。
例如下面代码会抛出这个异常:
public static void main(String[] args) {
ArrayList<Integer> list = new ArrayList<>();
list.addAll(Arrays.asList(1,2,3,4,5,6,7,8));
for (Integer integer : list) {
if (integer < 5) {
list.remove(integer);
}
}
}
2.2 构造
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
我们最常用的是这种构造方式,当第一次添加元素时,会拷贝一个长度为10的数组。
另外,我们还可以自己指定默认长度:
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);
}
}
关于EMPTY_ELEMENTDATA
和DEFAULTCAPACITY_EMPTY_ELEMENTDATA
上面已经说过,不再赘述。
如果initialCapacity大于0时,会直接创建指定长度的数组。
还可以通过其它集合构造,这个就不详细说明了。
public ArrayList(Collection<? extends E> c)
2.3 主要函数
add方法:
public void add(int index, E element) {
// 检查index是否越界
rangeCheckForAdd(index);
// 扩充数组长度(如果需要的话)
ensureCapacityInternal(size + 1); // Increments modCount!!
// 通过数组拷贝的方式添加元素。
// (原数组,原索引,目标数组,目标索引,拷贝长度)
// 例如[0,1,2,3,4,5, , , ],拷贝过后可能为[0,1,2,3, ,4,5, , ]
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
elementData[index] = element;
size++;
}
数组扩充:
private void ensureCapacityInternal(int minCapacity) {
// new ArrayList()的方式,elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(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;
// 主要看这行代码,延长原来长度的1.5倍。
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);
}
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
return (minCapacity > MAX_ARRAY_SIZE) ?
Integer.MAX_VALUE :
MAX_ARRAY_SIZE;
}
addAll、remove等方法类似,都是数组拷贝,就不赘述了。
3. LinkedList源码解析
LinkedList不仅仅只是一个列表,它还实现了Deque接口,可以作为双向队列或队列使用。
例如:
Queue<String> queue = new LinkedList<>();
Deque<String> deque = new LinkedList<>();
3.1 成员
// 链表的长度
transient int size = 0;
// 链表的开始节点
transient Node<E> first;
// 链表的结束节点
transient Node<E> last;
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;
}
}
Node是LinkedList的一个静态内部类,可以看到链表的结构如上,每个节点分别持有上一个节点和下一个节点的引用,从而形成双向链表结构。
3.2 构造
public LinkedList() {}
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);
}
只有两个简单的构造。
l
3.3 主要函数
我们先来跟踪一下add方法。
public boolean add(E e) {
linkLast(e);
return true;
}
void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
add(E e)方法比较简单,不赘述了。来看看add方法的另一个重载:
public void add(int index, E element) {
// 检查角标是否越界
checkPositionIndex(index);
// 如果index == size,那么直接在最后插入,提高性能。
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
// node方法就是获取索引的值。根据索引的大小,判断从头部还是尾部开始遍历。
Node<E> node(int index) {
// assert isElementIndex(index);
// 索引小于长度的一半,从头部开始遍历,否则从尾部开始遍历。
// 最终返回目标节点。
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;
}
}
// e是需要插入的元素,succ则是需要插入的索引对应的节点。
// 此方法是将一个新的节点插入到succ渊先的位置。
void linkBefore(E e, Node<E> succ) {
// assert succ != null;
final Node<E> pred = succ.prev;
final Node<E> newNode = new Node<>(pred, e, succ);
succ.prev = newNode;
if (pred == null)
first = newNode;
else
pred.next = newNode;
size++;
modCount++;
}
另外,remove(E e)、remove(int index)等操作集合的方法,都会调用一系列linkXxx()或者unLinkXxx()方法,都是使用近似的逻辑操作节点,就不再赘述了。
4. 再说modCount(fail-fast)
4.1 fail-fast机制
上面简单说过modCount是用来判断集合迭代时是否出现并发修改问题的(forEach实际上是通过迭代器实现的),而这个检查机制称为“fail-fast”机制。
通过迭代器自身提供的方法,在一定程度上可以解决fail-fast问题,不过这些方法是有限的。
Iterator.remove();
ListIterator.remove();
ListIterator.add(E e);
ListIterator.set(E e);
另外,ListIterator除了比Iterator多了两个可修改集合的方法外,还可以进行双向遍历,而Iterator只能向后遍历。
那么,List是如何通过modCount实现fail-fast判断的呢?
在进行集合的修改操作时,会让modCount自增,例如ArrayList调用add方法,会在在检查长度时做这个操作:
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
而获取迭代器时,会记录下这个modCount的值,在每次调用next()或remove()等方法时,都会检查这个值是否被改变。
private class Itr implements Iterator<E> {
int cursor; // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount;
// 省略其余代码
final void checkForComodification() {
// 每次迭代时都会判断modCount是否被改变。
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
而迭代器自身提供的修改集合的方法不会出现fail-fast问题,是因为在这些方法中重置了expectedModCount的值:
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();
}
}
4.2 使用concurrent包下的容器类避免fail-fast
但是,上面这种方式仅仅对于单线程环境而言是安全的,在多线程环境中,虽然我们可以使用这个方式让一个List集合转换成线程安全的集合:
// 返回的是SynchronizedList,一个List的装饰类,给所有方法加上同步锁。
List<Integer> syncList = Collections.synchronizedList(new ArrayList<>());
但是这并不能解决多线程环境的fail-fast问题,因为多线程的fail-fast是这么产生的:
同步锁并不能阻止一个线程在迭代时让另一个线程修改这个集合,所以当线程A修改了集合,导致modCount自增,而线程B正在迭代,判断出expectedModCount不等于modCount,从而引发了并发修改异常。
而有效的解决fail-fast问题的办法是使用concurrent包下的容器类,例如CopyOnWriteArrayList。而CopyOnWriteArrayList不单单只是解决了多线程的fail-fast问题,而是从根本原因上避免这个问题,无论是多线程还是单线程。
那么CopyOnWriteArrayList是如何避免fail-fast问题的呢?
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();
}
}
例如add方法,实际上是在添加一个元素时,是拷贝了一个新的数组,然后这个新的数组上操作,最后再将这个新的数组替换掉原来的。这样,在迭代时就可以直接使用List本身的方法,不需要使用迭代器的方法去修改集合结构,因为迭代中的数组和集合当前的数组可能已经不是同一个数组了,自然就没有了fail-fast问题。所以,CopyOnWriteArrayList的迭代器也没有实现add、remove等方法,因为根本不需要。
public static void main(String[] args) {
// ArrayList<Integer> list = new ArrayList<>();
CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
list.addAll(Arrays.asList(1,2,3,4,5,6,7,8));
for (Integer integer : list) {
if (integer < 5) {
list.remove(integer);
}
}
System.out.println(list);
}
在之前使用ArrayList时,上面的代码会引发ConcurrentModificationException
,但是替换成CopyOnWriteArrayList就没问题了,打印结果为[5, 6, 7, 8]
。
4.3 弱一致性(weakly consistency)
有些文章将CopyOnWriteArrayList这类容器说成是“fail-safe”的,实际上java中并没有这个机制,相对的应该叫weakly consistency(弱一致性)。
例如,线程A正在迭代一个集合,此时线程B往集合末尾添加一个元素,线程A的迭代器是无法迭代到这个新增的元素的,因为操作的不是同一个数组。
事实上看看CopyOnWriteArrayList的get方法就知道“弱一致性”体现在哪。
public E get(int index) {
return get(getArray(), index);
}
private E get(Object[] a, int index) {
return (E) a[index];
}
get方法并没有加锁,所以线程A可能获取不到线程B新增的一个元素,或者获取到了线程B已经移除的元素。
CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。