【JavaSE】HashMap底层原理、面试题详解
创始人
2024-03-21 14:37:09
0

【JavaSE】HashMap底层原理、面试题详解

文章目录

  • 【JavaSE】HashMap底层原理、面试题详解
    • 一:HashMap的数据结构
      • 1:JDK1.7
      • 2:JDK1.8
    • 二:hash 方法的原理
    • 三:HashMap的put流程
    • 四:HashMap的get流程
    • 五:HashMap的扩容机制
      • 1:扩容时机
      • 2:扩容机制
        • JDK1.7
        • JDK1.8
    • 六:加载因子为什么是0.75
    • 七:为什么HashMap的容量是2的倍数
    • 八:如果初始化HashMap,传一个17的值`new HashMap<>`,它会怎么处理
    • 九:JDK 1.8对HashMap主要做了哪些优化
    • 十:HashMap 线程不安全
      • 1:多线程下扩容会死循环
        • JDK1.7头插法引发的问题
        • JDK1.8解决了吗?
      • 2:多线程下 put 会导致元素丢失
      • 3:put 和 get 并发时会导致 get 到 null
    • 十一:为什么重写equals方法的时候需要重写hashCode方法
    • 十二:有什么办法能解决HashMap线程不安全

一:HashMap的数据结构

1:JDK1.7

JDK1.7的数据结构是数组+链表。数据结构示意图如下:

img

2:JDK1.8

JDK1.8的数据结构是数组+链表+红黑树。数据结构示意图如下:

jdk1.8 hashmap数据结构示意图

其中,桶数组是用来存储数据元素,链表是用来解决冲突,红黑树是为了提高查询的效率。

  • 数据元素通过映射关系,也就是散列函数,映射到桶数组对应索引的位置
  • 如果发生冲突,从冲突的位置拉一个链表,插入冲突的元素
  • 如果链表长度>8&数组大小>=64,链表转为红黑树
  • 如果红黑树节点个数<6 ,转为链表

二:hash 方法的原理

来看一下 hash 方法的源码(JDK 8 中的 HashMap):

image-20221204210748265

我们都知道,key.hashCode() 是用来获取键位的哈希值的,理论上,哈希值是一个 int 类型,范围从-2147483648 到 2147483648。前后加起来大概 40 亿的映射空间,只要哈希值映射得比较均匀松散,一般是不会出现哈希碰撞的。

但问题是一个 40 亿长度的数组,内存是放不下的。HashMap 扩容之前的数组初始大小只有 16,所以这个哈希值是不能直接拿来用的,用之前要和数组的长度做取模运算,用得到的余数来访问数组下标才行。

一处是往 HashMap 中 put 的时候(putVal 方法中):

image-20221204214602521

一处是从 HashMap 中 get 的时候(getNode 方法中):

image-20221204214713415

其中的 (n - 1) & hash 正是取模运算,就是把哈希值和(数组长度-1)做了一个“与”运算。

操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度 16 为例,16-1=15。2 进制表示是 0000 0000 0000 0000 0000 0000 0000 1111。和某个散列值做 操作如下,结果就是截取了最低的四位值。

哈希&运算

这样是要快捷一些,但是新的问题来了,就算散列值分布再松散,要是只取最后几位的话,碰撞也会很严重。如果散列本身做得不好,分布上成等差数列的漏洞,如果正好让最后几个低位呈现规律性重复,那就更难搞了。

这时候 扰动函数 的价值就体现出来了,看一下扰动函数的示意图:

扰动函数示意图

明白了取模运算后,我们再来看 put 方法以及 get 方法的源码,它们在调用 putVal 和 getNode 之前,都会先调用 hash 方法:

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

看下面这个图。

image-20221204221233067

某哈希值为 11111111 11111111 11110000 1110 1010,将它右移 16 位(h >>> 16),刚好是 00000000 00000000 11111111 11111111,再进行异或操作(h ^ (h >>> 16)),结果是 11111111 11111111 00001111 00010101

