ArrayList源码与实现原理

ArrayList简介

在初学Java时就会学习ArrayList、LinkedList这两种JDK中常见的集合类,其中ArrayList本质上是一种动态数组,具有良好的随机访问的性能,即在集合中查找元素的性能比较高,但是其进行元素的插入和删除效率却偏低;而LinkedList则是一种双向链表,其随机访问元素的性能较差,但是对其进行插入和删除元素的效率却比ArrayList高。
以上都是初学Java时需要掌握的基础知识,如果需要知道为什么ArrayList、LinkedList具有这些区别,它们的实现原理又是什么,则需要阅读一下这两个集合类的源码。

ArrayList源码

1、ArrayList的相关属性

(1)ArrayList内部定义了一个使用transient修饰的Object类型的数组elementData,用来存储元素。ArrayList中的元素实际上都是存储在elementData数组中,正因如此,ArrayList才被称为一种动态数组。因为elementData是Object类型的数组,所以向ArrayList里添加元素时,其类型都会向上转型为Object型。
在这里插入图片描述
如果在创建ArrayList实例时使用了泛型,则从ArrayList中取出元素时,会进行向下转型,将取出的元素类型由Object型转为传入的泛型实参类型。反之如果不使用泛型,则取出的元素均无Object型。
在这里插入图片描述

这里的transient用来关闭变量的serialization(持久化)机制,一旦变量被transient修饰,变量将不再是对象持久化的一部分,该变量内容在序列化后无法获得访问。
为什么elementData[]数组需要用transient来修饰?
因为ArrayList里面自定义了writeObject和readObject方法分别用于序列化和反序列化时将存储的元素写入ObjectOutputStream和从ObjectOutputStream里面读取元素并存入elementData。这两个方法最主要的特征是根据ArrayList集合的元素数量来遍历elementData里面的元素写入输出流或从输出流中读取元素存入elementData,并不是直接用elementData进行序列化和反序列化。
ArrayList之所以不直接用elementData来序列化,而要使用自定义的序列化机制,原因在于在Java语言中,数组的长度是不可变的,ArrayList通过数组的拷贝来实现数组容量的扩容,即当数组容量不够时会创建一个新的长度更大的数组并且将原数组的元素全部拷贝到新数组内。那么如果每次向ArrayList增加元素时,elementData数组都要扩容一次,则太消耗性能,因此ArrayList实际上每次进行数组扩容时会扩容至一个比当前元素数量更大的长度,以便预留出一些容量,等容量不足时再扩充容量,以此来减少数组扩容的次数(ArrayList扩容机制下文会讲解)。正因如此,实际上elementData数组的长度一般比ArrayList的元素数量要大,数组中的有些空间可能就没有实际存储元素,则不需要序列化。采用自定义的方式来实现序列化时,就可以保证只序列化实际存储的那些元素,而不是整个数组,从而节省空间和时间。
在这里插入图片描述

(2)ArrayList在属性中实例化了两个空的数组,以备ArrayList实例的容量为0时使用。
DEFAULTCAPACITY_EMPTY_ELEMENTDATA和EMPTY_ELEMENTDATA即为ArrayList内定义的两个空数组变量,用static和final关键字修饰,其中用static修饰表示这两个空数组是静态变量,即被所有该类的对象所共享的类变量,用final修饰表示指向这两个空数组的变量不能再指向其他数组对象。
在这里插入图片描述
这里有两个问题,首先需要理解ArrayList为什么定义这两个空数组。在ArrayList的构造方法中,如果使用无参构造创建对象或者使用带参构造但是传入的初始容量为零或者Collection对象为空时,则此时创建出来的ArrayList的实例不需要存储元素,即是一个空集合。在JDK1.7中,如果ArrayList对象的容量是0的话,会创建一个空数组并将其引用赋给elementData,这就造成了如果我们的项目中空的ArrayList集合的数量比较多的话,则会创建很多个空数组,造成性能与空间的浪费。为了解决这个问题,在JDK1.8中,ArrayList定义了两个由所有对象共享的静态属性指向两个空数组,并且该属性用final修饰所以存储的数组地址不能改变,并且由所有ArrayList实例所共享。这样,无论有多少个没有存储元素的空ArrayList对象,其elementData属性都将其指向了这两个相同的空数组中的一个,不必创建空数组,很大程度上减少了空数组的创建与存在。
还有一个问题,就是为什么要定义两个成员变量指向空数组,一个不就够了吗?想知道答案的话请看下文的ArrayList构造方法与扩容机制。

