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

29 阅读15分钟

面试官:请简要介绍一下 Java 核心知识中的面向对象编程三大特性。

王铁牛:嗯,三大特性嘛,封装、继承、多态。封装就是把数据和操作数据的方法封装在一起;继承就是子类继承父类的属性和方法;多态就是同一个行为具有多个不同表现形式。

面试官:不错,回答得挺清晰。那说说 HashMap 的底层数据结构。

王铁牛:HashMap 底层是数组和链表、红黑树结合的数据结构。当链表长度超过 8 且数组长度大于 64 时,链表会转换为红黑树。

面试官:很好。那多线程中如何保证线程安全?

王铁牛:可以用 synchronized 关键字,还有 Lock 接口及其实现类,比如 ReentrantLock。

第一轮结束。

面试官:谈谈 JVM 的内存模型。

王铁牛:JVM 内存模型包括程序计数器、虚拟机栈、本地方法栈、堆和方法区。程序计数器记录当前线程执行的字节码指令地址;虚拟机栈存放局部变量表、操作数栈等;本地方法栈和虚拟机栈类似,不过是为 Native 方法服务;堆是对象实例的存放地;方法区存储类信息、常量、静态变量等。

面试官:那 JUC 包下有哪些常用的并发工具类?

王铁牛:有 CountDownLatch、CyclicBarrier、Semaphore、Exchanger 等。

面试官:线程池的核心参数有哪些,分别有什么作用?

王铁牛:核心参数有 corePoolSize(核心线程数)、maximumPoolSize(最大线程数)、keepAliveTime(线程存活时间)、unit(时间单位)、workQueue(任务队列)、threadFactory(线程工厂)、handler(拒绝策略)。corePoolSize 是线程池创建时的初始线程数;maximumPoolSize 是线程池能容纳的最大线程数;keepAliveTime 是线程空闲时的存活时间;unit 是时间单位;workQueue 用于存放提交的任务;threadFactory 用于创建线程;handler 用于处理超过线程池容量和队列容量的任务。

第二轮结束。

面试官:Spring 框架中 IOC 和 AOP 的原理是什么?

王铁牛:IOC 就是控制反转,通过依赖注入的方式,将对象的创建和依赖关系的管理交给 Spring 容器。AOP 是面向切面编程,通过动态代理等方式,在不修改原有代码的基础上,为目标对象添加额外的功能,比如日志、事务等。

面试官:Spring Boot 自动配置原理了解吗?

王铁牛:Spring Boot 自动配置是通过条件注解实现的,根据类路径下的依赖和配置属性,自动配置相应的 Bean。

面试官:MyBatis 的缓存机制是怎样的?

王铁牛:MyBatis 有一级缓存和二级缓存。一级缓存是 SqlSession 级别的,在同一个 SqlSession 中,查询相同的数据时会直接从缓存中获取。二级缓存是 mapper 级别的,多个 SqlSession 可以共享二级缓存。

面试官:好的,面试就到这里,回去等通知吧。

