(高频问题)301-320 计算机 Java后端 实习 and 秋招 面试高频问题汇总
300. Java 15 中偏向锁废弃的考量:性能与适用场景
Java 15 决定废弃偏向锁,主要基于以下两方面考量。
首先,在多线程竞争激烈的场景下,偏向锁的撤销成本较高。当一个线程获取了偏向锁后,若其他线程尝试获取该锁,JVM 必须执行撤销偏向锁并升级为轻量级锁的操作。此过程涉及线程暂停、锁状态修改及恢复等,这些操作的开销在频繁发生时,甚至可能超过直接使用轻量级锁。因此,高并发下的频繁撤销会成为性能瓶颈,导致系统整体性能下降。
其次,偏向锁的设计初衷是优化单线程频繁锁重入的场景,然而现代应用程序普遍为多线程设计。在多个线程交替访问同一资源的情况下,偏向锁会被频繁撤销和重新偏向,无法发挥其优化作用,反而因额外的撤销开销导致性能下降。考虑到现代应用的多线程特性,偏向锁的适用性大打折扣,JVM 在检测到频繁竞争时,锁会快速升级,使得偏向锁的优势不再明显。
301. Redis 分布式锁的可重入性实现:关键要素与实践
标准的 Redis 分布式锁实现,例如通过 SETNX
(Set if Not eXists) 和 EXPIRE
命令,本身不直接支持可重入性。这意味着同一线程若在持有锁后再次尝试获取相同的锁,会因 SETNX
返回失败而无法成功,这与可重入锁允许同一线程多次获取的特性相悖。为实现可重入,核心在于追踪锁的当前持有者以及该持有者重入的次数。
实现可重入分布式锁的关键在于引入两个机制:唯一的线程(或请求)标识符和重入计数器。当一个线程请求锁时:如果锁不存在,则尝试获取锁,并记录其标识符,同时将重入计数器初始化为1;如果锁已存在,则检查锁的持有者标识符是否与当前请求线程的标识符一致。若一致,则说明是同一线程的重入请求,此时递增重入计数器。释放锁时,同样需要检查标识符,只有持有锁的线程才能释放。释放操作会递减计数器,仅当计数器归零时,才真正从 Redis 中删除该锁记录。
具体实现时,常将锁的持有者标识和重入次数存储在 Redis 的哈希(Hash)数据结构中,例如使用 HSET
命令将锁的键(lock_key)关联一个包含 owner
字段(存储线程唯一标识符)和 count
字段(存储重入次数)的哈希对象。每次成功获取锁(无论是首次还是重入)都必须刷新锁的过期时间,以防止因业务执行时间过长导致锁提前释放而引发问题。为保证这些复合操作的原子性,强烈建议将获取锁和释放锁的逻辑封装在 Lua 脚本中执行,因为 Redis 服务器能以原子方式执行整个 Lua 脚本,避免了并发场景下的竞态条件。
以下是一个获取锁的 Lua 脚本示例思路:
-- KEYS[1]: 锁的键名 (e.g., "lock_key") -- ARGV[1]: 当前请求线程的唯一标识符 (e.g., "thread_id_1") -- ARGV[2]: 锁的过期时间(毫秒) local lock_key = KEYS[1] local lock_value_owner = ARGV[1] local lock_expire_ms = ARGV[2] if redis.call("EXISTS", lock_key) == 0 then -- 锁不存在,创建锁,设置持有者和计数 redis.call("HSET", lock_key, "owner", lock_value_owner) redis.call("HSET", lock_key, "count", 1) redis.call("PEXPIRE", lock_key, lock_expire_ms) return 1 -- 获取成功 elseif redis.call("HGET", lock_key, "owner") == lock_value_owner then -- 锁已存在且被当前线程持有,增加重入计数 redis.call("HINCRBY", lock_key, "count", 1) redis.call("PEXPIRE", lock_key, lock_expire_ms) -- 刷新过期时间 return 1 -- 获取成功 else -- 锁被其他线程持有 return 0 -- 获取失败 end
释放锁的 Lua 脚本也应遵循类似的原子性检查和计数器递减逻辑。
302. 利用 Java 多线程与 AtomicInteger 实现高效并发累加
为加速大规模数据集的求和操作,可以采用多线程并行计算。其核心思想是将原始数据集分割成多个子任务(数据段),分配给不同的线程独立计算各自数据段的部分和,最后将这些部分和安全地汇总得到最终结果。
以下 Java 代码示例展示了如何实现这一过程。程序首先将一个大数组分成若干块,每块交由一个线程处理。每个线程计算其负责数据块的和,然后将该部分和累加到一个全局的 totalSum
变量中。为确保多线程环境下对 totalSum
更新的线程安全性,代码中使用了 java.util.concurrent.atomic.AtomicInteger
。AtomicInteger
提供了原子性的更新操作(如 addAndGet
),避免了在使用普通 int
类型并进行 +=
操作时可能引发的竞态条件,确保了结果的正确性。主线程在启动所有工作线程后,会调用 join()
方法等待它们全部执行完毕,最终输出累加得到的总和。
import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; public class MultiThreadedSumLambda { // 定义全局原子整型变量来存储最终的总和 private static AtomicInteger totalSum = new AtomicInteger(0); public static void main(String[] args) throws InterruptedException { // 创建一个大数组 int[] array = new int[1000000]; for (int i = 0; i < array.length; i++) { array[i] = i + 1; // 初始化数组元素 } // 定义线程数量 int numberOfThreads = 4; List<Thread> threads = new ArrayList<>(); // 将任务分配给多个线程 int chunkSize = array.length / numberOfThreads; for (int i = 0; i < numberOfThreads; i++) { // Lambda表达式中使用的局部变量必须是final或effectively final final int start = i * chunkSize; final int end = (i == numberOfThreads - 1) ? array.length : start + chunkSize; Thread thread = new Thread(() -> { int localSum = 0; for (int j = start; j < end; j++) { localSum += array[j]; } // 使用AtomicInteger的addAndGet方法原子性地累加线程的部分结果 totalSum.addAndGet(localSum); }); threads.add(thread); thread.start(); } // 等待所有线程完成任务 for (Thread thread : threads) { thread.join(); } // 输出总和 System.out.println("Array sum: " + totalSum.get()); } }
在 Lambda 表达式中捕获的外部局部变量(如 start
和 end
)必须是 final
或实际上是 final
的(即这些变量在初始化后其值不再被修改)。
303. MySQL UPDATE 语句的内部执行流程与核心日志机制
当客户端向 MySQL 服务器发送一条 UPDATE
语句后,其内部会经历一系列严谨的处理步骤以确保操作的正确性和数据的持久性。
语句首先抵达连接器进行权限验证和连接管理。随后,若查询缓存开启且命中(尽管 UPDATE 通常不会直接命中查询缓存,但了解其位置有益),则可能跳过后续步骤。接着,语句会经过**解析器(Parser)**进行词法分析和语法分析,生成语法树,并校验SQL的合规性。之后,**优化器(Optimizer)**介入,它会根据统计信息和规则(例如,评估不同索引的访问成本、决定是否需要表连接以及连接顺序等)生成一个或多个可能的执行计划,并从中选择一个最优的执行计划。
在正式执行前,系统还会进行权限校验,确认用户是否具备对目标表和列的 UPDATE
权限。若无权限,操作将被拒绝。通过校验后,执行器(Executor)依据优化器生成的执行计划开始操作。它会调用存储引擎的接口来执行更新。对于 InnoDB 存储引擎,这通常涉及:首先对需要更新的记录加行级锁以防止并发修改冲突;然后根据 WHERE
子句通过全表扫描或索引扫描定位到待更新的记录;最后根据 SET
子句修改记录中的值。
在数据修改过程中及事务提交时,MySQL 会利用多种日志来保证数据的一致性、持久性和可恢复性:
- 重做日志(Redo Log):这是 InnoDB 存储引擎特有的物理日志。它采用 Write-Ahead Logging (WAL) 机制,在数据页的变更实际写入磁盘前,相关的修改操作(而非数据本身)会先被记录到重做日志中。这确保了即使在数据库崩溃时,也能通过重做日志恢复已提交事务的修改,保证事务的持久性。
- 撤销日志(Undo Log):同样是 InnoDB 特有的逻辑日志,记录了数据被修改前的状态。它主要用于事务回滚(当事务需要取消时,可以根据 Undo Log 将数据恢复到修改前的版本)和实现多版本并发控制(MVCC),使得读操作可以不阻塞写操作。
- 二进制日志(Binlog):这是 MySQL Server 层的逻辑日志,记录了所有对数据库执行更改的SQL语句(或行的变更事件)。Binlog 在事务提交后写入,主要用于数据复制(主从同步)和基于时间点的恢复(Point-in-Time Recovery)。
304. InnoDB 存储引擎数据持久化:从缓冲池到磁盘的刷盘机制
MySQL 的 InnoDB 存储引擎在处理数据时,并不会立即将所有修改直接写入磁盘,而是采用了一套涉及内存缓冲和日志的复杂机制来平衡I/O性能与数据持久性。
核心组件是缓冲池(Buffer Pool),这是一块内存区域,用于缓存从磁盘读取的数据页(包括表数据和索引页)。当数据在缓冲池中被修改后,该数据页即被标记为“脏页”(Dirty Page),表示其内存中的内容与磁盘上的数据不再同步。而未被修改或已同步到磁盘的页称为“清洁页”(Clean Page)。
在数据页发生变更时,InnoDB 会遵循 Write-Ahead Logging (WAL) 原则。这意味着在脏页被刷新到磁盘之前,对这些数据页的修改操作必须首先被记录到**重做日志(Redo Log)**中,并且重做日志本身也需要持久化到磁盘。Redo Log 是顺序写入的,开销较小,它保证了即使系统在脏页刷盘前崩溃,在重启后也能通过重做日志恢复已提交事务的修改,确保数据不丢失。
InnoDB 会在特定条件下将缓冲池中的脏页刷新到磁盘,这一过程称为刷盘(Flush)。触发刷盘的主要时机包括:
- 缓冲池空间不足:当缓冲池中的脏页过多,导致可用空间不足以缓存新的数据页时,会触发刷盘以释放空间。
- 重做日志写满:虽然Redo Log是循环使用的,但当其快要写满时,也需要强制将一些脏页刷盘,以便对应的Redo Log空间可以被重用。
- 后台线程定时刷盘:InnoDB 有专门的后台线程(如Master Thread)会周期性地将一定量的脏页异步刷新到磁盘,以平滑I/O负载,避免集中刷盘导致性能抖动。
- 数据库正常关闭:在 MySQL 服务器正常关闭时,会执行一个checkpoint,确保所有缓冲池中的脏页都被刷新到磁盘,保证数据的一致性。
- 事务提交时,根据
innodb_flush_log_at_trx_commit
参数的设置,可能会触发 Redo Log 的刷盘,但不一定会立即触发数据页的刷盘。
305. Redis 键过期策略:惰性删除与定期删除机制
Redis 采用多种策略来处理设置了过期时间的键(keys with an expiry time set),以确保过期数据能够被及时清理,从而有效管理内存资源。
主要的过期删除策略有两种:惰性删除(Lazy Deletion)和定期删除(Periodic Deletion)。
惰性删除是指当客户端尝试访问一个设置了过期时间的键时,Redis 会首先检查该键是否已经过期。如果判断键已过期,Redis 会立即执行删除操作,并将该键从内存中移除,然后向客户端返回空结果(或相应的未找到提示)。这种方式的优点是对CPU友好,只在访问时才进行检查和删除;缺点是如果一个键设置了过期时间但之后再也没有被访问,它可能会一直占据内存,直到某个其他机制将其清除。
定期删除则是 Redis 后台任务的一部分,用于弥补惰性删除的不足。Redis 服务器会按照预设的频率(默认为每秒10次,可配置 hz
参数影响),周期性地、随机地从设置了过期时间的键空间中抽取一部分键进行检查。如果发现这些被抽查到的键已过期,则将它们删除。这个过程是随机且分批进行的,旨在通过控制检查的键数量和执行时长,来平衡过期键删除的及时性和对 Redis正常服务性能的影响,避免长时间阻塞。
除了这两种主动的过期删除策略外,当 Redis 的内存使用达到 maxmemory
配置的上限时,会触发内存淘汰(Eviction)策略。根据所选的淘汰策略(例如 volatile-lru
:从设置了过期时间的键中使用LRU算法淘汰;allkeys-lru
:从所有键中使用LRU算法淘汰;volatile-ttl
:从设置了过期时间的键中选择剩余生存时间最短的进行淘汰等),Redis 会移除一些键来释放空间。虽然内存淘汰机制的主要目标是控制内存上限,但当配置了针对过期键的淘汰策略时,它也会间接加速已过期键的清理。
306. 海量 IP 地址数据中快速定位指定 IP 的高效策略
面对百万级别的 IP 地址段数据,要实现对特定 IP 地址归属地的快速查询,核心在于预处理数据并采用高效的查找算法。给定的数据格式为 IP 地址范围及其对应的地理位置信息。
首先,需要将点分十进制的 IP 地址转换为长整型(Long)数字,以便进行数值比较和排序。IPv4 地址本质上是一个32位的无符号整数,可以将其四个八位字节看作一个整体进行转换。例如,A.B.C.D
可以转换为 A * 2^24 + B * 2^16 + C * 2^8 + D
。
完成转换后,将每一条 IP 地址段数据(包含起始 IP 的长整型、终止 IP 的长整型以及地理位置信息)存储为一个对象。然后,将这些 IP 地址段对象构成的列表(或数组)按照起始 IP 的长整型值进行升序排序。这一步是至关重要的,因为有序的数据结构是后续高效查找的基础。
在查询特定 IP 地址时,同样先将其转换为长整型。由于 IP 地址段数据已经按照起始 IP 有序,并且每个 IP 地址段覆盖了一个连续的 IP 范围,我们可以利用二分查找算法来快速定位目标 IP 地址所在的 IP 地址段。具体而言,二分查找会比较目标 IP 与数据集中间 IP 段的起始 IP 和终止 IP。
- 如果目标 IP 小于中间 IP 段的起始 IP,则在左半部分继续查找。
- 如果目标 IP 大于中间 IP 段的终止 IP(注意,这里的逻辑与原始文本略有调整,更精确的应该是比较目标IP与IP段的起始IP来决定向左还是向右,然后判断目标IP是否落在当前段内),则在右半部分继续查找。
- 如果目标 IP 落在当前 IP 段的起始 IP 和终止 IP 之间(包含边界),则找到了对应的 IP 地址段。
通过这种方式,可以将查找的时间复杂度从线性扫描的 O(N) 降低到二分查找的 O(log N),对于100万条数据,查询效率极高,通常在毫秒级别。
Java 实现的关键步骤包括:
- 定义一个
IPBean
类来存储每个 IP 地址段的信息(起始IP长整型、终止IP长整型、国家、省份、城市等)。 - 提供一个
ipToLong
方法将点分十进制 IP 字符串转换为长整型。 - 在程序初始化时,读取 IP 地址数据文件,将每行数据解析并转换为
IPBean
对象,存入一个列表。 - 使用
Collections.sort()
或类似方法,根据IPBean
对象的起始 IP 长整型值对列表进行排序,然后可以转换为数组以优化后续访问性能。 - 实现一个
getIPByHalf
(或类似名称的) 方法,该方法接收一个待查询的 IP 字符串,将其转换为长整型,然后在已排序的IPBean
数组上执行二分查找,返回匹配的IPBean
对象。二分查找的循环条件和中间值计算需要精确,确保覆盖所有情况并避免整数溢出(如使用mid = low + ((high - low) >>> 1)
)。
// IPBean 类定义 (已提供,略) class IPUtil { // ipToLong 和 longToIP 方法 (已提供,略) /** * 根据IP地址(长整型)在已排序的IP段数组中通过二分查找确定其归属。 * @param ipLong 待查询IP的长整型表示 * @param ipBeans 已按起始IP排序的IP段数组 * @return 匹配的IPBean对象,未找到则返回null */ public static IPBean findIpLocation(long ipLong, IPBean[] ipBeans) { if (ipBeans == null || ipBeans.length == 0) { return nu
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
曾获多国内大厂的 ssp 秋招 offer,且是Java5年的沉淀老兵(不是)。专注后端高频面试与八股知识点,内容系统详实,覆盖约 30 万字面试真题解析、近 400 个热点问题(包含大量场景题),60 万字后端核心知识(含计网、操作系统、数据库、性能调优等)。同时提供简历优化、HR 问题应对、自我介绍等通用能力。考虑到历史格式混乱、质量较低、也在本地积累了大量资料,故准备从头重构专栏全部内容