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源码会简单不少,后续会分析这三个类的源码理解
相关推荐
RunsenLIu1 分钟前
基于Django实现的图书分析大屏系统项目
后端·python·django
Chandler2411 分钟前
Go:低级编程
开发语言·后端·golang
Asthenia041219 分钟前
ArrayList与CopyOnWriteArrayList源码深度解析及面试拷打
后端
Asthenia041219 分钟前
深入解析String、StringBuilder、StringBuffer与final修饰对象的问题
后端
Asthenia041223 分钟前
Java数据类型的四类八种与拆装箱底层原理
后端
郭萌6961 小时前
Docker 安装陀螺匠教程
后端
u0103731061 小时前
Django REST Framework (DRF)
后端·python·django
牛马喜喜1 小时前
sequelize的进阶使用(助力成为优秀全栈)
后端·node.js
勇敢牛牛_1 小时前
【Rust基础】使用Rocket构建基于SSE的流式回复
开发语言·后端·rust
Anarkh_Lee2 小时前
解决 Spring Boot 多数据源环境下事务管理器冲突问题(非Neo4j请求标记了 @Transactional 尝试启动Neo4j的事务管理器)
spring boot·后端·spring