android面试多线程,掌握Android和Java线程原理下,这份1307页Android面试全套真题解析

====================================================================

知道了如何解决线程安全问题,接下来就要考虑性能问题了,毕竟多线程的使用,就是为了提高性能,如果使用多线程不能将性能发挥出来,就是很大的浪费了。在并发的优化上,无锁的性能肯定是最好的,但是很多时候我们又不得不加锁,在加锁的方案中,有忙阻塞等待如自旋锁,以及休眠等待,这两种加锁的方式,并不存在哪一种性能更好,需要根据并发的数据进行选择。下面就详细说一下这三种方案如何进行优化。

无锁


我们已经知道协程和本地线程存储可以实现无锁的多线程,还有其他优化方案吗?有,锁消除和偏向锁。

锁消除

我们可以根据经验和业务分析判断是否会有产生临界区脏数据的可能,如果没有这个可能,则可以消除锁。业务中的锁我们可以自己消除,但是虚拟机或者Java内部库中的锁我们就没法消除了,什么是Java内部的锁呢?比如我们常用的StringBuffer.append()函数中就有同步代码块。内部库的锁可以通过编译时消除,比如JVM的逃逸分析技术,在编译代码的过程中,会判断对象是否逃逸,如果没有逃逸,逃逸指的时对象可能会被其他线程使用到,就说明没有线程安全的可能性,于是会消除锁。

偏向锁

偏向锁并不是不加锁,而是只加一次锁,只要一个线程获得了偏向锁,即使当这个线程退出临界区后,这个锁依然会“偏向”这个线程,当这个线程再次要进入临界区是,就可以直接进入临界区,不需要重新加锁的过程。

这里介绍一下JVM的偏向锁的实现,但我们需要先了解一下Java类的对象头的知识点。当一个Java类会被解析成class对象,并加载在内存中后,这个对象由三部分组成:对象头、实例数据和对齐填充,对象头又由markWord和Klass指针组成。

  • Mark Word:对象的 Mark Word 部分占 4 个字节(32位系统),包含一些的标记位,比如轻量级锁、偏向锁标记等等

  • Klass Pointer:Class 对象指针占是 4个字节(32位系统),指向的位置是对象对应的 Class 对象的内存地址

  • 实例数据:这里面包括了对象的所有成员变量

  • 对齐填充:最后一部分是对齐填充的字节,按 8 个字节填充

我们主要看一下Mark Word,它的结构如下。

可以看到markwork实际上大部分数据都是用来记录锁相关的信息的,比如锁状态,偏向锁等等。

了解了markwork,我们接着了解什么是偏向锁,偏向锁中由一个ThreadId字段,这个字段如果是空的,某个线程第一次获取锁的时候,就将自身的ThreadId写入到锁的ThreadId字段内,将markwork内的是否偏是向锁的状态位置1,这样下次获取锁的时候,直接检查ThreadId是否和自身线程Id一致,如果一致,则认为当前线程已经获取了锁,因此不需再次获取锁。

忙等待阻塞锁


自旋锁

忙等待锁就是自旋锁,它通过循环不断的获取锁,如果在自旋的途中,获取锁成功,则进入临界区,不成功就一直自旋。关于自旋锁的实现,前面CAS和TAS都有实现过。自旋锁的优点是不需要让线程陷入休眠,避免了线程切换事件,但是如果自旋过久,会浪费CPU的资源,这个时候让线程陷入休眠是一种更好的选择。所以自旋次数的选择就比较重要了,在JDK1.6之前,Synchronized再获取锁时,会先自旋10次,如果不成功就会升级成重量级锁,让线程陷入休眠。

自适应自旋锁

JDK1.6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,虚拟机就会变得越来越“聪明”了。

休眠阻塞锁


休眠阻塞锁,顾名思义,就是在线程获取锁后,让线程陷入休眠,它一般被称为重量级锁。这种锁需要管程的介入,线程的休眠和唤醒也会比较耗费性能,既然都已经是重量级锁了,还有优化的空间吗?还是有的。

