《揭秘互联网大厂Java面试:从基础到进阶的核心知识点大考察》

27 阅读3分钟

第一轮面试 面试官:先问些基础的,Java 中 ArrayList 和 HashMap 的底层数据结构分别是什么? 王铁牛:ArrayList 底层是数组,HashMap 底层是数组加链表,JDK1.8 后引入了红黑树。 面试官:不错,回答得很准确。那 ArrayList 在扩容时具体是怎么操作的? 王铁牛:当 ArrayList 元素个数达到容量阈值时,会创建一个新的数组,新数组容量是原数组的 1.5 倍,然后把原数组的元素复制到新数组。 面试官:很好。HashMap 在 JDK1.8 中,链表转红黑树的条件是什么? 王铁牛:当链表长度大于等于 8 且数组容量大于等于 64 时,链表会转成红黑树。

第二轮面试 面试官:接下来聊聊多线程和线程池。创建线程有几种方式? 王铁牛:有三种,继承 Thread 类、实现 Runnable 接口、实现 Callable 接口并搭配 FutureTask 使用。 面试官:那线程池的核心参数有哪些,分别代表什么含义? 王铁牛:嗯……有核心线程数、最大线程数,还有……还有队列容量吧。核心线程数就是线程池一开始创建的线程数量,最大线程数是能创建的最大线程数,队列容量就是存放任务的队列大小。 面试官:那线程池的拒绝策略有哪些? 王铁牛:呃……好像有 AbortPolicy,直接抛出异常,还有 DiscardPolicy,丢弃任务不抛出异常,其他的我不太确定了。

第三轮面试 面试官:再谈谈框架相关的,Spring 中 Bean 的作用域有哪些? 王铁牛:有 singleton 单例、prototype 原型、request、session 这些。 面试官:Spring Boot 自动配置的原理是什么? 王铁牛:就是……它会根据类路径下的依赖和配置文件,自动配置一些 Bean 吧,具体原理我不太能说清楚。 面试官:MyBatis 中 #{} 和 {} 的区别是什么? **王铁牛**:#{} 是预编译处理,{} 是字符串替换,#{} 能防止 SQL 注入,${} 不能。 面试官:Dubbo 服务暴露的过程是怎样的? 王铁牛:嗯……好像是通过注册中心,然后……然后进行一些配置啥的,具体细节我不太记得了。 面试官:RabbitMQ 的工作模式有哪些? 王铁牛:有简单模式、工作队列模式、发布订阅模式,还有……还有其他几种,我一下子想不起来了。 面试官:xxl - job 任务调度的核心原理是什么? 王铁牛:就是……它有个调度中心,然后去调度任务,具体怎么调度的我不太清楚。 面试官:Redis 有哪些数据类型,分别适用于什么场景? 王铁牛:有 String、Hash、List、Set、ZSet。String 可以存简单数据,Hash 适合存对象,List 可以做队列,Set 可以去重,ZSet 可以排序,具体场景我不太会详细说。

面试总结:从这三轮面试来看,你在一些基础知识上掌握得还不错,像 ArrayList、HashMap 的底层结构,创建线程的方式等回答得比较准确。但在一些进阶和框架原理方面,比如 Spring Boot 自动配置原理、Dubbo 服务暴露过程、xxl - job 任务调度核心原理等,回答得不是很清晰,还有提升的空间。我们后续会综合评估所有面试者的情况,你回家等通知吧,无论结果如何,我们都会在一周内给你回复。

