大家好,我是二哥呀。
今天继续给大家带来硬核面经,这次我们以《Java 面试指南》中同学 10 的 京东后端实习一面(已挂)为例,来看看如果你在面试中遇到这些面试题的话,该如何回答?
一共 18 道题,基本上围绕着二哥一直给大家强调的 Java 后端四大件展开(除了 Redis 没问)。准备 25 届、26 届 Java 后端实习的小伙伴一定要注意,精力多放在这四大件上面,准没错,听劝(😂)
京东 24 届秋招薪资参考
牛顿曾说过,“如果我比别人看得更远,那是因为我站在巨人的肩膀上”。因此,如果你也想冲京东的话,就一定要多看看前辈们的面经。
先来看技术一面的题目大纲(围绕 Java 后端四大件展开):
内容较长,撰写硬核面经不容易,建议大家先收藏起来,面试的时候大概率会碰到,我会尽量用通俗易懂+手绘图的方式,让你能背会的同时,还能理解和掌握,总之:让天下没有难背的八股 😂
①、由于 ArrayList 是基于数组实现的,所以 get(int index)
可以直接通过数组下标获取,时间复杂度是 O(1);LinkedList 是基于链表实现的,get(int index)
需要遍历链表,时间复杂度是 O(n)。
当然,get(E element)
这种查找,两种集合都需要遍历通过 equals 比较获取元素,所以时间复杂度都是 O(n)。
②、ArrayList 如果增删的是数组的尾部,直接插入或者删除就可以了,时间复杂度是 O(1);如果 add 的时候涉及到扩容,时间复杂度会提升到 O(n)。
但如果插入的是中间的位置,就需要把插入位置后的元素向前或者向后移动,甚至还有可能触发扩容,效率就会低很多,O(n)。
LinkedList 因为是链表结构,插入和删除只需要改变前置节点、后置节点和插入节点的引用就行了,不需要移动元素。
如果是在链表的头部插入或者删除,时间复杂度是 O(1);如果是在链表的中间插入或者删除,时间复杂度是 O(n),因为需要遍历链表找到插入位置;如果是在链表的尾部插入或者删除,时间复杂度是 O(1)。
三分恶面渣逆袭:ArrayList和LinkedList中间插入
三分恶面渣逆袭:ArrayList和LinkedList中间删除
注意,这里有个陷阱,LinkedList 更利于增删不是体现在时间复杂度上,因为二者增删的时间复杂度都是 O(n),都需要遍历列表;而是体现在增删的效率上,因为 LinkedList 的增删只需要改变引用,而 ArrayList 的增删可能需要移动元素。
HashSet 的 add 方法是通过调用 HashMap 的 put 方法实现的:
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
所以 HashSet 判断元素重复的逻辑底层依然是 HashMap 的底层逻辑:
三分恶面渣逆袭:HashMap插入数据流程图
HashMap 在插入元素时,通常需要三步:
第一步,通过 hash 方法计算 key 的哈希值。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
第二步,数组进行第一次扩容。
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
第三步,根据哈希值计算 key 在数组中的下标,如果对应下标正好没有存放数据,则直接插入。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
如果对应下标已经有数据了,就需要判断是否为相同的 key,是则覆盖 value,否则需要判断是否为树节点,是则向树中插入节点,否则向链表中插入数据。
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
}
也就是说,HashSet 通过元素的哈希值来判断元素是否重复,如果重复了,会覆盖原来的值。
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
hashCode()
方法的作⽤是获取哈希码,它会返回⼀个 int 整数,定义在 Object 类中, 是一个本地⽅法。
public native int hashCode();
hashCode 方法主要用来获取对象的哈希码,哈希码是由对象的内存地址或者对象的属性计算出来的,它是⼀个 int 类型的整数,通常是不会重复的,因此可以用来作为键值对的建,以提高查询效率。
例如 HashMap 中的 key 就是通过 hashCode 来实现的,通过调用 hashCode 方法获取键的哈希码,并将其与右移 16 位的哈希码进行异或运算。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
维护 equals()
和 hashCode()
之间的一致性是至关重要的,因为基于哈希的集合类(如 HashSet、HashMap、Hashtable 等)依赖于这一点来正确存储和检索对象。
具体地说,这些集合通过对象的哈希码将其存储在不同的“桶”中(底层数据结构是数组,哈希码用来确定下标),当查找对象时,它们使用哈希码确定在哪个桶中搜索,然后通过 equals()
方法在桶中找到正确的对象。
如果重写了 equals()
方法而没有重写 hashCode()
方法,那么被认为相等的对象可能会有不同的哈希码,从而导致无法在集合中正确处理这些对象。
这主要是由于哈希码(hashCode)的本质和目的所决定的。
哈希码是通过哈希函数将对象中映射成一个整数值,其主要目的是在哈希表中快速定位对象的存储位置。
由于哈希函数将一个较大的输入域映射到一个较小的输出域,不同的输入值(即不同的对象)可能会产生相同的输出值(即相同的哈希码)。
这种情况被称为哈希冲突。当两个不相等的对象发生哈希冲突时,它们会有相同的 hashCode。
为了解决哈希冲突的问题,哈希表在处理键时,不仅会比较键对象的哈希码,还会使用 equals 方法来检查键对象是否真正相等。如果两个对象的哈希码相同,但通过 equals 方法比较结果为 false,那么这两个对象就不被视为相等。
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
如果只重写 equals 方法,没有重写 hashcode 方法,那么会导致 equals 相等的两个对象,hashcode 不相等,这样的话,这两个对象会被放到不同的桶中,这样就会导致 get 的时候,找不到对应的值。
JVM,也就是 Java 虚拟机,它是 Java 实现跨平台的基石。
Java 程序运行的时候,编译器会将 Java 源代码(.java)编译成平台无关的 Java 字节码文件(.class),接下来对应平台的 JVM 会对字节码文件进行解释,翻译成对应平台的机器指令并运行。
三分恶面渣逆袭:Java语言编译运行
同时,任何可以通过 Java 编译的语言,比如说 Groovy、Kotlin、Scala 等,都可以在 JVM 上运行。
三分恶面渣逆袭:JVM跨语言
JVM 的内存区域可以粗暴地划分为堆
和栈
,当然了,按照 Java 的虚拟机规范,可以再细分为程序计数器
、虚拟机栈
、本地方法栈
、堆
、方法区
等。
三分恶面渣逆袭:Java虚拟机运行时数据区
其中方法区
和堆
是线程共享区,虚拟机栈
、本地方法栈
和程序计数器
是线程私有的。
Java 虚拟机栈(Java Virtual Machine Stack),通常指的就是“栈”,它的生命周期与线程相同。
Java 虚拟机栈(JVM 栈)中是一个个栈帧,每个栈帧对应一个被调用的方法。当线程执行一个方法时,会创建一个对应的栈帧,并将栈帧压入栈中。当方法执行完毕后,将栈帧从栈中移除。
三分恶面渣逆袭:Java虚拟机栈
本地方法栈(Native Method Stacks)与虚拟机栈相似,区别在于虚拟机栈是为虚拟机执行 Java 方法服务的,而本地方法栈是为虚拟机使用到的本地(Native)方法服务的。
Java 堆(Java Heap)是虚拟机所管理的内存中最大的一块,被所有线程共享,在虚拟机启动时创建。
以前,Java 中“几乎”所有的对象都会在堆中分配,但随着 JIT 编译器的发展和逃逸技术的逐渐成熟,所有的对象都分配到堆上渐渐变得不那么“绝对”了。
从 JDK 7 开始,Java 虚拟机已经默认开启逃逸分析了,意味着如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
Java 堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC 堆”(Garbage Collected Heap)。
从回收内存的角度来看,由于垃圾收集器大部分都是基于分代收集理论设计的,所以 Java 堆中经常会出现新生代
、老年代
、Eden空间
、From Survivor空间
、To Survivor空间
等名词。
三分恶面渣逆袭:Java 堆内存结构
总结来说:堆属于线程共享的内存区域,几乎所有的对象都在对上分配,生命周期不由单个方法调用所决定,可以在方法调用结束后继续存在,直到不在被任何变量引用,然后被垃圾收集器回收。
栈就是前面提到的 JVM 栈(主要存储局部变量、方法参数、对象引用等),属于线程私有,通常随着方法调用的结束而消失,也就无需进行垃圾收集。
垃圾回收器的核心作用是自动管理Java应用程序的运行时内存。它负责识别哪些内存是不再被应用程序使用的(即“垃圾”),并释放这些内存以便重新使用。
这一过程减少了程序员手动管理内存的负担,降低了内存泄漏和溢出错误的风险。
推荐阅读:一次内存溢出的排查优化实战
在 Java 中,和内存相关的问题主要有两种,内存溢出和内存泄漏。
内存泄漏是内在病源,外在病症表现可能有:
OutOfMemoryError
错误三分恶面渣逆袭:Java异常体系
Throwable
是 Java 语言中所有错误和异常的基类。它有两个主要的子类:Error 和 Exception,这两个类分别代表了 Java 异常处理体系中的两个分支。
Error 类代表那些严重的错误,这类错误通常是程序无法处理的。比如,OutOfMemoryError 表示内存不足,StackOverflowError 表示栈溢出。这些错误通常与 JVM 的运行状态有关,一旦发生,应用程序通常无法恢复。
Exception 类代表程序可以处理的异常。它分为两大类:编译时异常(Checked Exception)和运行时异常(Runtime Exception)。
①、编译时异常(Checked Exception):这类异常在编译时必须被显式处理(捕获或声明抛出)。
如果方法可能抛出某种编译时异常,但没有捕获它(try-catch)或没有在方法声明中用 throws 子句声明它,那么编译将不会通过。例如:IOException、SQLException 等。
②、运行时异常(Runtime Exception):这类异常在运行时抛出,它们都是 RuntimeException 的子类。对于运行时异常,Java 编译器不要求必须处理它们(即不需要捕获也不需要声明抛出)。
运行时异常通常是由程序逻辑错误导致的,如 NullPointerException、IndexOutOfBoundsException 等。
这道题通常会和实际的代码结合起来看:
public class TryDemo {
public static void main(String[] args) {
System.out.println(test());
}
public static int test() {
try {
return 1;
} catch (Exception e) {
return 2;
} finally {
System.out.print("3");
}
}
}
在test()
方法中,首先有一个try
块,接着是一个catch
块(用于捕获异常),最后是一个finally
块(无论是否捕获到异常,finally
块总会执行)。
①、try
块中包含一条return 1;
语句。正常情况下,如果try
块中的代码能够顺利执行,那么方法将返回数字1
。在这个例子中,try
块中没有任何可能抛出异常的操作,因此它会正常执行完毕,并准备返回1
。
②、由于try
块中没有异常发生,所以catch
块中的代码不会执行。
③、无论前面的代码是否发生异常,finally
块总是会执行。在这个例子中,finally
块包含一条System.out.print("3");
语句,意味着在方法结束前,会在控制台打印出3
。
当执行main
方法时,控制台的输出将会是:
31
这是因为finally
块确保了它包含的System.out.print("3");
会执行并打印3
,随后test()
方法返回try
块中的值1
,最终结果就是31
。
三分恶面渣逆袭:事务四大特性
原子性子性意味着事务中的所有操作要么全部完成,要么全部不完成,它是不可分割的单位。如果事务中的任何一个操作失败了,整个事务都会回滚到事务开始之前的状态,如同这些操作从未被执行过一样。
一致性确保事务从一个一致的状态转换到另一个一致的状态。
比如在银行转账事务中,无论发生什么,转账前后两个账户的总金额应保持不变。假如 A 账户(100 块)给 B 账户(10 块)转了 10 块钱,不管成功与否,A 和 B 的总金额都是 110 块。
隔离性意味着并发执行的事务是彼此隔离的,一个事务的执行不会被其他事务干扰。就是事务之间是井水不犯河水的。
隔离性主要是为了解决事务并发执行时可能出现的问题,如脏读、不可重复读、幻读等。
数据库系统通过事务隔离级别(如读未提交、读已提交、可重复读、串行化)来实现事务的隔离性。
持久性确保事务一旦提交,它对数据库所做的更改就是永久性的,即使发生系统崩溃,数据库也能恢复到最近一次提交的状态。通常,持久性是通过数据库的恢复和日志机制来实现的,确保提交的事务更改不会丢失。
Java 数据库连接(JDBC)是一个用于执行 SQL 语句的 Java API,它为多种关系数据库提供了统一访问的机制。使用 JDBC 操作数据库通常涉及以下步骤:
在与数据库建立连接之前,首先需要通过Class.forName()
方法加载对应的数据库驱动。这一步确保 JDBC 驱动注册到了DriverManager
类中。
Class.forName("com.mysql.cj.jdbc.Driver");
使用DriverManager.getConnection()
方法建立到数据库的连接。这一步需要提供数据库 URL、用户名和密码作为参数。
Connection conn = DriverManager.getConnection(
"jdbc:mysql://localhost:3306/databaseName", "username", "password");
Statement
对象通过建立的数据库连接对象Connection
创建Statement
、PreparedStatement
或CallableStatement
对象,用于执行 SQL 语句。
Statement stmt = conn.createStatement();
或者创建PreparedStatement
对象(预编译 SQL 语句,适用于带参数的 SQL):
PreparedStatement pstmt = conn.prepareStatement("SELECT * FROM tableName WHERE column = ?");
pstmt.setString(1, "value");
使用Statement
或PreparedStatement
对象执行 SQL 语句。
执行查询(SELECT)语句时,使用executeQuery()
方法,它返回ResultSet
对象;
执行更新(INSERT、UPDATE、DELETE)语句时,使用executeUpdate()
方法,它返回一个整数表示受影响的行数。
ResultSet rs = stmt.executeQuery("SELECT * FROM tableName");
或
int affectedRows = stmt.executeUpdate("UPDATE tableName SET column = 'value' WHERE condition");
如果执行的是查询操作,需要处理ResultSet
对象来获取数据。
while (rs.next()) {
String data = rs.getString("columnName");
// 处理每一行数据
}
最后,需要依次关闭ResultSet
、Statement
和Connection
等资源,释放数据库连接等资源。
if (rs != null) rs.close();
if (stmt != null) stmt.close();
if (conn != null) conn.close();
使用 JDBC 操作数据库的过程包括加载驱动、建立连接、创建执行语句、执行 SQL 语句、处理结果集和关闭资源。
在 Java 开发中,通常会使用 JDBC 模板库(如 Spring 的 JdbcTemplate)或 ORM 框架(如 Hibernate、MyBatis、MyBatis-Plus)来简化数据库操作和资源管理。
在 JDBC 的执行步骤中,创建连接后拿到的对象是java.sql.Connection
对象。这个对象是 JDBC API 中用于表示数据库连接的接口,它提供了执行 SQL 语句、管理事务等一系列操作的方法。
Connection
对象代表了应用程序和数据库的一个连接会话。
通过调用DriverManager.getConnection()
方法并传入数据库的 URL、用户名和密码等信息来获得这个对象。
一旦获得Connection
对象,就可以使用它来创建执行 SQL 语句的Statement
、PreparedStatement
和CallableStatement
对象,以及管理事务等。
Statement
和PreparedStatement
都是用于执行 SQL 语句的接口,但它们之间存在几个关键的区别:
①、Statement:每次执行Statement
对象的executeQuery
或executeUpdate
方法时,SQL 语句在数据库端都需要重新编译和执行。这适用于一次性执行的 SQL 语句。
②、PreparedStatement:代表预编译的 SQL 语句的对象。这意味着 SQL 语句在PreparedStatement
对象创建时就被发送到数据库进行预编译。
之后,可以通过设置参数值来多次高效地执行这个 SQL 语句。这不仅减少了数据库编译 SQL 语句的开销,也提高了性能,尤其是对于重复执行的 SQL 操作。
setXxx
方法(如setString
、setInt
)设置参数,可以有效防止 SQL 注入。总的来说,PreparedStatement
相比Statement
有着更好的性能和更高的安全性,是执行 SQL 语句的首选方式,尤其是在处理含有用户输入的动态查询时。
在 Spring 中,事务管理可以分为两大类:声明式事务管理和编程式事务管理。
三分恶面渣逆袭:Spring事务分类
编程式事务可以使用 TransactionTemplate 和 PlatformTransactionManager 来实现,需要显式执行事务。允许我们在代码中直接控制事务的边界,通过编程方式明确指定事务的开始、提交和回滚。
public class AccountService {
private TransactionTemplate transactionTemplate;
public void setTransactionTemplate(TransactionTemplate transactionTemplate) {
this.transactionTemplate = transactionTemplate;
}
public void transfer(final String out, final String in, final Double money) {
transactionTemplate.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(TransactionStatus status) {
// 转出
accountDao.outMoney(out, money);
// 转入
accountDao.inMoney(in, money);
}
});
}
}
在上面的代码中,我们使用了 TransactionTemplate 来实现编程式事务,通过 execute 方法来执行事务,这样就可以在方法内部实现事务的控制。
声明式事务是建立在 AOP 之上的。其本质是通过 AOP 功能,对方法前后进行拦截,将事务处理的功能编织到拦截的方法中,也就是在目标方法开始之前启动一个事务,在目标方法执行完之后根据执行情况提交或者回滚事务。
相比较编程式事务,优点是不需要在业务逻辑代码中掺杂事务管理的代码, Spring 推荐通过 @Transactional 注解的方式来实现声明式事务管理,也是日常开发中最常用的。
不足的地方是,声明式事务管理最细粒度只能作用到方法级别,无法像编程式事务那样可以作用到代码块级别。
@Service
public class AccountService {
@Autowired
private AccountDao accountDao;
@Transactional
public void transfer(String out, String in, Double money) {
// 转出
accountDao.outMoney(out, money);
// 转入
accountDao.inMoney(in, money);
}
}
Spring 的声明式事务管理是通过 AOP(面向切面编程)和代理机制实现的。
第一步,在 Bean 初始化阶段创建代理对象:
Spring 容器在初始化单例 Bean 的时候,会遍历所有的 BeanPostProcessor 实现类,并执行其 postProcessAfterInitialization 方法。
在执行 postProcessAfterInitialization 方法时会遍历容器中所有的切面,查找与当前 Bean 匹配的切面,这里会获取事务的属性切面,也就是 @Transactional
注解及其属性值。
然后根据得到的切面创建一个代理对象,默认使用 JDK 动态代理创建代理,如果目标类是接口,则使用 JDK 动态代理,否则使用 Cglib。
第二步,在执行目标方法时进行事务增强操作:
当通过代理对象调用 Bean 方法的时候,会触发对应的 AOP 增强拦截器,声明式事务是一种环绕增强,对应接口为MethodInterceptor
,事务增强对该接口的实现为TransactionInterceptor
,类图如下:
图片来源网易技术专栏
事务拦截器TransactionInterceptor
在invoke
方法中,通过调用父类TransactionAspectSupport
的invokeWithinTransaction
方法进行事务处理,包括开启事务、事务提交、异常回滚等。
事务的传播机制定义了在方法被另一个事务方法调用时,这个方法的事务行为应该如何。
Spring 提供了一系列事务传播行为,这些传播行为定义了事务的边界和事务上下文如何在方法调用链中传播。
三分恶面渣逆袭:6种事务传播机制
事务传播机制是使用 ThreadLocal 实现的,所以,如果调用的方法是在新线程中的,事务传播会失效。
Spring 默认的事务传播行为是 PROPAFATION_REQUIRED,即如果多个 ServiceX#methodX()
都工作在事务环境下,且程序中存在调用链 Service1#method1()->Service2#method2()->Service3#method3()
,那么这 3 个服务类的 3 个方法都通过 Spring 的事务传播机制工作在同一个事务中。
频繁更新的字段,不要作为主键或者索引。
索引能提高查询效率的根本原因在于它提供了一种快速查找数据的方式,而不需要扫描整个表。B+树索引作为数据库中最常用的索引结构之一,它通过维护数据的有序性并利用树形结构实现了快速查找,将数据访问的时间复杂度从O(n)降低到了O(log n)。
当对表进行插入、删除或更新操作时,不仅要修改表中的数据,还需要同步更新索引,以保证索引的有序性和准确性。这个过程中可能涉及到的操作包括:分裂、旋转。
频繁更新的字段,尤其是更新操作导致的插入位置随机且不可预测时,建立索引的成本可能会抵消查询性能的提升,因此不推荐为这类字段建立索引。
我们通过实际的 SQL 来验证一下。
示例 1(a=1,c=1):
EXPLAIN SELECT * FROM tbn WHERE A=1 AND C=1\G
key 是 idx_abc,表明 a=1,c=1 会使用联合索引。但因为缺少了 B 字段的条件,所以 MySQL 可能无法利用索引来直接定位到精确的行,而是使用索引来缩小搜索范围。
最终,MySQL 需要检查更多的行(rows: 3)来找到满足所有条件的结果集,但总体来说,使用索引明显比全表扫描要高效得多。
示例 2(b=1,c=1):
EXPLAIN SELECT * FROM tbn WHERE B=1 AND C=1\G
key 是 NULL,表明 b=1,c=1 不会使用联合索引。这是因为查询条件中涉及的字段 B 和 C 没有遵循之前定义的联合索引 idx_abc(A、B、C 顺序)的最左前缀原则。
在 idx_abc 索引中,A 是最左边的列,但是查询没有包含 A,因此 MySQL 无法利用这个索引。
示例 3(a=1,c=1,b=1):
EXPLAIN SELECT * FROM tbn WHERE A=1 AND C=1 AND B=1\G
key 是 idx_abc,表明 a=1,c=1,b=1 会使用联合索引。
并且 rows=1,因为查询条件包含了联合索引 idx_abc 中所有列的等值条件,并且条件的顺序与索引列的顺序相匹配,使得查询能够准确、快速地定位到目标数据。