The source of this article ConcurrentHashMap source code analysis , please indicate the source
If you want to learn more about the inner workings of ConcurrentHashMap, you can read my previous article HashMap source code analysis to understand the hash algorithm principle, array expansion, red-black tree conversion and so on.
initTable
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0) //这时已经有其他线程获取到执行权,沉睡一会
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n]; //初始化数组
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
This is to initialize the map array, and the core is the variable sizeCtl: a shared variable modified with volatile, which is used to obtain the execution right of initialization or expansion of the array through exchange and comparison competition. When the thread finds that sizeCtl is less than 0, the thread will give up the execution right. When the thread successfully competes to set -1, it is equivalent to obtaining the execution right, so it can initialize the array. When positive, saves the next element count value to resize the Map
Explain the Unsafe exchange comparison method
/**
* 通过对象属性的值,修改前、更新后一致,才是更新成功
* @param o 需要被修改的属性的对象
* @param l 对象Field的指针地址,可以理解成属性值引用
* @param i 修改前的值
* @param i1 修改后的值
* @return
*/
public final native boolean compareAndSwapInt(java.lang.Object o, long l, int i, int i1);
put method analysis
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
//ConcurrentHashMap 不允许key value为空,因为在并发情况下不能通过获取get(key) 判断key存不存在
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh; K fk; V fv;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { //通过计算hash得到数组下标,为空通过交换比较设置进去,这时不需要加锁的
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED) //这种情况说明数组正在扩容,需要对链表和黑红树进行迁移
tab = helpTransfer(tab, f);
else if (onlyIfAbsent // check first node without acquiring lock
&& fh == hash
&& ((fk = f.key) == key || (fk != null && key.equals(fk)))
&& (fv = f.val) != null)
return fv;
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) { //头节点没有改变,说明获取锁过程,没有线程扩容
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key, value);
break;
}
}
}
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
else if (f instanceof ReservationNode)
throw new IllegalStateException("Recursive update");
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
Briefly describe the put business logic:
- First determine whether the table array is empty, without locking, call initTable to initialize
- Calculate the hash value of the key, and calculate the subscript in the array by hashing. If the subscript is just empty, set it in by exchange comparison. When multiple threads are set through the array subscript, when the setting fails, the subscript is not satisfied, and the head node lock is obtained.
- At this time, the node has a value, and the hash value is equal to -1, it means that the array is expanding, and the helpTransfer method is called to copy the array in multiple threads.
- Only lock the root node of the linked list or the red-black tree, and first determine whether the root node is equal to the object that acquired the lock, because it is possible that the acquired lock object has already been offset due to the expansion of the array. If the offset has been performed, the root node is still inserted. Causes the hash calculation error, resulting in the get can not get the value. This is an important security check.
Why judge fh >= 0 is mainly because the ConcurrentHashMap Node hash has a special meaning
- int MOVED = -1; Array expansion is in progress, ready to migrate
- int TREEBIN = -2 red-black tree root node
- int RESERVED = -3; temporarily reserve node
- If it is greater than 0, it is known that this is a linked list. It is traversed from the beginning to the last insertion. One advantage of changing the Java8 linked list to tail insertion is that after traversing the linked list, the length of the linked list is known, and binCount is the length of the linked list. Each time the node is traversed, the keys will be compared for equality and the old value will be overwritten, otherwise it will be inserted at the end of the linked list.
- The logical insertion of the red-black tree is similar to that of the linked list. Traversing and inserting nodes through putTreeVal will return the node object only if the same key node is found. If it is added under the red-black tree, it will only return null.
- binCount is the length of the linked list. If the length is greater than the linked list length threshold (default 8), it will be converted into a red-black tree. Because the same key value is encountered during the above traversal, the node will be assigned to oldVal. If it is not empty, it will be overwritten, and there is no need to perform accumulation, just return it directly.
- As long as addCount is to accumulate the map size, because the size method cannot be directly locked to add one under concurrent conditions, which violates the setting of coarse and fine-grained locks. The actual situation is relatively replicated, and the logic of array expansion is also implemented in this method, which is analyzed in detail below.
size() method
Before analyzing the map size, let's see how the size method obtains the length, which is beneficial to the explanation of addCount.
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
final long sumCount() {
CounterCell[] cs = counterCells;
long sum = baseCount;
if (cs != null) {
for (CounterCell c : cs)
if (c != null)
sum += c.value;
}
return sum;
}
The size length is mainly accumulated through the baseCount + CounterCell array. baseCount As long as a thread is using map, baseCount will be used to record the size. When multiple threads operate on map at the same time, baseCount will be abandoned, and the number of operations of each thread will be put into the CounterCell array. It can be understood that CounterCell is just a calculation box. Through some algorithms, the number of operations of different threads is put into the specified index position, which can isolate threads to compete for the same number of modifications.
addCount analysis
private final void addCount(long x, int check) {
CounterCell[] cs; long b, s;
if ((cs = counterCells) != null ||
!U.compareAndSetLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell c; long v; int m;
boolean uncontended = true;
// 此时cs 没有初始化,或者 cs刚刚初始化,长度还是0,通过线程随机数获取下标刚好为空
if (cs == null || (m = cs.length - 1) < 0 ||
(c = cs[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSetLong(c, CELLVALUE, v = c.value, v + x))) {
fullAddCount(x, uncontended); //将累加值添加到数组中
return;
}
if (check <= 1)
return;
s = sumCount();
}
if (check >= 0) { //于于0不会判断数组扩容情况
Node<K,V>[] tab, nt; int n, sc;
// 只有满足size长度等于或大于sizeCtl 扩容阈值长度,才会进行下面逻辑
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n) << RESIZE_STAMP_SHIFT;
if (sc < 0) { //当前tab数组来扩容,通过竞争去抢夺扩容权
if (sc == rs + MAX_RESIZERS || sc == rs + 1 ||
(nt = nextTable) == null || transferIndex <= 0)
break;
if (U.compareAndSetInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
else if (U.compareAndSetInt(this, SIZECTL, sc, rs + 2))
transfer(tab, null);
s = sumCount();
}
}
}
Start with the if judgment. If the countCells array is not empty, the second condition does not need to be judged. Enter the countCells setting and accumulate. If the first condition is not established, the baseCount accumulation will be executed. If the accumulation success condition is not established, the if logic will not be entered.
The initialization of countCells, the addition of x values to the array, or the multi-thread control of the accumulation of subscripts in the same array all require fullAddCount to be implemented.
As mentioned above, if sizeCtl is a positive integer, it is the threshold for the capacity expansion of the array. While, first judge whether the map size reaches or exceeds the threshold, and it will meet the conditions.
Enter the loop to expand the array. When sizeCtl > 0, multiple threads are expanding the array, and they need to compete for execution rights.
Specifically analyze how the expansion conditions are established, first until
resizeStamp(n) << RESIZE_STAMP_SHIFT;
This method is to shift the array n, the low-order zeros are left-shifted by 16, which is equivalent to saving the length of the array to the high-order 16 bits. Each thread participating in concurrency will add +1 to sizeCtl, and the lower 16 bits are used to save the number of participating array expansions.
sc > 0
- At this time, there is no thread to expand the tab array
sz < 0
- If a thread is already expanding, add sizeCtl+1 and call transfer() to let the current thread participate in the expansion.
Let's analyze the judgment in while
- sc == rs + MAX_RESIZERS The current number of threads has reached the maximum limit, and there is no point in adding new threads to expand the capacity
- sc == rs + 1 As long as rs + 1 is successful, transfer will be called, but now that the rs value has been modified by other threads, the accumulation has failed, and there is no need to perform a swap comparison.
- (nt = nextTable) == null At this point, the expansion has been completed, and the new thread does not need to enter the expansion method
- transferIndex <= 0 The number of migrations that are not allocated in the transferIndex tab array. At this time, it is 0, which means that the expansion thread has reached the maximum number, and there is no need to use a new thread to enter.
Let's talk about the fullAddCount method in detail
ThreadLocalRandom.getProbe() can be simply understood as the hash value of each thread, but this value is not fixed, and it can be recalculated if the array slot cannot be found.
private final void fullAddCount(long x, boolean wasUncontended) {
int h;
if ((h = ThreadLocalRandom.getProbe()) == 0) { //初始化
ThreadLocalRandom.localInit(); // force initialization
h = ThreadLocalRandom.getProbe();
wasUncontended = true;
}
boolean collide = false;
for (;;) {
CounterCell[] cs; CounterCell c; int n; long v;
if ((cs = counterCells) != null && (n = cs.length) > 0) {
if ((c = cs[(n - 1) & h]) == null) {
if (cellsBusy == 0) { // Try to attach new Cell
CounterCell r = new CounterCell(x); // Optimistic create
if (cellsBusy == 0 &&
U.compareAndSetInt(this, CELLSBUSY, 0, 1)) { //竞争成功了可以对数组修改
boolean created = false;
try { // Recheck under lock
CounterCell[] rs; int m, j;
if ((rs = counterCells) != null &&
(m = rs.length) > 0 &&
rs[j = (m - 1) & h] == null) {
rs[j] = r;
created = true;
}
} finally {
cellsBusy = 0;
}
if (created)
break;
continue; // Slot is now non-empty
}
}
collide = false;
}
else if (!wasUncontended) // 这时CAS已经失败了,继续自旋等待扩容
wasUncontended = true; // Continue after rehash
else if (U.compareAndSetLong(c, CELLVALUE, v = c.value, v + x)) //累加成功,退出自旋
break;
//当数组长度超过CPU个数了,这时就不应该扩容了,而是继续自旋直到累加成功
else if (counterCells != cs || n >= NCPU)
collide = false; // At max size or stale
else if (!collide) //如果竞争继续失败了,不扩容的话会继续自旋
collide = true;
else if (cellsBusy == 0 &&
//走到这里说明第一次竞争失败了,有冲突直接对数据扩容
U.compareAndSetInt(this, CELLSBUSY, 0, 1)) {
try {
if (counterCells == cs) // Expand table unless stale
counterCells = Arrays.copyOf(cs, n << 1);
} finally {
cellsBusy = 0;
}
collide = false;
continue; // Retry with expanded table
}
//重新生成probe ,因为此时是CAS插入失败。 更换其他的插槽试试
h = ThreadLocalRandom.advanceProbe(h);
}
else if (cellsBusy == 0 && counterCells == cs &&
U.compareAndSetInt(this, CELLSBUSY, 0, 1)) {//初始化数组
boolean init = false;
try { // Initialize table
if (counterCells == cs) {
CounterCell[] rs = new CounterCell[2];
rs[h & 1] = new CounterCell(x);
counterCells = rs;
init = true;
}
} finally {
cellsBusy = 0;
}
if (init)
break;
}
// 这时候counterCells 数组没有初始化,有没有多个线程竞争,可以使用baseCount 进行累加
else if (U.compareAndSetLong(this, BASECOUNT, v = baseCount, v + x))
break; // Fall back on using base
}
}
This attribute of cellsBusy is very familiar with the sizeCtl function we mentioned before, and it is used to control the permission to modify the array. When cellsBusy = 0, the counterCells array can be inserted and expanded. The thread only needs to set the cellsBusy to 1 to obtain the modification permission. After the change, the cellsBusy can be changed to 0.
Students who have read the source code of LongAdder think it is very familiar. I can't say that they know each other very well, but they are exactly the same, and even the variable names are copied. In fact, the reason why I am willing to talk about this detail involves the design idea of LongAdder. The value value is separated into an array. When accessed by multiple threads, it is mapped to one of the numbers through the hash algorithm for counting.
transfer
Next, let's see how the map can be expanded under multi-threading.
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
//这里是计算一个线程最大要迁移数组个数,相当于将数组分拆成这么多部分,每个线程最大可以处理
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
transferIndex = n;
}
int nextn = nextTab.length;
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true; //数组下标移动标记
boolean finishing = false; //一个线程负责数组上元素是否已经全部迁徙完成
//i 表示上一次分配剩余数量 bound数组现在剩下数量
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
//这里只是为了i-1,或者就是所有数组元素已经全部迁移完成了
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {//数组已经全部被标注完了
i = -1;
advance = false;
}
//这里给进来线程分配迁移数量,分配成功就会跳出wile循环到下面去执行copy
else if (U.compareAndSetInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
// 这三个条件任意一个成立都说明旧数据已经完全迁徙完成 了
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
if (U.compareAndSetInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
//在addCount 时 sc +2 就会进入扩容,现在再减回去 如果不相等则表示现在还要线程在处理数据扩容,否则将finish改成true表示扩容已经结束
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // recheck before commit
}
}
//下面进入数组元素迁移,每处理完一个将advance 改成true 重新进入while
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
//表明这个节点已经有其他线程在处理了
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else { //要迁移这个元素,直接锁住
synchronized (f) {
if (tabAt(tab, i) == f) { //双重检查
Node<K,V> ln, hn; //将链表分拆成两个 ln表示低位 hn高位
if (fh >= 0) { //上面说过,只要hash大于0就是链表
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) { //这里先找出最后一个元素
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
//将链表拆成两个,分别连起来了
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
// 标记旧数组,此下标不可以使用了
setTabAt(tab, i, fwd);
advance = true;
}
else if (f instanceof TreeBin) { //红黑树原来和上面差不多了
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
//遍历红黑树仍然使用链表下标去做,找到最后一个
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode<K,V> p = new TreeNode<K,V>
(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
//新产生红黑树链表不满足阈值,转换成链表
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<K,V>(hi) : t;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
else if (f instanceof ReservationNode)
throw new IllegalStateException("Recursive update");
}
}
}
}
}
ForwardingNode This is a special node. During the process of array migration, the node that has been migrated will be marked as this object. Prevent insertion into old arrays during migration of arrays. Internally encapsulates a find method, which can go to the new array to find the key value, which will be used by the get method below.
How to support concurrency and assign tasks to each thread. Each thread entry will be assigned a task in the while of the double-layer loop. The first two ifs and else ifs will not satisfy the conditions when they first come in, but they will reassign transferIndex to nextIndex. At this time, nextIndex is still the length of the array. When it starts to compete to modify transferIndex, the thread that has been successfully set is assigned to the array index. Tab.leng -1 to tab.length -1 - stride The subscript of this range, the element is allocated from the end of the array forward, and the index is set to bound after the migration is completed, i is the subscript of the array copy, and the subscript has been migrated to the bottom When the thread starts, it will jump out of the while loop, otherwise every time you enter while, it will make the subscript -1. The thread that fails the competition will re-enter the while loop and continue to get the allocation number from transferIndex. When transferIndex is not empty, it means that the array still has tasks allocated and continues to compete for acquisition.
if (i < 0 || i >= n || i + n >= nextn) { When i < 0 is established, it means that the current thread has completed the copy task, i >= n || i + n >= nextn It is related to i = n below, which performs bounds checking on the current array. We said above that the lower 16 bits of sizeCtl express the current thread expansion amount, and after a thread completes the task, sizeCtl -1 . SizeCtl -2 is not equal mainly and sizeCtl +2 calls the transfer direction during expansion. Inequality means that other threads are still copying at this time. If the expansion is not completed, the thread automatically exits the expansion method after completion. Let the last thread do the finishing touches, turning the nextTab into a tab.
get method
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
if ((eh = e.hash) == h) { //数组元素就是当前key
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
else if (eh < 0) //这里是红黑树或者ForwardingNode ,使用内部封装方法去查询
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
The get method does not use locks, supports maximum concurrent reads, and does not present security issues. When eh < 0, if it is a red-black tree, call the red-black tree traversal method to operate the node. If the ForwardingNode is at this time, although the data of this node has been migrated to the new array, the find method is encapsulated internally to search in the new array. No matter what kind of node it is, the corresponding key value can be found.
delete method
public V remove(Object key) {
return replaceNode(key, null, null);
}
final V replaceNode(Object key, V value, Object cv) {
int hash = spread(key.hashCode()); //计算hash值
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0 ||
(f = tabAt(tab, i = (n - 1) & hash)) == null) //找不到对应key 节点,退出循环
break;
else if ((fh = f.hash) == MOVED) //参考上面put 此时数组在扩容,线程帮忙进入扩容
tab = helpTransfer(tab, f);
else {
V oldVal = null;
boolean validated = false;
synchronized (f) { //锁住数组下标节点
if (tabAt(tab, i) == f) {
if (fh >= 0) { //此时是链表结构
validated = true;
for (Node<K,V> e = f, pred = null;;) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) { //已经在链表找到对应元素了
V ev = e.val;
if (cv == null || cv == ev ||
(ev != null && cv.equals(ev))) {
oldVal = ev;
if (value != null) //替换旧值
e.val = value;
//前一个节点不为空,代替的值为空,则删除当前节点,改变前后指引就行了
else if (pred != null)
pred.next = e.next;
else
//前面节点为空,头节点就是要被删除的
setTabAt(tab, i, e.next); //
}
break;
}
pred = e;
if ((e = e.next) == null) //遍历到最后一个,退出循环
break;
}
}
else if (f instanceof TreeBin) {
validated = true;
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> r, p;
if ((r = t.root) != null &&
(p = r.findTreeNode(hash, key, null)) != null) { //使用红黑树方法去查找
V pv = p.val;
if (cv == null || cv == pv ||
(pv != null && cv.equals(pv))) {
oldVal = pv;
if (value != null)
p.val = value;
else if (t.removeTreeNode(p)) //从黑红树删除节点
setTabAt(tab, i, untreeify(t.first));
}
}
}
else if (f instanceof ReservationNode)
throw new IllegalStateException("Recursive update");
}
}
if (validated) {
if (oldVal != null) { //旧值不存在,才会真正去删除节点数据
if (value == null)
addCount(-1L, -1); //size -1
return oldVal;
}
break;
}
}
}
return null;
}
data citation
https://xilidou.com/2018/11/27/LongAdder/
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。