一文浅析ArrayList

简介

ArrayList
  ArrayList底层为动态数组结构,用一个Object数组存储数据,当数组容量不够时,会实现动态扩容机制。且增删慢,查找快。

  实现了RandomAccess接口,具备快速随机访问的能力,因此遍历时,相比较迭代器而言,用for循环通过下标遍历更加快速(当数据量多到一定程度)。

  实现了Iterable接口,支持用迭代器的方式访问元素。实现了Serializable接口,内部提供序列化和反序列化方法。

  另外,ArrayList可以存储null,插入的元素允许重复且是有序的,非线程安全等。

ArrayList基础

ArrayList基本变量

//默认容量为10
private static final int DEFAULT_CAPACITY = 10;

//传入参数为0时,构造的数组。
private static final Object[] EMPTY_ELEMENTDATA = {};

//默认构造时的数组
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

// The array buffer into which the elements of the ArrayList are stored.
//真正存放数据的数组缓冲区,(缓冲区)则说明会有预留空间,不是直接存满,用transient修饰与序列化有关
transient Object[] elementData;

//数组最大容量
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

//包含的元素个数(注意不是数组的长度)
private int size;

构造方法

    public ArrayList() {
		//不传入数组大小,默认空数组
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }

    public ArrayList(int initialCapacity) {
        if (initialCapacity > 0) {
            this.elementData = new Object[initialCapacity];
        } else if (initialCapacity == 0) {
        	//传入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) {
            // c.toArray might (incorrectly) not return Object[] 
            //如果传入集合转化后不是Object[]类型,size不为0时,转化成Object数组
            if (elementData.getClass() != Object[].class)
                elementData = Arrays.copyOf(elementData, size, Object[].class);
        } else {
            //size为0,返回空数组
            this.elementData = EMPTY_ELEMENTDATA;
        }
    }

  此处有个小彩蛋,那就是第三个构造方法的toArray(),返回的就是Object[],为啥还需要判断是否是Object[].class类型呢?
  具体需结合下面toArray()方法及另一篇blog,因内容有点牵扯到别的,故另写一篇,没放在ArrayList中。欲知详情,请看简述Arrays中的小彩蛋

add()

    public boolean add(E e) {
		//先确保size+1后不会超出数组容量,否则需扩容
        ensureCapacityInternal(size + 1);  // Increments modCount!!
		
		//修改对应位置的元素,赋值后将size+1
        elementData[size++] = e;
        return true;
    }
    private void ensureCapacityInternal(int minCapacity) {
		// 1.先调用calculateCapacity,计算当前数组所需容量;
		// 2.再根据需要容量与其实际容量比较,看是否需扩容。
        ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
        
    }
    private static int calculateCapacity(Object[] elementData, int minCapacity) {
		//如果数组是空的,说明还没初始化,这是第一次添加,则返回默认容量(10)和size+1的最大值,即10
        if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
            return Math.max(DEFAULT_CAPACITY, minCapacity);
        }
        //否则,说明数组已有长度,直接返回size+1,说明数组需要size+1的长度
        return minCapacity;
    }

    private void ensureExplicitCapacity(int minCapacity) {
		//此方法用来判断当size+1时,所需要的的容量是否大于数组实际容量,即是否需要扩容。
		//添加元素,数组结构变化,modCount要加1
        modCount++;

		//到此说明数组所需容量大于实际容量,需扩容
        if (minCapacity - elementData.length > 0)
        	//扩容参数为:所需的最小容量
            grow(minCapacity);
    }


    public void add(int index, E element) {
		//先判断index是否小于0或越界
        rangeCheckForAdd(index);

		//同上,判断所需容量是否大于现有容量
        ensureCapacityInternal(size + 1);  // Increments modCount!!

		//将elementData中从index开始,共size-index个元素,拷贝到elementData中index+1位置,(可理解为替换或覆盖),因上面已检查所需容量,所以这不会发生越界。
        System.arraycopy(elementData, index, elementData, index + 1,size - index);
        
        //赋值,将index位置赋值为element
        elementData[index] = element;
        size++;
    }

  1. 这里主要要理解System.arrayCopy方法,它是System类的一个native方法,主要作用就是将数组元素拷贝到另一个数组里去。

  2. add(E element)方法因为是直接添加到数组末尾,因此不需要System.arrayCopy()方法进行一个数据的拷贝。而add(int index,E element)方法可能是从中间添加元素,所以需移动元素。

  3. 另ArrayList是可以存放null的,且不止一个。

