4

ArrayList分析1-循环、扩容、版本 - funnyZpC

 1 year ago
source link: https://www.cnblogs.com/funnyzpc/p/16407733.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

ArrayList分析1-循环、扩容、版本

转载请注明出处 https://www.cnblogs.com/funnyzpc/p/16407733.html

前段时间抽空看了下ArrayList的源码,发现了一些有意思的东东,真的是大受裨益哈,尤其是版本问题😊
所以,本篇博客开始我将大概分三篇讲讲ArrayList里面一些有意思的点哈,由于源码大概一千八百逾行,里面大多代码都很通俗,也有些部分存在重复的(Itr以及SubList的内部方法),因为大多通俗遂这里不会逐行的分析哈,好了,现在开始~😂

一.关于循环的一个问题

首先,我给出一个很easy的循环

public static void main(String[] args) { for(int i = 0;i<8;i++){ System.out.print(i+"\t"); // 0 1 2 3 4 5 6 7 } }

看起来很简单吧,哈哈,这时我会问:各位有没试过将i提到for循环外边呢,像下面这样:

public static void main(String[] args) { int i; for(i = 0;i<8;i++){ System.out.println(i); } System.out.println(i);// ? }

上面第六行的i会输出什么呢?真是个有意思的问题,这真是一个微小而有意思的问题,我们经常使用,却很少利用for去做一些别样的事儿,ArrayList就有一骚操作,
原本我是准备臆测出来,却发现怎么也理解不了,当然啦,这个问题接下来我会说到:探究这个问题我们先看看一个普通的for循环的结构

for(定义1;定义2;定义3){ //定义4 :循环内的语句块}

个人文采拙劣,这里就用定义一词哈😳
定义1: 这个地方我们经常会用int i=0; 这样一个语句,其实这个地方是对循环的变量做一次定义,这个地方的定义是一次性的,而且是第一次循环的时候会执行。
定义2: 这里一般是个判断性的表达式,而且这个地方的整体必须返回一个boolean,这个很重要,既然这个地方只需要返回一个布尔的结果,那么你想过没有,如果这个地方 我写的是 (i<10 && i>=0) 会不会抛错呢 🤣
定义4: 这是循环内语句块,通常我们会取到当前循环到的i进行某些逻辑处理,这里不是重点哈。
定义3: 这个地方是重点,一般我们会说每次循环后我们会将i--或者i++, 这种循环变量变化我们一般都会写在这个位置,这是_very very normal_的,但问题是每次执行完定义4的部分 就一定会执行定义3这个地方嘛? 答案是:一定会的!,为什么呢,看看生成的字节码指令就知道了哈🌹 :

0 iconst_01 istore_12 iload_13 bipush 85 if_icmpge 21 (+16)8 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>11 iload_112 invokevirtual #3 <java/io/PrintStream.println : (I)V> //打印i15 iinc 1 by 118 goto 2 (-16)21 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>24 iload_125 invokevirtual #3 <java/io/PrintStream.println : (I)V> //打印i28 return

以上是main函数内的完整字节码内容(jdk=java8), 可以看到指令内有两处println,自然第一个println即是for循环内的(标号12处的),下面一行就很重要了,官方描述是:将局部栈帧的索引+1(see: https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.iinc),说明白些也就是将i加一,然后就到了标号18这个位置,goto是将当前语句指向标号2这个位置 将storei加载...到这里也就很明白了, goto指令是在i自增1之后,可以完全确认循环外的println打印的就一定是 8
看似简单的操作 ArrayList 则时常使用,比如可以用i循环,循环完成后,数组的大小不就是这个i了?以下ArrayList->Itr内的一段代码:

// 循环每个剩余操作 // 这是java8提供给iterator的函数式循环接口,其使用方式如下 // ArrayList arr = new ArrayList(); // arr.add("a"); // arr.add("b"); // arr.add("c"); // System.out.println(arr); // Iterator iterator = arr.iterator(); // iterator.next(); // a // iterator.forEachRemaining(item-> System.out.println(item)); // b c @Override @SuppressWarnings("unchecked") public void forEachRemaining(Consumer<? super E> consumer) { // 检查是否为null,否则抛出错误 Objects.requireNonNull(consumer); // 获取当前数组大小并检查迭代器的游标位置是否大于数组大小 final int size = ArrayList.this.size; int i = cursor; if (i >= size) { return; } // 老实说 elementData.length 与 ArrayList.this.size 是一对一关联的,这里这样做似乎多余 final Object[] elementData = ArrayList.this.elementData; if (i >= elementData.length) { throw new ConcurrentModificationException(); } while (i != size && modCount == expectedModCount) { // 消费这个元素,同时将游标位置+1 consumer.accept((E) elementData[i++]); } // update once at end of iteration to reduce heap write traffic 在迭代结束时更新一次以减少堆写入流量 // 因为i在以上已经+1了,所以这里直接赋值以及重置当前迭代的索引位置(lastRet) cursor = i; lastRet = i - 1; checkForComodification(); }

上面这是迭代器内的部分迭代方法的定义,可以看到有句 cursor = i; 这个地方的i其实也就是size哈,稍不注意就理解错了,小问题让大家见笑了...😂

二.ArrayList扩容

ArrayList内部其实也就是维护了一个 Object 类型的数组,它具体是这样定义的transient Object[] elementData; ,看起来是不是超简单呢?呵呵呵,如果将ArrayList看作成一个大水缸的话,这个elementData就是水缸的本体,ArrayList可以看做一个用户态的接口,简单理解它其实就是是水缸外层刷的油漆或盖子抑或是漏斗。
打比方,如果这个水缸 (elementData )能装四桶水,那么这四桶水我们用 initialCapacity 这个变量来表示,当前实际上如果只有两桶水,则这个水缸实际储水容量(两桶水)我们用size来表示,这样就好理解了吧?
好了,如果预先知道将有8桶水倒入缸内,那我们就要准备一个能容纳8桶水的水缸,对于代码就是这样的:public ArrayList(int initialCapacity) {....}
当然,如果我们只允许用一个水缸来储存水的话,这个水缸当前如果是满载(8桶水),第9桶水的时候就需要准备一个至少能容纳9桶水的水缸,对于ArrayList来说这时候就需要扩容了,代码是这样的:

// 確保顯式容量(官方直译,不懂直接看代码) private void ensureExplicitCapacity(int minCapacity) { // 这个变量记录的是当前活动数组被修改的次数 // 每添加一个(准确的说是一次)元素修改次数+1,如果是addAll也算做是+1 modCount++; // overflow-conscious code 判断是否溢出 // 可以简单的理解 minCapacity 为当前需要保证的最小容量(具体大小为当前容量+1:这是对于当前add元素的个数而定的),elementData.length则为当前活动数组的容量 // minCapacity 也为添加元素后所需数组容量大小,如果(所需容量)大于当前(添加前)数组容量即需要<b>扩容</b> if (minCapacity - elementData.length > 0) grow(minCapacity); //增长(扩容) }

当然这时本着少腾挪多储水的原则,我们一般不会准备一个只能容纳9桶水的水缸,水缸太大也不好,容易浪费缸的容量维护也麻烦😓,所以对于ArrayList这个水缸,我们每次增长为现有容量的1.5倍(多了50%左右,如果当前Capacity是10->增长到15,9->增长到13),具体对应到ArrayList的代码就是:

/** * Increases the capacity to ensure that it can hold at least the * number of elements specified by the minimum capacity argument. * 增加容量以確保它至少可以容納最小容量參數指定的元素數量。 * @param minCapacity the desired minimum capacity 所需的最小容量(也即当前需要的容量大小) */ private void grow(int minCapacity) { // overflow-conscious code int oldCapacity = elementData.length; // 这里的增长策略是 oldCapacity=10 -> newCapacity=15 oldCapacity=9 -> newCapacity=14 // 即 每一次增长的为上一次的一半 int newCapacity = oldCapacity + (oldCapacity >> 1); // 这里个人觉得只是一个保险,对于类似addAll这样的操作 newCapacity 可能小于一次add的数量 // 比如当前容量是10[oldCapacity:10->newCapacity:15],addAll(100)后所需的容量还是不够 这时就会出现[newCapacity:100] if (newCapacity - minCapacity < 0) newCapacity = minCapacity; // 这里也是一个保险,对于待扩容后的大小比数组最大(MAX_ARRAY_SIZE)还要大的时候启用hugeCapacity(minCapacity) // 这里调用 hugeCapacity 后顶多扩容8个大小 MAX_ARRAY_SIZE=2147483639(Integer.MAX_VALUE-8) if (newCapacity - MAX_ARRAY_SIZE > 0) newCapacity = hugeCapacity(minCapacity); // minCapacity is usually close to size, so this is a win: // 翻译: minCapacity 通常接近 size,所以這是一個勝利 // Arrays.copyOf: // 複製指定的數組,截斷或填充空值(如有必要),使副本具有指定的長度。對於在原始數組和副本中都有效的所有索引, // 這兩個數組將包含相同的值。對於在副本中有效但在原始副本中無效的任何索引,副本將包含 null。 // 當且僅當指定長度大於原始數組的長度時,此類索引才會存在。結果數組與原始數組的類完全相同。 elementData = Arrays.copyOf(elementData, newCapacity); }

上面有一句很重要 : int newCapacity = oldCapacity + (oldCapacity >> 1); ,每次换用更大的水缸时都会将之前缸内的水兑过来,对应ArrayList就是这句:elementData = Arrays.copyOf(elementData, newCapacity);
算是很形象吧😅,这只是简单的理解,扩容也还有很多内容,比如什么时候扩容,扩容一个单位不够怎么办,满了怎么办???等等问题....

三.ArrayList中的版本管理

一开始大家会觉得这是个奇怪的问题,ArrayList中为啥会有版本,版本做什么用?

首先,我详细解答第一个问题:ArrayList中为什么有版本?,首先先看一段ArrayList的源码,关于Iterator的:
(以下为第一段代码)

public Iterator<E> iterator() { // 虽然与都会返回一个迭代器,但是iterator只能单向循环,且不能实现增删改查 // 详见: https://www.cnblogs.com/tjudzj/p/4459443.html return new Itr(); }

(以下为第二段代码)

public E next() { // 版本检查 checkForComodification(); // 这个将游标赋予i,然后检查是否i是否超出当前数组索引位置(size) // 我暂时没看出以下三行跟 hasNext() 有多少区别。。。,而且checkForComodification内也是做了安全检查了的 // 总结就是:十分没必要啊... int i = cursor; if (i >= size) throw new NoSuchElementException(); // 不大明白为啥要再整个引用 ,通过这个新引用索引返回数组值 Object[] elementData = ArrayList.this.elementData; if (i >= elementData.length) throw new ConcurrentModificationException(); // 因为迭代器每次循环前都会调用 hasNext ,故此推测这里也应该将游标+1 cursor = i + 1; // 需要说明的是这个 lastRet 是个成员变量,而i只是个方法内临时变量而已 // 所以每循环一次这个 lastRet 需要记录为当前返值前的当前索引位置 return (E) elementData[lastRet = i]; }

(以下为第三段代码)

//// example: // ArrayList arr = new ArrayList(); // arr.add("a"); // arr.add("b"); // arr.add("c"); // ListIterator listIterator = arr.listIterator(); // arr.remove("a");// throw error // Object previous = listIterator.previous(); final void checkForComodification() { if (modCount != expectedModCount) throw new ConcurrentModificationException(); }

额,由于只是截取了部分代码,我先简单讲讲上面三段代码,第一段代码很明显,我们使用迭代器的时候首先调用的就是集合类的 iterator() 方法,而 iterator() 内部 只做了一件事儿:new Itr() ,现在知道迭代器是一个类对象,这点很重要。
继续看第二段next()方法,这个方法内部第一行代码 是 checkForComodification(), 这是一个较为特殊的存在,点进去会看到以上第三段代码,if判断内有两个参数 ,一个是 modCount (直译为修改次数),第二个参数为 expectedModCount (预期修改的次数),点到这个参数定义,它只有一句很简单的定义int expectedModCount = modCount; ,是不是很迷糊😂?
next()内还有一句也很重要 Object[] elementData = ArrayList.this.elementData; ,这句估计很好懂了,Itr迭代器内使用的数组其实也就是ArrayList中维护的数组对象(elementData),倒退一步,再往回思考下 checkForComodification()看...
不知读者老爷有没恍然大悟,其实很简单啦: Itr对象不希望你在使用Itr迭代器的过程中修改(主要是增删)ArrayList中的(elementData)元素,不然在迭代的时候源数组少了个元素会直接抛错的,Itr内的expectedModCount只会在 new Itr() 时被赋值一次,这就是很好的证明啦~
ItrIterator的实现,里面只有迭代的操作,如果有更复杂的操作,比如ListItr(是Itr以及ListIterator的继承实现) 里面更是对迭代器增加了增删改查方法,以及SubList这个对象内部也是,内部均是对ArrayList维护elementData直接操作(他们并未拷贝elementData),所以里面的增删操作不仅仅要比较ArrayListelementData版本,也要在操作(增删)之后同步ArrayListmodCount版本,以下是ListItr内的add(E e)函数的源码:

public void add(E e) { checkForComodification(); try { // ArrayList的添加方法,在游标位置添加元素,游标及之后的元素往后移动, int i = cursor; // 这里还需要注意的是这个插入是在当前元素之后插入元素,ArrayList则是在元素之前,这主要是游标是当前位置+1 ArrayList.this.add(i, e); // 因为增加了个元素,所以游标的位置要+1,当前位置lastRet会在下一次调用next或previous时会被重置 cursor = i + 1; lastRet = -1; // 这个其实类似于版本的概念,主要由于ArrayList与Iterator修改不同步,expectedModCount,这个会在 checkForComodification() 中进行校验 expectedModCount = modCount; } catch (IndexOutOfBoundsException ex) { throw new ConcurrentModificationException(); } }

以上代码中有这样一句 expectedModCount = modCount; 不知读者们明白否。。。😂

ArrayList中为啥有版本管理,版本管理怎么用?

好了,我总结下本小节开头的的两个问题
首先版本管理就是在增删元素的时候对 modCount 自增1
因为对ArrayList的迭代器 ItrListItr以及SubList(截取类) 他们是单独类对象同时内部也是直接操作的ArrayList的源elementData数组对象,所以在ArrayList添加元素时这三个类内部方法均不知道数组元素个数已发生变化,所以在操作elementData时候均需要判读版本是否一致,这就是为啥有版本;
他解决的是:这几个类在操作 elementData (ArrayList的)时 ArrayList可能对其的增删导致的版本不一致的问题,总结似乎臭长了些,但就是这么个意思😂
理解这个很重要,不然你在读 ArrayListaddremove这类方法时不知modCount作甚云云,哈哈。。。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK