源码分析-Golang Map

相关常量解析

bucketCntBits = 3 // 代表的是bit 
bucketCnt     = 1 << bucketCntBits // 代表的是一个bucket(bmap)最大存储8个key

loadFactorNum = 13
loadFactorDen = 2 // 通过这2者计算得出 负载因子(负载因子关乎到什么时候触发扩容)

maxKeySize  = 128  
maxElemSize = 128 // 

emptyRest      = 0  : 代表该topHash对应的K/V可用 ,或者表示该位置及其后面的bucket也是可用的
emptyOne       = 1  : 仅代表该topHash对应的K/V可用
evacuatedX     = 2 : 与rehash有关,代表的是原先的元素可能被迁移到了X位置(原地),当然也有可能迁移到了Y位置
evacuatedY     = 3
evacuatedEmpty = 4  : 当这个bucket中的元素都迁移完之后,设置evacuatedEmpty
minTopHash     = 5 
当topHash<=5的时候,存储的是状态,否则存储的是hash值
  • 每一个tophash对应的下标都是一个kv

map结构体

  • src/runtime/map.go

  • 内部对象是hmap

    • type hmap struct {
      	// Note: the format of the hmap is also encoded in cmd/compile/internal/gc/reflect.go..
      	count     int // map中的元素个数
      	flags     uint8	// 标识状态
      	B         uint8  // 用于设定buckets的最大个数,为 2^B 个,既 len(buckets)=2^B
      	noverflow uint16 // 溢出的buckets 个数
      	hash0     uint32 // hash seed,涉及到hash函数
      
      	buckets    unsafe.Pointer // buckets的指针对象
      	oldbuckets unsafe.Pointer // 当触发扩容时的buckets
      	nevacuate  uintptr        // 渐进是rehash的进度,有点类似于redis
      
      	extra *mapextra // 
      }
      
  • 同时,与Java的hashMap类似,也是有桶的概念,在golang中则是 bmap

    • type bmap struct {
      	tophash [bucketCnt]uint8 // 可以发现,一个bucket 只会存储8个key 
      }
      编译后生成的实际对象为:
      type bmap struct {
        topbits  [8]uint8
        keys     [8]keytype
        values   [8]valuetype
        pad      uintptr
        overflow uintptr  // 当 k,v 都是非指针对象时,为了避免被gc扫描,会将overflow 移动到hmap,使得bmap依旧不涵指针
      }
      
  • bmap的内存模型为:

    • key/key/key => value/value/value的形式,而不是 key/value/key/value的形式

    • 在这里插入图片描述

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-G2p99OKJ-1631841576712)(/Users/joker/Nutstore Files/我的坚果云/复习/imgs/golang_map_bmap.png)]


MAP的初始化

  • m1 := make(map[int]int)  // 对应的内部函数是: makemap_small
    m2 := make(map[int]int, 10) // 对应的函数是: makemap 创建map支持传递一个参数,表示初始大小
    
  • func makemap_small() *hmap {
    	h := new(hmap)
    	h.hash0 = fastrand()
    	return h
    }
    只是简单的new 一个,不会初始化bucket数组
    
  • 核心函数:

    • 
      
      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()
      
      	// hint 表示的是创建map的时候,预期的大小值,这个与Java的hashMap类似,最终都会使得初始容量为2的n次方
      	B := uint8(0)
      	for overLoadFactor(hint, B) {
      		B++
      	}
      	h.B = B
      
      
      	// 当B==0的时候,代表的是,只有当被put写入的时候,才会触发buckets的初始化
      	if h.B != 0 {
      		var nextOverflow *bmap
      		// 申请创建bucket数组
      		h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
      		if nextOverflow != nil {
      			h.extra = new(mapextra)
      			h.extra.nextOverflow = nextOverflow
      		}
      	}
      
      	return h
      }
      
  • 总结

    • map初始化的时候,总的来说只有2种情况,一种是
      • makemap_small: 这种方式,只会创建hmap,而不会初始化buckets
      • makemap: 将容量自动修改为2的n次方,然后初始化buckets数组