addAll()

public boolean addAll(Collection<? extends E> c) {
        Object[] a = c.toArray();
        int numNew = a.length;
        ensureCapacityInternal(size + numNew);  // Increments modCount
        System.arraycopy(a, 0, elementData, size, numNew);
        size += numNew;
        return numNew != 0;
    }

  addAll方法,通过System.arrayCopy将转化成Object[]的集合直接添加到原集合的末尾,可用于求两集合的并集(未去重),返回值需判断添加的集合中参数个数是否为0.

  注意:如果如下,b是为true的,因为存放2个null,元素个数为2,而存放一个null会抛出NPE。

    List<Integer> asList = Arrays.asList(null, null);
	boolean b = list.addAll(asList);

get()

    public E get(int index) {
        rangeCheck(index);
        return elementData(index);
    }

    private void rangeCheck(int index) {
        if (index >= size)
            throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
    }

	@SuppressWarnings("unchecked")
    E elementData(int index) {
        return (E) elementData[index];
    }

  1.get(int index)方法比较简单,因为ArrayList是实现了RandomAccess接口的,可以直接根据下标获取元素,虽然rangeCheck判断条件是>=size,但如果index小于0,在下一个方法还是会抛异常。

remove()

    public E remove(int index) {
		//检查下标
        rangeCheck(index);
        modCount++;
        //先存储旧的值
        E oldValue = elementData(index);
        
		//计算需要移动的元素的个数,为0则说明删除的是最后一个元素,不用移动。
        int numMoved = size - index - 1;
        if (numMoved > 0)
            System.arraycopy(elementData, index+1, elementData, index,numMoved);
            
        //移动后size减1,且该位置元素最后一个元素置null帮助GC
        elementData[--size] = null; // clear to let GC do its work
        return oldValue;
    }

    public boolean remove(Object o) {
		//判断要删除的元素否为null
        if (o == null) {
			//直接根据下标遍历删除,删除成功返回true,
            for (int index = 0; index < size; index++)
                if (elementData[index] == null) {
                    fastRemove(index);
                    return true;
                }
        } else {
            for (int index = 0; index < size; index++)
                if (o.equals(elementData[index])) {
                    fastRemove(index);
                    return true;
                }
        }
        return false;
    }
    
    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
    }

  1. 可以看到,类似add方法,remove方法也需要移动元素,因此ArrayList虽然随机访问较快,但增删较慢。

