基于ArrayList源码探讨如何使用ArrayList

简介: 基于ArrayList源码探讨如何使用ArrayList

目录

ArrayList的使用.png

基础篇

实例化ArrayList对象

实例化ArrayList对象有三种方式:

1、无参构造方法ArrayList()

2、指定集合容量构造方法ArrayList(int initialCapacity)

3、通过集合构造ArrayList(Collection<? extends E> c)

/**
 * 实例化一个ArrayList对象,
 * 从System.identityHashCode()方法可以得到这个对象在内存中的地址
 */
public static void constructor() {
   
    // 无参构造,默认容量为10
    List<Object> list1 = new ArrayList<>();
    System.out.println("list1:" + System.identityHashCode(list1));

    // 指定容量为100
    List<Object> list2 = new ArrayList<>(100);
    System.out.println("list2:" + System.identityHashCode(list2));

    // 根据集合List2实例化对象list3
    List<Object> list3 = new ArrayList<>(list2);
    System.out.println("list3:" + System.identityHashCode(list3));
}

输出:

实例化ArrayList对象输出.jpg

从三个对象的地址我们可以明显看出,这是三个不同的对象。

判断集合是否为空

通过isEmpty()方法可以获取集合是否为空

/**
 * 判断集合是否为空
 */
public static void isEmpty() {
   
    List<Object> list1 = new ArrayList<>();
    System.out.println("list1是否为空:" + list1.isEmpty());

    List<Object> list2 = new ArrayList<>();
    list2.add(new Object());
    System.out.println("list2是否为空:" + list2.isEmpty());


    List<Object> list3 = new ArrayList<>();
    list3.add(null);
    System.out.println("list3是否为空:" + list3.isEmpty());
}

输出:

集合是否为空.jpg

从输出中可以了解到,当集合中包含元素时,isEmpty()方法返回false,当集合中不包含元素时,返回true

另外,从第三个例子中也可以看出来,ArrayList是允许元素为null的。

获取集合内元素数量

通过size()方法可以获取集合中元素的数量

/**
 * 获取元素数量
 */
public static void size() {
   
    List<Object> list1 = new ArrayList<>();
    System.out.println("list1元素数量:" + list1.size());

    List<Object> list2 = new ArrayList<>();
    list2.add(new Object());
    System.out.println("list2元素数量:" + list2.size());


    List<Object> list3 = new ArrayList<>();
    list3.add(null);
    list3.add(null);
    System.out.println("list3元素数量:" + list3.size());
}

输出:

获取元素数量.jpg

从输出中可以看出来,ArrayList不仅允许元素为null,而且还允许元素重复

注意:

在判断集合是否为空时,应使用语义更加直观的isEmpty()方法,而不是通过size() == 0的方式,因为从方法命名上就可以知道这两个方法的功能是完全不同的,阿里巴巴开发手册中也有明确的说明

阿里巴巴手册isEmpty.jpg

判断集合是否包含指定元素

通过contains(obj)方法可以判断当前集合中是否包含指定的对象

/**
 * 是否包含指定元素
 */
public static void contains() {
   
    List<Integer> list = new ArrayList<>();
    list.add(1);
    System.out.println("list集合中是否包含1:" + list.contains(1));
    System.out.println("list集合中是否包含2:" + list.contains(2));

    Person person1 = new Person("张三");
    Person person2 = new Person("张三");
    List<Person> personList = new ArrayList<>();
    personList.add(person1);
    System.out.println("personList集合中是否包含person2:" + personList.contains(person2));

}

static class Person {
   
    private String name;

    public Person(String name) {
   
        this.name = name;
    }
}

输出结果为:

contains方法1.jpg

前两行输出是在我们的意料之中的,但是第三行输出为什么是false呢?明明两个person对象都叫张三。

contains(obj)方法的源码中我们可以看到一个关键点:equals()方法,源码中通过调用对象的equals()方法判断当前对象是否与集合中的对象是否相同

public boolean contains(Object o) {
   
    return indexOf(o) >= 0;
}

public int indexOf(Object o) {
   
    if (o == null) {
   
        for (int i = 0; i < size; i++)
            if (elementData[i]==null)
                return i;
    } else {
   
        for (int i = 0; i < size; i++)
            if (o.equals(elementData[i]))
                return i;
    }
    return -1;
}

如果我们的对象没有重写equals()方法,那么就是继承Object类中的逻辑,比较的是两个对象在内存中的地址。

public boolean equals(Object obj) {
   
    return (this == obj);
}

呐~,只要我们按照我们自己的想法重写equals()方法,就可以得到不同的结果了

static class Person {
   
    private String name;

    public Person(String name) {
   
        this.name = name;
    }

    /**
     * 重写equals方法,如果两个Person对象的name值的equals结果为true,就可以认为这两个对象相同
     */
    @Override
    public boolean equals(Object o) {
   
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return name.equals(person.name);
    }
}

现在再来执行一下demo就可以得到不同的结果

contains方法2.jpg

获取某个元素的下标

通过indexOf(obj)方法可以获取指定元素位于元素的下标

/**
 * 获取某个元素的下标
 */
