GO语言基础

类型系统

分类

  • 命名类型:预声明的简单类型和自定义类型。
  • 未命名类型(类型字面量):array ,chan ,slice ,map ,pointer ,struct ,interface ,func

未命名类型 == 类型字面量 ==复合类型

底层类型

  1. 预声明类型类型字面量的底层类型是自身。
  2. 自定义类型的底层类型需要逐层向下查找。

类型方法

只有命名类型才有方法,且只能给当前包下的类型添加方法。

方法调用

一般调用:实例.方法名(参数)

go 复制代码
s := Student{name: "张三", age: 19} // Student 类型对象的创建和初始化
s.eat() // 调用方法

类型字面量调用:类型.方法(实例,参数)

go 复制代码
Student.eat(s) // 因为方法其实就是特殊的函数
func eat(s Student){ // eat() 方法转为函数
    fmt.Println(s.name, "正在吃饭")
}

追加和切片扩容

GO1.17版本之前

append 会在编译时期被当成一个Token直接编译成汇编代码,因此append并不是运行时调用的一个函数,如果发生扩容则会调用**growsline()**函数。

  1. new_cap<=old_cap直接向后覆盖
  2. 超过则扩容:为切片重新分配新的空间并复制原数组内容
    • new_cap>2*old_cap:直接按照新切片的容量来扩容。
    • old_cap<1024:直接分配2*old_cap
    • old_cap>=1024:每次增加1.25*old_cap直到大于new_cap为止。

GO1.18版本之后

  1. new_cap>2*old_cap:直接按照new_old进行扩容。
  2. old_cap<256:直接分配2*old_cap
  3. old_cap>=256:进入循环,每次增加**(旧容量+3*256)/4**

然后根据切片中的元素大小内存对齐。如果元素所占字节大小为1,2或8的倍数时会根据class_to_size数组向上取整来提高内存分配效率来减少碎片。

map扩容

桶(Bucket)是存储数据的一个基本单位,哈希表通过哈希函数将元素映射到特定的桶中。一个桶中会有 8 个 cell,理想情况是,一个 Bucket 只装一个 key,这样就能达到 O(1) 的效率。

Go 语言一个 Bucket 里装载 8 个 key,定位到某个 key 后,还需要再定位到具体的 key,这实际上是用时间换了空间,但这要有一个度。装载因子就是用来衡量上面描述的情况的

装载因子:count 就是 map 的元素个数

触发 map 扩容的时机:

(装载因子 = 元素数量 / 桶数量;每个桶 8 个空位,所有 bucket 都满了时,装载因子值就是 8,所以当装载因子超过 6.5 时,表明很多 bucket 都快要装满了)

在向 map 插入新 key 时,如果符合下面任意一个条件,就会扩容:

  1. 装载因子 > 6.5 引发成倍扩容

    预分配 2 倍原 bucket 大小的newbucket放到bucket上,原bucket放到oldbucket上

  2. 溢出桶的数量过多引发等量扩容:

    a. Bucket 总数 (2^B) < 2 ^15 时,如果溢出桶的数量超过 2^B

    b. Bucket 总数 (2^B) >= 2^15 时,如果溢出桶的数量超过 2^15

目的:整理溢出桶,回收冗余的溢出桶)

和增量扩容的区别就是创建和原bucket等大小的新桶,最后清空旧桶和旧的溢出桶

增量是桶不够,然后添加桶,等量是桶里面的空白太多,重新排布 key

溢出桶过多的原因:当我们持续向哈希表中插入数据并将它们全部删除时,如果哈希表中的数据量没有超过阈值,就会不断积累溢出桶造成缓慢的内存泄露。

如果处于扩容状态,每次写操作时,就先搬迁bmap数据到新桶(增量扩容分到两个桶,等量扩容分到一个桶)再继续,读会优先从旧桶读。

线程和协程的区别

比较维度 线程 协程(用户级线程) Goroutine(Go 语言的协程)
调度方式 由内核调度,抢占式调度(基于 CPU 时间片,有多种调度算法) 对内核透明,由用户程序调度,协作式调度(需主动转让控制权) 本质是协程,语言层面封装调度,遇到长时间执行或系统调用时主动转让 CPU 控制权
创建方式 依赖操作系统 API 创建 由用户程序自行实现创建逻辑 在函数/方法前加 go 关键字即可创建
内存消耗(默认) 8MB - 2KB
切换调度开销 涉及用户态到内核态的模式切换,需刷新 16 个寄存器、PC、SP 等寄存器 仅需修改三个寄存器的值(PC / SP / DX) 同协程,仅需修改三个寄存器的值(PC / SP / DX)

