滴滴一面面经
一、进程、线程、协程的区别
1.1 面试问题
- 进程:操作系统资源分配的最小单位,CPU、内存、磁盘。每一个进程都是操作系统中的一个独立的单元,拥有自己的地址空间、堆栈和代码段等。其中各个进程之间是相互独立的,彼此不会受到影响。进程的创建/销毁和切换回比较消耗系统资源。
- 线程:CPU调度的最小单位,能够利用CPU时间片来执行代码的最小调度单位。进程内能够用多个线程来进行异步操作,提高系统性能,并且一个进程内的多个线程共享该进程内所申请的资源。
- 协程:用户态的线程(大白话就是用户代码在线程层级上实现的轻量级线程,其只需要在用户态执行少量汇编代码去保存寄存器的值,而不需要由用户态切换到内核态来切换线程),避免当线程的时间片用完之后来回切换线程导致内核态和用户态之间的来回切换,以及线程上下文来回切换的性能损耗。假设m当前所挂载的线程由于时间片到了,要切换到其他线程来执行,此时线程之间的切换是无法避免的,此时仍然要进行线程的上下文保存等。通过该问题,面试官衍生出来内核态和用户态之间的区别。不是答的很好。
1.2 衍生问题
-
进程间同步
-
临界区与同步与互斥:堆临界资源进行访问的代码称为临界区,为了多个进程之间的互斥访问资源,每个进程在进入临界区间内,需要进程自身内通过互斥来进行访问。
-
信号量(初始值为1):信号量的值大于0表示有可用的资源,进程可以获取到该资源,并将信号量-1(原语操作),如果整形的信号量的值等于0,则表示没有可用的资源,进程必须等待。直到有可用的资源,并且其他进程释放了信号量。
刚开始A进程占用信号量,信号量-1,变为0的同时,进程A已经进入临界区域内。此时B进程发现信号量等于0,则进行Wait。当A进程退出临界区后,会将信号量+1,B进程获取到信号量时则会进入临界区。
-
-
进程间通信
- 管道:通过pipe创建一个管道,返回两个文件描述符,一个指向读,一个指向写。
- 消息队列:多个进程之间通过利用可靠的消息队列实现通信,比如Kafka等。
- 共享存储:多个进程之间共享同一段内存,针对这共享内存来实现数据同步。比如Redis。
- Socket:通过Socket网络编程实现进程之间的通信。
二、内核态和用户态之间的区别
2.1 面试问题
定义:
- 内核态:理解什么是内核态就要理解操作系统是什么。操作系统针对底层硬件资源进行封装和屏蔽,并向应用层提供了各种接口。而该接口的下层就是内核态。我们可以把内核态理解为一个执行环境,在该执行环境内,程序执行权限为特权级,也就是操作系统最高权限模式,在该模式下可以访问操作系统内部的一切所有资源。
- 用户态:其实用户态也是一个执行环境,只不过在该环境内程序可以执行的权限为用户模式,也就是说在用户模式下的执行环境中,程序只能访问自己的地址空间,不能直接访问底层硬件设备。而如果要访问内存、磁盘等都需要通过系统调用切换到内核态由内核态来执行相关高权限的操作指令。
2.2 衍生问题
- 用户模式:在用户模式下程序只能访问自己的地址空间,不能直接访问操作系统内核的地址空间。用户程序运行在该模式下,权限比较低,无法执行特权操作。
- 特权模式:内核模式或者管理模式,是操作系统的最高执行权限,在该模式下,操作系统代码可以执行特权操作来访问系统资源、执行IO操作、切换进程/线程等。其实切换线程其实就是对CPU的调用和轮换执行线程。只要操作系统内核和特权代码可以运行在这个模式下。
- 特权指令:中断相关操作、进程/线程切换、执行IO操作、清理内存等
- 什么情况下会发生用户态和内核态之间的切换:切换的意思就是从一个执行环境切换到另一个执行环境。
- 系统调用
- 异常或者中断处理
- 进程切换
- 为什么内核态和用户态之间的切换会浪费资源:
- 上下文切换开销:在切换执行环境时,需要保存在各个执行环境中保存当前环境的上下文(各个寄存器的值、栈指针等),并加载新的执行环境的上下文。这些操作需要CPU时间和内存带宽。
- 缓存失效:切换执行环境会导致CPU缓存失效,需要重新加载新环境的数据到缓存中。
- 额外操作:切换环境时需要权限检查、页表切换等。
三、数组、Slice之间的区别
一般为Golang Runtime 提供的数据结构或者操作等。
3.1 面试问题
-
数组:固定大小,不可扩容。
-
Slice实现原理:slice持有长度和底层数组的指针。当长度不够时需要进行扩容操作,这里要注意一个就是扩容之后的array和扩容之前的array不是一个指针。
type slice struct { array unsafe.Pointer len int cap int }
3.2 衍生问题
3.2.1 协程通信Channel工作原理
发送:
- 发送时,如过receq有g在等待,则直接将本次要发送的data直接传给receq中的队头g
- 如果receq没有等待的g,此时如果有Buffer且Buffer没有满,则会将本次要发送的内容存入到Buffer中。
- 如果上面两步都没有完成,那么就会将当前发送的g存入到Channel的sendq中。
接受:
- 接受数据时,如果当前sendq有等待发送的g,则会从sendq中取出第一个g,如果此时有Buffer则会取出Buffer中的第一个元素给当前的接受数据的g,并将sendq中的第一个g的数据放入到Buffer中。然后修改g的状态为可运行状态。
- 如果既没有sendq,Buffer中也没有数据,那么当前接受数据的g则会存入到recvq中进行等待。
3.2.1 sync.WaitGroup
Golang中的sync.WaitGroup通过一个8个字节的int64来实现当前有多少g在并发执行,当前有多少g在执行wait,每次进行Done都会触发信号量的通知,而当最后一个运行的g执行Done触发信号量通知后,Wait等待的g则会被唤醒。
**Golang 中的信号量通知机制:**runtime实现的,并没有对外暴露。
3.2.1 sync.Poll
针对每一个P都会有一个链表,而每一个链表上都维护了一个环形队列,用来保存具体的数据,其在某个结构体用完之后put到Poll中,然后Get到下次再用。避免GC和重新内存申请。
四、垃圾回收
4.1 面试问题
4.1.1 GC什么时候触发
-
后台定时运行定时检查和垃圾收集
在启动main时会优先调用runtime.main处理函数(并非用户可见的main函数,而是Golang Runtime自己的main函数,该函数内会调用用户main函数),在调用用户main函数之前会创建一个g用来去执行
sysmon后台函数,该函数在go进程停掉之前一直存在,可以认为是go进程的监控g。在该g的处理逻辑中,会在每次循环中都去检查是否需要进行GC,如果需要进行GC则会触发GC。func sysmon() { (...) for { // check if we need to force a GC // 每次for循环都检查是否需要GC,如果需要GC则会触发GC。t.test()函数具体判断当前是否需要GC。 if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && forcegc.idle.Load() { lock(&forcegc.lock) forcegc.idle.Store(false) var list gList list.push(forcegc.g) injectglist(&list) unlock(&forcegc.lock) } } } -
申请内存时根据堆大小触发垃圾收集
申请内存时,当申请的内存大于32KB或者小对象需要mcache从mcentrol中获取内存时一定触发尝试GC函数,如果此时
test()函数返回true那么就会进行GC。具体源码逻辑如下:func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer { shouldhelpgc := false ... if size <= maxSmallSize { if noscan && size < maxTinySize { ... v := nextFreeFast(span) if v == 0 { // shouldhelpgc 从mcentrol获取内存时为true v, _, shouldhelpgc = c.nextFree(tinySpanClass) } ... } else { ... v := nextFreeFast(span) if v == 0 { v, span, shouldhelpgc = c.nextFree(spc) } ... } } else { // shouldhelpgc 本次申请的内存大小大于32KB。 shouldhelpgc = true ... } ... // 当满足上面两个条件时则会进行GC。 if shouldhelpgc { if t := (gcTrigger{kind: gcTriggerHeap}); t.test() { gcStart(t) } } return x } -
用户程序手动触发垃圾收集:用户代码维度手动调用
runtime.GC()函数触发GC,该函数是阻塞的。
**上面三种是主动触发GC的时机,但是其还需要通过test()函数来判断是否需要启动GC。**具体实现逻辑:
// 判断是否需要进行GC。
func (t gcTrigger) test() bool {
if !memstats.enablegc || panicking.Load() != 0 || gcphase != _GCoff {
return false
}
switch t.kind {
case gcTriggerHeap:
// 堆内存分配达到控制器计算的触发堆大小(Golang调步算法)
trigger, _ := gcController.trigger()
return gcController.heapLive.Load() >= trigger
case gcTriggerTime:
// 如果一段时间内没有执行GC,则会触发GC
if gcController.gcPercent.Load() < 0 {
return false
}
lastgc := int64(atomic.Load64(&memstats.last_gc_nanotime))
return lastgc != 0 && t.now-lastgc > forcegcperiod
case gcTriggerCycle:
// 不是很理解。
return int32(t.n-work.cycles.Load()) > 0
}
return true
}
总结下来就是三种情况:
- 堆内存分配达到控制器计算的触发堆大小(Golang调步算法)
- 一段时间内没有触发GC,则会触发GC。
- 不是很理解。
4.1.2 内存屏障和总线嗅探技术
面试的时候没有把内存屏障时什么说清楚,其对内存屏障的本质核心不理解。总线嗅探技术?
- 内存屏障:内存屏障是一种同步机制,用于确保在多线程或者多核系统中对共享内存读写操作的一致性和顺序性。其作用hi强制对内存的访问按照特定的顺序执行,以确保程序的正确性和可预测性。
- 读屏障:确保在读内存之前,使之前的所有写入操作对当前线程可见。读屏障保证当前线程读取到的数据永远是最新的。
- 写屏障:确保在写入内存后,将之前的所有写入操作刷新到内存中,使其他线程能够及时看到最新的数据。
- 全屏障:读屏障和写屏障的组合,确保在内存操作前后都进行同步。
Golang 的写屏障的实现原理是申请内存时先做一些逻辑处理,然后在执行后续Write等操作。
**总线嗅探技术:**该技术是 Java 中关键字 Volatile 实现的原因? 、
总线嗅探技术和内存屏障有什么关系呢?
总线嗅探技术指的是操作系统或者硬件系统中用于监视计算机上的数据传输和操作的一种技术,计算机中的总线是链接CPU、内存、IO设备等组件的数据传输通道,总线嗅探技术可以让系统或者硬件实时的监控总线上的数据传输情况,从而实现一些缓存一致性的能力:当某个处理修改了内存中的数据时,其他处理器的缓存中可能存在失效的数据副本,总线嗅探技术可以让处理器监视总线上的数据传输,当发现有处理器写入内存时,及时使的其他处理器的缓存中的数据失效,保存CPU缓存一致性。
4.1.3 三色标记法
- 开启STW,之后标记过程和用户程序并发执行
- 将所有对象标记为白色对象
- 从GCRoot节点开始遍历,将GCRoot可直达的对象标记为灰色对象
- 遍历灰色对象集合,将灰色对象所引用的白色对象标记为灰色对象,如果灰色对象没有白色对象引用,则将灰色对象标记为黑色对象。
- 循环遍历灰色对象集合步骤,直到灰色对象集合中不存在任何灰色对象
- 关闭STW
- 执行GC回收
**疑问:**三色标记法是不是也可以用2个颜色来实现呢?
我理解是可以的,三色标记法的重点是通过不断遍历灰色集合将白色/灰色集合不断放入到黑色,如果放入不到黑色集合中,则可以认为是本次GC要进行清理的内容。如果我们将灰色/白色标记成一种颜色(我们就称作为白色集合),理论上也是可以实现的,我们从GCRoot开始遍历(GC开始前所有对象都被放在白色集合中),如果一个对象是GCRoot可以直达的,则直接存入到黑色对象中。然后循环遍历黑色对象,这里只需要循环一次,这一次就可以将白色对象集合中的剩余可达对象存入到黑色对象中,那么剩余的就是本次GC可以回收的内容。同时,标记过程也是需要开启写屏障的,直接将新对象放入到黑色对象集合中。
4.2 衍生问题
Java 中的几种GC的原理。
新生代、老年代?
4.2.0 JVM 内存分配
JVM内存布局:
- 堆:Java 堆是被所有线程共享的内存区域,所有通过
new对象创建的对象都会被分配到堆上。 - 栈:线程私有内存区域,用于存储方法调用、局部变量、操作数栈等信息。每个线程
- 方法区:所有线程共享的内存区域,用于存储类的元信息、静态变量、常量池、方法字节码等数据。
- 程序计数器:存储当前线程执行的字节码指令地址。
**堆划分:**划分为老年代和年轻代,是Java堆中的两个主要分区,用于存储对象实例。
- 年轻代:年轻代又划分为Eden区、From区、To区
- 当使用New关键字创建新的对象时,这些对象就会被分配到Eden区,大多数新创建的对象在短时间内就会变成垃圾对象,这些垃圾对象会在年轻代中被垃圾回收器快速回收。
- 当进行垃圾回收时,年轻代中的存活对象会被移动到From区。当From区经历过多次GC后就会被放入老年代。
- 老年代:Java 中主要用于存放长时间存活的对象,在Java中如果一个对象在多次GC后仍然存活,那么就可以认为时长时间存活的对象。当老年代空间不足时会触发Full GC。
4.2.1 CMS
- 初始标记:需要暂停所有用户线程,其主要的目的就是标记GC Root可以直达的对象。因为GC是必须需要从GC Root开始遍历寻找到垃圾对象的。
- 并发标记:递归标记初始标记的GC Root所标记的对象,这样就可以将所有存活的对象全部遍历
- 重新标记:针对并发标记,重新标记在并发标记过程中产生的浮动垃圾。因为这部分浮动垃圾是和并发标记用户线程并发执行的,在并发标记阶段就可能会有用户线程产生多余的垃圾。
- 并发清除:清除标记过程中标记的垃圾对象。同理该过程也会产生浮动垃圾,这部分浮动垃圾将会在下次GC时执行处理。
Java 为了解决浮动垃圾的问题导致CMS并发失败,其在Go内存使用率达到92%时就会强制进行GC,剩余的8%提供给并发执行的用户线程使用。
4.2.2 G1
G1摒弃掉了传统的分代GC(由此可见Go落后Java很多,目前Go GC还在朝着分代GC努力),不在将将固定大小的堆划分为老年代和年轻代,而是把连续的Java堆划分为多个大小相等的Region区域,每一个Region在不同时候都可以扮演新生代或者老年代。
G1要解决如下问题:
- 不同Region跨代引用:G1维护了一个双向卡表,其中维护了谁指向我以及我指向谁等。G1需要预留足够大的空间来进行维护卡表。
- GC线程和用户线程并发执行:每一个Region都预留了两个TAMS指针,在并发GC过程中,新产生的内存对象都会被分配在这两个指针区间的内存中。
- 可靠的停顿模型:
具体步骤:
- 初始标记:仅仅标记GC Root能直接关联到的对象,并且修改TAMS指针,回收上一次GC过程中产生的垃圾。
- 并发标记:从GC Root关联的对象开始递归扫描整个对象图,找出要进行GC的对象,这个阶段耗时比较长,所以和用户线程并发执行。
- 最终标记:STW,对并发标记过程中出现的SATB记录
- 筛选回收:针对每个要回收的Region进行价值排序,优先回收价值高的Region。
4.2.3 ZGC
**染色指针:**目前操作系统总线其实是用不到64位的,而Java则从剩余的位上提取出4位用来存储GC相关信息,而通过内存多重映射将修改过后的指针依然能够访问到目标内存地址。
ZGC过程:参考
zhuanlan.zhihu.com/p/364813270
五、日常需求开发流程
这里就是要问项目了。
- 需求沟通
- 理解需求内容
- 需求评审
- 技术评审(方案评审)
- 开发、自测、联调、测试、灰度、上线。
- 需求功能线上数据监控,保证需求质量没有问题
六、需求怎么才能算完成
- 规定时间内完成,没有延期。
- 按照需求内容实现该需求所需要的效果,也就是产品想要实现的效果。
- 上线之后,通过日志、数据监控观测本次新feature是否正常等。
七、项目
- 项目介绍:介绍自己所主要负责的项目,重点是讲清楚自己在做了什么东西,取的了什么效果,中间有什么问题。可以这么理解公式:介绍=为什么做这个项目+自己做了什么东西+取的了什么效果+中间有什么问题来描述,这样面试官就会知道你在这个中间做了什么,负责什么,人家才会有针对性的提出问题。
- 方案设计:各个项目的方案不尽相同。
- 技术选型
- 为什么用Redis而不用Kafka:我这个项目的场景需要知道某个容器所持有的元素数量,而Kafka消息队列无法知道容器内消息长度。而我项目场景需要知道容器内现有元素是多少,所以就选择了Redis利用其Zset实现滑动窗口效果。其他消息队列没有去参考是因为团队内只引用了Kafka,在引入一个其他消息队列不太合适。
- 整个方案中所依赖的中间件
- Redis
- MySQL
- KVSQL
- 项目难点
- 方案设计
- 代码规范
- 并发调试
- 内存泄漏:离线任务上线后出现了内存暴涨,直接从300M涨到了1G,通过查看监控可以得知一定是服务内部出现了内存泄漏。于是停止灰度。在测试环境利用pprof进行复现观察。最终通过pprof内存树得知某个SDK的TCP链接使用了但是未进行Close,导致内存暴涨。定位到时某个SDK的问题之后,重新去回顾逻辑可以一眼就看到问题。修改测试后,发现测试环境没有问题,直接灰度正式环境,灰度一个Pod后,进行测试,发现测试期间内内存有涨,但是测试结束内存下降到水位线。
八、MySQL
项目问完之后,又问MySQL不是很懂这个流程。
8.1 面试问题
-
索引是什么?为什么要用索引:索引就是数据按照某个结构来存储,用来加快数据查询,降低IO次数。
-
为什么索引要用B+树?而不用B树或者哈希?
不用哈希是因为范围查找很浪费性能,要进行多次IO。B树和B+树呢? B树并不是将索引和数据存放在叶子节点,其非页子节点也有可能存储索引,这样会导致树的高度会很高,会进行多次IO对比B+树来说。B+树只有叶子节点才会存储数据,而非叶子节点不会存储数据。这样就会降低多次IO,提升性能。
-
主键索引和二级索引的区别
InnoDB存储引擎在存储数据时其本身就存储在主键索引的叶子节点上,而二级索引则会重现单独创建索引数据结构和索引文件。如果在二级索引上命中数据则需要通过回表来去主键索引中找到对应的叶子节点获取数据。
-
主键一般用什么类型的值比较合适
主键一般用int类型的值就可以了。不用varchar是因为索引遍历是性能要比int低。
8.2 衍生问题
- B+树索引结构、遍历过程
- 1500w的数据表,如果根据主键来进行索引,需要几次IO
- Record Lock、GapLock、NextKey Lock
九、算法
算法是在问完GC之后写的。现场给出思路,尝试写,但是最后没有运行。回家后按照之前的思路已实现。
字符串转Number
// convertStringToNumber 字符串转换数字
func convertStringToNumber(str string) (int, bool) {
if len(str) == 0 {
return 0, false
}
numberMapping := make(map[string]int)
for i := 0; i < 10; i++ {
numberMapping[strconv.Itoa(i)] = i
}
var result int
for i := 0; i < len(str); i++ {
curStr := str[i : i+1]
number, exists := numberMapping[curStr]
if !exists {
return 0, false
}
result = result + number*getTargetNumberBase(len(str)-i-1)
}
return result, true
}
// getTargetNumberBase 获取Number的基数
func getTargetNumberBase(carry int) int {
return int(math.Pow(10, float64(carry)))
}