Golang map 实现原理

4611 字
23 分钟
Golang map 实现原理

1. 基本用法#

1.1 概述#

map 又称字典,是一种常用的数据结构,核心特征包含下述三点:

(1)存储基于 key-value 对映射的模式;

(2)基于 key 维度实现存储数据的去重;

(3)读、写、删操作控制,时间复杂度 O(1)O(1).

1.2 初始化#

1.2.1 几种初始化方法#

golang 中,对 map 的初始化分为以下几种方式:

myMap1 := make(map[int]int,2)

通过 make 关键字进行初始化,同时指定 map 预分配的容量.

myMap2 := make(map[int]int)

通过 make 关键字进行初始化,不显式声明容量,因此默认容量 为 0.

myMap3 :=map[int]int{
1:2,
3:4,
}

初始化操作连带赋值,一气呵成.

1.2.2 key 的类型要求#

map 中,key 的数据类型必须为可比较的类型,chan、map、func不可比较

1.3 读#

读 map 分为下面两种方式:

v1 := myMap[10]

第一种方式是直接读,倘若 key 存在,则获取到对应的 val,倘若 key 不存在或者 map 未初始化,会返回 val 类型的零值作为兜底.

v2,ok := myMap[10]

第二种方式是读的同时添加一个 bool 类型的 flag 标识是否读取成功. 倘若 ok == false,说明读取失败, key 不存在,或者 map 未初始化.

此处同一种语法能够实现不同返回值类型的适配,是由于代码在汇编时,会根据返回参数类型的区别,映射到不同的实现方法.

1.4 写#

myMap[5] = 6

写操作的语法如上. 须注意的一点是,倘若 map 未初始化,直接执行写操作会导致 panic:

const plainError string
panic(plainError("assignment to entry in nil map"))

1.5 删#

delete(myMap,5)

执行 delete 方法时,倘若 key 存在,则会从 map 中将对应的 key-value 对删除;倘若 key 不存在或 map 未初始化,则方法直接结束,不会产生显式提示.

1.6 遍历#

遍历分为下面两种方式:

for k,v := range myMap{
// ...
}

基于 k,v 依次承接 map 中的 key-value 对;

for k := range myMap{
// ...
}

基于 k 依次承接 map 中的 key,不关注 val 的取值.

需要注意的是,在执行 map 遍历操作时,获取的 key-value 对并没有一个固定的顺序,因此前后两次遍历顺序可能存在差异.

1.7 并发冲突#

map 不是并发安全的数据结构,倘若存在并发读写行为,会抛出 fatal error.

具体规则是:

(1)并发读没有问题;

(2)并发读写中的”写”是广义上的,包含写入、更新、删除等操作;

(3)读的时候发现其他 goroutine 在并发写,抛出 fatal error;

(4)写的时候发现其他 goroutine 在并发写,抛出 fatal error.

fatal("concurrent map read and map write")
fatal("concurrent map writes")

需要关注,此处并发读写会引发 fatal error,是一种比 panic 更严重的错误,无法使用 recover 操作捕获.

2. 核心原理#

map 又称为 hash map,在算法上基于 hash 实现 key 的映射和寻址;在数据结构上基于桶数组实现 key-value 对的存储.

以一组 key-value 对写入 map 的流程为例进行简述:

(1)通过哈希方法取得 key 的 hash 值;

(2)hash 值对桶数组长度取模,确定其所属的桶;

(3)在桶中插入 key-value 对.

hash 的性质,保证了相同的 key 必然产生相同的 hash 值,因此能映射到相同的桶中,通过桶内遍历的方式锁定对应的 key-value 对.

因此,只要在宏观流程上,控制每个桶中 key-value 对的数量,就能保证 map 的几项操作都限制为常数级别的时间复杂度.

2.1 hash#

