likes
comments
collection
share

【面试宝典】ArrayList详解

作者站长头像
站长
· 阅读数 43

1. 集合概述

Java中集合主要分为三大类:

  • List:有顺序,可重复。
  • Set:无顺序,不可重复。
  • Map:无顺序,不可重复。

其中List类集合中,最常用的就是ArrayList。

2. ArrayList概述

ArrayList拆分成两个单词分别是Array+List,Array表示数组,List表示列表。所以也表示了ArrayList的底层使用数组实现的。

传统数组在初始化时必须定义长度,且长度不能更改。

int[] arr1 = new int[10];
// 语法糖定义方式,初始化时按照初始成员确定长度
int[] arr2 = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

ArrayList是动态数组,初始化时可以不定义长度,在JVM判断ArrayList容量不足时,会自动扩容数组。

ArrayList<Integer> arrayList = new ArrayList<>();

3. 数组特性

ArrayList底层使用数组实现,所以ArrayList具备数组的所有特性。在研究ArrayList之前,必须了解数组在JVM中的底层实现原理。

数组的特性

  • 数组元素必须是同一种数据类型。
  • 数组元素在内存中是连续存储的。
  • 数组元素的随机访问效率特别高,可以实现常量级的随机访问,时间复杂度为O(1)。

由第一个特性可知,数组中每一个元素的占用空间都是一样的。第一个特性结合第二个特性可以得出第三个特性。

【面试宝典】ArrayList详解

问题:为什么数组查询的效率比链表高?

一个数组对象在创建时,JVM会给其分配一个基地址。在查询该数组中第 K+1 个元素时,只需要 [ 基地址 + K * 元素大小 ] 可以直接得到到第 K+1元素的地址,从而可以访问该元素中的数据。该过程只进行了 1 次寻址的操作。

在查询链表中第 K+1 个元素时,一般情况下,会从链表的头节点依次通过next指针指向找到第 K+1个元素。该操作需要进行 K 次寻址的操作。

4. ArrayList源码分析

4.1 继承结构

【面试宝典】ArrayList详解

4.2 类结构

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
    
    // 序列化处理过程中验证传输类和本地类版本是否一致
    private static final long serialVersionUID = 8683452581122892189L;

    // 数组默认初始化容量
    private static final int DEFAULT_CAPACITY = 10;
    
    // 数组中当前包含的元素个数
    private int size;

    // 数据存储的数组
    transient Object[] elementData; 
    
    // 用于空实例的共享空数组实例
    private static final Object[] EMPTY_ELEMENTDATA = {};
   
    // 用于默认大小的空实例的共享空数组实例
    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; 	
    
    // 集合版本号,每次对集合进行增加和删除元素都会+1
    protected transient int modCount = 0;
    
    ...
}
  • ArrayList类继承了AbstractList抽象类和实现了List接口,表示ArrayList实例具备add,remove,set,get等最基本的位置操作。
  • ArrayList类实现了RandomAccess标记接口,标记ArrayList实例具备快速随机访问的能力。
  • ArrayList类实现了Cloneable标记接口,标记ArrayList实例可以被克隆。
  • ArrayList类实现了Serializable标记接口,标记ArrayList实例支持序列化,可以在网络中传输。
  • elementData成员变量被transient关键字修饰,表示elementData在ArrayList实例序列化处理的过程中会被忽略。因为在实际使用场景中,elementData可能不是满的,只有数据部分才需要序列化。所以ArrayList采用了重写writeObject方法和readObject方法来自定义elementData的序列化处理过程。
  • modCount成员变量的作用是用来触发fail-fast机制,下文会具体介绍。

4.3 初始化

4.3.1 无参构造器

public ArrayList() {
    // 赋值DEFAULTCAPACITY_EMPTY_ELEMENTDATA空数组
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

使用DEFAULTCAPACITY_EMPTY_ELEMENTDATA空数组赋值给elementData。

4.3.2 有参int构造器

public ArrayList(int initialCapacity) {
    // 判断初始容量是否为0
    if (initialCapacity > 0) {
        // 创建指定容量的数组
        this.elementData = new Object[initialCapacity];
    } else if (initialCapacity == 0) {
        // 赋值EMPTY_ELEMENTDATA空数组
        this.elementData = EMPTY_ELEMENTDATA;
    } else {
        throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity);
    }
}

初始容量不为0时,直接创建指定容量的数组赋值给elementData。

