JDK源码学习之List集合篇

JDK源码学习之List集合篇

此篇只针对于比较常使用的容器来具体展开,类关系见下图:

image-20240828192625163

ArrayList

第一章:CRUD的本源

首先就是我们耳熟能详的 ArrayList集合了。大家无论是在写项目或者demo都会遇到,算是入门时期最常见的一个集合。今天我们就深入源码去探索这个容器,学习其设计思想与本质。

image-20240828192903159

初见茅庐,我们需要对这个类整体有个印象,从第一行开始看。

  • 我们知道ArrayList首先是一个泛型实现,这也好理解,因为我们在使用的时候通常是我们自定义的对象。

  • 第二个我们发现其继承了 AbstractList 类。这个类根据我们的猜测,应该是规定了一些方法规范,让子类用于实现或扩展。

  • 其次就是实现了RandomAccessClonenableSerializable接口,接下来我简单的概括一下这三个接口的作用,因为不是本章的核心,所以不展开说明

    首先我们要知道的是,这三个接口都是空接口,是不是很奇怪?如果是初学者来说或许有些摸不着头脑,实际上他们只是作为一个标记实现接口去使用,那么什么叫标记接口呢,简单点说,就是每一个对应的空接口类对应一个事件,你实现了这个接口,那么就会触发这个接口对应的功能。这里借用RandomAccess的接口描述太解释

    Marker interface used by <tt>List</tt> implementations to indicate that they support fast (generally constant time) random access.  The primary purpose of this interface is to allow generic algorithms to alter their behavior to provide good performance when applied to either random or sequential access lists.
    The best algorithms for manipulating random access lists (such as <tt>ArrayList</tt>) can produce quadratic behavior when applied to sequential access lists (such as <tt>LinkedList</tt>).  Generic listalgorithms are encouraged to check whether the given list is an <tt>instanceof</tt> this interface before applying an algorithm that would provide poor performance if it were applied to a sequential access list, and to alter their behavior if necessary to guarantee acceptable
    performance.
    It is recognized that the distinction between random and sequential access is often fuzzy.  For example, some <tt>List</tt> implementations provide asymptotically linear access times if they get huge, but constant access times in practice.  Such a <tt>List</tt> implementation should generally implement this interface.  As a rule of thumb, a <tt>List</tt> implementation should implement this interface if
    翻译来说就是:
    标记接口用于表示它们支持快速(通常是恒定时间O(1))随机访问。此接口的主要目的是允许通用算法更改其行为,以便在应用于随机或顺序访问列表时提供良好的性能
    但是操作随机访问列表的最佳算法,如果引用于顺序访问列表(LinkedList),就会产生时间复杂度上升为O(n^2)的性能问题。泛型列表算法应当在应用那些可能会导致顺序访问列表性能低下的算法之前,先检查给定的列表是否是这个接口的实例,并在必要时调整其行为,以保证性能的可接受性
    怎么理解最后这句话,举个例子,在Collections中的binarySearch方法中,有如下代码:
    public static <T> int binarySearch(List<? extends Comparable<? super T>> list, T key) {
            if(list instanceof RandomAccess || list.size()<BINARYSEARCH_THRESHOLD) {
                return Collections.indexedBinarySearch(list, key);
            } else {
                return Collections.iteratorBinarySearch(list, key);
            }
        }
    其中就是用于判断该list是否是RandomAccess实例,我们知道区别就是一个走的是indexedBinarySearch,一个走的是iteratorBinarySearch。其区别在下图中给出,我们可以看到这两个方法本质上就是获取中间值的区别,那这两个本质上又是什么方法呢?
        public static
        int checkIndex(int index, int length) {
            return Preconditions.checkIndex(index, length, null);
        }
        private static <T> T get(ListIterator<? extends T> i, int index) {
            T obj = null;
            int pos = i.nextIndex();
            if (pos <= index) {
                do {
                    obj = i.next();
                } while (pos++ < index);
            } else {
                do {
                    obj = i.previous();
                } while (--pos > index);
            }
            return obj;
        }
    	从上面我们知道,实现了RandomAccess的list集合,能够快速遍历的原因就是直接去调用的线性数组的第i个元素值,而如果没有实现此接口,就会走迭代器查找,那迭代器就需要通过遍历的形式去获取target元素,所以时间复杂度就是O(n^2)级别的
    

    image-20240828203550896

    • RandomAccess 作用同上
    • Cloneable 实现 clone 接口
    • Serializable序列化接口

