Kotlin刨根问底(二):for循环引起的一起“血案”

2020/2/15 23:04:29

本文主要是介绍Kotlin刨根问底(二):for循环引起的一起“血案”,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

本文灵感来源于:群友遍历列表时remove元素引发异常,后对for循环的实现原理进行一系列的探究~


0x0、要点提炼


  • 普通for循环」类似代码,Java不报错,Kotlin却数组越界,因「循环条件不一样

Java先判断是否满足条件执行循环体自增
Kotlin遍历的是范围,直接进循环体

  • 增强for循环」= while循环 + 迭代器Iterator
  • 迭代器的设计哲学」→ 将 遍历行为被遍历对象 分离,无需关心容器底层结构;
  • ConcurrentModificationException」异常原因(通过两个字段配合)

modCount → 记录列表结构修改次数,调用列表的remove方法,此值+1
 
expectedModCount → 预计修改次数,调用Itr迭代器中的remove方法时,会调用列表的remove方法,而后将modCount赋值给expectedModCount,即保证遍历过程中两个值是相等的;
 
如果此时调用列表的remove方法modCount增加1,而迭代器中的expectedModCount没变,两者不等,就会引发ConcurrentModificationException异常。

  • fail-fast(快速失败)」

做系统设计的时候先考虑异常情况,一旦发生异常,直接停止并上报,一种用于提前预警的Bug检测机制;在集合中的应用就是:在遍历一个集合时,当集合结构被修改,直接抛出异常。

  • 规避ConcurrentModificationException的几种方法」
  • 单线程:使用迭代器进行remove;
  • 多线程:在使用迭代器处加锁(如synchronize);
  • 使用fail-safe(安全失败)机制的同步容器,如CopyOnWriteArrayList,在java.util.concurrent包中;
  • Kotlin中使用toList()后可以规避异常的原因

创建了新的ArrayList用作遍历,remove移除的是旧ArrayList的元素,故互不影响


0x1、起因


寒冷的午后,在一个交流群里,一位开发者盆友抛出了下面的问题:

同时附带两张截图

刚好在写代码的我,随手点开了 toList() 的源码:

惯性思维 回了句:涉及到可变列表和不可变列表吧

接着截了个 Collection.kt 的文件结构图后,追加:

其实我并没有理解他的问题,就开始跟起了RecyclerView的源码,再经历过一番排查得出:

应该和 Iterable,普通for循环,增强for循环有关

然后被拉去开了个两个半小时的用例评审…回来看到问题还没解决,看了聊天记录,捋了一下才弄懂他的问题:

1、普通for循环(下标遍历),类似的代码,Java不报错,Kotlin却抛 IndexOutOfBoundsException
2、增强for循环(foreach),remove移除,都会抛 ConcurrentModificationException

看不懂?写个简单的代码演示下问题:

Java

Kotlin

初始化列表:

行吧,接着一个个讲解~


0x2、数组越界问题解析


原因其实很简单「循环条件不一样」,Java中是:

先判断是否满足条件执行循环体自增 ,打断点跟下i、ls.size(),记录如下:

0→5、1→4、2→3、3(此时size=3,判断条件不成立,不会自增,走循环体),所以上面看到只打印了3次

而Kotlin则不一样,点开 for(i in l.indices) 里的indices

噢,indices是一个扩展属性,值为:0..size - 1,一个范围(Range),比如现在有5个元素,这个值就是:0..(5-1) → 0..4,你可以简单地把Range看成一个List(列表),那么这里的 循环条件 就变成了:遍历这个范围,所以

0→5、1→4、2→3、3→3(这里是 直接进循环体,数组长度为3,访问3个,所以就引发了异常!)

到此:普通for循环的“血案(数组越界)”就此水落石出,接着看下增强for循环~


0x3、增强for循环异常分析


有读者可能有疑问,上述的Kotlin代码用的是 forEach 扩展方法,增强for循环不是应该这样写吗?

其实,forEach也是用的增强for循环,点开源码就知道了:

在增强for循环的基础上,传入一个处理函数,不了解函数式编程的可移步至:《8、Kotlin高阶函数与lambda表达式》


① 增强for循环的原理


反编译下增强for循环字节码,如下:

逐行源码解析:

  • Iterator var → 定义迭代器类型的临时变量var1
  • l.iterator() → 获取列表l的迭代器
  • var1.hasNext() → 判断迭代器中是否有未遍历的元素
  • Integer i = (Integer)var1.next() → 获取第一个未遍历元素,赋值给临时变量i
  • l.remove(i) → 移除列表中的i元素

不难看出底层是:while循环 + Iterator(迭代器)


② 迭代器的设计“哲学”


设计模式中有一种模式「迭代器模式

  • 针对 =>「容器对象中元素的迭代访问」;
  • 核心思想 => 将「遍历行为」与「被遍历对象」分离;
  • 好处 => 无需关心容器的底层结构,拿到对象,使用迭代器即可遍历对象内部;

打开 Collection.kt,文件结构如下:

这里要对「Iterable」和「Iterator」进行区分:

  • Iterable接口:实现此接口的集合对象支持迭代(可配合foreach使用),定义了一个iterator()函数,返回一个Iterator迭代器对象。

  • Iterator:迭代器,提供迭代机制的对象,代码如下:

两个函数:next() => 返回当前迭代元素;hasNext() => next前调用此方法判断是否迭代到终点
可以跟下实现看看:ListIteratorAbstractList

IteratorImpl

以上就是 迭代器的设计“哲学” 的简单讲解。


③ 寻根问底


回到增强for循环中remove元素引起的「ConcurrentModificationException」,此异常直接翻译:并发修改异常
但是问题是:我们并没有用多线程修改集合,却引起这样的异常?跟一波异常,定位到:
java.util.ArrayList$Itr.checkForComodification

直译下变量名:modCount(修改次数),expectedModCount(预计修改次数),em…这是判断两值不等就直接抛异常?往上看:

modCount赋值给expectedModCount,说明两者一开始是相等的,那modCount是在哪里赋初值的呢?
点进去来到ArrayList的父类AbstractList

Tips:transient关键字 用来标记的成员变量不参与序列化过程)

AbstractList中搜了下modCount,没发现有值变化的操作;
回到ArrayList,观察源码可以发现「在涉及结构变化的方法中modCount都会自增1

而add(),addAll() 方法虽然没有明写modCount++,但暗地里调用ensureCapacityInternal()完成自增:

到此,我们知道了modCount →「会在ArrayList的结构发生变化时变化
接着,到expectedModCount,定位到内部类 Itr

类的组成比较简单:实现了Iterator迭代器接口,重写next()和hasNext()方法;定义了两个变量:cursor(下一个元素的下标),lastRet(上一个元素的下标);读者有疑问的可能是next()里的:ArrayList.this.elementData,跟一下:

噢,就是一个缓存数组,长度为列表的容量,当第一个元素添加进来,会扩展至DEFAULT_CAPACITY

看回remove()方法,调用ArrayList.this.remove()移除元素后,把modCount的赋值给expectedModCount
继续往下走,因为此时两者相等,hasNext()执行checkForComodification()没抛出异常,按流程走是没问题的。

但:我们上面跳过了迭代器,直接对列表进行了remove,而此时modCount已经+1,但expectedModCount没变,所以执行到checkForComodification就抛出异常了!

嗯,你有个大胆的想法?直接用迭代器进行remove会抛异常吗?那就试试:

行吧,用迭代器的方式remove,但此时已经不是增强for循环了,哈哈!


0x4、fail-fast(快速失败)


从上面我们知道了异常发生的原因了,那为何要这样设计呢?这种玩法有个专业名词 →「fail-fast(快速失败)机制

在做系统设计的时候先考虑异常情况,一旦发生异常,直接停止并上报。

举个简单的例子,写个两数相除的方法,如果不小心除以0,运行时就会引发异常ArithmeticException by/zero,而及时失败,则是在执行运算前检测被除数是否为0,是直接抛出异常。示例如下:

行吧,一种检测Bug机制,用于提前预警,那ArrayList里为何要用到这种机制呢?

→ 因为ArrayList不是线程安全的,多个线程同时访问同一个ArrayList可能会引起异常;
→ 引入及时失败是想着:若某个线程通过Iterator遍历时,集合内容被其他线程改变,抛出异常;

在上面的例子中,我们利用迭代器遍历remove的方式来规避ConcurrentModificationException,
但那是只有在单个线程访问列表,如果是有多个线程在访问呢?写个例子试试:

行吧,抛异常了,那有在多线程的时候有什么办法规避呢?换个线程安全的容器,比如:Vector

一样会抛异常,Vector虽然采用synchronized进行同步,但还是继承自AbstarctList,直接通过Iterator即可访问容器元素,根本不需要获取锁。

哪有解决方法吗?

有,直接在使用iterator处加锁即可,代码示例如下:

可以,那除了对iterator加锁的方式外,还有其他方法吗?

有,上并发容器,它采用的是fail-safe(安全失败)机制:迭代时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历,遍历期间原集合发生的修改迭代器是不知道的,以此避免了ConcurrentModificationException,但同样的迭代器不能访问到修改后的内容。Java中的并发容器放在java.util.concurrent包中,比如可以使用CopyOnWriteArrayList来代替ArrayList和Vector。


0x5、Kotlin中toList()的原理


差点漏了,那位盘友问的为啥加个toList()就可以了,代码示例如下:

跟下源码:

传入的集合类型,大小为5,走else,进toMutableList

啧啧,看到这里,你get到原理没?

  • 直接创建了一个新的ArrayList,把原列表作为参数传入初始化;
  • 拿新列表的迭代器遍历,移除的是旧列表中的元素,肯定互不影响啊,和fail-safe(安全失败)的玩法如出一辙。


参考文献




这篇关于Kotlin刨根问底(二):for循环引起的一起“血案”的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程