List与Map的循环删除

469 阅读10分钟

List与Map的循环删除

List

先创建list

// 创建list
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
list.add(4);

普通循环遍历中删除

出现的问题
for (int i = 0; i < list.size(); i++)
{    
    if(list.get(i)>=2){        
        list.remove(i);    
    }
}

System.out.println(list);

理论上大于2的值都应该被删除,但是实际打印出的结果却与预期不符,多出了3这个元素:

[1, 3]

原因

为什么会出现这种情况呢?

list一共两种remove的方法待实现,分别为根据索引删除和根据object本身删除:

先来看根据索引删除的方法,描述为:删除列表中特定位置(传入的index)的元素。后续元素向左平移(即自己的坐标减一)。返回列表中删除的元素。

再来看这个方法在ArrayList中的实现:

public E remove(int index) {
    rangeCheck(index);

    modCount++;
    E oldValue = elementData(index);

    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    elementData[--size] = null; // clear to let GC do its work

    return oldValue;
}

其中 rangeCheck检查传入的index是否在数组范围内,否则抛出数组越界异常。

然后modCount自增。此处modCount可以理解为版本号,后文会有详细介绍。 接着取出要删除的位置的元素,待后续返回给调用处。

接下来就是重点的删除的操作了

numMoved为去掉删除的元素后,从索引位置下一位开始到最后一位的元素个数,也就是一会copy操作需要向前移动的元素个数。

然后判断一下numMoved是否大于0,判断通过就是将数组索引位置下一位开始到最后一位的元素往前覆盖的从索引位置开始的数组复制操作了。

也就是在此处,elementData,也就是ArrayList存储元素的array buffer,索引位置之后的元素他们与索引的对应关系发生了改变(减一),但是我们操作的for循环在删除完索引位置的元素之后继续根据索引寻找索引位置加一的元素,因此漏掉了在删除之后因为自身索引减一所以填补到了原索引位置的这个元素,因此发生了遗漏元素的问题。

tips:remove方法在return之前,会将原先最后一位置为空,否则最后会有两个重复的元素。

接收object的remove方法大同小异,遍历元素找到索引然后与根据索引删除的操作基本一致。

解决方法

只在确定仅删除一个元素时使用这种遍历方式,或者从后向前删除元素。再或者在删除之后修正下标:

    if(list.get(i)>=2){        
        list.remove(i);   
        i = i - 1;
    }

foreach循环遍历中删除

出现的问题
// foreach循环遍历
for (Integer i :list) {    
    if(i >= 2)    
        list.remove(i);
}

程序运行时抛出异常:

java.util.ConcurrentModificationException at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:911) at java.util.ArrayList$Itr.next(ArrayList.java:861) at Main.main(Main.java:25)

原因

list的foreach循环相当于用迭代器做的循环。 原因主要在于java的fast-fail机制。 在Iterator创建时会创建一个变量并将一个全局变量赋值给他。 int expectedModCount = modCount; 此处modCount为全局变量,表示“list发生结构变化的次数”也即“该集合在使用过程中被修改的次数”,其中结构变化包括增加删除等操作。 list的删除方法(以根据索引删除为例):

    public E remove(int index) {
        rangeCheck(index);
        // 数组结构发生改变,modCount+1
        modCount++;
        E oldValue = elementData(index);

        int numMoved = size - index - 1;
        if (numMoved > 0)
            System.arraycopy(elementData, index+1, elementData, index,
                             numMoved);
        elementData[--size] = null; // clear to let GC do its work

        return oldValue;
    }

而expectedModCount是Iterator内部的变量,在创建Iterator时同步了一下数值。 出现ConcurrentModificationException异常的原因在于迭代器中的next方法取元素的时候会判断一下expectedModCount与modCount是否同步。 迭代器的next方法:

        public E next() {
            // 判断expectedModCount与modCount不相等,则抛出异常。
            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];
        }

modCount相当于版本号,每次做增加删除都会使modCount+1. 但是在ArrayList中的remove方法只更新了modCount,迭代器中的expectedModCount并未更新,因此在foreach循环,也就是迭代器循环中,通过next获取元素的时候导致expectedModCount与modCount产生了差异,因此在检验的时候抛出了异常。 不过在迭代器的remove方法中有对二者进行校验并且同步并且嵌套调取list的remove方法。这也是这个问题的解决方法,即使用迭代器的remove方法而非list的remove方法。 迭代器的删除方法:

        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();
            }
        }

