概述

  在上一篇文章中介绍了ConcurrentHashMap的存储结构,以及put和get方法,那本篇文章就介绍一下其扩容原理。其实说到扩容,无非就是新建一个数组,然后把旧的数组中的数据拷贝到新的数组中,在HashMap的实现中,由于没有加锁,可能会同时有多个线程创建了多个数组,而且拷贝的时候也没有加锁,所以在多线程的时候非常混乱,当然HashMap本身设计就是线程不安全的,那要实现一个好的扩容,要解决以下几点问题:

  • 创建新数组的时候,只能由一个线程创建
  • 拷贝数据的时候,已经拷贝过的数据不能重复拷贝
  • 拷贝数据的时候,一个桶只能由一个线程负责,不能多个线程一起拷贝一个桶的数据
  • 多个线程如何协作,加速扩容过程

其实以上几点问题在ConcurrentHashMap都被解决了,下面就带着上面几个问题来分析扩容的源码。

扩容的触发点

既然要分析扩容,就要先分析一下在什么情况下会进行扩容。总体来说有两种情况。

情况一

集合中的元素个数达到负载因子规定的大小,比如数组初始化容量是16,负载因子0.75,那达到12个元素的时候就要扩容,下面就看一下触发代码。

private final void addCount(long x, int check) {
//省略部分代码,省略部分是处理计数的
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
//sizeCtl正常就是值就是扩容点的值,首次设置在initTable方法设置的
//s >= sizeCtl说明达到了扩容点,后面两个方法是为了处理极端的情况的
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
//这个方法的目的就是为每次扩容生成一个唯一的标识,在第一篇文章中介绍属性的时候
//介绍了好几个属性,都是在这个方法中使用的,后面会分析这个方法,标记:说明1
int rs = resizeStamp(n);
//sc小于零说明在扩容中,设置小于0的是下面哪个else if设置的
if (sc < 0) {
//这个是判断sc的高16位是不是和rs相等,一会分析rs就知道了,只要处在同一轮扩容中,这个标志就是一样的
//后面几个方法都是在处理一些极端情况,最后一个transferIndex <= 0这个一会需要说明下,标记:说明2
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
//上面的条件就是判断一些极端条件,如果符合,上面就直接break了,如果不满足就通过CAS将sc加1
//这个其实就是把sizeCtl的低16位加1,意思是又多了一个协助扩容的线程,至于为什么要加1后面说明,标记:说明3
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
//这里由于是协助扩容,所以传入的是nt = nextTable,因为已经创建好了
//这里其实就是解释了概述中提到的第一个问题,一次只能由一个线程创建表
transfer(tab, nt);
}
//如果sc >= 0,说明nextTable还没有创建,通过CAS竞争去创建
//注意这里把sizeCtl加2,不是加1,意思可能是一方面要创建表,另一方面要扩容
//如果CAS成功,说明只能由要给线程去创建表

else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
//这里传入null
transfer(tab, null);
//计算元素个数,与此次扩容无关
s = sumCount();
}
} }

总结一下流程

  1. 判断是否需要扩容
  2. 如果正在扩容中,就协助扩容
  3. 如果还没有扩容,新建数组开始扩容

说明1

    static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
  • Integer.numberOfLeadingZeros表示一个数从高位算起为0的位的个数,比如当n = 1时,结果为31,当n = 2时,结果为30。
  • 1 << (RESIZE_STAMP_BITS - 1),由于RESIZE_STAMP_BITS = 16,所以这个就是把1的二进制左移15位,也就是2^16,2的16次方。

  • 上面两个结果做或运算,就相当于两个数向加,因为第二数的低16位全是0。假设n = 16,最后的结果为:32795
  • 由于每次传入的n不相同,所以每次结果也不同,也就是每次的标识也不同,这个值这么做的好处就是只在低16位有值,在下面计算sizeCtl的时候,只要继续左移16位,那低16位也就没有值了

说明2

sc >>> RESIZE_STAMP_SHIFT) != rs

这段代码是否成立,要想搞清楚这段代码是否成立,要先搞清楚sc是多少。

 while (s >= (long)(sc = sizeCtl)

从while循环看,sc = sizeCtl,那sizeCtl是多少,如下:

 else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))

