【Golang】关于Map扩容策略

一、概括

使用哈希表的目的就是要快速查找到目标 key,然而,随着向 map 中添加的 key 越来越多,key 发生碰撞的概率也越来越大。bucket 中的 8 个 cell 会被逐渐塞满,查找、插入、删除 key 的效率也会越来越低。最理想的情况是一个 bucket 只装一个 key,这样,就能达到 O(1) 的效率,但这样空间消耗太大,用空间换时间的代价太高。

Go 语言采用一个 bucket 里装载 8 个 key,定位到某个 bucket 后,还需要再定位到具体的 key,这实际上又用了时间换空间。当然,这样做,要有一个度,不然所有的 key 都落在了同一个 bucket 里,直接退化成了链表,各种操作的效率直接降为 O(n),是不行的。因此,需要有一个指标来衡量前面描述的情况,这就是装载因子。Go 源码里这样定义 装载因子

1
loadFactor := count / (2^B)

count 就是 map 的元素个数,2^B 表示 bucket 数量,再来说触发 map 扩容的时机:在向 map 插入新 key 的时候,会进行条件检测,符合下面这 2 个条件,就会触发扩容:

Map的扩容有2种机制

1、装载因子超过阈值,源码里定义的阈值是 6.5,触发double扩容

2、overflow 的 bucket 数量过多:当 B 小于 15,也就是 bucket 总数 2^B 小于 2^15 时,如果 overflow 的 bucket 数量超过 2^B;当 B >= 15,也就是 bucket 总数 2^B 大于等于 2^15,如果 overflow 的 bucket 数量超过 2^15,触发等量扩容

可以看对应的函数是 mapassign

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/runtime/hashmap.go/mapassign// 触发扩容时机
if !h.growing() && (overLoadFactor(int64(h.count), h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
hashGrow(t, h)
}// 装载因子超过 6.5
func overLoadFactor(count int64, B uint8) bool {
    return count >= bucketCnt && float32(count) >= loadFactor*float32((uint64(1)<<B))
}// overflow buckets 太多
func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
    if B < 16 {
        return noverflow >= uint16(1)<<B
    }
    return noverflow >= 1<<15
}

第 1 点:我们知道,每个 bucket 有 8 个空位,在没有溢出,且所有的桶都装满了的情况下,装载因子算出来的结果是 8。因此当装载因子超过 6.5 时,表明很多 bucket 都快要装满了,查找效率和插入效率都变低了。在这个时候进行扩容是有必要的。

第 2 点:是对第 1 点的补充。就是说在装载因子比较小的情况下,这时候 map 的查找和插入效率也很低,而第 1 点识别不出来这种情况。表面现象就是计算装载因子的分子比较小,即 map 里元素总数少,但是 bucket 数量多(真实分配的 bucket 数量多,包括大量的 overflow bucket)。

不难想像造成这种情况的原因:不停地插入、删除元素。先插入很多元素,导致创建了很多 bucket,但是装载因子达不到第 1 点的临界值,未触发扩容来缓解这种情况。之后,删除元素降低元素总数量,再插入很多元素,导致创建很多的 overflow bucket,但就是不会触犯第 1 点的规定,你能拿我怎么办?overflow bucket 数量太多,导致 key 会很分散,查找插入效率低得吓人,因此出台第 2 点规定。这就像是一座空城,房子很多,但是住户很少,都分散了,找起人来很困难。

对于命中条件 1,2 的限制,都会发生扩容。但是扩容的策略并不相同,毕竟两种条件应对的场景不同。

对于条件 1,元素太多,而 bucket 数量太少,很简单:将 B 加 1,bucket 最大数量(2^B)直接变成原来 bucket 数量的 2 倍。于是,就有新老 bucket 了。注意,这时候元素都在老 bucket 里,还没迁移到新的 bucket 来。而且,新 bucket 只是最大数量变为原来最大数量(2^B)的 2 倍(2^B * 2)。

