JUC:ConcurrentHashMap(并发容器)


关键词

  • synchronized:并发度,头节点加锁;cas:初始化竞争 / transferIndex多线程扩容进度
  • sizeCtl(Hash表处于不同状态变量),进行CAS操作
  • transferIndex(扩容的进度),CAS进行操作(每次减stride)
  • ForwardingNode(转发节点),记录新ConcurrentHashMap引用
  • tab.length 是2的整数次方,每次扩容又是2倍(新index位置)

同步容器 并发容器 特点
HashMap ConcurrentHashMap JDK6中采用一种更加细粒度的加锁机制Segment“分段锁”,JDK8中采用CAS无锁算法
ArrayList CopyOnWriteArrayList 对读操作不加锁,对写操作,先复制一份新的集合,在新的集合上面修改,然后将新集合赋值给旧的引用,并通过volatile 保证其可⻅性,当然写操作的锁是必不可少的了。
HashSet CopyOnWriteArraySet 利用高并发往往是读多写少的特性,对读操作不加锁,对写操作,先复制一份新的集合,在新的集合上面修改,然后将新集合赋值给旧的引用,并通过volatile 保证其可⻅性,当然写操作的锁是必不可少的了。
HashSet CopyOnWriteArraySet 基于CopyOnWriteArrayList实现,其唯一的不同是在add时调用的是CopyOnWriteArrayList的addIfAbsent方法,其遍历当前Object数组,如Object数组中已有了当前元素,则直接返回,如果没有则放入Object数组的尾部,并返回。
TreeMap ConcurrentSkipListMap Skip list(跳表)是一种可以代替平衡树的数据结构,默认是按照Key值升序的。
TreeSet ConcurrentSkipListSet 内部基于ConcurrentSkipListMap实现
Queue ConcurrentLinkedQueue 基于链表实现的FIFO队列(LinkedList的并发版本)
BlockingQueue LinkedBlockingQueue、ArrayBlockingQueue、PriorityBlockingQueue 通过ReentrantLock实现线程安全,通过Condition实现阻塞和唤醒

一、 数据结构

HashMap通常的实现方式是“数组+链表”,这种方式被称为“拉链法”。ConcurrentHashMap在这个基本原理之上进行了各种优化。

首先是所有数据都放在一个大的HashMap中;其次是引入了红黑树

其原理如下图所示:

如果头节点是Node类型,则尾随它的就是一个普通的链表;如果头节点是TreeNode类型,它的后面就是一颗红黑树,TreeNode是Node的子类。

链表和红黑树之间可以相互转换:初始的时候是链表,当链表中的元素超过某个阈值时,把链表转换成红黑树;反之,当红黑树中的元素个数小于某个阈值时,再转换为链表。

那为什么要做这种设计呢?

  1. 使用红黑树,当一个槽里有很多元素时,其查询和更新速度会比链表快很多,Hash冲突的问题由此得到较好的解决。

  2. 加锁的粒度,并非整个ConcurrentHashMap,而是对每个头节点分别加锁,即并发度,就是Node数组的长度,初始长度为16。

  3. 并发扩容,这是难度最大的。当一个线程要扩容Node数组的时候,其他线程还要读写,因此处理过程很复杂,后面会详细分析。

由上述对比可以总结出来:这种设计一方面降低了Hash冲突,另一方面也提升了并发度

二、 源码剖析

从构造方法开始,一步步深入分析其实现过程

1.构造方法分析

在上面的代码中,变量cap就是Node数组的长度,保持为2的整数次方。tableSizeFor(…)方法是根据传入的初始容量,计算出一个合适的数组长度。具体而言:1.5倍的初始容量+1,再往上取最接近的2的整数次方,作为数组长度cap的初始值。

这里的 sizeCtl,其含义是用于控制在初始化或者并发扩容时候的线程数,只不过其初始值设置成cap。

2.初始化

在上面的构造方法里只计算了数组的初始大小,并没有对数组进行初始化。当多个线程都往里面放入元素的时候,再进行初始化。这就存在一个问题:多个线程重复初始化。下面看一下是如何处理的。

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(); // 自旋等待else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {  // 重点:将sizeCtl设置为-1原子操作,谁抢到-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;// sizeCtl不是数组长度,因此初始化成功后,就不再等于数组长度// 而是n-(n>>>2)=0.75n,表示下一次扩容的阈值:n-n/4sc = n - (n >>> 2);}} finally {sizeCtl = sc;  // 设置sizeCtl的值为sc。}break;}}return tab;
}