细化锁的粒度

我们可以通过减少同步的代码块数量来优化锁的性能,比如将Synchronize锁住整个方法改成只锁住方法内可能会产生线程安全的代码。

粗化锁的粒度

粗化锁的粒度在某些场景也能优化锁的性能,比如某个方法内有好几个锁,我们可以将这些锁都可以一个锁,来减少加锁和释放锁的损耗。JVM虚拟机也会通过粗化锁的粒度来优化锁性能的,比如StringBuffer.append()方法内部是由同步代码快的,如果我们多次连续调用append方法,JVM会将这些append方法内部的锁消除,并在连续append方法间加一把锁。

增加锁的数量

如果大量的并发线程都用同一把锁,那么所有的线程始终同时只有一个线程能访问临界区,其他的线程都在等待,这样也会造成性能的浪费。我们可以通过增加锁的数量,将临界区不同的区域分别加锁,这样就可以让更多的线程对临界区进行访问。

锁优化案例


Synchronized

在JDK1.6版本上,HotSpot虚拟机开发团队花费了很大的的精力去实现和优化各种锁优化技术。Synchronized就是优化的重点之一,Synchronized会先使用偏向锁加锁,如果访问临界区的线程超过了一个,就会升级成轻量级锁,轻量级锁通过互斥来实现加锁的过程,只要多个线程没有产生竞争条件,就可以通过互斥进行加锁,当有多个线程同时竞争锁时,互斥就没用了,所以Synchronized会从轻量级锁升级成重量级锁,Synchronized的重量级锁在获取锁的过程中,也会先通过自旋的方式获取锁,如果自旋失败,最后才采用管程,将线程陷入休眠。

可以看到,Synchronized是从偏向锁,到轻量级锁,然后到自旋锁,最后休眠阻塞锁这样一个不断升级的过程。

ConcurrentHashMap

ConcurrentHashMap在jdk1.7之前采用分断锁来对锁进行了优化,分断锁是通过增加锁的数量来达到优化的目的。我们看一下它的实现。

1.7版本的ConcurrentHashMap主要由两部分组成:Segments和HashEntry。Segments存方HashEntry,HashEntry存放我们的Key,Value。他们的关系实现如下:

public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>

implements ConcurrentMap<K, V>, Serializable {

final int segmentMask; //segments的掩码值

final int segmentShift; //segments的偏移量

final Segment<K,V>[] segments;

……

}

static final class Segment<K,V> extends ReentrantLock implements Serializable {

private static final long serialVersionUID = 2249069246763182397L;

transient volatile int count;

transient int modCount;

transient int threshold;

transient volatile HashEntry<K,V>[] table;

final float loadFactor; //扩容负载因子

……

}

我们接着看ConcurrentHashMap的put和get方法是如何保证线程安全的。

put方法的实现

public V put(K key, V value) {

Segment<K,V> s;

if (value == null)

throw new NullPointerException();

//获取key的hash值

int hash = hash(key);

//hash值右移segmentShift位与段掩码进行位运算,定位segment

int j = (hash >>> segmentShift) & segmentMask;

if ((s = (Segment<K,V>)UNSAFE.getObject

(segments, (j << SSHIFT) + SBASE)) == null)

s = ensureSegment(j);

return s.put(key, hash, value, false);

}

ConcurrentHashMap的put方法获主要是获取获取key值的哈希函数,然后根据hash获取Segment段,接着调用Segment的put方法,它的实现如下。