对于条件 2,其实元素没那么多,但是 overflow bucket 数特别多,说明很多 bucket 都没装满。解决办法就是开辟一个新 bucket 空间,将老 bucket 中的元素移动到新 bucket,使得同一个 bucket 中的 key 排列地更紧密。这样,原来,在 overflow bucket 中的 key 可以移动到 bucket 中来。结果是节省空间,提高 bucket 利用率,map 的查找和插入效率自然就会提升。

对于条件 2 的解决方案,曹大的博客里还提出了一个极端的情况:如果插入 map 的 key 哈希都一样,就会落到同一个 bucket 里,超过 8 个就会产生 overflow bucket,结果也会造成 overflow bucket 数过多。移动元素其实解决不了问题,因为这时整个哈希表已经退化成了一个链表,操作效率变成了 O(n)。

二、源码分析

这个6.5来源于作者的一个测试程序,取了一个相对适中的值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Picking loadFactor: too large and we have lots of overflow
// buckets, too small and we waste a lot of space. I wrote
// a simple program to check some stats for different loads:
// (64-bit, 8 byte keys and elems)
//  loadFactor    %overflow  bytes/entry     hitprobe    missprobe
//        4.00         2.13        20.77         3.00         4.00
//        4.50         4.05        17.30         3.25         4.50
//        5.00         6.85        14.77         3.50         5.00
//        5.50        10.55        12.94         3.75         5.50
//        6.00        15.27        11.67         4.00         6.00
//        6.50        20.90        10.79         4.25         6.50
//        7.00        27.14        10.15         4.50         7.00
//        7.50        34.03         9.73         4.75         7.50
//        8.00        41.10         9.40         5.00         8.00
//
// %overflow   = percentage of buckets which have an overflow bucket
// bytes/entry = overhead bytes used per key/elem pair
// hitprobe    = # of entries to check when looking up a present key
// missprobe   = # of entries to check when looking up an absent key
//
// Keep in mind this data is for maximally loaded tables, i.e. just
// before the table grows. Typical tables will be somewhat less loaded.

hashGrow函数

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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
// 只是分配新的buckets,并将老的buckets挂到oldbuckets字段上
// 真正搬迁的动作在growWork()中
func hashGrow(t *maptype, h *hmap) {
    // If we've hit the load factor, get bigger.
    // Otherwise, there are too many overflow buckets,
    // so keep the same number of buckets and "grow" laterally.
    // B+1 相当于之前的2倍空间
    bigger := uint8(1)
    // 对应条件2
    if !overLoadFactor(h.count+1, h.B) {
        // 进行等量扩容,B不变
        bigger = 0
        h.flags |= sameSizeGrow
    }
    // 将oldbuckets挂到buckets上
    oldbuckets := h.buckets
    // 申请新的buckets空间
    newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)
 
    // 对标志位的处理
    // &^表示 按位置0
    // x=01010011
    // y=01010100
    // z=x&^y=00000011
    // 如果y的bit位为1,那么z相应的bit位就为0
    // 否则z对应的bit位就和x对应的bit位相同
    //
    // 其实就是将h.flags的iterator和oldItertor位置为0
    // 如果发现iterator位为1,那就把它转接到 oldIterator 位
    // 使得 oldIterator 标志位变成 1
    // bucket挂到了oldbuckets下,那么标志位也一样转移过去
    flags := h.flags &^ (iterator | oldIterator)
    if h.flags&iterator != 0 {
        flags |= oldIterator
    }
 
    // // 可能有迭代器使用 buckets
    // iterator     = 1
    // 可能有迭代器使用 oldbuckets
    // oldIterator  = 2
    // 有协程正在向 map 中写入 key
    // hashWriting  = 4
    // 等量扩容(对应条件 2)
    // sameSizeGrow = 8
    // 提交grow的动作
    // commit the grow (atomic wrt gc)
    h.B += bigger
    h.flags = flags
    h.oldbuckets = oldbuckets
    h.buckets = newbuckets
    // 搬迁进度为0
    h.nevacuate = 0
    // 溢出bucket数量为0
    h.noverflow = 0
 
    if h.extra != nil && h.extra.overflow != nil {
        // Promote current overflow buckets to the old generation.
        if h.extra.oldoverflow != nil {
            throw("oldoverflow is not nil")
        }
        h.extra.oldoverflow = h.extra.overflow
        h.extra.overflow = nil
    }
    if nextOverflow != nil {
        if h.extra == nil {
            h.extra = new(mapextra)
        }
        h.extra.nextOverflow = nextOverflow
    }
 
    // the actual copying of the hash table data is done incrementally
    // by growWork() and evacuate().
}
 