这个CAS操作,会修改sizeCtl的值,最后sizeCtl = ( (rs << 16) + 2),可以知道最开始的那个不等式就相当于如下:

((rs << RESIZE_STAMP_SHIFT) + 2) >>> RESIZE_STAMP_SHIFT != rs

上面这个公式就很清楚了,相当于rs先有符号左移16位,之后加2,最后再无符号右移16位,由于加的2在低位,右移的时候就没了,所以最后的结果还是rs。

说明3

经过上面两个说明,应该可以清楚的知道sizeCtl的高16位是标志位,就是每一轮扩容生成的一个唯一的标志,低16位标识参与扩容的线程数,所以这里进行加1操作。那问题来了,为什么要记录参与扩容的线程数?这个原因一会看扩容的代码就明白了,这里先提一下,记录参与扩容的线程数的原因是每个线程执行完扩容,sizeCtl就减1,当最后发现sizeCtl = rs <<RESIZE_STAMP_SHIFT的时候,说明所有参与扩容的线程都执行完,防止最后以为扩容结束了,旧的数组都被干掉了,但是还有的线程在copy。

情况二

上面只是分析了情况一,还有另一种情况也会扩容,就是当容量小于64,但是链表中发生hash冲突的节点个数大于等于8,这时也会扩容。

private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
//容量小于64
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
//扩容
tryPresize(n << 1);
//省略部分代码
}

这个是链表转为红黑树的方法,里面的tryPressize就是扩容,下面分析一下这个方法。

