AbstractQueuedSynchronizer源码分析

核心概念

state 状态变量

通过volatile int state表示资源状态,子类通过CAS操作修改该值 例如:ReentrantLock:state表示锁的重入次数。 Semaphore:state表示可用许可数量。 CountDownLatch:state表示未完成的计数

同步队列

CAS state失败后,认为是竞争锁失败,包装成Node节点加入队列中

节点模式

  • 共享模式,多个线程可以共享资源 (如Semaphore、CountDownLatch)
java 复制代码
static final Node SHARED = new Node();
  • 独占模式,只有一个线程获取资源(如ReentrantLock)
java 复制代码
static final Node EXCLUSIVE = null;

Node 队列节点类分析

核心属性字段

java 复制代码
//共享模式
static final Node SHARED = new Node();
//独占模式
static final Node EXCLUSIVE = null;
//线程已取消
static final int CANCELLED =  1;
//后续节点需要唤醒
static final int SIGNAL    = -1;
//条件队列
static final int CONDITION = -2;
//共享模式下传播唤醒
static final int PROPAGATE = -3;
//节点状态
volatile int waitStatus;
//上一个节点 
volatile Node prev;
//下一个节点
volatile Node next;
//关联线程
volatile Thread thread;
//条件队列的下一个等待节点
Node nextWaiter;

构造函数

arduino 复制代码
//同步队列节点使用
Node(Thread thread, Node mode) {     // Used by addWaiter
    this.nextWaiter = mode;
    this.thread = thread;
}
//条件队列节点使用
Node(Thread thread, int waitStatus) { // Used by Condition
    this.waitStatus = waitStatus;
    this.thread = thread;
}

ConditionObject 条件队列

ConditionObject 用于实现等待/通知机制(await()和signal()),类似于Object的wait/notify机制,但是更灵活,可以支持多个条件队列。

同步队列与条件队列的关系

  • 同步队列用于管理竞争锁的线程节点,双向链表结构,通过prev和next构建双向链表
  • 条件队列用于管理等待条件的线程节点,单链表结构,通过nextWaiter链接。

关键点

  • 每个 ConditionObject 实例对应一个独立的条件队列。
  • 条件队列中的节点状态为 CONDITION(值为-2),表示线程正在等待条件触发。
  • 当条件满足时(调用 signal()),节点从条件队列转移到同步队列,状态更新为 0,重新参与锁竞争。

属性字段

java 复制代码
// 条件队列第一个节点
private transient Node firstWaiter;
//条件队列最后一个节点
private transient Node lastWaiter;

核心方法

signal()

