Java集合:关于 ArrayList 的内容盘点

2022-12-01 20:38:35 浏览数 (1)

本篇内容包括:ArrayList 概述、ArrayList 的扩容机制(包含源码部分)、如何在遍历 ArrayList 时正确的移除一个元素、ArrayList 的构造方法及常用方法、关于 Array 与 ArrayList 的区别、关于 CopyOnWriteArrayList、关于 Fail Fast 与 Fail Safe 机制!


文章目录
  • 一、ArrayList 概述
  • 二、ArrayList 的扩容
    • 1、ArrayList 的扩容机制(源码)
    • 2、在遍历 ArrayList 时移除一个元素

  • 三、ArrayList 的使用
    • 1、构造方法
    • 2、常用方法
  • 四、相关知识点
    • 1、关于 Array 与 ArrayList 的区别
    • 2、关于 CopyOnWriteArrayList
    • 3、关于 Fail Fast
    • 4、关于 Fail Safe

一、ArrayList 概述

ArrayList 是最常用的 List 实现类,内部是通过数组实现的,它允许对元素进行快速随机访问。数组的缺点是每个元素之间不能有间隔,当数组大小不满足时需要增加存储能力,就要将已经有数组的数据复制到新的存储空间中。当从 ArrayList 的中间位置插入或者删除元素时,需要对数组进行复制、移动、代价比较高。因此,它适合随机查找和遍历,不适合插入和删除。

ArrayList 是基于数组实现的,相当于动态数组,相当于动态数组,其容量能动态增长,类似于 C 语言中的动态申请内存,动态增长内存。

ArrayList 的每个实例都有一个容量,该容量是指用来存储列表元素的数组的大小。它总是大于等于列表的大小。随着向 ArrayList 中不断添加元素,其容量也自动增长。自动增长会带来数据向新数组的重新拷贝,因此,如果可预知数据量的多少,可在构造 ArrayList 时指定其容量。

ArrayList 在被添加大量元素前,应用程序可以使用 ensureCapacity() 操作来指定 ArrayList 实例的容量,这可以减少递增式再分配的数量。

ArrayList 是非线程安全的,只能在单线程环境下使用,多线程环境下可以考虑用 Collections.synchronizedList(List l) 函数返回一个线程安全的 ArrayList 类,也可以使用 java.util.concurrent 并发包下的 CopyOnWriteArrayList 类。


二、ArrayList 的扩容

1、ArrayList 的扩容机制(源码)

ArrayList 底层是一个 Object 数组 elementData,用于存放插入的数据:

代码语言:javascript复制
private transient Object[] elementData;		// 存储ArrayList中的元素
/**
 * 定义元素个数
 */
private int size();

我们知道,数组需要使用着一块连续的内存空间,因此数组的大小一旦被规定就无法改变。那如果我们不断的往里面添加数据的话,ArrayList 是如何进行扩容的呢 ?

代码语言:javascript复制
public boolean add(E e) {
        // 确认elementData容量是否足够
        ensureCapacityInternal(size   1);  // 第一次调用add()方法时,size=0
        elementData[size  ] = e;
        return true;
    }

ArrayList 添加元素时会先调用 ensureCapacityInternal(int minCapacity) 方法,对数组容量进行检查,判断剩余空间是否足够,不够时则进行扩容

代码语言:javascript复制
private void ensureCapacityInternal(int minCapacity) {
    		// 如果elementData为"{}"即第一次调用add(E e),重新定义minCapacity的值,赋值为DEFAULT_CAPACITY=10
    		// 即第一次调用add(E e)方法时,定义底层数组elementData的长度为10
    		if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
       					minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    		}
				// 判断是否需要扩容
        ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
        modCount  ;

				// 第一次进入时,minCapacity=10,elementData.length=0,对数组进行扩容
				// 之后再进入时,minCapacity=size 1,elementData.length=10(每次扩容后会改变),
				// 需要minCapacity>elementData.length成立,才能扩容
        if (minCapacity - elementData.length > 0){
            grow(minCapacity);
        }
}

ArrayList 通过 grow(minCapacity) 方法对数组进行扩容

