37、Lock(中)
上一节我们讲解了JUC Lock 的各种特性,比如:支持重入锁、公平锁、可中断锁、非阻塞锁、可超时锁,本节我们就来讲一下 JUC Lock 的底层实现原理
JUC Lock 底层主要依赖 AQS 来实现,AQS 也是 JUC 中非常重要的基础组件
JUC 中很多锁(Lock、ReadWriteLock)和同步工具(Condition、Semaphore、CountDownLatch)都是基于 AQS 来实现的
因此在讲解 JUC Lock 的底层实现原理时,我们会重点讲解 AQS(Abstract Queued Synchronizer 抽象队列同步器)
1、AQS 简介
AQS 是抽象类 Abstract Queue Synchronizer 的简称,中文翻译为抽象队列同步器
前面讲到,在 Hotspot JVM 中,synchronized 主要依赖 ObjectMonitor 类来实现,类中的 _cxq、_EntryList、_WaitSet 用来排队线程
其中 _cxq、_EntryList 用来实现锁,也就是 synchronized,_WaitSet 用来实现条件变量,也就是 wait() 和 notify()
实际在功能上,AQS 跟 ObjectMonitor 非常类似,都实现了:排队线程、阻塞线程、唤醒线程等功能
class ObjectMonitor { void *volatile _object; // 该 Monitor 锁所属的对象 void *volatile _owner; // 获取到该 Monitor 锁的线程 ObjectWaiter *volatile _cxq; // 没有获取到锁的线程暂时加入 _cxq, 单链表, 负责存操作 ObjectWaiter *volatile _EntryList; // 存储等待被唤醒的线程, 双链表, 负责取操作 ObjectWaiter *volatile _WaitSet; // 存储调用了 wait() 的线程, 双链表 }
不过在实现思路上,AQS 跟 ObjectMonitor 有所不同
- ObjectMonitor 类是在 JVM 中基于 C++ 来实现的,因为 synchronized、wait()、notify() 是 Java 语言提供的内置的语法和函数
AQS 类是在 JDK 中基于 Java 语言实现的,因为 JUC 只是 JDK 中的一个并发工具包而已 - ObjectMonitor 使用不同的队列来实现锁和同步工具,AQS 使用同一个队列来实现锁和同步工具
接下来,我们就详细讲解一下 AQS 的实现原理
2、数据结构
AQS 类中所包含的成员变量并不多,如下代码所示,这几个成员变量构成了 AQS 实现锁和同步工具所依赖的核心数据结构
public abstract class AbstractOwnableSynchronizer { private transient Thread exclusiveOwnerThread; // 独占所有者线程 } public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer { private transient volatile Node head; private transient volatile Node tail; private volatile int state; }
如上代码所示,AQS 继承自 AbstractOwnableSynchronizer 类
AbstractOwnableSynchronizer 类只包含一个成员变量 exclusiveOwnerThread,AQS 连带继承来的一个成员变量,总共有 4 个成员变量
一个线程获取锁,无非就是对 state 变量进行 CAS 修改,修改成功则获取锁,修改失败则进入队列
而 AQS 就是负责线程进入同步队列以后的逻辑,如何出入队列?如何阻塞?如何唤醒?一切的核心都在 AQS 里
2.1、state
前面在讲到 synchronized 的底层实现原理时我们讲到:当多个线程竞争锁时,它们会通过 CAS 操作来设置 ObjectMonitor 中的 _owner 字段,谁设置成功,谁就获取了这个锁
AQS 中的 state 的作用就类似于 ObjectMonitor 中的 _owner 字段
只不过 _owner 字段是一个指针,存储的是获取锁的线程,而 state 是一个 int 类型的变量,存储 0、1 等整型值
- 0 表示锁没有被占用
- 1 表示锁已经被占用
- 大于 1 的数表示重入的次数
当多个线程竞争锁时,它们会通过如下所示的 CAS 操作来更新 state 的值
这里 CAS 指的是:先检查 state 的值是否为 0,如果是的话,将 state 值设置为 1,谁设置成功,谁就获取了这个锁
protected final boolean compareAndSetState(int expect, int update) { return unsafe.compareAndSwapInt(this, stateOffset, expect, update); }
compareAndSetState() 函数底层使用 Unsafe 类提供的 native 函数来实现
native 函数是 JVM 中的 C++ 函数,如果想要阅读 native 函数的代码实现,那么我们需要查看 JVM 源码
实际上 compareAndSetState() 函数经过层层调用,最底层仍然是依靠硬件提供的原子 CAS 指令来实现
2.2、exclusiveOwnerThread
AQS 中的 exclusiveOwnerThread 成员变量存储持有锁的线程,它配合 state 成员变量,可以实现锁的重入机制,关于重入机制的实现方式,我们稍后讲解
2.3、head 和 tail
在 ObjectMonitor 中,_cxq、_EntryList 用来存储等待锁的线程,_WaitSet 用来存储调用了 wait() 函数(等待条件变量的函数)的线程
相比而言,AQS 只有一个等待队列,既可以用来存储等待锁的线程,又可以用来存储等待条件变量的线程
在 ObjectMonitor 中,_cxq 使用单链表来实现,_EntryList 和 _WaitSet 使用双向链表来实现
在 AQS 中,等待队列使用双向链表来实现,双向链表的节点定义如下所示,AQS 中的 head 和 tail 两个成员变量分别为双向链表的头指针和尾指针
static final class Node { static final Node SHARED = new Node(); static final Node EXCLUSIVE = null; static final int CANCELLED = 1; static final int SIGNAL = -1; static final int CONDITION = -2; static final int PROPAGATE = -3; volatile Thread thread; volatile Node prev; volatile Node next; volatile int waitStatus; Node nextWaiter; }
3、基本原理
3.1、AQS 模板方法模式
AQS 使用模板方法模式来实现,在《设计模式之美》一书中我们讲到,模板方法模式包含两个主要的组件:模板方法和抽象方法
模板方法包含主功能逻辑,并且依赖抽象方法来实现部分逻辑的可定制化
当使用模板方法模式时,我们需要定义一个子类,让其继承模板类,并实现其中的抽象方法,然后再使用子类创建对象,调用对象的模板方法来做编程开发
AQS 的代码结构和使用方法大致也是如此
1、AQS 定义了 8 个模板方法,如下所示
以下 8 个函数可以分为 2 组,分别用于 AQS 的两种工作模式:独占模式和共享模式,其中前 4 个函数用于独占模式,后 4 个函数用于共享模式
- Lock 为排它锁,因此 Lock 的底层实现只会用到 AQS 的独占模式
- ReadWriteLock 中的读锁为共享锁,写锁为排它锁,因此 ReadWriteLock 的底层实现既会用到 AQS 的独占模式,又会用到 AQS 的共享模式
- Semaphore、CountdownLatch 这些同步工具只会用到 AQS 的共享模式
// 独占模式 public final void acquire(int arg) { // ... } public final void acquireInterruptibly(int arg) throws InterruptedException { // ... } public final boolean tryAcquireNanos(int arg, long nanosTimeout) throws InterruptedException { // ... } public final boolean release(int arg) { // ... }
// 共享模式 public final void acquireShared(int arg) { // ... } public final void acquireSharedInterruptibly(int arg) throws InterruptedException { // ... } public final boolean tryAcquireSharedNanos(int arg, long nanosTimeout) throws InterruptedException { // ... } public final boolean releaseShared(int arg) { // ... }
2、AQS 提供了 4 个抽象方法,如下所示
加锁和解锁,就是利用 CAS 对 state、exclusiveOwnerThread 进行操作
由模板方法处理其它逻辑(同步队列):加锁失败后的添加队列和阻塞线程等(当前)、解锁成功后的删除队列和唤醒线程等(其它)
前两个抽象方法用于独占模式的 4 个模板方法,后两个抽象方法用于共享模式的 4 个模板方法
在标准的模板方法模式的代码实现中,抽象方法需要使用 abstract 关键字来定义,以强制子类去实现它
但以下抽象方法并没有使用 abstract 关键字来定义,而是给出了默认的实现,即抛出 UnsupportOperationException 异常
这样做是为了减少开发量,即我们不需要在子类中实现所有的抽象方法,用到哪个就实现哪个即可
// 用于独占模式的 4 个模板方法 protected boolean tryAcquire(int arg) { throw new UnsupportedOperationException(); } protected boolean tryRelease(int arg) { throw new UnsupportedOperationException(); }
// 用于共享模式的 4 个模板方法 protected int tryAcquireShared(int arg) { throw new UnsupportedOperationException(); } protected boolean tryReleaseShared(int arg) { throw new UnsupportedOperationException(); }
3.2、ReentrantLock 的 lock() 和 unlock()
ReentrantLock 既支持非公平锁,又支持公平锁,其部分代码如下所示
ReentrantLock 定义了两个继承自 AQS 的子类:NonfairSync 和 FairSync,分别用来实现非公平锁和公平锁
因为 NonfairSync 和 FairSync 的释放锁的逻辑是一样的,所以 NonfairSync 和 FairSync 又抽象出了一个公共的父类 Sync
注意:为了更清晰的展示原理,在不改变代码逻辑的情况下,我对本节中的代码均做了少许调整
public class ReentrantLock implements Lock { private final Sync sync; // 父类 Sync 继承 AQS abstract static class Sync extends AbstractQueuedSynchronizer {} // 非公平锁 static final class NonfairSync extends Sync {} // 公平锁 static final class FairSync extends Sync {} public ReentrantLock() { sync = new NonfairSync(); } public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); } public void lock() { sync.acquire(1); } public void unlock() { sync.release(1); } // ... 省略其他方法 ... }
ReentrantLock 中
- lock() 函数调用 AQS 的 acquire() 模板方法来实现
- unlock() 函数调用 AQS 的 release()模板方法来实现
接下来我们就来看下 acquire() 和 release() 的底层实现原理
3.3、acquire() 模板方法
acquire() -> tryAcquire() -> addWaiter() -> acquireQueued()
acquire() 的代码实现如下所示,实现看似非常简单,实际上其包含的逻辑可不少,acquire() 先调用 tryAcquire() 方法去竞争获取锁
- 如果 tryAcquire() 获取锁成功:acquire() 就直接返回
- 如果 tryAcquire() 获取锁失败:执行 addWaiter(),将线程包裹为 Node 节点放入等待队列的尾部,最后调用 acquireQueued() 阻塞当前线程
selfInterrupt() 用来处理中断,如果在等待锁的过程中,线程被其他线程中断,那么在获取锁之后,将线程的中断标记设置为 true,这里的中断不是重点,简单了解即可
public final void acquire(int arg) { // tryAcquire() -> addWaiter() -> acquireQueued() if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }
1、tryAcquire() 竞争获取锁
tryAcquire() 是抽象方法,在 NonfairSync 和 FairSync 中实现,代码如下所示,我对代码做了详细的注释,这里就不再重述其中的代码逻辑了
两个 tryAcquire() 方法的代码实现区别也不大,唯一的区别是:在获取锁之前,FairSync 会调用 hasQueuedPredecessors() 函数,查看等待队列中是否有线程在排队
如果有,那么 tryAcquire()返回 false,表示竞争锁失败,从而禁止 "插队" 获取锁的行为
// 非公平锁 static final class NonfairSync extends Sync { // 尝试获取锁, 成功返回 true, 失败返回 false, AQS 用于实现锁时, acquires = 1 protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); // 获取当前线程 int c = getState(); // 获取 state 值 // 1、锁没有被其他线程占用 if (c == 0) { // CAS 设置 state 值为 1 if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); // 设置 exclusiveOwnerThread return true; // 获取锁成功 } } // 2、锁可重入 else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; // state + 1 // 重入次数太多, 超过了 int 最大值, 溢出为负数, 此情况罕见 if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); // state = state + 1, state 记录重入的次数, 解锁的时候用 return true; // 获取锁成功 } // 3、锁被其他线程占用 return false; } }
// 公平锁 static final class FairSync extends Sync { protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); // 1、锁没有被占用 if (c == 0) { if (!hasQueuedPredecessors() && // 等待队列中没有线程时才获取锁 compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } // 2、锁可重入 else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } // 3、锁被其他线程占用 return false; } }
2、addWaiter() 将线程包裹为 Node 节点放入等待队列的尾部
addWaiter() 函数的代码实现如下所示,在多线程环境下,往链表尾部添加节点会存在线程安全问题
因此下面的代码采用自旋 + CAS 操作的方式来解决这个问题,这种方式在 AtomicInteger 等原子类中被大量使用,我们在讲解原子类时再详细讲解
除此之外,addWaiter() 函数还需要特殊处理链表为空的情况,同样也存在线程安全问题,也同样是采用自旋 + CAS 操作解决的
注意:为了方便操作,AQS 中的双向链表带有虚拟头节点,关于虚拟头节点,你可以阅读我的《数据结构与算法之美》这本书来了解
private Node addWaiter(Node mode) { Node node = new Node(Thread.currentThread(), mode); // 自旋执行 CAS 操作, 直到成功为止 for (; ; ) { Node t = tail; // 链表为空, 添加虚拟头节点 if (t == null) { // CAS 操作解决添加虚拟头节点的线程安全问题 if (compareAndSetHead(null, new Node())) tail = head; } // 链表不为空 else { node.prev = t; // CAS 操作解决了同时往链表尾部添加节点时的线程安全问题 if (compareAndSetTail(t, node)) { t.next = node; return node; } } } }
3、acquireQueued() 阻塞当前线程,这个方法是最重要的
acquireQueued() 的代码实现如下所示,主要包含两部分逻辑:使用 tryAcquire() 函数来竞争锁和使用 park() 函数来阻塞线程,并且采用 for 循环来交替执行这两个逻辑
之所以这样做,是因为线程在被唤醒(取消阻塞)之后,并不是直接获取锁,而是需要重新竞争锁,如果竞争失败,那么就需要再次被阻塞
关于代码中涉及的中断的处理逻辑,我们在本节中的中断机制小结中讲解(如果线程被中断唤醒,继续自旋阻塞,即不对中断做处理)
final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { boolean interrupted = false; // 自旋(竞争锁 + 阻塞), 因为被唤醒之后不一定能竞争到锁, 所以要自旋 for (; ; ) { final Node p = node.predecessor(); // p 是 node 的上一个节点 // 只有前驱节点是头节点的才能尝试获取同步状态 // 1、头节点既是虚拟头节点,又是成功获取到同步状态的节点 // 而头节点的线程释放了同步状态后,将会唤醒后继节点 // 后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点 // 如果线程是被中断唤醒的, 那么 p 就不一定等于 head, 也就不能去竞争锁 // 2、维护同步队列的 FIFO 原则 if (p == head && tryAcquire(arg)) { // 成功获得锁 // 设置首节点是通过获取同步状态成功的线程完成的 // 由于只有一个线程成功获取到同步状态,因此设置头节点的方法并不需要 CAS 来保证 setHead(node); // 把 node 设置成虚拟头节点, 也就相当于将它删除, 头节点是成功获取到同步状态的节点 p.next = null; // help GC failed = false; return interrupted; } // 调用 park() 函数来阻塞线程, 线程被唤醒有以下两种情况 // 1、其他线程调用 unpark() 函数唤醒, 此时节点位于虚拟头节点的下一个, p == head // 2、被中断唤醒, 此时节点不一定是虚拟头节点的下一个, p 不一定等于 head if (parkAndCheckInterrupt()) interrupted = true; } } finally { // 以上过程只要抛出异常, 都要将这个节点标记为 CANCELLED, 等待被删除 if (failed) cancelAcquire(node); } } private final boolean parkAndCheckInterrupt() { // 底层调用 JVM 提供的 native park() 函数来实现, 跟 synchronized 使用的 park() 函数相同 // 阻塞当前线程,只有调用 unpark(Thread thread) 方法或者当前线程被中断,才能从 park() 方法返回 // 参数 Object blocker,用来标识当前线程在等待的对象(阻塞对象),主要用于问题排查和系统监控 LockSupport.park(this); return Thread.interrupted(); }
3.4、release() 模板方法
release() -> tryRelease() -> unpark()
release() 模板方法的代码实现比较简单,如下所示,主要包含两部分逻辑:使用 tryRelease() 函数释放锁、调用 unpark() 函数唤醒链表首节点(除虚拟头节点之外)对应的线程
public final boolean release(int arg) { if (tryRelease(arg)) { Node h = head; if (h != null && h.waitStatus != 0) unparkSuccessor(h); // 内部调用 unpark() 函数, LockSupport.unpark(h.next.thread); return true; } return false; }
tryRelease() 是抽象方法,不管是公平锁还是非公平锁,tryRelease() 释放锁的逻辑相同,如下所示,代码中有详细的注释,这里就不再赘述代码逻辑了
static final class Sync extends AbstractQueuedSynchronizer { // 释放锁, 成功返回 true, 失败返回 false, AQS 用于实现锁时, releases = 1 protected final boolean tryRelease(int releases) { int c = getState() - releases; // state - 1 // 不持有锁的线程去释放锁, 这不是瞎胡闹嘛, 抛出异常 if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); // state - 1 之后为 0, 解锁 if (c == 0) { setExclusiveOwnerThread(null); return true; } setState(c); // state - 1 之后不为 0, 说明锁被重入多次, 还不能解锁 return false; } }
3.5、总结
从上述分析我们可以发现
- 模板方法 acquire() 包含加锁的所有逻辑,比如:竞争锁、竞争失败之后的排队、阻塞
而竞争锁这部分逻辑由抽象方法 tryAcquire() 来实现,因此我们可以在子类中定制如何竞争锁,比如:是否支持重入锁、是否支持公平锁等 - 模板方法 release() 包含解锁的所有逻辑,比如:释放锁、唤醒等待线程
而释放锁这部分逻辑由抽象方法 tryRelease() 来实现,因此我们也可以在子类中定制如何释放锁
独占式同步状态的获取和释放
- 在获取同步状态时,同步器维护一个同步队列
获取状态失败的线程都会被加入到队列中并在队列中进行自旋
移出队列(或停止自旋)的条件是前驱节点为头节点且成功获取了同步状态 - 在释放同步状态时,同步器调用 tryRelease(int arg) 方法释放同步状态,然后唤醒头节点的后继节点
4、中断机制
4.1、lockInterruptibly()
ReentrantLock 中的 lockInterruptibly() 函数:由 aquireInterruptibly() 模板方法来实现
public void lockInterruptibly() throws InterruptedException { sync.acquireInterruptibly(1); }
4.2、acquireInterruptibly() 模板方法
acquireInterruptibly()
acquireInterruptibly() 模板方法对应的代码实现如下所示,代码实现也非常简单
- 如果线程被中断,则抛出 InterruptedException 异常,否则调用 tryAcquire() 竞争获取锁
- 如果获取锁成功,则直接返回,否则调用 doAcquireInterruptible() 函数
public final void acquireInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); if (!tryAcquire(arg)) doAcquireInterruptibly(arg); }
doAcquireInterruptibly()
doAcquireInterruptibly() 函数的代码实现如下所示,跟之前讲的 acquireQueued() 函数的代码实现非常相似,唯一的区别是对中断的响应处理不同
parkAndCheckInterrupt() 函数返回有两种情况,一种是其他线程调用了 unpark() 函数取消阻塞,另一种是被其他线程中断,对于第二种情况
- acquireQueued() 函数不对中断做任何处理,继续等待锁
- doAcquireInterruptibly() 函数则是将中断包裹为 InterruptedException 异常抛出,终止等待锁
因此调用 acquire() 实现的 lock() 函数,在阻塞等待锁时不会被中断,调用 acquireInterruptibly() 实现的 lockInterruptibly() 函数,在阻塞等待锁时可以被中断
private void doAcquireInterruptibly(int arg) throws InterruptedException { final Node node = addWaiter(Node.EXCLUSIVE); boolean failed = true; try { for (; ; ) { final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; // help GC failed = false; return; } if (parkAndCheckInterrupt()) throw new InterruptedException(); // 区别: 抛出异常! } } finally { if (failed) cancelAcquire(node); } }
5、超时机制
5.1、tryLock()
ReentrantLock 中带超时时间的 tryLock() 函数:由 tryAquireNanos() 模板方法来实现
public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException { return sync.tryAcquireNanos(1, unit.toNanos(timeout)); }
5.2、tryAquireNanos() 模板方法
tryAquireNanos()
tryAquireNanos() 模板方法的代码实现如下所示,代码实现也非常简单
- 如果线程被中断,则直接抛出 InterruptedException 异常,否则调用 tryAcquire() 竞争获取锁
- 如果获取锁成功,则直接返回,否则调用 doAcquireNanos() 函数
public final boolean tryAcquireNanos(int arg, long nanosTimeout) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); return tryAcquire(arg) || doAcquireNanos(arg, nanosTimeout); }
doAcquireNanos()
doAcquireNanos() 函数的代码实现如下所示,在 doAcquireInterruptibly() 函数的代码实现的基础之上,doAcquireNanos() 函数又添加了对超时的处理机制
因此使用 tryAcquireNanos() 实现的 ReentrantLock 的 tryLock() 函数,既支持中断,又支持设置超时时间
private boolean doAcquireNanos(int arg, long nanosTimeout) throws InterruptedException { if (nanosTimeout <= 0L) return false; final long deadline = System.nanoTime() + nanosTimeout; final Node node = addWaiter(Node.EXCLUSIVE); boolean failed = true; try { for (; ; ) { final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; // help GC failed = false; return true; } nanosTimeout = deadline - System.nanoTime(); if (nanosTimeout <= 0L) return false; // 如果获取锁失败,最终在这里返回 // 不着急阻塞, 先自旋一下 if (nanosTimeout > spinForTimeoutThreshold) LockSupport.parkNanos(this, nanosTimeout); // 超时阻塞 if (Thread.interrupted()) throw new InterruptedException(); } } finally { if (failed) cancelAcquire(node); } }
parkNanos()
为了支持超时阻塞,在阻塞线程时,doAcquireNanos() 函数调用 parkNanos() 函数,parkNanos() 函数的实现方式跟 park() 函数差不多
在讲解 synchronized 的时候我们提到,park() 函数的代码实现大致如下所示
parkNanos() 只需要将其中的 pthread_cond_wait() 函数替换成了pthread_cond_timewait() 函数便可以实现超时等待
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond = PTHREAD_COND_INITIALIZER; boolean ready = false; void park() { // ... pthread_mutex_lock(&mutex); while (!ready) { pthread_cond_wait(&cond, &mutex); } ready = false; pthread_mutex_unlock(&mutex); // ... }
6、课后思考题
本节中我们讲到 ReentrantLock 中
- lock() 函数使用 AQS 中的 acquire() 模板方法来实现
- unlock() 函数使用 AQS 中的 release() 模板方法来实现
- lockInterruptibly() 函数使用 acquireInterruptibly() 模板方法来实现
- 带超时时间的 tryLock() 函数使用 AQS 中的 tryAcquireNanos() 模板方法来实现
那么 ReentrantLock 中的 tryLock() 函数是如何实现的呢?
tryLock() 相较于 lock() 区别在于,当尝试加锁失败之后,线程并不会进入队列等待唤醒重新竞争获取锁
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17484512.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步