1、生活中案例场景介绍
今天我们就举一个生活中的例子来理解下并发底层的AQS。

大家如果去过某些大医院的话,就能知道,由于互联网的快速发展,医院的挂号、交费、取药的流程都是比较方便的,交费也可以使用支付宝、微信支付了,而不用带现金了。

医生开完单子,交费完成 ,单子上都会有一个长条二维码,可以直接在取药的地方自助扫码,叫号系统自动分配取药窗口,然后你在关注下指定窗口等待着叫号就可以了,叫到你的时候再过去取药,而不需要一直在等待着。

我们用一张图来直观的感受下:

这里面涉及到了几个角色:

1)药房,提供取药窗口的,内部有自助取药机或人工取药

2)取药叫号系统,当用户扫码药单后,自动录入到该系统中

3)取药用户

接下来咱们细化下取药流程。

当取药用户在自助机器上扫码时,可以直观的看下下面的流程图

第一个用户是程序猿,因为有多个自助扫码机,他一看二维码就知道咋回事了,所以第一个在自助机上扫码完成,可以优先第一个去取药窗口(State窗口)。

此时叫号系统的药单队列中还没有其他人,程序猿扫码后,就可以直接去窗口等待着取药了。

接下来,本来是张大爷和王大妈看着先前程序猿的操作,也跟着在自助机上来回扫码一把,由于不大懂扫哪里,扫了半天也没有个反应,老头此时有点懵 : (。

后来热心的程序猿看到了,给指点了一下 : ),帮助顺利的扫码完成。

再看下面这个流程图:


正好,张大爷和王大妈的取药单,也被分配到跟程序猿同一个取药窗口中 ,此时只能排队了,按照他们的扫码顺序排队,如上图所示。

当程序猿取药完成,叫号系统会自动呼叫下一位用户,即队列中的排在首节点的张大爷,自助取药机收到消息会自动给张大爷取药。此时,王大妈还是要等一会。后面的用户 CCC 扫码完成后,会继续放到药单队列中,药单队列是按照 FIFO,也就是谁先扫码谁就在前面,所以 CCC 排在王大妈的后面。

再看下面的流程图:

张大爷还在等待取药过程中,王大妈也知道下一个可能就是她了,所以王大妈会时不时的,抬头看看叫号窗口是否显示了自己的名字。
此时,王大妈可以稍微在等待区休息一会,等待系统叫号就可以了。

2、联想到 AQS 到底是什么
其实,上面的场景介绍中,在医院里是很常见的。那么这个场景对应的,我们可以联想到 Java 中的并发编程。

如果没有中间的叫号系统来做控制,如果医院没有限制,很多用户要么一拥而上没有秩序的乱挤,要么就有秩序的都在窗口站着排成长队等待着。

所以中间的叫号系统解决了很多问题,解决了很多取药用户的有序性、安全性,而且不需要用户一直等着,用户线程无阻塞,当收到系统通知信号后,用户再继续执行取药动作。

这个生活中的例子,可以很好的联想到 Java 中我们常用的,并发包的底层技术:AQS (AbstractQueuedSynchronizer)队列同步器(简称同步器)。

就像我们举得例子中的提到的几个角色,有很多用户(理解为用户线程),有共享资源(取药窗口)。在用户线程和共享资源之间,是通过中间系统来协调控制的,这里面就会涉及锁的概念。

锁是用来控制多个线程访问共享资源的方式。一个锁能防止多个线程对共享资源的同时访问,有些锁也允许多个线程并发访问共享资源,比如读写锁。

在 Java 中经常使用的锁是 synchronized,synchronized 会隐式的获得锁,但它必须是先获得锁再释放锁。这种方式简化了同步的管理,但扩展性不如 Lock 显示的获得锁和释放锁更加灵活。

synchronized 和 Lock 锁之间的区别:

从性能上来讲,当并发量高、竞争激烈的场景下,Lock 锁会较 synchronized 性能上表现的
更稳定些。反之,当并发量不高的情况下,synchronized 有分级锁的优势,因此两者性能差不多,synchronized 相对来说使用上更加简单,不用考虑手工释放锁。

直观感受下两者的性能对比:

Lock 显示的锁使用,因为使用上更加灵活,这得益于其底层基础同步框架的实现机制,它就是 AQS。

如下图所示:

上述图中列出了多个并发包中的类,每一个并发工具类解决的问题场景不同,但是其底层同步框架基本都是使用的 AQS 来实现的。

3、AQS 的设计初衷
Java 大佬考虑并发底层使用 AQS 的设计思想初衷,就是为了能够抽象出来统一的同步协调处理器,设计好顶层结构,作为并发包构建的基本骨架,该骨架里封装了多线程的入队/出队、线程阻塞/唤醒等一系列复杂的操作。Java SDK 中面向开发者针对不同需求场景提供了多个并发包工具。

尽管,提供的这些并发包的实现方式是不一样的,但都是基于顶层抽象出来的 AQS 所定义的统一接口基础上,然后部分定制逻辑延迟到子类去自行实现。同时,部分定义的方法中是按照既定的顺序执行的,由此,我们也能够想到,AQS 使用了模板方法模式。

在上一节图中提到的几个并发包中,我们来简单介绍下实现场景。

多线程独占式并发工具:

1)ReentrantLock

可重入锁,同一时刻仅允许一个线程访问,所以可以称作 独占锁,线程可以重复获取同一把锁。

多线程共享式并发工具:

1)ReentrantReadWriteLock

可重入的读写锁,允许多个读线程同时进行,但不允许写-读、写-写线程同时访问。

适用于读多写少的场景下。

2)CountDownLatch

主要用来解决一个线程等待 N 个线程的场景。

就像短跑运动员比赛,等到所有运动员全部都跑完才算竞赛结束。

3)CycliBarrier

主要用于 N 个线程之间互相等待。

就像几个驴友约好爬山,要等待所有驴友都到齐后才能统一出发。

4)Semaphore

限流场景使用,限定最多允许N个线程可以访问某些资源。

就像车辆行驶到路口,必须要看红绿灯指示,要等到绿灯才能通行。

基于上述这些并发包工具,我们可以根据多线程的不同使用场景去选择。JDK 提供的这些并发包基本能够满足了大部分的开发者的使用需求。

4、揭秘 AQS 底层实现
在用户取药的这个例子中,我们可以把多个用户扫码取药行为,联想为多线程共用争抢一个窗口的锁,窗口就作为共享资源来看待。所以,哪个用户先扫码,这个用户就优先有机会能提前取药。

对应联想到 AQS 内部结构,如下图所示:


们根据用户取药的流程,对应画出来的一个 AQS 底层的大致结构图。经过举例分析,多个用户(线程)扫码取药会争抢一把锁(同一个取药窗口,共享资源),所以用 Java 并发包里的 ReentrantLock 锁的使用来描绘一下也更加贴切,因为 ReentrantLock 是一个独占锁,同一个时刻只允许一个用户执行。

结构图中的 AQS 里,包含了几个关键的属性:

state 变量:表示同步状态
exclusiveOwnerThread 变量:表示当前加锁的线程
Node:CLH 队列,是一个 FIFO 的双端双向链表队列
啥是CLH?在 AQS 源码中你能找到一段话,The wait queue is a variant of a “CLH” (Craig, Landin, and Hagersten) lock queue,看上去像是三个人的名字,他们来发明的自旋算法,没具体查资料。

AQS 队列同步器主要包括:

独占式同步状态获取和释放,如:ReentrantLock
共享式同步状态获取和释放,如:Semaphore、CountDownLatch、CycliBarrier
接下来,我们就用独占式 ReentrantLock 可重入锁来分析下 AQS 底层到底了做了哪些事情。

使用 ReentrantLock 显示加锁解锁代码很简单,如下所示:

Lock lock = new ReentrantLock();
lock.lock();// doSomething...lock.unlock();

先来一张类图:

列出了 Lock 接口和 ReentrantLock 实现类里的核心方法,其中 ReentrantLock 里的有个非常核心的属性是 Sync ,它才是最最关键的组件,继承了 AbstractQueuedSynchronizer 抽象类,作为子类实现了加锁和解锁。

再看一张全景类图:

这张类图中列出了 ReentrantLock 类里的 Sync 及其两个子类 FairSync 公平锁 和 NonfairSync 非公平锁的核心方法,AQS 类里的核心属性和方法。

AQS 中的 Node同步队列关键属性介绍:

waitStatus 等待状态:

CANCELLED:值为1,等待的线程等待超时或被中断,需从同步队列中取消等待,节点进入该状态不在变化。

SIGNAL:值为 -1,后继节点的状态处于等待状态,而当前节点线程如果释放了同步状态或被取消,将会通知后继节点,使得后继节点的线程得以运行。

CONDITION:值为 -2,节点在等待队列中,节点线程等待在 Condition 上,当其他线程对 Condition 调用了 signal() 方法后,该节点将会从等待队列转移到同步队列中,获取同步状态。

PROPAGATE:值为 -3,表示下一次共享式同步状态获取将会无条件的被传播下去。

INITAL:值为 0,初始状态,当你创建新的节点时,默认就是这个状态值。

双向双端队列:

在 AQS 结构图中已经有所描述,Node 是一个双端双向链表的队列,双端表示有 head (头节点)和 tail(尾节点)。

双向链表表示有 prev (指向前驱节点)和 next (指向后继节点)两个指针来标识 ,在上述 AbstractQueuedSynchronizer.Node 类图中也能够看得到。
此外,Node 中还有 thread 属性表示当前的线程。

介绍完了类图中的关键属性和数据结构,我们来分析下,ReentrantLock 对象调用了 lock() 方法加锁的过程。

找到 ReentrantLock 类里的 lock() 方法如下:

public void lock() {
sync.lock();
}
看到没,sync 变量就是 Sync 刚提到的 AQS 的子类,调用了 sync 的 lock() 方法。

当我们点击进去 sync#lock() 方法时,发现是个抽象方法,可以找到两个实现类,如下所示:

此时,如果不经常看源码的同学,可能有点懵,到底是走那个方法?一种方式,你可以在 NonfairSync 和 FairSync 两个类的 lock() 方法上都打上断点,直接调试看到底是哪个类;另外,你可以猜测下,这个实现类应该是在对象初始化时创建的,所以你就直接去找构造方法。

public ReentrantLock() {sync = new NonfairSync();
}

我们是通过默认构造方法创建的 ReentrantLock,跟进去看到的是创建的 NonfairSync,即默认创建的是非公平锁方式。

看下 NonfairSync#lock() 方法实现:

final void lock() {if (compareAndSetState(0, 1))setExclusiveOwnerThread(Thread.currentThread());elseacquire(1);
}

有两条执行路径:

1)直接通过 compareAndSetState(0, 1) 方法,使用了 CAS 可以无锁化的保证一个数值修改的原子性,判断下如果 state 变量是 0,说明没有线程加锁,可以把 state 设置为 1。设置成功后,

调用 setExclusiveOwnerThread(Thread.currentThread()) 方法,将当前线程设置为加锁线程,即将 exclusiveOwnerThread 变量赋值为当前线程。

protected final boolean compareAndSetState(int expect, int update) {// See below for intrinsics setup to support thisreturn unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

compareAndSetState(int expect, int update) 底层调用了 UnSafe 类的 compareAndSwapInt(this, stateOffset, expect, update) 方法,该方法为 JDK 内部使用的API,进行的是指针操作,基于 CPU 指令实现的原子性的 CAS。

图示如下:


2)如果 state 变量不是 0,说明有线程已经加锁了,compareAndSetState(0, 1) 方法返回 false,执行 acquire(1) 方法。

当我们点击 acquire(1) 方法后,就进入到了 AbstractQueuedSynchronizer 类里面了。

acquire(int arg) 方法源码:

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

其他线程加入同步队列,图示如下:

上述代码完成如下几个步骤:

1)首先调用 tryAcquire(int arg) 方法,保证线程安全的获取同步状态,如果同步状态获取失败,进入步骤2)。

2)调用 addWaiter(Node node) 方法,参数为构建的独占式 Node.EXCLUSIVE 节点,将构建好的节点通过 CAS 无锁化方式添加到同步队列的尾部,并返回该节点。

3)最后调用 acquireQueued(Node node, int arg) 方法,使得该节点按「死循环」方式获取同步状态。如果节点获取不到同步状态,则会调用 LockSupport#park() 方法挂起,阻塞节点中的线程,被阻塞的线程等待唤醒,唤醒方式主要是前驱节点出队或被中断来实现的。

下面结合源码具体剖析下上述的几个步骤。

当调用 tryAcquire(int arg) 方法,注意 AQS 里的 方法是这样的:

protected boolean tryAcquire(int arg) {throw new UnsupportedOperationException();
}

1)tryAcquire(int arg) 尝试获取同步状态分析:

这就是 AQS 提供的模板方法,由于子类自定义同步器去实现的。

所以,会跳转到 NonfairSync 里的 tryAcquire(int arg) 方法:

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

内部调用了 nonfairTryAcquire(int acquires) 方法,该方法是 Sync 父类的,如下所示:

类的,如下所示:

帮助文档
快捷键目录标题文本样式列表链接代码片表格注脚注释自定义列表LaTeX 数学公式插入甘特图插入UML图插入Mermaid流程图插入Flowchart流程图插入类图
代码片复制

下面展示一些 内联代码片

inal boolean nonfairTryAcquire(int acquires) {// 获取当前线程final Thread current = Thread.currentThread();//  获取同步状态int c = getState();// 如果同步状态是0,没人加锁if (c == 0) {// 通过CAS方式设置同步状态,尝试将0修改为1if (compareAndSetState(0, acquires)) {// 设置当前加锁的线程,给exclusiveOwnerThread变量赋值setExclusiveOwnerThread(current);return true;}}// 当前线程等于当前加锁线程else if (current == getExclusiveOwnerThread()) {// 计算新的同步状态值,nextc = 1 + 1 = 2int nextc = c + acquires;// 判断下nextc,防止溢出if (nextc < 0) // overflowthrow new Error("Maximum lock count exceeded");// 更新同步状态值setState(nextc);return true;}return false;
}

即使是多线程访问,同一时刻总是仅有一个线程能够获得同步状态,就会走上述的 c == 0 里的逻辑。

如果是在同一个线程中进行了第二次调用 ReentrantLock#lock() 和 unlock() 方法呢?此时 c = 1,所以会走到 current == getExclusiveOwnerThread() 判断当前线程是等于加锁线程的,那么就会计算 nextc 新的同步状态 ,如果该值不会溢出,则调用 setState(int newState) 更新同步状态值,state 同步状态值变为 2。

** 2)addWaiter(Node node) 添加到同步队列分析:**

addWaiter(Node node) 主要是将节点加入到同步队列队尾,源码如下所示:

private Node addWaiter(Node mode) {// mode传进来的参数为Node.EXCLUSIVE,构建Node节点Node node = new Node(Thread.currentThread(), mode);// Try the fast path of enq; backup to full enq on failureNode pred = tail;// 尾节点不为空if (pred != null) {node.prev = pred;// 1. 将当前节点作为尾节点添加到同步队列// 2. 原尾节点作为当前节点的前驱节点if (compareAndSetTail(pred, node)) {pred.next = node;return node;}}// 同步队列为空,调用enq(node)方法enq(node);return node;
}

继续看 enq(Node node) 方法源码:

private Node enq(final Node node) {for (;;) {Node t = tail;// 第一次循环,尾节点为空if (t == null) { // Must initialize// 创建空Node节点作为Head头节点if (compareAndSetHead(new Node()))tail = head;} else {// 第二次循环过来,只有一个节点,就是头结点node.prev = t;// 将当前节点作为尾节点添加到同步队列中if (compareAndSetTail(t, node)) {// 当前节点作为头结点的后继节点t.next = node;return t;}}}
}

也是使用了 CAS 无锁化保证节点,可以正确的添加到同步队列中。

第一次循环,尾节点为空,调用了 compareAndSetHead(new Node()) 方法,底层调用了 unsafe.compareAndSwapObject(this, headOffset, null, update) 如果 head 变量所在位置为 null,则更新为空 Node 节点。

第二次循环,尾节点不空,调用了 compareAndSetTail(t, node) 方法,底层调用了 unsafe.compareAndSwapObject(this, tailOffset, expect, update) ,此时 tail 变量所在位置为空 Node 节点,更新为当前节点,即 Node.EXCLUSIVE 独占式节点。

** 3)acquireQueued(Node node, int arg) 获得同步状态分析:**

节点加入到同步队列后,就进入到了自旋的过程,每个节点都在不断的观察,是否可以获得同步状态,成功获得同步状态,就会从这个自旋过程中退出。如下所示是自旋过程的实现代码。

acquireQueued() 方法源码如下:

final boolean acquireQueued(final Node node, int arg) {boolean failed = true;try {boolean interrupted = false;for (;;) {// 获得当前节点的前驱节点final Node p = node.predecessor();// 如果p是头节点,则尝试获得同步状态if (p == head && tryAcquire(arg)) {// 成功获得同步状态,把自己作为Head头节点setHead(node);// 原头节点从同步队列移除,不需要CAS操作p.next = null; // help GCfailed = false;return interrupted;}// 1. 如果不是头节点,失败获得同步状态,判断下是否可以挂起// 2. 允许挂起 ,调用 LockSupport#park() 方法完成线程挂起,释放锁if (shouldParkAfterFailedAcquire(p, node) &&parkAndCheckInterrupt())interrupted = true;}} finally {if (failed)cancelAcquire(node);}
}

当前线程挂起过程,先调用 shouldParkAfterFailedAcquire(Node pred, Node node) 方法,如下所示:

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {int ws = pred.waitStatus;// 前驱节点状态为SIGNAL,返回trueif (ws == Node.SIGNAL)return true;if (ws > 0) {// 跳过 CACALLED 状态的节点do {node.prev = pred = pred.prev;} while (pred.waitStatus > 0);pred.next = node;} else {// 前驱节点的状态小于0,则更新为SIGNAL状态compareAndSetWaitStatus(pred, ws, Node.SIGNAL);}return false;
}

图示如下:
在这里插入图片描述

线程1首先获得了同步状态,线程2、线程3发现 AQS 类里的 state 不为 0,所以都被添加到 AQS 的同步队列尾部。

此时,同步队列中的线程2和线程3的节点会进行自旋过程,线程2的前驱节点是头节点,满足这个条件,然后调用 tryAcquire(int arg) 方法尝试获得同步状态。

当线程1业务处理完成,需要释放同步状态,是的后续节点线程能够获得同步状态。示例中会使用 ReentrantLock#unlock() 方法来解锁。

继续来分析 unlock() 方法,如下代码所示:

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

在 unlock() 方法中,调用的 Sync 类的 release(int arg),进入到该方法中。

public final boolean release(int arg) {// 释放同步状态if (tryRelease(arg)) {Node h = head;if (h != null && h.waitStatus != 0)// 唤醒后继节点unparkSuccessor(h);return true;}return false;
}

这个 release(int arg) 是在 AQS 类里的了,其内部会调用 tryRelease(int arg) 方法尝试释放同步状态,如果成功释放,获得同步队列里的 head 头节点,头节点不为空并且它的 waitStatus 状态不为 0(即不为 INITAL 初始状态),则会调用 unparkSuccessor(Node node) 唤醒后续节点。

当直接点击进入 tryRelease(int arg) 方法,还是在 AQS 类里,如下所示:

protected boolean tryRelease(int arg) {throw new UnsupportedOperationException();
}

AQS 类的该方法并没有提供实现,跟 tryAcquire(int arg) 方法类似的,会由 Sync子类里的 tryRelease(int arg) 重写该方法实现,如下所示:

/protected final boolean tryRelease(int releases) {// 获得同步状态为1,releases为1,所以c计算得到0int c = getState() - releases;// 当前线程不是加锁线程,则抛出IllegalMonitorStateException异常if (Thread.currentThread() != getExclusiveOwnerThread())throw new IllegalMonitorStateException();boolean free = false;if (c == 0) {free = true;// 将加锁线程变量设置为nullsetExclusiveOwnerThread(null);}// 将state变量更新为计算得到的0,即更新同步状态setState(c);return free;
}

如果释放同步状态成功,上述方法将会返回 true。完成的事情很简单,就是将 state 变量的同步状态更新一下,然后将加锁线程 exclusiveOwnerThread 变量设置为 null。

然后,调用 unparkSuccessor(Node node) 方法通知后继节点,源码如下:

private void unparkSuccessor(Node node) {int ws = node.waitStatus;// 等待状态小于0,则通过CAS更新等待状态值为0if (ws < 0)compareAndSetWaitStatus(node, ws, 0);// 获得头节点的后继节点,即线程2Node s = node.next;// 如果后继节点等待状态大于0,说明是CACELLED失效节点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);
}

图示如下:

通过图示并结合源码,相信大家理解起来就更加清晰了。

注意,线程1释放同步状态后,会通知 后继节点是线程2,不是 Head 头节点。

上述图中,同步队列中的线程2被唤醒后,我们回到 acquireQueued(final Node node, int arg) 这个节点自旋过程的源码看下。可以在上面找一下这个方法的源码,其中线程2调用了 parkAndCheckInterrupt() 方法将线程挂起着,如下所示:

private final boolean parkAndCheckInterrupt() {LockSupport.park(this);return Thread.interrupted();
}

唤醒之后,继续执行,调用 Thread.interrupted() 方法检测下当前线程中断情况。如果没有被中断,则继续循环,执行如下代码:

final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {setHead(node);p.next = null; // help GCfailed = false;return interrupted;
}

node 变量为线程2,调用 p = node.predecessor() 方法获得前驱节点为头节点,满足 p == head 条件,然后调用 tryAcquire(int arg) 尝试获得同步状态,经过上述分析,因为 state 为 0,说明没有线程加锁,所以获得同步状态成功,该方法返回 true。

调用 setHead(node) 方法,如下所示:

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

将 node 作为头节点,node 的 prev 前驱节点指针和 thread 线程变量设置为 null。

图示如下所示:

上述图中,看到原来的头节点,已经没有任何引用了,将来会被 JVM 垃圾回收掉。

刚刚被唤醒的线程2当做了头节点,但实际也是个空节点了, 因为该节点的 thread 设置为 null了。此时,线程3的节点还在自旋状态,等线程2释放锁后,通知后继节点,唤醒线程3。都会执行我们上面分析的同一个套路。

最后,经过对上述源码和图示的分析,咱们来两张完整的流程图,方便大家记忆。
ReentrantLock#lock() 方法获得锁流程图:

ReentrantLock#unlock() 方法释放锁流程图:

**5、最后的总结 **
本文以生活案例场景(医院窗口取药流程)介绍为例,联想到 AQS 到底是什么,接着介绍对 AQS 设计初衷, 并且以 ReentrantLock 独占式锁为例,深入剖析了 AQS 底层数据结构,以及源码的实现细节。

AQS 是 Java 并发包中很多同步组件的构建基石,它内部主要是由同步状态 state 变量和一个 CLH 同步 FIFO 队列协作来完成的,CLH是一个双端双向链表数据结构。

当新的线程节点无法获得同步状态,将会加入到同步队列队尾,此时会采用 CAS 无锁化来确保该操作的线程安全,保证原子性。线程加入到同步队列后会被挂起,等待释放锁唤醒后继节点,使得继续获得同步状态。

AQS 采用了模板方法设计模式,根据不同并发包组件同步需求场景,子类同步器只需重写 tryAcquire(),tryAcquireShared(),tryRelease(),tryReleaseShared() 几个方法来决定同步状态的获取和释放,tryAcquire() 和 tryRelease() 方法同于独占式,tryAcquireShared() 和 tryReleaseShared() 用于共享式。

对于 Java 中很多并发包背后复杂的入队/出队,线程阻塞/唤醒,线程安全的保证等,全部都由 AQS 来帮助你完成了,Doug Lea 大神很是牛逼呀!

弄懂了 AQS,大部分并发包里的工具类都是很容易理解了。另外,对于共享式并发包的源码,大家如果感兴趣,可以借助本文的源码分析过程,去自行画图分析一下。

揭开并发包底层AQS的神秘面纱相关推荐

  1. exchange揭开拨号音还原法的神秘面纱

    之前exchange揭开拨号音还原法的神秘面纱英文版本的翻译,用了两周的时间翻译出来!图片感觉很小,可以下载附件直接查看,会更好! 揭开exchange拨号音恢复的方法(第一部分) 在这三部分系列里, ...

  2. javascript关键字_让我们揭开JavaScript的“ new”关键字的神秘面纱

    javascript关键字 by Cynthia Lee 辛西娅·李(Cynthia Lee) 让我们揭开JavaScript的" new"关键字的神秘面纱 (Let's demy ...

  3. 轻轻揭开 b*tree 索引结构的神秘面纱

    李翔宇 云和恩墨西区技术专家 大家好,我是云和恩墨的技术专家李翔宇,今天要为大家分享的主题是<轻轻揭开b*tree索引结构的神秘面纱>. 说到索引,大家应该都或多或少的了解甚至熟悉它,它是 ...

  4. 揭开自然拼读法(Phonics)的神秘面纱

    揭开自然拼读法(Phonics)的神秘面纱 自然拼读法  (Phonics),是指看到一个单词,就可以根据英文字母在单词里的发音规律把这个单词读出来的一种方法.即从"字母发音-字母组合发音- ...

  5. 80老翁谈人生(291):揭开沃森超级电脑的神秘面纱

    80老翁谈人生(291):揭开沃森超级电脑的神秘面纱 当今,国外媒体掀起鼓吹IBM沃森超级电脑的狂浪,每天新闻不断.沃森超级电脑是什么"神物"?它的工作原理如何? 2016年6月, ...

  6. 比MySQL快839倍!揭开分析型数据库JCHDB的神秘面纱

    前不久,京东智联云云产品研发部架构师王向飞老师在线上公开课<Clickhouse在京东智联云的大规模应用和架构改良>中,介绍了Clickhouse 数据库在京东智联云的落地应用与优化改进经 ...

  7. 揭开手机天线材料LCP的神秘面纱

    一.LCP概述 手机天线行业近半年来的热词相信非LCP莫属.这个被苹果公司看中,并率先应用在iPhone8及iPhoneX上的天线材料是何许物也,又有什么"神奇"之处?本文为就您揭 ...

  8. (转)MS Exchange揭开拨号音还原法的神秘面纱

    (第一部分) 在这三部分系列里,我会解释一下,什么是EXCHANGE拨号音还原法(又称即时恢复,稍后恢复法)所有相关内容,以及何时在什么情况下使用.面对一个或者更多的发生故障的邮箱存储的时候,为什么使 ...

  9. 揭开「拓扑排序」的神秘面纱

    作者 | 小齐本齐 责编 | Carol 来源 | 码农田小齐 Topological sort 又称 Topological order,这个名字有点迷惑性,因为拓扑排序并不是一个纯粹的排序算法,它 ...

最新文章

  1. Vue项目中使用wangEditor富文本输入框(推荐)
  2. 一份值得收藏的,互联网电商购物车架构演变案例
  3. Linux运维之批量下载指定网站的100个图片文件,并找出大于200KB的文件
  4. Linux C编程--string h函数解析
  5. 【sql】牛客网练习题 (共 61 题)
  6. 继承Javadoc方法注释
  7. java timeout超时不抛异常_springCloud 请求超时解决方案 java.net.SocketTimeOut Exception: Read time out 异常解决...
  8. div css 登录页面布局,DIV+CSS页面布局
  9. GDAL读写矢量文件——C#
  10. 使用python实现日志功能
  11. 在Angular外部使用js调用Angular控制器中提供的函数方法或变量
  12. 【中山大学】【东校区】【无线路由】【wr703n】【openwrt】电脑客户端的iptv网络电视在无线路由下不能打开的解决方法
  13. 南京大学计算机专业考研难吗,计算机专业考研,除了南大和中山,还有哪些大学难度大性价比高...
  14. 安全防范趋势、信息安全管理、隐私保护
  15. php webp格式转换,webp的格式的转换
  16. 空间相册怎么移到计算机里,qq空间上传照片_怎样把电脑里存的照片传到qq空间??...
  17. 计算机仿真撤稿,LOL云顶之弈11.5天神裁决天使阵容攻略 新版本裁决天使运营思路...
  18. BLDC无刷直流电机驱动程序
  19. zoj 1905 Power String(后缀数组)
  20. (算法篇)Java实现删除链表倒数第n个节点

热门文章

  1. python关闭线程根据id_python之线程相关操作
  2. 对比学习系列论文CPCforHAR(一):Contrastive Predictive Coding for Human Activity Recognition
  3. hc05与单片机连接图_单片机科普:单片机的IO口不够用了怎么办?如何扩展单片机的IO口...
  4. Python入门100题 | 第014题
  5. 数据分析系列:完善统计图(matplotlib)
  6. LeetCode-剑指 Offer 10- I. 斐波那契数列
  7. Cracer渗透视频课程学习笔记——漏洞分析
  8. IDEA创建Web项目及部署Tomcat
  9. lucene-solr源码编译导入eclipse--转
  10. 深入分析 Java I/O 的工作机制--转载