hash 译作散列,是一种将任意长度的输入压缩到某一固定长度的输出摘要的过程,由于这种转换属于压缩映射,输入空间远大于输出空间,因此不同输入可能会映射成相同的输出结果. 此外,hash在压缩过程中会存在部分信息的遗失,因此这种映射关系具有不可逆的特质.

(1)hash 的可重入性:相同的 key,必然产生相同的 hash 值;

(2)hash 的离散性:只要两个 key 不相同,不论其相似度的高低,产生的 hash 值会在整个输出域内均匀地离散化;

(3)hash 的单向性:企图通过 hash 值反向映射回 key 是无迹可寻的.

(4)hash 冲突:由于输入域(key)无穷大,输出域(hash 值)有限,因此必然存在不同 key 映射到相同 hash 值的情况,称之为 hash 冲突.

2.2 桶数组#

桶数组.png
桶数组.png

map 中,会通过长度为 2 的整数次幂的桶数组进行 key-value 对的存储:

(1)每个桶固定可以存放 8 个 key-value 对;

(2)倘若超过 8 个 key-value 对打到桶数组的同一个索引当中,此时会通过创建桶链表的方式来化解这一问题.

2.3 拉链法解决 hash 冲突#

首先,由于 hash 冲突的存在,不同 key 可能存在相同的 hash 值;

再者,hash 值会对桶数组长度取模,因此不同 hash 值可能被打到同一个桶中.

综上,不同的 key-value 可能被映射到 map 的同一个桶当中.

此时最经典的解决手段分为两种:拉链法和开放寻址法.

(1)拉链法

拉链法中,将命中同一个桶的元素通过链表的形式进行链接,因此很便于动态扩展.

(2)开放寻址法

开放寻址法中,在插入新条目时,会基于一定的探测策略持续寻找,直到找到一个可用于存放数据的空位为止.

对标拉链还有开放寻址法,两者的优劣对比:

方法优点
拉链法简单常用;无需预先为元素分配内存.
开放寻址法无需额外的指针用于链接元素;内存地址完全连续,可以基于局部性原理,充分利用 CPU 高速缓存.

在 map 解决 hash /分桶 冲突问题时,实际上结合了拉链法和开放寻址法两种思路. 以 map 的插入写流程为例,进行思路阐述:

(1)桶数组中的每个桶,严格意义上是一个单向桶链表,以桶为节点进行串联;

(2)每个桶固定可以存放 8 个 key-value 对;

(3)当 key 命中一个桶时,首先根据开放寻址法,在桶的 8 个位置中寻找空位进行插入;

(4)倘若桶的 8 个位置都已被占满,则基于桶的溢出桶指针,找到下一个桶,重复第(3)步;

(5)倘若遍历到链表尾部,仍未找到空位,则基于拉链法,在桶链表尾部续接新桶,并插入 key-value 对.

拉链法.png
拉链法.png

2.4 扩容优化性能#

倘若 map 的桶数组长度固定不变,那么随着 key-value 对数量的增长,当一个桶下挂载的 key-value 达到一定的量级,此时操作的时间复杂度会趋于线性,无法满足诉求.

因此在实现上,map 桶数组的长度会随着 key-value 对数量的变化而实时调整,以保证每个桶内的 key-value 对数量始终控制在常量级别,满足各项操作为 O(1) 时间复杂度的要求.

map 扩容机制的核心点包括:

(1)扩容分为增量扩容和等量扩容;

(2)当桶内 key-value 总数/桶数组长度 > 6.5 时发生增量扩容,桶数组长度增长为原值的两倍;

(3)当桶内溢出桶数量大于等于 2^B 时( B 为桶数组长度的指数,B 最大取 15),发生等量扩容,桶的长度保持为原值;

(4)采用渐进扩容的方式,当桶被实际操作到时,由使用者负责完成数据迁移,避免因为一次性的全量数据迁移引发性能抖动.

3. 数据结构#

3.1 hmap#