java 复制代码
public final void signal() {
    //是否持有锁,因为此操作要先加锁成功
    if (!isHeldExclusively())
        throw new IllegalMonitorStateException
    //条件队列首节点    
    Node first = firstWaiter;
    if (first != null)
        //唤醒
        doSignal(first);
}
typescript 复制代码
private void doSignal(Node first) {
    do {
        //当前节点是最后一个节点
        if ( (firstWaiter = first.nextWaiter) == null
            //置空
            lastWaiter = null;
        //头节点从队列中移除    
        first.nextWaiter = null;
    }
    //迁移失败并且新的首节点不为空,继续尝试迁移新的首节点
    while (
    //迁移到同步队列
    !transferForSignal(first) &&
             //新的首节点不为空
             (first = firstWaiter) != null);
}
scss 复制代码
final boolean transferForSignal(Node node) {
    /*
     * If cannot change waitStatus, the node has been cancelled.
     */
     //CAS设置这个节点的waitStatus 设置为0,失败返回
    if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
        return false;

    /*
     * 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;
    //如果入队节点的状态是CANCELLED状态,或者CAS设置状态为SIGNAL
    if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))
        //唤醒线程
        LockSupport.unpark(node.thread);
    return true;
}
java 复制代码
private Node enq(final Node node) {
    for (;;) {
        //尾节点
        Node t = tail;
        //尾节点是空的,说明同步队列为空
        if (t == null) { // Must initialize
            //无参构造一个Node CAS设置到头节点
            if (compareAndSetHead(new Node()))
                //尾节点也指向它
                tail = head;
        } 
        //队列不为空
        else {
            //入队node的prev指针指向队列的尾节点
            node.prev = t;
            //CAS 把入队node设置成新的尾节点
            if (compareAndSetTail(t, node)) {
                //队列尾节点next指针指向入队node
                t.next = node;
                return t;
            }
        }
    }
}

signal()逻辑总结

  1. 校验是否获取锁,signal在加锁后操作,不然抛出异常;
  2. 从条件队列的首节点开始,将其状态设置为0,成功的话转移到同步队列队尾并返回其前驱节点;
  3. 失败的话并且还有后继节点,重复第二步逻辑;
  4. 第二步成功,检查第二步返回前驱节点的状态,如果是CANCELA状态或者CAS设置SIGNAL状态失败,说明前驱节点有问题,直接唤醒当前节点以确保前驱节点的问题造成当前节点不能被唤醒

await()

scss 复制代码
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)) {
        //不在的话阻塞
        //判断节点是否在同步队列中,一开始肯定不在同步队列中,一直阻塞着,直到被唤醒加入到同步队列或中断
        LockSupport.park(this);
        //interruptMode不等于0退出循环,表示放入同步队列成功
        if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
            break;
    }
    //重新获取锁,检查中断状态
    if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
        interruptMode = REINTERRUPT;
    //清理CANCLE节点    
    if (node.nextWaiter != null) // clean up if cancelled
        unlinkCancelledWaiters();
    //不同中断模式去处理    
    if (interruptMode != 0)
        reportInterruptAfterWait(interruptMode);
}
ini 复制代码
private Node addConditionWaiter() {
    //尾节点
    Node t = lastWaiter;
    // If lastWaiter is cancelled, clean out.
    //尾节点不是CONDITION状态
    if (t != null && t.waitStatus != Node.CONDITION) {
        //释放条件队列不是CONDITION状态的所有节点
        unlinkCancelledWaiters();
        //更新尾节点
        t = lastWaiter;
    }
    //构造一个新的节点状态为CONDITION
    Node node = new Node(Thread.currentThread(), Node.CONDITION);
    //队列是空的
    if (t == null)
        //头指针指向此节点
        firstWaiter = node;
    //队列不为空    
    else
        //原队列尾节点指向新节点
        t.nextWaiter = node;
    //尾指针指向此节点    
    lastWaiter = node;
    return node;
}
ini 复制代码
private void unlinkCancelledWaiters() {
    Node t = firstWaiter;
    //上一个有效节点
    //单链表清除无效节点需要一个指针去处理
    Node trail = null;
    while (t != null) {
        Node next = t.nextWaiter;
        //满足清除条件
        if (t.waitStatus != Node.CONDITION) {
            //断开和下个节点的链接
            t.nextWaiter = null;
            if (trail == null)
                //头指针指向第一个有效节点,就不用动了
                firstWaiter = next;
            else
                //跳过当前无效节点
                trail.nextWaiter = next;
            if (next == null)
                //尾指针指向尾节点
                lastWaiter = trail;
        }
        else
            //更新有效节点
            trail = t;
        //更新下一个指针    
        t = next;
    }
}
ini 复制代码
final int fullyRelease(Node node) {
    boolean failed = true;
    try {
        //返回同步状态
        int savedState = getState();
        //释放
        if (release(savedState)) {
            failed = false;
            return savedState;
        } else {
            throw new IllegalMonitorStateException();
        }
    } finally {
        if (failed)
            node.waitStatus = Node.CANCELLED;
    }
}
kotlin 复制代码
final boolean isOnSyncQueue(Node node) {
    //状态是CONDITION或者上个节点为空,不在同步队列中
    if (node.waitStatus == Node.CONDITION || node.prev == null)
        return false;
    //有后继节点一定在同步队列中    
    if (node.next != null) // If has successor, it must be on queue
        return true;
    /*
     * node.prev can be non-null, but not yet on queue because
     * the CAS to place it on queue can fail. So we have to
     * traverse from tail to make sure it actually made it.  It
     * will always be near the tail in calls to this method, and
     * unless the CAS failed (which is unlikely), it will be
     * there, so we hardly ever traverse much.
     */
     //在同步队列中查找
    return findNodeFromTail(node);
arduino 复制代码
private int checkInterruptWhileWaiting(Node node) {
    return Thread.interrupted() ?
        (transferAfterCancelledWait(node) ? THROW_IE : REINTERRUPT) :
        0;
}
java 复制代码
final boolean transferAfterCancelledWait(Node node) {
   //CAS节点状态为0
    if (compareAndSetWaitStatus(node, Node.CONDITION, 0)) {
        //放入同步队列
        enq(node);
        return true;
    }
    /*
     * If we lost out to a signal(), then we can't proceed
     * until it finishes its enq().  Cancelling during an
     * incomplete transfer is both rare and transient, so just
     * spin.
     */
     //不在同步队列中不能唤醒,自旋等待加入同步队列
    while (!isOnSyncQueue(node))
        Thread.yield();
    return false;
}

await逻辑总结:

  1. 构建条件队列节点加入到条件队列,并把条件队列中的不是CONDITION状态的节点清除掉;
  2. 完全释放锁,如果同步队列的首节点是有效节点,唤醒其后继节点;
  3. 判断节点是否在同步队列中,一开始肯定不在同步队列中,一直阻塞着,直到被唤醒加入到同步队列或中断;
  4. 如果等待期间被中断,根据中断时机设置中断模式();
  5. 唤醒后重新获取锁;
  6. 清理无效节点;
  7. 根据中断模式处理,THROW_IE抛出中断异常,REINTERRUPT 进行中断

AQS 核心方法分析

acquire(int arg)

独占模式下获取锁

scss 复制代码
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}
arduino 复制代码
protected boolean tryAcquire(int arg) {
    //子类实现
    throw new UnsupportedOperationException();
}
ini 复制代码
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节点的前驱指针和尾节点连接
        node.prev = pred;
        //把新加入的节点CAS设置为尾节点
        if (compareAndSetTail(pred, node)) {
            //之前尾节点的next指针和新加入节点链接,完成新节点加入到同步队列中
            pred.next = node;
            return node;
        }
    }
    //如果同步队列是空的执行enq方法逻辑
    enq(node);
    return node;
}
java 复制代码
private Node enq(final Node node) {
    for (;;) {
        //尾节点
        Node t = tail;
        //尾节点是空的,说明同步队列为空
        if (t == null) { // Must initialize
            //无参构造一个Node CAS设置到头节点
            if (compareAndSetHead(new Node()))
                //尾节点也指向它
                tail = head;
        } 
        //队列不为空
        else {
            //入队node的prev指针指向队列的尾节点
            node.prev = t;
            //CAS 把入队node设置成新的尾节点
            if (compareAndSetTail(t, node)) {
                //队列尾节点next指针指向入队node
                t.next = node;
                return t;
            }
        }
    }
}
ini 复制代码
final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            //前驱节点
            final Node p = node.predecessor();
            //是头节点并且获取锁成功,成功后移除此节点
            if (p == head && tryAcquire(arg)) {
                //设置头节点
                setHead(node);
              
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            //阻塞并中断
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}
arduino 复制代码
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;
    //状态是SIGNAL表示可以放心停止
    if (ws == Node.SIGNAL)
        /*
         * This node has already set status asking a release
         * to signal it, so it can safely park.
         */
        return true;
    //大于0表示CANCLE状态    
    if (ws > 0) {
        /*
         * Predecessor was cancelled. Skip over predecessors and
         * indicate retry.
         */
         //找到前驱节点不是CANCLE的
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        /*
         * waitStatus must be 0 or PROPAGATE.  Indicate that we
         * need a signal, but don't park yet.  Caller will need to
         * retry to make sure it cannot acquire before parking.
         */
         //更新前驱节点为SIGNAL
         //
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}
java 复制代码
private void cancelAcquire(Node node) {
    // Ignore if node doesn't exist
    if (node == null)
        return;
    //清空线程
    node.thread = null;

    // Skip cancelled predecessors
    //跳过已取消的前驱节点,找到不为CANCLE的节点
    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.
    //记录前驱节点的next指针
    Node predNext = pred.next;

    // 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;

    // 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;
        //不是尾节点,把前驱节点的next指针指向当前节点的next指针,也就把当前节点从链表中断开了
        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 {
            //唤醒后继节点
            unparkSuccessor(node);
        }
        //自己本身的next指针指向自己,就和别人没关系了,GC root不可达,帮助垃圾回收
        node.next = node; // help GC
    }
}