代码语言:javascript复制
private void grow(int minCapacity) {
        // 将数组长度赋值给oldCapacity
        int oldCapacity = elementData.length;
    		// 将oldCapacity右移一位再加上oldCapacity,即相当于newCapacity=1.5oldCapacity(不考虑精度损失)
        int newCapacity = oldCapacity   (oldCapacity >> 1);
    		// 如果newCapacity还是小于minCapacity,直接将minCapacity赋值给newCapacity
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
    		// 特殊情况:newCapacity的值过大,直接将整型最大值赋给newCapacity,
				// 即newCapacity=Integer.MAX_VALUE
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        // 将elementData的数据拷贝到扩容后的数组
        elementData = Arrays.copyOf(elementData, newCapacity);
}

// 如果大于临界值,进行整型最大值的分配
private static int hugeCapacity(int minCapacity) {
        if (minCapacity < 0) {
        // overflow
            throw new OutOfMemoryError();
         }
        return (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE;
}

总结:ArrayList 在添加元素时,会进行一个判断,当「元素个数 1> 当前数组长度(size 1 > elementData.length)」时,进行扩容,扩容后的数组大小是原大小的 1.5 倍(oldCapacity (oldCapacity >> 1))。最后将旧数组进行复制(调用 Arrays.copyof(),再调用 System.arraycopy() ),达到扩容的目的,此时新旧列表的 size 大小相同,但 elementData 的长度即容量不同。

2、在遍历 ArrayList 时移除一个元素

在遍历 ArrayList 时移除一个元素,这是一个比较经典的面试题,这里最常用的有 2 种方式:

方式一:在 for 循环中使用倒序遍历 remove 删除元素.

假设按照从 0size-1 下标来删有相邻且相同的两个元素,删除第一个,数组长度会 -1 并且所有元素往前移动一位,那么第二个就到第一个元素的位置,此时控值 for 循环的下标 i 已经 1 ,相当于直接就跳过了第二个重复元素,而倒叙可以避免此类情况。

方式二:使用迭代器遍历 ArrayList 并删除元素(推荐)。

Eg:

代码语言:javascript复制
List<String> strs = new ArrayList<>();
strs.add("1")
strs.add("2")
strs.add("3")
strs.add("4")
strs.add("5")
strs.add("6")

Iterator<String> iter = strs.iterator();
while(iter.hasNext()) {
    if (iter.next().toString().equals("1")) {
        iter.remove();
    }
}
System.out.println(strs);

三、ArrayList 的使用

1、构造方法

方法名

方法说明

public ArrayList()

无参构造函数,此构造函数用于创建一个空列表,其初始容量足以容纳10个元素

public ArrayList(int initialCapacity)

此构造函数用于创建具有初始容量的空列表

public ArrayList(Collection<? extends E> c)

此构造函数用于创建包含指定集合的元素的列表

2、常用方法

方法名

方法说明

boolean add(E e)

此方法将指定的元素追加到此列表末尾

void add(int index, E element)

此方法将指定的元素插入此列表中的指定位置

boolean addAll(Collection<? extends E> c)

此方法按指定集合迭代器的返回顺序将指定集合中所有元素加到列表末尾

boolean addAll(int index, Collection<? extends E> c)

此方法从指定位置开始将指定集合中的所有元素插入此列表

E get(int index)

此方法返回此列表中指定位置的元素

E set(int index, E element)

此方法返回此列表中指定位置的元素,并使用参数中的元素进行替换

E remove(int index)

此方法返回此列表中指定位置的元素,并删除此指定位置的元素

boolean remove(Object o)

此方法从该列表中删除指定元素的第一个匹配项(如果存在)

void clear()

此方法将从此列表中删除所有元素

Object clone()

此方法返回此ArrayList实例的浅表副本

boolean contains(Object o)

如果此列表包含指定的元素,则此方法返回true

boolean isEmpty()

如果此列表为空,则此方法返回true

void ensureCapacity(int minCapacity)

此方法增加了此列表的容量

int size()

此方法返回此列表中的元素数

Object[] toArray()

此方法以适当的顺序(从第一个元素到最后一个元素)返回包含此列表中所有元素的数组

<T> T[] toArray(T[] a)

此方法以适当的顺序(从第一个元素到最后一个元素)返回包含此列表中所有元素的数组; 返回数组的运行时类型是指定数组的运行时类型

void trimToSize()

此方法将此ArrayList实例的容量修剪为列表的当前大小

void sort(Comparator<? super E> c)

此方法对列表内对象,以指定方式进行排序

List<E> subList(int fromIndex, int toIndex)

此方法将截取集合的一部分并返回一个List集合


四、相关知识点

1、关于 Array 与 ArrayList 的区别
  • (包含类型)Array 既可以包含基本类型,也可以包含对象类型;而 ArrayList 只能包含对象类型。
  • (实例声明)Array 作为变量在声明的时必须进行实例化(至少得初始化数组的大小),而 ArrayList 可以只是先声明。
  • (初始大小)Array 对象创建后的数组大小是固定的,而 ArrayList 的大小可以动态指定,也就是说该对象的空间可以任意增加。
  • (方法特性)Arraylist 提供了更多的方法和特性,比如添加全部addAll(),删除全部removeAll(),返回迭代器iterator()等等。
2、关于 CopyOnWriteArrayList

Java 并发包中的并发 List 只有 CopyOnWriteArrayList。CopyOnWriteArrayList 是一个线程安全的 ArrayList,对其进行的修改操作都是在底层的一个复制数组(快照)上进行的,也就是使用了写时复制策略。

写时复制(CopyOnWrite,简称 COW)思想是计算机程序涉及领域中的一种优化策略。其核心思想是,如果多个调用者(Callers)同时要求相同的资源(如内存或者磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者视图修改资源内容时,系统才会真正复制一份专用的副本给调用者,而其他调用者所见到的最初的资源仍然保持不变。这个过程对其他调用者都是透明的。样做的好处就是可以对 CopyOnWrite 容器进行并发的读而不需要加锁,因为当前容器不会被修改。

从 Jdk1.5 开始 Java 并发包里提供了两个使用 CopyOnWrite 机制实现的并发容器,它们是 CopyOnWriteArrayList 和 CopyOnWriteArraySet。

CopyOnWriteArrayList 中 add 方法添加的时候是需要加锁的,保证同步,避免了多线程写的时候复制出多个副本。读的时候不需要加锁,如果读的时候有其他线程正在向 CopyOnWriteArrayList 添加数据,还是可以读到旧的数据。

写时复制的缺点:

  • 内存占用问题。由于 CopyOnWrite 的写时复制机制,在进行写操作的时候,内存里会同时驻扎两个对象的内存。
  • CopyOnWrite 容器不能保证数据的实时一致性,可能读取到旧数据。
3、关于 Fail Fast

Fail Fast 是 Java 集合的一种错误机制。当多个线程对同一个集合进行操作时,就有可能会产生 fast-fail 事件。例如:当线程 A 正通过 iterator 遍历集合,另一个线程 B 修改了集合的内容,此时 modCount(记录集合操作过程的修改次数)会加 1,不等于 expectedModCount,那么线程 A 访问集合的时候,就会抛出 Concurrent Modification Exception,产生 fast-fail 事件。边遍历边修改集合也会产生 fast-fail 事件。

解决方法:

  • 使用 Colletions.synchronizedList 方法或在修改集合内容的地方加上 synchronized。这样的话,增删集合内容的同步锁会阻塞遍历操作,缺点是会影响性能。
  • 使用 CopyOnWriteArrayList 来替换 ArrayList。在对 CopyOnWriteArrayList 进行修改操作的时候,会拷贝一个新的数组,对新的数组进行操作,操作完成后再把引用移到新的数组。
4、关于 Fail Safe

Fail Safe 也是 Java 集合的一种机制,采用安全失败机制的集合容器(Eg:CopyOnWriteArrayList)在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。

  • 原理:由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发 Concurrent Modification Exception
  • 缺点:基于拷贝内容的优点是避免了 Concurrent Modification Exception,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。

Ps:java.util.concurrent 包下的容器都是 Fail Safe 的,可以在多线程下并发使用,并发修改。

0 人点赞