Java集合 LinkedList源码浅析

1)类分析

        分析一个类的时候,我们首先要从它的继承关系入手。继承关系很大程度上反应了该类的功能。  

        首先看大家比较熟悉的List接口和Queue接口,这两个接口分别说明了LinkedList可以同时作为队列和列表来使用。

        LinkedList实现了Deque(double ended queue,双端队列),Deque的父类就是Queue,实现该接口代表了LinkedList可以作为一个队列来使用。在文章末尾我会就该使用方法来做一个演示。

        LinkedList同时也实现了List(列表),所以代表LinkedList也能像ArrayList一样,能过通过索引来实现增删改查,这也是我们后面进行源码分析的主要部分。

       然后就是就是继承了AbstractSequentialList,这个类就是实现了列表的核心类。子类继承该类,只需要额外实现部分代码即可完成的创造一个能够访问某种列表(链表)的类。  

        Serializable接口,允许LinkedList被序列化和反序列化。

        Cloneable接口,允许LinkedList被克隆(复制),是浅拷贝。

至此,我们已经大概看完LinkedList的继承关系了,大概总结一下,就是LinkedList的底层实现其实就是一个双端队列(双向链表),该类可以由Queue的多态形式作为一个队列使用,也可以以List的多态形式作为一个列表来使用。

2)List方法解析:

        List方法的核心无非就是增删改查,我们就对这四个点一一分析。

       该类内部有一个节点类,了解即可。

private static class Node<E> {
        E item;
        Node<E> next;
        Node<E> prev;

        Node(Node<E> prev, E element, Node<E> next) {
            this.item = element;
            this.next = next;
            this.prev = prev;
        }
    }

        2.1 增(插入)

                   先看不指定插入位置的add(E e)方法,该方法将元素插入到列表的末尾。

    /**
     * 直接向链表末尾添加元素,尾添加
     * @param e 需要添加的元素
     * @return 是否添加成功
     */
    public boolean add(E e) {
        linkLast(e);
        return true;
    }

    /**
     * 向末尾添加元素的具体执行方法
     * @param e 需要添加的元素
     */
    void linkLast(E e) {
        //将类中的成员变量last复制一份
        final Node<E> l = last;
        //通过构造方法创建了新的节点,该新节点的pre指向当前的尾节点,next指向null
        final Node<E> newNode = new Node<>(l, e, null);
        //然后让类的last节点尾新创建的节点
        last = newNode;
        //如果我们之前复制的尾节点是空的话,说明原来的列表是空的
        //当前创建的节点是列表中的唯一一个节点,所以要令first也是这个新创建的节点
        if (l == null)
            first = newNode;
        else //如果之前复制的尾节点不是空节点,那么代表列表中至少存在一个以上节点
            //我们就直接让之前复制的这个节点的next指向我们新创建的节点
            //这样就实现了之前复制的节点和我们新创建的节点的前后联系
            //是的我们新创建的节点成功成为最新的尾节点
            l.next = newNode;
        //列表的长度+1
        size++;
        //列表的操作次数+1
        modCount++;
    }

                这里的modCount需要注意,这里记录列表的操作次数,是为了防止在并发的时候出现的一些并发异常(比如一个遍历在删除某元素,另一个遍历在修改该元素)。这个modCount就起到了一个校验的作用。

                之后再看根据索引插入的add(int index,E element)方法,一个个慢慢来分析:

    /**
     * 通过索引将元素插入指定的位置
     * @param index 需要插入的位置(索引)
     * @param element 需要插入的元素
     */
    public void add(int index, E element) {
        checkPositionIndex(index);

        if (index == size)
            linkLast(element);
        else
            linkBefore(element, node(index));
    }

                add方法先调用了checkPositionIndex(int index)方法来判断index是否合法:

private void checkPositionIndex(int index) {
        if (!isPositionIndex(index))
            throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
    }

                如果index不合法,就抛出下标越界异常。在具体看一下isPositionIndex方法:

    private boolean isPositionIndex(int index) {
        return index >= 0 && index <= size;
    }

                这里有一个小细节,就是index必须是>=0,这个我们知道,因为索引是从0开始的,但是为什么index要<=size呢?回到add方法,add方法有个判断index == size的,也就是说,如果插入的位置跟列表的长度一致,就是默认插在列表的最后,所以这里是允许index<=size。

                这里特意讲这个,是因为后续的修改和查询也有相应的判断代码,但是它们的判断代码可没有=index的处理哦,index只能<size。怕大家记混了,所以在这里就把这点说一下。

                继续回来看我们的add方法,需要注意的是,调用linkBefore的时候,同时调用node(index)这个方法哦,该方法我们只分析一遍,但是在后面也都会出现的。

    /**
     * 通过索引将元素插入指定的位置
     * @param index 需要插入的位置(索引)
     * @param element 需要插入的元素
     */
    public void add(int index, E element) {
        //检查索引是否合法
        checkPositionIndex(index);
        if (index == size) //如果索引等于列表的长度,代表需要插入尾部
            linkLast(element);
        else //否则就插入指定的位置
            //先通过node(index)拿到指定位置的节点
            linkBefore(element, node(index));
    }

    /**
     * 根据索引返回指定位置处的节点
     * @param index 需要查找的节点的位置(索引)
     * @return 指定位置的节点
     */
    Node<E> node(int index) {
        //这里size右移一位,相当于size/2,在计算机内部,左移右移是比 * / 要快的
        //这段的意思就是如果index<size/2,那么就从头节点开始向后遍历,直到找到指定位置的节点
        if (index < (size >> 1)) {
            Node<E> x = first;
            for (int i = 0; i < index; i++)
                x = x.next;
            return x;
        } else {//如果index>=size/2,那么就从尾节点开始向前遍历,直到找到指定位置的节点
            Node<E> x = last;
            for (int i = size - 1; i > index; i--)
                x = x.prev;
            return x;
        }
    }

    /**
     * 通过拿到的指定位置的节点,创建新的节点插入到它之前
     * @param e 需要插入的元素
     * @param succ 拿到的指定位置的节点
     */
    void linkBefore(E e, Node<E> succ) {
        //复制一个我们拿到节点的上一个节点,也就是拿到节点的上一个的节点
        final Node<E> pred = succ.prev;
        //将拿到的节点作为新创建节点的next,将拿到节点指向上一个的节点pred作为新创建节点的pred节点
        //举例如下: (1是succ是上一个节点,2是新创建的节点,3是succ)
        // 在没有创建新节点的时候 1->3, 1<-3,
        //创建了新节点2之后 1->3,1<-3,1<-2,2->3,也就是当前虽然1和2节点还是互相链接
        //但是新创建的节点前后已经将他们关联起来了,也就是该节点已经满足插入的条件
        //接下来只需要修改1和3节点的后一个节点和前一个节点即可
        final Node<E> newNode = new Node<>(pred, e, succ);
        //这里我们令拿到的节点的上一个节点是新创建的节点
        succ.prev = newNode;
        if (pred == null) //如果拿到的节点复制出来的上一个节点是空的话,代表我们拿到的节点有可能只有一个
            //所以我们需要更新头节点
            //因为我们拿到的节点succ,是要在我们创建的节点之后的,所以succ不能是头节点了
            first = newNode;
        else
            //如果pred不为空,代表该列表中不是只有一个元素,所以我们直接令拿到的节点复制出来的上一个节点
            //pred的下一个为我们新创建的节点,这样就实现了 1->2, 2->3,完成了插入操作(1是succ是上一个节点,2是新创建的节点,3是succ)
            pred.next = newNode;
        size++; //列表的长度增加
        modCount++; //列表的操作次数增加
    }

                这样就实现了我们List中通过索引增加元素的add方法了

        2.2 删(删除)

                删除和增加一样,有无参的remove()方法和能够指定删除某个索引的remove(int index)方法,一样是一个个来分析。

                首先看无参的remove()方法:

    /**
     * 删除并得到头节点的元素
     * @return 删除的头节点的元素
     */
    public E remove() {
        return removeFirst();
    }

    /**
     * 删除头节点的方法
     * @return 删除的头节点的元素
     */
    public E removeFirst() {
        //先复制头节点
        final Node<E> f = first;
        //判断头节点是否为空,为空则代表当前队列都为空,直接抛出无元素异常
        if (f == null)
            throw new NoSuchElementException();
        //如果头节点不为空,则调用具体的删除方法
        return unlinkFirst(f);
    }

    /**
     * 具体的删除头节点代码
     * @param f 头节点
     * @return 删除的头节点的元素
     */
    private E unlinkFirst(Node<E> f) {
        //先将头节点的元素复制出来,用作返回
        final E element = f.item;
        //然后复制头节点的下一个元素
        final Node<E> next = f.next;
        f.item = null;
        f.next = null; // help GC
        /**
         * 这里很有意思,有一句 help GC
         * GC( Garbage Collection) ,垃圾收集器
         * 这个涉及到Java的垃圾回收机制,有兴趣的可以自行查找
         * 将f节点的三个属性设置为空之后,就会让该节点被回收
         */
        //令头节点为复制的下一个节点,也就是头节点向后移动了一个节点
        first = next;
        if (next == null) // 如果向后移动了一个节点,该节点为null了,代表列表中没有元素了
            //所以就要头节点和尾节点都为空
            last = null;
        else
            //如果向后移动一个节点不为空,那么代表该节点就成为了头节点,头节点是没有上一个节点的
            //所以就设置该节点的上一个节点为空了
            next.prev = null;
        //然后列表的元素减少一个
        size--;
        //列表的操作次数+1
        modCount++;
        //返回我们删除的头节点的元素
        return element;
    }

                再看一下根据索引删除的remove(int index)方法:

    private void checkElementIndex(int index) {
        if (!isElementIndex(index))
            throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
    }
    private boolean isElementIndex(int index) {
        return index >= 0 && index < size;
    }
    

    /**
     * 根据索引删除元素
     * @param index 需要删除的节点的位置(索引)
     * @return 删除的节点的元素
     */
    public E remove(int index) {
        //先检查索引是否合法
        checkElementIndex(index);
        //同样是根据node(index),先取出对应位置的节点给unlink()方法
        return unlink(node(index));
    }
    /**
     * 根据索引返回指定位置处的节点
     * @param index 需要查找的节点的位置(索引)
     * @return 指定位置的节点
     */
    Node<E> node(int index) {
        //这里size右移一位,相当于size/2,在计算机内部,左移右移是比 * / 要快的
        //这段的意思就是如果index<size/2,那么就从头节点开始向后遍历,直到找到指定位置的节点
        if (index < (size >> 1)) {
            Node<E> x = first;
            for (int i = 0; i < index; i++)
                x = x.next;
            return x;
        } else {//如果index>=size/2,那么就从尾节点开始向前遍历,直到找到指定位置的节点
            Node<E> x = last;
            for (int i = size - 1; i > index; i--)
                x = x.prev;
            return x;
        }
    }

    /**
     * 根据节点删除某个节点的具体方法
     * @param x 需要删除的节点
     * @return 删除的节点的元素
     */
    E unlink(Node<E> x) {
        //复制该节点的元素出来,用作返回
        final E element = x.item;
        //复制该节点的下一个节点
        final Node<E> next = x.next;
        //复制该节点的上一个节点
        final Node<E> prev = x.prev;

        //判断上一个节点是否为空,如果为空,那么代表当前的节点是列表中的唯一一个节点
        if (prev == null) {
            //所以该节点就为头节点,尾节点的话下面的代码会处理
            first = next;
        } else { //如果上一个节点不是空的,那么可以直接进行替换操作
            //直接令上一个节点 的 下一个节点为 我们拿到节点的下一个节点
            //举例说明: 如  1->2 , 2->3 , (1是复制的上一个节点,2是我们拿到的节点,3是复制的下一个节点)
            //也就是直接令  1->3 (直接让1的下一个节点是3),就直接把2这个节点无视了,跳过了它
            prev.next = next;
            x.prev = null; //help GC 这里同前面一样,为了帮助垃圾收集器回收该节点
        }
        
        //这里判断下一个节点是否为空,如果为空,那么代表当前节点是列表中的唯一一个节点
        if (next == null) {
            //就直接令尾节点 = 头节点(列表中只有一个节点的时候,头节点=尾节点)
            last = prev;
        } else { //下一个节点不是空的,那么也可以直接进行替换操作
            //直接令下一个节点 的 上一个节点为 我们拿到节点的上一个节点
            //举例说明: 如  1<-2 , 2<-3 (1是复制的上一个节点,2是我们拿到的节点,3是复制的下一个节点)
            //也就是直接令  1<-3 (直接让3的上一个节点是1),就直接把2这个节点无视了,跳过了它
            next.prev = prev;
            x.next = null; //help GC
        }

        x.item = null; //help GC
        //列表的长度-1
        size--;
        //列表的操作次数+1
        modCount++;
        //返回我们删除的节点的元素
        return element;
    }

                这样就实现了我们List中通过索引删除元素的remove方法了

        2.3 改(修改)

                相较于增加和删除,修改和查询是最容易理解的操作了,因为节点本质上就是一个引用类型的对象,这个对象只要取出来,修改它的值即可。原先列表的结构并没有发生变化:

    private void checkElementIndex(int index) {
        if (!isElementIndex(index))
            throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
    }
    private boolean isElementIndex(int index) {
        return index >= 0 && index < size;
    }
    
    /**
     * 根据索引修改某处节点的值
     * @param index 需要修改的节点的位置
     * @param element 需要修改成的元素
     * @return 修改之前的那个元素
     */
    public E set(int index, E element) {
        //和删除一样,同样的索引检查是否合法
        checkElementIndex(index);
        //然后通过node方法拿到需要修改元素的节点
        Node<E> x = node(index);
        //将该节点的元素记录下来,用作返回
        E oldVal = x.item;
        //直接修改该节点的元素即可
        x.item = element;
        //返回修改之前的元素
        return oldVal;
    }

    /**
     * 根据索引返回指定位置处的节点
     * @param index 需要查找的节点的位置(索引)
     * @return 指定位置的节点
     */
    Node<E> node(int index) {
        //这里size右移一位,相当于size/2,在计算机内部,左移右移是比 * / 要快的
        //这段的意思就是如果index<size/2,那么就从头节点开始向后遍历,直到找到指定位置的节点
        if (index < (size >> 1)) {
            Node<E> x = first;
            for (int i = 0; i < index; i++)
                x = x.next;
            return x;
        } else {//如果index>=size/2,那么就从尾节点开始向前遍历,直到找到指定位置的节点
            Node<E> x = last;
            for (int i = size - 1; i > index; i--)
                x = x.prev;
            return x;
        }
    }

                修改的代码是不是看起来蛮简单的呀,但是也只是看起来简单,但是效率可是没有arrayList快的哦。

        2.4 查(查询)

                也就是List中的get方法,拿到对应的节点,并返回该节点的元素,这样就实现了通过索引取出某个值。

    private void checkElementIndex(int index) {
        if (!isElementIndex(index))
            throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
    }
    private boolean isElementIndex(int index) {
        return index >= 0 && index < size;
    }
    
    public E get(int index) {
        //跟删除一样,检查索引是否合法
        checkElementIndex(index);
        //直接通过node(int index)方法拿到对应节点
        //然后返回该节点的元素即可
        return node(index).item;
    }
    /**
     * 根据索引返回指定位置处的节点
     * @param index 需要查找的节点的位置(索引)
     * @return 指定位置的节点
     */
    Node<E> node(int index) {
        //这里size右移一位,相当于size/2,在计算机内部,左移右移是比 * / 要快的
        //这段的意思就是如果index<size/2,那么就从头节点开始向后遍历,直到找到指定位置的节点
        if (index < (size >> 1)) {
            Node<E> x = first;
            for (int i = 0; i < index; i++)
                x = x.next;
            return x;
        } else {//如果index>=size/2,那么就从尾节点开始向前遍历,直到找到指定位置的节点
            Node<E> x = last;
            for (int i = size - 1; i > index; i--)
                x = x.prev;
            return x;
        }
    }

        2.5 小结

                其实LinkedList中的实现逻辑都很清晰的,只要看明白了它的内部类Node的创建,它实现的list的功能也就都明白了,核心就是能够根据所以查询的Node(int index)方法。

