引导程序整体结构8个关键字:package、import、const、var、func、defer、go、return。
声明复合数据结构的4个关键字:struct、interface、map、chan。
控制程序结构的13个关键字:if、else;for、 range、break、continue;switch、select、type、case、default、fallthrough;goto。
内置数据类型标识符(20个):
数值:
整型(12个):byte、int、int8、int16、int32、int64
uint uint8、uint16、uint32、uint64、uintptr
浮点型:float32 float64
复数:complex64、complex128
字符和字符串型: string rune
接口型:error
布尔型:bool
内置函数(15个):
make、new、len、cap、append、copy、delete、panic、recover、close、complex、real、image、print、println。
常量标识符(4个):
true、false:表示bool类型的两个常亮值。
iota://用在连续枚举类型的声明中。
nil: //指针,引用类型的变量的默认值是nil
空白标识符(1个):
_
GO操作符47个。
基本类型的可比较性:
- 布尔值可比较
- 整数、浮点数、复数值、字符串值
- 指针值可比较,如果指针指向相同的变量,或者两个指针的值均为nil,则他们相等。
- 通道值可比较。如果两个通道值是由相同的make函数调用创建的,或者两个值都为nil,则他们相等。
- 接口值是可以比较的,如果两个接口值具有相同的动态类型和相等的动态值,或者两个值都为nil,则他们相等。
- 如果结构体的所有字段都是可比较的,则他们的值是可比较的。
- 如果数组元素类型的值可比较,则数组值可比较。如果两个数组对应的元素相等,则他们相等。
- 切片、函数、map是不可比较的。
HTTP协议和websocket协议的区别:
较少的控制开销。在连接创建后,服务器和客户端之间交换数据时,用于协议控制的数据包头部相对较小。在不包含扩展的情况下,对于服务器到客户端的内容,此头部大小只有2至10字节(和数据包长度有关);对于客户端到服务器的内容,此头部还需要加上额外的4字节的掩码。相对于HTTP请求每次都要携带完整的头部,此项开销显著减少了。
更强的实时性。由于协议是全双工的,所以服务器可以随时主动给客户端下发数据。相对于HTTP请求需要等待客户端发起请求服务端才能响应,延迟明显更少;即使是和Comet等类似的长轮询比较,其也能在短时间内更多次地传递数据。
保持连接状态。与HTTP不同的是,Websocket需要先创建连接,这就使得其成为一种有状态的协议,之后通信时可以省略部分状态信息。而HTTP请求可能需要在每个请求都携带状态信息(如身份认证等)。
更好的二进制支持。Websocket定义了二进制帧,相对HTTP,可以更轻松地处理二进制内容。
可以支持扩展。Websocket定义了扩展,用户可以扩展协议、实现部分自定义的子协议。如部分浏览器支持压缩等。
更好的压缩效果。相对于HTTP压缩,Websocket在适当的扩展支持下,可以沿用之前内容的上下文,在传递类似的数据时,可以显著地提高压缩率。
map的底层原理:
go语言中的map采用的是哈希查找表,由一个key通过哈希函数得到哈希值,64位系统中就生成一个64bit的哈希值,由这个哈希值将key对应到不同的桶(bucket)中,当有多个哈希映射到相同的的桶中时,使用链表解决哈希冲突。
根据哈希函数将key生成一个hash值,其中低位hash用来判断桶的位置,高位hash确定在桶中的哪个cell。。每个桶可以存储8对key-value,存储结构不是key/value/key/value...,而是key/key..value/value,这样可以避免字节对齐时的padding,节省内存空间。
当不同的key根据哈希得到的tophash和低位hash都一样,发生哈希碰撞,这个时候就体现overflow pointer字段的作用了。
如果overflow bucket也溢出了呢?那就再给overflow bucket新建一个overflow bucket,用指针串起来就形成了链式结构,map本身有2^B个bucket,只有当发生哈希碰撞后才会在bucket后链式增加overflow bucket。
map加载因子map长度 / 2^B6.5B
装填因子是否大于6.5
装填因子 = 元素个数/桶个数,大于6.5时,说明桶快要装满,需要扩容
overflow bucket是否太多
当bucket的数量 < 2^15,但overflow bucket的数量大于桶数量
当bucket的数量 >= 2^15,但overflow bucket的数量大于2^15
双倍扩容:装载因子多大,直接翻倍,B+1;扩容也不是申请一块内存,立马开始拷贝,每一次访问旧的buckets时,就迁移一部分,直到完成,旧bucket被GC回收。
等量扩容:重新排列,极端情况下,重新排列也解决不了,map成了链表,性能大大降低,此时哈希种子hash0的设置,可以降低此类极端场景的发生。
切片扩容:
- 当需要的容量超过原切片容量的两倍时,会使用需要的容量作为新容量。
- 当原切片长度小于1024时,新切片的容量会直接翻倍。而当原切片的容量大于等于1024时,会反复地增加25%,直到新容量超过所需要的容量。
查找
根据key计算出哈希值
根据哈希值低位确定所在bucket
根据哈希值高8位确定在bucket中的存储位置
当前bucket未找到则查找对应的overflow bucket。
对应位置有数据则对比完整的哈希值,确定是否是要查找的数据
如果当前处于map进行了扩容,处于数据搬移状态,则优先从oldbuckets查找。
插入
根据key计算出哈希值
根据哈希值低位确定所在bucket
根据哈希值高8位确定在bucket中的存储位置
查找该key是否存在,已存在则更新,不存在则插入
map无序
map的本质是散列表,而map的增长扩容会导致重新进行散列,这就可能使map的遍历结果在扩容前后变得不可靠,Go设计者为了让大家不依赖遍历的顺序,故意在实现map遍历时加入了随机数,让每次遍历的起点--即起始bucket的位置不一样,即不让遍历都从bucket0开始,所以即使未扩容时我们遍历出来的map也总是无序的。
channel底层原理:
channel主要用于goroutine之间的通信,分为有缓冲和无缓冲通道两种类型。
chan的实现在runtime/chan.go,是一个hchan的结构体:
type hchan struct {
qcount uint // 队列中的数据个数
dataqsiz uint // 环形队列的大小,channel本身是一个环形队列
buf unsafe.Pointer // 存放实际数据的指针,用unsafe.Pointer存放地址,为了避免gc
elemsize uint16
closed uint32 // 标识channel是否关闭
elemtype *_type // 数据 元素类型
sendx uint // send的 index
recvx uint // recv 的 index
recvq waitq // 阻塞在 recv 的队列
sendq waitq // 阻塞在 send 的队列
lock mutex // 锁
}
可以看出,channel本身是一个环形缓冲区,数据存放到堆上面,channel的同步是通过锁实现的,并不是想象中的lock-free的方式,channel中有两个队列,一个是发送阻塞队列,一个是接收阻塞队列。当向一个已满的channel发送数据会被阻塞,此时发送协程会被添加到sendq中,同理,当向一个空的channel接收数据时,接收协程也会被阻塞,被置入recvq中。
1、goroutine函数内部的panic能否被main函数捕捉?go并发函数应该注意些什么?
goroutine 函数panic不会被主routine捕获,你需要在routine内捕获。
panic有两种,1是代码调用panic函数触发,可以recover,1中是map并发写冲突,程序只会挂掉,不能回复。
go并发函数应注意三点:1. map并发写 2. 内存溢出OOM 3.死锁
[]byte[]rune
byteuint8uint8runeruneint32int32stringnil
string和[]byte类型转换时,会产生新的内存占用。一般强制类型转换都会产生新的内存占用。
3、请简述切片和数组的区别
从语法上来看,数组遵循传统的三要素 – 名称、类型、长度。数组的一切传递都是值拷贝。
而切片只有名称、类型,这意味着切片是不定长的。
从内存的角度来看,数据是一整块连续的、固定长度、固定位置的内存。
而切片则是一个指针,指向一块内存,当容量不够时就开辟更大的内存。
切片扩容:slice在append时如果超出了原来的容量时会翻倍扩容
//原切片长度低于1024时直接翻倍
//原切片长度大于等于1024时,每次只增加25%,直到满足需要的容量
4、在Go语言中,列表使用 container/list 包来实现,内部的实现原理是双链表,列表能够高效地进行任意位置的元素插入和删除操作。
list 的初始化有两种方法:分别是使用 New() 函数和 var 关键字声明,两种方法的初始化效果都是一致的。
1) 通过 container/list 包的 New() 函数初始化 list
变量名 := list.New()
2) 通过 var 关键字声明初始化 list
var 变量名 list.List
在列表中添加删除元素
package main
import "container/list"
func main() {
l := list.New()
// 尾部添加
l.PushBack("canon")
// 头部添加
l.PushFront(67)
// 尾部添加后保存元素句柄
element := l.PushBack("fist")
// 在fist之后添加high
l.InsertAfter("high", element)
// 在fist之前添加noon
l.InsertBefore("noon", element)
// 使用
l.Remove(element)
}
遍历列表:
l := list.New()
// 尾部添加
l.PushBack("canon")
// 头部添加
l.PushFront(67)
for i := l.Front(); i != nil; i = i.Next() {
fmt.Println(i.Value)
}
5、go语言中哪些类型的值可以被取地址,哪些不可以被取地址?
Go中以下的值是可寻址的,因此可以被取地址:
变量、可寻址的结构体的字段、可寻址的数组的元素、任意切片的元素(无论是可寻址切片或不可寻址切片)、指针解引用操作
Go中以下的值是不可寻址的:
字符串的字节、元素映射、元素接口值的动态值(类型断言的结果)、常量值字面值、声明的包级别函数方法(用做函数值)、表达式中间结果值、数据通道接收操作、子字符串操作、子切片操作、加法、减法、乘法、以及除法等等。
函数调用显式值转换各种操作,不包含指针解引用(dereference)操作,但是包含数据通道接收操作、子字符串操作、子切片操作,以及加法/减法/乘法/除法等等。
6、为什么两个nil值有时候会不相等?
1. nil 标识符是不能比较的
2. nil 不是关键字或保留字
3. nil 没有默认类型
4. 不同类型 nil 的指针是一样的
5. 不同类型的 nil 是不能比较的
6. 两个相同类型的 nil 值也可能无法比较
7. nil 是 map、slice、pointer、channel、func、interface 的零值
8. 不同类型的 nil 值占用的内存大小可能是不一样的
Gomapslicefunctionnil
mapslicefunction
7、简述go语言中make和new的区别。
new:原来初始化泛型,并且返回指针存储的位置。
make:用来初始化一些特别的类型,如slice、map、channel,返回没有指针。
8、哪些类型是值类型,那些是引用类型?
值类型:int、float、bool、sturct等
引用类型有:数组、切片、map、channel、interface。
9、函数返回局部变量的指针是否安全?
能不能返回局部指针变量,不在于这个指针变量的类型和性质(不在于该指针是不是局部指针变量),而在于该指针指向的对象的类型和性质。
如果该指针指向函数内部的栈空间,则程序非法,如果指向静态区域的地址,则合法。
go语言编译器会自动决定把一个变量放在栈还是放在堆,编译器会做逃逸分析(escape analysis),当发现变量的作用域没有跑出函数范围,就可以在栈上,反之则必须分配在堆。所以不用担心会不会导致memory leak,因为GO语言有强大的垃圾回收机制。go语言声称这样可以释放程序员关于内存的使用限制,更多的让程序员关注于程序功能逻辑本身。
10、switch流程控制代码块中的case表达式能重复吗?
switch-case代码块中的case常量表达式有时候可以重复,有时候则不可以。Go白皮书特地说明了具体编译器可以使用不同的实现。比如,下面这个例子使用标准Go编译器和gccgo都是编译不过的,因为这两个编译器都不允许重复的数值型的case常量表达式。
package main
func main() {
switch n := 1; n {
case 0, 1, 2:
case 2, 3: // error: 2重复了
}
}
标准Go编译器和gccgo都允许重复的布尔case常量表达式
package main
func main() {
switch {
case 1 != 1, 2 == 2:
case 3 == 3, true:
case false, false:
case false:
}
}
标准Go编译器不允许重复的字符串型的case常量表达式,但是gccgo却允许。
package main
func main() {
switch 'abc' {
case 'abc', 'xyz', 'def':
case 'abc', 'xyz': // 上面这行标准编译器报错, // 但是gccgo允许。
}
}
11、简要描述go中的main和init函数的区别:
-
相同点:
- 两个函数在定义时不能有任何的参数和返回值,且Go程序自动调用。
-
不同点:
- init可以应用于任意包中,且可重复定义多个。
- main函数只能用于main包中,且只能定义一个。
-
两个函数的执行顺序:
-
对同一个go文件的init()调用顺序是从上到下的。
-
对同一个package中不同文件是按文件名字符串比较“从小到大”顺序调用各文件中的init()函数。
-
对于不同的package,如果不相互依赖的话,按照main包中"先import的后调用"的顺序调用其包中的init(),如果package存在依赖,则先调用最早被依赖的package中的init(),最后调用main函数。
-
12、简述下闭包的生命周期和作用范围。
闭包是携带状态的函数,它是将函数内部和函数外部连接起来的桥梁。通过闭包,可以读取函数内部的变量,也可以使用闭包封装私有状态,让他们常驻内存当中。
闭包能够引用其作用域上部的变量进行修改,被捕获到闭包中的变量将随着闭包的生命周期一直存在。
13、go语言是一个面向对象语言吗?
是的。
14、对任意的非指针和非接口定义类型T,为什么类型*T的方法集总是类型T的方法集的超集,但是反之却不然?
16、罗列出在go语言中哪些行为会触发异常,不少于3种。
参数或者函数的值为nil也会触发异常
类型断言错误会引发异常
运行时错误也会触发异常
goroutine高并发时,容易死锁也会触发异常
17、函数调用time.Sleep(d)和数据通道接收<-time.After(d)操作之间有何区别?
time.Time
19、select可以用于什么?
用于多路监听多个通道
20、无缓冲和缓冲通道之间有什么区别?
无缓冲通道:cap()和len()都是0。用于通信和goroutine同步。
有缓冲通道:len代表没有读取的元素数,cap代表整个通道的容量。主要用于通信。
管道没有缓冲区时,从管道读数据会阻塞,直到有协程向管道中写入数据。类似的向管道中写入数据也会阻塞,直到有协程从管道中读取数据。
管道有缓冲区但缓冲区没有数据时,从管道读取数据也会阻塞,直到有协程写入数据。类似的,向管道写入数据时,如果缓冲区已满,那么也会阻塞,直到有协程从缓冲区中读取数据。
对于值为nil的管道,无论读写都会阻塞,而且是永久阻塞。
使用内置函数close()可以关闭通道,尝试已经关闭的通道写入数据会触发panic,但关闭的管道仍可读。
x, ok := <-ch
第一个变量表示读出的数据,第二个变量(bool类型)表示是否成功读取了数据。第二个变量不用于指示管道的关闭状态 。
一个关闭的管道有两种情况:
① 管道缓冲区没有数据 // ok =false
② 管道缓冲区有数据 // ok=true
只有管道已经关闭且缓冲区没有数据时,管道读取表达式返回的第二个变量才跟管道关闭状态一致。
goroutine退出后,写到缓冲通道中的数据不会消失,它可以缓冲和适配两个goroutine处理速度不一致的情况,缓冲通道和消息队列类似,有削峰和增大吞吐量的功能。
21、goroutine的泄漏怎么处理?
常见导致内存泄露的情况:内存泄漏是如何产生的呢?
1、发送一个没有接受者的channel
2、nil channel 写入到nil channel会永远阻塞
go是一门自己gc的语言,大概两分钟会gc一次。如果有内存泄漏,无非两种情况。
-
有goroutine泄漏,goroutine“飞”了,zombie goroutine没有结束,这个时候在这个goroutine上分配的内存对象将一直被这个僵尸goroutine引用着,进而导致gc无法回收这类对象,内存泄漏。
- 有一些全局(或者生命周期和程序本身运行周期一样长的)的数据结构意外的挂住了本该释放的对象,虽然goroutine已经退出了,但是这些对象并没有从这类数据结构中删除,导致对象一直被引用,无法被回收。
排除掉goroutine泄漏
首先,使用runtime.NumGoroutine返回正在执行和排队的任务总数,没有观察有没有泄漏的goroutine,确定goroutine是否泄露。
其次,确定是不是全局变量无回收
什么是pprof?
pprof是Go的性能分析工具,在程序运行过程中,可以记录程序的运行信息,可以是CPU使用情况、内存使用情况、goroutine运行情况等,当需要性能调优或者定位Bug时候,这些记录的信息是相当重要。
基本使用
使用pprof有多种方式,Go已经现成封装好了1个:net/http/pprof,使用简单的几行命令,就可以开启pprof,记录运行信息,并且提供了Web服务,能够通过浏览器和命令行2种方式获取运行数据。
import (
"fmt"
"net/http"
_ "net/http/pprof"
)
func main() {
// 开启pprof,监听请求
ip := "127.0.0.1:6060"
if err := http.ListenAndServe(ip, nil); err != nil {
fmt.Printf("start pprof failed on %s\n", ip)
}
我们输入ip:port/debug/pprof/打开pprof主页
例如我的地址
http://127.0.0.1:6060/debug/pprof/
full goroutine stack dump
goroutine
go tool pprof -inuse_space []()
这个命令的作用是, 抓取当前程序已使用的 heap. 抓取后, 就可以进行类似于 gdb 的交互操作.
- top 命令, 默认能列出当前程序中内存占用排名前 10 的函数.
22、如何实现消息队列(多生产者,多消费者)?
package main
import (
"fmt"
"time"
)
func consumer(cname string, ch chan int) {
//可以循环 for i := range ch 来不断从 channel 接收值,直到它被关闭。
for i := range ch {
fmt.Println("consumer-----------", cname, ":", i)
}
fmt.Println("ch closed.")
}
func producer(pname string, ch chan int) {
for i := 0; i < 4; i++ {
fmt.Println("producer--", pname, ":", i)
ch <- i
}
}
func main() {
//用channel来传递"产品", 不再需要自己去加锁维护一个全局的阻塞队列
ch := make(chan int)
go producer("生产者1", ch)
go producer("生产者2", ch)
go consumer("消费者1", ch)
go consumer("消费者2", ch)
time.Sleep(10 * time.Second)
close(ch)
time.Sleep(10 * time.Second)
}
for i := range ch {
fmt.Println("consumer-----------", cname, ":", i)
}
这个也可以改成:LOOP:
for {
select {
case i,ok:=<-ch:
if ok {
fmt.Println("consumer--------", cname, ":", i)
} else {
break LOOP
}
}
}
//注意: i := <- ch 从空的channel中读取数据不会panic, i读取到的值是0, 如果channel是bool的,那么读取到的是fa
23、什么是乐观锁?什么是悲观锁?各自的应用场景是什么?
悲观锁:
当要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。
借助数据库锁机制,在修改数据之前先锁定,再修改的方式被称之为悲观并发控制【Pessimistic Concurrency Control,缩写“PCC”,又名“悲观锁”】。
2️⃣悲观锁主要分为共享锁和排他锁:
- 共享锁【shared locks】又称为读锁,简称S锁。顾名思义,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。
- 排他锁【exclusive locks】又称为写锁,简称X锁。顾名思义,排他锁就是不能与其他锁并存,如果一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁,但是获取排他锁的事务是可以对数据行读取和修改。
乐观锁:(适用于读操作多的场景)
假设数据一般情况下不会造成冲突,所以在数据进行提交更新时,才正式对数据的冲突与否进行检测,如果发现冲突了,则返回给用户错误的信息,让用户决定如何去做。适用于读操作多的场景,这样可以提高程序的吞吐量。
乐观锁不会刻意使用数据库本身的锁机制,而是依据数据本身来保证数据的正确性。
乐观锁机制采取了更加宽松的加锁机制。乐观锁是相对悲观锁而言,也是为了避免数据库幻读、业务处理时间过长等原因引起数据处理错误的一种机制。
乐观锁的实现:
- CAS 实现:Java 中java.util.concurrent.atomic包下面的原子变量使用了乐观锁的一种 CAS 实现方式。
- 版本号控制:一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会+1。当线程A要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值与当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
2️⃣说明
乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁。
拿比较常用的 MySql Innodb 引擎举例,来说明一下在 SQL 中如何使用悲观锁。
要使用悲观锁,必须关闭 MySQL 数据库的自动提交属性。因为 MySQL 默认使用 autocommit 模式,也就是说,当执行一个更新操作后,MySQL 会立刻将结果进行提交。(sql语句:set autocommit=0)
使用乐观锁就不需要借助数据库的锁机制了。
主要就是两个步骤:冲突检测和数据更新。其实现方式有一种比较典型的就是。
五、如何选择乐观锁和悲观锁?
在乐观锁与悲观锁的选择上面,主要看下两者的区别以及适用场景就可以了。
1️⃣响应效率:如果需要非常高的响应速度,建议采用乐观锁方案,成功就执行,不成功就失败,不需要等待其他并发去释放锁。乐观锁并未真正加锁,效率高。一旦锁的粒度掌握不好,更新失败的概率就会比较高,容易发生业务失败。
2️⃣冲突频率:如果冲突频率非常高,建议采用悲观锁,保证成功率。冲突频率大,选择乐观锁会需要多次重试才能成功,代价比较大。
3️⃣重试代价:如果重试代价大,建议采用悲观锁。悲观锁依赖数据库锁,效率低。更新失败的概率比较低。
4️⃣乐观锁如果有人在你之前更新了,你的更新应当是被拒绝的,可以让用户从新操作。悲观锁则会等待前一个更新完成。这也是区别。
随着互联网的提出,悲观锁已经越来越少的被应用到生产环境中了,尤其是并发量比较大的业务场景。
24、赋值是原子操作吗?
不一定是,还跟上下文有关,如果上下文是一个没有并发进程的程序,那么该代码在该上下文中就是原子的。
25、值传递和指针传递的区别?
T*T
一般的判断标准是看副本创建的成本和需求。
T*T*TT*T
26、panic和recover
panic用来主动抛出错误,recover用来捕获panic抛出的错误。
panic的入口是一个空接口类型interface{ },任何变量都可以传递给panic。
27、内置map和syn.map的区别?
内置map在并发情况下,只读是线程安全的,同时写线程不安全,所以为了并发安全 & 高效,官方实现了一把。
sync.Map也是在golang提供的map关键字之上封装实现的。
sync.Map 整体的优化可以描述为以下几点:
空间换时间。 通过冗余的两个数据结构(read、dirty),实现加锁对性能的影响。
map只保存key和对应的value的指针,这样可以并发的读写map, 实际更新指向value的指针再通过基于CAS的无锁atomic。
使用只读数据(read),避免读写冲突
动态调整,miss次数多了之后,将dirty数据提升为read。
double-checking。
延迟删除。 删除一个键值只是打标记,只有在提升dirty的时候才清理删除的数据。
优先从read读取、更新、删除,因为对read的读取不需要锁。
28、两个结构体可以比较吗?
1、两个结构体无论类型是否相同,是否包含不可比较数据类型,都可以通过reflect.DeepEqual(struct1,struct2)进行比较
2、若两结构体是同一个结构体类型,且该结构体类型中不包含map,slice等不可比较数据类型,那么这两个结构体可以使用“==”进行比较是否相等。