通过上面的代码可以看到,多个线程的竞争是通过对sizeCtl进行CAS操作实现的。如果某个线程成功地把 sizeCtl 设置为-1,它就拥有了初始化的权利,进入初始化的代码模块,等到初始化完成,再把sizeCtl设置回去;其他线程则一直执行while循环,自旋等待,直到数组不为null,即当初始化结束时,退出整个方法。

因为初始化的工作量很小,所以此处选择的策略是让其他线程一直等待,而没有帮助其初始化。

3.put(…)实现分析

final V putVal(K key, V value, boolean onlyIfAbsent) {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;// 分支1:整个数组初始化if (tab == null || (n = tab.length) == 0)tab = initTable();// 分支2:第i个元素初始化else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))break;          // no lock when adding to empty bin}// 分支3:扩容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;// 分支4:放入元素(加锁)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");}}// 如果是链表,上面的binCount会一直累加if (binCount != 0) {if (binCount >= TREEIFY_THRESHOLD) //TREEIFY_THRESHOLD默认8treeifyBin(tab, i); // 超出阈值,转换为红黑树if (oldVal != null)return oldVal;break;}}}addCount(1L, binCount); // 总元素个数累加1return null;
}

上面的for循环有4个大的分支:

  • 第1个分支,是整个数组的初始化,前面已讲;

  • 第2个分支,是所在的槽为空,说明该元素是该槽的第一个元素,直接新建一个头节点,然后返回;

  • 第3个分支,说明该槽正在进行扩容,帮助其扩容;

  • 第4个分支,就是把元素放入槽内。槽内可能是一个链表,也可能是一棵红黑树,通过头节点的类型可以判断是哪一种。第4个分支是包裹在synchronized (f)里面的,f对应的数组下标位置的头节点,意味着每个数组元素有一把锁,并发度等于数组的长度。

    上面的binCount表示链表的元素个数,当这个数目超过TREEIFY_THRESHOLD=8时,把链表转换成红黑树,也就是 treeifyBin(tab,i)方法。

    但在这个方法内部,不一定需要进行红黑树转换,可能只做扩容操作,所以接下来从扩容讲起。

4.扩容

扩容的实现是最复杂的,下面从treeifyBin(Node<K,V>[] tab, int index)讲起。

