面试十几家公司(小中大企业)总结的Java八股文,标记重点的一定要掌握,几乎50%概率会被问到。一直不推荐死记硬背,应该结合场景业务代码、手动画图加深理解,传承程序猿开源精神,现分享有需要的人。

一、框架篇

1.Spring框架中的单例bean是线程安全的吗?

       单例Bean 的线程安全性取决于 Bean 的具体实现。一般来说,在spring中的bean都是无状态(没有可修改的成员变量)的对象,就没有线程安全问题,但如果在bean中定义了可修改的成员变量,是需要考虑线程安全问题的。

       我们可以加锁,使用synchronized关键字进行同步,保证同一时刻只有一个线程能访问该方法;也可以通过 @Lookup 注解设置bean为多例,每次请求都会创建一个bean实例,多个线程操作互不影响,但性能受损;

2.能介绍一下动态代理吗

       动态代理是指在程序运行时通过反射机制自动生成代理对象,确保在不修改目标类的情况下,增强目标类的功能(类增强),Java提供了两种方式来实现动态代理:

       在SpringBoot2之前,Spring会根据目标对象的特性自动选择,比如目标类实现了至少一个接口默认使用JDK动态代理,如果没有实现接口就默认CGLIB代理;而SpringBoot2.2之后,统一使用CGLIB代理(无论目标类是否实现接口)

       JDK动态代理:基于接口的动态代理,只能代理实现了接口的类,创建代理对象速度快(直接根据接口生成字节码并由类加载器加载),方法调用较慢(需要通过Mehtod.invoke( )方法调用,反射方法相对较慢。说明:JDK6方法调用比CGLIB慢,但JDK7/8对反射调用做了优化,不比CGLIB慢);

       CGLIB动态代理:基于类继承的动态代理,生成目标类的子类来实现代理,代理对象创建速度慢(使用ASM字节码生成目标类的子类),方法调用快(直接覆盖目标方法,并在方法中插入拦截逻辑,没有走反射);

动态代理提供了高度的灵活性和扩展性,但也带来了性能和复杂性,广泛应用于AOP、远程代理、Spring的依赖注入等场景。

3.过滤器和拦截器有什么区别?

(1)过滤器是Servlet层面的,随Servlet容器启动初始化;拦截器是Spring框架的,随Spring容器启动初始化;

(2)过滤器在拦截器之前执行,可以处理所有的请求;而拦截器只能处理SpingMVC的Controller请求;

(3)过滤器操作更底层,一般处理跟业务无关的逻辑操作,比如请求/响应的全局编码设置;拦截器更贴近业务,比如用户登陆状态验证;

