b站一面,大概率挂
在字节KPI面,摄像头都不开之后,就对面试没什么期待了,但b站还是中规中矩的,只是我准备的不充分。
面试时间定在下午2点,时长一个小时。
面试提问
简单自我介绍,项目中细节追问,深挖了底层的技术实现,全程感觉都是在挖底层实现的细节,奈何我底层研究的不多。下面是进行记录+ai回答的标准答案,希望自己能记住吧。
1、项目中用到了@preAuthorize注解,底层原理是什么,为什么能够拦截请求进行验证。
@PreAuthorize是Spring Security提供的基于表达式的方法级安全控制机制。它的工作原理是基于Spring AOP,通过创建代理对象在方法调用前进行拦截。
核心处理流程是:当调用被@PreAuthorize注解的方法时,调用会被MethodSecurityInterceptor拦截,它会提取注解中的SpEL表达式,在特殊的评估上下文(MethodSecurityExpressionRoot)中执行这个表达式。
这个上下文提供了访问当前认证信息、方法参数和安全工具方法的能力。表达式执行结果为true时,允许方法调用;为false时,抛出AccessDeniedException。
2、mybatis中防止SQL注入的原理是什么?#{}和${}区别是什么
MyBatis 提供了两种参数占位符:
- #{} 占位符(推荐 ✅)
- ${} 占位符(危险 ❌,可能导致注入)
✅#{}
的原理
使用 #{}
时,MyBatis 会将参数传给 JDBC 的 PreparedStatement,内部使用 ? 占位符,并且通过 setXxx()
方法(如 setString
、setInt
)安全绑定参数。
例子:
<select id="findUser" resultType="User"> SELECT * FROM user WHERE name = #{name} </select>
执行过程大概是:
SELECT * FROM user WHERE name = ?
然后 JDBC 做参数绑定:
preparedStatement.setString(1, "Jack");
因为参数不会直接拼接到 SQL 字符串里,所以即便输入恶意 SQL 片段,也会被当成普通字符串,无法执行注入。
❌ ${}
的原理
${}
是 字符串替换,MyBatis 会把参数直接拼接到 SQL 中,效果类似:
SELECT * FROM user WHERE name = 'Jack'
如果传入 ' OR '1'='1
,就会产生注入风险。
因此 ${}
一般只用于 动态拼接表名、字段名 之类不能用 ?
占位的场景,而且要做好白名单校验。
3、MySQL中什么是事务,ACID四个特性分别是由什么底层能力来实现的?
1️⃣ 什么是事务
在 MySQL(尤其是 InnoDB 引擎)中,事务 (Transaction) 是数据库作为一个执行单元的操作集合,要么全部执行成功,要么全部执行失败(回滚),保证数据一致性。
2️⃣ 事务的 ACID 四大特性
事务有 ACID 四个特性:
🔹 1. 原子性(Atomicity)
- 定义:事务中的所有操作要么全部完成,要么全部不完成。
- 底层实现:通过 Undo Log(回滚日志) 实现。每次执行更新操作前,InnoDB 会在 Undo Log 中记录一条回滚记录。如果事务失败或回滚,可以通过 Undo Log 将数据恢复到执行前的状态。
🔹 2. 一致性(Consistency)
- 定义:事务执行前后,数据库必须保持一致性约束。比如转账,转账前后总金额不变。
- 底层实现:由 原子性 + 隔离性 + 持久性 共同保证,同时依赖 数据库约束(外键、唯一性约束、触发器等)。事务保证操作的正确性。约束保证数据不会出现非法状态。
🔹 3. 隔离性(Isolation)
- 定义:多个事务并发执行时,一个事务的中间状态对其他事务不可见。
- 底层实现:锁机制(行锁、表锁、意向锁):保证并发访问时的正确性。MVCC(多版本并发控制):通过 Undo Log + 隐藏字段(trx_id、roll_pointer) 生成数据快照,实现读已提交、可重复读等隔离级别。
🔹 4. 持久性(Durability)
- 定义:事务提交后,数据必须持久化,即使系统宕机也不会丢失。
- 底层实现:通过 Redo Log(重做日志) 实现。InnoDB 在写数据时,先把修改记录写入 Redo Log(顺序写,性能高),再异步刷新到磁盘数据页。如果宕机,重启时可通过 Redo Log 恢复已提交事务的数据。
4、索引是什么,底层是由什么数据结构实现的?在哪些场景会失效?
1️⃣ 什么是索引?
👉 索引(Index)就像书本的目录。
- 你要查「某个词」时,不用从头翻到尾,而是先看目录,快速定位页码。
- 在数据库中,索引就是一种 加速查询 的数据结构,可以极大提高数据检索速度。
常见的索引类型:
- 主键索引(聚簇索引):数据按主键存储在 B+Tree 的叶子节点中。
- 普通索引(非聚簇索引):叶子节点存储的是主键值,通过主键再去表里查数据(二次回表)。
- 唯一索引:不允许重复值。
- 组合索引:多个字段联合组成的索引。
2️⃣ 索引的底层数据结构
MySQL(InnoDB 引擎)索引主要用 B+ 树 实现。
🔹 为什么用 B+ 树?
- 二叉树:树高太高,大表需要很多次 IO。
- B 树:叶子节点和非叶子节点都存数据,范围查询效率低。
- B+ 树:所有数据都存在叶子节点,叶子节点形成一个有序链表 → 适合范围查询。非叶子节点只存键(目录),层级少,查询时磁盘 IO 次数更少。一个节点能存很多索引值(因为页大小固定 16KB)。
3️⃣ 索引失效的常见场景
即使建了索引,以下情况也可能失效,导致全表扫描:
- 对索引字段做计算或函数操作❌
- LIKE 以 % 开头
- OR 条件混用,部分字段无索引。【SELECT * FROM user WHERE id = 1 OR age = 20;】如果 id 有索引但 age 没索引,就会全表扫描。
- 组合索引未遵守最左前缀原则。假设组合索引 (a, b, c),查询必须从 a 开始,如果只查 b, c,索引可能失效。
- 数据分布不均(区分度低)比如 gender 只有 男/女,即使建索引,优化器可能判断全表扫描更快。
- 隐式类型转换这里 MySQL 会隐式转换成字符串,可能导致索引失效。
5、SQL调优有哪些思路?
1️⃣ SQL 语句层面的优化
就像写作文,结构合理,老师(优化器)才能快速理解。
- 避免 SELECT *👉 只取需要的字段,减少网络传输和 IO。
-- ❌ SELECT * FROM user; -- ✅ SELECT id, name, age FROM user;
- 减少子查询,尽量用 JOIN
-- ❌ 子查询嵌套,执行效率低 SELECT name FROM user WHERE id IN (SELECT user_id FROM order); -- ✅ 用 JOIN SELECT u.name FROM user u JOIN order o ON u.id = o.user_id;
- LIMIT 分页优化大偏移量分页会慢:✅ 优化:基于索引或 ID 过滤
SELECT * FROM order LIMIT 100000, 10; -- 会扫描前 100000 行❌ SELECT * FROM order WHERE id > 100000 LIMIT 10; -- ✅
- 避免在 WHERE 条件里对字段做函数/运算(会导致索引失效)
-- ❌ SELECT * FROM user WHERE YEAR(create_time) = 2024; -- ✅ SELECT * FROM user WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01';
2️⃣ 索引层面的优化
索引是 SQL 调优的核心,就像高速公路 vs 土路。
- 建立合适的索引
高频查询的字段 → 建单列索引
多条件查询 → 建联合索引(遵守最左前缀原则)
唯一值字段(如手机号、身份证) → 建唯一索引
- 覆盖索引(避免回表,提高查询速度)
-- 如果 user(id, name, age) 有联合索引 SELECT id, name FROM user WHERE age = 20; -- ✅ 可以直接从索引中获取数据,不用回表
- LIKE 以 % 开头
- 对索引字段做运算/函数
- 隐式类型转换
- OR 条件里有未建索引字段
- 数据区分度过低(如性别字段)
避免索引失效的场景
3️⃣ 数据库层面的优化
像是高速公路的“基础设施建设”。
- 读写分离主库负责写,从库负责读,提高并发能力。
- 分库分表单表数据量过大(>千万级),查询慢时,可以做垂直/水平拆分。
- 缓存(Redis)高频访问、实时性要求不高的数据可以先查缓存,减少数据库压力。
- SQL 执行计划分析(EXPLAIN)通过 EXPLAIN 查看 SQL 走没走索引,是否全表扫描,关键字段:type、rows、Extra。
- 适当的表设计字段选择合适的数据类型(如 INT 比 VARCHAR 高效)。避免过多的 JOIN,大表 JOIN 大表性能差。
6、哪些因素会导致线程不安全?如何解决?
1️⃣ 什么是线程不安全?
👉 线程不安全就是多个线程并发访问共享数据时,可能导致数据错误或状态异常。
2️⃣ 导致线程不安全的常见因素
🔹 1. 多线程对共享变量的写操作
- 如果多个线程同时修改一个变量,没有同步机制,就可能产生错误。
- 例子:多线程调用 increment() 时,count++ 实际包含三步(读、加一、写),可能导致丢失更新。
class Counter { private int count = 0; public void increment() { count++; // 非原子操作 } public int getCount() { return count; } }
多线程调用 increment()
时,count++
实际包含三步(读、加一、写),可能导致丢失更新。
- 一个线程修改了变量值,但另一个线程读到的还是旧值。
- 因为 CPU 有缓存,线程可能读的是自己缓存的数据。
- 比喻:就像两个人拿着同一本书的复印件,A 改了原件,但 B 手里的复印件没变。
- JVM 和 CPU 为了优化性能,会对指令执行顺序做调整。
- 如果没有同步机制,可能导致线程看到的执行顺序与代码不一致。
- 常见场景:单例模式双重检查锁(DCL),如果没有 volatile,可能导致对象还没初始化就被使用。
🔹 2. 内存可见性问题
🔹 3. 指令重排序
3️⃣ 如何解决线程不安全?
✅ 1. 加锁机制
- synchronized:保证代码块/方法在同一时刻只能有一个线程进入。
- ReentrantLock:更灵活的锁,可以实现公平锁、超时锁。
✅ 2. 使用原子类(CAS)或线程安全类
- AtomicInteger、AtomicBoolean 等,底层用 CAS(Compare And Swap) 保证原子性,性能比锁更高。
- concurrentHashMap、CopyOnWriteArrayList、等类内部有锁机制,考研保证原子性。
✅ 3. 使用 volatile
保证可见性
- 适用于“一个线程写,多个线程读”的场景。
- 比如 停止线程:
✅ 5. 无锁并发编程(减少共享)
- 尽量减少线程间共享数据,采用 线程本地变量(ThreadLocal)。
- 例子:每个线程都有自己独立的副本,不会互相干扰。
7、JVM是什么?类加载的顺序是怎样的?出现类加载冲突常是由什么原因导致的?
1. JVM 是什么
JVM(Java Virtual Machine,Java 虚拟机)是 Java 程序的运行环境,它屏蔽了底层操作系统和硬件的差异,使得 Java 可以做到“一次编写,到处运行”。
主要职责:
- 类加载(Class Loading):加载 .class 字节码文件。
- 字节码执行(Execution Engine):将字节码解释或 JIT 编译为机器码。
- 内存管理(Memory Management):包括堆、方法区、栈、本地方法栈、程序计数器等。
- 垃圾回收(GC):自动管理对象生命周期。
2.类加载顺序
类加载顺序为“加盐准析出”即 加载→验证→准备→解析→初始化
1、加载:JVM根据类的权限定义命找到.class文件生成二进制字节流,将该类的字段、方法、父类存入方法区生成运行时数据结构,在堆区生成代表该类的.class对象。
2、验证:对于文件格式、字节码、是否继承final等方面进行验证。
3、准备:对于静态变量分配内存,并且赋初始值。
4、解析:将类中的符号引用改为直接引用,指向具体的地址。
5、初始化:对于代码中涉及的初始化值进行赋值,
3. 类加载器(ClassLoader)机制
- 启动类加载器(Bootstrap)加载 JDK 核心类(rt.jar)。
- 扩展类加载器(Extension)加载 JDK 扩展目录下的类。
- 应用类加载器(App)加载用户自定义的类(classpath 下的类)。
- 自定义类加载器程序员可实现 ClassLoader,打破默认的加载逻辑。
👉 采用 双亲委派模型:
类加载器在加载类时,会先把请求交给父类加载器,如果父类加载失败才会自己尝试加载,防止核心类被篡改。
4. 类加载冲突的原因
类加载冲突常见于:
- 多版本 JAR 包冲突(最常见),比如项目依赖了两个版本的 log4j,不同版本中的同一个类可能冲突。
- 不同类加载器加载了相同的类,JVM 认为“类的唯一性 = 类的全限定名 + 加载它的类加载器”。即使两个类的字节码完全相同,如果由不同的类加载器加载,JVM 也认为它们是不同的类。这就是 ClassCastException 的根本原因之一。
- 打破双亲委派,有些框架(如 Tomcat、OSGi、Spring Boot)为了模块化或插件化,使用自定义类加载器,不同模块之间可能出现冲突。
类加载器在加载类时,会先把请求交给父类加载器,如果父类加载失败才会自己尝试加载,防止核心类被篡改。
8、创建线程池的参数有哪些?分别是什么含义?随着任务数量增加,线程池内的线程数目是怎么变化的?阻塞队列情况如何变化?
1、线程池参数
public ThreadPoolExecutor( int corePoolSize, // 核心线程数 int maximumPoolSize, // 最大线程数 long keepAliveTime, // 空闲线程存活时间 TimeUnit unit, // 存活时间单位 BlockingQueue<Runnable> workQueue, // 任务阻塞队列 ThreadFactory threadFactory, // 线程工厂(一般用于命名线程) RejectedExecutionHandler handler // 拒绝策略 )
线程池是 Java 并发编程中的重要工具,它通过复用线程来减少线程创建和销毁的开销,从而提高系统的性能和稳定性。在 Java 中,线程池的核心实现类是 ThreadPoolExecutor,它提供了七个重要的参数来配置线程池的行为。接下来我会详细讲述这七大参数的定义、作用以及它们如何影响线程池的工作机制。
第一个是核心线程数(corePoolSize),它是指线程池中始终保持存活的线程数量,即使这些线程处于空闲状态。
当提交一个新任务时,如果当前线程数小于核心线程数,线程池会优先创建新线程来处理任务,而不是将任务放入队列。例如,设置 corePoolSize=5 表示线程池会始终维护至少 5 个线程。
第二个是最大线程数(maximumPoolSize),它是指线程池中允许的最大线程数量。当任务队列已满且当前线程数小于最大线程数时,线程池会继续创建新线程来处理任务。如果线程数已经达到最大值,则任务会被拒绝。例如,设置 maximumPoolSize=10 表示线程池最多可以创建 10 个线程。
第三个是线程空闲时间(keepAliveTime),它是指非核心线程在空闲状态下保持存活的时间。当线程池中的线程数超过核心线程数时,多余的空闲线程会在指定的空闲时间后被回收。例如,设置 keepAliveTime=60 表示非核心线程在空闲 60 秒后会被销毁。
第四个是时间单位(unit),它用于指定线程空闲时间的计量单位。常见的单位包括 TimeUnit.SECONDS(秒)、TimeUnit.MILLISECONDS(毫秒)等。例如,unit=TimeUnit.SECONDS 表示空闲时间以秒为单位。
第五个是任务队列(workQueue),它是一个阻塞队列,用于存放等待执行的任务。当线程池中的线程数达到核心线程数时,新提交的任务会被放入任务队列中等待执行。常见的队列类型包括:
ArrayBlockingQueue:有界队列,适用于控制资源使用。
LinkedBlockingQueue:无界队列,适用于任务量较大的场景。
SynchronousQueue:不存储任务的队列,适用于直接传递任务给线程的场景。
第六个是线程工厂(threadFactory),它用于创建线程池中的线程。通过自定义线程工厂,可以为线程设置名称、优先级或其他属性,便于调试和管理。例如,使用 Executors.defaultThreadFactory() 创建默认线程工厂。
第七个是拒绝策略(handler),它用于处理当线程池无法接受新任务时的情况(例如线程数达到最大值且任务队列已满)。常见的拒绝策略包括:
AbortPolicy:抛出异常,拒绝任务。
CallerRunsPolicy:由调用线程执行任务。
DiscardPolicy:直接丢弃任务。
DiscardOldestPolicy:丢弃队列中最旧的任务,并尝试重新提交新任务。
2. 线程池中线程数和队列的变化过程
假设不断提交任务,线程池的行为如下(重点!):
- 任务数 ≤ corePoolSize直接创建新线程执行任务(直到线程数达到 corePoolSize)。此时 阻塞队列为空。
- 任务数 > corePoolSize,队列未满不会再创建新线程,而是把任务放入 阻塞队列 等待执行。
- 任务数 > corePoolSize 且 队列已满创建新的线程执行任务,直到线程数达到 maximumPoolSize。
- 任务数 > maximumPoolSize 且 队列已满执行 拒绝策略。
9、对于多线程任务,假设有线程池,核心线程数目为100,每个task用1秒处理完成,请问持续20秒,每秒提交1个task,线程池内线程的数目是如何变化的?
分析:
- 第 1 秒提交第一个任务,线程池还没有线程。线程池会创建一个新线程执行任务。活跃线程数 = 1。
- 第 2 秒提交第 2 个任务,前一个任务已执行完毕。线程池会复用刚才的线程(空闲线程不会销毁,因为线程池会保留核心线程)。活跃线程数 = 1。
- 第 3 秒 ~ 第 20 秒每秒只提交 1 个任务,速率是 1/s。每个线程处理时间 = 1s,能刚好跟上任务速率。所以始终只需要 1 个线程 就能处理所有任务。
结论:
- 在 整个 20 秒内,线程池最多只会创建 1 个线程,并反复复用它。
- 虽然 corePoolSize = 100,但由于任务速率太低,线程池不会预先创建 100 个线程(默认情况下,核心线程是懒加载的,即有任务才创建)。
- 因此:线程池线程数变化:从 0 → 1 → 一直保持 1。队列变化:一直为空,因为线程能实时消费任务。
✅ 关键点总结:
- 线程池不会一下子创建 corePoolSize 个线程,而是根据需要逐个创建(除非你调用 prestartAllCoreThreads() 提前启动)。
- 线程数目取决于 任务提交速率 和 任务执行时长。
- 在这个例子里,因为 提交速率 ≤ 单线程消费速率,所以线程池只需要 1 个线程即可。
10、泛泛而谈的问题
给你一个需求,你会怎么去实现?
如何学习新的知识点?
看书学习和看视频学习有什么差异?
对于论坛经验贴如何辨识正确性?
你喜欢看源码吗?
算法题
对于一个链表,就地反转其中的第m~n个节点
思路很简单,找到第m个节点的前驱和第n个节点的后继,用头部插入法进行逆转。
总结
自己对于底层的业务逻辑还是太生疏了,应该认真钻研,而不是仓促去面试,之前的面试错过了一个好面试官,当前这个也没把握住,还是慢一点比较好。
同时找实习的同学陆陆续续有了新的offer,自己留在原地难免落魄,毕竟每个人的状态基础是不一样的,不应该和别人去比较。
我看到双9✌学历优秀,但被导师困在原地,
我看到科大前舍友速通企鹅,但入职2个月项目组被裁,需要再找实习,
我看到研一就在美团的舍友,但9月想换新厂没约面,(现在已快手oc)
我看到2段大厂的学长,但秋招无offer……
人人都有自己的剧本,都有各种意难平,比昨天好一点就行,不必强行催促自己,
毕竟在学校的日子,过一天少一天。