Java集合框架及其源码分析

13 篇文章 0 订阅
3 篇文章 0 订阅

1. 容器概述

1.1. Java容器的引入及容器中的接口

在Java当中,如果有一个类专门用来存放其它类的对象,这个类就叫做容器(或者就叫做集合),集合就是将若干性质相同或相近的类对象组合在一起而形成的一个整体。也就是Java容器是用来对多个数据进行存储操作的结构(这里的存储,指的是内存层面的存储,不涉及到如硬盘、数据库等持久化的存储)。而对于存储多个数据在Java中已经有数组这种数据结构存在, 那为什么要引入容器呢,因为数组具有如下的特点

  • 数组一旦初始化后,其长度就确定了,难以扩容;
  • 数组一旦定义后,其元素类型也就确定了,即数组中的元素类型必须相同;
  • 数组中提供的方法非常有限,对于添加、删除、插入数据等操作,比较不方便,效率也较差;
  • 数组无法获取数组中的实际元素个数(比如一个长度为10的数组保存整数,非0位有效,存了5个整数,剩余位置用0站位,我们无法获得其实际元素个数为5);
  • 数组存储的数据是有序、可重复的,对于无序、不可重复数据的需求,数组不能满足。

Java容器和数组的区别:

  • 容器不是数组,容器不能通过下标的方式访问容器中的元素;
  • 数组的所有功能通过 ArrayList (可以看作动态数组)容器都可以实现,只是实现的方式不同;
  • 如果非要将容器当做一个数组来使用,通过 toArray 方法返回的就是一个数组。

Java容器可以分为 CollectionMap 两种体系:

  • Collection 接口(集合):存储的是单列数据,是存储一组对象的容器,Collection 接口没有直接提供其实现类,而是又分为若干个更为详细的接口,下面介绍一些重要的接口:
    • List 接口(列表):存储的元素有序、可重复,主要实现类有 ArrayListLinkedListVector等;
    • Set 接口(集):存储的元素无序、不可重复,主要实现类有 HashSetLinkedHashSetTreeSet等;
    • Queue 接口(队列):对各种队列的实现,主要实现类有 ArrayDequePriorityQueue等。
  • Map 接口(映射):存储的是双列数据,是存储具有映射关系即存储**键值对(key-value)**的容器,Map 接口直接就提供了其实现类,主要实现类有 HashMapLinkedHashMapTreeMapHashtableProperties等。

在Java容器框架为不同类型的容器定义了大量的接口,其主要接口如下:

在这里插入图片描述

1.2. Collection<E>接口中的抽象方法

Iterator<E> iterator(); //返回一个用于访问集合中各个元素的迭代器
int size(); //返回当前存储在集合中的元素个数
boolean isEmpty(); //判断集合中是否有元素,如果集合中没有元素,返回true
boolean contains(Object obj); //判断集合中是否包含和obj相等的对象,包含则返回true
boolean containsAll(Collection<?> other); //判断集合中是否包含另一个集合中的所有元素,包含则返回true
boolean add(E element); //向集合中添加指定的元素,如果由于该方法改变了集合,返回true
boolean addAll(Collection<?> other); //向集合中添加另一个集合的全部元素,如果由于该方法改变了集合,返回true
boolean remove(Object obj); //从集合中删除等于obj的元素,如果由于该方法改变了集合,返回true
boolean removeAll(Collection<?> other); //从集合中删除另一个集合中存在的所以元素,如果由于该方法改变了集合,返回true
void clear(); //从集合中删除所有的元素
boolean retainAll(Collecion<?> other); //从集合中删除所有与另一个集合中元素不同的元素(求交集),如果由于该方法改变了集合,返回true
Object[] toArray(); //返回这个集合的对象的数组
boolean equals(Object obj); //比较对象是否与集合相等,相等则返回true
int hashCode(); //返回该集合的哈希值

也就是说,所有 Collection 接口体系下的容器,都需要实现上述的抽象方法。需要注意的是,上述设计判断是否包含对象等类似方法,判断的都是对象的内容是否相等(需要对象的类重写了 equals 方法),也就是内部调用的 equals() 进行判断,而不是判断是否为同一个对象(用 == 判断)。

1.3. Iterable接口和Iterator接口

对于 Collection 接口,它继承了 Iterable接口:

在这里插入图片描述

而我们查看 Iterable 接口的源码可以看到,Collection 继承该接口表示可以返回迭代器用于遍历集合中的元素,这也是为什么 Collection 接口中有 Iterator<E> iterator(); 这个方法,同时也是集合框架中的实现类可以运用 foreach 循环的原因:

public interface Iterable<T> {
    // 返回一个迭代器.
    Iterator<T> iterator();
    
    // 定义了 foreach 循环的方法
    default void forEach(Consumer<? super T> action) {
        Objects.requireNonNull(action);
        for (T t : this) {
            action.accept(t);
        }
    }
	…………………………
}

我们查看 Iterator 接口的源码可以发现其主要有 hasNext()next()remove() 方法:

public interface Iterator<E> {
    // 如果迭代过程还有剩余的元素,则返回true
    // 也就是说下面的当next()方法没有抛出异常,而是可以返回元素时,hasNext()返回true
    boolean hasNext();
    
    // 返回迭代中的下一个元素
    // 如果迭代没有下一个元素可返回则抛出异常:NoSuchElementException
    E next();
  
    // 删除上次访问的对象
    // 该方法必须紧跟在访问一个元素之后执行
    void remove();
    ……………………
}