private final void treeifyBin(Node<K,V>[] tab, int index) {Node<K,V> b; int n;if (tab != null) {if ((n = tab.length) < MIN_TREEIFY_CAPACITY)// 数组长度小于阈值64,不做红黑树转换,直接扩容tryPresize(n << 1);else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {// 链表转换为红黑树synchronized (b) {if (tabAt(tab, index) == b) {TreeNode<K,V> hd = null, tl = null;// 遍历链表,初始化红黑树for (Node<K,V> e = b; e != null; e = e.next) {TreeNode<K,V> p = new TreeNode<K,V>(e.hash, e.key, e.val,null, null);if ((p.prev = tl) == null)hd = p;elsetl.next = p;tl = p;}setTabAt(tab, index, new TreeBin<K,V>(hd));}}}}
}

在上面的代码中,MIN_TREEIFY_CAPACITY=64,意味着当数组的长度没有超过64的时候,数组的每个节点里都是链表,只会扩容,不会转换成红黑树。只有当数组长度大于或等于64时,才考虑把链表转换成红黑树。

直接扩容:在 tryPresize(int size)内部调用了一个核心方法 transfer(Node<K,V>[] tab,Node<K,V>[] nextTab),先从这个方法的分析说起。

private final void tryPresize(int size) {int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :tableSizeFor(size + (size >>> 1) + 1);int sc;while ((sc = sizeCtl) >= 0) {Node<K,V>[] tab = table; int n;if (tab == null || (n = tab.length) == 0) {n = (sc > c) ? sc : c;if (U.compareAndSetInt(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 (U.compareAndSetInt(this, SIZECTL, sc,(rs << RESIZE_STAMP_SHIFT) + 2))transfer(tab, null);}}}

核心的迁移函数:transfer

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; // 计算步长if (nextTab == null) {       // 初始化新的HashMaptry {@SuppressWarnings("unchecked")Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1]; // 扩容两倍nextTab = nt;} catch (Throwable ex) {    // try to cope with OOMEsizeCtl = Integer.MAX_VALUE;return;}nextTable = nextTab;// 初始的transferIndex为旧HashMap的数组长度transferIndex = n;}int nextn = nextTab.length;ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);boolean advance = true;boolean finishing = false; // to ensure sweep before committing nextTab// 此处,i为遍历下标,bound为边界。// 如果成功获取一个任务,则i=nextIndex-1// bound=nextIndex-stride;// 如果获取不到,则i=0,bound=0for (int i = 0, bound = 0;;) {Node<K,V> f; int fh;// advance表示在从i=transferIndex-1遍历到bound位置的过程中,是否一直继续while (advance) {int nextIndex, nextBound;// 以下是哪个分支中的advance都是false,表示如果三个分支都不执行,才可以一直while循环// 目的在于当对transferIndex执行CAS操作不成功的时候,需要自旋,以期获取一个stride的迁移任务。if (--i >= bound || finishing)// 对数组遍历,通过这里的--i进行。如果成功执行了--i,就不需要继续while循环了,因为advance只能进一步。advance = false;else if ((nextIndex = transferIndex) <= 0) {// transferIndex <= 0,整个HashMap完成i = -1;advance = false;}// 对transferIndex执行CAS操作,即为当前线程分配1个stride。// CAS操作成功,线程成功获取到一个stride的迁移任务;// CAS操作不成功,线程没有抢到任务,会继续执行while循环,自旋。else if (U.compareAndSetInt(this, TRANSFERINDEX, nextIndex,nextBound = (nextIndex > stride ?nextIndex - stride : 0))) {bound = nextBound;i = nextIndex - 1;advance = false;}}// i越界,整个HashMap遍历完成if (i < 0 || i >= n || i + n >= nextn) {int sc;// finishing表示整个HashMap扩容完成if (finishing) {nextTable = null;// 将nextTab赋值给当前tabletable = nextTab;sizeCtl = (n << 1) - (n >>> 1);return;}if (U.compareAndSetInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)return;finishing = advance = true;i = n; // recheck before commit}}// tab[i]迁移完毕,赋值一个ForwardingNodeelse if ((f = tabAt(tab, i)) == null)advance = casTabAt(tab, i, null, fwd);// tab[i]的位置已经在迁移过程中else if ((fh = f.hash) == MOVED)advance = true; // already processedelse {// 对tab[i]进行迁移操作,tab[i]可能是一个链表或者红黑树synchronized (f) {if (tabAt(tab, i) == f) {Node<K,V> ln, hn;// 链表if (fh >= 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之后的所有元素,hash值都是一样的// 记录下这个最后的位置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);elsehn = 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;elseloTail.next = p;loTail = p;++lc;}else {if ((p.prev = hiTail) == null)hi = p;elsehiTail.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;}}}}}
}

该方法非常复杂,下面一步步分析:

  1. 扩容的基本原理如下图,首先建一个新的HashMap,其数组长度是旧数组长度的2倍(涉及key的hash取模的问题,节省空间效率高),然后把旧的元素逐个迁移过来。所以,上面的方法参数有2个,第1个参数tab是扩容之前的HashMap,第2个参数nextTab是扩容之后的HashMap。当nextTab=null的时候,方法最初会对nextTab进行初始化。这里有一个关键点要说明:该方法会被多个线程调用,所以每个线程只是扩容旧的HashMap部分,这就涉及如何划分任务的问题

  2. 上图为多个线程并行扩容-任务划分示意图。旧数组的长度是N,每个线程扩容一段,一段的长度用变量stride(步长)来表示,transferIndex表示了整个数组扩容的进度。

    stride的计算公式如上面的代码所示,即:在单核模式下直接等于n,因为在单核模式下没有办法多个线程并行扩容,只需要1个线程来扩容整个数组;在多核模式下为 (n>>>3)/NCPU(核芯数量),并且保证步长的最小值是 16。显然,需要的线程个数约为n/stride。

transferIndex是ConcurrentHashMap的一个成员变量,记录了扩容的进度。初始值为n,从大到小扩容,每次减stride个位置,最终减至n<=0,表示整个扩容完成。因此,从[0,transferIndex-1]的位置表示还没有分配到线程扩容的部分,从[transfexIndex,n-1]的位置表示已经分配给某个线程进行扩容,当前正在扩容中,或者已经扩容成功。

因为transferIndex会被多个线程并发修改,每次减stride,所以需要通过CAS进行操作,如下面的代码所示。

  1. 在扩容未完成之前,有的数组下标对应的槽已经迁移到了新的HashMap里面,有的还在旧的HashMap 里面。这个时候,所有调用 get(k,v)的线程还是会访问旧 HashMap,怎么处理呢?

    下图为扩容过程中的转发示意图:当Node[0]已经迁移成功,而其他Node还在迁移过程中时,如果有线程要读取Node[0]的数据,就会访问失败。为此,新建一个ForwardingNode(解决上述问题),即转发节点,在这个节点里面记录的是新的 ConcurrentHashMap 的引用。这样,当线程访问到ForwardingNode之后,会去查询新的ConcurrentHashMap。

  2. 因为数组的长度 tab.length 是2的整数次方,每次扩容又是2倍。

    而 Hash 函数是hashCode%tab.length,等价于hashCode&(tab.length-1)。这意味着:处于第i个位置的元素,在新的Hash表的数组中一定处于第i个或者第i+n个位置,如下图所示。

    举个简单的例子:假设数组长度是8,扩容之后是16
    ​ 若hashCode=5,5%8=0,扩容后,5%16=0,位置保持不变;
    ​ 若hashCode=24,24%8=0,扩容后,24%16=8,后移8个位置;
    ​ 若hashCode=25,25%8=1,扩容后,25%16=9,后移8个位置;
    ​ 若hashCode=39,39%8=7,扩容后,39%8=7,位置保持不变;
    ​ ……

正因为有这样的规律,所以如下有代码:

也就是把tab[i]位置的链表或红黑树重新组装成两部分,一部分链接到nextTab[i]的位置,一部分链接到nextTab[i+n]的位置,如上图所示。然后把tab[i]的位置指向一个ForwardingNode节点。

同时,当tab[i]后面是链表时,使用类似于JDK 7中在扩容时的优化方法,从lastRun往后的所有节点,不需依次拷贝,而是直接链接到新的链表头部。从lastRun往前的所有节点,需要依次拷贝。

了解了核心的迁移函数transfer(tab,nextTab),再回头看tryPresize(int size)函数。这个函数的输入是整个Hash表的元素个数,在函数里面,根据需要对整个Hash表进行扩容。想要看明白这个函数,需要透彻地理解sizeCtl变量,下面这段注释摘自源码。

当sizeCtl=-1时,表示整个HashMap正在初始化;
当sizeCtl=某个其他负数时,表示多个线程在对HashMap做并发扩容;
当sizeCtl=cap时,tab=null,表示未初始之前的初始容量(如上面的构造函数所示);

扩容成功之后,sizeCtl存储的是下一次要扩容的阈值,即上面初始化代码中的n-(n>>>2)=0.75n。

所以,sizeCtl变量在Hash表处于不同状态时,表达不同的含义

明白了这个道理,再来看上面的tryPresize(int size)函数。

private final void tryPresize(int size) {int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :tableSizeFor(size + (size >>> 1) + 1);int sc;while ((sc = sizeCtl) >= 0) {Node<K,V>[] tab = table; int n;if (tab == null || (n = tab.length) == 0) {n = (sc > c) ? sc : c;if (U.compareAndSetInt(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 (U.compareAndSetInt(this, SIZECTL, sc,(rs << RESIZE_STAMP_SHIFT) + 2))transfer(tab, null);}}
}

tryPresize(int size)是根据期望的元素个数对整个Hash表进行扩容,核心是调用transfer函数。

在第一次扩容的时候,sizeCtl会被设置成一个很大的负数U.compareAndSwapInt(this,SIZECTL,sc,(rs <<RESIZE_STAMP_SHIFT)+2);之后每一个线程扩容的时候,sizeCtl 就加 1,U.compareAndSwapInt(this,SIZECTL,sc,sc+1),待扩容完成之后,sizeCtl减1。

JUC:ConcurrentHashMap(并发容器)相关推荐

  1. java并发编程笔记3-同步容器并发容器闭锁栅栏信号量

    一.同步容器: 1.Vector容器实现了List接口,Vector实际上就是一个数组,和ArrayList类似,但是Vector中的方法都是synchronized方法,即进行了同步措施.保证了线程 ...

  2. JUC:ConcurrentSkipListMap/ConcurrentSkipListSet(并发容器)

    JUC:ConcurrentSkipListMap/ConcurrentSkipListSet(并发容器) 关键词 无锁链表(无锁地实现节点的增加.删除,只在队头.队尾进行CAS操作) 并发问题,删除 ...

  3. JDK1.7和JDK1.8中HashMap是线程不安全的,并发容器ConcurrentHashMap模型

    一.HashMap是线程不安全的 前言 只要是对于集合有一定了解的一定都知道HashMap是线程不安全的,我们应该使用ConcurrentHashMap.但是为什么HashMap是线程不安全的呢,之前 ...

  4. 10、并发容器,ConcurrentHashMap

    Java 提供了不同层面的线程安全支持.在传统集合框架内部,除了 Hashtable 等同步容器,还提供了所谓的同步包装器(Synchronized Wrapper),我们可以调用 Collectio ...

  5. 并发容器(J.U.C)中的集合类

    同步容器是通过synchronized来实现同步的,所以性能较差.而且同步容器也并不是绝对线程安全的,在一些特殊情况下也会出现线程不安全的行为.那么有没有更好的方式代替同步容器呢?----> 那 ...

  6. 多线程---并发容器的使用

    多线程---并发容器的使用 1. 容器概览 2. 容器的使用 1. Map 1. HashTable 2. HashMap 3. SynchronizedHashMap 4. ConcurrentHa ...

  7. Java集合之并发容器

    一:java中的并发容器总结 JDK提供的这些容器大部分在 java.util.concurrent 包中. ConcurrentHashMap: 线程安全的HashMap CopyOnWriteAr ...

  8. 探索JAVA并发 - 并发容器全家福!

    作者:acupt,专注Java,架构师社区合伙人! 14个并发容器,你用过几个? 不考虑多线程并发的情况下,容器类一般使用ArrayList.HashMap等线程不安全的类,效率更高.在并发场景下,常 ...

  9. 高并发编程_高并发编程系列:7大并发容器详解(附面试题和企业编程指南)...

    不知道从什么时候起,在Java编程中,经常听到Java集合类,同步容器.并发容器,高并发编程成为当下程序员需要去了解掌握的技术之一,那么他们有哪些具体分类,以及各自之间的区别和优劣呢? 只有把这些梳理 ...

最新文章

  1. Ubuntu 14.04 64bit上安装Scrapy
  2. 26.使用ajaxSetup()方法设置全局Ajax默认选项
  3. 如何选择正确的RAID级别
  4. 中兴手机数据通道打不开_我用的是中兴手机,里面有流量,但是数据开不了,应该怎么办呢?...
  5. TortoiseGit:记住用户名和密码
  6. P2820 局域网(最小生成树)
  7. bzoj2424 [HAOI2010]订货 费用流
  8. noip2016的研究
  9. GEF:使用Draw2D画流程图-(下)
  10. 15.高性能MySQL --- 备份与恢复
  11. 中文词语自动纠错_编辑距离
  12. 华硕点亮系统无法连接到服务器,华硕点亮点亮后台管理系统
  13. 毕业设计之 --- 新闻分类系统
  14. 会议OA项目之我的审批功能
  15. 全球与中国铝合金窗型材市场规模预测与产销前景调研报告2022版
  16. AngularJs实战(六)
  17. 通过流量分析,通过xposed hook#2
  18. python 录音_python实现录音功能(可随时停止录音)
  19. IC学习笔记3——异步FIFO
  20. 显卡DirectX技术发展详解

热门文章

  1. NTT Docomo研究主管Kazuaki OBANA:NTT DOCOMO NFV案例解析
  2. React系列---Babel
  3. chrome的timeline的问题?
  4. 炼数成金hadoop视频干货03
  5. 省、省、省!!!企业如何搭建易用性网络
  6. 下面我这个方法可以实现限制某些QQ号登陆,而没有被限制的QQ号可以登陆,不需要借助任何工具。...
  7. 让VirtualBox虚拟机实现开机自动后台运行
  8. 2013流行Python项目汇总
  9. DEDECMS给图集图片{dede:productimagelist}自动编号
  10. 虚拟CentOS访问Windows下共享文件(二)