还有个问题,就是把2改成3就没问题,虽然仍然会有漏元素的情况,但不会有异常被抛出。

// foreach循环遍历
for (Integer i :list) 
{    
    if(i >= 3)    
        list.remove(i);
}

改成3也意味着删除的是倒数第二个元素。若删除的元素位于倒数第二位则不会有抛出ConcurrentModificationException异常这个问题。因为倒数第二个删除之后,判断hasNext的时候,由于上一个问题的原因(索引平移)导致hasNext判断不过,因此不会继续调用next方法继续获取元素。但若是后面还有元素,调用next方法,一检查modCount就会抛出异常。

解决方法
  1. 使用迭代器进行遍历删除:
        Iterator<String> it = list.iterator();
        for (;it.hasNext();){
            String str = it.next();
            if("banana".equals(str)){
                it.remove();
            }
        }

这里有个小问题,我以为it.next()取了元素,cursor+1了之后再删除会删成下一个元素,但其实删的还是取出的这个元素,因为是用lastRet这个参数来调的list的remove方法。但是连着调it.remove就不行了,因为调完一次lastRet就被置成了-1。 2. 使用listremoveIf方法,一边迭代一边删除,底层调的也是迭代器的remove方法。只是此方法更简便。 list.removeIf(item->"banana".equals(item)); 同时编译器提示上述语句可以写作: list.removeIf("banana"::equals);,就不赘述了。 list的removeIf方法:

    default boolean removeIf(Predicate<? super E> filter) {
        Objects.requireNonNull(filter);
        boolean removed = false;
        final Iterator<E> each = iterator();
        while (each.hasNext()) {
            if (filter.test(each.next())) {
                each.remove();
                removed = true;
            }
        }
        return removed;
    }

map

同样作为集合,map与list其实异曲同工。 创建map:

        Map<Integer, String> map = new HashMap<>();
        map.put(1, "apple");
        map.put(2, "banana");
        map.put(3, "cipher");
        map.put(4, "dig");

发生问题的情况

  1. foreach遍历entryset
        for (Map.Entry<Integer, String> entry: map.entrySet()) {
            Integer i = entry.getKey();
            if(i >= 3){
                map.remove(i);
            }
        }

报错:

Exception in thread "main" java.util.ConcurrentModificationException at java.util.HashMap$HashIterator.nextNode(HashMap.java:1469) at java.util.HashMap$EntryIterator.next(HashMap.java:1503) at java.util.HashMap$EntryIterator.next(HashMap.java:1501) at Main.main(Main.java:91)

与上面list的foreach遍历删除同样的错误

  1. 用map的forEach方法遍历:
        map.forEach((key, value) -> {
            if (key >= 3) {
                map.remove(key);
            }
        });

一样的报错:

Exception in thread "main" java.util.ConcurrentModificationException at java.util.HashMap.forEach(HashMap.java:1293) at Main.main(Main.java:97)

原因

与list类似。 迭代器的nextNode方法:

        final Node<K,V> nextNode() {
            Node<K,V>[] t;
            Node<K,V> e = next;
            // 快速失败判断
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            if (e == null)
                throw new NoSuchElementException();
            if ((next = (current = e).next) == null && (t = table) != null) {
                do {} while (index < t.length && (next = t[index++]) == null);
            }
            return e;
        }

map的remove方法:

    public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
    }

map的removeNode方法:

    final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
        Node<K,V>[] tab; Node<K,V> p; int n, index;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (p = tab[index = (n - 1) & hash]) != null) {
            Node<K,V> node = null, e; K k; V v;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                node = p;
            else if ((e = p.next) != null) {
                if (p instanceof TreeNode)
                    node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
                else {
                    do {
                        if (e.hash == hash &&
                            ((k = e.key) == key ||
                             (key != null && key.equals(k)))) {
                            node = e;
                            break;
                        }
                        p = e;
                    } while ((e = e.next) != null);
                }
            }
            if (node != null && (!matchValue || (v = node.value) == value ||
                                 (value != null && value.equals(v)))) {
                if (node instanceof TreeNode)
                    ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
                else if (node == p)
                    tab[index] = node.next;
                else
                    p.next = node.next;
                // 结构变化,modCount值+1,但并未同步给迭代器的expectedModCount
                ++modCount;
                --size;
                afterNodeRemoval(node);
                return node;
            }
        }
        return null;
    }

