在初学Java时就会学习ArrayList、LinkedList这两种JDK中常见的集合类,其中ArrayList本质上是一种动态数组,具有良好的随机访问的性能,即在集合中查找元素的性能比较高,但是其进行元素的插入和删除效率却偏低;而LinkedList则是一种双向链表,其随机访问元素的性能较差,但是对其进行插入和删除元素的效率却比ArrayList高。
以上都是初学Java时需要掌握的基础知识,如果需要知道为什么ArrayList、LinkedList具有这些区别,它们的实现原理又是什么,则需要阅读一下这两个集合类的源码。
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,直接将size和element写入ObjectOutputStream;反序列化时调用readObject,从ObjectInputStream获取size和element,再恢复到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对象,只要是在其容量为0时,其elementData属性都将其指向了这两个相同的空数组,很大程度上减少了空数组的创建与存在。
还有一个问题,就是为什么要定义两个成员变量指向空数组,一个不就够了吗?想知道答案的话请看下文的ArrayList构造方法与扩容机制。
(3)定义默认的集合容量为10,此初始容量只有当第一次往集合中添加数量小于10的元素时才会生效,此时ArrayList为了减少数组扩容次数,会直接将数组长度扩容至10,而不是实际需要的最小容量(例如增加1个元素就扩容到1,再次增加元素时扩容到2……)。如果使用无参构造或者传入数量为0的带参构造创建ArrayList实例时,elementData会赋予一个空数组,此时集合的容量为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),是的话以默认容量10和当前集合元素总数加上1中的最大值作为数组需要的最小容量,否的话以原集合元素总数加上1作为数组需要的最小容量。
②、比较数组需要的最小容量和当前数组的实际长度两者的大小,如果数组需要的最小容量大于数组的实际长度,则调用方法将数组进行扩容。
③、在进行数组扩容时,会先计算数组需要扩容到的新的容量的大小。按照以下逻辑进行处理:
Ⅰ、采用右移位运算将原数组长度加上50%作为默认的新的数组长度。
Ⅱ、如果默认的新的数组长度比先前计算的最小容量小,则以最小容量作为新的数组长度。
Ⅲ、如果新的数组长度大于ArrayList配置的最大数组长度(int型数据的最大值减去8),则开始比较最小容量和配置的最大数组长度,如果最小容量大于配置的最大数组长度,则以int型数组的最大值为新的数组长度,反之会配置的最大数组长度作为新的数组长度。因此,ArrayList集合默认存储的最大元素数量是nt型数据的最大值减去8,实际能存储的最大元素数量是int型数据的最大值,即为2147483647。
④、得到新的容量大小后,采用数组拷贝的方式,将旧的数组拷贝至一个以新容量为长度的新数组中。
由此,可以得到以下结论
a、ArrayList的扩容机制并非是将存储元素的数组elementData每次扩容至与实际存储元素数量相同的大小,而是一般采用移位运算将原先数组长度增加50%作为新的数组长度。在这种机制下,可以有效的减少数组的扩容次数,但是也会造成内部封装的用来存储元素的数组的长度一般比ArrayList集合元素数量大,这是在效率和空间方面做出的权衡。
b、当ArrayList使用带参构造创建实例但是集合中没有元素时,空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA会赋值给elementData,此后第一次向集合里面添加元素时,如果添加的元素个数小于10,则直接将10作为数组需要的最小容量。
当ArrayList使用无参构造创建实例时,EMPTY_ELEMENTDATA会赋值给elementData,此后此后第一次向集合里面添加元素时,无论添加元素的个数是否小于10,均以元素的个数作为数组需要的最小容量。
纵观ArrayList的全部源码,DEFAULTCAPACITY_EMPTY_ELEMENTDATA和EMPTY_ELEMENTDATA的区别就在于此。至于JDK1.8为什么要这样做,我想是因为既然使用带参构造创建实例,那么该集合有较大可能在创建后频繁使用,所以第一次添加小于10个的元素时,直接将用10作为数组的最小容量进入扩容,能减少数组扩容次数。如果使用无惨构造创建实例,则该集合在创建后使用的频繁性相对偏小,那么在第一次添加元素时以实际的元素个数作为数组需要的最小容量可以避免空间浪费。
以上这段为笔者个人的猜想,有兴趣的程序员可以自己研究下。
(2)在指定位置添加元素
①检查传入的位置是否合法,如果位置的值小于0或者大于集合的长度,则抛出异常。
②检查数组大小是否足够,不够则扩容。
③调用System类的arraycopy()进行数组拷贝,拷贝规则为:将原数组中从传入的位置开始到数组的最后一个元素这一段的所有元素,拷贝到以原数组中,但是以传入位置加上1为起始位置进行放置。相当于把从传入的位置开始直到最后的所以元素向后移了一位,然后传入的位置就空了出来。
④将数组中传入位置的元素赋值。
备注:System类的arraycopy()方法的作用是实现数组复制,其中各个参数含义为:src:原数组;srcPos:源数组要复制的起始位置; dest:目的数组; destPos:目的数组放置的起始位置; length:复制的长度。
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)方法类似,只是不获取要删除的对象并返回。
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,但不保证获取的是最新的数据。