例子:过滤器设置请求编码 UTF-8

  1. @WebFilter("/*"// 拦截所有请求  
  2. public class EncodingFilter implements Filter {  
  3.     @Override  
  4.     public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)  
  5.             throws IOException, ServletException {  
  6.         // 设置编码  
  7.         request.setCharacterEncoding("UTF-8");  
  8.         response.setCharacterEncoding("UTF-8");  
  9.   
  10.         // 继续往下走  
  11.         chain.doFilter(request, response);  
  12.     }  

例子:登录拦截器(未登录跳转到登录页)

  1. public class LoginInterceptor implements HandlerInterceptor {  
  2.    @Override  
  3.     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)  
  4.             throws Exception {  
  5.         // 假设 session 里有 loginUser 才表示登录  
  6.         Object user = request.getSession().getAttribute("loginUser");  
  7.         if (user == null) {  
  8.             response.sendRedirect("/login"); // 跳转到登录页  
  9.             return false// 拦截  
  10.         }  
  11.         return true// 放行  
  12.     }  

4.spring事务失效的场景有哪些?

       事务底层是基于AOP实现的,而AOP是通过代理对象实现的。@Transactional注解的实现逻辑,可修饰类或方法:

(1)启动时扫描注解

当Spring容器启动时,TransactionAnnotationParser会扫描@Transactional注解,并将其解析为一个TransactionAttribute对象

(2)创建代理对象

Spring使用AOP为带有@Transactional的类生成代理对象,核心是TransactionInterceptor(事务拦截器)

(3)方法调用拦截

当外部调用代理对象的方法时,事务拦截器会拦截方法调用,拦截器通过TransactionManager来管理事务:判断当前是否存在事务,如果需要新建事务就调用begin( )方法,执行目标方法,方法执行成功则提交事务,异常时根据回滚规则决定是否回滚。

spring事务失效的场景如下:

(1)方法没有用public 修饰,代理默认只会拦截公共方法;

(2)同一个类中通过this来调用事务方法,这时候调用不会经过代理对象(事务的开启、提交、回滚等逻辑都是在代理对象的方法调用链中实现的,this是当前对象本身,不是代理对象)。解决方案:通过将自调用的方法提取到另一个服务类解决;

(3)事务注解标记在接口上而不是实现类上;

(4)自己手动捕获异常没有抛出,事务无法知悉

(5)事务默认只会回滚运行时异常和错误。发生受检查异常时事务无法回滚,可通过在catch中抛出RuntimeException异常;或者通过配置@Transactional(rollbackFor = Exception.class)设置回滚所有异常。

  1. @Service  
  2. public class OrderService {  
  3.    
  4.     @Autowired  
  5.     private OrderRepository orderRepository;  
  6.    
  7.     @Transactional  
  8.     public void updatePriceWrong(Long orderId) {  
  9.         try {  
  10.             // 更新数据库数据  
  11.             orderRepository.updatePrice(orderId, 20);  
  12.    
  13.             // 抛出受检查异常  
  14.             throw new IOException("模拟IO异常");  
  15.         } catch (IOException e) {  
  16.             // 手动捕获,但没有继续抛出  
  17.             System.out.println("捕获异常: " + e.getMessage());  
  18.         }  
  19.         // 此时事务感知不到异常,最终数据会提交!  
  20.     }  
  21. }  

5.spring的IOC容器和bean的创建过程?

IOC容器的创建过程为:

(1)读取配置文件:加载配置文件或配置类中的Bean定义;

(2)创建BeanFactory:初始化 DefaultListableBeanFactory 作为默认的Bean工厂,来管理 Bean的定义(BeanDefinition)和实例;

(3)解析BeanDefinition:Spring解析每个Bean,封装成BeanDefinition对象,存入BeanDefinitionMap中;

(4)注册BeanDefinition到BeanFactory中;

(5)创建单例Bean:ApplicationContext在容器刷新时就创建单例Bean

(1)实例化:Spring容器启动时,根据XML配置文件或注解扫描来获取Bean的定义信息,通过反射调用构造函数来创建Bean实例;

(2)依赖注入:Spring容器根据Bean的配置,通过setter注入、字段注入或构造器注入进行依赖注入;

(3)初始化:Spring容器为每个Bean调用初始化方法,比如Aware接口回调、前置处理等;

(4)使用Bean:在容器中,Bean可以随时被获取和使用;

(5)销毁bean:当容器关闭时,Spring会调用Bean的销毁方法来销毁bean

6.能说一下Spring中的循环引用吗,怎么解决

       循环依赖就是两个或多个模块、类(Bean)、组件之间互相依赖,形成一个闭环。循环依赖在spring中是允许的,spring框架利用三级缓存能解决大部分的循环依赖,其关键就是要提前暴露未完全创建好的Bean。

       一级缓存(Singleton Objects Map),单例池,用来存储完全初始化好的单例Bean;二级缓存(Early Singleton Objects Map):用来存储已经实例化但未完全初始化的Bean(用于提前暴露对象);三级缓存(Singleton Factories Map):用来存储对象工厂,可以通过工厂创建早期Bean(用于解决代理对象的创建)。

       一般的循环依赖,比如A和B对象通过setter注入形成循环依赖,首先创建A实例,将这个半成品A加入到二级缓存中,接着注入依赖B,从二级缓存中获取半成品A将B初始化, B创建成功,将B存储到单例池中,在单例池中将B注入给A,A创建成功,存储到单例池中;(但二级缓存不能解决代理对象,所以引入了三级缓存)

       创建A实例,将对象A生成ObjectFactory对象放入到三级缓存中,接着注入B,发现B不存在,实例化B,B也生成ObjectFactory对象放入三级缓存,B从三级缓存中获取A对象工厂创建的代理对象,代理对象放入二级缓存中,将A的代理对象注入给B,B创建成功放入单例池,最后将B注入给A,A也创建成功放入单例池;

以上的方法有两种条件:1.依赖的Bean必须是单例;2.不是通过构造器注入依赖的。对于构造行循环依赖可以使用@Lazy 注解进行懒加载来实现。

7.能说一下SpringMVC的执行流程吗?

       SpringMVC 是Spring框架中用于构建Web应用程序的核心模块,以前端控制器为基础(类似于调度器),提供了一套灵活强大的组件来处理HTTP请求、业务逻辑和视图渲染

(1)发送请求:客户端发送请求,被前端控制器拦截,将请求发送给处理器映射器;

(2)处理器映射:处理器映射器根据URL找到对应的Controller类和方法;

(3)处理器适配:找到处理器后,前端控制器交给处理器适配器执行,处理器适配器处理参数,将请求参数绑定到方法参数或对象里

(4)调用处理器:处理器适配器通过反射调用Controller中的业务方法,返回对象数据;

(5)响应数据:将对象转化为JSON,返回给前端渲染页面;

8.SpringBoot的自动配置原理能说一下吗?

       SprintBoot项目中的启动类上有一个注解@SpringBootApplication,这个注解封装@EnableAutoConfiguration注解:用来启动SpringBoot的自动配置机制,通过@Import 导入了 AutoConfigurationImportSelector在SpringBoot启动时,会扫描META-INF/ spring.factories文件(springboot2)/ AutoConfiguration.import(springboot3),文件里列出了所有自动配置类的路径位置,配置类通过各种@Conditional条件注解动态加载满足条件的bean,将其导入到Spring容器中;

常见的有:

  • @ConditionalOnClass:某个类在 classpath 下才生效(比如 JdbcTemplate)。
  • @ConditionalOnMissingBean:如果用户没有自定义这个 Bean,才注入默认的 Bean。
  • @ConditionalOnProperty:配置文件中存在某个配置项时才生效。

👉 这保证了 默认生效,但允许用户覆盖

9.Spring中的常用注解有哪些?

10.Mybatis的执行流程?

(1)配置加载阶段:解析Mybatis配置文件(如XML文件,或直接添加依赖,在 .yml 文件上配置Mybatis)根据配置文件创建会话工厂SqlSessionFactory;

(2)SQL执行阶段:通过会话工厂创建会话SqlSession实例(封装了JDBC操作,包含了执行SQL语句的方法);通过动态代理生成Mapper接口的实例;通过Executor执行SQL语句:1.将Java对象属性转换为SQL参数,2.进行SQL解析,替换${ } 和 #{ },生成最终的SQL执行,将结果转换为Java对象;

(3)释放资源阶段:Mybatis还提供了事务的管理,支持自动提交和手动控制事务;最后释放SqlSession对象资源,关闭数据库连接

11.spring的核心是什么?(重点)

(1)依赖注入(Dependency Injection,DI)

依赖注入是 Spring 框架的核心功能之一,它是基于控制反转(IoC,Inversion of Control)来实现。传统的开发中,组件通常会显式地创建其依赖的对象,而在 Spring 中, IoC 容器负责管理对象的生命周期及其依赖关系。

简化开发:通过将对象的创建和管理交给 Spring 容器,开发者只需要关注业务逻辑,而无需手动创建和管理对象的实例。

解耦:对象之间的依赖关系由 Spring 容器管理,而不是硬编码在代码中,这样可以降低模块之间的耦合度,提高代码的灵活性和可测试性。

依赖注入方式:

  1. 构造器注入:通过构造器传递依赖对象。
  2. Setter 注入:通过 setter 方法注入依赖对象。
  3. 字段注入:直接在字段上使用注解来注入依赖。

注意:官方推荐使用构造器注入的方式(因为依赖对象定义往往用final修饰,确保了依赖的不可变性和不为null,更加稳定),但企业中常用的是字段注入,更加方便;

  1. 面向切面编程(Aspect-Oriented Programming,AOP)

AOP称为面向切面编程,核心思想是将类中的公共行为(横切关注点)切割出来,封装为一个可重用的模块(切面类),在切面类中定义了切入点和通知。作用是减少系统中的重复代码,降低模块之间的耦合度,提高可维护性和可扩展性;

       而我在项目中也经常用到AOP,比如有很多数据库表都有一些公共字段(createTime, updateTime, createUser, updateUser),每次对这些表进行插入或修改操作都要更新这些公共字段,所以可以利用AOP封装一个模块,根据当前时间、用户ID对属性赋值,在mapper中添加注解就实现了公共字段的自动填充功能。

       而AOP在一些业务场景用的比较多的比如:记录操作日志、缓存处理等等。

(3)Spring 的 IoC 容器

IOC(Inversion of Control),控制反转,这是一种设计模式,核心思想是将对象的创建、依赖注入和生命周期管理交给IOC容器。在传统的编程方式中,我们一般需要在类中显示地创建依赖对象,通过硬编码方式来控制对象的创建和管理,而在Spring中,Bean以及对象之间的依赖关系都交给IOC容器负责,降低了代码之间的耦合度,也提高了系统的灵活性;

Spring中主要是通过XML配置和注解配置(@Component 、@Autowired、@Configuration)两种方式来注册Bean

例如:使用AOP給业务添加日志记录

  1. @Aspect    // 定义为切面类
  2. @Component  
  3. public class LogAspect {  
  4.   
  5.     // 1. 切入点:拦截所有 service 包下的方法  
  6.     @Pointcut("execution(* com.example.service.*.*(..))")  
  7.     public void serviceMethods() {}  
  8.   
  9.     // 2. 前置通知:方法执行前记录  
  10.     @Before("serviceMethods()")  
  11.     public void beforeMethod(JoinPoint joinPoint) {  
  12.         String methodName = joinPoint.getSignature().getName();  
  13.         Object[] args = joinPoint.getArgs();  
  14.         System.out.println("[前置日志正在调用方法:" + methodName + ",参数:" + Arrays.toString(args));  
  15.     }  
  16.   
  17.     // 3. 后置通知:方法执行后记录  
  18.     @AfterReturning(value = "serviceMethods()", returning = "result")  
  19.     public void afterMethod(JoinPoint joinPoint, Object result) {  
  20.         String methodName = joinPoint.getSignature().getName();  
  21.         System.out.println("[后置日志方法:" + methodName + 返回值:" + result);  
  22.     }  
  23.   
  24.     // 4. 异常通知:出现异常时记录  
  25.     @AfterThrowing(value = "serviceMethods()", throwing = "ex")  
  26.     public void afterThrowingMethod(JoinPoint joinPoint, Exception ex) {  
  27.         String methodName = joinPoint.getSignature().getName();  
  28.         System.out.println("[异常日志方法:" + methodName + 抛出了异常:" + ex.getMessage());  
  29.     }  
  30. }  

12. ObjectFactory 和 BeanFacotry的区别?

BeanFactory实际就是IOC容器,而ObjectFactory的作用如下:

(1)当需要推迟对象的创建来避免循环依赖或优化性能时,可以使用ObjectFactory

(2)在单例Bean中注入一个短作用域的Bean时,可以通过ObjectFactory确保获取最新的实例

13.Spring Bean一共有几种作用域?

       在注解配置@Scope或XML配置scope属性就可以设置Bean的作用域。

(1)singleton(默认),每个IOC容器仅存在一个实例,所有依赖注入共享同一个对象;

(2)prototype:每次注入依赖(或getBean( )方法)都会创建一个新实例;

(3)request:每个HTTP请求创建一个新实例,仅在Web应用中有效;

(4)session:每个HTTP Session创建一个实例,用户会话期间共享;

(5)application:每个ServletContext生命周期内一个实例;

(6)websocket:每个websocket会话一个实例,生命周期和websocket连接一致;

14.Spring拦截链的实现?

       在Spring框架中,拦截链通过责任链模式实现,核心目的是将多个拦截器(或通知)按顺序组织,在目标方法或请求处理的不同阶段依次触发:

       在Spring Web中,拦截器实现HandlerInterceptor接口,编写preHandel、postHandle、afterCompletion方法实现业务执行前后逻辑添加(如权限校验、日志记录),另外需要编写一个实现WebMvcConfigurer接口的配置类,注册拦截器,设置哪些路径需要拦截和放行,

执行步骤如下:

(1)初始化链:根据Hander(处理器/controller层)对应的URL和拦截器列表配置,创建处理器执行链

(2)preHandel阶段:按拦截器配置顺序依次调用preHandle方法。如果某个拦截器返回false,终止后续拦截器和handler的执行;

(3)执行handler:调用Controller方法处理请求;

(4)postHandler阶段:按拦截器配置的逆序调用postHandle( );

(5)渲染视图:处理ModelAndView,生成相应内容;

(6)afterCompletion阶段:无论请求成功或异常,按拦截器配置的逆序调用afterCompletion方法

✅ 1. 编写拦截器类

  1. import org.springframework.stereotype.Component;  
  2. import org.springframework.web.servlet.HandlerInterceptor;  
  3. import javax.servlet.http.HttpServletRequest;  
  4. import javax.servlet.http.HttpServletResponse;  
  5.   
  6. @Component  
  7. public class LogInterceptor implements HandlerInterceptor {  
  8.   
  9.     /** 
  10.      * 在请求处理之前执行(Controller方法调用前) 
  11.      * @return true 表示继续流程,false 表示拦截请求 
  12.      */  
  13.     @Override  
  14.     public boolean preHandle(HttpServletRequest request,  
  15.                              HttpServletResponse response,  
  16.                              Object handler) throws Exception {  
  17.         String uri = request.getRequestURI();  
  18.         System.out.println("拦截器日志:请求 URI 为:" + uri);  
  19.         return true;  
  20.     }  
  21.   
  22.     /** 
  23.      * 请求处理之后执行(Controller方法调用之后) 
  24.      */  
  25.     @Override  
  26.     public void postHandle(HttpServletRequest request,  
  27.                            HttpServletResponse response,  
  28.                            Object handler,  
  29.                            org.springframework.web.servlet.ModelAndView modelAndView) throws Exception {  
  30.         System.out.println("拦截器日志:请求处理完成(postHandle");  
  31.     }  
  32.   
  33.     /** 
  34.      * 请求完全完成之后执行(包括视图渲染后) 
  35.      */  
  36.     @Override  
  37.     public void afterCompletion(HttpServletRequest request,  
  38.                                 HttpServletResponse response,  
  39.                                 Object handler,  
  40.                                 Exception ex) throws Exception {  
  41.         System.out.println("拦截器日志:请求完全结束(afterCompletion");  
  42.     }  
  43. }  

✅ 2. 注册拦截器(配置类)

  1. import org.springframework.beans.factory.annotation.Autowired;  
  2. import org.springframework.context.annotation.Configuration;  
  3. import org.springframework.web.servlet.config.annotation.InterceptorRegistry;  
  4. import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;  
  5.   
  6. @Configuration  // Spring MVC 配置类  
  7. public class WebConfig implements WebMvcConfigurer {  
  8.   
  9.     @Autowired  
  10.     private LogInterceptor logInterceptor;  
  11.   
  12.     @Override  
  13.     public void addInterceptors(InterceptorRegistry registry) {  
  14.         registry.addInterceptor(logInterceptor)       // 注册拦截器  
  15.                 .addPathPatterns("/**")          // 拦截所有请求路径  
  16.                 .excludePathPatterns("/login""/error"); // 排除某些路径  
  17.     }  
  18. }  

15.spring用到了哪些设计模式?

(1)工厂模式:BeanFactory和ApplicationContext是Spring的核心容器,负责创建和管理Bean实例;

(2)单例模式:Spring默认将Bean的作用域设置为单例,保证容器中只有一个实例;

(3)代理模式:Spring AOP使用动态代理生成代理对象增强类;

(4)模板方法模式:jdbcTemplate、RestTemplate等模板类封装了固定流程,用户只需实现具体逻辑(SQL执行、HTTP请求);

(5)适配器模式:在SpringMVC中适配不同类型的控制器

16. Spring Bean注册到容器的方式有哪些?

(1)基于XML配置文件,显示声明Bean的类和依赖关系;

(2)基于注解扫描注册,通过@Component、@Service、@Controller注解定义为Bean,通过组件扫描(@ComponentScan)注册为Bean

(3)基于Java配置类注册,在@Configuration注解的类中的方法添加@Bean注解;

17. @Qualifier注解有什么作用?

       @Qualifier注解用于解决依赖注入的歧义性问题,当容器中存在多个相同类型的Bean时,@Autowired注解无法自动选择具体的Bean注入,此时需要配合@Qualifier来指定具体的Bean名称。

       @Qualifier注解和@Primary注解的区别:@Primary用来标记某个Bean为默认优先注入的候选者,而@Qualifier用来显示指定具体Bean的名称;

  1. @Component  
  2. public class UserService {  
  3.   
  4.     @Autowired  
  5.     @Qualifier("mysqlUserRepository"// 精确指定注入哪个 Bean  
  6.     private UserRepository userRepository;  

18. @Bean和@Component有什么区别?

(1)作用目标:@Component注解作用在类上;@Bean注解作用在方法上,一般在配置类;

(2)使用场景:@Service、@Controller等注解都是@Component的特化形式,一般用于自己编写的类;而@Bean一般用于第三方库的类或需要手动控制实例化的对象;

(3)使用方式:@Component标记一个类,让Spring自动扫描并创建Bean;@Bean需要显示地配置和创建,返回一个对象作为Bean

19. @Component、@Controller、@Repository、@Service注解的区别是什么?

(1)@Component是它们的通用注解,标记任意类为Bean,没有特定的功能,是其他注解的元注解;

(2)@Controller用于标记控制层,处理HTTP请求,支持请求URL映射;

(3)@Service用于标记业务类,没有额外功能,区分职责提高代码可读性;

(4)@Repository用于标记持久层,自动转换数据访问异常

20.spring启动过程?

(1)容器初始化:调用Application.run( )方法启动应用,创建ApplicationContext;

(2)配置加载:解析配置类、扫描组件、注册Bean的定义;

(3)Bean实例化和依赖注入:根据Bean的定义创建对象并注入依赖;

(4)扩展处理:在Bean初始化前后插入逻辑(如AOP代理),实现生命周期回调增强功能;

(5)容器就绪:完成启动,通过事件机制实现扩展点

21. springBoot启动过程?

(1)初始化:调用Application.run( )方法启动应用,加载配置器和监听器;

(2)准备环境:加载配置文件(如application.properties)

(3)创建ApplicationContext:初始化容器;

(4)刷新容器:加载Bean定义、执行自动配置、启动内嵌容器、Bean的依赖注入初始化;

(5)执行自定义执行后逻辑;

(6)发布 ApplicationReadyEvent:标志应用完全就绪

22. application.properties和application.yml和application.ymal有什么区别?

(1)语法格式:.properties文件使用键值对格式,通过 . 表示层级关系;yml和ymal文件基于YAML格式,使用换行缩进表示层级关系,可读性更高;

(2)加载顺序:同目录下,.properties 文件的优先级高于 .yml/.yaml ; 不同目录下,按目录优先级覆盖(例如根目录的配置覆盖类路径的配置)

(3)复杂数据结构:.properties文件对于列表需要逗号隔开,对象之间需要手动拆分;yml和ymal文件对于多个对象只需要换行,对于列表只需要换行加短横线,操作更加方便;

示例:application.properties

  1. 普通字符串  
  2. app.name=DemoApp  
  3.   
  4. 列表  
  5. app.servers=192.168.1.1,192.168.1.2,192.168.1.3  
  6.   
  7. 嵌套对象  
  8. app.datasource.url=jdbc:mysql://localhost:3306/test  
  9. app.datasource.username=root  
  10. app.datasource.password=123456  

示例:application.yml

  1. app:  
  2.   name: DemoApp  
  3.   servers:  
  4.     - 192.168.1.1  
  5.     - 192.168.1.2  
  6.     - 192.168.1.3  
  7.   
  8.   datasource:  
  9.     url: jdbc:mysql://localhost:3306/test  
  10.     username: root  
  11.     password: 123456  

23.能说一下SpringBoot吗?

SpringBoot是一个基于Spring框架的开源工具,旨在简化Java应用(尤其是Web应用)的初始搭建和开发过程。通过“约定优于配置”的理念,大幅度减少传统Spring开发中的复杂配置,让开发者能快速构建独立运行、生产级的应用。

说明:传统的Spring需要大量的XML或@Bean配置,而Spring Boot内置了一套默认的约定,如果没有提供配置,Spring Boot就会按照“约定”的默认逻辑工作,只有当你的需求不同于默认约定时,才需要额外配置。比如,在pom.xml引入spring-boot-starter-data-jpa依赖,SpringBoot自动创建DataSource的Bean,默认使用application.yml中的配置。

它的特性如下:

(1)自动配置:SpringBoot根据@EnableAutoConfiguration注解开启自动配置机制,扫描AutoConfiguration.imports文件根据条件动态加载预定义的配置类;传统的Spring需要手动编写配置类,显示定义dataSource等一些Bean。

(2)起步依赖:通过预定义集成的依赖包(一系列Starter),自动引入所有相关依赖包,避免版本冲突;传统的Spring需要手动指定每个依赖及其兼容版本;

(3)内嵌服务器:添加spring-boot-starter-web依赖后,就会内嵌Tomcat、SpringMVC服务器,应用可直接打包为可执行JAR包,无需额外部署到web服务器;传统的Spring需要打包为WAR包,部署到独立的Tomcat服务器;

(4)生产就绪功能:SpringBoot提供监控和管理端点(如健康检查、性能指标),方便运维;传统的Spring需要自行配置健康检查、指标收集等;

(5)简化配置约定优于配置原则,默认提供合理的配置参数(如端口号、数据库连接池参数)和创建默认配置类,可通过properties文件或yml文件做修改覆盖;

24. 说一下Mybatis的缓存机制?

       Mybatis的缓存机制分为一级缓存(本地缓存)和二级缓存(全局缓存),通过缓存减少数据库查询次数来提升性能;

(1)一级缓存仅在同一个SqlSession中生效(默认开启),生命周期与Sqlsession绑定,会话结束或执行写操作时、事务提交回滚缓存都会失效;

(2)二级缓存在同一个Mapper中生效,多个SqlSession共享缓存(需手动开启,在对应XML 文件中添加 <cache> 标签),生命周期跟SqlsessionFactory一致

       开启二级缓存后,查询时会先查一级缓存,未命中则查二级缓存,如果二级缓存也未命中就访问数据库,并将结果存入一级和二级缓存;需要注解一级和二级缓存都可能导致脏读,频繁修改的数据不适合开启二级缓存。

25. Mybatis和Hibernate有什么区别?

(1)对象关系映射:Hibernate完全将数据库表映射为Java对象,开发者几乎无需手动编写SQL,他自动生成SQL;Mybatis中需要手动编写SQL,通过XML或注解将SQL映射到Java方法中;

(2)SQL控制权:Hibernate自动生成SQL,开发者无法直接干预,可能会生成冗余SQL性能较差;Mybatis中开发者完全控制SQL,需要自行维护SQL;

(3)性能:Hibernate内置强大的一级缓存和二级缓存减少数据库访问,适合读多写少场景;Mybatis手动编写高效SQL,避免不必要的查询,适合高性能场景;

Hibernate 配置(hibernate.cfg.xml)

  1. <!DOCTYPE hibernate-configuration PUBLIC   
  2.         "-//Hibernate/Hibernate Configuration DTD 3.0//EN"  
  3.         "http://hibernate.sourceforge.net/hibernate-configuration-3.0.dtd">  
  4. <hibernate-configuration>  
  5.     <session-factory>  
  6.         <property name="hibernate.connection.driver_class">com.mysql.jdbc.Driver</property>  
  7.         <property name="hibernate.connection.url">jdbc:mysql://localhost:3306/testdb</property>  
  8.         <property name="hibernate.connection.username">root</property>  
  9.         <property name="hibernate.connection.password">root</property>  
  10.         <property name="hibernate.dialect">org.hibernate.dialect.MySQLDialect</property>  
  11.         <property name="hibernate.hbm2ddl.auto">update</property> <!-- 自动建表 -->  
  12.         <mapping class="User"/>  
  13.     </session-factory>  
  14. </hibernate-configuration>  

保存数据的代码

  1. public class HibernateTest {  
  2.     public static void main(String[] args) {  
  3.         // 加载配置文件  
  4.         Configuration cfg = new Configuration().configure();  
  5.         SessionFactory sessionFactory = cfg.buildSessionFactory();  
  6.   
  7.         // 打开会话  
  8.         Session session = sessionFactory.openSession();  
  9.         session.beginTransaction();  
  10.   
  11.         // 创建对象  
  12.         User user = new User();  
  13.         user.setId(1);  
  14.         user.setUsername("admin");  
  15.         user.setPassword("123456");  
  16.   
  17.         // 保存对象  
  18.         session.save(user);  
  19.   
  20.         // 提交事务  
  21.         session.getTransaction().commit();  
  22.   
  23.         // 关闭会话  
  24.         session.close();  
  25.         sessionFactory.close();  
  26.     }  
  27. }  

26.能说一下全局异常捕获嘛?

说明:受检查异常和运行时异常

1.受检查异常

在编译阶段,Javac编译器会强制检查方法中是否显示地处理这些异常,如果没有使用try-catch捕获或没有通过throws向上抛出,编译就会报错。这种异常一般是外部资源或环境问题引起的,这样做的目的是提醒开发者写代码时考虑这些异常情况。

  1. public void readFile(String path) throws IOException {  
  2.     FileReader fr = new FileReader(path);  // 可能抛出 IOException  
  3.     BufferedReader br = new BufferedReader(fr);  
  4.     String line = br.readLine();  
  5.  

如果不写 throws IOException 或 try-catch,编译就过不了。

受检查异常常见例子:

IOException:文件不存在、读写失败

SQLException:数据库连接失败、SQL 执行错误

ClassNotFoundException:类加载失败

InterruptedException:线程被中断

2.运行时异常

继承了RuntimeException,在运行阶段才会被发现,编译器不会强制要求处理,通常是编程错误或逻辑缺陷导致的。

运行时异常常见例子:

NullPointerException(空指针)

ArrayIndexOutOfBoundsException(数组越界)

ArithmeticException(除以 0)

ClassCastException(类型转换错误)

如何实现全局异常捕获,方法如下:

首先使用@ControllerAdvice注解将类注册为一个全局异常处理器组件,作用范围是所有的控制层;在SpringMVC的请求处理链中有一个关键接口HandleExceptionResolver(处理异常解析器),所有实现了该接口的类在出现异常时会依次调用该接口,将异常转化为响应;

  1. public interface HandlerExceptionResolver {  
  2.     ModelAndView resolveException(HttpServletRequest request,  
  3.                                   HttpServletResponse response,  
  4.                                   Object handler,  
  5.                                   Exception ex);  
  6. }  

步骤如下:1、控制层抛出异常;2、Spring捕获异常后,调用HandlerExceptionResolverComposite(处理异常解析器组合);3、遍历所有注册的HandleExceptionResolver实例(例如ExceptionHandleExceptionResolver);4、ExceptionHandleExceptionResolver查找对应的@ExceptionHandler方法;5、匹配成功后执行该方法,返回结果;

  1. import org.springframework.web.bind.annotation.ExceptionHandler;  
  2. import org.springframework.web.bind.annotation.RestControllerAdvice;
  3. import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;  
  4. import org.springframework.http.converter.HttpMessageNotReadableException;  
  5.   
  6. @RestControllerAdvice  // 等价于 @ControllerAdvice + @ResponseBody  
  7. public class GlobalExceptionHandler {  
  8.   
  9.     /** 
  10.      * 处理自定义业务异常 
  11.      */  
  12.     @ExceptionHandler(BusinessException.class)  
  13.     public Result<?> handleBusinessException(BusinessException ex) {
  14.         return Result.fail("业务异常:" + ex.getMessage());  
  15.     }  
  16.   
  17.     /** 
  18.      * 参数类型不匹配 
  19.      */  
  20.     @ExceptionHandler(MethodArgumentTypeMismatchException.class)  
  21.     public Result<?> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {  
  22.         return Result.fail("参数类型错误:" + ex.getMessage());  
  23.     }  
  24.   
  25.     /** 
  26.      * 请求体解析错误 
  27.      */  
  28.     @ExceptionHandler(HttpMessageNotReadableException.class)  
  29.     public Result<?> handleMessageNotReadable(HttpMessageNotReadableException ex) {  
  30.         return Result.fail("请求参数格式错误");  
  31.     }  
  32.   
  33.     /** 
  34.      * 捕获所有其他异常 
  35.      */  
  36.     @ExceptionHandler(Exception.class)  
  37.     public Result<?> handleOtherException(Exception ex) {  
  38.         ex.printStackTrace();  // 可记录日志  
  39.         return Result.fail("服务器内部错误:" + ex.getMessage());  
  40.     }  
  41. }  

二、数据库篇

1.如何定位慢查询?

(1)可以借用一些运维工具比如Skywalking,可以检测出哪个接口运行时间比较长;

(2)在MySQL中开启慢查询日志(set global solw_query_log = 1),可以设置慢日志的时间为2秒(set global long_query_time = 2),当SQL语句执行时间超过2秒,就会视为慢查询记录;

2.这条SQL语句执行得很慢,怎么分析?

使用explain结合SQL语句分析,explain作用是输出优化器的“执行计划”

1通过type字段判断根据主键还是索引扫描,还是全表扫描,查看sql是否有进一步的优化空间,(const表示主键或唯一索引等值查询,ref表示普通索引等值查询,range表示根据索引范围查询,ALL表示全表扫描);

2通过extra额外信息分析是否做到覆盖索引(Using index表示覆盖索引,Using where表示where过滤),如果是则尝试添加索引或修改返回字段来完善;

3.了解过索引吗?

(1)索引是提高数据查询速度的数据结构;

(2)有了索引不需要全盘扫描,提高数据检索的效率,降低数据库的I/O操作;

(3)通过索引列对数据进行排序,降低数据排序的成本,降低了CPU的消耗;

4.索引的底层数据结构了解过吗?(重点)

节点类型

存储在什么

内容

根节点

一个页

索引 + 指向下一层页的指针

中间节点

若干页

索引 + 指向下一层页的指针

叶子节点

若干页

存储真实的 行数据 主键值(辅助索引)

联合索引,会根据索引队列的第一个排序,以此类推,因此联合索引作为查询条件需要遵循最左前缀原则。

MySql的InnoDB存储引擎采用的是B+树的数据结构来存储索引的,常用于等值查询和范围查询;

(1)叶子节点用于存储数据,而非叶子节点只存储指针和索引,使得非叶子节点能存储大量的指针(有更多的分叉,整个树是矮胖型的),减少向下遍历次数,降低磁盘IO;

(2)所有的叶子节点都在同一级,从根节点到任意叶子节点的路径长度相同,查询效率高且稳定;

(3)B+树默认按索引升序排列,而叶子节点是一个双向链表,使得范围查询和排序(between、order by)更加高效(有序链表)

(4)主键索引直接将数据存储到叶子节点,减少了回表操作,提升查询效率;

5.什么是聚簇索引,非聚簇索引,回表查询?

(1)聚簇索引(主键)指的是将数据和索引放到一起,B+树的叶子节点存储了整行数据,一个表有且只有一个;

(2)非聚簇索引(二级索引)指的是将数据和索引分开存储,B+树的叶子节点存储的是对应的主键,一个表可以有多个;

(3)回表查询就是通过二级索引找到对应的主键值,到聚集索引中查找整行数据;

6.什么是覆盖查询?

       覆盖查询是指查询中所需要的数据都可以通过索引直接获取,不需要回表查询,覆盖查询可以提高查询效率,节省时间和资源;而超大数据分页时可以使用覆盖查询+子查询来解决;

7.索引创建的原则有哪些?

1)数据量比较大且查询比较频繁的表尽可能创建索引;

2)常作为查询条件、连接条件、排序、分组的字段;

(3)字段内容区分度高,尽量建立唯一索引;

(4)字段内容较长时,使用前缀索引;

5)尽量使用联合索引,联合索引很多时候可以覆盖索引,避免回表;

6)索引并不是多多益善,索引越多,维护索引结构的代价也越大,会影响增删改的效率;

说明:如果数据的分区度低,即使查询频率高也没必要建立索引。区分度=不同值数量/总记录数,比如总共有100万条图书数据,状态字段只有三个不同值,即使状态字段成为索引,根据状态查询也要扫描几十万行数据,还不如直接全表扫描。

8.什么情况下索引会失效?(通解:破坏索引结构,无法匹配B+树的结构)

(1)违背最左前缀法则:最左前缀法则指的是查询条件的索引要按照联合索引的顺序来写,即查询条件必须从索引列的最左边开始匹配;(B+树按最左字段排序,跳过左字段无法定位数据范围。)

  1. – 联合索引:(name, age)  
  2. SELECT * FROM user WHERE age = 25;    
  3. --  无法使用索引,因为没有从最左边的 name 开始  

(2)范围查询右边的列,使用索引会失效;(无法匹配索引结构)

  1. -- 联合索引:(name, age)  
  2. SELECT * FROM user WHERE name > 'A' AND age = 20;  
  3. --  age 无法使用索引,因为 name 是范围查询,age 失效  

(3)在索引字段上进行函数操作或计算;(索引存储原始值,计算后的值无法匹配索引结构)

  1. -- 索引:(name)  
  2. SELECT * FROM user WHERE UPPER(name) = 'ZHANGSAN';  
  3. --  函数处理后的 name 无法匹配原始索引值  

(4)查询字段和索引字段类型不匹配,造成隐式类型转换;(无法匹配索引结构)

  1. -- id  INT 类型,存在索引  
  2. SELECT * FROM user WHERE id = '1001';  
  3. --  索引可能失效,因为 '1001' 是字符串,会导致隐式转换 

(5)以“%”或下划线开头的like模糊查询;(B+树按前缀排序,无法确定起始位置)

  1. -- 索引:(name)  
  2. SELECT * FROM user WHERE name LIKE '%';  
  3. --  前缀模糊无法利用 B+ 树索引  

(6)使用or连接非索引字段(优化器可能放弃使用索引,转为全表扫描);

  1. -- name 有索引,age 没有索引  
  2. SELECT * FROM user WHERE name = '张三' OR age = 20;  
  3. --  age 没有索引,可能导致全表扫描  

9.谈一谈你对于数据库的优化经验?(重点)

(1)表设计优化

  1. 对于字段选择合适的数据类型,尽量使用最小的数据类型(比如使用INT替代BIGINT,使用CHAR替代VARCHAR)来减少存储和查询成本;(char和varchar的区别:Char固定长度,而varchar根据实际字符串长度动态分配空间;char长度固定,无需解析长度信息,读取速度更快,适合频繁查询,varchar读取时需额外解析长度信息,且长度变化可能导致内存碎片,影响性能;)
  2. 分表:对于数据量极大的表可以考虑水平、垂直分表,将表按某些规则分割成多个小表或进行冷热数据分离,提高查询效率;
  3. 主从复制、读写分离:如果数据库读的操作比较多,为了避免写操作所造成的性能影响,可以采用主从复制、读写分离的架构;

(2)索引优化:

  1. 遵循索引创建原则,创建适当的索引,避免过多的索引;
  2. 覆盖索引:使用联合索引尽可能做到覆盖查询,避免回表,提高效率;
  3. 索引失效:尽可能避免索引失效的场景

(3)SQL语句优化:

  1. 尽量明确指定需要的列,避免使用SELECT *,以减少不必要的数据传输;
  2. 尽量用union all 替代 union ,union多了一次过滤,效率较低;
  3. 避免子查询,特别是当子查询返回大量数据时,可以考虑JOIN或WITH子句替代

10.什么是事务的特性,可以详细说明一下吗?(重点)

事务是一组操作的集合,把所有的操作作为一个整体一起向系统提交或撤销操作请求,它是一个不可分割的工作单位,这些操作要么都成功,要么都不成功。事务的特性如下:

(1)原子性:事务中的所有操作要么全部成功,要么全部失败。事务执行过程中即使发生错误,也会通过回滚使数据回到事务前的状态

(2)一致性:事务开始前和结束后,数据库的状态都是一致的。事务不会破坏数据库的完整性约束(如外键、唯一性)

(3)隔离性:事务的执行不会被其他事务干扰,不同事务之间的操作应该是隔离的、相互独立的。

(4)持久性:事务一旦提交或回滚,它对数据库中的数据的改变是永久保存的,即使系统崩溃也不会丢失;

      

11.并发事务带来哪些问题,怎么解决这些问题,MySQL默认隔离级别是什么?

(1)脏读:一个事务读取了另一个事务未提交的修改数据。比如事务1修改了数据,此时事务2读取这数据,事务1回滚修改操作,事务2读取的数据是无效的;

(2)不可重复读:一个事务在读取某个数据后,另一个事务修改或删除了这条数据并提交事务,导致第一个事务再次读取同一个数据得到不相同的值;

(3)幻读:一个事务读取某个范围的数据,另一个事务插入或删除了该查询结果集的某部分数据,导致第一个事务再次执行相同的查询时,导致前后结果集不同。

解决方案是将事务隔离。MySQL的默认的隔离级别是可重复读,以下是四个隔离级别(效率下降,安全性上升)

比如,数据库中status = 1, 事务1将其改为2,事务2同时读取这个字段

  1. 读未提交:允许脏读、不可重复读和幻读;(事务2读取为2)
  2. 读已提交:避免脏读,但允许不可重复读和幻读;(事务2读取为1,如果事务1提交了,事务2读取为2)
  3. 可重复读:避免脏读和不可重复读,允许幻读;(事务2开始读取为1,即使事务1提交了,事务2仍然读取为1)注意:InnoDB通过MVCC和间隙锁可以避免幻读,但是其他数据库可能无法完全避免幻读
  4. 串行化:避免所有的并发问题,但性能较差;(相当于加锁,事务2只能读取为1,甚至可能被阻塞)

不可重复读和幻读的区别:不可重复读是同一行数据的值被修改或删除,导致同一行数据的值前后不一样;幻读是新增或删除符合条件的数据,导致查询的结果集行数前后不一样;不可重复读用MVCC+间隙锁(可重复读的隔离机制)来解决;幻读可用两阶段锁(读共享锁和写排他锁)(可串行化的隔离机制)来解决

说明:间隙锁和排他锁的区别

       间隙锁作用于两条记录之间(边界区间),为了防止其他事务在这个间隙对数据集进行写操作;排他锁作用于某一行数据上,为了防止其他事务对这行数据进行写操作

      

12.重做日志(redo log)回滚日志(undo log)的区别?

(1)重做日志记录的是数据页的物理变化,发生服务宕机时可以同步数据;

(2)回滚日志记录的是逻辑命令,当事务回滚时通过逆操作恢复原来的数据;

(3)重做日志确保了事务的持久性,回滚日志确保了事务的原子性一致性

13.MVCC解释一下?(重点)

       MVCC(多版本并发控制)是一种数据库并发控制机制,旨在通过维护数据的多个版本来提高并发性,同时确保事务的隔离性,主要用于避免数据库的“脏读”和“不可重复读”现象,主要原理如下:

(1)多个版本的记录:每条数据都有多个版本,每个版本对应着不同的事务。在数据库中通过为每条数据维护版本号,可以实现对不同事务的数据区分;

(2)数据版本控制:数据的版本信息通常会存储到隐藏的系统字段中,比如创建时间、事务ID等。这使得数据库能够追踪每个事务数据的产生时间和状态;

(3)读取和写入:事务在读取数据时,会读取该事务的最新版本,而写操作会创建新版本,而不是直接覆盖旧版本。旧版本依旧保留,直到没有事务访问它才会被清理;

(4)提交与回滚:提交事务时,会将该事务对数据的修改永久保存,并生成新版本;如果事务回滚,则不会产生新版本,通过回滚日志执行逆操作,恢复到回滚前的版本;

14.讲一下数据库主从同步原理

       MySQL主从复制的核心是二进制日志binlog(记录的是DDL(数据定义语句)和DML(数据操纵语句))

(1)主库在提交事务时,会把数据变更以事件形式记录在二进制日志文件Binlog中;

(2)从库通过I/O线程读取主库的二进制日志文件,写入到从库的中继日志Relay Log;

(3)从库的SQL线程解析中继日志中的事件,转化为SQL语句并在本地执行,实现数据同步;

15.分库分表有了解过吗?

分库分表是数据库设计中的一种策略,用于提高数据库的性能、可扩展性和管理效率,尤其是在数据量非常大时。它的基本思想是将数据分散存储到多个数据库或数据表中,减少单个数据库或表的压力,从而提高系统的整体性能。

(1)水平分库:将数据按某种规则(如用户ID、时间)分散到不同的数据库中,可以有效分散负载,减少单库的存储压力;

(2)垂直分库:将不同业务模块或功能将表分到不同的数据库中(按业务划分

(3)水平分表:将数据按某种规则(如用户ID、时间)分散到不同的数据表中,避免单表数据量过大;

(4)垂直分表:将数据表按字段的查询频率差异拆分成多个表,达到冷热数据分离

16.SQL操作

17. MYSQL的存储引擎有哪些,他们有什么区别?

       MYSQL支持多种存储引擎,常见的有InnoDB、MyISAM、Memory、CSV,区别如下:

  1. InnoDB(默认):

特征:

  1. 事务支持:支持ACID事务,支持提交、回滚、崩溃恢复,符合事务隔离级别;
  2. 行级锁:使用行级锁锁定单行数据,支持更高并发;
  3. MVCC(多版本并发控制):通过快照读实现高并发;
  4. 外键约束:支持外键约束,用于保证数据的完整性;
  5. 崩溃恢复:通过回滚日志和重做日志保证数据的一致性和持久性;
  6. 数据存储:数据按主键聚簇索引存储,主键查询效率高;

限制:

  1. 因为数据和索引分开存储,导致占用磁盘空间较大;
  2. 全表扫描性能可能低于MyISAM;

  1. MyISAM

特征:

  1. 表级锁:锁定整个表,并发写性能差;
  2. 非事务性:不支持事务和崩溃后的自动恢复;
  3. 全文索引:支持全文搜索;
  4. 压缩表:支持压缩存储,适合静态数据;
  5. 高速读:因为没有事务开销,查询性能优于InnoDB;

限制:

  1. 表损坏风险高(崩溃后需手动修复);
  2. 不支持外键和行级锁

  1. Memory

特征:

  1. 内存存储:数据完全存储在内存中,读写效率高;
  2. 表级锁:并发写性能受限;
  3. 临时性:服务重启后数据丢失;
  4. 哈希索引:默认使用哈希索引,适合等值查询;

限制:

  1. 数据量受内存限制;
  2. 不支持TEXT/BLOB类型;

18.MYSQL的索引类型有哪些?

(1)按数据结构分类:

(2)按逻辑功能分类:

主键(聚簇)索引:唯一且非空,每张表只有一个主键索引,数据按主键顺序物理存储;

唯一索引:索引列的值必须唯一(允许为NULL值),可加速等值查询;

普通索引:仅加速查询,支持联合索引;

联合索引:索引包含多个列,查询需按照索引列顺序使用,否则导致索引失效

19.MYSQL的索引下推是什么?

       索引下推(Index Condition Pushdown, ICP)是MySQL 从 5.6 版本开始 引入的一种查询优化技术,在存储引擎层提前过滤数据,减少回表次数,从而提升查询性能;核心思想:将过滤条件尽可能地下推到存储引擎层,避免多次回表

(1)传统查询流程(无ICP)时,存储引擎根据索引的最左前缀查找数据,返回符合条件的主键,根据主键查询对应的行数据,再根据剩余的索引过滤这些数据,需要多次回表;

(2)启用ICP时,存储引擎会根据所有的查询条件来返回符合所有条件的主键,不需要二次过滤,减少回表次数:

(3)索引下推仅适合二级索引,不适合主键索引(主键索引的叶子节点直接存储行数据,无需回表)

实例:SELECT * FROM user WHERE name LIKE 'A%' AND age > 30;

无ICP:存储引擎根据 name LIKE 'A%' 找到所有可能行 → 返回 Server 层 → Server 层再过滤 age > 30。大量回表,效率低。

有ICP:存储引擎在扫描索引时,就对 age > 30 进行过滤 → 只返回符合条件的行。
明显减少回表次数,查询更快。

20.索引数量越多越好吗,为什么?

       在MySQL中,索引的数量并不是越多越好,需要根据实际场景进行权衡。

(1)索引的维护成本:

  1. 写操作性能下降:每次对表进行插入、更新、删除操作时,所有相关索引都需要同步。比如一个表有5个索引,插入一条数据就需要更新5个索引结构,导致写入性能下降;
  2. 锁竞争风险:在高并发写入场景中,频繁的索引维护可能加剧锁竞争,进一步影响吞吐量;

(2)存储空间占用:

  1. 索引占用磁盘空间:每个索引本质上是一个独立的B+树,索引数量越多导致存储开销越大;
  2. 内存资源压力:InnoDB缓冲池需要缓存索引和数据页,过多的索引可能挤占缓冲池空间,降低热点数据的缓存命中率;

(3)优化器选择困难:MySQL优化器需要分析所有的索引来执行生成最佳执行计划。索引数量过多时,优化器的分析时间会显著增加,尤其是在复杂查询中(涉及多个JOIN查询)

21. 描述Mysql的B+树中查询数据的全过程

(1)根据聚簇索引查询:

  1. InnoDB通过系统表找到主键索引的根页号,确定好根节点位置;
  2. 接着从根节点向下遍历,每一步都会查询缓冲池
  3. 如果叶子结点的页数据已在缓冲池中,直接读取内存数据;否则触发磁盘I/O,将页加载到缓冲池再读取数据;
  4. 将匹配的行数据返回给客户端;

(2)根据二级索引查询

  1. 首先根据根页号定位二级索引根节点,从根节点开始逐层向下查找;
  2. 在叶子节点找到对应的主键值,回到主键索引的B+树查找完整数据行并返回;

22.B+树相对于B树的优点是什么?

(1)B+树的非叶子节点只存储指针(索引),将实际数据放在叶子节点上,因此每个结点可以容纳更多的键值,从而减少树的高度,降低磁盘IO;

(2)B+树的叶子节点是一个双向链表,范围查询和排序性能更优;而B树的数据分布在各个阶层的节点中,范围查询需要多次回溯跳跃,性能较低;

(3)全表扫描遍历所有数据时,B+树只需要顺序扫描叶子节点链表,而B树需要递归访问所有层级的结点;

(4)B+树从根节点到各结点的路径长度相等,查询效率高且稳定

23. MySQL三层B+树能存多少数据?

存储容量取决于页大小、主键类型和行数据大小。页大小默认是16KB,而主键(INT)4字节,InnoDB页指针为6字节,行数据大小平均为1KB。

(1)根节点:每个键值占用10B:主键(4B)+指针(6B),根节点页可以存储大约1600个键:16KB/10B。单个根节点可以指向1.6K个中间节点;

(2)中间节点:每个中间结点同样可以存储约1.6K个键(指向叶子节点),因此约2563k个叶子页;

(3)叶子节点:每个叶子页存储数据行为16行:16KB/1KB,因此大约41011K行数据:2563K*16

24. 描述一条SQL语句在MYSQL中的执行过程

(1)建立连接和权限验证:客户端和MySQL服务器建立连接,进行身份验证、权限验证;

(2)查询缓存:以Key查询缓存,若命中缓存直接返回结果,如果是写操作会删除相关缓存(MySQL8后删除了查询缓存,因在高并发下写入操作维护成本较高)

(3)解析和预处理:会进行词法分析(select、from),法语分析和预处理;

(4)查询优化:利用优化器进行逻辑优化和物理优化

(5)执行语句:调用存储引擎InnoDB执行SQL语句,优先从缓冲池读取内存数据,未命中就触发磁盘IO,将数据写入缓冲池,若开启事务,执行器Excutor协调事务的提交回滚;

(6)返回结果:将处理后的数据封装返回给客户端

25.MySQL是如何实现事务的?

       MySQL通过InnoDB存储引擎来实现事务,主要依赖于回滚日志、重做日志、锁机制和多版本并发控制来满足ACID的特性;

(1)原子性:事务的每个操作都会记录在回滚日志中,保存数据操作的逆操作,一旦事务执行失败回滚,InnoDB就利用回滚日志逆向恢复数据;

(2)持久性:提交事务时,所有数据操作都会写入重做日志,再异步刷新磁盘数据文件,在系统崩溃恢复时可以利用重做日志恢复数据;

(3)隔离性:默认的隔离机制是可重复读,通过MVCC多版本并发控制和间隙锁,避免了脏读和不可重复读问题;

(4)一致性:数据库内部设置约束,比如主键、外键、唯一非空约束,直接拦截非法数据;

26.能说一下缓冲池嘛?

缓冲池就像 InnoDB 的“内存数据库”,它缓存了绝大多数读写热点页,是提高 MySQL 性能的关键组件。缓冲池中缓存的是 页(Page)级别 的数据,主要包括以下几类:

类型

描述

数据页(Data Page)

存储表的行记录(用户数据)

索引页(Index Page)

存储 B+ 树结构中的索引信息(辅助索引、聚簇索引)

Undo

存储事务的 Undo 日志(回滚信息)

Insert Buffer

用于缓存插入到非唯一二级索引中的记录

系统页(System Page)

存储系统元信息(如文件段信息)

自适应哈希索引(AHI)

Buffer Pool 中热点数据自动构建的哈希索引,加速查询

数据字典信息

表结构、索引定义、字段类型等元数据

缓冲池的作用如下:

(1)查询数据时,会先查询缓冲池,如果数据页已存储在缓冲池中,直接读取数据,避免磁盘IO,不存在就将读取的数据写入缓冲池;

(2)缓存回滚日志页,支持多版本并发控制和事务回滚;

(3)缓存索引页,索引查找时通常命中缓存,加快定位;

三、Reids篇

1.什么是缓存穿透,怎么处理?

       缓存穿透是指请求数据时,缓存和数据库中都没有没有该数据,导致每次请求都穿过了缓存直接访问数据库,造成数据库压力增大;

(1)使用布隆过滤器:一种概率性数据结构,能够高效判断一个元素是否在一个集合中,但也存在误判(存在的元素可能被误判为不存在,不存在的元素不会被误判);

  1. 在访问缓存之前使用布隆过滤器判断请求中的数据是否存在,如果不存在则直接返回空或错误信息,避免查询数据库;
  2. 如果布隆过滤器认为数据存在,才查询缓存,如果缓存也没有再查询数据库并将结果放入缓存中;

(2)缓存空对象:对于缓存和数据库都查询不到的对象,我们可以将该对象设置为空缓存起来,需要设置合适的过期时间,防止空对象长时间存在缓存中。

说明:布隆过滤器的实现

通过JredisBloomRedission实现,结合位数组多个哈希函数来实现高效的概率型数据查询;

位数组(Bit Array

  1. 布隆过滤器的核心是一个长度为m的二进制数组,初始所有位为0。每个元素通过多个哈希函数映射到位数组的多个位置,并设置为1。
  2. 例如,插入元素时使用k个哈希函数生成k个不同的索引,将对应位置置为1;查询时检查所有对应位置是否均为1(存在误判可能)

哈希函数的选择

  1. RedisBloom默认使用MurmurHash3算法,并通过不同的种子(seed)生成多个哈希值,模拟多个独立哈希函数的效果。例如,通过调整种子生成3个哈希函数,分别映射到位数组的不同位置

2.什么是缓存击穿,怎么处理?

缓存击穿是指某个热点数据过期,导致在同一时间内大量请求直接访问数据库,导致数据库压力过大甚至崩溃;

(1)使用互斥锁:查询缓存未命中就尝试获取锁,获取锁成功就查询数据库,将数据写入缓存中,接着释放锁和返回数据;若获取锁失败就回旋,回到查询缓存的步骤,确保了强一致性但性能差;

  1. @Service  
  2. public class CacheService {  
  3.     @Autowired  
  4.     private StringRedisTemplate redisTemplate;  
  5.     @Autowired  
  6.     private RedissonClient redissonClient;  
  7.     @Autowired  
  8.     private DataMapper dataMapper;  
  9.   
  10.     private static final String CACHE_KEY = "data:";  
  11.     private static final String LOCK_KEY = "lock:data:";  
  12.     private static final int MAX_RETRY = 5; // 最大回旋次数  
  13.   
  14.     public String getData(Long id) {  
  15.         String key = CACHE_KEY + id;  
  16.   
  17.         for (int i = 0; i < MAX_RETRY; i++) {  
  18.             // 1. 查缓存,存在直接返回  
  19.             String cacheValue = redisTemplate.opsForValue().get(key);  
  20.             if (StringUtils.hasText(cacheValue)) {  
  21.                 return cacheValue;  
  22.             }  
  23.   
  24.             // 2. 缓存未命中,尝试获取锁  
  25.             RLock lock = redissonClient.getLock(LOCK_KEY + id);  
  26.             try {  
  27.                 // 最多等待5秒,最多持有锁10  
  28.                 if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {  
  29.                     try {  
  30.                         // double check  
  31.                         cacheValue = redisTemplate.opsForValue().get(key);  
  32.                         if (StringUtils.hasText(cacheValue)) {  
  33.                             return cacheValue;  
  34.                         }  
  35.   
  36.                         // 3. 查数据库  
  37.                         String dbValue = dataMapper.selectById(id);  
  38.                         if (dbValue != null) {  
  39.                             // 4. 写入缓存  
  40.                             redisTemplate.opsForValue().set(key, dbValue, 30, TimeUnit.MINUTES);  
  41.                         }  
  42.                         return dbValue;  
  43.                     } finally {  
  44.                         if (lock.isHeldByCurrentThread()) {  
  45.                             lock.unlock();  
  46.                         }  
  47.                     }  
  48.                 } else {  
  49.                     // 没拿到锁,稍等再试  
  50.                     Thread.sleep(50);  
  51.                 }  
  52.             } catch (InterruptedException e) {  
  53.                 throw new RuntimeException(e);  
  54.             }  
  55.         }  
  56.   
  57.         // 超过最大回旋次数,可以选择返回 null 或者直接查库  
  58.         return dataMapper.selectById(id);  
  59.     }  
  60. }  

(2)使用逻辑过期:在查询数据上添加一个expireTime逻辑过期时间(真正的TTL设置长一点),查询缓存时检验是否已过期,没过期就重置逻辑过期时间和真实过期时间并返回结果;如果过期了跟方法1一样尝试获取锁,无论是否获取锁成功都返回数据。高可用性和性能好,但不能保证数据强一致性;(在互斥锁基础上给对象添加逻辑过期时间字段,无论是否获取锁成功都返回数据

3.什么是缓存雪崩,怎么处理?

       缓存雪崩是指在同一时间内有大量的缓存key失效或者Redis服务宕机,导致大量请求访问数据库,给数据库带来巨大压力。

(1)随机设置过期时间,避免同一时间内大量key过期;

(2)使用分布式锁来实现互斥锁,确保同一时刻只有一个请求可以访问;

(3)缓存预热,通过定时任务,根据访问频率加载热点数据,减少缓存未命中的概率

(4)降级处理,缓存未命中返回默认值或备用缓存;

4.reids作为缓存,怎么确保数据库和redis的数据的一致性?(结合黑马点评)

(1)如果追求高性能且数据一致性要求没这么高,可以采用异步操作,就是使用MQ中间件。项目中收到数据更新请求后,先删除缓存数据,接着发送消息异步更新数据库;

(2)如果要求数据的强一致性但允许短暂延迟,可以采用延迟双删。在更新数据库前先删除缓存中的旧数据,更新数据库等待一段时间(通常为几百毫秒),再次删除缓存,尽可能地避免脏数据;

(3)确保强一致性可以加锁,利用redission分布式锁或其他同步锁,确保同一时刻仅有一个线程对共享资源的访问,但性能相对来说比较低。

说明:一般更新数据都是删除缓存而不是更新缓存,删除缓存更加高效,避免脏数据

5.Redis作为缓存,数据的持久化是怎么实现的?

       Redis中提供了两种数据持久化的方式,一种是RDB(默认开启),另一种是AOF。RDB是一个快照文件,定时对整个内存做一个快照,将redis存储的数据写到磁盘中,当redis实例宕机需要恢复数据时,可以从RDB的快照文件中恢复数据;而AOF是追加文件(默认关闭),当redis执行写操作时,都会将命令存储到这个文件中,当redis实例宕机恢复数据时,就可以从这个文件再次执行一遍命令来恢复数据;

在服务宕机需要恢复数据时优先使用AOF追加文件,因为它记录了依次执行的命令,相对来说数据完整性较高,但恢复速度较慢。

6.Redis的数据过期删除策略有哪些?

(1)定期删除:Redis会定期扫描数据库中的键,检查这些键的过期时间,如果键的过期时间到了,就会立即删除,默认是100毫秒扫描一次

(2)惰性删除:当你访问一个键时,Reids首先会检查这个键的过期时间,如果过期立即删除,也就是说过期数据只有被访问时才会被删除

(3)内存不足时删除:当Reids的内存被占满时,会根据淘汰策略删除一些过期或不常用的键。

       因此,Redis的过期删除策略时定期删除和惰性删除配合使用。

7.Redis的数据淘汰策略有哪些?

(1)不淘汰任何的key,当内存满时写入新数据就会报错(noeviction, 默认)

(2)对于设置了TTL的数据,比较key的TTL剩余值,越小越优先淘汰(volatile – ttl)

(3)对全体/设置了TTL的key,随机进行淘汰(allkeys/volatile – random)

(4)对全体/设置了TTL的key,基于LRU(最近最少使用)算法进行淘汰(allkeys/volatile – lru)

(5)对全体/设置了TTL的key,基于LFU(最少频率使用)算法进行淘汰(allkeys/volatile – lfu)

       综上,建议根据业务需求设置数据淘汰策略如下:

(1)如果业务中有明显的冷热数据区分,使用allkeys-lru 策略,将最近最常访问的数据留在缓存中;

(2)如果业务中数据访问频率差别不大,建议使用allkeys-random,随机淘汰

(3)如果业务有置顶的需求,可以使用volatile-lru策略,确保没有设置过期时间的数据不被删除

(4)如果业务中有短时高频访问的数据,可以使用allkeys-lfu或volatile-lfu策略

8.redis分布式锁是怎么实现的?

       可以通过 setIfAbsent 这个原子命令来简单实现;

       项目中我是使用redission实现的分布式锁,底层是通过setnx(SET if Not eXists)和lua脚本实现的,获取锁、释放锁和锁的续期等都使用到Lua 脚本来确保操作的原子性。

在redission的分布式锁中,提供了一个看门狗机制,当我们没有显示得设置锁的过期时间就会启动看门狗机制。一个线程获取锁成功之后,默认设置锁的有效时间为30秒,看门狗会开启定时任务检查锁的有效时间,当过去有效时间的三分之一会续期至30秒;

另外,redission利用hash结构存储客户端唯一标识(UUID + 线程ID)和重入的次数,它是可以重入的,根据线程ID判断持有锁的是否当前线程,当重入的次数为0时就释放锁;

但这个不能解决redis主从数据一致的问题,可以使用redission提供的红锁来解决(因性能低不推荐使用),如果非要保证数据的强一致性,建议使用zookeepr实现分布式锁;

Redission基于单Redis实例或普通主从复制,如果Redis发生主从切换、网络宕机等可能导致锁丢失(主节点没同步到从节点);Redission提供了RedLock算法,在N个独立Redis节点上同时尝试获取锁,客户端在大多数节点(>N/2)成功加锁才算获取锁,在Redis节点宕机后仍能保证大概率互斥。

9.redis为啥这么快?(重点)

(1)Redis 是基于内存存储的,内存IO性能远远高于磁盘IO;

(2)采用的是单线程,确保了每个操作的执行顺序和一致性,避免了复杂的并发控制和锁机制;

(3)使用IO多路复用模型,可以在单线程下处理成千上万的并发连接;

(4)redis的存储形式是简单的键值对,健是字符串,值可以是字符串、哈希、列表等数据结构,并且它的命令也不复杂;

说明:IO 多路复用就是用一个线程同时监视多个 IO 通道,一旦某个通道就绪,就去处理它。

10.redis有哪些基本数据结构?

(1)String(字符串),支持存储任何类型的字符串(例如文本、数字、序列化对象、二进制数据等),常见操作为:set , get, del, incr, decr;

(2)List(列表),有序可重复列表,本质上是一个双向链表,可以在头部或尾部进行增删操作,常见操作为:lpush , rpush , lpop , rpop, lrange;

(3)Set(集合),无序的字符串集合,唯一性,常见的操作为:sadd , srem , smembers , sinter , sunion;

(4)Sorted Set(有序集合),类似于集合,但每个元素都会关联一个分数(score),并根据分数排序,常见操作为:zadd , zrem , zrange , zrevrange , zincrby;

(5)Hash(哈希),键值对集合,适合存储对象,常见操作为:hset , hget , hdel , hgetall;

除了上述五种基本数据结构,Redis 还支持以下扩展数据类型(基于基本结构实现):

  1. Bitmaps  位图,比特单位的数组,通过使用单个比特位(0或1)表示数据状态,将大量数据压缩为一个位数组(如用户在线状态统计、签到系统)。
  1. SETBIT users_online 10001 1   # 用户 ID 10001 设置为在线  
  2. GETBIT users_online 10001     # 检查用户 ID 10001 是否在线  
  1. HyperLogLog(概率性数据结构),用于基数统计(如 UV 去重计数,误差约 0.81%)。
  2. GEO  地理位置(存储坐标,支持距离计算)。
  1. GEOADD places 13.361389 38.115556 "Palermo"  
  2. GEOADD places 15.087269 37.502669 "Catania"  
  3. GEODIST places "Palermo" "Catania" km   # 返回两地之间的距离  
  1. Stream      消息流,用于处理实时数据流(用于实现发布订阅或消息队列)。

11.能说一下SortedSet底层的数据结构嘛?

SortedSet是通过跳表哈希表实现的,跳表详情如下:

SkipList跳表就是多层的有序链表,每个节点可包含多个指针,层数最多为32,不同层指针的跨度不同,层数越高,跨度越大,每一层的节点数约为下一层的二分之一。通过多级索引,跳表增删查的时间复杂度从O(n)降为O(logN)

跳表节点不仅包含成员(number)和分数(score),还包含了多层次的指针,来提高查询效率,实现成员的排序和范围查询;哈希表用于维护成员和分数之间的映射关系,根据成员快速找到对应的分数,时间复杂度为常数级别

12.

13.能介绍一下redis主从数据同步吗

       因为单节点Redis的并发能力是有上限的,如果想要进一步提高reids的并发能力,可以搭建主从集群(多个Redis实例),实现读写分离。一般默认是一主多从,主节点负责写操作,从节点负责读操作。主节点写入数据之后,需要把数据同步到从节点中。步骤如下:

(1)开始步骤:从节点向主节点发送replidPSYNC命令请求数据同步

(2)全量复制:如果是第一次连接或之前的连接失败,从节点会请求全量复制,主节点根据replid是否一致判断是否第一次同步,如果是就将当前数据快照(RDB文件)发送给从节点执行;

(3)增量复制:全量复制完毕后,主从结点之间会保持TCP长连接,主节点会将复制积压缓冲区发送给从节点执行,来保证数据的一致性。

14.说一下redis的哨兵集群模式?

Redis的哨兵集群模式是Redis提供的一种高可用和故障转移的解决方案,通过监控Redis实例的健康状况、实行故障转移来保证Redis集群的高可用性。

哨兵集群模式的工作流程:

       ·监控:哨兵每隔1秒向各个redis实例发送ping命令来检测健康状况,如果超过一定时间没有响应就认为是主观下线;

       ·故障检测:如果多个哨兵(默认是超过一半数量的哨兵)发现某个redis实例下线,通过投票机制确认该实例为故障(客观下线);

       ·故障转移:如果哨兵确认主节点发生故障,就会根据某种规则(优先级、偏移量最大)从从节点中选择一个晋升为新的主节点,原来的主节点变为从节点;

       ·更新配置:哨兵在进行故障转移后,会更新集群中所有节点的配置,使所有从节点指向新的主节点;

       ·通知客户端:哨兵通过发布/订阅模式,通知客户端当前的主从节点信息;

       总结,哨兵集群模式的作用为:监控;故障转移;配置和通知

Redis的主从集群哨兵集群模式可以解决高可用、高并发读问题;

分片集群可以解决海量数据存储、水平扩展、高可用性、负载均衡

15.能说一下Redis的分片集群嘛?

Redis的分片集群是Redis提供的一个分布式解决方案,将数据分散多个Redis节点上,从而实现数据的水平扩展和负载均衡。关键特性如下:

(1)哈希槽和分片:Redis Cluster一共被划分为16384个哈希槽,键计算出哈希值后对16384取模,映射到对应的槽中。每个redis实例对这16384个槽进行分片,负责管理各自范围的哈希槽;

(2)主从复制:每个分片要求至少一个主节点和一个从节点进行数据备份,实现读写分离,保证系统高可用性;

(3)数据迁移:当集群新增或删除节点时,Redis Cluster会进行数据迁移,根据哈希槽值将数据重新分配;

(4)客户端路由:客户端会获取集群节点信息,通过计算键的哈希值,确定该键属于哪个哈希槽,从而知道请求发送到哪个节点;

(5)节点通信:节点之间通过ping命令实现通信,若某个节点宕机,从主观下线到客观下线,会进行故障转移,将从节点晋升为主节点;

说明:取模是进行按位与运算,速度更快

hash % length ≡ hash & (length - 1)   (前提:length 是 2 的幂,即 length = 2^n)

比如length = 64 = 2^6,hash = 77,就是取hash的低6位。length – 1对应的二进制是 111111

16.redis有哪些应用场景?

(1)缓存:可以先将热点数据存储到redis中,实现数据预热,缓解数据库的访问压力;

(2)会话存储:在分布式系统中,可以集中存储和管理用户会话(如登陆状态、token);

(3)数据排行:redis中的Sorted Set(有序集合)内部维护了一个属性score,通过得分进行数据的排行;

(4)消息队列:redis中的List、stream、发布订阅模式等可以实现消息队列,实现任务的异步处理;

(5)分布式锁:基于setnx 命令或使用redission实现了分布式锁,分布式系统中确保对共享资源的互斥访问,比如秒杀扣库存;

(6)实时数据分析:统计实时在线用户数、页面访问量等等;

(7)地理位置服务:GEO数据结构存储经纬度,可以快速计算距离、范围查询;

17. redis支持事务吗,如何实现的?

       Redis支持事务,但因为没有事务日志,不支持事务回滚,如果某个命令执行失败,后续命令仍会继续执行;

事务执行流程为:1.使用mulit 开启事务;2.执行多个redis命令(这些命令不会立即执行,而是进入事务队列);3.使用exec 提交事务,事务队列中的命令按顺序执行;4。如果在exec 之前调用 discard ,事务就会取消;

可以使用watch 监听一个或多个键值,如果在事务执行前这些键被其他客户端修改,事务就会被取消。实现乐观锁,防止并发修改;

18 redis的Big Key问题是什么,如何解决?

Big Key指某个key对应的数据量特别大或特别多,常见的两种情况:比如value是几十兆的字符串文本;或者是hash/set/list/zset结构,存储了大量的元素。会造成网络延迟和性能下降。

检测方案如下:

(1)命令工具:

·redis-cli --bigkeys:快速扫描数据库,查询各类型第一大Key;

·redis-cli –memkeys:按内存占用排序;

(2)第三方工具:

    ·redis-rdb-tools:分析RDB文件,离线统计所有Key的详细内存分布

    ·监控工具(如Prometheus):实时跟踪网络流量和内存使用

解决方法如下:

(1)数据拆分:1.垂直拆分:将单个key按字段拆分(如将哈希拆分为多个子哈希);2.水平拆分:按某种规则进行范围分片(如按时间将List拆分为多个子列表);

(2)数据结构优化:比如用Sorted Set替代List + Timestamp,避免全量遍历

(3)冷热数据分离:将高频访问的部分数据保留在redis,冷数据迁移至数据库中

19. redis实现消息队列的几种方法?

(1)List(双向链表)实现简单队列:

  1. 原理:生产者使用LPUSH向列表头部插入消息,消费者使用BRPOP从列表尾部获取消息;
  2. 优点:实现简单,性能高;支持阻塞获取消息,避免轮询;
  3. 缺点:消息不可重试,消息一旦被弹出,如果消费者处理失败,消息无法自动回到队列;不支持多个消费组;

(2)Pub / Sub 实现发布订阅:

  1. 原理:生产者使用PUBLISH向指定频道(Channel)发布消息,消费者使用SUBSCRIBE订阅频道实时接收消息;
  2. 优点:实时性高,支持一对多广播;轻量级,适合瞬时消息传递;
  3. 缺点:消息没有堆积能力(不持久),没有消费者或消费者离线期间消息会丢失;

(3)Stream实现可靠消息队列:

  1. 原理:类似于Kafka的日志结构,支持消息持久化、多消费者、消息确认机制(ack),多个消费者共享消息,每条消息仅被组内一个消费者处理;它会记录已发送但未确认的消息,支持重试;
  2. 优点:高可靠性:支持消息持久化、ACK确认机制、重试机制;支持多个消费组,实现负载均衡和消息广播;消息回溯:通过消息ID读取历史数据;
  3. 缺点:复杂度较高,需要管理消费组等;性能却低于List(仍达到每秒数万级别)

(4)Sorted Set 实现延迟队列

  1. 原理:生产者使用ZADD插入消息,设置score为延迟时间,消费者使用ZRANGEBYSCORE获取到期消息,处理完后使用ZREM删除;核心思想是将消息的存储时间作为分数Score,消费者定时查询Score范围来获取到期的消息;
  2. 优点:实现延迟消息;可通过分数排序实现优先级队列;
  3. 缺点:需要定期查询Score,高频率可能增加redis负载;

四、JUC篇

1.进程和线程的区别是什么?

(1)进程是运行程序的实例,线程是CPU调度的最小单位,进程包含了多个线程,每个线程执行不同的任务;

(2)不同的进程使用不同的内存空间,而当前进程的所有线程共享内存空间;

(3)线程更加轻量,线程上下文切换一般比进程低

说明:上下文切换指的是CPU从执行一个任务(进程/线程)切换到另一个任务时,需要保存和恢复执行环境的过程;所谓的“上下文”,就是一个任务执行所依赖的各种状态信息,比如CPU寄存器、程序计数器(PC)、堆栈指针、内存映射信息等。

2.并发和并行有什么区别?

(1)并发是指多个任务在同一时间段内交替执行,通过快速切换任务看起来像是“同时执行”,可以在单核处理器上实现;

(2)并行是指多个任务在同一时刻同时执行,通常依赖于多核CPU实现;

3.创建线程的方式有哪些?(重点)

(1)实现Runnable接口并重写run方法;

(2)实现Callable接口并重写call( )方法。两个接口的区别为:Runnable接口的run方法没有返回值,而Callable接口是个泛型,它的call方法有返回值,方法返回类型就是泛型类型,可以利用Futrue来获取call方法的结果;Callable接口的call方法允许抛出异常,而Runable接口的run方法只能捕获异常,在内部处理(try-catch),不能向上抛。

(3)继承Thread类并重写run( ) 方法(实际上也是实现Runnable接口);

(4)通过 Executors或ThreadPoolExcutor创建线程池,使用submit方法提交任务,shutdown方法关闭线程池,它复用线程,避免了频繁的创建和销毁线程带来的性能开销,适用于高并发的场景;另外推荐使用ThreadPoolExcutor创建线程,开发者自己定义线程池参数更加灵活,避免OOM;

  1. ExecutorService pool = new ThreadPoolExecutor(  
  2.     5,                   // 核心线程数  
  3.     10,                  // 最大线程数  
  4.     60, TimeUnit.SECONDS, // 空闲线程存活时间  
  5.     new LinkedBlockingQueue<>(1000), // 队列  
  6.     Executors.defaultThreadFactory(),  
  7.     new ThreadPoolExecutor.AbortPolicy() // 拒绝策略  
  8. );  
  9.   
  10. pool.execute(() -> {  
  11.     System.out.println("异步任务:" + Thread.currentThread().getName());  
  12. });  

补充:相对于继承Thread类,推荐使用实现Runnabl接口创建线程,原因:Runnable接口避免了单继承的限制,增强可扩展性;任务逻辑和县城机制解耦;更适合线程池管理,线程池只能接收Runnableh或Callable接口;

  1. ExecutorService pool = Executors.newFixedThreadPool(5);  
  2. pool.submit(new MyRunnable());   

4.run( )方法和start( ) 方法有什么区别?

(1)作用:run方法是线程实际执行的任务逻辑,无论是继承Thread类还是实现Runnable接口都要重写run方法来定义线程的工作内容;start方法是用来启动线程的,执行后自动调用run方法;

(2)调用次数:每个线程实例的run方法可以调用多次,而start方法只能调用一次,调用多次会报异常

5.线程有哪些状态,这些状态是怎么转化的?

       线程的状态新建、可运行、阻塞、等待、时间等待终止六个状态;

(1)创建线程对象的时候是新建状态;

(2)调用了start方法就转化为了可执行状态;

(3)线程获取了CPU的执行权并执行完毕就转化为终止状态;

(4)在可执行状态的过程中,如果没有CPU执行权,可能会切换到其他状态:

  1. 如果没有获取锁就进入阻塞状态,获得锁后切换为可执行状态;
  2. 如果线程调用了wait( )方法进入等待状态,通过其他线程调用notify( )方法唤醒后切换为可执行状态;
  3. 如果线程调用了wait或sleep方法并设置睡眠时间就进入了时间等待状态,到时间后切换为可执行状态;

6.Java中的waitsleep方法有什么不同?(重点)

       wait和sleep方法都是用来暂停线程的执行,但他们有很大的区别如下:

(1)归属类不同:wait方法是Object中的一个方法,被finally修饰,不可被重写,因此每个对象都可以调用wait方法;而sleep是Thread中的一个静态方法,只能通过Thread类来调用;

(2)醒来机制不同:wait方法可以选择设置或不设置睡眠时间,而sleep方法必须设置睡眠时间。wait方法不设置睡眠时间可以一直让当前线程进入等待状态,直到有其他线程调用该对象的notify()或notifyAll()方法来唤醒;wait(long)和sleep(long)方法用于使当前线程暂停执行指定的时间,然后恢复运行;

(3)锁特性不同(重点):wait方法的调用是基于对象的监视器锁实现的,只能放在同步代码块或同步方法中,wait方法执行后会释放对象锁(只有获取锁的线程才能执行wait方法,否则报异常);而sleep没有这个限制,sleep如果在synchronized代码块中执行,并不会释放对象锁;

7. 怎么停止一个正在运行的线程?

       在Java中,不恰当的线程停止可能会导致不一致的状态或资源泄露,下面列举几种常见的停止线程的方法:

(1)Thread.stop( ) 方法会强制停止线程,不管线程是否在进行关键操作,可能会导致线程在不安全的情况下被中断,进而破坏数据一致性,所以这个方法不推荐使用(已作废);

(2)使用退出标志位,定义一个用volatile修饰的标志变量(如running),线程在循环中定期检查该标志位来决定是否退出,这方法适用于没有阻塞操作(sleep、wait等)的线程;

(3)使用interrupt方法,每个线程都有一个内置的中断标志位(interrupt status),interrupt方法用于向线程发送中断信号,而不是直接停止线程。当打断阻塞的线程,它会抛出InterruptedException异常并重置中断状态为false,打断正常的线程会根据中断状态来决定是否退出线程;

       对于阻塞操作,可以使用interrupt方法;非阻塞操作,使用退出标志位或.isInterrupted()方法;或者结合两种方法,同时兼容阻塞和非阻塞;

8.synchronized关键字的底层原理是什么?

       Synchronized 关键字在JVM中是通过对象的监视器锁monitor来实现的。每个对象都有一个监视器锁,当一个线程执行一个synchronized代码块时,它必须先获取该对象的锁才能往下执行,获得锁的线程离开代码块时会释放锁。

       JVM会根据线程竞争情况来自动升级锁的类型:当对象锁大多数时候只被一个线程访问,JVM会使用偏向锁,第一次获取锁时JVM将线程ID记录到对象中的Mark Word(表示此锁偏向该线程),之后线程再次获取锁只需要判断对Mark Word记录中是否是该线程id即可,减少锁竞争开销;当其他线程尝试获取该锁时,就是多个线程竞争锁时,偏向锁会升级为轻量级锁(利用CAS获取锁);当某线程CAS获取锁失败,就是锁竞争特别激烈时,JVM使用重量级锁(Mark Word切换为Monitor,涉及了用户态、内核态切换和线程上下文切换,性能开销大。

       Synchronized在高并发下会使用重量级锁,导致性能下降;

说明:用户态是指普通应用程序运行的状态,CPU不允许执行特权指令,只能通过“系统调用”来请求操作系统完成敏感操作;内核态是指操作系统内核运行的状态,CPU可以执行所有指令。当应用程序需要操作系统提供的服务时,会触发用户态切换到内核态,涉及保存和恢复执行环境操作。比如:

FileInputStream fis = new FileInputStream("a.txt");

fis.read();

底层流程:

  1. Java 程序运行在 用户态,调用 read()。
  2. JVM 通过 JNI 调用 C 语言实现的系统调用接口。
  3. CPU 执行一条 陷入指令(trap / syscall,进入 内核态
  4. 内核代码执行文件读取逻辑:找到文件、读磁盘、拷贝数据到内存。
  5. 内核执行完成后,返回结果,并通过 返回指令 切回 用户态,继续执行 Java 代码。

9.

10.你能谈一下JMM吗

(1)JMM(Java Memory Model)是Java内存模型,定义了共享内存中多线程程序读写操作的行为规范,JMM 通过sychronized、volatile、ReentrantLock、AtomicInteger等解决三大并发问题:原子性、可见性、有序性

(2)JMM把内存分为两部分,一个是私有线程的工作区域(工作内存),另一个是所有线程的共享区域(主内存);

(3)线程跟线程之间是相互隔离的,线程跟线程交互需要通过主内存实现;

11.CAS能说一下吗?

       CAS(Compare And Swap)意思是先比较再交换,操作步骤为先比较内存位置的值与预期值是否匹配,如果匹配就更新为新值,否则不操作。这是一种在并发编程中实现无锁操作的原子指令,用于确保多线程环境下数据更新的安全性。

       它不需要锁,能避免线程阻塞和死锁问题,常用于乐观锁和自旋锁,但可能引起ABA问题,无法洞察数据从A到B再到A的变化(解决方案是添加版本号),长时间自旋导致CPU资源浪费(设置自旋次数)

12.乐观锁和悲观锁的区别?(重点)

(1)核心思想:乐观锁乐观地认为数据竞争不一定发生,所以不加锁,而是在更新数据时检查数据是否发生变化;而悲观锁悲观地认为数据竞争一定发生,先加锁再操作;

(2)实现方法:悲观锁操作前加锁,确保多线程下同步操作,比如数据库采用行级锁或表级锁,Java中采用synchronized关键字和ReentrantLock等显式锁;乐观锁基于CAS操作,先比较再操作,不需要显示设置锁

(3)性能:乐观锁没有锁机制带来的额外开销,性能比较高,但在高并发下大量重试可能会导致CPU资源浪费、性能下降;而悲观锁频繁加锁/释放锁,性能较差;

(4)适合场景:悲观锁适合高并发数据竞争大的场景;乐观锁适合数据冲突小的场景;

(5)缺点:高并发下悲观锁可能导致死锁;乐观锁需处理自旋锁重试逻辑和ABA问题;

13.请谈一下你对volatile的理解?

volatile 是 Java 内存模型(JMM) 提供的关键字,用于保证变量的可见性有序性,但不保证原子性。

(1)可见性:用volatile修饰共享变量,能够防止编译器对其进行优化,确保了线程对共享变量的修改对其他线程可见;

(2)有序性:用volatile修饰共享变量会在读、写共享变量时加入不同的屏障,防止CPU和编译器优化指令执行顺序,从而达到阻止重排序的效果;

14.能说一下AQS吗?

(1)AQS(AbstractQueuedSynchronizer)抽象队列同步器,它是Java并发包的核心基础框架,用于构建锁、信号量等同步工具,像ReentrantLock、Semaphore都是基于AQS实现的;其核心思想是通过一个被volatile修饰的共享资源的状态(state和一个线程等待队列,来管理各个线程的阻塞与唤醒,实现多线程的竞争和协调;

(2)线程等待队列是基于一个先进先出的双向链表实现的,每个结点存储了线程的信息。在公平模式下,所有线程按顺序进入等待队列,头节点表示获取锁的线程,而后继节点表示等待的线程,当资源释放时,会按顺序唤醒队列中的线程;在非公平模式下(默认),外部线程不管队列中是否存在等待的线程,直接使用CAS操作来竞争资源,竞争失败就加入到队列尾部;

(3)通过volatile 修饰同步状态state,确保了共享变量的可见性和有序性,这个state相对于一个资源,默认是0(无锁状态),线程通过CAS操作修改state,保证操作原子性;

15.说一下ReentrantLock的实现原理?

(1)ReentrantLock是Java中基于AQS实现的可重入锁,内部维护了属性state表示锁的持有次数,0表示未被占用,大于0表示重入的次数;

(2)默认是非公平锁:外部线程不管队列中是否存在等待的线程,直接调用lock( )来直接CAS竞争,竞争失败就加入到队列尾部。如果线程已经持有锁并重入,state递增,每次释放state递减,直到state归零时完全释放锁,唤醒队列中等待的线程;它减少了线程切换开销,但可能导致线程饥饿;

(3)公平锁:ReentrantLock也支持公平锁。可以在构造函数中通过传入一个true的参数来指定为公平锁;当释放锁资源时,会唤醒队列中最前面的线程,确保队列中的线程按顺序获取锁,而外部线程在调用tryAcquire方法中会先检查队列中是否有等待的线程,保证公平性,但增加了上下文切换成本;

说明:唤醒队列里的线程会触发上下文切换,非公平锁下,可能存在线程正在运行且抢到了锁 → 无需唤醒队列里的线程,避免了上下文切换

16.synchronizedLock有什么区别?(重点)

(1)底层实现和使用:synchronized是关键字,源码在JVM内部,用C++实现的,基于对象的监视器锁实现的,它用来修饰方法或对象,确保同一时刻只有有一个线程访问该方法或代码块;而Lock是个接口,源码由JDK提供,用Java语言实现的,基于AQS抽象队列同步器实现的,需要显示创建实现了Lock接口的对象,比如ReentrantLock;

(2)锁的获取和释放:synchronized锁的获取和释放是隐式的,当一个线程进入synchronized块时会自动尝试获取锁,没有获得锁会被阻塞直到获取锁,线程离开就会自动释放锁;而Lock的获取和释放都需要显式地设置,调用lock()方法来获取锁,调用unlock()方法来释放锁;

(3)功能层面:二者都属于悲观锁,都具备基本的互斥、同步功能。但Lock提供了很多synchronized不具备的功能,比如公平锁可中断,可超时,多条件变量;Lock有适合不同场景的实现,如ReentrantLock,ReentranReadWriteLock;

(4)性能方面:在没有线程竞争情况下,synchronized做了很多优化,如偏向锁,轻量级锁,使用CAS操作减少了性能开销;如果竞争比较激烈,Lock的实现通常会提供更好的性能(比如尝试锁、定时锁);

(5)获取锁机制:synchronized获取锁是不可中断的,如果一个线程无法获取锁,他会一直等待直到获取锁,并且持有锁的时间不可控;而Lock尝试获取锁和持有锁是可控的,可以调用tryLock()方法尝试获取锁,设置等待时间和持有锁的最长时间,等待时间过后就放弃获取锁实现其他逻辑

17.死锁产生的原因是什么?

       死锁是指多个线程(或进程)因循环等待资源而永久处于阻塞的状态,其产生需要同时满足以下四个条件:

1)互斥:资源一次只能被一个线程独立占用;

2)保持和请求:线程持有至少一个资源,同时请求被其他线程持有的资源;

3)不可剥夺:资源只能由持有者主动释放,不可被强势剥夺;

4)循环等待:形成一个环形线程等待链,每个线程都在等待下一个线程所持有的资源;

       说白一点,就是双方占有对方需要的资源,并等待对方释放资源,处于僵持状态导致死锁。

18.

19.导致并发程序出现问题的原因是什么?

1)原子性破坏:一个操作被线程调度器打断,非原子性操作暴露在中间状态,可以通过synchronized、ReentrantLock锁解决;

2)可见性问题:线程对共享变量的修改未能及时同步到主内存,其他线程看到的是过时数据,可以通过volitile 、锁来解决;

3)有序性破坏:编译器和CPU的指令排序导致代码执行顺序与预期不符,可以通过volatile 解决;

20.线程池的执行流程知道吗?

       线程池是管理并复用线程的机制,核心目标是通过减少线程创建/销毁的开销来提升系统性能,执行流程如下:

(1)先调用execute 或submit方法提交任务

(2)判断核心线程数是否已满,未满就添加到工作线程集合中并执行,若核心线程已满就将任务添加到阻塞队列;

(3)接着判断阻塞队列是否已满,未满在任务队列等待执行;

(4)如果阻塞队列已满,会判断当前线程数是否大于最大线程数,是的话就根据拒绝策略处理(默认直接抛出异常),否则将创建非核心线程(临时线程)执行任务;

(5)另外,如果核心或临时线程完成任务会检查阻塞队列中是否有需要执行的线程,如果有,则使用临时线程执行任务;

说明: CallerRunsPolicy拒绝策略:让调用者线程去执行任务,但调用方线程忙于执行任务导致提交任务的速度变慢,从而起到一种“削峰填谷”的效果

21.你知道有哪些阻塞队列吗?

  1. ThreadPoolExecutor executor = new ThreadPoolExecutor(  
  2.     corePoolSize,     // 核心线程数  
  3.     maximumPoolSize,  // 最大线程数  
  4.     keepAliveTime,    // 空闲线程存活时间  
  5.     TimeUnit.SECONDS, // 存活时间单位  
  6.     workQueue         // 任务队列(阻塞队列)  
  7. ); 

(1)ArrayBlockingQueue:基于数组实现的有界阻塞队列,创建时必须指定容量,适用于固定任务的场景;

(2)LinkedBlockingQueue(默认使用):基于链表实现的无界阻塞队列(默认是Integer.MAX_VALUE),但可以指定容量防止OOM,适用于任务数量较多且执行较快的场景;

(3)DelayWorkQueue:基于小顶堆实现的,任务必须实现Delayed接口,任务按剩余时间排序,用于定时任务或延迟任务;

(4)SynchronousQueue:容量为0,没有任务缓冲区,任务提交后必须立即被线程处理;核心线程数必须设为0或1,否则会造成线程资源耗尽,适用于低延迟任务、高实时性;

(5)PriorityBlockingQueue:基于小顶堆实现的,无界队列,任务具有优先级(需要实现Comparable<T>接口),线程池不会按照FIFO,而是根据优先级来执行任务;

22.如何确定核心线程数量?

       首先,我们可以把任务分为CPU密集型任务I/O密集型任务

(1)CPU密集型任务就好比是一些单纯的数学计算任务,充分利用CPU资源,不会涉及太多的I/O操作,不会因为I/O操作而被阻塞,因此不需要太多的线程。因此核心线程数 = CPU核数+1,减少线程上下文切换;

(2)I/O密集型任务例如文件的读取、数据库的读取,涉及很多I/O操作,任务在读取这些数据时,是无法利用CPU的,对应的线程会被阻塞等待I/O读取完成,因此需要更多的线程来执行任务。核心线程数 = CPU核数 * 2 或更多;

(3)以上的公式仅仅是一个纯理论值,仅供参考。实际开发中还需要另外考虑机器的硬件配置、预期CPU利用率和负载等因素;

23. 线程池的种类有哪些?

(1)FixedThreadPool(固定大小线程池),线程池中的线程数是固定的(核心线程数等于最大线程数),阻塞队列采用的是无界队列LinkedBlockingQueue,可能导致OOM。超出的任务会在阻塞队列中等待,直到有线程空闲。适用于处理大量短时间任务,且希望保持一定数量的工作线程进行处理;

(2)SingleThreadExecutor(单线程化线程池),线程池只有一个线程(核心线程数=最大线程数=1),阻塞队列采用的是无界队列LinkedBlockingQueue,可能导致OOM。保证所有的任务按照指定顺序(FIFO)执行,适用场景如日志记录,任务调度;

(3)CachedThreadPool(缓存线程池),线程池的线程数量没有固定的上限(核心线程数为0,最大线程数为Integer.MAX_VALUE,可能会导致OOM),根据需要创建新的线程,线程默认的存活时间为1分钟。适用于执行大量短生命周期的任务(短时间的I/O密集型任务);

(4)ScheduledThreadPool(定时任务线程池),线程数量是固定的,由核心线程数量决定,提供了定时任务调度的能力,支持定时任务、延迟任务、周期任务执行。适用于需要定时执行任务的场景;

24.为什么不推荐用Executors创建线程池?

       不推荐使用Executors直接创建线程池,主要是因为默认参数不合理,可能会导致OOM或任务堆积,阿里巴巴官方也建议使用ThreadPoolExcutor来创建线程,自己根据实际情况设置合理的线程池参数。

(1)固定大小线程池和单线程化线程池默认的阻塞队列为无界队列LinkedBlockingQueue长度为Integer.MAX_VALUE,可能会堆积大量的任务,占满内存从而导致OOM;

(2)缓存线程池创建的最大线程数量为Integer.MAX_VALUE,可能会创建大量的线程,耗尽CPU资源;

(3)ScheduledThreadPool默认的阻塞队列也是无界队列DelayedWorkQueue,可能会堆积大量的任务,占满内存从而导致OOM;

(4)因此推荐使用通过ThreadPoolExecutor的方式创建线程池,可以自己根据需要设置核心线程数,最大线程数,阻塞队列类型和长度。

25.如何在某个方法中限制并发线程的数量?

(1)在多线程中提供了一个工具类Semaphore信号量,通过Semaphore来限制某个方法中的并发线程数。首先创建常量Semaphore对象,初始化并发下最大线程数(总信号),调用acquire( ) 可以请求一个信号量,总信号量就会减一,如果没有信号量,该线程就会被阻塞直到有其他线程释放;调用release()释放一个信号量,总信号量加一;

  1. import java.util.concurrent.*;  
  2.    
  3. public class ThreadPoolLimitedConcurrency {  
  4.     private static final ExecutorService threadPool = new ThreadPoolExecutor(  
  5.             3,  // 核心线程数  
  6.             3,  // 最大线程数  
  7.             0L, TimeUnit.MILLISECONDS,  
  8.             new LinkedBlockingQueue<>(10// 队列容量  
  9.     );  
  10.     public static void doTask(String taskName) {  
  11.         System.out.println(taskName + 开始执行");  
  12.         try {  
  13.             Thread.sleep(2000); // 模拟任务执行  
  14.         } catch (InterruptedException e) {  
  15.             e.printStackTrace();  
  16.         }  
  17.         System.out.println(taskName + 执行完毕");  
  18.     }  
  19.    
  20.     public static void main(String[] args) {  
  21.         for (int i = 1; i <= 10; i++) {  
  22.             final String taskName = "任务-" + i;  
  23.             threadPool.submit(() -> doTask(taskName));  
  24.         }  
  25.         threadPool.shutdown(); // 关闭线程池,等待任务完成  
  26.     }  
  27. }  


(2)使用ThreadPoolExecutor创建线程池时通过设置最大线程数来限制并发线程数;

26.谈一下你对ThreadLocal的理解?(重点)

       ThreadLocal是Java提供的线程本地存储机制,它允许每个线程拥有独立的变量副本、内存空间,互不影响,从而在多线程环境中实现数据隔离。

       底层实现:每个线程(Thread)内部有一个ThreadLocalMap,调用set方法就是ThreadLocal对象作为key,资源对象作为value,存储到当前线程的ThreadLocalMap集合中。调用get方法,就是以ThreadLocal对象作为key,到当前线程查找关联的资源。调用remove方法,就是以ThreadLocal自己作为key,移除当前线程关联的资源。

       内存泄漏问题:ThreadLocalMap中的key是弱引用,但value是强引用,key会被GC释放内存,但value关联的内存不会被释放,因此建议显式remove释放key和value

  1. // 工具类:UserContext,用于在当前线程中保存用户信息  
  2. public class UserContext {  
  3.   
  4.     private static final ThreadLocal<Long> userIdHolder = new ThreadLocal<>();  
  5.   
  6.     // 设置当前用户ID  
  7.     public static void setUserId(Long userId) {  
  8.         userIdHolder.set(userId);  
  9.     }  
  10.   
  11.     // 获取当前用户ID  
  12.     public static Long getUserId() {  
  13.         return userIdHolder.get();  
  14.     }  
  15.   
  16.     // 清除ThreadLocal,避免内存泄漏  
  17.     public static void clear() {  
  18.         userIdHolder.remove();  
  19.     }  
  20. }  

五、常见集合篇

1.数组索引为啥从0开始,不从1开始呢?

数组是一种用连续的内存空间存储相同数据类型数据的线性数据结构;

数组的寻址公式:a[i] = baseAddress + i * dataTypeSize;  地址等于数组的首地址+索引数据类型大小*ix,如果索引从1开始,就需要增加一个减法操作,对于CPU来说就多了一个指令,性能不高。

2.ArrayList底层的实现原理是什么?

(1)Array List底层是用动态扩展的数组实现的;

(2)JDK8之后,ArrayList初始容量为0,当第一次添加数据的时候才初始容量为10;

(3)在进行扩展时新容量是原来的1.5倍,每次扩展都需要拷贝数据

(4)在添加数据的时候有以下情况:首先判断当前数组是否有足够容量存储新数据,如果容量不足,将调用grow方法进行扩容(原来的1.5倍),确保新增数据有地方存储后,将新元素添加到位于size的位置上,返回添加成功布尔值。

✅ 1. 底层结构

  1. // ArrayList 底层维护一个 Object 类型的数组  
  2. transient Object[] elementData; // 实际存储元素的数组  
  3.   
  4. private int size; // 当前 ArrayList 中元素的个数  

✅ 2. 添加元素的核心方法:add(E e)

  1. public boolean add(E e) {  
  2.     ensureCapacityInternal(size + 1); // 确保容量够  
  3.     elementData[size++] = e; // 元素插入到数组中,size 自增  
  4.     return true;  

✅ 3. 容量检查和扩容:ensureCapacityInternal()

  1. private void ensureCapacityInternal(int minCapacity) {  
  2.     if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {  
  3.         // 第一次添加元素时默认容量设为 10  
  4.         minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);  
  5.     }  
  6.   
  7.     ensureExplicitCapacity(minCapacity);  
  8. }  
  9.   
  10. private void ensureExplicitCapacity(int minCapacity) {  
  11.     modCount++;  
  12.     // 如果实际容量不够,就扩容  
  13.     if (minCapacity - elementData.length > 0)  
  14.         grow(minCapacity);  
  15. }  

3.如何实现数组和List之间的转换?

(1)利用Arrays.asList 将数组转换为List。实现机制:将数组转化为一个固定大小的列表,底层直接引用原始数组。转换之后如果修改了数组内容,list将受影响,因为它的底层使用的是Arrays类中的一个内部类ArrayList来构造集合,在这个集合的构造器中,把我们传入的这个集合做了包装而已,最终指向的都是同一个内存地址as看作)

(2)利用list.toArray 将List转化为数组。实现机制:将列表中的元素复制道一个新数组,新数组和原来列表独立。转化之后修改list内容,数组不受影响,在底层它是进行了数组的拷贝,跟原来的没啥关系;to转换)

4.ArrayList和LinkedList的区别是什么?

(1)底层数据结构不同。ArrayList是动态数组的数据结构实现的;LinkedList是双向链表的数据结构实现的;

(2)操作数据效率不同。

  1. 查找:ArrayList的内存是连续的,可按照下标索引随机访问,时间复杂度为O(1),如果根据值来查找元素需要遍历数组,时间复杂度为O(n), 而LinkedList没有索引,需要遍历链表查找元素,时间复杂度为O(n);
  2. 增删:ArrayList插入和删除尾部元素时间复杂度为O(1),其他部分需要移动后续元素,时间复杂度为O(n); LinkedList插入和删除头部或尾部时间复杂度为O(1), 其他节点在给定节点指针的情况下时间复杂度为O(1)

 (3)内存空间占用不同。ArrayList底层是数组,内存连续,节省空间;LinkedList底层是双向链表,需要存储数据和两个指针,更占用内存;

5.二叉树、二叉搜索树、红黑树的定义?

(1)二叉树是一种树形数据结构,其中每个节点最多有两个子节点,分别成为左子节点和右子节点。分为为满二叉树:除了叶子节点,所有节点都有两个子节点,并且叶子节点都位于同一层;完全二叉树:树的每一层节点都尽量靠左排列,只有最后一层的节点可能不满;

(2)二叉搜索树是二叉树的一种扩展,其左子树的所有节点的值都小于根结点的值,右子树所有结点的值都大于根结点的值,左、右子树本身也是二叉搜索树;中序遍历(左根右)会生成一个升序的序列;插入和查找的时间复杂度是O(h), 其中h是树的高度。在极端最坏的情况下,二叉搜索树会退化为链表(如连续插入升序数据),此时查找效率下降为O(n);

 (3)红黑树是一种自平衡的二叉搜索树,通过附加的颜色属性(红色或黑色)以及旋转操作,保证树的平衡,从而提高效率;红黑树满足以下性质:

  1. 每个节点要么是红色,要么是黑色;
  2. 根节点是黑色;
  3. 叶子节点都是空的黑色节点;
  4. 红色节点的子节点必须是黑色(既没有连续的红色节点);
  5. 从任何节点都其每个叶子节点的所有路径中,包含相同数量的黑色节点;

       查找、插入、删除最坏情况下的时间复杂度为O(logn),通过选择和重新着色保持树的平衡;

      

6.什么是散列表,散列冲突,链表法(拉链)?

(1)散列表用名为哈希表,是一种基于键值对存储的数据结构,通过散列函数将键映射到对应的存储位置(由数组演化而来,根据下标进行随机访问数据);平均情况下,插入、删除和查找的时间复杂度为O(1);

(2)散列冲突又称为哈希冲突,是指两个不同的键经过散列函数计算出相同的索引,映射到相同的存储位置;常见的解决方法为:开放地址法:如果发生冲突,按某种策略(线性探测、二次探测)寻找下一个空位置;拉链法:将冲突的元素存储在同一索引的链表中;再散列法:通过另一个散列函数重新计算索引;开放地址法在哈希表里找下一个空格放进去,相比之下再散列法更加均匀分布;

(3)散列表的每个下标位置称之为桶或槽,每个桶会对应一条链表,当发生冲突后的元素将放到相同槽位对应的链表或红黑树;查找时,现根据散列函数找到对应的链表,在链表中搜索目标键值;

7.HashMap实现原理

       HashMap的底层是使用哈希表的数据结构,即数组+链表或红黑树

(1)当我们往HashMap中put元素时,根据key值利用hashCode方法计算出hash值,映射出索引位置(数组下标);

(2)存储数据时,如果出现hash值相同的key,有两种情况如下:

  1. 如果key相同,则覆盖原始值;
  2. 如果key不相同(即哈希冲突),则将当前的键值对放入链表或红黑树;

(3)获取数据时,直接找到hash值对应的下标,在进一步判断key是否相同,从而找到对应值;

✅ 1. 底层结构定义

  1. // HashMap 是一个数组加链表的结构,数组中存的是 Node 节点  
  2. transient Node<K,V>[] table;  
  3.   
  4. // 键值对数量  
  5. transient int size;  

✅ 2. Node 节点定义

  1. static class Node<K,V> implements Map.Entry<K,V> {  
  2.     final int hash;  
  3.     final K key;  
  4.     V value;  
  5.     Node<K,V> next; // 指向链表的下一个节点  
  6. }  

✅ 3. put() 方法源码(核心逻辑)

  1. public V put(K key, V value) {  
  2.     return putVal(hash(key), key, value, falsetrue);  
  3. }

核心方法是 putVal():

  1. final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {  
  2.     Node<K,V>[] tab; Node<K,V> p; int n, i;  
  3.   
  4.     // 如果数组还没初始化,调用 resize() 初始化  
  5.     if ((tab = table) == null || (n = tab.length) == 0)  
  6.         n = (tab = resize()).length;  
  7.   
  8.     // 计算索引位置,若当前位置为空直接插入  
  9.     if ((p = tab[i = (n - 1) & hash]) == null)  
  10.         tab[i] = newNode(hash, key, value, null);  
  11.     else {  
  12.         // 冲突处理:遍历链表或树结构  
  13.         Node<K,V> e; K k;  
  14.         if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))  
  15.             e = p; // 键重复,覆盖值  
  16.         else if (p instanceof TreeNode)  
  17.             e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);  
  18.         else {  
  19.             for (int binCount = 0; ; ++binCount) {  
  20.                 if ((e = p.next) == null) {  
  21.                     p.next = newNode(hash, key, value, null);  
  22.                     // 如果链表长度大于8,则转为红黑树  
  23.                     if (binCount >= TREEIFY_THRESHOLD - 1)  
  24.                         treeifyBin(tab, hash);  
  25.                     break;  
  26.                 }  
  27.                 if (e.hash == hash && (e.key.equals(key)))  
  28.                     break;  
  29.                 p = e;  
  30.             }  
  31.         }  
  32.         if (e != null) {  
  33.             V oldValue = e.value;  
  34.             if (!onlyIfAbsent || oldValue == null)  
  35.                 e.value = value;  
  36.             return oldValue;  
  37.         }  
  38.     }  
  39.     ++modCount;  
  40.     if (++size > threshold)  
  41.         resize(); // 超过阈值就扩容  
  42.     return null;  
  43. }  

