快手Java一面,体验感极差!

科技   2024-12-10 14:06   湖北  

你好,我是 Guide。几个月前,一位读者参加秋招面试快手 Java 后端岗位,整个过程非常不愉快,据他所说是整个秋招体验最差的一次面试。

我身边不少面试快手的读者并没有反馈过类似的问题。个人觉得这个主要和面试官有关系,毕竟每个公司参与面试招聘的人非常多,鱼龙混杂的。而且,可能负责这位读者的面试官当时刚好心情不太好。

接下来,分享一下这位读者的面经,我会对涉及到的面试题补充上对应的参考答案(省略了手撕算法和一些非技术问题)。

拷打项目(25min 左右)

认证登录这块为什么用 JWT?

相比于 Session 认证的方式来说,使用 JWT 进行身份认证主要有下面 4 个优势:

  1. 无状态:JWT 自身包含了身份验证所需要的所有信息,因此,我们的服务器不需要存储 JWT。这显然增加了系统的可用性和伸缩性,大大减轻了服务端的压力。
  2. 有效避免了 CSRF 攻击:CSRF 攻击需要依赖 Cookie。一般情况下我们使用 JWT 的话,在我们登录成功获得 JWT 之后,一般会选择存放在 localStorage 中。前端的每一个请求后续都会附带上这个 JWT,整个过程压根不会涉及到 Cookie。因此,即使你点击了非法链接发送了请求到服务端,这个非法请求也是不会携带 JWT 的,所以这个请求将是非法的。
  3. 适合移动端应用:只要 JWT 可以被客户端存储就能够使用。
  4. 单点登录友好:使用 Session 进行身份认证的话,实现单点登录,需要我们把用户的 Session 信息保存在一台电脑上,并且还会遇到常见的 Cookie 跨域的问题。但是,使用 JWT 进行认证的话, JWT 被保存在客户端,不会存在这些问题。

数据库直接保存的密码还是?

如果我们需要保存密码这类敏感数据到数据库的话,需要先加密再保存。

Spring Security 提供了多种加密算法的实现,开箱即用,非常方便。这些加密算法实现类的接口是 PasswordEncoder ,如果你想要自己实现一个加密算法的话,也需要实现 PasswordEncoder 接口。

PasswordEncoder 接口一共也就 3 个必须实现的方法。

public interface PasswordEncoder {
    // 加密也就是对原始密码进行编码
    String encode(CharSequence var1);
    // 比对原始密码和数据库中保存的密码
    boolean matches(CharSequence var1, String var2);
    // 判断加密密码是否需要再次进行加密,默认返回 false
    default boolean upgradeEncoding(String encodedPassword) {
        return false;
    }
}

官方推荐使用基于 bcrypt 强哈希函数的加密算法实现类。

无状态有什么问题考虑过吗?强制让用户下线怎么弄?

由于 JWT 的无状态,也导致了它最大的缺点:不可控!

就比如说,我们想要在 JWT 有效期内废弃一个 JWT 或者更改它的权限的话,并不会立即生效,通常需要等到有效期过后才可以。再比如说,当用户 Logout 的话,JWT 也还有效。除非,我们在后端增加额外的处理逻辑比如将失效的 JWT 存储起来,后端先验证 JWT 是否有效再进行处理。

常见的解决办法有两种:

  1. 将 JWT 存入数据库:将有效的 JWT 存入数据库中,更建议使用内存数据库比如 Redis。如果需要让某个 JWT 失效就直接从 Redis 中删除这个 JWT 即可。但是,这样会导致每次使用 JWT 都要先从 Redis 中查询 JWT 是否存在的步骤,而且违背了 JWT 的无状态原则。
  2. 黑名单机制:和方案 1 类似,使用内存数据库比如 Redis 维护一个黑名单,如果想让某个 JWT 失效的话就直接将这个 JWT 加入到 黑名单 即可。然后,每次使用 JWT 进行请求的话都会先判断这个 JWT 是否存在于黑名单中。

虽然这两种方案都违背了 JWT 的无状态原则,但是一般实际项目中我们通常还是会使用这两种方案。