MAP # put

  • 函数: src/runtime/map.go#mapassign

    • 第一阶段: 初始化阶段

      • 	// ... 省略一些常规的debug 和校验
        	
        	// 判断是否并发读写
        	if h.flags&hashWriting != 0 {
        		throw("concurrent map writes")
        	}
        	// 编译时就会得到对应的hash函数
        	hash := t.hasher(key, uintptr(h.hash0))
        	
        	// 标识处于写状态(用于并发读写判断)
        	h.flags ^= hashWriting
        	
        	// 如果是makemap_small,则此时是没有初始化buckets的
        	if h.buckets == nil {
        		h.buckets = newobject(t.bucket) // newarray(t.bucket, 1)
        	}
        
        
        
    • 第二阶段: 定位bucket阶段

      •  // 获取bucket的内存地址
        b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize)))
        // 获取hash的高8位作为key
        top := tophash(hash)
        
        	var inserti *uint8
        	var insertk unsafe.Pointer
        	var elem unsafe.Pointer
        bucketloop:
        	for {
        	// 遍历每个cell
        		for i := uintptr(0); i < bucketCnt; i++ {
        		// 如果当前hash与高8位hash不匹配
        			if b.tophash[i] != top {
        				// 如果bucket为nil,并且当前元素没有赋值
        				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))
        				}
        				// 如果当前的bucket处于 overflow状态,代表着容量不足,则直接跳出整个写入
        				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))
        			}
        			// 只有相同的key,才能更新
        			if !t.key.equal(key, k) {
        				continue
        			}
        			// 通过内存拷贝,更新key,value ,
        			if t.needkeyupdate() {
        				typedmemmove(t.key, k, key)
        			}
        			// 最后通过指针句柄移动,指向新的value
        			elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
        			goto done
        		}
        		// 如果上述没有退出,意味着当前bucket中的元素都满了,我们需要获取下一个
        		ovf := b.overflow(t)
        		if ovf == nil {
        		// 代表这所有的bucket都满了
        			break
        		}
        		// 遍历获取下一个bucket,继续for循环
        		b = ovf
        	}
        	// 表明没有找到 相同的key,没有插入,所以可能是bucket都满了
        	// 如果当前需要触发扩容(既当前的每个bucket中的平均元素个数>=loadfactor的时候就会触发扩容)
        	if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
        		// 开始扩容
        		hashGrow(t, h)
        		// 因为扩容涉及到rehash,所以需要重新走一遍
        		goto again 
        	}
        
    • 第三阶段: 申请新的bucket阶段

      • 当到这里时,代表着,bucket满了,需要申请新的bucket,然后一切开始重新赋值
        if inserti == nil {
        		// The current bucket and all the overflow buckets connected to it are full, allocate a new one.
        		newb := h.newoverflow(t, b)
        		inserti = &newb.tophash[0]
        		insertk = add(unsafe.Pointer(newb), dataOffset)
        		elem = add(insertk, bucketCnt*uintptr(t.keysize))
        	}
        	
        	// 存储k,v 如果不是指针,则还需要解引用
        	if t.indirectkey() {
        		kmem := newobject(t.key)
        		*(*unsafe.Pointer)(insertk) = kmem
        		insertk = kmem
        	}
        	if t.indirectelem() {
        		vmem := newobject(t.elem)
        		*(*unsafe.Pointer)(elem) = vmem
        	}
        	// 内存拷贝key
        	typedmemmove(t.key, insertk, key)
        	*inserti = top
        	h.count++
        	
        	// 最后,消除flag位
        done:
        	if h.flags&hashWriting == 0 {
        		throw("concurrent map writes")
        	}
        	h.flags &^= hashWriting
        	if t.indirectelem() {
        		elem = *((*unsafe.Pointer)(elem))
        	}
        	return elem
        