初始容量为0时,使用EMPTY_ELEMENTDATA空数组赋值给elementData。

4.3.3 有参Collection构造器

// 支持其他集合转换成ArrayList
public ArrayList(Collection<? extends E> c) {
    // 集合通过toArray方法转成数组赋值给elementData
    elementData = c.toArray();
    if ((size = elementData.length) != 0) {
        // c.toArray方法转换的数组类型可能不是Object[](这是一个bug,JDK9已经修复)
        if (elementData.getClass() != Object[].class)
            // 创建一个Object[],将原elementData的内容拷贝进去
            elementData = Arrays.copyOf(elementData, size, Object[].class);
    } else {
        // 赋值EMPTY_ELEMENTDATA空数组
        this.elementData = EMPTY_ELEMENTDATA;
    }
}

Collection的容量不为0时,直接将其转为数组赋值给elementData。

Collection的容量为0时,使用EMPTY_ELEMENTDATA空数组赋值给elementData。

4.4 添加元素

public boolean add(E e) {
    // 验证elementData是否需要扩容,添加元素后最小capacity为size+1
    ensureCapacityInternal(size + 1); 
    // 添加元素操作
    elementData[size++] = e;
    return true;
}

private void ensureCapacityInternal(int minCapacity) {
    // 先计算最小capacity,再确认计算出来的最小capacity是否大于原capacity,如果大于就扩容
    ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}

// 计算elementData的最小capacity
private static int calculateCapacity(Object[] elementData, int minCapacity) {
    // 无参构造器创建需要初始化默认capacity,返回最小capacity和初始默认capacity中较大的那一个
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        // DEFAULT_CAPACITY = 10
        return Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    // 有参构造器创建不需要初始化默认capacity,直接返回最小capacity
    return minCapacity;
}

// 确认elementData的容量
private void ensureExplicitCapacity(int minCapacity) {
    // 集合版本号+1
    modCount++;
    if (minCapacity - elementData.length > 0)
        // 进行扩容
        grow(minCapacity);
}

private void grow(int minCapacity) {
    int oldCapacity = elementData.length;
    // 一般扩容规则 newCapacity = 1.5 * oldCapacity
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    // 如果正常扩容后的capacity仍然比最小capacity小,直接用最小capacity作为扩容后的capacity
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    // 判断扩容后的capacity是否超过最大容量
    // MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        // 将Integer.MAX_VALUE赋值给newCapacity
        newCapacity = hugeCapacity(minCapacity);
    // 扩容的实际操作,创建一个capacity等于newCapacity的数组,将elementData的元素全部拷贝进去
    elementData = Arrays.copyOf(elementData, newCapacity);
}

主要流程:

  1. 每一次添加元素之前,需要先分情况计算最小capacity。
    • 如果使用无参构造器创建,则从10和size + 1中选择较大的作为最小capacity。
    • 如果使用有参构造器创建,则选择size + 1作为最小capacity。
  2. 然后根据计算出来的最小capacity,确认是否需要扩容。
    • 如果最小capacity大于原capacity,elementData进行扩容,然后添加元素。
    • 如果最小capacity小于等于原capacity,直接添加即可。

注意

  1. capacity就是elementData.length。

  2. 无参构造器创建的ArrayList实例中的elementData在没有添加过任何元素之前是一个capacity为0的空数组。当初次添加元素时,会被扩容为capacity为10的数组。

4.5 移除元素

ArrayList类提供三种移除元素的方式,根据下标移除,根据元素移除,迭代器移除。

根据下标移除和根据元素移除本质都是一样的,都是将目标删除元素之后的所有元素向前移动一位来实现覆盖目标元素达到删除的目的。

迭代器移除方式下文会单独讲解。

4.5.1 根据下标移除

// 根据下标移除元素
public E remove(int index) {
    // 判断index是否大于等于size,如果大于,抛出异常
    rangeCheck(index);
    // 集合版本号+1
    modCount++;
    // 根据index找到elementData[index]
    E oldValue = elementData(index);
    // 需要移动的元素个数
    int numMoved = size - index - 1;
    if (numMoved > 0)
        // 将目标删除节点之后的所有元素向前移动一位
        System.arraycopy(elementData, index+1, elementData, index, numMoved);
    // 将elementData数据部分最后一位置空,size - 1
    elementData[--size] = null; 

    return oldValue;
}