好了,前置知识解释的差不多了。接下来我们正式的来学习这个 ArrayList

首先我们先把基础的CRUD操作,也就是大家经常使用到的 api 操作说明,然后在中间穿插一些知识点的描述,让大家不仅知其然还能知其所以然。

初始化操作

空参

空参就是什么都不带,我们最常使用的也是这个构造方法,其会初始化一个空数组等待插入:

public ArrayList() {
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
带参

带参又分为初始容量和容器复制。

public ArrayList(int initialCapacity) {
        if(initialCapacity>0) {
            this.elementData = new Object[initialCapacity];
        } else if(initialCapacity == 0) {
            this.elementData = EMPTY_ELEMENTDATA;
        } else {
            throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity);
        }
    }
public ArrayList(Collection<? extends E> c) {
	    //把容器转换为对象数组
        elementData = c.toArray();
        
        if((size = elementData.length) != 0) {
            /*
             * defend against c.toArray (incorrectly) not returning Object[]
             * (see e.g. https://bugs.openjdk.java.net/browse/JDK-6260652)
             */
            if(elementData.getClass() != Object[].class) {
            //拷贝一个新数组并赋值,注意是浅拷贝
                elementData = Arrays.copyOf(elementData, size, Object[].class);
            }
        } else {
            // replace with empty array.
            this.elementData = EMPTY_ELEMENTDATA;
        }
    }

ADD

添加操作分为添加值和添加值和元素

	private void add(E e, Object[] elementData, int s) {
        if (s == elementData.length)
        	//扩容
            elementData = grow();
        elementData[s] = e;
        size = s + 1;
    }
    
	public boolean add(E e) {
    //修改计数器++
        modCount++;
        add(e, elementData, size);
        return true;
    }
    
    public void add(int index, E element) {
    	//检查index范围是否合法
        rangeCheckForAdd(index);
        modCount++;
        final int s;
        Object[] elementData;
        if ((s = size) == (elementData = this.elementData).length)
        	//扩容
            elementData = grow();
        //移动元素
        System.arraycopy(elementData, index,
                         elementData, index + 1,
                         s - index);
        elementData[index] = element;
        size = s + 1;
    }

这就是add操作源码,我们知道无论是带不带位置的添加,本质上都会有一个扩容的检查操作,其执行前提是当前数组大小等于size,我们接下来扩展一下知识点,那就是扩容操作。

Grow(扩容)
private Object[] grow() {
        return grow(size + 1);
    }
 
//对当前线性表扩容,minCapacity是申请的容量
private Object[] grow(int minCapacity) {
        // 根据申请的容量,返回一个合适的新容量
        int newCapacity = newCapacity(minCapacity);
        return elementData = Arrays.copyOf(elementData, newCapacity);
    }
    
