校招准备:(三):java集合类

Table of Contents

2.集合类原理与比较:

2.1hashmap原理,为什么不是线程安全的?:

2.2concurrenthashmap原理:

2.3集合类接口与实现比较:

2.4hashset原理,set如何去重的?:

2.5treemap原理:

2.6其他集合类的线程安全问题,以及CopyOnWriteArrayList原理:

2.7集合类的遍历方式以及为什么不能在便利中删除元素?

2.8java泛型擦除:


2.集合类原理与比较:

2.1hashmap原理,为什么不是线程安全的?:

Java 8系列之重新认识HashMap

允许一条记录的键值为null,key最好为不可变对象,如果key发生改变,hashcode也变了,则会定位不到对象。
实现为:数组(哈希桶数组,键值对)+链表+红黑树:当链表长度超过8,链表转化成红黑树
使用哈希表存储,采用链地址法解决冲突。每个数组上有一个链表结构,数据被hash后,得到数组下标,把数据放在对应下标元素的链表上。如何hash:hashcode进行高位运算和取余运算,

何时扩容:初始化长度为16,负载因子为0.75,所能若哪的最大数量为16*0.75=12;超过12就会扩容,容量为之前的两倍。长度每次都为2的n次方,常规是把大小设置为素数减少冲突,因为合数会导致某些位失效。这样做的原因是为取模(hashcode&(length-1)=hashcode%length,比如8来说,8-1=7=0111,只会保留后三位,就相当于在求余数)和扩容做优化,为了减少冲突也加入了高位参与运算的过程:hashcode高16位和低16位异或,可以在table的length比较小的时候,高低位都可以参与到hash计算中。

put方法:①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;
②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;
⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。
扩容机制:使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。

jdk1.7同一位置上新元素总会被放在链表的头部位置;采用LIFO方式,即队头插入。这样做的目的是:避免尾部遍历。尾部遍历是为了避免在新列表插入数据时,遍历队尾的位置。因为,直接插入的效率更高。但是在多线程情况下,会出现问题:这种逆序的扩容方式在多线程时有可能出现环形链表,出现环形链表的原因大概是这样的:线程1准备处理节点,线程二把HashMap扩容成功,链表已经逆向排序,那么线程1在处理节点时就可能出现环形链表。

