Golang 自学总结(二)

89 阅读20分钟
十、内存

谈谈内存泄漏,什么情况下内存会泄漏?怎么定位排查内存泄漏问题?
go 中的内存泄漏一般都是 goroutine 泄漏,就是 goroutine 没有被关闭,或者没有添加超时控制,让goroutine 一只处于阻塞状态,不能被 GC。
内存泄露有下面一些情况

  1. 如果goroutine在执行时被阻塞而无法退出,就会导致goroutine的内存泄漏,一个goroutine的最低栈大小为2KB,在高并发的场景下,对内存的消耗也是非常恐怖的;
  2. 互斥锁未释放或者造成死锁会造成内存泄漏;
  3. time.Ticker是每隔指定的时间就会向通道内写数据。作为循环触发器,必须调用stop方法才会停止,从而被GC掉,否则会一直占用内存空间;
  4. 字符串的截取引发临时性的内存泄漏。
func main() {
 var str0 = "12345678901234567890"
 str1 := str0[:10]
}
  1. 切片截取引起子切片内存泄漏
func main() {
   var s0 = []int{0,1,2,3,4,5,6,7,8,9}
   s1 := s0[:3]
}
  1. 函数数组传参引发内存泄漏
    【如果我们在函数传参的时候用到了数组传参,且这个数组够大(我们假设数组大小为100万,64位机上消耗的内存约为800w字节,即8MB内存),或者该函数短时间内被调用N次,那么可想而知,会消耗大量内存,对性能产生极大的影响,如果短时间内分配大量内存,而又来不及GC,那么就会产生临时性的内存泄漏,对于高并发场景相当可怕。】
排查方式:

一般通过 pprof 是 Go 的性能分析工具,在程序运行过程中,可以记录程序的运行信息,可以是 CPU 使用情况、内存使用情况、goroutine 运行情况等,当需要性能调优或者定位 Bug 时候,这些记录的信息是相当重要。
补充:
在Go中内存泄露分为暂时性内存泄露和永久性内存泄露。
暂时性内存泄露, string相比切片少了一个容量的cap字段,可以把string当成一个只读的切片类型。获取长string或者切片中的一段内容,由于新生成的对象和老的string或者切片共用一个内存空间,会导致老的string和切片资源暂时得不到释放,造成短暂的内存泄漏。
永久性内存泄露, 主要由goroutine永久阻塞而导致泄漏以及time.Ticker未关闭导致泄漏引起。

内存管理

Go语言的内存分配器采用了多级缓存分配模型,该模型将引入了线程缓存(Thread Cache)、中心缓存(Central Cache)和页堆(Page Heap)三个组件分级管理内存。 7f983420738e4ccd9c3aed1de307baf8_副本.png 线程缓存 属于每一个独立的线程,它能够满足线程上绝大多数的内存分配需求,因为不涉及多线程,所以也不需要使用互斥锁来保护内存,这能够减少锁竞争带来的性能损耗。
当线程缓存不能满足需求时,运行时会使用 中心缓存 作为补充解决小对象的内存分配。
在遇到大对象时,内存分配器会选择 页堆 直接分配大内存。
f9ef2dd6df824351bd518cb672b23ee0_副本.png 在 Golang 中, mcache , mspan , mcentral 和 mheap 是内存管理的四大组件

  • mspan 是内管管理的基本单元,
  • mcache 充当 “线程缓存”
  • mcentral 充当 “中心缓存”
  • mheap 充当 “页堆”
  • 下级组件内存不够时向上级申请一个或多个 mspan。

根据对象的大小不同,内部会使用不同的内存分配机制,详细参考函数 mallocgo()。

  • 小于16KB 会使用微小对象内存分配器从 P 中的 mcache 分配,主要使用 mcache.tinyXXX 这类的字段。
  • 16~32KB 从 P 中的 mcache 中分配。
  • 大于32KB 直接从 mheap 中分配。