8.jdk1.7和jdk1.8的HashMap有什么区别?

(1)新增红黑树:jdk1.7底层是数组+链表;jdk1.8添加了红黑树,当HashMap容量达到64且链表长度超过8就会转化为红黑树,查找、插入和删除的时间复杂度由O(n)提升至O(logn);

(2)哈希函数的计算:jdk1.8优化了哈希函数,JDK1.7操作多经历了四次异或,JDK1.8将key的哈希码的高16位和低16位进行异或,得到hash值同时拥有高位和低位的特性,使得哈希的分布更加均匀,减少哈希冲突;

(3)扩容机制优化:JDK1.8在扩容过程中不会再对每个元素重新计算哈希值,而是通过判断 hash 中的特定位是否为 1 或 0来决定元素是留在原来位置,还是迁移到新数组的新位置。这一改动减少了不必要的计算,提升了扩容效率;

(4)头插法改为尾插法:头插法的优点是插入元素不需要遍历链表,时间复杂度为O(1),但缺点是在扩容的时候会逆序,而逆序在多线程下可能会出现环导致死循环。

说明:

(2)哈希函数的计算优化:JDK1.8 对 key 的 hashCode() 做了一次扰动运算,使得高位的信息能够影响低位,从而避免低位参与计算时过于集中导致冲突。

  1. static final int hash(Object key) {  
  2.     int h;  
  3.     // 扰动函数:高16位与低16位异或,提高 hash 分布均匀性  
  4.     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);  
  5. }  