Map 扩容 rehash

  • golang的rehash是一种渐进式hash的过程,先通过hashGrow申请新的bucket数组(或者是压根不申请:既第二种触发情况),然后每次写数据或者是读数据的时候,会判断当前map是否处于rehash过程,是的话,则会辅助rehash

  • 最关键函数是evacuate

  • 扩容的原因分两种

    • 一种是因为达到了loadFactor而导致的扩容

    • 另外一种则是因为 太多overflow而导致

      • // 装载因子超过 6.5
        func overLoadFactor(count int, B uint8) bool {
          return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
        }
        
        // overflow buckets 太多
        func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
          if B > 15 {
            B = 15
          }
          return noverflow >= uint16(1)<<(B&15)
        }
        
    • 如果是前者,则直接扩容bucket一倍( 既 二进制下左移一位)

  • func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
    	b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
    	newbit := h.noldbuckets()
    	if !evacuated(b) { // 先判断当前bucket是否已经rehash过了 (通过内部的flag来判断,因为bmap会被装换成上面的bmap)
    		var xy [2]evacDst // 因为扩容可能会是2倍扩容,所以定义一个长度为2 的数组,0用来定位之前的元素
    		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))
    
    		if !h.sameSizeGrow() {
    			// 两倍扩容,所以可能会影响到另外一个元素,因此记录被影响的元素
    			y := &xy[1]
    			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,因为bucket都是连在一起的
    		for ; b != nil; b = b.overflow(t) {
    			k := add(unsafe.Pointer(b), dataOffset)
    			e := add(k, bucketCnt*uintptr(t.keysize))
    			// 遍历bucket内部的每个元素
    			for i := 0; i < bucketCnt; i, k, e = i+1, add(k, uintptr(t.keysize)), add(e, uintptr(t.elemsize)) {
    				top := b.tophash[i]
    				// 如果是一个空的值(既未赋值),则直接标记为被rehash过了
    				if isEmpty(top) {
    					b.tophash[i] = evacuatedEmpty
    					continue
    				}
    				// 不为空,但是又不是初始值,panic
    				if top < minTopHash {
    					throw("bad map state")
    				}
    				// 如果是指针,则触发解引用
    				k2 := k
    				if t.indirectkey() {
    					k2 = *((*unsafe.Pointer)(k2))
    				}
    				var useY uint8
    				if !h.sameSizeGrow() {
    					// 如果是 2倍扩容,则重新计算一次hash值
    					hash := t.hasher(k2, uintptr(h.hash0))
    					if h.flags&iterator != 0 && !t.reflexivekey() && !t.key.equal(k2, k2) {
    						// 表明当前有routine在遍历这个map,同时,重新计算hash后key不匹配,代表着该value需要
    						挪到一个新的bucket,因此,为了有一个新的hash, 这里会做一个 &1的操作,该操作的妙处在于
    						使得rehash后的bucket下标,要么在原来的位置,要么是在 bucketIndex+2^B 个位置处
    						useY = top & 1,这点其实与Java很像 ,但是Java怎么实现的我忘了 :-(
    						top = tophash(hash)
    					} else {
    						if hash&newbit != 0 {
    							useY = 1
    						}
    					}
    				}
    
    				if evacuatedX+1 != evacuatedY || evacuatedX^1 != evacuatedY {
    					throw("bad evacuatedN")
    				}
    
    				b.tophash[i] = evacuatedX + useY // evacuatedX + 1 == evacuatedY
    				dst := &xy[useY]                 // evacuation destination
    				// 如果当前元素的bucket刚好是最后一个bucket
    				if dst.i == bucketCnt {
    					dst.b = h.newoverflow(t, dst.b)
    					dst.i = 0
    					dst.k = add(unsafe.Pointer(dst.b), dataOffset)
    					dst.e = add(dst.k, bucketCnt*uintptr(t.keysize))
    				}
    				dst.b.tophash[dst.i&(bucketCnt-1)] = top // mask dst.i as an optimization, to avoid a bounds check
    				// 拷贝赋值/直接赋值
    				if t.indirectkey() {
    					*(*unsafe.Pointer)(dst.k) = k2 // copy pointer
    				} else {
    					typedmemmove(t.key, dst.k, k) // copy elem
    				}
    				if t.indirectelem() {
    					*(*unsafe.Pointer)(dst.e) = *(*unsafe.Pointer)(e)
    				} else {
    					typedmemmove(t.elem, dst.e, e)
    				}
    				dst.i++
    				dst.k = add(dst.k, uintptr(t.keysize))
    				dst.e = add(dst.e, uintptr(t.elemsize))
    			}
    		}
    		// 最后,将hmap与oldBuckets解引用,使得可以被gc
    		if h.flags&oldIterator == 0 && t.bucket.ptrdata != 0 {
    			b := add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))
    			ptr := add(b, dataOffset)
    			n := uintptr(t.bucketsize) - dataOffset
    			memclrHasPointers(ptr, n)
    		}
    	}
    
    	if oldbucket == h.nevacuate {
    	// 最后判断是否 rehash全部完毕,是则消除一些flag位
    		advanceEvacuationMark(h, t, newbit)
    	}
    }
    

