京东后端实习面试经验分享:如何应对基础问题并提升面试技巧

今天要分享的是来自华中科技大学一位同学的京东一面面经,内容主要集中在一些相对基础的问题,这些问题相对简单,容易准备,属于常见的面试八股。

图片这位同学是人生中的第一次面试,结果迅速被淘汰,情况实属正常,毕竟缺乏经验。在 Java 后端实习面试中,这位同学所遇到的问题已经非常基础,对大多数准备充分的候选人来说并不具挑战性。

在许多同学看来,这类基础问题的考查意义不大,实际上却非常重要。这些基础知识在实际开发中经常会用到。比如,线程池的拒绝策略、核心参数配置等,如果不了解,使用线程池时可能会产生误解,从而导致问题的发生。而且,基础性的问题是最容易准备的,比起底层原理、系统设计、场景问题以及深入挖掘项目经历等复杂问题,基础问题的准备显得要简单得多。

1. 你了解 Redis 吗?它的作用是什么?

Redis(REmote DIctionary Server)是一个基于 C 语言开发的开源 NoSQL 数据库,它与传统数据库不同,Redis 的数据存储在内存中(内存数据库,支持持久化),因此读写速度极快,广泛应用于分布式缓存。Redis 存储的是键值对数据(KV)。

为了满足不同的业务场景,Redis 内置了多种数据类型(例如字符串、哈希、排序集、位图、HyperLogLog、地理位置等)。此外,Redis 还支持事务、持久化、Lua 脚本和多种开箱即用的集群方案(如 Redis Sentinel 和 Redis Cluster)。

Redis 的数据类型概览

Redis 通过多种性能优化实现高效运行,主要包括以下三点:

  1. 由于 Redis 基于内存,内存的访问速度远高于磁盘,速度提升可达千倍。
  2. Redis 采用 Reactor 模式设计了一套高效的事件处理模型,主要包括单线程事件循环和 IO 多路复用(关于 Redis 的线程模型后面将详细说明)。
  3. Redis 内置了多种经过优化的数据结构,实现高性能。

除了缓存,Redis 还可以做什么?

  • 分布式锁:通过 Redis 实现分布式锁是一种常见方案,通常基于 Redisson。有关 Redis 如何实现分布式锁的详细讨论,
  • 限流:通常通过 Redis 和 Lua 脚本实现限流。
  • 消息队列:Redis 提供的 List 数据结构可作为简单的队列使用。Redis 5.0 中新增的 Stream 数据结构更适合用作消息队列,类似于 Kafka,支持主题和消费组的概念,支持消息持久化和 ACK 机制。
  • 延时队列:Redisson 提供了基于排序集实现的延时队列。
  • 分布式 Session:利用字符串或哈希数据类型保存 Session 数据,所有服务器均可访问。
  • 复杂业务场景:借助 Redis 及其扩展(如 Redisson),可方便实现许多复杂业务场景,例如通过位图统计活跃用户,或通过有序集合维护排行榜。

2. Redis 中常见的数据结构有哪些?

Redis 中的常见数据类型包括:

  • 五种基础数据类型:字符串(String)、列表(List)、集合(Set)、哈希(Hash)、有序集合(Zset)。
  • 三种特殊数据类型:HyperLogLog(基数统计)、位图(Bitmap)、地理位置信息(Geospatial)。

此外,还有其他数据结构,例如 布隆过滤器(Bloom filter)、位域(Bitfield)。

有关 Redis 中五种基础数据类型和三种特殊数据类型的详细介绍,请参考 Redis 官方文档:Redis 数据类型介绍

3. 同步与异步的区别

  • 同步:在发出调用后,必须等待结果返回才能继续进行调用。
  • 异步:调用发送后,无需等待返回结果,调用立即返回。

4. 线程的创建方法有哪些?

创建线程的方法有多种,例如继承Thread类、实现Runnable接口、实现Callable接口、使用线程池、使用CompletableFuture类等。