jdk1.8不需要像jdk1.7那样重新计算hash,因为为2的倍数,所以只需要原索引+新增的bit*oldcap,这样的情况下采取新的扩容思路,就不需要倒序遍历了。(具体方式还是有点麻烦,请参考Hashmap实现原理及扩容机制详解


1.7、1.8区别:
引入红黑树的数据结构和扩容的优化,计算索引优化
如何改造成线程安全:
使用concurrentHashMap或者collections.synchronizedMap方法:synchronizedMap将一个非线程安全的Map集合通过同步方法块锁住对象自身包装为线程安全的Map集合

为什么hashmap不是线程安全:漫画:高并发下的HashMap

首先,hashmap并不是为高并发设计的,所以代码中很可能出现线程安全问题。具体的线程安全问题比如:
1.上文提到的,在jdk1.7情况下的循环链表。
2.数据丢失,数据重复。也和扩容有关系(hashmap的线程不安全体现在哪里?

2.2concurrenthashmap原理:

Java并发编程笔记之ConcurrentHashMap原理探究
ConcurrentHashMap在jdk1.8和1.7中的区别

jdk1.7:Segment(继承了reentrantLock,锁分段)数组和多个HashEntry数组组成+链表。当有key却没有value时,将加lock锁,等待value写入再读取。

ConcurrentHashMap 为了提高本身的并发能力,在内部采用了一个叫做 Segment 的结构,一个 Segment 其实就是一个类 Hash Table 的结构,Segment 内部维护了一个链表数组,ConcurrentHashMap 定位一个元素的过程需要进行两次Hash操作,第一次 Hash 定位到 Segment,第二次 Hash 定位到元素所在的链表的头部。因此,这一种结构的带来的副作用是 Hash 的过程要比普通的 HashMap 要长,但是带来的好处是写操作的时候可以只对元素所在的 Segment 进行操作即可,不会影响到其他的 Segment,这样,在最理想的情况下,ConcurrentHashMap 可以最高同时支持 Segment 数量大小的写操作(刚好这些写操作都非常平均地分布在所有的 Segment上),所以,通过这一种结构,ConcurrentHashMap 的并发能力可以大大的提高。

请问ConcurrentHashMap中变量使用final和volatile修饰有什么用呢?其中链表是final的next属性,那么发生删除某个元素,如何实现的?

使用final来实现不变模式(immutable),他是多线程安全里最简单的一种保障方式。因为你拿想改变它也没有机会。不变模式主要通过final关键字来限定的。在JMM中final关键字还有特殊的语义。Final域使得确保初始化安全性(initialization safety)成为可能,初始化安全性让不可变形对象不需要同步就能自由地被访问和共享。使用volatile来保证某个变量内存的改变对其他线程即时可见,在配合CAS可以实现不加锁对并发操作的支持
remove执行的开始就将table赋给一个局部变量tab,将tab依次复制出来,最后直到该删除位置,将指针指向下一个变量。

描述一下ConcurrentHashMap中remove操作,有什么需要注意的?

需要注意如下几点。第一,当要删除的结点存在时,删除的最后一步操作要将count的值减一。这必须是最后一步操作,否则读取操作可能看不到之前对段所做的结构性修改。第二,remove执行的开始就将table赋给一个局部变量tab,这是因为table是volatile变量,读写volatile变量的开销很大。编译器也不能对volatile变量的读写做任何优化,直接多次访问非volatile实例变量没有多大影响,编译器会做相应优化。

HashTable与ConcurrentHashMap有什么区别,描述锁分段技术。

HashTable容器在竞争激烈的并发环境下表现出效率低下的原因,是因为所有访问HashTable的线程都必须竞争同一把锁,那假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。这里“按顺序”是很重要的,否则极有可能出现死锁,在ConcurrentHashMap内部,段数组是final的,并且其成员变量实际上也是final的,但是,仅仅是将数组声明为final的并不保证数组成员也是final的,这需要实现上的保证。这可以确保不会出现死锁,因为获得锁的顺序是固定的。

jdk1.8底层是散列表+红黑树和hashmap,value和next属性都被volatile修饰。key和value都不允许为null。部分加锁(synchronized)和 利用cas(compare and swap)算法实现同步

get方法非阻塞无锁。如果为链表直接读取,如果为红黑树且平衡则直接读取,如果不平衡则加cas防止其他线程更新。

put时

这个put的过程很清晰,对当前的table进行无条件自循环直到put成功,可以分成以下六步流程来概述
1.如果没有初始化就先调用initTable()方法来进行初始化过程
2.如果没有hash冲突就直接CAS插入
3.如果还在进行扩容操作就先进行扩容
4.如果存在hash冲突,就对单个头节点进行synchronized加锁来保证线程安全,这里有两种情况,一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入
5.最后一个如果该链表的数量大于阈值8,就要先转换成黑红树的结构,break再一次进入循环
6.如果添加成功就调用addCount()方法统计size,并且检查是否需要扩容

2.3集合类接口与实现比较:

(来自百度百科)
Collection<--List<--Vector<--Stack
Collection<--List<--ArrayList
Collection<--List<--LinkedList
Collection<--Set<--HashSet
Collection<--Set<--HashSet<--LinkedHashSet
Collection<--Set<--SortedSet<--TreeSet

空值:关于List,Set,Map能否存储null

list(arraylist和linkedlist)都可以传入大量null。

map:HashMap中最多只有一个key == null的节点。TreeMap的put方法会调用compareTo方法,对象为null时,会报空指针错。HashTable,ConcurrentHashMap无论是key为null,还是value为null,都会报错。

set:HashSet底层是HashMap,所以它的put()如下,也只能有一个null。treeset不能添加null。

treeMap传入的key必须实现comparable接口或者传入自定义的comparator
hashset,treeset区别时间复杂度。
HashSet是基于散列表实现的,元素没有顺序;add、remove、contains方法的时间复杂度为O(1)。(contains为false时,就直接往集合里存)
总结:查 0(1) 增 0(1) 删0(1)

TreeSet是基于树实现的(红黑树),元素是有序的;add、remove、contains方法的时间复杂度为O(log (n))(contains为false时,插入前需要重新排序)。
总结:查 0(log n) 增 0(log n) 删0(log n)

2.4hashset原理,set如何去重的?:

为了代码复用,hashset底层使用的hashmap。present是用static final修饰的常量。(用transient关键字标记的成员变量不参与序列化过程。)

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

public boolean contains(Object o) {
return map.containsKey(o);
}

可以看到add的源码,就可以参考map.put的流程。

去重就和hashmap去重方式一样,通过hash和equal来比较。

但是Treemap和treeset则是通过对象实现comparable接口或者在tree***的构造方法中传递compartor对象来进行比较去重。不需要使用hash和equals

Java中的TreeMap、Comparable、Comparator

2.5treemap原理:

TreeMap:我是看不懂,只能回答个红黑树。
几棵树的相关比较我放到了数据库篇。

2.6其他集合类的线程安全问题,以及CopyOnWriteArrayList原理:

获取线程安全的List和Set以及Map

//对于List接口
Collections.synchronizedList(new ArrayList<>());
Collections.synchronizedList(new LinkedList<String>())
CopyOnWriteArrayList<Object> objects = new CopyOnWriteArrayList<>();
ConcurrentLinkedQueue......

//对于Set接口
Collections.synchronizedSet(new HashSet<>());
CopyOnWriteArraySet<Object> objects1 = new CopyOnWriteArraySet<>();

//对于Map接口,HashTable和ConcurrentHashMap都是线程安全的
Collections.synchronizedMap(new HashMap<>());

ArrayList和CopyOnWriteArrayList

  • 实现了List接口
  • 内部持有一个ReentrantLock lock = new ReentrantLock();
  • 底层是用volatile transient声明的数组 array
  • 读写分离,写时复制出一个新的数组,完成插入、修改或者移除操作后将新数组赋值给array

增删改都需要获得锁,并且锁只有一把,而读操作不需要获得锁,支持并发。为什么增删改中都需要创建一个新的数组,操作完成之后再赋给原来的引用?这是为了保证get的时候都能获取到元素,如果在增删改过程直接修改原来的数组,可能会造成执行读操作获取不到数据。

CopyOnWriteArrayList为什么并发安全且性能比Vector好

我知道Vector是增删改查方法都加了synchronized,保证同步,但是每个方法执行的时候都要去获得锁,性能就会大大下降,而CopyOnWriteArrayList 只是在增删改上加锁,但是读不加锁,在读方面的性能就好于Vector,CopyOnWriteArrayList支持读多写少的并发情况。

2.7其他集合类的扩容机制和初始化容量:

arraylist

  1. 不指定ArrayList的初始容量,在第一次add的时候会把容量初始化为10个,这个数值是确定的;
  2. ArrayList的扩容时机为add的时候容量不足,扩容的后的大小为原来的1.5倍,扩容需要拷贝以前数组的所有元素到新数组。

vector初始为10每次扩大为2倍

hashtable初始化为11,每次扩大为2*n+1,负载因子为0.75

hashset和hashmap相同。

2.8集合类的遍历方式以及为什么不能在便利中删除元素?

Java中集合类遍历性能

1)传统的for循环遍历基于计数器
遍历者自己在集合外部维护一个计数器,然后依次读取每一个位置的元素,直到读取到最后一个元素后。主要就是需要按元素的位置来读取元素,适合于遍历顺序存储的数据结构;

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

2)迭代器遍历Iterator
Iterator本来是OO的一个设计模式,主要目的就是屏蔽不同数据集合的特点,统一遍历集合的接口。从结构上可以看出,迭代器模式在客户与容器之间加入了迭代器角色。迭代器角色的加入,就可以很好的避免容器内部细节的暴露,而且也使得设计符号“单一职责原则”;
每一个具体实现的数据集合,一般都需要提供相应的Iterator。相比于传统for循环,Iterator取代了显式的遍历计数器。所以基于顺序存储集合的Iterator可以直接按位置访问数据。而基于链式存储集合的Iterator,正常的实现都是需要保存当前遍历的位置,然后根据当前位置来向前或者向后移动指针