public static void indexOf() {
   
    List<Integer> list = new ArrayList<>();
    list.add(1);
    list.add(3);
    System.out.println("元素1在list集合中的下标:" + list.indexOf(1));
    System.out.println("元素3在list集合中的下标:" + list.indexOf(3));
    System.out.println("元素2在list集合中的下标:" + list.indexOf(2));

    Person person1 = new Person("张三");
    Person person2 = new Person("张三");
    List<Person> personList = new ArrayList<>();
    personList.add(person1);
    personList.add(person2);
    System.out.println("张三在personList集合中的下标:" + personList.indexOf(person2));

}

输出为:

indexOf方法.jpg

从输出中可以发现

① 集合中的元素时有顺序的,我们添加的第一个元素的下标为0,第二个元素的下标为1

② 如果不存在指定元素,则indexOf()方法返回下标-1,表示不存在

indexOf()方法原理也是通过equals()方法比较,与contains()方法原理相同

转为数组

通过toArray()方法或toArray(T[] a)方法可以实现将集合转为数组

我们先看一下toArray()方法如何使用

public static void toArray() {
   
    List<Person> personList = new ArrayList<>();
    personList.add(new Person("张三"));
    personList.add(new Person("李四"));
    personList.add(new Person("王五"));
    System.out.println("转换前的集合:\n" + personList);

    Object[] objArray = personList.toArray();
    System.out.println("转换后的数组:");
    Arrays.stream(objArray).forEach(System.out::println);

    // 将第一个元素改为赵六
    ((Person)objArray[0]).setName("赵六");

    System.out.println("修改元素后的集合:\n" + personList);
    System.out.println("修改元素后的数组:");
    Arrays.stream(objArray).forEach(System.out::println);

}

输出:

toArray1.jpg

从输出中可以发现,通过toArray()方法得到的数组中的元素和原集合中的元素有着相同的内存地址,这就说明我们无论是修改原集合中的元素,还是修改数组中的元素,都会影响到对方,从输出中可以看到,事实确实如此。

再来看一下toArray(T[] a)方法,这个方法接收一个数组,将集合中的元素按顺序放在数组a中,

public static void toArray2() {
   
    List<Person> personList = new ArrayList<>();
    personList.add(new Person("张三"));
    personList.add(new Person("李四"));
    personList.add(new Person("王五"));
    System.out.println("转换前的集合:\n" + personList);

    Person[] personArray = new Person[personList.size()];
    personArray = personList.toArray(personArray);
    System.out.println("转换后的数组:");
    Arrays.stream(personArray).forEach(System.out::println);


    // 将第一个元素改为赵六
    (personArray[0]).setName("赵六");

    System.out.println("修改元素后的集合:\n" + personList);
    System.out.println("修改元素后的数组:");
    Arrays.stream(personArray).forEach(System.out::println);
}

输出:

toArray2.jpg

从输出中,我们可以看到和上面的toArray()方法有相同的结果

从输出中可以发现,通过toArray()方法得到的数组中的元素和原集合中的元素有着相同的内存地址,这就说明我们无论是修改原集合中的元素,还是修改数组中的元素,都会影响到对方,从输出中可以看到,事实确实如此。

那么既然结果相同,为什么还要重载两个方法呢?

toArray()方法:不接受参数,从而该方法不知道要转换成什么类型的数组,所以返回的对象类型为Object[],当我们需要操作该数组时,还需要对其进行强制转型。

toArray(T[] a)方法:接收一个数组对象参数,从而该方法知道要转换成什么类型的数组,所以返回的对象类型为T[],这样一来我们就不再需要对其进行转型了,但是在使用此方法时有一个需要我们留意的细节。

在声明数组对象Person[] personArray时,指定的数组长度如果小于原集合的长度personList.size(),那么方法内部会返回一个新的相同类型的数组对象,而如果指定的数组长度不小于原集合的长度personList.size(),则方法内部不会声明新的数组,而仅仅将元素按顺序放在数组中返回。

public static void toArray2() {
   
    List<Person> personList = new ArrayList<>();
    personList.add(new Person("张三"));
    personList.add(new Person("李四"));
    personList.add(new Person("王五"));

    Person[] personArray = new Person[2];
    System.out.println("转换前的数组地址:" + System.identityHashCode(personArray));
    personArray = personList.toArray(personArray);
    System.out.println("转换后的数组地址:" + System.identityHashCode(personArray));

    System.out.println("==================");

    Person[] personArray2 = new Person[3];
    System.out.println("转换前的数组地址:" + System.identityHashCode(personArray2));
    personArray2 = personList.toArray(personArray2);
    System.out.println("转换后的数组地址:" + System.identityHashCode(personArray2));

    System.out.println("==================");

    Person[] personArray3 = new Person[4];
    System.out.println("转换前的数组地址:" + System.identityHashCode(personArray3));
    personArray3 = personList.toArray(personArray3);
    System.out.println("转换后的数组地址:" + System.identityHashCode(personArray3));
}

输出:

toArray3.jpg

从输出中可以看到,只有当指定数组的长度大于等于集合长度时,该方法才会使用我们自己声明的数组对象保存元素,否则会重新声明一个数组对象来保存元素,因此,我们在日常开发中可以通过在声明数组对象时为其指定合适的长度,从而避免虚拟机创建多余的对象造成内存的浪费。

