java.util.concurrent.locks包下,包含了多种锁,ReentrantLock独占锁、ReentrantReadWriteLock读写锁等,还有java.util.concurrent下的Semaphore、CountDownLatch都是基于AQS实现的。

AQS是一个抽象类,但事实上它并不包含任何抽象方法。AQS将一些需要子类覆写的方法都设计成protect方法,将其默认实现为抛出UnsupportedOperationException异常。如果子类使用到这些方法,但是没有覆写,则会抛出异常;如果子类没有使用到这些方法,则不需要做任何操作。

可重写方法:

  • tryAcquire(int) 尝试获取锁
  • tryRelease(int) 尝试释放锁
  • tryAcquireShared(int) 共享的方式尝试获取锁
  • tryReleaseShared(int) 共享的方式尝试释放锁
  • isHeldExclusively() 判断当前是否为独占锁

final方法:

  • getState
  • setState(int)
  • compareAndSetState(int, int)
  • setExclusiveOwnerThread(Thread.currentThread())  将该线程设置为当前锁的持有者

根据实现方式的不同,可以分为两种:独占锁和共享锁

  • 独占锁:ReentrantLock
  • 共享锁:CountDownLatch、CyclicBarrier、Semaphore
  • ReentrantReadWriteLock写的时候是独占锁,读的时候是共享锁

使用方法:

推荐作为静态内部类来继承AQS。例如ReentrantLock作为外部类实现Lock接口,静态抽象内部类Sync继承AQS。重写Lock接口方法时,是直接调用Sync类的实现类公平锁NonfairSync或非公平锁FairSync内的方法来完成相应逻辑。

  • 公平锁:线程获取锁的顺序和调用lock的顺序一样,FIFO;
  • 非公平锁:线程获取锁的顺序和调用lock的顺序无关,全凭运气。

AQS的3部分:

state:

private volatile int state;
//volatile,state为0表示锁没有被占用,可以把state变量当做是当前持有该锁的线程数量。

队列:

一个FIFO的双向链表,这种结构的特点是每个数据结构都有两个指针,分别指向直接的后继节点和直接前驱节点。所以双向链表可以从任意一个节点开始很方便的访问前驱和后继。

每个Node其实是由线程封装,当线程争抢锁失败后会封装成Node加入到队列中去。

Node主要属性:

// 节点所代表的线程
volatile Thread thread;// 双向链表,每个节点需要保存自己的前驱节点和后继节点的引用
volatile Node prev;
volatile Node next;// 线程所处的等待锁的状态,初始化时,该值为0
volatile int waitStatus;
static final int CANCELLED =  1;
static final int SIGNAL    = -1;
static final int CONDITION = -2;
static final int PROPAGATE = -3;// 该属性用于条件队列或者共享锁
Node nextWaiter;
  • CANCELLED:值为1,在同步队列中等待的线程等待超时或被中断,需要从同步队列中取消该Node的结点,其结点的waitStatus为CANCELLED,即结束状态,进入该状态后的结点将不会再变化。
  • SIGNAL:值为-1,被标识为该等待唤醒状态的后继结点,当其前继结点的线程释放了同步锁或被取消,将会通知该后继结点的线程执行。说白了,就是处于唤醒状态,只要前继结点释放锁,就会通知标识为SIGNAL状态的后继结点的线程执行。
  • CONDITION:值为-2,与Condition相关,该标识的结点处于等待队列中,结点的线程等待在Condition上,当其他线程调用了Condition的signal()方法后,CONDITION状态的结点将从等待队列转移到同步队列中,等待获取同步锁。
  • PROPAGATE:值为-3,与共享模式相关,在共享模式中,该状态标识结点的线程处于可运行状态。

队列的头节点和尾节点

// 头节点,不代表任何线程,是一个哑结点
private transient volatile Node head;// 尾节点,每一个请求锁的线程会加到队尾
private transient volatile Node tail;

CAS(Compare and Swap)操作:

JAVA使用 循环CAS来实现原子操作,原子操作意为”不可被中断的一个或一系列操作”,保证只有一个线程操作数据。

