@yexiaoqi
2025-07-02T03:38:37.000000Z
字数 36962
阅读 1017
面试
技术
继承、封装、多态
Object 类的 equals() 方法比较的是对象的地址,hashCode() 是根据内存地址算出来的。同一个对象hashCode必须相同,hashCode相同不一定是同一个对象。
如果重写equals的时候没有重写hashCode,会违反Object.hashCode的通用约定,导致该类无法结合所有基于散列的集合一起工作,包括HashMap、HashSet、HashTable等。
正例:对象放入 HashSet 时,先计算对象的 hashcode 判断对象放入位置,与已经放入对象的 hashcode 比较,如果没有相同的,HashSet 会假设对象没有重复出现。如果有相同的,会再调用 equals() 是否真的相等,如果相等,HashSet 不会让其加入成功;如果不同,会重新散列到其他位置,大大减少了equals 的次数,提高速度。
反例:都不重写,new 两个Person 对象,都叫张三,hashCode()和equals()判断不同;重写一个同样不行。
// String的hashCode()
public int hashCode() {
int h = hash; //hash初始值为0
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];//31是质数,不大不小,避免溢出
}
hash = h;
}
return h;
}
可以参考Objects类中计算hashCode的逻辑
public static int hashCode(Object o) {
return o != null ? o.hashCode() : 0;
}
public static int hash(Object... values) {
return Arrays.hashCode(values);
}
public static int hashCode(Object a[]) {
if (a == null)
return 0;
int result = 1;
for (Object element : a)
//31是质数,不大不小,避免溢出
result = 31 * result + (element == null ? 0 : element.hashCode());
return result;
}
理想情况下O(1),最坏O(n),平均O(1)。
解决 Hash 冲突使用链表法,链表过长时性能会下降。
Java 8中 HashMap 的变化:
依据Key(必须可比较),Key 相等可能会比较节点的哈希值。
其他解决哈希冲突的方法:开放寻址法、再哈希法。
NullPointerException
。HashMap
可以存储 null
的 key 和 value,但 null 作为key只能有一个,作为值可以有多个。null 作为 key,会返回 hash 为0位置的值,单线程环境下不存在歧义问题。
参考:ConcurrentHashMap为什么key和value不能为null
Spring 事务:再对应service层方法上使用@Transcational
,分布式的话需使用分布式事务。下面几种情况会导致事务不生效
访问权限不是 public
方法用 final 修饰/是static
spring 事务底层通过 jdk 动态代理/cglib,帮我们生成代理类,在代理类中实现事务功能;如果某个方法用 final 修饰/是static,在它的代理类中就无法重写该方法
同一个类中的方法内部调用
未被 Spring 管理
多线程调用(不同的线程拿到的数据库连接不同,事务必然失效)
表不支持事务(比如使用 MyISAM)
未开启事务
下面几种情况会导致事务不回滚
REQUIRED
:如果当前上下文中存在事务,则加入该事务,如果不存在,则创建一个事务。只有这三种传播特性才会创建新事务:REQUIRED
,REQUIRES_NEW
,NESTED
RuntimeException
(运行时异常)和Error
(错误),对于普通的 Exception(非运行时异常)不会回滚propagation=Propagation.NESTED
),但将整个事务都回滚了,如何处理?使用 try……catch 包住嵌套事务内的异常不往外抛其他
大事务问题:事务执行耗时比较长,会导致死锁、锁等待、回滚时间长、接口超时、并发情况下数据库连接被占满、数据库主从延迟等问题
编程式事务,基于TransactionTemplate
的编程式事务优点:避免由于 Spring AOP 导致事务失效的问题、能更小粒度的控制事务的范围,更直观
@Autowired
private TransactionTemplate transactionTemplate;
public void save(final User user) {
queryData1();
queryData2();
transactionTemplate.execute((status) => {
addData1();
updateData2();
return Boolean.TRUE;
})
}
三级缓存分别是:
singletonObject
:一级缓存,该缓存 key=beanName, value=bean;该 bean 是已经创建完成的,经历过实例化->属性填充->初始化以及各类的后置处理。一旦需要获取 bean 时,第一时间就会寻找一级缓存;
earlySingletonObjects
:二级缓存,该缓存 key = beanName, value = bean;跟一级缓存的区别在于,该缓存所获取到的 bean 是提前曝光出来的,还没创建完成。获取到的 bean 只能确保已进行了实例化,但是属性填充跟初始化肯定还没有做完,因此该 bean 还没创建完成,仅仅能作为指针提前曝光,被其他 bean 所引用;
singletonFactories
:三级缓存,该缓存 key = beanName, value = beanFactory;在 bean 实例化完之后,属性填充、初始化之前,如果允许提前曝光,spring 会将实例化后的 bean 提前曝光,也就是把该 bean 转换成 beanFactory 并加入到三级缓存。在需要引用提前曝光对象时再通过 singletonFactory.getObject() 获取。
Spring Event 使用观察者模式,事件源发布一个事件,事件监听器可以消费这个事件,事件源不用关注发布的事件有哪些监听器,可以对系统进行解耦。实现方式上有两种:
ApplicationListener
接口@EventListener
注解Spring 中事件监听器的处理默认是同步方式,可以改为异步,有两种方式
@Async
注解Spring 事件监听器消费事件同步模式下支持自定义顺序,一般在监听方法上使用@Order(1)
,数字越小优先级越高;异步模式下顺序不靠谱。
适用场景:异步模式将主逻辑和监听器逻辑分到不同线程中,适合非主要逻辑,因为监听器运行有可能失败。如果非主要逻辑要求必须成功,考虑使用更重量级的消息中间件来解决。
具体代码参考:@EventListener:定义 spring 事件监听器
特征 | SOA | 微服务 |
---|---|---|
架构理念 | 企业级服务重用和集成 | 小型、自治的服务 |
服务范围 | 大型、复杂 | 小型、简单 |
通信方式 | 集中式消息总线ESB | 轻量级通信协议(RESTful API)或消息队列 |
技术栈 | 各种技术 | 轻量级技术 |
部署方式 | 集中式应用服务器 | 独立容器或虚拟机 |
适用场景 | 遗留系统改造 | 需要快速迭代、弹性伸缩和高可用的场景 |
corePoolSize
:核心线程数。当线程池中线程数 < corePoolSize 时,默认是添加一个任务才创建一个线程池;当线程数 = corePoolSize 时,新任务会追加到工作队列(workQueue)中。
maximumPoolSize
:允许的最大线程数(非核心线程数 + 核心线程数)。当工作队列也满了,线程池中总线程数 < maximumPoolSize 时就会增加线程数量。
keepAliveTime
:非核心线程(maximumPoolSize - corePoolSize)闲置下来的最多存活时间。
unit
:线程池中非核心线程存活时间的单位
workQueue
:线程池等待队列,维护着等待执行的 Runnable 对象。当运行线程数 = corePoolSize 时,新的任务会被添加到 workQueue 中,如果 workQueue 也满了,则尝试用非核心线程执行任务,等待队列应该尽量用有界的。常用的阻塞队列:
threadFactory
:创建新线程时的工厂,可以用来设定线程名、是否为守护线程等。
handler
:corePoolSize、workQueue、maximumPoolSize 都不可用的时候执行的拒绝策略。
线程池刚创建时,里面没有线程,任务队列作为参数传进来
当调用 execute() 方法添加一个任务时,线程池会判断:
当线程完成任务时,会从队列中取一个任务来执行
当前运行的线程数大于 corePoolSize,线程空闲超过一定时间(keepAliveTime)时,那么这个线程就会被停掉。线程池所有任务完成后,线程数最终会收缩到 corePoolSize 的大小。
public class ThreadPoolException {
public static void main(String[] args) {
//创建一个线程池
ExecutorService executorService=Executors.newFixedThreadPool(1);
//当线程池抛出异常后submit无提示,其他线程继续执行
executorService.submit(new task());
//当线程池抛出异常后execute抛出异常,其他线程继续执行新任务
executorService.execute(new task());
}
}
//任务类
class task implements Runnable{
@Override
public void run() {
System.out.println("进入了task方法!!!");
int i=1/0;
}
}
任务提交方式中,execute会打印异常信息,submit不打印异常,要想获取异常信息就必须使用get()
//当线程池抛出异常后submit无提示,其他线程继续执行
Future<?> submit = executorService.submit(new task());
submit.get();
方案一:使用try-catch
方案二:使用Thread.setDefaultUncaughtExceptionHandler
方法捕获异常。
重写线程工厂方法,在线程工厂创建线程的时候,赋予UncaughtExceptionHandler
处理器对象
方案三:重写afterExecute
进行异常处理
public class ThreadPoolException3 {
public static void main(String[] args) throws InterruptedException,ExecutionException {
//1.创建一个自己定义的线程池
ExecutorService executorService = new ThreadPoolExecutor(2,3,0,TimeUnit.MILLISECONDS,new LinkedBlockingQueue(10)) {
//重写afterExecute方法
@Override
protected void afterExecute(Runnable r, Throwable t) {
//这个是excute提交的时候
if (t != null) {
System.out.println("获取到excute提交的异常信息,处理异常"+t.getMessage());
}
//如果r的实际类型是FutureTask那么是submit提交的,所以可以在里面get到异常
if (r instanceof FutureTask) {
try {
Future<?> future = (Future<?>) r;
//get获取异常
future.get();
} catch (Exception e) {
System.out.println("获取到submit提交的异常信息,处理异常" + e);
}
}
}
};
//当线程池抛出异常后execute
executorService.execute(new task());
//当线程池抛出异常后submit
executorService.submit(new task());
}
}
class task3 implements Runnable {
@Override
public void run() {
System.out.println("进入了task方法!!!");
int i = 1 / 0;
}
}
简单定时任务
new Thread 里面放一个 while(true) 循环。优点是简单,缺点是功能单一,无法应对复杂场景
监听器
搭配配置中心,修改配置开关后,动态刷新,服务端用开关判断是否启动业务逻辑处理
收集日志
如果没有引入 MQ,可以通过多个线程/线程池异步写日志数据到数据库
excel导入
excel 导入读取数据后可能有复杂的业务逻辑需要处理,可以通过多线程处理。最简单的实现方式:parallelStream
,它通过ForkJoinPool
实现
统计数量
count++ 并非原子操作,多线程执行时,统计次数可能出现异常。可以使用AtomicInteger
等atomic
包下面的类
查询接口
接口中有串行远程调用的,可以改为并行调用
public UserInfo getUserInfo(Long id) throws InterruptedException, ExecutionException {
final UserInfo userInfo = new UserInfo();
CompletableFuture userFuture = CompletableFuture.supplyAsync(() -> {
getRemoteUserAndFill(id, userInfo);
return Boolean.TRUE;
}, executor);
CompletableFuture bonusFuture = CompletableFuture.supplyAsync(() -> {
getRemoteBonusAndFill(id, userInfo);
return Boolean.TRUE;
}, executor);
CompletableFuture growthFuture = CompletableFuture.supplyAsync(() -> {
getRemoteGrowthAndFill(id, userInfo);
return Boolean.TRUE;
}, executor);
CompletableFuture.allOf(userFuture, bonusFuture, growthFuture).join();
userFuture.get();
bonusFuture.get();
growthFuture.get();
return userInfo;
}
ThreadLocal 是线程本地变量,存放每个线程的私有数据。
每个线程都有一个 ThreadLocalMap,维护一个 Entry[],数组元素的Key是 ThreadLocal 对象,value是线程对应的数据。使用线性探测解决 hash 冲突,需要手动调用 set、get、remove 防止内存泄漏。
使用场景:
每个线程都有一个 ThreadLocalMap 的内部属性,map 的 key 是 ThreadLocal 且是弱引用,value 是强引用。垃圾回收时会自动回收 key,value的回收取决于线程对象的生命周期。线程池中的线程长时间存货,value 释放不掉导致内存泄露。
锁是一种并发控制机制,用于在多线程/进程环境下,控制对共享资源的访问,从而保证数据的一致性和完整性。
多线程下的锁(JVM层面)
synchronized
(监视器锁) monitorenter
和monitorexit
指令实现;修饰方法时由方法控制位ACC_SYNCHRONIZED
控制。ReentrantLock
(可重入锁) state
变量表示锁的获取次数,并记录当前持有锁的线程。ReadWriteLock
(读写锁) StampedLock
(邮戳锁) Atomic
系列的类(原子操作) MySQL层面的锁
ReentrantLock 基于 AbstractQueuedSynchronizer
实现了几个方法:
方法 | 描述 |
---|---|
boolean isHeldExclusively() |
该线程是否正在独占资源,只有用到Condition才需要去实现它。 |
boolean tryAcquire(int arg) |
独占,arg为获取锁的次数 |
boolean tryRelease(int arg) |
独占,arg为获取锁的次数 |
int tryAcquireShared(int arg) |
共享,arg为获取锁的次数。返回值负数表示失败;0表示成功但无可用资源;正数表示成功且有剩余资源。 |
boolean tryReleaseShared(int arg) |
共享,arg为获取锁的次数。释放后允许唤醒后续等待节点返回True,否则返回False |
synchronized代码块是由 monitorenter 和 monitorexit 指令实现的。Java 6之前 Monitor 的实现依靠操作系统内部的互斥锁,因为需要进行用户态和内核态的切换,所以同步操作是个重量级操作。现代JDK中,JVM提供了偏向锁、轻量级锁、重量级锁三种Monitor实现,大大改进了其性能。
锁升级、降级就是JVM优化 synchronized 运行的机制,当JVM检测到不同的竞争状态时,会自动切换到适合的锁实现,这种切换就是锁的升级降级。
Java 对象结构分为 对象头、对象体、对齐字节。对象头包含三部分:
- Mark Word:存储自身运行数据(当前对象线程锁状态及 GC 标志)
- class 指针:指向方法区中该 class 的对象,JVM 通过此字段判断当前对象事哪个类的实例
- 数组长度:只有是数组时才会用到
锁升级过程按照无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁方向升级。当JVM进入安全点的时候,会检查是否有闲置的Monitor,然后试图进行降级。
AQS是Java并发用来构建锁和其他同步组件的基础框架。AQS使用一个线程可见的state表示同步状态,通过内置的FIFO队列来完成线程的排队和调度,通过CAS对State值修改。
state变量 volatile int类型,表示同步状态
AQS的核心思想是,如果被请求的共享资源空闲,就将当前请求线程设置为有效的工作线程,将共享资源设定为锁定状态;如果共享资源被占用,将暂时获取不到锁的线程加入队列中。
等待队列:CHL双向链表,FIFO,未争抢到锁的线程会被放入等待队列
条件队列:
wait/await
方法,表示当前线程要暂时释放锁,则当前线程进入条件队列的队尾;singal
方法,将条件队列队首的线程移到同步队列,并调用acquireQueued
方法尝试争抢锁,通过判断前驱节点是不是头节点。Disruptor是一个高性能异步处理框架,能够在无锁的情况下实现队列的并发。
使用环形数组实现了类似队列的功能,并且是一个有界队列。通常用于生产者-消费者场景
核心设计原理
优点
详细参考:高性能并发队列Disruptor使用详解
@Autowired注解用于自动装配Bean,实现主要依赖于Spring容器中的一个后置处理器:AutowiredAnnotationBeanPostProcessor。
作用域 | 描述 | 适用场景 |
---|---|---|
singleton | 单例,整个容器只有一个实例 | 无状态Bean,如工具类、配置类 |
prototype | 原型,每次调用创建一个新实例 | 有状态Bean,如数据库连接 |
request | 请求范围,一次HTTP请求一个实例 | Web应用程序中需要在每个请求中保持独立状态的Bean |
session | 会话范围,一次HTTP会话一个实例 | 需要在一次会话中保持状态的Bean |
application | 应用范围,整个Servlet上下文一个实例 | 需要在整个应用程序中共享数据的Bean |
websocket | 对应于单个 websocket 的生命周期 |
BeanNameAware
、BeanFactoryAware
、ApplicationContextAware
接口;BeanPostProcessor
的postProcessBeforeInitialization
方法;@PostConstruct
或者会调用InitializingBean
接口的afterPropertiesSet()
方法;BeanPostProcessor
的postProcessAfterInitialization
方法。DisposableBean
接口的destroy()
方法或者@PreDestroy
注解的方法。spring.factories
文件:在resource/META-INF
创建一个 spring.factories
文件和spring-configuration-metadata.json
,分别用于导入自动配置类(必须有)、用于在填写配置文件时的智能提示(可以没有)。类加载过程:获取类的二进制字节流 -> 结构化静态存储结构 -> 在内存中生成 Class 对象
双亲委派模型:类加载请求先交给父类加载器,加载不了子类加载器才会尝试加载。避免重复加载,保证安全性。
JMM 是用于描述 Java 程序中多线程并发访问共享内存的规范。
共享变量与可见性
为了确保每个线程都能看到其他线程对共享变量的修改,Java 内存模型提供了一系列规则。例如:
有序性
为了避免指令重排导致多线程程序出现意想不到的结果,Java 内存模型定义了 happens-before 规则来确保多线程间的操作顺序符合预期。
原子性
Java 内存模型还规定了某些操作具有原子性。例如:对 volatile 变量的读写操作。对于非原子性的操作,需要使用锁等机制来保证线程安全。
主内存和工作内存
各部分内存的合理配置和优化对JVM性能至关重要。
源码到代码执行过程:编译 -> 加载 -> 解释 -> 执行
编译阶段:语法分析 -> 语义分析 -> 注解处理 -> class文件
加载阶段:装载 -> 连接 -> 初始化
装载阶段:查找并加载类的二进制数据,在 JVM 堆中创建一个 java.lang.Class类的对象,并将类相关的信息存储在 JVM 方法区中。
连接阶段:对 class 的信息进行验证、为类变量分配内存空间并对其赋默认值
解释阶段:把字节码转换为操作系统识别的指令
JVM 会检测热点代码,超过阈值会触发即时编译,生成机器码保存起来,下次直接执行
执行阶段:调用操作系统执行指令
类加载检查:首先检查要创建的对象的类是否已经被加载。如果未加载,会触发该类的加载、验证、准备、解析、初始化过程,将类的元数据(如静态变量、方法信息)加载到方法区。在准备阶段,静态变量会被赋零值;在初始化阶段,会执行静态代码块和静态变量的赋值。
内存分配:确认类已加载后,会在堆上分配内存空间。
赋零值:对象实例变量部分赋上默认的零值。
设置对象头:新对象的内存空间会设置对象头,包含对象哈希码、GC 信息以及指向其类元数据(方法区)的指针。
执行构造器:执行代码层面的构造器方法,方法的局部变量和操作数存储在虚拟机栈。
赋值引用:将对象引用赋值给成员变量,局部变量-虚拟机栈、实例变量-堆、静态变量-方法区。
默认的,Young:Old=1:2 (可通过参数 –XX:NewRatio 来指定)。其中,新生代(Young)被细分为 Eden 和 两个 Survivor 区域,这两个 Survivor 区域分别被命名为 from 和 to。老年代过小会导致频繁发生 fullGC,继而导致 STW
默认的,Eden:from:to = 8:1:1 (可通过参数 –XX:SurvivorRatio 来设定),新生代比例过小会导致频繁发生MinorGC
Java 的 GC 机制负责识别并回收不再被程序使用的对象,从而避免内存泄漏和溢出问题。垃圾回收过程:
标记-整理:标记出所有存活对象,将所有存活对象向内存一边移动,清理掉边界以外的部分。
解决内存碎片问题。主要用于老年代,移动存活对象代价比较大,而且需要STW,从整体的吞吐量来考量,老年代使用标记-整理算法更合适。
System.gc()
时,会告诉 JVM 可以GC<
历代晋升到老年代的对象的平均大小时,会触发Full GC 来让老年代腾出更多空间jmap -histo:live PID | head -n20
:查看对应进程对象中占用空间较大的前20个对象
jstat -gcutil -h20 PID 1000
:查看堆内存各区域使用率及 GC 情况
jstat -gc PID 1000
:查看 GC 次数时间等,一秒一次
jmap -dump:live,format=b,file=heap4.hprof PID
:使用 jmap 生成 dump 文件后再详细分析
G1将Java堆划分为2048个大小相同的独立 Region 块,每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理
GC策略
Serial:单线程,垃圾收集时必须暂停其他所有工作线程直到收集结束。
-XX:+UseSerialGC
指定。Parallel:多线程,并行标记扫描
-XX:UseParNewGC
:新生代并行回收;-XX:+UseParallelOldGC
:新生代和老年代都使用并行收集器。CMS:多线程,采用“标记-清除”算法实现。初始标记、并发标记、并发预处理、重新标记、并发清除、并发重置。
-XX:+UseConcMarkSweepGC
开启。G1:堆被划分为许多连续的区域,采用G1算法回收。
-XX:UseG1GC
开启。堆栈配置相关
垃圾收集器相关
辅助信息
一条查询语句的执行过程一般是经过连接器、分析器、优化器、执行器等功能模块,最后到达存储引擎
执行语句前要先连接数据库,分析器会通过词法和语法解析知道这是一条更新语句,会把表T上所有缓存结果都清空。优化器决定要使用哪这个索引,然后执行器负责具体执行,找到这一行,然后更新。
在 InnoDB 引擎下,按照锁的粒度,可以简单分为行锁和表锁。
行锁是作用在索引上的,SQL命中了索引,锁住的就是命中条件内的索引节点(行锁)。如果没有命中索引,那锁住的就是整个索引树(表锁)。
行锁可以简单分为读锁和写锁。读锁是共享的,多个事务可以读取同一个资源,但不允许其他事务修改。写锁是排他的,写锁会阻塞其他的写锁和读锁。
B+ 树是有序的,和B-树的主要区别:
假设由如下SQL:age 加个索引,这条 SQL 是如何在索引生执行的?
select * from Temployee where age=32;
这条 SQL 查询语句的执行大概流程:
InnoDB 三种行锁:
- Record Lock(记录锁):锁住某一行记录
- Gap Lock(间隙锁):锁住一段左开右开的区间(m, n)
- Next-key Lock(临键锁):锁住一段左开右闭的区间(m, n]
select …… for update
加行级写锁select …… lock in share mode
加行级读锁查找过程中访问到的对象才会加锁
加锁的基本单位是临键锁
slow_query_log=1
和long_query_time=N
(N为慢查询阈值,单位为秒)。慢查询日志位置由slow_query_log_file
参数指定。ALL
全表扫描、index
索引扫描、range
范围扫描、ref
使用索引查找等Using filesort
文件排序、innodb_buffer_pool_size
控制缓冲池大小,适当增加;query_cache_size
控制查询缓存大小缺点
- 事务问题,不能用本地事务,需要分布式事务;
- 跨节点 Join 的问题:解决这一问题可任意分两次查询实现;
- 跨节点的 count、order by、group by 以及聚合函数问题:分别在各个节点上得到结果后在应用程序端进行合并;
- ID 问题:数据库被切分后,不能再依赖数据库自身的主键生成机制,最简单可以考虑 UUID;
- 跨分片的排序分页问题(后台加大 pagesize 处理)
单表行数超过500万行或者单表容量超过2GB,才推荐进行分库分表。
跨节点join关联问题
解决思路:字段冗余、全局表(基础表每个库中保存一份)、数据同步、应用层代码组装
聚合函数问题:分别在各个节点上得到结果后,在中间件/应用端合并
TIME_WAIT
和CLOSE_WAIT
状态,是出现在哪一方的?SIGIO
,然后应用程序立即返回。当内核数据准备号后,再通过SIGIO
信号通知应用进程。应用进程收到信号后使用recvfrom
去读取数据(数据复制到应用缓冲区期间,进程阻塞)select:监听的 I/O 最大连接数有限,Linux 一般是 1024,可以调大;需要遍历 fdset,找到就绪的 fd
poll:poll解决了连接数的限制
epoll:采用事件驱动,epoll 先通过 epoll_ctl() 来注册一个 fd(文件描述符)。一旦某个 fd 就绪时,内核会采用回调机制,迅速激活这个 fd,当进程掉 epoll_wait() 时便得到通知,采用监听事件回调机制。
read+write
方式会产生2次DMA拷贝+2次CPU拷贝,同时有4次上下文切换。mmap+write
方式则产生2次DMA拷贝+1次CPU拷贝,4次上下文切换,通过内存映射减少了一次CPU拷贝,可以减少内存使用,适合大文件的传输。sendfile
方式是新增的一个系统调用函数,产生2次DMA拷贝+1次CPU拷贝,但是只有2次上下文切换。因为只有一次调用,减少了上下文的切换,但是用户空间对IO数据不可见,适用于静态文件服务器。sendfile+DMA gather
方式产生2次DMA拷贝,没有CPU拷贝,而且也只有2次上下文切换。虽然极大地提升了性能,但是需要依赖新的硬件设备支持。雪崩:缓存机器宕机,导致请求全部打到数据库上,打死数据库
穿透:大量构造的恶意请求,导致缓存查不到,最终打死数据库
击穿:热点key再失效的瞬间,大量请求直接请求到数据库,打死数据库
布隆过滤器:通过一个bit数组和多个哈希函数判断某个元素是否存在于一个集合中。
核心思想是使用哈希函数将元素映射到为数组的不同位置,并在这些位置1。查询时通过哈希函数判断某个元素对应的位是否全为1,如果是,可能存在该元素;如果有0,则一定不存在。哈希函数存在碰撞,所以可能会误判元素存在。
通过链地址法、重新哈希、渐进式哈希、负载因子、哈希桶等多种机制来解决键冲突的问题。
链地址法:多个键被哈希到同一个位置时,使用链表将键值对连起来。
重新哈希:当哈希表中键值对数量达到一定阈值时,会自动扩容重新哈希。
渐进式哈希:将重新哈希的过程分摊到多个操作中,每次只迁移一部分键值对。
负载因子:会根据负载因子大小决定是否扩容,保证哈希表性能。
哈希桶:发生哈希冲突时,会将冲突的键值对存储在同一个哈希桶中。减少哈希冲突,提高性能。
类似于 RocketMQ中广播模式,简单易上手
优点:轻量级、低延迟、低可靠性
缺点:
使用需考虑:消费者并发数、生产的速度和消费的速度、是否需保证可靠性
如果要求可靠性 & redis版本>=5.0,可以考虑使用 Redis Stream,如果有更复杂的要求,还是考虑使用专门的消息队列(kafka、RocketMQ、Pulsar等)
一致性 Hash 算法能够在 Hash 输出空间发生变化时,引起最小的改动。应用在像分布式系统扩容缩容的时候。
原理是将整个哈希输出空间设置为一个环形区域,对数据进行 Hash 操作,映射在环形区域上,然后让该值沿顺时针方向移动,遇到的第一个服务器就是它分配的节点。
当服务节点很少的时候,会有很多 key 被分配到同一个服务实例上,称之为“数据倾斜”。如何解决?虚拟节点,即对内阁服务节点计算多个哈希,每个计算结果位置都放置一个此服务节点,称之为虚拟节点。具体做法可以在服务器 ip 或主机名的后面增加编号来实现。同时数据定位算法不变,只是多了一步虚拟节点到实际节点的映射。虚拟节点越多数据月均匀,一般虚拟节点数要在32个以上
没有一个完美方案可以适用于所有场景。
旁路缓存:先更新数据库,再删缓存。
延迟双删:先更新数据库,再删缓存,延迟一段时间后再删一次。
DelayQueue
,会随着JVM进程死亡丢失更新的风险。读写请求串行化
可能丢失的情况:
生产者发消息时丢失。
同步发送 + 重试机制 + 部署多个broker
同步重试机制:如果同步模式发送失败,则轮转到下一个Broker。
broker存储消息时丢失。broker会将消息写入缓冲区,再发送给生产者回执,最后异步刷盘。如果刷盘过程中broker宕机了,会导致消息丢失。
同步刷盘 + 同步复制 + 主从
同步刷盘:消息真正持久化到磁盘后,Broker才会返回一个ACK,保证可靠性,但影响性能。
异步刷盘:消息写入PageCache就返回ACK,后台异步刷盘,提高性能和吞吐量,可能丢消息。同步复制:消息投递到主从节点都落盘成功,Broker才会当作消息投递成功。增大延迟,降低吞吐。
异步复制:只要master写入消息成功,就反馈给客户端写入成功的状态。速度快,同样可能丢消息。Broker重试机制:消息消费失败会写入重试Topic,最大重试16次,如果还不行会写入死信队列。
消费者消费时候丢失。网络、消费者宕机。
手动ACK + 消费重试机制 + 尽量不要异步消费
兜底方案:存到其他地方,用定时任务重新发送来降级处理。
顺序消费的核心:生产者有序生产 + 消费者有序消费。
生产者有序生产:同步发送 + 自定义队列选择器,Hash取模保证同一个订单在同一个队列。
注意!Broker宕机或者增加,也会短暂的造成部分消息无序。
消费者有序消费:有序消费模式MessageListenerOrderly,是通过加分布式锁和本地锁保证同时只有一条线程去消费一个队列上的数据。
注意!使用锁降低了吞吐量,因为顺序,后面消息会被前面消息阻塞,消费失败自动重试无法跳过,必须设置最大消费次数,处理好可能的异常情况。
一次调度任务只有一个机器执行,不会因为是分布式部署而出现多台机器同时执行某个job。思路是使用分布式锁
Quartz:
acquireTriggersWithinLock=true
获取 trigger 的时候上锁(抢占式获取数据库锁并由抢占成功的节点负责运行),默认是 false,使用乐观锁,但可能出现 ABA 导致重复调度XXL-JOB:使用数据库悲观锁
setAutoCommit(false)
关闭隐式自动提交select lock for update
显式排他锁,其他事务无法进入&无法实现for update
- 读数据库任务信息 -> 拉任务到内存时间轮 -> 更新数据库任务信息
commit
提交事务,同时释放for update
排他锁(悲观锁)
性能高,但redis master-slave架构的主从异步复制导致的redis分布式锁的最大缺陷:在redis master实例宕机的时候,可能导致多个客户端同时完成加锁。
可靠性高,但性能不如redis分布式锁
单机系统演变为分布式系统是一个复杂的过程,需要根据具体的业务需求和场景,选择合适的技术方案,逐步完成系统的改造。
前端负载均衡
问题:单机难以承受高并发请求,需要将流量分发到多台服务器。
解决方案:引入负载均衡器
服务拆分
问题:单机代码耦合度高,无法维护。
解决方案:将系统拆为多个独立服务,每个服务负责特定业务。
数据库拆分
分布式缓存
问题:单机内存容量有限,无法满足高并发场景下的缓存需求。
解决方案:引入分布式缓存
消息队列
分布式事务
监控与日志
RedLock
算法是 Redis 官方支持的分布式锁算法。
最普通的实现方式,是使用SET key value [EX seconds|PX milliseconds] NX
创建一个key,就算加锁成功
EX seconds
:设置key
的过期时间。seconds
秒后锁自动释放,被人创建的时候发现已经有了就不能加锁了PX milliseconds
:设置key
的过期时间,精确到毫秒级NX
:只有key
不存在的时候才会设置成功,如果存在这个key
,则会设置失败 lua
脚本删除:
-- 删除锁的时候,找到key对应的value,与传过去的value做比较,如果是一样的才删除
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
考虑到 Redis 单实例会出现单点故障风险;或者普通主从异步复制,如果主节点挂了,key 还没有同步到从节点,此时从节点切换为主节点,别人就可以 set key,从而拿到锁。
RedLock 算法
假设有一个 Redis cluster,有5个 Redis master 实例。然后执行如下步骤获取一把锁:
2PC 两阶段提交(准备,提交/回滚)
TCC(尝试、确认、取消)
优点:控制数据库操作的粒度,降低了锁冲突,提升性能;
缺点:侵入性强,根据网络、系统故障等不同失败原因实现不同的回滚策略,实现难度大。
SAGA
核心思想是将长事务拆分成多个本地短事务,由Saga事务协调器协调,如果正常结束就完成;若某个步骤失败,则根据相反顺序依次调用补偿操作。
本地消息表:写本地消息和业务操作放在一个事务里,保证业务和发消息的原子性,要么全部成功,要么全部失败。
事务消息:本质是把本地消息表放到 RocketMQ 上,解决生产端消息发送与本地事务执行的原子性问题。
最大努力通知
二阶段消息:参考 DTM 框架
具体参考分布式事务的七种解决方案
事务模式 | 宽松一致性 | 开发量 | 性能 | 回滚支持 |
---|---|---|---|---|
Msg | 无中间状态 | 低 | 高 | × |
XA | 无中间状态 | 低 | 低 | √ |
TCC | 内部可见中间态 | 高 | 较低 | √ |
Saga | 外部可见中间态 | 较高 | 高 | √ |
核心思想:通过唯一的业务单号保证幂等性,非并发情况下,查询业务单号有没有操作过,没有则执行操作;并发情况下,此过程需加锁。
根据唯一业务号取更新数据:通过版本号来控制。用户查询出要修改的数据,系统将数据返给页面,将数据版本号放入隐藏域,用户修改数据提交时将版本号一同提交,后台使用版本号作为更新条件
update set version=version+1, xxx=${xxx} where id=xxx and version=${version};
没有唯一业务号的 update 与 insert 操作:进入到注册页时,后台统一生成 Token,返回前台隐藏域中,用户在页面提交时将 Token 一同传入后台,使用 Token 获取分布式锁,完成 insert 操作,执行成功后不释放锁,等待过期自动释放。
Kubernetes 是一个开源的容器编排系统,用于自动化部署、扩展和管理容器化应用。K8s 作为应用服务与服务器之间的中间层,通过策略协调和管理多个应用服务,简化部署流程,实现自动扩缩容,并能在服务器出问题时自动重新部署应用。
要点:
可视化平台 KubeSphere
战略设计主要关注的是从业务角度理解问题域,并为软件系统划定合理的边界。你需要弄清楚以下几点:
战术设计关注的是如何在单个限界上下文内部构建领域模型。你需要弄清楚以下几点:
DDD 是一种以领域知识为驱动的软件设计方法。DDD 的核心价值在于:
仓储 (Repository) 充当领域模型与数据持久化机制之间的中介。设计仓储接口的关键在于:
业务上重要的状态变更或发生的事实,可能触发领域内或其他界限上下文中的进一步行动。
维度 | 工作流 | 规则引擎 |
---|---|---|
目的 | 管理和协调一系列任务或活动的执行顺序,聚焦流程自动化 | 执行预定义的业务规则,根据条件自动决定下一步动作,聚焦规则逻辑处理 |
核心概念 | 强调流程流转,确保任务按照既定顺序执行 | 强调规则匹配与执行,根据条件动态触发逻辑 |
技术实现 | 基于状态机或任务调度器,控制任务间顺序和依赖关系 | 基于规则库和推理引擎,采用条件-动作(if-then)模式 |
关键组件 | 流程设计器、任务队列、状态跟踪器 | 规则库、推理引擎、决策表/决策树 |
触发方式 | 事件驱动,任务间有明确的依赖关系 | 条件触发,与流程无关,仅关注规则是否满足 |
动态性 | 流程变更需要修改流程图或配置文件,动态性较弱 | 规则可动态添加、修改或删除、动态性强 |
应用场景 | 明确的固定流程,如审批流程、任务分发、跨部门协作流程 | 适合复杂且频繁变动的逻辑,如动态定价、风险评估、推荐系统 |
执行方式 | 按事先定义的步骤线性或并行执行,执行过程可见 | 根据条件匹配动态执行,规则逻辑分离,执行过程不可见 |
扩展性 | 对复杂业务支持有限,适合固定流程 | 支持复杂逻辑和频繁变化,适合动态业务需求 |
开源产品 | Camunda、Activiti、JBPM、Apache Airflow | Drools、Easy Rules、OpenRules、NxBRE |
参考 Java问题排查
JSR-356 规范,即 Javax WebSocket,定义了 Java 针对 WebSocket 的 API,主流的 Web 容器都已经提供了 JSR-356 的实现,例如说 Tomcat、Jetty、Undertow 等等。
目前提供 WebSocket 服务的项目中一般有几种方案:
一般非 IM 即时通讯项目,使用前两种都可以。具体参考:
为何会断线?
如何判断在线、离线?
如何解决断线问题?
客户端心跳检测:
1. 客户端定时通过管道发送心跳,发送时启动一个超时定时器
2. 服务端接收到心跳包,应答一个pong
3. 如果客户端收到服务端的应答包,则说明服务端正常,删除超时定时器
4. 如果客户端的定时器超时依然没有收到应答包,说明服务端挂了
服务端心跳检测:
1. 客户端定时通过管道发送心跳
2. 服务端记录每个用户最后一次心跳时间,并配置一个心跳最大间隔时长
3. 开启一个定时任务,根据每个用户最后一次心跳间隔时间和最大间隔时长来判断用户是否断线
4. 遇到超过最大间隔时长的直接剔除会话
基于滑动窗口 ACK 。整体流程如下:
这种方式,在业务被称为推拉结合的方案。此种方案客户端和服务端不一定需要使用长连接,也可以使用长轮询所替代。客户端发送带有消息版本号的 HTTP 请求到服务端。
事故情况:由于redis内存报警,导致接口失败率上升(没有配置redis拒绝策略,接口阻塞)。询问发现是上游同事进行了发布,于是迅速回滚,同时增大redis内存。
措施:让上游服务回滚;对redis扩容增大内存
事故原因:上游服务的对账系统调用自己的接口
事故情况:MQ消费者服务OOM,致使服务直接挂掉
措施:重启服务,先暂时恢复
事故定位:查看 prometheus 上的服务监控,从某时刻开始内存明显飙升 && dump内存快照
jmap -dump:live,format=b,file=/tmp/dump.hprof PID
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/local/app/oom
XSSFWorkbook wb = new XSSFWorkbook(new FileInputStream(file));
XSSFSheet sheet = wb.getSheetAt(0);
解决:改用 SXSSFWorkbook
XSSFWorkbook wb = new XSSFWorkbook(new FileInputStream(file));
SXSSFWorkbook swb = new SXSSFWorkbook(wb,100);//使用SXSSFWorkbook包装,一次读入内存100条记录
SXSSFSheet sheet = (SXSSFSheet) swb.createSheet("sheet1");
//省略中间代码
sheet.flushRows();
后续思考:使用 EasyExcel。MQ异步导入excel,并发量大的话仍然会出现OOM,有安全隐患,调整mq消费者线程池,设置成4个线程,避免消费者同时处理过多消息、
事故情况:有个服务内存使用率不断飙高,主要逻辑就是消费MQ的数据然后持久化
措施:重启应用,但无果
事故定位:查看监控数据发现老年代内存发生 GC 也一直居高不下,查看jstat日志发现老年代内存回收不了。尝
试在本地复现。
com.lmax.disruptor.RingBuffer
对象内存占用很大1024*1024
,目测是这的问题解决:调小 Disruptor 的 RingBuffer 配置,调整到多少得根据业务具体情况配置
事故情况:生产某个服务版本升级的时候,实例拉起后CPU占用飙高触发报警,响应缓慢。鉴于k8s配置的是滚动更新,导致响应缓慢持续了10分钟左右
措施:发布改到周末或晚上请求少的时候
事故定位:由于其他服务发布没有出现这种现象,考虑是不是应用版本有问题。后与开发确认版本没问题,进入容器中在线定位,
1. top
找出 CPU 占用最高的 PID
2. ps -ef | grep PID
何查看对应的应用
3. jstack -l PID >> PID.log
获取进程堆栈信息
4. ps -mp PID -o THREAD,tid,time
拿到占用CPU最高的 tid
5. printf "%x\n" tid
获取16进制的线程 TID
6. grep TID -A20 PID.log
确定是线程哪里出了问题
发现有几个C2 ComilerThread
线程 CPU 占用比较高,但这几个不是应用线程。
Java 把编译过程分为两个阶段:编译期(javac编译成通用的字节码)、运行期(解释器逐条将字节码解释为机器码来执行)。
为了优化,HotSpot 引入了 JIT 即时编译器:
- 解释器:当程序启动时使用解释器快速执行,节省编译时间
- JIT编译器:程序启动后长时间提供服务时,JIT将越来越多的代码编译为本地机器码,获得更高的执行效率。
由于应用重启,大量的代码被识别为热点代码,触发了即时编译,造成CPU使用率飙高。
解决:jdk1.8默认打开分层编译,有一下几种办法
- 关闭分层编译:-XX:-TieredCompilation -client
关闭分层编译,效果不好
- 预热:提前录制流量/流量控制预热/龙井预热(Jwarmup功能)
- 调高JIT阈值:-XX:CompileThreshold=5000
修改JIT编译阈值为5000,资源不多的话可以使用
- 调整服务资源上限:调大CPU的limit,需要CPU就给够。有资源可以弹性的话可以使用
事故情况:服务内存缓慢增长,CPU正常
措施:重启服务会好点,但依旧缓慢增长
事故定位:长时间的缓慢增长,说明GC回收不了,感觉像是内存泄漏
查看ERROR
日志,发现有 LEAK 字样,ByteBuf 分配的堆外内存,GC不能释放
ByteBuf.release() 在垃圾回收之前没有被调用。启用高级泄漏报告以找出泄漏发生的位置。要启用高级泄漏报告,请指定 JVM 选项“-Dio.netty.leakDetectionLevel=advanced”或调用 ResourceLeakDetector.setLevel()
本地复现,调整非堆内存大小-XX:PermSize=30M
、-XX:MaxPermSize=43M
,用Postman定时批量发请求,以达到服务的堆外内存泄漏。
Netty
的高级内存泄漏检测级别:-Dio.netty.leakDetectionLevel=advanced
,查看泄露日志解决办法:
ReferenceCountUtil.release(msg)
ReferenceCountUtil.release(msg)
来释放,这个方法会判断上下文是否可以释放,稳妥详细参考:百万级数据excel导出功能如何实现?
导入功能比导出麻烦点,需要各种校验,留出接口方便业务校验扩展
Server服务主要维护客户端的长连接,它能力决定了一次可以在线多少用户。需要集群部署。
Route服务负责将消息分发到各个平台
客户端连接Server服务的流程:
客户端上线后,向其他客户端投递消息的流程:
1. 客户端请求路由服务提供的消息投递接口
2. 路由服务根据消息上的目标客户端id,找到对应的Server服务,并向该服务投递消息
3. Server服务从自己维护的socket里找到目标客户端,最终完成消息投递
Q:路由服务如何通过负载均衡算法找到一个可用的socket服务器ip和端口?
A:socket服务提供一个查询本机ip和端口的接口,路由服务请求此接口时通过自定义的负载均衡算法选择一个可用的服务器ip和端口
Q:消息投递的时候,路由服务如何根据目标客户端id找到对应的server服务?
A:在客户端与某个server服务连接成功(上线)后,客户端与server服务之间的关系需要保存(数据库/redis)。然后写一个feign的拦截器,根据目标客户端的id获取server服务id,加到当前线程里
Q:如何优化性能
A:路由服务要承担路由、上下线、消息推送及其他业务,因此服务内部使用了Disruptor
环形队列做异步处理,尽可能让消息推送接口更快返回。如果并发量比较高,可以加入消息队列,路由服务直接消费消息,以此来提升服务整体性能。