type hmap struct {
count int
flags uint8
B uint8
noverflow uint16
hash0 uint32
buckets unsafe.Pointer
oldbuckets unsafe.Pointer
nevacuate uintptr
extra *mapextra
}

(1)count:map 中的 key-value 总数;

(2)flags:map 状态标识,可以标识出 map 是否被 goroutine 并发读写;

(3)B:桶数组长度的指数,桶数组长度为 2^B;

(4)noverflow:map 中溢出桶的数量;

(5)hash0:hash 随机因子,生成 key 的 hash 值时会使用到;

(6)buckets:桶数组;

(7)oldbuckets:扩容过程中老的桶数组;

(8)nevacuate:扩容时的进度标识,index 小于 nevacuate 的桶都已经由老桶转移到新桶中;

(9)extra:预申请的溢出桶.

3.2 mapextra#

type mapextra struct {
overflow *[]*bmap
oldoverflow *[]*bmap
nextOverflow *bmap
}

在 map 初始化时,倘若容量过大,会提前申请好一批溢出桶,以供后续使用,这部分溢出桶存放在 hmap.mapextra 当中:

(1)mapextra.overflow:供桶数组 buckets 使用的溢出桶;

(2)mapextra.oldoverFlow: 扩容流程中,供老桶数组 oldBuckets 使用的溢出桶;

(3)mapextra.nextOverflow:下一个可用的溢出桶.

3.3 bmap#

const bucketCnt = 8
type bmap struct {
tophash [bucketCnt]uint8
}

(1)bmap 就是 map 中的桶,可以存储 8 组 key-value 对的数据,以及一个指向下一个溢出桶的指针;

(2)每组 key-value 对数据包含 key 高 8 位 hash 值 tophash,key 和 val 三部分;

(3)在代码层面只展示了 tophash 部分,但由于 tophash、key 和 val 的数据长度固定,因此可以通过内存地址偏移的方式寻找到后续的 key 数组、val 数组以及溢出桶指针;

(4)为方便理解,把完整的 bmap 类声明代码补充如下:

type bmap struct {
tophash [bucketCnt]uint8
keys [bucketCnt]T
values [bucketCnt]T
overflow uint8
}

4. 构造方法#

创建 map 时,实际上会调用 runtime/map.go 文件中的 makemap 方法,下面对源码展开分析:

4.1 makemap#

方法主干源码一览:

func makemap(t *maptype, hint int, h *hmap) *hmap {
mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
if overflow || mem > maxAlloc {
hint = 0
}
if h == nil {
h = new(hmap)
}
h.hash0 = fastrand()
B := uint8(0)
for overLoadFactor(hint, B) {
B++
}
h.B = B
if h.B != 0 {
var nextOverflow *bmap
h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
if nextOverflow != nil {
h.extra = new(mapextra)
h.extra.nextOverflow = nextOverflow
}
}
return h
}

(1)hint 为 map 拟分配的容量;在分配前,会提前对拟分配的内存大小进行判断,倘若超限,会将 hint 置为零;

mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
if overflow || mem > maxAlloc {
hint = 0
}

(2)通过 new 方法初始化 hmap;

if h == nil {
h = new(hmap)
}

(3)调用 fastrand,构造 hash 因子:hmap.hash0;

h.hash0 = fastrand()`
``
(4)大致上基于 log2(B) >= hint 的思路(具体见 4.2 小节 overLoadFactor 方法的介绍),计算桶数组的容量 B;
```go
B := uint8(0)
for overLoadFactor(hint, B) {
B++
}
h.B = B

(5)调用 makeBucketArray 方法,初始化桶数组 hmap.buckets;

var nextOverflow *bmap
h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)

(6)倘若 map 容量较大,会提前申请一批溢出桶 hmap.extra.

if nextOverflow != nil {
h.extra = new(mapextra)
h.extra.nextOverflow = nextOverflow
}

4.2 overLoadFactor#