private final void tryPresize(int size) {
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
//该方法就是生成最小的大于当前或等当前数字的2的倍数
tableSizeFor(size + (size >>> 1) + 1);
int sc;
while ((sc = sizeCtl) >= 0) {
Node<K,V>[] tab = table; int n;
//这一部分就不分析,既然都hash冲突了,tab一定存在
if (tab == null || (n = tab.length) == 0) {
n = (sc > c) ? sc : c;
if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if (table == tab) {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
}
}
else if (c <= sc || n >= MAXIMUM_CAPACITY)
break;
//这一部分就可以去扩容的代码,可以看到和刚刚那个方法写的基本上一摸一样,就不重复了
else if (tab == table) {
int rs = resizeStamp(n);
if (sc < 0) {
Node<K,V>[] nt;
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
}
}
}

transfer()方法

下面正式进入分析扩容的方法,这部分参考:并发编程——ConcurrentHashMap#transfer() 扩容逐行分析

/**
* Moves and/or copies the nodes in each bin to new table. See
* above for explanation.
*
* transferIndex 表示转移时的下标,初始为扩容前的 length。
*
* 我们假设长度是 32
*/
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
// 将 length / 8 然后除以 CPU核心数。如果得到的结果小于 16,那么就使用 16。
// 这里的目的是让每个 CPU 处理的桶一样多,避免出现转移任务不均匀的现象,如果桶较少的话,默认一个 CPU(一个线程)处理 16 个桶
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range 细分范围 stridea:TODO
// 新的 table 尚未初始化
if (nextTab == null) { // initiating
try {
// 扩容 2 倍
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
// 更新
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
// 扩容失败, sizeCtl 使用 int 最大值。
sizeCtl = Integer.MAX_VALUE;
return;// 结束
}
// 更新成员变量
nextTable = nextTab;
// 更新转移下标,就是 老的 tab 的 length
transferIndex = n;
}
// 新 tab 的 length
int nextn = nextTab.length;
// 创建一个 fwd 节点,用于占位。当别的线程发现这个槽位中是 fwd 类型的节点,则跳过这个节点。
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
// 首次推进为 true,如果等于 true,说明需要再次推进一个下标(i--),反之,如果是 false,那么就不能推进下标,需要将当前的下标处理完毕才能继续推进
boolean advance = true;
// 完成状态,如果是 true,就结束此方法。
boolean finishing = false; // to ensure sweep before committing nextTab
// 死循环,i 表示下标,bound 表示当前线程可以处理的当前桶区间最小下标,死循环的作用是保证拷贝全部完成。
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
// 如果当前线程可以向后推进;这个循环就是控制 i 递减。同时,每个线程都会进入这里取得自己需要转移的桶的区间
//这个循环只是用来控制每个线程每轮最多copy的桶的个数,如果只有一个线程在扩容,也是可以完成的,只是分成多轮

while (advance) {
int nextIndex, nextBound;
// 对 i 减一,判断是否大于等于 bound (正常情况下,如果大于 bound 不成立,说明该线程上次领取的任务已经完成了。那么,需要在下面继续领取任务)
// 如果对 i 减一大于等于 bound(还需要继续做任务),或者完成了,修改推进状态为 false,不能推进了。任务成功后修改推进状态为 true。
// 通常,第一次进入循环,i-- 这个判断会无法通过,从而走下面的 nextIndex 赋值操作(获取最新的转移下标)。其余情况都是:如果可以推进,
//将 i 减一,然后修改成不可推进。如果 i 对应的桶处理成功了,改成可以推进。
if (--i >= bound || finishing)
advance = false;// 这里设置 false,是为了防止在没有成功处理一个桶的情况下却进行了推进
// 这里的目的是:1. 当一个线程进入时,会选取最新的转移下标。2. 当一个线程处理完自己的区间时,如果还有剩余区间的没有别的线程处理。再次获取区间。
else if ((nextIndex = transferIndex) <= 0) {
// 如果小于等于0,说明没有区间了 ,i 改成 -1,推进状态变成 false,不再推进,表示,扩容结束了,当前线程可以退出了
// 这个 -1 会在下面的 if 块里判断,从而进入完成状态判断
i = -1;
advance = false;// 这里设置 false,是为了防止在没有成功处理一个桶的情况下却进行了推进
}// CAS 修改 transferIndex,即 length - 区间值,留下剩余的区间值供后面的线程使用
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;// 这个值就是当前线程可以处理的最小当前区间最小下标
i = nextIndex - 1; // 初次对i 赋值,这个就是当前线程可以处理的当前区间的最大下标
advance = false; // 这里设置 false,是为了防止在没有成功处理一个桶的情况下却进行了推进,这样对导致漏掉某个桶。下面的 if (tabAt(tab, i) == f) 判断会出现这样的情况。
}
}// 如果 i 小于0 (不在 tab 下标内,按照上面的判断,领取最后一段区间的线程扩容结束)
// 如果 i >= tab.length(不知道为什么这么判断)
// 如果 i + tab.length >= nextTable.length (不知道为什么这么判断)
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) { // 如果完成了扩容
nextTable = null;// 删除成员变量
table = nextTab;// 更新 table
sizeCtl = (n << 1) - (n >>> 1); // 更新阈值
return;// 结束方法。
}// 如果没完成

