Skip to content

面试煎熬成蛋_面试速刷版_第二版

作者简介:⼤家好,我是 洛亓,Java摆烂选⼿一枚,很⾼兴认 识⼤家 CSDN/掘⾦/B站: 轻松的洛亓 实时更新地址:暂无 如果感觉博主的⽂章还不错的话,请点赞⽀持⼀下哦

基础

双层循环中的作用 continue、break、return 的区别

  • continue:只会影响当前所在的内层循环,继续内层循环的下一次迭代
  • break:只会影响当前所在的内层循环,跳出内层循环。
  • return:终止整个方法的执行,直接返回到方法调用点。

示例:

for (int i = 0; i < 5; i++) {
    for (int j = 0; j < 5; j++) {
        if (j == 2) {
            continue; // 结束当前内层循环的当前迭代,继续下一次迭代
        }
        System.out.println("i = " + i + ", j = " + j);
    }
}


for (int i = 0; i < 5; i++) {
    for (int j = 0; j < 5; j++) {
        if (j == 2) {
            break; // 终止内层循环
        }
        System.out.println("i = " + i + ", j = " + j);
    }
}

public void exampleMethod() {
    for (int i = 0; i < 5; i++) {
        for (int j = 0; j < 5; j++) {
            if (j == 2) {
                return; // 终止方法执行,返回到调用点
            }
            System.out.println("i = " + i + ", j = " + j);
        }
    }
    System.out.println("This will not be printed if j == 2");
}

public static void main(String[] args) {
    new MyClass().exampleMethod();
    System.out.println("Method has returned");
}

String 为什么是不可变的

  • String 类内部使用 private final char[]private final byte[] 来存储字符串数据。
  • 好处
    • 防止被恶意更改
    • 作为HashMap的key可以保证不可变性
    • 可以实现字符串常量池,在Java中,创建字符串对象的⽅式
      • 通过字符串常量进⾏创建
        • 在字符串常量池判断是否存在,如果存在就返回,不存 在就在字符串常量池创建后返回
      • 通过new字符串对象进⾏创建
        • 在字符串常量池中判断是否存在,如果不存在就创建, 再判断堆中是否存在,如果不存在就创建,然后返回该 对象,总之要保证字符串常量池和堆中都有该对象

是不是 String 使用 final 修饰 ,它才是不可变的;被 final 修饰的常量,是不可变的吗

当一个变量被 final 修饰时,它是不可变的,即它的引用不能被改变,但如果这个变量是一个对象的引用,那么对象本身还是可以被改变的(除非这个对象本身也是不可变的)

final 关键字用于修饰变量、方法和类,其作用如下:

  1. 修饰变量:表示该变量只能被赋值一次,赋值后不能被重新赋值。
  2. 修饰方法:表示该方法不能被子类重写。
  3. 修饰类:表示该类不能被继承。

String 类本身是不可变的,这是因为:

  1. String 类是由 final 修饰的类,因此不能被继承。
  2. String 类中的字符数组 value 是私有的,并且没有提供任何修改字符数组的方法。
  3. String 类的方法不会修改字符串对象本身,而是返回一个新的字符串对象。

集合

  • 常见的一些问题
    • 主要还是围绕 HashMap 去问的,相关的 put 流程和扩容机制需要掌握
  • 概念前提
    • ArrayList的数组默认⻓度10,1.5倍数组扩容,扩容通过开辟新数 组进⾏转移⽼数组元素
    • HashMap、ConcurrentHashMap的数组默认⻓度16,2倍数组扩 容,扩容通过开辟新数组进⾏转移⽼数组元素
  • 核⼼思想
    • 读写分离
      • CopyOnWriteArrayList底层实现、处理快速失败
    • 分散热点
      • ConcurrentHashMap底层addCount计算
    • 索引优化
      • HashMap/ConcurrentHashMap 从数组->链表->红⿊树
  • 考察重点
    • 集合底层实现以及设计思想
  • 注意点
    • 对于存放⼤量元素到ArrayList 或 HashMap中时,建议提前初始化 好容量避免扩容导致性能损耗

成员关系

image.png

HashMap

  • 1.7 头插法会导致循环链表问题
    • 由于每次插入都会改变链表的顺序,这可能会导致长时间的性能下降
  • 1.8 改为了尾插法,在并发修改时减少了循环链表出现的风险

image.png

  • 头插
  • 尾插
  • 扩容

HashMap 的 put 流程讲一下

  • 1、计算数组下标:首先基于键(Key)的 hash 值计算得到数组下标。
  • 2、如果数组下标为空
    • 如果下标位置为空:
      • 在 JDK 7 中,会将键值对(K-V)封装成 Entry 对象。
      • 在 JDK 8 中,会将键值对封装成 Node 对象。
    • 然后将该对象放到数组对应的槽位中。
  • 3、如果数组下标不为空
    • 如果下标位置不为空:
      • JDK 7:
        • 先判断是否需要扩容,如果需要扩容就先进行扩容。
        • 然后采用头插法将新的节点插入当前槽位的链表上。
        • 如果键相同,就更新对应的值(Value)。
      • JDK 8:
        • 不会先判断是否需要扩容,而是先判断节点类型(链表节点或红黑树节点)。
        • 如果是链表节点:
          • 将键值对封装成链表节点,采用尾插法插入当前槽位的链表上。
          • 如果键相同,就更新对应的值(Value)。
          • 当数组长度达到 64 且链表长度达到 8 时,会将链表转化为红黑树以提升查询速度。
        • 如果是红黑树节点:
          • 将键值对封装成红黑树节点,放到红黑树上。
          • 如果键相同,就更新对应的值(Value)。
          • 当红黑树节点数减少到 6 时,会退化成链表

HashMap 的 get 流程

  1. 计算数组下标
    • 基于键(Key)的 hash 值计算得到数组的下标。
  2. 查找元素
    • 遍历下标位置的每个槽位(链表/红黑树)。
    • 通过 equals 方法查找与键相同的元素。

HashMap 的扩容流程 🚩

参考: https://www.bilibili.com/video/BV14r4y1R7e6

扩容操作:

  • 1、每次扩容是两倍
  • 2、当元素个数 > 槽位数组长度 * 负载因子,进行扩容操作
  • 3、JDK 7 是数组 + 链表,JDK 8 是 数组 + 链表 + 红黑树,JDK7 链表是头插法,JDK8 是尾插法
  • 4、resize 的时候会重新哈希计算,红黑树的话会进行拆分操作,当当数组长度达到 64 且链表长度达到 8 时,会将链表转化为红黑树,当红黑树元素个数减少到 6 时,会退化成链表
  • JDK 7 头插法存在并发情况下循环链表问题,JDK8 通过尾插进行解决。

HashMap 的扩容实质上是数组的扩容,会重新开辟一份容量为原数组两倍的新数组,并将老数组的元素转移过来。

  • 扩容条件
    • 当插入新元素后,HashMap 中元素的数量超过当前数组容量的负载因子(默认是 0.75)时,HashMap 会触发扩容
  • 扩容过程
    • JDK7
      • 新数组容量为原数组容量的两倍。
      • 将老数组中的元素重新计算哈希值,得到新数组的下标。
      • 将元素放到新数组对应槽位的链表上。
      • 扩容的主要目的是解决链表过长的问题
    • JDK8
      • 新数组容量为原数组容量的两倍。
      • 重新计算老数组中元素的数组下标。
      • 红黑树槽位:
        • 通过低位和高位(老位置的元素和新位置的元素)拆分成两个子链表,存放到不同的槽位。
        • 当数组长度达到 64 且链表长度达到 8 时,会将链表转化为红黑树。
      • 链表槽位:
        • 操作与 JDK 7 相同,将元素放到新数组对应槽位的链表上

低位和高位的概念

在扩容过程中,重新计算数组下标时,会出现两种情况:

  1. 低位:元素在新数组中的位置与在旧数组中的位置相同。
  2. 高位:元素在新数组中的位置是旧数组位置加上旧数组的容量。

这两种情况取决于元素的哈希值与旧数组容量进行按位与操作的结果:

  • 低位:index = oldIndex
  • 高位:index = oldIndex + oldCapacity

ConcurrentHashMap

ConcurrentHashMap 是一种线程安全的高效Map集合

  • ConcurrentHashMap
    • 底层数据结构
      • 1.7:底层采用分段 Segment,每一个 Segment 是由 数组 + 链表实现
      • 1.8:采用的数据结构跟 HashMap 1.8 的结构一样,Node 数组 + 链表/红黑二叉树
    • 加锁的方式
      • 1.7:采用 Segment 分段锁,底层使用的是 ReentrantLock
      • 1.8:采用 CAS 添加新节点,采用 synchronized 锁定链表或红黑二叉树的首节点,相对 Segment 分段锁粒度更细,性能更好
    • 新增元素(1.8)
      • hash(key) → 在该数组节点下通过 CAS 操作新增元素操作
      • 采用 synchronized 锁定链表或红黑二叉树的首节点

加锁程度

  • 1.7 通过对Segment加锁实现,每个 Segment 有自己的锁
  • 1.8 对于链表的修改操作,通过synchronized关键字对 Node 进行加锁;对于新增操作,如果需要更新链表的头部,使用CAS操作来确保原子性。当链表长度达到一定阈值后,链表会转换为红黑树,提高搜索效率

1.7 中使用了分段锁的机制,通过对于 HashEntry 数组进行加锁,每一个 Segment 元素都是使用的 ReentrantLock ,用于在高并发环境下对于集合的线程安全保护。

同时为了 hash 冲突的解决,这里是通过拉链法的方式进行解决的。

image.png

锁粒度更细

image.png

Deque/Queue

  • Queue是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循先进先出(FIFO)规则。

  • Deque是双端队列,在队列的两端均可以插入或删除元素。

SortedSet/TreeSet

  • TreeSet 是 SortedSet 的实现类
  • 一般是两种排序方式:一种是自然排序(实现 Comparable 接口,重写 compareTo 方法):还有一种是自定义比较器(Comparator)

Java集合_基础概念#四、Set

TreeSet :TreeSet 是通过 TreeMap 来实现的(内部结构)

CopyOnWriteArrayList

CopyOnWriteArrayList 的底层原理是什么样的

  • CopyOnWriteArrayList 是 Java 中的一种线程安全的 List 实现,它通过在每次修改操作(如添加、删除、更新)时,创建一个新数组来保证线程安全。这种设计使得它非常适合读多写少的场景
  • CopyOnWriteArrayList 内部使用一个 volatile 修饰的数组来存储数据,这个数组确保了内存可见性
  • 所有的读取操作都是基于这个 volatile 数组的,由于数组被 volatile 修饰,保证了读取操作的最新性和线程安全性。读取操作不会进行加锁,因为读取时不会改变数组的状态。
  • 写入操作是 CopyOnWriteArrayList 的核心,它通过对数组进行复制来实现线程安全
    • 加锁:写入操作需要加锁以确保线程安全。
    • 复制数组:在进行修改操作前,首先复制一份当前数组。
    • 修改数组:在复制的数组上进行修改操作。
    • 替换数组:将修改后的数组替换掉原来的数组

