八股文

262 阅读22分钟

Tomcat为什么使用自定义类加载器?

一个Tomcat中可以部署多个应用,多个应用中的类可能出现类名相同的情况。如果使用默认的应用程序类加载器,这是不行的。因此为了解决这个问题Tomcat自定义了web应用程序类加载器。而且利用自定义类加载器实现了热加载的功能。

spring 为什么被设计为不可变?

@Autowired 和 @Resource 的区别是什么?

@Autowired` 是 Spring 提供的注解,@Resource是 JDK 提供的注解。

@Autowired默认的注入方式为byType(根据类型进行匹配),@Resource默认注入方式为 byName(根据名称进行匹配)。

当一个接口存在多个实现类的情况下,@Autowired@Resource都需要通过名称才能正确匹配到对应的 Bean。Autowired 可以通过 @Qualifier 注解来显式指定名称,@Resource可以通过 name 属性来显式指定名称。

Spring中Bean的生命周期

生命周期是指:Bean在Spring(IOC)中从创建到销毁的整个过程。

分为:

1、实例化:为Bean分配内存空间;

2、设置属性:将当前类依赖的Bean属性,进行注入和装配。

3、初始化:

执行各种通知,执行初始化的前置方法,

执行初始化方法,执行初始化的后置方法。

4、使用Bean:在程序中使用Bean对象

5、销毁Bean:将Bean对象进行销毁操作。

ps:值得注意的是:实例化和初始化是两个完全不同的过程。实例化只是给Bean分配了内存空间,而初始化则是将程序的执行权从系统级别转换到用户级别,比开始执行用户添加的业务代码。

Spring中的循环依赖及解决

在Spring中,循环依赖是指两个或多个bean之间相互依赖,形成一个循环的依赖关系。这种情况下,Spring容器无法正确地创建这些bean,会抛出BeanCurrentlyInCreationException异常。

为了解决循环依赖问题,Spring采用了三级缓存的方式:

  1. 一级缓存:singletonObjects,用于存储已经创建好的单例对象。
  2. 二级缓存:earlySingletonObjects,用于存储正在创建中的单例对象。
  3. 三级缓存:singletonFactories,用于存储用于创建单例对象的工厂类。

当Spring创建一个bean时,首先会检查一级缓存中是否已经存在该bean的实例,如果存在,则直接返回该实例;如果不存在,则进入二级缓存中查找是否存在正在创建中的实例,如果存在,则返回该实例的代理对象;如果也不存在,则进入三级缓存中查找是否存在用于创建该bean的工厂类,如果存在,则使用该工厂类创建实例,并将实例存储到二级缓存中,最后返回该实例的代理对象。

如果在创建bean的过程中发现循环依赖的情况,Spring会通过提前暴露当前正在创建的bean,解决循环依赖问题。即将正在创建的bean放入二级缓存中,并将其提前暴露,供其他bean使用,以解决循环依赖问题。

需要注意的是,循环依赖只有在同一个ApplicationContext中才会出现,因为不同的ApplicationContext拥有不同的BeanFactory,所以不存在循环依赖的问题。

spring中的bean都是单例的吗?什么情况下不是?

在Spring中,默认情况下,所有的bean都是单例的,即在容器启动时就被创建,且只创建一次,之后每次请求都返回同一个实例。

但是,Spring也支持创建非单例的bean,即在每次请求时都创建一个新的实例。创建非单例的bean需要在bean的定义中设置scope属性,常用的scope有:

  1. singleton(默认):单例,即在容器启动时创建,之后每次请求都返回同一个实例。
  2. prototype:原型,即每次bean请求都创建一个新的实例。
  3. request:请求,即在一个HTTP请求中创建一个实例,该实例在整个请求期间都可用。
  4. session:会话,即在一个HTTP会话中创建一个实例,该实例在整个会话期间都可用。
  5. websocket:在websocket的生命周期中复用一个单例对象。

除了以上常用的scope,还有其他一些较少使用的scope,例如:global session、application等。

需要注意的是,非单例的bean需要考虑线程安全的问题,因为每次请求都会创建一个新的实例,可能会导致并发问题。因此,在设计非单例的bean时,需要考虑其线程安全性。

spring中的bean是线程安全的吗?

在Spring中,单例的bean默认情况下是线程安全的,因为在容器启动时就被创建,且只创建一次,之后每次请求都返回同一个实例。

非单例的Bean则不一定是线程安全的,因为每次请求都会创建一个新的实例,可能会导致并发问题。

除此之外,Spring中还有一些特殊的作用域,例如请求作用域和会话作用域。请求作用域的Bean在一次HTTP请求中创建一个实例,该实例在整个请求期间都可用。会话作用域的Bean在一个HTTP会话中创建一个实例,该实例在整个会话期间都可用。这些作用域的Bean需要考虑并发问题,因为它们可能会被多个线程同时访问。

因此,跟Bean的作用域没有关系对于如果Bean是无状态的,则是线程安全的,如果是有状态的,则不一定是线程安全的。对于任意作用域的Bean,是不是线程安全还是要看这个Bean本身。但是不同作用域的Bean需要考虑不同的线程安全问题。

spring中的依赖注入。

Spring中的依赖注入(Dependency Injection,DI)是一种设计模式,它允许对象相互解耦,从而增强了代码的灵活性、可维护性和可测试性。

在Spring中,依赖注入是通过容器来实现的。容器负责创建对象并将它们相互连接起来,从而形成一个完整的应用程序。当一个对象需要另一个对象时,它不再通过自己创建或查找依赖项,而是通过容器来获取依赖项。

依赖注入可以分为三种方式:构造函数注入、Setter方法注入和字段注入。其中,构造函数注入是最常用的方式,它允许我们在创建对象时直接注入依赖项。Setter方法注入是通过设置对象的属性来注入依赖项,而字段注入则是直接将依赖项注入到对象的字段中。

Spring中的依赖注入有以下优点:

1、减少代码的耦合性,提高代码的重用性和可维护性;

2、支持AOP等高级功能,可以更加方便地实现事务管理、日志记录等功能。

总之,依赖注入是Spring框架的核心特性之一,它大大简化了应用程序的开发和维护,提高了代码的质量和可测试性。

redis中zset的底层

Redis中的有序集合(sorted set)是一种类似于集合的数据结构,但是每个元素都会关联一个分数(score),使得元素可以按照分数进行排序。

在Redis中,有序集合的底层实现是跳跃表(Skip List)。跳跃表是一种基于链表的数据结构,它通过使用多级索引来加快查找元素的速度。

跳跃表由多个节点组成,每个节点包括一个元素值和一个分数值,以及若干个指向下一个节点的指针。在Redis中,跳跃表的节点被称为zskiplistNode,其中包含以下几个字段:

  • level:表示节点在跳跃表中所处的层数,层数越高,节点的访问次数越少;
  • score:表示节点所关联的分数值;
  • ele:表示节点所关联的元素值;
  • backward:表示指向前一个节点的指针;
  • level[]:表示一个指针数组,用于指向下一个节点。

跳跃表中的每个节点都可以有多个指向下一个节点的指针,这些指针可以是正常的指针,也可以是跨越多个节点的指针,从而实现了快速查找元素的功能。同时,跳跃表还可以动态地调整节点的层数,以保证跳跃表的平衡性和效率。

在Redis中,有序集合的每个元素都会作为一个节点插入到跳跃表中,节点按照分数从小到大排列。当需要查找元素时,Redis会从跳跃表的最高层开始逐层查找,直到找到目标元素或者找到比目标元素大的节点为止。

总之,Redis中的有序集合底层实现是跳跃表,它通过多级索引和快速查找算法实现了高效的元素排序和查找功能。

说一下object类中的常用方法

Object类是所有Java类的父类,其中包含了一些常用的方法,如下:

  1. equals(Object obj):判断当前对象是否与另一个对象相等,返回一个布尔值。

  2. hashCode():返回当前对象的哈希码值,用于在哈希表中进行查找。

  3. toString():返回当前对象的字符串表示形式。

  4. getClass():返回当前对象的运行时类。

  5. notify():唤醒在当前对象上等待的单个线程。

  6. notifyAll():唤醒在当前对象上等待的所有线程。

  7. wait():使当前线程进入等待状态,直到另一个线程调用当前对象的notify()或notifyAll()方法唤醒它。

  8. finalize():在垃圾回收器将当前对象回收前调用,用于执行一些清理工作。

这些方法都是Object类中的常用方法,也是Java程序中经常使用的方法。需要注意的是,如果在自定义类中需要使用到equals()和hashCode()方法,一定要重写它们,以确保它们的正确性。

springmvc的工作流程是什么样的?

  1. 用户发送请求到DispatcherServlet。

  2. DispatcherServlet收到请求后,根据请求URL找到对应的Controller。

  3. Controller根据业务逻辑处理请求,并返回一个ModelAndView对象。

  4. DispatcherServlet根据返回的ModelAndView对象,找到对应的View。

  5. View负责渲染ModelAndView中的数据,并返回给DispatcherServlet。

  6. DispatcherServlet将渲染后的结果返回给用户。

总的来说,SpringMVC的流程就是请求到达DispatcherServlet,DispatcherServlet将请求分发给对应的Controller,Controller处理请求并返回结果,DispatcherServlet将结果交给对应的View进行渲染,最后将渲染后的结果返回给用户。

守护线程是什么意思?

在Java中,守护线程(Daemon Thread)是一种特殊的线程类型,它的作用是为其他线程提供服务,比如垃圾回收线程就是一个守护线程。与普通线程不同的是,只要有一个非守护线程在运行,JVM就不会退出。但是,当所有的非守护线程都执行完毕后,守护线程也会自动退出。

守护线程通常用于执行一些后台任务,比如定时检查某个状态或者清理缓存等。在创建线程时,可以通过Thread类的setDaemon方法将线程设置为守护线程

Java中同步和异步

Java中的同步和异步是指程序执行时的两种不同方式。

同步是指程序按照顺序依次执行,每个操作都必须等待前一个操作完成后才能进行下一个操作。

异步是指程序在执行时不需要等待前一个操作完成,可以同时执行多个操作,多个操作之间相互独立。

在Java中,同步和异步通常与多线程相关。在多线程环境中,同步是为了保证多个线程之间的数据一致性和安全性,而异步则是为了提高程序的执行效率和响应速度。

同步通常使用synchronized关键字或Lock锁来实现,异步则可以使用Java中的Future和CompletableFuture等类来实现。

kafka如何保证消息不丢失

Kafka通过以下几个机制来保证消息不丢失:

  1. 消息持久化:Kafka将消息存储在磁盘上,即使发生宕机,也可以从磁盘中读取未消费的消息。

  2. 复制机制:Kafka使用副本机制来复制消息,确保即使一个Broker宕机,也可以从其他Broker中获取相同的消息。

  3. 确认机制:Kafka使用确认机制来确保消息已被消费。当消费者成功消费一条消息后,它会向Broker发送确认消息。如果Broker没有收到确认消息,则会将消息重新发送给消费者。

  4. 重试机制:如果消息发送失败,Kafka会自动进行重试,直到消息被成功发送。

kafka如何保证消息不被重复消费?

Kafka通过以下几个机制来保证消息不被重复消费:

  1. 消息偏移量:Kafka使用消息偏移量(offset)来标识消息在分区中的位置。消费者可以通过保存偏移量的方式来避免消费重复消息。

  2. 消费者组:Kafka允许多个消费者组同时消费同一个主题的消息。每个消费者组都会维护自己的偏移量,因此不同消费者组之间不会相互影响,也不会重复消费消息。

  3. 顺序消费:Kafka支持按顺序消费消息的功能。消费者可以通过指定分区来消费消息,这样可以避免多个消费者同时消费同一个分区的消息。

综上所述,Kafka通过偏移量、消费者组、顺序消费等机制来保证消息不被重复消费。

快排相关

Java快速排序(Quick Sort)是一种常见的排序算法,其基本思想是选取一个基准值,将待排序序列分为左右两部分,左边的元素都小于等于基准值,右边的元素都大于等于基准值,然后递归地对左右两部分进行排序,最终得到有序序列。

时间复杂度分析:

最好情况下,每次选取的基准值都正好是待排序序列的中位数,此时快速排序的时间复杂度为O(nlogn)。

最坏情况下,每次选取的基准值都是待排序序列的最大或最小值,此时快速排序的时间复杂度为O(n^2)。

平均情况下,快速排序的时间复杂度为O(nlogn)。

稳定性分析:

快速排序是一种不稳定排序算法,因为在排序过程中,元素的相对位置可能会发生变化,从而导致相同元素的顺序发生改变。例如,对于序列{3, 1, 4, 1, 5, 9, 2, 6},如果选取第一个元素3作为基准值,第一次排序后得到序列{1, 1, 2, 3, 5, 9, 4, 6},其中两个1的顺序发生了改变。

HashMap的常用API

HashMap是Java中常用的一种Map类型,下面是它常用的API:

  1. put(key, value):将key和value映射关系放入HashMap中
  2. get(key):根据key取出对应的value
  3. containsKey(key):判断HashMap中是否含有对应的key
  4. containsValue(value):判断HashMap中是否含有对应的value
  5. remove(key):根据key在HashMap中删除对应的映射关系
  6. size():获取HashMap中键值对的数量
  7. keySet():获取HashMap中所有的key组成的Set集合
  8. values():获取HashMap中所有的value组成的Collection集合
  9. entrySet():获取HashMap中所有的键值对组成的Set集合

这些API可以满足HashMap的基本使用需求,还有其他一些API可以进一步了解。

什么是restful api

RESTful API是一种基于REST(Representational State Transfer)架构风格的API设计方式,它使用HTTP协议中的GET、POST、PUT、DELETE等标准方法来实现对Web资源的操作。

RESTful API的设计原则包括以下几点:

  1. 客户端-服务器:客户端和服务器之间的通信是通过HTTP协议来实现的。

  2. 无状态性:每个请求都必须包含足够的信息来完成请求,服务器不会保存任何客户端的状态信息。

  3. 可缓存性:服务器可以对响应进行缓存,以提高性能。

  4. 统一接口:RESTful API使用统一的接口来访问资源,包括使用URI来标识资源、使用HTTP方法来定义操作、使用HTTP头来传递元数据等。

  5. 分层系统:RESTful API是一个分层系统,每个组件都只知道与其相邻的组件,这样可以提高系统的可扩展性。

RESTful API的优点包括:

  1. 简化了API的设计和开发过程,使得API更加易于理解和使用。

  2. 支持多种编程语言和平台,使得API可以被广泛应用。

  3. 支持缓存和分布式系统,可以提高系统的性能和可扩展性。

  4. 支持HTTP协议的标准方法,使得API的开发和测试更加方便。

总的来说,RESTful API是一种简单、灵活、易于理解和使用的API设计方式,被广泛应用于各种Web应用程序和移动应用程序中。

如何保证数据库和缓存数据一致性

同步删除

核心流程:

先更新数据库数据

然后删除缓存数据

存在的问题:

1)删除缓存失败存在脏数据

2)难以收拢所有更新数据库入口

使用同步删除方案,你必须在所有更新数据库的地方都进行缓存的删除操作,如果你有一个地方漏掉了,对应的缓存就相当于没有删除了,就会导致脏数据问题。

还有就是如果我们通过命令行直接来更新数据库的数据,或者通过公司提供的数据库管理平台来直接更新数据库数据,但缓存中的数据没有更新,这个时候也就导致脏数据问题了,这也是为什么说同步删除很难覆盖所有的入口,同时存在很大的风险。

3)并发场景下存在脏数据

我们看一个例子:

例子:表A存在数据 a=1,

image.png

并发情况下可能有以下流程:

该例子中,由于线程2在查询完数据库数据之后,写入缓存之前,数据库的数据被线程1更新并且执行完同步删除操作了,所以最终导致脏数据问题,并且脏数据可能会持续很久。

由于更新数据库操作耗时一般比写缓存更久,所以该例子发生的概率并不会太大。但有可能

典型场景就是,线程2查询完数据库之后,写缓存之前,线程2所在服务器发生了YGC,这个时候线程2可能就需要等待几十毫秒才能执行写缓存操作,这种情况就很容易出现上面这个例子了。

小结:由于难以收拢所有更新数据库入口,同时可能存在长期的脏数据问题,该方案一般不会被单独使用,但是可以作为一个补充,下面的方案会提到。

先删除缓存,后更新数据库可能问题

在出现失败时可能出现的问题:

1:线程A删除缓存成功,线程A更新数据库失败;

2 :线程B从缓存中读取数据;由于缓存被删,进程B无法从缓存中得到数据,进而从数据库读取数据;此时数据库中的数据更新失败,线程B从数据库成功获取旧的数据,然后将数据更新到了缓存。 最终,缓存和数据库的数据是一致的,但仍然是旧的数据。

延迟双删

核心流程:

删除缓存数据

更新数据库数据

等待一小段时间

再次删除缓存数据

存在的问题:

1)延迟时间难以确认

到底是延迟一秒或者是几秒,这个其实很难确认,你总不能延迟几分钟吧,因为你如果延迟几分钟,那这几分钟可能就存在脏数据了,所以这个时间很难确定。

2)无法绝对保障数据的一致性

我们看下面这个例子:

例子:表A存在数据 a=1,并发情况下可能有以下流程

image.png

该例子中,由于数据库主从同步完成之间,存在并发的请求,从而导致脏数据问题,并且脏数据可能会持续很久。

可能有的同学觉得稍微调大点延迟时间就可以解决这个问题,但是其实主库在写压力比较大的时候,主从之间的同步延迟甚至可能是分钟级的。

因此,该方案整体来说还是有明显的问题,所以说一般也不会使用这个方案。

小结:由于延迟时间难以确认,同时无法绝对保障数据的一致性,该方案一般不会使用。

异步监听binlog删除 + 重试

核心流程:

更新数据库

监听binlog删除缓存

缓存删除失败则通过MQ不断重试,直至删除成功

整体流程图如下:

image.png

该方案是当前的主流方案,整体上没太大的问题,但是极端场景下可能还是有一些小问题。

存在的问题:

1)脏数据时间窗口“较大”

这个脏数据时间窗口较大,是相对同步删除来说。在你收到binlog之前,他中间要经过:binlog从主库同步到从库、binlog从库到binlog监听组件、binlog从监听组件发送到MQ、消费MQ消息,这些操作每个都是有一定的耗时的,可能是几十毫秒甚至几百毫秒,所以说它其实整体是有一个脏数据的时间窗口。

而同步删除是在更新完数据库后马上删除,时间窗口大概也就是1毫秒左右,所以说binlog的方式相对于同步删除,可能存在的脏数据窗口会稍微大一点。

2)极端场景下存在长期脏数据问题

binlog抓取组件宕机导致脏数据。该方案强依赖于监听binlog的组件,如果监听binlog组件出现宕机,则会导致大量脏数据。

拆库拆表流程中可能存在并发脏数据

拆库拆表流程中并发脏数据问题

我们来看下面这个例子:

表A正在进行数据库拆分,当前进行到灰度切读流量阶段:部分读新库,部分读老库

数据库拆分大致流程:增量数据同步(双写)、全量数据迁移、数据一致性校验、灰度切读、切读完毕后停写老库。

此时表A存在数据 a=1,并发情况下可能有以下流程

image.png

该例子中,灰度切读阶段中,我们还是优先保障老库的流程,因此还是先写老库,由于写新库和写老库之间存在时间间隔,导致线程2并发查询到新库的老数据,同时在监听binlog删除缓存流程之后将老数据写入缓存,从而导致脏数据问题,并且脏数据可能会持续很久。

双写的方式有很多种,我们使用的是通过公司的中间件直接将老库数据通过binlog的方式同步到新库,该方案通过监控发现在写压力较大的情况下,延迟可能会达到几秒,因此出现了上述问题。

而如果是使用代码进行同步双写,双写之间的时间间隔会较小,该问题出现的概率会相对低很多,但是还是无法保障绝对不会出现,就像上面提过的,写老库和写新库2个操作之间如果发生了YGC或者FGC,就可能导致这两个操作之间的时间间隔比较大,从而可能发生上面的案例。

还有就是代码双写的方式必须收敛所有的写入口,上文提到过的,通过命令行或者数据库管理平台的方式修改的数据,代码双写也是无法覆盖的,需要执行者在新老库都执行一遍,如果遗漏了新库,则可能导致数据问题。

小结:该方案在大多数场景下没有太大问题,业务比较小的场景可以使用,或者在其基础上进行适当补充。