通过 overLoadFactor 方法,对 map 预分配容量和桶数组长度指数进行判断,决定是否仍需要增长 B 的数值:

const loadFactorNum = 13
const loadFactorDen = 2
const goarch.PtrSize = 8
const bucketCnt = 8
func overLoadFactor(count int, B uint8) bool {
return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}
func bucketShift(b uint8) uintptr {
return uintptr(1) << (b & (goarch.PtrSize*8 - 1))
}

(1)倘若 map 预分配容量小于等于 8,B 取 0,桶的个数为 1;

(2)保证 map 预分配容量小于等于桶数组长度 * 6.5.

4.3 makeBucketArray#

makeBucketArray 方法会进行桶数组的初始化,并根据桶的数量决定是否需要提前作溢出桶的初始化. 方法主干代码如下:

func makeBucketArray(t *maptype, b uint8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) {
base := bucketShift(b)
nbuckets := base
if b >= 4 {
nbuckets += bucketShift(b - 4)
}
buckets = newarray(t.bucket, int(nbuckets))
if base != nbuckets {
nextOverflow = (*bmap)(add(buckets, base*uintptr(t.bucketsize)))
last := (*bmap)(add(buckets, (nbuckets-1)*uintptr(t.bucketsize)))
last.setoverflow(t, (*bmap)(buckets))
}
return buckets, nextOverflow
}

makeBucketArray 会为 map 的桶数组申请内存,在桶数组的指数 b >= 4时(桶数组的容量 >= 52 ),会需要提前创建溢出桶。

通过 base 记录桶数组的长度,不包含溢出桶;通过 nbuckets 记录累加上溢出桶后,桶数组的总长度.

5. 读流程#

5.1 读流程梳理#

map 读流程主要分为以下几步:

(1)根据 key 取 hash 值;

(2)根据 hash 值对桶数组取模,确定所在的桶;

(3)沿着桶链表依次遍历各个桶内的 key-value 对;

(4)命中相同的 key,则返回 value;若 key 不存在,则返回零值.

map 读操作最终会走进 runtime/map.go 的 mapaccess 方法中,下面是关键实现和说明:

5.2 mapaccess 方法(关键代码)#

func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
if h == nil || h.count == 0 {
return unsafe.Pointer(&zeroVal[0])
}
if h.flags&hashWriting != 0 {
fatal("concurrent map read and map write")
}
hash := t.hasher(key, uintptr(h.hash0))
m := bucketMask(h.B)
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
if c := h.oldbuckets; c != nil {
if !h.sameSizeGrow() {
m >>= 1
}
oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))
if !evacuated(oldb) {
b = oldb
}
}
top := tophash(hash)
bucketloop:
for ; b != nil; b = b.overflow(t) {
for i := uintptr(0); i < bucketCnt; i++ {
if b.tophash[i] != top {
if b.tophash[i] == emptyRest {
break bucketloop
}
continue
}
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
if t.indirectkey() {
k = *((*unsafe.Pointer)(k))
}
if t.key.equal(key, k) {
e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
if t.indirectelem() {
e = *((*unsafe.Pointer)(e))
}
return e
}
}
}
return unsafe.Pointer(&zeroVal[0])
}

关键说明(简要):

  • 未初始化或空 map:直接返回零值指针;
  • 并发写检测:若发现写标记(hashWriting),抛出 fatal;
  • 扩容兼容:若处于扩容(oldbuckets != nil),可能需要先在 oldbuckets 中取桶并判断是否已迁移;
  • 使用 tophash(hash 的高 8 位,且避开 0-4 的保留值)进行快速筛选,匹配后再比较完整 key。

6. 写流程#

6.1 写流程梳理#

map 写流程主要分为:

  1. 根据 key 取 hash 值;
  2. 根据 hash 值计算桶索引;
  3. 若处于扩容,帮助迁移命中的桶(渐进扩容);
  4. 遍历桶链表查找匹配或空槽;
  5. 若已存在 key,则更新 value;否则插入;
  6. 若触发扩容条件,则开启扩容并重试定位。