// growWork 真正执行搬迁工作的函数
// 调用其的动作在mapssign和mapdelete函数中,也就是插入、修改或删除的时候都会尝试进行搬迁
func growWork(t *maptype, h *hmap, bucket uintptr) {
    // make sure we evacuate the oldbucket corresponding
    // to the bucket we're about to use
    // 确保搬迁的老bucket对应的正在使用的新bucket
    // bucketmask 作用就是将key算出来的hash值与bucketmask相&,得到key应该落入的bucket
    // 只有hash值低B位决策key落入那个bucket
    evacuate(t, h, bucket&h.oldbucketmask())
 
    // evacuate one more oldbucket to make progress on growing
    // 再搬迁一个bucket,加快搬迁进度,这就是说为什么可能每次操作会搬迁1-2个bucket
    if h.growing() {
        evacuate(t, h, h.nevacuate)
    }
}
 
// 返回扩容前的bucketmask
//
// 所谓的bucketmask作用就是将 key 计算出来的哈希值与 bucketmask 相与
// 得到的结果就是 key 应该落入的桶
// 比如 B = 5,那么 bucketmask 的低 5 位是 11111,其余位是 0
// hash 值与其相与的意思是,只有 hash 值的低 5 位决策 key 到底落入哪个 bucket。
// oldbucketmask provides a mask that can be applied to calculate n % noldbuckets().
func (h *hmap) oldbucketmask() uintptr {
    return h.noldbuckets() - 1
}
 
