(高频问题)201-220 计算机 Java后端 实习 and 秋招 面试高频问题汇总

专栏简介

200. 利用 Rand7() 构造 Rand10():均匀随机数生成

要利用已有的 rand7() 方法(生成 [1,7] 范围内的均匀随机整数)来实现 rand10() 方法(生成 [1,10] 范围内的均匀随机整数),核心在于构造一个更大的、均匀分布的中间范围,然后通过拒绝采样法筛选出适用于目标范围的数值

具体实现中,可以通过两次调用 rand7() 来构造一个更大的随机数范围。表达式 (rand7() - 1) * 7 + rand7() 能够等概率地生成 [1,49] 范围内的整数。第一次 rand7() - 1 产生 [0,6] 之间的随机数,乘以 7 后得到 [0, 7, 14, 21, 28, 35, 42] 中的一个值,这代表了7个等概率的“基数”。第二次 rand7() 产生 [1,7] 之间的随机数,与基数相加后,就构成了从 1 (0+1) 到 49 (42+7) 的均匀分布。

为了得到 [1,10] 的均匀分布,我们采用拒绝采样。我们只接受生成的 num 在 [1,40] 范围内的值。如果 num 大于 40(即 41 到 49),则丢弃该值并重新生成,直到获得一个在 [1,40] 区间内的数。这样做可以保证每个选中的数字(从1到40)出现的概率是相等的。最后,通过 num % 10 + 1 的操作,可以将 [1,40] 范围内的数映射到 [1,10] 范围内的数,且保持均匀性。例如,1, 11, 21, 31 都会映射到 1;而 10, 20, 30, 40 都会映射到 10。

201. 重放攻击的防御策略

重放攻击(Replay Attack)是一种网络攻击手段,攻击者会截获合法的网络通信数据包,并在稍后重新发送这些数据包给接收方,以达到欺骗系统、获取未授权访问或重复执行操作的目的。攻击者通常无需破解数据包内容,仅利用其重放特性。

为有效抵御重放攻击,常用的防御机制包括:

使用时间戳(Timestamp):在每个传输的数据包中嵌入当前的时间戳。接收方在收到数据包后,会校验时间戳是否在其预设的有效时间窗口内。若时间戳过旧或与当前时间差异过大,则判定为潜在的重放数据包并予以丢弃。这种方法能有效防止旧数据包的重放,但其挑战在于需要通信双方维持较为精确的时间同步,并且时间戳的校验与管理会带来一定的处理开销。

使用序列号或随机数(Nonce/Sequence Number):在通信会话的每个数据包中包含一个唯一的、单调递增的序列号,或者一个一次性的随机数 (Nonce)。接收方会维护一个已接收序列号的记录或期望的下一个序列号(对于序列号机制),或者一个已使用的 Nonce 集合(对于 Nonce 机制)。若收到的数据包序列号重复、不符合预期顺序,或 Nonce已被使用,则视为重放攻击。这种方法实现相对简单且有效,但挑战在于序列号或 Nonce 的生成、同步和管理,尤其是在分布式或高并发环境下

202. Java 进程 CPU 占用率飙升的排查与定位方法

当 Java 进程出现 CPU 占用率异常飙升时,可采用以下步骤和工具进行定位:

首先,在服务器层面,可以使用 Linux 系统自带的命令进行初步排查。top 命令能够实时显示系统中各个进程的资源占用情况,包括 CPU 使用率、内存使用等,通过 Shift + P 可以按 CPU 使用率排序,快速找出消耗 CPU 最多的 Java 进程ID(PID)。另外,ps -eo pid,ppid,cmd,%mem,%cpu --sort=-%cpu 命令也可以按 CPU 使用率降序显示进程信息。

