[关闭]
@yexiaoqi 2025-07-02T03:38:37.000000Z 字数 36962 阅读 1017

整理

面试 技术


基础

面向对象三大特性

继承、封装、多态

重写 equals() 时必须重写 hashCode() ?

Object 类的 equals() 方法比较的是对象的地址,hashCode() 是根据内存地址算出来的。同一个对象hashCode必须相同,hashCode相同不一定是同一个对象。
如果重写equals的时候没有重写hashCode,会违反Object.hashCode的通用约定,导致该类无法结合所有基于散列的集合一起工作,包括HashMap、HashSet、HashTable等。

正例:对象放入 HashSet 时,先计算对象的 hashcode 判断对象放入位置,与已经放入对象的 hashcode 比较,如果没有相同的,HashSet 会假设对象没有重复出现。如果有相同的,会再调用 equals() 是否真的相等,如果相等,HashSet 不会让其加入成功;如果不同,会重新散列到其他位置,大大减少了equals 的次数,提高速度。

反例:都不重写,new 两个Person 对象,都叫张三,hashCode()和equals()判断不同;重写一个同样不行。

hashCode()应该怎样重写?

  1. // String的hashCode()
  2. public int hashCode() {
  3. int h = hash; //hash初始值为0
  4. if (h == 0 && value.length > 0) {
  5. char val[] = value;
  6. for (int i = 0; i < value.length; i++) {
  7. h = 31 * h + val[i];//31是质数,不大不小,避免溢出
  8. }
  9. hash = h;
  10. }
  11. return h;
  12. }

可以参考Objects类中计算hashCode的逻辑

  1. public static int hashCode(Object o) {
  2. return o != null ? o.hashCode() : 0;
  3. }
  4. public static int hash(Object... values) {
  5. return Arrays.hashCode(values);
  6. }
  7. public static int hashCode(Object a[]) {
  8. if (a == null)
  9. return 0;
  10. int result = 1;
  11. for (Object element : a)
  12. //31是质数,不大不小,避免溢出
  13. result = 31 * result + (element == null ? 0 : element.hashCode());
  14. return result;
  15. }

String为什么被设计为不可变的?

HashMap的时间复杂度?Hash冲突是怎么解决的?Java8中的HashMap有什么变化?

理想情况下O(1),最坏O(n),平均O(1)。

解决 Hash 冲突使用链表法,链表过长时性能会下降。

Java 8中 HashMap 的变化

  1. 引入红黑树:当链表长度超过阈值时,链表会转为红黑树,提高查找效率。
  2. 使用尾插法:避免并发环境下可能出现的死循环问题。
  3. 扩容机制优化

红黑树是依据什么进行比较大小的?其他Hash冲突解决方式?

依据Key(必须可比较),Key 相等可能会比较节点的哈希值。

其他解决哈希冲突的方法:开放寻址法、再哈希法。

ConcurrentHashMap的Key为何不能为null?

  1. 源码中有校验,如果key或者value为null,会抛出NullPointerException
  2. 从设计上说,为了防止并发场景下的歧义问题。比如 get 方法取值,不确定是key不存在,还是value本来就是 null,并发场景下有可能被别的线程修改。

HashMap可以存储 null的 key 和 value,但 null 作为key只能有一个,作为值可以有多个。null 作为 key,会返回 hash 为0位置的值,单线程环境下不存在歧义问题。

参考:ConcurrentHashMap为什么key和value不能为null

Spring事务失效的12种场景

Spring 事务:再对应service层方法上使用@Transcational,分布式的话需使用分布式事务。下面几种情况会导致事务不生效

下面几种情况会导致事务不回滚

其他

大事务问题:事务执行耗时比较长,会导致死锁、锁等待、回滚时间长、接口超时、并发情况下数据库连接被占满、数据库主从延迟等问题

编程式事务,基于TransactionTemplate的编程式事务优点:避免由于 Spring AOP 导致事务失效的问题、能更小粒度的控制事务的范围,更直观

  1. @Autowired
  2. private TransactionTemplate transactionTemplate;
  3. public void save(final User user) {
  4. queryData1();
  5. queryData2();
  6. transactionTemplate.execute((status) => {
  7. addData1();
  8. updateData2();
  9. return Boolean.TRUE;
  10. })
  11. }

Spring的三级缓存机制

三级缓存分别是:
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 AOP遇上循环依赖

三级缓存

Spring Event

Spring Event 使用观察者模式,事件源发布一个事件,事件监听器可以消费这个事件,事件源不用关注发布的事件有哪些监听器,可以对系统进行解耦。实现方式上有两种:

Spring 中事件监听器的处理默认是同步方式,可以改为异步,有两种方式

Spring 事件监听器消费事件同步模式下支持自定义顺序,一般在监听方法上使用@Order(1),数字越小优先级越高;异步模式下顺序不靠谱。
适用场景:异步模式将主逻辑和监听器逻辑分到不同线程中,适合非主要逻辑,因为监听器运行有可能失败。如果非主要逻辑要求必须成功,考虑使用更重量级的消息中间件来解决。
具体代码参考:@EventListener:定义 spring 事件监听器

SOA和微服务的区别?

特征 SOA 微服务
架构理念 企业级服务重用和集成 小型、自治的服务
服务范围 大型、复杂 小型、简单
通信方式 集中式消息总线ESB 轻量级通信协议(RESTful API)或消息队列
技术栈 各种技术 轻量级技术
部署方式 集中式应用服务器 独立容器或虚拟机
适用场景 遗留系统改造 需要快速迭代、弹性伸缩和高可用的场景

多线程

线程池的有哪些参数?

线程池的执行流程?

线程池的执行流程

  1. 线程池刚创建时,里面没有线程,任务队列作为参数传进来

  2. 当调用 execute() 方法添加一个任务时,线程池会判断:

    • 运行的线程数量 < corePoolSize,则创建线程运行此任务
    • 运行的线程数量 >= corePoolSize,那么这个任务将放入队列
      • 队列已满 & 运行的线程数 < maximumPoolSize,则创建非核心线程运行此任务
      • 队列已满 & 运行的线程数 >= maximumPoolSize,则根据拒绝策略来处理
  3. 当线程完成任务时,会从队列中取一个任务来执行

  4. 当前运行的线程数大于 corePoolSize,线程空闲超过一定时间(keepAliveTime)时,那么这个线程就会被停掉。线程池所有任务完成后,线程数最终会收缩到 corePoolSize 的大小。

线程池的线程数如何配置?

具体参考 Java线程池实现原理及其在美团业务中的实践

线程池中线程抛异常了,如何处理?

  1. public class ThreadPoolException {
  2. public static void main(String[] args) {
  3. //创建一个线程池
  4. ExecutorService executorService=Executors.newFixedThreadPool(1);
  5. //当线程池抛出异常后submit无提示,其他线程继续执行
  6. executorService.submit(new task());
  7. //当线程池抛出异常后execute抛出异常,其他线程继续执行新任务
  8. executorService.execute(new task());
  9. }
  10. }
  11. //任务类
  12. class task implements Runnable{
  13. @Override
  14. public void run() {
  15. System.out.println("进入了task方法!!!");
  16. int i=1/0;
  17. }
  18. }