答案:

  1. Java 面向对象编程三大特性
    • 封装
      • 封装是把数据和操作数据的方法封装在一起。它的好处在于可以隐藏对象的内部实现细节,提高代码的安全性和可维护性。比如一个类中有一些属性,我们不想让外部直接访问和修改这些属性,就可以通过设置访问修饰符(如 private),然后提供公共的 get 和 set 方法来间接访问和修改属性。这样外部只能通过我们提供的方法来操作属性,而不能随意更改内部数据,保证了数据的一致性和安全性。
    • 继承
      • 继承是子类继承父类的属性和方法。它实现了代码的复用,子类可以继承父类已有的属性和方法,避免重复编写代码。例如,有一个父类 Animal,它有一些属性(如 name、age)和方法(如 eat),子类 Dog 继承自 Animal,那么 Dog 类就可以直接拥有这些属性和方法,并且可以根据自身特点进行扩展,比如重写 eat 方法来实现狗独特的进食方式。
    • 多态
      • 多态是同一个行为具有多个不同表现形式。它有编译时多态(如方法重载)和运行时多态(如方法重写)。以方法重写为例,子类重写父类的方法后,当使用父类引用指向子类对象时,调用该方法会执行子类重写后的方法。比如父类 Animal 有一个方法 move,子类 Dog 和 Bird 都重写了 move 方法,当 Animal animal = new Dog(); 时,调用 animal.move() 实际上执行的是 Dog 类的 move 方法,这就是多态的体现,使得程序更加灵活,可扩展性更强。
  2. HashMap 底层数据结构
    • HashMap 底层是数组和链表、红黑树结合的数据结构。
    • 当我们向 HashMap 中插入键值对时,首先会通过 key 的 hash 值计算出在数组中的索引位置。如果该位置为空,就直接插入新的键值对。
    • 如果该位置不为空,就会比较 key 的 hash 值和已存在键值对的 key 的 hash 值,如果相等,再比较 key 是否相等,若相等则覆盖原来的值;若 hash 值不相等,则将新的键值对以链表的形式插入到该位置。
    • 当链表长度超过 8 且数组长度大于 64 时,链表会转换为红黑树。这是为了提高查询效率,因为红黑树的查找时间复杂度为 O(log n),而链表的查找时间复杂度为 O(n)。
  3. 多线程中保证线程安全的方法
    • synchronized 关键字
      • 它可以修饰代码块或方法。当一个线程访问被 synchronized 修饰的代码块或方法时,会先获取对象的锁。如果锁被其他线程占用,该线程就会进入阻塞状态,直到锁被释放。例如,在一个类中有一个被 synchronized 修饰的方法 synchronized void method(){},当一个线程调用该方法时,会获取该类实例对象的锁,其他线程就无法同时调用这个方法,从而保证了同一时间只有一个线程能执行该方法中的代码,避免了数据竞争等线程安全问题。
    • Lock 接口及其实现类(如 ReentrantLock)
      • Lock 接口提供了比 synchronized 更灵活的锁控制。例如 ReentrantLock,它可以实现公平锁(通过构造函数传入 true),即按照线程请求锁的顺序来分配锁,避免了某些线程一直等待锁的情况。
      • 它还提供了 lock() 方法用于获取锁,tryLock() 方法用于尝试获取锁(如果获取不到不会阻塞,直接返回 false),unlock() 方法用于释放锁等功能。比如在一段代码中,当需要获取锁时调用 lock() 方法,如果获取成功则执行相应的业务逻辑,执行完后调用 unlock() 方法释放锁,这样可以更精细地控制锁的获取和释放,满足不同的业务需求。
  4. JVM 内存模型
    • 程序计数器
      • 程序计数器记录当前线程执行的字节码指令地址。它是线程私有的,每个线程都有自己独立的程序计数器。这意味着不同线程可以同时执行不同的代码段,并且程序计数器不会受到垃圾回收等影响。例如,一个线程在执行一段 Java 代码时,程序计数器会随着指令的执行不断更新,记录下当前执行到的指令位置,当线程切换时,程序计数器会保存当前线程暂停时的指令地址,以便下次继续执行。
    • 虚拟机栈
      • 虚拟机栈存放局部变量表、操作数栈等。局部变量表用于存放方法中的局部变量,包括基本数据类型、对象引用等。操作数栈用于执行字节码指令时进行操作数的存储和计算。每个方法在执行时都会创建一个对应的栈帧,栈帧中包含局部变量表和操作数栈等信息。当方法调用时,会将新的栈帧压入虚拟机栈,方法执行完毕后,栈帧会从虚拟机栈中弹出。例如,在一个方法中有局部变量 int a = 10; 这个变量 a 就会存放在局部变量表中,执行一些计算指令时会在操作数栈中进行数据的操作。
    • 本地方法栈
      • 本地方法栈和虚拟机栈类似,不过是为 Native 方法服务。Native 方法是用其他语言(如 C、C++)实现的方法,在 Java 中通过 JNI(Java Native Interface)调用。当一个 Java 程序调用 Native 方法时,会在本地方法栈中为该方法分配栈帧,执行完毕后栈帧弹出。例如,Java 程序调用一个用 C 语言实现的计算函数,这个函数的执行环境就由本地方法栈来管理。
      • 堆是对象实例的存放地。它是 JVM 中最大的一块内存区域,被所有线程共享。当我们创建一个对象时,会在堆中分配内存空间来存储对象的实例变量。垃圾回收器主要就是对堆中的对象进行回收管理。比如 new Object(); 这个语句会在堆中为新创建的 Object 对象分配内存。
    • 方法区
      • 方法区存储类信息、常量、静态变量等。它也是被所有线程共享的。类信息包括类的结构、方法、字段等信息。常量就是在程序中不会改变的值,如字符串常量 "hello"。静态变量是类的所有实例共享的变量,通过类名直接访问。例如,一个类中有 static int num = 10; 这个静态变量 num 就存储在方法区中。
  5. JUC 包下常用的并发工具类
    • CountDownLatch
      • 它允许一个或多个线程等待其他一组线程完成操作。例如,有一个主线程需要等待多个子线程都完成任务后再继续执行。可以创建一个 CountDownLatch 对象,设置计数器的值为子线程的数量。每个子线程执行完任务后调用 countDown() 方法,将计数器减 1。主线程调用 await() 方法等待,直到计数器的值变为 0,主线程才会继续执行。比如有三个子线程分别执行不同的任务,主线程创建 CountDownLatch(3),子线程执行完任务后调用 countDown(),主线程调用 await() 等待所有子线程完成。
    • CyclicBarrier
      • 它可以让一组线程互相等待,直到所有线程都到达某个屏障点,然后再一起继续执行。例如,在一个多线程的计算任务中,每个线程负责一部分计算,当所有线程都完成自己的部分计算后,需要一起进行汇总操作。可以使用 CyclicBarrier 创建一个屏障,设置参与的线程数量。每个线程执行完自己的计算任务后调用 await() 方法等待,当所有线程都调用 await() 方法到达屏障点时,所有线程会同时继续执行后续的汇总操作。
    • Semaphore
      • 它用于控制对某个资源的访问并发数。比如有一个资源,同时只能允许一定数量的线程访问。可以创建一个 Semaphore 对象,设置许可数量。线程在访问资源前调用 acquire() 方法获取许可,如果没有可用许可,线程会阻塞。访问完资源后调用 release() 方法释放许可。例如,有一个数据库连接池,同时只能允许 5 个线程获取连接进行操作,就可以用 Semaphore(5) 来控制连接的并发访问。
    • Exchanger
      • 它用于两个线程之间交换数据。例如,有两个线程分别负责不同的数据处理任务,处理完后需要交换数据。可以创建一个 Exchanger 对象,两个线程分别调用 exchange() 方法,在这个方法中传入自己要交换的数据,两个线程会阻塞等待对方传入数据,当双方都传入数据后,会交换数据并继续执行后续操作。
  6. 线程池的核心参数及作用
    • corePoolSize(核心线程数)
      • 线程池创建时的初始线程数。当提交的任务数小于 corePoolSize 时,线程池会创建新的线程来执行任务。例如,一个线程池的 corePoolSize 为 3,当提交 1 个任务时,会创建一个新线程来执行该任务。
    • maximumPoolSize(最大线程数)
      • 线程池能容纳的最大线程数。当提交的任务数大于 corePoolSize 且任务队列已满时,如果线程数小于 maximumPoolSize,会创建新的线程来执行任务;如果线程数达到 maximumPoolSize,就会根据拒绝策略来处理新提交的任务。比如线程池 corePoolSize 为 3,任务队列容量为 5,当提交了 8 个任务时,前 3 个任务由核心线程执行,接下来 5 个任务放入任务队列,当再提交第 9 个任务时,由于线程数还未达到 maximumPoolSize(假设为 8),会创建新线程来执行第 9 个任务。
    • keepAliveTime(线程存活时间)
      • 线程空闲时的存活时间。当线程池中的线程数大于 corePoolSize 时,这些多余的线程在空闲时间超过 keepAliveTime 后会被销毁。例如,keepAliveTime 为 60 秒,一个线程执行完任务后空闲了 70 秒,那么这个线程就会被销毁。
    • unit(时间单位)
      • keepAliveTime 的时间单位。可以是 TimeUnit.SECONDS(秒)、TimeUnit.MINUTES(分钟)等。
    • workQueue(任务队列)
      • 用于存放提交的任务。当提交的任务数大于 corePoolSize 时,任务会被放入任务队列中。常见的任务队列有 ArrayBlockingQueue(有界队列)、LinkedBlockingQueue(无界队列)等。例如,使用 ArrayBlockingQueue(5) 作为任务队列,当提交的任务数超过 corePoolSize 且小于等于 5 时,任务会放入该队列中等待线程执行。
    • threadFactory(线程工厂)
      • 用于创建线程。可以通过自定义 threadFactory 来设置线程的名称、优先级等属性。例如,创建一个自定义的 ThreadFactory,设置线程名称前缀为 "myThread - ",这样创建出来的线程名称就会以 "myThread - " 开头。
    • handler(拒绝策略)
      • 用于处理超过线程池容量和队列容量的任务。常见的拒绝策略有 AbortPolicy(直接抛出异常)、CallerRunsPolicy(调用者运行策略,由调用线程处理该任务)、DiscardPolicy(丢弃新提交的任务)、DiscardOldestPolicy(丢弃队列中最旧的任务,然后重新尝试执行新提交的任务)等。比如当线程池和任务队列都已满,新提交任务时,如果采用 AbortPolicy 拒绝策略,就会直接抛出异常。
  7. Spring 框架中 IOC 和 AOP 的原理
    • IOC(控制反转)
      • 通过依赖注入的方式,将对象的创建和依赖关系的管理交给 Spring 容器。
      • 例如,有一个类 UserService 依赖于 UserDao 接口。在传统的方式中,UserService 类内部需要自己创建 UserDao 的实例,如 UserService { UserDao userDao = new UserDaoImpl(); }。
      • 而在 Spring 中,通过配置文件或注解等方式,将 UserDao 的创建交给 Spring 容器,UserService 只需要声明依赖 UserDao 即可,如 。这样,当 UserService 需要使用 UserDao 时,Spring 容器会自动注入已经创建好的 UserDao 实例,实现了对象创建和依赖管理的反转,提高了代码的可维护性和可测试性。
    • AOP(面向切面编程)
      • 通过动态代理等方式,在不修改原有代码的基础上,为目标对象添加额外的功能,比如日志、事务等。
      • 以动态代理为例,当一个目标对象实现了某个接口时,Spring 会使用 JDK 动态代理为其创建代理对象。在代理对象中,会在目标方法执行前后等位置织入额外的逻辑。比如有一个 UserService 接口及其实现类 UserServiceImpl,Spring 会为 UserServiceImpl 创建代理对象。当调用代理对象的方法时,会先执行切面中的前置通知逻辑(如记录方法开始执行的日志),然后调用目标方法,最后执行切面中的后置通知逻辑(如记录方法执行结束的日志)。如果目标方法出现异常,还可以执行切面中的异常通知逻辑。通过这种方式,在不修改 UserServiceImpl 类代码的情况下,为其添加了日志记录等额外功能。
  8. Spring Boot 自动配置原理
    • Spring Boot 自动配置是通过条件注解实现的,根据类路径下的依赖和配置属性,自动配置相应的 Bean。
    • 例如,当项目中引入了 Spring Data JPA 的依赖时,Spring Boot 会自动扫描类路径下的相关配置和类。通过条件注解(如 @ConditionalOnClass、@ConditionalOnMissingBean 等)来判断是否满足自动配置的条件。
    • 如果满足条件,就会自动配置相应的 Bean。比如当检测到类路径下存在 JpaRepository 接口时,并且没有自定义的实现该接口的 Bean,Spring Boot 会自动配置 JpaRepostory 的实现类,将其注册到 Spring 容器中,方便开发者直接使用 JPA 进行数据访问,而不需要开发者手动配置大量的 JPA 相关的 Bean 和配置信息,大大简化了开发过程。
  9. **MyB