Java

集合框架

ArrayList

是基于动态数组实现的 List 接口的可变长度集合类,适合频繁读取、较少修改的场景

本质上是一个 Object 数组

  • 默认初始容量为 10
  • 元素顺序按插入顺序保存,允许重复元素和 null
  • 每次扩容都会拷贝整个旧数组到新数组grow()
  • 由于数组支持随机访问,get() 方法时间复杂度是 O(1)
  • 删除后会将后面所有元素向前移动一位,时间复杂度 O(n)

LinkedList

是基于双向链表实现的 List 接口实现类,也实现了 Deque 接口,因此它可以作为栈、队列或双端队列使用

1
2
3
4
5
6
7
8
9
10
11
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;

Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}

first(头节点)和 last(尾节点)两个引用串联整个链表

  • add(E e):内部使用 linkLast() 将元素插入尾部
  • get(int index):为了加快访问,LinkedList 会根据 index 判断从头还是尾遍历(根据size()
  • remove(int index):删除操作是通过修改指针实现的,不需要移动其他元素(**O(n)**(查找) + O(1)(删除))

HashSet

基于哈希表实现的集合,主要用来存储不重复的元素,且对元素的存取速度非常快,但不保证元素的顺序

  • HashSet 内部是靠一个 HashMap 来实现的
  • HashSet 中的元素作为 HashMap 的 key,value 是一个固定的常量对象
  • HashMap 通过元素的 hashCode() 定位元素所在的桶(数组索引),通过 equals() 判断元素是否相同
  • 扩容机制:默认初始容量(数组大小)是 16;默认加载因子(load factor)是 0.75
  • 扩容时,HashMap 会把底层数组的大小翻倍。重新计算每个元素的索引,将元素重新分布到新数组中

LinkedHashSet

HashSet 的子类,除了保证元素唯一外,还能保证元素的插入顺序

  • HashMap 的基础上,维护了一条双向链表,记录元素的插入顺序
  • 添加元素时,除了像 HashSet 一样放到对应桶,还会把元素串到双向链表尾部。迭代时,根据双向链表顺序返回元素

TreeSet

是基于红黑树实现的有序集合,保证元素按照自然顺序或自定义排序顺序排列

  • 底层使用 TreeMap(红黑树实现),元素必须实现 Comparable 接口或传入 Comparator 比较器
  • 插入、删除、查找时间复杂度是 O(log n)
  • 自平衡的二叉搜索树

HashMap

是基于哈希表实现的键值对集合,数组 + 链表/红黑树 结合的数据结构

  • 底层维护一个数组(称为“桶数组”,默认大小16)

  • 每个数组元素是链表或红黑树的头节点

  • 同一个桶内通过链表或红黑树存储多个哈希冲突的节点

  • 如果链表长度超过阈值(默认8),将链表转换成红黑树以提高查找效率

LinkedHashMap

继承自 HashMap,保持了插入顺序或访问顺序,底层维护了一个双向链表

  • 可以通过重写 removeEldestEntry() 实现缓存(如 LRU 缓存),自动删除最旧条目

TreeMap

基于红黑树实现的有序键值对集合,键必须实现 Comparable 接口或传入 Comparator

  • 红黑树,每个节点包含键值对

PriorityQueue

是一个 优先队列,即元素出队的顺序不是插入顺序,而是优先级顺序(默认自然顺序,可传 Comparator 自定义)

  • 使用 最小堆(二叉堆) 实现,底层是 数组
  • 初始容量默认 11,扩容为原容量的 1.5 倍

ArrayDeque

是一个 基于数组的双端队列,支持栈、队列、双端队列操作,效率比 LinkedList 高(无链表节点开销)

  • 底层是一个 循环数组
  • 初始容量为 16,每次满时容量扩大为 原容量的 2 倍

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(内核)

  1. 全局队列(Global Queue):存放等待运行的G
  2. P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G’时,G’优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列
  3. P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个
  4. 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() 调度流程

  1. 我们通过 go func()来创建一个goroutine
  2. 有两个存储G的队列,一个是局部调度器P的本地队列、一个是全局G队列。新创建的G会先保存在P的本地队列中,如果P的本地队列已经满了就会保存在全局的队列中
  3. G只能运行在M中,一个M必须持有一个P,M与P是1:1的关系。M会从P的本地队列弹出一个可执行状态的G来执行,如果P的本地队列为空,就会想其他的MP组合偷取一个可执行的G来执行
  4. 一个M调度G执行的过程是一个循环机制
  5. 当M执行某一个G时候如果发生了syscall或则其余阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P
  6. 当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
2
3
4
5
6
7
package main

import "fmt"

func main() {
fmt.Println("Hello world")
}
  1. runtime创建最初的线程m0和goroutine g0,并把2者关联
  2. 调度器初始化:初始化m0、栈、垃圾回收,以及创建和初始化由GOMAXPROCS个P构成的P列表
  3. 示例代码中的main函数是main.mainruntime中也有1个main函数——runtime.main,代码经过编译后,runtime.main会调用main.main,程序启动时会为runtime.main创建goroutine,称它为main goroutine吧,然后把main goroutine加入到P的本地队列
  4. 启动m0,m0已经绑定了P,会从P的本地队列获取G,获取到main goroutine
  5. G拥有栈,M根据G中的栈信息和调度信息设置运行环境
  6. M运行G
  7. G退出,再次回到M获取可运行的G,这样重复下去,直到main.main退出,runtime.main执行Defer和Panic处理,或调用runtime.exit退出程序

runtime.main的goroutine运行,才是调度器的真正开始,直到runtime.main结束而结束

可视化GMP

go tool trace

trace记录了运行时的信息,能提供可视化的Web页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
"os"
"fmt"
"runtime/trace"
)

func main() {

//创建trace文件
f, err := os.Create("trace.out")
if err != nil {
panic(err)
}

defer f.Close()

//启动trace goroutine
err = trace.Start(f)
if err != nil {
panic(err)
}
defer trace.Stop()

//main
fmt.Println("Hello World")
}
1
2
go run trace.go
go tool trace trace.out
Debug trace
1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import (
"fmt"
"time"
)

func main() {
for i := 0; i < 5; i++ {
time.Sleep(time.Second)
fmt.Println("Hello World")
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
go build trace.go
GODEBUG=schedtrace=1000 ./trace

SCHED 0ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=1 idlethreads=1 runqueue=0 [0 0]
Hello World
SCHED 1003ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
SCHED 2014ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
SCHED 3015ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
SCHED 4023ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 从全局队列中偷取,调用时必须锁住调度器
func globrunqget(_p_ *p, max int32) *g {
// 如果全局队列中没有 g 直接返回
if sched.runqsize == 0 {
return nil
}

// per-P 的部分,如果只有一个 P 的全部取
n := sched.runqsize/gomaxprocs + 1
if n > sched.runqsize {
n = sched.runqsize
}

// 不能超过取的最大个数
if max > 0 && n > max {
n = max
}

// 计算能不能在本地队列中放下 n 个
if n > int32(len(_p_.runq))/2 {
n = int32(len(_p_.runq)) / 2
}

// 修改本地队列的剩余空间
sched.runqsize -= n
// 拿到全局队列队头 g
gp := sched.runq.pop()
// 计数
n--

// 继续取剩下的 n-1 个全局队列放入本地队列
for ; n > 0; n-- {
gp1 := sched.runq.pop()
runqput(_p_, gp1, false)
}
return gp
}

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回收销毁)

参考文档

  1. Documentation - The Go Programming Language
  2. Golang修养之路

GC回收机制

Go V1.3标记-清除(mark and sweep)算法

  1. 暂停程序业务逻辑, 分类出可达和不可达的对象,然后做上标记

  2. 开始标记,程序找出它所有可达的对象,并做上标记

  3. 标记完了之后,然后开始清除未标记的对象

    操作非常简单,但是有一点需要额外注意:mark and sweep算法在执行的时候,需要程序暂停!即 STW(stop the world),STW的过程中,CPU不执行用户代码,全部用于垃圾回收,这个过程的影响很大,所以STW也是一些回收机制最大的难题和希望优化的点。所以在执行第三步的这段时间,程序会暂定停止任何工作,卡在那等待回收执行完毕

  4. 停止暂停,让程序继续跑。然后循环重复这个过程,直到process程序生命周期结束

缺点

  • STW,stop the world;让程序暂停,程序出现卡顿;
  • 标记需要扫描整个heap;
  • 清除数据会产生heap碎片

Go V1.5三色并发标记法

Golang中的垃圾回收主要应用三色标记法,GC过程和其他用户goroutine可并发运行,但需要一定时间的STW(stop the world)

  1. 每次新创建的对象,默认的颜色都是标记为“白色”

  2. 每次GC回收开始, 会从根节点开始遍历所有对象,把遍历到的对象从白色集合放入“灰色”集合

    本次遍历是一次遍历,非递归形式,是从程序抽次可抵达的对象遍历一层

  3. 遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合

  4. 重复第三步, 直到灰色中无任何对象

    当我们全部的可达对象都遍历完后,灰色标记表将不再存在灰色对象,目前全部内存的数据只有两种颜色,黑色和白色。那么黑色对象就是我们程序逻辑可达(需要的)对象,这些数据是目前支撑程序正常业务运行的,是合法的有用数据,不可删除,白色的对象是全部不可达对象,目前程序逻辑并不依赖他们,那么白色对象就是内存中目前的垃圾数据,需要被清除

  5. 回收所有的白色标记表的对象. 也就是回收垃圾

思考:没有STW的三色标记法会出现什么问题?

  • 条件1: 一个白色对象被黑色对象引用**(白色被挂在黑色下)**

  • 条件2::灰色对象与它之间的可达关系的白色对象遭到破坏**(灰色同时丢了该白色)**

    如果当以上两个条件同时满足时,就会出现对象丢失现象!

屏障机制

“强 - 弱” 三色不变式
  • 强三色不变色实际上是强制性的不允许黑色对象引用白色对象,这样就不会出现有白色对象被误删的情况
  • 黑色对象可以引用白色对象,但是这个白色对象必须存在其他灰色对象对它的引用,或者可达它的链路上游存在灰色对象。 这样实则是黑色对象引用白色对象,白色对象处于一个危险被删除的状态,但是上游灰色对象的引用,可以保护该白色对象,使其安全
插入屏障(不在栈)

具体操作: 在A对象引用B对象的时候,B对象被标记为灰色。(将B挂在A下游,B必须被标记为灰色)

满足: 强三色不变式. (不存在黑色对象引用白色对象的情况了, 因为白色会强制变成灰色)

删除屏障

具体操作: 被删除的对象,如果自身为灰色或者白色,那么被标记为灰色

满足弱三色不变式. (保护灰色对象到白色对象的路径不会断)

Go V1.8混合写屏障机制

  1. GC开始将栈上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW)
  2. GC期间,任何在栈上创建的新对象,均为黑色
  3. 被删除的对象标记为灰色
  4. 被添加的对象标记为灰色

满足: 变形的弱三色不变式

场景一: 对象被一个堆对象删除引用,成为栈对象的下游

场景二:对象被一个栈对象删除引用,成为另一个栈对象的下游

场景三:对象被一个堆对象删除引用,成为另一个堆对象的下游

场景四:对象从一个栈对象删除引用,成为另一个堆对象的下游

在面向对象这块,go和java有什么区别

1.类与结构体

Java:是一种典型的面向对象语言,所有代码都必须定义在类中。每个对象都是类的实例,支持封装、继承和多态等特性。

Go:没有类的概念,使用 struct(结构体)来定义数据结构。方法可以绑定到结构体上,但不支持传统的类继承机制。

2.继承与组合

Java:支持单继承,即一个类只能继承一个父类,但可以实现多个接口。通过继承,子类可以复用父类的属性和方法。

Go:不支持继承,提倡通过组合来复用代码。可以在结构体中嵌套其他结构体,实现类似继承的效果,但不会自动继承方法。

3.接口与多态

Java:接口需要显式声明实现关系,类必须使用 implements 关键字来实现接口。多态通过继承和接口实现。

Go:采用结构化类型系统,接口的实现是隐式的。只要一个类型实现了接口中定义的所有方法,就被视为实现了该接口,无需显式声明。

4.语言设计理念

Java:强调面向对象的设计,适合构建大型、复杂的企业级应用。

Go:强调简洁性和组合优于继承的原则,适合构建高性能、可维护的系统,如微服务和云原生应用。

c++、go和java有什么区别,优点和缺点

1.语言特性

特性 C++ Go Java
语言范式 多范式(面向对象、泛型、过程式) 多范式(过程式、并发、结构化) 面向对象(类和接口)
编译与运行 编译为本地机器码,直接执行 编译为本地机器码,快速启动 编译为字节码,由 JVM 执行
内存管理 手动管理,支持指针操作 自动垃圾回收,内存安全 自动垃圾回收,成熟的内存管理
并发模型 通过线程和锁机制实现 原生支持 Goroutines 和 Channel 通过线程和同步块实现
标准库 丰富但复杂,学习曲线陡峭 简洁实用,适合快速开发 功能全面,适合企业级应用
错误处理 通过异常机制处理 返回值和错误分离,简洁明了 通过异常机制处理

2.性能与资源消耗

  • **C++**:性能最优,适合对资源和速度要求极高的应用,如游戏引擎、操作系统和嵌入式系统。
  • Go:启动速度快,内存占用低,适合构建高并发的网络服务和微服务架构。
  • Java:通过 JIT 编译和 JVM 优化,性能接近原生代码,适合构建大型企业级应用。

3.开发效率与生态系统

  • **C++**:灵活性高,但开发复杂度大,调试和维护成本较高。
  • Go:语法简洁,编译速度快,工具链完善,适合快速迭代开发。
  • Java:生态系统成熟,拥有丰富的框架和库,适合构建复杂的企业应用。

4.应用场景

  • **C++**:适用于对性能和资源控制要求极高的系统级开发,如操作系统、游戏引擎和高频交易系统。
  • Go:适用于构建高并发、可扩展的网络服务和微服务架构,特别是在云原生应用中表现出色。
  • Java:适用于构建大型、复杂的企业级应用,如电商平台、银行系统和企业资源规划系统。

项目技术总结

微精弘

网络流量切换

背景

为了提升系统在调用外部认证服务(统一验证 OAuth、正方教务系统 ZF)过程中的可用性与鲁棒性,项目设计实现了一套基于断路器模式的网络流量切换机制

该机制实现了对多个 API 接口的高可用访问策略,能够根据接口返回结果自动判断接口是否失效,并动态地切换或恢复流量,提高系统稳定性与容错性

当某一认证服务频繁失败时,自动暂停使用,并定期进行探测,恢复后重新纳入负载均衡池中,避免影响整体服务质量

优势

  • 快速失败:避免访问异常接口拖慢整体服务
  • 自动恢复:接口恢复后可自动重新启用
  • 动态流量切换:可在多个接口之间智能选择最可用者

该机制主要涉及三大组件:

  • 负载均衡器(LoadBalance)
  • 熔断器状态快照(ApiSnapShot) Closed,Open,HalfOpen
  • 存活性探测器(LiveNessProbe)

核心模块

CircuitBreaker 熔断器主控结构
1
2
3
4
type CircuitBreaker struct {
LB LoadBalance
SnapShot *sync.Map
}
  • 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)
  • ZFOauth 都可用,随机选择一个
  • 若仅一方可用,则返回该类型
  • 若全部不可用,抛出错误
ApiSnapShot 接口快照

记录失败/总调用次数与状态

1
2
3
4
5
6
type ApiSnapShot struct {
State State
ErrCount Counter
TotalCount Counter
AccessLast time.Time
}
  • **Fail()**:失败计数 +1,当失败数 > 50 且状态为 Closed,切换为 Open
  • **Success()**:重置错误计数并切换回 Closed 状态
LiveNessProbe 存活性探测器

失效接口被熔断后,会被移出负载池并加入 LiveNessProbe。定时轮询检测其是否恢复:

1
func (l *LiveNessProbe) Start(ctx context.Context)
  • 通过构造表单请求目标接口
  • 若返回状态码为 200、412、416,则视为恢复成功
  • 成功后重新加入负载池,更新熔断状态为 Closed

流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
调用接口

判断是否可用(CircuitBreaker.GetApi)

调用接口 → 是否失败?
↓ ↓
是 否
↓ ↓
CircuitBreaker.Fail CircuitBreaker.Success

失败计数超过阈值?
↓ 是
移出负载池

LiveNessProbe 开始探测

恢复则重新加入负载池

微信小程序登录流程

1. 小程序端获取临时登录凭证(code)

小程序调用 wx.login() 方法,获取临时登录凭证 code。该 code 有效期为5分钟,且每次调用都会生成新的 code。此凭证仅能使用一次。

2. 将 code 发送至开发者服务器

小程序通过 wx.request() 将获取到的 code 发送给开发者服务器。服务器需配置在微信公众平台的“开发设置”中的“request合法域名”中,以确保请求合法性。

3. 开发者服务器调用微信接口,换取用户标识

服务器使用 appidappSecret 和接收到的 code,调用微信提供的 auth.code2Session 接口,获取用户的 openid(用户唯一标识)和 session_key(会话密钥)。

4. 生成并返回自定义登录态(token)

服务器根据获取到的 openidsession_key,生成自定义的登录态标识(如 token),并返回给小程序。小程序将该 token 保存在本地(如使用 wx.setStorageSync()),用于后续请求中验证用户身份

如果要拓展到两个及以上单体服务,token怎么在多个服务中使用

1.通过 API 网关统一验证和转发

token 附加在请求头中发送给 API 网关,验证通过后,API 网关将请求转发给相应的后端服务。

2.后端服务独立验证 token

每个后端服务都具备验证 token 的能力,通过共享的公钥来验证签名。

3.服务间使用用户上下文传递

单体服务更新项目程序,会有短暂时间真空期,怎么解决

1.蓝绿部署

准备新环境:在绿色环境中部署新版本的应用程序。

切换流量:将用户流量从蓝色环境切换到绿色环境。(负载均衡,DNS,云服务平台,灰度发布)

验证和回滚:如果新版本运行正常,保留绿色环境;如果出现问题,快速切换回蓝色环境。

2.滚动更新

逐个更新实例:依次将每个实例替换为新版本。

保持服务可用:在更新过程中,其他实例继续处理请求,确保服务不中断。

3.灰度发布

小范围测试:将新版本部署给一部分用户,观察其表现。

逐步扩展:如果新版本稳定,逐步扩大其覆盖范围,最终完全替代旧版本。

4.负载均衡或反向代理

流量控制:在部署新版本时,将流量引导到健康的实例。

健康检查:定期检查实例的健康状态,自动将流量从故障实例转移出去。

5.采用高可用架构

多实例部署:部署多个实例,使用负载均衡器分发流量。

故障转移:当某个实例出现故障时,自动将流量转移到其他健康实例。

精弘毅行报名系统

提交队伍(高并发)

防止高并发抢名额导致的超卖、重复、系统崩溃等问题

  1. 连接池优化:合理配置数据库与 Redis 连接池(900)
  2. 令牌桶限流:限制瞬时高并发流量,防止服务器雪崩(5000)
  3. Redis 单用户 QPS 限制
    • 利用 Redis 的 INCR + SETNX 设置一个临时计数器
    • 每次访问将 openid_Limit +1,过期时间为 60s
    • 超过 200 次/分钟就拒绝请求
  4. Redis + Lua 脚本原子操作
    • 是否已经提交过队伍(SISMEMBER)
    • 是否票数已达上限(GET 判断剩余票数)
    • 原子提交操作:DECR 剩余票数;SADD 记录已提交队伍
  5. 提交队伍前的业务验证:是否加入队伍;是否为队长;队伍人数是否≥4

如何解决数据一致性问题?

异步落库 + 补偿机制

写入Redis 成功后,不立即写 MySQL,而是写入消息队列,后台异步写入 MySQL

Redis 写成功后,即使 MySQL 写失败,可以 记录失败队列,定期重试

论坛

技术选型

  • 语言:Java
  • 版本:JDK17
  • 开发框架:Spring Boot
  • RPC框架:Dubbo
  • RPC通信协议:triple
  • RPC API:Protobuf(IDL)
  • 前后端交互协议:HTTP
  • 服务注册中心:Nacos
  • 配置中心:Nacos

API 模块

  • 包含:注解、RPC 接口及其所需的入参出参类。
  • 作用:作为对外暴露的 SDK,供其他系统通过 RPC 调用本系统服务。

common 模块

  • 包含:通用工具类和基础配置。
  • 例如:常量、配置、异常处理、工具类、安全注解、API 调用工具等。
  • 作用:为整个项目提供基础能力支撑。

service 模块

  • 包含:
    • RPC 接口实现。
    • 业务逻辑代码。
    • 消息队列的生产者与消费者。
    • 工具类、转换器、数据库操作类。
    • 对其他系统 RPC 调用的代理类等。
  • 作用:承担系统核心的内部服务逻辑,是业务处理的主要模块。

start 模块

  • 包含:
    • HTTP 请求接口及其实现。
    • 定时任务处理器。
  • 作用:项目的启动入口,负责对外暴露 HTTP 接口与调度任务。

其他