死锁

发生的必要条件,打破任意一个即可。

  1. 互斥:解决临界区安全(不考虑破坏)。
  2. 请求保持:一个进程因请求资源而阻塞时,对已占有的资源不释放。
  3. 不可抢占:进程已获得的资源,在未使用之前,不能强行剥夺(抢夺资源)。
  4. 循环等待:若干进程之间形成一种头尾相接的循环等待的资源关闭(死循环)。

解决方法:

  1. 请求保持:所有的进程在开始运行之前,必须一次性的申请其在整个运行过程各种所需要的全部资源。
  2. 不可抢占:当持有一定资源的线程在无法申请到新的资源时必须释放已有的资源,待以后需要使用的时候再重新申请
  3. 循环等待:规定资源的申请顺序。

数组和切片的区别

  • 数组是一块连续的内存空间,元素在内存中紧挨着,而且是不可变长的;
  • 切片是可动态扩容的,切片的底层由三部分组成,len 长度,cap 容量, data 数据,数据是引用类型的,所以多个切片可以共享同一个底层数组,切片有自己的动态扩容机制。具体的扩容机制就是如果需要扩容的容量是当前的两倍还要多,就直接扩容到相应的大小;如果在两倍以内,且容量小于1024,就是两倍扩容;大于1024就是1.25倍扩容。

make和new的区别

  • make返回的是一个实例,new返回的是一个指针类型的数据。
  • make创建的类型有局限性,它只可以创建slice,map,chan,并且会对创建的数据进行初始化,new用于类型分配内存,接收一个类型作为参数,返回的是一个该类型的零值指针。

切片扩容

当切片容量不够的时候,就会触发扩容机制,当所需要容量大于二倍的时候,就把容量扩大到所需要的容量,当容量小于1024,就是2倍扩容,当大于1024时候就是1.25倍扩容。

什么情况下会发生内存逃逸

  • 内存逃逸问题是指在函数内部创建的对象或变量,在函数结束后仍然被其他部分引用或持有,例如:原本应该分配到栈上的内存分配到了堆上。
  • 导致逃逸的原因就是如果一个内存分配的变量空间过大,无法容纳到栈上,就会逃逸到堆上。
  • 函数的内部变量以指针的方式返回地址,被外部函数所引用的时候,作用域发生了变化,就会发生内存逃逸。
  • 编译的时候无法确认其类型和大小的时候会发生内存逃逸。
  • 切片扩容也可能导致内存逃逸。
  • 在闭包情况下也会导致内存逃逸,本质就是变量声明周期发生了变化。

进程、线程、协程的区别?

  • 进程是操作系统资源分配的基本单位,是程序的运行实例,拥有独立的内存空间,可以包含多个线程。
  • 线程是进程的一个执行任务,是处理器任务调度和执行的基本单位,线程共享所属进程的资源,但是拥有自己的程序计数器,虚拟机栈,本地方法栈,线程直接的切换是需要由用户态到内核态的切换,线程的创建和销毁都有内核态调度的开销,一个进程最多可以高效运行几千个线程,再多就会导致由于频繁的切换调度,性能暴跌。
  • 协程的出现完全基于用户态,减少了调度开销 ,由GMP模型去管理协程,因此有着极低的切换成本,而且协程的资源极其轻量化,Goroutine的初始内存只有几KB,而且可以动态扩容。

乐观锁、悲观锁(MySQL中如何实现乐观锁、悲观锁)

  • 乐观锁就是别人大概率不会修改我的数据,所以操作时不上锁,只在最后提交的时候检查数据有没有被人修改过,如果没有修改过就是成功,失败就重试。
  • 悲观锁就是以一种悲观的方式,别人肯定会修改我的数据,所以在操作开始之前就上锁,操作完再解锁。
  • MYSQL中的原生锁都是悲观锁,一般是通过上行级锁来实现的,通过for update的关键字在修改的时候给修改行上锁,避免其他事务修改数据,事务成功或者失败后释放锁。
  • 而乐观锁需要自己手动实现,最常用的方式是添加字段实现的,一般是添加版本号和时间戳实现,每次对数据库的操作都会让版本号加一,每次更新地时候都要检查版本号,只有当版本号符合预期地时候,才算成功,不然说明有其他事务修改了数据,则数据更新失败。