(3)定义默认的集合容量为10,此初始容量只有在使用带参构造但是创建出没有存储元素的空ArrayList实例,第一次往集合中添加数量小于10的元素时才会生效。此时ArrayList为了减少数组扩容次数,会直接将数组长度扩容至10,而不是实际需要的最小容量(例如增加1个元素就扩容到1,再次增加元素时扩容到2……)。当ArrayList实例刚创建但是没有存储元素时,elementData会赋予一个空数组,此时集合的容量size值为0,并非默认容量10。
在这里插入图片描述

(4)用size标记集合的元素个数,size为集合的元素个数,并非集合中用来存储元素的数组elementData的长度,实际上用来存储元素的数组的长度一般比size更大一些。
在这里插入图片描述

2、ArrayList的构造方法
上文中说到的ArrayList中定义了静态最终成员变量指向两个空数组,在ArrayList的构造方法中,可以看到这两个变量的应用。

(1)如果使用ArrayList的无参构造,则使用成员变量中定义的空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA作为存储元素的数组。
在这里插入图片描述
(2)如果使用带容量大小的构造函数,会判断传入的容量大小,如果容量大于0,则会创建一个与容量大小等长度的数组;如果容量大小小于0,则会抛出异常;如果容量大小等于0,则会使用成员变量EMPTY_ELEMENTDATA定义的空数组作为存储元素的数组elementData。
在这里插入图片描述
(3)如果使用带有collection集合作为参数的构造方法,也会使用toArray()方法将集合转化为数组然后赋给存储元素的数组。如果传入的collection集合为空,则也会使用成员变量EMPTY_ELEMENTDATA定义的空数组作为存储元素的数组elementData。
在这里插入图片描述
这里,可以做一下总结,在创建ArrayList对象时:
1.如果使用无参构造创建对象,则将空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA赋值给elementData。
2.如果使用带参构造创建一个元素个数为零的ArrayList对象时,则将空数组EMPTY_ELEMENTDATA赋值给elementData。
这是在构造方法中两个空数组属性的区别,在ArrayList的扩容机制中,两者还有最为本质区别。当然,两者也有共同点,即都是在集合容量为0时将不同ArrayList对象的elementData属性指向同一个空数组,避免了空数组的重复创建与存在。

3、增加元素
(1)、在尾部增加元素
其步骤为:
①、首先调用ensureCapacityInternal()方法检查数组长度是否不足,不足的话则创建一个新长度的数组,采用数组拷贝的方式将旧数组的元素复制给新数组。
②、将数组的最第N+1个元素赋值。
③、以上两步完成则返回true。
在这里插入图片描述
ArrayList检查数组长度的方法是ensureCapacityInternal方法,其中包含了ArrayList的扩容机制,其过程为:
①、确定数组所需要的最小容量。
逻辑为:判断elementData是否是ArrayList属性中定义的其中一个空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA(注意,此处非EMPTY_ELEMENTDATA),即ArrayList集合是否是以无参构造创造出来的实例,是的话以默认容量10和集合即将具有的元素个数这两者的最大值作为数组需要的最小容量,否的话以集合即将具有的元素个数作为数组需要的最小容量。
在这里插入图片描述
②、比较数组需要的最小容量和当前数组的实际长度两者的大小,如果数组需要的最小容量大于数组的实际长度,则调用方法将数组进行扩容。
在这里插入图片描述

③、在进行数组扩容时,会先计算数组需要扩容到的新的容量的大小。按照以下逻辑进行处理:
Ⅰ、采用右移位运算将原数组长度加上50%作为默认的新的数组长度。
Ⅱ、如果默认的新的数组长度比先前计算的数组需要的最小容量小,则以最小容量作为新的数组长度。
注意:此时原数组长度增加50%后所得的新数组默认长度并不一定比数组需要的最小容量大,因为可能调用了ArrayList的addAll方法一次性的将Collection集合添加到了ArrayList对象中,如果添加的Collection集合的元素数量较大,此时elementData长度增加50%也未必够用,则会将elementData数组的长度扩容到数组需要的最小容量。
Ⅲ、如果新的数组长度大于ArrayList配置的最大数组长度(int型数据的最大值减去8,在ArrayList用MAX_ARRAY_SIZE属性表示),则开始比较数组需要的最小容量和配置的最大数组长度。如果数组需要的最小容量大于配置的最大数组长度,则以int型数组的最大值为新的数组长度,反之会配置的最大数组长度作为新的数组长度,此时不会让数组长度用50%的比例来扩容。因此,ArrayList集合默认存储的最大元素数量是nt型数据的最大值减去8,实际能存储的最大元素数量是int型数据的最大值,即为2147483647。
在这里插入图片描述
④、得到新的容量大小后,首先会创建一个长度为新容量大小的新数组,然后采用数组拷贝的方式,将旧的数组拷贝至一个以新容量为长度的新数组中。数组拷贝使用的是System类的arraycopy方法。