不过,这些方式并不真正创建线程。严格来说,Java 中只有一种方式可以创建线程,那就是通过new Thread().start()。不论是哪种方式,最终都是依赖于new Thread().start()

5. 线程池的作用是什么?

线程池提供了一种限制和管理资源(包括执行任务)的方法。每个线程池还维护一些基本统计信息,例如已完成任务的数量。

借用《Java 并发编程的艺术》的观点,使用线程池的好处包括:

  • 降低资源消耗。通过重复利用已有线程,减少线程创建和销毁带来的开销。
  • 提升响应速度。任务到达时,无需等待线程创建即可立即执行。
  • 提高线程管理。线程是稀缺资源,如果不加限制地创建线程,不仅会消耗系统资源,还会降低系统稳定性。使用线程池可以进行统一的分配、调优和监控。

在《阿里巴巴 Java 开发手册》中,强制要求不使用 Executors 创建线程池,而是通过 ThreadPoolExecutor 构造函数的方式,这样可以明确线程池的运行规则,避免资源耗尽的风险。

Executors 返回线程池对象的缺陷如下(将会在后文详细介绍):

  • FixedThreadPoolSingleThreadExecutor:使用无界的 LinkedBlockingQueue,可能会导致大量请求堆积,进而引发 OOM。
  • CachedThreadPool:使用的是同步队列 SynchronousQueue,允许创建的线程数量为 Integer.MAX_VALUE,当任务数量过多且执行速度缓慢时,可能会创建大量线程,导致 OOM。
  • ScheduledThreadPoolSingleThreadScheduledExecutor : 使用无界的延迟阻塞队列DelayedWorkQueue,同样可能会导致 OOM。

6. Spring, Spring MVC 和 Spring Boot 之间的关系?

很多人对 Spring、Spring MVC 和 Spring Boot 的关系感到困惑。其实这些概念并不复杂。

Spring 是一个包含多个功能模块的框架,其中最重要的是 Spring-Core(主要提供 IoC 依赖注入功能的支持)模块,Spring 中的其他模块(如 Spring MVC)的功能实现基本依赖于该模块。

下图展示了 Spring 4.x 版本的模块结构。在最新的 5.x 版本中,Web 模块的 Portlet 组件已被废弃,同时新增了用于异步响应式处理的 WebFlux 组件。

Spring 主要模块

Spring MVC 是 Spring 中的重要模块,主要用于快速构建 MVC 结构的 Web 应用程序。MVC 是模型(Model)、视图(View)和控制器(Controller)的简称,其核心思想是通过将业务逻辑、数据和显示分离来组织代码。

使用 Spring 开发时,过于复杂的配置(例如开启某些 Spring 特性时,需要用 XML 或 Java 显式配置)促成了 Spring Boot 的诞生。Spring 旨在简化 J2EE 企业应用程序的开发,而 Spring Boot 则旨在简化 Spring 开发(减少配置,开箱即用)。

Spring Boot 仅仅简化了配置,如果要构建 MVC 架构的 Web 应用程序,依然需要使用 Spring MVC 作为框架,只是 Spring Boot 帮助简化了 Spring MVC 的许多配置,实现了真正的开箱即用。

7. IoC 和 AOP

IoC

**IoC(控制反转)**是一种设计思想,而非具体的技术实现。IoC 的核心思想是将原本在程序中手动创建对象的控制权转交给 Spring 框架管理。然而,IoC 并非 Spring 独有,其他语言中也有类似应用。

为什么称其为控制反转?

  • 控制:指的是对象创建(实例化、管理)的权力。
  • 反转:将控制权交给外部环境(如 Spring 框架、IoC 容器)。

IoC 容器负责管理对象之间的相互依赖关系,并进行对象注入。这种方式大大简化了应用的开发,使得应用从复杂的依赖关系中解脱出来。IoC 容器就如同一个工厂,当我们需要创建一个对象时,只需配置好配置文件或注解即可,完全无需考虑对象的创建过程。

