当前位置: 首页 > news >正文

HashMap原理硬核分析,细到每一行代码!

Map接口

Map是一种双列集合的顶级接口,在Java中用于实现K,V的双列存储,子类实现常用的有: HashMapLinkedHashMap,不常用的有: TreeMap,等。


一、HashMap⚡️

HashMapMap接口的其中一个实现,在日常开发中运用的最为广泛,它是基于哈希表的一种实现,允许键值都为nullHashMap不保证元素插入的顺序,它会在插入时对key进行hash操作,以保证插入的元素在存储的数组中分布均匀。HashMap的默认大小(在进行第一次resize()后)为16,默认的负载因子为0.75,默认的扩容阈值为(16 * 0.75) = 12。当Node节点后连接的元素过多时,会将此Node进行树化操作,变更为红黑树结构进行存储,已优化性能。

1. 静态常量
  • DEFAULT_INITIAL_CAPACITY 默认初始化容量,必须是2的n次幂,否则进行&操作时会出现问题!

    • static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 值为 16
      

      1向左位移4位,得到的值为16。 相当于 1 * 2 * 2 * 2 * 2 左移几位就相当于乘几个2

  • MAXIMUM_CAPACITY 最大容量

    • static final int MAXIMUM_CAPACITY = 1 << 30; // 值为 1073741824
      

      如果通过构造器指定的最大容量大于此容量的话,则会使用次容量覆盖指定的初始化容量。

  • DEFAULT_LOAD_FACTOR 默认的负载因子

    • static final float DEFAULT_LOAD_FACTOR = 0.75f;
      

      负载因子,通俗的来讲:就是HashMap可以接受的最大扩容阈值是多少。

      计算公式为:当前 size * DEFAULT_LOAD_FACTOR

  • TREEIFY_THRESHOLD 树化阈值

    • static final int TREEIFY_THRESHOLD = 8;
      

      当数组中某个下边下存储的Node节点长度等于8的时候,会将这个Node进行树化,变更为红黑树,进行存储。

  • MIN_TREEIFY_CAPACITY 树化table大小阈值

    • static final int MIN_TREEIFY_CAPACITY = 64;
      

      Node长度满足TREEIFY_THRESHOLD(树化阈值) 并且 table长度满足,MIN_TREEIFY_CAPACITY(树化table大小阈值)时,才会将Node进行树化,也就是说判断一个Node是否需要树化,必须要同时满足这两个条件。


2. 内部属性
  • table 存储数据的数组

    • transient Node<K,V>[] table;
      

      用于存储数据用的数组,transient修饰,表示不需要被序列化。

  • entrySet 用于缓存所有的元素

    • transient Set<Map.Entry<K,V>> entrySet;
      

      用于缓存添加到HashMap中的所有元素,当调用entrySet()方法时,将会返回内部存储的Entey引用。

  • size 当前Map长度

    • transient int size;
      

      集合长度

  • modCount 用于记录被修改的次数

    • transient int modCount;
      
  • threshold 扩容阈值

    • int threshold;
      

      table中存储的元素数量 > 扩容阈值的时候,将触发扩容。


3. 构造方法

HashMap共有4个构造方法,分别为:

  • 无参构造

    • public HashMap() {
        	// 将负载因子初始化为默认的 0.75
          this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
      }
      
  • 指定容量

    • public HashMap(int initialCapacity) {
          this(initialCapacity, DEFAULT_LOAD_FACTOR);
      }
      

      构造一个带有指定初始容量和默认负载因子(0.75)的空HashMap。

  • 指定容量 和 负载因子

    • public HashMap(int initialCapacity, float loadFactor) {
        			// 如果容量小于0 抛出参数不合法异常
              if (initialCapacity < 0)
                  throw new IllegalArgumentException("Illegal initial capacity: " +
                                                     initialCapacity);
        			// 如果容量大于最大容量 抛出参数不合法异常
              if (initialCapacity > MAXIMUM_CAPACITY)
                  initialCapacity = MAXIMUM_CAPACITY;
        			// 如果负载因子小于=0 或者是个无效的值 抛出参数不合法异常
              if (loadFactor <= 0 || Float.isNaN(loadFactor))
                  throw new IllegalArgumentException("Illegal load factor: " +
                                                     loadFactor);
        			// 初始化数据
              this.loadFactor = loadFactor;
              this.threshold = tableSizeFor(initialCapacity);
      }
      

      构造一个具有指定初始容量和加载因子的空HashMap。

  • 指定Map实现

    • public HashMap(Map<? extends K, ? extends V> m) {
        			// 初始化默认的负载因子
              this.loadFactor = DEFAULT_LOAD_FACTOR;
        			// 将指定的Map实现中的元素赋值给自身
              putMapEntries(m, false);
      }
      

      构造一个新的HashMap,具有与指定Map相同的映射。HashMap使用默认负载因子(0.75)和足够容纳指定Map中的映射的初始容量创建。