但指定数组长度为多少才算合适呢?《阿里巴巴java开发手册》中有明确的强制说明:

使用 toArray 带参方法,数组空间大小的 length:

1) 等于 0,动态创建与 size 相同的数组,性能最好。

2) 大于 0 但小于 size,重新创建大小等于 size 的数组,增加 GC 负担。

3) 等于 size,在高并发情况下,数组创建完成之后,size 正在变大的情况下,负面影响与 2 相同。

4) 大于 size,空间浪费,且在 size 处插入 null 值,存在 NPE 隐患。

根据下标获取对象

通过get(int index)方法可以根据下标获取对应的元素

 public static void get() {
   
     List<Person> personList = new ArrayList<>();
     personList.add(new Person("张三"));
     personList.add(new Person("李四"));
     personList.add(new Person("王五"));

     Person person = personList.get(0);
     System.out.println("第0个元素:" + person.toString());
     Person person3 = personList.get(3);
     System.out.println("第3个元素:" + person3.toString());

 }

输出:

注意:该方法对参数有限制要求

① 参数小于0:抛出数组越界异常

② 参数区间为[0, 元素数量 - 1]:返回对应的元素

③ 参数区间为(元素数量 - 1, +∞]:抛出下标越界异常

将指定下标设置为指定元素

通过set(int index, E element)方法,将下标为index位置的元素设置为element

public static void set() {
   
    List<Person> personList = new ArrayList<>();
    personList.add(new Person("张三"));
    personList.add(new Person("李四"));
    personList.add(new Person("王五"));
    System.out.println("转换前的集合:\n" + personList);

    personList.set(0, new Person("赵六"));
    System.out.println("转换后的集合:\n" + personList);
}

输出:

set.jpg

而且,该方法有一个返回值,返回的是指定下标之前的元素

同样的,该方法会对下标的范围进行检查,如果传入的下标取值不在区间[0, 元素数量 - 1],则会抛出数组越界异常下标越界异常

添加元素

ArrayList提供了两个方法供我们向集合中添加元素,分别是add(E element)add(int index, E element),分别表示按顺序在集合后面添加一个元素,和**在指定位置添加元素

public static void add() {
   
    List<Person> personList = new ArrayList<>();
    personList.add(new Person("张三"));
    System.out.println("集合中的元素:" + personList);
    personList.add(new Person("李四"));
    System.out.println("集合中的元素:" + personList);
    personList.add(new Person("王五"));
    System.out.println("集合中的元素:" + personList);

    // 在下标为一的位置添加元素赵六
    System.out.println("在下标为1的位置添加元素");
    personList.add(1, new Person("赵六"));
    System.out.println("集合中的元素:" + personList);
}

输出:

add.jpg

注意:

① 在使用add(int index, E element)方法时,该方法会对传入的下标值进行校验,如果不在[0, size()]区间内,则会抛出下标越界异常

② 在使用add(int index, E element)方法时,会重新创建一个数组对象,将index下标之前的元素保持位置不变,index下标之后的元素位置向后移一位,从而增加cpu的处理时间。

移除元素

通过remove(int index)方法和remove(Object o)方法,可以对集合中的元素进行删除操作

先看remove(int index)方法

public static void remove() {
   
    List<Person> personList = new ArrayList<>();
    personList.add(new Person("张三"));
    personList.add(new Person("李四"));
    personList.add(new Person("王五"));
    System.out.println("集合中的元素:" + personList);

    personList.remove(1);
    System.out.println("集合中的元素:" + personList);
}

输出:

remove(int).jpg

再看remove(Object o)方法

public static void remove() {
   
    Person zhangsan = new Person("张三");
    Person lisi = new Person("李四");
    Person wangwu1 = new Person("王五");
    Person wangwu2 = new Person("王五");
    List<Person> personList = new ArrayList<>();
    personList.add(zhangsan);
    personList.add(lisi);
    personList.add(wangwu1);
    personList.add(wangwu2);
    System.out.println("集合中的元素:" + personList);

    personList.remove(wangwu1);
    System.out.println("集合中的元素:" + personList);
}

输出:

remove1.jpg

从输出中可以看到,我们要删除集合中的对象wangwu1,只要将wangwu1作为参数调用方法即可

注意:

remove(Object o)方法方法在删除集合中的元素时,有一个执行比较的顺序:① 先比较集合中元素的内存地址是否存在与参数的内存地址相同的元素,②集合内元素与参数进行equals()比较结果是否为true。如果符合,则说明两个元素相同,执行删除集合。

上面的示例因为传入的参数与集合中的元素是同一个对象,即拥有相同的内存地址,因此可以删除成功。

我们可以传入内存地址不同的元素,通过重写equals()方法使两个对象相同。

// 先重写Person类中的equals()方法
// 如果对象地址相同,或有相同的类型,或name属性相同,都返回true
@Override
public boolean equals(Object o) {
   
    if (this == o) 
        return true;
    if (o == null || getClass() != o.getClass()) 
        return false;
    Person person = (Person) o;
    return name.equals(person.name);
}