✅ h >>> 16 是无符号右移 16 位,让高位数据参与计算。

✅ ^ 是按位异或。

(3)扩容机制优化:JDK1.8 中扩容是 搬迁元素位置而非重新计算 hash,通过判断 hash 中的特定位是否为 1 或 0 来决定是否“留在原位”或“移动到新位置”。

  1. int oldCap = oldTab.length;  
  2. int newCap = oldCap << 1// 扩容为原来的 2   
  3.   
  4. for (int j = 0; j < oldCap; ++j) {  
  5.     Node<K,V> e;  
  6.     if ((e = oldTab[j]) != null) {  
  7.         oldTab[j] = null;  
  8.         if (e.next == null) {  
  9.             // 无链表,直接放  
  10.             newTab[e.hash & (newCap - 1)] = e;  
  11.         } else {  
  12.             // 链表拆分成两个部分:low  high  
  13.             Node<K,V> loHead = null, loTail = null;  
  14.             Node<K,V> hiHead = null, hiTail = null;  
  15.             Node<K,V> next;  
  16.             do {  
  17.                 next = e.next;  
  18.                 if ((e.hash & oldCap) == 0) {  
  19.                     // 低位为0,仍然在原位  
  20.                     if (loTail == null)  
  21.                         loHead = e;  
  22.                     else  
  23.                         loTail.next = e;  
  24.                     loTail = e;  
  25.                 }  
  26.                 else {  
  27.                     // 低位为1,移到新位置 = 原位置 + oldCap  
  28.                     if (hiTail == null)  
  29.                         hiHead = e;  
  30.                     else  
  31.                         hiTail.next = e;  
  32.                     hiTail = e;  
  33.                 }  
  34.             } while ((e = next) != null);  
  35.   
  36.             if (loTail != null) {  
  37.                 loTail.next = null;  
  38.                 newTab[j] = loHead;  
  39.             }  
  40.             if (hiTail != null) {  
  41.                 hiTail.next = null;  
  42.                 newTab[j + oldCap] = hiHead;  
  43.             }  
  44.         }  
  45.     }  
  46. }  

