Java集合迭代时修改

Java集合迭代时修改

本文主要分如下几个要点:

0)Java集合分类

1)对于熟悉JDK集合源码的帮你加深对ConcurrentModificationException的下印象

2)对于迭代时修改提供一个正确的姿势。

3)单线程和多线程环境下迭代时修改的方案

PS:本文不会详细讲解每个集合的源码,也不会画出集合的继承关系(网上有太多详细的讲解和关系图)我们从另一个角度来看下集合,看你是否真正理解集合(容器)。

集合(也叫容器)归类

  1. 普通容器: List/Set/Map
  2. 同步容器:Vector/HashTable
  3. 并发容器:CopyOnWriteArrayList、ConcurrentHashMap、ArrayBlockQueue

java多线程与集合以及与你的应用程序的性能有着千丝万缕的关系。

什么是集合?

对java而言就是对一些数据结构如:数组、链表、队列、栈、以及KV对, 进行增、删、改、查、统计的内存操作,
我们都知道在内存中操作要比查询数据库写文件性能高得多,集合就是装你要做操数据的内存容器。

集合在框架中使用一定要谨慎,我们的应用大部分都是基于Spring的,那么你的Controller也基本都是单例的,如果你在Controller中有个成员是集合,你的浏览器(本质是SocketClient)每次请求到你Contorller(web容器如tomtcat接收到请求后分配一个线程来调用你的Servlet,你的应用如果是SpringMvc的话DispatchServert会将请求Mapping到你的Contorller上),这样就成了多个线程操作同一个集合了。

我们以List来举例说明下这几类结合的差别:

数组:有序可放入重复元素的同类型连续的内存区域。

几乎没什么方法,只有几个属性,你可以想象下如果在特定位置删除或者添加一个元素?

以添加的为例:
检查数组是否是满了
1)满了:换个大点的,特定位置的元素和原先老数组的全都放入到这个大的数组,
2)未满:这个位置的元素之后的每个都往后移动一下,将新的元素插入进来。
总之写代码的话就是一大坨,重复性的

List就是就是为了解决这个数组没有方法的问题的,提供add、remove、迭代、统计
我们以迭代时修改为例对比下这几类集合。

单线程环境迭代修改

init

最原始的迭代删除方式:
这里写图片描述

单线程环境下都不能完美正常运行,最明显的问题就是连续的集合值是符合条件的就少删除,原因就是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<User> list1 = null;
    static {
        list1 = new ArrayList<User>();
        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<User> vector = null;
    static {
        vector = new Vector<User>();
        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
在修改时候是独占的,这里补充下。

发布了39 篇原创文章 · 获赞 27 · 访问量 3万+
展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 大白 设计师: CSDN官方博客

分享到微信朋友圈

×

扫一扫,手机浏览