异或(^)运算是基于二进制的位运算,采用符号 XOR 或者^来表示,运算规则是:如果是同值取 0、异值取 1

由于混合了原来哈希值的高位和低位,所以低位的随机性加大了(掺杂了部分高位的特征,高位的信息也得到了保留)。

结果再与数组长度-1(00000000 00000000 00000000 00001111)做取模运算,得到的下标就是 00000000 00000000 00000000 00000101,也就是 5。

还记得之前我们假设的某哈希值 10100101 11000100 00100101 吗?在没有调用 hash 方法之前,与 15 做取模运算后的结果也是 5,我们不妨来看看调用 hash 之后的取模运算结果是多少。

某哈希值 00000000 10100101 11000100 00100101(补齐 32 位),将它右移 16 位(h >>> 16),刚好是 00000000 00000000 00000000 10100101,再进行异或操作(h ^ (h >>> 16)),结果是 00000000 10100101 00111011 10000000

结果再与数组长度-1(00000000 00000000 00000000 00001111)做取模运算,得到的下标就是 00000000 00000000 00000000 00000000,也就是 0。

综上所述,hash 方法是用来做哈希值优化的,把哈希值右移 16 位,也就正好是自己长度的一半,之后与原哈希值做异或运算,这样就混合了原哈希值中的高位和低位,增大了随机性。

说白了,hash 方法就是为了增加随机性,让数据元素更加均衡的分布,减少碰撞

三:HashMap的put流程

先上个流程图吧:

HashMap插入数据流程图

  1. 首先进行哈希值的扰动,获取一个新的哈希值。(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

    image-20221205013940001

  2. 判断tab是否位空或者长度为0,如果是则进行扩容操作。

    image-20221205014037975

  3. 根据哈希值计算下标,如果对应小标正好没有存放数据,则直接插入即可否则需要覆盖。tab[i = (n - 1) & hash])

    image-20221205014053718

  4. 判断tab[i]是否为树节点,否则向链表中插入数据,是则向树中插入节点。

    image-20221205014352999

  5. 如果链表中插入节点的时候,链表长度大于等于8,则需要把链表转换为红黑树。treeifyBin(tab, hash);

    image-20221205014702015

  6. 最后所有元素处理完成后,判断是否超过阈值;threshold,超过则扩容。

    image-20221205014725321

四:HashMap的get流程

先看流程图:

HashMap查找流程图

HashMap的查找就简单很多:

  1. 使用扰动函数,获取新的哈希值

  2. 计算数组下标,获取节点

    image-20221205014936548

  3. 当前节点和key匹配,直接返回

    image-20221205015101552

  4. 否则,当前节点是否为树节点,查找红黑树

    image-20221205015132263

  5. 否则,遍历链表查找

    image-20221205015149348

五:HashMap的扩容机制

1:扩容时机

为了减少哈希冲突发生的概率,当当前HashMap的元素个数达到一个临界值的时候,就会触发扩容,把所有元素rehash之后再放在扩容后的容器中,这是一个相当耗时的操作。

image-20221204223056867

而这个临界值threshold就是由加载因子和当前容器的容量大小来确定的,假如采用默认的构造方法:

临界值(threshold)=默认容量(DEFAULTINITIALCAPACITY)∗默认扩容因子(DEFAULTLOADFACTOR)临界值(threshold )= 默认容量(DEFAULT_INITIAL_CAPACITY)* 默认扩容因子(DEFAULT_LOAD_FACTOR) 临界值(threshold)=默认容量(DEFAULTI​NITIALC​APACITY)∗默认扩容因子(DEFAULTL​OADF​ACTOR)
image-20221204223354907

那就是大于16x0.75=12时,就会触发扩容操作。

2:扩容机制

HashMap 的底层用的是数组。向 HashMap 里不停地添加元素,当数组无法装载更多元素时,就需要对数组进行扩容,以便装入更多的元素。