在实际项目中,一个 Service 类可能依赖多个其他类,若每次都需要明确这些类的构造函数,可能会令人头痛。利用 IoC,我们只需进行配置,便可在需要的地方引用,提高了项目的可维护性,降低了开发难度。

在 Spring 中,IoC 容器是实现 IoC 的载体,实际上就是一个存放对象的 Map(key,value)。

早期 Spring 通常通过 XML 文件配置 Bean,后来开发人员认为 XML 配置不太方便,因此采用了 Spring Boot 注解配置。

AOP

**AOP(面向切面编程)**能够将与业务无关但被多个业务模块共享的逻辑或责任(如事务处理、日志管理、权限控制等)封装,从而减少系统的重复代码,并降低模块间的耦合度,提高系统的可拓展性与可维护性。

Spring AOP 基于动态代理实现,如果要代理的对象实现了某个接口,Spring AOP 将使用 JDK Proxy 创建代理对象;如果对象未实现接口,则 Spring AOP 会使用 Cglib 生成一个被代理对象的子类。

当然,你也可以使用 AspectJ,Spring AOP 已经集成了 AspectJ,AspectJ 是 Java 生态系统中最完整的 AOP 框架。

AOP 切面编程涉及到的一些专业术语:

术语含义
目标(Target)被通知的对象
代理(Proxy)向目标对象应用通知后创建的代理对象
连接点(JoinPoint)目标对象类中所有方法均为连接点
切入点(Pointcut)被切面拦截或增强的连接点(切入点一定是连接点,但连接点不一定是切入点)
通知(Advice)增强的逻辑/代码,表示在连接点上执行的操作
切面(Aspect)切入点(Pointcut)与通知(Advice)的组合
织入(Weaving)将通知应用到目标对象,进而生成代理对象的过程

8. 浅拷贝与深拷贝的区别

关于深拷贝和浅拷贝的区别,我给出结论:

  • 浅拷贝:在堆上创建新对象,但如果原对象内部属性为引用类型,浅拷贝将直接复制内部对象的引用地址,结果是拷贝对象和原对象共享同一内部对象。
  • 深拷贝:完全复制整个对象,包括其内部对象。

若上述结论尚未理解,不妨看一个具体示例!

浅拷贝

以下是浅拷贝的示例代码,我们实现了 Cloneable 接口,并重写了 clone() 方法。

clone() 方法简单地调用父类 Objectclone() 方法。