acquire 的流程总结:

  1. 尝试直接获取锁;
  2. 获取锁失败,加入到同步队列;
  3. 循环尝试获取锁。 如果前驱节点不是头节点或者获取锁失败,判断前驱节点状态是否是SGNAL,是的话放心阻塞;
  4. 判断前驱节点状态如果是CANCELLED状态,往上找不是CACLE状态的节点,继续第三步;
  5. 前驱节点状态不是CANCELLED状态,设置前驱节点为SIGNAL状态继续第三步;
  6. 直到第三步满足前驱节点是头节点并且获取锁成功,移除头节点返回中断状态;
  7. 如果第六步是true进行中断操作;
  8. 在执行acquireQueued方法逻辑中出现异常(线程被中断抛出中断异常、其他异常等)执行cancelAcquire方法取消这个有问题的节点

release(int arg)

独占模式下释放锁

java 复制代码
public final boolean release(int arg) {
    //释放锁
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            //唤醒后继节点
            unparkSuccessor(h);
        return true;
    }
    return false;
}
arduino 复制代码
protected boolean tryRelease(int arg) {
//子类实现
    throw new UnsupportedOperationException();
}
java 复制代码
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;
    //小于0设置状态为0
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);

    /*
     * Thread to unpark is held in successor, which is normally
     * just the next node.  But if cancelled or apparently null,
     * traverse backwards from tail to find the actual
     * non-cancelled successor.
     */
 
    Node s = node.next;
    //下个节点为空或者状态大于0
    if (s == null || s.waitStatus > 0) {
        //置空
        s = null;
        //从队尾一直找,找到状态小于等于0的节点
        //这段代码是addWaiter(Node mode)方法入队时候通过CAS  (compareAndSetTail(pred, node))
        //保证了
        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为什么从队尾开始找?

从上面两张截图红框位置可以看到prev 指针的设置在 CAS 更新尾指针前已经完成,而next指针在并发操作的时候可能还是空的,所以用pre去操作更安全,可以得到正确的结果

release(int arg)方法总结:

  1. 子类实现tryRelease(arg)成功;
  2. 唤醒后继节点。首节点的下一个节点不为空并且状态小于等于0,说明是有效节点直接唤醒;否则从队尾一直找,找到状态小于等于0的节点并唤醒

acquireShared(int arg)

共享模式获取锁

arduino 复制代码
public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}
arduino 复制代码
protected int tryAcquireShared(int arg) {
//子类实现
    throw new UnsupportedOperationException();
}
ini 复制代码
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) {
                //获取锁
                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);
    }
}
ini 复制代码
private void setHeadAndPropagate(Node node, int propagate) {
    Node h = head; // Record old head for check below
    //设置新节点为头节点
    setHead(node);
    //propagate > 0任有剩余资源
    //h==null表示原头节点被移除
    //h.waitStatus < 0 表示需要唤醒后续节点
    //通过二次获取 head 并检查其状态,确保判断基于最新队列状态,防止遗漏
    if (propagate > 0 || h == null || h.waitStatus < 0 ||
        (h = head) == null || h.waitStatus < 0) {
        Node s = node.next;
        if (s == null || s.isShared())
             //传播唤醒
            doReleaseShared();
    }
}
csharp 复制代码
private void doReleaseShared() {
    for (;;) {
        Node h = head;
        //头节点不为空并且头节点不是尾节点(队列不止头节点)
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            //状态是SIGNAL
            if (ws == Node.SIGNAL) {
                //设置状态为0
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    //失败继续
                    continue;            // loop to recheck cases
                //成功唤醒后继节点    
                unparkSuccessor(h);
            }
            //状态为0设置waitStatus为PROPAGATE
            //这里设置为PROPAGATE表示后续线程继续做传播唤醒工作
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                //成功继续     
                continue;                // loop on failed CAS
        }
        //头节点没有变化,说明当前线程已经完成了唤醒操作,退出循环
        //如果有变化,说有其他线程修改了头节点,有新的头节点诞生,继续循环处理
        if (h == head)                   // loop if head changed
            break;
    }
}