HashMap 的扩容是通过 resize 方法来实现的,JDK 8 中融入了红黑树,比较复杂,为了便于理解,就使用 JDK 7 的源码,搞清楚了 JDK 7 的,我们后面再详细说明 JDK 8 和 JDK 7 之间的区别。

JDK1.7

resize 方法的源码:

// newCapacity为新的容量
void resize(int newCapacity) {// 小数组,临时过度下Entry[] oldTable = table;// 扩容前的容量int oldCapacity = oldTable.length;// MAXIMUM_CAPACITY 为最大容量,2 的 30 次方 = 1<<30if (oldCapacity == MAXIMUM_CAPACITY) {// 容量调整为 Integer 的最大值 0x7fffffff(十六进制)=2 的 31 次方-1threshold = Integer.MAX_VALUE;return;}// 初始化一个新的数组(大容量)Entry[] newTable = new Entry[newCapacity];// 把小数组的元素转移到大数组中transfer(newTable, initHashSeedAsNeeded(newCapacity));// 引用新的大数组table = newTable;// 重新计算阈值threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

transfer 方法用来转移,将小数组的元素拷贝到新的数组中:

void transfer(Entry[] newTable, boolean rehash) {// 新的容量int newCapacity = newTable.length;// 遍历小数组for (Entry e : table) {while(null != e) {// 拉链法,相同 key 上的不同值Entry next = e.next;// 是否需要重新计算 hashif (rehash) {e.hash = null == e.key ? 0 : hash(e.key);}// 根据大数组的容量,和键的 hash 计算元素在数组中的下标int i = indexFor(e.hash, newCapacity);// 同一位置上的新元素被放在链表的头部e.next = newTable[i];// 放在新的数组上newTable[i] = e;// 链表上的下一个元素e = next;}}
}

e.next = newTable[i],也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到链表的尾部(如果发生了hash冲突的话),这一点和 JDK 8 有区别。

在旧数组中同一个链表上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上(仔细看下面的内容,会解释清楚这一点)。

假设 hash 算法就是简单的用键的哈希值(一个 int 值)和数组大小取模(也就是 hashCode % table.length)。

继续假设:

  • 数组 table 的长度为 2
  • 键的哈希值为 3、7、5

取模运算后,哈希冲突都到 table[1] 上了,因为余数为 1。那么扩容前的样子如下图所示。

img

小数组的容量为 2, key 3、7、5 都在 table[1] 的链表上。

假设负载因子 loadFactor 为 1,也就是当元素的实际大小大于 table 的实际大小时进行扩容。

扩容后的大数组的容量为 4。

  • key 3 取模(3%4)后是 3,放在 table[3] 上。
  • key 7 取模(7%4)后是 3,放在 table[3] 上的链表头部。
  • key 5 取模(5%4)后是 1,放在 table[1] 上。

img

JDK1.8

按照我们的预期,扩容后的 7 仍然应该在 3 这条链表的后面,但实际上呢? 7 跑到 3 这条链表的头部了。针对 JDK 7 中的这个情况,JDK 8 做了哪些优化呢?

看下面这张图。

img

n 为 table 的长度,默认值为 16。

  • n-1 也就是二进制的 0000 1111(1X202^020+1X212^121+1X222^222+1X232^323=1+2+4+8=15);
  • key1 哈希值的最后 8 位为 0000 0101
  • key2 哈希值的最后 8 位为 0001 0101(和 key1 不同)
  • 做与运算后发生了哈希冲突,索引都在(0000 0101)上。

扩容后为 32。

  • n-1 也就是二进制的 0001 1111(1X202^020+1X212^121+1X222^222+1X232^323+1X242^424=1+2+4+8+16=31),扩容前是 0000 1111。
  • key1 哈希值的低位为 0000 0101
  • key2 哈希值的低位为 0001 0101(和 key1 不同)
  • key1 做与运算后,索引为 0000 0101。
  • key2 做与运算后,索引为 0001 0101。

新的索引就会发生这样的变化:

  • 原来的索引是 5(0 0101)
  • 原来的容量是 16
  • 扩容后的容量是 32
  • 扩容后的索引是 21(1 0101),也就是 5+16,也就是原来的索引+原来的容量

扩容位置变化

也就是说,JDK 8 不需要像 JDK 7 那样重新计算 hash,只需要看原来的hash值新增的那个bit是1还是0就好了,是0的话就表示索引没变,是1的话,索引就变成原索引+oldCap

扩容节点迁移示意图

JDK 8 的这个设计非常巧妙,既省去了重新计算hash的时间,同时,由于新增的1 bit是0还是1是随机的,因此扩容的过程,可以均匀地把之前的节点分散到新的位置上。

JDK 8 扩容的主要源代码:

扩容主要逻辑

六:加载因子为什么是0.75

JDK 8 中的 HashMap 是用数组+链表+红黑树实现的,我们要想往 HashMap 中放数据或者取数据,就需要确定数据在数组中的下标。

先把数据的键进行一次 hash:

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

再做一次取模运算确定下标:

i = (n - 1) & hash

哈希表这样的数据结构容易产生两个问题:

  • 数组的容量过小,经过哈希计算后的下标,容易出现冲突;
  • 数组的容量过大,导致空间利用率不高。

加载因子是用来表示 HashMap 中数据的填满程度:

加载因子 = 填入哈希表中的数据个数 / 哈希表的长度

这就意味着:

  • 加载因子越小,填满的数据就越少,哈希冲突的几率就减少了,但浪费了空间,而且还会提高扩容的触发几率;
  • 加载因子越大,填满的数据就越多,空间利用率就高,但哈希冲突的几率就变大了。

这就必须在“哈希冲突”与“空间利用率”两者之间有所取舍,尽量保持平衡,谁也不碍着谁。

我们知道,HashMap 是通过拉链法来解决哈希冲突的。

为了减少哈希冲突发生的概率,当 HashMap 的数组长度达到一个临界值的时候,就会触发扩容,扩容后会将之前小数组中的元素转移到大数组中,这是一个相当耗时的操作。

这个临界值由什么来确定呢?

临界值 = 初始容量 * 加载因子

一开始,HashMap 的容量是 16:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

加载因子是 0.75:

static final float DEFAULT_LOAD_FACTOR = 0.75f;

也就是说,当 16*0.75=12 时,会触发扩容机制。

我们都知道,HashMap的散列构造方式是Hash取余,负载因子决定元素个数达到多少时候扩容。

假如我们设的比较大,元素比较多,空位比较少的时候才扩容,那么发生哈希冲突的概率就增加了,查找的时间成本就增加了。

我们设的比较小的话,元素比较少,空位比较多的时候就扩容了,发生哈希碰撞的概率就降低了,查找时间成本降低,但是就需要更多的空间去存储元素,空间成本就增加了。

七:为什么HashMap的容量是2的倍数

  • 第一个原因是为了方便哈希取余:

将元素放在table数组上面,是用hash值%数组大小定位位置,而HashMap是用hash值&(数组大小-1),却能和前面达到一样的效果,这就得益于HashMap的大小是2的倍数,2的倍数意味着该数的二进制位只有一位为1,而该数-1就可以得到二进制位上1变成0,后面的0变成1,再通过&运算,就可以得到和%一样的效果,并且位运算比%的效率高得多

HashMap的容量是2的n次幂时,(n-1)的2进制也就是1111111***111这样形式的,这样与添加元素的hash值进行位运算时,能够充分的散列,使得添加的元素均匀分布在HashMap的每个位置上,减少hash碰撞。

  • 第二个方面是在扩容时,利用扩容后的大小也是2的倍数,将已经产生hash碰撞的元素完美的转移到新的table中去

我们可以简单看看HashMap的扩容机制,HashMap中的元素在超过负载因子*HashMap大小时就会产生扩容。

image-20221205133527426

八:如果初始化HashMap,传一个17的值new HashMap<>,它会怎么处理

简单来说,就是初始化时,传的不是2的倍数时,HashMap会向上寻找离得最近的2的倍数,所以传入17,但HashMap的实际容量是32。

我们来看看详情,在HashMap的初始化中,有这样⼀段⽅法;

image-20221205133933819

image-20221205134035821

  • 阀值 threshold ,通过⽅法 tableSizeFor 进⾏计算,是根据初始化传的参数来计算的。

image-20221205134129579

  • 同时,这个⽅法也要要寻找⽐初始值⼤的,最⼩的那个2进制数值。⽐如传了17,我应该找到的是32。

以17为例,看一下初始化计算table容量的过程:

容量计算

九:JDK 1.8对HashMap主要做了哪些优化

jdk1.8 的HashMap主要有五点优化:

  1. 数据结构:数组 + 链表改成了数组 + 链表或红黑树

    原因:发生 hash 冲突,元素会存入链表,链表过长转为红黑树,将时间复杂度由O(n)降为O(logn)

  2. 链表插入方式:链表的插入方式从头插法改成了尾插法

    简单说就是插入时,如果数组位置上已经有元素,1.7 将新元素放到数组中,原始节点作为新节点的后继节点,1.8 遍历链表,将元素放置到链表的最后。

    原因:因为 1.7 头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环。

  3. 扩容rehash:扩容的时候 1.7 需要对原数组中的元素进行重新 hash 定位在新数组的位置,1.8 采用更简单的判断逻辑,不需要重新通过哈希函数计算位置,新的位置不变或索引 + 新增容量大小。

    原因:提高扩容的效率,更快地扩容。

  4. 扩容时机:在插入时,1.7 先判断是否需要扩容,再插入,1.8 先进行插入,插入完成再判断是否需要扩容;

  5. 散列函数:1.7 做了四次移位和四次异或,jdk1.8只做一次。

    原因:做 4 次的话,边际效用也不大,改为一次,提升效率。

十:HashMap 线程不安全

多线程下扩容会死循环、多线程下 put 会导致元素丢失、put 和 get 并发时会导致 get 到 null。

1:多线程下扩容会死循环

JDK1.7头插法引发的问题

现在我们要在容量为2的容器里面用不同线程插入A,B,C,假如我们在resize之前打个短点,那意味着数据都插入了但是还没resize那扩容前可能是这样的。

我们可以看到链表的指向A->B->C

Tip:A的下一个指针是指向B的

在这里插入图片描述

因为resize的赋值方式,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置,在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。

就可能出现下面的情况,大家发现问题没有?

B的下一个指针指向了A

图片

一旦几个线程都调整完成,就可能出现环形链表

在这里插入图片描述

如果这个时候去取值,悲剧就出现了——Infinite Loop。

JDK1.8解决了吗?

因为java8之后链表有红黑树的部分,大家可以看到代码已经多了很多if else的逻辑判断了,红黑树的引入巧妙的将原本O(n)的时间复杂度降低到了O(logn)。

使用头插会改变链表的上的顺序,但是如果使用尾插,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了。

就是说原本是A->B,在扩容后那个链表还是A->B
在这里插入图片描述

Java7在多线程操作HashMap时可能引起死循环,原因是扩容转移后前后链表顺序倒置,在转移过程中修改了原来链表中节点的引用关系。

Java8在同样的前提下并不会引起死循环,原因是扩容转移后前后链表顺序不变,保持之前节点的引用关系。

2:多线程下 put 会导致元素丢失

正常情况下,当发生哈希冲突时,HashMap 是这样的:

img

但多线程同时执行 put 操作时,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。

问题发生在这里,如图所示:

image-20221205195914747

if ((p = tab[i = (n - 1) & hash]) == null)tab[i] = newNode(hash, key, value, null);

两个线程都执行了 if 语句,假设线程 A 先执行了 tab[i] = newNode(hash, key, value, null),那 table 是这样的:

img

接着,线程 B 执行了 tab[i] = newNode(hash, key, value, null),那 table 是这样的:

img

3 被干掉了。

3:put 和 get 并发时会导致 get 到 null

线程 A 执行put时,因为元素个数超出阈值而出现扩容,线程B 此时执行get,有可能导致这个问题。

注意来看 resize 源码:

final Node[] resize() {Node[] 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;}// 没超过最大值,就扩充为原来的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 thresholdnewCap = oldThr;else {               // zero initial threshold signifies using defaultsnewCap = DEFAULT_INITIAL_CAPACITY;newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);}// 计算新的resize上限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[] newTab = (Node[])new Node[newCap];table = newTab;
}