public class Address implements Cloneable {
    private String name;
    // 省略构造函数、Getter & Setter
    @Override
    public Address clone() {
        try {
            return (Address) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
}

public class Person implements Cloneable {
    private Address address;
    // 省略构造函数、Getter & Setter
    @Override
    public Person clone() {
        try {
            return (Person) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
}

测试代码:

Person person1 = new Person(new Address("武汉"));
Person person1Copy = person1.clone();
// true
System.out.println(person1.getAddress() == person1Copy.getAddress());

从输出结果看,person1 的克隆对象和 person1 使用的是同一 Address 对象。

深拷贝

简单修改 Person 类的 clone() 方法,以便一并复制 Person 对象内部的 Address 对象。

@Override
public Person clone() {
    try {
        Person person = (Person) super.clone();
        person.setAddress(person.getAddress().clone());
        return person;
    } catch (CloneNotSupportedException e) {
        throw new AssertionError();
    }
}

测试代码:

Person person1 = new Person(new Address("武汉"));
Person person1Copy = person1.clone();
// false
System.out.println(person1.getAddress() == person1Copy.getAddress());

从输出结果可见,person1 的克隆对象和其包含的 Address 对象已不同。

引用拷贝指的是两个不同的引用指向同一对象。

9. List a, list b; a=b 是浅拷贝还是深拷贝,如何实现深拷贝?

List a, list b; a=b 本质上属于引用拷贝,即两个不同的引用指向同一对象,因此 ab 会指向同一个 List 对象。若更改 ba 也会随之更改,反之亦然。

示例代码:

List<String> a = new ArrayList<>();
a.add("Element1");
a.add("Element2");
List<String> b = new ArrayList<>();
b.add("Element3");
a = b;
System.out.println(a.hashCode() == b.hashCode());
b.add("Element4");
System.out.println(a);

输出结果:

true
[Element3, Element4]

若想实现深拷贝,则需创建新 List 对象,并将原 List 中的元素复制到新 List 中。如果 List 中的元素本身是对象,则还需确保这些对象被复制。

10. 接口与抽象类的区别,以及抽象类的作用

接口与抽象类的共同点

  • 都无法实例化。
  • 都可以包含抽象方法。
  • 都可以有默认实现的方法(Java 8 允许在接口中使用 default 关键字定义默认方法)。

接口与抽象类之间的区别

  • 接口主要用于对类行为的约束,实现某个接口即具备对应行为;抽象类则强调代码复用,提供类的模板。
  • 一个类只能继承一个抽象类,但可以实现多个接口。
  • 接口中的成员变量只能是 public static final 类型,不能修改且必须有初始值;抽象类的成员变量默认是可重定义的。

抽象类的作用

抽象类的作用主要是为子类提供一个共同的模板,定义一些通用方法和属性。子类继承抽象类后可以拥有这些通用属性,并按需实现或覆盖其中的方法。抽象类是面向对象编程的重要概念,有助于提高代码的复用性和可读性,并对类的继承进行限制。

11. String、StringBuffer 和 StringBuilder 的区别?

可变性

String 是不可变的。

StringBuilderStringBuffer 都继承自 AbstractStringBuilder 类,使用字符数组保存字符串且没有使用 finalprivate 关键字修饰,最重要的是 AbstractStringBuilder 提供了多种修改字符串的方法,如 append 方法。

abstract class AbstractStringBuilder implements Appendable, CharSequence {
    char[] value;
    public AbstractStringBuilder append(String str) {
        if (str == null) 
            return appendNull();
        int len = str.length();
        ensureCapacityInternal(count + len);
        str.getChars(0, len, value, count);
        count += len;
        return this;
    }
    // ...
}

线程安全性

由于 String 对象不可变,因此可视为常量,线程安全。而 AbstractStringBuilderStringBuilderStringBuffer 的公共父类,定义了一些字符串的基本操作。StringBuffer 对方法进行了同步锁处理,因此是线程安全的;StringBuilder 则未进行同步锁处理,因此是非线程安全的。

性能

每次对 String 类型的修改时,都会生成一个新的 String 对象,并将指针指向新的对象。而 StringBuffer 则直接对对象本身进行操作,而不会生成新的对象并改变引用。在相同条件下,使用 StringBuilder 相较于 StringBuffer 仅能提升 10%~15% 的性能,但需要冒多线程不安全的风险。

三者使用的总结

  1. 对少量数据的操作:适合使用 String
  2. 在单线程下操作大量数据的字符串缓冲区:适合使用 StringBuilder
  3. 在多线程下操作大量数据的字符串缓冲区:适合使用 StringBuffer

12. 后端如何向前端传输数据?

后端向前端传输数据的常用方法包括:

  1. RESTful API:通过 HTTP 请求进行数据交换,前端可以通过 GET、POST、PUT 等方法请求服务端数据或将数据发送到服务端。
  2. WebSocket:提供全双工通信通道,允许服务端与客户端之间进行实时数据传输。
  3. Server-Sent Events (SSE):允许服务端向客户端推送实时数据更新,通常用于单向通信,如推送通知。

这些方法各有所长,选择哪种方式取决于应用的需求和具体场景。例如,实时双向通信可以选择 WebSocket,仅服务端向客户端推送数据则可以选择 SSE,而标准的客户端-服务端数据交换则可以选择 RESTful API(这是通常使用最频繁的方式)。

参考资料

[1] Redis: https://redis.io/

[2] Redis 官方文档对 Redis 数据类型的介绍: https://redis.io/docs/data-types/