golang中的内存申请流程如下图所示: afecdedd49d64d6e95797f9054cda5ea_副本.png

内存分配

image.png Golang 程序在启动时,会向操作系统申请一定区域的内存,分为栈(Stack)和堆(Heap)。

  • 栈内存会随着函数的调用分配和回收;
  • 堆内存由程序申请分配,由垃圾回收器(Garbage Collector)负责回收。

性能上,栈内存的使用和回收更迅速一些;
尽管Golang 的 GC 很高效,但也不可避免的会带来一些性能损耗。因此,Go 优先使用栈内存进行内存分配。在不得不将对象分配到堆上时,才将特定的对象放到堆中。

堆和栈都是编程语言里的虚拟概念,并不是说在物理内存上有堆和栈之分,两者的主要区别是栈是每个线程或者协程独立拥有的,从栈上分配内存时不需要加锁。而整个程序在运行时只有一个堆,从堆中分配内存时需要加锁防止多个线程造成冲突,同时回收堆上的内存块时还需要运行可达性分析、引用计数等算法来决定内存块是否能被回收,所以从分配和回收内存的方面来看栈内存效率更高。

1.因为栈比堆更高效,不需要 GC,因此 Go 会尽可能的将内存分配到栈上。
2.当分配到栈上可能引起非法内存访问等问题后,会使用堆,主要场景有:

  • 当一个值可能在函数被调用后访问,这个值极有可能被分配到堆上。
  • 当编译器检测到某个值过大,这个值会被分配到堆上。
  • 当编译时,编译器不知道这个值的大小(slice、map…)这个值会被分配到堆上。
内存逃逸

1)本该分配到栈上的变量,跑到了堆上,这就导致了内存逃逸。
2)栈是高地址到低地址,栈上的变量,函数结束后变量会跟着回收掉,不会有额外性能的开销。
3)变量从栈逃逸到堆上,如果要回收掉,需要进行 gc,那么 gc 一定会带来额外的性能开销。编程语言不断优化 gc 算法,主要目的都是为了减少 gc 带来的额外性能开销,变量一旦逃逸会导致性能开销变大。

内存逃逸的情况如下:

1)方法内返回局部变量指针。
2)向 channel 发送指针数据。
3)在闭包中引用包外的值。
4)在 slice 或 map 中存储指针。
5)切片(扩容后)长度太大。
6)在 interface 类型上调用方法。

Channel 分配在栈上还是堆上?

Channel被设计用来实现协程间通信的组件,其作用域和生命周期不可能仅限于某个函数内部,所以golang直接将其分配在堆上。

哪些对象分配在堆上,哪些对象分配在栈上?

Go 通过编译阶段的逃逸分析来判断变量应该被分配到栈还是堆上,总结以下几点:

  • 栈比堆更高效,不需要 GC,因此 Go 会尽可能的将内存分配到栈上。Go 的协程栈可以自动扩 容和缩容
  • 当分配到栈上可能会引起非法内存访问等问题,则会使用堆,如: 当一个值在函数被调用后访问 (即作为返回值返回变量地址),这个值极有可能被分配到堆 上;
    当编译器检测到某个值过大,这个值被分配到堆上(栈扩容和缩容有成本);
    当编译时,编译器不知道这个值的大小(slice、map等引用类型)这个值会被分配到堆上。
    最后,不要去猜值在哪,只有编译器和编译器开发者知道。
介绍一下大对象小对象,为什么小对象多了会造成 gc 压力?

小于等于32k的对象就是小对象,其它都是大对象。一般小对象通过 mspan 分配内存;大对象则直接由 mheap 分配内存。通常小对象过多会导致GC三色法消耗过多的CPU。优化思路是,减少对象分配。

十一、defer

底层

每个 defer 语句都对应一个_defer 实例,多个实例使用指针连接起来形成一个单连表,保存在 gotoutine 数据结构中,每次插入_defer 实例,均插入到链表的头部,函数结束再一次从头部取出,从而形成后进先出的效果。