问题答案

  1. ArrayList 和 HashMap 的底层数据结构
    • ArrayList:底层是数组结构,它允许我们以数组下标的方式快速访问元素。例如,ArrayList<Integer> list = new ArrayList<>(); list.add(10); int num = list.get(0); 这里通过 get(0) 能快速获取到添加的第一个元素。数组结构的优点是查询效率高,时间复杂度为 O(1),但在插入和删除元素时,如果不是在末尾操作,需要移动大量元素,时间复杂度为 O(n)。
    • HashMap:JDK1.8 之前底层是数组加链表结构,JDK1.8 后引入了红黑树。数组的每个位置称为桶(bucket),当发生哈希冲突时,会将冲突的元素以链表的形式存储在桶中。当链表长度大于等于 8 且数组容量大于等于 64 时,链表会转成红黑树,以提高查找效率。HashMap 通过 key 的哈希值来确定元素在数组中的位置,其平均查找时间复杂度为 O(1),但在哈希冲突严重时,链表过长,查找时间复杂度会退化为 O(n),红黑树的引入优化了这种极端情况下的查找效率。
  2. ArrayList 扩容操作
    • ArrayList 有一个容量(capacity)和实际元素个数(size)。当 size 达到 capacity 时,就会触发扩容。扩容时,会创建一个新的数组,新数组的容量是原数组容量的 1.5 倍(newCapacity = oldCapacity + (oldCapacity >> 1))。然后通过 System.arraycopy() 方法将原数组的元素复制到新数组中。例如:
    int[] oldArray = {1, 2, 3};
    int[] newArray = new int[oldArray.length * 1.5];
    System.arraycopy(oldArray, 0, newArray, 0, oldArray.length);
    
    这样就完成了数组的扩容和元素复制。
  3. HashMap 链表转红黑树条件
    • 当链表长度大于等于 8 且数组容量大于等于 64 时,链表会转成红黑树。这是因为链表在长度较短时,查询效率虽然会因为哈希冲突而有所下降,但整体性能还可以接受。而当链表过长时,查询时间复杂度退化为 O(n),效率大幅降低。红黑树是一种自平衡的二叉查找树,其查找、插入、删除的时间复杂度均为 O(log n),在链表过长时转换为红黑树能显著提高查找效率。
  4. 创建线程的方式
    • 继承 Thread 类:通过继承 Thread 类,重写 run() 方法来定义线程执行的逻辑。例如:
    class MyThread extends Thread {
        @Override
        public void run() {
            System.out.println("Thread is running");
        }
    }
    MyThread thread = new MyThread();
    thread.start();
    
    • 实现 Runnable 接口:实现 Runnable 接口的 run() 方法,然后将实现类的实例作为参数传递给 Thread 类的构造函数。例如:
    class MyRunnable implements Runnable {
        @Override
        public void run() {
            System.out.println("Runnable is running");
        }
    }
    MyRunnable runnable = new MyRunnable();
    Thread thread = new Thread(runnable);
    thread.start();
    
    • 实现 Callable 接口并搭配 FutureTask 使用Callable 接口的 call() 方法可以有返回值,并且可以抛出异常。FutureTask 类实现了 RunnableFuture 接口,既可以作为 Runnable 被线程执行,又可以作为 Future 得到 Callable 的返回值。例如:
    import java.util.concurrent.Callable;
    import java.util.concurrent.ExecutionException;
    import java.util.concurrent.FutureTask;
    
    class MyCallable implements Callable<Integer> {
        @Override
        public Integer call() throws Exception {
            return 100;
        }
    }
    
    MyCallable callable = new MyCallable();
    FutureTask<Integer> futureTask = new FutureTask<>(callable);
    Thread thread = new Thread(futureTask);
    thread.start();
    try {
        Integer result = futureTask.get();
        System.out.println("Result: " + result);
    } catch (InterruptedException | ExecutionException e) {
        e.printStackTrace();
    }
    
  5. 线程池核心参数
    • 核心线程数(corePoolSize):线程池在初始化后,会创建 corePoolSize 数量的线程来处理任务。这些线程即使在空闲状态下也不会被销毁(除非设置了 allowCoreThreadTimeOuttrue)。例如,一个线程池设置 corePoolSize 为 5,那么初始化后就会有 5 个线程随时准备处理任务。
    • 最大线程数(maximumPoolSize):线程池能创建的最大线程数量。当任务队列已满,且当前线程数小于 maximumPoolSize 时,线程池会创建新的线程来处理任务。但线程数不会超过 maximumPoolSize。比如,maximumPoolSize 设置为 10,当任务过多,核心线程数 5 个处理不过来,且队列已满时,最多还能再创建 5 个线程,总共 10 个线程处理任务。
    • 队列容量(workQueue):用于存放等待处理任务的队列。常用的队列类型有 ArrayBlockingQueue(有界队列)、LinkedBlockingQueue(无界队列,默认容量为 Integer.MAX_VALUE)等。当核心线程都在忙碌时,新的任务会被放入队列中等待处理。例如,使用 ArrayBlockingQueue 作为任务队列,设置容量为 100,那么最多可以存放 100 个任务等待核心线程处理。
    • 线程存活时间(keepAliveTime):当线程数大于核心线程数时,多余的空闲线程在等待新任务到来的时间超过 keepAliveTime 后,会被销毁。例如,keepAliveTime 设置为 60 秒,当有多余的线程空闲时间超过 60 秒,就会被销毁。
    • 时间单位(unit)keepAliveTime 的时间单位,如 TimeUnit.SECONDS(秒)、TimeUnit.MINUTES(分钟)等。
  6. 线程池拒绝策略
    • AbortPolicy:这是默认的拒绝策略,当任务无法提交到线程池(队列已满且线程数达到最大线程数)时,直接抛出 RejectedExecutionException 异常。例如:
    ThreadPoolExecutor executor = new ThreadPoolExecutor(
        2, 4, 10, TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(2),
        new ThreadPoolExecutor.AbortPolicy());
    for (int i = 0; i < 8; i++) {
        executor.submit(() -> System.out.println(Thread.currentThread().getName() + " is running"));
    }
    
    运行上述代码,当任务数超过队列容量和最大线程数时,就会抛出异常。
    • DiscardPolicy:丢弃任务,不抛出异常。当任务无法提交到线程池时,默默丢弃该任务,不会有任何提示。例如:
    ThreadPoolExecutor executor = new ThreadPoolExecutor(
        2, 4, 10, TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(2),
        new ThreadPoolExecutor.DiscardPolicy());
    for (int i = 0; i < 8; i++) {
        executor.submit(() -> System.out.println(Thread.currentThread().getName() + " is running"));
    }
    
    这里多余的任务会被丢弃,但程序不会报错。
    • DiscardOldestPolicy:丢弃队列中最老的任务(即队列头部的任务),然后尝试提交新任务。例如:
    ThreadPoolExecutor executor = new ThreadPoolExecutor(
        2, 4, 10, TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(2),
        new ThreadPoolExecutor.DiscardOldestPolicy());
    for (int i = 0; i < 8; i++) {
        executor.submit(() -> System.out.println(Thread.currentThread().getName() + " is running"));
    }
    
    当队列满且有新任务时,会丢弃队列头部的任务,为新任务腾出空间。
    • CallerRunsPolicy:当任务无法提交到线程池时,由提交任务的线程(调用者线程)来执行该任务。例如:
    ThreadPoolExecutor executor = new ThreadPoolExecutor(
        2, 4, 10, TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(2),
        new ThreadPoolExecutor.CallerRunsPolicy());
    for (int i = 0; i < 8; i++) {
        executor.submit(() -> System.out.println(Thread.currentThread().getName() + " is running"));
    }
    
    这样当线程池无法处理任务时,调用 submit 方法的主线程会执行任务。
  7. Spring 中 Bean 的作用域
    • singleton(单例):在整个 Spring 容器中,只会创建一个该 Bean 的实例。例如,配置一个 UserService 的 Bean 为单例,无论在多少个地方注入 UserService,都是同一个实例。这适用于无状态的服务,如 UserService 只提供一些业务逻辑方法,不依赖于特定的用户状态等。
    • prototype(原型):每次从容器中获取该 Bean 时,都会创建一个新的实例。比如 User 类,不同的用户可能有不同的属性,每次获取 User Bean 都希望是一个新的实例,就可以将其作用域设置为 prototype
    • request:在一次 HTTP 请求中,只会创建一个该 Bean 的实例。当请求结束,该 Bean 实例也会被销毁。适用于处理与请求相关的业务逻辑,如 RequestContextHolder 相关的操作,在每个请求中可能需要不同的上下文实例。
    • session:在一个 HTTP Session 中,只会创建一个该 Bean 的实例。当 Session 过期或被销毁时,该 Bean 实例也会被销毁。常用于与用户会话相关的业务,比如用户登录后,在整个会话期间可能需要一个 UserSession Bean 来保存用户的会话信息。
  8. Spring Boot 自动配置原理
    • Spring Boot 依赖于 SpringFactoriesLoader 机制。在 META - INF/spring.factories 文件中,定义了各种自动配置类。例如,spring - boot - starter - web 依赖中,spring.factories 文件里有 WebMvcAutoConfiguration 等自动配置类。
    • 当 Spring Boot 应用启动时,SpringFactoriesLoader 会加载这些自动配置类。自动配置类通过 @Configuration 注解标记为配置类,并且使用 @Conditional 系列注解来判断是否满足自动配置的条件。比如 @ConditionalOnClass 表示当类路径下存在某个类时才进行配置,@ConditionalOnProperty 表示当配置文件中某个属性满足条件时才进行配置。
    • DataSourceAutoConfiguration 为例,@ConditionalOnClass(HikariDataSource.class) 表示当类路径下存在 HikariDataSource 类时(即引入了 HikariCP 连接池依赖),才会进行数据源的自动配置。然后在配置类中,通过 @Bean 注解定义各种 Bean,如 DataSource Bean,这样 Spring Boot 就自动帮我们配置好了数据源相关的内容。
  9. MyBatis 中 #{} 和 ${} 的区别
    • #{}:是预编译处理,MyBatis 在处理 #{} 时,会将 SQL 中的 #{} 替换为 ?,然后使用 PreparedStatement 进行参数设置。例如,SQL 语句 SELECT * FROM user WHERE username = #{username},实际执行时会变成 SELECT * FROM user WHERE username =?,然后通过 PreparedStatement.setString(1, "admin") 这样的方式设置参数。这种方式能有效防止 SQL 注入,因为参数是作为字符串传入的,不会被解析为 SQL 语句的一部分。
    • **:是字符串替换,MyBatis在处理{}**:是字符串替换,MyBatis 在处理 `{}时,会直接将中的内容替换到SQL中。例如,SQL语句SELECTFROMuserWHEREusername={}` 中的内容替换到 SQL 中。例如,SQL 语句 `SELECT * FROM user WHERE username = {username},如果 username的值为admin,实际执行的 SQL 就是 SELECT * FROM user WHERE username = admin。这种方式存在 SQL 注入风险,如果 username的值被恶意修改为admin OR 1 = 1,就会导致 SQL 语句被篡改,查询出所有用户数据。所以在使用 ${}` 时要特别小心,一般用于传入表名、字段名等不会引起 SQL 注入的场景。
  10. Dubbo 服务暴露过程
  • 配置阶段:在服务提供方,通过 XML 配置或注解等方式定义要暴露的服务接口和实现类,以及注册中心地址等信息。例如,使用 XML 配置:
<dubbo:service interface="com.example.UserService" ref="userServiceImpl"/>
<dubbo:registry address="zookeeper://127.0.0.1:2181"/>
  • 初始化阶段:Dubbo 框架启动时,会解析配置信息,创建服务实例,并根据注册中心地址创建与注册中心的连接。
  • **代理生成