• 解析ReentrantLock实现原理


      在Java中通常实现锁有两种方式,一种是synchronized关键字,另一种是Lock(Lock的实现主要有ReentrantLock、ReadLock和WriteLock)。synchronized是基于JVM层面实现的,而Lock是基于JDK层面实现的。synchronized是基于Monitor实现的,ReentrantLock是基于AQS实现的,AQS的基础又是CAS。

      ReentrantLock重入锁,是实现Lock接口的一个类,也是在实际编程中使用频率很高的一个锁,支持重入性,表示能够对共享资源能够重复加锁,即当前线程获取该锁再次获取不会被阻塞。与此同时,ReentrantLock还支持公平锁和非公平锁两种方式。

      在java关键字synchronized隐式支持重入性,synchronized通过获取自增,释放自减的方式实现重入,在JDK1.5之后synchronized引入了偏向锁,轻量级锁和重量级锁,从而大大的提高了synchronized的性能。

      对于使用者的直观体验上Lock是比较复杂的,需要lock和realse,如果忘记释放锁就会产生死锁的问题,所以,通常需要在finally中进行锁的释放。但是synchronized的使用十分简单,只需要对自己的方法或者关注的同步对象或类使用synchronized关键字即可。但是对于锁的粒度控制比较粗,同时对于实现一些锁的状态的转移比较困难。

    Locks包结构

      Lock的实现主要有ReentrantLock、ReadLock和WriteLock,ReentrantLock类在java.util.concurrent.locks包中,它的上一级的包java.util.concurrent主要是常用的并发控制类。

                              

      下面是ReentrantLock的UML图,从图中可以看出,ReentrantLock实现Lock接口,在ReentrantLock中Sync类引用了AbstractQueuedSynchronizer的子类,所有的同步操作都是依靠AbstractQueuedSynchronizer(队列同步器)实现。

            

    AbstractQueuedSynchronizer结构

      ReentrantLock实现的前提就是AbstractQueuedSynchronizer,简称AQS,是java.util.concurrent的核心,CountDownLatch、FutureTask、Semaphore、ReentrantLock等都有一个内部类是这个抽象类的子类。 由于AQS是基于FIFO队列的实现,因此必然存在一个个节点,Node就是一个节点,Node里面有:

    属    性 定    义
    Node SHARED = new Node() 表示Node处于共享模式
    Node EXCLUSIVE = null 表示Node处于独占模式
    int CANCELLED = 1 因为超时或者中断,Node被设置为取消状态,被取消的Node不应该去竞争锁,只能保持取消状态不变,不能转换为其他状态,处于这种状态的Node会被踢出队列,被GC回收
    int SIGNAL = -1 表示这个Node的继任Node被阻塞了,到时需要通知它
     int CONDITION = -2 表示这个Node在条件队列中,因为等待某个条件而被阻塞 
    int PROPAGATE = -3 使用在共享模式头Node有可能处于这种状态, 表示锁的下一次获取可以无条件传播
     int waitStatus 0,新Node会处于这种状态 
     Node prev 队列中某个Node的前驱Node 
     Node next 队列中某个Node的后继Node 
    Thread thread 这个Node持有的线程,表示等待锁的线程
    Node nextWaiter 表示下一个等待condition的Node

      AbstractQueuedSynchronizer中有其余的变量和方法:

    属性/方法 含    义
    Thread exclusiveOwnerThread 这个是AQS父类AbstractOwnableSynchronizer的属性,表示独占模式同步器的当前拥有者
    Node 上面已经介绍过了,FIFO队列的基本单位
    Node head FIFO队列中的头Node
    Node tail FIFO队列中的尾Node
    int state 同步状态,0表示未锁
    int getState() 获取同步状态
    setState(int newState) 设置同步状态
    boolean compareAndSetState(int expect, int update)  利用CAS进行State的设置 
     long spinForTimeoutThreshold = 1000L 线程自旋等待的时间 
    Node enq(final Node node)  插入一个Node到FIFO队列中 
    Node addWaiter(Node mode) 为当前线程和指定模式创建并扩充一个等待队列
    void setHead(Node node) 设置队列的头Node
    void unparkSuccessor(Node node) 如果存在的话,唤起Node持有的线程
    void doReleaseShared() 共享模式下做释放锁的动作
    void cancelAcquire(Node node) 取消正在进行的Node获取锁的尝试
    boolean shouldParkAfterFailedAcquire(Node pred, Node node) 在尝试获取锁失败后是否应该禁用当前线程并等待
    void selfInterrupt() 中断当前线程本身
    boolean parkAndCheckInterrupt() 禁用当前线程进入等待状态并中断线程本身
    boolean acquireQueued(final Node node, int arg) 队列中的线程获取锁
    tryAcquire(int arg) 尝试获得锁(由AQS的子类实现它
    tryRelease(int arg) 尝试释放锁(由AQS的子类实现它
    isHeldExclusively() 是否独自持有锁
    acquire(int arg) 获取锁
    release(int arg) 释放锁
    compareAndSetHead(Node update) 利用CAS设置头Node
    compareAndSetTail(Node expect, Node update) 利用CAS设置尾Node
    compareAndSetWaitStatus(Node node, int expect, int update) 利用CAS设置某个Node中的等待状态

      上面列出了AQS中最主要的一些方法和属性。整个AQS是典型的模板模式的应用,设计得十分精巧,对于FIFO队列的各种操作在AQS中已经实现了,AQS的子类一般只需要重写tryAcquire(int arg)和tryRelease(int arg)两个方法即可

    ReentrantLock的lock实现

      ReentrantLock中有一个抽象类Sync:

    private final Sync sync;
    /**
         * Base of synchronization control for this lock. Subclassed
         * into fair and nonfair versions below. Uses AQS state to
         * represent the number of holds on the lock.
         */
        abstract static class Sync extends AbstractQueuedSynchronizer {
    }

      ReentrantLock根据传入构造方法的布尔型参数实例化出Sync的实现类FairSyncNonfairSync,分别表示公平的Sync和非公平的Sync。由于ReentrantLock我们用的比较多的是非公平锁,所以看下非公平锁是如何实现的。假设线程1调用了ReentrantLock的lock()方法,那么线程1将会独占锁,整个调用链十分简单:  

                    

      第一个获取锁的线程就做了两件事情:

        1、设置AbstractQueuedSynchronizer的state为1。

        2、设置AbstractOwnableSynchronizer的thread为当前线程。

      这两步做完之后就表示线程1独占了锁。然后线程2也要尝试获取同一个锁,在线程1没有释放锁的情况下必然是行不通的,所以线程2就要阻塞。那么,线程2如何被阻塞?看下线程2的方法调用链,这就比较复杂了:

                    

      代码流程如下:

       ReentrantLock调用lock方法:

    1 final void lock() {
    2     if (compareAndSetState(0, 1))
    3         setExclusiveOwnerThread(Thread.currentThread());
    4     else
    5     acquire(1);
    6 }

      首先线程2尝试利用CAS去判断state是不是0,是0就设置为1,当然这一步操作肯定是失败的,因为线程1已经将state设置成了1,所以第2行必定是false,因此线程2走第5行的acquire方法:

    /**
     * Acquires in exclusive mode, ignoring interrupts.  Implemented
     * by invoking at least once {@link #tryAcquire},
     * returning on success.  Otherwise the thread is queued, possibly
     * repeatedly blocking and unblocking, invoking {@link
     * #tryAcquire} until success.  This method can be used
     * to implement method {@link Lock#lock}.
     *
     * @param arg the acquire argument.  This value is conveyed to
     *        {@link #tryAcquire} but is otherwise uninterpreted and
     *        can represent anything you like.
     * tips:此处主要是处理没有获取到锁的线程
     *   tryAcquire:重新进行一次锁获取和进行锁重入的处理。
     *      addWaiter:将线程添加到等待队列中。
     *   acquireQueued:自旋获取锁。      
     *      selfInterrupt:中断线程。
     *      三个条件的关系为and,如果 acquireQueued返回true,那么线程被中断selfInterrupt会中断线程
     */
    public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

      先走第一个判断条件尝试获取一次锁,如果获取的结果为false即失败,走第二个判断条件添加FIFO等待队列。所以先看一下tryAcquire方法做了什么,这个方法最终调用到的是Sync的nonfairTryAcquire方法:(ReentrantLock支持两种锁:公平锁和非公平锁。何谓公平性,是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求上的绝对时间顺序,满足FIFO。)

      AbstractQueuedSynchronizer为抽象方法,调用tryAcquire时,调用的为NonfairSync的tryAcquire。

    protected final boolean tryAcquire(int acquires) {
      return nonfairTryAcquire(acquires);
    }
    @ReservedStackAccess
    final boolean nonfairTryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();
       //1. 如果该锁未被任何线程占有,该锁能被当前线程获取
        if (c == 0) {
            if (compareAndSetState(0, acquires)) {
                setExclusiveOwnerThread(current);
                return true;
            }
        }
       //2.若被占有,检查占有线程是否是当前线程
        else if (current == getExclusiveOwnerThread()) {
         // 3. 再次获取,计数加一
            int nextc = c + acquires;
            if (nextc < 0) // overflow
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            return true;
        }
        return false;
    }

      具体初始化公平锁和非公平锁在ReentrantReadWriteLock的初始化代码中:

    public ReentrantReadWriteLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
        readerLock = new ReadLock(this);
        writerLock = new WriteLock(this);
    }

      由于state是volatile的,所以state对线程2具有可见性,线程2拿到最新的state,再次判断一下能否持有锁(可能线程1同步代码执行得比较快,这会儿已经释放了锁),不可以就返回false。

      注意一下else if这段代码的作用是让某个线程可以多次调用同一个ReentrantLock,每调用一次给state+1,由于某个线程已经持有了锁,所以这里不会有竞争,因此不需要利用CAS设置state相当于一个偏向锁。从这段代码可以看到,nextc每次加1,当nextc<0的时候抛出error,那么同一个锁最多能重入Integer.MAX_VALUE次,也就是2147483647。

      然后就走到if的第二个判断里面了,先走AQS的addWaiter方法:

    private Node addWaiter(Node mode) {
        Node node = new Node(Thread.currentThread(), mode);
        // Try the fast path of enq; backup to full enq on failure
        Node pred = tail;
        if (pred != null) {
            node.prev = pred;
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        enq(node);
        return node;
    }

      先创建一个当前线程的Node,模式为独占模式(因为传入的mode是一个NULL),再判断一下队列上有没有节点,没有就创建一个队列,因此走enq方法:

     1 private Node enq(final Node node) {
     2     for (;;) {
     3         Node t = tail;
     4         if (t == null) { // Must initialize
     5             Node h = new Node(); // Dummy header
     6             h.next = node;
     7             node.prev = h;
     8             if (compareAndSetHead(h)) {
     9                 tail = node;
    10                 return h;
    11             }
    12         }
    13         else {
    14             node.prev = t;
    15             if (compareAndSetTail(t, node)) {
    16                 t.next = node;
    17                 return t;
    18             }
    19         }
    20     }
    21 }

      这个方法其实画一张图应该比较好理解,形成一个队列之后应该是这样的:

                  

      每一步都用图表示出来了,由于线程2所在的Node是第一个要等待的Node,因此FIFO队列上肯定没有内容,tail为null,走的就是第4行~第10行的代码逻辑。这里用了CAS设置头Node,当然有可能线程2设置头Node的时候CPU切换了,线程3已经把头Node设置好了形成了上图所示的一个队列,这时线程2再循环一次获取tail,由于tail是volatile的,所以对线程2可见,线程2看见tail不为null,就走到了13行的else里面去往尾Node后面添加自身。整个过程下来,形成了一个双向队列。最后走AQS的acquireQueued(node, 1):

     1 final boolean acquireQueued(final Node node, int arg) {
     2     try {
     3         boolean interrupted = false;
     4         for (;;) {
     5             final Node p = node.predecessor();
     6             if (p == head && tryAcquire(arg)) {
     7                 setHead(node);
     8                 p.next = null; // help GC
     9                 return interrupted;
    10             }
    11             if (shouldParkAfterFailedAcquire(p, node) &&
    12                 parkAndCheckInterrupt())
    13                 interrupted = true;
    14         }
    15     } catch (RuntimeException ex) {
    16         cancelAcquire(node);
    17         throw ex;
    18     }
    19 }

      此时再做判断,由于线程2是双向队列的真正的第一个Node(前面还有一个h),所以第5行~第10行再次判断一下线程2能不能获取锁(可能这段时间内线程1已经执行完了把锁释放了,state从1变为了0),如果还是不行,先调用AQS的shouldParkAfterFailedAcquire(p, node)方法:

     1 private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
     2     int s = pred.waitStatus;
     3     if (s < 0)
     4         /*
     5          * This node has already set status asking a release
     6          * to signal it, so it can safely park
     7          */
     8         return true;
     9     if (s > 0) {
    10         /*
    11          * Predecessor was cancelled. Skip over predecessors and
    12          * indicate retry.
    13          */
    14     do {
    15     node.prev = pred = pred.prev;
    16     } while (pred.waitStatus > 0);
    17     pred.next = node;
    18 }
    19     else
    20         /*
    21          * Indicate that we need a signal, but don't park yet. Caller
    22          * will need to retry to make sure it cannot acquire before
    23          * parking.
    24          */
    25          compareAndSetWaitStatus(pred, 0, Node.SIGNAL);
    26     return false;
    27 }

      这个waitStatus是h的waitStatus,很明显是0,所以此时把h的waitStatus设置为Noed.SIGNAL即-1并返回false。既然返回了false,上面的acquireQueued的11行if自然不成立,再走一次for循环,还是先尝试获取锁,不成功,继续走shouldParkAfterFailedAcquire,此时waitStatus为-1,小于0,走第三行的判断,返回true。然后走acquireQueued的11行if的第二个判断条件parkAndCheckInterrupt:

    private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);
        return Thread.interrupted();
    }
    public static void park(Object blocker) {
        Thread t = Thread.currentThread();
        setBlocker(t, blocker);
        unsafe.park(false, 0L);
        setBlocker(t, null);
    }

      最后一步,调用LockSupport的park方法阻塞住了当前的线程。至此,使用ReentrantLock让线程1独占锁、线程2进入FIFO队列并阻塞的完整流程已经整理出来了。

    ReentrantLock的unlock实现

      调用ReentrantLock的unlock方法:

    public void unlock() {
        sync.release(1);
    }

      走AQS的release:

    public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
               unparkSuccessor(h);
            return true;
        }
        return false;
    }

      先调用Sync的tryRelease尝试释放锁:

    protected final boolean tryRelease(int releases) {
        //1. 同步状态减1
        int c = getState() - releases;
        if (Thread.currentThread() != getExclusiveOwnerThread())
            throw new IllegalMonitorStateException();
        boolean free = false;
        if (c == 0) {
            //2. 只有当同步状态为0时,锁成功被释放,返回true
            free = true;
            setExclusiveOwnerThread(null);
        }
        // 3. 锁未被完全释放,返回false
        setState(c);
        return free;
    }

      首先,只有当c==0的时候才会让free=true,这和上面一个线程多次调用lock方法累加state是对应的,调用了多少次的lock()方法自然必须调用同样次数的unlock()方法才行,这样才把一个锁给全部解开。

      当一条线程对同一个ReentrantLock全部解锁之后,AQS的state自然就是0了,AbstractOwnableSynchronizer的exclusiveOwnerThread将被设置为null,这样就表示没有线程占有锁,方法返回true。代码继续往下走,上面的release方法的第四行,h不为null成立,h的waitStatus为-1,不等于0也成立,所以走第5行的unparkSuccessor方法:

     1 private void unparkSuccessor(Node node) {
     2     /*
     3      * Try to clear status in anticipation of signalling.  It is
     4      * OK if this fails or if status is changed by waiting thread.
     5      */
     6     compareAndSetWaitStatus(node, Node.SIGNAL, 0);
     7 
     8     /*
     9      * Thread to unpark is held in successor, which is normally
    10      * just the next node.  But if cancelled or apparently null,
    11      * traverse backwards from tail to find the actual
    12      * non-cancelled successor.
    13      */
    14     Node s = node.next;
    15     if (s == null || s.waitStatus > 0) {
    16         s = null;
    17         for (Node t = tail; t != null && t != node; t = t.prev)
    18             if (t.waitStatus <= 0)
    19                 s = t;
    20     }
    21     if (s != null)
    22         LockSupport.unpark(s.thread);
    23 }

      s即h的下一个Node,这个Node里面的线程就是线程2,由于这个Node不等于null,所以走21行,线程2被unPark了,得以运行。有一个很重要的问题是:锁被解了怎样保证整个FIFO队列减少一个Node呢?这是一个很巧妙的设计,又回到了AQS的acquireQueued方法了:

     1 final boolean acquireQueued(final Node node, int arg) {
     2     try {
     3         boolean interrupted = false;
     4         for (;;) {
     5             final Node p = node.predecessor();
     6             if (p == head && tryAcquire(arg)) {
     7                 setHead(node);
     8                 p.next = null; // help GC
     9                 return interrupted;
    10             }
    11             if (shouldParkAfterFailedAcquire(p, node) &&
    12                 parkAndCheckInterrupt())
    13                 interrupted = true;
    14         }
    15     } catch (RuntimeException ex) {
    16         cancelAcquire(node);
    17         throw ex;
    18     }
    19 }

      被阻塞的线程2是被阻塞在第12行,注意这里并没有return语句,也就是说,阻塞完成线程2依然会进行for循环然后,阻塞完成了,线程2所在的Node的前驱Node是p,线程2尝试tryAcquire,成功,然后线程2就成为了head节点了,把p的next设置为null,这样原头Node里面的所有对象都不指向任何块内存空间,h属于栈内存的内容,方法结束被自动回收,这样随着方法的调用完毕,原头Node也没有任何的引用指向它了,这样它就被GC自动回收了。此时,遇到一个return语句,acquireQueued方法结束,后面的Node也是一样的原理。

      这里有一个细节,看一下setHead方法:

    private void setHead(Node node) {
        head = node;
        node.thread = null;
        node.prev = null;
    }

      setHead方法里面的前驱Node是Null,也没有线程,那么为什么不用一个在等待的线程作为Head Node呢?

      因为一个线程随时有可能因为中断而取消,而取消的话,Node自然就要被GC了,那GC前必然要把头Node的后继Node变为一个新的头而且要应对多种情况,这样就很麻烦。用一个没有thread的Node作为头,相当于起了一个引导作用,因为head没有线程,自然也不会被取消。

      再看一下上面unparkSuccessor的14行~20行,就是为了防止head的下一个node被取消的情况,这样,就从尾到头遍历,找出离head最近的一个node,对这个node进行unPark操作。

    compareAndSetState锁获取

    /**
     * Atomically sets synchronization state to the given updated
     * value if the current state value equals the expected value.
     * This operation has memory semantics of a <tt>volatile</tt> read
     * and write.
     *
     * @param expect the expected value
     * @param update the new value
     * @return true if successful. False return indicates that the actual
     *         value was not equal to the expected value.
     *         
     * tips: 1.compareAndSetState的实现主要是通过Unsafe类实现的。
     *       2.之所以命名为Unsafe,是因为这个类对于JVM来说是不安全的,我们平时也是使用不了这个类的。
     *       3.Unsafe类内封装了一些可以直接操作指定内存位置的接口,是不是感觉和C有点像了?
     *       4.Unsafe类封装了CAS操作,来达到乐观的锁的争抢的效果
     */
    protected final boolean compareAndSetState(int expect, int update) {
        // See below for intrinsics setup to support this
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }

      接下来简单的看一下 compareAndSwapInt的实现:

    /**
     * Atomically update Java variable to <tt>x</tt> if it is currently
     * holding <tt>expected</tt>.
     * @return <tt>true</tt> if successful
     */
    public final native boolean compareAndSwapInt(Object o, long offset,
                                                  int expected,
                                                  int x);

      一个native方法,以CAS的方式将制定字段设置为指定的值。这个方法可能是用java实现不了,只能依赖JVm底层的C代码实现。下面看看操作的stateOffset:

    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long stateOffset;
    private static final long headOffset;
    private static final long tailOffset;
    private static final long waitStatusOffset;
    private static final long nextOffset;
    
    static {
        try {
            //这个方法很有意思,主要的意思是获取AbstractQueuedSynchronizer的state成员的偏移量
            //通过这个偏移量来更新state成员,另外state是volatile的来保证可见性。
            stateOffset = unsafe.objectFieldOffset
                (AbstractQueuedSynchronizer.class.getDeclaredField("state"));
            headOffset = unsafe.objectFieldOffset
                (AbstractQueuedSynchronizer.class.getDeclaredField("head"));
            tailOffset = unsafe.objectFieldOffset
                (AbstractQueuedSynchronizer.class.getDeclaredField("tail"));
            waitStatusOffset = unsafe.objectFieldOffset
                (Node.class.getDeclaredField("waitStatus"));
            nextOffset = unsafe.objectFieldOffset
                (Node.class.getDeclaredField("next"));
    
        } catch (Exception ex) { throw new Error(ex); }
    }

      stateOffset 是AbstractQueuedSynchronizer内部定义的一个状态量,AbstractQueuedSynchronizer是线程的竞态条件,所以只要某一个线程CAS改变状态成功,同时在没有释放的情况下,其他线程必然失败。
      对于竞争成功的线程会调用 setExclusiveOwnerThread方法:

    /**
     * The current owner of exclusive mode synchronization.
     */
    private transient Thread exclusiveOwnerThread;
    
    /**
     * Sets the thread that currently owns exclusive access. A
     * <tt>null</tt> argument indicates that no thread owns access.
     * This method does not otherwise impose any synchronization or
     * <tt>volatile</tt> field accesses.
     */
    protected final void setExclusiveOwnerThread(Thread t) {
        exclusiveOwnerThread = t;
    }

      这个实现是比较简单的,只是获取当前线程的引用,令AbstractOwnableSynchronizer中的exclusiveOwnerThread引用到当前线程。竞争失败的线程,会调用acquire方法,这个方法也是ReentrantLock设计的精华之处(参考ReentrantLock的Lock实现)。

    ReentrantLock其他方法的实现

      如果能理解ReentrantLock的实现方式,那么你会发现ReentrantLock中其余一些方法的实现还是很简单的,从JDK API关于ReentrantLock方法的介绍这部分,举几个例子:

    1、int getHoldCount()

    //获取ReentrantLock的lock()方法被调用了几次,就是state的当前值
    final int getHoldCount() {
        return isHeldExclusively() ? getState() : 0;
    }

    2、Thread getOwner()

    //获取当前占有锁的线程,就是AbstractOwnableSynchronizer中exclusiveOwnerThread的值
    final Thread getOwner() {
        return getState() == 0 ? null : getExclusiveOwnerThread();
    }

    3、Collection<Thread> getQueuedThreads()

    //从尾到头遍历一下,添加进ArrayList中
    public final Collection<Thread> getQueuedThreads() {
        ArrayList<Thread> list = new ArrayList<Thread>();
        for (Node p = tail; p != null; p = p.prev) {
            Thread t = p.thread;
            if (t != null)
                list.add(t);
        }
        return list;
    }

    4、int getQueuedLength()

    //从尾到头遍历一下,累加n。当然这个方法和上面那个方法可能是不准确的,因为遍历的时候可能别的线程又往队列尾部添加了Node。
    public final int getQueueLength() {
        int n = 0;
        for (Node p = tail; p != null; p = p.prev) {
            if (p.thread != null)
                ++n;
        }
        return n;
    }
  • 相关阅读:
    sql:除非另外还指定了 TOP 或 FOR XML,否则,ORDER BY 子句在视图、内联函数、派生表、子查询
    [转]sql:除非另外还指定了 TOP 或 FOR XML,否则,ORDER BY 子句在视图、内联函数、派生表、子查询
    [转]IIS6 伪静态 IIS文件类型映射配置方法 【图解】
    IIS6 伪静态 IIS文件类型映射配置方法 【图解】
    [转]正则表达式的多行模式与单行模式
    正则表达式的多行模式与单行模式
    [原]MS SQL表字段自增相关的脚本
    MS SQL表字段自增相关的脚本
    angular学习笔记(六)-非入侵式javascript
    angular学习笔记(五)-阶乘计算实例(3)
  • 原文地址:https://www.cnblogs.com/jing99/p/10618675.html
Copyright © 2020-2023  润新知