Semaphore

前情提要:在学习本章前,需要先了解笔者先前讲解过的ReentrantLock源码解析,ReentrantLock源码解析里介绍的方法有很多是本章的铺垫。下面,我们进入本章正题Semaphore。

从概念上来讲,信号量(Semaphore)会维护一组许可证用于限制线程对资源的访问,当我们有一资源允许线程并发访问,但我们希望能限制访问量,就可以用信号量对访问线程进行限制。当线程要访问资源时,要先调用信号量的acquire方法获取访问许可证,当线程访问完毕后,调用信号量的release归还许可证。使用信号量我们可以服务做限流,尤其像淘宝天猫这样平时访问量就很大的电商大户,在双十一的时候更要评估其服务能承受的访问量并对其做限流,避免因为访问量过大导致服务宕机。然而,Semaphore内部实际上并没有维护一组许可证对象,而是维护一个数字作为许可证数量,如果线程要获取许可证,则会根据线程请求的许可证数量扣减内部的维护的数量,如果足够扣除则线程获取许可证成功,否则线程必须陷入阻塞,直到信号量内部的许可证数量足够。

我们来看下面的代码,假设OrderService是一个远程服务,我们预估这个服务能承受的并发量是5000,访问一次远程服务需要获取一个许可证,执行methodA()的业务只需要请求一次远程服务,所以调用semaphore.acquire()默认获取一个许可证。执行methodB()的业务需要向远程服务并发发送两次请求,所以这里acquire(int permits)的参数我们传2,以保证不管是执行methodA()还是methodB(),远程服务的并发量不超过5000。

当我们的业务不再对远程服务的访问,需要归还许可证,methodA()原先只请求一个许可证,这里调用release()对信号量内部的许可证数量+1即可。methodB()请求两个许可证,所以这里要调用release(int permits)归还两个。假设我们的服务里同时有4999个线程已经在执行methodA()方法,有一个线程要执行methodB()方法,可以知道许可证数量是不够的,信号量维护的许可证数量为5000,但线程如果要同时执行需要5001个许可证,所以要执行methodB()的线程会陷入阻塞,直到信号量内部的许可证数量足够扣除,才会获取需要的许可证数量,然后访问远程服务。

public class OrderService {
private Semaphore semaphore = new Semaphore(5000); public void methodA() {
try {
semaphore.acquire();
//methodA body
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release();
}
} public void methodB() {
try {
semaphore.acquire(2);
//methodB body
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release(2);
}
} }

  

如果是许可证为1的信号量可以把它当做互斥锁,这时信号量只有两个状态:0或者1,我们把1代表锁未被占用,0代表锁被占用。如果是用这种方式将信号量当做互斥锁我们可以用一个线程来获取锁,而另一个线程来释放锁,比如下面的<1>处和<2>处分别在不同的线程加锁和释放锁。某种程度上来说这一做法可以避免死锁,与传统java.util.concurrent.locks.Lock的实现会有很大的不同,传统的Lock实现,比如:ReentrantLock会要求解锁的线程必须要是原先加锁的线程,否则会抛出异常。

