Java并发学习(三)-AbstractQueuedSynchronizer
来源:互联网 发布:淘宝客服不理人 编辑:程序博客网 时间:2024/06/06 20:34
花了一段时间学习了AbstractQueuedSynchronizer,研究了源码以及博客,这篇文章主要以自己的理解去一起学习AQS。
AQS简介
AQS是简写,全称是AbstractQueuedSynchronizer,在java.util.concurrent.locks
包下面,这个类是Java并发中的一个核心类,从名字里面可以看出来,里面维护这一个双向队列(queue)。和synchronize不同,它在Java语言层面实现了锁,而synchronize则是借用了JVM以及操作系统的。所以会说Reentrant更细腻。
与ReentrantLock的关系
前面说过,AQS里面维护着一个队列,那么队列里面又是什么呢?
主要是线程,以及其他属性。
相信很多人都用过Java里面的锁,除了可以用Synchronize
,同样可以用ReentrantLock
来实现,ReentrantLock就是用AQS来实现的,它继承自AQS,AQS更像一个框架,如果你想实现锁,共享锁或者排他锁,你需要遵循他的规范去继承它,并且重写方法就可以实现,这里可以看我分析ReentrantLock这篇文章。
AQS队列结构
前面说过了,AQS里面维护这一个queue,它是双向的,按照类里面的含义,这条队列是先入先出的,即FIFO。那么里面的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; //表示当前节点要被进入sync时,的后继节点要被唤醒,也就是unparking static final int CONDITION = -2; // 表示当前节点在等待condition,也就是在condition队列中。 static final int PROPAGATE = -3; // 表示当前节点的后续的,acquireShared 能够被执行。 volatile int waitStatus; //初始为0 当为0的时候,就处于sync队列中,等待着获取锁。 volatile Node prev; //在检查waitStatus情况下,的前一个节点。 volatile Node next; //后一个节点,当被出队时就会被gc。 volatile Thread thread; //当前执行的线程。 Node nextWaiter; //在condition队列的下一个等待着,或者是share模式的下一个。 }
如上所示,Node节点里面有5个状态:
- CANCELLED
- SIGNAL
- CONDITION
- PROPAGATE
- 0
具体的解释在上面代码中已经有体现。
那么这个Node,什么时候会被使用呢?
在用到锁的时候,我们有个基础的理解,如果一个线程获取不到锁,它会怎么办,它会等待继续尝试获取,或者进入挂起进入等待队列(不消耗cpu)。所以应该知道了,这里的Node是用来存储线程的。下面我将通过AQS源码从以下几个方面分析AQS的结构
AQS结构分析
先看AQS的声明:
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable { ...}
继承自一个父类AbstractOwnableSynchronizer
而这个父类里面,只是很简单的定义了一个当前拥有排他锁的线程:
public abstract class AbstractOwnableSynchronizer implements java.io.Serializable { protected AbstractOwnableSynchronizer() { } private transient Thread exclusiveOwnerThread; protected final void setExclusiveOwnerThread(Thread thread) { exclusiveOwnerThread = thread; } protected final Thread getExclusiveOwnerThread() { return exclusiveOwnerThread; }}
AQS,负责管理线程状态, 子类主要通过对volatile变量state的操作去决定是否获取锁。然后再决定是否进入队列。
AQS里面的state
AQS里面有个state,它是volatile类型的,它是用来干嘛的呢?
关于volatile,可以看我这篇文章:Java并发学习(二)-JMM
首先思考下,为什么能够判定某一个线程能够获取锁呢?
答案就是这个state,在AQS里面有一些方法,专门用于去给子类重写的:
... protected boolean tryAcquire(int arg) { throw new UnsupportedOperationException(); } protected boolean tryRelease(int arg) { throw new UnsupportedOperationException(); } ...
这里只是给了一个头,具体有子类去实现,接下来看看ReentrantLock的实现:
protected final boolean tryAcquire(int acquires) { return nonfairTryAcquire(acquires); } final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) // overflow throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; }
上面只贴出了ReentrantLock里面的非公平锁的重写,首先调用父类的getState()获取state变量,在尝试用原子性CAS方式compareAndSetState
来尝试设置state的值,实际上也就是尝试获取锁。设置成功就返回true,失败的话再检查是否为同一个线程 如果同一个线程,则在一定范围下也获取成功。否则获取不成功。
关于非公平锁,可以看我的专门分析Reentrant的文章。
经过了上面的分析,大概可以清楚,AQS是如何控制资源,并且给管理线程获取状态的。
- volatile类型的state
- CAS原子性修改
AQS中锁的获取与释放
上一个小结中,主要通过Reentrant为例大致说明了state的作用,接下来看AQS里面获取锁的思想。
AQS给子类提供了两种类型的锁:
- 排他锁 (EXCLUSIVE)
- 共享锁 (SHARED)
排他锁
普通获取排他锁的过程:acquire
public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }
arg为用户自定义子类传回的state变量,由短路与,可以知道,当(tryAcquire)获取失败时候,才会执行&&后面条件判断。
接下来看addWaiter
方法。
private Node addWaiter(Node mode) { //由mode,新建node,排他 Node node = new Node(Thread.currentThread(), mode); Node pred = tail; if (pred != null) { node.prev = pred; if (compareAndSetTail(pred, node)) { pred.next = node; return node; } } //先尝试一次入队,如果失败,就用自旋方式入队。 enq(node); return node; } /** * 节点入队操作。 * 插入到队尾 * 返回插入时候的这个tail尾节点。 */ private Node enq(final Node node) { for (;;) { Node t = tail; if (t == null) { //如果尾节点为null,就把头结点设为尾节点。 if (compareAndSetHead(new Node())) tail = head; } else { node.prev = t; //否则就就把node插入到队列最后。 if (compareAndSetTail(t, node)) { t.next = node; return t; } } } }
由上代码,当获取锁失败,就需要在AQS里面记录嘛,就是插入这条队列,首先通过addWaiter
尝试插入一次,如果失败,则需要以自旋方式进行插入,一定得插入。都是利用CAS方式替换tail。
可中断获取排他锁:doAcquireInterruptibly
/** * 在排他锁模式下,可中断的获取锁。 * Acquires in exclusive interruptible mode. * @param arg the acquire argument */ 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 (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) throw new InterruptedException(); } } finally { if (failed) //取消获取,状态设为cancelled。 cancelAcquire(node); } }
同样是先通过自旋的方式获取锁,如果自旋多次,仍然没有获取,并且前置节点已经发生了变化,则需要的时候将线程阻塞。具体什么变化呢?
接下来看shouldParkAfterFailedAcquire
。
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { int ws = pred.waitStatus; if (ws == Node.SIGNAL) /*这个节点已经成功设置了状态,请求获取锁,所以只需要等 待前屈节点运行玩就会unpark自己,所以可以进行park。 * This node has already set status asking a release * to signal it, so it can safely park. */ return true; if (ws > 0) { /* * 跳过被cancell的。 */ do { node.prev = pred = pred.prev; } while (pred.waitStatus > 0); pred.next = node; } else { /* * condition或者 在sync,propagate * 尝试设为signal。 */ compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; }
然后进行park操作:
private final boolean parkAndCheckInterrupt() { //调用native方法进行park LockSupport.park(this); return Thread.interrupted(); }
如果获取失败,则需要cancell,看cancelAcquire
,代码比较复杂,看慢慢分析:
/** * 当获取不到就会取消 *一旦发生异常,导致获取锁失败,则会调用cancelAcquire()方法"Cancels an ongoing attempt to acquire"。 * node是tail * node是head * node既不是tail,又不是head * @param node the node */ private void cancelAcquire(Node node) { // Ignore if node doesn't exist if (node == null) return; //1. node不再关联到任何线程 node.thread = null; //2. 跳过被cancel的前继node,找到一个有效的前继节点pred // Skip cancelled predecessors Node pred = node.prev; while (pred.waitStatus > 0) node.prev = pred = pred.prev; // predNext is the apparent node to unsplice. CASes below will // fail if not, in which case, we lost race vs another cancel // or signal, so no further action is necessary. Node predNext = pred.next; //3. 将node的waitStatus置为CANCELLED // Can use unconditional write instead of CAS here. // After this atomic step, other Nodes can skip past us. // Before, we are free of interference from other threads. node.waitStatus = Node.CANCELLED; //4. 如果node是tail,更新tail为pred,并使pred.next指向null // If we are the tail, remove ourselves. if (node == tail && compareAndSetTail(node, pred)) { compareAndSetNext(pred, predNext, null); } else { // If successor needs signal, try to set pred's next-link // so it will get one. Otherwise wake it up to propagate. // int ws; //5. 如果node既不是tail,又不是head的后继节点 //则将node的前继节点的waitStatus置为SIGNAL //并使node的前继节点指向node的后继节点(相当于将node从队列中删掉了) if (pred != head && ((ws = pred.waitStatus) == Node.SIGNAL || (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) && pred.thread != null) { Node next = node.next; if (next != null && next.waitStatus <= 0) compareAndSetNext(pred, predNext, next); } else { //6. 如果node是head的后继节点,则直接唤醒node的后继节点 unparkSuccessor(node); } node.next = node; // help GC } }
上述cancelAcquire
一共有6步:
- node不再关联到任何线程
- 跳过被cancel的前继node,找到一个有效的前继节点pred
- 将node的waitStatus置为CANCELLED
- 如果node是tail,更新tail为pred,并使pred.next指向null
- 如果node既不是tail,又不是head的后继节点则将node的前继节点的waitStatus置为SIGNAL并使node的前继节点指向node的后继节点(相当于将node从队列中删掉了)
- 如果node是head的后继节点,则直接唤醒node的后继节点
超时获取排它锁doAcquireNanos:
超时获取排它锁中,主要就是添加了nanosTimeout
这个参数,用来判定是否已经过了设定时间,从而执行不同逻辑,其他的都与可中断排它锁一致,就不多说了。
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 (shouldParkAfterFailedAcquire(p, node) && nanosTimeout > spinForTimeoutThreshold) LockSupport.parkNanos(this, nanosTimeout); if (Thread.interrupted()) throw new InterruptedException(); } } finally { if (failed) cancelAcquire(node); } }
释放排它锁release
有获取锁当然也会有释放所,在ReentrantLock里面使用unlock来实现,这里主要讲当ReentrantLock调用到父类AQS代码时候操作:
/** * 释放排他锁。 * 首先tryRelese释放标记 * 然后,排它锁获取的肯定是head出现,此时我只要唤醒(unpack)继任线程就可以了。 */ 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是由子类重写的方法,目的是为了改变state的值,从而代表释放资源。当释放成功后,unparkSuccessor(h)
,释放头结点的后继节点。
/** * 如果继任者存在,唤醒继任者开始执行。 * 如果继任者waitStatus<0,则会将其设为=0进行 * 如果继任者>0(状态无效为cancelled。),则接着往后面找 */ private void unparkSuccessor(Node node) { /* * If status is negative (i.e., possibly needing signal) try * to clear in anticipation of signalling. It is OK if this * fails or if status is changed by waiting thread. */ int ws = node.waitStatus; //如果ws<0,则需要设为0,因为:值为0,表示当前节点在sync队列中,等待着获取锁。 if (ws < 0) compareAndSetWaitStatus(node, ws, 0); Node s = node.next; //重新找一个successor. 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; } //找到了,解锁。 if (s != null) LockSupport.unpark(s.thread); }
unparkSuccessor方法的作用就是唤醒继任节点,因为一般当前运行的节点会设为head,所以当要释放的时候,unpark后面这个节点就可以了。
当然需要过滤掉cancelled的节点。
共享锁
共享锁,就是指可以允许多个线程同时对某一资源进行访问,这里会有个最开始的问题,会不会有并发问题?因为AQS只是提供了一个并发的核心框架,具体是否有并发问题,要看子类的自我实现,以及子类工具的用途。
什么意思呢?
就好比说,exclusive模式下,AQS管理一次只能一个线程进行运行,运行完后按照规则传递给后继节点。
而share模式下,AQS管理的则是一次能让多个线程同时运行,可以设置同时进入的数量,运行完后再把排它锁状态往后延伸。
共享锁的普通获取acquireShared
首先看acquireShared方法:
/** * 获取共享锁。 */ public final void acquireShared(int arg) { if (tryAcquireShared(arg) < 0) doAcquireShared(arg); } /** * 获取共享锁。 * Acquires in shared uninterruptible mode. * @param arg the acquire argument */ private void doAcquireShared(int arg) { final Node node = addWaiter(Node.SHARED); boolean failed = true; try { boolean interrupted = false; for (;;) { final Node p = node.predecessor(); if (p == head) { //首先是这样去尝试获取共享锁。通过volatile变量。 int r = tryAcquireShared(arg); if (r >= 0) { setHeadAndPropagate(node, r); p.next = null; // help GC if (interrupted) selfInterrupt(); failed = false; return; } } if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } }
和获取排他锁思路基本一致,都是先尝试获取,获取不到在进行park操作,但是还有两个地方不同:
- int r = tryAcquireShared(arg); if (r >= 0)
- setHeadAndPropagate(node, r);
第一个是通过子类实现的tryAcquireShared方法去判定是否能获取锁,还记得,最开始acquireShared里面也是执行了这个方法,所以这里是被执行了2次的,最后判断是否能够获取锁是通过r>=0
来判断的,所以是个范围,也就印证了这是个共享的锁。
第二个的setHeadAndPropagate,和排它锁(only setHead)不同,主要是为了设置head,以及把共享状态往后穿,大家应该还记得Node节点的waitState属性里面有个Propagate的值-3吧。
/** * 设置头结点。 * tryAcquireShared执行相关。 * @param node the node * @param propagate the return value from a tryAcquireShared * 某个节点被设置为head之后,如果它的后继节点是SHARED状态的,那么将继续通过 * doReleaseShared方法尝试往后唤醒节点,实现了共享状态的向后传播。 */ private void setHeadAndPropagate(Node node, int propagate) { Node h = head; // Record old head for check below setHead(node); /* * 尽力去唤醒下一个节点。 */ if (propagate > 0 || h == null || h.waitStatus < 0 || (h = head) == null || h.waitStatus < 0) { Node s = node.next; if (s == null || s.isShared()) doReleaseShared(); } }
doAcquireSharedInterruptibly以及doAcquireSharedNanos
超时获取以及可中断获取,在获取锁代码逻辑上与acquireShared基本一致,而在interruption以及超时判断上,则与排它锁一致,这里就不重复来说了,可以参看上面文章。
ConditionObject
还记得上面说过,在AQS的Node节点里面,有个waitState值为Condition值为-2.就是代表他在阻塞状态,类似于synchronize里面的wait,而ConditionObject也就可以充当Lock的wait和signal。后续会介绍Condition类具体分析。
先看ConditionObject里面内容:
public class ConditionObject implements Condition, java.io.Serializable { ... /** 引用的头节点。 */ private transient Node firstWaiter; /** 引用的尾节点。. */ private transient Node lastWaiter; ...}
如上,在ConditionObject里面包含了两个重要的字段,都是Node类型,firstWaiter和lastWater;其实ConditionObject和AQS里面那条队列并不是同一条,由代码可以分析出来,请看我下文分析。
当然,里面方法主要就是await和signal。
- private Node addConditionWaiter(),添加当前线程作为waiter
- private void doSignal(Node first),唤醒firstWaiter
- private void doSignalAll(Node first),唤醒,所有waiter
- private void unlinkCancelledWaiters(),删除cancell的节点
- public final void signal(),对外唤醒任意一个,主要第一个
- public final void signalAll(),对外唤醒所有
- public final void awaitUninterruptibly(),不会中断的等待
- private int checkInterruptWhileWaiting(Node node),判断等待过程中是否发生了异常
- private void reportInterruptAfterWait(int interruptMode),等待后重新报告异常
- public final void await() throws InterruptedException ,对外的等待方法
- final boolean isOwnedBy(AbstractQueuedSynchronizer sync) ,判断是否为当前线程condition
- protected final boolean hasWaiters(),是否有waiter。
ConditionObject的主要就是用来阻塞队列以及唤醒队列的,而阻塞队列,一方面是把waitState改为condition,另一方面还需要执行park的native方法,而对于唤醒队列,则是把waitState改为0,执行unpark方法。思路理清楚了,接下来分析两个具体代码:
await方法
public final void await() throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); Node node = addConditionWaiter(); //首先释放锁 int savedState = fullyRelease(node); int interruptMode = 0; while (!isOnSyncQueue(node)) { //如果不在syncqueue里面,即waitState != 0,阻塞本线程 LockSupport.park(this); if ((interruptMode = checkInterruptWhileWaiting(node)) != 0) //中断了就直接退出 break; } if (acquireQueued(node, savedState) && interruptMode != THROW_IE) interruptMode = REINTERRUPT; if (node.nextWaiter != null) // clean up if cancelled //清楚cancelled的节点 unlinkCancelledWaiters(); if (interruptMode != 0) //判断,是纪录异常还是抛出 reportInterruptAfterWait(interruptMode); }
如上代码,
1. 首先会addConditionWaiter方法,主要作用就是把本线程添加到condition队列的尾端,注意是根据lastWaiter来判定的,所以添加到的是condition队列,waitState为condition。
2. 其次,fullyRelease来释放当前线程锁,也就是占有的资源。
3. 下一步,利用自旋的方式isOnSyncQueue,判断是否仍然处于等待获取资源状态。如果是的就阻塞本线程,有错误就退出。
4. 最后判断是否有错误,以及是否被设置为抛出错误,ConditionObject里面用THROW_IE
和REINTERRUPT
来判断。
/** * 重新报告interrupt类型。 */ private void reportInterruptAfterWait(int interruptMode) throws InterruptedException { if (interruptMode == THROW_IE) throw new InterruptedException(); else if (interruptMode == REINTERRUPT) selfInterrupt(); }
signal方法
ConditionObject里面有两个signal,一个是有FIFO,唤醒头结点,另一个是signalAll,唤醒所有节点。
接下来看signal方法:
/** * 默认唤醒第一个节点线程。 */ public final void signal() { if (!isHeldExclusively()) throw new IllegalMonitorStateException(); Node first = firstWaiter; if (first != null) doSignal(first); }
如上里面会调用doSignal(first):
/** * 唤醒一个waiter。 * 放入sync队列。 * @param first (non-null) the first node on condition queue */ private void doSignal(Node first) { do { if ( (firstWaiter = first.nextWaiter) == null) lastWaiter = null; first.nextWaiter = null; } while (!transferForSignal(first) && (first = firstWaiter) != null); }
自旋阻塞式的,直到成功被唤醒,具体细节在transferForSignal(first)
方法里面:
/** * 唤醒节点,状态改为0。 * 唤醒成功的话,就插入队尾并且如果此时队尾元素cancell或者强行设置队尾元素失败,那么就需要唤醒此时队尾元素。 */ final boolean transferForSignal(Node node) { /* * If cannot change waitStatus, the node has been cancelled. * 设置成功了! */ if (!compareAndSetWaitStatus(node, Node.CONDITION, 0)) return false; /* * 把前一个节点设为signnal。 * Splice onto queue and try to set waitStatus of predecessor to * indicate that thread is (probably) waiting. If cancelled or * attempt to set waitStatus fails, wake up to resync (in which * case the waitStatus can be transiently and harmlessly wrong). */ //放到最后重新排队。 Node p = enq(node); int ws = p.waitStatus; //检查前一个节点是否为signal。 if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL)) LockSupport.unpark(node.thread); return true; }
transferForSignal方法的目的就是讲节点waitState值设为0,并且添加到AQS队列的尾端。
收获
看了多遍源码即博客,才慢慢理解了AQS运行的原理,Doug Lea大佬真是厉害,从AQS里面也读到了两个比较有特色的写法:
- 使用&& 和||短路与和短路或去实现if的操作,里面用的真多。
- 在一个条件判断中,我发现同样的条件用&&连接了两次,后来知道是为了检查两次二故意做的。因为在并发环境下,很可能多做一次,就得到想要的结果了。
/** * 获取队列第一条线程。 * 有头结点就判断头结点,没有头结点,就从尾端一直找到头结点。 * Version of getFirstQueuedThread called when fastpath fails */ private Thread fullGetFirstQueuedThread() { Node h, s; Thread st; //两段判断一模一样,检查了两次 if (((h = head) != null && (s = h.next) != null && s.prev == head && (st = s.thread) != null) || ((h = head) != null && (s = h.next) != null && s.prev == head && (st = s.thread) != null)) return st; //审查不通过,那就从尾端开始 Node t = tail; Thread firstThread = null; while (t != null && t != head) { Thread tt = t.thread; if (tt != null) firstThread = tt; t = t.prev; } return firstThread; }
分析有诸多不足之处,如有错误,还请指出~
参考文章:
http://ifeve.com/introduce-abstractqueuedsynchronizer/
https://www.cnblogs.com/leesf456/p/5350186.html
- Java并发学习(三)-AbstractQueuedSynchronizer
- Java JUC并发 AbstractQueuedSynchronizer学习
- Java并发包--AbstractQueuedSynchronizer
- java并发之AbstractQueuedSynchronizer
- 【Java并发】详解 AbstractQueuedSynchronizer
- java 并发包-AbstractQueuedSynchronizer
- Java并发之AbstractQueuedSynchronizer分析
- java并发编程之AbstractQueuedSynchronizer
- Java并发编程(一)--AbstractQueuedSynchronizer
- java并发编程--AbstractQueuedSynchronizer公平锁和非公平锁分析(三)
- [Java并发] AbstractQueuedSynchronizer实现(一)
- 读JAVA并发包之AbstractQueuedSynchronizer
- Java多线程并发器之AbstractQueuedSynchronizer分析
- 【Java并发】- AbstractQueuedSynchronizer详解(AQS)
- Java 并发 ---AbstractQueuedSynchronizer(同步器)-独占模式
- Java 并发 ---AbstractQueuedSynchronizer-共享模式与Condition
- 深入学习java并发编程:Lock与AbstractQueuedSynchronizer(AQS)实现
- Java 并发深入学习三
- 673. Number of Longest Increasing Subsequence
- 【C/C++】计时函数比较
- 欢迎使用CSDN-markdown编辑器
- TensorFlow实现用于图像分类的卷积神经网络(代码详细注释)
- Pytorch从入门到精通(一):线性模型
- Java并发学习(三)-AbstractQueuedSynchronizer
- golang基础-etcd介绍与使用、etcd存取值、etcd监测数据写入
- 用栈实现队列的先进先出结构
- 文章标题
- ubuntu14.04、CentOS安装oracle 11g数据库
- codeup 题目解答(结构体的使用)
- Catch That Cow(BFS)
- MySQL用户变量和系统变量
- 当程序取代程序员写代码,会发生什么呢?