任务提交方式中,execute会打印异常信息,submit不打印异常,要想获取异常信息就必须使用get()

  1. //当线程池抛出异常后submit无提示,其他线程继续执行
  2. Future<?> submit = executorService.submit(new task());
  3. submit.get();

方案一:使用try-catch
方案二:使用Thread.setDefaultUncaughtExceptionHandler方法捕获异常。
重写线程工厂方法,在线程工厂创建线程的时候,赋予UncaughtExceptionHandler处理器对象
方案三:重写afterExecute进行异常处理

  1. public class ThreadPoolException3 {
  2. public static void main(String[] args) throws InterruptedException,ExecutionException {
  3. //1.创建一个自己定义的线程池
  4. ExecutorService executorService = new ThreadPoolExecutor(2,3,0,TimeUnit.MILLISECONDS,new LinkedBlockingQueue(10)) {
  5. //重写afterExecute方法
  6. @Override
  7. protected void afterExecute(Runnable r, Throwable t) {
  8. //这个是excute提交的时候
  9. if (t != null) {
  10. System.out.println("获取到excute提交的异常信息,处理异常"+t.getMessage());
  11. }
  12. //如果r的实际类型是FutureTask那么是submit提交的,所以可以在里面get到异常
  13. if (r instanceof FutureTask) {
  14. try {
  15. Future<?> future = (Future<?>) r;
  16. //get获取异常
  17. future.get();
  18. } catch (Exception e) {
  19. System.out.println("获取到submit提交的异常信息,处理异常" + e);
  20. }
  21. }
  22. }
  23. };
  24. //当线程池抛出异常后execute
  25. executorService.execute(new task());
  26. //当线程池抛出异常后submit
  27. executorService.submit(new task());
  28. }
  29. }
  30. class task3 implements Runnable {
  31. @Override
  32. public void run() {
  33. System.out.println("进入了task方法!!!");
  34. int i = 1 / 0;
  35. }
  36. }

从线程池角度考虑如何减少任务的阻塞时间?

多线程的业务场景

  1. 简单定时任务
    new Thread 里面放一个 while(true) 循环。优点是简单,缺点是功能单一,无法应对复杂场景

  2. 监听器
    搭配配置中心,修改配置开关后,动态刷新,服务端用开关判断是否启动业务逻辑处理

  3. 收集日志
    如果没有引入 MQ,可以通过多个线程/线程池异步写日志数据到数据库

  4. excel导入
    excel 导入读取数据后可能有复杂的业务逻辑需要处理,可以通过多线程处理。最简单的实现方式:parallelStream,它通过ForkJoinPool实现

  5. 统计数量
    count++ 并非原子操作,多线程执行时,统计次数可能出现异常。可以使用AtomicIntegeratomic包下面的类

  6. 查询接口
    接口中有串行远程调用的,可以改为并行调用

  1. public UserInfo getUserInfo(Long id) throws InterruptedException, ExecutionException {
  2. final UserInfo userInfo = new UserInfo();
  3. CompletableFuture userFuture = CompletableFuture.supplyAsync(() -> {
  4. getRemoteUserAndFill(id, userInfo);
  5. return Boolean.TRUE;
  6. }, executor);
  7. CompletableFuture bonusFuture = CompletableFuture.supplyAsync(() -> {
  8. getRemoteBonusAndFill(id, userInfo);
  9. return Boolean.TRUE;
  10. }, executor);
  11. CompletableFuture growthFuture = CompletableFuture.supplyAsync(() -> {
  12. getRemoteGrowthAndFill(id, userInfo);
  13. return Boolean.TRUE;
  14. }, executor);
  15. CompletableFuture.allOf(userFuture, bonusFuture, growthFuture).join();
  16. userFuture.get();
  17. bonusFuture.get();
  18. growthFuture.get();
  19. return userInfo;
  20. }

并发

ThreadLocal 原理?

ThreadLocal 是线程本地变量,存放每个线程的私有数据。

每个线程都有一个 ThreadLocalMap,维护一个 Entry[],数组元素的Key是 ThreadLocal 对象,value是线程对应的数据。使用线性探测解决 hash 冲突,需要手动调用 set、get、remove 防止内存泄漏。

使用场景

ThreadLocal 内存泄漏的原因?

每个线程都有一个 ThreadLocalMap 的内部属性,map 的 key 是 ThreadLocal 且是弱引用,value 是强引用。垃圾回收时会自动回收 key,value的回收取决于线程对象的生命周期。线程池中的线程长时间存货,value 释放不掉导致内存泄露。

知道哪些锁,每个锁的原理是什么?多线程下的锁有哪些?MySQL层面的锁?

锁是一种并发控制机制,用于在多线程/进程环境下,控制对共享资源的访问,从而保证数据的一致性和完整性。

多线程下的锁(JVM层面)

MySQL层面的锁

ReentrantLock 和 Synchronized 的区别?Synchronized 原理?

ReentrantLock 的实现原理?

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 锁升级过程,锁的升级、降级?

synchronized代码块是由 monitorenter 和 monitorexit 指令实现的。Java 6之前 Monitor 的实现依靠操作系统内部的互斥锁,因为需要进行用户态和内核态的切换,所以同步操作是个重量级操作。现代JDK中,JVM提供了偏向锁、轻量级锁、重量级锁三种Monitor实现,大大改进了其性能。

锁升级、降级就是JVM优化 synchronized 运行的机制,当JVM检测到不同的竞争状态时,会自动切换到适合的锁实现,这种切换就是锁的升级降级。

Java 对象结构分为 对象头、对象体、对齐字节。对象头包含三部分:

  • Mark Word:存储自身运行数据(当前对象线程锁状态及 GC 标志)
  • class 指针:指向方法区中该 class 的对象,JVM 通过此字段判断当前对象事哪个类的实例
  • 数组长度:只有是数组时才会用到

锁升级过程按照无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁方向升级。当JVM进入安全点的时候,会检查是否有闲置的Monitor,然后试图进行降级。

  1. 线程A在进入同步代码块前,先检查 MarkWord 中的线程 ID 是否与当前线程 ID 一致,如果一致,则直接无需通过 CAS 来加锁、解锁
  2. 如果不一致,再检查是否为偏向锁,如果不是,自适应自旋等待锁释放
  3. 如果是,在检查对应线程是否存在,如果不在,则设置线程 ID 为线程 A 的 ID,还是偏向锁
  4. 如果在,撤销偏向锁,升级为轻量级锁。线程 A 自旋等待锁释放
  5. 如果自旋到了阈值,锁还没释放,又有一个线程来竞争锁,此时升级为重量级锁。其他竞争线程都被阻塞,防止CPU空转
  6. 锁被释放,唤醒所有阻塞线程,重新竞争锁