6.2 mapassign(关键代码)#

func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
if h == nil {
panic(plainError("assignment to entry in nil map"))
}
if h.flags&hashWriting != 0 {
fatal("concurrent map writes")
}
hash := t.hasher(key, uintptr(h.hash0))
h.flags ^= hashWriting
if h.buckets == nil {
h.buckets = newobject(t.bucket)
}
again:
bucket := hash & bucketMask(h.B)
if h.growing() {
growWork(t, h, bucket)
}
b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize)))
top := tophash(hash)
var inserti *uint8
var insertk unsafe.Pointer
var elem unsafe.Pointer
bucketloop:
for {
for i := uintptr(0); i < bucketCnt; i++ {
if b.tophash[i] != top {
if isEmpty(b.tophash[i]) && inserti == nil {
inserti = &b.tophash[i]
insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
}
if b.tophash[i] == emptyRest {
break bucketloop
}
continue
}
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
if t.indirectkey() {
k = *((*unsafe.Pointer)(k))
}
if !t.key.equal(key, k) {
continue
}
if t.needkeyupdate() {
typedmemmove(t.key, k, key)
}
elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
goto done
}
ovf := b.overflow(t)
if ovf == nil {
break
}
b = ovf
}
if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
hashGrow(t, h)
goto again
}
if inserti == nil {
newb := h.newoverflow(t, b)
inserti = &newb.tophash[0]
insertk = add(unsafe.Pointer(newb), dataOffset)
elem = add(insertk, bucketCnt*uintptr(t.keysize))
}
if t.indirectkey() {
kmem := newobject(t.key)
*(*unsafe.Pointer)(insertk) = kmem
insertk = kmem
}
if t.indirectelem() {
vmem := newobject(t.elem)
*(*unsafe.Pointer)(elem) = vmem
}
typedmemmove(t.key, insertk, key)
*inserti = top
h.count++
done:
if h.flags&hashWriting == 0 {
fatal("concurrent map writes")
}
h.flags &^= hashWriting
if t.indirectelem() {
elem = *((*unsafe.Pointer)(elem))
}
return elem
}

要点:

  • 对未初始化的 map 进行写会 panic;
  • 写前设置写标记(hashWriting),写后清除;
  • 在插入前会检查是否需要触发扩容;
  • 插入时优先复用第一个空槽(inserti),否则新建溢出桶。

7. 删除流程#

7.1 删除流程梳理#

删除流程与读、写类似:先定位桶,再遍历链表;命中后清除 key、value,并更新 tophash 与相邻空位标记,必要时帮助扩容迁移。

7.2 mapdelete(关键代码)#

func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
if h == nil || h.count == 0 {
return
}
if h.flags&hashWriting != 0 {
fatal("concurrent map writes")
}
hash := t.hasher(key, uintptr(h.hash0))
h.flags ^= hashWriting
bucket := hash & bucketMask(h.B)
if h.growing() {
growWork(t, h, bucket)
}
b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize)))
bOrig := b
top := tophash(hash)
search:
for ; b != nil; b = b.overflow(t) {
for i := uintptr(0); i < bucketCnt; i++ {
if b.tophash[i] != top {
if b.tophash[i] == emptyRest {
break search
}
continue
}
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
k2 := k
if t.indirectkey() {
k2 = *((*unsafe.Pointer)(k2))
}
if !t.key.equal(key, k2) {
continue
}
// Only clear key if there are pointers in it.
if t.indirectkey() {
*(*unsafe.Pointer)(k) = nil
} else if t.key.ptrdata != 0 {
memclrHasPointers(k, t.key.size)
}
e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
if t.indirectelem() {
*(*unsafe.Pointer)(e) = nil
} else if t.elem.ptrdata != 0 {
memclrHasPointers(e, t.elem.size)
} else {
memclrNoHeapPointers(e, t.elem.size)
}
b.tophash[i] = emptyOne
// 如果是末位或后续为空,则向前合并为 emptyRest
if i == bucketCnt-1 {
if b.overflow(t) != nil && b.overflow(t).tophash[0] != emptyRest {
goto notLast
}
} else {
if b.tophash[i+1] != emptyRest {
goto notLast
}
}
for {
b.tophash[i] = emptyRest
if i == 0 {
if b == bOrig {
break
}
c := b
for b = bOrig; b.overflow(t) != c; b = b.overflow(t) {
}
i = bucketCnt - 1
} else {
i--
}
if b.tophash[i] != emptyOne {
break
}
}
notLast:
h.count--
if h.count == 0 {
h.hash0 = fastrand()
}
break search
}
}
if h.flags&hashWriting == 0 {
fatal("concurrent map writes")
}
h.flags &^= hashWriting
}

