管中窥豹:由例子切入ArrayList及其Iterator的源码实现

简介: 管中窥豹:由例子切入ArrayList及其Iterator的源码实现

管中窥豹:由例子切入ArrayList及其Iterator的源码实现

文章目录

  • 管中窥豹:由例子切入ArrayList及其Iterator的源码实现
  • 一个例子
  • ArrayList的add(E e)方法
  • ArrayList的无参构造、初始化与扩容
  • ArrayList的内部类Itr、并发修改异常与modCount
  • 改正例子中的错误代码
  • 迭代器的remove()方法的特别之处
  • foreach循环、ArrayList的remove方法
  • 来看看阿里巴巴Java开发规范

一个例子

  假设现在需要写这样一个最简单不过的程序:创建一个ArrayList,将3个元素加入其中,然后用Iterator遍历ArrayList将其输出。初学Java时,我们可能会写出如下错误代码:

public class Test {
    public static void main(String[] args) {
        ArrayList<Integer> arrayList = new ArrayList<>();
        Iterator iterator = arrayList.iterator();
        arrayList.add(1);
        arrayList.add(2);
        arrayList.add(3);
        while (iterator.hasNext()) {
            System.out.println(iterator.next());
        }
    }
}

  试着运行一下,发现报ConcurrentModificationException异常:

1.png

  可能会觉得奇怪,明明没有并发修改,为什么会报这个异常?我们进入源码看看到底发生了什么

ArrayList的add(E e)方法

  我们在错误代码中调用了arrayList.add(1)来向集合中加入元素,来看看add(E e)方法的源码:

2.png

  我们发现add(E e)方法中size和elementData都是ArrayList的成员变量:

3.png

  size表示集合的大小,即ArrayList中元素的数目,而ArrayList实际上是使用Object[]数组elementData来保存元素的。

  因此add(E e)方法中elementData[size++] = e即是在数组末尾添加元素e,同时使数组长度加1。那么我们不禁想问,如果add元素时当前数组已满,数组elementData是如何扩容的呢,elementData初始的大小又是多少?答案会不会在ensureCapacityInternal(size + 1)中?

ArrayList的无参构造、初始化与扩容

  我们来看看ensureCapacityInternal方法:

4.png

  DEFAULTCAPACITY_EMPTY_ELEMENTDATA是啥?我们发现它表示一个空的Object[]数组,并在ArrayList的无参构造时将其赋给elementData:

5.png

6.png

  ensureCapacityInternal方法中出现的常量DEFAULT_CAPACITY的值为10。我们可以进一步发现ensureCapacityInternal方法出现在所有的add和addAll方法中,当ArrayList被无参构造初始化时,其elementData为一个空Object[]数组的引用,当用不同的参数调用add或addAll方法,都会调用ensureCapacityInternal方法,给其传递的参数为size + 新加入的元素个数,调用add方法新加入的元素个数就是1,调用addAll方法新加入的元素个数为参数中集合的元素个数。ensureCapacityInternal方法的参数minCapacity表示集合所需的最小容量(当集合元素个数>=10时,最小容量即正好装入所有元素,当集合元素个数<10时,最小容量为10,即最小容量有多余),当ArrayList刚被无参构造初始化,它第一次通过add或addAll方法加入元素时,都会计算一个minCapacity,它最小为10,即使我们初始化后第一次add就加了一个元素,minCapacity的值也为10。

  来看看minCapacity有什么用,在ensureCapacityInternal方法中minCapacity最后作为了ensureExplicitCapacity方法的参数,来看看ensureExplicitCapacity方法:

7.png

  逻辑很明显了,如果我们现在需要的集合容量比现有的大,那么调用grow方法,看名字就能猜到这是去扩容了,来看看grow方法:

8.png

  扩容方法grow的逻辑是:若集合所需的最小容量minCapacity大于当前集合大小的1.5倍(当前所需要的容量很大),则调用Arrays.copyOf()方法返回大小为minCapacity的新的数组对象(复制了原数组的已有元素),并使elementData成为该新数组的引用。若集合所需的最小容量minCapacity较小,小于等于当前集合大小的1.5倍,则干脆多给它点空间,将数组扩容到原来大小的1.5倍(为什么这样做源码注释里已经写了,一般我们添加元素都是一个个加,因此所需容量和原容量相差不大,直接多给点容量,可以减少反复扩容调用Arrays.copyOf()方法的开销)。若minCapacity非常大,大于Integer.MAX_VALUE - 8,则调用hugeCapacity方法将集合大小调到最大:Integer.MAX_VALUE,即ArrayList大小最大为Integer.MAX_VALUE

  根据以上逻辑,我们可以推断出ArrayList初始化之后,第一次调用add方法加入一个元素后,它内部数组的大小一定为10。

ArrayList的内部类Itr、并发修改异常与modCount

  回到错误代码,首先我们调用了arrayList.iterator(),返回了一个实现了Iterator接口的对象,我们来看看这个方法的源码:

9.png

  我们发现,调用arrayList.iterator()方法,实际上是通过无参构造给我们返回了一个Itr对象,ltr对象是个什么鬼?继续往下看:

10.png

11.png

12.png

  我们看到Itr是ArrayList的一个内部类,它实现了Iterator接口,即实现了hasNext()、next()、remove()、forEachRemaining(Consumer<? super E> action)四个方法,同时它还有自己的方法checkForComodification()

  在Itr内定义了3个int类型的成员变量,cursor表示下一个元素的下标,lastRet表示上一个元素的下标,初始为-1,expectedModCount暂时看不出表示什么(实际上它是造成错误代码抛异常的关键所在),我们接着看看hasNext()、next()方法的实现:

13.png

  hasNext()方法的实现十分简单,cursor表示下一个元素的下标,那么它不等于elementData的大小size时,即容器内还有可供访问的元素,返回true,否则元素已被遍历完,返回false。

  再来看看next()方法:

14.png

  逻辑也十分简单,返回数组中cursor指示下标对应的元素,并将lastRet设为当前返回元素的下标,并将cursor向后移一位,即cursor + 1。不过在next()方法的最开头,有个checkForComodification()方法,它是不是造成例子中错误代码抛异常的关键呢?来看看这个方法:

15.png

  逻辑非常简单,只要modCount != expectedModCount,就抛ConcurrentModificationException异常,expectedModCount作为Itr的成员变量,在成员变量初始化时就被赋成了modCount。ArrayList继承了AbstractList抽象类,而modCount是AbstractList的成员变量:

16.png

  源码注释中指明了modCount表示list被结构性修改的次数,结构性修改是指改变了list的大小(比如add、remove等操作),或者以其他方式扰乱list,以致迭代器正在进行的迭代可能会产生不正确的结果。如果此字段的值意外更改,则迭代器将抛出ConcurrentModificationException异常。通过modCount提供了fail-fast机制,在迭代期间面对并发修改时会马上抛出异常,而不是进行非确定性行为。

  我们再来回看一下add(E e)方法、ensureCapacityInternal方法以及ensureExplicitCapacity方法:

17.png

18.png

19.png

  add(E e)方法会调用ensureCapacityInternal方法,进而调用ensureExplicitCapacity方法,在这个方法中,modCount进行加1,表示进行了一次结构性修改。

  我们在错误代码中调用了三次add方法,modCount从最初的0变到3

  回看一下ArrayList的内部类ltr,它的成员变量expectedModCount在初始化时被我们赋成了0(因为在调用arrayList.iterator()无参构造Itr对象时,此时arrayList并没有进行任何结构性修改,它所继承的成员变量modCount = 0)

20.png

  再来看看next方法中调用的checkForComodification()方法,造成异常的原因已经被我们找到了