线程 A 执行完 table = newTab 之后,线程 B 中的 table 此时也发生了变化,此时去 get 的时候当然会 get 到 null 了,因为元素还没有转移。

十一:为什么重写equals方法的时候需要重写hashCode方法

因为在java中,所有的对象都是继承于Object类。Ojbect类中有两个方法equals、hashCode,这两个方法都是用来比较两个对象是否相等的。

在未重写equals方法我们是继承了object的equals方法,那里的 equals是比较两个对象的内存地址,显然我们new了2个对象内存地址肯定不一样

  • 对于值对象,==比较的是两个对象的值
  • 对于引用对象,比较的是两个对象的地址

大家是否还记得我说的HashMap是通过key的hashCode去寻找index的,那index一样就形成链表了,也就是说”ab“和”ba“的index都可能是2,在一个链表上的。

我们去get的时候,他就是根据key去hash然后计算出index,找到了2,那我怎么找到具体的”ab“还是”ba“呢?

equals!是的,所以如果我们对equals方法进行了重写,建议一定要对hashCode方法重写,以保证相同的对象返回相同的hash值,不同的对象返回不同的hash值。

不然一个链表的对象,你哪里知道你要找的是哪个,到时候发现hashCode都一样,这不是完犊子嘛。

十二:有什么办法能解决HashMap线程不安全