// 检查oldbuckets是否搬迁完
// growing reports whether h is growing. The growth may be to the same size or bigger.
func (h *hmap) growing() bool {
    return h.oldbuckets != nil
}

 核心搬迁函数:evacuate

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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
// evacDst is an evacuation destination.
type evacDst struct {
    // 标识bucket移动的目标地址
    b *bmap          // current destination bucket
    // k-v的索引
    i int            // key/elem index into b
    // 指向k
    k unsafe.Pointer // pointer to current key storage
    // 指向v
    e unsafe.Pointer // pointer to current elem storage
}
// evacuate 核心搬迁函数
func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
    // 定位老的bucket的地址
    b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
    // 结果是2^B,进行计算 如 B = 5,结果为32
    newbit := h.noldbuckets()
    // 如果b没被搬迁过
    if !evacuated(b) {
        // TODO: reuse overflow buckets instead of using new ones, if there
        // is no iterator using the old buckets.  (If !oldIterator.)
 
        // xy contains the x and y (low and high) evacuation destinations.
        // xy包含了两个可能搬迁到的目的bucket地址
        // 默认是等量扩容的,用x来搬迁
        var xy [2]evacDst
        x := &xy[0]
        x.b = (*bmap)(add(h.buckets, oldbucket*uintptr(t.bucketsize)))
        x.k = add(unsafe.Pointer(x.b), dataOffset)
        x.e = add(x.k, bucketCnt*uintptr(t.keysize))
 
        // 如果不是等量扩容,前后的bucket序号有变
        // 使用y来搬迁
        if !h.sameSizeGrow() {
            // Only calculate y pointers if we're growing bigger.
            // Otherwise GC can see bad pointers.
            y := &xy[1]
            // y代表的bucket序号增加了2^B
            y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.bucketsize)))
            y.k = add(unsafe.Pointer(y.b), dataOffset)
            y.e = add(y.k, bucketCnt*uintptr(t.keysize))
        }
 
        // 遍历所有的bucket,包括溢出bucket
        // b是老bucket的地址
        for ; b != nil; b = b.overflow(t) {
            k := add(unsafe.Pointer(b), dataOffset)
            e := add(k, bucketCnt*uintptr(t.keysize))
 
            // 遍历bucket里所有的cell
            for i := 0; i < bucketCnt; i, k, e = i+1, add(k, uintptr(t.keysize)), add(e, uintptr(t.elemsize)) {
                // 当前cell的tophash值
                top := b.tophash[i]
                // 如果cell为空,即没有key
                // 说明其被搬迁过,作标记然后继续下一个cell
                if isEmpty(top) {
                    b.tophash[i] = evacuatedEmpty
                    continue
                }
 
                // 一般不会出现这种情况
                // 未搬迁的cell只可能是empty或者正常的tophash
                // 不会小于minTopHash
                if top < minTopHash {
                    throw("bad map state")
                }
                // 进行一次拷贝避免相同内存地址问题
                k2 := k
                // key如果是指针就进行解引用
                if t.indirectkey() {
                    k2 = *((*unsafe.Pointer)(k2))
                }
                // 默认值为0标识默认是使用x,进行等量扩容
                var useY uint8
                // 增量扩容
                if !h.sameSizeGrow() {
                    // Compute hash to make our evacuation decision (whether we need
                    // to send this key/elem to bucket x or bucket y).
                    // 计算hash值,与第一次写入一样
                    hash := t.hasher(k2, uintptr(h.hash0))
 
                    // 有协程在遍历map 且 出现相同的key,计算出的hash值不同
                    // 这里只会有一种情况,也就是float64的时候
                    // 每次hash出来都会是不同的hash值,这就意味着无法通过get去获取其key确切位置
                    // 因此采用取最低位位置来分辨
                    // 为下一个level重新计算一个随机的tophash
                    // 这些key将会在多次增长后均匀的分布在所有的存储桶中
                    if h.flags&iterator != 0 && !t.reflexivekey() && !t.key.equal(k2, k2) {
                        // If key != key (NaNs), then the hash could be (and probably
                        // will be) entirely different from the old hash. Moreover,
                        // it isn't reproducible. Reproducibility is required in the
                        // presence of iterators, as our evacuation decision must
                        // match whatever decision the iterator made.
                        // Fortunately, we have the freedom to send these keys either
                        // way. Also, tophash is meaningless for these kinds of keys.
                        // We let the low bit of tophash drive the evacuation decision.
                        // We recompute a new random tophash for the next level so
                        // these keys will get evenly distributed across all buckets
                        // after multiple grows.
                        // 第B位 置1
                        // 如果tophash最低位是0就分配到x part 否则分配到y part
                        useY = top & 1
                        top = tophash(hash)
                    } else {
                        // 对于正常的key
                        // 第B位 置0
                        if hash&newbit != 0 {
                            // 使用y部分
                            useY = 1
                        }
                    }
                }
 
                if evacuatedX+1 != evacuatedY || evacuatedX^1 != evacuatedY {
                    throw("bad evacuatedN")
                }
                // 这里其实就是重新设置tophash值
                // 标记老的cell的tophash值,表示搬到useT部分(可能是x也可能是y,看具体取值)
                b.tophash[i] = evacuatedX + useY // evacuatedX + 1 == evacuatedY
                // 选择目标bucket的内存起始部分
                dst := &xy[useY]                 // evacuation destination
 
                // 如果i=8说明要溢出了
                if dst.i == bucketCnt {
                    // 新建一个溢出bucket
                    dst.b = h.newoverflow(t, dst.b)
                    // 从0开始计数
                    dst.i = 0
                    // 标识key要移动到的位置
                    dst.k = add(unsafe.Pointer(dst.b), dataOffset)
                    // 标识value要移动到的位置
                    dst.e = add(dst.k, bucketCnt*uintptr(t.keysize))
                }
                // 重新设置tophash
                dst.b.tophash[dst.i&(bucketCnt-1)] = top // mask dst.i as an optimization, to avoid a bounds check
                if t.indirectkey() {
                    // 将原key(指针类型)复制到新的位置
                    *(*unsafe.Pointer)(dst.k) = k2 // copy pointer
                } else {
                    // 将原key(值类型)复制到新位置
                    typedmemmove(t.key, dst.k, k) // copy elem
                }
                // 如果v是指针,操作同key
                if t.indirectelem() {
                    *(*unsafe.Pointer)(dst.e) = *(*unsafe.Pointer)(e)
                } else {
                    typedmemmove(t.elem, dst.e, e)
                }
                // 定位到下一个cell
                dst.i++
                // These updates might push these pointers past the end of the
                // key or elem arrays.  That's ok, as we have the overflow pointer
                // at the end of the bucket to protect against pointing past the
                // end of the bucket.
                // 两个溢出指针在bucket末尾用于保证 遍历到bucket末尾的指针
                dst.k = add(dst.k, uintptr(t.keysize))
                dst.e = add(dst.e, uintptr(t.elemsize))
            }
        }
        // 如果没有协程在用老的bucket,就将老的bucket清除,帮助gc
        // Unlink the overflow buckets & clear key/elem to help GC.
        if h.flags&oldIterator == 0 && t.bucket.ptrdata != 0 {
            b := add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))
            // Preserve b.tophash because the evacuation
            // state is maintained there.
            ptr := add(b, dataOffset)
            n := uintptr(t.bucketsize) - dataOffset
            // 只清除k-v部分,tophash用于标识搬迁状态
            memclrHasPointers(ptr, n)
        }
    }
 
    // 如果此次搬迁的bucket等于当前搬迁进度,更新搬迁进度
    if oldbucket == h.nevacuate {
        advanceEvacuationMark(h, t, newbit)
    }
}
 