8. 遍历流程#

map 的遍历首先创建迭代器(hiter),随机选择起始桶与起始偏移,然后通过 mapiternext 实现按顺序遍历(兼容扩容场景)。

8.1 迭代器结构(hiter)#

type hiter struct {
key unsafe.Pointer
elem unsafe.Pointer
t *maptype
h *hmap
buckets unsafe.Pointer
bptr *bmap
overflow *[]*bmap
oldoverflow *[]*bmap
startBucket uintptr
offset uint8
wrapped bool
B uint8
i uint8
bucket uintptr
checkBucket uintptr
}

8.2 mapiterinit / mapiternext(要点)#

  • mapiterinit 初始化迭代器并随机选取 startBucket 与 offset;若 t.bucket.ptrdata == 0,会预取 overflow 信息供迭代器使用;
  • mapiternext 在遍历时检测并发写(hashWriting),并在遇到扩容时通过 checkBucket 兼容旧桶未迁移的数据顺序;
  • 遍历遇到被标记为 evacuated 的条目时,会通过 mapaccessK 等方法完成安全读取。

9. 扩容流程#

9.1 扩容类型#

  • 增量扩容(grow bigger):桶数组长度翻倍;
  • 等量扩容(same size grow):保持桶数量不变,但降低溢出桶数量,提高填充率。

9.2 何时扩容#

  • 只有写操作可能触发扩容;
  • overLoadFactor 判断(count > bucketCnt 且 count > loadFactorNum*(bucketShift(B)/loadFactorDen))会触发增量扩容;
  • tooManyOverflowBuckets 判断溢出桶过多会触发等量扩容。

相关常量与函数:

const loadFactorNum = 13
const loadFactorDen = 2
const bucketCnt = 8
func overLoadFactor(count int, B uint8) bool {
return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}
func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
if B > 15 { B = 15 }
return noverflow >= uint16(1)<<(B&15)
}

9.3 开启扩容(hashGrow 要点)#

  • 根据 overLoadFactor 决定 bigger(0 或 1);
  • 将旧 buckets 赋给 oldbuckets,创建新 buckets,并初始化 nevacuate 等字段;
  • 如果存在预分配的 overflow,会把它们提升为 oldoverflow。

9.4 扩容迁移规则与渐进式迁移#

  • 在增量扩容中,旧桶中的条目要么迁到 x 区(同索引),要么迁到 y 区(索引 + oldBucketsLen),取决于 hash 的低位;
  • map 采用渐进式扩容:每次写/删会迁移两组桶 —— 当前命中的桶与最小未迁移桶(h.nevacuate);
  • evacuate 会遍历旧桶,把条目按规则搬到目标桶(并标记已搬迁),在适当时机释放旧桶引用。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Golang map 实现原理
https://www.lansganbs.cn/posts/go源码/golang-map-实现原理/
作者
Zowely
发布于
2026-01-12
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Zowely
红叶最多情,一舞寄相思。
公告
欢迎来到我的博客!这里分享计算机等相关内容。