前言:如下题目于面试结束两周后,整理了当初未能回答上来的问题,在此分享总结,以供参考。

常见的 GC 算法有哪些,Go 是怎么做的

当时对这个问题的了解程度更多的停留在各类技术文章或网传八股文面试题中,没有对 GC 有更广更深入的了解,栽到了一些细节上。

常见的 GC 算法

  1. 引用计数
    为每个对象维护一个计数器,该对象被使用就 +1,使用结束后 -1,当该对象计数器归 0 时,即被认定为可回收对象,但是一般不立即回收,出于效率考虑,系统一般会定期的等待一批可回收对象一块回收。同时存在个弊端,对于循环引用的对象,计数器的值始终不为 0,无法被回收。

  2. 标记清除
    引入了 “可达对象” 的概念(双方存在直接或间接的引用关系),从根对象开始扫描遍历所有可达对象并标记为 “存活”,然后扫描整个堆内存,回收未被标记的对象。解决了上述循环引用的问题,但是会因为 STW (Stop The World,程序暂停执行)导致性能开销较大,多次 GC 后,内存会不连续碎片化。

  3. 标记整理
    标记阶段同标记清除算法一样,在整理阶段不直接清除,而是将对象向内存一端移动,然后清除端边界外的所有空间。优点解决了内存不连续碎片化的问题,但是因为多了对对象在内存的移动整理过程,使得整体的性能开销更大。

  4. 标记复制
    标记阶段依然同标记清除算法一样,区别于标记整理,而是将内存分为大小相等的两块,每次只使用一块,GC 是将所有的可达对象复制到另一块内存空间,然后清除当前块。优点同样可以解决内存不连续碎片化的问题,缺点只能使用整个内存空间的一半,同样因为存在复制过程,所以整体开销也不小。

Go 采用的 GC 算法

标记清除(三色标记法)

定义三个颜色:

  • 白色:未被标记的对象
  • 灰色:已被标记,但还未处理完其所有可达对象
  • 黑色:已被标记且处理完所有可达对象

GC 过程:

  1. 初始化:把所有的对象均标记为白色
  2. 标记:从根对象开始,把所有可达对象标记为灰色,当前对象标记为黑色,循环这个过程,直到所有可达对象都被标记,换言之所有对象不是黑色就是白色
  3. 清除:清除所有白色对象

优点:

  • 在 1.5 版本的时候引入了三色标记算法,减少了 STW 时间

  • 在 1.8 版本的时候引入混合写入屏障,进一步压缩 STW 时间

    ps :在引用赋值时,将新对象标记为灰色,在引用被删除前,将被删除的对象标记为灰色。

思考

标记的时候,所谓的根对象是什么?或者说根对象在哪里?

结合上述的标记清除的过程,当前对象存在可达对象的时候或者说当前对象被其他对象所引用,自身就会标记为黑色,那么可以得出如果自身没有引用其他对象的时候,那么自身就是所谓的根对象。

结合 go 语言特性,思考哪些对象是存在上述特征,换个角度出发,当程序开始运行的时候,首次被加载到内存的对象就符合该特征,那么例如全局常量,包级变量,每个 goroutine 的局部变量常量等即为根对象。

互斥锁底层数据结构是什么样的

互斥锁仅停留在使用的层面,如多线程并发中保护资源等操作上,对于底层是如何实现的,属于是被问到知识盲区了。

Golang 的互斥锁主要由内部 sync 包下的 Mutex 对象实现。

跳进该对象内部(源码),可看到该对象定义了两个变量:state & sema,这也是互斥锁的核心数据结构:

type Mutex struct {
	state int32		// 状态
	sema  uint32	// 信号量
}

往下翻可看到定义的几个常量,这几个常量是互斥锁底层状态管理的核心:

const (
	mutexLocked = 1 				// 锁被持有
	mutexWoken						// 锁被释放
	mutexStarving					// 锁处于饥饿模式
	mutexWaiterShift = iota			// 等待者数量的偏移量,值为 3
)

接着进一步分析该对象下的具体方法(加锁 & 解锁):

func (m *Mutex) Lock() {
	// Fast path: grab unlocked mutex.
	if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
		if race.Enabled {
			race.Acquire(unsafe.Pointer(m))
		}
		return
	}
	// Slow path (outlined so that the fast path can be inlined)
	m.lockSlow()
}

func (m *Mutex) lockSlow() {
// 具体逻辑请移步源码
}