public static void main(String[] args) {
Semaphore semaphore = new Semaphore(1);
new Thread(() -> {
try {
semaphore.acquire();//<1>
System.out.println(Thread.currentThread().getName() + "获取独占锁");
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "线程1").start();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(() -> {
semaphore.release();//<2>
System.out.println(Thread.currentThread().getName() + "释放独占锁");
}, "线程2").start();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

  

当信号量的许可证数量为0时,如果还有线程请求获取许可证,信号量会将线程放入一个队列,然后挂起线程,直到有许可证被归还,信号量会尝试唤醒队列中等待许可证最长时间的线程。所以信号量就分为公平(FairSync)和非公平(NonfairSync)两种模式。在公平模式下,如果有线程要获取信号量的许可证时,会先判断信号量维护的等待队列中是否已经有线程,如果有的话则乖乖入队,没有才尝试请求许可证;而非公平模式则是直接请求许可证,不管队列中是否已有线程在等待信号量的许可证。

而下面的代码也印证了笔者之前所说的,信号量本身并不会去维护一个许可证对象的集合,当我们把许可证数量传给信号量的构造函数时,最终会由静态内部类Sync调用其父类AQS的setState(permits)方法将许可证赋值给AQS内部的字段state,由这个字段决定信号量有多少个许可证,请求许可证的线程能否成功。

public class Semaphore implements java.io.Serializable {
private final Sync sync;
abstract static class Sync extends AbstractQueuedSynchronizer {
//...
Sync(int permits) {
setState(permits);
}
//...
}
static final class NonfairSync extends Sync {//非公平
NonfairSync(int permits) {
super(permits);
}
//...
}
static final class FairSync extends Sync {//公平
//...
FairSync(int permits) {
super(permits);
}
//...
}
public Semaphore(int permits) {
sync = new NonfairSync(permits);
}
public Semaphore(int permits, boolean fair) {
sync = fair ? new FairSync(permits) : new NonfairSync(permits);
}
//...
}

    

从上面节选的代码来看,官方更推荐使用非公平的信号量,因为根据许可证数量创建信号量默认使用的非公平信号量,而相比于公平信号量,非公平信号量有更高的吞吐量。因此笔者先介绍非公平信号量,再介绍公平信号量。

我们先来看看acquire()和acquire(int permits)  这两个方法,可以看到不管我们是请求一个许可证,还是请求多个许可证,本质上都是调用Sync.

acquireSharedInterruptibly(int arg)方法。如果大家观察静态内部类Sync的代码可以发现:Sync并没有实现acquireSharedInterruptibly(int arg)方法,而是其父类AQS实现了此方法。

public class Semaphore implements java.io.Serializable {
//...
private final Sync sync;
abstract static class Sync extends AbstractQueuedSynchronizer {
//...
}
public void acquire() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
//...
public void acquire(int permits) throws InterruptedException {
if (permits < 0) throw new IllegalArgumentException();
sync.acquireSharedInterruptibly(permits);
}
//...
}

  

于是我们追溯到AQS实现的acquireSharedInterruptibly(int arg)方法,这个方法的实现其实并不难,先判断当前线程是否有中断标记,有的话则直接抛出中断异常InterruptedException,之后调用tryAcquireShared(int arg)尝试获取许可证,AQS本身并没有实现tryAcquireShared(int arg)方法,而是交由子类去实现的,才有了子类来决定是直接尝试获取许可证,还是先判断信号量的等待队列是否有线程正在等待许可证,有的话则排队,没有则尝试请求。

public abstract class AbstractQueuedSynchronizer
extends AbstractOwnableSynchronizer
implements java.io.Serializable {
//...
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
if (tryAcquireShared(arg) < 0)
doAcquireSharedInterruptibly(arg);
}
//...
protected int tryAcquireShared(int arg) {
throw new UnsupportedOperationException();
}
//...
}

  

所以我们来看看非公平锁实现的tryAcquireShared(int arg)方法,在非公平锁的ryAcquireShared(int arg)方法中会调用到Sync类实现的nonfairTryAcquireShared(int acquires)方法,这个方法会先获取当前信号量剩余的许可证数量available,然后减去请求的数量(available - acquires)得到剩余许可证数量remaining,如果remaining大于0代表信号量现有的许可证数量是允许分配调用线程请求的许可证数量,是允许分配的,所以<1>处的条件为false,会进行<2>处的CAS扣减,如果能扣减成功,则返回剩余许可证数量,返回的remaining如果大于等于0,则代表扣减成功,如果小于0代表请求失败,表示信号量现有的许可证数量不足调用线程所需。

public abstract class AbstractQueuedSynchronizer
extends AbstractOwnableSynchronizer
implements java.io.Serializable {
//...
abstract static class Sync extends AbstractQueuedSynchronizer {
final int nonfairTryAcquireShared(int acquires) {
for (;;) {
int available = getState();
int remaining = available - acquires;
if (remaining < 0 ||//<1>
compareAndSetState(available, remaining))//<2>
return remaining;
}
}
}
//...
static final class NonfairSync extends Sync {
private static final long serialVersionUID = -2694183684443567898L; NonfairSync(int permits) {
super(permits);
} protected int tryAcquireShared(int acquires) {
return nonfairTryAcquireShared(acquires);
}
}
//...
}

  

如果在<1>处执行tryAcquireShared(arg)尝试获取许可证失败,则会调用<2>处的方法将当前线程挂起。

public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
if (tryAcquireShared(arg) < 0)//<1>
doAcquireSharedInterruptibly(arg);//<2>
}

  

那么我们来看看如果调用tryAcquireShared(arg)请求许可证失败后,doAcquireSharedInterruptibly(int arg)里面完成的逻辑。如果有看过笔者前一章ReentrantLock源码解析的朋友在看到这个方法应该会觉非常熟悉,这里会先调用<1>处的addWaiter(Node mode)方法将当前请求许可证的线程封装成一个Node节点并入队,这里我们也首次看到使用Node.SHARED的地方,如果一个节点Node的nextWaiter指向的是静态常量Node.SHARED,则代表这个节点是一个共享节点,换句话说这个节点的线程可以和其他同为共享节点的线程共享资源。

当线程作为节点入队后,判断节点的前驱节点是否是头节点,如果是头节点则话则进入<2>处的分支,这里会再次调用tryAcquireShared(arg)请求许可证,之前说过如果tryAcquireShared(arg)返回的结果大于等于0代表请求许可证成功,否则请求失败。如果请求失败的话,之后的流程大家想必都清楚了,会先执行shouldParkAfterFailedAcquire(p, node)判断前驱节点p的等待状态是否为SIGNAL(-1),如果为SIGNAL则直接返回true,调用parkAndCheckInterrupt()阻塞当前线程,如果前驱节点p的等待状态为0,会先用CAS的方式修改为SIGNAL,然后再下一次循环中阻塞当前线程。

public abstract class AbstractQueuedSynchronizer
extends AbstractOwnableSynchronizer
implements java.io.Serializable {
//...
private void doAcquireSharedInterruptibly(int arg)
throws InterruptedException {
final Node node = addWaiter(Node.SHARED);//<1>
try {
for (;;) {
final Node p = node.predecessor();
if (p == head) {//<2>
int r = tryAcquireShared(arg);
if (r >= 0) {
setHeadAndPropagate(node, r);
p.next = null; // help GC
return;
}
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
throw new InterruptedException();
}
} catch (Throwable t) {
cancelAcquire(node);
throw t;
}
}
//...
private Node addWaiter(Node mode) {
Node node = new Node(mode); for (;;) {
Node oldTail = tail;
if (oldTail != null) {
node.setPrevRelaxed(oldTail);
if (compareAndSetTail(oldTail, node)) {
oldTail.next = node;
return node;
}
} else {
initializeSyncQueue();
}
}
}
//...
static final class Node {
static final Node SHARED = new Node();
static final Node EXCLUSIVE = null;
static final int CANCELLED = 1;
//...
static final int PROPAGATE = -3;
volatile int waitStatus;
volatile Node prev;
volatile Node next;
volatile Thread thread;
Node nextWaiter;
final boolean isShared() {
return nextWaiter == SHARED;
}
//...
Node(Node nextWaiter) {
this.nextWaiter = nextWaiter;
THREAD.set(this, Thread.currentThread());
}
}
//...
}

  

上面的流程是当前线程没有请求许可证成功而陷入阻塞的情况,那么如果是线程进入等待队列后又获取到许可证呢?即:执行完下面<1>处的代码确定线程对应的节点入队,在<2>处判断节点的前驱节点是头节点,进入<2>处的分支后执行<3>处的tryAcquireShared(arg)方法成功获取到许可证此时返回的r>=0,进入<4>处的分支,那么在setHeadAndPropagate(Node node, int propagate)方法中又会做什么呢?

首先会保留一个原始头节点head的引用,其次替换头节点为当前节点。如果原先返回r(propagate)大于0,代表当前线程在请求完许可证后,信号量还有剩余许可证,于是<5>处的分值一定成立,因为propagate大于0,这里会判断当前节点的下一个节点next是否是共享模式,是的话则调用doReleaseShared()方法唤醒当前节点的下一个节点。但如果传入的propagate等于0,还有另外几个条件可以尝试通知当前节点的后继节点,只要条件(h == null || h.waitStatus < 0 ||  (h = head) == null || h.waitStatus < 0)成立,且当前节点的下个节点仍为共享节点,则可以唤醒后继节点申请许可证。那么怎么来理解条件(h == null || h.waitStatus < 0 ||  (h = head) == null || h.waitStatus < 0)呢?

首先我们可以先忽略这4个条件里面其中的两个条件,h == null和(h = head) == null都不可能成立,h是原始头节点,只要有节点入队,头节点不可能为null,其次判断head也不可能为null,因为头节点已经是当前节点,就笔者看来这两个判断是防止空指针异常的标准写法,只是预防空指针不代表会发生空指针异常。所以我们只要关注h.waitStatus < 0或者head.waitStatus < 0两个条件,其中一个成立,就可以进入<5>处的分支。那么,又如何来理解h.waitStatus < 0和head.waitStatus < 0两个条件呢?

我们先来回忆下shouldParkAfterFailedAcquire(Node pred, Node node)这个方法,这个方法接收一个前驱节点和当前节点,把前驱节点的等待状态改为SIGNAL(-1),代表前驱节点pred的下一个节点node等待唤醒。于是我们能够明白如果head.waitStatus < 0代表当前节点的下一个节点等待唤醒,如果下一个节点的模式是共享节点,就会尝试调用doReleaseShared()方法唤醒下一个节点尝试申请许可证,即便目前传入的信号量剩余许可证数量propagate为0,因为可能存从唤醒到申请许可证的期间,已经有别的线程归还了许可证,这样做可以提高整体的吞吐量,即便下一个线程被唤醒后没有可申请的许可证数量,要做的也无非是重新阻塞线程。需要注意的是:如果队列中有n个节点,唤醒后继节点这个操作不一定会从头节点一直传播到尾节点,即便前n-1个节点的等待状态(waitStatus)都为SIGNAL,最后一个因为是尾节点,它没有下一个等待唤醒的节点,所以等待状态为0。要知道当前节点能唤醒下一个节点的前提条件,首先是前驱节点为头节点,其次当前节点的线程申请到许可证,才有资格尝试唤醒下一个节点,如果节点被唤醒后,虽然前驱节点是头节点,却没有多余的许可证可以申请,无法将头节点替换成当前节点,就会重新陷入阻塞,也就不会尝试唤醒下一个节点。

public abstract class AbstractQueuedSynchronizer
extends AbstractOwnableSynchronizer
implements java.io.Serializable {
//...
private void doAcquireSharedInterruptibly(int arg)
throws InterruptedException {
final Node node = addWaiter(Node.SHARED);//<1>
try {
for (;;) {
final Node p = node.predecessor();
if (p == head) {//<2>
int r = tryAcquireShared(arg);//<3>
if (r >= 0) {//<4>
setHeadAndPropagate(node, r);
p.next = null; // help GC
return;
}
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
throw new InterruptedException();
}
} catch (Throwable t) {
cancelAcquire(node);
throw t;
}
}
//...
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) {//<5>
Node s = node.next;
if (s == null || s.isShared())
doReleaseShared();
}
}
//...
private void setHead(Node node) {
head = node;
node.thread = null;
node.prev = null;
}
//...
}

  

下面该来讲解当h.waitStatus < 0,事实上h.waitStatus < 0这一判断并非必要,但可以提高吞吐量。

这里笔者预先介绍一个知识点,当线程调用release()或release(int permits)方法向信号量(Semaphore)归还许可证时后,会再调用doReleaseShared()方法唤醒信号量等待队列中被阻塞的线程起来申请许可证,这里如果判断头节点的等待状态为SIGNAL,则表明头节点的后继节点陷入阻塞,如果能用CAS的方式修改头节点的等待状态成功,则调用unparkSuccessor(h)唤醒被阻塞的后继节点起来申请许可证。被阻塞的线程唤醒后如果能申请到许可证,会先把头节点替换成当前节点,并根据条件判断是否要调用doReleaseShared()唤醒下一个后继节点,如果申请许可证失败则执行两次shouldParkAfterFailedAcquire(Node pred, Node node)后重新挂起当前线程。

那么h.waitStatus < 0这一判断是如何来提高吞吐量呢?举个例子:有一信号量许可证为2,并已经分配给线程1和线程2,此时信号量的许可证数量为0。线程3和线程4想要请求许可证只能先入队等待,线程3和线程4的对应节点是N3和N4,队列中的节点排序为:header->N3->N4。假设N3在入队之后,线程1就归还了许可证,此时N3判断它的前驱节点是头节点,继而申请到许可证,因此N3不会调用shouldParkAfterFailedAcquire(Node pred, Node node)改变原先头节点的等待状态。线程1在归还许可证后,调用doReleaseShared(),假定N3入队的时候队列为空,header是调用initializeSyncQueue()方法初始化完成的。所以header的等待状态为0,在<3>处会用CAS的方式修改header的等待状态为PROPAGATE(-3)。于是线程3在执行setHeadAndPropagate(Node node, int propagate)的时候,将头节点指向N3,假定此时线程4虽然入队,但尚未修改前驱节点N3的等待状态为SIGNAL,所以((h = head) == null || h.waitStatus < 0)为false,但原先头节点的等待状态小于0,这里还是会进入<1>处的分支,判断N4是共享节点,调用doReleaseShared()唤醒线程4。

如果线程4正在执行,且有别的线程调用LockSupport.unpark(Thread thread)唤醒线程4,线程4在第一次执行LockSupport.park(Object blocker)并不会陷入阻塞,会退出parkAndCheckInterrupt()方法后又重新申请许可证,如果申请失败,再次调用parkAndCheckInterrupt()执行LockSupport.park(Object blocker)才会被阻塞,相当于线程4多了一次申请许可证的机会。也许在线程4第一次执行LockSupport.park(Object blocker)却没陷入阻塞的时候,线程2就归还了许可证,在新一轮的循环时线程4就直接申请到许可证。

如果线程4被阻塞,此时线程2归还了许可证却还没来得及调用doReleaseShared(),线程3先进入<1>处的分支调用了doReleaseShared(),线程4会被唤醒起来申请许可证,相当于有两个线程争相唤醒线程4。由此可见,如果头节点的等待状态为0,修改其等待状态为PROPAGATE,并在<1>处加上判断原先头节点的等待状态,可以提高吞吐量。

当然,执行h.compareAndSetWaitStatus(0, Node.PROPAGATE)存在失败的情况,比如原先判定头节点的等待状态为0,在执行<3>处代码之前,头节点的后继节点修改前驱节点的等待状态为SIGNAL,此时CAS修改头节点的等待状态为PROPAGATE失败,会重新执行一次循环,此时会进入<2>处唤醒后继节点,于是后继节点就又多了一次申请许可证的机会。

public abstract class AbstractQueuedSynchronizer
extends AbstractOwnableSynchronizer
implements java.io.Serializable {
//...
static final class Node {
//...
static final int SIGNAL = -1;
static final int PROPAGATE = -3;
//...
}
//...
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 ||//<1>
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
if (s == null || s.isShared())
doReleaseShared();
}
}
//...
private void doReleaseShared() {
for (;;) {
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {//<2>
if (!h.compareAndSetWaitStatus(Node.SIGNAL, 0))
continue; // loop to recheck cases
unparkSuccessor(h);
}
else if (ws == 0 &&
!h.compareAndSetWaitStatus(0, Node.PROPAGATE))//<3>
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}
//...
}

  