V put(K key, int hash, V value, boolean onlyIfAbsent) {

//对segment加锁

lock();

try {

int c = count;

if (c++ > threshold) //如果超过再散列的阈值

rehash(); //执行再散列,table 数组的长度将扩充一倍

HashEntry<K,V>[] tab = table;

//把散列码值与 table 数组的长度减 1 的值相“与”

//得到该散列码对应的 table 数组的下标值

int index = hash & (tab.length - 1);

//找到散列码对应的具体的那个桶

HashEntry<K,V> first = tab[index];

HashEntry<K,V> e = first;

while (e != null && (e.hash != hash || !key.equals(e.key)))

e = e.next;

V oldValue;

if (e != null) { //如果键值对以经存在

oldValue = e.value;

if (!onlyIfAbsent)

e.value = value; // 设置 value 值

}

else { //键值对不存在

oldValue = null;

++modCount; //添加新节点到链表中,modCont 要加 1

// 创建新节点,并添加到链表的头部

tab[index] = new HashEntry<K,V>(key, hash, first, value);

count = c; //写 count 变量

}

return oldValue;

} finally {

unlock(); //解锁

}

}

get方法的实现

在接着看get方法的实现

public V get(Object key) {

Segment<K,V> s;

HashEntry<K,V>[] tab;

int h = hash(key);

long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;

//先定位Segment,再定位HashEntry

if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&

(tab = s.table) != null) {

for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile

(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);

e != null; e = e.next) {

K k;

if ((k = e.key) == key || (e.hash == h && key.equals(k)))

return e.value;

}

}

return null;

}

由于get并不会导致线程安全问题,所以直接从Segment取HashEntry就行了,并不用加锁。

在JDK1.8中,已经放弃了分段锁的方式,Segment数组也没有了。所有的HashEntry都存放在Node数组中,并且采用CAS+Synchronize的加锁方式,在put方法中,会先判断所存放的Node的位置是否有值,即是否会产生HASH冲突,如果没值,直接采用CAS加锁,存放HashEntry,如果有,则采用Synchronize加锁后再进行存放逻辑。有兴趣的可以去看JDK1.8中ConcurrentHashMap的实现,这里就不说了。

Synchronize实现原理

=============================================================================

通过前面的学习,我们已经对多线程的基础知识掌握充分了,靠着这些基础知识,可以挑战更高难度的Boss了,那么接下来就看一下Synchronize在JVM和Art虚拟机中的实现源码。

JVM中的实现原理


我们平常在使用Synchronize进行加锁时,主要有两个方式,一种是锁住整个方法,将Synchronize字段加载方法名上,第二种是锁住一段代码,将Synchronize用在代码中间。

public void f() {

synchronized (this) {

this.hashCode();

}

}

synchronized public void f() {

this.hashCode();

}

当Synchronize修饰方法时,会在方法的访问标志中添加ACC_SYNCHRONIZED

public synchronized void f();

descriptor: ()V

flags: ACC_PUBLIC, ACC_SYNCHRONIZED // 访问标志

Code:

stack=1, locals=1, args_size=1

0: aload_0

1: invokevirtual #2 // Method java/lang/Object.hashCode:()I

4: pop

5: return

LineNumberTable:

line 3: 0

line 4: 5

LocalVariableTable:

Start Length Slot Name Signature

0 6 0 this LMain;

Synchronize放在代码段中时,会在代码的字节码指令被锁住的代码段前后加入monitorenter和monitorexit标志。

public void f();

Code:

0: aload_0

1: dup

2: astore_1

3: monitorenter // synchronized 入口

4: aload_0

5: invokevirtual #4; //Method java/lang/Object.hashCode:()I

8: pop

9: aload_1

10: monitorexit // synchronized 正常出口

11: goto 19

14: astore_2

15: aload_1

16: monitorexit // synchronized 异常出口

17: aload_2

18: athrow

19: return

不管是在方法的访问访问标志的设置ACC_SYNCHRONIZED,还是在方法字节码指令的前后加入monitorenter和monitorexit,其实都是为了告诉解释器,这段代码需要进入管程。管程在前面的知识中提到过,我们可以把它理解成是专门帮我们管理线程并发的程序。

加锁流程

我们先看看Hotspot虚拟机是如何进入管程的。