func (m *Mutex) Unlock() {
	if race.Enabled {
		_ = m.state
		race.Release(unsafe.Pointer(m))
	}

	// Fast path: drop lock bit.
	new := atomic.AddInt32(&m.state, -mutexLocked)
	if new != 0 {
		// Outlined slow path to allow inlining the fast path.
		// To hide unlockSlow during tracing we skip one extra frame when tracing GoUnblock.
		m.unlockSlow(new)
	}
}

func (m *Mutex) unlockSlow(new int32) {
// 具体逻辑请移步源码
}

可以看出整个加锁解锁的过程是原子性的,并且有两种途径加解锁(快速途径和慢速途径)粗略的分析了具体实现逻辑,大致总结如下(不保证准确性,仅供参考):

后续不定期补充

加锁

  1. 快速途径:如锁未被持有(state 低 3 位为 0)直接获取锁

  2. 慢速途径:

    • 循环尝试获取锁
    • 正常模式下,新请求可抢占锁,等待者可能被插队
    • 饥饿模式下,锁直接交给队首等待者,新请求直接入队
    • 若获取失败,通过 sema 阻塞当前 goroutine

解锁

  1. 快速途径:直接解锁

  2. 慢速途径:

    • 正常模式下:若存在等待者,唤醒一个 goroutine
    • 饥饿模式下:直接将锁交给队首等待者

总结

Golang 的互斥锁的底层由两个变量 state 锁状态和 sema 信号量构成,并结合原子操作实现对线程加解锁的控制。

事务的四个特性分别通过什么手段实现的

本以为会问 ACID 的特性,这个倒是都了解,出乎意料的是底层是如何实现的,一下子有些猝不及防,脑子里闪过:约束?redo log?undo log?思索片刻还是放弃了。

原子性

定义:事务执行要么都成功要么都失败,不可分割

实现原理:利用 Undo Log(回滚日志),记录每一次执行对应的相反操作,例如:执行 INSERT 操作,那么日志中则记录对应的 DELETE 操作。一旦事务中某一条 SQL 执行失败,那么直接使用日志中记录的操作回滚到事务执行前的状态。

一致性

定义:事务执行前后,数据库中数据的一致性状态需保持一致

实现原理:执行事务时,InnoDB 会检查数据完整性约束(主键、唯一索引、外键)。若违反约束,事务会自动回滚,同时也包括业务规则上的约束,比如经典的银行转账问题,通过数据库触发器或应用层逻辑,确保业务规则被遵守。

隔离性

定义:多个事务并发执行时,相互隔离,互不干扰

实现原理:InnoDB 默认的隔离级别是可重复读,通过 MVCC 多版本并发控制机制,每个事务读取自己开始时的数据库快照,避免脏读、幻读、不可重复读问题。对于读已提交隔离级别来说,采用了行级锁来保证;对于串行来讲,直接使用表锁,强制让事务串行执行。

持久性

定义:事务一旦执行成功,其结果将持久化到磁盘中

实现原理:利用 Redo Log(重做日志),记录事务对数据页的物理修改(与 Undo Log 相反),按顺序写入磁盘,即使系统崩溃,过后可通过日志恢复未持久化的数据页。

如下代码分别输出什么

func main() {
	var a = []int{1, 2, 3}
	fmt.Println(len(a), cap(a))

	a = append(a, 4, 5, 6, 7)
	fmt.Println(len(a), cap(a))
}

先前面试中印象里没有被问到这个问题,不过这个题蛮有意思,考察关于切片扩容的问题。

结论

> 3 3
> 7 8

原因:

先说长度:这个无可厚非,append 前 3 个元素,append 后追加了 4 个元素,所以长度第一次输出 3,第二次输出 7。

再来聊聊容量:虽然在 go 1.18 版本之前和之后对于基准容量的把控从 1024 更新到了 256,但在上述示例中可以忽略;首先初始的容量为 3,所以结果输出 3,随后进行了 append 操作,新增了 4 个元素进去,切片首先会进行 2 倍扩容:3 * 2 = 6,但是 6 的容量不足以容纳 7 个元素,理论上会采用最大的容量作为新容量也就是 7 ,但是 go 底层还有个逻辑,即为了内存对齐会选择不小于当前最大容量的最小 2 的幂次,也就是 8,所以第二次会输出 8。

爬楼梯问题

力扣第 70 题:

假设你正在爬楼梯。需要n阶你才能到达楼顶。

每次你可以爬12个台阶。你有多少种不同的方法可以爬到楼顶呢?

示例 1:

输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶

示例 2:

输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶

该题其实在过往的学习过程中有遇到过,属于很经典的算法题,或许是时间间隔太久,一时间思维没跟上...

思考加回忆:

第一印象是经典的斐波那契数列,兔子问题的变体,接着一步步推算一下:

n = 1 —— 1 种方法

