Java集合框架——ArrayList

本文详细探讨了Java ArrayList的内部实现,包括成员变量、构造方法、扩容和插入操作。ArrayList通过一个Object数组实现,初始容量为10。在插入元素时,若容量不足,会进行扩容,通常扩大1.5倍。此外,文章还分析了边界检查、快速删除、索引提示等功能,以及ArrayList如何处理多线程环境下的并发修改问题。
摘要由CSDN通过智能技术生成

本文基于JDK1.8,代码中方法头的注释都是对照源码翻译过来的
自顶向下阅读

类头

public class ArrayList<E> extends AbstractList<E>
    implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
    ...
}

可以看到,ArrayList继承了AbstractList抽象类,在此类中封装好了一些方法和抽象方法,当继承此抽象类时,只需要重写其中的抽象方法,而不需要重写全部方法
实现List接口(提供List接口中所有方法的实现)
实现RandomAccess接口,可以随机访问
实现Cloneable接口,支持克隆,可以调用clone()进行拷贝
实现java.io.Serializable接口,支持序列化

成员变量

//序列版本号
private static final long serialVersionUID = 8683452581122892189L;

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

//用于空实例的共享空数组,用于在用户初始化传的容量为0时使用
private static final Object[] EMPTY_ELEMENTDATA = {};

/*
用于缺省大小的空实例的共享空数组实例,用于默认构造器中
将此与 EMPTY_ELEMENTDATA 区分开来,以了解何时需要膨胀多少添加第一个元素
*/
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

/*
ArrayList底层实现,是一个Object数组
存储数组列表元素的数组缓冲区
数组的容量是这个数组缓冲器的长度
*/
transient Object[] elementData; // non-private to simplify nested class access

//集合中元素的个数。不是ArrayList容量的大小,而是已经存在的元素个数
private int size;

可以看到ArrayList的初始容量大小是10。

构造方法

/**
 * 构造具有指定初始容量的空列表
 *
 * @param initialCapacity 列表的初始容量
 * @throws IllegalArgumentException 如果指定初始容量是否定的,抛出异常
 */
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);
    }
}

创建一个ArrayList对象时若是传入了初始化容量的话,就直接this.elementData = new Object[initialCapacity];
否则,将 private static final Object[] EMPTY_ELEMENTDATA = {} 这个空数组赋值给this.elementData

/**
 * Constructs an empty list with an initial capacity of ten.
 *
 * 构造一个初始容量为10的空列表
 */
public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

默认构造器,若创建时不指定ArrayList容量,则将DEFAULTCAPACITY_EMPTY_ELEMENTDATA赋给elementData
这里使用的是成员变量DEFAULTCAPACITY_EMPTY_ELEMENTDATA,不是EMPTY_ELEMENTDATA
从源码的注释中可以看到默认构造器创建一个默认大小为10的空数组,这里可能会有疑惑,DEFAULTCAPACITY_EMPTY_ELEMENTDATA和EMPTY_ELEMENTDATA不是两个空Object数组吗,怎么就创建了一个默认容量为10的列表了?在工具方法中的插入会讲到

/**
 * 构造包含指定集合元素的列表,按集合迭代器返回的顺序
 *
 * @param c 将其元素放入列表中的集合
 * @throws NullPointerException 如果指定集合为空抛出异常
 */
public ArrayList(Collection<? extends E> c) {
    elementData = c.toArray();
    if ((size = elementData.length) != 0) {
        // c.toArray might (incorrectly) not return Object[] (see 6260652)
        if (elementData.getClass() != Object[].class)
            elementData = Arrays.copyOf(elementData, size, Object[].class);
    } else {
        // replace with empty array.
        this.elementData = EMPTY_ELEMENTDATA;
    }
}

此构造方法使用了泛型
从成员变量和构造方法可以看出,ArrayList的底层是通过一个Object数组(transient Object[] elementData)来实现的,所以ArrayList能够存放任何对象类型

工具方法

这些方法都是private私有的,不提供给用户使用

1. 扩容

/**
 * 增加容量以确保它至少能容纳由最小容量参数指定的元素数量
 *
 * @param minCapacity 期望最小容量
 */
private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    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:
    elementData = Arrays.copyOf(elementData, newCapacity);
}

