这是《码出高效》的第一篇笔记,会记录第六章“数据结构与集合”的6.7小节的一个知识点:fail-fast机制。因为之前的几个章节都很快速的看过去了,没有来得及写下来心得,后续会慢慢补上。
之所以第一个讲fail-fast是因为对于我来说之前从来没有意识到这样的一个问题会造成严重的危害。
fail-fast介绍
fail-fast在java里是对集合执行遍历操作时的错误检测机制,常常出现在多线程操作下,当线程A去遍历一个集合时,该集合被其他线程修改了,就会立马抛出异常。因为线程的集合会维护一个expectedModCount和modCount,expectedModCount是记录着集合被修改的次数,在遍历前和modCount相等,一旦被集合被操作了,expectedModCount就会加一,但是modCount却没有被通知到,如果两者不相等了就会抛出ConcurrentModificationException异常。
java.util包下的所有集合都是fail-fast的。
fail-safe则和fail-safe不同,它对底层集合做拷贝,不被源集合的修改影响,concurrent包下的集合都是fail-safe。
事故现场
事故一
public class Test3 {
public static void main(String[] args) {
List<String> list = new ArrayList<>(5);
list.add("1");
list.add("2");
list.add("3");
list.add("4");
list.add("5");
List<String> subList = list.subList(0,3);
//下面三行不注释,操作subList时会抛出ConcurrentModificationException
list.remove(0);
list.add("10");
list.clear();
subList.clear();
subList.add("6");
subList.add("7");
subList.remove(0);
for(String s : list){
System.out.println(s);
}
//打印[7, 4, 5]
System.out.println(list);
}
}
这里面有几个注意点:
- subList()出来的子列表无法序列化
subList返回的是ArrayList
的内部类SubList
,它没有实现Searlizable接口。
private class SubList extends AbstractList<E> implements RandomAccess
-
subList的修改会同时修改list。
-
list(主列表)的修改会让子列表操作抛出异常。
事故二
用foreach遍历集合演示fail-fast机制。
public class Test2 {
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
list.add("1");
list.add("2");
list.add("3");
list.add("4");
list.add("5");
for (String s : list) {
System.out.println(s);
//将“4”换成其他元素则会异常
if(s.equals("4")){
list.remove(s);
}
System.out.println("after if");
}
//输出[1, 2, 3, 5]
System.out.println(list);
}
}
依然是一个list里面有五个数据,然后foreach循环,当等于"4"的时候,将它从列表的移除。编译运行一切正常,最后输出结果。但是将判断条件换成“4”以外的元素的时候,就会触发fail-fast,抛出ConcurrentModificationException。
源码
这个问题的关键在于ArrayList
的内部类Itr
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;
public boolean hasNext() {
return cursor != size;
}
@SuppressWarnings("unchecked")
public E next() {
checkForComodification();
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
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();
}
}
@Override
@SuppressWarnings("unchecked")
public void forEachRemaining(Consumer<? super E> consumer) {
Objects.requireNonNull(consumer);
final int size = ArrayList.this.size;
int i = cursor;
if (i >= size) {
return;
}
final Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length) {
throw new ConcurrentModificationException();
}
while (i != size && modCount == expectedModCount) {
consumer.accept((E) elementData[i++]);
}
// update once at end of iteration to reduce heap write traffic
cursor = i;
lastRet = i - 1;
checkForComodification();
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
为什么恰好4的时候不报错呢,因为循环的cursor从0开始,每次加1,当cursor等于列表size的时候,就退出遍历,当遍历到“4”的时候,删除4,此时cursor加一变成了4,同时size也成了4,这个时候遍历就退出了,5不会被遍历到和在循环中被打印出来。但是其他情况下,cursor和size不相等,因此会继续执行next()操作,执行next()方法第一行的checkForComodification(),这个方法会去检查expectedModCount和modCount是否相等,因为执行了一次remove,expectedModCount+1变成6,但是modCount没有变还是5,因此就抛出异常了。
solution
1.在多线程环境下,用Iterator遍历时要加锁
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()){
synchronized (对象){
String item = iterator.next();
if(){
iterator.remove();
}
}
}
2.使用CopyOnWriteArrayList 代替 ArrayList
CopyOnWriteArrayList内部对iterator加锁,COW是fail-safe的,它实行读写分离,写操作会复制一个新集合,在新集合上操作元素,操作完成后将原来的引用指向新集合。
使用COWList有两个注意点:
- 尽量设置合理的初始容量,扩容成本较大。
- 使用批处理操作,addAll和removeAll等。
COWList适用于读多写极少的场景。
COW满足了一致性,但是读取不到最新的数据(CAP 一致性和可用性之间的矛盾)。