• 我说ArrayList初始容量是10,面试官让我回去等通知


    引言

    在Java集合中,ArrayList是最常用到的数据结构,无论是在日常开发还是面试中,但是很多人对它的源码并不了解。下面提问几个问题,检验一下大家对ArrayList的了解程度。

    1. ArrayList的初始容量是多少?(90%的人都会答错)
    2. ArrayList的扩容机制
    3. 并发修改ArrayList元素会有什么问题
    4. 如何快速安全的删除ArrayList中的元素

    接下来一块分析一下ArrayList的源码,看完ArrayList源码之后,可以轻松解答上面四个问题。

    简介

    ArrayList底层基于数组实现,可以随机访问,内部使用一个Object数组来保存元素。它维护了一个 elementData 数组和一个 size 字段,elementData数组用来存放元素,size字段用于记录元素个数。它允许元素是null,可以动态扩容。

    初始化

    当我们调用ArrayList的构造方法的时候,底层实现逻辑是什么样的?

    1. // 调用无参构造方法,初始化ArrayList
    2. List<Integer> list1 = new ArrayList<>();
    3. // 调用有参构造方法,初始化ArrayList,指定容量为10
    4. List<Integer> list1 = new ArrayList<>(10);

    看一下底层源码实现:

    1. // 默认容量大小
    2. private static final int DEFAULT_CAPACITY = 10;
    3. // 空数组
    4. private static final Object[] EMPTY_ELEMENTDATA = {};
    5. // 默认容量的数组对象
    6. private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
    7. // 存储元素的数组
    8. transient Object[] elementData;
    9. // 数组中元素个数,默认是0
    10. private int size;
    11. // 无参初始化,默认是空数组
    12. public ArrayList() {
    13. this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    14. }
    15. // 有参初始化,指定容量大小
    16. public ArrayList(int initialCapacity) {
    17. if (initialCapacity > 0) {
    18. // 直接使用指定的容量大小
    19. this.elementData = new Object[initialCapacity];
    20. } else if (initialCapacity == 0) {
    21. this.elementData = EMPTY_ELEMENTDATA;
    22. } else {
    23. throw new IllegalArgumentException("Illegal Capacity: "+initialCapacity);
    24. }
    25. }

    可以看到当我们调用ArrayList的无参构造方法 new ArraryList<>() 的时候,只是初始化了一个空对象,并没有指定数组大小,所以初始容量是零。至于什么时候指定数组大小,接着往下看。

    添加元素

    再看一下往ArrayList种添加元素时,调用的 add() 方法源码:

    1. // 添加元素
    2. public boolean add(E e) {
    3. // 确保数组容量够用,size是元素个数
    4. ensureCapacityInternal(size + 1);
    5. // 直接在下个位置赋值
    6. elementData[size++] = e;
    7. return true;
    8. }
    9. // 确保数组容量够用
    10. private void ensureCapacityInternal(int minCapacity) {
    11. ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
    12. }
    13. // 计算所需最小容量
    14. private static int calculateCapacity(Object[] elementData, int minCapacity) {
    15. // 如果数组等于空数组,就设置默认容量为10
    16. if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
    17. return Math.max(DEFAULT_CAPACITY, minCapacity);
    18. }
    19. return minCapacity;
    20. }
    21. // 确保容量够用
    22. private void ensureExplicitCapacity(int minCapacity) {
    23. modCount++;
    24. // 如果所需最小容量大于数组长度,就进行扩容
    25. if (minCapacity - elementData.length > 0)
    26. grow(minCapacity);
    27. }

    看一下扩容逻辑:

    1. // 扩容,就是把旧数据拷贝到新数组里面
    2. private void grow(int minCapacity) {
    3. int oldCapacity = elementData.length;
    4. // 计算新数组的容量大小,是旧容量的1.5
    5. int newCapacity = oldCapacity + (oldCapacity >> 1);
    6. // 如果扩容后的容量小于最小容量,扩容后的容量就等于最小容量
    7. if (newCapacity - minCapacity < 0)
    8. newCapacity = minCapacity;
    9. // 如果扩容后的容量大于Integer的最大值,就用Integer最大值
    10. if (newCapacity - MAX_ARRAY_SIZE > 0)
    11. newCapacity = hugeCapacity(minCapacity);
    12. // 扩容并赋值给原数组
    13. elementData = Arrays.copyOf(elementData, newCapacity);
    14. }

    可以看到:

    • 扩容的触发条件是数组全部被占满
    • 扩容是以旧容量的1.5倍扩容,并不是2倍扩容
    • 最大容量是Integer的最大值
    • 添加元素时,没有对元素校验,允许为null,也允许元素重复。

    再看一下数组拷贝的逻辑,这里都是Arrays类里面的方法了:

    1. /**
    2. * @param original 原数组
    3. * @param newLength 新的容量大小
    4. */
    5. public static <T> T[] copyOf(T[] original, int newLength) {
    6. return (T[]) copyOf(original, newLength, original.getClass());
    7. }
    8. public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
    9. // 创建一个新数组,容量是新的容量大小
    10. T[] copy = ((Object)newType == (Object)Object[].class)
    11. ? (T[]) new Object[newLength]
    12. : (T[]) Array.newInstance(newType.getComponentType(), newLength);
    13. // 把原数组的元素拷贝到新数组
    14. System.arraycopy(original, 0, copy, 0,
    15. Math.min(original.length, newLength));
    16. return copy;
    17. }

    最终调用了System类的数组拷贝方法,是native方法:

    1. /**
    2. * @param src 原数组
    3. * @param srcPos 原数组的开始位置
    4. * @param dest 目标数组
    5. * @param destPos 目标数组的开始位置
    6. * @param length 被拷贝的长度
    7. */
    8. public static native void arraycopy(Object src, int srcPos,
    9. Object dest, int destPos,
    10. int length);

    总结一下ArrayList的 add() 方法的逻辑:

    1. 检查容量是否够用,如果够用,直接在下一个位置赋值结束。
    2. 如果是第一次添加元素,则设置容量默认大小为10。
    3. 如果不是第一次添加元素,并且容量不够用,则执行扩容操作。扩容就是创建一个新数组,容量是原数组的1.5倍,再把原数组的元素拷贝到新数组,最后用新数组对象覆盖原数组。

    需要注意的是,每次扩容都会创建新数组和拷贝数组,会有一定的时间和空间开销。在创建ArrayList的时候,如果我们可以提前预估元素的数量,最好通过有参构造函数,设置一个合适的初始容量,以减少动态扩容的次数。

    删除单个元素

    再看一下删除元素的方法 remove() 的源码:

    1. public boolean remove(Object o) {
    2. // 判断要删除的元素是否为null
    3. if (o == null) {
    4. // 遍历数组
    5. for (int index = 0; index < size; index++)
    6. // 如果和当前位置上的元素相等,就删除当前位置上的元素
    7. if (elementData[index] == null) {
    8. fastRemove(index);
    9. return true;
    10. }
    11. } else {
    12. // 遍历数组
    13. for (int index = 0; index < size; index++)
    14. // 如果和当前位置上的元素相等,就删除当前位置上的元素
    15. if (o.equals(elementData[index])) {
    16. fastRemove(index);
    17. return true;
    18. }
    19. }
    20. return false;
    21. }
    22. // 删除该位置上的元素
    23. private void fastRemove(int index) {
    24. modCount++;
    25. // 计算需要移动的元素的个数
    26. int numMoved = size - index - 1;
    27. if (numMoved > 0)
    28. //index+1位置开始拷贝,也就是后面的元素整体向左移动一个位置
    29. System.arraycopy(elementData, index+1, elementData, index, numMoved);
    30. // 设置数组最后一个元素赋值为null,防止会导致内存泄漏
    31. elementData[--size] = null;
    32. }

    删除元素的流程是:

    1. 判断要删除的元素是否为null,如果为null,则遍历数组,使用双等号比较元素是否相等。如果不是null,则使用 equals() 方法比较元素是否相等。这里就显得啰嗦了,可以使用 Objects.equals()方法,合并ifelse逻辑。
    2. 如果找到相等的元素,则把后面位置的所有元素整体相左移动一个位置,并把数组最后一个元素赋值为null结束。

    可以看到遍历数组的时候,找到相等的元素,删除就结束了。如果ArrayList中存在重复元素,也只会删除其中一个元素。

    批量删除

    再看一下批量删除元素方法 removeAll() 的源码:

    1. // 批量删除ArrayList和集合c都存在的元素
    2. public boolean removeAll(Collection<?> c) {
    3. // 非空校验
    4. Objects.requireNonNull(c);
    5. // 批量删除
    6. return batchRemove(c, false);
    7. }
    8. private boolean batchRemove(Collection<?> c, boolean complement){
    9. final Object[] elementData = this.elementData;
    10. int r = 0, w = 0;
    11. boolean modified = false;
    12. try {
    13. for (; r < size; r++)
    14. if (c.contains(elementData[r]) == complement)
    15. // 把需要保留的元素左移
    16. elementData[w++] = elementData[r];
    17. } finally {
    18. // 当出现异常情况的时候,可能不相等
    19. if (r != size) {
    20. // 可能是其它线程添加了元素,把新增的元素也左移
    21. System.arraycopy(elementData, r,
    22. elementData, w,
    23. size - r);
    24. w += size - r;
    25. }
    26. // 把不需要保留的元素设置为null
    27. if (w != size) {
    28. for (int i = w; i < size; i++)
    29. elementData[i] = null;
    30. modCount += size - w;
    31. size = w;
    32. modified = true;
    33. }
    34. }
    35. return modified;
    36. }

    批量删除元素的逻辑,并不是大家想象的:

    遍历数组,判断要删除的集合中是否包含当前元素,如果包含就删除当前元素。删除的流程就是把后面位置的所有元素整体左移,然后把最后位置的元素设置为null。

    这样删除的操作,涉及到多次的数组拷贝,性能较差,而且还存在并发修改的问题,就是一边遍历,一边更新原数组。 批量删除元素的逻辑,设计充满了巧思,具体流程就是:

    1. 把需要保留的元素移动到数组左边,使用下标 w 做统计,下标 w 左边的是需要保留的元素,下标 w 右边的是需要删除的元素。
    2. 虽然ArrayList不是线程安全的,也考虑了并发修改的问题。如果上面过程中,有其他线程新增了元素,把新增的元素也移动到数组左边。
    3. 最后把数组中下标 w 右边的元素都设置为null。

    所以当需要批量删除元素的时候,尽量使用 removeAll() 方法,性能更好。

    并发修改的问题

    当遍历ArrayList的过程中,同时增删ArrayList中的元素,会发生什么情况?测试一下:

    1. import java.util.ArrayList;
    2. import java.util.List;
    3. public class Test {
    4. public static void main(String[] args) {
    5. // 创建ArrayList,并添加4个元素
    6. List<Integer> list = new ArrayList<>();
    7. list.add(1);
    8. list.add(2);
    9. list.add(2);
    10. list.add(3);
    11. // 遍历ArrayList
    12. for (Integer key : list) {
    13. // 判断如果元素等于2,则删除
    14. if (key.equals(2)) {
    15. list.remove(key);
    16. }
    17. }
    18. }
    19. }

    运行结果:

    1. Exception in thread "main" java.util.ConcurrentModificationException
    2. at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:911)
    3. at java.util.ArrayList$Itr.next(ArrayList.java:861)
    4. at com.yideng.Test.main(Test.java:14)

    报出了并发修改的错误,ConcurrentModificationException。 这是因为 forEach 使用了ArrayList内置的迭代器,这个迭代器在迭代的过程中,会校验修改次数 modCount,如果 modCount 被修改过,则抛出ConcurrentModificationException异常,快速失败,避免出现不可预料的结果。

    1. // ArrayList内置的迭代器
    2. private class Itr implements Iterator<E> {
    3. int cursor;
    4. int lastRet = -1;
    5. int expectedModCount = modCount;
    6. // 迭代下个元素
    7. public E next() {
    8. // 校验 modCount
    9. checkForComodification();
    10. int i = cursor;
    11. if (i >= size)
    12. throw new NoSuchElementException();
    13. Object[] elementData = ArrayList.this.elementData;
    14. if (i >= elementData.length)
    15. throw new ConcurrentModificationException();
    16. cursor = i + 1;
    17. return (E)elementData[lastRet = i];
    18. }
    19. // 校验 modCount 是否被修改过
    20. final void checkForComodification() {
    21. if (modCount != expectedModCount)
    22. throw new ConcurrentModificationException();
    23. }
    24. }

    如果想要安全的删除某个元素,可以使用 remove(int index) 或者 removeIf() 方法。

    1. import java.util.ArrayList;
    2. import java.util.List;
    3. public class Test {
    4. public static void main(String[] args) {
    5. // 创建ArrayList,并添加4个元素
    6. List<Integer> list = new ArrayList<>();
    7. list.add(1);
    8. list.add(2);
    9. list.add(2);
    10. list.add(3);
    11. // 使用 remove(int index) 删除元素
    12. for (int i = 0; i < list.size(); i++) {
    13. if (list.get(i).equals(2)) {
    14. list.remove(i);
    15. }
    16. }
    17. // 使用removeIf删除元素
    18. list.removeIf(key -> key.equals(2));
    19. }
    20. }

    总结

    现在可以回答文章开头提出的问题了吧:

    1. ArrayList的初始容量是多少?

    答案:初始容量是0,在第一次添加元素的时候,才会设置容量为10。

    1. ArrayList的扩容机制

    答案:

    1. 创建新数组,容量是原来的1.5倍。
    2. 把旧数组元素拷贝到新数组中
    3. 使用新数组覆盖旧数组对象
    4. 并发修改ArrayList元素会有什么问题

    答案:会快速失败,抛出ConcurrentModificationException异常。

    1. 如何快速安全的删除ArrayList中的元素

    答案:使用remove(int index) 、 removeIf() 或者 removeAll() 方法。 我们知道ArrayList并不是线程安全的,原因是它的 add() 、remove() 方法、扩容操作都没有加锁,多个线程并发操作ArrayList的时候,会出现数据不一致的情况。 想要线程安全,其中一种方式是初始化ArrayList的时候使用 Collections.synchronizedCollection() 修饰。这样ArrayList所有操作都变成同步操作,性能较差。还有一种性能较好,又能保证线程安全的方式是使用 CopyOnWriteArrayList,就是下章要讲的。

    1. // 第一种方式,使用 Collections.synchronizedCollection() 修饰
    2. List<Integer> list1 = Collections.synchronizedCollection(new ArrayList<>());
    3. // 第二种方式,使用 CopyOnWriteArrayList
    4. List<Integer> list1 = new CopyOnWriteArrayList<>();
  • 相关阅读:
    使用docker搭建homarr
    Breakdance评测:改变游戏规则的WordPress网站生成器
    计算机毕业设计Java宠物互助领售平台(源码+系统+mysql数据库+lw文档)
    3. 一级缓存解析
    Linux安全加固 附脚本
    VSCODE 系列(三)Settings Sync同步设置
    CocosCreator3.8研究笔记(十四)CocosCreator 资源管理Asset Manager
    【HDFS】Hadoop RPC中的Responder、Listener、Reader、Handler
    pandas使用dataframe中的两列时间对象数据列作差生成时间差数据列、指定时间数据列减去timedelta数据列实现数据偏移(向后偏移、时间减小)
    【算法刷题日记之本手篇】二进制插入与查找组成一个偶数最接近的两个素数
  • 原文地址:https://blog.csdn.net/mxt51220/article/details/133824357