还得是美团啊!依旧是校招大户,给力!
共 30352字,需浏览 61分钟
·
2024-08-02 17:00
大家好,我是小林。
早上看到美团要准备开始秋招了,计划招聘的人数也写出来了:6000 人,规模还是非常庞大的,不愧是秋招大户!
去年美团就是大厂里校招大户,很多进了大厂的同学,都是拿了美团的 offer,而且发 offer 发的快,也发的很早,10-11 月份就基本发了 多 offer,很多同学拿到的第一个大厂 offer,就是美团给的。
去年美团是大厂里第一个开奖校招薪资的,今年美团校招招聘也开启比较早,大概率 9-10 月份就能看到很多同学都收到了美团的开奖通知,那么先给大家看看去年美团校招薪资,等今年校招薪资出来,在做一下对比。
美团年总包构成 = 月薪 x 15.5
-
普通 offer:21k*15.5,年包:32w -
sp offer:24k~26k*15.5,年包:37w~40w -
ssp offer:27k~29k * 15.5 + 股票 + 签字费,年包:42w~50w
既然美团秋招准备开始了,那么给大家分享一位去年秋招拿到美团offer 同学的 Java后端面经,给准备参加秋招的同学做一个参考。
面经比较有代表性,也对问题做了总结,希望能帮助最近在准备面试的同学。根据面试热点题目去准备知识的,目的性会比较强,方向也比较清晰一点。
-
Java:线程池、锁、反射、ThreadLocal、四种引用、spring、JVM -
MySQL:事务特性、解决慢查询、explain、B+树、索引、MVCC -
Redis: 数据结构、跳表 -
框架:请求到SpringBoot的处理流程 -
算法:层序遍历
Java
Java线程池工作原理是什么?
线程池是为了减少频繁的创建线程和销毁线程带来的性能损耗。
线程池分为核心线程池,线程池的最大容量,还有等待任务的队列,提交一个任务,如果核心线程没有满,就创建一个线程,如果满了,就是会加入等待队列,如果等待队列满了,就会增加线程,如果达到最大线程数量,如果都达到最大线程数量,就会按照一些丢弃的策略进行处理。
那线程池的参数有哪些?
线程池的构造函数有7个参数:
-
corePoolSize:线程池核心线程数量。默认情况下,线程池中线程的数量如果 <= corePoolSize,那么即使这些线程处于空闲状态,那也不会被销毁。 -
maximumPoolSize:线程池中最多可容纳的线程数量。当一个新任务交给线程池,如果此时线程池中有空闲的线程,就会直接执行,如果没有空闲的线程且当前线程池的线程数量小于corePoolSize,就会创建新的线程来执行任务,否则就会将该任务加入到阻塞队列中,如果阻塞队列满了,就会创建一个新线程,从阻塞队列头部取出一个任务来执行,并将新任务加入到阻塞队列末尾。如果当前线程池中线程的数量等于maximumPoolSize,就不会创建新线程,就会去执行拒绝策略。 -
keepAliveTime:当线程池中线程的数量大于corePoolSize,并且某个线程的空闲时间超过了keepAliveTime,那么这个线程就会被销毁。 -
unit:就是keepAliveTime时间的单位。 -
workQueue:工作队列。当没有空闲的线程执行新任务时,该任务就会被放入工作队列中,等待执行。 -
threadFactory:线程工厂。可以用来给线程取名字等等 -
handler:拒绝策略。当一个新任务交给线程池,如果此时线程池中有空闲的线程,就会直接执行,如果没有空闲的线程,就会将该任务加入到阻塞队列中,如果阻塞队列满了,就会创建一个新线程,从阻塞队列头部取出一个任务来执行,并将新任务加入到阻塞队列末尾。如果当前线程池中线程的数量等于maximumPoolSize,就不会创建新线程,就会去执行拒绝策略
线程池里面的核心线程数设置多少合适?
-
CPU 密集型任务配置尽可能小的线程,cpu核数+1。 -
IO 密集型任务则由于线程并不是一直在执行任务,则配置尽可能多的线程,如2*cpu核数。
介绍一下Java里面锁的分类和特点?
Java中的锁是用于管理多线程并发访问共享资源的关键机制。锁可以确保在任意给定时间内只有一个线程可以访问特定的资源,从而避免数据竞争和不一致性。Java提供了多种锁机制,可以分为以下几类:
-
内置锁(synchronized):Java中的 synchronized
关键字是内置锁机制的基础,可以用于方法或代码块。当一个线程进入synchronized
代码块或方法时,它会获取关联对象的锁;当线程离开该代码块或方法时,锁会被释放。如果其他线程尝试获取同一个对象的锁,它们将被阻塞,直到锁被释放。其中,syncronized加锁时有无锁、偏向锁、轻量级锁和重量级锁几个级别。偏向锁用于当一个线程进入同步块时,如果没有任何其他线程竞争,就会使用偏向锁,以减少锁的开销。轻量级锁使用线程栈上的数据结构,避免了操作系统级别的锁。重量级锁则涉及操作系统级的互斥锁。 -
ReentrantLock: java.util.concurrent.locks.ReentrantLock
是一个显式的锁类,提供了比synchronized
更高级的功能,如可中断的锁等待、定时锁等待、公平锁选项等。ReentrantLock
使用lock()
和unlock()
方法来获取和释放锁。其中,公平锁按照线程请求锁的顺序来分配锁,保证了锁分配的公平性,但可能增加锁的等待时间。非公平锁不保证锁分配的顺序,可以减少锁的竞争,提高性能,但可能造成某些线程的饥饿。 -
读写锁(ReadWriteLock): java.util.concurrent.locks.ReadWriteLock
接口定义了一种锁,允许多个读取者同时访问共享资源,但只允许一个写入者。读写锁通常用于读取远多于写入的情况,以提高并发性。 -
乐观锁和悲观锁:悲观锁(Pessimistic Locking)通常指在访问数据前就锁定资源,假设最坏的情况,即数据很可能被其他线程修改。 synchronized
和ReentrantLock
都是悲观锁的例子。乐观锁(Optimistic Locking)通常不锁定资源,而是在更新数据时检查数据是否已被其他线程修改。乐观锁常使用版本号或时间戳来实现。 -
自旋锁:自旋锁是一种锁机制,线程在等待锁时会持续循环检查锁是否可用,而不是放弃CPU并阻塞。通常可以使用CAS来实现。这在锁等待时间很短的情况下可以提高性能,但过度自旋会浪费CPU资源。
Java的反射机制是什么?
Java 反射机制是在运行状态中,对于任意一个类,都能够知道这个类中的所有属性和方法,对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为 Java 语言的反射机制。
反射具有以下特性:
-
运行时类信息访问:反射机制允许程序在运行时获取类的完整结构信息,包括类名、包名、父类、实现的接口、构造函数、方法和字段等。 -
动态对象创建:可以使用反射API动态地创建对象实例,即使在编译时不知道具体的类名。这是通过Class类的newInstance()方法或Constructor对象的newInstance()方法实现的。 -
动态方法调用:可以在运行时动态地调用对象的方法,包括私有方法。这通过Method类的invoke()方法实现,允许你传入对象实例和参数值来执行方法。 -
访问和修改字段值:反射还允许程序在运行时访问和修改对象的字段值,即使是私有的。这是通过Field类的get()和set()方法完成的。
Spring框架的依赖注入(DI)和控制反转(IoC)
Spring框架是Java生态系统中最流行的框架之一,它大量使用反射来实现其核心特性——依赖注入。在Spring中,开发者可以通过XML配置文件或者基于注解的方式声明组件之间的依赖关系。当应用程序启动时,Spring容器会扫描这些配置或注解,然后利用反射来实例化Bean(即Java对象),并根据配置自动装配它们的依赖。
例如,当一个Service类需要依赖另一个DAO类时,开发者可以在Service类中使用@Autowired注解,而无需自己编写创建DAO实例的代码。Spring容器会在运行时解析这个注解,通过反射找到对应的DAO类,实例化它,并将其注入到Service类中。这样不仅降低了组件之间的耦合度,也极大地增强了代码的可维护性和可测试性。
动态代理的实现
在需要对现有类的方法调用进行拦截、记录日志、权限控制或是事务管理等场景中,反射结合动态代理技术被广泛应用。一个典型的例子是Spring AOP(面向切面编程)的实现。Spring AOP允许开发者定义切面(Aspect),这些切面可以横切关注点(如日志记录、事务管理),并将其插入到业务逻辑中,而不需要修改业务逻辑代码。
例如,为了给所有的服务层方法添加日志记录功能,可以定义一个切面,在这个切面中,Spring会使用JDK动态代理或CGLIB(如果目标类没有实现接口)来创建目标类的代理对象。这个代理对象在调用任何方法前或后,都会执行切面中定义的代码逻辑(如记录日志),而这一切都是在运行时通过反射来动态构建和执行的,无需硬编码到每个方法调用中。
这两个例子展示了反射机制如何在实际工程中促进松耦合、高内聚的设计,以及如何提供动态、灵活的编程能力,特别是在框架层面和解决跨切面问题时。
ThreadLocal原理,怎么使用?
ThreadLocal
是Java中用于解决线程安全问题的一种机制,它允许创建线程局部变量,即每个线程都有自己独立的变量副本,从而避免了线程间的资源共享和同步问题。
ThreadLocal的作用:
-
线程隔离: ThreadLocal
为每个线程提供了独立的变量副本,这意味着线程之间不会相互影响,可以安全地在多线程环境中使用这些变量而不必担心数据竞争或同步问题。 -
降低耦合度:在同一个线程内的多个函数或组件之间,使用 ThreadLocal
可以减少参数的传递,降低代码之间的耦合度,使代码更加清晰和模块化。 -
性能优势:由于 ThreadLocal
避免了线程间的同步开销,所以在大量线程并发执行时,相比传统的锁机制,它可以提供更好的性能。
ThreadLocal的原理
ThreadLocal
的实现依赖于Thread
类中的一个ThreadLocalMap
字段,这是一个存储ThreadLocal
变量本身和对应值的映射。
每个线程都有自己的ThreadLocalMap
实例,用于存储该线程所持有的所有ThreadLocal
变量的值。当你创建一个ThreadLocal
变量时,它实际上就是一个ThreadLocal
对象的实例。
每个ThreadLocal
对象都可以存储任意类型的值,这个值对每个线程来说是独立的。当调用ThreadLocal
的get()
方法时,ThreadLocal
会检查当前线程的ThreadLocalMap
中是否有与之关联的值。如果有,返回该值;如果没有,会调用initialValue()
方法(如果重写了的话)来初始化该值,然后将其放入ThreadLocalMap
中并返回。
当调用set()
方法时,ThreadLocal
会将给定的值与当前线程关联起来,即在当前线程的ThreadLocalMap
中存储一个键值对,键是ThreadLocal
对象自身,值是传入的值。
调用remove()
方法时,会从当前线程的ThreadLocalMap
中移除与该ThreadLocal
对象关联的条目。
可能存在的问题
当一个线程结束时,其ThreadLocalMap
也会随之销毁,但是ThreadLocal
对象本身不会立即被垃圾回收,直到没有其他引用指向它为止。
因此,在使用ThreadLocal
时需要注意,如果不显式调用remove()
方法,或者线程结束时未正确清理ThreadLocal
变量,可能会导致内存泄漏,因为ThreadLocalMap
会持续持有ThreadLocal
变量的引用,即使这些变量不再被其他地方引用。
因此,实际应用中需要在使用完ThreadLocal
变量后调用remove()
方法释放资源。ThreadLocal
类在Java中提供了一种线程绑定的变量版本,这样每个线程都有自己的独立变量副本,从而避免了多线程之间的数据冲突和竞态条件。
下面是如何使用ThreadLocal
的基本步骤和示例:创建ThreadLocal实例你可以直接创建一个ThreadLocal
对象,或者继承ThreadLocal
类并覆盖其initialValue()
方法来提供一个初始值。示例1:不带初始值
ThreadLocal<String> threadLocal = new ThreadLocal<>();
示例2:带有初始值
ThreadLocal<String> threadLocal = new ThreadLocal<String>() {
@Override
protected String initialValue() {
return "Default Value";
}
};
在Java 8及以上版本,你也可以使用ThreadLocal.withInitial(Supplier<T> supplier)
方法来提供一个初始值:
ThreadLocal<String> threadLocal = ThreadLocal.withInitial(() -> "Default Value");
设置值使用set()
方法将值设置到当前线程的ThreadLocal
副本中。
threadLocal.set("Hello World");
获取值使用get()
方法从当前线程的ThreadLocal
副本中获取值。
String value = threadLocal.get();
System.out.println(value); // 输出: Hello World
删除值使用remove()
方法来删除当前线程的ThreadLocal
副本中的值。这有助于垃圾回收,避免内存泄漏。
threadLocal.remove();
ThreadLocal内存泄露问题是什么?
当一个线程结束时,其ThreadLocalMap
也会随之销毁,但是ThreadLocal
对象本身不会立即被垃圾回收,直到没有其他引用指向它为止。
因此,在使用ThreadLocal
时需要注意,如果不显式调用remove()
方法,或者线程结束时未正确清理ThreadLocal
变量,可能会导致内存泄漏,因为ThreadLocalMap
会持续持有ThreadLocal
变量的引用,即使这些变量不再被其他地方引用。
因此,实际应用中需要在使用完ThreadLocal
变量后调用remove()
方法释放资源。
强引用,软引用,弱引用,虚引用,举例子说明分别怎么使用
强引用 (Strong Reference)
这是最常见的引用类型,只要一个对象被强引用关联,垃圾收集器永远不会回收这个对象,即使是在内存不足的情况下。示例代码:
String str = new String("Hello");
// str 是一个强引用,只要 str 存在,"Hello" 对象就不会被垃圾回收。
软引用 (Soft Reference)
软引用用于描述一些“有用但不是必须”的对象。如果内存空间足够,软引用的对象不会被回收,但如果内存不足,垃圾收集器会回收软引用的对象。示例代码:
import java.lang.ref.SoftReference;
SoftReference<String> softRef = new SoftReference<>(new String("Hello"));
String data = softRef.get(); // 如果软引用的对象还没有被回收,get() 将返回它
if (data == null) {
System.out.println("Object has been garbage collected.");
} else {
System.out.println("Object is still alive: " + data);
}
弱引用 (Weak Reference)
弱引用的对象拥有更短的生命周期。只要垃圾收集器进行清理,无论内存是否充足,弱引用的对象都会被回收。示例代码:
import java.lang.ref.WeakReference;
WeakReference<String> weakRef = new WeakReference<>(new String("Hello"));
String data = weakRef.get(); // 如果弱引用的对象还没有被回收,get() 将返回它
if (data == null) {
System.out.println("Object has been garbage collected.");
} else {
System.out.println("Object is still alive: " + data);
}
虚引用 (Phantom Reference)
虚引用是最弱的引用类型。一个对象如果有虚引用存在,几乎跟没有引用一样,无法通过虚引用来获取对象实例。虚引用的主要用途是跟踪对象的垃圾回收过程。示例代码:
import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;
ReferenceQueue<String> queue = new ReferenceQueue<>();
PhantomReference<String> phantomRef = new PhantomReference<>(new String("Hello"), queue);
// 无法通过 PhantomReference 获取对象实例
String data = phantomRef.get(); // 返回 null
if (queue.poll() != null) {
System.out.println("Object has been garbage collected and reference enqueued.");
}
在使用虚引用时,通常需要和ReferenceQueue
配合使用。当垃圾收集器准备回收一个对象时,如果发现它有虚引用,就会把这个虚引用加入到与之关联的引用队列中。通过检查这个队列,可以了解对象何时被回收。
spring循环依赖是什么?怎么解决?
循环依赖指的是两个类中的属性相互依赖对方:例如 A 类中有 B 属性,B 类中有 A属性,从而形成了一个依赖闭环,如下图。循环依赖问题在Spring中主要有三种情况:
-
第一种:通过构造方法进行依赖注入时产生的循环依赖问题。 -
第二种:通过setter方法进行依赖注入且是在多例(原型)模式下产生的循环依赖问题。 -
第三种:通过setter方法进行依赖注入且是在单例模式下产生的循环依赖问题。
只有【第三种方式】的循环依赖问题被 Spring 解决了,其他两种方式在遇到循环依赖问题时,Spring都会产生异常。Spring 解决单例模式下的setter循环依赖问题的主要方式是通过三级缓存解决循环依赖。三级缓存指的是 Spring 在创建 Bean 的过程中,通过三级缓存来缓存正在创建的 Bean,以及已经创建完成的 Bean 实例。具体步骤如下:
-
实例化 Bean:Spring 在实例化 Bean 时,会先创建一个空的 Bean 对象,并将其放入一级缓存中。 -
属性赋值:Spring 开始对 Bean 进行属性赋值,如果发现循环依赖,会将当前 Bean 对象提前暴露给后续需要依赖的 Bean(通过提前暴露的方式解决循环依赖)。 -
初始化 Bean:完成属性赋值后,Spring 将 Bean 进行初始化,并将其放入二级缓存中。 -
注入依赖:Spring 继续对 Bean 进行依赖注入,如果发现循环依赖,会从二级缓存中获取已经完成初始化的 Bean 实例。
通过三级缓存的机制,Spring 能够在处理循环依赖时,确保及时暴露正在创建的 Bean 对象,并能够正确地注入已经初始化的 Bean 实例,从而解决循环依赖问题,保证应用程序的正常运行。
spring有哪些常用注解?
Spring框架提供了许多注解来简化Java开发中的依赖注入(DI)和面向切面编程(AOP)。下面列举了一些Spring中常用的注解及其用途:
-
依赖注入相关注解: -
@Autowired
: 自动装配Bean,Spring会自动寻找类型匹配的Bean并注入。 -
@Qualifier
: 与@Autowired
配合使用,当有多个相同类型的Bean时,可以指定具体要注入哪一个。 -
@Primary
: 当存在多个相同类型的Bean时,指定首选的Bean。 -
@Resource
: 用于注入资源,可以指定名称,优先级低于@Autowired
。 -
组件扫描和Bean定义注解: -
@Component
: 泛指组件,用于定义Spring Bean。 -
@Repository
: 用于数据访问层(DAO层)的Bean定义。 -
@Service
: 用于业务逻辑层的Bean定义。 -
@Controller
: 用于Web层的Bean定义。 -
@RestController
: 结合了@Controller
和@ResponseBody
,用于RESTful风格的Web层Bean定义。 -
@Configuration
: 标记类为配置类,可以替代XML配置文件。 -
@Bean
: 在配置类中定义Bean,可以替代XML配置中的<bean>
标签。 -
作用域相关注解: -
@Scope
: 定义Bean的作用域,如单例(singleton)、原型(prototype)等。 -
切面编程相关注解: -
@Aspect
: 标记类为切面。 -
@Pointcut
: 定义切入点表达式。 -
@Before
: 在切入点方法之前执行。 -
@After
: 在切入点方法之后执行,无论方法是否成功。 -
@AfterReturning
: 在切入点方法成功返回后执行。 -
@AfterThrowing
: 在切入点方法抛出异常后执行。 -
Spring MVC和Web相关注解: -
@RequestMapping
: 用于映射HTTP请求到处理方法。 -
@GetMapping
,@PostMapping
,@PutMapping
,@DeleteMapping
: 分别用于映射GET、POST、PUT、DELETE请求。 -
@PathVariable
: 用于获取URL中的变量值。 -
@RequestParam
: 用于获取URL查询字符串中的参数值。 -
@RequestBody
: 用于将HTTP请求体中的数据绑定到方法参数上。 -
@ResponseBody
: 表明方法的返回值应该直接写入HTTP响应体中。 -
Spring Boot相关注解: -
@SpringBootApplication
: 包含了@Configuration
、@EnableAutoConfiguration
和@ComponentScan
的功能,用于标记Spring Boot的主配置类。 -
@EnableAutoConfiguration
: 开启自动配置功能,让Spring Boot能够自动配置应用。
JVM中堆分为哪些区域?
根据 JVM8 规范,JVM 运行时内存共分为虚拟机栈、堆、元空间、程序计数器、本地方法栈五个部分。还有一部分内存叫直接内存,属于操作系统的本地内存,也是可以直接操作的。
JVM的内存结构主要分为以下几个部分:
-
元空间:元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。 -
Java 虚拟机栈:每个线程有一个私有的栈,随着线程的创建而创建。栈里面存着的是一种叫“栈帧”的东西,每个方法会创建一个栈帧,栈帧中存放了局部变量表(基本数据类型和对象引用)、操作数栈、方法出口等信息。栈的大小可以固定也可以动态扩展。 -
本地方法栈:与虚拟机栈类似,区别是虚拟机栈执行java方法,本地方法站执行native方法。在虚拟机规范中对本地方法栈中方法使用的语言、使用方法与数据结构没有强制规定,因此虚拟机可以自由实现它。 -
程序计数器:程序计数器可以看成是当前线程所执行的字节码的行号指示器。在任何一个确定的时刻,一个处理器(对于多内核来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,我们称这类内存区域为“线程私有”内存。 -
堆内存:堆内存是 JVM 所有线程共享的部分,在虚拟机启动的时候就已经创建。所有的对象和数组都在堆上进行分配。这部分空间可通过 GC 进行回收。当申请不到空间时会抛出 OutOfMemoryError。堆是JVM内存占用最大,管理最复杂的一个区域。其唯一的用途就是存放对象实例:所有的对象实例及数组都在对上进行分配。jdk1.8后,字符串常量池从永久代中剥离出来,存放在队中。 -
直接内存:直接内存并不是虚拟机运行时数据区的一部分,也不是Java 虚拟机规范中农定义的内存区域。在JDK1.4 中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O 方式,它可以使用native 函数库直接分配堆外内存,然后通脱一个存储在Java堆中的DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
Java中堆和栈的区别?
-
用途:栈主要用于存储局部变量、方法调用的参数、方法返回地址以及一些临时数据。每当一个方法被调用,一个栈帧(stack frame)就会在栈中创建,用于存储该方法的信息,当方法执行完毕,栈帧也会被移除。堆用于存储对象的实例(包括类的实例和数组)。当你使用 new
关键字创建一个对象时,对象的实例就会在堆上分配空间。 -
生命周期:栈中的数据具有确定的生命周期,当一个方法调用结束时,其对应的栈帧就会被销毁,栈中存储的局部变量也会随之消失。堆中的对象生命周期不确定,对象会在垃圾回收机制(Garbage Collection, GC)检测到对象不再被引用时才被回收。 -
存取速度:栈的存取速度通常比堆快,因为栈遵循先进后出(LIFO, Last In First Out)的原则,操作简单快速。堆的存取速度相对较慢,因为对象在堆上的分配和回收需要更多的时间,而且垃圾回收机制的运行也会影响性能。 -
存储空间:栈的空间相对较小,且固定,由操作系统管理。当栈溢出时,通常是因为递归过深或局部变量过大。堆的空间较大,动态扩展,由JVM管理。堆溢出通常是由于创建了太多的大对象或未能及时回收不再使用的对象。 -
可见性:栈中的数据对线程是私有的,每个线程有自己的栈空间。堆中的数据对线程是共享的,所有线程都可以访问堆上的对象。
Redis
Redis常用的数据结构及底层
Redis 提供了丰富的数据类型,常见的有五种数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)。
-
String 类型的应用场景:缓存对象、常规计数、分布式锁、共享 session 信息等。 -
List 类型的应用场景:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。 -
Hash 类型:缓存对象、购物车等。 -
Set 类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。 -
Zset 类型:排序场景,比如排行榜、电话和姓名排序等。
String 类型内部实现
String 类型的底层的数据结构实现主要是 SDS(简单动态字符串)。SDS 和我们认识的 C 字符串不太一样,之所以没有使用 C 语言的字符串表示,因为 SDS 相比于 C 的原生字符串:
-
SDS 不仅可以保存文本数据,还可以保存二进制数据。因为 SDS 使用 len 属性的值而不是空字符来判断字符串是否结束,并且 SDS 的所有 API 都会以处理二进制的方式来处理 SDS 存放在 buf[] 数组里的数据。所以 SDS 不光能存放文本数据,而且能保存图片、音频、视频、压缩文件这样的二进制数据。 -
**SDS 获取字符串长度的时间复杂度是 O(1)**。因为 C 语言的字符串并不记录自身长度,所以获取长度的复杂度为 O(n);而 SDS 结构里用 len 属性记录了字符串长度,所以复杂度为 O(1)。 -
Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出。因为 SDS 在拼接字符串之前会检查 SDS 空间是否满足要求,如果空间不够会自动扩容,所以不会导致缓冲区溢出的问题。
List 类型内部实现
List 类型的底层数据结构是由双向链表或压缩列表实现的:
-
如果列表的元素个数小于 512 个(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的值都小于 64 字节(默认值,可由 list-max-ziplist-value 配置),Redis 会使用压缩列表作为 List 类型的底层数据结构; -
如果列表的元素不满足上面的条件,Redis 会使用双向链表作为 List 类型的底层数据结构;
但是在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表。
Hash 类型内部实现
Hash 类型的底层数据结构是由压缩列表或哈希表实现的:
-
如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用压缩列表作为 Hash 类型的底层数据结构; -
如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的底层数据结构。
在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。
Set 类型内部实现
Set 类型的底层数据结构是由哈希表或整数集合实现的:
-
如果集合中的元素都是整数且元素个数小于 512 (默认值,set-maxintset-entries配置)个,Redis 会使用整数集合作为 Set 类型的底层数据结构; -
如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为 Set 类型的底层数据结构。
ZSet 类型内部实现
Zset 类型的底层数据结构是由压缩列表或跳表实现的:
-
如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构; -
如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构;
在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。
Redis跳表的原理及应用场景
Redis 只有 Zset 对象的底层实现用到了跳表,跳表的优势是能支持平均 O(logN) 复杂度的节点查找。
跳表结构设计
链表在查找元素的时候,因为需要逐一查找,所以查询效率非常低,时间复杂度是O(N),于是就出现了跳表。跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表,这样的好处是能快读定位数据。那跳表长什么样呢?我这里举个例子,下图展示了一个层级为 3 的跳表。图中头节点有 L0~L2 三个头指针,分别指向了不同层级的节点,然后每个层级的节点都通过指针连接起来:
-
L0 层级共有 5 个节点,分别是节点1、2、3、4、5; -
L1 层级共有 3 个节点,分别是节点 2、3、5; -
L2 层级只有 1 个节点,也就是节点 3 。
如果我们要在链表中查找节点 4 这个元素,只能从头开始遍历链表,需要查找 4 次,而使用了跳表后,只需要查找 2 次就能定位到节点 4,因为可以在头节点直接从 L2 层级跳到节点 3,然后再往前遍历找到节点 4。
可以看到,这个查找过程就是在多个层级上跳来跳去,最后定位到元素。当数据量很大时,跳表的查找复杂度就是 O(logN)。
那跳表节点是怎么实现多层级的呢?这就需要看「跳表节点」的数据结构了,如下:
typedef struct zskiplistNode {
//Zset 对象的元素值
sds ele;
//元素权重值
double score;
//后向指针
struct zskiplistNode *backward;
//节点的level数组,保存每层上的前向指针和跨度
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;
Zset 对象要同时保存「元素」和「元素的权重」,对应到跳表节点结构里就是 sds 类型的 ele 变量和 double 类型的 score 变量。每个跳表节点都有一个后向指针(struct zskiplistNode *backward),指向前一个节点,目的是为了方便从跳表的尾节点开始访问节点,这样倒序查找时很方便。
跳表是一个带有层级关系的链表,而且每一层级可以包含多个节点,每一个节点通过指针连接起来,实现这一特性就是靠跳表节点结构体中的zskiplistLevel 结构体类型的 level 数组。
level 数组中的每一个元素代表跳表的一层,也就是由 zskiplistLevel 结构体表示,比如 leve[0] 就表示第一层,leve[1] 就表示第二层。zskiplistLevel 结构体里定义了「指向下一个跳表节点的指针」和「跨度」,跨度时用来记录两个节点之间的距离。
比如,下面这张图,展示了各个节点的跨度。第一眼看到跨度的时候,以为是遍历操作有关,实际上并没有任何关系,遍历操作只需要用前向指针(struct zskiplistNode *forward)就可以完成了。
跨度实际上是为了计算这个节点在跳表中的排位。具体怎么做的呢?因为跳表中的节点都是按序排列的,那么计算某个节点排位的时候,从头节点点到该结点的查询路径上,将沿途访问过的所有层的跨度累加起来,得到的结果就是目标节点在跳表中的排位。
跳表节点查询过程
查找一个跳表节点的过程时,跳表会从头节点的最高层开始,逐一遍历每一层。在遍历某一层的跳表节点时,会用跳表节点中的 SDS 类型的元素和元素的权重来进行判断,共有两个判断条件:
-
如果当前节点的权重「小于」要查找的权重时,跳表就会访问该层上的下一个节点。 -
如果当前节点的权重「等于」要查找的权重时,并且当前节点的 SDS 类型数据「小于」要查找的数据时,跳表就会访问该层上的下一个节点。
如果上面两个条件都不满足,或者下一个节点为空时,跳表就会使用目前遍历到的节点的 level 数组里的下一层指针,然后沿着下一层指针继续查找,这就相当于跳到了下一层接着查找。
跳表节点层数设置
跳表的相邻两层的节点数量的比例会影响跳表的查询性能。
举个例子,下图的跳表,第二层的节点数量只有 1 个,而第一层的节点数量有 6 个。
这时,如果想要查询节点 6,那基本就跟链表的查询复杂度一样,就需要在第一层的节点中依次顺序查找,复杂度就是 O(N) 了。所以,为了降低查询复杂度,我们就需要维持相邻层结点数间的关系。
**跳表的相邻两层的节点数量最理想的比例是 2:1,查找复杂度可以降低到 O(logN)**。
下图的跳表就是,相邻两层的节点数量的比例是 2 : 1。那怎样才能维持相邻两层的节点数量的比例为 2 : 1 呢?
如果采用新增节点或者删除节点时,来调整跳表节点以维持比例的方法的话,会带来额外的开销。
Redis 则采用一种巧妙的方法是,跳表在创建节点的时候,随机生成每个节点的层数,并没有严格维持相邻两层的节点数量比例为 2 : 1 的情况。
具体的做法是,跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数。
这样的做法,相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 64。虽然我前面讲解跳表的时候,图中的跳表的「头节点」都是 3 层高,但是其实如果层高最大限制是 64,那么在创建跳表「头节点」的时候,就会直接创建 64 层高的头节点。
MySQL
B+树的优缺点是什么?
-
B+树有一个最大的好处,方便扫库,B树必须用中序遍历的方法按序扫库,而B+树直接从叶子结点挨个扫一遍就完了。B+树支持range-query(区间查询)非常方便,而B树不支持。这是数据库选用B+树的最主要原因。 -
B+树最大的性能问题是会产生大量的随机IO,随着新数据的插入,叶子节点会慢慢分裂,逻辑上连续的叶子节点在物理上往往不连续,甚至分离的很远,但做范围查询时,会产生大量读随机IO。对于大量的随机写也一样,举一个插入key跨度很大的例子,如7->1000->3->2000 ... 新插入的数据存储在磁盘上相隔很远,会产生大量的随机写IO。
索引的实践优化知道哪些?
常见优化索引的方法:
-
前缀索引优化:使用前缀索引是为了减小索引字段大小,可以增加一个索引页中存储的索引值,有效提高索引的查询速度。在一些大字符串的字段作为索引时,使用前缀索引可以帮助我们减小索引项的大小。 -
覆盖索引优化:覆盖索引是指 SQL 中 query 的所有字段,在索引 B+Tree 的叶子节点上都能找得到的那些索引,从二级索引中查询得到记录,而不需要通过聚簇索引查询获得,可以避免回表的操作。 -
主键索引最好是自增的: -
如果我们使用自增主键,那么每次插入的新数据就会按顺序添加到当前索引节点的位置,不需要移动已有的数据,当页面写满,就会自动开辟一个新页面。因为每次插入一条新记录,都是追加操作,不需要重新移动数据,因此这种插入数据的方法效率非常高。 -
如果我们使用非自增主键,由于每次插入主键的索引值都是随机的,因此每次插入新的数据时,就可能会插入到现有数据页中间的某个位置,这将不得不移动其它数据来满足新数据的插入,甚至需要从一个页面复制数据到另外一个页面,我们通常将这种情况称为页分裂。页分裂还有可能会造成大量的内存碎片,导致索引结构不紧凑,从而影响查询效率。 -
防止索引失效: -
当我们使用左或者左右模糊匹配的时候,也就是 like %xx
或者like %xx%
这两种方式都会造成索引失效; -
当我们在查询条件中对索引列做了计算、函数、类型转换操作,这些情况下都会造成索引失效; -
联合索引要能正确使用需要遵循最左匹配原则,也就是按照最左优先的方式进行索引的匹配,否则就会导致索引失效。 -
在 WHERE 子句中,如果在 OR 前的条件列是索引列,而在 OR 后的条件列不是索引列,那么索引会失效。
查询的实践优化你知道哪些?
-
使用EXPLAIN分析查询计划:EXPLAIN命令可以帮助你理解数据库如何执行查询,包括使用的索引、表的扫描方式等,从而找出性能瓶颈。 -
避免SELECT :明确列出查询中需要的列,而不是使用SELECT *。这可以减少数据传输量,提高查询速度。 -
创建或优化索引:根据查询条件创建合适的索引,特别是经常用于WHERE子句的字段、Orderby 排序的字段、Join 连表查询的字典、 group by的字段,并且如果查询中经常涉及多个字段,考虑创建联合索引,使用联合索引要符合最左匹配原则,不然会索引失效 -
限制结果集大小:使用LIMIT限制返回的行数,特别是处理大数据量时,这可以显著提高查询速度。 -
避免索引失效:比如不要用左模糊匹配、函数计算、表达式计算等等。 -
分页优化:针对 limit n,y 深分页的查询优化,可以把Limit查询转换成某个位置的查询:select * from tb_sku where id>20000 limit 10,该方案适用于主键自增的表, -
使用分批处理:处理大量数据时,可以考虑分批加载数据,减少内存压力和锁的竞争。 -
合理使用缓存:对于频繁查询的结果,可以考虑使用缓存机制,减少数据库的查询压力。 -
优化数据库表:如果单表的数据超过了千万级别,考虑是否需要将大表拆分为小表,减轻单个表的查询压力。也可以将字段多的表分解成多个表,有些字段使用频率高,有些低,数据量大时,会由于使用频率低的存在而变慢,可以考虑分开。
MySQL事务4大特性?如何保证?
-
原子性(Atomicity):一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节,而且事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样,就好比买一件商品,购买成功时,则给商家付了钱,商品到手;购买失败时,则商品在商家手中,消费者的钱也没花出去。 -
一致性(Consistency):是指事务操作前和操作后,数据满足完整性约束,数据库保持一致性状态。比如,用户 A 和用户 B 在银行分别有 800 元和 600 元,总共 1400 元,用户 A 给用户 B 转账 200 元,分为两个步骤,从 A 的账户扣除 200 元和对 B 的账户增加 200 元。一致性就是要求上述步骤操作后,最后的结果是用户 A 还有 600 元,用户 B 有 800 元,总共 1400 元,而不会出现用户 A 扣除了 200 元,但用户 B 未增加的情况(该情况,用户 A 和 B 均为 600 元,总共 1200 元)。 -
隔离性(Isolation):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致,因为多个事务同时使用相同的数据时,不会相互干扰,每个事务都有一个完整的数据空间,对其他并发事务是隔离的。也就是说,消费者购买商品这个事务,是不影响其他消费者购买的。 -
持久性(Durability):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
InnoDB 引擎通过什么技术来保证事务的这四个特性的呢?
-
持久性是通过 redo log (重做日志)来保证的; -
原子性是通过 undo log(回滚日志) 来保证的; -
隔离性是通过 MVCC(多版本并发控制) 或锁机制来保证的; -
一致性则是通过持久性+原子性+隔离性来保证;
MVCC如何实现的?
对于「读提交」和「可重复读」隔离级别的事务来说,它们是通过 Read View 来实现的,它们的区别在于创建 Read View 的时机不同,大家可以把 Read View 理解成一个数据快照,就像相机拍照那样,定格某一时刻的风景。
-
「读提交」隔离级别是在「每个select语句执行前」都会重新生成一个 Read View; -
「可重复读」隔离级别是执行第一条select时,生成一个 Read View,然后整个事务期间都在用这个 Read View。
Read View 有四个重要的字段:
-
m_ids :指的是在创建 Read View 时,当前数据库中「活跃事务」的事务 id 列表,注意是一个列表,“活跃事务”指的就是,启动了但还没提交的事务。 -
min_trx_id :指的是在创建 Read View 时,当前数据库中「活跃事务」中事务 id 最小的事务,也就是 m_ids 的最小值。 -
max_trx_id :这个并不是 m_ids 的最大值,而是创建 Read View 时当前数据库中应该给下一个事务的 id 值,也就是全局事务中最大的事务 id 值 + 1; -
creator_trx_id :指的是创建该 Read View 的事务的事务 id。
对于使用 InnoDB 存储引擎的数据库表,它的聚簇索引记录中都包含下面两个隐藏列:
-
trx_id,当一个事务对某条聚簇索引记录进行改动时,就会把该事务的事务 id 记录在 trx_id 隐藏列里; -
roll_pointer,每次对某条聚簇索引记录进行改动时,都会把旧版本的记录写入到 undo 日志中,然后这个隐藏列是个指针,指向每一个旧版本记录,于是就可以通过它找到修改前的记录。
在创建 Read View 后,我们可以将记录中的 trx_id 划分这三种情况:一个事务去访问记录的时候,除了自己的更新记录总是可见之外,还有这几种情况:
-
如果记录的 trx_id 值小于 Read View 中的 min_trx_id 值,表示这个版本的记录是在创建 Read View 前已经提交的事务生成的,所以该版本的记录对当前事务可见。 -
如果记录的 trx_id 值大于等于 Read View 中的 max_trx_id 值,表示这个版本的记录是在创建 Read View 后才启动的事务生成的,所以该版本的记录对当前事务不可见。 -
如果记录的 trx_id 值在 Read View 的 min_trx_id 和 max_trx_id 之间,需要判断 trx_id 是否在 m_ids 列表中: -
如果记录的 trx_id 在 m_ids 列表中,表示生成该版本记录的活跃事务依然活跃着(还没提交事务),所以该版本的记录对当前事务不可见。 -
如果记录的 trx_id 不在 m_ids列表中,表示生成该版本记录的活跃事务已经被提交,所以该版本的记录对当前事务可见。
这种通过「版本链」来控制并发事务访问同一个记录时的行为就叫 MVCC(多版本并发控制)。
MySQL 索引分类有哪些?
MySQL可以按照四个角度来分类索引。
-
按「数据结构」分类:B+tree索引、Hash索引、Full-text索引。 -
按「物理存储」分类:聚簇索引(主键索引)、二级索引(辅助索引)。 -
按「字段特性」分类:主键索引、唯一索引、普通索引、前缀索引。 -
按「字段个数」分类:单列索引、联合索引。
MySQL事务的原子性怎么保证的
如果我们每次在事务执行过程中,都记录下回滚时需要的信息到一个日志里,那么在事务执行中途发生了 MySQL 崩溃后,就不用担心无法回滚到事务之前的数据,我们可以通过这个日志回滚到事务之前的数据。实现这一机制就是 undo log(回滚日志),它保证了事务的 ACID 特性中的原子性(Atomicity)。
undo log 是一种用于撤销回退的日志。在事务没提交之前,MySQL 会先记录更新前的数据到 undo log 日志文件里面,当事务回滚时,可以利用 undo log 来进行回滚。如下图:
每当 InnoDB 引擎对一条记录进行操作(修改、删除、新增)时,要把回滚时需要的信息都记录到 undo log 里,比如:
-
在插入一条记录时,要把这条记录的主键值记下来,这样之后回滚时只需要把这个主键值对应的记录删掉就好了; -
在删除一条记录时,要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中就好了; -
在更新一条记录时,要把被更新的列的旧值记下来,这样之后回滚时再把这些列更新为旧值就好了。
在发生回滚时,就读取 undo log 里的数据,然后做原先相反操作。比如当 delete 一条记录时,undo log 中会把记录中的内容都记下来,然后执行回滚操作的时候,就读取 undo log 里的数据,然后进行 insert 操作。
MySQL怎么解决慢查询问题
-
分析查询语句:使用EXPLAIN命令分析SQL执行计划,找出慢查询的原因,比如是否使用了全表扫描,是否存在索引未被利用的情况等,并根据相应情况对索引进行适当修改。 -
创建或优化索引:根据查询条件创建合适的索引,特别是经常用于WHERE子句的字段。并且如果查询中经常涉及多个字段,考虑创建联合索引。 -
优化数据库架构:考虑是否需要将大表拆分为小表,减轻单个表的查询压力。 -
使用缓存技术:引入缓存层,如Redis或Memcached,存储热点数据和频繁查询的结果。实施缓存更新策略,如Cache Aside或Write Through,确保数据一致性。 -
查询优化:避免使用SELECT *,只查询真正需要的列。使用覆盖索引,即索引包含所有查询的字段。尽量减少子查询和临时表的使用,转而使用JOIN或窗口函数。 -
数据库监控与日志分析:定期查看数据库的慢查询日志,找出慢查询的模式。 -
代码层面优化:检查应用程序代码,避免N+1查询问题,优化数据加载逻辑。使用数据库连接池,减少连接建立和断开的开销。
explain执行计划有哪些信息?
在MySQL中,EXPLAIN是用于分析和优化SQL查询的性能。当你在查询前加上EXPLAIN关键字,MySQL会返回查询的执行计划,这可以帮助你理解MySQL优化器如何执行查询,以及查询的各个部分是如何被处理的。EXPLAIN 的基本语法如下:
EXPLAIN SELECT ...;
执行后,我们可以得到如下图所示的信息EXPLAIN可以揭示以下信息:
-
查询中涉及的表及其访问方式。 -
索引的使用情况。 -
查询中表的连接顺序。 -
数据读取的类型,比如全表扫描(full table scan)还是使用索引(index scan)。 -
是否使用了临时表或文件排序等。
接下来我们来具体看看EXPLAIN的参数有哪些:
-
possible_keys 字段表示可能用到的索引; -
key 字段表示实际用的索引,如果这一项为 NULL,说明没有使用索引; -
key_len 表示索引的长度; -
rows 表示扫描的数据行数。 -
type 表示数据扫描类型,我们需要重点看这个。
type 字段就是描述了找到所需数据时使用的扫描方式是什么,常见扫描类型的执行效率从低到高的顺序为:
-
All(全表扫描):在这些情况里,all 是最坏的情况,因为采用了全表扫描的方式 -
index(全索引扫描):index 和 all 差不多,只不过 index 对索引表进行全扫描,这样做的好处是不再需要对数据进行排序,但是开销依然很大。所以,要尽量避免全表扫描和全索引扫描。 -
range(索引范围扫描):range 表示采用了索引范围扫描,一般在 where 子句中使用 < 、>、in、between 等关键词,只检索给定范围的行,属于范围查找。从这一级别开始,索引的作用会越来越明显,因此我们需要尽量让 SQL 查询可以使用到 range 这一级别及以上的 type 访问方式。 -
ref(非唯一索引扫描):ref 类型表示采用了非唯一索引,或者是唯一索引的非唯一性前缀,返回数据返回可能是多条。因为虽然使用了索引,但该索引列的值并不唯一,有重复。这样即使使用索引快速查找到了第一条数据,仍然不能停止,要进行目标值附近的小范围扫描。但它的好处是它并不需要扫全表,因为索引是有序的,即便有重复值,也是在一个非常小的范围内扫描。 -
eq_ref(唯一索引扫描):eq_ref 类型是使用主键或唯一索引时产生的访问方式,通常使用在多表联查中。比如,对两张表进行联查,关联条件是两张表的 user_id 相等,且 user_id 是唯一索引,那么使用 EXPLAIN 进行执行计划查看的时候,type 就会显示 eq_ref。 -
const(结果只有一条的主键或唯一索引扫描):const 类型表示使用了主键或者唯一索引与常量值进行比较,比如 select name from product where id=1。
除了关注 type,我们也要关注 extra 显示的结果。这里说几个重要的参考指标:
-
Using filesort :当查询语句中包含 group by 操作,而且无法利用索引完成排序操作的时候, 这时不得不选择相应的排序算法进行,甚至可能会通过文件排序,效率是很低的,所以要避免这种问题的出现。 -
Using temporary:使了用临时表保存中间结果,MySQL 在对查询结果排序时使用临时表,常见于排序 order by 和分组查询 group by。效率低,要避免这种问题的出现。 -
Using index:所需数据只需在索引即可全部获得,不须要再到表中取数据,也就是使用了覆盖索引,避免了回表操作,效率不错。
框架
请求到SpringBoot具体处理函数的流程
-
请求接收:当一个HTTP请求到达服务器时,它会被服务器容器(如Tomcat、Jetty或Undertow)接收。服务器容器将请求转发给Spring Boot的前端控制器 DispatcherServlet
。 -
前端控制器处理: DispatcherServlet
作为Spring MVC的核心组件,负责接收请求并进行初步处理。它会委托HttpRequestHandlerMapping
或HandlerMapping
接口的实现类去寻找合适的处理器(即控制器方法)。 -
映射处理器: HandlerMapping
接口的实现类(如RequestMappingHandlerMapping
)会根据URL、HTTP方法等信息,查找与请求匹配的处理器方法。查找成功后,HandlerMapping
会返回一个HandlerExecutionChain
对象,包含处理器对象和拦截器列表。 -
处理器适配器: DispatcherServlet
会调用HandlerAdapter
接口的实现类来确定如何调用处理器方法。找到合适的HandlerAdapter
后,它会调用HandlerAdapter
的handle
方法来执行处理器方法。 -
参数绑定和类型转换:在调用处理器方法前,参数绑定和类型转换会自动进行。这包括从请求中提取数据并将其转换为处理器方法所需的参数类型。 WebDataBinder
和HandlerMethodArgumentResolver
参与此过程。 -
拦截器执行:如果 HandlerExecutionChain
中包含拦截器,则在调用处理器方法前后会执行拦截器的preHandle
和postHandle
方法。 -
调用处理器方法:经过上述步骤后,具体的处理器方法(控制器方法)将被执行。控制器方法可能会返回一个模型和视图,或者直接返回一个响应体。 -
处理返回值:根据控制器方法的返回值类型, HandlerAdapter
会选择相应的ViewResolver
或ResponseBody
处理器来处理返回值。如果是视图,ViewResolver
会解析视图名称并生成视图对象。如果是响应体,HttpMessageConverter
会将返回的对象转换为HTTP响应体。 -
视图渲染:如果有视图对象,它会被渲染,将模型数据填充到视图模板中,生成HTML或其他格式的响应。 -
响应发送:最终的响应(HTML页面、JSON数据等)将被封装在HTTP响应中发送回客户端。 -
拦截器后处理:如果有拦截器, postHandle
和afterCompletion
方法将在响应发送后被调用,以便进行额外的后处理或资源清理。
算法
-
层序遍历
点击关注公众号,阅读更多精彩内容