聊聊 AQS(抽象队列同步器)?

AQS是Java并发用来构建锁和其他同步组件的基础框架。AQS使用一个线程可见的state表示同步状态,通过内置的FIFO队列来完成线程的排队和调度,通过CAS对State值修改。

state变量 volatile int类型,表示同步状态

排他锁

共享锁

AQS的核心思想是,如果被请求的共享资源空闲,就将当前请求线程设置为有效的工作线程,将共享资源设定为锁定状态;如果共享资源被占用,将暂时获取不到锁的线程加入队列中。

等待队列:CHL双向链表,FIFO,未争抢到锁的线程会被放入等待队列

条件队列

  1. 当前线程 threadA 调用 wait/await方法,表示当前线程要暂时释放锁,则当前线程进入条件队列的队尾;
  2. 同步队列或者其他线程 threadB 获取到锁;
  3. threadB 线程调用singal方法,将条件队列队首的线程移到同步队列,并调用acquireQueued方法尝试争抢锁,通过判断前驱节点是不是头节点。

Disruptor

Disruptor是一个高性能异步处理框架,能够在无锁的情况下实现队列的并发。
使用环形数组实现了类似队列的功能,并且是一个有界队列。通常用于生产者-消费者场景

核心设计原理

优点

详细参考:高性能并发队列Disruptor使用详解

框架

Spring

@Autowired的实现原理

@Autowired注解用于自动装配Bean,实现主要依赖于Spring容器中的一个后置处理器:AutowiredAnnotationBeanPostProcessor

  1. Spring容器启动时,会实例化并注册各种后置处理器,包括AutowiredAnnotationBeanPostProcessor。
  2. 当Spring容器创建Bean实例时,在doCreateBean()方法中会调用populateBean()方法,来为bean进行属性填充,完成自动装配等工作。
  3. AutowiredAnnotationBeanPostProcessor会在这些方法中,扫描Bean的属性、方法参数等,查找标注了@Autowired注解的地方。
  4. 找到@Autowired注解后,会根据类型匹配、名称匹配等规则,从Spring容器中查找对应的Bean。
  5. 找到匹配的Bean后,会通过反射机制将找到的Bean注入到当前Bean的属性或方法参数中。

Bean的默认作用范围是什么?其他的作用范围?

作用域 描述 适用场景
singleton 单例,整个容器只有一个实例 无状态Bean,如工具类、配置类
prototype 原型,每次调用创建一个新实例 有状态Bean,如数据库连接
request 请求范围,一次HTTP请求一个实例 Web应用程序中需要在每个请求中保持独立状态的Bean
session 会话范围,一次HTTP会话一个实例 需要在一次会话中保持状态的Bean
application 应用范围,整个Servlet上下文一个实例 需要在整个应用程序中共享数据的Bean
websocket 对应于单个 websocket 的生命周期

Bean的生命周期

  1. 定义:启动时扫描XML、注解或配置类中需要被Spring管理的Bean信息,装到BeanDefinitionMap中,真实对象还未实例化;
  2. 实例化:Spring 使用反射机制为Bean分配内存空间,但此时Bean对象的属性还未注入;
  3. 属性填充:将当前类依赖的Bean属性进行注入和装配。
  4. 初始化
    1. 执行各种通知;BeanNameAwareBeanFactoryAwareApplicationContextAware接口;
    2. 执行初始化的前置方法;BeanPostProcessorpostProcessBeforeInitialization方法;
    3. 执行初始化方法;@PostConstruct或者会调用InitializingBean接口的afterPropertiesSet()方法;
    4. 执行初始化的后置方法。BeanPostProcessorpostProcessAfterInitialization方法。
  5. 使用
  6. 销毁DisposableBean接口的destroy()方法或者@PreDestroy注解的方法。

SpringBoot

如何定义一个starter

  1. 新建两个模块,命名规范:xxx-spring-boot-starter
    • xxx-spring-boot-autoconfigure:自定配置核心代码
    • xxx-spring-boot-starter:管理依赖
  2. 在 xxx-spring-boot-autoconfigure 项目中
    1. 引入 maven 依赖
    2. 创建自定义的 XXXProperties 类:类中的属性要出现在配置文件中
    3. 创建自定义类,实现自定义功能
    4. 创建自定义的 XXXAutoConfigure 类:用于自动配置时的一些逻辑,需将上方自定义类进行 Bean 对象创建,同时让 XXXProperties 类生效
    5. 创建自定义的spring.factories文件:在resource/META-INF创建一个 spring.factories文件和spring-configuration-metadata.json,分别用于导入自动配置类(必须有)、用于在填写配置文件时的智能提示(可以没有)。
  3. 在 xxx-spring-boot-starter 项目中引入 xxx-spring-boot-autoconfigure 依赖,其他项目使用时只需依赖 xxx-spring-boot-starter 即可

JVM

类加载机制?双亲委派模型?

类加载过程:获取类的二进制字节流 -> 结构化静态存储结构 -> 在内存中生成 Class 对象
双亲委派模型:类加载请求先交给父类加载器,加载不了子类加载器才会尝试加载。避免重复加载,保证安全性。

Java内存模型(JMM)?

JMM 是用于描述 Java 程序中多线程并发访问共享内存的规范。

共享变量与可见性

为了确保每个线程都能看到其他线程对共享变量的修改,Java 内存模型提供了一系列规则。例如:

有序性

为了避免指令重排导致多线程程序出现意想不到的结果,Java 内存模型定义了 happens-before 规则来确保多线程间的操作顺序符合预期。

原子性

Java 内存模型还规定了某些操作具有原子性。例如:对 volatile 变量的读写操作。对于非原子性的操作,需要使用锁等机制来保证线程安全。

主内存和工作内存

JVM内存结构?

各部分内存的合理配置和优化对JVM性能至关重要。
jvm内存结构

JVM加载类的阶段、static成员变量在各个阶段中发生了什么变化

源码到代码执行过程:编译 -> 加载 -> 解释 -> 执行
编译阶段:语法分析 -> 语义分析 -> 注解处理 -> class文件

加载阶段:装载 -> 连接 -> 初始化

装载阶段:查找并加载类的二进制数据,在 JVM 堆中创建一个 java.lang.Class类的对象,并将类相关的信息存储在 JVM 方法区中。
连接阶段:对 class 的信息进行验证、为类变量分配内存空间并对其赋默认值

  1. 验证:验证类是否符合 java 规范和 JVM 规范
  2. 准备:为类的静态变量分配内存,初始化为系统默认值
  3. 解析:将符号引用转为直接引用
    初始化:为类的静态变量赋予正确的初始值

解释阶段:把字节码转换为操作系统识别的指令
JVM 会检测热点代码,超过阈值会触发即时编译,生成机器码保存起来,下次直接执行

执行阶段:调用操作系统执行指令