Iterator 的对象成为迭代器,主要用于遍历 Collection 集合中的元素,集合对象每次调用 iterator() 方法都会得到一个全新的迭代器对象,对于每个迭代器对象,默认的游标都在集合的第一个元素之前

在这里插入图片描述

每次调用迭代器的 next() 方法,都会返回游标之后的那个元素,然后将游标后移。需要注意的是,remove()方法必须紧跟在访问一个元素之后执行(即进跟着调用next()方法后),如果上次访问之后集合已经发生变化,则这个方法会抛出IllegalStateExcepption 异常。下面是对一个集合使用迭代器迭代的例子,下面的测试程序创建了一个集合,并在迭代器遍历集合的过程中删除了值为"davis"的元素:

@Test
public void test() {
    Collection<String> c = new ArrayList<>();
    c.add("james");
    c.add("curry");
    c.add("davis");
    c.add("durant");

    Iterator<String> iterator = c.iterator();
    while (iterator.hasNext()) {
        String next = iterator.next();
        if ("davis".equals(next)) {
            iterator.remove();
        }
    }

    System.out.println(c.toString());
}

其输出结果为:

[james, curry, durant]

1.4. List 接口

List (since 1.2)接口中的元素都是有序、且可重复的,可以看做是“动态”数组,即可以扩容的数组,以替换原来是数组,其主要的实现类有ArrayListLinkedListVector(遗留的集合)。

ArrayListLinkedListVector 的异同:

  • ArrayList:since 1.2,作为 List 接口的主要实现类,线程不安全的,效率高,底层使用Object[]数组存储;
  • LinkedList:since 1.2,对于频繁的插入、删除操作,使用效率比 ArrayList 高,底层使用双向链表存储,也实现了Deque 接口(Deque接口继承了Queue接口);
  • Vector:since 1.0,作为 List 接口的古老实现类(出现的比 List 接口还早),线程安全的,效率低,共有方法都由 synchronized 关键字进行了修饰,底层使用Object[]数组存储。

List接口中除了具有Collection接口中具有的抽象方法外,还具有其特有的抽象方法,可以在指定位置插入add或删除remove元素,也可以查询get指定位置的元素,还可以修改set指定位置的元素,且其可以返回一个相反方向的迭代器即 ListIterator

1.5. Set 接口

Set 接口是 Collection 的子接口,其无序性不等于随机性,其无序性指的是存储数据时并非向底层结构中按索引顺序添加元素,而是根据元素的哈希值进行添加。其不可重复性指的是两个对象是否相同,其判断两个对象是否相同不是使用 == 运算符,而是根据 equals() 方法(会先比较其哈希值)。Set接口没有提供额外的抽象方法,其主要的实现类有HashSetLinkedHashSetTreeSet

HashSetLinkedHashSetTreeSet的异同:

  • HashSet:作为 Set 接口的主要实现类,线程不安全的,可以存储null值;
  • LinkedHashSet:是 HshSet 子类,增加了链表结构,使的遍历其内部数据时,可以按照添加的顺序遍历;
  • TreeSet:可以按照添加对象的指定属性进行排序。

Set添加元素的过程(以HashSet为例):向HashSet中添加新元素,先调用该元素的hashCode()方法,计算其哈希值,此哈希值通过某种算法计算出在HashSet底层数组中的存放位置,判断数组在此位置上是否已经有元素,如果没有则直接添加成功,如果有元素,比较新元素和已经存在元素的哈希值,不同则添加成功,相同再调用equals()方法进行比较。

1.5. QueueDeque接口

顾名思义,这两个接口分别为队列和双端队列,在 Queue 中定义了从队尾入队、对头出队的方法,而在 Deque 中定义了在对头、队尾分别出队、入队的方法。

如果想用栈话,尽量不要用JDK中的 Stack 类,因为事实上 StackVector 的子类,而 Vector 实际上已经不推荐使用了,同样 Stack 也不推荐使用,而是可以用实现了 Deque 接口的 ArrayDequeLinkedList

1.7. Map 接口

存储key-value键值对,其主要的实现类有:

  • HashMap:作为Map的主要实现类,线程不安全的,效率高,可以存储null的键值对(keyvalue都可以是null),其底层在jdk7及之前为数组+链表,在jdk8中为数组+链表+红黑树
    • LinkedHashMap:继承了HashMap,保证在遍历映射元素时,可以按照添加的顺序实现遍历,对于频繁的 遍历操作,此类执行效率高于HashMap
  • TreeMap:可以按照添加的键值对(主要是对键key进行排序)进行排序,实现排序遍历;
  • Hashtable:作为Map的古老实现类,线程安全的,效率低,不能存储null的键值对,属于历史遗留的实现类;
    • Properties:继承了Hashtablekey-value都是String类型,常用来处理配置文件。

通过一个 Map 进行迭代要比 Collection 复杂,因为 Map 不提供迭代器,而是提供了三种方法,将 Map 对象的视图作为 Collection 对象返回,而这些视图本身就是 Collection (继承了 Iterable 接口),故可以将它们迭代:

// Views(视图)
Set<K> keySet();
Collection<V> values();
Set<Map.Entry<K, V>> entrySet();

Map 中还定义了其内部接口 Entry,用于表示 key-value 结构:

interface Entry<K,V> {
    K getKey();
    V getValue();
    V setValue(V value);
    /**
     * Compares the specified object with this entry for equality.
     * Returns <tt>true</tt> if the given object is also a map entry and
     * the two entries represent the same mapping.  More formally, two
     * entries <tt>e1</tt> and <tt>e2</tt> represent the same mapping
     * if<pre>
     *     (e1.getKey()==null ?
     *      e2.getKey()==null : e1.getKey().equals(e2.getKey()))  &amp;&amp;
     *     (e1.getValue()==null ?
     *      e2.getValue()==null : e1.getValue().equals(e2.getValue()))
     * </pre>
     * This ensures that the <tt>equals</tt> method works properly across
     * different implementations of the <tt>Map.Entry</tt> interface.
     *
     * @param o object to be compared for equality with this map entry
     * @return <tt>true</tt> if the specified object is equal to this map
     *         entry
     */
    boolean equals(Object o);
    /**
     * Returns the hash code value for this map entry.  The hash code
     * of a map entry <tt>e</tt> is defined to be: <pre>
     *     (e.getKey()==null   ? 0 : e.getKey().hashCode()) ^
     *     (e.getValue()==null ? 0 : e.getValue().hashCode())
     * </pre>
     * This ensures that <tt>e1.equals(e2)</tt> implies that
     * <tt>e1.hashCode()==e2.hashCode()</tt> for any two Entries
     * <tt>e1</tt> and <tt>e2</tt>, as required by the general
     * contract of <tt>Object.hashCode</tt>.
     *
     * @return the hash code value for this map entry
     * @see Object#hashCode()
     * @see Object#equals(Object)
     * @see #equals(Object)
     */
    int hashCode();
    
    // 一系列的比较器,比较键值等
    public static <K extends Comparable<? super K>, V> Comparator<Map.Entry<K,V>> comparingByKey() {
        return (Comparator<Map.Entry<K, V>> & Serializable)
            (c1, c2) -> c1.getKey().compareTo(c2.getKey());
    }
    
    public static <K, V extends Comparable<? super V>> Comparator<Map.Entry<K,V>> comparingByValue() {
        return (Comparator<Map.Entry<K, V>> & Serializable)
            (c1, c2) -> c1.getValue().compareTo(c2.getValue());
    }
    
    public static <K, V> Comparator<Map.Entry<K, V>> comparingByKey(Comparator<? super K> cmp) {
        Objects.requireNonNull(cmp);
        return (Comparator<Map.Entry<K, V>> & Serializable)
            (c1, c2) -> cmp.compare(c1.getKey(), c2.getKey());
    }
    
    public static <K, V> Comparator<Map.Entry<K, V>> comparingByValue(Comparator<? super V> cmp) {
        Objects.requireNonNull(cmp);
        return (Comparator<Map.Entry<K, V>> & Serializable)
            (c1, c2) -> cmp.compare(c1.getValue(), c2.getValue());
    }
}

注意特意保留了其 hashCode 方法中的注释,可以看到一个 Entryhash值实际上是用了其 keyvalue 的哈希值做了异或运算

2. 主要的具体集合及其源码分析

2.1. ArrayList

ArrayList的类图如下,其实现了List接口,且可以随机访问

在这里插入图片描述

2.1.1. jdk 7 情况下:

查看ArrayList源码,发现其类中有属性值:

private transient Object[] elementData; //用于存储数据
private int size; //记录ArrayList中添加的元素个数,初始为0

也就是说ArrayList在底层仍然是由数组实现的,只不过其内部实现了对数组的动态扩容,再查看其空参构造器,在空参构造器中调用了类中其他的构造器:

/**
 * Constructs an empty list with an initial capacity of ten.
 */
public ArrayList() {
    this(10);
}

空参构造器调用的构造器如下:

/**
 * Constructs an empty list with the specified initial capacity.
 *
 * @param  initialCapacity  the initial capacity of the list
 * @throws IllegalArgumentException if the specified initial capacity
 *         is negative
 */
public ArrayList(int initialCapacity) {
    super();
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal Capacity: "+
                                           initialCapacity);
    this.elementData = new Object[initialCapacity];
}

public ArrayList(int initialCapacity)构造器可以创建一个initialCapacity长度的数组,而如果调用的是空参构造器的话,ArrayList在底层创建了长度为10Object[]数组,在明白了其如何初始化一个数组后,我们关心的就是如何向其中添加数据,查看其add(E e)方法:

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}

在添加数据前,先别着急添加数据,第一步时用ensureCapacityInternal方法确保容量可以容纳添加的数据,再查看具体的ensureCapacityInternal方法,关于属性modCount的作用见ArrayList分析最下面的批注:

private void ensureCapacityInternal(int minCapacity) {
    modCount++;
    // overflow-conscious code
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}

我们发现在add方法中我们使minCapacity = size + 1,而只有minCapacity - elementData.length > 0时才会触发ensureCapacityInternal中调用的grow方法,而size最开始为0,我们分析了elementData数组最开始其长度为10,也就是说当向ArrayList添加第11个元素时,此时minCapacity = size + 1 = 10 + 1 = 11大于了elementData的长度,此时就触发了grow()方法:

private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length; //扩容前底层数组长度
    int newCapacity = oldCapacity + (oldCapacity >> 1); //扩容为原来数组长度的1.5倍
    //如果扩容完还是不够,就将elementData直接扩容为minCapacity长度的数组
    if (newCapacity - minCapacity < 0) 
        newCapacity = minCapacity;
    //如果扩容完的值已经大于了要求的最大值-8,就返回最大值或最大值-8,否则就直接overflow
    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);
}
	