在定位到具体的 Java 进程后,可以进一步使用 JDK 提供的工具进行深入分析。jps 命令用于列出当前系统中所有 Java 进程及其 PID,方便确认目标进程。jstack <PID> 命令则非常关键,它可以打印出指定 Java 进程内所有线程的堆栈跟踪信息。通过分析线程堆栈,可以找出哪些线程正在执行、是否处于死锁状态,或者哪个方法调用消耗了大量 CPU 时间。通常,会多次执行 jstack,对比不同时间点的线程状态,定位繁忙的线程。此外,jstat 可用于监控 JVM 的各类运行时数据,如 GC 活动、类加载等。如果怀疑是内存问题间接导致 CPU 升高(例如频繁 GC),jmap 命令可以生成堆转储快照(heap dump),后续可使用 MAT (Memory Analyzer Tool) 等工具进行分析。

203. 双重检查锁定单例模式中 volatile 关键字的必要性分析

在双重检查锁定(Double-Checked Locking)实现的单例模式中,volatile 关键字对于保证线程安全至关重要。其必要性主要体现在防止指令重排序导致的初始化问题。

考虑以下典型的双重检查锁定代码片段:

// instance 变量需要被 volatile 修饰
private static volatile Singleton instance;

public static Singleton getInstance() {
    if (instance == null) { // 第一次检查
        synchronized (Singleton.class) {
            if (instance == null) { // 第二次检查
                instance = new Singleton(); // 关键赋值操作
            }
        }
    }
    return instance;
}

问题出在 instance = new Singleton(); 这一行。在 JVM 中,对象的创建并非原子操作,大致可以分解为以下三个步骤:

  1. Singleton 实例分配内存空间。
  2. 调用 Singleton 的构造函数,初始化成员字段。
  3. instance 引用指向分配的内存地址。

如果没有 volatile 修饰 instance 变量,编译器或处理器可能会对上述步骤进行指令重排序。例如,实际执行顺序可能变成 1 -> 3 -> 2。在这种情况下,线程 A 执行到步骤 3,instance 引用已经指向了内存地址,不再是 null。但此时对象的初始化(步骤 2)可能尚未完成。如果线程 B 在此刻进入第一个 if (instance == null) 判断,它会发现 instance 不为 null,从而直接返回一个尚未完全初始化的 instance 对象,后续使用该对象就可能引发错误。

volatile 关键字通过其内存屏障作用,可以禁止特定类型的指令重排序,确保了对象初始化的有序性 (具体来说,它保证了在将引用赋值给 instance 之前,对象的构造函数一定已经执行完毕并完成了所有成员变量的初始化),同时也保证了 instance 变量在多线程之间的可见性。因此,volatile 在此场景下是确保单例模式线程安全的关键。

204. Java 应用响应缓慢或卡顿的线上排查思路

当线上 Java 应用程序出现运行卡顿、响应延迟增长等不符合预期的情况时,通常需要一套系统性的排查思路来定位问题根源。

首先,应全面检查服务器的系统资源使用状况使用 tophtop 命令监控 CPU 使用率,若 CPU 持续高位运行,可能指示计算密集型任务或死循环。通过 free -mvmstat 查看内存使用情况,高内存占用可能指向内存泄漏或需要优化内存分配策略。同时,利用 iostat 关注磁盘 I/O,高 I/O 负载可能意味着磁盘读写瓶颈。

其次,深入分析 Java 应用程序本身的线程活动通过 jstack <PID> 命令获取 Java 进程的线程堆栈信息,仔细分析是否存在死锁、锁竞争激烈或长时间运行的线程。可以多次抓取堆栈进行对比,找出持续占用 CPU 的热点代码。图形化工具如 JConsole 或 VisualVM 也能直观展示线程状态和 CPU 消耗。

接下来,重点排查垃圾回收(GC)行为分析 GC 日志(通过 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log 等参数开启),关注 GC 的频率(尤其是 Full GC)和单次停顿时间。jstat -gcutil <PID> <interval> 命令可以实时监控 GC 分代变化、GC 次数和耗时。频繁或耗时过长的 GC 是导致应用卡顿的常见原因,可能需要调整堆大小、GC 策略或排查内存泄漏。