// 示例
public static void remove() {
   
    List<Person> personList = new ArrayList<>();
    personList.add(new Person("张三"));
    personList.add(new Person("李四"));
    personList.add(new Person("王五"));
    personList.add(new Person("王五"));
    System.out.println("集合中的元素:" + personList);

    personList.remove(new Person("王五"));
    System.out.println("集合中的元素:" + personList);
}

输出:

remove2.jpg

从输出中发现,虽然要删除另一个Person对象,但只要两个对象的name值相同,即可删除成功,而且是按集合中元素的顺序仅删除第一个相同的元素

清空集合

通过clear()方法可以将集合中的元素清空

public static void clear() {
   
    List<Person> personList = new ArrayList<>();
    personList.add(new Person("张三"));
    personList.add(new Person("李四"));
    personList.add(new Person("王五"));
    personList.add(new Person("王五"));
    System.out.println("集合中的元素:" + personList);

    personList.clear();
    System.out.println("集合中的元素被清空后:" + personList);
}

输出:

clear.jpg

遍历集合

① 通过for循环遍历,此种方式需要结合get()方法

public static void for1() {
   
    List<Person> personList = new ArrayList<>();
    personList.add(new Person("张三"));
    personList.add(new Person("李四"));
    personList.add(new Person("王五"));

    for (int i = 0; i < personList.size(); i++) {
   
        Person person = personList.get(i);
        System.out.println(person);
    }
}

输出:

for1.jpg

② 通过增强型for循环遍历

public static void for2() {
   
    List<Person> personList = new ArrayList<>();
    personList.add(new Person("张三"));
    personList.add(new Person("李四"));
    personList.add(new Person("王五"));

    for (Person person : personList) {
   
        System.out.println(person);
    }
}

输出:

for2.jpg

③ 通过forEach方法遍历

​ 此方法通过向forEach()方法传入一个Consumer函数来遍历集合中的每一个元素,如果不了解java8新特性可自行百度

public static void for3() {
   
    List<Person> personList = new ArrayList<>();
    personList.add(new Person("张三"));
    personList.add(new Person("李四"));
    personList.add(new Person("王五"));

    personList.forEach(person -> System.out.println(person));
}

输出:

for3.jpg

高级篇

在高级篇中,有一些方法是ArrayList类自己定义的方法,并不是从List接口AbstractList抽象类中继承过来的,因此要使用这些方法时,对象的引用类型就不能使用List了,而是ArrayList

释放浪费的内存空间

通过trimToSize()方法,可以整理内部数组的长度,以达到节省内存空间的目的,比如我们在实例化ArrayList时指定容量为100,此时内部数组elementData长度为100,但是只向其中添加了1个元素,那么就导致elementData中有99个对象长度的内存空间得不到利用从而造成内存的浪费

public static void trimToSize() {
   
    ArrayList<Object> list = new ArrayList<>(100);
    list.add(new Object());
    // 此时list内部elementData数组的长度为100
    System.out.println("list中元素的数量:" + list.size());

    list.trimToSize();
    // 此时list内部elementData数组的长度为1,即list.size()
    System.out.println("调用trimToSize方法后list中元素的数量:" + list.size());
}

输出:

trimToSize方法.jpg

从输出中我们看到调用trimToSize()方法前后size()方法的返回值相同,没有什么区别,但是我们可以看一下它内部的变化

trimToSize方法调试过程.jpg

当端点刚进入trimToSize()方法时,我们可以看到elementData数组长度为100,而当该方法执行结束后,elementData数组长度为1,帮助我们节省了一些内存空间上的浪费

手动扩容

通过ensureCapacity()方法可以将elementData数组的长度扩充至指定的长度,当我们在实例化对象时如果没有指定初始化长度,而实际场景我们需要向该集合中添加大量元素时,可以通过这个方法来避免在添加元素时内部频繁自动扩容而导致性能降低的问题

先看一下如何使用

/**
 * 手动扩容
 */
public static void ensureCapcity() {
   
    // 默认elementData数组长度为10
    ArrayList<Object> list = new ArrayList<>(10);
    System.out.println("扩容前list的元素数量:" + list.size());

    // 手动将elementData数组长度扩充为100
    list.ensureCapacity(100);
    System.out.println("扩容后list的元素数量:" + list.size());
}

将断点设置在ensureCapacity()方法的第一行,对比一下刚进入这个方法时elementData数组的长度和方法执行完elementData数组的长度有什么变化,不难看出,在方法执行前数组长度为10,执行后方法长度为100

手动扩容前后对比.jpg

序列化与反序列化

序列化:

public static void serializable() throws IOException {
   

    ArrayList<Person> personList = new ArrayList<>();
    personList.add(new Person("张三"));
    personList.add(new Person("李四"));
    personList.add(new Person("王五"));

    ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream(System.getProperty("user.dir") + "/src/main/resources/aa.txt"));
    outputStream.writeObject(personList);
    outputStream.close();
}

输出:

writeObject.jpg

反序列化:

public static void derializable() throws IOException, ClassNotFoundException {
   

    InputStream is = ArrayListDemo.class.getClassLoader().getResourceAsStream("aa.txt");
    ObjectInputStream ois = new ObjectInputStream(is);
    Object o = ois.readObject();
    System.out.println(o);
    ois.close();
}

输出:

readObject.jpg

注意:

从反序列化的结果可以看出,我们在序列化的时候是可以将元素序列化到文件中去的,但是保存元素的数组对象elementData[]明明已经使用transient关键字修饰了,也就是说在序列化ArrayList对象时,elementData[]属性是会被忽略掉的,那么为什么在序列化时又可以输出到文件呢?

从两个方面解答:

1、为什么要使用transient关键字修饰?

由于ArrayList集合实际上是通过数组elementData保存元素的,这就意味着数组长度可能远远大于实际的元素数量,这就会导致序列化时产生空间和时间上不必要的浪费,因此使用transient关键字修饰,忽略掉这个数组

2、为什么可以将元素序列化?

首先可以查看源码,ArrayList中有writeObject(ObjectOutputStream s)方法和readObject(ObjectInputStream s)方法的实现,从实现逻辑中不难发现,在序列化时,通过遍历elementData[]数组,仅将已保存在数组中的元素进行序列化,仅做必要的序列化从而节省空间和时间。

延伸:

为什么writeObject(ObjectOutputStream s)方法和readObject(ObjectInputStream s)方法都是使用private来修饰的呢?而且没有发现有什么地方调用这两个方法,那么他是如果发挥作用呢?

答:通过反射调用。

以序列化为例,在序列化时,我们会调用ObjectOutputStream对象的writeObject(obj)方法,进而通过反射调用writeObject(ObjectOutputStream s)方法完成序列化。

移除交集

通过removeAll(Collection<?> c)方法可以从当前集合中移除掉与集合c的交集元素

public static void removeAll() {
   

    ArrayList<Person> personList1 = new ArrayList<>();
    personList1.add(new Person("张三"));
    personList1.add(new Person("李四"));
    personList1.add(new Person("王五"));
    System.out.println("personList1集合中的元素:" + personList1);


    ArrayList<Person> personList2 = new ArrayList<>();
    personList2.add(new Person("王五"));
    personList2.add(new Person("赵六"));
    System.out.println("personList2集合中的元素:" + personList2);

    boolean b = personList1.removeAll(personList2);
    System.out.println(b);

    System.out.println("personList1集合中的元素:" + personList1);
    System.out.println("personList2集合中的元素:" + personList2);
}

输出:

removeAll.jpg

removeAll()方法有一个boolean类型的返回值,如果返回值为true,表示有交集元素被移除,反之,表示没有交集元素被移除。

保留交集