//说明1
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {// 尝试将 sc -1. 表示这个线程结束帮助扩容了,将 sc 的低 16 位减一。
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)// 如果 sc - 2 不等于标识符左移 16 位。如果他们相等了,说明没有线程在帮助他们扩容了。也就是说,扩容结束了。
return;// 不相等,说明没结束,当前线程结束方法。
finishing = advance = true;// 如果相等,扩容结束了,更新 finising 变量
i = n; // 再次循环检查一下整张表
}
}
else if ((f = tabAt(tab, i)) == null) // 获取老 tab i 下标位置的变量,如果是 null,就使用 fwd 占位。
advance = casTabAt(tab, i, null, fwd);// 如果成功写入 fwd 占位,再次推进一个下标
else if ((fh = f.hash) == MOVED)// 如果不是 null 且 hash 值是 MOVED。
advance = true; // already processed // 说明别的线程已经处理过了,再次推进一个下标
else {// 到这里,说明这个位置有实际值了,且不是占位符。对这个节点上锁。为什么上锁,防止 putVal 的时候向链表插入数据
synchronized (f) {
// 判断 i 下标处的桶节点是否和 f 相同
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;// low, height 高位桶,低位桶
// 如果 f 的 hash 值大于 0 。TreeBin 的 hash 是 -2
if (fh >= 0) {
// 对老长度进行与运算(第一个操作数的的第n位于第二个操作数的第n位如果都是1,那么结果的第n为也为1,否则为0)
// 由于 Map 的长度都是 2 的次方(000001000 这类的数字),那么取于 length 只有 2 种结果,一种是 0,一种是1
// 如果是结果是0 ,Doug Lea 将其放在低位,反之放在高位,目的是将链表重新 hash,放到对应的位置上,让新的取于算法能够击中他。
int runBit = fh & n;
Node<K,V> lastRun = f; // 尾节点,且和头节点的 hash 值取于不相等
// 遍历这个桶

//说明2
for (Node<K,V> p = f.next; p != null; p = p.next) {
// 取于桶中每个节点的 hash 值
int b = p.hash & n;
// 如果节点的 hash 值和首节点的 hash 值取于结果不同
if (b != runBit) {
runBit = b; // 更新 runBit,用于下面判断 lastRun 该赋值给 ln 还是 hn。
lastRun = p; // 这个 lastRun 保证后面的节点与自己的取于值相同,避免后面没有必要的循环
}
}
if (runBit == 0) {// 如果最后更新的 runBit 是 0 ,设置低位节点
ln = lastRun;
hn = null;
}
else {
hn = lastRun; // 如果最后更新的 runBit 是 1, 设置高位节点
ln = null;
}// 再次循环,生成两个链表,lastRun 作为停止条件,这样就是避免无谓的循环(lastRun 后面都是相同的取于结果)
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
// 如果与运算结果是 0,那么就还在低位
if ((ph & n) == 0) // 如果是0 ,那么创建低位节点
ln = new Node<K,V>(ph, pk, pv, ln);
else // 1 则创建高位
hn = new Node<K,V>(ph, pk, pv, hn);
}
// 其实这里类似 hashMap
// 设置低位链表放在新链表的 i
setTabAt(nextTab, i, ln);
// 设置高位链表,在原有长度上加 n
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);
// 和链表相同的判断,与运算 == 0 的放在低位
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
} // 不是 0 的放在高位
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
// 如果树的节点数小于等于 6,那么转成链表,反之,创建一个新的树
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;
}
}
}
}
}
}

以上流程如下:

  1. 根据CPU核数和集合length计算每个核一轮处理桶的个数,最小是16
  2. 修改transferIndex标志位,每个线程领取完任务就减去多少,比如初始大小是transferIndex = table.length = 64,每个线程领取的桶个数是16,第一个线程领取完任务后transferIndex = 48,也就是说第二个线程这时进来是从第48个桶开始处理,再减去16,依次类推,这就是多线程协作处理的原理

  3. 领取完任务之后就开始处理,如果桶为空就设置为ForwardingNode,如果不为空就加锁拷贝,拷贝完成之后也设置为ForwardingNode节点

  4. 如果某个线程分配的桶处理完了之后,再去申请,发现transferIndex = 0,这个时候就说明所有的桶都领取完了,但是别的线程领取任务之后有没有处理完并不知道,该线程会将sizeCtl的值减1,然后判断是不是所有线程都退出了,如果还有线程在处理,就退出

  5. 直到最后一个线程处理完,发现sizeCtl = rs<< RESIZE_STAMP_SHIFT,才会将旧数组干掉,用新数组覆盖,并且会重新设置sizeCtl为新数组的扩容点

以上过程总的来说分成两个部分:

  • 分配任务部分:这部分其实很简单,就是把一个大的数组给切分,切分多个小份,然后每个线程处理其中每一小份,当然可能就只有1个或者几个线程在扩容,那就一轮一轮的处理,一轮处理一份
  • 处理任务部分:复制部分主要有两点,第一点就是加锁,第二点就是处理完之后置为ForwardingNode

上面代码中有两处标注要说明的地方,这里解释一下:

说明1

if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {// 尝试将 sc -1. 表示这个线程结束帮助扩容了,将 sc 的低 16 位减一。
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)// 如果 sc - 2 不等于标识符左移 16 位。如果他们相等了,说明没有线程在帮助他们扩容了。也就是说,扩容结束了。
return;// 不相等,说明没结束,当前线程结束方法。
finishing = advance = true;// 如果相等,扩容结束了,更新 finising 变量
i = n; // 再次循环检查一下整张表
}

