拉克丝小声的在嘀咕: 天苍苍海茫茫,面试我很忙...

" 今天看你早早地准备去面试,又这么沮丧的回来,面试的不好? "

拉克丝十分生气地说:" 我学了这么多的知识,为啥面试以前的问题还问,现在都没人用了,他问我你对 JDK1.7版本的HashMap有什么了解,现在不是都用 1.8了,他居然还问我这个问题(深深叹了一口气)。

"来我给你讲一下JDK 1.7HashMap 去给我买杯 奈雪的茶"

" 你怎么还不去买呀"

"你给我掏钱呀?"

(我居然自己搬石头扎了自己脚),过了几分钟,拉克丝高兴地拿着奶茶回来了给我,我拿着手中的奶茶,奶茶再也不香了前言

  • 这篇文章是基于JDK1.7 来深入了解HashMap 的底层数组+链表
  • 你通过这篇文章可以学到 在1.8版本更新前 HashMap的工作原理,怎么填充数据 和获取数据已经1.7版本HashMap存在的几个问题。

正文

接下来我们用 思维导图来分析一下HashMap

假如,我们现在已经把刘备放进了这个数组,这是我们再放进去一个 变量如下

  • 例如,我们又有一个问题,如果我们再添加一个元素.索引和上面一样都是相同的,那我们是添加上边呢还是下边呢?

  1. 这里为啥 刘备2会放在头部呢,
  2. 因为这样是基于链表的效率来说,放在链表的头部是最快的,那为啥不是放在 刘备1 的下面呢?
  3. 因为 如果你放在尾部的话 你需要遍历整个链表的尾节点是谁,所以是放在链表头部是最快的

但是这样也出现了一个问题

  • 我们现在是模拟put 添加数据,但是如果我们 想要获取 get("刘备2")来获取它的value,他返回的也是 数组刘备的下标,所以我们就无法获取到刘备2的value值,这个问题我们已经怎么解决呢?
  • 解决思路: 当我们在刘备的 头部添加刘备2 的同时应该在做一步操作就是向下移动一位如下

  1. 这样链表的所有数据我们都可以通过 get 来获取到了

"你说的好复杂呀,怎么思维导图都这么复杂呢"

"其实也不是很复杂,来接下来带你看看源码,你就知道了"

源码

1),首先我们先来看一下它的属性

//默认数组的容量是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16//默认数组最大的限制
static final int MAXIMUM_CAPACITY = 1 << 30;//默认的加载因子是 3/4=0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;//空的数组
static final Entry<?,?>[] EMPTY_TABLE = {};//table 存的就是Entry 放的就是 k ,v
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;//每次put 添加数据的时候 +1  size()方法返回的就是size
transient int size;//扩容界限 默认是 16*0.75=12
int threshold;final float loadFactor;//这个也很重要,这个与 ConcurrentModificationException 异常有关
transient int modCount;static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;