private static int hugeCapacity(int minCapacity) {
    if (minCapacity < 0) // overflow
        throw new OutOfMemoryError();
    return (minCapacity > MAX_ARRAY_SIZE) ?
        Integer.MAX_VALUE :
        MAX_ARRAY_SIZE;
}

总结起来就是,ArrayList在不指定initialCapacity初始容量的条件下,底层会创建一个长度为10的数组,当向ArrayList中添加元素,当添加元素无法容纳时,就会对底层数组进行扩容为原来数组的1.5倍,如果添加的元素以集合的形式添加比如调用了addAll(Collection<?> other)方法,扩容为1.5倍后都无法满足的话,那就直接扩容为当前size + 添加的集合的size(即minCapacity),同时将老数组中的值copy入新数组中。所以我们在开发中尽量不要使用ArrayList的无参构造器,大概知道要存放多少数据的时候,尽量使用带参构造器public ArrayList(int initialCapacity)以免ArrayList底层多次的扩容拷贝数组操作

2.1.2. jdk 8 情况下:

其属性值仍然有Object[]数组和size,也定义了一些重要的常量:

private static final int DEFAULT_CAPACITY = 10; //默认的底层数组初始容量
/**
 * Shared empty array instance used for empty instances.
 */
private static final Object[] EMPTY_ELEMENTDATA = {};
/**
 * Shared empty array instance used for default sized empty instances. We
 * distinguish this from EMPTY_ELEMENTDATA to know how much to inflate when
 * first element is added.
 */
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; //空数组
transient Object[] elementData; // non-private to simplify nested class access
private int size;

再查看其空参构造器:

public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;//底层数组最开始为空数组
}

我们发现在jdk7中创建ArrayList对象的同时就初始化了底层数组Object[] elementData长度为10,而在jdk8中创建ArrayList对象的同时并没有直接创建长度为10的底层数组。那么在jdk8中,什么时候创建了具体的底层数组呢?我们查看其add(E e)操作:

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}

和jdk7中一样,然后再查看其具体的ensureCapacityInternal方法:

private void ensureCapacityInternal(int minCapacity) {
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    ensureExplicitCapacity(minCapacity);
}

也就是说在jdk8中,当第一次对ArrayList对象进行add操作时,其底层才创建初始容量为10的数组,并将第一个数据添加到数组中。至于其对底层数组的扩容机制和拷贝和jdk7中的一致。总结来说jdk8中的ArrayList对象的创建延迟了底层数组的创建,节省了内存。

需要注意的是在 Iterable 接口中提到了其实现的默认方法 foreach,在 ArrayList 中也进行了重写:

@Override
public void forEach(Consumer<? super E> action) {
Objects.requireNonNull(action);
// 当迭代器被建立时,存储集合当前的modCount并设置为不可变量
final int expectedModCount = modCount; 
@SuppressWarnings("unchecked")
final E[] elementData = (E[]) this.elementData;
final int size = this.size;
for (int i=0; modCount == expectedModCount && i < size; i++) {
  action.accept(elementData[i]);
}
	// 在迭代器迭代期间集合被结构性修改
if (modCount != expectedModCount) {
  throw new ConcurrentModificationException();
}
}

可以看到在 foreach 循环时确实会发生并发修改异常。其记录了一个 modCount 字段,每次 addremove 操作都会更行该字段,其主要想法是,当一个迭代器被建立时,存储集合的 modCount 作为 expectedModCount,每次对一个迭代器方法(nextremove)的调用都会用当前集合的 modCount 检测迭代器内存储的 expectedModCount,并且当这个两个计数不匹配时抛出并发修改异常。

而对于 ArrayList 中返回的迭代器,其实也是在 ArrayList 中定义的内部类,其内部类迭代器抛出并发修改异常的原理和上面描述的基本一致:

// 返回一个迭代器
public Iterator<E> iterator() {
return new Itr(); // 内部类
}
// 迭代器内部类
private class Itr implements Iterator<E> {
int cursor;       // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount; // 也是记录当前的修改数用于比对
……………………
}

2.2. LinkedList

LinkedList 的类图如下:

在这里插入图片描述

LinkedList的源码在jdk7和jdk8中没有什么区别,这里仅以jdk8为例分析即可。

ArrayList的源码中我们在其属性值中可以看到其底层为数组,而在LinkedList中我们可以看到其有入下的属性值:

transient int size = 0;
transient Node<E> first; //头节点
transient Node<E> last; //尾结点

可以看到其底层为Node<E> 的数据结构,也就是链表中的节点,这就是 LinkedList 数据存储的基本单位,再看其节点的具体定义:

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;
    }
}

知道了 LinkedList 的底层结构后,下面我们分析其添加元素的方法,发现其调用了在链表尾部添加元素的方法,然后我们再进入linkLast()方法进行查看:

public boolean add(E e) {
    linkLast(e); //调用了在链表尾部添加元素的方法
    return true;
}

void linkLast(E e) {
    final Node<E> l = last; //l为添加节点之前的尾结点
    final Node<E> newNode = new Node<>(l, e, null); //令添加节点的前驱结点指向原来的尾结点
    last = newNode; //令添加节点作为新的尾结点
    //如果首次添加元素,即原来的尾结点为空,那么添加的节点就作为头结点(同时也是尾结点)
    if (l == null)
        first = newNode;
    //添加元素前若有尾结点,则尾结点的后继节点指向添加的节点
    else
        l.next = newNode;
    size++;
    modCount++;
}

