最近在做项目的时候使用到了jdk8的parallelStream想来加快程序的执行效率,没有想到得到的集合里面有很多null,同时偶尔也会出现ArrayIndexOutOfBoundsException错误,下面就逐步进行解析。
Jdk8有如下几个新特性:
>Lambda表达式:允许把函数作为一个方法的参数传递进去。
>方法引用:直接引用已有Java类或对象(示例)的方法或构造器。
>Date Time API:加强对日期和时间的处理。
>Stream API:Stream是对集合对象功能的增强,它专注于对集合对象进行各种非常便利、高效的聚合操作,它提供串行(stream)和并行(parallelStream)两种模式进行汇聚操作,并发模式能够充分利用多核处理器的优势,使用fork/join方式来拆分任务和加速处理过程。
stream():为集合创建串行流
parallelStream():为集合创建并行流
forEach:用来迭代流中的每个数据
举个例子,把一个list中的值赋值到另外一个list中:
@Test
public void streamTest1() {
int LOOPS = 30;
List<Integer> sourceList = new ArrayList<>();
List<Integer> streamResList = new ArrayList<>();
List<Integer> parallelStreamResList = new ArrayList<>();
for(int i = 0 ; i < LOOPS; i++) {
sourceList.add(i);
}
System.out.println("sourceList.size = " + sourceList.size());
System.out.println("sourceList = " + sourceList);
sourceList.stream().forEach(item -> {
streamResList.add(item);
});
System.out.println("streamResList.size = " + streamResList.size());
System.out.println("streamResList = " + streamResList);
sourceList.parallelStream().forEach(item -> {
parallelStreamResList.add(item);
});
System.out.println("parallelStreamResList.size = " + parallelStreamResList.size());
System.out.println("parallelStreamResList = " + parallelStreamResList);
}
输出结果:
结果解析:
由于stream是串行执行的,最终的大小和内容都符合预期,但是parallelStream得到的结果却是不符合预期,数量少了两个(size=28,实际上少了5,9,24三个数),并且数组中还要null,先不考虑parallelStream的效率,结果不对是为何?
先说结论:parallelStreamResList用的是ArrayList,非线程安全导致结果不符合预期。如果把ArrayList改成Vector或者Collections.synchronizedList(new ArrayList())或者CopyOnWriteArrayList等线程安全即可。
ArrayList是线程不安全的?
一言以蔽之,ArrayList中没有加锁的动作(Synchronized或Lock),会造成数据不一致。
List<Integer> streamResList = new ArrayList<>();
/**
* Constructs an empty list with an initial capacity of ten.
*/
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
构建一个初始容量大小为10的空列表。再看一下add动作:
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
确认容量的大小;然后将元素写入到数组末端;看一下ensureCapacityInternal方法:
private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
如果尚未扩容,比较默认容量DEFAULT_CAPACITY和minCapacity并取最大值,然后调用ensureExplicitCapacity确认精确容量:
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
通过grow扩容:
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
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);
}
新大小newCapacity扩容到oldCapacity的1.5倍,并通过Arrays.copyOf得到新的扩容后的数据。当然问题还是出现在add动作的时候的size的读取和赋值过程中,在多个线程读写size的时候(执行size++动作),造成数据不一致,size偏小。
vector的add方法,通过synchronized关键字加锁:
public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}
CopyOnWriteArrayList的add方法,通过Lock进行加锁:
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();
}
}
parallelStream的并发原理:
parallelStream是一个并行执行的流,使用ForkJoinPool并行方式来拆分任务和加速处理过程。ForkJoinPool的使用分治法将大人物拆分成子任务,分别放到不同的双端队列总,并未每个队列创建单独的线程来执行队列里的任务。同时采用了工作窃取算法来最大限度地提高并行处理能力。
双端队列和工作密取
Java6增加了两种容器类型,Deque(发音为"Deck")和BlockingQueue,它们分别对Queue和BlockingQueue进行了扩展。Deque是一个双端队列,实现了队列头和队列尾的高效插入和移除。具体实现包括ArrayDeque和LinkedBlockingDeque。
正如阻塞队列适用于生产者-消费者模式,双端队列同样适用于另一种相关模式,即工作密取(Working Stealing)。在生成者-消费者设计中,所有消费者有一个共享的工作队列,而在工作密取设计中,每个消费者都有各自的双端队列。如果一个消费者完成了自己双端队列中全部工作,那么它可以从其他消费者双端队列末尾秘密地获取工作。密取工作模式比传统的生产者-消费者模式具有更高的可伸缩性,这是因为工作者线程不会在单个共享的任务队列上发生竞争。在大多数时候,它们都只是访问自己的双端队列,从而极大地减小了竞争。当工作者线程需要访问另一个队列时,它会从队列的尾部而不是从头部获取工作,因此进一步降低了队列上的竞争程度。
使用场景:工作密取非常适合于既是消费者也是生产者问题--当执行某个工作时可能导致出现更多的工作。例如,在网页爬虫程序中在处理一个页面时,通常会发现有更多的页面需要处理。类似的还有许多搜索图的算法,例如在垃圾回收阶段对堆进行标记,都可以通过工作密取机制来实现高效并行。当一个工作线程找到新的任务单元时,它会将其放到自己队列的末尾(或者在工作共享设计模式中,放入其他工作者线程的队列中)。当双端队列为空是,它会在另一个线程的队列队尾查找新的任务,从而确保每个线程都保持在忙碌状态。
最后看一下执行效率:
本来以为parallelStream的并行处理方式一定会比stream高效,但是在普通的赋值场景下,线程安全的容器在并发的情况下需要加锁,效率上反而没有串行的效率高,但是在CPU密集和IO密集型的计算方式下,并行的确实会高,下面看一下测试代码:
@Test
public void streamTestN() {
int NUM = 500000;
int LOOPS = 10;
long sumStream = 0l;
long sumParallelStream = 0l;
Random random = new Random();
for (int j = 0; j < LOOPS; j++) {
//List<DriverDto> resList = Lists.newArrayList();
//List<DriverDto> resList1 = Lists.newArrayList();
List<DriverDto> resList = new Vector<>();
List<DriverDto> resList1 = new Vector<>();
//List<DriverDto> resList = Collections.synchronizedList(new ArrayList<>());
//List<DriverDto> resList1 = Collections.synchronizedList(new ArrayList<>());
//List<DriverDto> resList = new CopyOnWriteArrayList<>();
//List<DriverDto> resList1 = new CopyOnWriteArrayList<>();
List<DriverDto> sourceList = Lists.newArrayList();
for (int i = 0; i < NUM; i++) {
DriverDto dto = new DriverDto();
dto.setDriverId(random.nextInt(2 ^ 20));
dto.setAge(random.nextInt(100));
sourceList.add(dto);
}
System.out.println("parallelStream begin.");
long start = System.currentTimeMillis();
sourceList.parallelStream().forEach(model -> {
DriverDto dto = new DriverDto();
dto.setDriverId(model.getDriverId());
dto.setAge(model.getAge());
resList.add(model);
try {
Thread.sleep(1);//IO密集型的任务
compute();//计算密集型的任务
} catch (Exception e) {
}
});
long end = System.currentTimeMillis();
System.out.println("parallelStream end. cost = " + (end - start) + " ms");
sumParallelStream += end - start;
System.out.println("stream begin.");
long start1 = System.currentTimeMillis();
sourceList.stream().forEach(model -> {
DriverDto dto = new DriverDto();
dto.setDriverId(model.getDriverId());
dto.setAge(model.getAge());
resList1.add(model);
try {
Thread.sleep(1); //IO密集型的任务
compute();//计算密集型的任务
} catch (Exception e) {
}
});
long end1 = System.currentTimeMillis();
System.out.println("stream end. cost = " + (end1 - start1) + " ms");
sumStream += end1 - start1;
System.out.println("resList.size() = " + resList.size());
System.out.println("resList1.size() = " + resList1.size());
}
System.out.println("sumStream = " + sumStream/LOOPS);
System.out.println("sumParallelStream = " + sumParallelStream/LOOPS);
}
private static void compute() {
for(long i = 0 ;i < 100000l; i++) {
long x = i * i;
}
}
class DriverDto implements Serializable {
private static final long serialVersionUID = 1;
//司机Id
private long driverId;
//age
private int age;
public long getDriverId() {
return driverId;
}
public void setDriverId(long driverId) {
this.driverId = driverId;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
仅赋值动作(串行平均耗时低),int NUM = 500000:
sumStream = 27
sumParallelStream = 57
加上IO和CPU计算(并行平均耗时低),int NUM = 5000:
sumStream = 6235
sumParallelStream = 788
所以,没有绝对的方式,看具体的应用场景。
Author:忆之独秀
Email:leaguenew@qq.com
注明出处:https://blog.csdn.net/lavorange/article/details/90737929