Java并发之Semaphore源码解析(一)的更多相关文章

  1. Java并发之Semaphore源码解析(二)

    在上一章,我们学习了信号量(Semaphore)是如何请求许可证的,下面我们来看看要如何归还许可证. 可以看到当我们要归还许可证时,不论是调用release()或是release(int permit ...

  2. Java并发之ReentrantReadWriteLock源码解析(一)

    ReentrantReadWriteLock 前情提要:在学习本章前,需要先了解笔者先前讲解过的ReentrantLock源码解析和Semaphore源码解析,这两章介绍了很多方法都是本章的铺垫.下面 ...

  3. Java并发之ReentrantReadWriteLock源码解析(二)

    先前,笔者和大家一起了解了ReentrantReadWriteLock的写锁实现,其实写锁本身实现的逻辑很少,基本上还是复用AQS内部的等待队列思想.下面,我们来看看ReentrantReadWrit ...

  4. Java并发之ThreadPoolExecutor源码解析(二)

    ThreadPoolExecutor ThreadPoolExecutor是ExecutorService的一种实现,可以用若干已经池化的线程执行被提交的任务.使用线程池可以帮助我们限定和整合程序资源 ...

  5. Java并发之ReentrantLock源码解析(二)

    在了解如何加锁时候,我们再来了解如何解锁.可重入互斥锁ReentrantLock的解锁方法unlock()并不区分是公平锁还是非公平锁,Sync类并没有实现release(int arg)方法,这里会 ...

  6. Java并发之ReentrantLock源码解析(四)

    Condition 在上一章中,我们大概了解了Condition的使用,下面我们来看看Condition再juc的实现.juc下Condition本质上是一个接口,它只定义了这个接口的使用方式,具体的 ...

  7. Java并发之ReentrantLock源码解析(三)

    ReentrantLock和BlockingQueue 首先,看到这个标题,不要怀疑自己进错文章,也不要怀疑笔者写错,哈哈.本章笔者会从BlockingQueue(阻塞队列)的角度,看看juc包下的阻 ...

  8. Java并发之ThreadPoolExecutor源码解析(三)

    Worker 先前,笔者讲解到ThreadPoolExecutor.addWorker(Runnable firstTask, boolean core),在这个方法中工作线程可能创建成功,也可能创建 ...

  9. Java并发之ReentrantLock源码解析(一)

    ReentrantLock ReentrantLock是一种可重入的互斥锁,它的行为和作用与关键字synchronized有些类似,在并发场景下可以让多个线程按照一定的顺序访问同一资源.相比synch ...

随机推荐

  1. Mybatis-spring-boot-starter自动配置的原理分析

    相信大家在使用SpringBoot的过程中,经常会使用到mybatis,通过使用mybatis-spring-boot-starter依赖进行自动配置,省去了自己依赖配置和Bean配置的很多麻烦. 有 ...

  2. select执行顺序

    先from 找到表on过滤 找到两张表有对应关系的记录按join的方式添加外部行where 过滤group by分组having 过滤select 从having 过滤出来的字段中选择需要的字段dis ...

  3. [bug] Python AttributeError: module 'web' has no attribute 'application'

    原因 文件名是web.py,与包名web冲突 解决 重命名文件,再运行

  4. [刷题] PTA 7-62 切分表达式 写个tokenizer吧

    我的程序: 1 #include<stdio.h> 2 #include<string.h> 3 #define N 50 4 char token[]= {'+','-',' ...

  5. GB 18030-2000《信息技术信息交换用汉字编码字符集基本集的扩充

    中文编码:GB2312编码.GBK编码.GB18030编码 2016-09-01 0 By ADMIN 一.GB 2312编码 中华人民共和国国家标准简体中文字符集,全称<信息交换用汉字编码字符 ...

  6. Win10屏幕亮度不能调节,调节无效怎么办?

    Win10屏幕亮度不能调节,调节无效怎么办? 听语音 浏览:1027 | 更新:2019-11-22 11:43 1 2 3 4 5 6 7 分步阅读 一些用户在使用win10系统之后,出现了电脑屏幕 ...

  7. EasyUI_使用datagrid分页 (Day_28)

    本次分页涉及技术点 SSM+PageHelper+DatagrId 先来看下效果: 这是无条件分页,下一篇博客我们将讲有条件分页. 无论你是使用js加载table 还是直接使用标签. 使用datagr ...

  8. GO学习-(38) Go语言结构体转map[string]interface{}的若干方法

    结构体转map[string]interface{}的若干方法 本文介绍了Go语言中将结构体转成map[string]interface{}时你需要了解的"坑",也有你需要知道的若 ...

  9. Go语言web开发---Beego的cookie

    1.简介 (1)Http是无状态的协议,服务器不能记录浏览器的访问状态,也就是说服务器不能区分两次请求是否是同一个客户端,这样的设计严重阻碍了web程序的设计. (2)Cookie是解决Http协议无 ...

  10. Springboot单元测试@RunWith注解

    1.RunWith 注解 RunWith 就是一个运行器 可以在单元测试的时候,自动创建spring的应用上下文 2.正确使用 pom.xml <dependency> <group ...