Map的删除

  • func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
    	// ....省略debug信息
    	if h == nil || h.count == 0 {
    		if t.hashMightPanic() {
    			t.hasher(key, 0) // see issue 23734
    		}
    		return
    	}
    	// 并发读写判断
    	if h.flags&hashWriting != 0 {
    		throw("concurrent map writes")
    	}
    	// 获取这个 key所对应的hash
    	hash := t.hasher(key, uintptr(h.hash0))
    
     // 添加安全标识
    	h.flags ^= hashWriting
    	// 通过hash的高8位获取得到对应的bucket下标
    	bucket := hash & bucketMask(h.B)
    	// 如果此时正在扩容,则辅助扩容
    	if h.growing() {
    		growWork(t, h, bucket)
    	}
    	// 通过偏移量:获取bmap(cell)的内存地址,这时候是链表的首位
    	b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize)))
    	bOrig := b
    	// 获取hash的高8位
    	top := tophash(hash)
    search:
    	for ; b != nil; b = b.overflow(t) {
    		for i := uintptr(0); i < bucketCnt; i++ {
    			if b.tophash[i] != top {
    			// 如果该cell已经被标识为全部都为空,代表着后面的不需要去查询判断,快速结束
    				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
    			}
    			
    			if t.indirectkey() {
    				*(*unsafe.Pointer)(k) = nil
    			} else if t.key.ptrdata != 0 {
    				memclrHasPointers(k, t.key.size)
    			}
    			// 获取对应的value
    			e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
    			// 清除value
    			if t.indirectelem() {
    				*(*unsafe.Pointer)(e) = nil
    			} else if t.elem.ptrdata != 0 {
    				memclrHasPointers(e, t.elem.size)
    			} else {
    				memclrNoHeapPointers(e, t.elem.size)
    			}
    			// 标识该cell可用
    			b.tophash[i] = emptyOne
    			if i == bucketCnt-1 {
    				if b.overflow(t) != nil && b.overflow(t).tophash[0] != emptyRest {
    					goto notLast
    				}
    				// 说明上一个bucket的topHash[0]已经被设置为了emptyRest,既整个bucket都是可用的
    			} else {
    				if b.tophash[i+1] != emptyRest {
    					goto notLast
    				}
    				// 说明下一个topHash已经被设置为了emptyRest,则之前的都是可用的
    			}
    			// 则设置为emptyRest
    			for {
    				b.tophash[i] = emptyRest
    				if i == 0 {
    					if b == bOrig {
    						break // beginning of initial bucket, we're done.
    					}
    					// Find previous bucket, continue at its last entry.
    					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--
    			// Reset the hash seed to make it more difficult for attackers to
    			// repeatedly trigger hash collisions. See issue 25237.
    			if h.count == 0 {
    				h.hash0 = fastrand()
    			}
    			break search
    		}
    	}
    	// 消除保护位
    	if h.flags&hashWriting == 0 {
    		throw("concurrent map writes")
    	}
    	h.flags &^= hashWriting
    }
    

Map的获取

  • func mapaccessK(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, unsafe.Pointer) {
    	if h == nil || h.count == 0 {
    		return nil, nil
    	}
    	hash := t.hasher(key, uintptr(h.hash0))
    	m := bucketMask(h.B)
    	// 通过hash的低8位,获取得到对应的bucket地址
    	b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + (hash&m)*uintptr(t.bucketsize)))
    	// 说明正在扩容
    	if c := h.oldbuckets; c != nil {
    		// 如果不是等size扩容
    		if !h.sameSizeGrow() {
    			// 则获取得到之前的bucket的地址
    			// There used to be half as many buckets; mask down one more power of two.
    			m >>= 1
    		}
    		oldb := (*bmap)(unsafe.Pointer(uintptr(c) + (hash&m)*uintptr(t.bucketsize)))
    		if !evacuated(oldb) {
    		// 如果之前的还没有被rehash,说明数据还在原来的地方,则利用之前的bucket
    			b = oldb
    		}
    	}
    	top := tophash(hash)
    bucketloop:
    // 遍历cell进行匹配,然后找到则返回结果
    	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 k, e
    			}
    		}
    	}
    	return nil, nil
    }
    