备注:System类的arraycopy()方法的作用是实现数组复制,其中各个参数含义为:src:原数组;srcPos:源数组要复制的起始位置; dest:目的数组; destPos:目的数组放置的起始位置; length:复制的长度。
在这里插入图片描述
ArrayList最终调用的创建新数组并拷贝数据的方法:以新的数量大小为长度创建一个新数组,然后将原数组的元素从0开始拷贝到末尾,到新数组中从0位置开始放置。
在这里插入图片描述

由此,可以得到以下结论
a、ArrayList的扩容机制并非是将存储元素的数组elementData每次扩容至与实际存储元素数量相同的大小,而是一般采用移位运算将原先数组长度增加50%作为新的数组长度。在这种机制下,可以有效的减少数组的扩容次数,但是也会造成内部封装的用来存储元素的数组的长度一般比ArrayList集合元素数量大,这是在效率和空间方面做出的权衡。

b、ArrayList默认的最大容量MAX_ARRAY_SIZE是int型数据的最大值减去8,即(2147483647-8=2147483639)。但是如果你想存储int的最大值即2147483647个元素也可以,只是这样会导致内存溢出的风险。因此ArrayList采取的是尽量不将数组扩容到2147483647,即如果采用原数组长度增加50%的机制扩容则数组长度会超过MAX_ARRAY_SIZE的话,就不再按照原数组长度增加50%的机制来扩容,转而只扩容到数组实际需要的最小长度。

c、当ArrayList使用无参构造创建实例时,空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA会赋值给elementData,此后第一次向集合里面添加元素时,如果添加的元素个数小于10,则直接将10作为数组需要的最小容量。
当ArrayList使用带参构造创建实例但是集合中没有元素时,EMPTY_ELEMENTDATA会赋值给elementData,此后此后第一次向集合里面添加元素时,无论添加元素的个数是否小于10,均以元素的个数作为数组需要的最小容量。
纵观ArrayList的全部源码,DEFAULTCAPACITY_EMPTY_ELEMENTDATA和EMPTY_ELEMENTDATA的区别就在于此,即决定了第一次向空集合中添加元素时,数组的扩容方法,如果添加的元素个数小于10,那么数组是直接扩容到10还是以实际需要元素数量来扩容。至于JDK1.8为什么要这样做,笔者查阅了很多资料也没有找到具有说服力的解释。笔者是这么认为的,既然使用无参构造创建ArrayList实例,那么如果创建后使用较少而且每次添加元素的数量很小时,会导致每向ArrayList集合添加一个元素就扩容一次。第一次添加1个元素,那么数组扩容到1,再添加1个元素,原长度增加50%也只是1.5,数组再次扩容也只会扩容到实际需要的长度2。这样就会造成性能浪费,还不如直接一次性扩容到10,以此节省时间。但是使用带参构造创建ArrayList实例的话,以后使用此集合存储大量元素的几率相对较大,就没有必要在第一次添加元素时将数组长度扩容到10以上了,节省了空间。这些都是笔者的个人猜想,各位程序员也可以自己研究下JDK1.8为什么这么做,欢迎留言讨论哦。

(2)在指定位置添加元素
①检查传入的位置是否合法,如果位置的值小于0或者大于集合的长度,则抛出异常。
在这里插入图片描述
②检查数组大小是否足够,不够则扩容。
③调用System类的arraycopy()进行数组拷贝,拷贝规则为:将原数组中从传入的位置开始到数组的最后一个元素这一段的所有元素,拷贝到以原数组中,但是以传入位置加上1为起始位置进行放置。相当于把从传入的位置开始直到最后的所以元素向后移了一位,然后传入的位置就空了出来。
④将数组中传入位置的元素赋值。
在这里插入图片描述

4、获取元素

获取元素即是返回数组中指定位置的元素,会先检查位置是否合法,再从数组中返回该位置的元素。
在这里插入图片描述
这里其实就可以看出为什么在ArrayList中进行查找指定位置元素的速度很快,因为ArrayList内部封装了一个数组进行元素存储,数组具有下标索引,只需要返回数组中对应下标的元素即可,不用做任何遍历操作,所以速度很快。

5、在指定位置删除元素
(1)检查传入的位置是否合法,即如果位置的值小于0或者大于集合的长度,则抛出异常。
(2)从数组中取出该位置的元素。
(3)判断传入的位置是否是数组中最后一个非空元素的位置。
(4)如果传入的位置非数组中最后一个非空元素的位置,则调用System类的arraycopy()进行数组拷贝,拷贝的规则为:将原数组中从传入的位置+1的位置开始到数组的最后一个元素这一段的所有元素,拷贝到以原数组中,但是以传入的位置为起始位置。相当于将删除位置以后的所有元素前进一位,把需要删除的元素覆盖掉。
(5)拷贝完成后,该数组最末尾有两个相同的元素,则需要将数组的最后一个非空元素的值设为null。
(6)返回删除的元素。
在这里插入图片描述
这里就可以看出为什么在ArrayList中进行插入和删除元素的效率比较低,一方面因为数组本身不能截断或者拼接,在指定位置进行插入和删除都需要在原数组上进行一次范围性的拷贝,以此来让数组中该位置后面的所有元素实现前进或后移;另一方面数组的长度也不能改变,如果添加元素导致数组长度不够,则需要新建一个更长的数组并且将原数组的所有元素拷贝到新数组中,这是非常消耗性能的。