CAS(自旋锁)是什么

  • CAS全称是Compare And Swap(比较并交换),整个过程是cpu原子级别的操作,一步完成,不会被打断,自旋锁是用CAS实现的一种锁机制,核心逻辑是:"没拿到锁就会一直重试,不放弃"CPU"。
  • 而go中的select不是自旋的,select会检查所有的case,如果都没有就绪且没有default语句就会阻塞直到某个case就绪,如果有defalut就直接执行,不转圈。

IO 模型有哪些?

  • 阻塞IO
  • 非阻塞IO
  • IO多路复用
  • 信号驱动IO
  • 异步IO

GMP

  • GMP模型是Go语言为了管理协程(goroutine)所设置的调度器。
  • M(thread/Machine):这个有些地方回被称为thread,本质上是一样的,都是代表操作系统的线程,也是真正处理goroutine的地方。
  • P(Processer):本质上就是一个队列,存放待M消费的goroutine,这个被叫做本地队列,当然也有一个公共队列。
  • G(goroutine):就是平时所书写的go func()。

在程序启动的时候,Go语言会自动确认P的数量,P的数量一般等于cpu核心的数量,当然也可以通过参数具体设置,然后启动一批M,M的数量少于P,每个M启动以后,都会先绑定空闲的P,只有绑定了P的M才可以执行处理G.准备工作完成以后就可以正常消费G了,当使用go function()的时候,调度器会让刚创建的G先放入P的队列中,优先放置的是P的本地队列,这个本地队列的大小有限,默认256,如果满了,就会把一半的G转移到全局队列,而绑定了P的M就会不断的从对应P的本地队列中消费G,如果在消费过程中发生了堵塞,就会导致M卡顿,此时调度器会让MP解绑,此时其他空闲的M就会绑定P,消费P中的G,而堵塞的G会进去等待队列,等待阻塞完成.等待完成以后就会重新入队,这个入队是优先放回原有的P,如果原有的P满了就是放全局队列,主要看调度器策略.

  • 如果P的本地队列空了,就会触发(Work Stealing)机制,首先到全局队列中取一批G,(每次取1/64),如果全局队列空了,就会取其他P中的G,(通常偷一半,取队尾一半),实现了负载均衡.
  • 在Go语言1.14版本以后,当一个G长时间占用CPU资源,导致后续的G堵死时候,会向M发送一个信号,停止G,然后重新放回P本地队列,M则去执行下一个G

defer

  • 作用:延迟调用
  • 特点:延迟调用,多个defer函数的调用顺序为后进先出
  • 注意事项:无意识构建了闭包函数
  • 常见应用场景:资源释放、异常的捕获和处理。
  • 协程的多个defer是怎样的结构:运行和协程的_defer链表当中。

面向对象

面向对象编程的三大核心概念

  • 封装:将数据和操作数据的方法绑定在一起,对外部隐藏对象的内部实现细节,只提供一些公共的访问方法来操作这些数据,从而保证了数据的安全性和完整性,防止外部代码随意访问和修改对象的内部状态。

  • 继承:允许创建一个新的类(子类或派生类)从现有的类(父类或基类)继承属性和方法,子类可以在继承父类的基础上添加新的属性和方法,或者重写父类的方法以满足特定的需求,从而实现代码的复用和扩展。

  • 多态:指不同的对象对同一消息或方法调用可以产生不同的响应或行为,多态性使得代码更加灵活和可维护,增强了程序的可扩展性和可维护性。


面向对象编程的基本原则

  • 单一职责原则(Single Responsibility Principle,SRP):一个类应该只有一个引起它变化的原因,即一个类只负责一项职责

  • 开闭原则(Open-Closed Principle,OCP):软件实体(类、模块、函数等)应该对扩展开放,对修改关闭

  • 里氏替换原则(Liskov Substitution Principle,LSP):子类型必须能够替换掉它们的父类型,即派生类对象可以在程序中代替基类对象使用,且不会产生错误或异常行为

  • 依赖倒置原则(Dependency Inversion Principle,DIP):高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象

  • 接口隔离原则(Interface Segregation Principle,ISP):客户端不应该被迫依赖于它不使用的方法,一个类对另一个类的依赖应该建立在最小的接口上。