// 更新搬迁进度
func advanceEvacuationMark(h *hmap, t *maptype, newbit uintptr) {
    // 进度+1
    h.nevacuate++
    // 尝试往后看1024个bucket,确保行为是O(1)的
    // Experiments suggest that 1024 is overkill by at least an order of magnitude.
    // Put it in there as a safeguard anyway, to ensure O(1) behavior.
    stop := h.nevacuate + 1024
    if stop > newbit {
        stop = newbit
    }
    // 寻找没有搬迁过的bucket
    for h.nevacuate != stop && bucketEvacuated(t, h, h.nevacuate) {
        h.nevacuate++
    }
 
    // 现在h.nevacuate之前的bucket都被搬迁完毕了
 
    // 如果所有bucket搬迁完毕
    if h.nevacuate == newbit { // newbit == # of oldbuckets
        // 清除oldbuckets,释放bucket array
        // Growing is all done. Free old main bucket array.
        h.oldbuckets = nil
        // 清除老的溢出bucket
        // [0]表示当前溢出bucket
        // [1]表示老的溢出bucket
        // Can discard old overflow buckets as well.
        // If they are still referenced by an iterator,
        // then the iterator holds a pointers to the slice.
        if h.extra != nil {
            h.extra.oldoverflow = nil
        }
        // 清除正在扩容的标志位
        h.flags &^= sameSizeGrow
    }
}

源码里提到 X, Y part,其实就是我们说的如果是扩容到原来的 2 倍,桶的数量是原来的 2 倍,前一半桶被称为 X part,后一半桶被称为 Y part。一个 bucket 中的 key 会分裂落到 2 个桶中。一个位于 X part,一个位于 Y part。所以在搬迁一个 cell 之前,需要知道这个 cell 中的 key 是落到哪个 Part。