迭代器模式的写法如下:

Iterator iterator = list.iterator();
while (iterator.hasNext()) {
    iterator.next();
}

3)foreach循环遍历
foreach内部也是采用了Iterator的方式实现,根据反编译的字节码可以发现,foreach内部也是采用了Iterator的方式实现,只不过Java编译器帮我们生成了这些代码
优点:代码简洁,不易出错
缺点:只能做简单的遍历,不能在遍历过程中操作(删除、替换)数据集合

for (ElementType element : list) {
}

使用场景

1)传统的for循环遍历基于计数器
适用于遍历顺序存储集合,读取性能比较高如ArrayList;不适用于遍历链式存储的集合如LinkedList,时间复杂度太大;

2)迭代器遍历Iterator
对于顺序存储的数据结构,如果不是太在意时间,推荐选择此方式,毕竟代码更加简洁,也防止了Off-By-One的问题。
链式存储:推荐此种遍历方式,平均时间复杂度降为O(n)

3)foreach循环遍历
foreach让代码更加简洁,缺点就是遍历过程中不能操作数据集合(删除等),所以有些场合不使用。而且它本身就是基于Iterator实现的,但是由于类型转换的问题,所以会比直接使用Iterator慢一点,性能上相差不大

对JAVA集合进行遍历删除时务必要用迭代器