acquireShared(int arg)方法流程总结:

  1. (tryAcquireShared(arg) < 0)获取锁失败,添加共享节点到同步队列;
  2. 循环尝试获取锁。 如果前驱节点不是头节点,判断前驱节点状态是否是SGNAL,是的话放心阻塞;
  3. 判断前驱节点状态如果是CANCELLED状态,往上找不是CANCELLED状态的节点,继续第二步;
  4. 前驱节点状态不是CANCELLED状态,设置前驱节点为SIGNAL状态,继续第二步;
  5. 直到第二步满足前驱节点是头节点并且获取锁成功,设置当前节点为头节点,并唤醒后继节点,如果需要中断进行中断,退出循环

releaseShared(int arg)

共享模式释放锁

arduino 复制代码
public final boolean releaseShared(int arg) {
    if (tryReleaseShared(arg)) {
        doReleaseShared();
        return true;
    }
    return false;
}
arduino 复制代码
protected boolean tryReleaseShared(int arg) {
//子类实现
    throw new UnsupportedOperationException();
}
csharp 复制代码
private void doReleaseShared() {
    for (;;) {
        Node h = head;
        //头节点不为空并且头节点不是尾节点(队列不止头节点)
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            //状态是SIGNAL
            if (ws == Node.SIGNAL) {
                //设置状态为0
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    //失败继续
                    continue;            // loop to recheck cases
                //成功唤醒后继节点    
                unparkSuccessor(h);
            }
            //状态为0设置waitStatus为PROPAGATE
            //这里设置为PROPAGATE表示后续线程继续做传播唤醒工作
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                //成功继续     
                continue;                // loop on failed CAS
        }
        //头节点没有变化,说明当前线程已经完成了唤醒操作,退出循环
        //如果有变化,说有其他线程修改了头节点,有新的头节点诞生,继续循环处理
        if (h == head)                   // loop if head changed
            break;
    }
}