在JVM内存模型下,说说一个对象初始化的整个过程

  1. 类加载检查:首先检查要创建的对象的类是否已经被加载。如果未加载,会触发该类的加载、验证、准备、解析、初始化过程,将类的元数据(如静态变量、方法信息)加载到方法区。在准备阶段,静态变量会被赋零值;在初始化阶段,会执行静态代码块和静态变量的赋值。

  2. 内存分配:确认类已加载后,会在上分配内存空间。

  3. 赋零值:对象实例变量部分赋上默认的零值。

  4. 设置对象头:新对象的内存空间会设置对象头,包含对象哈希码、GC 信息以及指向其类元数据(方法区)的指针。

  5. 执行构造器:执行代码层面的构造器方法,方法的局部变量和操作数存储在虚拟机栈

  6. 赋值引用:将对象引用赋值给成员变量,局部变量-虚拟机栈实例变量-堆静态变量-方法区

Java堆的分区,比例,如果调大比例后会有什么样的后果?

Java堆内存结构

默认的,Young:Old=1:2 (可通过参数 –XX:NewRatio 来指定)。其中,新生代(Young)被细分为 Eden 和 两个 Survivor 区域,这两个 Survivor 区域分别被命名为 from 和 to。老年代过小会导致频繁发生 fullGC,继而导致 STW
默认的,Eden:from:to = 8:1:1 (可通过参数 –XX:SurvivorRatio 来设定),新生代比例过小会导致频繁发生MinorGC

Java怎么进行垃圾回收的?

Java 的 GC 机制负责识别并回收不再被程序使用的对象,从而避免内存泄漏和溢出问题。垃圾回收过程:

  1. 标记:标记存活对象;
  2. 清除:回收未标记的对象的内存空间;
  3. 整理(可选):酱存货对象移动到一起,减少内存碎片。

什么时候对象进入老年代?

JVM的GC算法,为什么“标记-整理”更好一些,能解决什么问题?

标记-整理:标记出所有存活对象,将所有存活对象向内存一边移动,清理掉边界以外的部分。

解决内存碎片问题。主要用于老年代,移动存活对象代价比较大,而且需要STW,从整体的吞吐量来考量,老年代使用标记-整理算法更合适。

什么情况会触发 FullGC?

看线上正在运行的服务的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和CMS垃圾回收器的区别,哪几个过程中会STW? G1的region

  1. 使用范围不一样
    CMS:老年代,需要配合新生代Serial/ParNew一起使用
    G1:新生代、老年代
  2. STW 的时间
    CMS:以最小停顿时间为目标
    G1:可预测垃圾回收的停顿时间
  3. 垃圾碎片
    CMS:使用“标记清除”算法,容易产生内存碎片
    G1:“标记整理”算法,没有内存碎片
  4. 垃圾回收过程不一样
    CMS:初始标记(STW)、并发标记、重新标记(STW)、并发清除
    G1:初始标记(STW)、并发标记、最终标记(STW)、筛选回收(STW)
  5. CMS会产生浮动垃圾

G1将Java堆划分为2048个大小相同的独立 Region 块,每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理

GC策略及选择

GC策略

Serial:单线程,垃圾收集时必须暂停其他所有工作线程直到收集结束。

Parallel:多线程,并行标记扫描

CMS:多线程,采用“标记-清除”算法实现。初始标记、并发标记、并发预处理、重新标记、并发清除、并发重置。

G1:堆被划分为许多连续的区域,采用G1算法回收。

JVM 调优

什么时候考虑 JVM 调优?

JVM 调优的目标

JVM 调优量化目标

JVM 调优的步骤

  1. 分析 GC 日志及 dump 文件,判断是否需要优化,确定瓶颈问题点;
  2. 确定 JVM 调优量化目标;
  3. 确定 JVM 调优参数(根据历史 JVM 参数来调整);
  4. 依次调优内存、延迟、吞吐量等指标;
  5. 对比观察调优前后的差异;
  6. 不断分析和调整,直到找到合适的 JVM 参数配置;
  7. 找到最合适的参数,将这些参数应用到所有服务器,并进行后续跟踪。

常见的 JVM 参数

堆栈配置相关

垃圾收集器相关

辅助信息

常用调优策略

MySQL

SQL查询语句是如何执行的?

MySQL的逻辑架构图
一条查询语句的执行过程一般是经过连接器、分析器、优化器、执行器等功能模块,最后到达存储引擎

执行语句前要先连接数据库,分析器会通过词法和语法解析知道这是一条更新语句,会把表T上所有缓存结果都清空。优化器决定要使用哪这个索引,然后执行器负责具体执行,找到这一行,然后更新。

MySQL锁

在 InnoDB 引擎下,按照锁的粒度,可以简单分为行锁表锁

行锁是作用在索引上的,SQL命中了索引,锁住的就是命中条件内的索引节点(行锁)。如果没有命中索引,那锁住的就是整个索引树(表锁)。

行锁可以简单分为读锁写锁。读锁是共享的,多个事务可以读取同一个资源,但不允许其他事务修改。写锁是排他的,写锁会阻塞其他的写锁和读锁。

事务的四大特性

MySQL隔离级别,分别解决什么问题

MVCC原理

  1. 获取事务自己的版本号,即事务ID
  2. 获取 Read View
  3. 查询到的数据,然后与 Read View 中的事务版本号进行比较
  4. 如果不符合 Read View 的可见性规则,就需要 undo log 中历史快照
  5. 最后返回符合规则的数据

索引

B+ 树和 B树的主要区别?B+ 树索引,一次查找过程?

B+ 树是有序的,和B-树的主要区别:

假设由如下SQL:age 加个索引,这条 SQL 是如何在索引生执行的?

  1. select * from Temployee where age=32;

idx_age二级索引树

id主键索引树

这条 SQL 查询语句的执行大概流程:

MongoDB为啥用B树而不是B+树

InnoDB 存储引擎中的行锁的加锁规则

InnoDB 三种行锁:
- Record Lock(记录锁):锁住某一行记录
- Gap Lock(间隙锁):锁住一段左开右开的区间(m, n)
- Next-key Lock(临键锁):锁住一段左开右闭的区间(m, n]

  1. 对于 update、delete、insert,InnoDB 会自动给相应记录行加写锁
  2. 对普通 select 语句不加锁,但是在 Serializable 隔离级别下会加行级读锁
  3. select …… for update加行级写锁
  4. select …… lock in share mode加行级读锁

查找过程中访问到的对象才会加锁
加锁的基本单位是临键锁

慢sql优化如何做?

发现慢SQL

分析慢SQL

优化慢SQL

其他优化方法

数据库优化

分库分表