// @SuppressWarnings("unchecked")表示让编译器忽略unchecked警告信息
@SuppressWarnings("unchecked")
E elementData(int index) {
    return (E) elementData[index];
}

主要流程

  1. 判断下标是否越界,如果越界直接结束。
  2. 确定需要移动的元素个数。
  3. 将目标删除元素之后的所有元素向前移动一位。

4.5.2 根据元素移除

public boolean remove(Object o) {
    // 遍历数组,挨个匹配元素,找到对应元素后删除
    if (o == null) {
        for (int index = 0; index < size; index++)
            if (elementData[index] == null) {
                fastRemove(index);
                return true;
            }
    } else {
        for (int index = 0; index < size; index++)
            if (o.equals(elementData[index])) {
                fastRemove(index);
                return true;
            }
    }
    return false;
}

private void fastRemove(int index) {
    // 集合版本号+1
    modCount++;
    // 计算需要移动的元素数量
    int numMoved = size - index - 1;
    if (numMoved > 0)
        // 将目标删除节点之后的所有元素向前移动一位
        System.arraycopy(elementData, index+1, elementData, index, numMoved);
    // 将elementData数据部分最后一位置空,size - 1
    elementData[--size] = null; 
}

主要流程

  1. 下标遍历数组,挨个元素进行匹配。
  2. 匹配到目标删除元素后,记录该元素下标。
  3. 确定需要移动的元素个数。
  4. 将目标删除元素之后的所有元素向前移动一位。

4.6 遍历元素

ArrayList提供了三种遍历方式,有iterator遍历,for循环遍历,增强for循环遍历。

但是真正遍历的方案只有两种,一种是迭代器遍历,另一种是数组遍历。

  • 迭代器遍历:iterator遍历和增强for循环遍历。

  • 数组遍历:for循环遍历。

迭代器遍历有ArrayList迭代器具体的实现方案,由下文给出。

数组遍历利用了ArrayList的底层实现是数组,通过数组的基地址和空间连续性可以通过下标来循环访问数组。

// 迭代器遍历
Iterator<Integer> iterator = arr.iterator();
while (iterator.hasNext()) {
    System.out.println(iterator.next());
}

// for循环遍历(不推荐)
for (int i = 0; i < arr.size(); i ++) {
    System.out.println(arr.get(i));
}

// 增强for循环遍历
for (Integer cur : arr) {
    System.out.println(cur);
}

// 增强for循环遍历的反编译代码
Iterator var = arr.iterator();
while(var.hasNext()) {
    Integer cur = (Integer)var.next();
    System.out.println(cur);
}

不推荐使用数组遍历的方式来遍历ArrayList。之所以我们知道可以使用for循环遍历ArrayList,是因为我们了解ArrayList底层维护的是一个数组。这样的代码和集合本身是紧密耦合的,无法将访问逻辑从集合类和客户端代码中分离出来。不同的集合会对应不同的遍历方法,客户端代码无法复用。在实际应用中如何将上面两个集合整合是相当麻烦的,所以才有了迭代器的出现。

4.7 迭代器

4.7.1 迭代器模式

Java提供了很多种集合,每一种集合的内部结构都不同。例如ArrayList底层维护的是一个数组,LinkedList底层维护的是一个链表,HashSet底层维护的是一个哈希表。因为容器的内部结构不同,很多时候不知道该怎么样去遍历某个集合,所以Java就把访问逻辑从各个不同类型的集合中抽取出来,抽象成了迭代器模式。

迭代器模式:提供一种方法对一个容器对象中的各个元素进行访问,而又不暴露该对象容器的内部细节。

4.7.2 类结构

ArrayList中对迭代器的实现是在ArrayList类中定义了一个私有内部类Itr,然后对外暴露了一个创建迭代器的成员方法。

public Iterator<E> iterator() {
    return new Itr();
}

private class Itr implements Iterator<E> {
    // 下一个元素的索引
    int cursor;  
    // 上一个元素的索引,如果没有,为-1
    int lastRet = -1; 
    // 迭代器版本号,迭代器实例化时初始化为集合版本号
    int expectedModCount = modCount;
    