LinkedList 内部声明了 Node 类型的 firstlast 属性,并且这两个属性的初始值都是null,当添加第一个元素时,该元素既作为头结点又作为尾结点,之后就进行正常的添加删除即可。

需要注意的是当清空 LinkedList 即其 clear 操作时,并不仅仅让其 firstlast 变为空,JDK的实现为了便于垃圾回收,而是将每个节点都进行了置空操作:

public void clear() {
    // Clearing all of the links between nodes is "unnecessary", but:
    // - helps a generational GC if the discarded nodes inhabit
    //   more than one generation
    // - is sure to free memory even if there is a reachable Iterator
    for (Node<E> x = first; x != null; ) {
        Node<E> next = x.next;
        x.item = null;
        x.next = null;
        x.prev = null;
        x = next;
    }
    first = last = null;
    size = 0;
    modCount++;
}

而对于按照索引获取节点的操作,JDK中先对索引进行了判断,若在链表的前半部,则从头结点开始遍历,若在链表的后半部,则从尾结点开始遍历:

Node<E> node(int index) {
    // assert isElementIndex(index);
    if (index < (size >> 1)) {
        Node<E> x = first;
        for (int i = 0; i < index; i++)
            x = x.next;
        return x;
    } else {
        Node<E> x = last;
        for (int i = size - 1; i > index; i--)
            x = x.prev;
        return x;
    }
}

2.4. ArrayDeque

ArrayDeque 的类图如下:

在这里插入图片描述

它和 LinkedList 一样都实现了 Deque (其父接口为 Queue),LinkedList 作为双端队列的链表实现类,而 ArrayDeque 作为双端队列的数组实现类,ArrayDeque 中有如下的属性字段:

// 存放数据的底层数组
transient Object[] elements; // non-private to simplify nested class access

// 头尾索引
transient int head;
transient int tail;

// 创建的底层数组的最小容量
private static final int MIN_INITIAL_CAPACITY = 8;

注意其最小容量为8,但是如果调用空参构造器创建的底层数组容量却不是8,而是16,也就是说 ArrayDeque 的默认容量为16

// 空参构造器
public ArrayDeque() {
    elements = new Object[16];
}

那这个8是什么意思呢?比如我们给与了 ArrayDeque 容量为5,则其创建的数组长度为8,也就是说如果初始容量小于8,则会创建长度为8的底层数组,调用了如下的构造器:

public ArrayDeque(int numElements) {
    allocateElements(numElements);
}

private void allocateElements(int numElements) {
    int initialCapacity = MIN_INITIAL_CAPACITY;
    // Find the best power of two to hold elements.
    // Tests "<=" because arrays aren't kept full.
    // 如果给出的容量 ≥ 8,则找到比所给容量大的2的n次方
    if (numElements >= initialCapacity) {
        initialCapacity = numElements;
        initialCapacity |= (initialCapacity >>>  1);
        initialCapacity |= (initialCapacity >>>  2);
        initialCapacity |= (initialCapacity >>>  4);
        initialCapacity |= (initialCapacity >>>  8);
        initialCapacity |= (initialCapacity >>> 16);
        initialCapacity++;
        if (initialCapacity < 0)   // Too many elements, must b
            initialCapacity >>>= 1;// Good luck allocating 2 ^ 
    }
    // 给出容量<8,则创建长度为8的数组
    elements = new Object[initialCapacity];
}

ArrayDeque 底层数组存放的数据满时,则会进行扩容,扩容操作会将 elements 数组扩容为原来的2倍,其扩容的最大值就是int型整数的最大值,当原长度×2后若整数溢出,则会抛出异常:

if (newCapacity < 0) // 整数溢出则会变为负数
    throw new IllegalStateException("Sorry, deque too big");

对于 ArrayDeque,它实现了栈、队列、双端队列的一系列操作,但是这些操作都是利用了双端队列的4个经典操作,即 addFirstaddLastpollFirstpollLast,而我们先看一下其 addFirst 方法的源码:

public void addFirst(E e) {
    if (e == null)
        throw new NullPointerException();
    elements[head = (head - 1) & (elements.length - 1)] = e;
    if (head == tail)
        doubleCapacity();
}

在这里插入图片描述

elements[head = (head - 1) & (elements.length - 1)] = e;

这句代码在干嘛???不知道有没有人和我第一次看 ArrayDeque 的源码一样有如此大的疑惑,在往后看看其他方法,这些什么与操作啊比比皆是!!!事实上我们要是掌握了 ArrayDeque 底层真正的数据结构,就可以真正掌握了这段看似妖魔鬼怪的源码,ArrayDeque 底层是将 elements 数组作为环形数组使用!正是因为是环形数组,所以才需要记录 headtail 两个指针,其中 head 指向当前的头,而 tail 指向要插入的尾,比如我们向最小容量(8)的 ArrayDeque 中的末尾插入数据:

在这里插入图片描述

可以看到,当 head==tail 时,那就证明数组已经没有位置可以供元素插入,所以扩容的条件就是 head==tailArrayDeque 的扩容函数如下:

private void doubleCapacity() {
    // 先断言扩容条件
    assert head == tail;
    int p = head;
    int n = elements.length;
    int r = n - p; // number of elements to the right of p
    int newCapacity = n << 1;
    if (newCapacity < 0)
        throw new IllegalStateException("Sorry, deque too big");
    Object[] a = new Object[newCapacity];
    // 先复制原数组中head到数组末端的数据
    System.arraycopy(elements, p, a, 0, r);
    // 再复制原数组中数组始端到tail的数据
    System.arraycopy(elements, 0, a, r, p);
    elements = a;
    head = 0;
    tail = n;
}