缺点
- 事务问题,不能用本地事务,需要分布式事务;
- 跨节点 Join 的问题:解决这一问题可任意分两次查询实现;
- 跨节点的 count、order by、group by 以及聚合函数问题:分别在各个节点上得到结果后在应用程序端进行合并;
- ID 问题:数据库被切分后,不能再依赖数据库自身的主键生成机制,最简单可以考虑 UUID;
- 跨分片的排序分页问题(后台加大 pagesize 处理)

  1. 为什么要分库、分表
    分库:拓展磁盘存储、并发连接支撑
    分表:单表大量数据时,存储和查询性能会遇到瓶颈,考虑分表分摊压力
  2. 什么时候考虑
    • 阿里《Java开发手册》中推荐
      单表行数超过500万行或者单表容量超过2GB,才推荐进行分库分表。
    • 根据业务提前预估
  3. 以什么维度来分表
    先找到业务的主题,比如表是一张客户信息表,可以考虑以客户id来分表
  4. 非分表键如何查询
    • 遍历:简单粗暴(不建议)
    • 将用户信息冗余同步到ES,通过ES来查询(推荐)
  5. 分表策略
    • 范围划分,比如以订单id,以订单作为分表键,300w一个表;或者时间,通常用于冷热数据分离
      优点:扩容方便
      缺点:会有热点问题
    • Hash取模,以指定的路由key(比如用户id、订单id)取哈希后对分表数取模,将数据分散到各个表中
      优点:不会存在明显的热点问题
      缺点:扩容比较麻烦,需提前规划好
  6. 跨节点join关联问题
    解决思路:字段冗余、全局表(基础表每个库中保存一份)、数据同步、应用层代码组装

  7. 聚合函数问题:分别在各个节点上得到结果后,在中间件/应用端合并

  8. 分库分表后的分页问题
    • 全局视野法:在各数据库查到对应结果后再代码端汇聚再分页。缺点是会返回过多数据
    • 业务折衷:禁止跳页查询,需要业务妥协
  9. 分布式ID:考虑使用雪花算法生成
  10. 分库分表中间件:推荐使用 Apache ShardingSphere
  11. 分表要停服吗?不停服如何做?
    • 编写代理层,加个开关,灰度期间还是访问老的DAO
    • 发版全量后,开启双写,既在旧表新增和修改,也在新表新增能够和修改。日志/临时表记下新表ID起始值,旧表中小于这个值的数据就是存量数据,这批数据就是要迁移的
    • 通过脚本把旧表的存量数据写入新表
    • 停读旧表改读新表,此时新表已经城在了所有读写业务,但是这时候不要立即停写旧表,保持双写一段时间
    • 当读写新表一段时间后,如果没有业务问题,就可以停写旧表了

双机热备

网络/IO

TCP

四次挥手过程,为什么是四次?

四次挥手中有TIME_WAITCLOSE_WAIT状态,是出现在哪一方的?

假设 TIME_WAIT 状态过多会有什么危害,怎么解决?

为什么TCP4次挥手时等待为2MSL?

怎么实现拥塞控制?

如何保证数据可靠性和顺序性

负载均衡

IO

IO 模型

Java中的BIO、NIO、AIO的区别和联系

NIO 和多路复用的区别?

零拷贝

缓存

缓存雪崩、缓存穿透、缓存击穿,如何应对?

雪崩:缓存机器宕机,导致请求全部打到数据库上,打死数据库

穿透:大量构造的恶意请求,导致缓存查不到,最终打死数据库

击穿:热点key再失效的瞬间,大量请求直接请求到数据库,打死数据库

布隆过滤器:通过一个bit数组和多个哈希函数判断某个元素是否存在于一个集合中。
核心思想是使用哈希函数将元素映射到为数组的不同位置,并在这些位置1。查询时通过哈希函数判断某个元素对应的位是否全为1,如果是,可能存在该元素;如果有0,则一定不存在。

哈希函数存在碰撞,所以可能会误判元素存在。

redis 内存满了会发生什么?

  1. 如果 redis 配置了内存淘汰策略,则会通过定时删除惰性删除定期删除等方式淘汰过期的key,如果配置的淘汰策略淘汰不掉,则报错OOM
  2. 如果启用了虚拟内存机制,会将部分最老数据的value持久化到磁盘

Redis如何解决key冲突?

通过链地址法重新哈希渐进式哈希负载因子哈希桶等多种机制来解决键冲突的问题。

链地址法:多个键被哈希到同一个位置时,使用链表将键值对连起来。

重新哈希:当哈希表中键值对数量达到一定阈值时,会自动扩容重新哈希。

渐进式哈希:将重新哈希的过程分摊到多个操作中,每次只迁移一部分键值对。

负载因子:会根据负载因子大小决定是否扩容,保证哈希表性能。

哈希桶:发生哈希冲突时,会将冲突的键值对存储在同一个哈希桶中。减少哈希冲突,提高性能。

Redis 发布订阅(pub/sub)

类似于 RocketMQ中广播模式,简单易上手

优点:轻量级、低延迟、低可靠性
缺点:

使用需考虑:消费者并发数、生产的速度和消费的速度、是否需保证可靠性

如果要求可靠性 & redis版本>=5.0,可以考虑使用 Redis Stream,如果有更复杂的要求,还是考虑使用专门的消息队列(kafka、RocketMQ、Pulsar等)

什么是一致性Hash算法

一致性 Hash 算法能够在 Hash 输出空间发生变化时,引起最小的改动。应用在像分布式系统扩容缩容的时候。

原理是将整个哈希输出空间设置为一个环形区域,对数据进行 Hash 操作,映射在环形区域上,然后让该值沿顺时针方向移动,遇到的第一个服务器就是它分配的节点。

当服务节点很少的时候,会有很多 key 被分配到同一个服务实例上,称之为“数据倾斜”。如何解决?虚拟节点,即对内阁服务节点计算多个哈希,每个计算结果位置都放置一个此服务节点,称之为虚拟节点。具体做法可以在服务器 ip 或主机名的后面增加编号来实现。同时数据定位算法不变,只是多了一步虚拟节点到实际节点的映射。虚拟节点越多数据月均匀,一般虚拟节点数要在32个以上

虚拟节点

如何保证数据库与redis缓存一致

没有一个完美方案可以适用于所有场景。

旁路缓存:先更新数据库,再删缓存。

延迟双删:先更新数据库,再删缓存,延迟一段时间后再删一次。

读写请求串行化

ELK

搞懂ELK并不是一件特别难的事

消息队列

RocketMQ

如何保证高可用

详见RocketMQ是如何保证高可用的?

如何保证消息不丢失