    ...
}
  • Itr类实现了Iterator接口,表示该迭代器具备对Collection进行迭代的基本规则。
  • Iterator接口和Iterable接口的区别在于
    • Iterator接口才是真正可以对Collection进行遍历的迭代器。如果某个集合只需要设计一个迭代器,就可以直接实现Iterator接口。
    • Iterable接口中聚合了Iterator接口。这样以来,如果某个集合需要设计多种不同的迭代器,就可以实现Iterable接口。例如LinkedList中就设计了listIterator和descendingIterator两种不同的迭代器。
  • 只有内部迭代器类实现了Iterator接口的集合才可以作为增强for循环遍历的对象。

两种接口的源码:

public interface Iterable<T> {
    Iterator<T> iterator();
}

public interface Iterator<E> {
    boolean hasNext();
    E next();
    void remove();
}

4.7.3 迭代器遍历

ArrayList迭代器使用hasNext方法和next方法配合while循环实现元素的遍历。

// 判断是否存在下一个对象元素
public boolean hasNext() {
    return cursor != size;
}

//获取下一个元素
@SuppressWarnings("unchecked")
public E next() {
    // 检查迭代器版本号和集合版本号是否相等,如果不相等,抛出异常
    checkForComodification();
    // 下一个元素的下标赋值给i
    int i = cursor;
    // 判断i是否超出数据区域,如果超出,抛出异常
    if (i >= size)
        throw new NoSuchElementException();
    Object[] elementData = ArrayList.this.elementData;
    // 判断i是否越界,如果越界,抛出异常
    if (i >= elementData.length)
        throw new ConcurrentModificationException();
    // 游标指向下一个元素
    cursor = i + 1;
    // i赋值给lastRet,返回游标指向的上一个元素
    return (E) elementData[lastRet = i];
}

// 检查迭代器版本号和集合版本号是否相等
final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

主要流程

  1. 检查版本号是否匹配,如果不匹配,遍历结束。
  2. 判断cursor指向的当前下标是否超出数据区域,如果超出,遍历结束。
  3. 判断cursor指向的当前下标是否超出数组边界,如果超出,遍历结束。
  4. cursor指向下一个元素。
  5. lastRet指向cursor指向的前一个元素。
  6. 返回lastRet指向的元素。
  7. 当cursor指向size时,遍历结束。

4.7.4 迭代器移除

在使用迭代器遍历ArrayList的同时,如果要删除元素,推荐使用迭代器提供的remove方法进行移除。

public void remove() {
    // 判断正在遍历的cursor是否在数据部分内,如果不在,抛出异常
    if (lastRet < 0)
        throw new IllegalStateException();
    // 检查迭代器版本号和集合版本号是否相等,如果不相等,抛出异常
    checkForComodification();
	
    try {
        // 使用ArrayList类中的remove方法移除元素
        ArrayList.this.remove(lastRet);
        // cursor指向上一个元素
        cursor = lastRet;
        // 重置lastRet
        lastRet = -1;
        // 同步迭代器版本号
        expectedModCount = modCount;
    } catch (IndexOutOfBoundsException ex) {
        throw new ConcurrentModificationException();
    }
}

// 检查迭代器版本号和集合版本号是否相等
final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

主要流程

  1. 判断目标移除元素是否在有效数据区内,如果不在,放弃移除。
  2. 检查版本号是否匹配,如果不匹配,放弃移除。
  3. 使用ArrayList类定义的remove方法将元素移除。
  4. 同步更新迭代器版本号。

4.7.5 fail-fast机制

fail-fast 机制,即快速失败机制,是Java集合(Collection)中的一种错误检测机制。在使用迭代器遍历集合的过程中该集合在结构上发生改变的时候,就有可能会触发fail-fast,即抛出ConcurrentModificationException异常。fail-fast机制并不保证在不同步的修改下一定会抛出异常,它只是尽最大努力去抛出,所以这种机制一般仅用于检测Bug。

注意,只有使用迭代器遍历集合,才有可能会触发fail-fast机制。

在ArrayList中,同样实现了fail-fast机制。ArrayList类设置了modCount作为集合版本号,每一次对elementData的修改modCount都会加1。ArrayList类中的迭代器类也设置了expectedModCount作为迭代器版本号,在迭代器被创建时,就将当前modCount赋值给了expectedModCount。在迭代器遍历每一个元素时,都会将modCount和expectedModCount匹配,如果匹配不一致,立刻抛出ConcurrentModificationException停止遍历。

例如执行如下代码,就会触发fail-fast机制:

public static void main(String[] args) {
    ArrayList<String> arr = new ArrayList<>();
    arr.add("1");
    arr.add("2");
    arr.add("2");
    arr.add("3");

    Iterator<String> iterator = arr.iterator();
    while (iterator.hasNext()) {
        String cur = iterator.next();
        if ("2".equals(cur)) {
            // 应该改成iterator.remove();
            arr.remove(cur);
        }
    }
}

所以在使用迭代器遍历ArrayList的同时,如果要删除元素,一定要使用迭代器提供的remove方法移除,不能使用ArrayList类提供的remove方法移除。

如果单独使用ArrayList类定义的remove方法,那么集合版本号modCount会加1,迭代器版本号没变。这样以来,next方法中调用的checkForComodification方法就会检查出两个版本号不匹配从而抛出异常。

迭代器提供的remove方法虽然底层也是由ArrayList类定义的remove方法实现元素的移除,但是迭代器提供的remove方法在移除元素后增加了同步更新迭代器版本号expectedModCount的操作。这样以来,modCount就会和expectedModCount一直保持一致,保证遍历的正常进行。

5. 面试题

5.1 题目一

题目:DEFAULTCAPACITY_EMPTY_ELEMENTDATA和EMPTY_ELEMENTDATA都是空数组,两者有何区别?

:两个是用来共享给空数组的,主要用来起区分作用。

无参构造器构造的空数组会用DEFAULTCAPACITY_EMPTY_ELEMENTDATA给elementData赋值,有参构造器构造的空数组会用EMPTY_ELEMENTDATA给elementData赋值。

争对不同的构造器创建的ArrayList,在扩容策略上稍有不同。在扩容时,会判断elementData是由无参构造器创建还是有参构造器创建,从而选择对应策略进行扩容。

5.2 题目二

题目:ArrayList是如何扩容的?

:ArrayList的扩容策略为:

  • 使用无参构造器创建,初始容量为10,每次扩容为原容量的1.5倍。(一般性扩容策略)

  • 使用有参构造器创建,每次扩容为原容量的1.5倍。

扩展:一般性扩容策略的性能如下:

元素总数扩容次数
1k12
1w18
10w23
100w29

5.3 题目三

题目:如果要在ArrayList中添加1w条数据,如何提高效率?

分析:ArrayList提高效率的关键点就在于,如何让ArrayList尽量少的扩容。因为扩容中的数组拷贝操作极大影响了ArrayList的添加性能。一个合格的工程师在使用ArrayList前会估算大概需要开辟多少空间,并在一开始就设置初始容量。

:如果确定要添加1w条数据,可以在初始化时就指定ArrayList开辟1w的容量。这样以来,虽然一开始开辟空间可能会稍微耗费一些资源,但是规避了之后ArrayList进行多次扩容的操作。

5.4 题目四

题目:fail_fast是什么?

:见4.7.5。

5.5 题目五

题目:分别运行如下代码,arr中的元素发生什么变化?

public static void main(String[] args) {
    ArrayList<String> arr = new ArrayList<>();
    arr.add("1");
    arr.add("2");
    arr.add("2");
    arr.add("3");
    for (int i = 0; i < arr.size(); i ++) {
        if ("2".equals(arr.get(i))) {
            arr.remove(i);
            // arr.remove("2"); 
        }
    }
}

:arr中的数据都为["1","2","3"]。

分析:因为由根据下标移除的源码可以看出,当两个相邻元素相同,正序遍历不能同时删除这两个元素,只能删除其中一个。如果改成arr.remove("2")结果也一样,因为两种移除元素的本质都是将目标删除元素之后的所有元素向前移动一位。在这道题目中,当第一个"2"被第二个"2"覆盖后,index就会向后移一位,从而跳过了对第二个"2"的判断。

如果要想把两个相邻又相同的元素都删除,怎么办?

倒叙遍历即可全部删除,如下代码:

public static void main(String[] args) {
    ArrayList<String> arr = new ArrayList<>();
    arr.add("1");
    arr.add("2");
    arr.add("2");
    arr.add("3");
    for (int i = arr.size() - 1; i > 0; i --) {
        if ("2".equals(arr.get(i))) {
            arr.remove(i);
            // arr.remove("2");
        }
    }
}

5.6 题目六

题目:由于ArrayList实现了fail-fast机制,那么在遍历ArrayList时,使用类提供的remove方法真的就不能移除元素吗?

