《互联网大厂Java面试:核心知识大考验》

25 阅读9分钟

面试官:第一轮提问,首先问你,Java 中多线程有哪些实现方式?

王铁牛:嗯……有继承 Thread 类,还有实现 Runnable 接口,还有实现 Callable 接口。

面试官:回答得不错。那线程池的核心参数有哪些?

王铁牛:这个我知道,有 corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。

面试官:很好。最后一个问题,在多线程环境下,如何保证数据的一致性?

王铁牛:呃……可以用 synchronized 关键字,还有 Lock 接口。

面试官:好,第一轮结束。

面试官:第二轮提问,说说 JVM 的内存结构。

王铁牛:有堆、栈、方法区、程序计数器、本地方法栈。

面试官:那垃圾回收算法有哪些?

王铁牛:有标记清除算法、标记整理算法、复制算法、分代收集算法。

面试官:简述一下 HashMap 的底层实现原理。

王铁牛:它是基于数组和链表实现的,还有红黑树。

面试官:第二轮结束。

面试官:第三轮提问,Spring 框架的核心特性有哪些?

王铁牛:依赖注入、面向切面编程、IoC 容器。

面试官:Spring Boot 自动配置原理是什么?

王铁牛:这个……不太清楚。

面试官:MyBatis 的缓存机制了解吗?

王铁牛:呃……好像有一级缓存和二级缓存。

面试官:好,面试结束,回去等通知吧。

答案

  1. Java 中多线程的实现方式
    • 继承 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 方法,将其作为线程执行体。这种方式可以避免单继承的局限性。比如:
class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("Runnable is running");
    }
}
Thread thread = new Thread(new MyRunnable());
thread.start();
  • 实现 Callable 接口:实现 Callable 接口的 call 方法,该方法有返回值。使用 FutureTask 来包装 Callable 对象,通过 Thread 来启动线程。示例如下:
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;

class MyCallable implements Callable<String> {
    @Override
    public String call() throws Exception {
        return "Callable result";
    }
}
FutureTask<String> futureTask = new FutureTask<>(new MyCallable());
Thread thread = new Thread(futureTask);
thread.start();
try {
    String result = futureTask.get();
    System.out.println(result);
} catch (Exception e) {
    e.printStackTrace();
}
  1. 线程池的核心参数
    • corePoolSize:线程池的核心线程数。当提交的任务数小于 corePoolSize 时,线程池会创建新线程来执行任务。
    • maximumPoolSize:线程池允许的最大线程数。当任务数超过 corePoolSize 且 workQueue 已满时,会创建新线程直到线程数达到 maximumPoolSize。
    • keepAliveTime:线程池中非核心线程的存活时间。当线程数超过 corePoolSize 且线程空闲时间超过 keepAliveTime 时,非核心线程会被销毁。
    • unit:keepAliveTime 的时间单位。
    • workQueue:任务队列,用于存放提交的任务。当线程池的线程都在忙碌时,新提交的任务会被放入 workQueue 中。
    • threadFactory:线程工厂,用于创建线程,可自定义线程的名称、优先级等属性。
    • handler:任务拒绝策略。当线程池已满且 workQueue 也已满时,会调用 handler 来处理新提交的任务。常见的拒绝策略有 AbortPolicy(直接抛出异常)、CallerRunsPolicy(调用者运行任务)、DiscardPolicy(丢弃任务)、DiscardOldestPolicy(丢弃最旧的任务)。
  2. 在多线程环境下保证数据一致性的方法
    • synchronized 关键字
      • 作用于代码块:对指定的代码块进行同步,确保同一时刻只有一个线程能执行该代码块。例如:
synchronized (object) {
    // 同步代码块
}
 - 作用于方法:对整个方法进行同步,方法所在对象作为锁。比如:
public synchronized void synchronizedMethod() {
    // 方法体
}
  • Lock 接口
    • Lock 接口提供了比 synchronized 更灵活的锁控制。例如 ReentrantLock 类实现了 Lock 接口。
    • 可中断锁:lock.lockInterruptibly() 方法可以使线程在等待锁的过程中响应中断。
    • 公平锁:构造函数可以传入 boolean 参数来创建公平锁,公平锁会按照线程请求锁的顺序来分配锁,避免线程饥饿。
    • 锁绑定多个条件:可以通过 newCondition() 方法创建多个 Condition 对象,实现不同条件下的线程等待和唤醒。
  1. JVM 的内存结构
    • 堆(Heap):是 JVM 中最大的一块内存区域,用于存储对象实例。堆被分为新生代、老年代和永久代(Java 8 后为元空间)。新生代又分为 Eden 区和两个 Survivor 区。
    • 栈(Stack):每个线程都有自己独立的栈,用于存储局部变量、方法调用等信息。栈中的数据是线程私有的,并且随着方法的调用和返回而进行入栈和出栈操作。
    • 方法区(Method Area):存储类信息、常量、静态变量等数据。在 Java 8 后,永久代被元空间取代,元空间使用本地内存,不再受限于 JVM 堆内存大小。
    • 程序计数器(Program Counter Register):是一块较小的内存区域,它记录当前线程执行的字节码指令地址。每个线程都有自己独立的程序计数器。
    • 本地方法栈(Native Method Stack):与栈类似,用于执行本地方法(用 C、C++ 实现的方法)。
  2. 垃圾回收算法
    • 标记清除算法(Mark-Sweep)
      • 标记阶段:从根对象开始,标记所有可达的对象。
      • 清除阶段:清除所有未被标记的对象。这种算法会产生大量不连续的内存碎片。
    • 标记整理算法(Mark-Compact)
      • 标记阶段:同标记清除算法。
      • 整理阶段:将所有存活的对象向一端移动,然后清除边界以外的内存。避免了内存碎片问题。
    • 复制算法(Copying)
      • 将内存分为两块相等的区域,每次只使用其中一块。当这一块内存使用完后,将存活的对象复制到另一块内存,然后清除原来的那块内存。这种算法适用于新生代,因为新生代对象存活率低。
    • 分代收集算法(Generational Collection)
      • 根据对象的存活周期将堆内存分为不同的代,如新生代、老年代。针对不同代采用不同的垃圾回收算法。新生代对象创建和销毁频繁,采用复制算法;老年代对象存活率高,采用标记清除或标记整理算法。
  3. HashMap 的底层实现原理
    • HashMap 底层是基于数组和链表(JDK 8 后引入红黑树)实现的。
    • 初始时,HashMap 会创建一个默认大小为 16 的数组。
    • 当添加键值对时,首先通过 key 的 hash 值计算出在数组中的索引位置。如果该位置为空,则直接插入新节点。
    • 如果该位置不为空,就会比较 key 的 hash 值和已存在节点的 hash 值,如果相等且 key 通过 equals 方法比较也相等,则更新 value;如果 hash 值相等但 key 不相等,则形成链表(JDK 8 前)或红黑树(JDK 8 后,当链表长度大于等于 8 且数组长度大于等于 64 时,链表会转换为红黑树)。
    • 在扩容时,会创建一个新的更大的数组(通常是原数组大小的 2 倍),然后将原数组中的节点重新计算 hash 值并插入到新数组中。
  4. Spring 框架的核心特性
    • 依赖注入(Dependency Injection):通过控制反转(IoC)容器,将对象之间的依赖关系由程序主动创建改为由容器注入。例如:
public class UserService {
    private UserDao userDao;
    public UserService(UserDao userDao) {
        this.userDao = userDao;
    }
}
// 在配置文件中注入
<bean id="userService" class="UserService">
    <constructor-arg ref="userDao"/>
</bean>
  • 面向切面编程(Aspect-Oriented Programming,AOP):允许将一些横切关注点(如日志、事务管理等)与业务逻辑分离。通过定义切面、切入点和通知来实现。例如:
@Aspect
public class LogAspect {
    @Before("execution(* com.example.service.*.*(..))")
    public void logBefore() {
        System.out.println("Before method execution");
    }
}
  • IoC 容器:负责创建、配置和管理对象之间的依赖关系。IoC 容器可以通过 XML 配置文件、注解等方式来定义对象及其依赖关系。
  1. Spring Boot 自动配置原理
    • Spring Boot 利用了 Spring 的条件化配置功能。它通过大量的 @Configuration 类和 @Conditional 注解来实现自动配置。
    • 当应用启动时,Spring Boot 会扫描 classpath 下所有的 jar 包和类,找到所有符合条件的自动配置类。
    • 每个自动配置类都会根据一定的条件来决定是否生效。例如,当项目中引入了 Spring Data JPA 相关的依赖时,Spring Boot 会自动配置 JPA 的相关组件(如数据源、EntityManagerFactory 等),这些配置是基于条件判断的,只有当满足特定条件(如存在相关依赖)时才会生效。
    • 自动配置类还会结合 properties 文件中的配置来进一步定制化配置。用户可以通过在 application.properties 等文件中配置相关属性,来调整自动配置的行为。
  2. MyBatis 的缓存机制
    • 一级缓存
      • 一级缓存是 SqlSession 级别的缓存。在同一个 SqlSession 中,对相同的 SQL 语句进行查询时,会先从一级缓存中获取数据,如果缓存命中,则直接返回结果,避免了重复查询数据库。
      • 当 SqlSession 关闭时,一级缓存会被清空。
    • 二级缓存
      • 二级缓存是基于 namespace 级别的缓存。多个 SqlSession 可以共享二级缓存。
      • 开启二级缓存后,当一个 SqlSession 执行查询操作并将结果放入二级缓存后,其他 SqlSession 再次执行相同的查询时,如果二级缓存命中,则直接返回结果。
      • 二级缓存的实现需要在 Mapper 接口对应的 XML 文件中配置 标签,并且实体类需要实现 Serializable 接口。

面试结束后,面试官让王铁牛回家等通知。这次面试中,王铁牛对一些简单问题回答得不错,展现了一定的基础知识,但对于复杂问题回答得不够清晰。面试官会综合考虑王铁牛的整体表现来决定是否录用他。对于王铁牛来说,通过这次面试也发现了自己的不足之处,后续可以针对复杂问题进一步深入学习和理解,提升自己的技术水平,以便在未来的面试中能有更好的表现。