n = 2 —— 2 种方法

n = 3 —— 3 种方法

n = 4 —— 5 种方法

...

其实这么多推几次,能发现个规律,就以 n = 4 来说,无非分为两种情况,第一步走一个台阶和第一步走两个台阶,或者最后一步走一个台阶和最后一步走两个台阶,至于是第一步还是最后一步其实无所谓,现在就以第一步怎么走来说,如果第一步走一个台阶,那么剩下的就是 3 级台阶的情况,如果第一步走两个台阶,那么剩下的就是 2 级台阶的情况,把这两个情况对应的方法数相加,就得到了当前 4 级台阶的方法数。

对应到数学公式即:f(x)=f(x-1)+f(x-2)

接下来开始编码:

func climbStairs(n int) int {
	// 这里判断两种情况直接返回
    if n == 1 {
		return 1
	}
	if n == 2 {
		return 2
	}
	return climbStairs(n-1) + climbStairs(n-2)
}

力扣执行测试一下,发现提示超出时间限制,说明上述代码虽然可以实现,但是时间复杂度高了。

回到 n = 4 的例子上,当第一步走一个台阶,剩下 3 级台阶的情况,递归的时候会把 3 带入,进而计算 2 级台阶的情况,当第一步走两个台阶,剩下 2 级台阶的情况,显然重复计算了。此时首先想到去重,那就引入一个 map ,改造代码:

// 定义结果集 map
var results = make(map[int]int)

func climbStairs(n int) int {
    // 结果集中存在直接返回,避免重复运算
    if val, ok := results[n]; ok {
        return val
    }
    if n == 1 {
        return 1
    }
	if n == 2 {
        return 2
    }
    results[n] = climbStairs(n-1) + climbStairs(n-2)
    return results[n]
}

力扣再次提交,成功通过。

计算器

给定一个包含正整数、加(+)、减(-)、乘(*)、除(/)的算数表达式(括号除外),计算其结果。

表达式仅包含非负整数,+、- 、*、/ 四种运算符和空格。整数除法仅保留整数部分。

示例 1:

输入:"3+2*2"
输出:7

示例 2:

输入:" 3/2 "
输出:1

示例 3:

输入:" 3+5 / 2 "
输出:5

说明:
你可以假设所给定的表达式都是有效的。
请不要使用内置的库函数 eval。

这个题拿到手后,当时首先想到就是拆分字符串,然后根据符号进行运算,但是这样会存在一些问题,要以什么字符来拆分呢?直接暴力使用 s := strings.Split(str, "") ,但也还是会存在个问题,如果数字不是 0-9 比如 12 呢?

上述方法行不通,所有不能直接拆分字符串,换个思路去做遍历字符串,第一次遍历遇到 * or / 就将符号两侧数值先进行计算返回一个只有 + or - 的新表达式,第二次遍历直接进行计算拿到最终的结果。

func calculate(str string) int {
    // 给字符串去空格
    str = strings.ReplaceAll(str, " ", "")

	// 第一次遍历:处理乘除运算
	var newExpr []string
	i := 0
	for i < len(str) {
		// 如果是 0-9 的数字直接 append
		if str[i] >= '0' && str[i] <= '9' {
			j := i
			for j < len(str) && str[j] >= '0' && str[j] <= '9' {
				j++
			}
			newExpr = append(newExpr, str[i:j])
			i = j
		} else {
			switch str[i] {
			case '+', '-':
				newExpr = append(newExpr, string(str[i]))
				i++
			case '*', '/':
				// 从表达式里边取出第一个数
				prev, _ := strconv.Atoi(newExpr[len(newExpr)-1])
				newExpr = newExpr[:len(newExpr)-1]

				// 提取后一个数
				j := i + 1
				for j < len(str) && str[j] >= '0' && str[j] <= '9' {
					j++
				}
				next, _ := strconv.Atoi(str[i+1 : j])

				// 计算结果并添加到新表达式中
				if str[i] == '*' {
					prev *= next
				} else {
					prev /= next
				}
				newExpr = append(newExpr, strconv.Itoa(prev))
				i = j
			}
		}
	}

	// 第二次遍历:处理加减运算
	result, _ := strconv.Atoi(newExpr[0])
	for i := 1; i < len(newExpr); {
		switch newExpr[i] {
		case "+":
			num, _ := strconv.Atoi(newExpr[i+1])
			result += num
			i += 2 // 跳过操作符和操作数
		case "-":
			num, _ := strconv.Atoi(newExpr[i+1])
			result -= num
			i += 2
		default:
			i++
		}
	}

	return result
}

运行一下,上述 case 成功通过。