(4)头插法改为尾插法

  1. if (binCount >= TREEIFY_THRESHOLD - 1// >= 8  
  2.     treeifyBin(tab, hash);  
  3. if (oldTail == null)  
  4.     first = newNode(hash, key, value, null);  
  5. else  
  6.     oldTail.next = newNode(hash, key, value, null);  
  7. oldTail = oldTail.next;  

9.HashMap的put方法的具体流程?

(1)判断键值对数组table是否为空,否则执行resize()方法进行扩容(容量初始化为16);

(2)根据键值key计算哈希值找到数组索引;

(3)判断table[i] 是否为空,如果为空直接添加新数据;

(4)table[i]不可空,如下操作:

  1. 判断table[i]的首个元素是否和key值一样,如果相同直接覆盖value值;
  2. 判断当前槽位是否为红黑树,如果是红黑树直接插入数据,遍历过程中如果发现key已经存在直接覆盖value值;
  3. 如果是链表就遍历链表,在链表尾部插入数据,接着判断如果链表长度大于8且HashMap容量大于等于64,链表将转化为红黑树,遍历过程中如果发现key已经存在直接覆盖value值;

  1. final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {  
  2.     Node<K,V>[] tab; // 哈希表数组  
  3.     Node<K,V> p; // 当前节点  
  4.     int n, i; // 数组长度和索引  
  5.     if ((tab = table) == null || (n = tab.length) == 0// 如果哈希表为空或长度为 0,则进行扩容  
  6.         n = (tab = resize()).length; // 调用 resize() 方法扩容并更新长度  
  7.   
  8.     // 根据哈希值计算桶数组的索引  
  9.     if ((p = tab[i = (n - 1) & hash]) == null// 如果该位置为空,则直接插入新的节点  
  10.         tab[i] = newNode(hash, key, value, null);  
  11.     else {  
  12.         Node<K,V> e;   
  13.         K k;  
  14.         // 如果哈希值相同并且 key 相等(或 null 相等),表示找到了相同的键,更新值  
  15.         if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))  
  16.             e = p; // 找到相同的键,设置为 e  
  17.         else if (p instanceof TreeNode) // 如果桶是一个红黑树节点,使用树的方式查找  
  18.             e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);  
  19.         else {  
  20.             // 如果桶是链表,遍历链表  
  21.             for (int binCount = 0; ; ++binCount) {  
  22.                 if ((e = p.next) == null) { // 如果到达链表末尾,插入新节点  
  23.                     p.next = newNode(hash, key, value, null);  
  24.                     // 如果链表长度超过阈值,转换为红黑树  
  25.                     if (binCount >= TREEIFY_THRESHOLD - 1)  
  26.                         treeifyBin(tab, hash);  
  27.                     break;  
  28.                 }  
  29.                 // 如果找到了相同的键,跳出循环  
  30.                 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))  
  31.                     break;  
  32.                 p = e; // 否则继续遍历链表  
  33.             }  
  34.         }  
  35.   
  36.         // 如果找到了相同的键 e  
  37.         if (e != null) {  
  38.             V oldValue = e.value; // 保存旧值  
  39.             // 如果 onlyIfAbsent  false 或者旧值为 null,更新值  
  40.             if (!onlyIfAbsent || oldValue == null)  
  41.                 e.value = value;  
  42.             afterNodeAccess(e); // 更新后处理(如更新访问时间等)  
  43.             return oldValue; // 返回旧值  
  44.         }  
  45.     }  
  46.   
  47.     ++modCount; // 增加修改次数  
  48.     if (++size > threshold) // 如果元素数量超过阈值,进行扩容  
  49.         resize();  
  50.     afterNodeInsertion(evict); // 插入后处理(如是否需要淘汰节点等)  
  51.     return null// 如果没有找到重复的键,则返回 null  
  52. }  

10.HashMap的扩容机制是什么?

(1)JDK8之后,新建如果不指定初始容量默认为0,在首次添加元素时调用resize( )方法进行扩容,初始化数组长度为16,之后每次扩容都需要达到扩容阈值,扩容阈值=数组长度*加载因子(默认0.75);

(2)每次扩容之后,新容量都是之前容量的两倍;(数组长度必须为2的n次幂),如果新容量大于最大容量,设置为最大容量;

(3)扩容之后,会创建一个新数组,需要把旧数组的数据拷贝到新数组中,规则为:

  1. 没有哈希冲突的节点,直接使用e.hash & (newCap-1) 计算新的索引位置;
  2. 如果当前索引位置是红黑树,进行拆分添加到新数组;
  3. 如果是链表,需要遍历链表,判断(e.hash & olcCap)是否等于0决定元素的高低位,如果为0添加到新数组原始索引位置,不为0就移动到原始索引+旧容量的索引位置;(可能拆分链表,减少每个桶的哈希冲突)
  1. final void resize() {  
  2.     Node<K,V>[] oldTable = table;  
  3.     int oldCapacity = (oldTable == null) ? 0 : oldTable.length;  
  4.     int oldThreshold = threshold;  
  5.     int newCapacity = oldCapacity << 1// 新容量是旧容量的两倍  
  6.     if (newCapacity > MAXIMUM_CAPACITY) // 如果新容量大于最大容量,设置为最大容量  
  7.         newCapacity = MAXIMUM_CAPACITY;  
  8.     int newThreshold = (int)(newCapacity * loadFactor); // 计算新的阈值  
  9.   
  10.     // 如果新容量大于最大容量或负载因子小于等于 0,则返回  
  11.     if (newCapacity - oldCapacity <= 0)  
  12.         return;  
  13.   
  14.     threshold = newThreshold; // 更新负载因子阈值  
  15.     table = new Node[newCapacity]; // 创建新数组  
  16.   
  17.     // 重新散列,将旧数组中的元素迁移到新数组中  
  18.     if (oldTab != null) {  
  19.       for (int j = 0; j < oldCap; ++j) {  
  20.          Node<K,V> e;  
  21.          if ((e = oldTab[j]) != null) {  
  22.              oldTab[j] = null;  
  23.               if (e.next == null) { // 单个节点直接迁移  
  24.                  newTab[e.hash & (newCap - 1)] = e;  
  25.               } else if (e instanceof TreeNode) { // 红黑树拆分  
  26.              ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);  
  27.               } else { // 链表拆分  
  28.                  Node<K,V> loHead = null, loTail = null;  
  29.                     Node<K,V> hiHead = null, hiTail = null;  
  30.                   do {  
  31.                          if ((e.hash & oldCap) == 0) { // 低位链表  
  32.                           if (loTail == null) loHead = e;  
  33.                           else loTail.next = e;  
  34.                          loTail = e;  
  35.                          } else { // 高位链表  
  36.                          if (hiTail == null) hiHead = e;  
  37.                             else hiTail.next = e;  
  38.                             hiTail = e;  
  39.                           }  
  40.                   } while ((e = e.next) != null);  
  41.                  if (loTail != null) {  
  42.                       newTab[j] = loHead; // 原位置  
  43.                   }  
  44.                     if (hiTail != null) {  
  45.                       newTab[j + oldCap] = hiHead; // 原位置+旧容量  
  46.                     }  
  47.             }  
  48.         }  
  49.     }  
  50. }  

11.jdk1.7的hashMap多线程死循环问题?

       Jdk1.7和hashMap在进行扩容的时候,链表使用头插法。

  1. 线程1:读取当前hashMap数据,准备扩容时,线程2介入;
  2. 线程2:也读取hashMap,接着扩容。原来链表顺序为AB,因为头插法扩容后为BA,线程2执行结束;
  3. 线程1:A的next指向B,形成B->A->A的循环;

因此jdk8将扩容做了修改,链表变为尾插法;

12.TreeMap的底层实现原理和属性?

(1)TreeMap是基于红黑树实现的有序映射,保证键值对按照键的顺序排列。

(2)有序性:键值对按照键的自然顺序(通过Comparable实现)或自定义排序顺序(通过构造时传入的Comparator)进行排序;唯一性:键是唯一的,映射出键值对;

(3)TreeMap的每个节点由TreeMap.Entry对象表示,它包含键、值、左右节点和父节点的信息;

  1. static final class Entry<K,V> extends AbstractMap.SimpleEntry<K,V> {  
  2.     Entry<K,V> left;   // 左子节点  
  3.     Entry<K,V> right;  // 右子节点  
  4.     Entry<K,V> parent; // 父节点  
  5.     boolean color;     // 颜色,红或黑  
  6.   
  7.     // 构造函数  
  8.     Entry(K key, V value, Entry<K,V> parent) {  
  9.         super(key, value);  
  10.         this.parent = parent;  
  11.     }  
  12. }  