为什么用 Redis 而不用本地缓存呢?

特性本地缓存Redis
数据一致性多服务器部署时存在数据不一致问题数据一致
内存限制受限于单台服务器内存独立部署,内存空间更大
数据丢失风险服务器宕机数据丢失可持久化,数据不易丢失
管理维护分散,管理不便集中管理,提供丰富的管理工具
功能丰富性功能有限,通常只提供简单的键值对存储功能丰富,支持多种数据结构和功能

项目用 Redis 缓存了什么数据?

Redis 除了可以用来缓存高频访问的数据之外,还以用来实现:

  • 分布式锁:通过 Redis 来做分布式锁是一种比较常见的方式。通常情况下,我们都是基于 Redisson 来实现分布式锁。关于 Redis 实现分布式锁的详细介绍,可以看我写的这篇文章:如何基于 Redis 实现分布式锁?
  • 限流:一般是通过 Redis + Lua 脚本的方式来实现限流。如果不想自己写 Lua 脚本的话,也可以直接利用 Redisson 中的 RRateLimiter 来实现分布式限流,其底层实现就是基于 Lua 代码+令牌桶算法。
  • 消息队列:Redis 自带的 List 数据结构可以作为一个简单的队列使用。Redis 5.0 中增加的 Stream 类型的数据结构更加适合用来做消息队列。它比较类似于 Kafka,有主题和消费组的概念,支持消息持久化以及 ACK 机制。
  • 延时队列:Redisson 内置了延时队列(基于 Sorted Set 实现的)。
  • 分布式 Session :利用 String 或者 Hash 数据类型保存 Session 数据,所有的服务器都可以访问。
  • 复杂业务场景:通过 Redis 以及 Redis 扩展(比如 Redisson)提供的数据结构,我们可以很方便地完成很多复杂的业务场景比如通过 Bitmap 统计活跃用户、通过 Sorted Set 维护排行榜。

面试中,根据你项目的实际情况去回答即可!

相关阅读:

Redis 和 MySQL 数据是否会不一致,如何解决?

缓存和数据库中的数据不一致是很常见的一个问题,只要用缓存就会遇到。

举个例子:如果数据库中的数据有更新,但没有及时更新缓存或者更新缓存的时候缓存突然宕机,就会导致缓存中的数据是过期的,读取缓存就会返回旧数据。

缓存和数据库一致性保证细说的话可以扯很多,但是我觉得其实没太大必要。我个人觉得引入缓存之后,如果为了短时间的不一致性问题,选择让系统设计变得更加复杂的话,完全没必要。

下面单独对 Cache Aside Pattern(旁路缓存模式) 来聊聊。

Cache Aside Pattern 中遇到写请求是这样的:更新数据库,然后直接删除缓存 。

如果更新数据库成功,而删除缓存这一步失败的情况的话,简单说有两个解决方案:

  1. 缓存失效时间变短(不推荐,治标不治本):我们让缓存数据的过期时间变短,这样的话缓存就会从数据库中加载数据。另外,这种解决办法对于先操作缓存后操作数据库的场景不适用。
  2. 增加缓存更新重试机制(常用):如果缓存服务当前不可用导致缓存删除失败的话,我们就隔一段时间进行重试,重试次数可以自己定。不过,这里更适合引入消息队列实现异步重试,将删除缓存重试的消息投递到消息队列,然后由专门的消费者来重试删除缓存,直到成功。虽然说多引入了一个消息队列,但其整体带来的收益还是要更高一些。

相关文章推荐:缓存和数据库一致性问题,看这篇就够了 - 水滴与银弹

项目统一异常处理这块是怎么做的?

推荐使用注解的方式统一异常处理,具体会使用到 @ControllerAdvice + @ExceptionHandler 这两个注解 。