//文件->\src\share\vm\interpreter\bytecodes.cpp

void Bytecodes::initialize() {

if (_is_initialized) return;

assert(number_of_codes <= 256, “too many bytecodes”);

// Java bytecodes

// bytecode bytecode name format wide f. result tp stk traps

……

def(_iload , “iload” , “bi” , “wbii” , T_INT , 1, false);

……

def(_istore , “istore” , “bi” , “wbii” , T_VOID , -1, false);

……

def(_iastore , “iastore” , “b” , NULL , T_VOID , -3, true );

……

def(_iadd , “iadd” , “b” , NULL , T_INT , -1, false);

……

def(_monitorenter , “monitorenter” , “b” , NULL , T_VOID , -1, true );

def(_monitorexit , “monitorexit” , “b” , NULL , T_VOID , -1, true );

// platform specific JVM bytecodes

pd_initialize();

……

// initialization successful

_is_initialized = true;

}

Hotspot虚拟机在字节码初始化时,会将所有字节码对应的方法封装定义好,可以看到JVM虚拟机最多支持256个字节码。接着看一下解析函数中时如何处理monitorenter的

//文件->\src\share\vm\interpreter\bytecodeInterpreter.cpp

void BytecodeInterpreter::run(interpreterState istate) {

……

switch (opcode)

{

……

CASE(_iload):

CASE(_fload):

SET_STACK_SLOT(LOCALS_SLOT(pc[1]), 0);

UPDATE_PC_AND_TOS_AND_CONTINUE(2, 1);

……

CASE(_istore):

CASE(_fstore):

SET_LOCALS_SLOT(STACK_SLOT(-1), pc[1]);

UPDATE_PC_AND_TOS_AND_CONTINUE(2, -1);

……

CASE(_return): {

// Allow a safepoint before returning to frame manager.

SAFEPOINT;

goto handle_return;

}

……

/* monitorenter and monitorexit for locking/unlocking an object */

CASE(_monitorenter): {

//1,获取对象头,这个oop就是前面提到过的包含了markwork和klass的对象头

oop lockee = STACK_OBJECT(-1);

// derefing’s lockee ought to provoke implicit null check

CHECK_NULL(lockee);

//线程栈中最后一个锁,这里是一个屏障

BasicObjectLock* limit = istate->monitor_base();

// 2,在私有线程栈找到一个最近并且空闲的锁,BasicObjectLock是锁的基类

BasicObjectLock* most_recent = (BasicObjectLock*) istate->stack_base();

BasicObjectLock* entry = NULL;

//如果获取的空闲所不是最后一个锁,说明锁可用

while (most_recent != limit ) {

//如果获取的锁中的对象头和当前的对象头一致,说明这个锁被分配给了这个对象,most_recent记录就不用加1,如果不是,说明这个锁没被使用most_recent加1

if (most_recent->obj() == NULL) entry = most_recent;

else if (most_recent->obj() == lockee) break;

most_recent++;

}

if (entry != NULL) {

//3,将对象头赋值给获取到的锁

entry->set_obj(lockee);

//4,创建一个无锁的markword头

markOop displaced = lockee->mark()->set_unlocked();

entry->lock()->set_displaced_header(displaced);

//5,通过cas将无锁的markword赋值给lockee对象头

if (Atomic::cmpxchg_ptr(entry, lockee->mark_addr(), displaced) != displaced) {

// 判断是否是锁重入,如果是重入,则不需要再次加锁

if (THREAD->is_lock_owned((address) displaced->clear_lock_bits())) {

entry->lock()->set_displaced_header(NULL);

} else {

//6,执行加锁逻辑

CALL_VM(InterpreterRuntime::monitorenter(THREAD, entry), handle_exception);

}

}

UPDATE_PC_AND_TOS_AND_CONTINUE(1, -1);

} else {

istate->set_msg(more_monitors);

UPDATE_PC_AND_RETURN(0); // Re-execute

}

}

CASE(_monitorexit): {

//1,获取对象头

oop lockee = STACK_OBJECT(-1);

CHECK_NULL(lockee);

BasicObjectLock* limit = istate->monitor_base();

BasicObjectLock* most_recent = (BasicObjectLock*) istate->stack_base();

while (most_recent != limit ) {

if ((most_recent)->obj() == lockee) {

BasicLock* lock = most_recent->lock();

//2,获取对象头中的mardword头

markOop header = lock->displaced_header();

most_recent->set_obj(NULL);

// If it isn’t recursive we either must swap old header or call the runtime

if (header != NULL) {

if (Atomic::cmpxchg_ptr(header, lockee->mark_addr(), lock) != lock) {

// restore object for the slow case

most_recent->set_obj(lockee);

//3,执行释放锁的逻辑

CALL_VM(InterpreterRuntime::monitorexit(THREAD, most_recent), handle_exception);

}

}

UPDATE_PC_AND_TOS_AND_CONTINUE(1, -1);

}

most_recent++;

}

// Need to throw illegal monitor state exception

CALL_VM(InterpreterRuntime::throw_illegal_monitor_state_exception(THREAD), handle_exception);

ShouldNotReachHere();

}

……

return;

}

可以看到虚拟机的解释器解析字节码指令的本质,是一个很长的switch函数,解析到的所有的字节码,如load,add,return,monitorenter等等都有对应的处理逻辑。我们先看monitorenter的处理逻辑,它主要做了这几件事情:

  1. 获取对象头,一个对象头由markword(数据)和klass(方法指针)组成

  2. 获取一个可用锁,这里用到了享元模式来复用锁达到优化的目的

  3. 获取对象头中的markword,并通过cas操作初始化锁状态

  4. 调用InterpreterRuntime::monitorenter进行加锁操作

接着看InterpreterRuntime::monitorenter函数

//文件–>\src\share\vm\interpreter\interpreterRuntime.cpp

IRT_ENTRY_NO_ASYNC(void, InterpreterRuntime::monitorenter(JavaThread* thread, BasicObjectLock* elem))

#ifdef ASSERT

thread->last_frame().interpreter_frame_verify_monitor(elem);

#endif

if (PrintBiasedLockingStatistics) {

Atomic::inc(BiasedLocking::slow_path_entry_count_addr());

}

Handle h_obj(thread, elem->obj());

assert(Universe::heap()->is_in_reserved_or_null(h_obj()),

“must be NULL or an object”);

//判断虚拟机是否开启了偏向锁

if (UseBiasedLocking) {

// 偏向锁加锁

ObjectSynchronizer::fast_enter(h_obj, elem->lock(), true, CHECK);

} else {

//自旋锁或重量级锁加锁

ObjectSynchronizer::slow_enter(h_obj, elem->lock(), CHECK);

}

assert(Universe::heap()->is_in_reserved_or_null(elem->obj()),

“must be NULL or an object”);

#ifdef ASSERT

thread->last_frame().interpreter_frame_verify_monitor(elem);

#endif

IRT_END

monitorenter主要根据虚拟机是否开启偏向锁来进行偏向锁加锁,如果没开启,则进行自旋锁或重量级锁加锁。先看偏向锁的加锁流程,它的实现在fast_enter函数。

偏向锁加锁流程

//文件–>\src\share\vm\runtime\synchronizer.cpp

void ObjectSynchronizer::fast_enter(Handle obj, BasicLock* lock, bool attempt_rebias, TRAPS) {

//判断是否开启了偏向锁

if (UseBiasedLocking) {

//安全检查

if (!SafepointSynchronize::is_at_safepoint()) {

//偏向锁测序或者重偏向逻辑

BiasedLocking::Condition cond = BiasedLocking::revoke_and_rebias(obj, attempt_rebias, THREAD);

if (cond == BiasedLocking::BIAS_REVOKED_AND_REBIASED) {

return;

}

} else {

assert(!attempt_rebias, “can not rebias toward VM thread”);

BiasedLocking::revoke_at_safepoint(obj);

}

assert(!obj->mark()->has_bias_pattern(), “biases should be revoked by now”);

}

//如果没有开启偏向锁,还是会走重量级锁的加锁流程

slow_enter (obj, lock, THREAD) ;

}

fast_enter的关键流程在revoke_and_rebias函数中实现,函数中的逻辑主要如下:

  1. 判断markwork是否为偏向锁状态,也就是偏向锁标志位是否为 1,如果为是偏向锁状态,进入下一步检测,如果不是,直接通过CAS进行偏向锁加锁,加锁成功后就可进入临界区执行临界区的字节码;

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数Android工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
img
img
img
img
img
img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且后续会持续更新

如果你觉得这些内容对你有帮助,可以添加V获取:vip204888 (备注Android)
img

最后

上面这些公司都是时下最受欢迎的互联网大厂,他们的职级、薪资、福利也都讲的差不多了,相信大家都是有梦想和野心的人,心里多少应该都有些想法。

也相信很多人也都在为即将到来的金九银十做准备,也有不少人的目标都是这些公司。

我这边有不少朋友都在这些厂工作,其中也有很多人担任过面试官,上面的资料也差不多都是从朋友那边打探来的。除了上面的信息,我这边还有这些大厂近年来的面试真题及解析,以及一些朋友出于兴趣和热爱一起整理的Android时下热门知识点的学习资料

部分文件:


本文已被CODING开源项目:《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》收录

一个人可以走的很快,但一群人才能走的更远。如果你从事以下工作或对以下感兴趣,欢迎戳这里加入程序员的圈子,让我们一起学习成长!

AI人工智能、Android移动开发、AIGC大模型、C C#、Go语言、Java、Linux运维、云计算、MySQL、PMP、网络安全、Python爬虫、UE5、UI设计、Unity3D、Web前端开发、产品经理、车载开发、大数据、鸿蒙、计算机网络、嵌入式物联网、软件测试、数据结构与算法、音视频开发、Flutter、IOS开发、PHP开发、.NET、安卓逆向、云计算

升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!**

由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且后续会持续更新

如果你觉得这些内容对你有帮助,可以添加V获取:vip204888 (备注Android)
[外链图片转存中…(img-jjUslt8D-1712235931356)]

最后

上面这些公司都是时下最受欢迎的互联网大厂,他们的职级、薪资、福利也都讲的差不多了,相信大家都是有梦想和野心的人,心里多少应该都有些想法。

也相信很多人也都在为即将到来的金九银十做准备,也有不少人的目标都是这些公司。

我这边有不少朋友都在这些厂工作,其中也有很多人担任过面试官,上面的资料也差不多都是从朋友那边打探来的。除了上面的信息,我这边还有这些大厂近年来的面试真题及解析,以及一些朋友出于兴趣和热爱一起整理的Android时下热门知识点的学习资料

部分文件:
[外链图片转存中…(img-OiVL19Aj-1712235931357)]
[外链图片转存中…(img-1vkm77nB-1712235931357)]
[外链图片转存中…(img-beKaI7OX-1712235931357)]

本文已被CODING开源项目:《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》收录

一个人可以走的很快,但一群人才能走的更远。如果你从事以下工作或对以下感兴趣,欢迎戳这里加入程序员的圈子,让我们一起学习成长!

AI人工智能、Android移动开发、AIGC大模型、C C#、Go语言、Java、Linux运维、云计算、MySQL、PMP、网络安全、Python爬虫、UE5、UI设计、Unity3D、Web前端开发、产品经理、车载开发、大数据、鸿蒙、计算机网络、嵌入式物联网、软件测试、数据结构与算法、音视频开发、Flutter、IOS开发、PHP开发、.NET、安卓逆向、云计算

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值