阿里巴巴java开发手册:【强制】不要在foreach循环里进行元素的remove/add操作。remove元素请使用Iterator方式,如果并发操作,需要对Iterator对象加锁。

基本上ArrayList采用size属性来维护自已的状态,而Iterator采用cursor来来维护自已的状态。

当size出现变化时,cursor并不一定能够得到同步,除非这种变化是Iterator主动导致的。

从上面的代码可以看到当Iterator.remove方法导致ArrayList列表发生变化时,他会更新cursor来同步这一变化。但其他方式导致的ArrayList变化,Iterator是无法感知的。ArrayList自然也不会主动通知Iterator们,那将是一个繁重的工作。Iterator到底还是做了努力:为了防止状态不一致可能引发的无法设想的后果,Iterator会经常做checkForComodification检查,以防有变。如果有变,则以异常抛出,所以就出现了上面的异常。

 如果对正在被迭代的集合进行结构上的改变(即对该集合使用add、remove或clear方法),那么迭代器就不再合法(并且在其后使用该迭代器将会有ConcurrentModificationException异常被抛出).

2.9java泛型擦除:

Java中泛型是类型擦除的:java 编译后的字节码中已经没有泛型的任何信息,在编译后所有的泛型类型都会做相应的转化,转化如下:
List<String>、List<T> 擦除后的类型为 List。
List<String>[]、List<T>[] 擦除后的类型为 List[]。
List<? extends E>、List<? super E> 擦除后的类型为 List<E>。
List<T extends Serialzable & Cloneable> 擦除后类型为 List<Serializable>。
Java 为什么这么处理呢?有以下两个原因:
1.避免 JVM 的大换血。如果 JVM 将泛型类型延续到运行期,那么到运行期时 JVM 就需要进行大量的重构工作了,提高了运行期的效率。2.版本兼容。 在编译期擦除可以更好地支持原生类型(Raw Type)。

明白了 Java 泛型是类型擦除的,下面的问题就很好理解了。

(1) 泛型的 class 对象是相同的
每个类都有一个 class 属性,泛型化不会改变 class 属性的返回值,例如:

public static void main(String[] args) {
    List<String> ls = new ArrayList<String>();
    List<Integer> li = new ArrayList<Integer>();
    System.out.println(ls.getClass() == li.getClass());
}
 代码返回值为 true,原因很简单,List<String> 和 List<Integer> 擦除后的类型都是 List。
(2) 泛型数组初始化时不能声明泛型类型
如下代码编译时通不过:
List<String>[] list = new List<String>[];
        在这里可以声明一个带有泛型参数的数组,但是不能初始化该数组,因为执行了类型擦除操作后,List<Object>[] 与 List<String>[] 就是同一回事了,编译器拒绝如此声明。
(3) instanceof 不允许存在泛型参数
以下代码不能通过编译,原因一样,泛型类型被擦除了。
List<String> list = new ArrayList<String>();
System.out.println(list instanceof List<String>);
        错误信息如下:
Cannot perform instanceof check against parameterized type List<String>. Use the form List<?> instead since further generic type information will be erased at runtime

Java泛型之类型擦除:Java泛型依赖编译器实现,只存在于编译期,JVM中没有泛型的概念;那么,编译器做了什么工作呢?(1)set方法是编译期检查;(2)get方法的返回值进行转型,编译器插入了一个checkcast语句。

java泛型(二)、泛型的内部原理:类型擦除以及类型擦除带来的问题:Java中的泛型基本上都是在编译器这个层次来实现的。在生成的Java字节码中是不包含泛型中的类型信息的。使用泛型的时候加上的类型参数,会在编译器在编译的时候去掉。这个过程就称为类型擦除。类型变量被擦除(crased)时,使用其限定类型(无限定的变量用Object)替换。
泛型出现的原因,就是为了解决类型转换的问题。

类型擦除与多态的冲突和解决方法:
 

class DateInter extends Pair<Date> {
	@Override
	public void setValue(Date value) {
		super.setValue(value);
	}
	@Override
	public Date getValue() {
		return super.getValue();
	}
}

类型擦除后,父类的的泛型类型全部变为了原始类型Object,先来分析setValue方法,父类的类型是Object,而子类的类型是Date,参数类型不一样,这如果实在普通的继承关系中,根本就不会是重写,而是重载。但是实际不会出现这个问题,因为jvm帮我们解决了这个问题:JVM采用了一个特殊的方法,桥方法来完成重写。

注意:1.泛型类中的静态方法和静态变量不可以使用泛型类所声明的泛型类型参数,因为泛型类中的泛型参数的实例化是在定义对象的时候指定的,而静态变量和静态方法不需要使用对象来调用。对象都没有创建,如何确定这个泛型参数是何种类型,所以当然是错误的。2.不能实例化泛型类型和数组,可以用反射构造泛型对象和数组。。3.注意泛型擦除后的方法可能会和其他方法冲突:public boolean equals(T value)-》boolean equals(Object)会覆盖父类Object中的equals方法4.不能抛出也不能捕获泛型类的对象。事实上,泛型类扩展Throwable都不合法。不能再catch子句中使用泛型变量

Java 泛型,你了解类型擦除吗?:泛型按照使用情况可以分为 3 种:1.泛型类。2.泛型方法。3.泛型接口。

public class Test1<T>{

	public  void testMethod(T t){
		System.out.println(t.getClass().getName());
	}
	public  <T> T testMethod1(T t){
		return t;
	}
}

 List<Sub> lsub = new ArrayList<>();
List<Base> lbase = lsub;
编译器不会让它通过的。Sub 是 Base 的子类,不代表 List<Sub>和 List<Base>有继承关系。

通配符的出现是为了指定泛型中的类型范围。通配符有 3 种形式。1.<?>被称作无限定的通配符。对象丧失了 add() 方法的功能2.<? extends T>被称作有上限的通配符,丧失了写操作的能力。3.<? super T>被称作有下限的通配符,有一定程度的写操作的能力。Collection<? super Sub> para;para.add(new Sub());//编译通过 para.add(new Base());//编译不通过 