可能丢失的情况

  1. 生产者发消息时丢失。

    同步发送 + 重试机制 + 部署多个broker

    同步重试机制:如果同步模式发送失败,则轮转到下一个Broker。

  2. broker存储消息时丢失。broker会将消息写入缓冲区,再发送给生产者回执,最后异步刷盘。如果刷盘过程中broker宕机了,会导致消息丢失。

    同步刷盘 + 同步复制 + 主从

    同步刷盘:消息真正持久化到磁盘后,Broker才会返回一个ACK,保证可靠性,但影响性能。
    异步刷盘:消息写入PageCache就返回ACK,后台异步刷盘,提高性能和吞吐量,可能丢消息。

    同步复制:消息投递到主从节点都落盘成功,Broker才会当作消息投递成功。增大延迟,降低吞吐。
    异步复制:只要master写入消息成功,就反馈给客户端写入成功的状态。速度快,同样可能丢消息。

    Broker重试机制:消息消费失败会写入重试Topic,最大重试16次,如果还不行会写入死信队列。

  3. 消费者消费时候丢失。网络、消费者宕机。

    手动ACK + 消费重试机制 + 尽量不要异步消费

兜底方案:存到其他地方,用定时任务重新发送来降级处理。

如何保证消息消费不重复

如何保证消费的幂等性?

如何保证消息顺序

顺序消费的核心:生产者有序生产 + 消费者有序消费。

  1. 生产者有序生产:同步发送 + 自定义队列选择器,Hash取模保证同一个订单在同一个队列。

    注意!Broker宕机或者增加,也会短暂的造成部分消息无序。

  2. 消费者有序消费:有序消费模式MessageListenerOrderly,是通过加分布式锁和本地锁保证同时只有一条线程去消费一个队列上的数据。

    注意!使用锁降低了吞吐量,因为顺序,后面消息会被前面消息阻塞,消费失败自动重试无法跳过,必须设置最大消费次数,处理好可能的异常情况。

定时任务

如何避免被重复调度?

一次调度任务只有一个机器执行,不会因为是分布式部署而出现多台机器同时执行某个job。思路是使用分布式锁

基于数据库实现分布式锁

QuartzacquireTriggersWithinLock=true获取 trigger 的时候上锁(抢占式获取数据库锁并由抢占成功的节点负责运行),默认是 false,使用乐观锁,但可能出现 ABA 导致重复调度

XXL-JOB:使用数据库悲观锁

  • setAutoCommit(false)关闭隐式自动提交
  • select lock for update显式排他锁,其他事务无法进入&无法实现for update
  • 读数据库任务信息 -> 拉任务到内存时间轮 -> 更新数据库任务信息
  • commit提交事务,同时释放for update排他锁(悲观锁)

基于缓存(redis)实现分布式锁

  1. 获取锁的时候,使用 setnx 命令设置锁、设置过期时间,value 作为一个随机字符串,解锁时用来判断
  2. 获取锁的时候,增加超时时间
  3. 解锁时通过随机字符串判断是不是同一把锁

性能高,但redis master-slave架构的主从异步复制导致的redis分布式锁的最大缺陷:在redis master实例宕机的时候,可能导致多个客户端同时完成加锁。

基于 zookeeper 实现分布式锁

  1. 创建目录 mylock
  2. 线程 A 想获取锁,就在 mylock 目录下创建临时节点,会返回一个数字
  3. 获取 mylock 目录下的所有节点,获取比自己小的兄弟节点,如果不存在说明当前线程的序号最小,那么他就获取了当前这把锁
  4. 线程 B 创建一个临时顺序节点,判断自己是不是最小节点,设置监听比自己小一位的节点
  5. 线程 A 处理完毕,删除自己的节点。线程 B 监听到了变更事件,判断自己是不是最小的,如果是则获取到锁

可靠性高,但性能不如redis分布式锁

分布式

单机演变为分布式,涉及哪些技术的调整?

单机系统演变为分布式系统是一个复杂的过程,需要根据具体的业务需求和场景,选择合适的技术方案,逐步完成系统的改造。

前端负载均衡

服务拆分

数据库拆分

分布式缓存

消息队列

分布式事务

监控与日志

redis分布式锁

RedLock算法是 Redis 官方支持的分布式锁算法。

最普通的实现方式,是使用SET key value [EX seconds|PX milliseconds] NX创建一个key,就算加锁成功

  1. -- 删除锁的时候,找到key对应的value,与传过去的value做比较,如果是一样的才删除
  2. if redis.call("get",KEYS[1]) == ARGV[1] then
  3. return redis.call("del",KEYS[1])
  4. else
  5. return 0
  6. end

考虑到 Redis 单实例会出现单点故障风险;或者普通主从异步复制,如果主节点挂了,key 还没有同步到从节点,此时从节点切换为主节点,别人就可以 set key,从而拿到锁。

RedLock 算法
假设有一个 Redis cluster,有5个 Redis master 实例。然后执行如下步骤获取一把锁:

  1. 获取当前的时间戳,单位是毫秒
  2. 轮流尝试在每个 master 节点创建锁,超时时间较短(客户端为了获取锁使用的超时时间比自动释放锁的总时间要小)
  3. 尝试在大多数节点上建立一个锁,比如5个节点就要求3个节点
  4. 客户端计算加锁的时间,如果建立锁的时间小于超时时间,就算建立成功了
  5. 要是加锁失败,就依次之前建立过的锁删除
  6. 只要别人建立了一把分布式锁,你就地不断轮询去尝试获取锁

分布式事务如何解决?

具体参考分布式事务的七种解决方案

事务模式 宽松一致性 开发量 性能 回滚支持
Msg 无中间状态 ×
XA 无中间状态
TCC 内部可见中间态 较低
Saga 外部可见中间态 较高

分布式接口幂等如何处理?

核心思想:通过唯一的业务单号保证幂等性,非并发情况下,查询业务单号有没有操作过,没有则执行操作;并发情况下,此过程需加锁。

根据唯一业务号取更新数据:通过版本号来控制。用户查询出要修改的数据,系统将数据返给页面,将数据版本号放入隐藏域,用户修改数据提交时将版本号一同提交,后台使用版本号作为更新条件

  1. update set version=version+1, xxx=${xxx} where id=xxx and version=${version};

没有唯一业务号的 update 与 insert 操作:进入到注册页时,后台统一生成 Token,返回前台隐藏域中,用户在页面提交时将 Token 一同传入后台,使用 Token 获取分布式锁,完成 insert 操作,执行成功后不释放锁,等待过期自动释放。

容器化部署

K8s

Kubernetes 是一个开源的容器编排系统,用于自动化部署、扩展和管理容器化应用。K8s 作为应用服务与服务器之间的中间层,通过策略协调和管理多个应用服务,简化部署流程,实现自动扩缩容,并能在服务器出问题时自动重新部署应用。

要点:

可视化平台 KubeSphere

DDD

战略设计 (Strategic Design)

