面试知识准备
Java
基础
面向对象的特征
抽象,继承,封装,多态
方法重载(overload)实现的是编译时的多态性(也称为前绑定),而方法重写(override)实现的是运行时的多态性(也称为后绑定)。运行时的多态是面向对象最精髓的东西,要实现多态需要做两件事:1). 方法重写(子类继承父类并重写父类中已有的或抽象的方法);2). 对象造型(用父类型引用引用子类型对象,这样同样
的引用调用同样的方法就会根据子类对象的不同而表现出不同的行为)
public,private,default,protected
修饰符 | 当前类 | 同包 | 子类 | 其他包 |
---|---|---|---|---|
public | √ | √ | √ | √ |
protected | √ | √ | √ | × |
default | √ | √ | × | × |
private | √ | × | × | × |
数据类型
基本类型:boolean,char,byte,short,int,long,float,double
包装类型(自动装箱/拆箱):Boolean,Character,Byte,Short,Integer,Long,Float,Double
1 | public class Test { |
1 | public class Test { |
引用类型:比如Java 5 以后引入的枚举类型
&和&&的区别
&运算符有两种用法:(1)按位与; (2)逻辑与。&&运算符是短路与运算。逻辑与跟短路与的差别是非常巨大的,虽然二者都要求运算符左右两端的布尔值都是true 整个表达式的值才是true。**&&之所以称为短路运算是因为,如果&&左边的表达式的值是false, 右边的表达式会被直接短路掉,不会进行运算**
栈(stack)、堆(heap)和方法区(method area)
通常定义一个基本数据类型的变量, 一个对象的引用, 还有就是函数调用的现场保存都使用JVM中的栈空间;而通过new 关键字和构造器创建的对象则放在堆空间,
堆是垃圾收集器管理的主要区域,由于现在的垃圾收集器都采用分代收集算法, 所以堆空间还可以细分为新生代和老生代,再具体一点可以分为Eden、Survivor(又可分为From Survivor 和To Survivor)、Tenured
方法区和堆都是各个线程共享的内存区域,用于存储已经被JVM 加载的类信息、常量、静态变量、JIT 编译器编译后的代码等数据
程序中的字面量(literal)如直接书写的100、”hello” 和常量都是放在常量池中, 常量池是方法区的一部分
栈空间操作起来最快但是栈很小,通常大量的对象都是放在堆空间,栈和堆的大小都可以通过JVM的启动参数来进行调整,栈空间用光了会引发StackOverflowError,而堆和常量池空间不足则会引发OutOfMemoryError
switch(expr)
在Java 5 以前,switch(expr)中,expr 只能是byte、short、char、int。从Java 5 开始, Java 中引入了枚举类型,expr 也可以是 enum 类型,从Java 7 开始,expr 还可以是字符串(String), 但是长整型(long)在目前所有的版本中都是不可以的
重写hashCode
为什么使用乘法
使用乘法来生成哈希值主要是为了增加哈希值的散列性。散列值是通过类的字段(通常是成员变量)计算出来的,乘法运算有助于在组合这些字段时,尽量减少不同字段组合产生相同哈希值的可能性。特别是对于不同的输入值,乘法能够让最终的哈希值尽量分布均匀,从而避免哈希冲突
为什么使用31这个素数
在 Java 的虚拟机(JVM)中,31 这个数有一个非常特别的优化,它可以通过 移位运算 来替代乘法运算。由于 31 等价于 (2^5 - 1),所以 31 * num 可以被优化成 (num << 5) - num,即通过左移 5 位再减去原值来代替乘法。这种方式能够大大提高性能,尤其是在循环中大量调用 hashCode() 时
equals与hashCode方法
Java 对于eqauls 方法和hashCode 方法是这样规定的: (1)如果两个对象相同(equals 方法返回true), 那么它们的hashCode 值一定要相同;(2)如果两个对象的hashCode 相同,它们并不一定相同(哈希冲突)
首先equals 方法必须满足自反性( x.equals(x)必须返回true)、对称性( x.equals(y)返回true 时, y.equals(x)也必须返回true)、传递性(x.equals(y)和y.equals(z)都返回true 时, x.equals(z)也必须返回true)和一致性(当x 和y 引用的对象信息没有被修改时, 多次调用x.equals(y)应该得到同样的返回值),而且对于任何非null 值的引用x,x.equals(null)必须返回false
实现高质量的equals 方法的诀窍包括:
- 使用==操作符检查”参数是否为这个对象的引用”;
- 使用instanceof 操作符检查”参数是否为正确的类型”;
- 对于类中的关键属性,检查参数传入对象的属性是否与之相匹配;
- 编写完equals方法后, 问自己它是否满足对称性、传递性、一致性;
- 重写equals 时总是要重写hashCode;
- 不要将equals 方法参数中的Object 对象替换为其他的类型,在重写时不要忘掉@Override 注解
Go
GMP模型
在多进程/多线程的操作系统中,就解决了阻塞的问题,因为一个进程阻塞cpu可以立刻切换到其他进程中去执行,而且调度cpu的算法可以保证在运行的进程都可以被分配到cpu的运行时间片。这样从宏观来看,似乎多个进程是在同时被运行
但新的问题就又出现了,进程拥有太多的资源,进程的创建、切换、销毁,都会占用很长的时间,CPU虽然利用起来了,但如果进程过多,CPU有很大的一部分都被用来进行进程调度了
很明显,CPU调度切换的是进程和线程。尽管线程看起来很美好,但实际上多线程开发设计会变得更加复杂,要考虑很多同步竞争等问题,如锁、竞争冲突等
协程
N:1关系
N个协程绑定1个线程,优点就是协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速。但也有很大的缺点,1个进程的所有协程都绑定在1个线程上
- 某个程序用不了硬件的多核加速能力
- 一旦某协程阻塞,造成线程阻塞,本进程的其他协程都无法执行了,根本就没有并发的能力了
1:1关系
1个协程绑定1个线程,这种最容易实现。协程的调度都由CPU完成了,不存在N:1缺点
- 协程的创建、删除和切换的代价都由CPU完成,有点略显昂贵了
M:N关系
M个协程绑定N个线程,是N:1和1:1类型的结合,克服了以上2种模型的缺点,但实现起来最为复杂
协程跟线程是有区别的,线程由CPU调度是抢占式的,协程由用户态调度是协作式的,一个协程让出CPU后,才执行下一个协程
goroutine
Go为了提供更容易使用的并发方法,使用了goroutine和channel。goroutine来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被runtime调度,转移到其他可运行的线程上
Go中,协程被称为goroutine,它非常轻量,一个goroutine只占几KB,并且这几KB就足够goroutine运行完,这就能在有限的内存空间内支持大量goroutine,支持了更多的并发。虽然一个goroutine的栈只占几KB,但实际是可伸缩的,如果需要更多内容,runtime
会自动为goroutine分配。
Goroutine特点:
- 占用内存更小(几kb)
- 调度更灵活(runtime调度)
早期goroutine调度器
M想要执行、放回G都必须访问全局G队列,并且M有多个,即多线程访问同一资源需要加锁进行保证互斥/同步,所以全局G队列是有互斥锁进行保护的
缺点:
- 创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争
- M转移G会造成延迟和额外的系统负载。比如当G中包含创建新协程的时候,M创建了G’,为了继续执行G,需要把G’交给M’执行,也造成了很差的局部性,因为G’和G是相关的,最好放在M上执行,而不是其他M’。
- 系统调用(CPU在M之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销
GMP模型设计思想
G —— goroutine协程(用户态)
P —— processor处理器
M —— thread(内核)
- 全局队列(Global Queue):存放等待运行的G
- P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G’时,G’优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列
- P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个
- M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去
P和M的个数问题
1、P的数量:
- 由启动时环境变量$GOMAXPROCS或者是由runtime的方法GOMAXPROCS()决定。这意味着在程序执行的任意时刻都只有$GOMAXPROCS个goroutine在同时运行
2、M的数量:
- go语言本身的限制:go程序启动时,会设置M的最大数量,默认10000.但是内核很难支持这么多的线程数,所以这个限制可以忽略
- runtime/debug中的SetMaxThreads函数,设置M的最大数量
- 一个M阻塞了,会创建新的M
M与P的数量没有绝对关系,一个M阻塞,P就会去创建或者切换另一个M,所以,即使P的默认数量是1,也有可能会创建很多个M出来
P和M何时会被创建
1、P何时创建:在确定了P的最大数量n后,运行时系统会根据这个数量创建n个P
2、M何时创建:没有足够的M来关联P并运行其中的可运行的G。比如所有的M此时都阻塞住了,而P中还有很多就绪任务,就会去寻找空闲的M,而没有空闲的,就会去创建新的M
调度器的设计策略
复用线程:避免频繁的创建、销毁线程,而是对线程的复用
- work stealing机制:当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程
- hand off机制:当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行
利用并行:GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行。GOMAXPROCS也限制了并发的程度,比如GOMAXPROCS = 核数/2,则最多利用了一半的CPU核进行并行
抢占:在coroutine中要等待一个协程主动让出CPU才执行下一个协程,在Go中,一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死,这就是goroutine不同于coroutine的一个地方
全局G队列:在新的调度器中依然有全局G队列,当P的本地队列为空时,优先从全局队列获取,如果全局队列为空时则通过work stealing机制从其他P的本地队列偷取G
go func() 调度流程
- 我们通过 go func()来创建一个goroutine
- 有两个存储G的队列,一个是局部调度器P的本地队列、一个是全局G队列。新创建的G会先保存在P的本地队列中,如果P的本地队列已经满了就会保存在全局的队列中
- G只能运行在M中,一个M必须持有一个P,M与P是1:1的关系。M会从P的本地队列弹出一个可执行状态的G来执行,如果P的本地队列为空,就会想其他的MP组合偷取一个可执行的G来执行
- 一个M调度G执行的过程是一个循环机制
- 当M执行某一个G时候如果发生了syscall或则其余阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P
- 当M系统调用结束时候,这个G会尝试获取一个空闲的P执行,并放入到这个P的本地队列。如果获取不到P,那么这个线程M变成休眠状态, 加入到空闲线程中,然后这个G会被放入全局队列中
调度器的生命周期
M0:是启动程序后的编号为0的主线程,这个M对应的实例会在全局变量runtime.m0中,不需要在heap上分配,M0负责执行初始化操作和启动第一个G, 在之后M0就和其他的M一样了
G0:是每次启动一个M都会第一个创建的gourtine,G0仅用于负责调度的G,G0不指向任何可执行的函数, 每个M都会有一个自己的G0。在调度或系统调用时会使用G0的栈空间, 全局变量的G0是M0的G0
1 | package main |
- runtime创建最初的线程m0和goroutine g0,并把2者关联
- 调度器初始化:初始化m0、栈、垃圾回收,以及创建和初始化由GOMAXPROCS个P构成的P列表
- 示例代码中的main函数是main.main,runtime中也有1个main函数——runtime.main,代码经过编译后,runtime.main会调用main.main,程序启动时会为runtime.main创建goroutine,称它为main goroutine吧,然后把main goroutine加入到P的本地队列
- 启动m0,m0已经绑定了P,会从P的本地队列获取G,获取到main goroutine
- G拥有栈,M根据G中的栈信息和调度信息设置运行环境
- M运行G
- G退出,再次回到M获取可运行的G,这样重复下去,直到main.main退出,runtime.main执行Defer和Panic处理,或调用runtime.exit退出程序
runtime.main的goroutine运行,才是调度器的真正开始,直到runtime.main结束而结束
可视化GMP
go tool trace
trace记录了运行时的信息,能提供可视化的Web页面
1 | package main |
1 | go run trace.go |
Debug trace
1 | package main |
1 | go build trace.go |
- SCHED:调试信息输出标志字符串,代表本行是goroutine调度器的输出;
- 0ms:即从程序启动到输出这行日志的时间;
- gomaxprocs: P的数量,本例有2个P, 因为默认的P的属性是和cpu核心数量默认一致,当然也可以通过GOMAXPROCS来设置;
- idleprocs: 处于idle状态的P的数量;通过gomaxprocs和idleprocs的差值,我们就可知道执行go代码的P的数量;
- threads: os threads/M的数量,包含scheduler使用的m数量,加上runtime自用的类似sysmon这样的thread的数量;
- spinningthreads: 处于自旋状态的os thread数量;
- idlethread: 处于idle状态的os thread的数量;
- runqueue=0: Scheduler全局队列中G的数量;
- [0 0]: 分别为2个P的local queue中的G的数量
调度器调度场景分析
G1创建G2
P拥有G1,M1获取P后开始运行G1,G1使用**go func()**创建了G2,为了局部性G2优先加入到P1的本地队列
G执行完毕
G1运行完成后(函数:goexit),M上运行的goroutine切换为G0,G0负责调度时协程的切换(函数:schedule)。从P的本地队列取G2,从G0切换到G2,并开始运行G2(函数:execute)。实现了线程M1的复用
G2开辟过多的G
G2本地满再创建G7
G2在创建G7的时候,发现P1的本地队列已满,需要执行负载均衡(把P1中本地队列中前一半的G,还有新创建G转移到全局队列)
G2本地未满再创建G8
G8加入到P1点本地队列的原因还是因为P1此时在与M1绑定,而G2此时是M1在执行。所以G2创建的新的G会优先放置到自己的M绑定的P上
唤醒正在休眠的M
在创建G时,运行的G会尝试唤醒其他空闲的P和M组合去执行
假定G2唤醒了M2,M2绑定了P2,并运行G0,但P2本地队列没有G,M2此时为自旋线程(没有G但为运行状态的线程,不断寻找G)
被唤醒的M2从全局队列获取批量G
M2尝试从全局队列(简称“GQ”)取一批G放到P2的本地队列(函数:**findrunnable()**)。M2从全局队列取的G数量符合下面的公式:
n = min(len(GQ) / GOMAXPROCS + 1, len(GQ/2))
1 | // 从全局队列中偷取,调用时必须锁住调度器 |
M2从M1中偷取G
全局队列已经没有G,那m就要执行work stealing(偷取):从其他有G的P哪里偷取一半G过来,放到自己的P本地队列。P2从P1的本地队列尾部取一半的G
自旋线程的最大限制
自旋本质是在运行,线程在运行却没有执行G,就变成了浪费CPU. 为什么不销毁线程,来节约CPU资源。因为创建和销毁CPU也会浪费时间,我们希望当有新goroutine创建时,立刻能有M运行它,如果销毁再新建就增加了时延,降低了效率。当然也考虑了过多的自旋线程是浪费CPU,所以系统中最多有GOMAXPROCS个自旋的线程,多余的没事做线程会让他们休眠
G发送系统调用/阻塞
假定当前除了M3和M4为自旋线程,还有M5和M6为空闲的线程(没有得到P的绑定,注意我们这里最多就只能够存在4个P,所以P的数量应该永远是M>=P, 大部分都是M在抢占需要运行的P),G8创建了G9,G8进行了阻塞的系统调用,M2和P2立即解绑,P2会执行以下判断:如果P2本地队列有G、全局队列有G或有空闲的M,P2都会立马唤醒1个M和它绑定,否则P2则会加入到空闲P列表,等待M来获取可用的p(为什么不与自旋线程绑定?因为自旋线程已经MP绑定了,无法再绑定P)
G发送系统调用/非阻塞
假如G8进行了非阻塞系统调用
M2和P2会解绑,但M2会记住P2,然后G8和M2进入系统调用状态。当G8和M2退出系统调用时,会尝试获取P2,如果无法获取,则获取空闲的P,如果依然没有,G8会被记为可运行状态,并加入到全局队列,M2因为没有P的绑定而变成休眠状态(长时间休眠等待GC回收销毁)
参考文档
GC回收机制
Go V1.3标记-清除(mark and sweep)算法
暂停程序业务逻辑, 分类出可达和不可达的对象,然后做上标记
开始标记,程序找出它所有可达的对象,并做上标记
标记完了之后,然后开始清除未标记的对象
操作非常简单,但是有一点需要额外注意:mark and sweep算法在执行的时候,需要程序暂停!即 STW(stop the world),STW的过程中,CPU不执行用户代码,全部用于垃圾回收,这个过程的影响很大,所以STW也是一些回收机制最大的难题和希望优化的点。所以在执行第三步的这段时间,程序会暂定停止任何工作,卡在那等待回收执行完毕
停止暂停,让程序继续跑。然后循环重复这个过程,直到process程序生命周期结束
缺点:
- STW,stop the world;让程序暂停,程序出现卡顿;
- 标记需要扫描整个heap;
- 清除数据会产生heap碎片
Go V1.5三色并发标记法
Golang中的垃圾回收主要应用三色标记法,GC过程和其他用户goroutine可并发运行,但需要一定时间的STW(stop the world)
每次新创建的对象,默认的颜色都是标记为“白色”
每次GC回收开始, 会从根节点开始遍历所有对象,把遍历到的对象从白色集合放入“灰色”集合
本次遍历是一次遍历,非递归形式,是从程序抽次可抵达的对象遍历一层
遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合
重复第三步, 直到灰色中无任何对象
当我们全部的可达对象都遍历完后,灰色标记表将不再存在灰色对象,目前全部内存的数据只有两种颜色,黑色和白色。那么黑色对象就是我们程序逻辑可达(需要的)对象,这些数据是目前支撑程序正常业务运行的,是合法的有用数据,不可删除,白色的对象是全部不可达对象,目前程序逻辑并不依赖他们,那么白色对象就是内存中目前的垃圾数据,需要被清除
回收所有的白色标记表的对象. 也就是回收垃圾
思考:没有STW的三色标记法会出现什么问题?
条件1: 一个白色对象被黑色对象引用**(白色被挂在黑色下)**
条件2::灰色对象与它之间的可达关系的白色对象遭到破坏**(灰色同时丢了该白色)**
如果当以上两个条件同时满足时,就会出现对象丢失现象!
屏障机制
“强 - 弱” 三色不变式
- 强三色不变色实际上是强制性的不允许黑色对象引用白色对象,这样就不会出现有白色对象被误删的情况
- 黑色对象可以引用白色对象,但是这个白色对象必须存在其他灰色对象对它的引用,或者可达它的链路上游存在灰色对象。 这样实则是黑色对象引用白色对象,白色对象处于一个危险被删除的状态,但是上游灰色对象的引用,可以保护该白色对象,使其安全
插入屏障(不在栈)
具体操作: 在A对象引用B对象的时候,B对象被标记为灰色。(将B挂在A下游,B必须被标记为灰色)
满足: 强三色不变式. (不存在黑色对象引用白色对象的情况了, 因为白色会强制变成灰色)
删除屏障
具体操作: 被删除的对象,如果自身为灰色或者白色,那么被标记为灰色
满足:弱三色不变式. (保护灰色对象到白色对象的路径不会断)
Go V1.8混合写屏障机制
- GC开始将栈上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW)
- GC期间,任何在栈上创建的新对象,均为黑色
- 被删除的对象标记为灰色
- 被添加的对象标记为灰色
满足: 变形的弱三色不变式
场景一: 对象被一个堆对象删除引用,成为栈对象的下游
场景二:对象被一个栈对象删除引用,成为另一个栈对象的下游
场景三:对象被一个堆对象删除引用,成为另一个堆对象的下游
场景四:对象从一个栈对象删除引用,成为另一个堆对象的下游
项目技术总结
微精弘
网络流量切换
背景
为了提升系统在调用外部认证服务(统一验证 OAuth、正方教务系统 ZF)过程中的可用性与鲁棒性,项目设计实现了一套基于断路器模式的网络流量切换机制
该机制实现了对多个 API 接口的高可用访问策略,能够根据接口返回结果自动判断接口是否失效,并动态地切换或恢复流量,提高系统稳定性与容错性
当某一认证服务频繁失败时,自动暂停使用,并定期进行探测,恢复后重新纳入负载均衡池中,避免影响整体服务质量
优势
- 快速失败:避免访问异常接口拖慢整体服务
- 自动恢复:接口恢复后可自动重新启用
- 动态流量切换:可在多个接口之间智能选择最可用者
该机制主要涉及三大组件:
- 负载均衡器(LoadBalance)
- 熔断器状态快照(ApiSnapShot) Closed,Open,HalfOpen
- 存活性探测器(LiveNessProbe)
核心模块
CircuitBreaker 熔断器主控结构
1 | type CircuitBreaker struct { |
- LB:根据接口可用性和类型进行负载均衡
- SnapShot:维护每个接口的调用快照(ApiSnapShot),用于统计失败次数、控制状态切换
方法 | 说明 |
---|---|
Fail(api, loginType) | 接口调用失败计数,失败超限则从负载池中移除并进入 Open 状态 |
GetApi(zfFlag, oauthFlag) | 从可用接口中选出目标接口 |
Success(api, loginType) | 接口调用成功,重置失败计数,切回 Closed |
LoadBalance 负载均衡器
支持两个类型接口:
- ZF(教务系统)
- Oauth(统一认证)
负载策略:随机轮询(randomLB)
通过 Pick() 方法从可用接口列表中随机选出一个
1 | func (lb *LoadBalance) Pick(zfFlag, oauthFlag bool) (string, funnelApi.LoginType, error) |
- 若 ZF 和 Oauth 都可用,随机选择一个
- 若仅一方可用,则返回该类型
- 若全部不可用,抛出错误
ApiSnapShot 接口快照
记录失败/总调用次数与状态
1 | type ApiSnapShot struct { |
- **Fail()**:失败计数 +1,当失败数 > 50 且状态为 Closed,切换为 Open
- **Success()**:重置错误计数并切换回 Closed 状态
LiveNessProbe 存活性探测器
失效接口被熔断后,会被移出负载池并加入 LiveNessProbe。定时轮询检测其是否恢复:
1 | func (l *LiveNessProbe) Start(ctx context.Context) |
- 通过构造表单请求目标接口
- 若返回状态码为 200、412、416,则视为恢复成功
- 成功后重新加入负载池,更新熔断状态为 Closed
流程
1 | 调用接口 |
精弘毅行报名系统
提交队伍(高并发)
防止高并发抢名额导致的超卖、重复、系统崩溃等问题
- 连接池优化:合理配置数据库与 Redis 连接池(900)
- 令牌桶限流:限制瞬时高并发流量,防止服务器雪崩(5000)
- Redis 单用户 QPS 限制:
- 利用 Redis 的 INCR + SETNX 设置一个临时计数器
- 每次访问将 openid_Limit +1,过期时间为 60s
- 超过 200 次/分钟就拒绝请求
- Redis + Lua 脚本原子操作:
- 是否已经提交过队伍(SISMEMBER)
- 是否票数已达上限(GET 判断剩余票数)
- 原子提交操作:DECR 剩余票数;SADD 记录已提交队伍
- 提交队伍前的业务验证:是否加入队伍;是否为队长;队伍人数是否≥4
如何解决数据一致性问题?
异步落库 + 补偿机制
写入Redis 成功后,不立即写 MySQL,而是写入消息队列,后台异步写入 MySQL
Redis 写成功后,即使 MySQL 写失败,可以 记录失败队列,定期重试