13.可以介绍一下HashSet、TreeSet吗?

(1)HashSet的底层是基于HashMap实现的,每个元素作为HashMap的键,而值是一个固定的常量PRESENT; 当添加元素时,实际上调用HashMap.put(key, PRESENT)方法;通过HashMap的键的唯一性来保证HashSet元素的唯一性;基本操作(添加、删除、查找)的时间复杂度为O(1);

(2)TreeSet的底层是基于TreeMap实现的,它的元素存储在TreeMap的键中;当添加元素时,实际上调用HashMap.put(key, PRESENT)方法;通过TreeMap的键的唯一性和红黑树的性质保证了元素的唯一性和有序性,基本操作(添加、删除、查找)的时间复杂度为O(log N);

  1. public class HashSet<E> implements Set<E>, Cloneable, Serializable {  
  2.   
  3.     private static final Object PRESENT = new Object();  
  4.     private transient HashMap<E,Object> map;  
  5.   
  6.     public HashSet() {  
  7.         map = new HashMap<>();  
  8.     }  
  9.   
  10.     public boolean add(E e) {  
  11.         return map.put(e, PRESENT) == null;  // 如果元素e没有存在,添加并返回true  
  12.     }  
  13.   
  14.     public boolean contains(Object o) {  
  15.         return map.containsKey(o);  // 查找元素o是否存在  
  16.     }  
  17.   
  18.     public boolean remove(Object o) {  
  19.         return map.remove(o) == PRESENT;  // 删除元素o,返回删除结果  
  20.     }  
  21.   
  22.     public void clear() {  
  23.         map.clear();  // 清空所有元素  
  24.     }  
  25.   
  26.     public int size() {  
  27.         return map.size();  // 返回元素数量  
  28.     }  
  29. }  

14.HashMap和HashTable的区别是什么?

HashMap和Hashtable都是Java中用于存储键值对的哈希表类,区别如下:

(1)线程安全:HashMap不是线程安全的,如果多个线程并发访问HahsMap,并且至少有一个线程做了修改,可能会导致数据不一致的情况;而Hashtable是线程安全的,他的所有公共方法都被synchronized修饰,同一个方法只有一个线程可以执行,确保了多线程环境下数据安全;

(2)性能:HashMap性能通常优于Hashtable,特别是在单线程环境下;而Hashtable由于方法上都有同步锁,性能较差;

(3)Null:HashMap允许一个null值(键唯一性)和多个null值;而Hashtable不允许出现null键或null值。

15. HashMap和concurrentHashMap的区别是什么?

(1)线程安全:HashMap不是线程安全的,在高并发操作下可能会导致数据不一致的情况;而concurrentHashMap是线程安全的,他的锁机制比HashTable粒度更加细化,利用CAS+synchronized实现:当桶的首节点为空,直接通过CAS操作插入新节点,如果桶的首节点不为空,利用synchronized锁住头节点,在锁内执行写操作。

(2)性能:HashMap没有锁开销,操作速度快,特别是在单线程;concurrentHashMap的写操作由于锁机制性能较低,而读操作不需要加锁。

(3)Null:HashMap允许一个null值(键唯一性)和多个null值;而concurrentHashMap不允许出现null键或null值。

16. 能说一下ConcurrentHashMap吗?

(1)数据结构:跟HashMap类似,JDK1.7底层采用分段的数组+链表实现;JDK1.8采用数组+链表/红黑树;

(2)加锁的方式:JDK1.7采用的是Segment分段锁(默认16个),即每个segment是相互独立的(可以看作为HashTable),可以并发访问不同的segment,因此最多有16个线程可以并发执行; JDK1.8移除了segment,锁的粒度更加细化,添加新节点时,如果该位置为空采用CAS,否则采用synchronized锁定链表或红黑树首结点,目的是防止多个线程同时执行put( ) 方法,可能同时定位到同一个桶的空位置,导致后写入的数据覆盖掉前写入的数据问题。

  1. final V putVal(K key, V value, boolean onlyIfAbsent) {  
  2.     int hash = spread(key.hashCode()); // 扰动函数,提高分布性  
  3.     int binCount = 0;  
  4.   
  5.     for (;;) {  
  6.         Node<K,V>[] tab = table;  
  7.         int n;  
  8.         if (tab == null || (n = tab.length) == 0) {  
  9.             tab = initTable(); // 初始化表  
  10.         }  
  11.         int i = (n - 1) & hash;  
  12.         Node<K,V> f = tabAt(tab, i);  
  13.   
  14.         // CAS 插入:如果该位置是空桶  
  15.         if (f == null) {  
  16.             if (casTabAt(tab, i, nullnew Node<K,V>(hash, key, value, null))) {  
  17.                 break// 插入成功  
  18.             }  
  19.         }  
  20.         // 已经有节点,进入 synchronized 锁桶操作  
  21.         else {  
  22.             V oldVal = null;  
  23.             synchronized (f) {  
  24.                 if (tabAt(tab, i) == f) {  
  25.                     if (f.hash == hash && (f.key.equals(key))) { 
  26.                         oldVal = f.val;  
  27.                         if (!onlyIfAbsent) {  
  28.                             f.val = value; // 覆盖旧值  
  29.                         }  
  30.                     } else {  
  31.                         // 桶内是链表,尾插新节点(省略红黑树逻辑)  
  32.                         for (Node<K,V> e = f; ; ++binCount) {  
  33.                             Node<K,V> pred = e;  
  34.                             if (e.next == null) {  
  35.                                 pred.next = new Node<K,V>(hash, key, value, null);  
  36.                                 break;  
  37.                             }  
  38.                             e = e.next;  
  39.                         }  
  40.                     }  
  41.                 }  
  42.             }  
  43.             if (oldVal != null)  
  44.                 return oldVal;  
  45.             break;  
  46.         }  
  47.     }  
  48.     size.add(1); // 累加元素数目  
  49.     return null;  
  50. }  

17.ArrayList和数组的区别是什么?

(1)数组长度固定,在创建数组时需要指定容量大小;ArrayList底层是基于动态扩展的数组实现,长度可变;

(2)数组可以存储基本数据类型和引用数据类型;ArrayList只能存储引用数据类型,无法存储基本数据类型;

(3)数组功能简单,只能存储和根据索引访问;ArrayList提供更加丰富的方法,如add( )、remove( )、contains( )、size( )等,使用更加方便;

六、拓展篇

1. 能说一下注解的底层机制和怎么自定义注解吗?

Java注解是JDK1.5引入的一种元数据机制,用于为代码添加标记信息。本质上注解是一个继承Annotation接口的接口,它的成员方法对应其属性。Java编译器在运行时通过反射机制读取并处理这些注解信息;注解底层工作分为以下三个阶段

(1)编译时:使用注解处理器在编译时扫描并生成对应代码(比如Lombok)

(2)类加载时:注解信息可以被保存在字节码文件中

(3)运行时:使用Java反射API读取注解并执行相关逻辑

例如:Spring 中使用注解

  1. @Autowired  
  2. private UserService userService;  

Spring 在运行时通过反射获取 @Autowired 注解,然后用 BeanFactory 给 userService 赋值。

自定义注解:结合 Spring AOP,拦截注解方法实现业务逻辑(最常用)

举个完整例子:你想让加了 @MyLog 注解的方法自动打印执行时间。

✅ 1. 定义注解

  1. @Target(ElementType.METHOD)    // 方法注解
  2. @Retention(RetentionPolicy.RUNTIME)   // 生命周期为运行时
  3. public @interface MyLog {  
  4.     String value() default "执行日志";  
  5. }  

✅ 2. 在方法上使用注解

  1. @MyLog("执行 getUser")  
  2. public void getUser() {  
  3.     // 模拟业务逻辑  
  4.     System.out.println("执行 getUser 方法");  
  5. }  

✅ 3. 定义 AOP 切面类,拦截带有该注解的方法

  1. @Aspect  
  2. @Component  
  3. public class MyLogAspect {  
  4.   
  5.     @Around("@annotation(myLog)")  // 拦截所有加了 @MyLog 的方法  
  6.     public Object around(ProceedingJoinPoint joinPoint, MyLog myLog) throws Throwable {  
  7.         long start = System.currentTimeMillis();  
  8.   
  9.         // 打印注解内容  
  10.         System.out.println("【日志】开始执行:" + myLog.value());  
  11.   
  12.         Object result = joinPoint.proceed();  // 执行原方法  
  13.   
  14.         long time = System.currentTimeMillis() - start;  
  15.         System.out.println("【日志】执行耗时:" + time + "ms");  
  16.   
  17.         return result;  
  18.     }  
  19. }  

2. 描述一下简单工厂模式、工厂方法模式和抽象工厂方法模式?

(1)简单工厂核心思想是:一个工厂类,它会根据传入的参数决定创建哪种具体产品

结构如下:

·工厂类:包含一个静态/实例方法,内部通过条件判断实例化哪个具体产品;

·抽象产品:定义产品的接口;

·具体产品:实现抽象产品接口;