通过retainAll(Collection<?> c)`方法可以从当前集合中保留与集合c的交集元素

public static void retainAll() {
   

    ArrayList<Person> personList1 = new ArrayList<>();
    personList1.add(new Person("张三"));
    personList1.add(new Person("李四"));
    personList1.add(new Person("王五"));
    System.out.println("personList1集合中的元素:" + personList1);


    ArrayList<Person> personList2 = new ArrayList<>();
    personList2.add(new Person("王五"));
    personList2.add(new Person("赵六"));
    System.out.println("personList2集合中的元素:" + personList2);

    boolean b = personList1.retainAll(personList2);
    System.out.println(b);

    System.out.println("personList1集合中的元素:" + personList1);
    System.out.println("personList2集合中的元素:" + personList2);
}

输出:

retainAll.jpg

retainAll()方法有一个boolean类型的返回值,如果返回值为true,表示有元素被移除,反之,表示没有元素被移除。

克隆

通过clone()方法可以实现对集合的复制

public static void cloneDemo() {
   

    ArrayList<Person> personArrayList = new ArrayList<>();
    personArrayList.add(new Person("张三"));
    personArrayList.add(new Person("李四"));
    personArrayList.add(new Person("王五"));
    System.out.println("克隆前person数组:" + personArrayList);

    Object personListClone = personArrayList.clone();
    ArrayList<Person> newPersonList = (ArrayList<Person>) personListClone;
    System.out.println("克隆后person数组:" + newPersonList);

}

static class Person {
   
    private String name;

    // 省略构造方法、get方法、set方法

    @Override
    public String toString() {
   
        return name + ",内存地址:" + System.identityHashCode(this);
    }
}

输出:

克隆.jpg

从输出中我们可以看到,通过调用clone()方法,可以获得与当前对象一模一样的复制品,连元素都是一模一样的,连每个元素的内存之地都是一模一样的,那会不会有一个问题呢?如果我们修改克隆前集合中的元素,会不会影响克隆后的集合呢?

public static void cloneDemo() {
   

    ArrayList<Person> personArrayList = new ArrayList<>();
    personArrayList.add(new Person("张三"));
    personArrayList.add(new Person("李四"));
    personArrayList.add(new Person("王五"));
    System.out.println("克隆前person数组:" + personArrayList);

    Object personListClone = personArrayList.clone();
    ArrayList<Person> newPersonList = (ArrayList<Person>) personListClone;
    System.out.println("克隆后person数组:" + newPersonList);

    newPersonList.get(0).setName("我修改了名字");
    System.out.println("修改元素==============");


    System.out.println("克隆前person数组:" + personArrayList);
    System.out.println("克隆后person数组:" + newPersonList);
}

输出:

克隆 - 浅拷贝.jpg

因此,克隆前和克隆后的两个对象引用的是内存中不同的地址,是两个对象,但是这两个对象中的元素却引用了内存中相同的地址,是同一个元素,那就可以很形象地比喻为换了马甲的王八,如果修改了一个元素的内容,那么就会影响引用了这个元素的其他集合。

这就是我们熟知的浅拷贝,即换了马甲的王八

有了浅拷贝,那么有深拷贝吗?

对于深拷贝,我们可以对标浅拷贝来思考一下,浅拷贝是只换马甲,也就是说拷贝的范围比较浅,只对当前对象的地址引用进行拷贝,而不关注当前对象内的属性。而深拷贝的范围应该是比较深的,在拷贝时不仅关注当前对象的地址引用,同时还关注当前对象的属性,以及属性的属性...直到最深处。

深拷贝与浅拷贝是针对对象属性为对象的,因为基本数据类型在进行赋值操作时(也就是深拷贝(值拷贝)),是直接将值赋给了新的变量,也就是该变量是原变量的一个副本,这时,你修改两个中的任意一个都不会影响另一个;而对于对象或引用数据在进行浅拷贝时,只是将对象的引用复制了一份,也就是内存地址,即两个不同的变量指向了同一个内存地址,那么在改变任意一个变量的值都是改变内存地址所存储的值,因此两个变量的值都会改变。

而ArrayList为我们提供的clone()方法实现的仅仅是浅拷贝,这一点在方法注释上有说明

克隆源码.jpg

线程安全问题

大家都知道,ArrayList是线程不安全的,从源码中也可以看到,每一个方法都没有使用synchronized关键字或使用AQS锁机制等方式来保证方法的线程安全性,也就是说,当有多个线程操作同一个ArrayList对象时,就会发生一些问题

public static void unSafeThread() throws InterruptedException {
   
    List<Integer> list = new ArrayList<>(1);
    MyThread thread1 = new MyThread(list);
    MyThread thread2 = new MyThread(list);

    thread1.start();
    thread2.start();
}

// 定义一个线程类,对集合进行添加元素的操作
class MyThread extends Thread {
   

    private List<Integer> list;

    public MyThread(List<Integer> list) {
   
        this.list = list;
    }

    @Override
    public void run() {
   
        list.add(1);
    }
}

上面示例代码中,我们开启了两个线程,每个线程都对同一个集合对象list进行add()操作,这时就有可能会发生线程不安全的问题,我们可以通过代码debug调式复现这个问题

多线程调式add方法.jpg

我们在add()方法第2行打上一个类型为线程的条件断点,因为我们的示例代码中集合元素为Integer类型,因此我们可以直接设置断点的条件为e instanceof Integer,当条件成立时断点才会生效

在控制台中可以可以看到有两个线程调用了该方法

多线程调试.jpg

在这时,两个线程都已经完成了ensureCapacityInternal(size + 1),而且两个线程的执行情况都是ensureCapacityInternal(1)size = 0,而我们在初始化ArrayList时指定了初始容量为1,那么这时其实是不需要自动扩容的,结果就是elementData[]数组的长度依然为1,而当两个线程都执行elementData[size++] = e时,第一个线程执行的是elementData[0] = e,可以执行成功,而第二个线程执行的却是elementData[1] = e,这时就会因为数组长度的原因发生数组下标越界异常,报错信息中还会告诉我们线程名表示哪一个线程出现异常:

多线程异常.jpg

那么如何保证线程安全呢?

1、给方法或代码块添加synchronized关键字

2、使用Collections.synchronizedList(new ArrayList<>())

3、在需要保证线程安全的代码块之间使用Lock

4、使用ThreadLocal

使用迭代器遍历

ArrayList内部维护了一个迭代器类private class Itr implements Iterator<E>,该迭代器为单向迭代,就是说只能按照集合中元素的顺序访问,我们可以通过iterator()方法获得一个迭代器。

使用迭代器遍历集合时我们只需要关注两个方法即可,分别是hasNext()next()

public static void iterator() {
   
    List<Integer> list = new ArrayList<>();
    list.add(1);
    list.add(2);
    list.add(3);
    list.add(4);
    list.add(5);
    list.add(6);

    // 通过iterator方法获取迭代器
    Iterator<Integer> iterator = list.iterator();
    // 判断当前迭代器是否已迭代至最后一个元素
    while (iterator.hasNext()) {
   
        // 通过next方法获取下一个元素
        Integer next = iterator.next();
        System.out.println(next);
    }
}

输出:

迭代器.jpg

当我们使用迭代器来遍历集合时,迭代器内部会有一个cursor来标记在elementData[]中当前遍历的位置(即数组下标),当前cursor的值小于集合的元素数量时,hasNext()方法始终返回true。当我们调用next()方法时,cursor的值自增,再获取数组中下标为cursor的元素并返回。

使用双向迭代器遍历

双向迭代器,顾名思义,就是具有前后两个方向的迭代器,前面的迭代器是单向的,而双向迭代器在单向迭代器的基础上添加了向前遍历的功能

public static void listIterator() {
   
    List<Integer> list = new ArrayList<>();
    list.add(1);
    list.add(2);
    list.add(3);

    ListIterator<Integer> listIterator = list.listIterator();
    System.out.println("正向遍历:");
    while (listIterator.hasNext()) {
   
        System.out.print(listIterator.next() + " ");
    }

    System.out.println("");

    System.out.println("反向遍历:");
    while (listIterator.hasPrevious()) {
   
        System.out.print(listIterator.previous() + " ");
    }
}

输出:

双向迭代.jpg

由于双向迭代器是对单向迭代器的扩展,而且双向迭代器继承了单向迭代器,只是添加了一些反向遍历的操作,因此,双向迭代器内部也是通过cursor来标记在elementData[]中当前遍历的位置(即数组下标)。当前cursor的值小于集合的元素数量时,hasNext()方法始终返回true,当前cursor的值大于0时,hasPrevious()方法始终返回true。原理与单向迭代器完全相同。

并发修改异常

所谓并发修改异常,其实指的就是ConcurrentModificationException,在多线程环境下,当一个线程在遍历一个集合的过程中,另一个线程对此集合的内部结构做了修改;在单线程环境下,在对集合遍历的过程中对集合结构进行修改。这两种情况都会导致ConcurrentModificationException

多线程环境下:

public static void multiThread() {
   
    List<Integer> list = new ArrayList<>(5);
    list.add(1);
    list.add(2);
    list.add(3);
    list.add(4);
    list.add(5);

    MyThread myThread = new MyThread(list);
    YourThread yourThread = new YourThread(list);

    yourThread.start();
    myThread.start();
}

class MyThread extends Thread {
   

    private List<Integer> list;

    public MyThread(List<Integer> list) {
   
        this.list = list;
    }

    @Override
    public void run() {
   
        // 断点
        list.add(1);
    }
}


class YourThread extends Thread {
   

    private List<Integer> list;

    public YourThread(List<Integer> list) {
   
        this.list = list;
    }

    @SneakyThrows
    @Override
    public void run() {
   
        for (Integer integer : list) {
   
            // 断点
            System.out.println(integer);
            Thread.sleep(2000L);
        }
    }
}

我们在两个线程的run方法内添加断点,在YourThread线程执行的集合遍历过程中,通过断点暂停程序执行,此时MyThread线程执行了list.add(1),我们知道,当add()方法执行过程中会涉及到自动扩容导致集合内部结构改变。MyThread线程执行结束,YourThread线程的断点放开继续遍历,此时就会抛出并发修改异常。如下所示,在遍历的过程中修改了集合的内部结构导致程序异常。

输出:

并发修改异常 - 多线程.jpg

单线程环境:

public static void singleThread() {
   
    List<String> list = new ArrayList<>(5);
    list.add("张三");
    list.add("李四");
    list.add("王五");
    list.add("赵六");
    for (String s : list) {
   
        if (s.equals("李四"))
            list.remove(s);
        else
            System.out.println(s);
    }
}

输出:

并发修改异常 - 单线程.jpg

出现该异常的原因是我们在遍历一个集合的同时,又对这个集合的内部结构进行修改,无论是单线程还是多线程环境下,都会抛出并发修改异常,其源码级别的原理我们在之前的文章中有相关描述

源码链接

那么既然面对了这个问题,我们该如何在遍历集合的同时又可以修改其内部结构呢?

可以通过获取集合的迭代器,使用迭代器遍历集合元素时,如果要移除元素,可以使用迭代器提供给我们的remove()方法,下面看示例:

public static void itrRemove() {
   
    List<String> list = new ArrayList<>(5);
    list.add("张三");
    list.add("李四");
    list.add("王五");
    list.add("赵六");

    Iterator<String> iterator = list.iterator();
    while (iterator.hasNext()) {
   
        String s = iterator.next();
        if (s.equals("李四"))
            iterator.remove();
        else
            System.out.println(s);
    }
}

输出:

并发修改异常 - 迭代器.jpg

获取集合的视图

ArrayList内部维护了一个子集合类private class SubList extends AbstractList<E> implements RandomAccess,该子集合类为ArrayList集合的视图,即SubListArrayList的子集,我们可以通过subList((int fromIndex, int toIndex))方法获取原集合的一个子集合。

public static void subList() {
   
    List<String> list = new ArrayList<>(5);
    list.add("张三");
    list.add("李四");
    list.add("王五");
    list.add("赵六");
    list.add("王八");
    // 通过subList方法,获取list集合中下标为1-3的两个元素作为子集合
    List<String> subList = list.subList(1, 3);

    System.out.println(subList);
}

输出:

subList1.jpg

因为subList()方法返回的实际上是ArrayList的内部类SubList,而非ArrayList,因此不可以将其强制转为ArrayList类型,这在《阿里巴巴java开发手册》中也有强制要求:

【强制】ArrayList 的 subList 结果不可强转成 ArrayList,否则会抛出 ClassCastException 异常:java.util.RandomAccessSubList cannot be cast to java.util.ArrayList。

说明:subList()返回的是 ArrayList 的内部类 SubList,并不是 ArrayList 本身,而是 ArrayList 的一个视图,对于 SubList 的所有操作最终会反映到原列表上。

除此之外,《阿里巴巴java开发手册》中也有另一个强制要求:

【强制】在 subList 场景中,高度注意对父集合元素的增加或删除,均会导致子列表的遍历、增加、删除产生 ConcurrentModificationException 异常。

从开发手册中,可以了解到,只要修改了父集合的结构(对父集合增加或删除),则子集合在遍历、增加、删除时都会导致并发修改异常

public static void subList1() {
   
    List<String> list = new ArrayList<>(5);
    list.add("张三");
    list.add("李四");
    list.add("王五");
    list.add("赵六");
    list.add("王八");
    List<String> subList = list.subList(1, 4);
    System.out.println(subList);

    list.add("郭九");
    System.out.println(subList);
}

输出:

subList2.jpg

那么不修改父集合的结构,只修改元素的值,对子集合有什么影响呢?

public static void subList2() {
   
    List<String> list = new ArrayList<>(5);
    list.add("张三");
    list.add("李四");
    list.add("王五");
    list.add("赵六");
    list.add("王八");
    List<String> subList = list.subList(1, 4);
    System.out.println(subList);

    list.set(2, "改了");
    System.out.println(subList);
}

输出:

subList3.jpg

修改子集合的结构,对父集合有什么影响?

public static void subList3() {
   
    List<String> list = new ArrayList<>(5);
    list.add("张三");
    list.add("李四");
    list.add("王五");
    list.add("赵六");
    list.add("王八");
    List<String> subList = list.subList(1, 4);
    System.out.println("子集合:" + subList);
    System.out.println("父集合:" + list);

    subList.add("新元素");
    System.out.println("添加了元素的子集合:" + subList);
    System.out.println("添加了元素的父集合:" + list);
}

输出:

subList4.jpg

修改子集合的元素的值,对父集合有什么影响?

public static void subList4() {
   
    List<String> list = new ArrayList<>(5);
    list.add("张三");
    list.add("李四");
    list.add("王五");
    list.add("赵六");
    list.add("王八");
    List<String> subList = list.subList(1, 4);
    System.out.println("子集合:" + subList);
    System.out.println("父集合:" + list);

    subList.set(1, "新元素");
    System.out.println("修改了元素的子集合:" + subList);
    System.out.println("修改了元素的父集合:" + list);
}

输出:

subList5.jpg

结论:

1、修改父集合的结构,子集合抛并发修改异常,

2、修改父集合的元素,子集合的元素会受影响,

3、修改子集合的结构,父集合的结构会受影响,

4、修改子集合的元素,父集合的元素会受影响。

到这里这篇文章就结束了,每天抽一点时间写文章竟也花了两周时间。或许文章应该是小而精,而不应该像这篇一样篇幅过长却无法深挖一个知识点

学习是这个世界上最简单的事。再会~

相关文章
|
7月前
|
存储 安全 Java
ArrayList源码全面解析
ArrayList源码全面解析
|
6月前
|
Java 索引
Java List实战:手把手教你玩转ArrayList和LinkedList
【6月更文挑战第17天】在Java中,ArrayList和LinkedList是List接口的实现,分别基于动态数组和双向链表。ArrayList适合索引访问,提供快速读取,而LinkedList擅长插入和删除操作。通过示例展示了两者的基本用法,如添加、访问、修改和删除元素。根据场景选择合适的实现能优化性能。
54 0
|
6月前
|
存储 Java C++
Java List大揭秘:ArrayList vs LinkedList,谁才是真正的王者?
【6月更文挑战第17天】ArrayList和LinkedList是Java中实现List接口的两种方式。ArrayList基于动态数组,适合随机访问和遍历,内存紧凑,但插入删除元素特别是在中间时效率低。LinkedList以双向链表实现,擅长任意位置的插入删除,内存管理灵活,迭代高效,但随机访问性能差。选择使用哪种取决于具体应用场景。
41 0
|
7月前
|
算法 Java Go
ArrayList源码解析
ArrayList源码解析
|
7月前
|
存储 Java
ArrayList源码学习
深入学习ArrayList源码
ArrayList源码学习
|
7月前
|
存储 安全 Java
java面试基础 -- ArrayList 和 LinkedList有什么区别, ArrayList和Vector呢?
java面试基础 -- ArrayList 和 LinkedList有什么区别, ArrayList和Vector呢?
63 0
|
7月前
|
存储 安全 算法
Java一分钟之-Java集合框架入门:List接口与ArrayList
【5月更文挑战第10天】本文介绍了Java集合框架中的`List`接口和`ArrayList`实现类。`List`是有序集合,支持元素重复并能按索引访问。核心方法包括添加、删除、获取和设置元素。`ArrayList`基于动态数组,提供高效随机访问和自动扩容,但非线程安全。文章讨论了三个常见问题:索引越界、遍历时修改集合和并发修改,并给出避免策略。通过示例代码展示了基本操作和安全遍历删除。理解并正确使用`List`和`ArrayList`能提升程序效率和稳定性。
62 0
|
存储 Java
Java基础进阶List-LinkedList集合
Java基础进阶List-LinkedList集合
Java基础进阶List-LinkedList集合
|
存储 缓存 Java
ArrayList源码浅析
Java中List是一个必须要掌握的基础知识,List是一个接口,实现List接口的基础类有很多,其中最具有代表性的两个:ArrayList和LinkedList。
173 0
|
存储 安全 Java
Java基础进阶List-ArrayList集合
Java基础进阶List-ArrayList集合
下一篇
DataWorks