ArrayList浅析

作者: 妖怪来了 | 来源:发表于2018-04-12 20:56 被阅读11次

    0x00 前言

    大家好,我是 ArrayList, 应该是大家都耳熟能详的容器之一了。学习一下内中原理,还是很有必要的。至于为什么叫浅析呢,因为本文不会分析 Arrays 的相关方法。为什么不分析 Arrays 的相关方法,就是浅析了呢?那就接着往下看~(本文分析源码基于jdk1.8。本文基于第一人称描述,我 == ArrayList。)

    0x01 一句话介绍

    我实际上就是一个可以自动扩容的数组,并可以进行增删改查等操作。

    0x02 概述

    我是list接口的一个可扩容实现。通过 Java 的泛型机制,我可以容纳任何类型的对象。我和 Vector 非常像,但我是线程不安全的,而他是线程安全的,其他地方的差异很小。

    我所有方法中,时间复杂度为O(1)的有:

    • size
    • get
    • isEmpty
    • set
    • iterator
    • listIterator
      add方法是O(n)的。

    我的每个实例,都有一个capacity变量。那么这个变量是干嘛的呢?这个变量用于衡量我内在的数组用来装变量的长度,他总是大于等于 size 。当添加一个元素到我这里,他会自动的增长,以满足需要。

    如果一个应用他需要往我这里放置很多的元素,那最好一开始就设置我的 ensureCapacity 变量,这样我一开始就申请很多的空间,而不用我一次次扩容浪费时间了。

    请注意,我不是线程安全的!如果多个线程同时修改我,一个要设置同步,否则会出现数据错误的情况,这个锅,我是不背的。简单的方式就是用Collections#synchronizedList来包装一下我,我就变成一个同步的容器了。

    我同样拥有fail-fast机制,如果你迭代我,同时又修改我,我就会抛出ConcurrentModificationException异常,让你承受。多线程同时修改迭代,也会出现这个问题。

    0x03 解释几个变量

    private static final int DEFAULT_CAPACITY = 10

    默认的数组大小。

    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}

    如果构造我时,采用的是默认的 capacity ,就使用 DEFAULTCAPACITY_EMPTY_ELEMENTDATA 来当做默认的空数组。

    private static final Object[] EMPTY_ELEMENTDATA = {}

    capacity == 0 ,则使用 EMPTY_ELEMENTDATA 来当做默认的空数组。此时,产生了一个疑问,为什么要弄两个这样的变量呢?后面我们在扩容的时候可以看到,他们是用来区分到底是被设置了 capacity 是 0 ,还是使用了默认的 capacity。那区分这个干嘛呢?那就往下看看扩容是怎么扩的。

    transient Object[] elementData

    先解释一下 transient 这个,这个主要是让此变量不进行序列化。更多的可以谷歌一下,看看详解,此处就略过了。这个就是我的核心部件,我所有的元素都放在他里面!实质就是一个 Object 数组!

    private int size

    想要知道我里面到底有多少个元素?喏,size 就是我所拥有的元素数量。

    0x04 方法分析

    构造函数分析
    // 拥有设置 capacity 参数的构造函数
    public ArrayList(int initialCapacity) {
        if (initialCapacity > 0) {  // 如果设置的 initialCapacity 初始值大于0,那我的数组就初始相应的长度
            this.elementData = new Object[initialCapacity];
        } else if (initialCapacity == 0) { 
            // 如果设置了 0 ,那就用 EMPTY_ELEMENTDATA 来初始化我的数组。
            this.elementData = EMPTY_ELEMENTDATA;
        } else { // 小于 0 ,抛出异常
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        }
    }
    
    // 无参构造
    public ArrayList() {
        // 直接用 DEFAULTCAPACITY_EMPTY_ELEMENTDATA 来初始化我的数组。
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }
    // 还有其他构造函数,此处略去不讲。
    
    size()

    此方法用于查看我有多少个元素,来看看实现。

    public int size() {
        // 非常简单,就是返回 size 变量。
        return size;
    }
    
    get(int index)

    get 方法用来返回指定索引处的元素。

    public E get(int index) {
        // 检查索引是否越界
        rangeCheck(index);
        // 直接根据索引返回元素
        return elementData(index);
    }
    
    private void rangeCheck(int index) {
        // 可以看到,如果索引大于等于 size,将会抛出异常。所以在使用时一定要注意不能传入错误的索引,导致程序异常。
        if (index >= size)
            throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
    }
    
    set(int index, E element)

    这个方法相当于修改操作。

    public E set(int index, E element) {
        // 检查边界
        rangeCheck(index);
        // 先拿到老的元素
        E oldValue = elementData(index);
        // 对应位置附上新元素
        elementData[index] = element;
        // 返回老元素。所以 set 方法的返回是对应的旧元素。
        return oldValue;
    }
    
    add(E e)

    这个方法相当于增加操作。

    public boolean add(E e) {
        // 确保数组空间充足
        ensureCapacityInternal(size + 1);  // Increments modCount!!
        // 将元素放到原数组长度后面一位。
        elementData[size++] = e;
        return true;
    }
    private void ensureCapacityInternal(int minCapacity) {
        // 这里使用了 DEFAULTCAPACITY_EMPTY_ELEMENTDATA 。
        // 这个变量是在我初始化时,使用了默认的 capacity 的时候,用来初始化数组的。
        if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
            // 在这种情况下, 将入参和 默认 capacity 进行比较,取其较大大者。
            minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
        }
        // 根据上面的操作,决定了使用什么长度来扩容。下面来进行扩容。
        ensureExplicitCapacity(minCapacity);
    }
    
    private void ensureExplicitCapacity(int minCapacity) {
        // 如果会执行这个操作,就代表会改变数组,则将改变标志位+1.
        modCount++;
    
        // 原文注释这里说可能会有内存溢出
        if (minCapacity - elementData.length > 0) // 如果大于数组长度,则进行扩容。
            grow(minCapacity);
    }
    
    // 我内部的数组最大可以这么大,为什么减了个8呢?因为有些VM底层实现,保留了一些内部字段,致使留给用户的长度就变小了。
    private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
    
    private void grow(int minCapacity) {
        // 要注意是否会溢出。
        // 先保存数组的原长度。
        int oldCapacity = elementData.length;
        // 新长度是原长度的1.5倍。(右移相当于除以2,所以加起来是1.5倍)
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        if (newCapacity - minCapacity < 0) // 如果新长度小于入参长度,则新长度赋值为入参长度。
            newCapacity = minCapacity;
        if (newCapacity - MAX_ARRAY_SIZE > 0) // 如果新长度大于数组最大长度,则调用 hugeCapacity 方法获取长度。
            newCapacity = hugeCapacity(minCapacity);
        // 调用了 Arrays 的方法,对数组进行了一个复制。
        // 底层就不解释了,可以简单理解为把原 elementData 数组中的值,一个个都搬移到了新的长度为 newCapacity 的数组中,然后让 elementData 指向新数组。
        //(由于没有解析 Arrays.copy 方法,所以本文只能算浅析,后面相关操作,也不会进行解析。要解析的话,一篇文章可能就放不下了。以后专开文章介绍这些工具类。)
        elementData = Arrays.copyOf(elementData, newCapacity);
    }
    
    private static int hugeCapacity(int minCapacity) {
        if (minCapacity < 0) // 如果 传递进来的参数小于0,则直接抛异常,此时数组已经放不下了。
            // 什么时候会小于0呢?可以看到参数是通过 index + 1 传递进来的,当index 已经到达了 Integer.MAX_VALUE,则会出现此情况。
            throw new OutOfMemoryError();
        // 发现参数比设置的最大长度还要大,那行吧,那就返回最大值,不然就直接返回最大的数组长度。
        return (minCapacity > MAX_ARRAY_SIZE) ?
            Integer.MAX_VALUE :
            MAX_ARRAY_SIZE;
    }
    
    add(int index, E element)

    这个方法实际上是一个插入操作。

    public void add(int index, E element) {
        // 范围检查
        rangeCheckForAdd(index);
    
        // 容量检查
        ensureCapacityInternal(size + 1);  // modCount增加了
        // 直接调用了 System.arraycopy 方法。
        // 这里也不去分析他底层的源码,去分析也不太容易,他实际上是一个 native 方法,要看只能去看 jni 层的代码了。
        // 解释一下现在参数所表示的意思:就是将数组根据传入的 index 分成两部分,然后把后面一部分往后面整体移动一个位置,index位置留出空位。
        // 第一个参数表示源数组
        // 第二个参数表示从哪个位置开始复制
        // 第三个参数表示复制到哪个数组中
        // 第四个参数表示复制从哪里开始
        // 第五个参数表示复制多少位
        System.arraycopy(elementData, index, elementData, index + 1,
                         size - index);
        // 将 index 位置进行填充
        elementData[index] = element;
        // 长度自增
        size++;
    }
    举个🌰解释一下 System.arraycopy 的操作。
    有一个数组如下:
    [0,1,2,3,4,5,6,null,null,null]
    现在如果要在第二位插入一个数字7.
    第一步:找到第2个位置,是2;
    第二步:把第二位开始的字段整体往后迁移一位,变成:[0,1,2,2,3,4,5,6,null,null]
    此时数组已经移动完毕,再进行赋值即可。
    

    通过源码的分析,可以看到插入实际上是先对数组进行复制移动,耗费巨大。所以应当避免此操作。

    remove(int index)

    此即删除操作。

    public E remove(int index) {
        // 边界检查
        rangeCheck(index);
        // 修改计数
        modCount++;
        // 找到旧值
        E oldValue = elementData(index);
        // 计算需要移动多少个元素。
        int numMoved = size - index - 1;
        if (numMoved > 0)
            // 和 上面的 add 操作调用,如出一辙。
            System.arraycopy(elementData, index+1, elementData, index,
                             numMoved);
        elementData[--size] = null; // 利于内存回收
        // 返回旧值
        return oldValue;
    }
    

    可以发现,删除操作也比较费劲,除非是最后一个元素。

    remove(Object o)

    删除指定元素,可以想象,肯定是一个个的遍历然后对比,再执行删除操作。

    public boolean remove(Object o) {
        // null 和 非 null 分开处理,私以为,直接使用 Objects.equals 方法不就好了么。
        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++)
                // 可以看到他调用的是 equals 方法,然而没有调用 == 来判断是否是同一个对象。
                // 所以此处要注意,如果重写了 equals 方法,则同一个对象未必相等。这里可能就识别不到。
                if (o.equals(elementData[index])) {
                    fastRemove(index);
                    return true;
                }
        }
        return false;
    }
    // 这个方法和 remove 的上一个方法里面的内容一模一样,不知道上个 remove 方法不用。
    private void fastRemove(int index) {
        modCount++;
        int numMoved = size - index - 1;
        if (numMoved > 0)
            System.arraycopy(elementData, index+1, elementData, index,
                             numMoved);
        elementData[--size] = null; // clear to let GC do its work
    }
    
    clear()

    清空此 list。

    public void clear() {
        modCount++;
    
        // 挨个赋值 null
        for (int i = 0; i < size; i++)
            elementData[i] = null;
    
        size = 0;
    }
    
    contains(Object o) && indexOf(Object o)

    为什么这两个方法放在一起呢,因为他们之间是好基友的关系。

    public boolean contains(Object o) {
        // 可以看到,直接调用的 indexOf 方法。
        return indexOf(o) >= 0;
    }
    
    public int indexOf(Object o) {
        // 还是将 null 和 非 null 进行了区分处理。是不是似曾相识的代码!remove 不也这么做的么!
        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;
    }
    

    所以可以看到, contains 和 indexOf 实际上都是对数组进行一个遍历操作,所以使用一定要谨慎。而 Set 方法的 contains 方法是直接用的 hash 计算的,复杂度是 O(1).所以尽量用 Set 进行类似操作。

    subList(int fromIndex, int toIndex)

    从字面上看,他就是返回一个我的孩子 list。

    public List<E> subList(int fromIndex, int toIndex) {
        // 边界检查
        subListRangeCheck(fromIndex, toIndex, size);
        // 返回一个 SubList 类!竟然不是 ArrayList
        return new SubList(this, 0, fromIndex, toIndex);
    }
    
    看看内部类 SubList 的声明:
    private class SubList extends AbstractList<E> implements RandomAccess
    和 ArrayList 竟然不一样。
    那就看看构造函数吧:
    
    SubList(AbstractList<E> parent,
            int offset, int fromIndex, int toIndex) {
        // parent 当然就是我咯,ArrayList
        this.parent = parent;
        // 相对我的偏移量,就是孩子是从哪开始截取的
        this.parentOffset = fromIndex;
        // 如果儿子也要生儿子,就是产生SubList,则要计算相对爷爷的偏移量,此值就是为了来计算这个。
        // 如果要生重孙子,那就要计算相对于祖爷爷的偏移量。子子孙孙无穷尽也。
        this.offset = offset + fromIndex;
        // 计算孩子有多长。
        this.size = toIndex - fromIndex;
        // 继承父亲的更改计数。
        this.modCount = ArrayList.this.modCount;
    }
    看一下 SubList 方法的 get 方法。
    public E get(int index) {
        // 检查边界
        rangeCheck(index);
        // 检查是否已经被修改
        checkForComodification();
        // 吃惊吗?竟然直接修改的是父亲的数组。
        return ArrayList.this.elementData(offset + index);
    }
    private void checkForComodification() {
        // 和父亲的修改计数进行一个对比,如果父亲变了,则抛出异常。
        if (ArrayList.this.modCount != this.modCount)
            throw new ConcurrentModificationException();
    }
    ......
    

    可以看到,SubList 实际上并不是拿到了一个和原数组完全分离的数组,对于 SubList 的修改,全都会作用于父亲这里。这就好比儿子惹得祸,父亲都要来背。所以使用此方法一定要注意。同理,生儿子也要慎重!哈哈。

    其他方法就先略过了

    0x05 喝口水,来个总结

    ArrayList 相对来说简单些,但是其中也不乏 contains subList 这种需要注意的方法。知己知彼,方能百战不殆!

    文中如有错误,请大家不吝赐教!感激不尽,与君共勉。

    相关文章

      网友评论

        本文标题:ArrayList浅析

        本文链接:https://www.haomeiwen.com/subject/dagfkftx.html