21.png

  当我们执行next()方法时,ltr对象内部的expectedModCount = 0,而实际的modCount = 3,因此抛出了并发修改异常。

  也就是说在arrayList.iterator()方法调用创建出ltr对象后,对arrayList的add操作都对该ltr对象不可见,若先创建了ltr对象,后对arrayList进行add操作,在调用next()方法遍历集合元素时会抛出异常

改正例子中的错误代码

  如何修改例子中的错误代码呢?非常简单,在add方法之后再调用arrayList.iterator()方法创建迭代器Iterator即可,这样ltr对象内部的expectedModCount = 3,而实际的modCount = 3,两者相等

public class Test {
    public static void main(String[] args) {
        ArrayList<Integer> arrayList = new ArrayList<>();
        arrayList.add(1);
        arrayList.add(2);
        arrayList.add(3);
        Iterator iterator = arrayList.iterator();
        while (iterator.hasNext()) {
            System.out.println(iterator.next());
        }
    }
}

  不会再抛异常了:

22.png

迭代器的remove()方法的特别之处

  通过之前的源码分析,我们知道了在arrayList.iterator()方法调用创建出ltr对象后,对arrayList的add操作都对该ltr对象不可见,若先创建了ltr对象,后对arrayList进行add操作,在调用next()方法遍历集合元素时会抛出异常。那么迭代器的remove()方法也和add(E e)方法一样吗,按照我们的经验,迭代器的remove()方法不会有上述特性,因为我们可以边用迭代器Iterator遍历,边删除元素:

public class Test {
    public static void main(String[] args) {
        ArrayList<Integer> arrayList = new ArrayList<>();
        arrayList.add(1);
        arrayList.add(2);
        arrayList.add(3);
        arrayList.add(4);
        Iterator<Integer> iterator = arrayList.iterator();
        while (iterator.hasNext()) {
            int item = iterator.next();
            if (item % 2 == 0) {
                iterator.remove();
            }
        }
        iterator = arrayList.iterator();
        while (iterator.hasNext()) {
            System.out.println(iterator.next());
        }
    }
}

  迭代器的remove()方法删除迭代器最近访问的元素(实际上是lastRet指向的上一个元素),上述代码中,我们删除了偶数,只输出了剩余的奇数。注意我们在输出剩余元素之前先要重新调用arrayList.iterator()方法,原因经过之前的源码分析已经很清楚了,需要重新返回一个ltr对象,重置它的cursor等成员变量,否则我们将得不到输出

23.png

  我们通过源码看看迭代器的remove()方法到底有何特殊之处:

24.png

25.png

  调用迭代器的remove()方法,实际上还是调用ArrayList的remove(int index)方法,不过传入的参数index = lastRet,表示上一个元素的下标。在remove(int index)方法中,首先将结构性修改次数modCount加1,然后进入删除的过程,删除的过程十分简单粗暴,若数组大小>1,我们要删除下标为index的元素,那么我们将index之后的所有元素都往前挪一位,这样原下标为index的元素被下标为index + 1的元素所替代,之后的元素也是以此类推,最后会空出原最后一个元素所占的位置,将其置为null,等着被GC。若数组大小为1,则直接将elementData[0]赋为null即可,最后数组大小size - 1,返回被删除的元素。

  删除完元素返回到迭代器的remove()方法后,看看迭代器的remove()方法接下来做了什么,它首先将cursor=lastRet,这样cursor指向所删元素的下一个元素,然后将lastRet=-1,表示刚刚访问的元素已经不存在了(被删除了),接下来就关键了:expectedModCount = modCount,这表明在迭代器的remove()方法中,ltr对象的成员变量expectedModCount会与modCount进行同步,这样我们下次执行next()方法时将不会抛出异常,因此在arrayList.iterator()方法调用创建出ltr对象后,对arrayList执行迭代器的remove()方法都是对该ltr对象可见的,这是迭代器的remove()方法和其add方法之间的重要差别。

  为什么要这样设计的道理也很简单,因为迭代器的remove()删除操作是很大可能和next操作配合完成的,我们一般都是遍历整个集合的元素,将一些特定元素删除,迭代器的remove()方法必须能和next方法配合使用,那么它一定要进行expectedModCountmodCount的同步,否则将抛出异常。而add方法是极小概率会和next方法配合的,如果造成了文章最开头例子中的错误,那就是初学java不熟练的原因了