2),接下来我们看下他的构造方法

    public HashMap(int initialCapacity, float loadFactor) {if (initialCapacity < 0)throw new IllegalArgumentException("Illegal initial capacity: " +initialCapacity);if (initialCapacity > MAXIMUM_CAPACITY)initialCapacity = MAXIMUM_CAPACITY;if (loadFactor <= 0 || Float.isNaN(loadFactor))throw new IllegalArgumentException("Illegal load factor: " +loadFactor);this.loadFactor = loadFactor;//为什么把容值赋值给一个域值( threshold ) 呢?threshold = initialCapacity;//init() 方法是空的,实际上是在 LinkedHashMap上用,这里我主要说hashmap 这里不在继续深入了init();}public HashMap(int initialCapacity) {this(initialCapacity, DEFAULT_LOAD_FACTOR);}public HashMap() {this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);}

3),接下来我们来分析一下 put() 方法

    public V put(K key, V value) {//判断当前数组是否已经初始化,类似于懒加载/延迟加载,当你put存储元素的时候,才进行初始化if (table == EMPTY_TABLE) {inflateTable(threshold);}if (key == null)return putForNullKey(value);int hash = hash(key);int i = indexFor(hash, table.length);for (Entry<K,V> e = table[i]; e != null; e = e.next) {Object k;if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {V oldValue = e.value;e.value = value;e.recordAccess(this);return oldValue;}}modCount++;addEntry(hash, key, value, i);return null;}

我们具体来看下inflateTable是怎么进行初始化的。

    inflateTable(threshold);方法为private void inflateTable(int toSize) {//翻译为: 找到一个 toSize的2的幂次函数// Find a power of 2 >= toSizeint capacity = roundUpToPowerOf2(toSize);threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//创建了一个 Entry对象长度为 capacty  table = new Entry[capacity];initHashSeedAsNeeded(capacity);}注意:1),不知道大家发现没有 inflateTable()传入的参数 toSize 就是 threshold 2),threshold 就是我们再构造器自己指定数组容量 ,如果没有指定那就是默认的容量16问题:1),为什么不是直接使用 toSize 来作为新数组的长度?2),为什么要根据 toSize 来获取它的2的幂次函数来作为新数组的长度呢?

我们来看下 roundUpToPowerOf2(int toSize) 的源码 ,我们假设在创建数组的时候,指定容量为10,toSize = threshold =10;

    private static int roundUpToPowerOf2(int number) {// assert number >= 0 : "number must be non-negative";return number >= MAXIMUM_CAPACITY? MAXIMUM_CAPACITY: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;} 注意:1),number >= MAXIMUM_CAPACITY 10<最大容量,索引会直接走三目表达式的的false2), Integer.highestOneBit((number - 1) << 1) 这个方法是roundUpToPowerOf2()方法的关键所在

那 Integer.highestOneBit() 这个方法是干什么的呢?

    public static void main(String[] args) {int i = Integer.highestOneBit(15);int i1 = Integer.highestOneBit(16);int i2 = Integer.highestOneBit(17);System.out.println(i);System.out.println(i1);System.out.println(i2);}//运行结果为81616从结果可以看出:这个方法会根据你传入的数,得到一个小于等于这个数的2幂次方函数,例如你传入的是10,那么你得到的就是8

拉克丝一脸疑惑地问: 你刚才不是说 roundUpToPowerOf2() 方法返回的是大于等于2的幂次方函数? 然而在这个方法内部实现却是 Integer.highestOneBit() 方法返回却是 小于等于 2的幂次方函数,这样不就发生冲突了?

"这个问题都被你发现了,那为什么会这样呢?让我来给你讲一下 Integer.highestOneBit() 方法是如何实现的你就知道了"

    public static int highestOneBit(int i) {// HD, Figure 3-1i |= (i >>  1);i |= (i >>  2);i |= (i >>  4);i |= (i >>  8);i |= (i >> 16);return i - (i >>> 1);}例如我们现在传入的i=10 扩展:8421
8421码: 例如 10的8421码,二进制表示为 0000 1010  , 这里结果为 8+2=10,如果高位有1,每次都是2倍递增。|(或运算): 只要有一个为1,结果就为1。例如 0|0=0; 0|1=1;1|0=1;1|1=1;方法执行1 =10如下步骤i |= (i >>  1); 运行如下 >> 右移是从左边添加一个0
第一步右移一位
10的二进制   0000 1010
右移一位     0000 0101
|(或运算)
结果为       0000 1111第二步 右移俩位
拿到第一步结果 0000 1111
右移二位为    0000 0011
|(或运算)
结果为       0000 1111其实后面的都不用直接运算了,因为右移都是向高位补0,而低位一直都是1,所以结果一直都不会变了这时,我们来看最后一句return 语句i - (i >>> 1)i值为     0000 1111
i >>> 1  0000 0111
-
结果为    0000 1000 即 8因为我们传入的是10 所以它的最小2的幂次函数就是8 ,这不正是我们需要的结果呢

"你这里是特殊情况吧,比如我要传个很大的值,他的二进制不是应该很长,你这种情况不适用呢?拉克丝问道

"看来你还是不是太懂呢,那我在给你举个栗子你就懂了"

现在我就不举一个具体数字的例子来给你算一下了,我就直接用 * 来表示这是一个很长的数字第一步左移1位
传入的值   0001 ****
右移一位后 0000 1***  (注意: 因为 0|1=1;1|0=1;1|1=1,所以取| 结果一定为1)
|(或运算)
运算结果   0001 1***第二步右移2位
1结果值    0001 1***
右移2位后  0000 011*
|(或运算)
运算结果为 0001 111*第二步右移4位
2结果值为  0001 111*
右移4位    0000 0001
|(或运算)
运算结果为 0001 1111发现:
这里你有没有发现 这个算法就是在慢慢地,慢慢地把你的这些低位转化为1,那你为什么还要有右移8位,右移16位呢?
:因为我们是一个int类型,int类型占4个字节,每个字节8位 总共有32位。

拉克丝感叹地说"啊,JDK的作者已经对运算已经到了出神入化的地步了,我想都想不到"

这时我们回到 初始化table的方法中 Integer.highestOneBit((number - 1) << 1)

  • 其实要返回大于等于2的幂次方函数 , 但方法中使用了 找到小于等于2的幂次方函数,其实是  (number - 1) << 1 这段代码起了作用,使其不冲突。
Integer.highestOneBit((number - 1) << 1)例如 我们传入的是10 这时我们要得到一个2的幂次方数,值为16,按照这样的话 我们肯定要把10变大,取值范围为 16< 值 <32这时我们不关心它-1,我们直接 把10左移一位10         0000 1010
左移一位为  0001 0100 即20例如 我们的后几位全是 0001 1111 后面全是1 结果是 31 也没有超出我们的范围这里左移一位,直接判断他的值取值范围在 16 < 20 < 32,正是我们要的 那它为什么要 -1 呢?
: 这里是一种特殊的情况,例如 我们传入的是8,本来就是2的幂次方函数,执行这个方法返回的应该是8,如果不减1会出现下面这种情况8的二进制 0000 1000
左移一位  0001 0000 结果为 16 与我们想要得到的结果不一致,所以要进行-1操作8-1=7 的二进制位 0000 0111
左移一位 为      0000 1110 结果为12 找到最低的2的幂次函数 是8与我们想要的结果一致

那初始化数组完成后,我们现在继续看这个方法是如何计算索引的

put() 方法内部源码int hash = hash(key);int i = indexFor(hash, table.length);//这是根据key计算出它的hashCodefinal int hash(Object k) {int h = hashSeed;if (0 != h && k instanceof String) {return sun.misc.Hashing.stringHash32((String) k);}h ^= k.hashCode();h ^= (h >>> 20) ^ (h >>> 12);return h ^ (h >>> 7) ^ (h >>> 4);}//通过拿到的hashcode 和数组长度 算出数组下标static int indexFor(int h, int length) {return h & (length-1);}扩展
按位与& :两位全为1,结果才为1,例如 0&0=0;0&1=0;1&0=0;1&1=1
异或 ^ : 两个相应位为“异”(值不同),则该位结果为1,否则为0,例如0^0=0; 0^1=1; 1^0=1; 1^1=0;疑问: 根据上面的思维导图是 hashcode的值 % 数组的长度来进行计算的,那么这里的这样一句代码是什么意思呢?例如我们调用 indexFor()这个方法,随便传入一个hashcode 数组长度为16 进行如下计算16-1 =15 的二进制位 : 0000 1111
随便写一个二进制 :    1010 1010
进行&操作
结果为               0000  1010 即10 从这个可以反映出,高位都是0,底位和这个hashcode的底层都是一样的,那么,我这个结果的取值范围就是你这个低四位的取值范围,而我这个hashcode本身就是随机的,是在 0000 -1111本身就是0-15之间的,正好符合我们的约束。其实这是有一个规律的:
:就是它的高位都是0,低位都是1,如果说我们直接拿16来进行运算,结果将会怎么样呢?16的二进制位    0001 0000
随机hashcode   1010 1010
进行&操作
结果为         0000 0000 即 0注意: 即得到了结果为0,不符合我们的要求这里我们反推一下,indexFor() 方法的length 一定要是2的幂次函数,才能和 h & (length-1) 进行配套使用,所以这里也解决了我们刚开始那个疑问,为什么要在创建数组长度的时候为什么一定要找一个大于等于2的幂次方函数,而不是直接使用我们指定的长度。最后我们再来看一个问题15的二进制: 0000 1111
Hash二进制: 0100 1010 (这里不管我们怎么改变hashcode 的高位,例如 1111 1010 ,1010 1010 等等)
进行&操作
结果为      0000 1010 即 10得出结论: 不论我们如何改变这个hashcode的高位 ,最终都不会影响它的结果hash() 计算出它的hashcode方法如下h ^= k.hashCode();h ^= (h >>> 20) ^ (h >>> 12);return h ^ (h >>> 7) ^ (h >>> 4);例如 一个hashcode 0100 1010 向右移动4位 0000 0100进行^操作结果为      0100 1110经过^运算,右移运算之后,^运算后的hashcode和原先 k.hashCode() 的方法所返回来的hashcode 值,高位也参加到这个运算中,这也就解答了我们第二个问题,为什么算出来的hashcode 直接拿出来之后为啥要进行右移运算(散列性)。因为拿到它的hashcode 是根据Object.hashCode()来拿到的,因为他考虑到你可能重写它的hashCode()方法,那么很有可能,你的技术水平不行的话,你重写出来的hashCode()方法,是有问题的,从而导致调用你重写的hashCode()返回的值,非常不均匀,那么它通过右移这些方法,可以去容错,这一点在jdk1.8会有一些优化。

"好了,到这里讲的听懂了?"

"有那么一点点感觉,我已经拿我的小本本给记下了,我这边再问你一个问题,那为HashMap什么要使用链表呢?"

"因为不管你是通过hashcode()算法 还是 indexFor() 方法来最终算出它的数组下标都有可能是重复的,这时就发生了冲突,这被称为Hash冲突,而解决Hash冲突只有俩个办法 1),就是我们这里说的链表法  2),就是再散列法,如果算出来的索引和旧的索引发生冲突,那么我就在次计算一个新的索引,直到没有发生重复为止。"

当我们的key为null的时候,put()方法是如何做的呢?

      if (key == null)return putForNullKey(value);private V putForNullKey(V value) {for (Entry<K,V> e = table[0]; e != null; e = e.next) {if (e.key == null) {V oldValue = e.value;e.value = value;e.recordAccess(this);return oldValue;}}modCount++;addEntry(0, null, value, 0);return null;}
  • 可以看出 当hashmap 处理 key == null 所有的元素,都会存储在数组的第一个元素, 在for循环中增加了 e.key == null的判断我们可以知道 数组只能存储一个 key 为null 的键值对

"我接下来讲的也很重要,来我们再看下put方法是如何解决下面这个问题的"

    public static void main(String[] args) {HashMap<String, String> map = new HashMap<String, String>();map.put("1", "1");String value = map.put("1", "2");System.out.println(value);System.out.println(map.get("1"));}运行结果为:
1
2
  • 我们继续看 put 源码如下
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {Object k;if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {V oldValue = e.value;e.value = value;e.recordAccess(this);return oldValue;}}
  • HashMap中只能存一个 相同的key
  • 通过运行结果我们可以发现,当我们在数组中在插入一个 字符串1 的时候返回的 value结果为 1 然后我们通过 get来获取1 则返回的是 2 ,我们可以看出来 2 覆盖了之前的1 ,并且当第二次put添加key为1的时候返回的是第一个 结果1
  • 当我们把相同的key放进数组的时候,他会循环整个数组,判断数组 hash值 key的地址值 字符串是否相同,如果相同 则返回 旧的value,然后把旧的value改为新的value。

我们接下来在put()方法往下面看具体代码

    //把k,v放进数组中去addEntry(hash, key, value, i);void addEntry(int hash, K key, V value, int bucketIndex) {//判断是否扩容if ((size >= threshold) && (null != table[bucketIndex])) {resize(2 * table.length);hash = (null != key) ? hash(key) : 0;bucketIndex = indexFor(hash, table.length);}createEntry(hash, key, value, bucketIndex);}//具体添加到HashMap 的方法void createEntry(int hash, K key, V value, int bucketIndex) {    // 获取链表节点的 Entry<K,V>        Entry<K,V> e = table[bucketIndex];//把新的节点插在了链表的头部table[bucketIndex] = new Entry<>(hash, key, value, e);//+1 表示数组个数size++;}
  • 当size >= capacity * load factor 的时候,有可能进行扩容的另一个条件是 null != table[bucketIndex] bucketIndex就是计算出来的那个索引

"那为什么hashMap需要进行扩容呢?"

  • 因为对于hashmap来说,数组是一个连续的空间,在初始化数组的时候,就已经固定好了数组的长度,我也不能像链表一样自动扩充它的长度,在对数组进行扩容的时候,必须新创建一个数组这才是数组的扩容
  • 主要原因是如果不进行数组扩容,当hashmap存储很多个数据的时候,因为数组的长度是不变的,所以一直数组上链表的长度会非常长,这样当hashmap 通过get来获取数据的时候,获取效率会非常低,所以我们要扩容数组的长度,使它能够保存更多的数据,这样链表就变短了,效率也增加了。
addEntry() 方法resize(2 * table.length);void resize(int newCapacity) {Entry[] oldTable = table;int oldCapacity = oldTable.length;if (oldCapacity == MAXIMUM_CAPACITY) {threshold = Integer.MAX_VALUE;return;}Entry[] newTable = new Entry[newCapacity];transfer(newTable, initHashSeedAsNeeded(newCapacity));table = newTable;threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);}void transfer(Entry[] newTable, boolean rehash) {int newCapacity = newTable.length;for (Entry<K,V> e : table) {while(null != e) {Entry<K,V> next = e.next;if (rehash) {e.hash = null == e.key ? 0 : hash(e.key);}int i = indexFor(e.hash, newCapacity);e.next = newTable[i];newTable[i] = e;e = next;}}}
  1. resize() 方法默认扩容长度是原数组的2倍
  2. transfer() 方法是把旧数组的数据 移动到新数组中,我们接下来主要看这个方法到底是怎么进行数据转移的。

  1. 其实hashmap 是按单线程来进行转移的,就算你数据掉头也是没有关系的。
  2. 我们现在在想一个问题,那么多线程情况下,就是俩个线程同时调用你这个 hashmap 对象,去调用你的 put() 方法,俩个线程都同时都会走到  resize() 方法,都会创建俩个新的数组,俩个线程会公用这一份旧的数据,如果又一个线程已经都执行完之后,第二个线程执行到 e.next() 就会被阻塞到。

那我们应该怎么在多线程情况下,避免hashmap的死循环链表呢?

  • :我们再使用HashMap的时候,尽量不让它进行扩容,如这个判断 size >= threshold ,这里假如我们这里有32个要存储的数据,这里我们通过配置 加载因子和 数组容量如 new HashMap<String, String>(32,1); 这里我们算出来的扩容 threshold也是32,这样的话就可以避免我们的扩容,这样也是一种解决情况。
  1. transfer() 数组转移 还有,一个判断我一直没有说如下
              if (rehash) {e.hash = null == e.key ? 0 : hash(e.key);}
  1. initHashSeedAsNeeded(newCapacity) 布尔类型的 rehash 主要是通过这个方法来生成的,如下
 //值默认为 0transient int hashSeed = 0;  //   capacity 值就是新数组容量的长度final boolean initHashSeedAsNeeded(int capacity) {//currentAltHashing 默认就是falseboolean currentAltHashing = hashSeed != 0;//sun.misc.VM.isBooted() 判断JVM是否启动 当然一般都是启动的呢 true//Holder.ALTERNATIVE_HASHING_THRESHOLD 值为 Integer.MAX_VALUEboolean useAltHashing = sun.misc.VM.isBooted() &&(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);//switching  因为 currentAltHashing 为false boolean switching = currentAltHashing ^ useAltHashing;if (switching) {hashSeed = useAltHashing? sun.misc.Hashing.randomHashSeed(this): 0;}return switching;}这里我们可以看出来
: switching  = false ^ useAltHashing 当useAltHashing  我们可以知道 只有当capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD 时useAltHashing   才为true 才能 运行if (rehash) {e.hash = null == e.key ? 0 : hash(e.key);}
但是我们有知道了 Holder.ALTERNATIVE_HASHING_THRESHOLD 是Integer 的最大值 ,所以我们怎么用都不会出现 为true的情况,所以 if(rehash) 方法里面的代码一直都不会执行

"可学到了,给你讲了这么多有点饿了,我去找点吃的,你先理解一下,故事还没有结束哦"

"HashMap 数组 , 链表" 拉克丝看起了笔记默默地念叨

教拉克丝去面试(二) 1.7版本HashMap源码你知道多少?相关推荐

  1. 教拉克丝去面试(一),String转换成int的几种方式

    这是一篇用趣味化的形式给大家来讲java面试, "语不惊人死不休",没错,本篇文章的标题就是这么酷炫,接受不了的同学就别点进来看了,所谓好奇心害死猫:能够接受的同学我只能说你赚到了 ...

  2. 面试常问问题: 剖析ArrayList源码还不会? 看这篇就够啦 !

    点击上方"java大数据修炼之道",选择"置顶公众号" 关键时刻,第一时间送达! 每晚9点,我们不见不散 每日英文 take control of your o ...

  3. 面试官:HashMap源码看过吧,讲一讲put方法的源码是怎样实现的???

    前言 点赞在看,养成习惯. 点赞收藏,人生辉煌. 点击关注[微信搜索公众号:编程背锅侠],第一时间获得最新文章. HashMap系列文章 第一篇 HashMap源码中的成员变量你还不懂? 来来来!!! ...

  4. hashmap containsvalue时间复杂度_不看看HashMap源码,怎么和面试官谈薪资

    HashMap 是日常开发中,用的最多的集合类之一,也是面试中经常被问到的 Java 类之一.同时,HashMap 在实现方式上面又有十分典型的范例.不管是从哪一方面来看,学习 HashMap 都可以 ...

  5. 面试官系统精讲Java源码及大厂真题 - 10 Map源码会问哪些面试题

    10 Map源码会问哪些面试题 更新时间:2019-09-10 10:34:08 人的一生可能燃烧也可能腐朽,我不能腐朽,我愿意燃烧起来! --奥斯特洛夫斯基 引导语 Map 在面试中,占据了很大一部 ...

  6. 面试官系统精讲Java源码及大厂真题 - 08 HashMap 源码解析

    08 HashMap 源码解析 自信和希望是青年的特权. --大仲马 引导语 HashMap 源码很长,面试的问题也非常多,但这些面试问题,基本都是从源码中衍生出来的,所以我们只需要弄清楚其底层实现原 ...

  7. paddle 标注_一看就会,手把手教你编程,批量文章标注拼音(附源码)

    文/IT可达鸭 图/IT可达鸭.网络 前言 是不是学了Python之后,苦于没有项目练手?是不是看了很多关于编程视频,等到自己动手时,却怎么也做不出一个项目? 工作在一线的老程序员告诉你,别慌,让我手 ...

  8. activiti学习(二十一)——流程虚拟机源码分析(三)——从进入到离开userTask

    前言 承接上文<activiti学习(二十)--流程虚拟机源码分析(二)--从开始节点离开到下个节点前>,假设execution接下来进入的节点是userTask,本文分析一下进入user ...

  9. 面试有没有看过spring源码_怎么阅读Spring源码?

    此问必是有心人,有心人必有心答. --题记 当我看到这个问题的时候,不禁心里一问,为何要阅读spring源码? 在我们的生活之中,有形形色色的万物(Object),有飞机,有汽车,有轮船,还有我这个沧 ...

最新文章

  1. eclipse 环境下 FreeMarker 编辑器插件
  2. luoguP1354房间最短路问题
  3. Oracle Enterprise Linux
  4. 739. [网络流24题] 运输问题
  5. 学习笔记25_MVC前台API
  6. mysql热块争用_Oracle 索引热块引起的latch争用实例分析(转)
  7. php 保存文件并换行,php是怎样向文件中写入换行_后端开发
  8. Linux制作软盘镜像
  9. 程序员5种编程入门方法,快速学会一门编程语言!
  10. a5松下驱动器参数设置表_松下伺服驱动器参数设置MSD043A1X
  11. 网盘源码php,PHP云盘网盘系统(PHP云盘源码工具)V1.1 免费版
  12. 【CodingNoBorder - 10】无际软工队 - 求职岛:ALPHA 阶段事后分析
  13. 三维匹配_为什么你的倾斜摄影三维建模模型效果差,都进来看看
  14. Hadoop研发工程师_岗位职责和技能要求
  15. F5 BIG-IP 17.0.0
  16. 通过爬虫获取免费IP代理,搭建自己的IP池(https)
  17. 二叉树遍历-层序-递归
  18. 航海王热血航线服务器维护怎么办,航海王热血航线无法登录怎么办
  19. qt绘制半透明的遮罩(通过qbrush设置半透明图片实现)
  20. Windows 7自带的显示器校准

热门文章

  1. 前端兼容IE的下载文件方法
  2. QGIS添加自定义点状符号库
  3. QT中QString的所有类用法大全
  4. 质量成本:一次性成本,非一次性成本
  5. 我的Exchange 2010 启用匿名了。我怎么防止别人任意伪造一个邮件地址发送给我内部的人呢?
  6. [附源码]Java计算机毕业设计SSM电竞资讯网站
  7. 代办高新技术企业认定需要提供什么材料?
  8. 史上最全的 Hexo 博客搭建配置完全指南
  9. 【电子学会】2022年09月图形化四级 -- 绘制图形
  10. 当着众多MM脱裤子放屁