• 带你阅读JDK1.8的HashMap源码(一)


    1.写在前面

    前面的博客的我们简单的介绍完了红黑树,主要就是为了今天看1.8的HashMap的代码准备的,因为1.8的HashMap的源码有一个树化的过程,所以我们先简单的谈了下红黑树。

    2.从put方法开始谈起

    废话不多说,直接上代码,具体的put方法源码如下:

    public V put(K key, V value) {
      return putVal(hash(key), key, value, false, true);
    }
    
    • 1
    • 2
    • 3

    这儿我们调用的putVal(hash(key), key, value, false, true);方法,这儿我们需要注意的是第四个和第五个参数。

    • 第四个参数onlyIfAbsent:如果为真,则不要更改现有值,就是如果为真的时候,插入相同的键的时候,如果值不一样的话,则不会修改原来的键的值。很明显我们的HashMap是改变原来的值,所以这儿的值是false
    • 第五个参数evict:这个参数在HashMap中没有使用,我们这儿可以不用纠结。这个参数主要是在LinkedHashMap中使用。与今天的博客没有太大的关系,所以我们这儿直接跳过。

    这儿我们还需要简单的了解下hash(key)方法的源码,具体的如下:

    static final int hash(Object key) {
      int h;
      return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    
    • 1
    • 2
    • 3
    • 4

    如果键为null,直接返回0,所以HashMap可以插入键为null的值。

    如果键不为null,这个时候值等于先将这个键的hashcode计算出来,然后将这个值右移16位,然后在异或。这儿我们也不用深究其中的含义。我们只知道是这样计算出来的即可。

    上面的方法大概讲完了,这个时候我们继续看源码,这个时候我们需要查看putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict)方法,具体的代码如下:

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
      Node<K,V>[] tab; Node<K,V> p; int n, i;
      if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
      
      if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
      else {
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
          e = p;
        
        else if (p instanceof TreeNode)
          e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        
        else {
          for (int binCount = 0; ; ++binCount) {
            
            if ((e = p.next) == null) {
              p.next = newNode(hash, key, value, null);
              if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                treeifyBin(tab, hash);
              break;
            }
            
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
              break;
            
            p = e;
          }
        }
        
        if (e != null) { // existing mapping for key
          V oldValue = e.value;
          if (!onlyIfAbsent || oldValue == null)
            e.value = value;
          afterNodeAccess(e);
          return oldValue;
        }
      }
      ++modCount;
      if (++size > threshold)
        resize();
      
      afterNodeInsertion(evict);
      return null;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50

    上面的方法比较长,可能比较难搞,这个时候我们只需要分成几种情况,这样就比较好理解,这儿我们已经将对应的分支分开了,只要讲几种情况就行了。

    2.1创建HashMap

    这种的情况,主要是HashMap没有创建,刚刚开始创建,于是会进入第一个分支,具体的代码的如下:

    if ((tab = table) == null || (n = tab.length) == 0)
      n = (tab = resize()).length;
    
    • 1
    • 2

    这儿我们看的resize方法,具体的代码如下:

    final Node<K,V>[] resize() {
      Node<K,V>[] oldTab = table;
      int oldCap = (oldTab == null) ? 0 : oldTab.length;
      int oldThr = threshold;
      int newCap, newThr = 0;
      if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
          threshold = Integer.MAX_VALUE;
          return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
          newThr = oldThr << 1; // double threshold
      }
      else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
      else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
      }
      if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
      }
      threshold = newThr;
      @SuppressWarnings({"rawtypes","unchecked"})
      Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
      table = newTab;
      if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
          Node<K,V> e;
          if ((e = oldTab[j]) != null) {
            oldTab[j] = null;
            if (e.next == null)
              newTab[e.hash & (newCap - 1)] = e;
            else if (e instanceof TreeNode)
              ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
            else { // preserve order
              Node<K,V> loHead = null, loTail = null;
              Node<K,V> hiHead = null, hiTail = null;
              Node<K,V> next;
              do {
                next = e.next;
                if ((e.hash & oldCap) == 0) {
                  if (loTail == null)
                    loHead = e;
                  else
                    loTail.next = e;
                  loTail = e;
                }
                else {
                  if (hiTail == null)
                    hiHead = e;
                  else
                    hiTail.next = e;
                  hiTail = e;
                }
              } while ((e = next) != null);
              if (loTail != null) {
                loTail.next = null;
                newTab[j] = loHead;
              }
              if (hiTail != null) {
                hiTail.next = null;
                newTab[j + oldCap] = hiHead;
              }
            }
          }
        }
      }
      return newTab;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73

    一看方法这么长,是不是想死的心都有,不过不用担心,因为这个方法有两个职责,所以这个方法比较长,这个方法主要用于创建HashMap同时还有一种职责扩容,我们由创建的HashMap进入这个方法的,所以很多的代码我们暂时不用看的,只需要看我们创建HashMap的代码就行,于是代码精简成如下的内容:

    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    /*if (oldCap > 0) {
      if (oldCap >= MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return oldTab;
      }
      else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
               oldCap >= DEFAULT_INITIAL_CAPACITY)
        newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
      newCap = oldThr;*/
    else {               // zero initial threshold signifies using defaults
      newCap = DEFAULT_INITIAL_CAPACITY;
      newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    /*if (newThr == 0) {
      float ft = (float)newCap * loadFactor;
      newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                (int)ft : Integer.MAX_VALUE);
    }*/
    threshold = newThr;
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    return newTab;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    为了提高代码的可读性,所以我们这儿将不调用的代码注释掉了。

    这儿主要四个比较重要的变量,分别是oldCap扩容前HashMap的容量,oldThr扩容前HashMap的负载因子乘以容量,newCap扩容后HashMap的容量,newThr扩容后的HashMap的负载因子乘以容量。这儿这4个值都是为0,因为这儿的HashMap还没有创建。那么这个时候,我们就需要创建这个HashMap,这个时候我们需要知道几个常量,具体的如下:

    • DEFAULT_INITIAL_CAPACITY的值为16
    • DEFAULT_LOAD_FACTOR的值为0.75

    于是将DEFAULT_INITIAL_CAPACITY的值赋值给newCap,然后将DEFAULT_INITIAL_CAPACITYDEFAULT_LOAD_FACTOR的乘积赋值给newThr然后创建对应的长度的Node的数组返回,同时将这个创建好的值赋值给全局的变量table。同时将newThr这个变量赋值给全局的变量threshold。至此整个创建就完成了。

    2.2插入(没有hash冲突的情况)

    回到我们的原来的方法,我们继续看下一个分支,具体的代码如下:

    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((p = tab[i = (n - 1) & hash]) == null)
      tab[i] = newNode(hash, key, value, null);
    
    • 1
    • 2
    • 3

    这儿我们需要的知道的这个数组的下标我们是怎么知道的,主要通过下面的式子计算出来的,具体的如下:

    i = (n - 1) & hash
    
    • 1

    这儿的n是16然后减去1就是15然后与上hash的值,这个时候我们会得到一个下标,然后获取这个数组对应的下标的这个值,如果为空,就直接创建这个Node节点。这个时候有一个疑问,**我们都知道前面的方法创建的这个HashMap的Node的数组的长度就是16,那么我们怎么一定肯定计算出来的这个下标就一定是0到15之间的值呢?**这个问题稍后再回答。我们先看看 newNode(hash, key, value, null);方法,具体的代码如下:

    tab[i] = newNode(hash, key, value, null);
    Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
      return new Node<>(hash, key, value, next);
    }
    
    • 1
    • 2
    • 3
    • 4

    需要注意的是这儿这个Node的这个对象的下一个节点为null,也是没有问题的,因为这儿就是为null,这个下标首次插入对应的值,它的下一个节点就是为空。

    回到刚才的问题,我们都知道前面的方法创建的这个HashMap的Node的数组的长度就是16,那么我们怎么一定肯定计算出来的这个下标就一定是0到15之间的值呢?

    我们需要再次看一下计算hash的方法,具体的代码如下:

    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    
    • 1
    • 2
    h >>> 16
    h       0000 0000 0000 0001 0000 0000 0000 0000
    h >>>16 0000 0000 0000 0000 0000 0000 0000 0001
    
    • 1
    • 2
    • 3

    这儿右移16位的话,就是int的值的高16位移动低16位,我们再来看下异或

    (h = key.hashCode()) ^ (h >>> 16)
    h       0000 0000 0000 0001 1000 0000 0000 0000
    h >>>16 0000 0000 0000 0000 0000 0000 0000 0001
    ^       0000 0000 0000 0001 1000 0000 0000 0001
    
    • 1
    • 2
    • 3
    • 4

    也就是hashcode的值的高16位于16位异或这个值作为结果值的低16位,然后原来的hashcode的值的高16位不变作为结果值的高16位

    这个时候我们再来看下标的计算方法

    i = (n - 1) & hash
    
    • 1
    15   0000 0000 0000 0000 0000 0000 0000 1111
    hash 0000 0000 0000 0001 1000 0000 0000 0001
    &    0000 0000 0000 0000 0000 0000 0000 0001
    
    • 1
    • 2
    • 3

    由于是与运算,所以这儿不管是什么值,与上15一定是0到15之间的值。

    2.3插入(有hash冲突的情况)

    有hash冲突的情况下,又有两种情况,一种是链表的插入,一种是红黑树的插入。我们先看链表的插入

    2.3.1链表的插入
    Node<K,V> e; K k;
    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
      e = p;
    /*else if (p instanceof TreeNode)
      e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);*/
    else {
      for (int binCount = 0; ; ++binCount) {
        if ((e = p.next) == null) {
          p.next = newNode(hash, key, value, null);
          if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            treeifyBin(tab, hash);
          break;
        }
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
          break;
        p = e;
      }
    }
    if (e != null) { // existing mapping for key
      V oldValue = e.value;
      if (!onlyIfAbsent || oldValue == null)
        e.value = value;
      afterNodeAccess(e);
      return oldValue;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    为了代码的可读性,我又注释了不用的代码,然后我们看对应代码。

    这儿也是两种情况,我们先看插入的key是一样的

    • 这儿的p是我们查找出来的Node,如果这两个Nodehash的值是一样的,同时这两个Node的值key是一样的,那么我就用e接受这个查找出来的Node
    • 然后根据onlyIfAbsent的值,如果这个值为false,我们就修改相同key的值,同时将这个key的原来的value的值返回出去。

    第二种情况,就是插入key的是不一样的

    • 这个时候我们直接进入else的的分支中去。先遍历这个找到的节点对应链表的尾部,然后直接插入进去。这个时候我们需要知道常量

      TREEIFY_THRESHOLD的值为8,也是说当链表的长度大于8的时候,我们需要转成红黑树。

    • 这个时候在遍历的时候可能找到对应的key是一样的,这个时候直接结束循环,然后走剩下的流程,就是和第一种的情况差不多。

    这个时候我们需要看看这儿怎么将链表转换成红黑树的方法treeifyBin(tab, hash);,具体的代码如下:

    final void treeifyBin(Node<K,V>[] tab, int hash) {
      int n, index; Node<K,V> e;
      if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
      else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        do {
          TreeNode<K,V> p = replacementTreeNode(e, null);
          if (tl == null)
            hd = p;
          else {
            p.prev = tl;
            tl.next = p;
          }
          tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
          hd.treeify(tab);
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    这个时候我们进来第一个if的分支,然后我们发现数组的长度是小于**MIN_TREEIFY_CAPACITY(64)**这个时候我们继续看resize()方法。具体的代码如下:

    final Node<K,V>[] resize() {
      Node<K,V>[] oldTab = table;
      int oldCap = (oldTab == null) ? 0 : oldTab.length;
      int oldThr = threshold;
      int newCap, newThr = 0;
      if (oldCap > 0) {
        /*if (oldCap >= MAXIMUM_CAPACITY) {
          threshold = Integer.MAX_VALUE;
          return oldTab;
        }*/
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
          newThr = oldThr << 1; // double threshold
      }
      /*else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
      else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
      }*/
      /*if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
      }*/
      threshold = newThr;
      @SuppressWarnings({"rawtypes","unchecked"})
      Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
      table = newTab;
      
      return newTab;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32

    扩容的代码我已经将这次流程不走的代码注释掉了,只看主要的流程。首先还是这四个变量。不懂的可以看看前面的介绍这4个变量,这儿不做过多的赘述。首先进入的是第一个if的判断,newCap变成原来的oldCap的两倍,前提有一个条件就是newCap小于MAXIMUM_CAPACITY(int的最大的值)同时oldCap大于DEFAULT_INITIAL_CAPACITY(16)这个时候newThrnewCap变成了原来的两倍了。

    然后就是一些赋值的操作,这个时候我们需要继续看剩下替换的功能。具体的代码如下:

    if (oldTab != null) {
      for (int j = 0; j < oldCap; ++j) {
        Node<K,V> e;
        if ((e = oldTab[j]) != null) {
          oldTab[j] = null;
          if (e.next == null)
            newTab[e.hash & (newCap - 1)] = e;
          else if (e instanceof TreeNode)
            ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
          else { // preserve order
            Node<K,V> loHead = null, loTail = null;
            Node<K,V> hiHead = null, hiTail = null;
            Node<K,V> next;
            do {
              next = e.next;
              if ((e.hash & oldCap) == 0) {
                if (loTail == null)
                  loHead = e;
                else
                  loTail.next = e;
                loTail = e;
              }
              else {
                if (hiTail == null)
                  hiHead = e;
                else
                  hiTail.next = e;
                hiTail = e;
              }
            } while ((e = next) != null);
            if (loTail != null) {
              loTail.next = null;
              newTab[j] = loHead;
            }
            if (hiTail != null) {
              hiTail.next = null;
              newTab[j + oldCap] = hiHead;
            }
          }
        }
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42

    上面的代码主要是重新分配下HashMap的值。主要分成三种情况

    • 第一种情况:就是这个数组的下标的值就只有一个值,这个时候只需要重新计算一下下标就可以了。然后放到新的数组中去。
    • 第二种情况:就是这个遍历出来的元素,是一个树节点,就直接调用((TreeNode)e).split(this, newTab, j, oldCap);方法。
    • 第三种情况:就是这个遍历的出来的原始,是一个链表,遍历这个链表,将这个链表分成两个链表,主要是这个键的hash的值与上这个原来的数组的长度,如果为0的话,就放到lo的链表中,如果不为0的话,就放到hi的链表中去。计算完了,将lo的链表挂到新的HashMap中去,下标是当前遍历的下标中去。将hi的链表挂到新的HashMap中去,下标是当前遍历的下标加上原来HashMap长度的下标中去。

    最后再回到原来的树化的流程,具体的代码如下:

    final void treeifyBin(Node<K,V>[] tab, int hash) {
      int n, index; Node<K,V> e;
      /*if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();*/
      else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        do {
          TreeNode<K,V> p = replacementTreeNode(e, null);
          if (tl == null)
            hd = p;
          else {
            p.prev = tl;
            tl.next = p;
          }
          tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
          hd.treeify(tab);
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    先将这个节点转换成TreeNode节点,然后遍历这个链表,转换成双像链表,同时将这个链表转换成功红黑树,主要调用的方法是hd.treeify(tab);

    2.32红黑树的插入
    Node<K,V> e; K k;
    /*if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
      e = p;*/
    else if (p instanceof TreeNode)
      e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
    /*else {
      for (int binCount = 0; ; ++binCount) {
        if ((e = p.next) == null) {
          p.next = newNode(hash, key, value, null);
          if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            treeifyBin(tab, hash);
          break;
        }
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
          break;
        p = e;
      }
    }
    if (e != null) { // existing mapping for key
      V oldValue = e.value;
      if (!onlyIfAbsent || oldValue == null)
        e.value = value;
      afterNodeAccess(e);
      return oldValue;
    }*/
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    主要调用的是 ((TreeNode)p).putTreeVal(this, tab, hash, key, value);方法,由于篇幅的问题,有关红黑树的操作,我们下篇博客我们继续讲。

    3.总结

    具体的流程图如下:

    在这里插入图片描述

    4.写在最后

    这篇博客主要介绍了1.8JDK的HashMap的方法,大概的介绍了流程,有关红黑树的操作,下篇博客我们继续介绍。

  • 相关阅读:
    传统用户管理方案有哪些利弊?
    小型数据库系统开发作业
    南卡与孩视宝护眼台灯哪个好?全方位分析两款护眼台灯
    flink state原理,TTL,状态后端,数据倾斜一文全
    黑帽python第二版(Black Hat Python 2nd Edition)读书笔记 之 第二章 网络工程基础(2)创建一个TCP代理
    webpack的插件webpack-dev-server
    # get请求和post请求的区别
    音视频开发:音频编码原理+采集+编码实战
    如何在Python中处理日期和时间相关问题
    Vast+产品展厅 | Vastbase G100数据库是什么架构?(1)
  • 原文地址:https://blog.csdn.net/qq_36434742/article/details/126855362