@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {

    @ExceptionHandler(BaseException.class)
    public ResponseEntity<?> handleAppException(BaseException exHttpServletRequest request
{
      //......
    }

    @ExceptionHandler(value = ResourceNotFoundException.class)
    public ResponseEntity<ErrorReponsehandleResourceNotFoundException(ResourceNotFoundException exHttpServletRequest request
{
      //......
    }
}

这种异常处理方式下,会给所有或者指定的 Controller 织入异常处理的逻辑(AOP),当 Controller 中的方法抛出异常的时候,由被@ExceptionHandler 注解修饰的方法进行处理。

ExceptionHandlerMethodResolvergetMappedMethod 方法决定了异常具体被哪个被 @ExceptionHandler 注解修饰的方法处理异常。

@Nullable
  private Method getMappedMethod(Class<? extends Throwable> exceptionType) {
    List<Class<? extends Throwable>> matches = new ArrayList<>();
    //找到可以处理的所有异常信息。mappedMethods 中存放了异常和处理异常的方法的对应关系
    for (Class<? extends Throwable> mappedException : this.mappedMethods.keySet()) {
      if (mappedException.isAssignableFrom(exceptionType)) {
        matches.add(mappedException);
      }
    }
    // 不为空说明有方法处理异常
    if (!matches.isEmpty()) {
      // 按照匹配程度从小到大排序
      matches.sort(new ExceptionDepthComparator(exceptionType));
      // 返回处理异常的方法
      return this.mappedMethods.get(matches.get(0));
    }
    else {
      return null;
    }
  }

从源代码看出:getMappedMethod()会首先找到可以匹配处理异常的所有方法信息,然后对其进行从小到大的排序,最后取最小的那一个匹配的方法(即匹配度最高的那个)。

Java(29min 左右)

讲一下 synchronized 这个锁

synchronized 是 Java 中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

不过,在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。因此, synchronized 还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized

关于偏向锁多补充一点:由于偏向锁增加了 JVM 的复杂性,同时也并没有为所有应用都带来性能提升。因此,在 JDK15 中,偏向锁被默认关闭(仍然可以使用 -XX:+UseBiasedLocking 启用偏向锁),在 JDK18 中,偏向锁已经被彻底废弃(无法通过命令行打开)。

synchronized 底层命令

synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

执行 monitorenter 获取锁
执行 monitorexit 释放锁

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取而代之的是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。

synchronized关键字原理

不过,两者的本质都是对对象监视器 monitor 的获取。

讲一下 CAS

CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。

CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。

原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。

CAS 涉及到三个操作数:

  • V:要更新的变量值(Var)
  • E:预期值(Expected)
  • N:拟写入的新值(New)

当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。

举一个简单的例子:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)。

  1. i 与 1 进行比较,如果相等, 则说明没被其他线程修改,可以被设置为 6 。
  2. i 与 1 进行比较,如果不相等,则说明被其他线程修改,当前线程放弃更新,CAS 操作失败。

当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

关于 Java 中 CAS 的底层实现方式可以阅读这篇文章:什么是乐观锁和悲观锁?Java 中 CAS 是如何实现的?

怎么查看 Java 中的内存占用?

JDK 内置工具:

  1. JConsole:基于 JMX 的可视化监视、管理工具。可以很方便的监视本地及远程服务器的 Java 进程的内存使用情况。
  2. JVisualVM:比 JConsole 更强大,支持 Java 进程监控、堆内存使用情况分析等功能。

JDK 内置命令:

  1. jstat :显示本地或者远程(需要远程主机提供 RMI 支持)虚拟机进程中的类信息、内存、垃圾收集、JIT 编译等运行数据。
  2. jmap:生成堆转储快照。

功能更强大的工具:

  1. Arthas:阿里开源的一款线上监控诊断产品,通过全局视角实时查看应用 load、内存、gc、线程的状态信息,并能在不修改应用代码的情况下,对业务问题进行诊断,包括查看方法调用的出入参、异常,监测方法执行耗时,类加载信息等,大大提升线上问题排查效率。
  2. MAT:一款快速便捷且功能强大丰富的 JVM 堆内存离线分析工具。其通过展现 JVM 异常时所记录的运行时堆转储快照(Heap dump)状态(正常运行时也可以做堆转储分析),帮助定位内存泄漏问题或优化大内存消耗逻辑。

说一下 JDK 21 最重要的新特性

虚拟线程在 JDK21 顺利转正,这是非常有标志性的一个新特性。

虚拟线程(Virtual Thread)是 JDK 而不是 OS 实现的轻量级线程(Lightweight Process,LWP),由 JVM 调度。许多虚拟线程共享同一个操作系统线程,虚拟线程的数量可以远大于操作系统线程的数量。

在引入虚拟线程之前,java.lang.Thread 包已经支持所谓的平台线程,也就是没有虚拟线程之前,我们一直使用的线程。JVM 调度程序通过平台线程(载体线程)来管理虚拟线程,一个平台线程可以在不同的时间执行不同的虚拟线程(多个虚拟线程挂载在一个平台线程上),当虚拟线程被阻塞或等待时,平台线程可以切换到执行另一个虚拟线程。

虚拟线程、平台线程和系统内核线程的关系图如下所示(图源:https://medium.com/javarevisited/how-to-use-java-19-virtual-threads-c16a32bad5f7):

虚拟线程、平台线程和系统内核线程的关系

相比较于平台线程来说,虚拟线程是廉价且轻量级的,使用完后立即被销毁,因此它们不需要被重用或池化,每个任务可以有自己专属的虚拟线程来运行。虚拟线程暂停和恢复来实现线程之间的切换,避免了上下文切换的额外耗费,兼顾了多线程的优点,简化了高并发程序的复杂,可以有效减少编写、维护和观察高吞吐量并发应用程序的工作量。

虚拟线程在其他多线程语言中已经被证实是十分有用的,比如 Go 中的 Goroutine、Erlang 中的进程。

Spring Boot(15min 左右)

Spring Boot 的自动配置是如何实现的?

Spring Boot 的自动配置机制是通过 @SpringBootApplication 注解启动的,这个注解本质上是几个关键注解的组合。我们可以将 @SpringBootApplication 看作是 @Configuration@EnableAutoConfiguration@ComponentScan 注解的集合。

  • @EnableAutoConfiguration: 启用 Spring Boot 的自动配置机制。它是自动配置的核心,允许 Spring Boot 根据项目的依赖和配置自动配置 Spring 应用的各个部分。
  • @ComponentScan: 启用组件扫描,扫描被 @Component(以及 @Service@Controller 等)注解的类,并将这些类注册为 Spring 容器中的 Bean。默认情况下,它会扫描该类所在包及其子包下的所有类。
  • @Configuration: 允许在上下文中注册额外的 Bean 或导入其他配置类。它相当于一个具有 @Bean 方法的 Spring 配置类。

@EnableAutoConfiguration是启动自动配置的关键,源码如下(建议自己打断点调试,走一遍基本的流程):

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.context.annotation.Import;

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@AutoConfigurationPackage
@Import({AutoConfigurationImportSelector.class})
public @interface EnableAutoConfiguration 
{
    String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";

    Class<?>[] exclude() default {};

    String[] excludeName() default {};
}

这个注解通过 @Import 导入了 AutoConfigurationImportSelector 类,而 AutoConfigurationImportSelector 是自动配置的核心类之一。@Import 注解的作用是将指定的配置类或 Bean 导入到当前的配置类中。

AutoConfigurationImportSelector 类的 getCandidateConfigurations 方法会加载所有可用的自动配置类,并将这些类的信息以 List 的形式返回。这些配置类会被 Spring 容器管理为 Bean,从而实现自动配置。

    protected List<String> getCandidateConfigurations(AnnotationMetadata metadata, AnnotationAttributes attributes) {
        List<String> configurations = SpringFactoriesLoader.loadFactoryNames(getSpringFactoriesLoaderFactoryClass(),
                getBeanClassLoader());
        Assert.notEmpty(configurations, "No auto configuration classes found in META-INF/spring.factories. If you "
                + "are using a custom packaging, make sure that file is correct.");
        return configurations;
    }

这里使用了 SpringFactoriesLoader 来加载位于 META-INF/spring.factories 文件中的自动配置类。这些配置类会根据应用的具体条件(例如类路径中的依赖)自动配置相应的组件。

自动配置信息有了,那么自动配置还差什么呢?

@Conditional 注解!在自动配置类中,Spring Boot 使用了一系列条件注解(如 @Conditional@ConditionalOnClass@ConditionalOnBean 等)来判断某些配置是否应该生效。这些注解是 @Conditional 注解的扩展,用于在特定条件满足时才启用相应的配置。

例如,在 Spring Security 的自动配置中,有一个名为 SecurityAutoConfiguration 的自动配置类,它导入了 WebSecurityEnablerConfiguration 类。

WebSecurityEnablerConfiguration 类的源码如下:

@Configuration
@ConditionalOnBean(WebSecurityConfigurerAdapter.class)
@ConditionalOnMissingBean(name 
= BeanIds.SPRING_SECURITY_FILTER_CHAIN)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@EnableWebSecurity
public class WebSecurityEnablerConfiguration {

}

WebSecurityEnablerConfiguration类中使用@ConditionalOnBean指定了容器中必须还有WebSecurityConfigurerAdapter 类或其实现类。所以,一般情况下 Spring Security 配置类都会去实现 WebSecurityConfigurerAdapter,这样自动将配置就完成了。

最后,简单总结一下:Spring Boot 的自动配置机制通过 @EnableAutoConfiguration 启动。该注解利用 @Import 注解导入了 AutoConfigurationImportSelector 类,而 AutoConfigurationImportSelector 类则负责加载并管理所有的自动配置类。这些自动配置类通常在META-INF/spring.factories 文件中声明,并根据项目的依赖和配置条件,通过条件注解(如 @ConditionalOnClass@ConditionalOnBean 等)判断是否应该生效。

常用的 Bean 映射工具有哪些?

我们经常在代码中会对一个数据结构封装成 DO、SDO、DTO、VO 等,而这些 Bean 中的大部分属性都是一样的,所以使用属性拷贝类工具可以帮助我们节省大量的 set 和 get 操作。

常用的 Bean 映射工具有:Spring BeanUtils、Apache BeanUtils、MapStruct、ModelMapper、Dozer、Orika、JMapper 。

由于 Apache BeanUtils 、Dozer 、ModelMapper 性能太差,所以不建议使用。MapStruct 性能更好而且使用起来比较灵活,是一个比较不错的选择。

Spring Boot 中如何实现定时任务 ?

我们使用 @Scheduled 注解就能很方便地创建一个定时任务。

@Component
public class ScheduledTasks {
    private static final Logger log = LoggerFactory.getLogger(ScheduledTasks.class);
    private static final SimpleDateFormat dateFormat = new SimpleDateFormat("HH:mm:ss");

    /**
     * fixedRate:固定速率执行。每5秒执行一次。
     */

    @Scheduled(fixedRate = 5000)
    public void reportCurrentTimeWithFixedRate() {
        log.info("Current Thread : {}", Thread.currentThread().getName());
        log.info("Fixed Rate Task : The time is now {}", dateFormat.format(new Date()));
    }
}

单纯依靠 @Scheduled 注解 还不行,我们还需要在 SpringBoot 中我们只需要在启动类上加上@EnableScheduling 注解,这样才可以启动定时任务。@EnableScheduling 注解的作用是发现注解 @Scheduled 的任务并在后台执行该任务。

📌Java 后端技术面试准备强烈推荐《Java 面试指北》 和 JavaGuide ,400 多人参与维护完善,质量非常高。另外,目前的面试趋势是场景题变多,可以参考《后端面试高频系统设计&场景题》(20+高频系统设计&场景面试题)进行准备!

⭐面经详解合集Java后端面经详解



专属面试小册/一对一交流/简历修改/专属求职指南,欢迎加入我的知识星球 ,和 3w+球友一起准备面试!


JavaGuide
JavaGuide(javaguide.cn)官方公众号,专注分享原创Java技术干货。
 最新文章