客户端和产品类解耦,无需直接实例化产品,但新增产品需要修改工厂类,违背开闭原则

  1. interface Animal {  
  2.     void speak();  
  3. }  
  4.   
  5. class Dog implements Animal {  
  6.     public void speak() { System.out.println("汪汪!"); }  
  7. }  
  8.   
  9. class Cat implements Animal {  
  10.     public void speak() { System.out.println("喵喵!"); }  
  11. }  
  12. class AnimalFactory {  
  13.     public static Animal createAnimal(String type) {  
  14.         if ("dog".equals(type)) return new Dog();  
  15.         else if ("cat".equals(type)) return new Cat();  
  16.         else return null;  
  17.     }  
  18.  

(2)工厂方法核心思想是:将工厂抽象化,每个具体产品对应各自独立工厂

结构如下:

·抽象工厂:声明抽象方法;

·具体工厂:实现抽象工厂,生成具体产品;

·抽象产品:定义产品的接口;

·具体产品:实现抽象产品接口;

新增产品时只需要添加新工厂,符合开闭原则,每个产品需对应一个工厂,容易导致类膨胀

  1. interface Animal {  
  2.     void speak();  
  3. }  
  4.   
  5. class Dog implements Animal {  
  6.     public void speak() { System.out.println("汪汪!"); }  
  7. }  
  8.   
  9. class Cat implements Animal {  
  10.     public void speak() { System.out.println("喵喵!"); }  
  11. }  
  12.   
  13. interface AnimalFactory {  
  14.     Animal createAnimal();  
  15. }  
  16.   
  17. class DogFactory implements AnimalFactory {  
  18.     public Animal createAnimal() {  
  19.         return new Dog();  
  20.     }  
  21. }  
  22.   
  23. class CatFactory implements AnimalFactory {  
  24.     public Animal createAnimal() {  
  25.         return new Cat();  
  26.     }  
  27. }  

(3)抽象工厂的核心思想是:创建产品族(多个相关或依赖的对象),而不指定具体产品

结构如下:

·抽象工厂:声明多个创建方法

·具体工厂:实现同一产品族的多类产品;

·抽象产品:定义每个类别产品的接口;

·具体产品:实现抽象产品接口;

保证了产品族的兼容性,客户端无需关心具体实现,但新增产品种类需修改所用工厂,扩展困难;

  1. interface Dog {  
  2.     void bark();  
  3. }  
  4.   
  5. interface Cat {  
  6.     void meow();  
  7. }  
  8.   
  9. class HomeDog implements Dog {  
  10.     public void bark() { System.out.println("家狗叫!"); }  
  11. }  
  12.   
  13. class WildDog implements Dog {  
  14.     public void bark() { System.out.println("野狗叫!"); }  
  15. }  
  16.   
  17. class HomeCat implements Cat {  
  18.     public void meow() { System.out.println("家猫叫!"); }  
  19. }  
  20.   
  21. class WildCat implements Cat {  
  22.     public void meow() { System.out.println("野猫叫!"); }  
  23. }  
  24.   
  25. interface AnimalFactory {  
  26.     Dog createDog();  
  27.     Cat createCat();  
  28. }  
  29.   
  30. class HomeAnimalFactory implements AnimalFactory {  
  31.     public Dog createDog() { return new HomeDog(); }  
  32.     public Cat createCat() { return new HomeCat(); }  
  33. }  
  34.   
  35. class WildAnimalFactory implements AnimalFactory {  
  36.     public Dog createDog() { return new WildDog(); }  
  37.     public Cat createCat() { return new WildCat(); }  

3.序列化和反序列化能说一下吗?

说明:字节流是以字节为单位传输或存储的数据流

       序列化就是将对象转化为字节流,这样对象就能通过网络进行数据传输或实现磁盘存储上,目的是将对象的状态转换为可以持久化的形式,从而在不同的计算机系统间传输;反序列化就是将字节流恢复为对象,使得程序可以重新使用对象;

       常见的序列化为Java对象转化为JSON字符串,反序列化为将JSON字符串转化为对象。实现了 Serializable 接口,仅表明对象能够被序列化

4. @Autowired、@Resource和@Qualifier是常用的依赖注入注解,它们有什么区别?

(1)Autowired注解是Spring框架提供的;如果只找到一个类型匹配的Bean就会自动注入,如果找到多个,就需要配合Qualifier注解根据名称明确指定注入哪一个;默认要求Bean存在,如果不存在就会报异常,可以设置“required = false”来允许Bean为null;

(2)Resource注解是JDK提供的;优先根据名称(name属性)注入,找不到再按类型注入;可用于兼容旧项目和跨平台Java EE应用,如果不支持Qualifier注解,可以通过@Resource(name = “….”) 进行依赖注入

5.说一下JWT?

JWT(JSON Web Token)用于网络应用环境中安全地传输信息,它可以用于身份验证信息交换等场景,具有无状态、跨平台和易用性的特点,但为了确保安全,开发者应当注解密钥管理、过期时间的设置等。

       JWT由三部分组成,每部分由.分割,分别是Header(头部)、Payload(有效载荷)、Signature(签名)

(1)头部由两部分组成: typ :令牌类型,声明该部分是JWT;  alg :签名算法,例如HS256、RS256

(2)有效载荷:包含了用户信息和其他业务相关的数据,比如设置主题、用户信息(避免密码)、JWT过期时间

(3)签名:签名就是对头部和有效载荷根据签名算法进行加密,为了验证token的完整性和身份验证,避免token被篡改

6.抽象和接口有什么区别?

抽象类(Abstract Class)和接口(Interface)在 Java 中都用于定义类的公共行为规范,但它们有一些显著的区别。以下是抽象类和接口的主要区别:

(1)定义:抽象类是一个不能实例化的类,用于定义子类的公共行为和属性,用abstract声明抽象类;接口是一个完全抽象的类,用于声明实现类需要实现的抽象方法,用interface声明接口;

(2)成员:抽象类可以定义任意类型的成员变量,也可以定义抽象和具体方法;而接口只能定义常量,在jdk1.8之前只能定义抽象方法(默认public abstract修饰),jdk1.8之后可以定义静态方法和默认方法,允许存在方法的实现;

(3)继承:一个子类只能继承一个抽象类;而一个实现类可以实现多个接口;

(4)设计思想:接口偏向于自上而下的顶层设计思想,一开始不关注具体实现,先定义好行为规范,接着实现具体方法;抽象类偏向于自下而上的设计思想,先有多个具体实现,接着将多个子类的公共逻辑抽象提取出来;

7.  JDK、JRE、JVM之间的关系

       JDK(Java Development Kit)java开发工具包:Java开发软件环境,由JRE、开发工具组成,开发工具如编译器(javac)、调试器(jdb)、打包工具(jar)

       JRE(Java Runtime Environment)Java运行环境:运行Java程序,由JVM和核心类库组成;

       JVM(Java visual Machine)Java虚拟机:操作系统上运行的系统软件,将Java字节码文件翻译成操作系统可以执行的机器码;

8. jar包和war包的区别是什么?

​(1)用途:JAR包用于存储Java类、资源文件和依赖库,可以独立运行;WAR包专门用于Web应用,需部署到Servlet容器(如Tomcat)

(2)运行方式:JAR包直接通过Java -jar运行,内嵌Tomcat服务器;WAR需部署到外部的Servlet容器;

(3)使用框架:JAR适用于SpringBoot,一键运行;WAR适用于传统SpringMVC,需配置服务器环境;

9. 说一下什么是单体架构、微服务架构、分布式系统?

(1)单体架构:所有的功能模块集中在一个代码库中,统一编译、部署和运行;

  1. 优点:部署简单,部署运维成本低;单一进程运行,适合小规模应用;开发调试简单,模块间通过函数/类直接调用,实现事务简单
  2. 缺点:代码耦合度高,扩展性查,技术栈统一,难以局部升级;

(2)微服务架构:将系统拆分为多个独立的服务,每个服务专注于单一业务功能,独立开发、部署和扩展;

  1. 优点:实现灵活扩展,降低模块耦合度,服务独立隔离性强,不影响整体系统;
  2. 缺点:分布式事务实现复杂,运维、网路通信成本高

(3)分布式系统:由多个物理或逻辑结点组成的系统,这些节点通过网络协作,形成一个整体系统;(可以是单体也可以是微服务)

  1. 特点:组件分布:服务、数据、计算分散到不同节点;高可用:通过冗余和容错机制保证系统持续运行;
  2. 常见形式:微服务架构:服务分布在多个结点;分布式数据库:如MySQL集群、Reids集群、Cassandra;分布式计算:如Hadoop、Spark;Kafka消息队列;
  3. 优点:负载均衡,高性能;高可用性、容错性;横向扩展能力强;
  4. 缺点:开发调试复杂;网络延迟和分区问题;难做到数据强一致性

10.说一下Java源文件是怎么转化,在操作系统上执行的完整过程?

(1)编写Java源代码:开发者使用文本编译器(如idea)编写Java代码,并保存为 .java文件;

(2)编译:使用JDK中的编译器(javac)编译Java源文件,生成字节码文件;

(3)类加载和执行:Java字节码需要一个运行环境来执行,即Java虚拟机。JVM中的类加载器读取字节码文件并加载到方法区,进行字节码文件验证。JVM的解释器将字节码翻译成CPU可执行的机器码。另外,JIT会将热点代码即时编译为机器码,提高执行效率;

       JVM运行时,其本质上是一个本地进程,由操作系统的CPU和内存管理,运行在操作系统之上;

11. springBoot中,有开发、测试和生产阶段,对应不同的配置文件,这应该怎么设置

在 Spring Boot 中,为了支持开发(dev)、测试(test)、生产(prod)等多个环境的配置,推荐使用多环境配置文件 + 激活指定环境的方式

一、配置文件命名规范

Spring Boot 支持通过 application-{profile}.yml 或 .properties 来定义多环境配置:

例如:

  1. src/main/resources/  
  2. ├── application.yml              # 公共配置(所有环境通用)  
  3. ├── application-dev.yml         # 开发环境配置  
  4. ├── application-test.yml        # 测试环境配置  
  5. ├── application-prod.yml        # 生产环境配置  

二、在 application.yml 中统一配置和指定激活环境

  1. # application.yml(公共配置)  
  2. spring:  
  3.   profiles:  
  4.     active: dev   # 指定当前激活的配置文件(可以是 dev/test/prod  
  5.   
  6. 公共数据库配置  
  7. datasource:  
  8.   driver-class-name: com.mysql.cj.jdbc.Driver  
  9.   username: root  
  10.   password: root 

三、修改运行环境方式

(1)在配置文件中设置:

  1. spring:  
  2.   profiles:  
  3.     active: test  

(2)命令行运行时指定:

java -jar myapp.jar --spring.profiles.active=test

(3)在 IDE 中设置 VM 参数:

-Dspring.profiles.active=test

多个配置文件之间不会互相覆盖整个文件,而是按键值合并覆盖

不建议在每个 application-{profile}.yml 中重复所有配置,推荐在 application.yml 中写公共配置,其他文件只写差异部分。

12.cookiesession的区别是什么?(重点)

       Cookie 和 Session 都是用于存储用户状态和信息的技术,通常用于 Web 开发中,但它们的工作原理、存储方式和使用场景有所不同。下面是它们的主要区别:

(1)存储位置:Cookie:存储在客户端(浏览器)本地,它是浏览器维护的一部分,每次请求都可以发送到服务器;Session:存储在服务器端,当创建HttpSession时,服务器会默认生成一个JSESSIONID并存入Cookie中,作为与服务器端会话的标识,之后客户端都会自动携带sessionId,使服务器能够识别用户;

(2)存储容量: Cookie因为存储在客户端,浏览器对每个网站存储的 Cookie 数量和容量会有限制,通常为 4KB 左右;Session存储在服务器端,通常存储量要大得多,具体容量取决于服务器的配置和资源限制,;

(3)生命周期:Cookie:可以设置过期时间,未设置过期时间的 Cookie 在浏览器关闭时默认会被删除(即会话结束后失效)。如果设置了过期时间,Cookie 会在过期后自动删除浏览器关闭仍可以保留);Session:会话的生命周期通常与用户的浏览器会话有关。当浏览器关闭或会话超时(通常是 15 到 30 分钟)后,Session 会自动失效;

(4)安全性: Cookie:由于存储在客户端,Cookie 可能会被用户修改或窃取,存在一定的安全风险。为了提高安全性,可以使用 Secure 和 HttpOnly 属性来防止通过 JavaScript 访问 Cookie,并且只通过 HTTPS 传输 Cookie;Session:因为数据存储在服务器端,安全性较高。但如果攻击者获取了 Session ID,没有其他机制(如 IP 地址限制、Cookie 安全等),仍然可能存在风险;

(5)性能影响:Cookie:每次请求都会将相关的 Cookie 信息(包括会话 ID 等)发送到服务器,这可能导致额外的带宽消耗;Session:客户端每次请求都会发送 Session ID(通常通过 Cookie 或 URL 参数传递),但服务器才是存储和管理会话数据的地方。因为数据不需要传输到客户端,所以带宽消耗较小。

13.post和get方法的区别是什么?

       POST和GET是HTTP协议中两种常用的请求方法,它们在不同的用途、特性和工作原理等方面区别如下:

(1)用途:GET用于请求数据,适合非敏感数据的传输(比如关键词搜索、查询信息等);而POST用于提交数据,适合敏感或大量数据传输(比如提交表单、上传文件);

(2)参数位置:GET的请求参数直接附加在URL路径上,可见且可被缓存/记录;而POST的参数放在请求体中,不会直接暴露在URL或浏览器历史记录中;

(3)安全性:由于GET的请求参数在URL中明文显示,容易被截取或记录;POST的参数在Body中传输,安全性较高;

(4)数据长度限制:GET受URL长度限制,(不同浏览器限制为2KB或8KB),不适合传输大量数据;POST理论上没有长度限制,但服务器可能配置了Body上传大小;

14. servlet的生命周期?

Servlet 是 Java 提供的一种 服务器端程序组件,用于接收客户端(通常是浏览器)发送的 HTTP 请求,并生成响应返回给客户端。

✅ Servlet 的作用:

  • 处理客户端请求(如表单提交、查询数据等)
  • 访问数据库、业务逻辑处理
  • 动态生成 HTML 页面或 JSON 数据
  • 返回处理结果给前端(浏览器或其他客户端)

📌 举个例子:

用户在网页上点击“登录”,浏览器就会向服务器发送请求,Servlet 接收这个请求,验证用户名密码,并返回“登录成功”或“失败”结果。

Servlet 容器 是一个运行 Servlet 的环境,Tomcat 就是最常用的 Servlet 容器之一。它可以理解为一个支持 Java Web 程序运行的“迷你服务器”。

✅ Tomcat 的主要作用:

功能

说明

管理 Servlet 生命周期

创建、初始化、调用、销毁 Servlet

处理 HTTP 请求和响应

接收浏览器请求、封装为 Request,再发送响应

多线程支持

为每个请求创建线程,保证高并发处理能力

部署与运行 Web 应用

加载 .war 包或 Web 工程目录,运行 JSP、Servlet

支持标准 Java EE 规范

实现 Servlet、JSP 等接口标准

(1)加载:当服务器启动或第一次请求某个Servlet,Servlet容器(如Tomcat),通过类加载器把Servlet类加载到内存中;

(2)实例化:加载后,容器会创建Servlet的实例对象(单例),由容器维护;

(3)初始化:容器调用init( ) 方法进行初始化,通常只调用一次;

(4)请求处理:每次客户端发送请求时,Servlet容器会调用service( ) 方法处理请求;

(5)销毁:当服务器关闭或Servlet被移除时,容器会调用destory( )方法,释放资源;

15. Websocket建立连接时,客户端和服务端的流程?

WebSocket是建立在 TCP 协议之上,是在 HTTP 协议基础上升级实现的。

首先客户端向服务端发起HTTP请求,在请求头信息中告诉服务端想要升级协议为WebSocket;接着服务端响应握手请求,状态码101表示协议切换成功并附带校验 key;连接从HTTP协议升级为WebSocket协议,之后双方使用 WebSocket 帧进行全双工通信。

16. 为啥会出现跨域问题,怎么解决?

       跨域问题的本质是浏览器的同源策略。同源策略要求:页面地址和请求地址的协议、域名、端口必须完全相同,否者浏览器会阻止前端页面去访问后端接口,这种限制是为了保护用户数据安全,防止恶意网站窃取敏感数据。

       常见的解决跨域方式:

  (1)CORS(跨域资源共享,最常用):在服务端设置响应头,告诉浏览器允许哪些源访问。

  1. Access-Control-Allow-Origin: http://localhost:8080   # 允许的域名,可以是具体域名或 *  
  2. Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS  
  3. Access-Control-Allow-Headers: Content-Type, Authorization  
  4. Access-Control-Allow-Credentials: true   # 如果需要携带 Cookie  

Spring Boot 里可以这样写:

  1. @Configuration  
  2. public class CorsConfig {  
  3.     @Bean  
  4.     public WebMvcConfigurer corsConfigurer() {  
  5.         return new WebMvcConfigurer() {  
  6.             @Override  
  7.             public void addCorsMappings(CorsRegistry registry) {  
  8.                 registry.addMapping("/**")  
  9.                         .allowedOriginPatterns("*"// 可以改成指定域名  
  10.                         .allowedMethods("GET""POST""PUT""DELETE""OPTIONS")  
  11.                         .allowedHeaders("*")  
  12.                         .allowCredentials(true);  
  13.             }  
  14.         };  
  15.     }  

  (2)反向代理:通过 Nginx、Apache 或开发框架自带的请求代理,把跨域请求转发到同域,从而绕过浏览器的跨域限制。

Nginx 示例:

  1. server {  
  2.     listen 80;  
  3.     server_name api.server.com;  
  4.   
  5.     location /api/ {  
  6.         proxy_pass http://localhost:8081/;   # 实际后端服务  
  7.         proxy_set_header Host $host;  
  8.     }  
  9. }  

Vue CLI 开发代理示例:

  1. // vue.config.js  
  2. module.exports = {  
  3.   devServer: {  
  4.     proxy: {  
  5.       '/api': {  
  6.         target: 'http://localhost:8081', // 后端服务  
  7.         changeOrigin: true  
  8.       }  
  9.     }  
  10.   }  
  11. }  

17.接口响应慢,怎么分析和排查?

       接口响应慢,可以从网络-前端-网关-服务端应用-数据库-外部依赖的全链路排查。

(1)网络:查看是否网络抖动导致接口请求异常;

(2)前端:F12打开开发者工具,选择“网络”和对应的请求方法,在“计时”选项中可以对比等待服务器响应时间和总请求时间,判断是前端问题还是服务器问题;也可以进一步分析是不是请求参数或请求头参数过大

(3)网关:查看网关是否实现了限流,多个过滤器等;

(4)服务器:分析服务器内部逻辑,是否涉及复杂的循环,过多的SQL查询,调用过多的外部服务,锁竞争导致业务阻塞,线程池线程数不足等;

(5)数据库:开启慢查询日志,找出对应的慢查询SQL;可以使用explain分析SQL语句,通过返回的“type”,“extra”字段判断该SQL是否命中了索引/回表查询,是否存在索引失效的场景;

(6)外部依赖:查看是否调用第三方接口/服务超时

18. 能说一下事务的传播行为吗?

       事务的传播行为决定了一个事务方法调用另一个事务方法时,事务该如何传播、合并或新建。

       事务MySQL的融入

  1. BEGIN:  
  2.  update yyy;   // 事务A  
  3.  update xxx;   // 事务B  
  4. commit;  

事务A和事务B其中只要发生异常都会回滚;

事务MySQL的挂起

  1. BEGIN:  
  2.  update yyy;   // 事务A  
  3.   BEGIN:  
  4.    update xxx;   // 事务B  
  5.   commit;  
  6.  update zzz;  // 唤醒事务A  
  7. commit;  

事务A和事务B互不影响,事务B执行完后唤醒事务A继续执行;

       通过设置事务的保存点,模拟事务MySQL的嵌套

  1. BEGIN:  
  2.  update yyy;   // 事务A  
  3.   SAVEPOINT b:  // 保存点
  4.    update xxx;   // 事务B  
  5.   ROLLBACK to b;  
  6.  update zzz;  // 事务A  
  7. commit;  

如果事务B发生异常,将回到保存点,继续往下执行事务A

 而事务的七个传播行为基于上面三种方式实现,如下:

传播行为

含义

场景

REQUIRED(默认)

事务的融入:如果当前有事务则加入,没有则新建,确保所有事务方法能整体提交或回滚

大多数业务逻辑

REQUIRES_NEW

事务的挂起:无论当前是否有事务,都新建一个事务,原事务挂起,确保事务之间独立提交回滚

子任务(如日志),即使主事务失败也独立提交

SUPPORTS

有事务就加入,没有就以非事务方式运行

可选事务操作

NOT_SUPPORTED

不支持事务,若有事务则挂起

查询类操作,不希望被事务影响

MANDATORY

必须存在事务,否则抛异常

确保在上层事务环境中运行

NEVER

不允许有事务,否则抛异常

强制非事务执行

NESTED

嵌套事务,有独立回滚点(依赖 JDBC Savepoint)

局部失败可局部回滚的业务

七、微服务篇

1.Spring Cloud的五大组件有哪些?(重点)

(1)注册中心/配置中心(Nacos),作用为:管理微服务的注册与发现,使服务之间能动态感知彼此的存在;

(2)负载均衡 Ribbon,作用为:在服务消费者端实现负载均衡,自动将请求分发到多个服务提供者;

(3)服务调用 Feign

(4)服务熔断与降级 sentinel,作用为:防止服务雪崩,提高系统的容错能力;

(5)服务网关 Gateway,作用为:统一入口,处理路由、鉴权、限流等跨横切面逻辑;

2.能介绍一下eureka和nacos注册中心吗?

注册中心都有相同的功能如下:

(1)服务注册:服务提供者需要把自己的服务信息(比如服务名称、IP地址、端口号等)注册到注册中心,由注册中心保存这些信息;

(2)服务发现:消费者向注册中心拉取服务列表信息,如果服务提供者有多个服务实例,则消费者利用负载均衡算法(默认轮询),选择一个服务实例;

(3)服务监控:服务提供者默认每隔30秒向注册中心发送心跳,报告健康状态。如果注册中心90秒还没有接收到心跳,就会将该服务实例剔除;

区别:

(1)nacos默认服务是临时实例,我们可以设置为非临时实例,nacos就会主动检测服务的健康状态,即使服务实例心跳不正常也不会剔除;

(2)nacos支持服务列表自动推送模式,确保服务列表及时更新;

(3)nacos默认采用AP模式(高可用性),当存在非临时实例时,采用CP模式(数据强一致性),而eureka只用AP模式,在网络分区时,即使部分节点失联也会继续提供服务;

(4)eureka只有注册中心,nacos还支持配置中心,有更强大的功能;

3.能说一下ribbon负载均衡吗?

Ribbon负载均衡策略主要如下:

(1)轮询:简单轮询服务列表选择服务实例;

(2)权重:根据权重来选择服务器,响应时间越长,权重越小;

(3)随机:随机选择一个可用的服务实例;

(4)区域:以区域可用的服务器为基础进行服务器的选择;

4.什么是服务雪崩,怎么解决这个问题?

       服务雪崩就是分布式系统中,一个或局部服务发生故障,导致整条链路的下游服务都无法正常处理,海量请求导致整个系统崩溃;

(1)服务降级:编写一个降级逻辑(减少核心代码),当服务不可用或响应过慢时,调用“降级”方案,比如返回默认结果/友好提示,保证核心功能可用。

(2)限流:限制单位时间内的请求数量(nginx限流,每秒窗口内最多发送几个请求/ 网关+redis+令牌桶,每秒最多请求几次)

(3)服务熔断(默认关闭):默认检测到10秒内请求的失败率超过50%,就会触发熔断机制,返回异常或采用备用方案,之后每隔5秒就会尝试请求恢复,成功后就关闭熔断机制,不成功继续保持熔断打开状态;

服务降级实例:

  1. @RestController  
  2. @RequestMapping("/order")  
  3. public class OrderController {  
  4.   
  5.     private final RestTemplate restTemplate = new RestTemplate();  
  6.   
  7.     @GetMapping("/create")  
  8.     @CircuitBreaker(name = "paymentService", fallbackMethod = "paymentFallback")  
  9.     public String createOrder() {  
  10.         // 调用支付服务  
  11.         String result = restTemplate.getForObject("http://payment-service/pay", String.class);  
  12.         return "下单成功,支付结果:" + result;  
  13.     }  
  14.   
  15.     // 降级方法:当支付服务不可用时走这里  
  16.     public String paymentFallback(Throwable t) {  
  17.         return "下单成功,但支付服务暂时不可用,请稍后手动支付";  
  18.     }  
  19. }  

application.yml 配置熔断规则:

  1. resilience4j:  
  2.   circuitbreaker:  
  3.     instances:  
  4.       stockService:  
  5.         slidingWindowSize: 10        # 窗口内10次请求  
  6.         failureRateThreshold: 50     # 失败率超过50%则熔断  
  7.         waitDurationInOpenState: 10s # 熔断后10秒再尝试恢复 

5.你们的微服务是怎么监控的?

项目中主要采用的是运维工具skywalking进行监控的:

(1)Skywalking主要可以监控接口、服务实例的状态,特别是在压测的时候可以看到那些服务和接口比较慢,可以针对性地进行分析和优化;

(2)还设置了告警规则,比如服务成功率低于80%达到两次就发出警告;

6. 微服务中怎么实现限流的?

限流的四个算法如下:

(1)固定时间窗口计数器:在固定时间(如1秒)窗口内统计请求数量,超过阈值就拒绝后续请求;(时间窗口指的是时间区间)

(2)滑动时间窗口:将时间窗口划分为多个小窗口,统计最近N个小窗口的总请求数。计算更复杂,控制更精准;

(3)令牌桶算法:以恒定速率向桶中放入令牌,请求需要获取令牌才能执行,没有令牌则拒绝。可以面对突发流量;

(4)漏桶算法:请求像水滴一样流入漏桶,桶以恒定速率出水(就是处理请求),桶满就拒绝请求。确保请求处理速率稳定;

       做法如下:

(1)nginx限流:使用漏桶算法实现过滤,以固定的速率进行处理;

  1. http {  
  2.     # 定义请求限流的区域  
  3.     limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=1r/s; 
  4.   
  5.     server {  
  6.         listen 80;  
  7.   
  8.         location /api {  
  9.             # 启用限流  
  10.             limit_req zone=req_limit_per_ip burst=5 nodelay;  
  11.             # 当请求超过速率时,返回 503 错误  
  12.             proxy_pass http://backend;  
  13.         }  
  14.     }  
  15. }  

(2)网关限流:支持局部过滤器RequestRateLimiter做限流,使用的是令牌桶算法;

  1. spring:  
  2.   cloud:  
  3.     gateway:  
  4.       routes:  
  5.         - id: my_route  
  6.           uri: http://localhost:8080  
  7.           predicates:  
  8.             - Path=/api/**  # 匹配路由  
  9.           filters:  
  10.             - name: RequestRateLimiter  
  11.               args:  
  12.                 # 配置限流规则,使用令牌桶算法  
  13.                 redis-rate-limiter.replenishRate=10  # 每秒放置 10 个令牌  
  14.                 redis-rate-limiter.burstCapacity=20  # 最大令牌数为 20 

(3)Redis+Lua脚本:使用令牌桶算法,进行分布式限流;

7.说一下CAP和BASE?

CAP 定理是分布式系统的基本理论,表示在一个分布式系统中,一致性(Consistency)、可用性(Availability)、分区容忍性(Partition Tolerance)三者不可兼得,最多只能同时满足两项。

  1. 一致性(Consistency):所有节点的数据始终保持一致;
  2. 可用性(Availability):保证每次请求都能得到响应(不管数据是否最新);
  3. 分区容忍性(Partition Tolerance):网络分区中,即使节点间通信失败,系统仍能继续运行。

分布式系统中,节点之间需要通过网络通信,网络分区指的是:因为网络故障,节点之间被分成多个不能相互通信的“孤岛”,所以分区容忍性一定会满足,所以只能在一致性和可用性之间二选一;

BASE(Basically Available, Soft state, Eventually consistent)是对 CAP 定理的一种折中方案,适用于AP 系统,强调最终一致性,即允许在短时间内数据不一致来追求高可用性,但最终会达到一致。

  1. Basically Available(基本可用):即使系统发生故障,仍然保证核心功能可用(如降级处理)。
  2. Soft state(柔状态):系统允许数据在一段时间内不同步,即存在临时不一致。
  3. Eventually consistent(最终一致性):系统在一段时间后,数据最终会达到一致性,而非强一致性。

BASE 理论牺牲了强一致性,但换取了更高的可用性和性能,是很多数据库(如 Redis、Cassandra、MongoDB)的设计原则。

8.微服务中有哪些分布式事务?

说明:为啥在分布式中@Transactional注解不行,在单体应用中@Transactional能确保事务是因为所有的操作都在同一个数据库里执行,Spring通过数据库本地事务就能实现,而微服务中每个服务都有自己的数据库。

(1)两阶段提交(2PC:由协调者和参与者组成,阶段一(准备阶段):协调者询问所有参与者是否可以提交事务,参与者对事务进行本地检查,根据情况返回yes or no;阶段二(提交/回滚阶段):如果所有参与者返回yes,协调者向所有参与者发送commit命令,要求提交事务,如果有一个参与者返回no,协调者发送rollback命令,要求回滚事务。保证强一致性,由于同步阻塞性能较低(银行业务);

(2)SeataAT模式(手动补偿):Try阶段:执行本地事务的预操作,通常是对外部资源进行预留或锁定,确保资源在事务中不可变动(如冻结库存);Confirm阶段:如果Try阶段成功,Confirm阶段会提交该操作,执行真正的资源修改(真正扣减库存);Cancel阶段:如果Try阶段执行失败,回滚之前的预操作(解冻库存)。需要人工编码补偿逻辑,灵活性和性能优,适合高并发业务编码复杂

(3)SeataAT模式(自动补偿):底层使用数据库的回滚日志来实现事务的自动补偿。在事务执行过程中,Seata会为每个数据库操作记录回滚日志,如果所有操作都成功,Seata会提交事务,数据库相关的各种操作正式生效;如果某个操作失败或发生异常,Seatac会通过回滚日志进行自动补偿;AT模式无需显示编写补偿逻辑,性能较好,依赖数据库

(4)MQ消息队列:通过消息队列异步传递事务状态,结合本地事务和消息表现实现最终一致性,性能最好

示例:Seata TCC 模式(账户扣减)

  1. @LocalTCC  
  2. public interface AccountTccAction {    
  3.     @TwoPhaseBusinessAction(name = "accountTccAction", commitMethod = "commit", rollbackMethod = "rollback")  
  4.     boolean prepareDecrease(BusinessActionContext ctx,  
  5.                             @BusinessActionContextParameter(paramName = "userId") Long userId,  
  6.                             @BusinessActionContextParameter(paramName = "money") BigDecimal money);  
  7.   
  8.     boolean commit(BusinessActionContext ctx);  
  9.   
  10.     boolean rollback(BusinessActionContext ctx);  
  11. }  
  12.   
  13. @Service  
  14. public class AccountTccActionImpl implements AccountTccAction {  
  15.   
  16.     @Autowired  
  17.     private AccountMapper accountMapper;  
  18.   
  19.     @Override  
  20.     public boolean prepareDecrease(BusinessActionContext ctx, Long userId, BigDecimal money) {  
  21.         log.info("Try 阶段:冻结金额");  
  22.         return accountMapper.freeze(userId, money) > 0;  
  23.     }  
  24.   
  25.     @Override  
  26.     public boolean commit(BusinessActionContext ctx) {  
  27.         Long userId = (Long) ctx.getActionContext("userId");  
  28.         BigDecimal money = (BigDecimal) ctx.getActionContext("money");  
  29.         log.info("Confirm 阶段:真正扣减余额");  
  30.         return accountMapper.commit(userId, money) > 0;  
  31.     }  
  32.   
  33.     @Override  
  34.     public boolean rollback(BusinessActionContext ctx) {  
  35.         Long userId = (Long) ctx.getActionContext("userId");  
  36.         BigDecimal money = (BigDecimal) ctx.getActionContext("money");  
  37.         log.info("Cancel 阶段:解冻余额");  
  38.         return accountMapper.rollback(userId, money) > 0;  
  39.     }  
  40. }  

示例:Seata AT 模式(Java)

  1. @GlobalTransactional // Seata 全局事务注解  
  2.     @Override  
  3.     public void createOrder(Order order) {  
  4.         log.info("开始创建订单");  
  5.         orderMapper.insert(order);  
  6.   
  7.         log.info("扣减库存");  
  8.         storageFeignClient.decrease(order.getProductId(), order.getCount());  
  9.   
  10.         log.info("扣减余额");  
  11.         accountFeignClient.decrease(order.getUserId(), order.getMoney());  
  12.   
  13.         log.info("修改订单状态");  
  14.         orderMapper.updateStatus(order.getId(), 1);  
  15.   
  16.         log.info("订单完成");  
  17.     }  

9.能说一下XXL-JOB吗?

XXL-JOB 是一款广泛使用的分布式任务调度平台,专注于解决分布式系统中的定时任务调度、任务编排、执行监控等核心问题。其设计目标是 轻量级、易扩展、高可用,尤其适合企业级应用场景。xxl-job路路由策略如下:

(1)轮询;

(2)故障转移:按照顺序进行心跳检测,第一个心跳检测成功的机器选定为目标执行器;

(3)分片广播:广播触发对应集群中所有执行器执行一次任务,同时系统自动传递分片参数;可根据分片参数开发分片任务;

       Xxl-job任务执行失败解决方案如下:

(1)设置重试次数;

(2)路由策略选择故障转移,使用健康的执行器来执行任务;

(3)查看日志+邮件告警来通知相关人员解决;

       有海量任务同时需要执行怎么解决?

(1)路由策略选择分片广播,让多个执行器一块执行任务;

(2)在任务执行的代码中可以获取分片总数和当前分片,按照取模的方式分摊到各个实例执行;

10.说一下服务降级和服务熔断?

(1)服务降级就是需要额外编写一个备用方案(减少核心业务逻辑),当服务不可用或响应超时的时候,执行该备用兜底方案,避免用户长时间等待(比如返回“系统繁忙”提示);

(2)服务熔断也需要额外编写一个兜底方法,服务调用失败率和次数达到阈值(默认5秒内20次失败或错误率超过50%),主动断开调用链路,后续请求直接降级,防止雪崩效应;

熔断器有三个状态,系统正常运行,进入关闭状态;某个时间段请求异常率超过50%,Sentinel进入打开状态,所有请求走blockHandler方法;10秒后自动进入半开状态,放行少量的请求,如果请求成功率较高,关闭熔断器,如果仍然失败,继续打开状态;

  1. @RestController  
  2. @RequestMapping("/order")  
  3. public class OrderController {  
  4.   
  5.     @GetMapping("/buy")  
  6.     @SentinelResource(value = "buyProduct",   
  7.                       fallback = "fallbackHandler",   
  8.                       blockHandler = "blockHandler")  
  9.     public String buyProduct() {  
  10.         // 模拟异常或远程调用  
  11.         int a = 1 / 0;  // 模拟异常  
  12.         return "调用商品服务成功";  
  13.     }  
  14.   
  15.     // 服务降级处理(调用失败时)  
  16.     public String fallbackHandler(Throwable t) {  
  17.         return "服务降级:系统异常,调用失败,请稍后再试";  
  18.     }  
  19.   
  20.     // 服务熔断处理(被限流、降级等规则拦截时)  
  21.     public String blockHandler(BlockException e) {  
  22.         return "服务熔断:请求过多,请稍后重试";  
  23.     }  
  24. }  

八、JVM篇

1.什么是程序计数器?

它是线程私有的,每个线程都有一份,内部保存的是字节码的行号,用来记录正在执行的字节码指令的地址

2.能详细介绍Java堆吗?

(1)Java堆是线程共享的区域,主要用来存储对象实例,数组等,当它内存不足时就会抛出OOM(内存泄漏)错误;

(2)Java堆由新生代老年代组成。新生代被划分为三部分,分别是Eden和两个大小严格相同的幸存者区,而老年代主要用来存储生命周期长的对象,一般是老的对象;

(3)Java堆在JDK1.7和JDK1.8有一些区别。1.7中有一个永久代,主要用于存储类信息、静态变量、常量、编译后的代码;而1.8之后就移除了永久代,把数据存储到本地内存的元空间中,目的:为了避免永久代的内存限制,减少内存溢出的风险;云空间的内存大小不再是固定的,JVM可以根据实际需要进行动态分配,提升了内存管理效率,减少了对堆内存的占用,提高了性能;

3.详细介绍一下虚拟机栈?

(1)什么是虚拟机栈?

  1. 虚拟机栈是每个线程运行时分配的一块内存区域;
  2. 每个栈由多个栈帧组成,分别对应着每次方法调用时所占用的内存;
  3. 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法;

(2)垃圾回收是否涉及栈内存?

垃圾回收主要针对的是堆内存,因为栈中的栈帧随着方法调用而创建,方法执行结束而销毁,当栈帧弹出后栈内存就会释放,因此不存在垃圾回收

(3)栈内存分配越大越好吗?

  1. 浪费内存资源。如果栈内存分配过大,而应用程序实际使用较少,会导致大量内存被浪费。
  2. 减少可用线程数量。栈内存默认是1024K(1M),如果栈内存分配过大,JVM将无法为新线程分配足够的内存,从而抛出OOM错误。
  3. 影响启动速度。栈内存分配过大会增加JVM启动时的初始化成本,导致程序启动时间变长。

(4)什么情况下方法是线程安全的?

  1. 当方法没有形参和没有返回局部变量时是线程安全的。

(5)什么情况下会导致栈内存溢出?

  1. 栈帧过多时会导致栈内存溢出(递归调用
  2. 栈帧过大(比较少见)

4.堆栈的区别是什么?

(1)栈内存一般用来存储局部变量和方法调用,但堆内存主要用来存储Java对象和数组。堆会进行GC垃圾回收,但栈不需要;

(2)栈内存是线程私有的,而堆内存是线程共享的;

(3)两者异常错误不同,但如果栈内存或者堆内存不足都会抛出异常;

5.能不能解释一下方法区?

(1)方法区是各个线程共享的内存区域;

(2)JVM规范里定义的一个逻辑区域,用来存储类的信息、运行时常量池、静态变量;

(3)虚拟机启动的时候创建,关闭虚拟机的时候释放;

(4)如果方法区中的内存无法满足分配请求,就会抛出OOM

      

6.介绍一下运行时常量池

(1)先解释什么是常量池:它可以看作一张常量表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息;

(2)当类被加载时,他的常量池信息会放入运行时常量池,并将里面的符号地址变为真实地址;

7.你听过直接内存吗?

(1)直接内存不属于JVM中的内存结构,不由JVM进行管理。是虚拟机的系统内存;

(2)常见于NIO操作,用于数据缓冲区,分配回收成本较高,单独写性能好,不受JVM内存回收管理;

8.什么是类加载器?

       JVM只会运行二进制文件,类加载器的作用就是将字节码文件加载到JVM中,转化为JVM可识别的数据结构,从而使Java程序能够运行启动。核心作用如下:

(1)动态加载:在程序运行时按需要加载类,而非一次性加载所有类;

(2)隔离性:不同类加载器可以加载不同来源的类,避免命名冲突(如Tomcat隔离Web应用);

(3)安全性:通过双亲委派机制,防止恶意修改核心类库代码;

9.类加载器有哪些?

(1)启动类加载器:负责加载JDK核心类库,比如JAVA_HOME/jre/lib目录下的核心类;

(2)扩展类加载器:加载JAVA_HOME/jre/lib/ext目录下的类,比如加密扩展;

(3)应用类加载器:负责加载应用程序的Classpath路径下的类,比如我们自己的的Java代码和或第三方jar包

(4)自定义类加载器:自定义类继承ClassLoader,实现自定义类加载规则,比如热部署Web服务器,加密Class文件

10.解释一下双亲委派机制?

       当一个类加载器收到加载请求时,优先委派给父类加载器处理,不断地向上委托;如果父类加载器能完成加载,直接返回结果。如果父类无法加载,子类才尝试加载;

(1)通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后无需重复加载,保证了唯一性

(2)保证类库API不会被修改,确保安全性

12.说一下类加载的执行过程?

(1)加载: JVM通过类加载器读取字节码文件到内存中,在方法区(或元空间)中生成对应的类的元数据。

(2)验证:检验字节码文件是否符合JVM规范

(3)准备:为类的静态变量分配内存并设置初始值

(4)解析:把符号引用转化为直接引用

(5)初始化:对类的静态变量、静态代码块执行初始化操作

(6)使用:JVM开始从入口方法开始执行用户的程序代码

(7)卸载:当用户程序代码执行完毕后,JVM开始销毁创建的Class对象

注意:public static int count = 42;  // 准备阶段赋值为 0,初始化阶段才赋值为 42

13.对象被GC的方式?

       如果一个或多个对象没有任何引用指向它,那么这个对象就是垃圾,如果被定位了垃圾,则有可能被垃圾回收器回收。

(1)引用计数法:每个对象维护一个引用计数器,当由新的引用指向该对象时,计数器+1,当引用失效或被删除时,计数器-1,如果计数器为0,说明该对象不可达变成垃圾;

这个方法实现简单,实时性强,但很少被使用,因为它有个致命缺点,就是不能解决对象之间互相引用的情况—两个对象互相引用,此时引用计数器永不为0,可能会导致内存泄漏。并且这个方法的维护成本比较高;

(2)可达性分析算法:从GC Root(垃圾回收的根节点)开始,沿着引用链向下搜索,能被访问的对象称为“可达对象”,无法访问的对象即为“不可达对象”,视为垃圾被回收。

GC Root 通常包括:虚拟机栈引用的对象(方法中的局部变量);方法区中静态属性引用的对象;方法区中常量引用的对象;本地方法栈中JNI(Native方法)引用的对象。

       它的优点是:支持循环引用;不需要维护引用计数器,专注于引用链的追踪,更加高效;缺点:垃圾回收时需要暂停程序,进行全局的可达性分析;算法复杂;

14.JVM垃圾回收方法有哪些?

(1)标记清除算法:分为“标记”、“清除”两个阶段,先根据可达性分析算法得出的垃圾进行标记,接着对标记为垃圾的内存进行回收;这个方法效率高,但存在有磁盘碎片,内存不连续的缺点,所以用的不多;

(2)标记整理算法:跟标记清除算法多了几个步骤,将存活对象都向内存一端移动,然后清理边界以外的垃圾,这个方法没有内存碎片,但多了几个步骤导致效率低;适合存活率高的场景,因此常用于老年代。

(3)复制算法:将原有的内存空间分为大小严格相等的内存区域(From区和To区),新生代的对象首先分配到From区,当垃圾回收时,遍历From区,将所有存活的对象复制到To区,然后清空From区中所有的数据,接着From区和To区互换角色;优点是没有内存碎片,效率高实现简单;缺点是浪费内存;适合对象生命周期短的场景,因此常用于新生代。

15.说一下JVM中的分代回收?

(1)堆的区域划分

  1. 堆被分为两部分:新生代和老年代  1:2
  2. 对于新生代,被分为三个区域。分别是Eden区和两个survivor区(From区和To区)  8:1:1

(2)分代回收的策略

  1. 新创建的对象,都会先分配到伊甸园区;
  2. 当伊甸园区内存不足时,标记伊甸园区和from区的存活对象;
  3. 根据复制算法将存活对象复制到to区;
  4. 清除伊甸园区和from区的所有数据,接着将from区和to区角色互换;
  5. 当伊甸园区内存不足时重复以上操作,当幸存者区中的对象达到晋升阈值(默认15),将晋升到老年代

16.Minor GC 、Mixed GC 、Full GC 的区别是什么?

(1)Minor GC 是对于新生代进行垃圾回收,当新生代的伊甸园区被填满时,就会触发Minor GC,主要用于清理生命周期较短的对象,特点是效率高,停顿时间短;

(2)Mixed GC 是一种G1垃圾回收器中专有回收类型,同时回收新生代和老年代的部分区域,当老年代内存使用率超过阈值(默认45%)触发,特点是局部回收,更短的停顿时间,适合大堆内存;

(3)Full GC 回收整个堆,包括新生代和老年代(甚至元空间);触发条件为老年代空间不足、元空间/永久代空间不足、调用了System.gc()、JVM空间分配失败;它的特点是停顿时间长、开销高和频率低;

17.JVM中有哪些垃圾回收器?

(1)串行垃圾回收器(新生代):单线程工作;在垃圾回收期间,会暂停所有的应用线程(STW);复制算法+标记整理算法;优点:简单高效,单线程开销小;缺点:暂停时间较长,不适合多线程、高并发场景;

(2)并行垃圾回收器(新生代):多线程回收,追求高吞吐量复制算法+标记整理算法;优点:适合多核CPU,垃圾回收效率高;缺点:GC时会暂停所有的应用线程;

(3)CMS(并发)垃圾回收器(老年代):老年代垃圾回收器,目标是降低GC暂停时间;使用标记清除算法;并发收集阶段允许应用程序线程和垃圾回收线程同时工作;优点:停顿时间较短,适合需要低延迟的场景;缺点:可能产生内存碎片,对CPU性能有较高要求;

(4)G1垃圾回收器(JDK9之后默认使用,新生代+老年代):适合大内存应用,目标是提供可预测的低延迟;堆被分为多个Region,每个区域可以存储新生代或老年代的对象;采用复制算法;工作流程:1.初始标记:进行可达性分析,伴随一次Minor GC、2.并发标记:在应用程序执行的同时对整个堆进行可达性分析、3.最终标记(STW):修正并发标记阶段遗漏的引用、4.筛选回收:按收益优先回收垃圾最多的Region;优点:可控的暂停时间、减少内存碎片;缺点:实现复杂,堆内存分区和标记开销较大;

18.强引用、软引用、弱引用和虚引用有什么区别?

(1)强引用:通过直接赋值给对象的方式创建,在任何情况下不会被垃圾回收,除非程序终止或手动将引用设置为null;

(2)软引用:需要配合SortReference使用,适合缓存场景,当内存不足时会被垃圾回收;

(3)弱引用:需要配合WeakReference使用,生命周期短,只要进行垃圾回收就会把弱引用对象回收;

(4)虚引用:需要PhantomReference和Reference(引用队列)配合使用,用于对象回收前的跟踪,被GC回收时,会将虚引用入队,由Reference Handler线程调用虚引用相关方法释放内存;

19.JVM中调优的参数有哪些?

(1)设置堆空间大小:通过-Xms<size> 设置堆内存的初始大小,-Xmx<size> 设置堆内存的最大大小,通过初始、最大大小设置为相同值,以减少堆大小动态调整的开销;

(2)虚拟机栈的设置:通过-Xss<size> 设置每个栈的大小,通常512k-1m合理范围,默认是1M

(3)新生代中伊甸园区和两个幸存者区的大小比例:通过-xx:SurvivorRatio = <n>设置伊甸园区和每个幸存者区的大小比例, 一般为8:1:1(默认),如果伊甸园区太大会增加垃圾回收的开销;如果太小会增加GC的次数和对象更容易晋升为老年代;

(4)新生代晋升为老年代的阈值:通过-XX:MaxtenuringThreshold=<n> 设置对象晋升为老年代的年龄条件,默认是15

(5)设置垃圾回收器:根据需要选择垃圾回收器,-XX:+PrintGCDetails -XX:+PrintGCDateStamps

20.java内存泄漏的排查思路?

       内存泄漏是指程序中不再使用的对象仍然被引用,导致这些对象无法被垃圾回收,可能导致内存溢出。

(1)当发生 OOM时,如果 JVM 启动参数中配置了
-XX:+HeapDumpOnOutOfMemoryError,JVM 会自动生成堆内存的 dump 文件。
当然,我们也可以在运行时通过 jmap 命令手动生成 dump 文件。

(2)通过工具,VisualVM去分析dump文件,VisualVM   可以加载离线的dump文件;

(3)通过查看堆信息的情况,可以大概定位内存溢出是哪行代码出现问题;

(4)找到对应的代码,通过阅读上下文情况,进行修复即可;

21.CPU飙高排查方案和思路?

(1)使用top命令查看占用cup的情况;

(2)通过top命令查看后,可以查看哪一个进程占用CPU较高;

(3)使用ps命令查看进程中的线程信息;

(4)使用jstack命令查看进程中那些线程出现问题,最终定位问题;

九、消息中间件篇

说明:消息中间件是一种在分布式系统中用于存储、转发和协调消息的中间服务。生产者只负责发送消息,不需要关心消费者是谁、什么时候消费等问题,降低模块之间的耦合度,实现异步通信来避免业务阻塞,提高吞吐量和流量削峰。

消息中间件的主要问题无非是消息不丢失和消息重复消费问题、集群模式实现高可用

Rabbit MQ

1.RabbitMQ 如何保证消息不丢失?

(1)开启生产者确认机制,利用Confirm模式(ConfirmCallback)确认消息是否达到交换机,利用Return模式(RetrunCallback)确认消息是否路由到队列。但这个机制会带来额外的性能开销,一般不建议使用;

(2)开启Broker持久化功能,Broker持久化分为交换机持久化、队列持久化和消息持久化,MQ默认内存存储消息(非持久化)。可以在创建交换机和队列时设置durable = true, 发送消息时.setDeliveryMode(MessageDeliveryMode.PERSISTENT);

(3)开启消费者确认机制为auto,由spring来检测代码是否出现异常,没有异常就返回ack, 抛出异常就返回nack;

(4)返回nack默认丢弃消息,因此设置消费失败重试+异常队列,多次重试失败后将消息投递到异常交换机路由到异常队列,交由人工处理;

(5)另外,自动ack+重试仍然有可能导致消息丢失,为了万无一失可以采用手动ack,显示地设置异常消息重新入队或重试n次后进入异常队列,实现复杂,也降低了吞吐量

2.RabbitMQ消息的重复消费问题怎么解决?

RabbitMQ 消息重复消费的根源在于 消息传递的不确定性(网络波动、服务故障等),导致消息可能被多次投递或处理。

(1)开启消费者确认机制(手动ack或自动ack,确保每次消费者成功处理完消息后,发送一个信号给RabbitMQ, 如果消费者处理失败,重新排队未确认的消息;

(2)核心思想是保证幂等性:无论消息被消费多少次,结果与一次消费相同:

  1. 设置唯一标识(消息ID:每次消费前先根据消息ID是否存在Redis中来判断消息是否被消费过,没有则处理逻辑,最后将消息ID缓存起来;
  2. 利用数据库的唯一索引、主键或者根据条件能否查询出数据来防止消息重复消费,比如用户下单,每个订单号都是唯一的;
  3. 使用乐观锁,通过版本号或条件判断是否重复消费,比如扣减库存,只有当版本号一致时才能更新成功;

3.延迟队列了解过吗?

       一般需要延迟队列的场景有超时订单、限时优惠、定时发布等,延迟队列由传统(不适用插件)和使用插件两种方法:

(1)不用插件:通过死信队列+TTL实现的。首先,我们将消息放到延迟队列中,将TTL设置为延迟时间,消息在TTL过期之后会被转发到死信交换机,再路由到死信队列,接着消费者到死信队列中消费消息,从而达到延迟消费的作用。

(2)安装插件:消息发送到延迟交换机时,携带X-delay头(延迟时间),插件将消息暂存在内部数据库中,不会立即路由到队列,到期后才将消息路由到目标队列;

(3)区别:

  1. 消息存储位置:没有插件的需要额外创建一个队列用来存储消息,而有插件的将消息暂存在Mnesia数据库中,占用资源较低;
  2. 性能和误差:没有插件的需要轮询检查消息TTL(默认是每秒检查一次),而有插件的使用 Erlang Timer 模块 或 时间轮算法管理延迟时间,性能比较好,延迟误差较小;
  3. 消息阻塞:没有插件的队列只会检查头部消息是否过期,会导致后续消息被阻塞;而有插件的对每个消息的延迟分别计算,到期后立即触发路由;

4.消息队列有很多消息累积怎么解决?

       这个问题核心就是消费速度比不上生产速度,重点是怎么提高消费速度。第一,可以增加更多的消费者,提高消费速度。第二、在消费者内开启线程池可以加快消息处理速度。

另外,可以扩大队列容量,提高堆积上限,比如采用惰性队列,它是基于磁盘存储的,性能比较稳定,但是受限于磁盘IO,时效性会降低。

5.RabbitMQ的高可用机制有了解过吗?

       在生产环境下,可以采用镜像集群模式来搭建集群,共有三个节点;镜像集群结构是一主多从(从节点就是镜像),所有的操作都在主节点完成,接着同步给镜像节点;如果主节点宕机,镜像节点会替代成为新的主节点(如果主从同步完成前,刚好主节点宕机,可能会导致数据丢失)镜像节点的作用是备份主节点的数据,并在主节点发生故障时接管它的角色

       如果数据丢失,我们可以采用仲裁队列(声明队列时候指定为仲裁队列即可),跟镜像集群一样都是主从模式,支持主从数据同步,它们的主从同步是基于Raft协议的,具有强一致性

Kafka

Kafka特性如下:

(1)高吞吐量、低延迟:收发消息十分快,每秒可以处理几十万条消息,最低延迟只有几毫秒;

(2)可用性:可以搭建集群模式多个broker,每个主题分为几个分区,同一个主题的分区、副本分布在不同的broker上,即使某一个分区的leader副本宕机,也会从剩下follower副本中选择一个晋升为leader;

(3)持久性、可靠性:先将数据写入页缓存,接着异步刷盘,采用顺序写入磁盘(每个分区在磁盘上对应一个目录,目录下包含多个 .log 文件,称为日志段);为了快速查找,Kafka为每个日志段生成对应的索引文件;默认是每写入1万条和每一秒刷盘,日志段默认保留七天;

使用场景如下:

(1)智能跟踪:比如在淘宝上购物,当我们打开淘宝的那一刻,登录信息、登录次数都会作为消息传输到Kafka,当我们浏览购物的时候,浏览信息、搜索指数和购物爱好等都会作为一个个消息传递给Kafka,这样就会生成用户的报告做智能推荐。

(2)度能指标:记录运营监控数据,收集各种分布式应用的数据

6.Kafka是如何保证消息不丢失的?

       从生产者、broker和消费者三方面考虑:

(1)生产者确认消息成功写入:

  1. Ack确认机制:生产者发送消息后,可以返回ack确认;acks = 0,不等待确认;默认acks = 1,只等待主副本(Leader)确认;acks = all,等待所有副本都确认,因此我们可以设置acks = all,保证数据至少写入一个副本,防止Leader宕机后数据丢失;
  2. 可以开启重试发送(设置重试次数和最大等待时间),防止因为网络波动导致数据丢失;

(2)确保消息在Broker中不丢失:

  1. 采用副本机制,搭建一主多从的集群,Leader负责读写操作,接着将数据同步到其他副本。当Leader宕机时,某个副本就会升级为Leader;
  2. 日志持久化机制:Kafka采用顺序写入磁盘(日志文件) + 页缓存,消息先写入操作系统的页缓存,再由操作系统异步刷盘,将页缓存中的数据写入日志文件中。默认是每1000条消息刷盘和每5秒刷盘,而数据默认保留7天。

(3)消费者确保消息不丢失:

  1. 手动提交Offset偏移量,确保消息处理完再提交Offset,避免在提交Offset瞬间系统崩溃导致消息未处理就丢失;
  2. 重试机制 + 死信队列:重试是为了防止短暂的错误(数据库连接失败、网络波动)导致消息丢失,重试几次还是失败就将消息存入异常主题分区中,交由人工处理,防止数据丢失;

7.Kafka中的消息重复消费怎么解决?

消息重复消费问题的原因有几种:1、生产者启用重试机制,因网络波动等原因导致消息重复发送;2、消费者未及时提交偏移量就崩溃了;3、发生重平衡时,分区重新分配后消费者从旧的Offset消费;

(1)Kafka消费消息都是按照偏移量来进行标记消费的,消费者默认是自动提交已经消费的偏移量(默认每隔5秒发送一次),我们可以设置为手动提交偏移量,确保消息处理完成后同步提交 Offset。

(2)核心思想是保证幂等性:无论消息被消费多少次,结果与一次消费相同:

  1. 设置唯一标识(消息ID:每次消费前先根据消息ID是否存在Redis中来判断消息是否被消费过,没有则处理逻辑,最后将消息ID缓存起来;
  2. 利用数据库的唯一索引、主键或者根据条件能否查询出数据来防止消息重复消费,比如用户下单,每个订单号都是唯一的;
  3. 使用乐观锁,通过版本号或条件判断是否重复消费,比如扣减库存,只有当版本号一致时才能更新成功;

(3)Kafka0.11+提供了事务机制,生产者设置enable.idempotence = true开启幂等性,开启事务,消费端结合事务提交offset,确保同一条消息不会重复写入Kafka。成本比较高,一般金融等强一致场景才用

说明:重平衡(Rebalance) 在消费者的数量发生变化(加入或离开)或消费者与 Kafka 集群之间的负载不均衡时, Kafka 会重新分配消费者组中各个消费者负责的分区。

8.KafKa是如何保证消费的顺序性?

       Kafka能保证分区内的消息是有序的,但多个分区之间的消息无法保证全局顺序。可以在发送消息时可以指定相同的分区键(Partition Key)或分区号,确保消息都被分配到同一个分区中,然后按发送顺序被消费;

9. Kafka的高可用机制有了解过吗?

       这主要是两个方面,一个是集群,另一个是副本机制

(1)Kafka集群指的是它有多个broker实例组成,即使某一台实例宕机了,也不耽误其他broker继续对外提供服务;

(2)一个主题有多个分区,每个分区可以有多个副本(集群节点有n个,就有1个leader和n-1各follower;如果单节点就只有1个leader,没有follower),每个分区的主副本和副本都分布在不同的broker上(避免某个broker宕机,导致某个分区的所有副本随之下线);如果leader发生故障,会自动将其中一个follower提升为leader,保证了系统的高可用性;

10.Kafka中是怎么实现高性能的设计?

(1)主题分区:一个主题可以有多个分区,每个分区可有多个副本,数据可以分布在不同的broker上,不受限地处理更多的数据;

(2)顺序写入+页缓存:消息先保存在操作系统的内存中,接着异步刷盘,将数据以追加方式写入日志文件,顺序I/O远高于随机I/O;

(3)页缓存:把磁盘中的数据缓存到内存中,把对磁盘的访问改为对内存的访问;

(4)零拷贝:数据从磁盘到页缓存再到网络获取,避免了用户态和内核态的数据拷贝;

11.说一下分区分配策略?

(1)RangeAssignor(默认:范围分配):分区数量除以消费者数量,按消费者排序依次分配相邻的分区,如果分区不能均分,会导致前面的消费者多了一个分区;

(2)RoundRobinAssignor(轮询分配):将所有分区顺序打散,按顺序轮流分给消费者;

(3)StickyAssignor(粘性分配):优先保持上一次的分配结果,如果发生重平衡,只重新分配必要的分区,尽量不变已分配的分区;

(4)CooperativeStickyAssignor(协同粘性分配):是粘性分配的改进版,让Kafka逐步收回和分配分区,而不是一次性调整所用分区,减少的重平衡的暂停时间。

适用情况:

(1)范围分配:适用于单个主题订阅;

(2)轮询分配:适用于所有消费者订阅相同的多个主题;

(3)粘性分配:适用于高吞吐量、低延迟,减少重平衡的影响;

(4)协同粘性分配:适用于对低延迟高要求,减少重平衡的停顿;

12.主题有多个分区,消息路由到分区的规则是什么?

 (1)指定分区号:如果send( )方法显示指定了分区号,消息就会直接进入该分区;

 (2)指定Key:如果传入了key, kafka会对key进行哈希计算,接着对哈希值对分区数取模,进入对应的分区号;

 (3)未指定分区号和Key:默认会采用轮询方式,将消息均匀分配到各个分区;

13. Kafka数据清理机制了解过吗?

       首先说一下Kafka的存储结构,Kafka的数据存储在主题的分区下,如果文件过大会进行分段存储segment,每个分段在磁盘上以索引日志文件的形式存储。分段的好处是,第一能减少单个文件内容的大小,查找数据方便,第二方便Kafka进行日志清理;

       日志的清理策略有两个:

(1)根据消息的保留时间,默认是一周,当消息保存的时间到了就会触发清理;

(2)根据主题存储的数据大小,当主题的文件大小超过阈值,就会删除最久的消息,需要手动开启此功能;

14.Kafka为什么要抛弃Zookeeper?

       Kafka 3.3+ 版本已经默认采用 Kraft,简化架构、提升性能、降低运维复杂度,支持更大规模的集群:

(1)简化架构,降低维护成本:之前需要同时部署和维护Kafka和zookeeper两个分布式系统,增加了部署、监控和故障排查的复杂度;如今Kafka直接使用Raft协议进行分布式协调,不依赖外部系统,变得更轻量级;

(2)性能优化:Kafka原本需要依赖zookeeper管理元数据(主题、分区、broker信息)和集群协调(如主副本选举),所有的元数据变更都需要通过zookeeper同步,在高并发、大规模集群下zookeeper会成为性能瓶颈;如今Kafka通过Kraft管理元数据存储、Leader 选举等任务,降低对外部组件的依赖;

(3)更大规模的集群:KRaft 提升了元数据操作的效率和可靠性,支持更大规模的集群。Zookeeper可能支持上千台broker会很吃力,而Kraft可以支持上万台broker;

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