其实很简单,重新计算 cell 中 key 的 hash,并向前“多看”一位,决定落入哪个 Part

设置 key 在原始 buckets 的 tophash 为 evacuatedX 或是 evacuatedY,表示已经搬迁到了新 map 的 x part 或是 y part。新 map 的 tophash 则正常取 key 哈希值的高 8 位。

对于增量扩容来说:某个 key 在搬迁前后 bucket 序号可能和原来相等,也可能是相比原来加上 2^B(原来的 B 值),取决于 hash 值 第 6 bit 位是 0 还是 1。

当搬迁碰到 math.NaN() 的 key 时,只通过 tophash 的最低位决定分配到 X part 还是 Y part(如果扩容后是原来 buckets 数量的 2 倍)。如果 tophash 的最低位是 0 ,分配到 X part;如果是 1 ,则分配到 Y part,已搬迁完的key的tophash值是一个状态值,表示key的搬迁去向

 三、map的结构

Go中的map是一个指针,占用8个字节,指向hmap构造体; 源码src/runtime/map.go中能够看到map的底层构造

每个map的底层构造是hmap,hmap蕴含若干个构造为bmap的bucket数组。每个bucket底层都采纳链表构造。接下来,咱们来具体看下map的构造

在源码中,表示 map 的结构体是 hmap,它是 hashmap 的“缩写”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// A header for a Go map.
type hmap struct {
    // 元素个数,调用 len(map) 时,直接返回此值
    count     int
    flags     uint8
    // buckets 的对数 log_2
    B         uint8
    // overflow 的 bucket 近似数
    noverflow uint16
    // 计算 key 的哈希的时候会传入哈希函数
    hash0     uint32
    // 指向 buckets 数组,大小为 2^B
    // 如果元素个数为0,就为 nil
    buckets    unsafe.Pointer
    // 扩容的时候,buckets 长度会是 oldbuckets 的两倍
    oldbuckets unsafe.Pointer
    // 指示扩容进度,小于此地址的 buckets 迁移完成
    nevacuate  uintptr
    extra *mapextra // optional fields
}

说明一下,B 是 buckets 数组的长度的对数,也就是说 buckets 数组的长度就是 2^B。bucket 里面存储了 key 和 value,后面会再讲。

buckets 是一个指针,最终它指向的是一个结构体:

1
2
3
type bmap struct {
    tophash [bucketCnt]uint8
}

但这只是表面(src/runtime/hashmap.go)的结构,编译期间会给它加料,动态地创建一个新的结构:

1
2
3
4
5
6
7
type bmap struct {
    topbits  [8]uint8
    keys     [8]keytype
    values   [8]valuetype
    pad      uintptr
    overflow uintptr
}

bmap 就是我们常说的“桶”,桶里面会最多装 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是“一类”的。在桶内,又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有8个位置)。

对于这些 overflow 的 bucket,在 hmap 结构体和 bmap 结构体里分别有一个 extra.overflow 和 overflow 字段指向它们。

如果我们仔细看 mapextra 结构体里对 overflow 字段的注释,会发现这里有“文章”。

1
2
3
4
5
6
type mapextra struct {
 overflow    *[]*bmap
 oldoverflow *[]*bmap
  
 nextOverflow *bmap
}

 其中 overflow 这个字段上面有一大段注释,我们来看看前两行:

1
2
// If both key and elem do not contain pointers and are inline, then we mark bucket
// type as containing no pointers. This avoids scanning such maps.

 

 

posted @   踏雪无痕SS  阅读(1430)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 地球OL攻略 —— 某应届生求职总结
· 提示词工程——AI应用必不可少的技术
· Open-Sora 2.0 重磅开源!
· 字符编码:从基础到乱码解决
历史上的今天:
2015-09-26 JavaScript中的闭包(closure)
点击右上角即可分享
微信分享提示