ThreadLocal

  • ThreadLocal主要是保存线程变量副本的,⼀般可以⽤来缓存⽤户信 息、连接等,通过保证每个线程变量隔离来实现线程安全
    • ThreadLocal 会对线程维护⼀个以 ThreadLocal 作为 Key 的 Map(ThreadLocalMap,Value对应的是当前线程变量的副本,Key 是弱引⽤所以GC时会回收,但是Value是强引⽤,所以如果不⼿动 清除,Value会⼀直霸占⽼年代空间导致内存泄露(ML)
  • 对于ThreadLocal只能保证线程内部的通信,如果想要进⾏⽗⼦线程 通信,可以使⽤InheritableThreadLocal,它实现原理是⽗线程在创建 ⼦线程时会将当前的变量副本拷⻉⼀份给⼦线程,但是 InheritableThreadLocal遇到线程池就不能完成⽗⼦通信了,因为线程 池⼀旦创建完线程就会进⾏复⽤,所以可以使⽤ TransmittableThreadLocal,它实现原理就是在线程池中不管线程是 否是新建都会在调⽤时抓取⽗线程的上下⽂给⼦线程
  • 另外 ThreadLocal 内部 ThreadLocalMap 使⽤线性探测法实现的哈希 表,所以⾯临⼤量线程绑定数据时性能较低,所以 Netty 引出了 FastThreadLocal,底层直接通过更⼤数组空间的开辟,通过数组索引 进⾏定位,避免线性探测,采⽤⽤空间换时间,并且 FastThreadLocal 在任务执⾏完之后会对Value清除避免内存泄露
    • 哈希冲突的解决⽅法
      • 拉链法
      • 红⿊树
      • 线性探测法
        • 寻找最靠近的下⼀个空槽

遍历集合你一般使用什么方式

  • 增强 for 循环
  • 迭代器
  • for 循环
  • stream

并发

阻塞队列长度设置

  • 如果任务执行时间较长,建议设置较大的队列长度,以防止过多的任务被拒绝。

什么是 CAS

  • 比较并交换

线程池中,如果一个线程没有捕获异常,发送异常后会发生什么情况

  • 当线程在执行任务时抛出未捕获的异常,该线程会立即终止。这意味着任务没有正确完成,并且该线程将不再继续执行后续代码。

  • 线程池(如 ThreadPoolExecutor)有一个内部机制来捕获线程抛出的异常。默认情况下,线程池会记录这个异常,但不会重新抛出或进行其他特殊处理。任务的提交者(调用 submit 方法的线程)不会直接感知到异常,除非通过 Future 获取任务的执行结果。

  • 线程池会创建一个新的线程来替换抛出异常并终止的线程,从而保证线程池中的线程数量维持在配置的核心线程数或最大线程数。线程池本身不会因为一个线程的异常而崩溃或停止工作。

  • 使用 Future.get() 可以捕获任务抛出的异常

可见性

volatile 保证

image.png

原子性

  • 这里 synchronized 的描述优点问题,是先有 偏向锁,再有轻量级锁,再有自旋锁,然后是重量级锁
  • 如果问 AQS 的话,其实是有一个 acquire 方法之类的进行加锁和放锁的操作

image.png

  • 改一下:原子性一般是线程通过对资源加锁的方式来实现的,这里可以引入 synchronized 和 ReetrantLock 的相关内容

  • synchronized 的重量级锁

线程状态

image.png

阻塞:没有获取到资源(没有获取到锁)

  • 线程状态

线程池

  • 使用场景

    • 1、异步
    • 2、记录日志
    • 3、定时任务
  • 线程池的核心线程数可以设置为 0 (通过非核心线程去处理任务)

  • 阻塞队列

    • Array 单个锁
    • Linked 两个锁,加锁

image.png

callable 接口回调:

FutureTask 提供了 Future 接口的基本实现,常用来封装 Callable 和 Runnable,具有取消任务、查看任务是否执行完成以及获取任务执行结果的方法

为什么不建议使用 Execution 创建线程池

Executors 返回线程池对象的弊端如下(后文会详细介绍到):

  • FixedThreadPoolSingleThreadExecutor:使用的是无界的 LinkedBlockingQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。
  • CachedThreadPool:使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。
  • ScheduledThreadPoolSingleThreadScheduledExecutor : 使用的无界的延迟阻塞队列DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。

说白了就是:使用有界队列,控制线程创建数量。

除了避免 OOM 的原因之外,不推荐使用 Executors提供的两种快捷的线程池的原因还有:

  • 实际使用中需要根据自己机器的性能、业务场景来手动配置线程池的参数比如核心线程数、使用的任务队列、饱和策略等等。
  • 我们应该显示地给我们的线程池命名,这样有助于我们定位问题

线程池参数定义

核心线程数

  • IO密集型: CPU核数 * 2
  • CPU计算性:CPU核心 + 1

最大线程数

  • 一个常见的做法是设置最大线程数为核心线程数的2到4倍

阻塞队列

  • 队列大小的设置取决于你期望的任务处理模式。如果希望能够缓冲大量的请求,那么可以设置较大的队列。如果希望减少等待时间,可以设置较小的队列
  • IO密集型: 设置多一些(会有网络阻塞等,等待线程可能会多)
  • CPU计算性:可以设置小一点(任务可以迅速完成,不需要太大的等待队列)

理解有一个问题改一下(线程池的执行流程)

  • 1、提交任务
  • 2、核心线程处理任务
  • 3、核心线程忙碌,放入新任务到阻塞队列
  • 4、阻塞队列满了之后,再来一个任务,线程池会创建一个非核心线程去处理新的任务。当非核心线程完成它们的任务后,如果阻塞队列中还有待处理的任务,这些非核心线程也会从阻塞队列中取任务来执行。
  • 5、如果 核心线程数 + 非核心线程数 > 最大线程数,才会进行触发拒绝策略

拒绝策略方式

  • 1.AbortPolicy:直接抛出异常,默认策略:
  • 2.CallerRunsPolicy:用调用者所在的线程来执行任务;
  • 3.DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务:
  • 4.DiscardPolicy:直接丢弃任务:

image.png

线程计算公式

image.png

假设:核数 12、CPU 利用率 90%,等待时间 50ms,计算时间51ms

N = 12 * 0.9 * (1 + 50/51) ≈ 22

设置核心线程数 22 是比较符合预期的值

在真实的程序中,一般很难获得准确的等待时间和计算时间,因为程序很复杂,不只是“计算”。一段代码中会有很多的内存读写,计算,I/O 等复合操作,精确的获取这两个指标很难,所以光靠公式计算线程数过于理想化。

image.png

死锁

线程死锁:通过 JVM 排查工具去排查到具体原因,然后定位现象

MySQL:一般有监控信息,可以看到,看到后进行处理,通过现象分析死锁原因,然后去进行事务或者SQL的优化

使用 synchronized 修饰类和方法的区别

  • synchronized修饰代码块(类锁)
    • 当使用synchronized修饰代码块时,需要指定一个锁对象。如果指定的是类的Class对象,那么这个synchronized代码块就相当于对这个类进行了锁定。
  • synchronized修饰方法
    • synchronized修饰一个实例方法时,
      • 它锁定的是当前实例对象。
      • 这意味着,同一时间内,只有一个线程能够访问这个对象的所有synchronized实例方法。如果一个线程访问该对象的一个synchronized实例方法,其他线程试图访问该对象的任何其他synchronized实例方法将会被阻塞,直到第一个线程完成方法的执行。
    • synchronized修饰一个静态方法时,
      • 它锁定的是这个类的所有实例。因为静态方法是属于类的,而不是任何实例对象的,所以此时锁定的是类的Class对象本身。这意味着,同一时间内,只有一个线程能够访问这个类的所有synchronized静态方法。

死锁问题如何排查定位

  • 排查定位:使用线程转储、监控工具和日志分析来识别死锁。
  • 解决:通过调整锁顺序、使用 tryLock、破坏死锁条件等方法来解决死锁问题。
  • 避免:避免嵌套锁定、使用超时锁定、采用高层次锁定机制、排序和定期检测等方法来避免死锁。

tryLock:ReentrantLock 的加锁方法,一般为了避免死锁可以用它并设置下超时时间

如何排查:一般需要查看当前死锁的进程是哪个,然后通过 jstack 之类的操作进行生成转储文件,通过 VisualVM 工具之类的进行查看,生产情况下可能需要通过适当时间重启并优化程序的方式来进行解决;

如何避免:可以使用数据库锁、分布式锁来避免代码的显示问题

JVM

类加载

  • 类加载过程
    • 加载、验证、准备、解析、初始化
  • 类加载器
    • 引导类加载器 (Bootstrap ClassLoader):加载 JRE 核心库,jre/lib 目录下的包。
    • 拓展类加载器 (Extension ClassLoader):加载扩展库,jre/lib/ext 目录下的包。
    • 应用程序类加载器 (Application ClassLoader):加载用户类路径下的包。
    • 自定义类加载器 (Custom ClassLoader):加载自定义路径下的包,开发者可以通过继承 ClassLoader 类实现。
  • 双亲委派机制
    • ⾃上⽽下的类加载过程,⾸先会委托⽗加载器去 加载⽬标类,如果能找到⽬标类就加载,如果找不到就继续委托它的 ⽗加载器去加载,如果⽗加载器都加载不了,就⾃⼰去加载
  • 如何打破双亲委派机制
    • 可以继承ClassLoader,重写 loadClass 以及 findClass ⽅法
      • loadClass主要使⽤来加载类的,findClass主要是⽤来寻找⽬标类 的

JVM 运行区区域

  • 虚拟机栈
  • 本地方法栈
  • 程序计数器
  • 方法区
    • JDK8 本地内存 → 元空间 → 方法区
    • JDK8 之前 → 堆中(永久代) → 方法区

在1.8 中,运行时常量池是放置在哪个区域? 🚩

在Java 8中,字符串常量池已经被移动到了堆内存中,以提高性能和减少内存溢出的风险。

运行时常量池除了字符串常量外的部分,仍然存储在方法区中,但方法区的实现已从永久代变更为元空间

image.png

栈上分配 💣

栈上分配是一种优化技术,用于减少堆内存分配和垃圾回收的压力。通常情况下,通过 new 关键字创建的对象都是分配在堆上的,但在某些情况下,JVM 可以通过逃逸分析(Escape Analysis)将一些临时对象分配到栈上。这意味着这些对象会随着栈帧的出栈而被销毁,从而减少对垃圾回收器(GC)的压力。

逃逸分析

逃逸分析是一种静态代码分析技术,用于确定对象的引用范围,即对象是否会逃逸出方法或线程。根据逃逸分析的结果,JVM 可以对对象进行不同的优化处理。

  • 方法逃逸:如果对象被返回或传递到方法外部,则认为它逃逸出了方法。
  • 线程逃逸:如果对象被共享到其他线程中,则认为它逃逸出了当前线程。

如果对象没有逃逸出方法或线程,JVM 可以进行进一步优化,比如栈上分配或标量替换。

标量替换

标量替换是逃逸分析优化的一部分。它将对象的成员变量拆分为单独的标量(例如,基本类型或引用),而不是将整个对象分配到堆上。通过这种方式,可以避免不必要的对象创建。

栈上分配的原理

  • 对象分配到栈上:如果通过逃逸分析确定对象没有逃逸出方法,则可以将该对象分配到栈上。栈上分配的对象在栈帧出栈时自动销毁,不需要GC。
  • 减少GC压力:栈上分配避免了临时对象在堆上的分配和回收,从而减轻了GC的压力,提高了性能。

判断垃圾回收

  • 引用计数法
  • 可达性分析算法

垃圾回收算法

  • 标记整理
  • 标记清除
  • 复制

对象的分配策略

在 Java 虚拟机(JVM)中,对象的分配和回收策略有助于提升内存管理效率和系统性能。以下是 JVM 对象分配的具体策略:

  1. 对象优先在 Eden 区进行分配
  • 新对象分配:新创建的对象会被分配到 Eden 区。当 Eden 区内存不足时,会触发 Minor GC。
  • Minor GC 后对象移动:每次 Minor GC 后,存活的对象会被移动到 Survivor 区的一个区域。在下一次 Minor GC 时,Eden 区和 Survivor 区存活的对象会被移动到 Survivor 区的另一个区域,同时对象的分代年龄会增加 1。
  • 对象晋升:当对象分代年龄达到一定次数后(默认 15 次,CMS 为 6 次),对象会被晋升到老年代。如果老年代使用空间达到阈值,会进行 Full GC。如果 Full GC 无法回收足够的内存,则会导致内存溢出(OOM)。
  1. 大对象直接进入老年代
  • 大对象处理:由于复制算法对大对象的来回复制会带来性能开销,JVM 可以设置一个阈值,当新创建的对象大小超过这个阈值时,会直接分配到老年代。
  1. 长期存活的对象进入老年代
  • 分代年龄机制:对象在年轻代存活一定次数(分代年龄)后,会被晋升到老年代。
  • 动态年龄判断机制
    • 机制:经过 Minor GC 后,如果 Survivor 区中存活的前 N 代对象的内存总和超过 Survivor 区内存的一半,会将 N 代及以上的对象直接移动到老年代。
    • 目的:尽可能将长期存活的对象直接进入老年代,避免频繁的 GC。
  1. 老年代空间分配担保机制
  • Minor GC 前的计算:在每次 Minor GC 之前,JVM 会计算老年代剩余可用空间。
    • 触发 Full GC 条件
      1. 如果老年代剩余可用空间小于年轻代所有对象的内存,会触发 Minor GC。
      2. 如果老年代剩余可用空间不小于年轻代所有对象的内存,会检查是否配置了担保参数。
      3. 如果配置了担保参数,并且老年代剩余可用空间小于历史上每次 Minor GC 进入老年代对象的内存大小,会触发 Full GC。
      4. 如果老年代剩余可用空间不小于历史上每次 Minor GC 进入老年代对象的内存大小,则触发 Minor GC。
      5. 如果没有担保参数,直接触发 Full GC。

垃圾收集器 🚩

  • Serial 、Serial Old
    • Serial 和 Serial Old 是 单线程垃圾收集器,在GC时,只允许⼀个 线程进⾏
    • Serial ⽤在年轻代采⽤的是 复制算法、Serial Old ⽤在⽼年代采⽤ 的是 标记整理算法
    • 在单核处理器的情况下,简单⾼效,但是多核处理器下⽆法发挥 多核的性能不推荐使⽤,适合 100M以内 内存
  • Parallel、Parallel Old
    • Parallel 和 Parallel Old 是 多线程垃圾收集器,是serial系列的多线 程版本
    • Parallel ⽤在年轻代采⽤的是 复制算法,Parallel Old采⽤的是 标 记整理算法
    • 关注点在于吞吐量,⽐较适合CPU密集型场景,⼀般 4G以下 内存 推荐使⽤
  • ParNew 、CMS
    • ParNew 与 Parallel类似,只是为了配合 CMS 才出现的
    • ParNew ⽤在年轻代采⽤的是 复制算法, CMS ⽤在⽼年代采⽤的 是 标记清除算法
    • CMS关注点是最⼤停顿时间,也就是**⽤户的体验度,⽐较适合 4~8G 内存的情况使⽤**
  • G1
    • 分区模型

CMS

  • 初始标记 → 并发标记 → 重新标记 → 并发清理 → 并发重置

  • 并发标记会产生多标、漏标问题,漏标主要的解决方案是三色标记

  • CMS的运作步骤

    • 初始标记
      • STW,从GC Root出发,只标记直接引⽤对象(不包含内部 成员变量相关的间接引⽤对象)
    • 并发标记
      • 从GC Root的直接引⽤对象出发,遍历整个对象图进⾏标 记,耗时较⻓,由于⽤户线程和GC线程都在运⾏着,所以 会有多标、漏标的问题
      • 多标
        • 多标 就是本应该是垃圾对象,但是由于⽤户线程还 在运⾏,所以没来及去清除标记
      • 漏标
        • 漏标 就是 新来的对象引⽤了GC Root链上的对象, 但是由于⽤户线程还在运⾏,没来得及标记为⾮垃 圾,被GC误清除
        • 漏标 的处理⽅案主要是 三⾊标记算法,主要分为 增量更新 和 原始快照
          • 三⾊标记主要是分为三种颜⾊,分别是⿊⾊、⽩ ⾊、灰⾊
            • ⿊⾊对象 表示 当前对象的引⽤对象图都扫描 完了
            • 灰⾊对象 表示 当前对象的引⽤对象图只扫描 了⼀部分
            • ⽩⾊对象 表示 当前对象的引⽤对象图没扫描
          • 增量更新 是通过 记录下⿊⾊对象新增的⽩⾊对 象引⽤关系,将⿊⾊对象回退到灰⾊对象,重新 深度扫描⼀次
          • 原始快照 是通过 记录下灰⾊对象删除的⽩⾊对 象的引⽤关系,以灰⾊对象为根简单扫描⼀下, 将⽩⾊对象标记为⿊⾊对象,当作浮动垃圾处 理,等待下⼀轮GC
            • 浮动垃圾
              • 浮动垃圾 就是在 并发标记 和 并发清理 阶 段产⽣的垃圾,对GC最终效果影响不 ⼤,只要等待下⼀轮GC处理就⾏
    • 重新标记
      • STW,对 并发标记 过程中产⽣状态改变的对象进⾏修正, 这⾥对于 漏标 的问题采⽤的是 三⾊标记算法 中的 增量更 新 来做的重新标记
    • 并发清理
      • 对未标记的对象进⾏清理,这⾥因为没有进⾏STW,所以 对于新增对象会被标记为⿊⾊对象
    • 并发重置
      • 将对象的标记位进⾏重置,进⾏下⼀轮GC

触发 Full GC 的一些情况 🚩

哪些对象可以作为 GC Root ?

  • 虚拟机栈中(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNI 引用的对象

MySQL

事务的四大特性

  • 原子性
    • 要么全部成功,要么全部失败
  • 一致性
    • 数据在事务的开始到结束的过程中要保持⼀致
  • 隔离性
    • 每个事务要互相隔离
  • 持久性
    • 事务完成后数据会被持久化下来

脏写、脏读、不可重复读、幻读

  • 脏写
    • 操作同⼀数据时,⼀个事务的修改被另外⼀个事务的修改覆盖
  • 脏读
    • ⼀个事务读取了另⼀个事务尚未提交的数据时
  • 不可重复读
    • 同⼀事务下,由于其他事务的修改导致同⼀数据被读取两次的结 果不⼀致
  • 幻读
    • ⼀个事务读取到另外⼀个事务已提交的新增数据

事务的隔离级别

  • 读未提交
  • 读已提交
  • 可重复读
  • 串行化
    • 解决幻读
    • 所有读操作后⾯加读锁 lock in share mode,因为读锁、写锁 互斥,所以不能同时发⽣

常见的索引结构

  • 二叉树
  • 红黑树
  • 哈希表
  • B-Tree
  • B+Tree

索引类型

聚簇索引和⾮聚簇索引

MySQL 的性能优化

  • 1、建表,字段
    • 选择合适的字段和属性,尽量遵循范式原则,尽量选择合适索引
  • 2、业务字段设计
    • 业务方面,违反范式,避免连表查询
  • 3、查询SQL优化
    • 添加索引:选择区分度比较高的列,建立唯一索引和联合索引等
    • 注意事项
      • 写 SQL 的时候避免索引失效
      • 尽量避免使用子查询,而使用连接查询
      • 修改语句中经常作为判断的话考虑添加索引,避免行锁
  • 4、架构优化
    • 主从集群,读写分离
    • 读写多的表,进行水平分表或者垂直分表
    • 考虑使用缓存
    • 集群优化

MySQL的执⾏计划怎么看

  • MySQL的执⾏计划可以通过explain关键字去查看,主要看type、 key_len、extra
    • type 会显示关联类型,⼀般优化到range就可以了
      • system > const > eq_ref > ref > range > index > all
    • key_len 会显示索引列占⽤的字节数,可以辅助判断⾛了哪些索 引列
    • extra 会显示解析查询的额外信息,⽐如临时表、⽂件排序等
  • 对于索引优化,当遇到 type 为 all 时,说明我们需要添加索引或优化索 引,对于整个优化过程要多次使⽤explain去分析

索引失效

  • 对于组合索引 没有⾛ 左前缀原则 会导致索引失效
  • 进⾏like模糊查询时,%放在索引字段之前⾛不了索引下推会导致索 引失效,倘若数据量过⼤,索引下推会失效
  • 查询条件的类型为字符串时,没有加单引号,可能因为类型不同导致 索引失效
  • 对索引列进⾏计算会导致索引失效
  • 查询条件使⽤or连接会导致索引失效
  • 判断索引列不等于某个值时会导致索引失效

索引优化原则

  • 不要基于使⽤频率较低的列加索引
  • 组合索引列、排序要遵循 左前缀原则
  • where 和 order by冲突时优先使⽤where,能⽤where过滤就不要 使⽤having
    • 不要对索引列进⾏运算,⽐如使⽤函数
      • MySQL 8/Oracle⽀持函数索引,但是数据量过⼤时,索引 列维护成本较⾼
  • 不要⽤or连接索引列
    • 对于like的使⽤,尽量使⽤右模糊匹配,让索引可以⾛索引下 推
      • %A%、%A不⾛索引
      • A%⼀般情况下可以⾛索引下推,但是数据量过⼤时会导致 失效
  • 索引列判空,⽐如is null、is not null不⾛索引
    • join要做到小表驱动⼤表
      • left join 左⼩右⼤
      • right join 右⼩左⼤
      • inner join 优化器会处理
    • in、exists要做到小表驱动⼤表
      • in 适合 ⼦表小于主表的,exists反之
  • 索引列建议都设置为not null,可以节省1字节
  • 对于获取总数count优先使⽤count( * ) 或 count(1),如果数据量过 ⼤建议采⽤ES进⾏记录
    • 倘若count字段有索引,count(* ) = count(1) > count(字段) > count(主键)
      • count(字段)效率⽐count(主键)好的原因是⾛了覆盖索引, ⼆级索引树⽐主键索引树数据量⼩
    • 倘若count字段⽆索引,count( * ) = count(1) > count(主键) > count(字段)
  • 频繁增删改的字段不要加索引,数据量⼀⼤维护成本会很⾼
  • ⻓字符串可以采纳左20字符作为索引,满⾜90%场景
  • 查询字段时,能采⽤覆盖索引的地⽅就去使⽤,避免查全部字段

流程

  • 根据业务完成sql代码之后,可以先评估有哪些字段需要索引,尽 量使⽤组合索引
  • 后续根据线上 skywalking/grafana/监控告警/耗时⽇志 去观察接 ⼝以及定位慢SQL,再通过explain⼯具以及DBA建议进⾏优化, ⼀般情况下都能处理,如果索引优化已经满⾜不了业务场景了, 可以使⽤ES作为查询⼯具(将数据的关键字段 通过MQ + canal/ogg 同步到ES)

SQL 的执行流程

  • 客户端 通过 连接器 进⾏权限验证,⽼版本的先去判断有没有开启缓 存,如果开启了,先从缓存中查询数据返回,如果没有开启,就通过 词法分析器去 进⾏词法分析,然后通过 优化器 去进⾏SQL优化,然 后再通过 执⾏器 去选择 存储引擎 去进⾏SQL执⾏
    • 客户端 -> (缓存) -> 词法分析器 -> 优化器 -> 执⾏器 -> 存储引擎

分库分表

日志 🚩

  • undolog
    • 数据的快照⽇志,可以⽤于数据回滚
    • 基于MVCC机制实现,通过创建多个版本的数据,从⽽使并发事务 互不影响,对于每个事务都会对应⾃⼰的版本链,读写操作都是 基于各⾃的版本去进⾏
    • undo log innerdb 储存引擎层面的日志,提供回滚和多版本并发控制下的读(MVCC);
  • redolog
    • InnoDB级别的,主要⽤于BufferPool数据恢复的
    • redo log 数据备份和数据提交
  • binlog
    • 会记录所有操作的⽇志,可⽤于数据归档
    • binlog 是 MySQ 服务层维护的一种二进制日志,主要做主从复制、数据恢复和备份

redo log 与 binlog 的区别

  • redo log 是在 InnoDB 存储引擎层产生,而 binlog 是 MySQL 数据库的上层产生的
  • 写入磁盘的时间点不同,binlog 在事务提交完成后进行一次写入。而 redo log 在事务进行中不断地被写入
  • binlog 在写满或者重启之后,会生成新的 binlog 文件,redo log 是循环使用

正常我们使用 ORM 框架,从 ORM 框架 到 SQL 执行的这个过程你了解多少。

  • 实体类映射
  • 会话工厂
  • 持久化操作
  • 生成执行操作

常见编码和字符集

  • UTF-8:最常用的字符编码,支持所有的 Unicode 字符。
  • UTF-8MB4:UTF-8 的扩展版本,支持 4 字节 Unicode 字符(如表情符号)

VARCHARCHAR 的区别

  • VARCHAR
    • 可变长度字符数据类型。
    • 存储时仅保存实际字符串的长度,并在前面加上一个或两个字节来存储字符串的长度(长度 <= 255 时使用一个字节,长度 > 255 时使用两个字节)
    • 可变长度,节省空间,适用于存储长度不确定或变化较大的字符串。
    • VARCHAR 类型可以存储的最大字符数是 65535 字节
  • CHAR
    • 固定长度字符数据类型。
    • 固定长度,性能稳定,适用于存储长度固定或变化较小的字符串。

数据库死锁问题怎么解决

排查:一般可以通过 SQL语句或者监控平台看到死锁现象,一般是资源争夺或者资源等待的问题,比如同时更新某一行等,事务长时间不释放锁,其他事务进行等待,也会导致死锁的现象。

解决:

  • 手动干预
    • 手动终止一个或多个导致死锁的事务,使其他事务可以继续执行。可以使用数据库管理工具或命令终止事务
  • 自动处理
    • 大多数现代数据库系统可以自动检测到死锁,并选择一个事务进行回滚以解除死锁。例如,MySQL 的 InnoDB 引擎会自动回滚最小的事务

优化建议:

  • 建议一些长事务,可以将它改为多个短事务去执行,避免事务一直没执行完,下一个事务又来了,导致死锁等待
  • 使用数据库特性
    • 使用悲观锁和乐观锁:根据业务场景选择适合的锁策略,悲观锁确保严格的并发控制,而乐观锁则适用于减少锁冲突的场景。
    • 设置死锁检测和超时时间:配置数据库的死锁检测和锁等待超时时间,确保死锁能及时被检测和处理。

行锁什么情况下会升级为表锁

在 MySQL 的 InnoDB 存储引擎中,行锁(Row Lock)是默认的锁机制,

  • 大量行锁导致内存耗尽
  • 表扫描导致锁定所有行
  • 锁升级
    • 多个行锁导致的锁升级。
    • 当多个行锁导致死锁或锁冲突时,InnoDB 可能会选择将行锁升级为表锁以简化锁管理。

为避免行锁升级为表锁,可以采取以下策略:

  • 使用合适的索引。
  • 分批处理大数据量操作。
  • 控制事务长度,减少锁的持有时间。
  • 使用合适的隔离级别,降低锁冲突

间隙锁

间隙锁主要是在索引记录之间的间隙加锁,从而保证某个间隙内的数据在锁定情况下不会发生任何变化

Redis

将数据库用户表的信息存入到缓存中,建议使用什么数据结构

一般使用 Hash 就可以了,如果数据量少的话;

数据量多的时候需要考虑通过哈希或者其他方式将数据分散存入到多个 Key 中

如果是存入一些用户信息,比如用户相关联的信息,一对多的时候,可以考虑 List 或者 Set 存入

Redis 的一些常用命令

#Key 操作
#设置键的值
SET key value
#获取键的值
GET key
#删除键
DEL key
#设置键的过期时间
EXPIRE key seconds
#获取键的数据类型
TYPE key

#String 操作
#设置键的值,并设置过期时间
SETEX key seconds value
#追加字符串到现有键值
APPEND key value
#获取字符串长度
STRLEN key
#设置并返回键的旧值
GETSET key value
#批量设置多个键值对
MSET key1 value1 key2 value2
#批量获取多个键值
MGET key1 key2

#Hash 操作
HSET key field value                #设置哈希字段的值
HGET key field                #获取哈希字段的值
HDEL key field1 field2                #删除一个或多个哈希字段
HGETALL key                #获取哈希中的所有字段和值
HKEYS key                #获取哈希中的所有字段
HVALS key                #获取哈希中的所有值
HEXISTS key field                #判断哈希字段是否存在

#List 操作
LPUSH key value1 value2                #从列表左端推入元素
RPUSH key value1 value2                #从列表右端推入元素
LPOP key                #从列表左端弹出元素
RPOP key                #从列表右端弹出元素
LLEN key                #获取列表长度
LRANGE key start stop                #获取列表中指定范围的元素
LREM key count value                #移除列表中等于 value 的元素

#Set 操作
SADD key member1 member2                #向集合添加一个或多个元素
SREM key member1 member2                #移除集合中的一个或多个元素
SISMEMBER key member                #检查集合中是否包含指定元素
SMEMBERS key                #获取集合中的所有元素
SCARD key                #获取集合的元素个数
SRANDMEMBER key                #随机返回集合中的一个元素

# Sorted Set 操作
ZADD key score1 member1 score2 member2                #向有序集合添加元素及其分数
ZRANGE key start stop                #获取有序集合中指定范围内的元素(按分数升序)
ZREVRANGE key start stop                #获取有序集合中指定范围内的元素(按分数降序)
ZRANGEBYSCORE key min max                #按分数范围获取有序集合中的元素
ZREM key member1 member2                #移除有序集合中的一个或多个元素
ZCARD key                 #获取有序集合中的元素个数

#事务操作
MULTI                  #开始事务
EXEC                #执行事务
DISCARD                #放弃事务

更新 hash 中某个 field 值的数据

  1. 增加用户信息:使用 HSET key field value 将用户对象序列化后存储到 Redis 哈希表中。
  2. 查询用户信息:使用 HGET key field 根据用户ID查询对应的值,并反序列化为用户对象。
  3. 更新用户信息:再次使用 HSET key field value 更新用户信息。
  4. 删除用户信息:使用 HDEL key field 删除指定用户ID的信息。
  5. 获取所有用户信息:使用 HGETALL key 获取哈希表中的所有字段和值。
  6. 获取所有用户ID:使用 HKEYS key 获取哈希表中的所有字段。
  7. 获取所有用户对象:使用 HVALS key 获取哈希表中的所有值。
  8. 判断用户是否存在:使用 HEXISTS key field 判断哈希字段是否存在

常用的缓存读写策略

缓存常用的三种读写策略:Cache Aside Pattern(旁路缓存模式)、Read/Write Through Pattern(读写穿透)、Write Behind Pattern(异步缓存写入)

  • 常用的缓存读写策略
    • Cache Aside Pattern(旁路缓存模式)
        • 从 cache 中读取数据,读取到就直接返回
        • cache 中读取不到的话,就从 db 中读取数据返回
        • 再把数据放到 cache 中。
        • 先更新 db
        • 然后直接删除 cache
    • Read/Write Through Pattern(读写穿透)
        • 从 cache 中读取数据,读取到就直接返回 。
        • 读取不到的话,先从 db 加载,写入到 cache 后返回响应。
        • 先查 cache,cache 中不存在,直接更新 db。
        • cache 中存在,则先更新 cache,然后 cache 服务自己更新 db(同步更新 cache 和 db)。
    • Write Behind Pattern(异步缓存写入)
        • **Read/Write Through 是同步更新 cache 和 db,
        • Write Behind 则是只更新缓存,不直接更新 db,而是改为异步批量的方式来更新 db

内存淘汰策略有哪些

数据的淘汰策略:当Redis中的内存不够用时,此时在向Redisr中添加新的key,那么Redis 就会按照某一种规则将内存中的数据删除掉,这种数据的删除规则被称之为内存的淘汰策略。

  • 默认策略noeviction,当内存不⾜以容纳新写⼊数据时,新写⼊操作 会报错
  • 针对设置过期时间的key
    • volatile-lru,按照LRU算法删除
    • volatile-lfu,按照LFU算法删除
    • volatile-radom,随机删除
    • volatile-ttl,按过期时间顺序删除
  • 针对所有key
    • allkeys-random,随机删除
    • allkeys-lru,按照LRU算法删除
    • allkeys-lfu,按照LFU算法删除

缓存加载和更新策略

  • 缓存加载
    • 预加载(Cache Warming)
    • 惰性加载(Lazy Loading)
    • 定时加载(Scheduled Loading)
  • 缓存更新
    • 过期时间(TTL)
    • 写通(Write Through)
      • 在更新数据源的同时,也同步更新缓存
    • 写回(Write Back / Write Behind)
      • 首先更新缓存,然后异步地将数据更新到数据源
    • 失效(Cache Invalidation)
      • 在数据源更新后,立即使缓存中的对应数据失效
      • 当数据源更新时,删除缓存中的旧数据。

这里讲一下一般什么情况下会使用定时加载,一般来说我们会使用旁路缓存模式,比如先读缓存,如果缓存没有再读数据库,这里可以避免减少数据的负担,可以通过定时操作,先进行定时刷写到缓存中,避免读数据的时候再往数据库中查。

这里定时加载并不需要说每次全部更新缓存数据,你也可以设置修改数据库的时候同时更新缓存的数据,然后定时的时候再将数据库中有的数据而缓存中没有的数据,放入到缓存中。

Spring

Spring 的生命周期

image.png

Spring 的传播事务 🚩

Service 中进行事务调用的时候,一般是需要注意一下事务传播行为,默认的 Spring 事务传播行为是:PROPAGATION_REQUIRED

  • PROPAGATION_REQUIRED ✈
    • 默认传播行为
    • 存在则加入,否则创建新的事务
  • PROPAGATION_SUPPORTS ✈
    • 存在则加入,否则以非事务方式执行
  • PROPAGATION_MANDATORY
    • 如果当前不存在事务就报错,否则就加 ⼊前事务
  • PROPAGATION_REQUIRES_NEW ✈
    • 每次都会创建新事务,外部事务可以进行捕获新事务的异常,再决定是否回滚事务
    • 外事务发出异常进行回滚,不会影响内事务(独立事务)
  • PROPAGATION_NOT_SUPPORTED
    • ,如果当前存在事务,就将当前事 务挂起,否则以⾮事务运⾏
  • PROPAGATION_NEVER
    • 如果当前存在事务就报错,否则以⾮事务运 ⾏
  • PROPAGATION_NESTED
    • 如果当前存在事务,就嵌套事务内运⾏, 如果不存在事务就按照PROPAGATION_REQUIRED运⾏
    • 外事务发出异常进行回滚,内事务也进行回滚

propagation 传播 (朴 r 朴 ga tion)

传播行为描述应用场景
PROPAGATION_REQUIRED存在则加入,否则创建新的事务默认行为,适用于绝大多数情况
PROPAGATION_SUPPORTS存在则加入,否则以非事务方式执行可选的事务,不强制要求
PROPAGATION_MANDATORY存在则加入,否则抛出异常强制要求必须在事务中执行
PROPAGATION_REQUIRES_NEW总是创建新事务,挂起当前事务需要独立事务的场景
PROPAGATION_NOT_SUPPORTED以非事务方式执行,挂起当前事务不需要事务的操作,或者事务会导致问题的场景
PROPAGATION_NEVER以非事务方式执行,如果有事务则抛出异常强制要求不在事务中执行
PROPAGATION_NESTED在嵌套事务内执行,如果没有则创建新事务需要嵌套事务的场景,支持内外事务单独回滚

这种传播行为主要是用来精准控制事务行为,可以看一下下面的示例

第一种是默认情况,默认情况是 Propagation_REQUEST:加入到同一个事务中

@Service
public class OrderService {
    @Autowired
    private PaymentService paymentService;

    @Transactional(propagation = Propagation.REQUIRED)
    public void placeOrder(Order order) {
        // 保存订单
        saveOrder(order);

        // 调用支付服务
        paymentService.processPayment(order.getPayment());
    }

    private void saveOrder(Order order) {
        // 保存订单逻辑
    }
}

@Service
public class PaymentService {
    @Transactional(propagation = Propagation.REQUIRED)
    public void processPayment(Payment payment) {
        // 支付处理逻辑
    }
}

在这个示例中,OrderService.placeOrder 方法和 PaymentService.processPayment 方法都使用 PROPAGATION_REQUIRED 传播行为。如果 placeOrder 方法已经在一个事务中执行,那么 processPayment 方法将加入到同一个事务中。这确保了订单和支付要么同时成功,要么同时失败。


示例2:PROPAGATION_REQUIRES_NEW 用于在现有事务中执行一个完全独立的事务

例如记录日志或处理独立的业务逻辑。

@Service
public class OrderService {
    @Autowired
    private PaymentService paymentService;

    @Transactional(propagation = Propagation.REQUIRED)
    public void placeOrder(Order order) {
        // 保存订单
        saveOrder(order);

        // 独立的事务处理支付
        paymentService.processPayment(order.getPayment());
    }

    private void saveOrder(Order order) {
        // 保存订单逻辑
    }
}

@Service
public class PaymentService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void processPayment(Payment payment) {
        // 支付处理逻辑
    }
}

在这个示例中,PaymentService.processPayment 方法使用 PROPAGATION_REQUIRES_NEW。即使 placeOrder 方法回滚,processPayment 方法所做的更改也不会被回滚,因为它们是在一个独立的事务中执行的。


示例 3:PROPAGATION_NESTED 用于在现有事务中执行嵌套事务,嵌套事务可以独立回滚。

@Service
public class OrderService {
    @Autowired
    private PaymentService paymentService;

    @Transactional(propagation = Propagation.REQUIRED)
    public void placeOrder(Order order) {
        // 保存订单
        saveOrder(order);

        // 嵌套事务处理支付
        try {
            paymentService.processPayment(order.getPayment());
        } catch (Exception e) {
            // 处理支付异常
        }
    }

    private void saveOrder(Order order) {
        // 保存订单逻辑
    }
}

@Service
public class PaymentService {
    @Transactional(propagation = Propagation.NESTED)
    public void processPayment(Payment payment) {
        // 支付处理逻辑
    }
}

在这个示例中,如果 processPayment 方法抛出异常,嵌套事务将回滚,但外部的 placeOrder 事务可以根据需要选择继续或回滚。

nested 嵌套的

常见的Spring事务失效场景

  • @Transactional注解应用于非public方法,事务将不会生效
  • 当一个类的方法调用同一个类中的另一个带有@Transactional注解的方法时,事务将不会生效。
    • 自调用现象,解决方案是通过self代理调用
  • 事务只会在未被捕获的RuntimeExceptionError类型的异常时回滚。如果抛出的是CheckedException,事务不会自动回滚,除非明确指定。
  • 数据库不支持事务
  • Spring的事务管理依赖于线程绑定。如果在事务中切换线程(例如通过异步调用),事务将失效
  • 错误的事务传播行为配置

自调用

当一个类的方法调用同一个类中的另一个带有@Transactional注解的方法时,事务将不会生效。Spring AOP是通过代理实现的,self-invocation绕过了代理对象。

@Service
public class MyService {

    @Autowired
    private MyService self;

    @Transactional
    public void methodA() {
        // 方法B的事务将不会生效
        methodB();
    }

    @Transactional
    public void methodB() {
        // 事务性代码
    }
}
  • methodA 的事务会生效,因为它是由外部调用的,Spring 会通过代理对象来处理事务。
  • methodB 的事务不会生效,因为它是通过 this.methodB() 调用的,没有经过 Spring 的代理对象。

解决方案是通过self代理调用:

@Service
public class MyService {

    @Autowired
    private MyService self;

    @Transactional
    public void methodA() {
        // 方法B的事务将生效
        self.methodB();
    }

    @Transactional
    public void methodB() {
        // 事务性代码
    }
}

异常类型抛出

to be contined....

编程式事务和声明式事务的优缺点

谈一下对于 AOP 的理解

  • AOP就是⾯向切⾯编程,跟OOP⾯向过程编程相对,AOP⼀般⽤于将 公共逻辑和业务逻辑进⾏拆分,可以减少代码间的耦合性
  • AOP的实现⽅式主要有基于CGLIB动态代理和基于JDK动态代理
    • 基于CGLIB动态代理是基于⽗⼦类实现的,主要是通过被代理的 类⽣成⼀个代理⼦类,代理⼦类重写⽗类⽅法,并且将被代理类 赋值给内部属性target,当执⾏完切⾯逻辑后,通过target执⾏被 代理类⽅法
    • 基于JDK动态代理是**基于接⼝**实现的,实现InvocationHandler和 Proxy接⼝就⾏
  • AOP在我们业务中应⽤场景主要有⽇志处理、限流处理、事务、异 步、缓存等

Spring AOP默认使用JDK动态代理。如果目标类没有实现任何接口,Spring会自动切换到使用CGLIB代理

默认代理方式,参考: https://blog.csdn.net/myli92/article/details/127586235

SpringBoot 2.x 版本默认使用的 AOP 动态代理方式是 CGLIB代理

循环依赖问题如何解决

image.png

循环依赖是多个对象之间存在属性相互依赖的问题,例如 A 类依赖 B 类,而 B 类又依赖 A 类。解决循环依赖的方法包括使用 @Lazy 注解和 Spring 提供的三级缓存机制。

解决循环依赖的方法

  1. 使用 @Lazy 注解:解决构造方法造成的循环依赖问题,延迟加载依赖。
  2. 使用 Spring 的三级缓存
    • 一级缓存(singletonObjects:存放完整生命周期的对象,使用 ConcurrentHashMap
    • 二级缓存(earlySingletonObjects:存放半成品对象,使用 ConcurrentHashMap
    • 三级缓存(singletonFactories:存放 ObjectFactory,用于创建对象的工厂,使用 HashMap

处理普通循环依赖

  • 实例化对象放入二级缓存中。
  • 完成生命周期的对象放入一级缓存,其他依赖该对象的对象也能完成生命周期。

处理有 AOP 的循环依赖

  • 三级缓存保存对象的代理配置信息。
  • 通过 ObjectFactory 创建动态代理类进行 AOP 处理。
  • 一级缓存存放代理对象。

核心代码

三级缓存机制的核心代码在 DefaultSingletonBeanRegistry#getSingleton 方法中

Autowire 和 Resource 的区别

  • @Resouce在没有指定别名的情况下,@Autowired和@Resource都是 先byType再byName
  • @Resouce在指定别名的情况下是先byName再byType

synchronized 关键字和 @Transactional 注解 一起使用的话需要注意什么

synchronized 关键字和 @Transactional 注解可以在同一个方法中使用,但它们的作用和行为是不同的

  • synchronized
    • 加锁操作,防止资源争抢
    • 线程同步synchronized 确保一个方法或代码块在同一时间只能被一个线程执行。它主要解决线程之间的并发问题,防止多个线程同时操作共享资源导致数据不一致。
  • @Transcational
    • 确保被注解的方法在一个事务中执行,如果方法抛出一个未被捕获的运行时异常,事务将被回滚
    • 事务管理@Transactional 确保方法在一个事务中执行,解决数据库操作的原子性、一致性、隔离性和持久性(ACID)
    • 事务延迟:使用 synchronized 关键字会导致线程等待,这可能会延长事务的执行时间。如果一个方法被 synchronized 修饰并且同时包含数据库操作,事务可能会因为等待其他线程释放锁而保持更长时间,从而潜在地影响性能。
    • 锁和数据库资源:长时间持有 synchronized 锁和数据库连接可能导致资源竞争和死锁问题。在设计时需要特别注意这种情况,确保不会长时间持有锁或占用数据库连接

SpringMVC

执行流程

  • 前端控制器
  • 处理器映射器
  • 处理器适配器
  • 处理器
  • 视图解析器
  • 视图

SpringMVC 的执行流程可以分为两种方式:前后端分离和前后端不分离。

  • 前后端不分离的视图阶段(JSP)
  1. 请求接收: 前端向前端控制器 DispatcherServlet 发起请求。
  2. 处理器映射: DispatcherServlet 收到请求,调用处理器映射器 HandlerMapping,根据 URL 请求映射到具体的某一个 Controller 类。
  3. 查找处理器: HandlerMapping 根据请求 URI 找到具体的处理器,并生成处理器和处理器拦截器(返回处理器执行链 HandlerExecutionChain),然后返回给前端控制器 DispatcherServlet
    • 拦截器可以在处理请求之前、之后以及视图渲染之前和之后执行额外的逻辑。
  4. 处理请求: 前端控制器根据处理器执行链向处理器适配器 HandlerAdapter 发起请求,HandlerAdapter 会找到对应的处理器(Handler)并发起请求(调用具体的页面控制器 Controller)。处理器根据请求生成对应的响应并返回到处理器适配器,处理器适配器再返回 ModelAndView 到前端控制器。
  5. 视图解析: 前端控制器根据响应的 ModelAndView,发送到视图解析器 ViewResolverViewResolver 会解析并返回对应的 View 实例对象到前端控制器,前端控制器再根据 View 进行渲染视图,从而进行视图展示。
  • 前后端分离阶段
  1. 请求接收: 前端控制器 DispatcherServlet 收到前端发起的请求后,会根据请求调用处理器映射器 HandlerMapping
  2. 查找处理器: HandlerMapping 返回处理器执行链 HandlerExecutionChain 到前端控制器,前端控制器再发送处理器执行链到处理器适配器。
  3. 处理请求: HandlerAdapter 会找到对应的处理器(Handler)并发起请求。通常,Controller 上的方法会添加 @ResponseBody 注解,这样响应结构会转换为 JSON 并返回。

核心组件:前端控制器、处理器映射器、处理器、处理器适配器、处理器、视图解析器

  • 前端控制器(DispatcherServlet)
    • 请求首先到达前端控制器 DispatcherServletDispatcherServlet 是整个流程的核心,它负责协调请求处理的各个组件
  • 处理器映射器(Handler Mapping)
    • DispatcherServlet 调用处理器映射器(HandlerMapping)来查找处理该请求的处理器(Controller)。HandlerMapping 根据请求 URL 和配置来确定哪个 Controller 来处理该请求
  • 处理器(Handler / Controller)
    • 找到处理器后,DispatcherServlet 将请求发送到该处理器。处理器通常是一个带有 @Controller 注解的类,并包含处理请求的方法,这些方法上带有 @RequestMapping 注解来映射请求路径
  • 处理器适配器(Handler Adapter)
    • 为了使处理器独立于具体的实现方式,DispatcherServlet 使用处理器适配器(HandlerAdapter)来调用处理器。HandlerAdapter 负责执行处理器的方法并返回一个 ModelAndView 对象
  • 视图解析器

简化版本

  • 前端控制器(DispatcherServlet):接收请求,响应结果,相当于电脑的CPU。
  • 处理器映射器(HandlerMapping):根据URL去查找处理器。
  • 处理器(Handler):需要程序员去写代码处理逻辑的。
  • 处理器适配器(HandlerAdapter):会把处理器包装成适配器,这样就可以支持多种类型的处理器,类比笔记本的适配器(适配器模式的应用)。
  • 视图解析器(ViewResovler):进行视图解析,多返回的字符串,进行处理,可以解析成对应的页面。

视图阶段(JSP) image.png

前后端分离阶段(接口开发,异步请求) image.png

常用注解

  • @Controller
    • 用于标识一个类为 SpringMVC 控制器(Controller),用于处理 HTTP 请求。
  • @RestController
    • @Controller@ResponseBody 的组合注解。标识一个类为控制器,并且该类中所有方法的返回值都会直接写入 HTTP 响应体
  • @RequestMapping
    • 用于映射请求到控制器类或处理器方法上。可以映射 URL 路径、请求方法、请求参数等
  • @GetMapping, @PostMapping, @PutMapping, @DeleteMapping, @PatchMapping
    • 这些注解是 @RequestMapping 的快捷方式,用于简化处理特定 HTTP 请求方法的映射。
  • @RequestParam
    • 用于将请求参数绑定到控制器方法的参数上
  • @PathVariable
    • 用于将 URL 路径中的变量绑定到控制器方法的参数上。
  • @RequestBody
    • 用于将 HTTP 请求体绑定到控制器方法的参数上,常用于处理 JSON 请求
  • @ResponseBody
    • 用于将控制器方法的返回值直接写入 HTTP 响应体,而不是解析为视图。

过滤器和拦截器

  • 过滤器(Filter)

    • 适用于需要对请求和响应进行全局过滤和处理的场景。
    • 例如:安全检查、日志记录、字符编码设置、跨域处理等。
  • 拦截器(Interceptor)

    • 适用于需要在控制器方法执行前后进行特定处理的场景。
    • 例如:权限验证、事务管理、日志记录、性能监控

Mybatis

动态SQL标签

  1. <if> 标签
  2. <choose><when><otherwise> 标签
  3. <where> 标签
  4. <set> 标签
  5. <trim> 标签
  6. <foreach> 标签
  7. <bind> 标签

二级缓存

  • 一级缓存
    • sqlsession
  • 二级缓存
    • namespace

image.png

JDBC 的执行流程

    1. 加载数据库驱动
    1. 建立连接
    1. 创建Statement或PreparedStatement
    1. 执行SQL语句
    1. 处理结果
    1. 关闭连接

Mapper 常用的标签

  • 基本标签
    • <configuration>
    • <mapper>
  • SQL 操作标签
    • <select>
    • <insert>
    • <update>
    • <delete>
  • 动态 SQL 标签
    • <if>
    • <trim>, <where>, <set>
    • <foreach>
    • <bind>
  • SQL 片断标签
    • <sql><include>
  • ResultMap 标签
    • <resultMap>

SpringBoot

自动配置原理

  • SpringBootApplication
    • SpringBootConfiguration
    • ComponentScan
    • EnableAutoConfiguration
      • @Import
      • spring-factries

image.png

starter

在项目的资源目录中创建一个 META-INF/spring.factories 文件,并指定自动配置类。

内置的 Tomcat 替换为其他嵌入式服务器

pom.xml 文件中排除默认的 Tomcat 依赖,并添加所需的嵌入式服务器依赖

示例

<dependencies>
    <!-- Exclude Tomcat -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <exclusions>
            <exclusion>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-tomcat</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
    
    <!-- Add Jetty -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jetty</artifactId>
    </dependency>
</dependencies>

SpringCloud

Ribbon

分布式事务的不同方案

限流

  • Nginx
    • 漏桶算法
  • Gateway
    • 令牌桶

RocketMQ

RocketMQ 的主要组件 🚩

待完善一下

  • NameServer
    • 作为服务注册以及Broker 路由
  • Broker
    • 作为消息存储,转发的服务
  • Producer
    • 发送消息到 Broker 端
  • Consumer
    • 接收 Broker 端推送的消息或主动 从 Broker 端拉取消息
  • Topic
    • RocketMQ 根据主题进行消息分类,生产端发送消息时需要指定主题
  • MessageQueue
    • 相当于 Topic 的分区,一个 Topic 默认四个 MessageQueue

RocketMQ的推、拉模式,以及广播、集群模式怎么理解的

  • 分配策略
    • 广播模式和集群模式是可以在消费者端进行设置的
    • 集群模式
      • 消费者默认开启的是集群模式
      • 集群模式下的消费者会和同一个 Consumer Group 中的其他同样开启了集群模式的消费者(如果有)一起分摊消费消息。如果没有其他消费者在同一个 Consumer Group 中,消费者 A 将独自消费所有消息。
    • 广播模式
      • 当消费者开启的是广播模式,它会接收所有消息,不管是否有其他消费者存在。
  • 处理方式
    • 消费者对于消息从 Broker 获取消息的方式:拉模式 vs 推模式
    • 针对推、拉模式,是消费者如何进行处理消息的一种策略,推模式是自动推送,实时消费,拉模式是主动拉取,理解推模式其实也是一种拉模式,只不过开启了定时监听。
    • 推模式在实现上是通过客户端定期向 Broker 拉取消息,然后触发回调函数进行消息处理。换句话说,推模式是一种高频拉取,并在消息到达时立即处理的模式

消息队列如何保证消息可靠传输

  • 可靠传输,需要保证的点,避免消息丢失 + 避免消息重复消费
  • 开启消息持久化(避免消息丢失)
    • RocketMQ 使用文件存储(CommitLog)来持久化消息
      • 同步刷盘
      • 异步刷盘
  • 集群模式(提高可靠性,高可用)
    • 主从复制
      • 同步复制
      • 异步复制
  • 重试机制(提高可靠性,高可用)
    • Producer 端重试:Producer 在发送消息失败时,自动进行重试
    • Consumer 端重试:Consumer 在消费消息失败时,RocketMQ 会将消息重新放回队列,稍后再重试消费。
  • 消息重复消费
    • 消息幂等性
      • 消费者应在消费消息时,检查消息是否已被处理。可以使用消息的唯一 ID(例如 Message ID 或自定义业务 ID)来进行去重
  • 监控预警
    • 通过 定时任务与监控,保持对系统状态的实时监控和告警

说一下这里的重试机制

  • 生产者端重试:如果消息发送失败,生产者可以配置自动重试,确保消息最终发送成功。
#在生产者端设置重试次数
DefaultMQProducer producer = new DefaultMQProducer("ProducerGroupName");
producer.setRetryTimesWhenSendFailed(3);
producer.start();
  • 消费者端重试:如果消费者处理消息失败,RocketMQ 会将消息重新放回队列,稍后重新投递给消费者。
consumer.registerMessageListener(new MessageListenerConcurrently() {
    @Override
    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
        try {
            // 处理消息
            return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
        } catch (Exception e) {
            // 处理异常,返回稍后重新消费状态
            return ConsumeConcurrentlyStatus.RECONSUME_LATER;
        }
    }
});

同时需要注意的是:RocketMQ 默认是开启持久化的,默认使用异步刷盘(ASYNC_FLUSH)策略。

RocketMQ 为什么要放弃 Zookeeper

  • RocketMQ 主要是保障最终一致性,他只需要一个轻量级的元数据服务就行
  • ZK 是作为强一致性的解决方案,同时减少一个中间件使用可以减少维护成本

RocketMQ 的消息模型

  • 顺序消息
    • 顺序消息只能保证局部消息有序,不能保证全局有序,实现全局 有序 可以 ⽣产端将⼀批消息有序发往MessageQueue,消费端通 过锁队列的⽅式,每次只拿⼀个MessageQueue⾥的消息
  • 广播消息
    • ⼴播消息并没有特定的消费者,因为这涉及到消费者的集群消费 模式,默认是集群模式
  • 延迟消息
    • 默认提供了18个延迟级别,延迟消息的难点其实是性能,需要不 断进⾏定时轮询,全部扫描所有消息是不可能的
  • 批量消息
    • 只能对同⼀topic下的消息进⾏批量发送,不⽀持延迟消息,以及 批量消息的⼤⼩不超过1MB,超过了需要⾃⾏拆分
  • 过滤消息
    • 消费端可以通过⼀定规则匹配topic下需要的消息,⽀持简单过滤 以及SQL过滤
    • 消息过滤在消费者端和Broker端都可以做,消费者端进⾏过滤可 以保障消息过滤的可控性,⽽Broker端过滤可以减少不必要数据 的⽹络IO(只把消费者端需要的消息发送出去就⾏)
  • 事务消息
    • 通过事务消息可以确保上下游的数据⼀致性
    • 实现思路
      • ⽣产者端将消息发往MQ服务,MQ服务将消息持久化后,向⽣ 产端反馈已收到,此时消息为半消息(半事务消息状态)
      • ⽣产端执⾏完本地事务后,会将执⾏结果向MQ服务进⾏⼆次 确认,判断是否提交或回滚
        • 如果提交,MQ服务将半消息标记为可投递,然后转发给消 费端
        • 如果回滚,MQ服务会将半消息删除
      • 如果MQ服务没有收到⼆次确认,会对⽣产端进⾏消息回查, 查看事务执⾏结果继续进⾏⼆次确认

RocketMQ 分布式原理的理解讲一下

  • 分布式事务 就是在分布式环境下,需要保证不同服务的数据⼀致性
  • 分布式事务的实现⽅式可以基于2PC两阶段提交
    • 准备阶段,协调者通知参与者准备提交各⾃的事务
    • 提交阶段,参与者反馈,协调者通过反馈去决定执⾏事务提交或 回滚
  • RocketMQ分布式事务也是基于2PC实现的,实现思路
    • ⽣产端将消息发往MQ服务,MQ服务将消息持久化后,向⽣产端 反馈已收到,此时消息为半消息
    • ⽣产端执⾏完本地事务后,会将执⾏结果向MQ服务进⾏⼆次确 认,判断是否提交或回滚
      • 如果提交,MQ服务将半消息标记为可投递,然后转发给消费 端
      • 如果回滚,MQ服务会将半消息删除
    • 如果MQ服务没有收到⼆次确认,会对⽣产端进⾏消息回查,查看 事务执⾏结果继续进⾏⼆次确认

RocketMQ 生产端的发送模式了解吗

一共有三种发送模式

  • 同步发送
    • 必须等到Broker反馈之后才能继续发,安全性最⾼但发消息最 慢
  • 单向发送
    • 不管消息是否发成功都能继续发,所以吞吐量最⾼,但是安全 性低,容易丢消息
  • 异步发送
    • 发送消息的同时回注册⼀个回调去处理响应,安全性低,容易 丢消息

RocketMQ 消费端的消费模式

  • 推拉模式
  • 推其实也是一种监听方式的拉模式

如何防止消息重复消费

消息堆积问题怎么解决

  • 增加一个中转的消息 topic ,然后配置足够多的 Queue ,增长对应的消费者数量
  • 如果仍然消费不过来,可以建议一个消息重试的表,将消息进行放入到这个处理表,后面定时任务根据状态去跑任务
  • 将生产者限流,服务降级
  • 服务保证幂等的操作

消息堆积在消息队列系统中是一个常见问题,不同系统的处理方式和性能影响有所不同。以下是 RocketMQ 处理消息堆积的方法及其具体步骤:

消息堆积对性能的影响

  • Kafka 和 RocketMQ:消息堆积对性能影响不大。
  • RabbitMQ:消息堆积会导致性能直线下降。

确定 RocketMQ 消息堆积的方法

可以通过 RocketMQ 控制台查看消息的积压情况,以确定是否存在大量消息堆积。

处理大量积压消息的方法

  1. 增加消费者

通过增加消费者来加快消息的消费速度。

  • 多个 MessageQueue

    • 如果 Topic 下的 MessageQueue 数量充足,每个消费者会分配多个 MessageQueue 进行消费。
    • 增加消费者数量可以加快消息的消费速度。
  • 消费者数 = MessageQueue 数

    • 如果消费者数量等于 MessageQueue 数量,增加额外的消费者不会提高消费效率。
    • 在这种情况下,可以通过新建一个新的 Topic 并配置足够的 MessageQueue,将旧 Topic 中的消息转移到新 Topic 中,并指定对应数量的消费者去平摊新 Topic 的 MessageQueue 进行消费。
  1. 新建 Topic 进行消息转移
  • 步骤

    1. 创建新 Topic:创建一个新的 Topic,并配置足够的 MessageQueue
    2. 消息转移:将旧 Topic 中的消息转移到新 Topic 中。
    3. 增加消费者:为新 Topic 配置对应数量的消费者,以平摊新 Topic 的 MessageQueue 进行消费。
    4. 恢复原有情况:根据情况,处理完堆积消息后可以恢复原有的 Topic 和消费者配置。
  • 示例流程

    • 原始配置Topic_A -> Consumer_A
    • 消息转移和新增消费者
      • Topic_A -> Consumer_A
      • Topic_A -> Consumer_A -> Topic_B -> Consumer_B

总结

  1. 监控消息堆积:通过 RocketMQ 控制台查看消息积压情况。
  2. 增加消费者:通过增加消费者数量加快消息消费速度,前提是 MessageQueue 数量充足。
  3. 新建 Topic 转移消息:在消费者数量等于 MessageQueue 数量时,通过新建 Topic 和增加消费者来处理堆积消息。

谈谈对RocketMQ零拷⻉的理解

零拷贝(Zero Copy)是一种通过避免数据在用户态和内核态之间来回拷贝来提升文件传输速度的技术。其核心思想是减少用户态与内核态之间的拷贝次数,从而提高传输效率。

RocketMQ 主要使用 mmap 技术:

mmap是Linux提供的一种内存映射文件的机制,它实现了将内核中读缓冲区地址与用户空间缓冲区地址进行映射,从而实现内核缓冲区与用户缓冲区的共享。

这样就减少了一次用户态和内核态的CPU拷贝,但是在内核空间内仍然有一次CPU拷贝。

  • mmap
    • RocketMQ 在处理消息时采用 mmap 技术。
    • mmap 使得 RocketMQ 能够直接访问文件内容,这不仅提升了消息传输速度,还支持处理消息的顺序和消息过滤功能。

方式不止 Mmap 一种

image.png

RocketMQ如何保证消息有序

在 RocketMQ 中,消息的有序性分为局部有序和全局有序两种:

局部有序

  • 定义:只保证一部分消息链路的消费有序。
  • 实现:生产端可以通过消息选择器指定发送到某个特定的 MessageQueue,从而保证这部分消息的消费顺序。

全局有序

  • 定义:整个消息链路严格按照先进先出的顺序进行消费。
  • 实现:为了保证全局有序,需要牺牲吞吐量。即,一个 topic 只能有一个 MessageQueue 被消费(默认是 4 个 MessageQueue)。可以通过锁定队列的方式进行消费,确保消息的全局有序性。

总结

  • 局部有序:通过生产端指定 MessageQueue 来保证。
  • 全局有序:通过限制一个 topic 只能有一个 MessageQueue 被消费来实现,尽管这样会影响系统的吞吐量。

RocketMQ 如果客户端发生消息超时的情况该如何排查

RabbitMQ

讲一下RabbitMQ的组成部分

核心概念

  • Broker:消息队列服务进程。此进程包括两个部分:Exchange和Queue。
    • Exchange:消息队列交换机。按一定的规则将消息路由转发到某个队列
    • Queue:消息队列,存储消息的队列。
  • Producer:消息生产者。生产方客户端将消息同交换机路由发送到队列中。
  • Consumer:消息消费者。消费队列中存储的消息。

说一下 RabbitMQ 的工作流程

image.png

  • 建立连接
  • 生产者
    • 声明交换机类型、名称、是否持久化等
    • 发送消息
  • 交换机
    • 交换机接收消息,进行消息路由
  • 消费者
    • 订阅消息(监听队列)
    • 接收消息,业务处理

讲一下 RabbitMQ 中常用的交换机类型

(交换机 → 队列)

  • Direct Exchange
    • binding key 与消息的 routing key 完全匹配队列
  • Topic Exchange
    • 模糊匹配
  • Fanout Exchange
    • 广播(忽略 routing key
  • Headers Exchange
    • 不依赖 routing key,头部属性匹配

RabbitMQ的死信队列和延迟队列

  • 死信交换机
    • 死信交换机(Dead-Letter Exchange, DLX)是RabbitMQ中用于处理无法正常投递的消息的一种机制。
    • 当消息在队列中变成死信(Dead Letter)后,可以被自动重新路由到另一个交换机,这个交换机就是所谓的死信交换机。
    • 消息变成死信的情况通常包括:
        1. 消息被拒绝(Basic.Reject/Basic.Nack)并且设置了requeue参数为false,不重新入队。
        1. 消息TTL过期(消息设置了生存时间,超过这个时间还未被消费),超时无人消费
        1. 队列达到最大长度(队列满了,无法再添加更多消息到队列中),最早的消息可能成为死信
  • 延迟队列
    • RabbitMQ本身并不直接支持延迟队列,但可以通过以下几种方式间接实现:
      • 一种方式是:RabbitMQ 的延迟队列通过死信交换机 + TTL (生存时间)来实现的;
      • 还有一种方式是使用 RabbitMQ 的延迟队列相关的一个插件,叫做:DelayExchange,通过这种方式的话只需要在声明交互机的时候,指定这个就是死信交换机,然后在发送消息的时候直接指定超时时间就行。

延迟队列插件使用

image.png

高可用

一般会使用集群模式,在 RabbitMQ 中,分为了普通集群模式、镜像集群模式、仲裁队列模式

普通集群模式(标准集群)

  • 1、在集群的各个节点共享部分数据
  • 2、当访问集群某个节点,如果队列不在该节点,会从数据所在节点传递到当前节点并返回
  • 3、队列所在节点宕机,队列中的消息就会丢失
    • 这种模式并不能包括高可用
    • 实际上还是为了提高吞吐量的

镜像集群:

  • 本质上是主从模式
    • 交换机、队列、队列中的消息会在各个 mq 的镜像节点之间同步备份。
    • 创建队列的节点被称为该队列的主节点,备份到的其它节点叫做该队列的镜像节点。
    • 一个队列的主节点可能是另一个队列的镜像节点
    • 所有操作都是主节点完成,然后同步给镜像节点
    • 主宕机后,镜像节点会替代成新的主

仲裁队列

  • 和镜像模式不同的是,他添加了一个主从基于 Raft 协议的方式,使得消息同步变为了强一致性。

如何保证消息不丢失

  • 发送消息过程中不丢失:消息 → 队列;队列本身,队列 → 消费者
    • 开启生产者确认机制,确保生产者的消息能到达队列
    • 开启持久化功能,确保消息末消费前在队列中不会丢失
    • 开启消费者确认机制为auto,由spring确认消息处理成功后完成ack
    • 开启消费者失数重试机别(重试次数一般设置为3),多次重试失数后将消息投递到异常交换机,交由人工处理

消息重复消费问题怎么解决

出现消息重复消费的问题一般是在网络抖动或者消息者挂掉,由于开启了消费者确认机制,导致消息重发

一般解决方式有两种:

  • 一种是每条消息设置一个唯一的标识 ID 从而避免重发消费
  • 也可以考虑幂等性设计问题,考虑使用分布式锁、数据库锁(悲观锁、乐观锁)等,但锁会对性能有影响,使用的时候需要考虑使用

消息堆积问题怎么解决

RabbitMQ 如果有100万消息堆积在MQ,如何解决?

当生产者发送消息的速度超过了消费者处理消息的速度,就会导致队列中的消息堆积,直到队列存储消息达到上限。之后发送的消息就会成为死信,可能会被丢弃,这就是消息堆积问题。

解决消息堆积有三种种思路:

  • 增加更多消费者,提高消费速度
  • 在消费者内开启线程池加快消息处理速度
  • 扩大队列容积,提高堆积上限,采用惰性队列
    • 在声明队列的时候可以设置属性x-queue-mode为lazy,即为惰性队列
    • 基于磁盘存储,消息上限高
    • 性能比较稳定,但基于磁盘存储,受限于磁盘 IO,时效性会降低

惰性队列:惰性队列通过改变消息存储的策略,将消息尽可能地存储在磁盘上,而不是保留在内存中,从而减轻对内存的压力。

系统设计

中台的概念

中台是一个平台化、服务化的系统架构层,聚合和沉淀企业内部共用的业务能力和技术服务。中台的目标是通过复用和共享,提高系统的灵活性、可扩展性和响应速度。

主要功能

  1. 业务中台:提供业务领域的共性能力,如用户管理、订单处理、库存管理等,支持前台业务应用的快速开发和迭代。
  2. 数据中台:聚合和管理企业的数据资源,提供数据分析、数据治理和数据服务等功能。
  3. 技术中台:提供技术基础设施服务,如微服务框架、消息队列、缓存等。

分库分表的概念,什么时候适合分库

  • 分表
    • 主要解决单个表的数据量和查询性能问题,通过增加表的数量来减少单个表的数据量。
  • 分库
    • 主要解决数据库实例的存储和处理能力瓶颈问题,通过增加数据库实例的数量来提升系统性能。

场景题

后端给前端推送消息有哪几种方式

一般我们会使用 websockets 的方式

服务迁移如何做到无感知的

一般是灰度发布或者 AB 发布

滚动发布,蓝绿发布

如何切流

  • 前端
    • Nginx
  • 后端
    • Nacos,动态配置,轮询,添加对应的服务(对外提高的接口字段对应是相同的)

慢切开关

什么是服务网格

如果你在开发中,一个接口报错,你的排查思路有哪些

排查接口报错问题需要系统化的思维,从重现问题、查看日志、检查请求和响应、验证代码实现、环境检查、网络问题、数据库操作、安全问题等方面逐一排查。通过这种系统化的排查方法,可以有效定位并解决接口报错问题。

如果一个接口很慢,有什么思路去排查

  • 网络
  • 下游接口响应
  • SQL 查询性能

水平分表的话,如果短时间内有大量数据产生,单表性能仍然不太行,你有什么策略去做。

  • 动态分表
    • 根据时间或数据量分表
      • 按时间段分表,例如按天、周、月等。
      • 按数据量分表,例如每当表中的数据达到一定数量时,自动创建新表。
  • 读写分离

水平分表后,你们之前的数据是如何清除到新的数据库表

  • 批量查,再通过接口插入
  • 或者先根据条件查询出需要插入的数据,再进行批量查

MVC 与 DDD

MVC

  • 模型(Model):代表应用程序的数据结构,通常包含数据的处理逻辑。
  • 视图(View):展示数据(即模型)的用户界面,不包含业务逻辑。
  • 控制器(Controller):接收用户的输入并调用模型和视图来完成用户的请求

DDD

  • 基于领域模型(Domain Model)
  • 实体(Entity)、值对象(Value Object)、聚合(Aggregate)、领域事件(Domain Event)、领域服务(Domain Service)等

性能优化

31a84718a5ffd47cc8e2f691ad3bd9e.png

授权认证

image.png

加密算法

image.png

使用账号密码访问流程(JWT 方式)

image.png

  • SpringSecurity:matches 方法(提取盐操作,生成哈希值,比较哈希值)

会员批量过期方案

参考: https://www.bilibili.com/video/BV1cQ4y1W7Wo

  • 场景:如果在会员订阅的情况下,当会员过期的时候向用户进行提醒
    • 有一张200W数据量的会员表,每个会员会有长短不一的到期时间,现在想在快到期之前发送邮件通知提醒续费,该如何实现?
  • 操作
    • 1、系统不主动轮询。而是等用户登绿到系统以后,触发一次检查
      • 判断会员过期时间 < 设定阈值,进行提醒
      • 缺点:不登陆无法提醒
    • 2、使用搜索引擎,将会员 ID 和 过期时间存入
    • 3、使用 Redis 来实现
      • 设置 redis 的过期时间,使用它的 redis 提醒功能
        • 修改配置项:notify-keyspace-events 改为notify-keyspace-events "Ex"
        • 会触发一个 key 过期事件
        • 通过在应用程序中监听这个事件进行处理
    • 4、使用 MQ 中的延迟队列
      • 计算过期时间,发送消息到延迟队列,过期时间过期,消费者进行消费操作

在2G大小的文件中 找出高频 top100的单词

  • 1、把2G的文件进行分割成大小为512KB小文件,总共得到2048个小文件,避兔一次性读入整个文件,造成内存不足
  • 2、定义一个长度为2048的hash表数组,用来统计每个小文件中单词出现的频率
  • 3、使用多线程并行遍历2048个小文件,针对每个单词进行hash取模运算分别存储到长度为2048的hash表数组中 image.png
  • 4、接着再遍历这2048个hash表,把频率前 100的单词存入小顶堆中
  • 5、小顶堆中最终得到的 100个单词,就是top 100 了

对接第三方接口需要考虑什么

  • 1、安全性问题
    • 通信协议 https
    • 数据安全,认证签名
  • 2、接口的稳定性和可靠性
  • 3、接口释放存在访问限制或者费用

表数据量大的时候,影响查询效率的主要原因有哪些

  • 1、磁盘 IO
  • 2、索引失效
  • 3、数据分页
  • 4、锁竞争
  • 5、内存使用

数据量达到多少的时候要开始分库分表

  • 结合具体业务场景和系统架构考虑

设计模式

image.png

观察者

观察者模式允许对象在状态发生变化时通知其他依赖对象

装饰器

装饰器模式是指动态地给一个对象增加一些额外的功能,同时又不改变其结构。

重在功能的加强

Linux

  • ls:列出所有文件及文件夹。
  • Pwd:找出当前所在的文件目录。
  • ps-ef|grep 名称,找出这个应用进程。
  • rm -rf 递归删除,
  • cp -rf 递归复制。
  • Chmod:给文件改权限。
  • lsof -i:端口号 查看端口是否被占用。
  • cat 文件名 | grep 关键字,从文件中查找该关键字的记录。
  • tail -f 文件名查看文件里面的内容,实时打印。
  • vi:编辑文件
  • set nu:给文件标识行数。
  • Linux 创建文件的几种方式:touch 文件名,vi 和 vim,echo

回答技巧

  • 1、当时遇到的场景,难点在什么地方,你怎么解决的,相关的技术选型
  • 2、结合项目场景去回答一些问题

排序算法

冒泡排序

冒泡排序是一种简单的排序算法。它重复地遍历待排序的序列,一次比较两个元素,如果它们的顺序错误就交换它们。遍历序列的工作重复进行,直到没有需要交换的元素为止。

public class BubbleSort {
    public static void bubbleSort(int[] arr) {
        int n = arr.length;
        boolean swapped;
        for (int i = 0; i < n - 1; i++) {
            swapped = false;
            for (int j = 0; j < n - 1 - i; j++) {
                if (arr[j] > arr[j + 1]) {
                    // 交换 arr[j] 和 arr[j+1]
                    int temp = arr[j];
                    arr[j] = arr[j + 1];
                    arr[j + 1] = temp;
                    swapped = true;
                }
            }
            // 如果没有发生交换,说明数组已经有序
            if (!swapped) break;
        }
    }

    public static void main(String[] args) {
        int[] arr = {64, 34, 25, 12, 22, 11, 90};
        bubbleSort(arr);
        System.out.println("Sorted array:");
        for (int num : arr) {
            System.out.print(num + " ");
        }
    }
}

快速排序

快速排序是一个高效的排序算法,采用分治法策略。它的基本思想是选择一个“基准”元素,然后将待排序的序列分成两个子序列,一个比基准元素小,一个比基准元素大,然后递归地对这两个子序列进行排序。

public class QuickSort {
    public static void quickSort(int[] arr, int low, int high) {
        if (low < high) {
            // pi 是分割点索引,arr[pi] 已经在正确的位置
            int pi = partition(arr, low, high);

            // 递归地对左右子数组排序
            quickSort(arr, low, pi - 1);
            quickSort(arr, pi + 1, high);
        }
    }

    public static int partition(int[] arr, int low, int high) {
        int pivot = arr[high];
        int i = (low - 1); // 较小元素的索引

        for (int j = low; j < high; j++) {
            // 如果当前元素小于或等于 pivot
            if (arr[j] <= pivot) {
                i++;

                // 交换 arr[i] 和 arr[j]
                int temp = arr[i];
                arr[i] = arr[j];
                arr[j] = temp;
            }
        }

        // 交换 arr[i+1] 和 arr[high] (或 pivot)
        int temp = arr[i + 1];
        arr[i + 1] = arr[high];
        arr[high] = temp;

        return i + 1;
    }

    public static void main(String[] args) {
        int[] arr = {64, 34, 25, 12, 22, 11, 90};
        quickSort(arr, 0, arr.length - 1);
        System.out.println("Sorted array:");
        for (int num : arr) {
            System.out.print(num + " ");
        }
    }
}

二分查找

编程题

Hr

你对我们公司有什么想问的吗

常规回答:谈公司的历史,产品

想必,绝大多数的求职者,在面试前会做准备功课。而对公司历史、产品的了解,则是必须掌握的 一项内容。如果你真的不知道该如何回答“你对公司有什么想法”这样的问题的话,不妨先说一说你 了解的公司概况,让面试官知道,你是有备而来,而不是来打酱油的

你有什么优缺点

优点:对工作认真负责,对自己的代码整洁程度和功能性有要求,测试比较充分,对待工作比较认真;

缺点:有时候会自己会对某个工作想着要做完可能搞的比较晚,自己的作息不是太规律;(尽量说生活的缺点)不太会做饭。