当ArrayList当前容量小于需求容量时就会进行扩容
在grow()方法中,首先通过将elementData数组的容量扩容至原来的1.5倍得到新容量(这里使用位操作,运算速度更快),然后判断若扩容后的容量小于期望最小容量minCapacity,就把minCapacity作为新容量;若容量大于最大数组容量MAX_ARRAY_SIZE(MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8)的话,继续调用hugeCapacity()

private static int hugeCapacity(int minCapacity) {
    if (minCapacity < 0) // overflow
        throw new OutOfMemoryError();
    return (minCapacity > MAX_ARRAY_SIZE) ?
        Integer.MAX_VALUE :
        MAX_ARRAY_SIZE;
}

在hugeCapacity()中,若minCapacity小于零,抛出异常;否则进行一次三目运算,判断minCapacity和MAX_ARRAY_SIZE大小,成立返回Integer.MAX_VALUE,不成立返回MAX_ARRAY_SIZE,于是在grow()方法中就得到了新的容量,最后再执行elementData = Arrays.copyOf(elementData, newCapacity)进行数组的扩容

2. 插入

这里的私有方法不太好描述,通过add方法来分析

2.1 add(E e):boolean

/**
 * 将指定的元素追加到列表的末尾
 *
 * @param e 要追加到这个列表的元素
 */
public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}

通过add方法可以看出,ArrayList在添加一个元素之前,需要先判断一下容量(底实现层elementData数组大小是否满足条件),如果容量不足,需要扩容,扩大后的容量取决于size+1,最后给elementData[size++]赋值就可以了,最后返回true

在前面提到,默认构造器会创建一个容量为10的ArrayList,默认构造器

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

而DEFAULTCAPACITY_EMPTY_ELEMENTDATA其实是一个空的Object数组,那容量10是怎么来的呢?
通过查看add方法可以发现,add方法本身不会做添加操作,而是调用了ensureCapacityInternal()方法
这里会有点绕,方法名也请看仔细对号入座

private void ensureCapacityInternal(int minCapacity) {
    ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}

在add()方法中传入当前列表元素个数minCapacity给ensureCapacityInternal()
ensureCapacityInternal中又调用了ensureExplicitCapacity和calculateCapacity两个方法,依次跟进

private static int calculateCapacity(Object[] elementData, int minCapacity) {
    //判断初始化ArrayList的时候,是不是使用默认的构造器
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        return Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    return minCapacity;
}

在calculateCapacity()中通过elementData判断是否使用默认构造器ArrayList() {…},若是,则调用Math.max()方法,取出DEFAULT_CAPACITY和minCapacity中最大的一个作为返回值;若不是,则返回值使用minCapacity(当前列表元素个数)。calculateCapacity()的返回值作为参数传给ensureExplicitCapacity()

private void ensureExplicitCapacity(int minCapacity) {
    modCount++;

    // overflow-conscious code
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}

在此方法中,minCapacity可以理解为需求容量,modCount记录的是修改的次数,判断minCapacity和列表elementData长度,若需求容量大于列表长度,则进行列表扩容,其他的插入方法调用原理与此类似

到这里就可以解释为什么默认构造器生成的列表长度为10,使用默认构造器,calculateCapacity()方法中Math.max()方法的返回值必然为DEFAULT_CAPACITY(成员变量DEFAULT_CAPACITY=10),传给ensureExplicitCapacity(),由于列表elementData为空,方法中的下列代码是成立的

if (minCapacity - elementData.length > 0)
        grow(minCapacity);

所以使用默认的DEFAULT_CAPACITY来进行扩容,列表容量也就变为10了
所以,ArrayList在使用默认构造器初始化时容量为空(基于JDK1.8),但在进行添加操作之后容量就变为10了

2.2 add(int index, E element):void

/**
 * 在该列表中的指定位置插入指定元素
 * 将当前在该位置的元素(如果有的话)和任何后续元素向右移动(将一个元素添加到它们的索引中)
 *
 * @param index 要插入指定元素的索引
 * @param element 要插入的元素
 * @throws IndexOutOfBoundsException 下标越界异常
 */