作用:

defer 延迟函数,释放资源,收尾工作;如释放锁、关闭文件、关闭链接、捕获panic;
避坑指南:defer函数紧跟在资源打开后面,否则defer可能得不到执行,导致内存泄露。
多个 defer 调用顺序是 后入先),defer 后的操作可以理解为压入栈中。

返回机制

defer 可以修改返回值
defer、return、返回值三者的执行逻辑应该是:
return最先执行,return负责将结果写入返回值中;
接着defer开始执行一些收尾工作;
最后函数携带当前返回值(可能和最初的返回值不相同)退出。

无名返回值:

package main

import (
	"fmt"
)

func a() int {
	var i int
	defer func() {
		i++
		fmt.Println("defer2:", i) 
	}()
	defer func() {
		i++
		fmt.Println("defer1:", i) 
	}()
	return i
}

func main() {
	fmt.Println("return:", a()) 
}

// 结果:
// defer1: 1
// defer2: 2
// return: 0

解释:
返回值由变量 i 赋值,相当于返回值 = i = 0。第二个 defer 中 i++ = 1, 第一个 defer 中 i++ = 2,所以最终 i 的值是2。但是返回值已经被赋值了,即使后续修改 i 也不会影响返回值。最终返回值返回,所以 main 中打印 0。

有名返回值:

package main

import (
	"fmt"
)

func b() (i int) {
	defer func() {
		i++
		fmt.Println("defer2:", i)
	}()
	defer func() {
		i++
		fmt.Println("defer1:", i)
	}()
	return i //或者直接写成return
}

func main() {
	fmt.Println("return:", b())
}

// 结果
// defer1: 1
// defer2: 2
// return: 2

解释:
这里已经指明了返回值就是i,所以后续对i进行修改都相当于在修改返回值,所以最终函数的返回值是2。

defer和recover捕获异常

Go程序抛出一个panic异常,在defer中通过recover捕获异常,然后处理

package main
import "fmt"

func test() {
    //在函数退出前,执行defer
    //捕捉异常后,程序不会异常退出
    defer func() {
        err := recover() //内置函数,可以捕捉到函数异常
        if err != nil {
            //这里是打印错误,还可以进行报警处理,例如微信,邮箱通知
            fmt.Println("err错误信息:", err)
        }
    }()
    //如果没有异常捕获,直接报错panic,运行时出错
    num1 := 10
    num2 := 0
    res := num1 / num2
    fmt.Println("res结果:", res)
}

func main() {
    test()
    fmt.Println("如果程序没退出,就走我这里")
}
defer用于关闭文件和互斥锁

关闭文件:

func ReadFile(filename string) ([]byte, error) {
    f, err := os.Open(filename)
    if err != nil {
        return nil, err
    }
    defer f.close()
    return ReadAll()
}

关闭互斥锁

var mu sync.Mutex
var m = make(map[string]int)
 
func lookup(key string) int {
    mu.Lock()
    defer mu.Unlock()
    return m[key]
}
调用os.Exit时defer不会被执行
func deferExit() {
    defer func() {
        fmt.Println("defer")
    }()
    
    os.Exit(0)
}

当调用 os.Exit() 方法退出程序时,defer 并不会被执行,上面的 defer 并不会输出。

十二、接口

接口声明的格式

每个接口类型由数个方法组成。接口的形式代码如下:

type 接口类型名 interface{
    方法名1( 参数列表1 ) 返回值列表1
    方法名2( 参数列表2 ) 返回值列表2
    …
}

对各个部分的说明:

  • 接口类型名:使用 type 将接口定义为自定义的类型名。
  • 方法名:当方法名首字母是大写时,且这个接口类型名首字母也是大写时,这个方法可以被接口所在的包(package)之外的代码访问。
  • 参数列表、返回值列表:参数列表和返回值列表中的参数变量名可以被忽略,例如:
实现接口的条件

如果一个任意类型 T 的方法集为一个接口类型的方法集的超集,则我们说类型 T 实现了此接口类型。T 可以是一个非接口类型,也可以是一个接口类型。
实现关系在Go语言中是隐式的。两个类型之间的实现关系不需要在代码中显式地表示出来。Go语言中没有类似于 implements 的关键字。 Go编译器将自动在需要的时候检查两个类型之间的实现关系。
接口定义后,需要实现接口,调用方才能正确编译通过并使用接口。接口的实现需要遵循两条规则才能让接口可用。
接口被实现的条件一:接口的方法与实现接口的类型方法格式一致
接口被实现的条件二:接口中所有方法均被实现

类型断言

类型断言(Type Assertion)是一个使用在接口值上的操作,用于检查接口类型变量所持有的值是否实现了期望的接口或者具体的类型。
在Go语言中类型断言的语法格式如下:

value, ok := x.(T)

其中,x 表示一个接口的类型,T 表示一个具体的类型(也可为接口类型)。

package main

import (
    "fmt"
)

func main() {
    var x interface{}
    x = 10
    value, ok := x.(int)
    fmt.Print(value, ",", ok)
}

// 10,true

类型断言还可以配合 switch 使用,示例代码如下:

package main

import (
    "fmt"
)

func main() {
    var a int
    a = 10
    getType(a)
}

func getType(a interface{}) {
    switch a.(type) {
    case int:
        fmt.Println("the type of a is int")
    case string:
        fmt.Println("the type of a is string")
    case float64:
        fmt.Println("the type of a is float")
    default:
        fmt.Println("unknown type")
    }
}

// the type of a is int

空接口类型 interface{}

空接口是接口类型的特殊形式,空接口没有任何方法,因此任何类型都无须实现空接口。从实现的角度看,任何值都满足这个接口的需求。因此空接口类型可以保存任何值,也可以从空接口中取出原值。
将值保存到空接口

var any interface{}

any = 1
fmt.Println(any)

any = "hello"
fmt.Println(any)

any = false
fmt.Println(any)

//1
//hello
//false

对代码的说明:
第 1 行,声明 any 为 interface{} 类型的变量。
第 3 行,为 any 赋值一个整型 1。
第 4 行,打印 any 的值,提供给 fmt.Println 的类型依然是 interface{}。
第 6 行,为 any 赋值一个字符串 hello。此时 any 内部保存了一个字符串。但类型依然是 interface{}。
第 9 行,赋值布尔值。
从空接口获取值
保存到空接口的值,如果直接取出指定类型的值时,会发生编译错误,代码如下:

// 声明a变量, 类型int, 初始值为1
var a int = 1

// 声明i变量, 类型为interface{}, 初始值为a, 此时i的值变为1
var i interface{} = a

// 声明b变量, 尝试赋值i
var b int = i 

var b int = i 代码编译报错:cannot use i (type interface {}) as type int in assignment: need type assertion
编译器告诉我们,不能将i变量视为int类型赋值给b。
在代码 var i interface{} = a 中,将 a 的值赋值给 i 时,虽然 i 在赋值完成后的内部值为 int,但 i 还是一个 interface{} 类型的变量。类似于无论集装箱装的是茶叶还是烟草,集装箱依然是金属做的,不会因为所装物的类型改变而改变。
为了让第 8 行的操作能够完成,编译器提示我们得使用 type assertion,意思就是类型断言。
使用类型断言修改第 8 行代码如下:
var b int = i.(int)
修改后,代码可以编译通过,并且 b 可以获得 i 变量保存的 a 变量的值:1。

空接口的值比较

空接口在保存不同的值后,可以和其他变量值一样使用 == 进行比较操作。空接口的比较有以下几种特性。

  1. 类型不同的空接口之间的比较,结果不相同
    保存有类型不同的值的空接口进行比较时,Go语言会优先比较值的类型。因此类型不同,比较结果也是不相同的,代码如下:
// a保存整型
var a interface{} = 100

// b保存字符串
var b interface{} = "hi"

// 两个空接口不相等
fmt.Println(a == b)

//false
  1. 不能比较空接口中的动态值
    当接口中保存有动态类型的值时,运行时将触发错误,代码如下:
// c保存包含10的整型切片
var c interface{} = []int{10}

// d保存包含20的整型切片
var d interface{} = []int{20}

// 这里会发生崩溃
fmt.Println(c == d)  //panic: runtime error: comparing uncomparable type []int

这是一个运行时错误,提示 []int 是不可比较的类型。下表中列举出了类型及比较的几种情况。

interface 数据结构

c2b859e061e441af9c128db872cd4e51_副本.png
eface 表示空的 interface{},它用两个机器字长表示,第一个字 _type 是指向实际类型描述的指针,第二个字 data 代表数据指针。
iface 表示至少带有一个函数的 interface, 它也用两个机器字长表示,第一个字 tab 指向一个 itab 结构,第二个字 data 代表数据指针。

data

data 用来保存实际变量的地址。
data 中的内容会根据实际情况变化,因为 golang 在函数传参和赋值时是 值传递 的,所以:

  • 如果实际类型是一个值,那么 interface 会保存这个值的一份拷贝。interface 会在堆上为这个值分配一块内存,然后 data 指向它。
  • 如果实际类型是一个指针,那么 interface 会保存这个指针的一份拷贝。由于 data 的长度恰好能保存这个指针的内容,所以 data 中存储的就是指针的值。它和实际数据指向的是同一个变量。 以 interface{} 的赋值为例:

3d2395ca798e4520bac68bf0022b9366_副本.png
上图中, i1 和 i2 是 interface,A 为要赋值给 interface 的对象。
i1 = A 将 A 的值赋值给 i1,则 i1 中的 data 中的内容是一块新内存的地址 (0x123456),这块内存的值从 A 拷贝。
i2 = &A 将 A 的地址赋值给 i2,则 i2 中的 data 的值为 A 的地址,即 0xabcdef;

itab

itab 表示 interface 和 实际类型的转换信息。对于每个 interface 和实际类型,只要在代码中存在引用关系, go 就会在运行时为这一对具体的 <Interface, Type> 生成 itab 信息。

  • inter 指向对应的 interface 的类型信息。
  • type 和 eface 中的一样,指向的是实际类型的描述信息 _type。
  • fun 为函数列表,表示对于该特定的实际类型而言,interface 中所有函数的地址。

07a74776ae544d2aadeeb7f70308f161_副本.png

_type

_type 表示类型信息。每个类型的 _type 信息由编译器在编译时生成。其中:

  • size 为该类型所占用的字节数量。
  • kind 表示类型的种类,如 bool、int、float、string、struct、interface 等。
  • str 表示类型的名字信息,它是一个 nameOff(int32) 类型,通过这个 nameOff,可以找到类型的名字字符串
  • 灰色的 extras 对于基础类型(如 bool,int, float 等)是 size 为 0 的,它为复杂的类型提供了一些额外信息。例如为 struct 类型提供 structtype,为 slice 类型提供 slicetype 等信息。
  • 灰色的 ucom 对于基础类型也是 size 为 0 的,但是对于 type Binary int 这种定义或者是其它复杂类型来说,ucom 用来存储类型的函数列表等信息。
  • 注意 extras 和 ucom 的圆头箭头,它表示 extras 和 ucom 不是指针,它们的内容位于 _type 的内存空间中。
interfacetype

interfacetype 也并没有什么神奇的地方,只是 _type 为 interface 类型提供的另一种信息罢了。 它包括这个 interface 所申明的所有函数信息。

7db613d14a204346a830ac655704b657_副本.png

golang实现面向对象的封装、继承、多态