总结

  • golang map中设置了一大堆状态变量,如emptyOne,emptyRest 等等,用处在于可以快速的失败

  • map在底层内部是 hmap+bmap实现,hash冲突的解决方法与Java类似,也是通过拉链法解决,默认情况下,一个bmap只能存储8个k,v ,并且k,v在bmap中的内存模型为key紧密排列之后再value紧密排列,原因在于减少padding

  • bmap的内部的overflow指向的是hmap的extra ,作用在于避免被gc扫描

  • 与Java类似,也有一个关键的负载因子,golang 默认为6.5 ,该值的计算方式为 count / bucket的数量 ,既 计算的结果为 每个bucket推荐存储的cell数量

  • golang 的map的扩容采取的是与redis类似的,渐进式rehash,既一次只扩容2个bucket,同时golang的扩容时机分两种,一种是达到负载因子,还有一种则是过多的overflowBucket (这个值的最大值为 2^15个),达到负载因子的扩容,会导致整个bucket的数量扩容一倍,后者则是等size扩容

  • golang rehash的时候与Java类似,也是 要么是在原地,要么是当前bucket的位置的两倍,具体实现是通过 原先的hash & 1

  • 基本流程都是一样的

    • golang map bucket定位是通过 hash的低6位来定位得到bucket,得到bucket之后,通过hash的高8位,得到topHash的下标
    • 如果该bucket找不到对应的值,则到该bucket的overflow bucket中定位
    • 然后是遍历内部的cell ,topHash匹配,则开始对应的处理
    • 查找
      • 如果当前正在扩容,既oldBuckets 不为空,则会先判断是samesize扩容还是已被扩容, 如果是双倍扩容,则先获取之前的bucket地址,判断是否已经全部rehash,未结束的话,则用原来的bucket
      • 如果对应的topHash中有匹配上的,则直接返回
    • 添加
      • 判断是否正在扩容,正处于扩容的话,会先辅助扩容
      • 然后遍历bucket中的cell,如果有重复的,则更新,否则的话插入新的数据
      • 最后判断是否满足扩容的两个条件,是的话,则开始准备扩容,但是不是直接扩容,而是标记为可以扩容了
    • 删除
      • 同样,也会判断是否正在扩容,是的话,辅助扩容
      • 然后遍历bucket中的cell,匹配则置空,最后会优化判断是否之前的bucket也是empty了(辅助以后的操作)

问题

  • map无序的原因在于

    • rehash的时候会重新计算一次hash,加入一个随机因子
  • bmap中overflow的作用

    • 作用在于当bucket溢出时(因为bucket中的元素个数是固定的8个),既当有第9个key也在这个bucket的时候,则会创建一个bucket.然后通过overflow指针连接形成链表
    • 为什么bucket中的个数是固定的
      • 因为bmap的tophash是高8位hash ,所以也就8位了(但是好像没啥依据)
    • overflow 个数什么时候增加
      • 当put的时候,这个bucket元素满了,并且overflow的bucket都满了,则会申请创建一个新的bucket,指向overflow
  • 啥是tophash,作用是啥

    • tophash是hash的高8位
    • 作用在于:
      • tophash是hash的高8位,作用在于快速定位, 因为每个bucket都有一个hash,这个topHash 可以与其快速匹配,不满足则快速下一个
  • 扩容的时机

      1. 当 每个bucket中的cell个数>= loadFactor的时候
      2. overflow的个数> 2^15 方的时候(最大值为2^15)
  • 为什么bmap采取的是key/key/key/value/value的形式,而非key/value的形式

    • 还是与操作系统有关,操作系统cache 是以cache line的形式存储在cache block中,每一行的大小是固定的,如果同一个数据缓存在2个cache line中,既命中率低,则效率低,所以会有padding ,map的前者这种形式,使得padding只需要放到value的最后就行,而不需要 key/value/padding 这种形式
  • hmap中flags的作用,B的作用

    • flags的作用在于判断是否处于并发读写状态,写的时候会标识为写状态,读同理
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值