public void add(int index, E element) {
    rangeCheckForAdd(index);

    ensureCapacityInternal(size + 1);  // Increments modCount!!
    System.arraycopy(elementData, index, elementData, index + 1,
                     size - index);
    elementData[index] = element;
    size++;
}

2.3 addAll(Collection<? extends E> c):boolean

/**
 * 按照指定集合的Iterator返回的顺序,将指定集合中的所有元素追加到列表末尾
 * 如果操作过程中指定的集合被修改,则此操作的行为未定义(这意味着如果指定的集合是该列表,并且该列表是非空的,则此调用的行为是未定义的)
 *
 * @param c 包含要添加到此列表的元素的集合
 * @return <tt>true</tt> 如果此列表由于调用而更改,返回调用结果
 * @throws NullPointerException 如果指定集合为空抛出空指针异常
 */
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;
}

首把Collection参数转化为一个数组,toArray()方法调用了Arrays类的copyOf()方法,最底层调用System.arraycopy()方法
然后得到转化后数组长度加上列表元素个数的新容量,后面的操作和上面的插入类似,最后进行数组的拷贝,把参数中的所有元素添加到底层数组elementData中,然后把代表ArrayList中元素个数的size重新设置
如果参数的元素不为空,则返回true,表明把元素添加了进去,如果为空的话,说明没有添加元素,则返回false

2.4 modCount字段

在上面ensureExplicitCapacity()方法中提到了modCount字段,不光是ArrayList,在LinkedList、HashMap等类中都有一个字段叫modCount,modCount字面意思就是修改次数,但为什么要记录modCount的修改次数呢?
可以发现ArrayList,LinkedList,HashMap等都是非线程安全的类,这个变量主要是用来保证在多线程环境下使用

查看源码

/**
 * 这个列表已经被结构修改的的次数
 * 结构修改是指改变列表大小,或以其他方式干扰列表以使得正在进行的迭代可能产生不正确结果的那些修改
 *
 * <p>此字段由{@code iterator}和{@code listIterator}方法返回的迭代器和列表迭代器实现使用。如果此字段的值意外改变,迭代器(或列表迭代器)将抛出{@code ConcurrentModificationException}来响应{@code next}、{@code remove}、{@code previous}、{@code set}或{@code add}操作。这提供了<i>快速失效</i>行为,而不是在迭代期间面对并发修改时的非确定性行为
 *
 * <p>子类对该字段的使用是可选的。如果子类希望提供快速失败迭代器(和列表迭代器),那么它只需要在其{@code add(int,E)}和{@code remove(int)}方法中增加这个字段(以及它覆盖的任何其他方法,从而导致对列表的结构修改)。对{@code add(int, E)}或{@code.(int)}的单个调用必须向该字段添加不超过一个,否则迭代器(和列表迭代器)将抛出假的{@code ConcurrentModificationExceptions}。如果一个实现不希望提供故障快速迭代器,则该字段可能被忽略
 */
protected transient int modCount = 0;

通过源码注释可以发现,这个变量只有在对应迭代器中才使用。迭代器在迭代列表并修改的同时,只能够有一个线程修改操作,如果多于一个,就会抛出ConcurrentModificationException

2.4 addAll(int index, Collection<? extends E> c):boolean

/**
 * 将指定集合中的所有元素插入到该列表中,从指定位置开始
 * 将当前在该位置的元素(如果有的话)和任何后续元素向右移动(增加它们的索引)。新元素将以指定集合的迭代器返回的顺序出现在列表中。
 *
 * @param index 从指定集合插入第一个元素的索引
 * @param c 包含要添加到此列表的元素的集合
 * @return <tt>true</tt> 如果此列表由于调用而更改,返回调用结果
 * @throws IndexOutOfBoundsException 下标越界异常
 * @throws NullPointerException 如果指定集合为空抛出空指针异常
 */
public boolean addAll(int index, Collection<? extends E> c) {
    rangeCheckForAdd(index);

    Object[] a = c.toArray();
    int numNew = a.length;
    ensureCapacityInternal(size + numNew);  // Increments modCount

    int numMoved = size - index;
    if (numMoved > 0)
        System.arraycopy(elementData, index, elementData, index + numNew,
                         numMoved);

    System.arraycopy(a, 0, elementData, index, numNew);
    size += numNew;
    return numNew != 0;
}