ArrayDeque 中无论是扩容操作,还是复制数组操作,是都要将数组分成两部分看待的,比如下面这种情况,第一部分是从头结点到数组尾部的部分,第二部分是从数组头部到尾结点的部分

在这里插入图片描述

那现在在回看其 addFirst 函数,我们看看这句代码到底干了什么:

elements[head = (head - 1) & (elements.length - 1)] = e;

就以上图为例,此时 head=5 (0x0101),则elements.length - 1 = 7(0x0111),则 4 & 7 = 4(0x0100 & 0x0111 = 0x0100),也就是说新元素要插入了索引为4的位置,同时该行代码将 head 指针指向了索引4,对于 pollFirst 操作也是同样的道理:

public E pollFirst() {
    int h = head;
    @SuppressWarnings("unchecked")
    E result = (E) elements[h];
    // Element is null if deque empty
    if (result == null)
        return null;
    // 原头结点元素置空
    elements[h] = null;     // Must null out slot
    // 更新头指针
    head = (h + 1) & (elements.length - 1);
    return result;
}

在其将head指针指向元素置空后也是更新了头指针,仍然拿上图的例子,如果要让S 元素出队,则 h + 1 = 6(0x0110),则 6 & 7 = 6 0x0110 & 0x0111 = 0x0110),也就是说头指针确实正确的指向了6。

关于ArrayDeque 中的环形数组的实现以及运用位运算巧妙地更新头结点,我觉得还是非常值得学习的!但是值得注意的是,该位与运算能够成功更行头指针的前提是数组的长度必须为2的n次幂,这也就是其底层数组长度必须为2的次方的原因。

2.4. PriorityQueue

PriorityQueue 的类图如下:

在这里插入图片描述

首先说明优先队列是什么,Java中的默认优先队列事实上就是一个小顶堆,既然是队列肯定是队尾入队,队头出队,而优先队列就保证了出队时一定是当前最小的元素,而关于堆的详情可以参考之前写的博文常用的排序算法,在堆排序中我对堆的数据结构进行了详细的介绍,而我们知道堆是一个完全二叉树,完全二叉树有一个特点,那就是除了底层都是被填满的,而底层也是从左到右被填充的,这就带来一个好处,一个堆(或完全二叉树)可以用数组表示而不需要用链,比如有如下的一棵完全二叉树(显然也是一个小顶堆):

在这里插入图片描述

那么该树就可以用数组如下表示:

在这里插入图片描述

这就可以得到结论,如果将数组的 0 索引空出,从 1 索引开始作为树的根,那么对于数组中任一位置 i 上的元素:

  • 其左儿子在位置 2 * i 上;
  • 其右儿子在位置 2 * i + 1 上;
  • 其父亲在位置 ⌊i / 2⌋ 上,⌊⌋ 代表向下取整。

而JDK中的PriorityQueue就是这么实现的,其有如下的字段:

// 默认初始容量
private static final int DEFAULT_INITIAL_CAPACITY = 11;
/**
 * Priority queue represented as a balanced binary heap: the two
 * children of queue[n] are queue[2*n+1] and queue[2*(n+1)].  The
 * priority queue is ordered by comparator, or by the elements'
 * natural ordering, if comparator is null: For each node n in the
 * heap and each descendant d of n, n <= d.  The element with the
 * lowest value is in queue[0], assuming the queue is nonempty.
 */
// 存放数据的底层数组
transient Object[] queue; // non-private to simplify nested class access

// 优先队列中成员个数
private int size = 0;

// 用于优先队列成员排序的比较器,若为定义则为自然排序
private final Comparator<? super E> comparator;

// 结构性修改次数
transient int modCount = 0; // non-private to simplify nested class access

其中底层数组的源码注释我进行了保留,可以看到就是像刚才分析的一样,PriorityQueue 用数组实现了小顶堆。至于对优先队列中元素的上浮或下沉操作,原理和堆排序中分析的原理是相同的,参考排序算法的那篇博文即可。

2.5. HashSet

HashSet 的类图如下:

在这里插入图片描述

我们查看HashSet底层源码时发现其有属性值:

private transient HashMap<E,Object> map;
//用来向HashMap中添加元素时站位的空对象
private static final Object PRESENT = new Object(); 

也就是说**HashSet实际上是一个HashMap**,当向HashSet中添加元素时,实际上是在向HashMap中添加元素,

public boolean add(E e) {
    return map.put(e, PRESENT)==null;
}

所以对于HashSet的底层源码不多赘述,更多细节在HashMap中进行总结即可。

2.6. LinkedHashSet

LinkedHashSet在添加数据的同时,每个数据还维护了两个引用,记录此数据的前一个数据和后一个数据,对于频繁的遍历操作,LinkedHashSet效率会高一些。其类图如下:

在这里插入图片描述

查看其空参构造方法,其调用了父类HashSet中的构造方法,而父类**HashSet的该构造器实际上由创建了一个LinkedHashMap**:

在这里插入图片描述

// LinkedHashSet调用的HashSet中的构造器
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
    map = new LinkedHashMap<>(initialCapacity, loadFactor);
}

所以对于LinkedHashSet的底层源码也无需赘述,重点看LinkedHashMap的底层源码即可。

2.7. HashMap

