(高频问题)61-80 计算机 Java后端 实习 and 秋招 面试高频问题汇总
61.HTTPS 在防御重放攻击中的作用与局限性
HTTPS 通过 SSL/TLS 协议为 HTTP 通信提供了强大的传输层安全保障,确保了数据的机密性(通过加密)和完整性(通过消息认证码)。然而,HTTPS 协议本身并不能直接防御重放攻击。重放攻击指的是攻击者截获合法的通信报文,并在稍后重新发送这些报文,试图欺骗接收方(通常是服务器)执行重复或非预期的操作。即使报文是加密的,其重放仍然可能导致问题。
要有效抵御重放攻击,需要在 HTTPS 传输层之上或协议内部(如 TLS 握手)实现额外的应用层或协议层机制。常见的策略包括:
- 时间戳(Timestamping): 在请求中加入当前时间戳,服务器校验时间戳是否在可接受的短暂窗口内,过时或未来的时间戳将被拒绝。
- 序列号(Sequence Numbers)或 Nonce(一次性随机数): 为每个请求或会话分配唯一的、递增的序列号或不可预测的随机数。服务器记录已处理的序列号/Nonce,拒绝重复的请求。TLS 协议的握手过程就内建了使用随机数来防止握手阶段的重放。
- 一次性令牌(One-time Tokens): 服务器为特定操作生成一次性使用的令牌,客户端在请求时必须携带此令牌,使用后即失效。
因此,虽然 HTTPS 提供了安全的通信通道,但防止重放攻击需要依赖于在通信协议或应用程序逻辑中实施上述策略。现代安全实践,如 OAuth 等授权框架,也通常包含防止重放的机制。
62.Java List 移除元素后的索引与元素位移机制
在 Java 中,当从 List(特别是像 ArrayList 这样基于数组实现的 List)中移除一个元素时,其后的所有元素确实会向前移动以填补被移除元素留下的位置。这个过程确保了列表内部存储的连续性,并会更新受影响元素的索引。
具体来说,如果移除了索引为 i 的元素,那么原来索引为 i+1 的元素会移动到索引 i,原来索引为 i+2 的元素会移动到索引 i+1,以此类推,直到列表的末尾。这个操作的成本与列表中被移动元素的数量成正比。例如,从包含 [A, B, C, D] 的 ArrayList 中移除第一个元素 A(索引 0),列表会变为 [B, C, D],其中 B 的索引变为 0,C 的索引变为 1,D 的索引变为 2。需要注意的是,对于基于链表实现的 List(如 LinkedList),移除元素主要是指针操作,不涉及大规模的元素物理移动,但索引逻辑仍然需要调整。
63.理解 Java HashMap 的初始化容量、负载因子与扩容机制
在 Java 中执行 HashMap map = new HashMap(50); 这行代码时,是在创建一个 HashMap 实例并指定其初始容量。这个操作本身并不会触发任何扩容。HashMap 内部会根据你提供的初始容量(50)来确定一个实际的内部数组大小。为了优化哈希计算,这个内部数组的大小总是会被调整为大于或等于指定初始容量的、最接近的 2 的次幂。因此,对于 new HashMap(50),实际的初始容量会被设置为 64。
HashMap 的扩容操作发生在其存储的键值对数量(size)超过了一个特定的**阈值(threshold)时。这个阈值由当前容量(capacity)乘以负载因子(load factor)**计算得出,即 threshold = capacity * loadFactor。HashMap 的默认负载因子是 0.75,这是一个在空间利用率和查找时间之间取得平衡的经验值。
因此,对于一个初始容量为 32 的 HashMap,其默认阈值是 32 * 0.75 = 24。当你要插入第 25 个元素时(即 size 从 24 变为 25),就会触发扩容。扩容是一个相对耗时的操作,它会创建一个新的、通常是原容量两倍的内部数组,并重新计算所有现有元素的哈希值,将它们放入新数组的适当位置(rehashing)。为了避免频繁扩容带来的性能开销,如果能预估存储元素的数量,建议在创建 HashMap 时指定一个足够大的初始容量。
64.识别无法保证每趟排序至少确定一个元素最终位置的排序算法
并非所有排序算法都能保证在每一趟处理后,至少将一个元素放置到其最终排序后的正确位置。典型的例子包括希尔排序(Shell Sort)和插入排序(Insertion Sort)。
希尔排序通过将列表按特定间隔(gap)分组,并对每个子组进行插入排序。在初始的几趟排序中,间隔较大,元素在其子组内有序并不意味着它在整个列表中的位置是最终的。随着间隔逐渐缩小,排序逐渐趋于全局有序,但早期阶段的排序无法保证任何元素已达到其最终位置。
插入排序在每一趟中将当前元素插入到其左侧已排序序列的合适位置。虽然保证了当前处理元素在其左侧子序列中的相对顺序是正确的,但这并不意味着该位置是它在整个列表完全排序后的最终位置。后续元素的插入可能会导致已排序部分的元素再次移动。相比之下,像选择排序(每趟找到最小/大元素放到起始/末尾)或冒泡排序(每趟将最大/小元素“冒泡”到末尾/起始)则能保证每趟至少确定一个元素的最终位置。
65.阻塞与非阻塞网络 I/O 的差异及适用场景分析
阻塞(Blocking)I/O 和非阻塞(Non-blocking)I/O 是网络编程中处理数据交换的两种核心模式,它们的主要区别在于应用程序在等待 I/O 操作完成时的行为。
阻塞 I/O 模式下,当应用程序发起一个 I/O 操作(如 read 或 write)时,如果数据尚未准备好或无法立即完成,应用程序的执行线程会被挂起(阻塞),直到操作成功完成或出错。在此期间,该线程无法执行其他任务。这种模式编程模型相对简单直接,适用于并发连接数不高、或者可以通过多线程/多进程模型为每个连接分配独立处理单元的应用场景,这样单个连接的阻塞不会影响其他连接的处理。
非阻塞 I/O 模式下,应用程序发起 I/O 操作时,调用会立即返回,而不会使线程挂起。如果操作因数据未就绪等原因无法立即完成,系统会返回一个特定状态(如错误码 EWOULDBLOCK 或 EAGAIN)。应用程序需要通过轮询或结合事件通知机制(如 select, poll, epoll, kqueue)来检查 I/O 操作是否就绪,并在就绪时再次尝试操作。这种模式允许单个线程管理多个 I/O 通道,极大地提高了系统资源的利用率,特别适用于需要处理大量并发连接的高性能服务器(如 Web 服务器、消息队列、数据库代理),以及需要保持高响应性的实时应用(如游戏服务器、在线交易系统)等 I/O 密集型场景。
总的来说,阻塞 I/O 以简单性换取了潜在的性能瓶颈,而非阻塞 I/O 通过更复杂的编程模型实现了更高的并发处理能力和系统效率。现代高性能网络应用普遍采用非阻塞 I/O 结合事件驱动的架构。
66.使用 wait()/notifyAll() 实现 Java 线程按序执行
在多线程编程中,有时需要控制线程的执行顺序,例如确保线程 T1 执行完毕后 T2 再执行,T2 执行完毕后 T3 再执行。Java 的 wait(), notify(), 和 notifyAll() 方法,结合 synchronized 关键字,提供了一种实现这种顺序控制的机制。
该方法的核心是利用一个共享对象锁(lock)和一个共享状态变量(例如 turn)来协调线程。每个线程在执行其核心逻辑前,首先获取共享对象的锁。然后,它检查共享状态变量是否指示轮到自己执行。如果不是,线程调用 lock.wait(),暂时释放锁并进入等待状态。如果是自己的回合,线程执行其任务,然后更新共享状态变量(例如 turn++),以指示下一个线程可以执行。最后,该线程调用 lock.notifyAll() 来唤醒所有在该锁上等待的其他线程。被唤醒的线程会重新尝试获取锁,并再次检查执行条件。
下面是使用 wait()/notifyAll() 实现三个线程按 T1->T2->T3 顺序执行的示例代码:
public class SequentialExecution { private static final Object lock = new Object(); private static int turn = 1; // 控制哪个线程该执行 public static void main(String[] args) { new Thread(new Task(1), "T1").start(); new Thread(new Task(2), "T2").start(); new Thread(new Task(3), "T3").start(); } static class Task implements Runnable { private final int threadId; public Task(int threadId) { this.threadId = threadId; } @Override public void run() { synchronized (lock) { try { // 如果不是当前线程的回合,则等待 while (turn != this.threadId) { lock.wait(); } // 执行任务 System.out.println(Thread.currentThread().getName() + " is running"); // 更新状态,轮到下一个线程 turn++; // 唤醒所有等待的线程,让它们检查条件 lock.notifyAll(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 重置中断状态 System.err.println("Thread interrupted: " + e.getMessage()); } } } } }
这种方法虽然能实现顺序控制,但在复杂场景下可能涉及较多的锁竞争和上下文切换。Java 并发包(java.util.concurrent)提供了如 CountDownLatch, CyclicBarrier, 或 Semaphore 等更高级的同步工具,以及 ExecutorService 结合 Future 等方式,可以更灵活或高效地实现线程间的协调。
67.Kafka 与 RabbitMQ 的消息持久化与可靠性保障机制
确保消息在传输和处理过程中不丢失是消息队列系统(如 Kafka 和 RabbitMQ)的核心能力之一。两者都提供了一系列机制来保障消息的可靠性。
Apache Kafka 通过多方面设计确保消息持久性:首先是副本机制(Replication)。每个主题分区可以配置多个副本,分布在不同的 Broker 上。其中一个副本作为领导者(Leader)处理所有读写请求,其他副本作为追随者(Follower)同步数据。若领导者失效,一个同步的追随者会被选举为新领导者。通过配置 min.insync.replicas 参数,可以要求至少有多少个同步副本确认写入后,消息才被视为“已提交”,显著提高容错性。其次是生产者确认机制(Acknowledgements, acks)。生产者发送消息时可配置 acks 参数:acks=0(不等待确认,性能高但易丢失),acks=1(默认,等待 Leader 确认,Leader 宕机可能丢失),acks=all 或 -1(等待所有 ISR 确认,最可靠但性能稍低)。此外,Kafka 将消息持久化存储在磁盘上,并通过事务(Transactions)支持提供精确一次处理语义(EOS)。对于消费者端,通过精确管理消费偏移量(Offset),确保消费者在故障恢复后能从正确的位置继续消费,避免消息丢失(需注意提交时机,通常在消息处理成功后提交)。
RabbitMQ 同样提供了多种保障消息不丢失的策略:核心机制是消息确认。**消费者确认(Consumer Acknowledgements)**要求消费者在成功处理消息后显式发送 ack 给 RabbitMQ,之后 RabbitMQ 才将消息标记为删除。若消费者在处理中失败且未发送 ack,消息会重新投递。**发布确认(Publisher Confirms)则让生产者知道消息是否已成功到达 RabbitMQ 服务器(交换机)。 持久化(Persistence)是另一关键。需要同时将队列声明为持久化(durable)并在发送消息时将其标记为持久化(delivery mode = 2),这样即使 RabbitMQ 重启,队列结构和消息内容都能恢复。 为处理无法路由或处理失败的消息,RabbitMQ 支持备份交换机(Alternate Exchange)用于接收无法路由的消息,以及死信队列(Dead Letter Exchange/Queue)**用于接收被拒绝、过期或队列溢出的消息,防止这些消息直接丢失。
68.Kafka 如何在多消费者环境下保证分区内消息的消费顺序
Apache Kafka 的一个核心设计原则是保证单个分区(Partition)内的消息是有序的。即使在存在多个消费者的场景下,这一分区内的顺序性也能得到保障,主要原因如下:
首先,消息在分区内是按序存储的。生产者发送到特定分区的消息会被严格按照发送顺序追加到该分区日志(Log)的末尾。每条消息在分区内都会获得一个唯一的、单调递增的偏移量(Offset)。
其次,分区与消费者的分配机制是关键。Kafka 引入了消费者组(Consumer Group)的概念。对于订阅了同一个主题的消费者组,主题中的每个分区在同一时刻最多只能被该组内的一个消费者实例所消费。Kafka 负责将分区均匀地分配给组内的消费者。
因此,尽管一个主题可以被多个消费者(可能属于不同组,或同一组的多个实例)并行消费,但针对特定分区的消息流,始终是由消费者组内唯一指定的那个消费者实例来顺序读取的。该消费者会维护自己在这个分区上的消费偏移量,从上次提交的位置开始,按偏移量递增的顺序逐条拉取并处理消息,从而完美地再现了消息在分区内的原始顺序。
当消费者组内成员发生变化(如增减消费者实例)或主题分区数量变化时,会触发**再平衡(Rebalance)**过程,重新分配分区给消费者。即使分区被重新分配给了组内的另一个消费者,新的消费者也会从该分区上次提交的偏移量开始继续按顺序消费,保证分区内消息处理的顺序性不被破坏。消费者组的设计实现了消费的负载均衡和高可用性,同时通过限制分区与消费者的绑定关系,巧妙地维持了分区内的消息顺序。
69.Redis 作为高性能缓存的关键特性及其速度优势来源
Redis 之所以被广泛用作高性能缓存,得益于其独特的设计和一系列关键特性。首先,是其核心优势,内存的读写速度远超磁盘,使得 Redis 能够提供微秒级的低延迟访问,这对于需要快速响应的缓存场景至关重要。其次,Redis 提供了(如 String, Hash, List, Set, Sorted Set),能够灵活地满足多样化和复杂的缓存需求,而不仅仅是简单的键值对。再者,Redis 内建了,允许为键设置生存时间(TTL),到期后
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
曾获多国内大厂的 ssp 秋招 offer,且是Java5年的沉淀老兵(不是)。专注后端高频面试与八股知识点,内容系统详实,覆盖约 30 万字面试真题解析、近 400 个热点问题(包含大量场景题),60 万字后端核心知识(含计网、操作系统、数据库、性能调优等)。同时提供简历优化、HR 问题应对、自我介绍等通用能力。考虑到历史格式混乱、质量较低、也在本地积累了大量资料,故准备从头重构专栏全部内容