利用反射,我们可以绕过泛型限制。

List<Integer> ls = new ArrayList<>();
ls.add(23);
Method method = ls.getClass().getDeclaredMethod("add",Object.class);
method.invoke(ls,"test");
method.invoke(ls,42.9f);

注意事项:1.泛型类或者泛型方法中,不接受 8 种基本数据类型。2.Java 不能创建具体类型的泛型数组。但是经过测试:

        ArrayList[] list= new ArrayList[5];
        list[1]=new ArrayList();
        list[1].add(123);
        System.out.println(list[1].get(0));
        List<?>[] li3 = new ArrayList<?>[10];
        li3[1] = list[1];
        System.out.println(li3[1].get(0));
        //li3[1].add("123");

可以创建没有泛型的数组,泛型为?的只能使用不能添加数据。

为什么泛型类无法继承自 Throwable

假设当前我们有两个类 —— SomeException<Integer> 类和 SomeException<String> 类,它们都是继承自 Throwable 类的。而上述代码中的 doSomeStuff() 方法可能是抛出 SomeException<Integer> 异常或 SomeException<String> 异常,我们针对不同的异常做出不同的逻辑操作。这样看似完全没有问题,但是熟悉泛型的小伙伴都知道,还有一种叫做类型擦除机制的存在,何为类型擦除?此处不扩展了,通俗点说:java 中不存在泛型代码,泛型代码是写给我们看的,编译器会将泛型代码转换成普通类代码。所以无论是 SomeException<Integer> 或者是 SomeException<String> 经过编译器的类型擦除后都将会变成 SomeException。故上述代码是不可以运行的,因为当代码抛出异常时编译器是无法判断走哪个 catch 分支的,所以 java 为了避免这样的问题出现,故泛型类是无法继承自 Throwable 类的。

2.9 Iterator和ListIterator区别

对List来说,你也可以通过listIterator()取得其迭代器,两种迭代器在有些时候是不能通用的,Iterator和ListIterator主要区别在以下方面:

1. ListIterator有add()方法,可以向List中添加对象,而Iterator不能

2. ListIterator和Iterator都有hasNext()和next()方法,可以实现顺序向后遍历,但是ListIterator有hasPrevious()和previous()方法,可以实现逆向(顺序向前)遍历。Iterator就不可以。

3. ListIterator可以定位当前的索引位置,nextIndex()和previousIndex()可以实现。Iterator没有此功能。

4. 都可实现删除对象,但是ListIterator可以实现对象的修改,set()方法可以实现。Iierator仅能遍历,不能修改。

因为ListIterator的这些功能,可以实现对LinkedList等List数据结构的操作。

基础篇-Iterator和Iterable的区别以及使用

迭代器(iterator)是一种对象,它能够用来遍历标准模板库容器中的部分或全部元素,每个迭代器对象代表容器中的确定的地址。迭代器修改了常规指针的接口,所谓迭代器是一种概念上的抽象:那些行为上像迭代器的东西都可以叫做迭代器。然而迭代器有很多不同的能力,它可以把抽象容器和通用算法有机的统一起来。

实现了iterator接口就可以实现使用迭代器

为什么数组可以用foreach遍历,却不能转换为Iterable?

实现了java.lang.Iterable接口的东西可以用for-each去遍历,但是能用for-each去遍历的不一定实现了该接口,比如数组就是。

for-each实现原理如下

关于数组,Java语言规范中对其有详细说明(Chapter 10. Arrays),它是java.lang.Object的直接子类,同时实现了java.lang.Cloneable和java.io.Serializable接口,所以它不能转换为java.lang.Iterable。

遍历map的四种方法

 Map<Integer, Integer> map = new HashMap<Integer, Integer>();
 for (Map.Entry<Integer, Integer> entry : map.entrySet())
 {  
      System.out.println("Key = " + entry.getKey() + ", Value = " + entry.getValue());
 }  

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值