对于HashMap事实上在JDK7和JDK8中是有区别的,其主要区别有以下三点:

  • 在JDK7以前HashMap的底层结构是数组+链表,而在JDK8中的底层结构为数组+链表+红黑树
  • JDK7时当出现哈希冲突时,新节点是添加到链表的表头,而在JDK8中新节点是添加到链表的表尾;
  • JDK7中的数据数组在 HashMap 初始构造时就进行了创建,而JDK8中是在其第一次使用时才进行创建。

这里仅对JDK8进行分析。(红黑树的部分以后有经理再进行补充吧,左旋右旋,treefy,实在是磕不懂源码了哈哈哈)

属性

首先说明 HashMap 中定义的若干常量:

// 默认的初始容量(即桶的个数),必须为2的次方,默认为 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

// 最大的容量,也必须为2的次方
static final int MAXIMUM_CAPACITY = 1 << 30;

// 默认的负载因子,为 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 对于一个桶内采取红黑树结构代替链表结构时,桶中元素个数的阈值
static final int TREEIFY_THRESHOLD = 8;

// 对于一个桶内取消红黑树结构而恢复为链表结构时,桶中元素个数的阈值
static final int UNTREEIFY_THRESHOLD = 6;

// 当哈希表的大小超过这个阈值,才会把链式结构转化成树型结构,否则仅采取扩容来尝试减少冲突
static final int MIN_TREEIFY_CAPACITY = 64;

然后在 HashMap 中定义了其静态内部类 HashMap.Node ,该内部类实现了上面在 Map 接口中提到的 Map.Entry 接口,Node类作为哈希表的一个桶中的默认结构,实际上就是一个链表,可以看到其具有 next 指针,而在当一个桶需要红黑树化后,其结构就会由另一个内部类 HashMap.TreeNode 所代替:

// 桶中的链表结构
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash; // 节点的哈希值
    final K key; // 键
    V value; // 值
    Node<K,V> next; // 链表的下一个节点
    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }
    public final K getKey()        { return key; }
    public final V getValue()      { return value; }
    public final String toString() { return key + "=" + value; }
    // 键值对节点的哈希码 = 键和值的哈希码做异或运算
    public final int hashCode() {
        // 该 hashCode 方法是一个本地方法
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }
    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }
    public final boolean equals(Object o) {
        if (o == this)
            return true;
        if (o instanceof Map.Entry) {
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;
            if (Objects.equals(key, e.getKey()) &&
                Objects.equals(value, e.getValue()))
                return true;
        }
        return false;
    }
}

HashMap 中具有如下的属性字段:

// 实际存储数据的 Node数组,在第一次使用时才初始化长度,否则为空数组
transient Node<K,V>[] table;

// 临时的节点集合
transient Set<Map.Entry<K,V>> entrySet;

// HashMap中的键值对数量
transient int size;

// 和之前在 ArrayList 中说明过的 modCount 为同种用途,用于记录HashMap的结构性修改(包括put、rehash等)
// 用于实现其Collection-views的fail-fast功能,即在遍历其keySet或values或entrySet时,
// 如果HashMap有结构性修改,则立即抛出ConcurrentModificationException
transient int modCount;

// rehash时的其键值对数量阈值 = 桶的个数(capacity,默认为16)* 负载因子(load factor,默认为0.75)
int threshold; // 默认为12

// 负载因子
final float loadFactor;

方法

主要分析下 getputremovere方法

get获取元素

get 方法的源码如下,短短一行,也就是说分析 get 方法的重点是分析 getNode 方法。

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

getNode 的源码如下,关键部分已经进行注释:

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // if 哈希表不为空 && 该key对应的桶不为空(桶中有元素)
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 先查看桶上的第一个node,看是否直接命中,若命中则返回
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 判断桶中是否有后继节点
        if ((e = first.next) != null) {
            // 如果该桶的结构已经变为红黑树,则用红黑树的方式获取节点
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 桶的结构仍是传统的拉链式,则一直遍历链表中的节点直到找到目标节点
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    // 无法找到则返回空
    return null;
}
put添加元素

put 方法的内部实际上也是调用了 putVal 方法,所以重点分析下 putVal 的源码。

public V put(K key, V value) {
    // 后两个参数 (boolean onlyIfAbsent, boolean evict)
    return putVal(hash(key), key, value, false, true);
}

对于 onlyIfAbsent 参数,其表达的含义为当该参数为 true 时,不改变已经存在键的值,由于 put 方法中该参数为 false,所以当向 HashMap 中加入已经存在键时,会更行该键的值。

对于 evice 参数,其表达的含义是该 put 操作是否与 LinkedHashMap 相关,它用来指示在添加新键值对后是否需要进行如更行 LinkedHashMap 相邻两个节点间链表等操作,由于是 HashMap,所以对于相关的方法并没有进行实现。