3) Queue方法解析

        Queue就是一个队列,那么队列基本的方法无非就是入队和出队。

                来分析一下Queue,我个人的常用方法,一个offer(e),将元素入队,返回值是一个boolean,代表是否入队成功,另一个是 poll(),让元素出队并且删除该元素。

                再看一下Queue的子接口,Deque,我们上面说了,它是一个双端队列,也就是可以同时从头尾进行操作,那么换个角度想一下,双端队列,和列表,有什么差别呢?这个就留给大家思考。

        3.1 练习

                这里我挑两个队列(Queue)的方法出来,作为作业,看一下大家是否真的看明白了LinkedList的源码~

                3.1.1 入队 

    public boolean offer(E e) {
        return add(e);
    }

    public boolean add(E e) {
        linkLast(e);
        return true;
    }

    void linkLast(E e) {
        final Node<E> l = last;
        final Node<E> newNode = new Node<>(l, e, null);
        last = newNode;
        if (l == null)
            first = newNode;
        else
            l.next = newNode;
        size++;
        modCount++;
    }

                3.1.2 出队

    public E poll() {
        final Node<E> f = first;
        return (f == null) ? null : unlinkFirst(f);
    }

    private E unlinkFirst(Node<E> f) {
        // assert f == first && f != null;
        final E element = f.item;
        final Node<E> next = f.next;
        f.item = null;
        f.next = null; // help GC
        first = next;
        if (next == null)
            last = null;
        else
            next.prev = null;
        size--;
        modCount++;
        return element;
    }

        3.2 小结

                如果3.1的练习的方法大家都能看懂,那么就证明确实理解了,如果还是迷迷糊糊的话,可以尝试自己点进LinkedList的源码中进行查看哦~

4) 总结

                其实LinkedList的成员属性,就记录并且帮助完成了一部分的功能,该类的底层实现并不复杂,还十分有趣,希望能对大家有所帮助,如果有疑惑或者文章出错也可以评论留言~

                最最最后的小作业,为什么我把final 修饰的临时存放的 l ( last ) ,或者 f (first) 叫做复制呢?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值