// 根据申请的容量,返回一个合适的新容量
    private int newCapacity(int minCapacity) {
        int oldCapacity = elementData.length;   // 旧容量
        int newCapacity = oldCapacity + (oldCapacity >> 1); // 预期新容量(增加0.5倍)
        
        // 如果预期新容量小于申请的容量
        if(newCapacity - minCapacity<=0) {
            // 如果数组还未初始化,这里主要是针对于默认构造器早构造的时候,会是空数组,不需要分配空间,惰性思想
            if(elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
                // 返回一个初始容量
                return Math.max(DEFAULT_CAPACITY, minCapacity);
            }
            
            // 溢出
            if(minCapacity<0) {
                // overflow
                throw new OutOfMemoryError();
            }
            
            return minCapacity;
        }
        
        // 判断是否需要采取最大容量扩容
        return (newCapacity - MAX_ARRAY_SIZE<=0) ? newCapacity : hugeCapacity(minCapacity);
    }
   
    // 大容量处理
    private static int hugeCapacity(int minCapacity) {
        if(minCapacity<0) {
            // overflow
            throw new OutOfMemoryError();
        }
        
        return (minCapacity>MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE;
    }

至此,扩容阶段结束,接下来我概括一下扩容的大致流程,让大家更清楚。

  1. 判断当前数组容量是否等于size大小,若是则触发扩容操作
  2. 确定扩容容量大小,为旧容量>>1倍
  3. 判断新容量大小是否大于等于预期容量,同时判断数组是否初始化。
  4. 判断新容量是否超出最大容量

DEL

public E remove(int index) {
	   //检查位置合法性
        Objects.checkIndex(index, size);
        final Object[] es = elementData;

        @SuppressWarnings("unchecked") E oldValue = (E) es[index];
        fastRemove(es, index);

        return oldValue;
    }
    
    public boolean remove(Object o) {
        final Object[] es = elementData;
        final int size = this.size;
        int i = 0;
        //标签语句
        found: {
            if (o == null) {
                for (; i < size; i++)
                    if (es[i] == null)
                        break found;
            } else {
                for (; i < size; i++)
                    if (o.equals(es[i]))
                        break found;
            }
            return false;
        }
        fastRemove(es, i);
        return true;
    }
    
    //移除es[i],i+1往前推
    private void fastRemove(Object[] es, int i) {
        modCount++;
        final int newSize;
        if ((newSize = size - 1) > i)
            System.arraycopy(es, i + 1, es, i, newSize - i);
        es[size = newSize] = null;
    }
    
    

这里需要提一嘴的是,我们可以看到在遍历过程中,如果remove的对象是空,那么需要单独判断,这是因为equals比对的实际上是对象的hashcode值,如果不单独判断会触发nullPoint异常

SET

// 将index处的元素更新为element,并返回旧元素
public E set(int index, E element) {
        Objects.checkIndex(index, size);
        E oldValue = elementData(index);
        elementData[index] = element;
        return oldValue;
    }

GET

int indexOfRange(Object o, int start, int end) {
        Object[] es = elementData;
        if(o == null) {
            for(int i = start; i<end; i++) {
                if(es[i] == null) {
                    return i;
                }
            }
        } else {
            for(int i = start; i<end; i++) {
                if(o.equals(es[i])) {
                    return i;
                }
            }
        }
        return -1;
    }

至此,常见的CRUD操作本质逻辑我们已经知晓,接下来我们继续探索其他更有趣的知识

第二章:影分身之后的我还是我吗?

在第一章,我们知晓了 ArrayListclonable接口实现,那么我们就去看看具体为什么要实现,怎样实现。

    public Object clone() {
        try {
            ArrayList<?> v = (ArrayList<?>) super.clone();
            v.elementData = Arrays.copyOf(elementData, size);
            v.modCount = 0;
            return v;
        } catch(CloneNotSupportedException e) {
            // this shouldn't happen, since we are Cloneable
            throw new InternalError(e);
        }
    }

这里我们调用的 super.clone()方法实际上是Objectclone 方法。这里默认是深拷贝,但只是对于但对象来说,如果这个类成员变量有其他引用类型的成员变量,需要单独实现clone接口,不然如果单单对于大对象进行clone操作,实际上还是浅拷贝,因为两个对象之间引用的成员变量是一样的。下面是测试代码:

public static void main(String[] args) throws CloneNotSupportedException {
        School school = new School();
        Student student = new Student();
        student.setAge(22);
        student.setName("welsir");
        school.setNumber(1);
        school.setStudent(student);
        School school2 = school.clone();
        System.out.println("s1:"+school+",s1 hashCode 值:"+school.hashCode());
        System.out.println("s2:"+school2+",s2 hashCode 值"+school2.hashCode());
        student.setName("welsirxx");
        System.out.println("s1:"+school+",s1 hashCode 值:"+school.hashCode());
        System.out.println("s2:"+school2+",s2 hashCode 值"+school2.hashCode());
    }
    
class School implements Cloneable{
    Student student;
    int number;

    @Override
    public School clone() throws CloneNotSupportedException {
        return (School) super.clone();
    }

    @Override
    public String toString() {
        return "School{" +
                "student=" + student +
                ", number=" + number +
                '}';
    }

    public Student getStudent() {
        return student;
    }

    public void setStudent(Student student) {
        this.student = student;
    }

    public int getNumber() {
        return number;
    }

    public void setNumber(int number) {
        this.number = number;
    }
}

class Student{
    String name;
    int age;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    @Override
    public String toString() {
        return "Student{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}

image-20240828224529656

第三章:我的另个一形态?

还是从第一章开始说起,我们知晓了还有一个接口没有触发,那就是 Serializable 序列化接口。其实这也很好理解,因为我们的集合需要在网络和磁盘之间来回转换,所以需要序列化。我们平常序列化是咋做的?是不是一般实现这个接口就不用管了,Java会自动给我们将所有的成员变量给序列化。大大降低了我们编码的复杂性和难度。

ArrayList做了什么?是不是也是一样的呢?

我们注意到一个关键点:在源码中,底层的数组实际上是一个对象数组,且被*transient*修饰

transient Object[] elementData

也就是说,前辈们在设计这个容器的时候,不希望此数组被JVM托管,而是自己手动去序列化(如果不序列化就没有任何意义)。

private void writeObject(ObjectOutputStream s) throws IOException {
        //防止并发导致的异常
        int expectedModCount = modCount;
        s.defaultWriteObject();
        
        // 写入实际容量大小
        s.writeInt(size);
        
        // 遍历写入每个元素
        for(int i = 0; i<size; i++) {
            s.writeObject(elementData[i]);
        }
        
        if(modCount != expectedModCount) {
            throw new ConcurrentModificationException();
        }
    }
    
 private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
        
        // Read in size, and any hidden stuff
        s.defaultReadObject();
        
        // Read in capacity
        s.readInt(); // ignored
        
        if(size>0) {
            // like clone(), allocate array based upon size not capacity
            SharedSecrets.getJavaObjectInputStreamAccess().checkArray(s, Object[].class, size);
            Object[] elements = new Object[size];
            
            // Read in all elements in the proper order.
            for(int i = 0; i<size; i++) {
                elements[i] = s.readObject();
            }
            
            elementData = elements;
        } else if(size == 0) {
            elementData = EMPTY_ELEMENTDATA;
        } else {
            throw new InvalidObjectException("Invalid size: " + size);
        }
    }

从上面代码中我们可看到一个很有意思的地方,前辈们在设计代码的时候,“多此一举”的循环遍历了数组,然后手动一个一个把元素写入流中,怎么会写出这样这么丑陋的代码呢?

实际真如此吗?我们在看源码文档对 elementData介绍的时候,有一行字挺特别:

The array buffer into which the elements of the ArrayList are stored.
存储 ArrayList 元素的数组缓冲区。

怎么理解这句话?缓冲区?为什么不是说存储 ArrayList元素的数组容器?缓冲区又缓冲了什么?

其实答案已经在前面章节提到过,为什么称其是缓冲区而不是具体的容器。是因为其整个过程都是动态的,而不是一个死数组,而又因为是动态的,所以在扩容的时候会不可避免地有剩余空间的留出,这也就是为什么文档称其为缓冲区的原因,它缓冲的正式接下来可能会被插入元素的对象。

而这个缓冲区大小我们没办法控制,再加之序列化和反序列化是一个高频操作,如果我们每次都任由JVM来进行序列化,那不可避免会序列化很多空对象,造成性能浪费。

那么以上就是本关的所有历程,也许我的说明并不完全正确。也可能存在争议的地方,若有不当还请指出。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值