本文从代码审查过程中发现的一个 ArrayList 相干的「线程平安」问题登程,来分析和了解线程平安。
案例剖析
前两天在代码 Review 的过程中,看到有小伙伴用了相似以下的写法:
List<String> resultList = new ArrayList<>();
paramList.parallelStream().forEach(v -> {String value = doSomething(v);
resultList.add(value);
});
印象中 ArrayList 是线程不平安的,而这里会多线程改写同一个 ArrayList 对象,感觉这样的写法会有问题,于是看了下 ArrayList 的实现来确认问题,同时温习下相干常识。
先贴个概念:
线程平安 是程式设计中的术语,指某个函数、函数库在多线程环境中被调用时,可能正确地解决多个线程之间的共享变量,使程序性能正确实现。——维基百科
咱们来看下 ArrayList 源码里与本话题相干的要害信息:
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
// ...
/**
* The array buffer into which the elements of the ArrayList are stored.
* The capacity of the ArrayList is the length of this array buffer...
*/
transient Object[] elementData; // non-private to simplify nested class access
/**
* The size of the ArrayList (the number of elements it contains).
*/
private int size;
// ...
/**
* Appends the specified element to the end of this list...
*/
public boolean add(E e) {ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
// ...
}
从中咱们能够关注到对于 ArrayList 的几点信息:
- 应用数组存储数据,即
elementData
- 应用 int 成员变量
size
记录理论元素个数 -
add
办法逻辑与执行程序:- 执行
ensureCapacityInternal(size + 1)
:确认elementData
的容量是否够用,不够用的话扩容一半(申请一个新的大数组,将elementData
里的原有内容 copy 过来,而后将新的大数组赋值给elementData
) - 执行
elementData[size] = e;
- 执行
size++
- 执行
为了不便了解这里探讨的「线程平安问题」,咱们选一个最简略的执行门路来剖析,假如有 A 和 B 两个线程同时调用 ArrayList.add
办法,而此时 elementData
容量为 8,size
为 7,足以包容一个新增的元素,那么可能产生什么景象呢?
一种可能的执行程序是:
- 线程 A 和 B 同时执行了
ensureCapacityInternal(size + 1)
,因7 + 1
并没超过elementData
的容量 8,所以并未扩容 - 线程 A 先执行
elementData[size++] = e;
,此时size
变为 8 - 线程 B 执行
elementData[size++] = e;
,因为elementData
数组长度为 8,却拜访elementData[8]
,数组下标越界
程序会抛出异样,无奈失常执行完,依据前文提到的线程平安的定义,很显然这曾经是属于线程不平安的状况了。
结构示例代码验证
有了以上的了解之后,咱们来写一段简略的示例代码,验证以上问题的确可能产生:
List<Integer> resultList = new ArrayList<>();
List<Integer> paramList = new ArrayList<>();
int length = 10000;
for (int i = 0; i < length; i++) {paramList.add(i);
}
paramList.parallelStream().forEach(resultList::add);
执行以上代码有可能体现失常,但更可能是遇到以下异样:
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException
at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
at java.lang.reflect.Constructor.newInstance(Constructor.java:423)
at java.util.concurrent.ForkJoinTask.getThrowableException(ForkJoinTask.java:598)
at java.util.concurrent.ForkJoinTask.reportException(ForkJoinTask.java:677)
at java.util.concurrent.ForkJoinTask.invoke(ForkJoinTask.java:735)
at java.util.stream.ForEachOps$ForEachOp.evaluateParallel(ForEachOps.java:160)
at java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateParallel(ForEachOps.java:174)
at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:233)
at java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:418)
at java.util.stream.ReferencePipeline$Head.forEach(ReferencePipeline.java:583)
at concurrent.ConcurrentTest.main(ConcurrentTest.java:18)
Caused by: java.lang.ArrayIndexOutOfBoundsException: 1234
at java.util.ArrayList.add(ArrayList.java:465)
at java.util.stream.ForEachOps$ForEachOp$OfRef.accept(ForEachOps.java:184)
at java.util.ArrayList$ArrayListSpliterator.forEachRemaining(ArrayList.java:1384)
at java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:482)
at java.util.stream.ForEachOps$ForEachTask.compute(ForEachOps.java:291)
at java.util.concurrent.CountedCompleter.exec(CountedCompleter.java:731)
at java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:289)
at java.util.concurrent.ForkJoinPool$WorkQueue.runTask(ForkJoinPool.java:1067)
at java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1703)
at java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:172)
从我这里试验的状况来看,length
值小的时候,因为达到容量边缘须要扩容的次数少,不易重现,将 length
值调到比拟大时,异样抛出率就很高了。
实际上除了抛出这种异样外,以上场景还可能造成数据笼罩 / 失落、ArrayList 里理论寄存的元素个数与 size 值不符等其它问题,感兴趣的同学能够持续开掘一下。
解决方案
对这类问题常见的无效解决思路就是对共享的资源拜访加锁。
我提出代码审查的修改意见后,小伙伴将文首代码里的
List<String> resultList = new ArrayList<>();
批改为了
List<String> resultList = Collections.synchronizedList(new ArrayList<>());
这样理论最终会应用 SynchronizedRandomAccessList
,看它的实现类,其实外面也是加锁,它外部持有一个 List,用 synchronized 关键字管制对 List 的读写访问,这是一种思路——应用线程平安的汇合类,对应的还能够应用 Vector 等其它相似的类来解决问题。
另外一种方思路是手动对要害代码段加锁,比方咱们也能够将
resultList.add(value);
批改为
synchronized (mutex) {resultList.add(value);
}
小结
Java 8 的并行流提供了很不便的并行处理、晋升程序执行效率的写法,咱们在编码的过程中,对用到多线程的中央要保持警惕,无意识地预防此类问题。
对应的,咱们在做代码审查的过程中,也要对波及到多线程应用的场景时刻绷着一根弦,在代码合入前把好关,将隐患拒之门外。
参考
- 线程平安——维基百科