SynchronousQueue 是一个由链表或栈结构组成的阻塞队列,采用无锁算法,并发安全。
每个线程的存入操作必须等待一个取出操作与之匹配(反之亦然),否则当前线程将阻塞在队列中等待匹配。
适用于传递性场景,即生产者线程处理的数据直接传递给消费者线程。
本文基于 jdk1.8.0_91
1. 继承体系
java.util.concurrent.SynchronousQueue
public class SynchronousQueue<E> extends AbstractQueue<E>
implements BlockingQueue<E>, java.io.Serializable
2. 数据结构
双重栈(Dual stack)或 双重队列(Dual Queue)。
定义了内部类 Transferer,作为栈和队列实现的公共 API。
// 传输器,即两个线程交换元素使用的东西。提供两种实现方式:队列、栈
private transient volatile Transferer<E> transferer;
3. 构造函数
如果是公平模式就使用队列,如果是非公平模式就使用栈,默认使用栈(非公平)。
/**
* Creates a {@code SynchronousQueue} with nonfair access policy.
*/
public SynchronousQueue() {
this(false);
}
/**
* Creates a {@code SynchronousQueue} with the specified fairness policy.
*
* @param fair if true, waiting threads contend in FIFO order for
* access; otherwise the order is unspecified.
*/
public SynchronousQueue(boolean fair) {
transferer = fair ? new TransferQueue<E>() : new TransferStack<E>();
}
4. 属性
/** The number of CPUs, for spin control */
static final int NCPUS = Runtime.getRuntime().availableProcessors();
// 指定超时时间情况下,当前线程最大自旋次数。
// CPU小于2,不需要自旋,否则自旋32次。
static final int maxTimedSpins = (NCPUS < 2) ? 0 : 32;
// 未指定超时时间情况下,当前线程最大自旋次数。// 16倍
static final int maxUntimedSpins = maxTimedSpins * 16;
// 指定超时时间情况下,若自旋结束后如果剩余时间大于该值,则阻塞相应时间
static final long spinForTimeoutThreshold = 1000L;
5. 容量
队列容量固定为 0。
/**
* Always returns {@code true}.
* A {@code SynchronousQueue} has no internal capacity.
*
* @return {@code true}
*/
public boolean isEmpty() {
return true;
}
/**
* Always returns zero.
* A {@code SynchronousQueue} has no internal capacity.
*
* @return zero
*/
public int size() {
return 0;
}
6. 存入取出操作
SynchronousQueue 中的传输器定义了公共方法 Transferer#transfer:
java.util.concurrent.SynchronousQueue.Transferer
/**
* Shared internal API for dual stacks and queues.
*/
abstract static class Transferer<E> {
/**
* Performs a put or take.
*
* @param e if non-null, the item to be handed to a consumer; // 传输的数据元素。非空表示需要向消费者传递数据,为空表示需要向生产者请求数据
* if null, requests that transfer return an item
* offered by producer.
* @param timed if this operation should timeout // 该操作是否会超时
* @param nanos the timeout, in nanoseconds
* @return if non-null, the item provided or received; if null, // 非空表示数据元素传递或接收成功,为空表示失败
* the operation failed due to timeout or interrupt -- // 失败的原因有两种:1.超时;2.中断,通过 Thread.interrupted 来检测中断
* the caller can distinguish which of these occurred
* by checking Thread.interrupted.
*/
abstract E transfer(E e, boolean timed, long nanos);
}
SynchronousQueue 由于继承了 BlockingQueue,遵循方法约定:
抛出异常 特殊值 阻塞 超时
插入 add(e) offer(e) put(e) offer(e, time, unit)
移除 remove() poll() take() poll(time, unit)
检查 element() peek() 不可用 不可用
底层都是通过调用 Transferer#transferer 来实现。
add(e) transferer.transfer(e, true, 0)
offer(e) transferer.transfer(e, true, 0)
put(e) transferer.transfer(e, false, 0)
offer(e, time, unit) transferer.transfer(e, true, unit.toNanos(timeout))
remove() transferer.transfer(null, true, 0)
poll() transferer.transfer(null, true, 0)
take() transferer.transfer(null, false, 0)
poll(time, unit) transferer.transfer(null, true, unit.toNanos(timeout))
其中 put 和 take 都是设置为不超时,表示一直阻塞直到完成。
7. 栈实现
栈定义
双重栈(Dual stack),指的是栈中的节点具有两种模式。
TransferStack 进行了扩展,其节点具有三种模式:请求模式、数据模式、匹配模式。
java.util.concurrent.SynchronousQueue.TransferStack
/** Dual stack */
static final class TransferStack<E> extends Transferer<E> {
/* Modes for SNodes, ORed together in node fields */
/** Node represents an unfulfilled consumer */
static final int REQUEST = 0; // 请求模式,消费者请求数据
/** Node represents an unfulfilled producer */
static final int DATA = 1; // 数据模式,生产者提供数据
/** Node is fulfilling another unfulfilled DATA or REQUEST */
static final int FULFILLING = 2; // 匹配模式,表示数据从正一个节点传递给另外的节点
/** Returns true if m has fulfilling bit set. */
static boolean isFulfilling(int m) { return (m & FULFILLING) != 0; }
/** The head (top) of the stack */
volatile SNode head;
boolean casHead(SNode h, SNode nh) {
return h == head &&
UNSAFE.compareAndSwapObject(this, headOffset, h, nh);
}
/**
* Creates or resets fields of a node. Called only from transfer
* where the node to push on stack is lazily created and
* reused when possible to help reduce intervals between reads
* and CASes of head and to avoid surges of garbage when CASes
* to push nodes fail due to contention.
*/
static SNode snode(SNode s, Object e, SNode next, int mode) {
if (s == null) s = new SNode(e);
s.mode = mode;
s.next = next;
return s;
}
}
节点定义
- 使用 mode 标记该节点的模式。
- 当前节点匹配成功,则 match 设置为所匹配的节点。
- 当前节点取消匹配,则 match 设置为自身。
- 使用 waiter 存储操作该节点的线程,等待匹配时挂起该线程,匹配成功时需唤醒该线程。
java.util.concurrent.SynchronousQueue.TransferStack.SNode
/** Node class for TransferStacks. */
static final class SNode {
volatile SNode next; // next node in stack // 栈中下一个节点
volatile SNode match; // the node matched to this // 当前节点所匹配的节点
volatile Thread waiter; // to control park/unpark // 等待着的线程
Object item; // data; or null for REQUESTs // 数据元素
int mode; // 节点的模式:REQUEST、DATA、FULFILLING
// Note: item and mode fields don't need to be volatile
// since they are always written before, and read after,
// other volatile/atomic operations.
SNode(Object item) {
this.item = item;
}
// 若下一个节点为cmp,将其替换为节点val
boolean casNext(SNode cmp, SNode val) {
return cmp == next &&
UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
}
/**
* Tries to match node s to this node, if so, waking up thread.
* Fulfillers call tryMatch to identify their waiters.
* Waiters block until they have been matched.
*
* @param s the node to match
* @return true if successfully matched to s
*/
// m.tryMatch(s),表示节点m尝试与节点s进行匹配。注意入参节点s由当前线程所持有,而节点m的线程是阻塞状态的(等待匹配)
boolean tryMatch(SNode s) {
if (match == null &&
UNSAFE.compareAndSwapObject(this, matchOffset, null, s)) { // 如果m还没有匹配者,就把s作为它的匹配者,设置m.match为s
Thread w = waiter; // 节点m的线程
if (w != null) { // waiters need at most one unpark
waiter = null;
LockSupport.unpark(w); // 唤醒m中的线程,两者匹配完毕
// 补充:节点m的线程从TransferStack#awaitFulfill方法中被唤醒,检查得知被匹配成功了,返回匹配的节点
}
return true;
}
return match == s; // 可能其它线程先一步匹配了m,判断是否是s
}
/**
* Tries to cancel a wait by matching node to itself.
*/
// 将节点的match属性设为自身,表示取消
void tryCancel() {
UNSAFE.compareAndSwapObject(this, matchOffset, null, this);
}
boolean isCancelled() {
return match == this;
}
}
transfer
基本思想,在循环中尝试三种操作:
- 当前栈内为空,或者栈顶节点模式与当前节点模式一致,尝试着把当前节点入栈并且等待匹配。若匹配成功,返回该节点的数据。若取消匹配,则返回空。
- 如果栈顶节点模式与当前节点模式互补,尝试把当前节点入栈进行匹配,再把匹配的两个节点弹出栈。
- 如果栈顶节点的模式为匹配中,则协助匹配和弹出。
java.util.concurrent.SynchronousQueue.TransferStack#transfer
/**
* Puts or takes an item.
*/
@SuppressWarnings("unchecked")
E transfer(E e, boolean timed, long nanos) {
SNode s = null; // constructed/reused as needed
int mode = (e == null) ? REQUEST : DATA; // 元素为空,为请求模式,否则为数据模式(注意,当前节点入栈前不会是匹配模式!)
for (;;) { // 自旋CAS
SNode h = head;
// 模式相同
if (h == null || h.mode == mode) { // empty or same-mode // 栈顶没有元素,或者栈顶元素跟当前元素是一个模式的(请求模式或数据模式)
if (timed && nanos <= 0) { // can't wait // 已超时
if (h != null && h.isCancelled()) // 头节点h不为空且已取消
casHead(h, h.next); // pop cancelled node // 把h.next作为新的头节点 // 把已经取消的头节点弹出,并进入下一次循环
else
return null;
} else if (casHead(h, s = snode(s, e, h, mode))) { // 把节点s入栈,作为新的头节点,s.next指向h(因为是模式相同的,所以只能入栈)
SNode m = awaitFulfill(s, timed, nanos); // 等待匹配,自旋阻塞当前线程
if (m == s) { // wait was cancelled // 等待被取消了,需要清除节点s
clean(s); // 出栈
return null;
}
if ((h = head) != null && h.next == s) // 执行到这里,说明节点s已匹配成功
// 若栈顶有数据,头节点为h,下一个节点为s,需要将节点h和s出栈
// 因为是等待匹配,这里是等到了其他线程入栈新的节点,跟当前节点s匹配了
casHead(h, s.next); // help s's fulfiller // 把s.next作为新的头节点
return (E) ((mode == REQUEST) ? m.item : s.item); // 若当前为请求模式,返回匹配到的节点m的数据;否则返回节点s的数据
}
}
// 模式互补(执行到这里,说明栈顶元素跟当前元素的模式不同)
else if (!isFulfilling(h.mode)) { // try to fulfill // 这里判断栈顶模式不是FULFILLING(匹配中),说明是互补的
if (h.isCancelled()) // already cancelled
casHead(h, h.next); // pop and retry // 弹出已取消的栈顶元素,并重试
else if (casHead(h, s=snode(s, e, h, FULFILLING|mode))) { // 把节点s入栈,并设为匹配模式(FULFILLING|mode的结果符合TransferStack#isFulfilling)
for (;;) { // loop until matched or waiters disappear
SNode m = s.next; // m is s's match
if (m == null) { // all waiters are gone
// 如果m为null,说明除了s节点外的节点都被其它线程先一步匹配掉了,就清空栈并跳出内部循环,到外部循环再重新入栈判断
casHead(s, null); // pop fulfill node
s = null; // use new node next time
break; // restart main loop
}
SNode mn = m.next;
if (m.tryMatch(s)) { // 如果m和s尝试匹配成功,就弹出栈顶的两个元素s和m
casHead(s, mn); // pop both s and m
return (E) ((mode == REQUEST) ? m.item : s.item);
} else // lost match // m和s匹配失败,说明m已经先一步被其它线程匹配了,需要清除
s.casNext(m, mn); // help unlink // 若s的下一个节点为m,则将m换成mn
}
}
}
// 模式互补(执行到这里,说明栈顶元素跟当前元素的模式不同,且栈顶是匹配模式,说明栈顶和栈顶下面的节点正在发生匹配,当前请求需要做协助工作)
else { // help a fulfiller
SNode m = h.next; // m is h's match
if (m == null) // waiter is gone // 节点m是h匹配的节点,但是m为空,说明m已经被其它线程先一步匹配了
casHead(h, null); // pop fulfilling node
else {
SNode mn = m.next;
if (m.tryMatch(h)) // help match // 协助匹配,如果节点m和h匹配,则弹出h和m
casHead(h, mn); // pop both h and m
else // lost match
h.casNext(m, mn); // help unlink // 如果节点m和h不匹配,则将h的下一个节点换为mn(即m已经被其他线程匹配了,需要清除它)
}
}
}
}
注意:
- TransferStack#casHead 可以用于入栈,也可以用于出栈。
mode = FULFILLING|mode
得到的结果满足 TransferStack#isFulfilling,说明是匹配模式。- TransferStack#awaitFulfill 是被动等待匹配
- TransferStack.SNode#tryMatch 是主动进行匹配
- 节点从阻塞中被其他线程唤醒时,一般是位于栈的第二个节点,此时栈的头节点是新入栈的与之匹配的节点。
awaitFulfill
节点s自旋或阻塞,直到被其他节点匹配。
java.util.concurrent.SynchronousQueue.TransferStack#awaitFulfill
/**
* Spins/blocks until node s is matched by a fulfill operation.
*
* @param s the waiting node
* @param timed true if timed wait
* @param nanos timeout value
* @return matched node, or s if cancelled
*/
SNode awaitFulfill(SNode s, boolean timed, long nanos) { // 节点s自旋或阻塞,直到被其他节点匹配
final long deadline = timed ? System.nanoTime() + nanos : 0L; // 到期时间
Thread w = Thread.currentThread(); // 当前线程
int spins = (shouldSpin(s) ?
(timed ? maxTimedSpins : maxUntimedSpins) : 0); // 自旋次数(自旋结束后再判断是否需要阻塞)
for (;;) {
if (w.isInterrupted()) // 当前线程中断了,尝试取消节点s
s.tryCancel(); // 取消操作,设置 s.match = s
SNode m = s.match; // 检查节点s是否匹配到了节点m(由其它线程的m匹配到当前线程的s,或者s已取消)
if (m != null)
return m; // 如果匹配到了,直接返回m
if (timed) {
nanos = deadline - System.nanoTime();
if (nanos <= 0L) { // 已超时,尝试取消节点s
s.tryCancel();
continue;
}
}
if (spins > 0)
spins = shouldSpin(s) ? (spins-1) : 0; // 如果spins大于0,则一直自减直到为0,才会执行去elseif的逻辑
else if (s.waiter == null)
s.waiter = w; // establish waiter so can park next iter // 如果s的waiter为null,把当前线程注入进去,并进入下一次自旋
else if (!timed)
LockSupport.park(this); // 如果无设置超时,则阻塞等待被其它线程唤醒,唤醒后继续自旋并查看是否匹配到了元素
else if (nanos > spinForTimeoutThreshold)
LockSupport.parkNanos(this, nanos); // 如果设置超时且还有剩余时间,就阻塞相应时间
}
}
在自旋过程中,执行以下逻辑:
- 检查当前线程是否已中断,是则取消当前节点。
- 检查当前线程是否已匹配成功,是则返回匹配的节点。
- 自旋结束,若允许超时,判断是否到达超时时间,未到达则阻塞剩余时间。
- 自旋结束,若不允许超时,则阻塞等待唤醒。
等待匹配的过程中,首先进行自旋再判断是否进入阻塞,是因为线程的阻塞和唤醒操作涉及到系统内核态与用户态之间的切换,比较耗时。
8. 队列实现
队列定义
双重队列(Dual Queue),指的是队列中的节点具有两种模式。
TransferStack 进行了扩展,队列中大部分节点具有两种模式:请求节点、数据节点。
队列的头节点不属于这两种模式,而是空节点(dummy node)。
java.util.concurrent.SynchronousQueue.TransferQueue
/** Dual Queue */
static final class TransferQueue<E> extends Transferer<E> { // 队列实现
/** Head of queue */
transient volatile QNode head; // 头节点
/** Tail of queue */
transient volatile QNode tail; // 尾节点
/**
* Reference to a cancelled node that might not yet have been
* unlinked from queue because it was the last inserted node
* when it was cancelled.
*/
transient volatile QNode cleanMe;
TransferQueue() {
QNode h = new QNode(null, false); // initialize to dummy node. // 队列的头节点,是一个空节点
head = h;
tail = h;
}
/**
* Tries to cas nh as new head; if successful, unlink
* old head's next node to avoid garbage retention.
*/
// CAS将头节点(TransferQueue#head属性的值)从节点h改为节点nh,再将h出队
void advanceHead(QNode h, QNode nh) {
if (h == head &&
UNSAFE.compareAndSwapObject(this, headOffset, h, nh))
h.next = h; // forget old next // 将节点h的next指针设为自身,表示h出队,方便垃圾回收
}
/**
* Tries to cas nt as new tail.
*/
void advanceTail(QNode t, QNode nt) {
if (tail == t)
UNSAFE.compareAndSwapObject(this, tailOffset, t, nt);
}
}
节点定义
- 使用 isData 标记该节点的模式。
- 当前节点匹配成功,则 item 设置为所匹配的节点的数据。
- 当前节点取消匹配,则 item 设置为自身。
- 当前节点出队,则 next 指向自身。
- 使用 waiter 存储操作该节点的线程,等待匹配时挂起该线程,匹配成功时需唤醒该线程。
java.util.concurrent.SynchronousQueue.TransferQueue.QNode
/** Node class for TransferQueue. */
static final class QNode {
volatile QNode next; // next node in queue // 队列的下一个节点
volatile Object item; // CAS'ed to or from null // 数据元素
volatile Thread waiter; // to control park/unpark // 等待着的线程
final boolean isData; // true表示为DATA类型,false表示为REQUEST类型
QNode(Object item, boolean isData) {
this.item = item;
this.isData = isData;
}
boolean casNext(QNode cmp, QNode val) {
return next == cmp &&
UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
}
boolean casItem(Object cmp, Object val) {
return item == cmp &&
UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
}
/**
* Tries to cancel by CAS'ing ref to this as item.
*/
void tryCancel(Object cmp) {
UNSAFE.compareAndSwapObject(this, itemOffset, cmp, this);
}
boolean isCancelled() {
return item == this;
}
/**
* Returns true if this node is known to be off the queue
* because its next pointer has been forgotten due to
* an advanceHead operation.
*/
// 如果节点已经出队了,则返回 true。由 TransferQueue#advanceHead 操作将 next 指向自身。
boolean isOffList() {
return next == this;
}
}
transfer
基本思想,在循环中尝试两种操作:
- 如果队列为空,或者尾节点模式与当前节点模式相同,则入队(尾部)并等待匹配。
- 如果队列中有等待中的节点,且首个非空节点与当前节点的模式互补,则匹配并出队(头部)。
/**
* Puts or takes an item.
*/
@SuppressWarnings("unchecked")
E transfer(E e, boolean timed, long nanos) {
QNode s = null; // constructed/reused as needed
boolean isData = (e != null);
for (;;) {
QNode t = tail;
QNode h = head;
if (t == null || h == null) // saw uninitialized value
continue; // spin
if (h == t || t.isData == isData) { // empty or same-mode // 队列为空,或者队尾节点模式与当前节点模式相同,只能入队
QNode tn = t.next;
if (t != tail) // inconsistent read // 尾节点已过期,需重新获取尾节点
continue;
if (tn != null) { // lagging tail // 尾节点已过期,需重新获取尾节点
advanceTail(t, tn);
continue;
}
if (timed && nanos <= 0) // can't wait // 已超时
return null;
if (s == null)
s = new QNode(e, isData); // 当前节点设为s
if (!t.casNext(null, s)) // failed to link in // 把当前节点s插入节点t之后,若失败则重试
continue;
advanceTail(t, s); // swing tail and wait // 将节点s设为新的尾节点
Object x = awaitFulfill(s, e, timed, nanos); // 节点s等待匹配
if (x == s) { // wait was cancelled // 说明当前节点s已取消,需要出队
clean(t, s);
return null;
}
if (!s.isOffList()) { // not already unlinked // 执行到这里,说明节点s匹配成功。如果s尚未出队,则进入以下逻辑
advanceHead(t, s); // unlink if head
// 已知t是s的上一个节点,这里判断如果t是头节点,则把t出队,把节点s作为新的头节点
// 下面操作进一步把s设为空节点,即dummy node
if (x != null) // and forget fields
s.item = s; // 把节点s的数据域设为自身,表示已取消,不再匹配
s.waiter = null;
}
return (x != null) ? (E)x : e; // REQUEST类型,返回匹配到的数据x,否则返回e
} else { // complementary-mode // 互补
QNode m = h.next; // node to fulfill // 首个非空节点,将它与请求节点匹配
if (t != tail || m == null || h != head)
continue; // inconsistent read
Object x = m.item;
if (isData == (x != null) || // m already fulfilled // 并不是互补的,说明m已经被先一步匹配了
x == m || // m cancelled // m已经取消
!m.casItem(x, e)) { // lost CAS // 若CAS失败说明m数据元素不为x,即节点m已经被匹配了。若CAS成功则说明匹配成功。
advanceHead(h, m); // dequeue and retry // 把头节点出队,把节点m作为新的头节点(dummy node),继续重试
continue;
}
advanceHead(h, m); // successfully fulfilled // 匹配成功,把节点m作为新的头节点(dummy node)
LockSupport.unpark(m.waiter); // 唤醒m上的线程
return (x != null) ? (E)x : e;
}
}
}
注意:
- 入队的时候,总是先获取最新的尾节点,说明 tail 是准确的(对比其他并发队列)。
- 出队的时候,总是先获取最新的头节点,说明 head 是准确的(对比其他并发队列)。
- TransferQueue#awaitFulfill 是被动等待匹配。
m.casItem(x, e)
是主动进行匹配,这里 m 为队首第一个非空节点,x 为 m.item,e 为入参数据元素。- 根据 FIFO 规则,当阻塞等待中的节点被唤醒时,表明该节点要么已超时,要么已从队尾排到了队头且匹配成功。
- 由于头节点是空节点(dummy node),从队头进行匹配时,不是比较 head 节点,而是比较 head.next 节点。
- 从队头匹配成功时,当前节点并不会入队,而是把旧的头节点出队,把匹配的节点设为新的头节点(dummy node)。
awaitFulfill
节点s自旋或阻塞,直到被其他节点匹配。
java.util.concurrent.SynchronousQueue.TransferQueue#awaitFulfill
/**
* Spins/blocks until node s is fulfilled.
*
* @param s the waiting node
* @param e the comparison value for checking match
* @param timed true if timed wait
* @param nanos timeout value
* @return matched item, or s if cancelled
*/
Object awaitFulfill(QNode s, E e, boolean timed, long nanos) { // 节点s等待匹配,其数据元素为e
/* Same idea as TransferStack.awaitFulfill */
final long deadline = timed ? System.nanoTime() + nanos : 0L;
Thread w = Thread.currentThread();
int spins = ((head.next == s) ?
(timed ? maxTimedSpins : maxUntimedSpins) : 0);
for (;;) {
if (w.isInterrupted())
s.tryCancel(e);
Object x = s.item; // 检查节点s是否匹配到了数据x,注意这里是跟栈结构不一样的地方!
if (x != e)
return x;
if (timed) {
nanos = deadline - System.nanoTime();
if (nanos <= 0L) {
s.tryCancel(e);
continue;
}
}
if (spins > 0)
--spins;
else if (s.waiter == null)
s.waiter = w;
else if (!timed)
LockSupport.park(this);
else if (nanos > spinForTimeoutThreshold)
LockSupport.parkNanos(this, nanos);
}
}
除了检测是否被匹配成功和取消节点的方式,与 TransferStack#awaitFulfill 不同之外,整体逻辑是一样的。
9. 总结
- SynchronousQueue 是一个阻塞队列,采用 CAS 和自旋来保证线程安全。
- SynchronousQueue 具有两种实现方式:双重栈(非公平模式)和双重队列(公平模式),默认使用双重栈。
- 栈实现的节点具有三种模式:数据节点、请求节点、匹配节点。队列实现的节点具有两种模式:数据节点、请求节点。
- 不管是哪种实现,都需要定义节点的匹配状态、取消状态、匹配方式。
- 存取操作先跟队列/栈中已存在的节点进行对比,匹配则出队/出栈,不匹配则入队/入栈。栈实现还会协助匹配。
- 入队/入栈之后,先自旋一定次数后再使用 LockSupport 来阻塞线程,等待匹配。
- SynchronousQueue 不直接存储数据元素(SynchronousQueue#size 固定为 0),内部的链表或栈结构用于暂存阻塞的线程。
- 适用于传递性场景,即生产者线程处理的数据直接传递给消费者线程。但是,当消费者消费速度赶不上生产速度,队列会阻塞严重。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。