foreach循环、ArrayList的remove方法

  foreach循环作为语法糖,可以替代迭代器进行集合的遍历,那么它是通过迭代器实现的吗?我们还是来删除ArrayList中的偶数,保留奇数,这次我们用foreach循环实现:

public class Test {
    public static void main(String[] args) {
        ArrayList<Integer> arrayList = new ArrayList<>();
        arrayList.add(1);
        arrayList.add(2);
        arrayList.add(3);
        arrayList.add(4);
        for (Integer item : arrayList) {
            if (item % 2 == 0) {
                arrayList.remove(item);
            }
        }
        for (Integer item2 : arrayList) {
            System.out.println(item2);
        }
    }
}

  执行一下,我们的老朋友ConcurrentModificationException异常又出现了:

26.png

  什么情况?难道和foreach循环语法糖的实现有关吗?我们用XJad工具反编译上述代码,解除语法糖,看看foreach循环是怎么实现的:

27.png

  我们发现foreach循环其实就是帮我们封装了迭代器iterator遍历集合的过程,它是用迭代器iterator实现的。那么为什么会抛异常呢,注意当我们在错误代码中使用foreach循环时,我们使用的是ArrayList的remove(Object object)方法,而不是使用迭代器的remove()方法,这两者有什么不同?我们来看看ArrayList的remove(Object object)方法源码:

28.png

  逻辑十分简单,若传入参数为null,则遍历数组elementData,找到第一个为null的元素,将其删除,若传入参数不为空,同样也是遍历数组,然后找到第一个需删除的元素。删除元素调用的是fastRemove方法:

29.png

  和我们之前看到的ArrayList的remove(int index)方法删除原理相同,通过元素统一往前挪一位实现删除,同样将结构性修改次数modCount加1,至于为什么单独分出这个方法,源码注释中已经提到了,这个方法与ArrayList的remove(int index)方法的不同之处在于不进行边界判断以及不返回被删除的元素,所以叫fastRemove。

  我们又看到了什么不得了的东西:modCount++,之前所有错误代码抛出并发修改异常总和这个modCount有关,这次也是这样。我们调用ArrayList的remove(Object object)方法,迭代器ltr中的expectedModCount这次又没有和modCount同步,因此在调用ArrayList的remove(Object object)方法删除元素后,再调用迭代器的next()方法,抛出了异常。

来看看阿里巴巴Java开发规范

  阿里Java开发规范中有一条强制性规定:

30.png

  经过之前的源码分析,这条强制性规范也就不难理解了。在foreach循环中调用ArrayList的remove或add方法进行元素的remove/add操作是一个非常危险的行为,一定会抛出ConcurrentModificationException异常,因为调用ArrayList的remove或add方法,modCount会加1,而ltr中的expectedModCount不变,在之后调用迭代器的next()方法时会抛出异常。这样的代码危险之处在于IDEA不会这么智能,给我们提示说代码会抛异常,如果安装了阿里编码规范插件,插件也判断不出这个错误(代码还做不到这么智能),因此如果在项目中使用错误代码而没有做单元测试的话,就等着背锅吧。

  remove元素时为什么要使用Iterator迭代器,也很容易理解了。因为ltr的remove()方法会将自己的expectedModCountmodCount同步,从而支持之后调用next()方法,不会抛出异常。

  并发操作为啥要给Iterator对象加锁?Iterator迭代器并不是线程安全的,不给它加锁,ltr中的expectedModCount、cursor和ArrayList中的modCount等就都乱套了,一定会抛ConcurrentModificationException异常