4. put()流程

简介

HashMap 添加一个元素的流程极其的复杂,首先会获取keyhash值,当keynull的时候,会返回0,否则的话,会调用keyhashCode()方法获取该对象的hashCode,然后在将得到的hashCode的高16为右移到低16位,用于减少hash碰撞,使得到的hash可以更均匀的分布,在将hashCode和右移16位的值进行异或操作,从而得到最终的值。得到hash值以后才是真正的put操作。

首先会判断当前的table是否为空,如果为空则代表是第一次进入,会直接调用resize()方法进行扩容操作,扩容完成后直接创建一个新的Node节点,放在指定的位置下,如果table不为空,则会先计算该hash所对应的数组槽位,判断传入的hash值是否与table指定位置下的头结点是否相等,如果相等,直接跳过。如果与头部节点不相等,则会判断这个Node是否为,红黑树结构,如果是的话则会进行红黑树结构的操作,如果不是的话,则会去改Node中进行循环比对,如果找到的hash一致的Node,则会跳过,如果没找到则会在该Node的尾部插入一个新的Node,并判断长度是否满足树化阈值,满足的话则会进行树化操作,不满足则会再次判断table长度,是否达到扩容阈值,达到的话就进行扩容,否则就返回null,代表put()元素成功!

源码

首先添加一个元素需要调用put()方法,在put()内部会调用putVal()方法

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

在执行之前需要计算一下传入key所对应的位置信息。也就是hash()方法。

hash()方法

