Java并发之ReentrantReadWriteLock源码解析(一)
2021/7/8 9:06:01
本文主要是介绍Java并发之ReentrantReadWriteLock源码解析(一),对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
ReentrantReadWriteLock
前情提要:在学习本章前,需要先了解笔者先前讲解过的ReentrantLock源码解析和Semaphore源码解析,这两章介绍了很多方法都是本章的铺垫。下面,我们进入本章正题ReentrantReadWriteLock。
ReentrantReadWriteLock与ReentrantLock的使用方式有些相似,它提供了读锁(ReadLock)和写锁(WriteLock),这两种锁都实现了java.util.concurrent.locks.Lock这一接口,允许调用lock()方法来抢锁,调用unlock()释放锁,也有:lockInterruptibly()、tryLock()、tryLock(long time, TimeUnit unit)……等一系列抢锁方法。
比如下面的RWHashMap,RWHashMap相比于Java原生实现的HashMap多了一重线程安全保障。我们在<1>处创建了一个可重入读写锁ReentrantReadWriteLock,并且分别在<2>、<3>处根据<1>处创建的可重入读写锁生成读锁(readLock)和写锁(writeLock),当我们要调用put()、clear()这些修改哈希表内容的方法时,占有写锁的线程能够保证当前没有其他线程修改或读取哈希表的数据。当我们调用get()或者allKeys()这些读取数据的方法时,读锁能保证没有其他的写入线程修改哈希表,并且读锁允许有多个线程同时读取哈希表的数据。
那么我们来思考下,如果不限制读写线程按照一定规则来访问/修改哈希表会有什么问题?我们知道,哈希表的实现是数组+链表的结构,数组中的每个元素都是一个链表,当有一个键值对<K1,V1>要在哈希表上建立映射时,会先计算出K1的哈希值,然后用哈希值对数组长度求余,算出一个不超过数组长度的索引i,这个索引i即是存放键值对的链表,如果数组对应索引i的位置为null,则代表链表还没有元素,于是把键值对<K1,V1>存到数组对应的索引i的位置上;当再进来一个键值对<K2,V2>,同样算出要存放在索引i的位置,这时判断数组的索引i的位置不为null,会遍历到链表最后一个节点,将键值对作为节点插入到链表。
那么如果用并发的方式将<K1,V1>和<K2,V2>写入到HashMap会有什么问题呢?假设线程1写入<K1,V1>时发现索引i的位置null,键值对可以作为链表的头节点,填充在数组索引i的位置上;同时线程2在写入<K2,V2>时也发现索引i的位置为null,也要把键值对填充在数组索引i的位置上。在两个线程确定好写入位置后,线程1先写入,线程2后写入,于是我们就丢失一个键值对。
那么读写线程之间又为什么需要互斥呢?之前说过HashMap是数组+链表的结构,如果数组的长度为10,哈希表的元素数量为50,这时可以兼顾查询和插入的性能,因为平均每个数组的链表长度为5,但是当数组长度为10,元素数量为5000,平均每个数组的链表长度为500,那么此时再链表查询和插入元素都有些吃力了,此时HashMap会进行扩容,将原先数组长度从10扩展到500,将5000个元素重新分散到500个链表中,平均每个链表的长度为10来保证查询和插入的性能。如果读写线程不互斥,可能出现原先读线程判断K1在数组索引i的链表上,但此时有写线程在HashMap新增键值对,导致HashMap扩容,K1从索引i移动到索引j的位置上。导致字典明明有K1这个键,读线程却在索引i的链表上找不到这个键值对。
上面举的写写并行、读写并行会产生的问题只是冰山一角,如果真的用并行的方式读写HashMap可能产生的问题会比我们想象中的多。因此,基于可重入读写锁(ReentrantReadWriteLock)的RWHashMap能够保证:当有多个写线程要修改HashMap的数据时,写锁能够保证写线程必须按照串行的方式修改HashMap;当有多个读线程要访问HashMap的数据,多个读线程可以同时进行,且读锁能够保证当前没有任何占有写锁的线程,不会出现在查找数据的同时,数据的位置被修改。
package org.example.ch3; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantReadWriteLock; public class RWHashMap { private final Map<String, Object> m = new HashMap<>(); private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock(); private final Lock r = rwl.readLock(); private final Lock w = rwl.writeLock(); public Object get(String key) { r.lock(); try { return m.get(key); } finally { r.unlock(); } } public List<String> allKeys() { r.lock(); try { return new ArrayList<>(m.keySet()); } finally { r.unlock(); } } public Object put(String key, Object value) { w.lock(); try { return m.put(key, value); } finally { w.unlock(); } } public void clear() { w.lock(); try { m.clear(); } finally { w.unlock(); } } }
学习过ReentrantLock的朋友都知道,当有线程占有了可重入互斥锁,如果还有别的线程请求锁,这些线程会形成一个独占(Node.EXCLUSIVE)节点进入在ReentrantLock内部维护的一个等待队列并陷入阻塞,当锁被释放时会唤醒队列中等待锁时间最长的线程起来竞争锁,即头节点的后继节点对应的线程。
ReentrantReadWriteLock作为ReentrantLock的兄弟,其实现和ReentrantLock有些相似,只不过,ReentrantLock只能被一个线程独占,因此等待队列中的每个节点都是独占节点,每个线程都是互斥的;而ReentrantReadWriteLock等待队列可能有的独占节点,也可能有共享(Node.SHARED)节点,即:有的线程会独占写锁,有的线程会共享读锁。
我们用Wn代表写线程,用Rn代表读线程,n代表线程id,以公平模式下的可重入读写锁为例。假如线程1已经独占了读写锁的写锁,此时不管是读线程还是写线程过来,都只能乖乖入队。这时线程2和线程3请求读锁,由于写锁被占用,线程2和线程3只能先入队,所以队列的节点分布为:head->R2->R3(tail),线程2和线程3入队后,线程4请求写锁,此时也只能先入队,队列的节点分布为:head->R2->R3->W4(tail),线程4入队后,线程5、6、7请求读锁,此时队列的节点分布为:head->R2->R3->W4->R5->R6->R7(tail)。
当线程1释放了写锁,会唤醒阻塞中的线程R2,R2被唤醒后获取到读锁,并且判断自己的后继节点为共享节点会唤醒R3。R3被唤醒后会接着判断后继节点是否是共享节点,R3的后继节点是独占节点W4,所以这里不会唤醒W4。R2和R3在释放完读锁后会继而唤醒W4获取写锁,W4在修改完数据释放写锁后,会继而唤醒R5,R5被唤醒后判断后继节点是共享节点会继唤醒R6,同理R6会唤醒R7,这就是公平模式下读写锁的分配逻辑。
相比于公平模式,非公平模式下的可重入读写锁就有点“蛮不讲理”。比如依旧以队列分布:head->R2->R3->W4->R5->R6->R7(tail)为列。线程1释放写锁唤醒R2,在R2准备请求读锁时,线程8(W8)抢在R2前占有了写锁,此时R2不能请求读锁,只能继续阻塞。或者当R2和R3释放读锁后唤醒W4,此时线程9(R9)抢在W4之前请求到读锁,此时W4不能占有写锁,只能继续阻塞。因此,非公平模式下的读写锁如果出现这种连续不断的竞争,可能无限期地延迟队列中的线程。但和公平锁相比,非公平拥有更高的吞吐量,所以ReentrantReadWriteLock默认的无参构造方法使用的是非公平模式,如果要使用公平模式的可重入读写锁,需要使用ReentrantReadWriteLock(boolean fair)构造函数指定公平模式。
需要注意几点:
- 读锁(ReadLock)和写锁(WriteLock)的tryLock()方法不会遵守公平模式,哪怕我们创建的是公平模式的读写锁,当调用tryLock()方法时如果读写锁的条件允许,则会立即占有读锁或写锁,不会去管队列中是否有等待线程。
- 占有写锁的线程可以再获取读锁,在写锁释放后,锁将从写锁降级为读锁;但读锁不能升级为写锁,共享读锁的线程如果尝试获取写锁,线程会陷入阻塞。
下面正式进入ReentrantReadWriteLock的源码解析,我们先看看下ReentrantReadWriteLock的源码概览。先前在讲解ReentrantLock和Semaphore相信大家都有看到这两个类的内部会有个静态内部类Sync,Sync会继承AQS类,同时会有公平(FairSync)和非公平(NonfairSync)类继承Sync,Sync会实现一些较为通用和基础的的方法,但具体是以公平或者非公平的方式抢锁由具体的FairSync和NonfairSync类实现。
ReentrantReadWriteLock的实现思路其实也与上面的套路类似,这里依旧会有个静态内部类Sync作为公平锁(FairSync)和非公平锁(NonfairSync)的父类,除此之外ReentrantReadWriteLock还有两个静态内部类读锁(ReadLock)和写锁(WriteLock),通过这两个锁可以让我们以线程安全的方式访问和修改资源。读锁和写锁本身也不关心抢锁的方式是公平或是非公平的方式,这两个类的内部会维护一个Sync类的内部,由真正的Sync实现决定是以公平/非公平的方式抢锁。
public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable { //... private final ReentrantReadWriteLock.ReadLock readerLock; private final ReentrantReadWriteLock.WriteLock writerLock; final Sync sync; public ReentrantReadWriteLock() { this(false); } public ReentrantReadWriteLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); readerLock = new ReadLock(this); writerLock = new WriteLock(this); } public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; } public ReentrantReadWriteLock.ReadLock readLock() { return readerLock; } abstract static class Sync extends AbstractQueuedSynchronizer { static final int SHARED_SHIFT = 16; static final int SHARED_UNIT = (1 << SHARED_SHIFT); static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1; static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1; static int sharedCount(int c) { return c >>> SHARED_SHIFT; } static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; } static final class HoldCounter { int count; // initially 0 // Use id, not reference, to avoid garbage retention final long tid = LockSupport.getThreadId(Thread.currentThread()); } static final class ThreadLocalHoldCounter extends ThreadLocal<HoldCounter> { public HoldCounter initialValue() { return new HoldCounter(); } } private transient ThreadLocalHoldCounter readHolds; private transient HoldCounter cachedHoldCounter; private transient Thread firstReader; private transient int firstReaderHoldCount; Sync() { readHolds = new ThreadLocalHoldCounter(); setState(getState()); // ensures visibility of readHolds } //... } //... static final class NonfairSync extends Sync { //... } //... static final class FairSync extends Sync { //... } //... public static class ReadLock implements Lock, java.io.Serializable { //... private final Sync sync; //... } //... public static class WriteLock implements Lock, java.io.Serializable { //... private final Sync sync; //... } //... }
观察上面ReentrantReadWriteLock.Sync类,可以发现这个类相比以前ReentrantLock.Sync和Semaphore.Sync多出很多字段,我们先来解析这些字段的用途:
在ReentrantReadWriteLock.Sync中,会用其父类AQS的state字段来存储读锁和写锁的数量,state是int类型(即:4字节,32位),其中高位16位用于存储读锁的数量,低位16位存储写锁的数量。因此不管是读锁和写锁,最多被重复获得65535(MaxUint16)次,不管是以循环、递归还是重入的方式。那么我们又是用这个int类型的state字段读取和存储读锁和写锁的数量呢?
SHARED_SHIFT为16,代表读锁和写锁在state字段里的分界线,SHARED_UNIT是往state字段增加一个读线程的单位数量,比如我们连续向state添加两个读线程:
#初始state为0,其二进制表示为32个0,加上SHARED_UNIT,高位16位表示当前读线程数量为1 0000 0000 0000 0000 0000 0000 0000 0000 + 0000 0000 0000 0001 0000 0000 0000 0000 —————————————————————————————————————————————— #此时又有新的读线程共享读锁,所以state加上SHARED_UNIT,高位16位表示当前读线程数量为2 0000 0000 0000 0001 0000 0000 0000 0000 + 0000 0000 0000 0001 0000 0000 0000 0000 —————————————————————————————————————————————— 0000 0000 0000 0010 0000 0000 0000 0000
现在state的值为:0000 0000 0000 0010 0000 0000 0000 0000,我们要如何从这个字段得到读线程的数量呢?我们可以将state传入到sharedCount(int c)算出读线程的数量,这个方法会对state无符号右移(也叫逻辑右移)16(SHARED_SHIFT)位,不管最高位是0还是1,右移后的前16位都会补0。我们根据现有的读线程数为2的state无符号右移16位:0000 0000 0000 0010 0000 0000 0000 0000 >>> 16 = 0000 0000 0000 0000 0000 0000 0000 0010,算出的结果刚好为2,也就是读线程的数量。所以,如果我们要增减一个读线程数,只要对state加减一个SHARED_UNIT,当需要从state获取当前的读线程数,只要将state无符号右移16位即可。
MAX_COUNT和EXCLUSIVE_MASK的结果一样,都为(2^16)-1=65535,其二进制表示为:0000 0000 0000 0000 1111 1111 1111 1111。但两者的使用场景却不同,之前说过读锁和写锁会多获取65535次,这里会用MAX_COUNT来做校验。EXCLUSIVE_MASK是用来计算写锁被重入次数,假设线程有一线程在重入两次写锁后又获取一次读锁,其state的二进制表示为:0000 0000 0000 0001 0000 0000 0000 0010,我们将state传入到exclusiveCount(int c)可以算出写锁的被重入次数,这个方法对将state和EXCLUSIVE_MASK做&运算,EXCLUSIVE_MASK的高16位都为0,做&运算其结果的高16位会清零,去除读锁的数量,EXCLUSIVE_MASK的低16位都为1,做&运算后能保留state低16位的结果。
#线程重入两次写锁后获取一次读锁,与EXCLUSIVE_MASK做&运算得到写锁被重入次数2 0000 0000 0000 0001 0000 0000 0000 0010 & 0000 0000 0000 0000 1111 1111 1111 1111 —————————————————————————————————————————————— 0000 0000 0000 0000 0000 0000 0000 0010
readHolds的类型为ThreadLocalHoldCounter,其父类是ThreadLocal,ThreadLocalHoldCounter重写了父类ThreadLocal的initialValue()方法,当读线程第一次调用readHolds.get()方法时会调用ThreadLocalHoldCounter.initialValue()方法,为获取读锁的线程生成一个线程局部变量HoldCounter对象,用于统计线程重入读锁的次数。
cachedHoldCounter、firstReader、firstReaderHoldCount用于更快地计算读线程获取读锁的次数,cachedHoldCounter用于指向上一个获取读锁的线程的HoldCounter对象,当有读线程获取读锁时,会先判断线程id是否与当前cachedHoldCounter的线程id相同,如果相同则对其获取次数count+1,firstReader用于指向第一个获取读锁的线程,第一个获取读锁的线程也不需要通过readHolds来生成线程局部变量HoldCounter对象,可以直接用firstReaderHoldCount来统计读锁的获取次数。当然,释放读锁的时候,也会对读锁的持有次数-1。
我们已经对ReentrantReadWriteLock的静态内部类Sync有了基本的认识,下面我们来看看写锁ReentrantReadWriteLock.WriteLock的实现。
我们先从lock()方法开始介绍,当我们调用WriteLock.lock()方法,会调用Sync父类AQS实现的acquire(int arg),这个方法会先调用子类实现的tryAcquire(int arg)尝试获取写锁,如果获取失败,在调用acquireQueued(addWaiter(Node.EXCLUSIVE), arg)方法将需要获取写锁的线程挂起,关于acquireQueued(addWaiter(Node.EXCLUSIVE), arg)的实现大家可以去ReentrantLock源码解析再复习一遍,笔者不再赘述,这里主要解释tryAcquire(int acquires)的实现是如何尝试获取锁的。
首先在<1>处获取读写锁当前的状态c,之后获取写锁的数量w,如果当前状态c不为0,代表有线程占有读锁或者写锁,则进入<3>处的分支。在读写锁状态不为0的情况写,如果获取写锁数量w为0,代表现在有线程获取到读锁,尝试获取写锁失败;又或者w不为0,代表当前有线程占有写锁,但当前线程不是独占写锁的线程,尝试获取写锁失败,这两种情况都会进入<4>处的分支。
如果<4>处的判断结果为false,代表写锁被重入,即线程重复获取写锁,这里会先判断原先获取写锁的次数加上要获取的次数acquires,如果总获取次数超过MAX_COUNT(即:65535,MaxUint16)则报错。否则将最新的写锁获取次数c+acquires保存进父类AQS的state字段里。
如果在<1>处获取到的读写锁状态为0,代表当先没有读线程或者写线程占有读写锁,这里会先调用writerShouldBlock()判断是否应该阻塞当前写线程,Sync并没有实现writerShouldBlock()方法,而是交由它的子类公平锁(FairSync)和非公平锁(NonfairSync)实现,由具体的实现决定此处是否以公平还是非公平的方式抢锁,如果是公平模式,则writerShouldBlock()的实现只要简单判断下等待队列中是否有线程,有线程则不抢锁,如果非公平模式,就会返回false,然后在<6>处尝试抢锁。假如writerShouldBlock()返回false,在<6>处会以CAS的方式尝试更新读写锁的状态为c + acquires,如果更新成功则代表线程成功获取写锁,会设置写锁的独占线程为当前线程并返回抢锁成功;如果CAS失败代表当前有线程获取到读锁或者写锁,会进入<6>处的分支返回抢锁失败。
public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable { //... abstract static class Sync extends AbstractQueuedSynchronizer { //... protected final boolean tryAcquire(int acquires) { Thread current = Thread.currentThread(); int c = getState();//<1> int w = exclusiveCount(c);//<2> if (c != 0) {//<3> // (Note: if c != 0 and w == 0 then shared count != 0) if (w == 0 || current != getExclusiveOwnerThread())//<4> return false; if (w + exclusiveCount(acquires) > MAX_COUNT)//<5> throw new Error("Maximum lock count exceeded"); // Reentrant acquire setState(c + acquires); return true; } if (writerShouldBlock() || !compareAndSetState(c, c + acquires))//<6> return false; setExclusiveOwnerThread(current); return true; } //... abstract boolean writerShouldBlock(); //... } //... public static class WriteLock implements Lock, java.io.Serializable { //... public void lock() { sync.acquire(1); } //... } //... } public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable { //... public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); } //... protected boolean tryAcquire(int arg) { throw new UnsupportedOperationException(); } //... }
这里我们看到Sync抽象方法writerShouldBlock()在公平锁和非公平锁的实现,公平锁会判断等待队里是否有线程,有的话则返回true,上面的代码会进入<6>处的分支返回抢锁失败,如果是非公平锁的writerShouldBlock()直接返回false,在<6>处会尝试用CAS修改写锁的获取次数。
static final class FairSync extends Sync { final boolean writerShouldBlock() { return hasQueuedPredecessors(); } //... } static final class NonfairSync extends Sync { final boolean writerShouldBlock() { return false; // writers can always barge } //... }
当调用写锁的unlock()方法时,会继而调用到AQS的release(int arg)方法,这个方法会先调用由子类Sync实现的tryRelease(int releases)方法尝试解锁,如果尝试解锁的线程不是独占写锁的线程,这里会抛出IllegalMonitorStateException异常,如果当前读写锁的状态减去释放写锁的数量(state-releases)算出的获取写锁数量nextc为0,代表线程要完全释放写锁,这里会先置空独占线程,在设置state为nextc,需要注意的是:可能存在获取写锁后又获取读锁的情况,所以这里在释放写锁后不能直接将state清零,要考虑到state的前16位可能存在被当前线程持有读锁的情况。如果能完全释放写锁,会再调用unparkSuccessor(h)尝试唤醒头节点的后继节点起来申请读写锁。
public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable { //... abstract static class Sync extends AbstractQueuedSynchronizer { //... protected final boolean tryRelease(int releases) { if (!isHeldExclusively()) throw new IllegalMonitorStateException(); int nextc = getState() - releases; boolean free = exclusiveCount(nextc) == 0; if (free) setExclusiveOwnerThread(null); setState(nextc); return free; } //... } public static class WriteLock implements Lock, java.io.Serializable { //... public void unlock() { sync.release(1); } //... } //... } public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable { //... public final boolean release(int arg) { if (tryRelease(arg)) { Node h = head; if (h != null && h.waitStatus != 0) unparkSuccessor(h); return true; } return false; } //... protected boolean tryRelease(int arg) { throw new UnsupportedOperationException(); } //... }
写锁的tryLock()没有公平和非公平之分,不管是公平锁还是非公平锁,会调用Sync类实现的tryWriteLock()方法统一以非公平的方式尝试抢锁,其实现思路也和上面的lock()十分相近,先获取读写锁当前状态c,如果c不为0则代表当前有线程获取到读锁或者写锁,会进入<1>处的分支判断当前是否是写锁被占用,如果占有写锁的数量为0则代表有线程获取到读锁,这里会直接返回抢锁失败,如果w不为0,则代表有线程获取到写锁,会再接着判断获取写锁的线程是否是当前线程,如果不是则返回抢锁失败。如果获取写锁的数量不为0且占有写锁的线程为当前线程,会再接着判断当前写锁的可重入次数是否已达到MAX_COUNT,是的话则抛出异常。如果当前没有线程占有读写锁,或者执行tryLock()方法的线程是已经占有写锁的线程,这里会执行到<2>处的分支用CAS的方式修改获取写锁的次数,如果state原先为0,这里可能会和其他线程抢锁导致失败,如果是原先就占有写锁的线程执行到<2>处是没有并发问题的。如果能CAS成功,则设置独占线程为当前线程。
public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable { //... abstract static class Sync extends AbstractQueuedSynchronizer { //... final boolean tryWriteLock() { Thread current = Thread.currentThread(); int c = getState(); if (c != 0) {//<1> int w = exclusiveCount(c); if (w == 0 || current != getExclusiveOwnerThread()) return false; if (w == MAX_COUNT) throw new Error("Maximum lock count exceeded"); } if (!compareAndSetState(c, c + 1))//<2> return false; setExclusiveOwnerThread(current); return true; } //... } //... public static class WriteLock implements Lock, java.io.Serializable { //... public boolean tryLock() { return sync.tryWriteLock(); } //... } }
下面,我们简单来看下写锁的tryLock(long timeout, TimeUnit unit),这里会调用Sync父类AQS实现的tryAcquireNanos(int arg, long nanosTimeout),tryAcquireNanos(int arg, long nanosTimeout)内部的实现其实基本上都讲过,比如:tryAcquire(arg)和doAcquireNanos(arg, nanosTimeout),这里就不再赘述了。
public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable { //... public static class WriteLock implements Lock, java.io.Serializable { //... public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException { return sync.tryAcquireNanos(1, unit.toNanos(timeout)); } //... } } public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable { //... public final boolean tryAcquireNanos(int arg, long nanosTimeout) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); return tryAcquire(arg) || doAcquireNanos(arg, nanosTimeout); } //... }
这篇关于Java并发之ReentrantReadWriteLock源码解析(一)的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-11-23Springboot应用的多环境打包入门
- 2024-11-23Springboot应用的生产发布入门教程
- 2024-11-23Python编程入门指南
- 2024-11-23Java创业入门:从零开始的编程之旅
- 2024-11-23Java创业入门:新手必读的Java编程与创业指南
- 2024-11-23Java对接阿里云智能语音服务入门详解
- 2024-11-23Java对接阿里云智能语音服务入门教程
- 2024-11-23JAVA对接阿里云智能语音服务入门教程
- 2024-11-23Java副业入门:初学者的简单教程
- 2024-11-23JAVA副业入门:初学者的实战指南