通过对比可以看出,在指定位置插入元素的方法中,多了一个rangeCheckForAdd(index)方法调用

3. 数组边界检查

3.1 add和addAll方法的边界检查

/**
 * 用于add和addAll方法的边界检查
 */
private void rangeCheckForAdd(int index) {
    if (index > size || index < 0)
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}

由于需要在指定位置插入元素,所以要进行一次边界的检查,避免数组下标越界
rangeCheckForAdd()是添加集合时做的判断,添加时index不能超出集合的范围,同时下标也不能为负数

3.2 get、remove、set之类方法的边界检查

/**
 * 检查给定索引是否在范围内。如果没有,则抛出一个适当的运行时异常
 * 此方法不检查索引是否为负:它总是在数组访问之前立即使用,如果索引为负,则抛出ArrayIndexOutOfBoundsException
 */
private void rangeCheck(int index) {
    if (index >= size)
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}

rangeCheck()方法是提供给get、remove、set之类的方法检查的,是给已经存在元素的集合操作的,范围0至size-1,不需要去判断他的index小于零,这个方法把检查负责的职责交给了数组的访问,像get(-1)时会报异常ArrayIndexOutOfBoundsException

4. 快速删除指定位置上的元素

/*
 * 跳过边界检查且不返回值的私有移除方法
 */
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
}

直接跳过范围检查,并且不返回值,底层使用的是System.arraycopy()方法

5. 索引消息提示

/**
 * 构造一个索引IndexOutOfBoundsException细节消息
 * 在错误处理代码的许多可能的重构中,这个“大纲”对服务器和客户端VM都是最好的
 */
private String outOfBoundsMsg(int index) {
    return "Index: "+index+", Size: "+size;
}

当出现数组下标越界异常时,返回索引值和数组当前元素个数

到这里ArrayList的私有方法就差不多了,下面面向用户的方法都是在调用上面的方法的基础上进行ArrayList的操作

用户方法

1. 缩至最小容量

/**
 * 将此ArrayList实例的容量修整为列表的当前大小。
 * 应用程序可以使用此操作来最小化。
 * 存储一个ArrayList实例
 */
public void trimToSize() {
    modCount++;
    if (size < elementData.length) {
        elementData = (size == 0)
          ? EMPTY_ELEMENTDATA
          : Arrays.copyOf(elementData, size);
    }
}

modCount自增1,接着判断语句,如果ArrayList中存储元素的个数小于底层数组长度,说明可以进行容量缩减,然后通过一个三目运算进行判断,若列表为空,则将EMPTY_ELEMENTDATA这个空数组赋给底层数组elementData,若不为空,则根据当前列表元素进行容量缩减,把底层数组的空间大小缩为size(元素个数)的大小

2. 指定元素在ArrayList中的位置

2.1 indexOf(Object o):int

从前向后遍历,得到第一个指定元素的位置

/**
 * 返回此列表中指定元素的第一次出现的索引,或如果该列表不包含元素,则为-1
 */
public int indexOf(Object o) {
    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;
}

源码通过if语句中的两次for循环遍历ArrayList。第一次判断当参数为空时,判断其在列表中的位置然后返回,由此可见ArrayList是可以存储null的,其实只要是List的子类,都可以存储null;第二次判断不为空,返回对应位置,若两次都不成立,也就是不存在相应元素,返回-1

2.2 lastIndexOf(Object o):int

从后向前遍历,得到最后一个指定元素的位置

/**
 * 返回此列表中指定元素的最后一个出现的索引,或如果该列表不包含元素,则为-1
 */
public int lastIndexOf(Object o) {
    if (o == null) {
        for (int i = size-1; i >= 0; i--)
            if (elementData[i]==null)
                return i;
    } else {
        for (int i = size-1; i >= 0; i--)
            if (o.equals(elementData[i]))
                return i;
    }
    return -1;
}

3. 判断是否包含某元素

public boolean contains(Object o) {
    return indexOf(o) >= 0;
}

调用上面的indexOf()方法

4. 获取某位置上的元素

/**
 * 返回列表中指定位置的元素
 */
public E get(int index) {
    rangeCheck(index);

    return elementData(index);
}

边界检查之后就是数组的操作

5. 替换列表中指定位置的元素