这段代码其实在上面介绍扩容点的时候就提过,每个线程要进来协助扩容的时候就sizeCtl + 1,这里处理完之后,就sizeCtl - 1,第二个if判断就是上面提到的判断是不是所有的线程都退出了,如果还有线程在执行,那个条件就会成立,就直接return。最后一个线程才会把finishing设置为true,这个是整个扩容结束的标志。

说明2

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

这部分代码是链表拷贝的时候,这里的问题是为什么搞两个for循环,网上很多资料在写扩容的时候,很多都会提这么一段话:“如果拷贝的是链表,就先把链表分成两个反向链表,再插入到新数组的和旧数据组相同的位置和旧数组的位置加上旧数组长度的位置上”,这段话后半部分没说错,前半部分说对了一半,原因就是跟第一个for循环有关,如果没有第一个for循环,确实是构建两个反向链表。

这里假设一种情况,第一个for循环中链表的所有的节点的runbit = 0,这时ln就是链表的首节点,那第二个for缓存就不会执行,因为条件不满足,这个时候就不会构建反向链表,通过这个例子大家应该能明白为什么搞两个for循环,就是为了减少new Node的数量。

helpTransfer()方法

在putVal()方法中,如果发现数组正在扩容,就是执行这个方法,这里贴一下代码

final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
int rs = resizeStamp(tab.length);
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}

其实和最开始分析扩容点时候的代码基本上是一样的。

总结

  本篇文章分析了扩容部分,这部分的代码比较长,需要点耐心,看完扩容的代码之后,大家应该都可以回答概述中的几个问题,总的来说扩容的思想其实很简单,就是多线程协作扩容,复制的时候加锁,防止多线程同时处理。这里面CAS用的非常多,而且很多地方用的很巧妙,unsafe这个魔法类在并发里面使用的真是太频繁了。

参考:

并发编程——ConcurrentHashMap#transfer() 扩容逐行分析

最新文章

  1. [转]Hibernate update和saveOrUpdate详解
  2. nexus私有仓库搭建
  3. Codeforces Round #369 (Div. 2) C. Coloring Trees DP
  4. HDU5812 Distance(枚举 + 分解因子)
  5. java Jsoup 抓取页面数据
  6. git checkout 和 git checkout --merge &lt;branch_name&gt;使用
  7. spark概论
  8. mongodb的查询方式与sql语句对比
  9. Linux开机自动挂载Windows分区
  10. Nagios显示器mysql定从库: libmysqlclient.so.18: cannot open shared object file: No such
  11. Error Code: 1175. You are using safe update mode and you tried to update a table without a WHERE that uses a KEY column To disable safe mode, toggle the option in Preferences -&gt; SQL Queries and reconn
  12. Pydev for eclipse 安装及配置
  13. IdentityServer4 指定角色授权(Authorize(Roles=&quot;admin&quot;))
  14. 图数据库Neo4j
  15. 虚拟机centos无法连接外网时怎么处理
  16. Centos7下ELK+Redis日志分析平台的集群环境部署记录
  17. QT建立简单的计时器程序
  18. 如何进行SQL排序
  19. Quartz.NET简介
  20. Linux学习8-Linux常用命令(4)

热门文章

  1. 1090 Highest Price in Supply Chain (25 分)(模拟建树,找树的深度)牛客网过,pat没过
  2. Jemter响应数据乱码
  3. 520是秀恩爱吃狗粮,521才是真正的告白日,- Python告白神器用起来 !
  4. Spring security OAuth2.0认证授权学习第二天(基础概念-授权的数据模型)
  5. ubuntu 18.04下修改python3指向
  6. Kubernetes-16:一文详解ServiceAccount及RBAC权限控制
  7. flask提交表单验证不通过,以及CSRF攻击原理
  8. spring boot 在框架中注入properties文件里的值(Spring三)
  9. Django 中 a href标签 使用方法 跳转页面(Django四)
  10. linux下查找文件中的某个关键字