putVal 的源码如下:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 如果哈希表为空,则调用resize()方法创建哈希表
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 如果当前桶没有哈希冲突,则直接将节点加入到桶的第一个节点处即可
    // 注意该hash值,put方法传入的是键的哈希值,也就是只比对键,不比对值
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 桶中第一个节点的key和要插入的key相同,则直接命中该节点(稍后再选择更新与否)
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 桶中节点结构以为红黑树,则调用红黑树的添加节点方法        
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 桶中节点结构为传统的拉链式
        else {
            // 遍历链表,查找是否有与要插入节点key相同的节点
            for (int binCount = 0; ; ++binCount) {
                // 没有和插入节点key相同的节点
                if ((e = p.next) == null) {
                    // 要插入的节点是个新节点,创建该节点链到链表尾部
                    p.next = newNode(hash, key, value, null);
                    // 链表中的节点数应达到TREEIFY_THRESHOLD,则将该桶节点结构红黑树化
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 找到了重复的key,则找到目标节点(稍后再选择更新与否)
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key
                    break;
                p = e;
            }
        }
        // e为和要插入节点的key重复的节点                                    
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            // 只有在onlyIfAbsent参数为false时,才会对该节点value进行更新!!!
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e); // 与LinkedHashMap相关
            // 若对相同key的值进行了更行则返回旧值
            return oldValue;
        }
    }
   	// put操作属于结构性更行,要更行modCount                                         
    ++modCount;
    // 若节点数已经大于阈值(桶数 * 负载因子)则进行resize()
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict); // 与LinkedHashMap相关
    return null;
}

上述过程的流程如下:

在这里插入图片描述

查阅源码可以知道对于 remove 操作,其原理和 put 操作相反即可,没有太多可说的了就。

hash计算

在上面的 put 操作计算新节点要放入的桶号时,有如下代码:

i = (n - 1) & hash

其中 i 就是计算的桶号,而 n 为哈希表中通的个数,hash 为要插入节点 key 的哈希值,而键的哈希值是用如下方法计算得出的:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

也就是说若键为空则哈希值为0,若不空则用本地方法hashCode计算出其哈希值h,并将 hh的高16位 做异或运算,之所以要和其高16位做异或运算,是因为如果当哈希表中的桶数即 n 很小,假设为64,那么 n-1 即为 63(0x111111),这样的值跟 h = key.hashCode() 直接做 & 操作,实际上只使用了哈希值的后 6 位。如果当哈希值的高位变化很大,低位变化很小,这样就很容易造成冲突了,所以这里把高低位都利用起来,从而解决了这个问题。

所以对于一个要插入节点该插入的桶号,其计算过程如下(以桶数为64为例):

在这里插入图片描述

则最终该节点应插入到 53(0x110101)号桶中。由于是要与桶的数量减一 n-1 做与运算,而我们知道桶数 capacity 一定是2的n次方,这就保证 n-1 每位都是 1,所以与运算的结构就取决于要插入节点的 key

resize扩容

扩容的源码如下:

/**
 * 初始化或加倍表大小。
 * 如果为空,则根据字段阈值中保留的初始容量目标进行分配。
 * 否则,因为我们使用的是二次展开的幂,每个bin中的元素必须保持在相同的索引中,
 * 或者在新表中以2的幂次偏移量移动。
 */
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        // 若原容量已经达到最大容量,则无法扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 可扩容的前提则将容量扩容为原来的2倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    // 还没有对表进行过初始化,则设置表的大小为16,设置阈值为 16 * 0.75 = 12
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    // 新的用于扩容的阈值
    threshold = newThr;
    // 创建扩容后的新表
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        // 遍历旧表中的每个桶中的节点,并加入到新表中
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

HashMap 在进行扩容时,使用的 rehash 方式非常巧妙,因为每次扩容都是翻倍,与原来计算 (n-1) & hash 的结果相比,只是多了一个 bit 位,所以节点要么就在原来的位置,要么就被分配到“原位置+旧容量”这个位置

例如,原来的容量为 32,那么应该拿 hash 跟 31(0x11111)做与操作;在扩容扩到了 64 的容量之后,应该拿 hash 跟 63(0x111111)做与操作。新容量跟原来相比只是多了一个 bit 位,假设原来的位置在 23,那么当新增的那个 bit 位的计算结果为 0 时,那么该节点还是在 23;相反,计算结果为 1 时,则该节点会被分配到 23+31 的
桶上。

正是因为这样巧妙的 rehash 方式,保证了 rehash 之后每个桶上的节点数必定小于等于原来桶上的节点数,即保证了 rehash 之后不会出现更严重的冲突,而是一定朝着更好的方向进行发展。

2.8. LinkedHashMap

LinkedHashMap 的实现其实非常简单,他就是在 HashMap.Node 节点的基础上增加了 beforeafter 指针,查看其类图,可以看到它是 HashMap 的子类:

在这里插入图片描述

其内部节点拓展了 HashMap.Node,增加了指向前驱和后继的指针:

static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after;
    Entry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
}

其构造方法及大多数添加查找方法都运行了其父类 HashMap 中的方法,如其构造方法:

public LinkedHashMap(int initialCapacity, float loadFactor) {
    super(initialCapacity, loadFactor);
    accessOrder = false;
}

只不过其增添了若干关于beforeafter 两个指针的操作,将添加到表中的节点通过链表进行连接从而达到根据插入顺序排序的效果,如添加新节点时就会调用将节点连接到链表尾部的操作:

TreeNode<K,V> newTreeNode(int hash, K key, V value, Node<K,V> next) {
    TreeNode<K,V> p = new TreeNode<K,V>(hash, key, value, next);
    linkNodeLast(p); // 将节点连接到尾部
    return p;
}

private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
    LinkedHashMap.Entry<K,V> last = tail;
    tail = p;
    if (last == null)
        head = p;
    else {
        p.before = last;
        last.after = p;
    }
}

集合的源码分析就告一段落吧,基本上除了实现了 SortedMap 接口的排序哈希没有分析外,基本都看差不多了~TreeMap 这种集合底层应用了红黑树,我对红黑树还掌握的不是太好,等以后有时间再详细的学习学习吧~

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值