/**
  * 为什么不直接返回hashCode,而是先 h >>> 16 在 ^ 异或操作呢?
  * 原因是因为,因为hashCode是int类型,范围有42亿左右,也不可能在数组初始化的时候全都放在内存当中,所以不能直接返回hashCode,直接返回的话hashCode很可能会超过数组的长度,造成下标越界
  * 此方法实际为一个扰动函数,目的就是为了将hashCode处理的更加松散,减少hash碰撞的概率
  * 流程如下:
  * 1. 将hashCode方法返回的10进制数字转换为32位2进制数,位数不足则会进行补码操作,即将空余的位补码为0
  * 2. 将32位2进制的hashCode的高16位无符号右移16位,并当做异或运算的参数。
  * 3. 将低16位与原值(hashCode原值)进行异或运算(相同位置的位,不同为1 相同为0)。
  * 3.1 本来位移操作并不会保留低16位的信息,但是在此处进行异或操作就相当于是把自身的高16位与低16位进行运算,也是变相的保留了低16位的信息,可以是hash更加的散列
  * 4. 得到异或的结果后,再将其转换为10进制返回,也就是这个元素在table中存储的下标
*/
static final int hash(Object key) {
  int h;
  return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

putVal()方法,真正执行put元素的方法。

/**
     * @param hash         hash方法计算得出的hash信息
     * @param key          要添加元素的key
     * @param value        要添加元素的value
     * @param onlyIfAbsent if true, don't change existing value
     * @param evict        if false, the table is in creation mode.
     * @return previous value, or null if none:上一个值,如果没有,则为空值
     */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
  // 初始化辅助变量 tab、p、n、i
  Node<K, V>[] tab;
  Node<K, V> p;
  int n, i;
  // 判断table是否为空,如果为空的话则代表是第一次进入的,首先初始化一下存放数据的数组
  if ((tab = table) == null || (n = tab.length) == 0) {
    // 初始化数组为16
    n = (tab = resize()).length;
  }
  // 因为table的长度大部分时候都是小于2^16即65536,所以也只会用hash的低16位进行运算(甚至再大部分时候,根本用不到hash的低16位,而是用的更低位进行运算)
  // 那高16位不就用不到了吗,但是要想得到的结果更加的随机,那么就只能将hash自身的高16位与低16位进行异或运算,使其得到的hash更加的随机(妙哇~),这也刚好对应hash(Object key)方法
  // 从上方的默认初始化变量可以得知,数组的长度必须是2的n次幂,之所以是这样是因为,当某个数是2的n次幂时,&位与运算 等于 %取模运算,在计算机里 &要比%性能高,所以必须要保证数组长度是2的n次幂
  // (n - 1) & hash 的意思就是,n-1 是因为数组下标是0开始的,防止越界所以要-1,&运算相当于%运算(参考上方解释),就是将hashCode平均分成数组长度-1份,然后取出余数以此确认改hashCode所对应的下标
  // 如果得出的下标内,没有值的话
  if ((p = tab[i = (n - 1) & hash]) == null) {
    // 就直接给对应的下标创建一个新的Node节点
    tab[i] = newNode(hash, key, value, null);
  }
  // 对应的下标不为空,里边有值
  else {
    Node<K, V> e; // 辅助变量
    K k; //辅助变量
    // 如果头部节点的hash值等于传入数据的hash值,并且key和传入的key一致
    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 {
      // 就搞一个死循环,binCount用于记录当前循环到Node下的第几个了
      for (int binCount = 0; ; ++binCount) {
        // 如果 p.next == null 头部节点的下一个节点指针是个空的话,就说明当前下标下的链表内只有一个头部节点
        if ((e = p.next) == null) {
          // 就直接创建一个新的元素,挂到头部节点的屁股后边
          p.next = newNode(hash, key, value, null);
          // 如果当前循环的指针大于了树化阈值 8
          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,为下一个节点 对应((e=p.next)) 这行代码,即赋值又判断(秒哇~)
        p = e;
      }
    }
    // 如果传入的元素在当前下标中的链表中存在的话,救记录一下值是啥,然后给你返回出去
    if (e != null) { // existing mapping for key
      V oldValue = e.value;
      // 如果这个参数为true的话,就改一下值
      if (!onlyIfAbsent || oldValue == null) {
        e.value = value;
      }
      // LinkedHashMap 使用到的回调函数,处理自己的逻辑,默认是个空实现,啥都不干。
      afterNodeAccess(e);
      // 把旧值返回出去,也就代表添加失败!因为已经存在了。
      return oldValue;
    }
  }
  // 修改操作次数
  ++modCount;
  // 判断是否需要扩容 数组长度是否超过了扩容阈值 threshold = table.size * 0.75
  if (++size > threshold) {
    // 扩容
    resize();
  }
  // 完成添加后续回调,空实现,LinkedHashMap用到了。
  afterNodeInsertion(evict);
  // 返回空代表添加成功
  return null;
}

resize()方法,这个方法是HashMap的精髓,复杂的扩容操作都在里边。

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) {
            // 如果超过的HashMap定义的最大容量
            if (oldCap >= MAXIMUM_CAPACITY) {
                // 将扩容阈值设置为int最大值
                threshold = Integer.MAX_VALUE;
                // 不扩容了,再扩就要出问题了,内存oom了
                return oldTab;
            }
            // 如果没有超过最大值,并且老容量大于初始容量,就扩容至原来的两倍 左移一位就是×2
            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;
        // 因为resize()方法在很多地方都有调用,所以要判断一下,如果不是第一次进来,老数组是有数据的,就将数据移动到新数组内
        if (oldTab != null) {
            // 循环老数组内所有的元素
            for (int j = 0; j < oldCap; ++j) {
                // 当前循环到的元素(辅助变量)
                Node<K, V> e;
                // 赋值,并判断当前下标元素是否不为空,如果为空则不进行任何操作
                if ((e = oldTab[j]) != null) {
                    // 将原有的数据清掉,可能是为了让gc回收
                    oldTab[j] = null;
                    // 如果当前位置只有1个头结点元素
                    if (e.next == null) {
                        // 就直接移动到新数组内,并重新计算位置
                        // todo: 原有位置 * 2 -1
                        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;
                        // 下一个元素的指针 用于do-while 判断
                        Node<K, V> next;
                        // 其实这个do-while就是一致在操作 e这个指针,通过不断更新指针的指向,从而将不同的数据挂载到 loXXXX 或 hiXXXX 中去
                        // 这个地方在一直循环变更e的指向,并且不断的赋值给辅助变量,所以理解起来很困难。
                        // tips:这地方有一个问题,如果链表内第一位的数据在条件:if ((e.hash & oldCap) == 0) 中成立,且整个循环流程中仅仅成立一次,
                        // 那么这时loHead将会保存完整的链表(因为此时e的指向是自身第一位元素,所以包含所有next节点),并且在这个链表loHead中肯定是包含hiHead中所有的元素的,那么它最终为什么不会重复呢?
                        //      原因是在下边执行了:loTail.next = null; 将尾巴后的元素全都清空掉了,所以不会重复

                        do {
                            // 更新下次循环的指针
                            next = e.next;
                            // 拿当前节点的hash值的高位去进行 & 操作,如果得到的值是0
                            // 就代表要把当前元素放置到低位中去
                            if ((e.hash & oldCap) == 0) {
                                // 判断是否是第一次进入,低位尾巴等于空,说明是第一次进入
                                if (loTail == null) {
                                    // 将第一个元素赋值给低位头部
                                    // 此时,这个元素是个完整的链表
                                    loHead = e;
                                }
                                // 不是第一次进入,将此节点挂载到低位的后边
                                else {
                                    // 因为 loHead等于e的指针 loTail也等于e的指针,所以操作这两个对象的其中一个,就相当于在同时操作这两个对象
                                    loTail.next = e;
                                }
                                // 低位
                                loTail = e;
                            } else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        // 赋值loHead至新数组下标
                        if (loTail != null) {
                            // 去重,打断原有链表的引用关系
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        // 赋值hiHead至新数组下标
                        if (hiTail != null) {
                            // 去重,打断原有链表的引用关系
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        // 返回新数组
        return newTab;
    }

至此,HashMap添加的全流程就结束了。(红黑树的操作暂时不讲解…)

原文地址: 传送门

5. get()流程

get流程与remove流程类似,都是先计算下标,然后找到对应的元素后返回,查找元素方法有:头部对比、循环对比、红黑树查找。

get()方法

public V get(Object key) {
  Node<K, V> e;
  // 寻找Node  如果找到了就返回这个Node的value,否则就是null
  return (e = getNode(hash(key), key)) == null ? null : e.value;
}

getNode()方法

final Node<K, V> getNode(int hash, Object key) {
        // 辅助变量
        Node<K, V>[] tab;
        Node<K, V> first, e;
        int n;
        K k;
        // table是否为空  hash对应的下标是否有值
        if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
            // 如果头部对上了,直接返回头就好
            if (first.hash == hash && // always check first node
                    ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            // 如果头部没对上,就判断是否存在next节点,存在就循环
            if ((e = first.next) != null) {
                // 红黑树方式查找
                if (first instanceof TreeNode)
                    return ((TreeNode<K, V>) first).getTreeNode(hash, key);
                // 循环子级
                do {
                    // 找到了
                    if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                        // 返回
                        return e;
                    // 修改循环指针
                } while ((e = e.next) != null);
            }
        }
        // 没找到
        return null;
    }

6. remove()流程

移除一个元素相对简单,就是先计算一下元素所在的位置,然后只针对这个位置下的链表进行:头部比对、循环比对、红黑树方式比对,然后删除这个元素。

源码如下:

remove()方法

public V remove(Object key) {
  Node<K, V> e;
  // 判断removeNode是否存在返回值,存在就是成功,不存在就是没找到这个key
  return (e = removeNode(hash(key), key, null, false, true)) == null ?
    null : e.value;
}

removeNode()方法

final Node<K, V> removeNode(int hash, Object key, Object value,
                            boolean matchValue, boolean movable) {
  // 辅助变量
  Node<K, V>[] tab;
  Node<K, V> p;
  int n, index;

  //   table不为null && lengtn>0 并且 (计算并赋值p为hash对应元素的下标) 不为null
  if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) {
    Node<K, V> node = null, e;
    K k;
    V v;
    // 如果头部的元素等于传入的元素,就不需要循环找了,找到了,直接赋值给node,等待删除
    if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) {
      node = p;
    }
    // 循环比对table对应下标下链表的所有元素
    else if ((e = p.next) != null) {
      // 如果是红黑树节点
      if (p instanceof TreeNode) {
        // 从红黑树节点内寻找
        node = ((TreeNode<K, V>) p).getTreeNode(hash, key);
      }
      // 普通节点
      else {
        // 循环普通节点
        do {
          // 找到了
          if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) {
            // node等于当前元素
            node = e;
            break;
          }
          // p等于当前循环的上一个元素
          p = e;
          // 修改e的指针,指向链表中的下一个,如果没有了就结束循环
        } while ((e = e.next) != null);
      }
    }


    // 如果找到了对应的元素
    if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) {
      // 红黑树方式删除
      if (node instanceof TreeNode) {
        ((TreeNode<K, V>) node).removeTreeNode(this, tab, movable);
      }
      // 如果在头部节点找到了,就把头删掉,数组直接指向下一个元素就好
      else if (node == p) {
        tab[index] = node.next;
      }
      // 将node的上一个节点和node的下一个节点绑定,node就是要删除的元素,绑定完成之后就相当于node被删掉了
      else {
        p.next = node.next;
      }
      // 修改操作数和总数
      ++modCount;
      --size;
      // 删除完了的回调
      afterNodeRemoval(node);
      return node;
    }
  }
  return null;
}

二、面试题

HashMap简介

HashMap是Map接口下的一个实现类,它是线程不安全的,在日常开发中使用非常频繁,它是以keyvalue形式存储数据,具体使用数组+链表进行存储,在特殊情况下会转换为红黑树优化性能,其中存储时,key可以为null。

HashMap添加流程

HashMap添加一个元素可以总结为以下几个步骤:计算存储位置,扩容,添加元素,返回是否成功。

首先说下计算存储位置:计算的流程是:先判断传入的key是否为null,如果为null的话,则该元素永远在下标0的位置存放,如果不为null则获取该key的hashCode,保存到变量h,在将h右移16位,与原先的hashCode进行异或操作,就是相当于将自身的高16位与低16位进行运算,本身右移是不保留高位的,而这个地方又和自身进行异或,就是相当于变相的保留了高位的信息,可以使hash更加的散列。

计算完hash后,会进行(n-1) & hash的操作,其实就相当于hash值模取数组的length,得到的值永远不可能大于数组长度,之所以可以这么干是因为hashMap的长度永远都是2的n次幂,所以(n-1)&hash这个操作就永远满足公式:值等于hash模取数组长度取余。这也是为什么规定hashMap长度永远都是2的n次幂的原因。

第二步就是扩容操作,扩容只发生在第一次进入HashMap、或容量小于扩容阈值时,扩容会先记录一下老数组的内容,和计算新数组的长度,扩容阈值等参数,将其容量扩大两倍,此时在创建一个新的数组,长度就是刚才计算的值,在将原数组中的所有参数存放在新数组中去,最后返回扩容完成的新数组。

第三步就是添加元素操作,如果该下标是个null,就创建一个新的Node节点,把值放进去就完活了。如果不是null,则会判断这个key是否存在,存在就跳过,不存在则判断是否是红黑树节点,是的话进行红黑树节点添加数据的操作。如果不是红黑树节点,就进行普通节点的添加操作,其实就是循环一下这个Node下的所有节点,看看有没有一样的,有一样的就跳过,不一样就在尾巴上添加新的节点,如果循环的次数小于树化阈值,就直接添加,否则就将这个节点尝试变更为红黑树的方式进行存储,变更时会判断数组长度是否大于64,如果是小于64的则进行扩容操作,下次再添加的时候再次尝试树化,如果满足树化阈值,就将这个Node转换为红黑树放方式进行存储。

添加完成之后,再次判断是否需要扩容,最终返回结果,null即代表成功!


CSDN不支持音频标签,请自行下载播放:地址
音频版

插入逻辑图片版

img

扩容逻辑图片版

图片

相关文章:

  • STK12与Python联合仿真(三):分析星座覆盖性能
  • 基于OpenHarmony/HarmonyOS操作系统的ArkUI框架深入学习——开篇1
  • WebSocket| Netty netty-websocket-spring-boot-starter
  • C语言for循环结构经典练习
  • 【老生谈算法】matlab实现元胞自动机算法源码——元胞自动机
  • wireshark协议或者协议内容解码异常
  • nginx部署vue项目(包括一个nginx部署多个vue项目)
  • 【py】[打包exe]用auto-py-to-exe将py程序打包为exe文件
  • 【数据库迁移系列】使用pgloader将数据从MySQL迁移到openGauss的最佳实践
  • 广和通携智慧金融解决方案惊艳亮相紫光展锐2022金融支付生态论坛
  • 安装Java环境
  • vue(js)拖拽事件的drop失效
  • RFID技术,让书架智能化
  • Android 导出PDF PdfDocument
  • 《kaggle竞赛攻顶秘笈》 | 任务种类 | 任务评价指标 | 评价指标与目标函数 | 评价指标最佳化
  • JavaScript DOM 10 - 滚动
  • javascript 哈希表
  • React Transition Group -- Transition 组件
  • Redux 中间件分析
  • Spring思维导图,让Spring不再难懂(mvc篇)
  • SQLServer之索引简介
  • 对话:中国为什么有前途/ 写给中国的经济学
  • 对象引论
  • 分享一份非常强势的Android面试题
  • 给自己的博客网站加上酷炫的初音未来音乐游戏?
  • 解决jsp引用其他项目时出现的 cannot be resolved to a type错误
  • 每天10道Java面试题,跟我走,offer有!
  • 数组的操作
  • 吴恩达Deep Learning课程练习题参考答案——R语言版
  • 原生 js 实现移动端 Touch 滑动反弹
  • ​第20课 在Android Native开发中加入新的C++类
  • #NOIP 2014#day.2 T1 无限网络发射器选址
  • #我与Java虚拟机的故事#连载03:面试过的百度,滴滴,快手都问了这些问题
  • %check_box% in rails :coditions={:has_many , :through}
  • (4)Elastix图像配准:3D图像
  • (Bean工厂的后处理器入门)学习Spring的第七天
  • (二十五)admin-boot项目之集成消息队列Rabbitmq
  • (附源码)springboot家庭财务分析系统 毕业设计641323
  • (附源码)ssm码农论坛 毕业设计 231126
  • (强烈推荐)移动端音视频从零到上手(上)
  • (学习日记)2024.01.09
  • (原創) 如何使用ISO C++讀寫BMP圖檔? (C/C++) (Image Processing)
  • (转)平衡树
  • (转)淘淘商城系列——使用Spring来管理Redis单机版和集群版
  • ../depcomp: line 571: exec: g++: not found
  • .form文件_一篇文章学会文件上传
  • .Net 中的反射(动态创建类型实例) - Part.4(转自http://www.tracefact.net/CLR-and-Framework/Reflection-Part4.aspx)...
  • .NET/C# 如何获取当前进程的 CPU 和内存占用?如何获取全局 CPU 和内存占用?
  • .NET多线程执行函数
  • .NET和.COM和.CN域名区别
  • .Net下的签名与混淆
  • .NET中GET与SET的用法
  • .NET中的Exception处理(C#)
  • ??eclipse的安装配置问题!??
  • [C#]winform使用引导APSF和梯度自适应卷积增强夜间雾图像的可见性算法实现夜间雾霾图像的可见度增强