封装、继承、多态和抽象是面向对象的4个基本特征。
1)封装
基本介绍:封装就是把抽象出的字段和字段的操作封装在一起,数据被保护在内部,程序的其他包只有通过被授权的操作(方法)才能对字段进行操作。
优点:隐藏实现细节;可以对数据进行验证。
实现如下面代码所示,需要注意的是,在golang内,除了slice、map、channel和显示的指针类型属于引用类型外,其它类型都属于值类型。
引用类型作为函数入参传递时,函数对参数的修改会影响到原始调用对象的值;
值类型作为函数入参传递时,函数体内会生成调用对象的拷贝,所以修改不会影响原始调用对象。所以在下面GetName中,接收器使用 this *Person 指针对象定义。当传递的是小对象,且不需要更改调用对象时,使用值类型做为接收器;大对象或者需要更改调用对象时使用指针类型作为接收器。

type Person struct {
	name string
	age  int
}

func NewPerson() Person {
	return Person{}
}

func (p *Person) SetName(name string) {
	p.name = name
}

func (p *Person) GetName() string {
	return p.name
}

func (p *Person) SetAge(age int) {
	p.age = age
}

func (p *Person) GetAge() int {
	return p.age
}

func main() {
	p := NewPerson()
	p.SetName("xiaofei")
	fmt.Println(p.GetName())
}

2)继承
基本介绍:当多个结构体存在相同的属性(字段)和方法时,可以从这些结构体中抽象出一个基结构体A,在A中定义这些相同的属性和方法。其他的结构体不需要重新定义这些属性和方法,只需嵌套一个匿名结构体A即可。
优点:可以解决代码复用,让编程更加靠近 人类思维。
实现:在golang中,如果一个struct嵌套了另一个匿名结构体,那么这个结构体可以直接访问匿名结构体的字段和方法,从而实现继承特性。
同时,一个struct还可以嵌套多个匿名结构体,那么该struct可以直接访问嵌套的匿名结构体的字段和方法,从而实现多重继承。

type Student struct {
	Person
	StuId int
}

func (this *Student) SetId(id int) {
	this.StuId = id
}

func (this *Student) GetId() int {
	return this.StuId
}

func main() {
	stu := Student{}

	stu.SetName("xiaofei")  // 可以直接访问Person的Set、Get方法
	stu.SetAge(22)
	stu.SetId(123)

	fmt.Printf("I am a student,My name is %s, my age is %d, my id is %d", stu.GetName(), stu.GetAge(), stu.GetId)
}

3)抽象
将共同的属性和方法抽象出来形成一个不可以被实例化的类型,由于抽象和多态是相辅相成的,或者说抽象的目的就是为了实现多态。
4)多态
基本介绍:基类指针可以指向任何派生类的对象,并在运行时绑定最终调用的方法的过程被称为多态。 多态是运行时特性,而继承则是编译时特性。也就是说继承关系在编译时就已经确定了,而多态则可以实现运行时的动态绑定。
实现:

// 小狗和小鸟都是动物,都会移动和叫,它们共同的方法就可以提炼出来定义为一个抽象的接口。
type Animal interface {
	Move()
	Shout()
}

type Dog struct {
}

func (dog Dog) Move() {
	fmt.Println("I am dog, I moved by 4 legs.")
}
func (dog Dog) Shout() {
	fmt.Println("wang wang wang")
}

type Bird struct {
}

func (bird Bird) Move() {
	fmt.Println("I am bird, I fly with 2 wings")
}
func (bird Bird) Shout() {
	fmt.Println("ji ji ji ")
}

type ShowAnimal struct {
}

func (s ShowAnimal) Show(animal Animal) {
	animal.Move()
	animal.Shout()
}

func main() {
	show := ShowAnimal{}
	dog := Dog{}
	bird := Bird{}

	show.Show(dog)
	show.Show(bird)
}

(参考资料:blog.csdn.net/sumatch/art…