zoukankan      html  css  js  c++  java
  • 深入图解AQS实现原理和源码分析

    AQS底层实现原理用一句话总结就是:volatile + CAS + 一个虚拟的FIFO双向队列(CLH队列)。所以在了解AQS底层实现时,需要先深入了解一下CAS实现原理。

    #名词解释
    (1)CAS:无锁的策略使用一种比较交换的技术(Compare And Swap)来鉴线程修改冲突,一旦检测到冲突产生,就重试当前操作直到没有冲突为止。
    (2)AQS:AbstractQuenedSynchronizer抽象的队列式同步器,主要提供了一些锁操作的模板方法。J.U.C都是基于AQS实现的。
    1.CAS底层原理
    java中CAS操作都是通过Unsafe类实现的,Unsafe类是在sun.misc包下,不属于Java标准,但是很多Java基础类库的CAS操作都是使用Unsafe。包括一些被广泛使用的高性能开发库都是基于Unsafe类开发的,比如Netty、Hadoop、Kafka等。

     使用Unsafe的CAS进行一个变量进行修改,实质是直接操作变量的内存地址来实现的,CPU需要通过寻找变量的物理地址来读取或修改变量。那么我们需要先了解一下CPU是怎么寻址物理地址的,这会涉及到计算机底层的段地址、偏移量和逻辑地址(即物理地址)相关概念,下面以8086 CPU处理器为例来讲解。

    (1)段地址、偏移量和逻辑地址关系
    #段地址、偏移量和逻辑地址(即物理地址)的关系:
    (1)关系:8086 CPU的逻辑地址由段地址和段内偏移量两部分组成
    物理地址 = 段地址*16 + offset偏移量。

    (2)段地址
    8086 CPU能提供20位的地址信息,可直接对1M(1M = 1024 * 1024 = 2^20)个存储单元进行访问,而CPU内部可用来
    提供地址信息的寄存器都是16位,那怎样用16位寄存器来实现20位地址寻址呢? 8086 CPU的20位的地址信息可以对1M个内存单元进行访问,
    也就是说编址00000H~FFFFFH,而段寄器CS,DS,SS,ES即存放了这些地址的高4位。
    如:123456H,则某个段寄存器便会存储1234H高4位信息,这即为段地址。

    (3)偏移量:段内偏移地址就是移动后相对于段地址的偏移量。

    (4)逻辑地址(物理地址):物理地址就是地址总线上提供的20位地址信息。
    物理地址 = 段地址*10H + 段内偏移地址。段地址乘以10H是因为段地址当时是取高四位得到的,
    所以还原后要让段地址左移4位(10H = 10000B)。

    例如:(cs)= 20A8H,(offset)=2008H,则物理地址为20A8H*10H+2008H = 22A88H。
    (2)Unsafe中CAS实现原理
    Unsafe的CAS操作方法基本都是native方法,具体的实现是由C实现的,下面以compareAndSwapInt()方法为例看看处理器低层是怎么实现CAS操作的。

    #1.源码
    public final native boolean compareAndSwapInt(Object o,long offset,int expect,int update)

    #2.调用:该方法一般的调用形式是:object对象传的是this
    unsafe.compareAndSwapInt(this, Offset, expect, update)
    CAS操作的比较原理:

    1)CPU的寻址方式:处理器会找到this寄存器cs里的段地址,通过段地址*16 + Offset偏移量得到物理地址。
    2)将物理地址处的存储值 和 期望值expect比较,如果相等,则进行update操作后返回true;不相等返回false。
    3)比较操作 和 更新赋值操作是原子进行的,CPU处理器是通过lock锁实现的(总线锁和缓存锁)。
    CAS操作在intel X86处理器的源代码的片段

    inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest,jint compare_value) {
    int mp = os::is_MP();
    __asm {
    mov edx, dest
    mov ecx, exchange_value
    mov eax, compare_value
    #程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀,lock锁的实现:总线锁和缓存锁
    LOCK_IF_MP(mp)
    cmpxchg dword ptr [edx], ecx
    }
    }
    CAS操作指令解析

    1)is_MP()函数判断当前处理器是多核,还是单核。
    2)如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(Lock Cmpxchg)。如果程序是在单处理器上运行,就省略lock前缀。
    intel处理器lock前缀作用

    1)确保对内存的读-改-写操作原子执行。处理器会使用总线锁 或 缓存锁来保持原子性。
    2)禁止该指令,与之前和之后的读和写指令重排序。
    3)把CPU写缓冲区中的所有数据刷新到内存中,使其它CPU缓存失效。
    2.AQS实现原理
    AQS全称为AbstractQueuedSynchronizer,AQS定义了一套多线程访问共享资源的同步器框架,为java并发同步组件提供统一的底层支持。常见的有:Lock、ReentrantLock、ReentrantReadWriteLock、CyclicBarrier、CountDownLatch、ThreadPoolExecutor等都是基于AQS实现的。

    AQS是一个抽象类,主要是通过继承的方式实现其模版方法,它本身没有实现任何的同步接口,仅仅是定义了同步状态的获取以及释放的方法来提供自定义的同步组件。

    (1)AQS的独占锁和共享锁
    独占锁:每次只能有一个线程持有锁,比如ReentrantLock就是以独占方式实现的互斥锁。

    共享锁:允许多个线程同时获取锁,并发访问共享资源,比如ReentrantReadWriteLock。

    (2)AQS内部实现
    AQS的实现依赖内部的FIFO的双向队列同步(只是一个虚拟的双向队列)和共享锁state变量。当线程竞争锁(state变量)失败时,就会把AQS把当前线程封装成一个Node加入到同步队列中,同时再阻塞该线程;当获取锁的线程释放锁以后,会从队列中唤醒一个阻塞的节点(线程)。 ASQ具体实现如下图:

    #1.AQS底层实现原理
    volatile + CAS + 一个虚拟的FIFO双向队列(CLH队列)。

    #2.AQS同步队列特点:
    (1)AQS同步队列内部维护的是一个FIFO的双向链表,双向链表可以从任意一个节点开始很方便的访问前驱和后继。
    (2)添加节点:每个Node其实是由线程封装的(node里面包含一个thread变量),当线程竞争锁失败后会封装成Node加入到ASQ同步队列尾部。
    (3)移除节点:AQS同步队列中每个node都在等待同一个资源(锁状态state变量)释放,锁释放后每次只有队列的第二个节点(head的后继节点)才有机会抢占锁,如果成功获取锁,则此节点晋升为头节点。
    (3)AQS的源码分析
    为了方便后面的源码理解,我们先了解一下AQS和Node类的主要内部变量的功能。

    //1.AQS的主要结构
    public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer{
    //AQS的双向链表队列的头节点
    private transient volatile Node head;
    //AQS的双向链表队列的未节点
    private transient volatile Node tail;
    //锁的状态
    private volatile int state;
    //AQS的内部类node节点,由线程封装而来的
    static final class Node {}
    }

    //2.Node类的内部结构
    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;
    //节点等待状态-条件等待状态:节点在等待队列Condition中,当Condition调用了signal()后节点将会从等待队列中转移到同步队列中,参与锁获取。
    static final int CONDITION = -2;
    //表示下一次共享式同步状态获取将会无条件地传播下去
    static final int PROPAGATE = -3;
    //等待状态
    volatile int waitStatus;
    //等待队列中的后继节点
    Node nextWaiter;
    //前驱节点
    volatile Node prev;
    //后继节点
    volatile Node next;
    //获取锁状态的线程
    volatile Thread thread;
    }
    为了弄清楚AQS的基本框架,我们以ReentrantLock的非公平锁为例来分析AQS的源码。先通过一个示例看看ReentrantLock锁是怎么使用的,后面我们再进一步分析ReentrantLock加锁 和 解锁的过程。

    @Test
    public void testLock() {
    ReentrantLock lock = new ReentrantLock();
    try {
    lock.lock();
    System.out.println("其他同步业务操作");
    } catch (Exception e) {
    e.printStackTrace();
    }finally {
    lock.unlock();
    }
    }
    ReentrantLock构造器

    //1.默认情况下ReentrantLock使用的非公平锁NonfairSync
    public ReentrantLock() {
    sync = new NonfairSync();
    }

    //2.可以指定ReentrantLock使用NofairSync(非公平锁) 还是 FailSync(公平锁)
    public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
    }
    ReentrantLock中的非公平锁(NofairSync)、公平锁(FailSync)都是Sync这个类的实现,Sync又是继承了AQS抽象类。

    公平锁:表示所有线程严格按照FIFO来获取锁。
    非公平锁:可以直接参与抢占锁,也就是说不管当前同步队列上是否存在其他线程等待,新线程都有机会抢占锁。
    1)加锁过程
    ReentrantLock中非公平锁的加锁lock()方法源码调用过程的时序图如下:

    从上图可以看出,当锁获取失败时,会调用addWaiter()方法将当前线程封装成Node节点加入到AQS同步队列尾部;然后再调用acquireQueued()方法将加入尾部队列的node进行阻塞。

    ReentrantLock.lock()源码

    //ReentrantLock加锁时,获取锁的入口是调用抽象类sync里面的方法。sync的具体实现在ReentrantLock构造时已经指定实例:NofairSync(非公平锁) 或 FailSync(公平锁)。
    public void lock() {
    sync.lock();
    }
    NofairSync.lock()源码实现

    //(1)获取锁lock()
    final void lock() {
    //非公平锁只要加锁,当前线程就会去竞争锁state,通过compareAndSetState()尝试锁的竞争
    if (compareAndSetState(0, 1))
    //获取锁成功,设置锁拥有的线程
    setExclusiveOwnerThread(Thread.currentThread());
    else
    //获取失败
    acquire(1);
    }

    //(2)使用CAS进行获取锁的状态,stateOffset是AQS里锁状态state的偏移地址
    protected final boolean compareAndSetState(int expect, int update) {
    //原子性操作:通过cas乐观锁的方式来做比较并替换。如果当前内存中的state的值和预期值expect相等,则替换为update。更新成功返回true,否则返回false。
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }
    (1)锁状态state在AQS是一个volatile的int变量,当state=0时,表示锁没被占用,其它线程可以获取锁资源;state>0时,表示锁已经被占用。AQS使用了volatile+CAS来保证锁状态state的原子性和可见性。

    (2)ReentrantLock是可重入锁,同一个线程多次获得同步锁的时候,state会递增;释放锁时state会递减。比如重入3次,那么state=3,对应释放锁也会释放3次直到state=0后,其他线程才有资格获取锁。

    acquire(int arg)源码

    主要完成了锁状态获取、节点构造、加入到同步队列中以及同步队列中节点被唤醒时自旋获取锁资源的相关工作。同步锁状态获取流程,也就是acquire(int arg)方法调用流程如下图:

    (1)调用tryAcquire再尝试锁的获取。

    (2)如果获取失败调用addWaiter将当前线程封装成node加到AQS同步队列的尾部。

    (3)最后再调用acquireQueued使节点以自旋方式来获取锁状态。如果获取不到则阻塞当前节点中的线程,而阻塞线程的唤醒主要依靠前驱节点的出队 或 阻塞线程被中断来实现。

    public final void acquire(int arg) {
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
    selfInterrupt();
    }
    tryAcquire()源码

    (1)tryAcquire方法在AQS中是个模板方法,具体实现在NonfairSync中。

    (2)方法主要作用是判断state锁是否被占用,如果没被占用会用CAS尝试获取锁;如果被占用了再判断是否是同一个线程锁重入,如果是重入锁就将重入锁的次数加1。

    protected final boolean tryAcquire(int acquires) {
    return nonfairTryAcquire(acquires);
    }

    final boolean nonfairTryAcquire(int acquires) {
    //先获取当前线程
    final Thread current = Thread.currentThread();
    //获取锁state状态
    int c = getState();
    //如果锁state=0说明锁没被占用,再用CAS尝试修改锁的状态来获取锁
    if (c == 0) {
    if (compareAndSetState(0, acquires)) {
    //获取锁成功后,设置锁拥有的线程
    setExclusiveOwnerThread(current);
    return true;
    }
    }
    //锁state被占用时,如果是同一个线程多次重入锁,则直接增加重入次数
    else if (current == getExclusiveOwnerThread()) {
    int nextc = c + acquires; //增加重入次数
    if (nextc < 0) // overflow溢出int的最大值时,抛出异常
    throw new Error("Maximum lock count exceeded");
    //重新设置锁state值
    setState(nextc);
    return true;
    }
    return false;
    }
    addWaiter()源码

    (1)addWaiter()方法是将当前线程封装成node,然后通过自旋使用CAS操作添加到AQS同步队列的尾部(tail)。

    (2)如果是AQS同步队列为空,表示第一次添加node,需要初始化AQS同步队列,即初始化队列的head头;如果是cas失败,则调用enq自旋将节点添加到AQS同步队列。

    private Node addWaiter(Node mode) {
    //将当前线程组装成一个node
    Node node = new Node(Thread.currentThread(), mode);
    Node pred = tail;
    //判断AQS同步队列是否需要初始化,只有第一次添加node时需要初始化head节点。
    if (pred != null) {
    //如果AQS不是空队列,会将新的node节点通过CAS添加到队里的尾部
    node.prev = pred;
    if (compareAndSetTail(pred, node)) {
    pred.next = node;
    return node;
    }
    }
    //如果队列为空或者cas失败,进入enq初始化队列或将节点添加到AQS同步队列中
    enq(node);
    return node;
    }

    //初始化队列或通过自旋将node添加到队列的尾部
    private Node enq(final Node node) {
    //自旋添加节点到队列尾部
    for (;;) {
    Node t = tail;
    //AQS为空时使用CAS初始化队列
    if (t == null) {
    if (compareAndSetHead(new Node()))
    tail = head;
    } else {
    //队列不为空就将node节点追加到AQS同步队列的尾部
    node.prev = t;
    if (compareAndSetTail(t, node)) {
    t.next = node;
    return t;
    }
    }
    }
    }
    addWaiter通过自旋向队列中添加节点时,会涉及到三步操作,例如下图向只有两个node的AQS同步队列中添加一个node3

    第一步:先将旧队列的尾节点node2赋给新加节点node3的前驱prev,即node3.prev指向节点node2。

    第二步:再通过CAS操作,将tail重新指向新加节点node3。

    第三步:如果上面的CAS成功,将旧队列尾节点node2的next指向新加节点node3,即完成双向指针操作。

    acquireQueued()源码

    节点进入同步队列之后,会判断节点(或者说线程)的前驱prev节点是不是head节点,如果是就获取锁资源方法结束;如果不是节点就被park()挂起(即阻塞)。当节点被unpark()唤醒时,会继续判断前驱prev节点是不是head节点,如果是就获取锁资源方法结束;如果不是又被park()挂起,等待下一次的park()。所以等待队列中的节点就一直这样循环(自旋)下去,直到获取锁成功。等待队列中的节点可以理解成如下图的“自旋”:

    (1)acquireQueued方法主要是进行自旋抢占锁的操作 ,如果当前节点node的前驱节点抢占锁失败时,会根据前驱节点等待状态(waitStatus)来决定是否需要挂起线程。

    (2)每个节点线程在“自旋”中尝试获取同步状态,而只有前驱节点是头节点才能够尝试获取锁的同步状态(state)。

    (3)如果抢占锁的操作抛出异常,会通过cancelAcquire方法取消获得锁的操作,并将当前node进行出队操作。

    final boolean acquireQueued(final Node node, int arg) {
    //操作失败标记,操作出现异常时需要将node移除队列
    boolean failed = true;
    try {
    //中断标记位
    boolean interrupted = false;
    //自旋
    for (;;) {
    //获取当前节点的前驱prev节点
    final Node p = node.predecessor();
    //如果前驱prev节点是head节点时,才有资格进行锁抢占
    if (p == head && tryAcquire(arg)) {
    //前驱prev节点抢占锁成功后,重新设置head头:将旧head的后继节点next设置为新head头,所以锁释放后每次只有队列的第二个节点(head的后继节点)才有机会抢占锁。
    setHead(node);
    //断开旧head节点:凡是head节点head.thread与head.prev永远为null, 但是head.next不为null,所以只需要断开head.next。
    p.next = null; // help GC
    failed = false;
    return interrupted;
    }
    //如果获取锁失败,会根据节点等待状态waitStatus来决定是否挂起线程
    if (shouldParkAfterFailedAcquire(p, node) &&
    parkAndCheckInterrupt())//若前面为true,则执行挂起,待下次唤醒的时候会检测中断的标志
    interrupted = true;
    }
    } finally {
    //如果抛出异常则取消锁的获取,再将node进行出队操作
    if (failed)
    cancelAcquire(node);
    }
    }

    //(1).shouldParkAfterFailedAcquire()方法主要作用是:把队列中node前的CANCELLED的节点给剔除掉。
    private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    //获取node前继节点pred的等待状态
    int ws = pred.waitStatus;
    //如果是SIGNAL状态,意味着node前继节点的线程需要被unpark唤醒
    if (ws == Node.SIGNAL)
    return true;
    //如果node前继节点pred的等待状态大于0,即为CANCELLED状态时,则会从pred节点往前一直找到一个没有被CANCELLED的节点设置为pred,即当前node节点的前驱节点。在寻找的过程中会把队列中CANCELLED的节点剔除掉(下面会用图进行讲解)
    if (ws > 0) {
    do {
    node.prev = pred = pred.prev;
    } while (pred.waitStatus > 0);
    pred.next = node;
    } else {
    //如果node的前继节点pred为初始状态0或者“共享锁”状态,则设置前继节点为SIGNAL状态。
    compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
    }

    //(2).parkAndCheckInterrupt()阻塞当前线程,等待唤醒的时候再检测中断的标志
    private final boolean parkAndCheckInterrupt() {
    //park阻塞当前node线程,等待unpark唤醒
    LockSupport.park(this);
    //线程被唤醒时,再判断是否是中断状态
    return Thread.interrupted();
    }
    shouldParkAfterFailedAcquire()方法中while循环的作用是:从当前节点的node.prev向前遍历,一直找到一个没有被CANCELLED的节点,在寻找过程中会把状态为CANCELLED的节点给剔除掉。如下图当前节点是node4,AQS同步队列里节点node2是取消状态(CANCELLED)时,就会进入该while循环移除掉节点node2。

    到此ReentrantLock加锁的整个过程就分析完了,在加锁过程中有几个地方需要注意

    (1)当同一个线程多次重入锁,直接增加重入次数,即将锁的状态state加1。

    (2)addWaiter使用自旋 + CAS将新node添加到AQS同步队列中,其中自旋的目的是为了防止CAS失败。

    (3)每次只有head节点才有资格进行抢占锁资源(state),head释放锁后只有队列的第二个节点(head的后继节点)才有机会抢占锁。

    (4)节点会根据等待状态(waitStatus)来决定是否挂起线程,在进行挂起线程操作时,会移除掉状态为CANCELLED的节点。

    获取锁过程总结:在获取同步状态时,AQS同步器维护一个同步队列,获取锁状态失败的线程都会被加入到队列尾部、并在队列中进行自旋;移出队列(或停止自旋)的条件是前驱节点成为头节点、且成功获取了同步状态。在释放同步状态时,同步器调用tryRelease(int arg)方法释放同步状态,然后唤醒头节点的后继节点。

    2)解锁过程
    相对于加锁过程,解锁就更为简单了,ReentrantLock中非公平锁的解锁unlock()方法调用的时序图如下:

    ReentrantLock.unlock()源码

    public void unlock() {
    //将锁的状态state减1
    sync.release(1);
    }
    release()源码

    调用这个release()方法干了两件事:1.释放锁 ;2.唤醒AQS同步队列里一个节点(park线程)。

    public final boolean release(int arg) {
    //尝试释放锁state
    if (tryRelease(arg)) {
    //如果释放锁成功,唤醒同步队列里一个节点(park线程)
    Node h = head;
    if (h != null && h.waitStatus != 0) //如果head是初始化节点时,不需要唤醒其他线程
    //通过unpark唤醒同步队列里一个阻塞线程
    unparkSuccessor(h);
    return true;
    }
    return false;
    }
    tryRelease()源码

    tryRelease释放锁时,如果是锁重入情况下释放锁,则减少锁state的重入次数(即减少state的值),直到锁的状态state=0时,才真正的释放掉锁资源,其他线程才能有资格获取锁。例如一个线程重入锁3次,即锁状态state=3,每执行tryRelease一次就释放锁一次state就减1,直到state=0时锁资源才真正释放掉。

    protected final boolean tryRelease(int releases) {
    //同一个线程每释放一次锁state就减1
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
    throw new IllegalMonitorStateException();
    //锁释放标志
    boolean free = false;
    //锁state=0时才真正释放掉锁,将锁持有线程设置为null
    if (c == 0) {
    free = true;
    setExclusiveOwnerThread(null);
    }
    //更新锁的状态(不需要CAS操作,因为释放锁操作是已经获得锁的情况下进行的)
    setState(c);
    return free;
    }
    unparkSuccessor()源码

    unparkSuccessor就是真正要释放了后,传入head节点唤醒下一个节点线程。线程唤醒逻辑可以总结成一下几点:

    (1)如果head节点其后继next节点waitStatus在等待唤醒状态(SIGNAL),则直接unpark后继节点(同步队列第二个节点) 。

    (2)如果head节点其后继next节点waitStatus不是在等待状态(SIGNAL),就从队列尾部向前遍历找到一个waitStatus在等待唤醒状态的节点进行唤醒 。留一个思考:为什么是从队列尾部向前遍历,而不是从前向尾部遍历?

    //传入的参数node就是head节点
    private void unparkSuccessor(Node node) {
    //获取节点的等待状态
    int ws = node.waitStatus;
    if (ws < 0)
    compareAndSetWaitStatus(node, ws, 0);
    Node s = node.next;
    //如果head的后继节点waitStatus为取消状态(CANCELLED)时,进行从队列尾部向前遍历寻找等待状态的node
    if (s == null || s.waitStatus > 0) {//判断后继节点是否为空或者是否是取消状态
    s = null;
    //循环遍历
    for (Node t = tail; t != null && t != node; t = t.prev)
    if (t.waitStatus <= 0)
    s = t;
    }
    //unpark唤醒下一个线程
    if (s != null)
    LockSupport.unpark(s.thread);
    }
    unparkSuccessor()方法里循环遍历从队列尾部向前遍历原因是防止死循环。因为在锁竞争acquireQueued()方法中,异常处理cancelAcquire()方法中最后的node.next = node操作,会出现如下图的环状结构导致死循环。

    到此lock加锁和解锁的源码就分析结束了,看了这么多AQS源码最值得借鉴的两个思路就是:第一使用了CAS + volatile来保证锁state操作的原子性和可见性;第二使用一个虚拟的FIFO双向队列来解决线程冲突问题。研究源码会让自己借鉴很多思维方式,并运用到自己的代码中,时间久了你会发现level提升了不少。

    每次痛苦的挣扎,都会迎来新的进步,越是吃力的时候越要坚持,过了这一阵会发现自己能力提升了不少,千万不要在温水中呆久了。
    ————————————————
    版权声明:本文为CSDN博主「有盐先生」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
    原文链接:https://blog.csdn.net/Seky_fei/article/details/106111832

  • 相关阅读:
    webpack打包的项目,如何向项目中注入一个全局变量
    移动端微信H5兼容ios的自动播放音视频
    移动端H5解决键盘弹出时之后滚动位置发生变化的问题
    微信网页开发,如何在H5页面中设置分享的标题,内容以及缩略图
    React实现组件缓存的一种思路
    React编写一个移动H5的纵向翻屏组件
    如何手写一个react项目生成工具,并发布到npm官网
    Puppeteer爬取单页面网站的数据示例
    modelsim中objects窗口为空的解决办法
    Lattice Diamond与modelsim联合仿真环境设置
  • 原文地址:https://www.cnblogs.com/hanease/p/14897445.html
Copyright © 2011-2022 走看看