目录
相关文章
|
Java
java集合框架Set子接口之HashSet源码剖析
HashSet类实现了由哈希表(实际上是HashMap实例)支持的Set接口 , 底层采用HashMap来保存的数据 , 存在HashSet中的元素是无序且不重复的并且HashSet是线程不安全的 , 这种不重复其实是由HashMap实现的 , 所以HashSet的实现也是相对比较简单的 , 对于它的操作其实都是调用HashMap的方法来实现的
66 2
|
1月前
|
安全 Java 索引
ArrayList 和 Vector 的方法有哪些异同?
【10月更文挑战第8天】 ArrayList 和 Vector 均属 Java 集合框架,支持添加、获取、迭代及清空元素等方法。主要区别在于线程安全性、性能和扩容机制:Vector 线程安全但性能较低,ArrayList 性能更优但需自行同步。选择时应根据具体需求决定。
15 2
|
5月前
|
安全 Java
Iterator 怎么使用?有什么特点
Iterator 怎么使用?有什么特点
|
6月前
|
Java 索引
【java进阶】集合的三种遍历(迭代器、增强for、Lambda)
【java进阶】集合的三种遍历(迭代器、增强for、Lambda)
157 0
【java进阶】集合的三种遍历(迭代器、增强for、Lambda)
|
6月前
|
存储 安全 算法
【深入探究Java集合框架】从List到Map的完整指南
【深入探究Java集合框架】从List到Map的完整指南
|
存储 Java 索引
java集合框架List子接口之LinkedList源码剖析
LinkendList从物理结构来说是一种线性结构 , 而从逻辑结构来说是一种链式存储结构 , 虽然是线性结构 , 但是并不会按照线性的顺序存储 , 而是分散存储在内存中 , 通过指针来建立节点与节点之间的联系, 同时实现了Deque这也说明它是一个双向链表 , 在源码中 , 没有看到它对线程安全问题的处理 , 所以它同时还是一个线程不安全的链表 , 也没有看到不允许插入null元素 , 重复元素的处理 , 所以它又是一个允许重复元素以及null元素的链表
56 0
|
JavaScript 前端开发 Java
【JDK源码】Iterator与Iterable的实现与区别
【JDK源码】Iterator与Iterable的实现与区别
115 0
【JDK源码】Iterator与Iterable的实现与区别
再谈HashMap:使用map优化代码,你得学我这样做
我并没有和HashMap杠上,想着重新开始写点技术的东西,就拿HashMap开头了。最近开始重新学习数据结构和算法,其中有些东西学完之后,对于HashMap的理解和运用又有新的认识。虽然之前运用HashMap也有这样用过,但是知道了方法论,才发现这样使用的好处。 上一期我写过HashMap,写的是JDK8之前的Hash,现在都JDK15了,大家有兴趣可以去看一下源计划之从HashMap认识数据结构
|
存储 编译器 C语言
【C++要笑着学】list 核心框架接口的模拟实现 | 运算符重载实现list迭代器 | 妙用模板实现const迭代器(一)
我们在上一章说过,list 其实就是带哨兵位循环双向链表而已,这种链表虽然结构复杂,但是实现起来反而是最简单的,我们在数据结构专栏中有过详细的讲解。
227 0
【C++要笑着学】list 核心框架接口的模拟实现 | 运算符重载实现list迭代器 | 妙用模板实现const迭代器(一)
【C++要笑着学】list 核心框架接口的模拟实现 | 运算符重载实现list迭代器 | 妙用模板实现const迭代器(二)
我们在上一章说过,list 其实就是带哨兵位循环双向链表而已,这种链表虽然结构复杂,但是实现起来反而是最简单的,我们在数据结构专栏中有过详细的讲解。
74 0
【C++要笑着学】list 核心框架接口的模拟实现 | 运算符重载实现list迭代器 | 妙用模板实现const迭代器(二)