6、根据元素对象找到指定元素并删除
从0位置开始遍历数组中的元素,直到找到与该对象相同的元素为止,然后使用fastRemove(index)方法进行删除并返回true。注:fastRemove(index)和remove(index)方法类似,只是不获取要删除的对象并返回而已。
因为此种删除的逻辑是遍历数组找到第一个与对象相同的元素就进行删除并返回true,所以如果ArrayList集合中包含重复的元素,则该方法只能删除第一个,不能将重复的元素全部删除。
在这里插入图片描述
fastRemove(index)和remove(index)方法类似,只是不获取要删除的对象并返回。
在这里插入图片描述

ArrayList的fail-fast(快速失败机制)

1、快速失败机制的概述
ArrayList内部定义了一个成员变量,叫做modCount,用来记录列表被修改的次数,每次对列表进行删除元素操作时,modCount便会自增1次。
ArrayList的快速失败机制,是Java集合中的一种失败检测机制,当迭代集合的过程中,该集合却被修改了一次之后,就有可能会发生fail-fast,即抛出 ConcurrentModificationException异常。
如下为源码,在迭代器每次返回下一个元素之前,都会调用checkForComodification()方法。其中expectedModCount变量记录的是迭代器初始化时列表的modCount值,如果modCount值有变,则抛出异常。

在这里插入图片描述

2、快速失败机制的引发原理
Iterator其实只是一个接口,具体的实现还是要看具体的集合类中的内部类去实现Iterator并实现相关方法。其中ArrayList类中实现Iterator接口的内部类源码为:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
该内部类定义了3个属性,分别为cursor,lastRet,expectedModCount,
这3个属性的含义分别是:
cursor:迭代器即将遍历的元素的索引,初始值为0,因为数组的下标从0开始,每遍历一个元素,其值自增1。
lastRet:迭代器上一次遍历的元素的索引,值为cursor-1,初始值为-1。
expectedModCount:用来保存迭代器所记录的ArrayList对象的modCount值,以便迭代时和列表实时的modCount值做对比,初始值为迭代器实例创建时ArrayList对象的modCount值。
在迭代器中,hasNext()是判断集合是否还有下一个可以元素可以迭代的方法,其实现在ArrayList中的实现为判断迭代器即将遍历的元素的索引是否等于集合的长度。
在这里插入图片描述
在迭代器中,next()是返回集合下一个元素的方法,在该方法的内部最开始机会调用checkForComodification()方法来检测迭代器记录的modCount值和当前列表实时的modCount值是否相同,如果不同则说明列表的modCount值被修改了,即有线程对列表进行了删除元素等操作,将抛出异常。
在这里插入图片描述
3、快速失败机制的解决方案
快速失败机制有两种解决方案
(1)使用迭代器本身的remove()方法来删除集合种的元素,因为迭代器自带的remove()方法中,会在每次删除操作后,将迭代器用来记录列表modCount值的expectedModCount变量进行更新,保证两者的一致性。
在这里插入图片描述
(2)使用java并发包(java.util.concurrent)中的类来代替 ArrayList 和hashMap。
比如使用 CopyOnWriterArrayList代替 ArrayList, CopyOnWriterArrayList在是使用上跟 ArrayList几乎一样, CopyOnWriter是写时复制的容器(COW),在读写时是线程安全的。该容器在对add和remove等操作时,并不是在原数组上进行修改,而是将原数组拷贝一份,在新数组上进行修改,待完成后,才将指向旧数组的引用指向新数组,所以对于 CopyOnWriterArrayList在迭代过程并不会发生fail-fast现象。但 CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。
对于HashMap,可以使用ConcurrentHashMap, ConcurrentHashMap采用了锁机制,是线程安全的。在迭代方面,ConcurrentHashMap使用了一种不同的迭代方式。在这种迭代方式中,当iterator被创建后集合再发生改变就不再是抛出ConcurrentModificationException,取而代之的是在改变时new新的数据从而不影响原有的数据 ,iterator完成后再将头指针替换为新的数据 ,这样iterator线程可以使用原来老的数据,而写线程也可以并发的完成改变。即迭代不会发生fail-fast,但不保证获取的是最新的数据。

  • 14
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 11
    评论
评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值