分析:在fail-fast的定义中,强调了fail-fast只是有可能被触发,在知道fail-fast具体的实现原理后,有两个思路:

  • 保证迭代器版本号不变。
  • 不让匹配版本号的方法执行。

第一个思路,首先迭代器版本号的存在意味着使用迭代器来进行遍历,保证迭代器版本号不变意味着在迭代器遍历的过程中不能进行类提供的remove进行移除操作。所以可以先将目标元素的引用赋值给其他变量,等迭代器遍历结束后,在单独使用类提供的remove方法进行删除。

public static void main(String[] args) {
    ArrayList<String> arr = new ArrayList<>();
    arr.add("1");
    arr.add("2");
    arr.add("3");
    // 用来存储删除元素的引用
    String delObj = null;
    Iterator<String> iterator = arr.iterator();
    while (iterator.hasNext()) {
        String cur = iterator.next();
        if ("2".equals(cur)) {
            // 将目标元素的引用另存
            delObj =cur;
        }
    }
    // 遍历结束后单独删除
    arr.remove(delObj);
}

第二个思路,不让匹配版本号的方法执行,意味着不能使用迭代器进行遍历。所以可以使用数组遍,遍历的同时就可以使用类提供的remove方法进行删除。

public static void main(String[] args) {
    ArrayList<String> arr = new ArrayList<>();
    arr.add("1");
    arr.add("2");
    arr.add("3");
    for (int i = 0; i < arr.size() ; i ++) {
        String cur = arr.get(i);
        if ("2".equals(cur)) {
            arr.remove(cur);
        }
    }
}

但是以上两个思路都不是正常的删除操作,只能说是钻fail-fast机制存在的漏洞。

5.7 题目七

题目:ArrayList是线程安全的吗?如果不是,如何做到线程安全?

分析:在讨论ArrayList的线程安全时,一般都会和Vector一起讨论。

Vector类是JDK1.0给出的类,也实现了动态数组,和ArrayList很相似,但是两者又是不同的。主要的不同点在于:

  • Vector是线程安全的。
  • Vector的扩容策略。

对于线程安全,Vector给所有可能出现线程安全的成员方法都用了synchronized关键字修饰。

对于扩容策略,Vector的一般性扩容策略是:初始容量为10,每一次扩容为原容量的2倍。

正因为Vector使用了大量的synchronized关键字,导致效率非常低,所以Vector目前不推荐使用。

再来讨论ArrayList的线程安全,见ArrayList增加元素的源码:

public boolean add(E e) {
    // 验证elementData是否需要扩容,最小capacity为size+1
    ensureCapacityInternal(size + 1); 
    // 添加元素操作
    elementData[size++] = e;
    return true;
}

该源码中

elementData[size++] = e;

可以拆分成

elementData[size] = e;
size ++;

假设有线程A和线程B同时访问同一个ArrayList实例的add方法,线程A先分配到CPU时间片进行添加元素操作,当线程A执行了elementData[size] = e过后,如果CPU暂停执行线程A转而去执行线程B,此时size并没有加1,B线程就会覆盖掉线程A赋的值。假如再多增加几个线程遍历ArrayList,在CPU时间片随机切换下,那么每个线程读取的数据可能都不一样。

总上所述,ArrayList是线程不安全的。即使有fail-fast机制的存在,也只是尽量保证ArrayList的线程安全。

那么如何将ArrayList做到彻底的线程安全呢?

Java又提供了一个类CopyOnWriteArrayList,CopyOnWriteArrayList是ArrayList的线程安全的变体。

该类最大的特点在于,对实例中的数组进行增加或者移除元素操作时,都使用了独占锁ReentrantLock来保证某个时间只有一个线程能对数组进行修改。具体修改操作是拷贝一个和原数组一模一样的新数组,增加或移除全部在新数组上进行。操作完成后,将指向原数组的引用重新指向新数组。

假如有线程A和线程B同时访问同一个CopyOnWriteArrayList实例的add方法,无论线程A和线程B谁先分配到CPU时间片,无论时间片什么时候切换,两个线程操作的数组都是基于原数组拷贝出来的副本,副本与副本之间相互独立,互不干扰且不会修改原数组的内容。假如再多增加几个线程遍历CopyOnWriteArrayList,那么每个线程读取的数据完全一致,都是原数组的数据。

CopyOnWriteArrayList中add方法源码:

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        newElements[len] = e;
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}
转载自:https://juejin.cn/post/6952539390514053150
评论
请登录