本文主要分如下几个要点:
0)Java集合分类
1)对于熟悉JDK集合源码的帮你加深对ConcurrentModificationException的下印象
2)对于迭代时修改提供一个正确的姿势。
3)单线程和多线程环境下迭代时修改的方案
PS:本文不会详细讲解每个集合的源码,也不会画出集合的继承关系(网上有太多详细的讲解和关系图)我们从另一个角度来看下集合,看你是否真正理解集合(容器)。
集合(也叫容器)归类
java多线程与集合以及与你的应用程序的性能有着千丝万缕的关系。
什么是集合?
对java而言就是对一些数据结构如:数组、链表、队列、栈、以及KV对, 进行增、删、改、查、统计的内存操作,
我们都知道在内存中操作要比查询数据库写文件性能高得多,集合就是装你要做操数据的内存容器。
集合在框架中使用一定要谨慎,我们的应用大部分都是基于Spring的,那么你的Controller也基本都是单例的,如果你在Controller中有个成员是集合,你的浏览器(本质是SocketClient)每次请求到你Contorller(web容器如tomtcat接收到请求后分配一个线程来调用你的Servlet,你的应用如果是SpringMvc的话DispatchServert会将请求Mapping到你的Contorller上),这样就成了多个线程操作同一个集合了。
我们以List来举例说明下这几类结合的差别:
数组:有序可放入重复元素的同类型连续的内存区域。
几乎没什么方法,只有几个属性,你可以想象下如果在特定位置删除或者添加一个元素?
以添加的为例:
检查数组是否是满了
1)满了:换个大点的,特定位置的元素和原先老数组的全都放入到这个大的数组,
2)未满:这个位置的元素之后的每个都往后移动一下,将新的元素插入进来。
总之写代码的话就是一大坨,重复性的
List就是就是为了解决这个数组没有方法的问题的,提供add、remove、迭代、统计
我们以迭代时修改为例对比下这几类集合。
单线程环境迭代修改
单线程环境下都不能完美正常运行,最明显的问题就是连续的集合值是符合条件的就少删除,原因就是List中的数组的下标变化了【我用的是list1.size()方法】。解决方法也就显而易见了:
删除的时候将下标减去1,保持下标是下一个真正要迭代的元素。
ps增强的for底层是Iterator,把for循环迭代看做是迭代iterator就行
前面说过for和iterator迭代的方式是一样的。可以看出我们这里只是用iterator.remove和list.remove不同而已。
抛出的异常就是ConcurrentModificationException,看下它是怎么出来的这个异常。
只要expectedModeCount!=modCount就会抛出异常
每次迭代 即便是增强的for都会new Itr 所以这个expectedModeCount=modCount
在看下这个modCount是怎么回事
就是一个ArrayList的成员,什么时候modCount的值会变化,add和remove方法都modCount++ 也就是容器被修改的时候会调用导致这个值发生变化,也就是说在迭代的过程中如果有容器被修改就会抛出这个异常。
用增强的for或者迭代器本身迭代的时候如果不是调用迭代器自身的remove方法,而是调用了list自身的方法的时候就会抛出ConcurrentModificationException异常。说到这这里单线程环境下调用使用迭代就完了。
多线程环境迭代修改
3个方法
updateRef在修改集合中的应用,并没有调用list的能使得mountCount值发生变化的
public class ListModifyGo {
static List list1 = null;
static {
list1 = new ArrayList();
list1.add(new User(0, "王五"));
list1.add(new User(1, "张三"));
list1.add(new User(2, "张三"));
list1.add(new User(3, "李四"));
}
//迭代结合
void list() {
for (User user : list1) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(user.getName());
}
}
//使用集合方法删除某个元素,目的是引起mountCount++
void update() {
list1.remove(2);
}
//不用引起mountCount++
void updateRef() {
User user = list1.get(3);
user.setName(user.getName() + " update");
}
public static void main(String[] args) throws Exception {
ListModifyGo listModifyGo = new ListModifyGo();
new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + ",listModifyGo.list();");
listModifyGo.list();
}
}, "t1").start();
new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + ",listModifyGo.update();");
listModifyGo.update();
}
}, "t2").start();
}
}
这个类很简单就是模拟两个线程一个操作同一个对象static List< User > list1
一个线程在调用list一个在调用update 本来两个方法互不干扰,但是在多线程环境下还是出现了我们不希望看到的ConcurrentModificationException异常,当然你可以在每个方法上加上synchronize,但这是你用容器的本质(提升访问速度),这样一来成了排队了违反了你使用容器的本质(性能降低了)。
有人就可能说把List换成线程安全的Vector。答案其实是否定的
public class VectorModifyGo {
static Vector vector = null;
static {
vector = new Vector();
vector.add(new User(0, "王五"));
vector.add(new User(1, "张三"));
vector.add(new User(2, "张三"));
vector.add(new User(3, "李四"));
}
// synchronized 将list()变成是原子操作
void list() {
for (User user : vector) {// next方法被多次调用,list()是复合操作
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(user.getName());
}
}
void update() {
vector.remove(2);
}
void updateRef() {
User user = vector.get(3);
user.setName(user.getName() + " update");
}
public static void main(String[] args) throws Exception {
VectorModifyGo vectorModifyGo = new VectorModifyGo();
new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + ",vectorModifyGo.list();");
vectorModifyGo.list();
}
}, "t1").start();
new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + ",vectorModifyGo.update();");
vectorModifyGo.update();
}
}, "t2").start();
}
}
我自己试过,也会看了下Vector源码,只是在Vector相应的读写方法上加上了synchronized关键字,所有的线程走在抢占Vectory对象的锁this 但是他不能坚决此类问题,它可以解决诸如线程1在统计时候线程2不能修改(add、remove、某个下标的对象里的值都不能修改)能做到互斥。
for迭代的时候也就是new Itr()这个步骤是互斥了将modeCount赋值成了初始值,但是new完之后退出了方法体没有synchronized保护了,这个时候有可能线程2获取锁执行了remove方法modeCount++了,再迭代next方法此时就有问题。
如果线程2在remove抢占锁失败即被迭代时每次next都能成功获取锁,这个时候就不会出现异常,这要是这个异常出现的偶然性。
这种场景是典型的复合操作,多个加锁的方法被同一个方法,方法体内还是暴露了共享对象,这样在多线程环境下还是有问题的。
解决方案1:
在类的list方法上在加上synchronized让所有的next方法都在外层的然而这样锁上锁会损失性能。
解决方案2:
使用java.util.concurrent并发包下的并发容器 注入CopyOnWriteArrayList
原理就是当线程1在迭代的时候迭代的当前数组
线程2修改的时候将当前数组拷贝一份进行迭代,然后将拷贝和修改之后的数组赋值给当前数组
所以线程1迭代的时候老的数据,有些人可能不明白为什么线程1迭代的时老的数据一张图解析下:
再多说一嘴,CopyOnWriteArrayList的成员array是被volatile修饰的线程2将引用赋值之后其他线程拷贝了应用之后都能感知到array的变化。但是由于线程1执行的list已经在使用原始array了,能感知到也没有用了,而其他线程3如果刚进入方法执行list此时在如果还没有使用这块原始区域则还会重新从主存load即拷贝过后的array,这块其实是java内存模型的之后可以关注下volatile的内存原语。
这种并发容器虽然能解决多线程环境操作同一个集合的情况,但是拷贝一份的代价其实也是很大的,所以更加适用于读多写少的场景。
可以看到CopyOnWriteArrayList在执行修改数组的时候拷贝了一份并且加锁了,我上图中没有表示出线程2
在修改时候是独占的,这里补充下。