releaseShared(int arg)方法流程总结:

  1. 尝试释放资源;
  2. 传播唤醒后续节点

总结

  • 重点分析了ConditionObject 条件队列的signal()和await()方法, signalAll()、awaitNanos(long nanosTimeout)等方法逻辑类似,没有再进行分析
  • 重点分析了AQS中的acquire(int arg), release(int arg), acquireShared(int arg), releaseShared(int arg)方法,其他的方法逻辑类似
  • 理解这些逻辑,再去看ReentrantLock、Semaphore、CountDownLatch源码会简单不少,后续会分析这三个类的源码理解
相关推荐
luckyext1 小时前
Postman发送GET请求示例及注意事项
前端·后端·物联网·测试工具·小程序·c#·postman
架构文摘JGWZ1 小时前
SQLite?低调不是小众...
数据库·后端·学习·sqlite
uhakadotcom2 小时前
Pandas DataFrame 入门教程
后端·面试·github
Asthenia04122 小时前
深入理解 Java 中的 ThreadLocal:从传统局限到 TransmittableThreadLocal 的解决方案
后端
勇哥java实战分享2 小时前
一次非常典型的 JVM OOM 事故 (要注意 where 1 = 1 哦)
后端
Asthenia04122 小时前
ThreadLocal原理分析
后端
绛洞花主敏明3 小时前
go中实现子模块调用main包中函数的方法
开发语言·后端·golang
孔令飞3 小时前
01 | Go 项目开发极速入门课介绍
开发语言·人工智能·后端·云原生·golang·kubernetes
幽络源小助理4 小时前
SpringBoot学生宿舍管理系统的设计与开发
java·spring boot·后端·学生宿舍管理