Java 中有 HashTable、Collections.synchronizedMap、以及 ConcurrentHashMap 可以实现线程安全的 Map。

  • HashTable 是直接在操作方法上加 synchronized 关键字,锁住整个table数组,粒度比较大;
  • Collections.synchronizedMap 是使用 Collections 集合工具的内部类,通过传入 Map 封装出一个 SynchronizedMap 对象,内部定义了一个对象锁,方法内通过对象锁实现;
  • ConcurrentHashMap 在jdk1.7中使用分段锁,在jdk1.8中使用CAS+synchronized。

相关内容

热门资讯

银河麒麟V10SP1高级服务器... 银河麒麟高级服务器操作系统简介: 银河麒麟高级服务器操作系统V10是针对企业级关键业务...
【NI Multisim 14...   目录 序言 一、工具栏 🍊1.“标准”工具栏 🍊 2.视图工具...
AWSECS:访问外部网络时出... 如果您在AWS ECS中部署了应用程序,并且该应用程序需要访问外部网络,但是无法正常访问,可能是因为...
不能访问光猫的的管理页面 光猫是现代家庭宽带网络的重要组成部分,它可以提供高速稳定的网络连接。但是,有时候我们会遇到不能访问光...
AWSElasticBeans... 在Dockerfile中手动配置nginx反向代理。例如,在Dockerfile中添加以下代码:FR...
Android|无法访问或保存... 这个问题可能是由于权限设置不正确导致的。您需要在应用程序清单文件中添加以下代码来请求适当的权限:此外...
月入8000+的steam搬砖... 大家好,我是阿阳 今天要给大家介绍的是 steam 游戏搬砖项目,目前...
​ToDesk 远程工具安装及... 目录 前言 ToDesk 优势 ToDesk 下载安装 ToDesk 功能展示 文件传输 设备链接 ...
北信源内网安全管理卸载 北信源内网安全管理是一款网络安全管理软件,主要用于保护内网安全。在日常使用过程中,卸载该软件是一种常...
AWS管理控制台菜单和权限 要在AWS管理控制台中创建菜单和权限,您可以使用AWS Identity and Access Ma...