战略设计主要关注的是从业务角度理解问题域,并为软件系统划定合理的边界。你需要弄清楚以下几点:

  1. 领域 (Domain):
    • 定义: 你需要理解什么是领域,它代表了组织所从事的业务范围和知识体系。
    • 重要性: DDD 的核心是围绕业务领域进行建模。
  2. 子域 (Subdomain):
    • 定义: 领域通常很大且复杂,需要将其划分为更小、更易于管理的子域。
    • 类型:
      • 核心域 (Core Domain): 这是组织业务的核心竞争力,需要投入最多的精力进行精确建模和创新。
      • 支撑子域 (Supporting Subdomain): 这些子域支持核心域,但不直接构成核心竞争力。通常可以自研或购买成熟的解决方案。
      • 通用子域 (Generic Subdomain): 这些子域是多个业务都需要的通用能力,例如身份认证、权限管理、日志记录等。通常可以直接采用成熟的第三方库或服务。
  3. 限界上下文 (Bounded Context):
    • 定义: 限界上下文是领域模型语义边界。在一个限界上下文中,模型中的术语和概念具有明确的、统一的含义。
    • 重要性: 避免模型在大型系统中变得混乱和不一致。每个团队在一个限界上下文中工作,拥有自己的模型。
    • 识别: 学习如何识别限界上下文,通常通过业务流程、团队组织结构、技术边界等因素来划分。
  4. 上下文映射 (Context Mapping):
    • 定义: 上下文映射描述了不同限界上下文之间如何相互集成和协作。
    • 常见模式:
      • 共享内核 (Shared Kernel): 多个限界上下文共享一部分模型。
      • 客户方-供应方 (Customer-Supplier): 一个限界上下文依赖于另一个限界上下文提供的服务。
      • 顺从者 (Conformist): 下游限界上下文完全遵循上游限界上下文的模型。
      • 防腐层 (Anti-corruption Layer): 在两个限界上下文之间引入一个中间层,以防止外部模型的变更影响到内部模型。
      • 开放主机服务 (Open Host Service): 一个限界上下文明确定义并暴露其服务接口。
      • 发布-订阅 (Published Language): 使用通用的、文档化的语言进行限界上下文之间的通信。
      • 分离方式 (Separate Ways): 两个限界上下文之间没有直接的集成。

战术设计 (Tactical Design)

战术设计关注的是如何在单个限界上下文内部构建领域模型。你需要弄清楚以下几点:

  1. 实体 (Entity):
    • 定义: 具有唯一标识符并且生命周期中会发生状态变化的对象。
    • 特点: 通过标识符来区分不同的实体,即使它们的属性值相同。
  2. 值对象 (Value Object):
    • 定义: 没有唯一标识符,通过其属性值来识别的对象。值对象是不可变的。
    • 特点: 相同的属性值代表相同的对象,通常用于描述事物的属性或特征。
  3. 聚合 (Aggregate):
    • 定义: 一组相关对象的集合,被视为一个独立的单元。聚合有一个根实体(Aggregate Root),外部只能通过根实体访问聚合内部的对象。
    • 目的: 维护数据的一致性和业务规则的完整性。
    • 设计原则:
      • 选择一个根实体作为聚合的入口。
      • 确保聚合内部事务的一致性。
      • 尽量减少聚合之间的引用。
  4. 领域服务 (Domain Service):
    • 定义: 当某个重要的业务逻辑不属于任何实体或值对象时,可以将其封装到领域服务中。
    • 特点: 通常是无状态的,专注于执行特定的业务操作。
    • 使用场景: 涉及多个实体或值对象的协作,或者执行与领域概念相关的操作,但不适合放在实体或值对象内部。
  5. 仓储 (Repository):
    • 定义: 提供一种抽象的方式来访问领域对象,隐藏底层数据访问的细节(例如数据库操作)。
    • 职责: 负责对象的持久化和检索。
    • 接口定义: 通常为每个聚合定义一个仓储接口。
    • 实现: 可以使用 ORM 框架或其他数据访问技术来实现仓储接口。
  6. 工厂 (Factory):
    • 定义: 用于创建复杂领域对象或聚合的机制,将对象的创建逻辑从客户端代码中分离出来。
    • 作用: 隐藏对象的创建细节,保证对象创建的正确性。
  7. 领域事件 (Domain Event):
    • 定义: 代表领域中发生的有意义的事件,用于在限界上下文内部或之间传递信息,实现松耦合。
    • 特点: 通常是过去时,描述已经发生的事情。
    • 作用: 促进不同模块或限界上下文之间的协作。

面试问题

什么是 DDD?它的核心价值是什么?

DDD 是一种以领域知识为驱动的软件设计方法。DDD 的核心价值在于:

请解释一下核心域、支撑子域和通用子域的区别和应用场景。

什么是限界上下文?如何划分限界上下文?

请解释一下共享内核、客户方-供应方和防腐层等上下文映射模式。

实体和值对象有什么区别?在什么情况下应该使用它们?

什么是聚合?聚合根的作用是什么?设计聚合时需要注意哪些方面?

什么是领域服务?它与实体方法有什么区别?

仓储的职责是什么?如何设计仓储接口?

仓储 (Repository) 充当领域模型与数据持久化机制之间的中介。设计仓储接口的关键在于:

什么是领域事件?它在 DDD 中有什么作用?

业务上重要的状态变更或发生的事实,可能触发领域内或其他界限上下文中的进一步行动。

其他

工作流 VS 规则引擎

维度 工作流 规则引擎
目的 管理和协调一系列任务或活动的执行顺序,聚焦流程自动化 执行预定义的业务规则,根据条件自动决定下一步动作,聚焦规则逻辑处理
核心概念 强调流程流转,确保任务按照既定顺序执行 强调规则匹配与执行,根据条件动态触发逻辑
技术实现 基于状态机或任务调度器,控制任务间顺序和依赖关系 基于规则库和推理引擎,采用条件-动作(if-then)模式
关键组件 流程设计器、任务队列、状态跟踪器 规则库、推理引擎、决策表/决策树
触发方式 事件驱动,任务间有明确的依赖关系 条件触发,与流程无关,仅关注规则是否满足
动态性 流程变更需要修改流程图或配置文件,动态性较弱 规则可动态添加、修改或删除、动态性强
应用场景 明确的固定流程,如审批流程、任务分发、跨部门协作流程 适合复杂且频繁变动的逻辑,如动态定价、风险评估、推荐系统
执行方式 按事先定义的步骤线性或并行执行,执行过程可见 根据条件匹配动态执行,规则逻辑分离,执行过程不可见
扩展性 对复杂业务支持有限,适合固定流程 支持复杂逻辑和频繁变化,适合动态业务需求
开源产品 Camunda、Activiti、JBPM、Apache Airflow Drools、Easy Rules、OpenRules、NxBRE

调优

影响系统响应慢的原因有哪些?如何排查和解决的?

  1. 慢SQL
  2. 系统负载过高
  3. Full GC 次数过高
  4. 程序异常,死锁等
  5. 网络原因

参考 Java问题排查

Tomcat 调优

WebSocket

JSR-356 规范,即 Javax WebSocket,定义了 Java 针对 WebSocket 的 API,主流的 Web 容器都已经提供了 JSR-356 的实现,例如说 Tomcat、Jetty、Undertow 等等。

目前提供 WebSocket 服务的项目中一般有几种方案:

一般非 IM 即时通讯项目,使用前两种都可以。具体参考:

断线重连

为何会断线?

如何判断在线、离线?

  1. 当客户端第一次发送请求到服务端是会携带唯一标识、时间戳,服务端到数据库/缓存中去查询请求的唯一标识,如果不存在就入库;
  2. 第二次客户端定时再次发送请求携带唯一标识、时间戳,服务端查询唯一标识,如果存在就把上次的时间戳拿出来,判断间隔毫秒数是否大于指定时间,小于就是在线,否则就是离线;

如何解决断线问题?

客户端心跳检测:
1. 客户端定时通过管道发送心跳,发送时启动一个超时定时器
2. 服务端接收到心跳包,应答一个pong
3. 如果客户端收到服务端的应答包,则说明服务端正常,删除超时定时器
4. 如果客户端的定时器超时依然没有收到应答包,说明服务端挂了

服务端心跳检测:
1. 客户端定时通过管道发送心跳
2. 服务端记录每个用户最后一次心跳时间,并配置一个心跳最大间隔时长
3. 开启一个定时任务,根据每个用户最后一次心跳间隔时间和最大间隔时长来判断用户是否断线
4. 遇到超过最大间隔时长的直接剔除会话

如何保证消息一定送达给用户?

基于滑动窗口 ACK 。整体流程如下:

这种方式,在业务被称为推拉结合的方案。此种方案客户端和服务端不一定需要使用长连接,也可以使用长轮询所替代。客户端发送带有消息版本号的 HTTP 请求到服务端。

线上故障

服务器监控哪些指标最需要关注?为什么?

故障1

事故情况:由于redis内存报警,导致接口失败率上升(没有配置redis拒绝策略,接口阻塞)。询问发现是上游同事进行了发布,于是迅速回滚,同时增大redis内存。
措施:让上游服务回滚;对redis扩容增大内存
事故原因:上游服务的对账系统调用自己的接口

  1. 没有考虑到对账数据范围(进行了全量对账,存在冷数据)
  2. 在业务高峰时期对账
  3. 自己redis没有设置合理的淘汰策略

故障2:OOM

事故情况:MQ消费者服务OOM,致使服务直接挂掉
措施:重启服务,先暂时恢复
事故定位:查看 prometheus 上的服务监控,从某时刻开始内存明显飙升 && dump内存快照

  1. 根据时刻判断业务操作,当时正在执行导入导出excel
  2. 服务没挂,使用jmap -dump:live,format=b,file=/tmp/dump.hprof PID
  3. 服务已挂:需提前配置 JVM 自动 dump-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/local/app/oom
  4. 打开 VisualVM 分析发现org.apache.poi.xssf.usermodel.XSSFSheet类的对象占用的内存是最多,与之前推测吻合。找到相关代码
  1. XSSFWorkbook wb = new XSSFWorkbook(new FileInputStream(file));
  2. XSSFSheet sheet = wb.getSheetAt(0);

解决:改用 SXSSFWorkbook

  1. XSSFWorkbook wb = new XSSFWorkbook(new FileInputStream(file));
  2. SXSSFWorkbook swb = new SXSSFWorkbook(wb,100);//使用SXSSFWorkbook包装,一次读入内存100条记录
  3. SXSSFSheet sheet = (SXSSFSheet) swb.createSheet("sheet1");
  4. //省略中间代码
  5. sheet.flushRows();

后续思考:使用 EasyExcel。MQ异步导入excel,并发量大的话仍然会出现OOM,有安全隐患,调整mq消费者线程池,设置成4个线程,避免消费者同时处理过多消息、

故障3:内存占用高

事故情况:有个服务内存使用率不断飙高,主要逻辑就是消费MQ的数据然后持久化
措施:重启应用,但无果
事故定位:查看监控数据发现老年代内存发生 GC 也一直居高不下,查看jstat日志发现老年代内存回收不了。尝
试在本地复现。

  1. 调小JVM内存,消费MQ数据处使用while循环不断生成数据
  2. VisualVM 连上应用实时监控内存、GC 的使用情况,跑十几分钟并未发现相同问题
  3. 检查代码逻辑,发现逻辑是批量一次几百条数据
  4. 修改模拟逻辑发现GC飙高,内存无法回收,问题复现
  5. dump堆内存使用情况,发现com.lmax.disruptor.RingBuffer对象内存占用很大
  6. 消费MQ数据一次取几百条数据扔给RingBuffer,考虑到扔给RingBuffer上的数据只有被覆盖了才会回收掉内存,检查RingBuffer配置1024*1024,目测是这的问题

解决:调小 Disruptor 的 RingBuffer 配置,调整到多少得根据业务具体情况配置

故障4:CPU飙高

事故情况:生产某个服务版本升级的时候,实例拉起后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就给够。有资源可以弹性的话可以使用

故障5:Netty内存泄露

事故情况:服务内存缓慢增长,CPU正常
措施:重启服务会好点,但依旧缓慢增长
事故定位:长时间的缓慢增长,说明GC回收不了,感觉像是内存泄漏

  1. 查看ERROR日志,发现有 LEAK 字样,ByteBuf 分配的堆外内存,GC不能释放

    ByteBuf.release() 在垃圾回收之前没有被调用。启用高级泄漏报告以找出泄漏发生的位置。要启用高级泄漏报告,请指定 JVM 选项“-Dio.netty.leakDetectionLevel=advanced”或调用 ResourceLeakDetector.setLevel()

  2. 本地复现,调整非堆内存大小-XX:PermSize=30M-XX:MaxPermSize=43M,用Postman定时批量发请求,以达到服务的堆外内存泄漏。

  3. 开启Netty的高级内存泄漏检测级别:-Dio.netty.leakDetectionLevel=advanced,查看泄露日志
  4. 定位具体代码。

解决办法

亮点

Excel 导入导出

使用MQ导出

详细参考:百万级数据excel导出功能如何实现?
导入功能比导出麻烦点,需要各种校验,留出接口方便业务校验扩展

多平台统一的消息推送系统

Server服务主要维护客户端的长连接,它能力决定了一次可以在线多少用户。需要集群部署。
Route服务负责将消息分发到各个平台

客户端连接Server服务的流程

  1. 客户端问路由服务要一个可用的Server服务器IP+端口
  2. 路由服务通过合适的负载均衡算法得到一个Server服务器ip+端口,返回给客户端
  3. 客户端向拿到的Server服务发起长连接
  4. 连接成功后,对应的Server服务器维护服务级别的session信息,然后向路由服务汇报,路由服务保存该客户端和Server服务的对应关系
  5. 客户端与对应的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环形队列做异步处理,尽可能让消息推送接口更快返回。如果并发量比较高,可以加入消息队列,路由服务直接消费消息,以此来提升服务整体性能。

系统设计(System design)

网关系统

参考:https://mp.weixin.qq.com/s/tl4D4Kc7r4wL7aouQYZC4g

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注