迭代器的remove方法:

        public final void remove() {
            Node<K,V> p = current;
            if (p == null)
                throw new IllegalStateException();
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            current = null;
            K key = p.key;
            // 同样调的map的removeNode方法
            removeNode(hash(key), key, null, false, false);
            // 调用完成后把更新后的modCount的值同步给了expectedModCount
            expectedModCount = modCount;
        }

遗留的问题: 0. 安全失败:fail-safe 指遍历时先复制原有集合内容,在拷贝的集合上进行遍历?

缺点:基于拷贝内容的优点是避免了 Concurrent Modification Exception,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。 最后补充一点:迭代器的remove只能解决单线程下的异常抛出问题,多线程的问题还是得靠使用安全失败机制的集合容器。

  1. 但是既然有快速失败机制防止并发操作,但是使用迭代器的remove方法不就饶过了快速失败机制,可能会出现并发问题吗

mp.weixin.qq.com/s?__biz=Mzg…

  1. 另外还有个解决方法是使用CopyOnWriteArrayList,每次操作的时候先复制出来一个数组进行操作,在更新数据,不会有多线程同时操作同一个数组的并发问题,因此也没有快速失败机制。 Copy-On-Write(COW),写时复制,牺牲数据实时性满足数据的最终一致性,通过延时更新的策略来实现数据的最终一致性,并且能够保证读线程间不阻塞。 COW通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。对CopyOnWrite容器进行并发的读的时候,不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,延时更新的策略是通过在写的时候针对的是不同的数据容器来实现的,放弃数据实时性达到数据的最终一致性。 原理: 内部维护一个数组:
    private transient volatile Object[] array;

get方法:简简单单的get

    /**
     * Gets the array.  Non-private so as to also be accessible
     * from CopyOnWriteArraySet class.
     */
    final Object[] getArray() {
        return array;
    }
    /**
     * {@inheritDoc}
     *
     * @throws IndexOutOfBoundsException {@inheritDoc}
     */
    public E get(int index) {
        return get(getArray(), index);
    }

可以看出来get方法实现非常简单,几乎就是一个“单线程”程序,没有对多线程添加任何的线程安全控制,也没有加锁也没有CAS操作等等,原因是,所有的读线程只是会读取数据容器中的数据,并不会进行修改。

    public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        // 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();
        }
    }

操作的是快照:

image.png

  • 采用ReentrantLock,保证同一时刻只有一个写线程正在进行数组的复制,否则的话内存中会有多份被复制的数据;

  • 前面说过数组引用是volatile修饰的,因此将旧的数组引用指向新的数组,根据volatile的happens-before规则,写线程对数组引用的修改对读线程是可见的。

  • 由于在写数据的时候,是在新的数组中插入数据的,从而保证读写实在两个不同的数据容器中进行操作。

COW的缺点

CopyOnWrite容器有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性问题。所以在开发的时候需要注意一下。

内存占用问题:因为CopyOnWrite的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对 象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对 象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)。如果这些对象占用的内存比较大,比 如说200M左右,那么再写入100M数据进去,内存就会占用300M,那么这个时候很有可能造成频繁的minor GC和major GC。

数据一致性问题:CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。

volatile blog.csdn.net/ThinkWon/ar…

  1. iterator 与 listIterator 的区别。
    • Iterator可用来遍历Set和List集合,但是ListIterator只能用来遍历List。

    • Iterator对集合只能是前向遍历,ListIterator即可以前向也可以后向。

    • ListIterator实现了Iterator接口,并包含其他的功能,比如:增加元素、替换元素、获取前一个和后一个元素的索引等。

public class ListTest {
	public static void main(String[] args) {
		List<String> list = new ArrayList<>();
		list.add("北京");
		list.add("上海");
		list.add("广州");
		list.add("深圳");
		System.out.println("List: " + list);
		// Get the list iterator
		ListIterator<String> iterator = list.listIterator();
		while (iterator.hasNext()) {
			int index = iterator.nextIndex();
			String element = iterator.next();
			System.out.println("Index=" + index + ", Element=" + element);
		}
		// Reuse the iterator to iterate from the end to the beginning
		while (iterator.hasPrevious()) {
			int index = iterator.previousIndex();
			String element = iterator.previous();
			System.out.println("Index=" + index + ",  Element=" + element);
		}
		
        // 放到线程安全的list里
		List<String> synchronizedList = Collections.synchronizedList(list);
		synchronizedList.add("杭州");
		synchronizedList.add("苏州");
		synchronizedList.forEach(System.out::println);
	}
}

Iterator与ListIterator的区别。