悲观锁与乐观锁:

  • 悲观锁: 假定会发生并发冲突,所以当某个线程获取共享资源时,会阻止别的线程获取共享资源。也称独占锁或者互斥锁,例如synchronized同步锁。
  • 乐观锁: 假设不会发生并发冲突,只有在最后更新共享资源的时候会判断一下在此期间有没有别的线程修改了这个共享资源。如果发生冲突就重试,直到没有冲突,更新成功。CAS就是一种乐观锁实现方式。

CAS的思想很简单:三个参数,一个当前内存值V、旧的预期值A、即将更新的值B,当且仅当预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。循环CAS实现的基本思路就是循环进行CAS操作直到成功为止。

Java中的CAS功能是通过Unsafe类来实现的。Java并发包中java.util.concurrent 大量使用了这种操作,来保证线程安全。compareAndSetState(int, int)方法就是通过Unsafe实现的,而setState就是线程不安全的。

除了try*()方法外,AQS自身实现了诸多的如acquire和doAcquire()等方法,他们之间的区别在于try*()方法代表一次尝试性的获取锁操作,如果获取到了就拿到了锁,否则直接返回。而AQS自身实现的acquire和doAcquire()等方法如果获取不到锁会能够进入同步/等待队列中阻塞等待进行锁的争夺,直到拿到了锁返回。对于共享锁,try*()也会进行自旋获取,因为共享锁可以被多个线程持有。

下面通过acquire方法来分析AQS怎么获取锁:

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

tryAcquire是子类获取锁应该实现的方法,一般在里面判断state值,state==0时就可以获取到锁,通过下面方法获取锁,然后返回true,否则返回false,公平锁的话会再判断自己有没有前驱节点等。

setExclusiveOwnerThread(current); // 将当前线程设置为占用锁的线程

当tryAcquire获取锁失败时,&&前面为true,才会调用addWaiter方法,将当前线程包装成Node,加到等待锁的队列中去, 因为是FIFO队列, 所以加在队尾。

private Node addWaiter(Node mode) {Node node = new Node(Thread.currentThread(), mode); //将当前线程包装成Node// 这里我们用注释的形式把Node的构造函数贴出来// 因为传入的mode值为Node.EXCLUSIVE,所以节点的nextWaiter属性被设为null/*static final Node EXCLUSIVE = null;Node(Thread thread, Node mode) {     // Used by addWaiterthis.nextWaiter = mode;this.thread = thread;}*/Node pred = tail;// 如果队列不为空, 则用CAS方式将当前节点设为尾节点if (pred != null) {node.prev = pred;if (compareAndSetTail(pred, node)) {pred.next = node;return node;}}enq(node); //将节点插入队列return node;
}

将一个节点node添加到sync queue末尾的三步:

  1. 设置node的前驱节点为当前的尾节点:node.prev = t
  2. 修改tail属性,使它指向当前节点
  3. 修改原来的尾节点,使它的next指向当前节点

添加有可能失败,原因可能是以下两种之一:

  1. 等待队列现在是空的,没有线程在等待。
  2. 其他线程在当前线程入队的过程中率先完成了入队,导致尾节点的值已经改变了,CAS操作失败。

失败的时候会调用一个enq(node)方法,在该方法中, 出现第一种情况时,该方法也负责在队列为空时, 初始化队列,然后使用了死循环, 即以自旋方式将节点插入队列,如果失败则不停的尝试, 直到成功为止,运用到了乐观锁的原理。