/**
 * 用指定的元素替换列表中指定位置的元素
 */
public E set(int index, E element) {
    rangeCheck(index);

    E oldValue = elementData(index);
    elementData[index] = element;
    return oldValue;
}

6. 移除元素

6.1 移除指定位置元素

public E remove(int index) {
    rangeCheck(index);

    modCount++;
    E oldValue = elementData(index);

    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    elementData[--size] = null; 

    return oldValue;
}

6.2 移除具体元素

public boolean remove(Object o) {
    //参数为空
    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++)
            if (o.equals(elementData[index])) {
                fastRemove(index);
                return true;
            }
    }
    return false;
}

6.3 移除指定集合所包含的所有元素

/**
 * 从该列表中移除包含在指定集合中的所有元素
 *
 * @param c 包含要从该列表中移除的元素的集合
 * @return {@code true} 如果此列表由于调用而更改返回true
 * @throws ClassCastException 如果此列表元素的类与指定集合不兼容抛出此异常
 * @throws NullPointerException 如果该列表包含空元素并且指定的集合不允许空元素,或者指定的集合为空抛出此异常
 */
public boolean removeAll(Collection<?> c) {
    Objects.requireNonNull(c);
    return batchRemove(c, false);
}

private boolean batchRemove(Collection<?> c, boolean complement) {
    //赋的是地址,通过操作Object[] elementData来改变底层数组
    final Object[] elementData = this.elementData;
    int r = 0, w = 0;
    boolean modified = false;
    try {
        for (; r < size; r++)
            if (c.contains(elementData[r]) == complement)
                elementData[w++] = elementData[r];
    } finally {
        // 与抽象集合保持行为兼容性,
        // 即使 c.contains() 抛出异常
        if (r != size) {
            System.arraycopy(elementData, r,
                             elementData, w,
                             size - r);
            w += size - r;
        }
        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()方法中,先将底层数组this.elementData赋值给一个Object数组elementData,注意,这里赋的是地址,this.elementData和Object[] elementData指向同一地址,如果Object[] elementData改变了,this.elementData也会改变
然后通过一个for循环得到一个前w位不包含集合c中元素的数组(可以举简单例子来验证)
接下来两个判断:r!=size,如果for循环是正常遍历完成的,r是等于size的;w!=size,说明数组前w位有数值,所以从w位开始将后面的元素全部设为null,并返回修改状态modified

7. 清空ArrayList

/**
 * 从该列表中移除所有元素
 * 此调用返回后,列表将为空
 */
public void clear() {
    modCount++;

    // clear to let GC do its work
    for (int i = 0; i < size; i++)
        elementData[i] = null;

    size = 0;
}

for循环遍历修改数组元素

你可以通过以下步骤来更换Ubuntu 18.04的: 1. 备份列表文件:打开终端,输入以下命令备份列表文件。 ``` sudo cp /etc/apt/sources.list /etc/apt/sources.list_backup ``` 2. 打开列表文件并修改:使用文本编辑器打开列表文件。 ``` sudo nano /etc/apt/sources.list ``` 3. 在列表文件中注释掉原有的,并添加新的。你可以选择使用国内的,例如清华大学的(即TUNA)。在列表文件中添加以下内容: ``` # 清华大学 deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ bionic main restricted universe multiverse deb-src https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ bionic main restricted universe multiverse deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ bionic-updates main restricted universe multiverse deb-src https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ bionic-updates main restricted universe multiverse deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ bionic-backports main restricted universe multiverse deb-src https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ bionic-backports main restricted universe multiverse deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ bionic-security main restricted universe multiverse deb-src https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ bionic-security main restricted universe multiverse ``` 4. 保存更改并退出文本编辑器。 5. 更新列表和软件包信息。在终端中运行以下命令来更新列表和软件包信息。 ``` sudo apt update ``` 现在,你的Ubuntu 18.04系统应该已成功更换为新的。这样可以提高终端下载速度。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *3* [Ubuntu18.04换源](https://blog.csdn.net/qq_58011370/article/details/124598788)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_1"}}] [.reference_item style="max-width: 50%"] - *2* [Ubuntu18.04 更换](https://blog.csdn.net/qq_19004627/article/details/102967909)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_1"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值