grow()

    private void grow(int minCapacity) {
		//该方法参数为存储当前所有元素所需要的容量
		//先得到旧的容量
        int oldCapacity = elementData.length;
		
		//扩容,扩成原来的1.5倍
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        
        //判断扩容后能否存下当前所有元素
        if (newCapacity - minCapacity < 0)
        	//不能则把当前容量大小赋值给新容量
            newCapacity = minCapacity;

		//判断是否大于最大容量
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        // minCapacity is usually close to size, so this is a win:
        //扩容后将元素copy到新的数组中
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

    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. ArrayList的扩容方法,默认扩充为原来容量的1.5倍,以此实现了动态数组的结构。

  2.另外,在这的几个判断,在stackOverFlow上找到一篇有趣的提问,感兴趣的可以看看,大意就是说if(a - b < 0)和if(a < b)的区别。Difference between if (a - b < 0) and if (a < b)

toArray()

    public Object[] toArray() {
        return Arrays.copyOf(elementData, size);
    }

    public <T> T[] toArray(T[] a) {
        if (a.length < size)
            // Make a new array of a's runtime type, but my contents:
            // 创建一个新的运行时类型的数组,而不是自存存储时的数组。
            return (T[]) Arrays.copyOf(elementData, size, a.getClass());
        System.arraycopy(elementData, 0, a, 0, size);
        if (a.length > size)
            a[size] = null;
        return a;
    }
	@Test
    void testToArray() {

        Object[] array = list.toArray();
        Assertions.assertEquals(Object[].class, array.getClass());

        Integer[] array1 = list.toArray(new Integer[0]);
        Assertions.assertEquals(Integer[].class, array1.getClass());

        Assertions.assertThrows(ArrayStoreException.class, ()->list.toArray(new String[0]));

    }

  toArray()方法主要用于将集合转化为数组,一般情况下可能第一种方便一点,这里返回的是Object[]类型,要注意不能向下强转。

  实际建议还是用第二种较好,可以直接转成存放的数据类型的数组。另外注意toArray()方法尽管返回的是Object[],有时却不一定是Object[]类型,且该方法也用到到了System.arraycopy()和Arrays.copyOf(),两者还是有区别的,因篇幅原因,就不放在本篇了,详情可看简述Arrays中的小彩蛋

removeAll()和retainAll()

	public boolean removeAll(Collection<?> c) {
        Objects.requireNonNull(c);
        return batchRemove(c, false);
    }
    
    public boolean retainAll(Collection<?> c) {
        Objects.requireNonNull(c);
        return batchRemove(c, true);
    }

    private boolean batchRemove(Collection<?> c, boolean complement) {
        final Object[] elementData = this.elementData;
        int r = 0, w = 0;
        boolean modified = false;
        try {
        	// 此处通过判断将后面的值往前覆盖
            for (; r < size; r++)
                if (c.contains(elementData[r]) == complement)					
					// 主要覆盖操作,加上for循环中的判断非常经典
                    elementData[w++] = elementData[r];
        } finally {
            // even if c.contains() throws.
            if (r != size) {
                System.arraycopy(elementData, r,
                                 elementData, w,
                                 size - r);
                w += size - r;
            }
            // 最后置null,帮助GC,且重新赋值size、modCount等
            if (w != size) {
                // clear to let GC do its work
                for (int i = w; i < size; i++)
                    elementData[i] = null;
                modCount += size - w;
                size = w;
                modified = true;
            }
        }
        return modified;
    }

  两个方法都需用到batchRemove(),仅仅是传入的complement不同,故放在一起对比讲解。

  这里主要要弄懂try中的for循环,需要多debug几次才行。我们可以先把complement去掉,默认为true,假设步骤如下:(下标从0开始)
  1.c包含元素,进行值得覆盖,覆盖后w为1.r一直随for循环递增。
  2.c不包含,跳过,r继续递增。
  3.c包含,覆盖,此时w还是1,r一直是原数组下标。

  意味着,当两集合有共有的元素时,就会就行值的覆盖,且是从0开始的(w只有覆盖了才会递增),而r一直递增,所以原集合中的每个元素都会判断到,可以理解为共有的元素整体往前挪,且从0开始挪。
  到最后,w!=size,w可以为共有元素的个数。再从w开始,将后面元素置null帮助GC,留下的就是两集合中的交集。

  同理:当complment为false时,就是不包含的元素就会挪动,相当于求两集合的差集。两方法如下图所示:

在这里插入图片描述
  另外,可以看到finally中还有一个r与size的判断,主要为了防止contains抛出异常,通过Collection类中了解到主要是NPE和ClassCastException。抛出异常后进行一个数组的copy,再判断w。

序列化机制

  ArrayList是实现了Serializable接口的,表明它可以被序列化,可是用来存储数据的Object[] 却用了transient修饰。我们来看它内部提供的相关序列化方法:

    private void writeObject(java.io.ObjectOutputStream s)throws java.io.IOException{
        int expectedModCount = modCount;

		// 先调用默认的序列化方法
        s.defaultWriteObject();
        // 将size序列化
        s.writeInt(size);
        
        //将不为空的元素序列化
        for (int i=0; i<size; i++) {
            s.writeObject(elementData[i]);
        }

        if (modCount != expectedModCount) {
            throw new ConcurrentModificationException();
        }
    }

    private void readObject(java.io.ObjectInputStream s)
        throws java.io.IOException, ClassNotFoundException {
        elementData = EMPTY_ELEMENTDATA;

        // 先调用默认的反序列化方法
        s.defaultReadObject();
        // 反序列化size
        s.readInt(); 

        if (size > 0) {
            //基于size实际存储的大小计算是否需扩容
            int capacity = calculateCapacity(elementData, size);
            SharedSecrets.getJavaOISAccess().checkArray(s, Object[].class, capacity);
            ensureCapacityInternal(size);

            Object[] a = elementData;
			// 反序列化对应的元素
            for (int i=0; i<size; i++) {
                a[i] = s.readObject();
            }
        }
    }

  可以看到,ArrayList内部提供了序列化的方法,因为ArrayList中的数组长度是变化的,实际存储时,不一定每次都存满了(即size不一定等于length),因此,用transient修饰,则可以保证值序列化实际存储的元素,而不是整个数组,避免浪费时间和空间。

  我们知道序列化如果没有重写writeObject方法的话,默认是会调用ObjectOutputStream中的defaultWriteObject方法的,如果重写了就利用反射直接调用重写的方法。
  可是这既然重写了writeObject方法,内部还要调用默认的序列化方法呢?
  need of defaultReadObject() and defaultWriteObject()?

  不仅要调用,而且还要放在前面调用,有助于实现向后的兼容性。因为该方法主要用于读取和写入类的非transient修饰的字段。如果将来向类中添加一些没用transient修饰的字段,并且试图用旧版本的类反序列化它,那么defaultReadObject()方法可以忽略新添加的字段,类似地,如果用新版本反序列化旧的序列化对象,则新的非transient字段将从JVM获取默认值。(对象为null,boolean为false…等)

遍历

  ArrayList有4种遍历方式:

public class ArrayListTest {

    public static int NUM = 10000000;

    public static ArrayList<Integer> list = new ArrayList<Integer>() {{
        for (int i = 0; i < NUM; i++) {
            add(i);
        }
        // list.add(1); 如此写会造成空指针异常
    }};


    @Test
    void testGetByIndex() {
        Integer value;
        int size = list.size();
        long start = System.nanoTime();

        for (int j = 0; j < size; j++) {
            value = list.get(j);
        }

        long end = System.nanoTime();
        System.out.println("Get by index :" + (end - start));
    }

    @Test
    void testGetByFor() {
        Integer value;
        long start = System.nanoTime();

        for (Integer integer : list) {
            value = integer;
        }

        long end = System.nanoTime();
        System.out.println("Get by for :" + (end - start));
    }

    @Test
    void testGetByIterator() {

        Iterator<Integer> iterator = list.iterator();
        Integer value;
        long start = System.nanoTime();

        while (iterator.hasNext()) {
            value = iterator.next();
        }

        long end = System.nanoTime();
        System.out.println("Get by iterator :" + (end - start));
    }

    @Test
    void testGetByForEach() {
        final Integer[] i = new Integer[1];
        long start = System.nanoTime();

        list.forEach(p -> i[0] = p);

        long end = System.nanoTime();
        System.out.println("Get by forEach :" + (end - start));
    }
}
遍历方式NUM=10NUM = 10 000NUM = 10 000 000
直接通过下标获取11465192325248856746
通过增强for循环遍历5129200954764140772
通过迭代器遍历29872389387590126997
通过Java8的forEach遍历626399321527564065640

说明:
  1. 用匿名内部类初始化时注意别写成list.add()方法,否则会造成NPE。
  2. 利用下标获取时记得size()方法别放在for里,否则会非常影响性能。
  3. 可以看出到后面通过下标获取是最有效率的。
  4. 删除元素只能在iterator中或用for循环逆序删除。

迭代器

  说到遍历,还得说下ArrayList内部有2种迭代器实现:Itr和ListItr。

  具体代码就不贴了,二者都是ArrayList的内部类,都实现了Iterator接口,而ListItr与Itr主要区别如下:

  1. ListItr只有一个带参构造,需传入迭代位置的下标,可以从任意位置开始迭代;而Itr只有无参构造,即从数组下标0开始迭代

  2. ListItr有hasPrevious()和previous()方法,可以向前迭代;而Itr只能向后迭代。

  3. ListItr有nextIndex()和previousIndex()方法,可以知道当前迭代的下标位置;而Itr则不能。

ArrayListSpliterator

  ArrayListSpliterator为jdk1.8新增的可分割迭代器,可以用来并行遍历元素,虽然也是迭代器的一种,但是实现的是Spliterator接口。

  Spliterator接口为jdk1.8新增接口,在Iterator接口中有默认方法实现,(目前还在学习中,故代码就不全贴了,附上测试代码帮助练习,最好还是能debug跟着一步一步看,这样更加清晰)。

	public Spliterator<E> spliterator() {
        return new ArrayListSpliterator<>(this, 0, -1, 0);
    }

static final class ArrayListSpliterator<E> implements Spliterator<E> {

        private final ArrayList<E> list;	//用于存放ArrayList对象
        private int index; // 起始位置
        private int fence; // 结束位置,-1 为到了最后一个元素
        private int expectedModCount; // 存放结构改变次数

		ArrayListSpliterator(ArrayList<E> list, int origin, int fence,int expectedModCount) {
            this.list = list; 
            this.index = origin;
            this.fence = fence;
            this.expectedModCount = expectedModCount;
        }
		
		//第一次使用时,初始化结束位置。
		private int getFence() { // initialize fence to size on first use
            int hi; // (a specialized variant appears in method forEach)
            ArrayList<E> lst;
            // 刚传进来为-1
            if ((hi = fence) < 0) {
            	//list 为空则为0
                if ((lst = list) == null)
                    hi = fence = 0;
                else {
                	//否则,大小为list.size
                    expectedModCount = lst.modCount;
                    hi = fence = lst.size;
                }
            }
            return hi;
        }

		//对单个元素执行给定的动作,执行成功则返回true
		public boolean tryAdvance(Consumer<? super E> action) {
            if (action == null)
                throw new NullPointerException();

			//第一次调用会初始化fence
            int hi = getFence(), i = index;
            // i < hi 说明还没到迭代器尾部,即还有元素
            if (i < hi) {
                index = i + 1;	//索引+1并取出元素
                @SuppressWarnings("unchecked") E e = (E)list.elementData[i];
                action.accept(e);
                if (list.modCount != expectedModCount)
                    throw new ConcurrentModificationException();
                return true;
            }
            return false;
        }

		// 对每个剩余元素执行给定动作,依次处理
		public void forEachRemaining(Consumer<? super E> action) {
            int i, hi, mc; // hoist accesses and checks from loop
            ArrayList<E> lst; Object[] a;
            if (action == null)
                throw new NullPointerException();
            // 给lst赋值,并判断是否有元素
            if ((lst = list) != null && (a = lst.elementData) != null) {
                if ((hi = fence) < 0) {
                    mc = lst.modCount;
                    hi = lst.size;
                }
                else
                    mc = expectedModCount;

				// 下标>0且结束位置小于数组长度,说明还没到末尾
                if ((i = index) >= 0 && (index = hi) <= a.length) {
                	//循环执行给定的Consumer方法
                    for (; i < hi; ++i) {
                        @SuppressWarnings("unchecked") E e = (E) a[i];
                        action.accept(e);
                    }
                    if (lst.modCount == mc)
                        return;
                }
            }
            throw new ConcurrentModificationException();
        }
        //返回还未处理的元素的个数
		public long estimateSize() {
            return (long) (getFence() - index);
        }
}

public class ArrayListTest {
	public static int NUM = 10;

    public static ArrayList<Integer> list = new ArrayList<Integer>() {{
        for (int i = 0; i < NUM; i++) {
            add(i);
        }
    }};
    
	@Test
    void testSpliterator(){

        // 获取可分割迭代器
        Spliterator<Integer> spliterator = list.spliterator();

		// 计算还剩下多少个元素需要遍历
        long size = spliterator.estimateSize();
        Assertions.assertEquals(10, size);	// size大小为10
        
        // 输出0 到 9
        spliterator.forEachRemaining(System.out::println);


        boolean b = spliterator.tryAdvance(System.out::println);
        Assertions.assertFalse(b);	//b 为false 因上个已经迭代完成,输出方法执行失败,返回false

        long size2 = spliterator.estimateSize();
        Assertions.assertEquals(0, size2);	// size2为0,因迭代器中已没有元素

    }
}

内部类SubList

  SubList为ArrayList的内部类,继承了AbstractList,实现了RandomAccess接口,通过调用subList方法返回,并且其注释说明,返回的只是一个视图,且属于左闭右开型,(即包含左小标,不包含右下标)
  在其构造方法中可以看到,只是将ArrayList各属性赋值给自己的属性,并没有重新创建一个List。其包含许多特性,使用时要特别注意一下:

  1. 因是ArrayList内部类,且没有继承ArrayList,因此,无法强转成ArrayList。
  2. 对原List或subLis的非结构性改变都会影响彼此,如set()等。
  3. 对List做结构性改变,会影响到subList,此时subList做任何操作都会抛异常。
  4. 对SubList做结构性改变,会影响到原List,不会抛异常。

  结构性改变:即改变了数组的长度,如add()或remove()等,使modCount发生改变的方法。
  非结构性改变:类似于get()等,没有改变数组长度,即没改变modCount的方法。


	/**
	 * Returns a view of the portion of this list between the specified
     * {@code fromIndex}, inclusive, and {@code toIndex}, exclusive.
	 */
    public List<E> subList(int fromIndex, int toIndex) {
    	//入参校验,无非就是判断有没超出下标啥的
        subListRangeCheck(fromIndex, toIndex, size);
        return new SubList(this, 0, fromIndex, toIndex);
    }

private class SubList extends AbstractList<E> implements RandomAccess {
        private final AbstractList<E> parent;
        private final int parentOffset;
        private final int offset;
        int size;

        SubList(AbstractList<E> parent,
                int offset, int fromIndex, int toIndex) {
            this.parent = parent;
            this.parentOffset = fromIndex;
            this.offset = offset + fromIndex;
            this.size = toIndex - fromIndex;
            this.modCount = ArrayList.this.modCount;
        }
		private void checkForComodification() {
            if (ArrayList.this.modCount != this.modCount)
                throw new ConcurrentModificationException();
        }
        public int size() {
            checkForComodification();
            return this.size;
        }
        // 其他方法就不贴了,基本操作类似于ArrayList
}   
	//此测试方法需用到之前初始化的list,因篇幅就不贴了,可直接从上面CV
	@Test
    void testSubList() {
        
        List<Integer> subList = list.subList(0, 5);

        subList.add(10);
        List<Integer> array = Arrays.asList(0, 1, 2, 3, 4, 10, 5, 6, 7, 8, 9);
        Assertions.assertEquals(new ArrayList<>(array), list);

        ArrayListTest.list.add(1);
        Assertions.assertThrows(ConcurrentModificationException.class, subList::size);
        Assertions.assertThrows(ConcurrentModificationException.class,()->subList.add(1));

    }

  说明:subList的操作和ArrayList基本类似,从源码中可以看出,subList的每个方法都会调用checkForComodification()检查原List和subList的modCount是否相等,以上第三点即可由此说明。

总结

  虽然ArrayList比较简单,相当于集合的入门级,但其中可扩展的知识点还是有的,如序列化、Arrays的相关方法、迭代等。本文通过源码从各个主要方法入手,(有些简单方法就没贴)逐步分析,有助于更加全面地了解集合底层的相关知识,在实际场景中能更加合理地选择,而不是一个ArrayList从头用到尾,能在面试中吹得比别人更久。
  如此,即可。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值