private Node enq(final Node node) {for (;;) {Node t = tail;// 如果是空队列, 首先进行初始化// 这里也可以看出, 队列不是在构造的时候初始化的, 而是延迟到需要用的时候再初始化, 以提升性能if (t == null) { // 注意,初始化时使用new Node()方法新建了一个dummy节点if (compareAndSetHead(new Node()))tail = head; // 这里仅仅是将尾节点指向dummy节点,并没有返回} else {// 到这里说明队列已经不是空的了, 这个时候再继续尝试将节点加到队尾node.prev = t;if (compareAndSetTail(t, node)) {t.next = node;return t;}}}
}

enq(node)方法运行可能会造成尾分叉既多个尾节点的现象,因为node.prev = t;可能被多个线程运行,后面if语句则是CAS操作保证了单线程运行。不过只是一种暂时的现象,因为线程不断循环保证入队。

enq(node)方法后返回到acquireQueued(addWaiter(Node.EXCLUSIVE), arg)方法。

该方法中将再次尝试去获取锁,因为如果当前节点的前驱节点就是HEAD节点,则可以再尝试获取锁。

final boolean acquireQueued(final Node node, int arg) {boolean failed = true;try {boolean interrupted = false;for (;;) {// 该方法用来查找并获取前置节点。final Node p = node.predecessor();// 在当前节点的前驱就是HEAD节点时, 再次尝试获取锁if (p == head && tryAcquire(arg)) {setHead(node);p.next = null; // help GCfailed = false;return interrupted;}//在获取锁失败后, 判断是否需要把当前线程挂起if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())interrupted = true;}} finally {if (failed)cancelAcquire(node);}
}

setHead方法将该节点设置成头节点,上一个头节点就被顶掉。以此来达成节点出队列的效果。

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

如果获取不到锁调用shouldParkAfterFailedAcquire,该方法用于决定在获取锁失败后, 是否将线程挂起,决定的依据就是前驱节点的waitStatus值。在独占锁的获取操作中,我们只用到了其中的两个——CANCELLED和SIGNAL。每一个节点最开始的时候waitStatus的值都被初始化为0。

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {int ws = pred.waitStatus; // 获得前驱节点的wsif (ws == Node.SIGNAL)// 前驱节点的状态已经是SIGNAL了,说明闹钟已经设了,可以直接睡了return true;if (ws > 0) {// 当前节点的 ws > 0, 则为 Node.CANCELLED 说明前驱节点已经取消了等待锁(由于超时或者中断等原因)// 既然前驱节点不等了, 那就继续往前找, 直到找到一个还在等待锁的节点// 然后我们跨过这些不等待锁的节点, 直接排在等待锁的节点的后面do {node.prev = pred = pred.prev;} while (pred.waitStatus > 0);pred.next = node;} else {// 前驱节点的状态既不是SIGNAL,也不是CANCELLED// 用CAS设置前驱节点的ws为 Node.SIGNAL,给自己定一个闹钟compareAndSetWaitStatus(pred, ws, Node.SIGNAL);}return false;
}
  • 如果为前驱节点的waitStatus值为 Node.SIGNAL 则直接返回 true运行&&后面的parkAndCheckInterrupt()方法。
  • 如果为前驱节点的waitStatus值为 Node.CANCELLED (ws > 0), 则跳过那些节点, 重新寻找正常等待中的前驱节点,然后排在它后面,返回false,继续循环。
  • 其他情况, 将前驱节点的状态改为 Node.SIGNAL, 返回false,继续循环。

返回false时会进行循环,就是将那些CANCELLED的节点移出队列,然后再循环一次,再尝试获取锁,因为自己有可能已经到头节点后面了,如果不是则自己排到waitStatus值为SIGNAL的前节点后面,此时shouldParkAfterFailedAcquire返回true。将调用parkAndCheckInterrupt()方法。

private final boolean parkAndCheckInterrupt() {LockSupport.park(this); // 线程被挂起,停在这里不再往下执行了return Thread.interrupted();
}

调用了LockSupport类的park方法。

LockSupport工具类主要用来挂起park(Thread)和唤醒unpark(Thread)线程,底层实现也是使用的Unsafe类。若其他线程调用了阻塞线程的interrupt()方法,阻塞线程也会返回,即阻塞的线程是响应中断的,而且不会抛出InterruptedException异常。LockSupport并不需要获取对象的监视器,而是给线程一个“许可”(permit),unpark可以先于park调用,unpark一个并没有被park的线程时,该线程在下一次调用park方法时就不会被挂起。

所以最后return Thread.interrupted();是因为不能保证他不是被中断的,所以返回Thread的中断状态。

锁的释放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;
}

tryRelease(arg)

该方法由继承AQS的子类实现, 为释放锁的具体逻辑。一般是将state设置为0,setExclusiveOwnerThread(null);再将当前线程设置为null。

unparkSuccessor(h)   唤醒h的后继线程

当有头节点且头节点的waitStatus不等于0的时候则唤醒后继线程,因为waitStatus初始值为0,当队列进入新节点时,头节点会被设置为SIGNAL。

private void unparkSuccessor(Node node) {int ws = node.waitStatus;// 如果head节点的ws比0小, 则直接将它设为0if (ws < 0)compareAndSetWaitStatus(node, ws, 0);// 此时从尾节点开始向前找起, 直到找到距离head节点最近的ws<=0的节点Node s = node.next;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; // 没有return, 继续向前找。}// 如果找到了还在等待锁的节点,则唤醒它if (s != null)LockSupport.unpark(s.thread);
}

从尾部开始遍历,因为新节点接入的时候是先node.prev = t,队列可能只执行到这步,后面还没执行,所以从尾向前遍历。

找到头节点的下一个不是CANCELLED的节点并唤醒,unpark方法对应前面添加节点的park方法,所以回到前面。

private final boolean parkAndCheckInterrupt() {LockSupport.park(this); // 线程被挂起,停在这里不再往下执行了return Thread.interrupted();
}

所以当线程获取不到锁,会被park一直阻塞状态,直到被interrupt或者有锁的线程释放锁时,才会获得锁。获得锁后返回中断状态

final boolean acquireQueued(final Node node, int arg) {boolean failed = true;try {boolean interrupted = false;for (;;) {// 该方法用来查找并获取前置节点。final Node p = node.predecessor();// 在当前节点的前驱就是HEAD节点时, 再次尝试获取锁if (p == head && tryAcquire(arg)) {setHead(node);p.next = null; // help GCfailed = false;return interrupted;}//在获取锁失败后, 判断是否需要把当前线程挂起if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())interrupted = true;}} finally {if (failed)cancelAcquire(node);}
}

如果是中断唤醒的返回true,再设置interrupted = true,因为Thread.interrupted()调用后中断状态会被重新设回false。继续循环,如果自己是在头节点下一位,就可以获取锁了,否则又要挂起。

共享锁

共享锁的acquireShared方法对应独占锁acquire

public final void acquireShared(int arg) {if (tryAcquireShared(arg) < 0)doAcquireShared(arg);
}
public final void acquire(int arg) {if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))selfInterrupt();
}

doAcquireShared对应了独占锁的acquireQueued,

private void doAcquireShared(int arg) {
··························································final Node node = addWaiter(Node.SHARED);   //代表共享模式
··························································boolean failed = true;try {boolean interrupted = false;for (;;) {final Node p = node.predecessor();//与独占锁的acquireQueued的区别主要就是中间这段代码
··························································if (p == head) {int r = tryAcquireShared(arg);if (r >= 0) {setHeadAndPropagate(node, r);p.next = null; // help GCif (interrupted)selfInterrupt();failed = false;return;}}··························································if (shouldParkAfterFailedAcquire(p, node) &&parkAndCheckInterrupt())interrupted = true;}} finally {if (failed)cancelAcquire(node);}
}

重点在setHeadAndPropagate方法

private void setHeadAndPropagate(Node node, int propagate) {Node h = head; // Record old head for check belowsetHead(node);//if里面包含了两个头节点,一个新一个老,多线程下两者可能不一样,两种情况。
//1.propagate > 0 表示调用方指明了后继节点需要被唤醒
//2.头节点后面的节点需要被唤醒(waitStatus<0),不论是老的头结点还是新的头结点if (propagate > 0 || h == null || h.waitStatus < 0 ||(h = head) == null || h.waitStatus < 0) {Node s = node.next;//如果当前节点的后继节点是共享类型或者没有后继节点,则进行唤醒
//这里可以理解为除非明确指明不需要唤醒(后继等待节点是独占类型),否则都要唤醒if (s == null || s.isShared())doReleaseShared();}
}

在setHeadAndPropagate方法里面,将获取锁的节点设置为头节点,然后再去doReleaseShared,doReleaseShared对应了独占锁的unparkSuccessor,作用是唤醒下一个线程,所以在共享锁的releaseShared方法(对应独占锁release),就是释放锁方法里,也主要是用doReleaseShared来释放锁。

独占锁与共享锁的区别

  • 独占锁是持有锁的线程释放锁之后才会去唤醒下一个线程。
  • 共享锁是线程获取到锁后,就会去唤醒下一个线程,所以共享锁在获取锁和释放锁的时候都会调用doReleaseShared方法唤醒下一个线程,当然这会收共享线程数量的限制

下面到doReleaseShared方法

private void doReleaseShared() {for (;;) {Node h = head;if (h != null && h != tail) {  //至少有头尾两个节点int ws = h.waitStatus;if (ws == Node.SIGNAL) {   //ws为SIGNAL的时候才去唤醒下一个节点if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) //将头节点的SIGNAL改为0,CAS操作continue;            // loop to recheck casesunparkSuccessor(h);   //保证单线程运行唤醒后继线程}else if (ws == 0 &&!compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) continue;                // loop on failed CAS}if (h == head)                   // loop if head changedbreak;}
}

doReleaseShared方法里,我们暂且把现在持有锁的线程成为节点A,下一个节点为B。

首先判断 if (ws == Node.SIGNAL),因为我们每次插入节点都会默认0,并且把前节点设成SIGNAL,所以当条件成立时,声明A节点后面已经有B了。 到下一层,if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)),意思是把节点A的SIGNAL改为0。

为什么需要CAS操作呢?

CAS保证了后面单线程唤醒后继线程的操作,在上面谈到的doReleaseShared这个方法,在获取锁和释放锁的时候都会调用,防止重复唤醒。

接下来是 else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))  什么时候满足这句呢?

ws为0,每当一个节点进同步队列都会把前面节点设置为SIGNAL,自己初始为0,所以满足ws==0的条件就是节点A是队列最后一个且后面还没有节点B入列的情况。

满足了ws==0,运行下面这句。

!compareAndSetWaitStatus(h, 0, Node.PROPAGATE),CAS操作失败返回true,将该新节点A的0设置APROPAGATE不成功。不成功就意味着新节点A的0已经被改了,被改意味着新节点A后面已经进入了节点B,设置前节点为SIGNAL的操作是线程在获取不到锁之后,阻塞之前,忘记的可以回顾一下前面的内容。

所以else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) 这个条件成立分为了两个阶段,既有尾节点又加入新节点的这个瞬间可能会满足。

那么满足为什么要continue呢?

因为节点B线程在获取不到锁之后,阻塞之前,所以此时A还没释放锁,A仍是头节点,h==head条件成立,执行break跳出循环,不会去唤醒B了,这不符合共享锁的机制。所以应该continue继续循环,去唤醒B节点,而不是等A运行完释放锁的时候才去调用。

h == head如果不成立,说明A唤醒完B,B已经调用了setHead这个方法了,这个时候再去循环看看B节点后面有没有节点。

多线程—AQS独占锁与共享锁原理相关推荐

  1. Java-Lock独占锁与共享锁原理

    个人理解记录 ReentrantLock基于aqs实现,他的基本原理是aqs的status为0时表示锁被占用,为1时表示锁被释放.ReentrantLock在使用时需要显式的获取和释放锁,一般用try ...

  2. java锁(公平锁和非公平锁、可重入锁(又名递归锁)、自旋锁、独占锁(写)/共享锁(读)/互斥锁、读写锁)

    前言 本文对Java的一些锁的概念和实现做个整理,涉及:公平锁和非公平锁.可重入锁(又名递归锁).自旋锁.独占锁(写)/共享锁(读)/互斥锁.读写锁 公平锁和非公平锁 概念 公平锁是指多个线程按照申请 ...

  3. java 共享锁 独占锁_java中的公平锁、非公平锁、可重入锁、递归锁、自旋锁、独占锁和共享锁...

    一.公平锁与非公平锁 1.1 概述 公平锁:是指多个线程按照申请锁的顺序来获取锁. 非公平锁:是指在多线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取到锁,在高并发的情 ...

  4. 基础篇:独占锁、共享锁、公平锁、非公平锁,叫我如何分得清

    文章目录 引言 锁的独占与共享 内置锁和显式锁的排他性 AQS 的模板方法 共享锁应用案例 锁的公平与非公平 插队的诱惑 内置锁和显式锁的公平性 启示录 引言 本文继续讲解 Java 并发编程实践的基 ...

  5. 独占锁、共享锁、更新锁,乐观锁、悲观锁

    转载自   独占锁.共享锁.更新锁,乐观锁.悲观锁 1.锁的两种分类方式 (1)从数据库系统的角度来看,锁分为以下三种类型: 独占锁(Exclusive Lock)       独占锁锁定的资源只允许 ...

  6. 独占锁(写锁)/共享锁(读锁)/互斥锁

    理论 独占锁:指该锁一次只能被一个线程所持有.对于ReentrantLock和Synchronized而言都是独占锁. 共享锁:该锁可以被多个线程所持有.对于ReentrantReadWriteLoc ...

  7. Java 独占锁与共享锁、公平锁与非公平锁、可重入锁

    目录 背景 独占锁与共享锁 独占锁 共享锁 公平锁与非公平锁 公平锁 非公平锁 可重入锁 总结 背景 最近有一些小伙伴会问我一些关于并发相关的问题,在与他们的沟通中,我发现他们对锁的概念很模糊.这部分 ...

  8. 深入分析AbstractQueuedSynchronizer独占锁的实现原理:ReentranLock

    一.ReentranLock 相信我们都使用过ReentranLock,ReentranLock是Concurrent包下一个用于实现并发的工具类(ReentrantReadWriteLock.Sem ...

  9. 【转】Sql server锁,独占锁,共享锁,更新锁,乐观锁,悲观锁

    锁有两种分类方法. (1) 从数据库系统的角度来看 锁分为以下三种类型: 独占锁(Exclusive Lock) 独占锁锁定的资源只允许进行锁定操作的程序使用,其它任何对它的操作均不会被接受.执行数据 ...

最新文章

  1. [Android] 关于系统工具栏和全屏沉浸模式
  2. 实战SSM_O2O商铺_31【商品】商品添加之View层的实现
  3. 【Linux】——常见的rc的含义
  4. springboot集成邮箱功能
  5. 中级统计师基础知识中计算机,【2014年中级统计师《统计基础理论及相关知识》预习:计算机操作系统】- 环球网校...
  6. 13种老人不适合带孩子_农村常见却叫上不上名字的13种野生植物,乡下长大的孩子都见过...
  7. 使用postman发送HttpServletRequest请求
  8. LINQ :最终统治了​所有的语言!
  9. jdk8 Function
  10. 监督学习 | SVM 之支持向量机Sklearn实现
  11. document.mozFullScreen
  12. Android 引导页
  13. 遍历INI文件和删除指定域内容
  14. java 函数式编程应用_java8 函数式编程应用
  15. linux c++ 时间戳转换,C++时间戳转换成日期时间的步骤和示例代码
  16. ICE java实现helloworld
  17. Java基础-多线程
  18. 职称英语计算机考试取消,职称英语考试取消了吗
  19. 继电器模块典型电路图
  20. 快手Java开发二面面经分享

热门文章

  1. ios mysql 创建不同的用户表_iOS中数据库-创建表-增删改查数据-基础语法
  2. latex的 多行注释_Latex图形注释的实现方法
  3. python rgb led控件_Raspberry Pi-用树莓派实现RGB LED的颜色控制——Python版本-电路城论坛 - 电子工程师学习交流园地...
  4. 制打印如下所示的n行数字金字塔_一日一技:在Python中实现阿拉伯数字加上中文数字...
  5. 用FPGA搭建一个STM32内核?
  6. 2020,Python 已死?
  7. 漫画:程序员相亲?哈哈哈哈哈哈
  8. 数字心电图仪综合系统设计与实现verilog
  9. java 网络文件_java实现从网络下载多个文件
  10. mysql 雇员表脚本,mysql压力测试脚本实例_MySQL