同时,审查应用程序日志是必不可少的环节仔细检查应用输出的日志文件,寻找异常堆栈、错误信息或处理缓慢的业务逻辑记录,这些往往能直接或间接指向问题所在。

最后,如果应用与数据库交互频繁,还需评估数据库的性能表现。检查慢查询日志,分析数据库连接池状态,确认是否存在数据库瓶颈或 SQL 优化空间。

205. 通过 Java GC 日志判断应用性能及预期符合度

通过分析 Java 的垃圾回收(GC)日志,可以有效地判断应用程序是否存在性能卡顿以及其运行状态是否符合预期。关键考察点包括:

GC 停顿时间(Pause Time):GC 日志会记录每次 GC 事件造成的应用停顿时间。例如,在 2024-08-04T12:00:00.123+0000: 123.456: [GC (Allocation Failure) [PSYoungGen: 2048K->256K(6144K)] 2048K->256K(19840K), 0.0056780 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 这样的日志中,0.0056780 secs (或者更准确地看 real=0.01 secs,代表实际停顿时间) 即表示该次 GC 的停顿时间。需要关注单次停顿时间是否过长,以及在特定时间窗口内(如1分钟)累积的总停顿时间。如果总停顿时间占比过高,例如在1分钟内累积停顿超过几百毫秒甚至数秒,则表明 GC 对应用吞吐量和响应时间产生了显著负面影响

GC 频率(Frequency)频繁的 GC,特别是 Full GC,会导致应用程序频繁停顿。通过统计单位时间内 Minor GC 和 Full GC 的发生次数,可以评估内存分配压力和老年代的健康状况。GC 日志中通常会记录 GC 的触发原因,如 Allocation Failure(年轻代空间不足导致 Minor GC)或 Ergonomics(JVM 根据当前堆状况自动触发 Full GC)、System.gc()(显式调用)。若 Allocation Failure 频繁出现,可能意味着对象创建速率过快或 Eden 区设置不当。

GC 类型(Type):GC 日志会明确标识 Minor GC(通常标记为 [GC ...] 或针对特定年轻代收集器的名称如 [PSYoungGen ...])和 Full GC(标记为 [Full GC ...]。Full GC 通常涉及整个堆的回收,停顿时间远长于 Minor GC,因此应重点关注 Full GC 的频率和耗时

堆内存使用情况(Heap Usage):GC 日志详细记录了每次 GC 前后堆内各区域(如年轻代、老年代)以及整个堆内存的使用量变化。在上述示例日志 [PSYoungGen: 2048K->256K(6144K)] 2048K->256K(19840K) 中:

  • [PSYoungGen: 2048K->256K(6144K)] 表示:年轻代(PSYoungGen)在此次 GC 前使用了 2048K 内存,GC 后降至 256K,其总大小为 6144K
  • 2048K->256K(19840K) 表示:整个堆(包括年轻代和老年代)在此次 GC 前总共使用了 2048K 内存,GC 后降至 256K,整个堆的总大小为 19840K。 通过观察 GC 后内存是否有效下降,可以判断内存回收效果;若 Full GC 后老年代内存依然居高不下,可能存在内存泄漏。

为了更高效地分析,可以借助 GC 日志分析工具,如 GCViewer 或 GCeasy。这些工具能将原始日志数据可视化,生成图表和报告,帮助快速定位 GC 瓶颈。

206. 阻塞式I/O (BIO) 服务器:连接数与线程数的关系

在基于同步阻塞I/O (BIO) 模型实现的服务器中,其典型的网络服务模式是为每一个客户端连接分配一个独立的线程进行处理。这种模式下,主线程(或称为Acceptor线程)负责监听并接受新的客户端连接请求。

当一个新的连接成功建立后,为了不阻塞主线程继续接受其他连接,服务器通常会创建一个新的处理线程专门负责该连接上的数据读取、业务处理和数据写入。因此,如果此时服务器建立了100个客户端连接,那么除了一个负责接收新连接的Acceptor线程外,还会额外创建100个用于处理这些已建立连接的线程。所以,总共会有 1 (Acceptor) + 100 (Handler) = 101 个线程在运行。这种模型的缺点是,如果连接被建立后长时间没有数据交互,对应的处理线程也会处于阻塞等待状态,造成线程资源的浪费。

207. 非阻塞式I/O (NIO) 服务器:连接数与线程数的关系及Selector机制

与BIO模型不同,非阻塞I/O (NIO) 模型采用多路复用 (Multiplexing) 技术,允许服务器使用少量线程处理大量并发连接。其核心组件是Selector (选择器),它能够同时监控多个通道 (Channel) 上的I/O事件(如连接就绪、数据可读、数据可写等)。

当服务器采用NIO模型时,即使建立了100个客户端连接,也不需要为每个连接都创建一个独立的线程。通常,服务器会启动一个或少数几个专门的I/O线程(通常称为Reactor线程)来运行Selector。这个Selector会监听所有注册给它的Channel。当任何一个Channel上发生I/O事件时(例如,一个新的客户端连接请求到达,或者某个已连接的客户端发送了数据),Selector会被唤醒,相应的I/O线程则可以获取到这些就绪的事件,并进行处理。对于连接建立事件,服务器会接受连接并将新的Channel注册到Selector上;对于数据读写事件,I/O线程会读取数据,然后可以将业务处理分发给后端的业务线程池。

因此,在NIO模型下,处理100个连接的I/O线程数量通常是固定的,且远小于连接数,例如CPU核心数个线程。实际的线程总数还会包括用于执行耗时业务逻辑的线程池中的线程,但负责网络I/O本身的线程数量得到了显著优化。

208. 处理器核心类型(大核/小核)与线程技术(物理/逻辑/超线程)解析

现代处理器为了平衡性能与功耗,常采用多种核心设计和线程技术。

大核与小核

大核 (Big Core) 通常设计有更强的单核计算能力和更复杂的指令流水线,适合处理计算密集型和对性能要求高的任务,但其功耗也相对较高。小核 (Little Core) 则侧重于能效,其计算能力相对较弱,但功耗显著低于大核,适合处理后台任务、轻量级应用或在系统负载不高时维持基本运行。大小核架构 (Big.LITTLE Architecture) 或类似混合架构将这两种核心集成在同一处理器中,操作系统通过动态调度,将高负载任务分配给大核执行,低负载或后台任务则交由小核处理,以实现性能与功耗的最佳平衡。大核和小核均属于物理核心

物理核心、逻辑核心与超线程

物理核心 (Physical Core) 是处理器芯片上实际存在的、独立的中央处理单元。每个物理核心都具备完整的执行单元、寄存器等。超线程技术 (Hyper-Threading,Intel的技术名称,AMD有类似SMT技术) 是一种允许单个物理核心模拟出两个或多个逻辑核心 (Logical Core) 的技术。从操作系统的视角来看,每个逻辑核心都表现为一个独立的处理单元,可以被分配和调度任务。操作系统在启动时会检测到处理器提供的所有逻辑核心数量,并基于这些逻辑核心进行任务调度。虽然操作系统主要与逻辑核心交互,但现代操作系统也能够感知物理核心的拓扑结构,并可能利用这些信息进行更优化的调度,例如尽量将关联不大的任务分散到不同的物理核心上,以减少资源竞争。

209. 负载均衡场景下客户端长连接池的挑战与缺点

在客户端使用长连接池访问经过负载均衡器(Load Balancer, LB)代理的后端服务集群时,虽然长连接可以减少连接建立的开销,但也可能引入一系列问题:

负载不均 (Uneven Load Distribution) 是一个核心问题。负载均衡器通常在建立新连接时根据其策略(如轮询、最少连接数等)将请求分发到不同的后端服务实例。然而,客户端一旦与某个后端服务实例建立了长连接,后续通过该连接的所有请求都会固定发送到此实例,负载均衡器无法对这些后续请求进行再次分发。这可能导致某些后端服务实例因承载了过多的长连接而过载,而其他实例则相对空闲。

连接滞留 (Connection Staleness):长连接长时间处于空闲状态后,可能因为网络设备(如防火墙、NAT网关)的超时策略或服务器端的意外重启而变得无效或“死亡”,但客户端连接池对此并不知情。当客户端尝试复用这些失效连接时,会导致请求失败,影响应用的可用性。

资源消耗 (Resource Consumption):每个长连接都会持续占用客户端和服务器端的内存、文件描述符等系统资源。如果连接池维护了大量不活跃的长连接,会造成不必要的资源浪费。

连接数限制 (Connection Limits):服务器通常对单个客户端或总体的并发连接数设有上限。大量长连接,尤其是在多客户端场景下,可能迅速耗尽服务器的可用连接数配额,导致新的连接请求被拒绝。

维护和更新挑战 (Maintenance and Update Challenges):当需要对后端服务实例进行维护、更新或缩容时,长连接的存在会使这些操作变得复杂。例如,要下线一个服务实例,必须妥善处理其上已建立的长连接,否则可能导致服务中断。如果强制断开,客户端需要有相应的重连和容错机制。

210. 连接池的典型位置:调用方(客户端)的实践

连接池(Connection Pool)这一机制,通常部署在服务的调用方,也就是客户端一侧。其核心目的是管理客户端与服务器之间的网络连接,以提高通信效率和系统性能。

当一个应用程序(作为客户端)需要频繁地与另一个服务(如数据库、远程API服务)进行通信时,如果每次请求都重新建立TCP连接,并在请求结束后关闭连接,将会带来显著的开销。这包括TCP三次握手建立连接的延迟和资源消耗,以及四次挥手关闭连接的过程。连接池通过预先创建并维护一定数量的连接,使得客户端在需要发送请求时,可以直接从池中获取一个已经建立好的空闲连接,使用完毕后将其归还给池中,而不是直接关闭。这样避免了每次请求都建立和关闭连接的开销提高了连接的复用率,显著减少了请求延迟,提升了应用的整体性能和吞吐量。

例如,一个Web应用在访问数据库时,会在Web应用内部(调用方)维护一个数据库连接池。同样,一个微服务A在调用微服务B时,微服务A(调用方)可以针对到微服务B的HTTP请求维护一个HTTP连接池。

211. Java 中通过代码精确控制 GC 行为:触发特定次数的 Young GC 与 Full GC

要在 Java 程序中精确地触发特定序列的垃圾回收(例如,两次 Young GC,然后一次 Full GC,接着再两次 Young GC),需要精心配置 JVM 参数并结合特定的对象分配和释放策略。直接且稳定地控制 GC 的确切发生时机是困难的,因为 GC 的行为受到多种因素影响,但可以通过以下方法尽可能地接近目标。

首先,配置合适的 JVM 启动参数至关重要。例如,通过 -Xms(初始堆大小)和 -Xmx(最大堆大小)设置一个固定的堆内存,通过 -Xmn 设置一个相对较小的新生代大小,以便更容易填满并触发 Young GC。使用 -XX:+PrintGCDet 可以观察 GC 的详细日志,帮助验证 GC行为。为了更可控,可以指定使用 (串行收集器),它的行为相对简单和可预测。

剩余60%内容,订阅专栏后可继续查看/也可单篇购买

曾获多国内大厂的 ssp 秋招 offer,且是Java5年的沉淀老兵(不是)。专注后端高频面试与八股知识点,内容系统详实,覆盖约 30 万字面试真题解析、近 400 个热点问题(包含大量场景题),60 万字后端核心知识(含计网、操作系统、数据库、性能调优等)。同时提供简历优化、HR 问题应对、自我介绍等通用能力。考虑到历史格式混乱、质量较低、也在本地积累了大量资料,故准备从头重构专栏全部内容

全部评论

相关推荐

评论
1
收藏
分享

创作者周榜

更多
牛客网
牛客企业服务