8.5
HTTP协议的缓存策略有哪些?
一、强缓存(无需与服务器通信,直接复用本地缓存)
强缓存是指客户端在缓存有效期内,直接从本地缓存读取资源,不向服务器发送请求。其有效性由响应头中的字段控制,优先级高于协商缓存。
- 核心响应头字段
-
Expires(HTTP/1.0)
定义:服务器返回的资源过期时间(绝对时间,格式为GMT,如Expires: Wed, 21 Oct 2025 07:28:00 GMT)。
工作原理:客户端会对比本地时间与Expires,若未过期则直接使用缓存;若过期则触发后续缓存策略(如协商缓存)。
缺点:依赖客户端本地时间,若客户端时间与服务器时间不同步(如手动修改系统时间),可能导致缓存失效或过期资源被错误使用。 -
Cache-Control(HTTP/1.1,优先级高于 Expires)
通过多个指令精确控制缓存行为,格式为Cache-Control: 指令1, 指令2。常见指令包括:max-age = 秒数:资源从被请求到过期的相对时间(如
max-age=3600表示 1 小时内有效)。客户端无需依赖本地时间,直接根据请求时间计算有效期,更可靠。s-maxage = 秒数:仅作用于共享缓存(如 CDN、代理服务器),优先级高于
max-age。用于控制中间节点的缓存时长(如s-maxage=86400表示 CDN 缓存 1 天)。public:资源可被任何缓存存储(包括客户端和共享缓存)。默认情况下,带
Authorization的请求资源为private,需显式声明public才允许共享缓存存储。private:资源仅能被用户代理缓存(如浏览器),禁止共享缓存存储(如敏感用户数据)。
no-cache:不使用强缓存,每次请求需与服务器协商(触发协商缓存),但允许缓存资源(只是使用前必须验证)。
no-store:完全禁止缓存(包括本地和中间节点),每次请求必须从服务器获取完整资源(如支付信息、实时数据)。
must-revalidate:缓存过期后,必须向服务器验证有效性,不允许使用过期缓存(即使客户端离线)。
强缓存工作流程
客户端首次请求资源,服务器返回资源及Cache-Control/Expires头。
客户端将资源存入本地缓存,并记录有效期。
再次请求时,若资源仍在有效期内,直接从缓存读取(状态码为 200 OK,来自缓存);若过期,则进入协商缓存流程。
二、协商缓存(需与服务器通信,验证缓存有效性)
当强缓存过期后,客户端需携带缓存标识向服务器请求,由服务器判断缓存是否可用。若可用,返回 304 Not Modified(不返回资源体,仅更新缓存有效期);若不可用,返回 200 OK 及新资源。
核心标识与对应头字段
协商缓存通过 “请求头(客户端发送)” 和 “响应头(服务器返回)” 配对实现,分为两类:
-
Last-Modified 与 If-Modified-Since(基于文件修改时间)
- Last-Modified(响应头):服务器返回资源的最后修改时间(如
Last-Modified: Tue, 12 Sep 2023 08:00:00 GMT)。 - If-Modified-Since(请求头):客户端再次请求时,将
Last-Modified的值作为该头的值发送给服务器,用于验证资源是否在该时间后被修改。
工作原理:
服务器对比If-Modified-Since与资源当前修改时间:- 若资源未修改(时间一致),返回 304 Not Modified,客户端复用缓存。
- 若资源已修改(时间更新),返回 200 OK 及新资源,并更新
Last-Modified。
缺点:
- 精度限制:仅能精确到秒,若资源在 1 秒内多次修改,无法识别。
- 误判风险:若资源内容未变但修改时间被更新(如手动编辑后未改内容),服务器会误判为 “已修改”,导致无效的资源传输。
- Last-Modified(响应头):服务器返回资源的最后修改时间(如
-
ETag 与 If-None-Match(基于文件内容哈希,优先级高于 Last-Modified)
-
ETag(响应头):服务器根据资源内容生成的唯一标识(如哈希值,格式为ETag: “abc123”)。内容不变则 ETag 不变,内容修改则 ETag 更新。
- 弱验证 ETag:以
W/前缀标识(如W/"abc123"),表示允许内容轻微修改(如空格、注释变化)时仍视为有效(适用于非核心内容)。
- 弱验证 ETag:以
-
If-None-Match(请求头):客户端再次请求时,将
ETag的值作为该头的值发送给服务器,用于验证资源内容是否变化。
工作原理:
服务器对比If-None-Match与资源当前 ETag:- 若 ETag 一致(内容未变),返回 304 Not Modified。
- 若 ETag 不一致(内容已变),返回 200 OK 及新资源,并更新
ETag。
优势:
- 精度更高:直接基于内容判断,不受修改时间影响,解决了
Last-Modified的误判问题。 - 灵活性:支持弱验证,适合允许轻微内容变化的场景。
-
协商缓存工作流程
- 强缓存过期后,客户端请求资源时,携带
If-Modified-Since(或If-None-Match)头。 - 服务器验证标识:
- 缓存有效:返回 304 Not Modified,客户端复用缓存并更新有效期。
- 缓存无效:返回 200 OK 及新资源,客户端更新缓存及标识。
介绍一下几种 IO 模型
- BIO:应用程序发起 IO 后,全程阻塞至数据准备完成并拷贝到用户空间。
- NIO:IO 操作立即返回,数据未就绪时返回错误,需应用程序轮询检查。
- AIO::应用程序发起后不阻塞,内核完成数据准备和拷贝,完成后通知应用。
- IO多路复用:通过系统调用监控多个 IO 通道,数据就绪时通知应用程序处理。
- 信号驱动IO:注册信号处理函数,数据就绪时内核发信号通知,无需主动轮询 / 阻塞。
说一说NIO的实现原理
一、核心组件与底层映射
Java NIO 的核心组件与操作系统底层 IO 模型存在明确的映射关系,这是理解其原理的关键:
- Channel(通道)
- 对应操作系统中的文件描述符(FD),是 Java 程序与底层 IO 设备(网络套接字、文件)的连接点。
- 常见实现:
SocketChannel(网络客户端通道)、ServerSocketChannel(网络服务端通道)、FileChannel(文件通道)等。 - 核心特性:支持非阻塞模式(通过
configureBlocking(false)开启),这是 NIO 的基础。在非阻塞模式下,read()、write()、accept()等操作会立即返回,不会阻塞线程。
- Buffer(缓冲区)
- 对应内存中的字节数组,是 Java 程序与 Channel 之间的数据传输载体。
- 工作原理:数据必须先读入 Buffer(从 Channel 到 Buffer),或从 Buffer 写出(从 Buffer 到 Channel),避免了 BIO 中直接操作流的低效性。
- 核心机制:通过
position、limit、capacity三个指针控制数据读写,支持flip()(切换读写模式)、clear()(清空缓冲区)等操作,优化数据处理效率。
- Selector(选择器)
- 对应操作系统的IO 多路复用器(如 Linux 的
epoll、Windows 的IOCP),是 NIO 实现高并发的核心。 - 作用:一个 Selector 可以同时监控多个 Channel 的事件(如连接请求、数据可读、数据可写),实现 “单线程管理多通道”。
- 对应操作系统的IO 多路复用器(如 Linux 的
二、非阻塞 IO 的工作流程(以网络通信为例)
Java NIO 的非阻塞特性通过 “通道非阻塞化 + 选择器事件驱动” 实现,具体流程如下:
-
初始化非阻塞通道与选择器
运行
// 创建服务器通道并设置为非阻塞 ServerSocketChannel serverChannel = ServerSocketChannel.open(); serverChannel.configureBlocking(false); serverChannel.bind(new InetSocketAddress(8080)); // 创建选择器 Selector selector = Selector.open(); // 将服务器通道注册到选择器,关注“接收连接”事件(OP_ACCEPT) serverChannel.register(selector, SelectionKey.OP_ACCEPT);- 底层:
configureBlocking(false)会调用操作系统的fcntl系统调用,为通道对应的 FD 设置O_NONBLOCK标志,使其进入非阻塞模式。
- 底层:
-
选择器等待事件就绪
运行
while (true) { // 阻塞等待事件发生(无事件时线程休眠,不消耗CPU) int readyChannels = selector.select(); if (readyChannels == 0) continue; // 获取所有就绪事件的SelectionKey集合 Set<SelectionKey> selectedKeys = selector.selectedKeys(); // ...处理事件 }- 底层:
selector.select()会调用操作系统的多路复用接口(如epoll_wait),阻塞等待内核通知 “注册的事件已就绪”,避免了应用程序主动轮询的 CPU 消耗。
- 底层:
-
事件驱动处理 IO 操作
当选择器检测到事件就绪(如客户端连接、数据到达),会通过SelectionKey通知应用程序,程序只需处理就绪的通道:运行
Iterator<SelectionKey> iterator = selectedKeys.iterator(); while (iterator.hasNext()) { SelectionKey key = iterator.next(); // 处理“接收连接”事件 if (key.isAcceptable()) { ServerSocketChannel server = (ServerSocketChannel) key.channel(); // 非阻塞接收连接(立即返回,若无可接收连接则返回null) SocketChannel clientChannel = server.accept(); if (clientChannel != null) { clientChannel.configureBlocking(false); // 注册客户端通道到选择器,关注“读数据”事件(OP_READ) clientChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024)); } } // 处理“读数据”事件 else if (key.isReadable()) { SocketChannel client = (SocketChannel) key.channel(); ByteBuffer buffer = (ByteBuffer) key.attachment(); // 非阻塞读取数据(立即返回,若无可读数据则返回0) int bytesRead = client.read(buffer); if (bytesRead > 0) { // 处理读取到的数据 buffer.flip(); // ... } } iterator.remove(); // 移除已处理的事件,避免重复处理 }- 核心:仅当通道有事件发生时才进行 IO 操作,且所有操作(
accept()、read())均为非阻塞,线程无需等待,大幅提升 CPU 利用率。
- 核心:仅当通道有事件发生时才进行 IO 操作,且所有操作(
介绍一下Java中的序列化与反序列化
在 Java 中,序列化(Serialization) 是指将对象的状态信息转换为可存储或传输的字节序列的过程;反序列化(Deserialization) 则是将字节序列恢复为原来的对象的过程。这一机制主要用于对象的持久化存储(如保存到文件)或网络传输(如远程服务调用)。
一、核心作用
- 对象持久化:将内存中的对象状态保存到磁盘文件中,程序重启后可通过反序列化恢复对象。
- 网络传输:在分布式系统中,对象需通过网络传输时,需先序列化为字节流,接收方再反序列化为对象(如 RPC 框架、Socket 通信)。
二、实现方式
Java 通过以下两个接口和相关 API 实现序列化:
-
Serializable接口- 一个标记接口(无任何方法),用于标识类的对象可以被序列化。
- 若类未实现
Serializable,尝试序列化其对象时会抛出NotSerializableException。
运行
import java.io.Serializable; // 实现Serializable接口,标识该类可序列化 public class User implements Serializable { private String name; private int age; // transient修饰的字段不会被序列化 private transient String password; // 构造方法、getter、setter省略 }transient关键字:修饰的字段在序列化时会被忽略,反序列化时该字段会被设为默认值(如null、0)。
-
序列化 / 反序列化工具类
ObjectOutputStream:负责将对象序列化为字节流(核心方法writeObject())。ObjectInputStream:负责将字节流反序列化为对象(核心方法readObject())。
算法题

private static ListNode removeDupliacates(ListNode head) {
if (head == null) {
return null;
}
// 创建哑节点
ListNode dummy = new ListNode(-1);
dummy.next = head;
ListNode pre = dummy;
ListNode cur = head;
while (cur != null) {
// 跳过重复的节点,找到当前值最后一个重复的位置
while (cur.next != null && cur.val == cur.next.val) {
cur = cur.next;
}
if (pre.next == cur) {
// 说明当前节点值只出现了一次,pre 后移
pre = pre.next;
} else {
// 说明当前节点值有重复,跳过这些重复节点
pre.next = cur.next;
}
cur = cur.next;
}
return dummy.next;
}

package hot100;
public class 最大子数组和 {
public static void main(String[] args) {
int[] nums=new int[]{-2,1,-3,4,-1,2,1,-5,4};
// 使用Kadane算法优化
int max = nums[0]; // 全局最大值
int currentSum = nums[0]; // 当前子数组和
for (int i = 1; i < nums.length; i++) {
// 如果当前子数组和为负数,则重新开始计算
currentSum = Math.max(nums[i], currentSum + nums[i]);
// 更新全局最大值
max = Math.max(max, currentSum);
}
System.out.println(max);
}
}
8.6
Serializable接口为什么需要定义serialVersionUID常量?
保证版本兼容性
当一个类实现了 Serializable 接口后,Java 序列化机制会根据类的结构(包括类名、字段类型、字段名称等)自动生成一个默认的 serialVersionUID。但是,如果类的结构发生了变化(例如添加、删除字段,修改字段类型等),重新编译后,自动生成的 serialVersionUID 也会改变。
在反序列化时,Java 会比较序列化对象中的 serialVersionUID 和当前类的 serialVersionUID。如果两者不匹配,就会抛出 InvalidClassException 异常,导致反序列化失败。通过显式地定义 serialVersionUID,可以在类的结构发生一些不影响序列化和反序列化逻辑的小变化时,保证版本兼容性,使得旧版本序列化的对象依然可以被新版本的类成功反序列化。例如:
import java.io.Serializable;
public class Person implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
// 省略构造函数、getter和setter方法
}
假设在后续版本中,为 Person 类添加了一个新字段 String address,只要 serialVersionUID 保持不变,之前序列化的 Person 对象仍然可以被正确反序列化。
增强控制能力
显式定义 serialVersionUID 可以让开发者对序列化和反序列化过程有更强的控制。开发者可以根据实际需求,在类的结构发生变化时,手动修改 serialVersionUID 的值,从而主动决定哪些旧版本的对象不应该再被反序列化,或者在反序列化时执行特定的兼容逻辑。
避免潜在的不一致问题
自动生成的 serialVersionUID 依赖于类的具体结构,不同的编译器或者 Java 运行时环境在计算默认 serialVersionUID 时可能存在细微差异。显式定义 serialVersionUID 可以消除这种潜在的不一致性,确保在不同环境下,序列化和反序列化的行为是一致的。
说一说synchronized的实现原理
一、synchronized 的基本实现:基于 “对象监视器”(Monitor)
synchronized 的同步本质是通过 对象监视器(Monitor) 实现的。在 Java 中,每个对象都天生携带一个 Monitor(可理解为一把 “锁”),当线程需要进入同步代码时,必须先获取该对象的 Monitor 所有权;退出同步代码时,则释放 Monitor 所有权。
Monitor 的底层结构(HotSpot 虚拟机)
Monitor 在 HotSpot 中由 ObjectMonitor 结构体实现,核心属性包括:
_owner:指向当前持有 Monitor 的线程(初始为 null)。_EntryList:阻塞队列,存放等待获取 Monitor 的线程(未获取到锁的线程会进入此队列)。_WaitSet:等待队列,存放调用wait()方法后释放锁的线程。_recursions:记录当前线程重入锁的次数(支持可重入性)。
二、synchronized 的两种使用形式及对应实现
synchronized 可修饰 方法 和 代码块,二者实现方式略有不同,但核心都是获取对象的 Monitor。
- 同步方法(修饰方法)
当修饰普通方法时,同步对象是 当前实例对象(this);当修饰静态方法时,同步对象是 类的 Class 对象。
public class SynchronizedExample {
// synchronized修饰普通方法,锁是当前实例对象(this)
public synchronized void method1() {
// 方法体
}
public synchronized void method2() {
// 方法体
}
}
// 使用示例
SynchronizedExample obj1 = new SynchronizedExample();
SynchronizedExample obj2 = new SynchronizedExample();
// 线程1执行obj1的method1
// 线程2可以同时执行obj2的method1(因为锁是不同的实例)
// 但线程2不能同时执行obj1的method2(因为锁是同一个实例obj1)
实现原理:
JVM 在编译时,会为同步方法添加一个 ACC_SYNCHRONIZED 标志(在方法表中)。当线程调用该方法时,JVM 会检查此标志:
- 若存在,线程会先尝试获取该方法所属对象的 Monitor。
- 获取成功后,执行方法体;执行完毕后释放 Monitor。
- 若未获取到,线程会进入该对象 Monitor 的
_EntryList队列阻塞等待。
- 同步代码块(修饰代码块)
同步代码块通过显式指定同步对象(如 synchronized(obj) { ... }),锁定的是 obj 对应的 Monitor。
实现原理:
编译时,JVM 会在同步代码块的 入口 和 出口 分别插入字节码指令 monitorenter 和 monitorexit:
monitorenter:线程尝试获取同步对象的 Monitor。若 Monitor 的_owner为 null,则当前线程成为_owner,并将_recursions设为 1;若当前线程已持有 Monitor(重入),则_recursions加 1;若被其他线程持有,则当前线程进入_EntryList阻塞。monitorexit:线程释放 Monitor。将_recursions减 1,当_recursions为 0 时,将_owner设为 null,唤醒_EntryList中等待的线程重新竞争锁。
(注:通常会生成两个 monitorexit 指令,一个处理正常退出,一个处理异常退出,确保锁一定会被释放。)
三、锁的优化:从重量级锁到多态锁(JDK 1.6+)
早期 JVM 中,synchronized 是 “重量级锁”,依赖操作系统的 互斥量(Mutex) 实现,线程阻塞 / 唤醒需要在用户态和内核态之间切换,开销极大。JDK 1.6 为优化性能,引入了 偏向锁、轻量级锁 和 重量级锁 的升级机制,根据竞争强度动态切换锁的类型。
- 偏向锁(Biased Locking):无竞争场景优化
适用场景:单线程重复获取同一把锁(无竞争)。
原理:当锁首次被线程获取时,JVM 会在对象头的 Mark Word 中记录该线程 ID(“偏向” 该线程),后续该线程再次获取锁时,无需 CAS 操作,直接通过比对线程 ID 即可获取锁,几乎无开销。
撤销:若有其他线程尝试获取锁,偏向锁会被撤销(需暂停持有锁的线程),升级为轻量级锁。
- 轻量级锁:短时间竞争场景优化
适用场景:多线程交替获取锁(竞争不激烈)。
原理:线程获取锁时,会在自己的栈帧中创建一个 “锁记录(Lock Record)”,并通过 CAS 操作将对象头的 Mark Word 复制到锁记录中,同时将 Mark Word 更新为指向锁记录的指针(表示锁被当前线程持有)。
膨胀:若 CAS 失败(说明有线程竞争),JVM 会自旋几次尝试获取锁;若自旋失败,轻量级锁膨胀为重量级锁。
- 重量级锁:激烈竞争场景
适用场景:多线程同时竞争锁(竞争激烈)。
原理:基于操作系统的互斥量(Mutex)实现,此时对象头的 Mark Word 指向 Monitor。未获取到锁的线程会进入 _EntryList 阻塞,不再自旋(避免 CPU 空耗),等待持有锁的线程释放后被唤醒。
四、synchronized 的核心特性
- 可重入性:同一线程可多次获取同一把锁(通过
_recursions计数实现),避免自己阻塞自己。 - 可见性:释放锁时,JVM 会将线程工作内存中的数据刷新到主内存;获取锁时,会从主内存加载最新数据,保证线程间数据可见。
- 排他性:同一时间只有一个线程能持有 Monitor(重量级锁下),或通过 CAS 保证交替执行(轻量级锁)。
HTTPS协议非对称加密的过程?
1. 非对称加密的核心角色
- 公钥(Public Key):公开传播,用于加密数据或验证签名。
- 私钥(Private Key):仅服务器持有,用于解密公钥加密的数据或生成签名。
- 特性:公钥加密的数据只能用对应的私钥解密,私钥加密的数据(签名)只能用对应的公钥验证。
2.HTTPS 中非对称加密的完整流程
(1)客户端发起 HTTPS 请求
客户端(如浏览器)向服务器发送Client Hello消息,包含:
- 支持的 TLS 版本(如 TLS 1.3)
- 支持的加密套件(如
ECDHE-RSA-AES256-GCM-SHA384) - 随机数(用于后续生成会话密钥)
(2)服务器返回证书与公钥
服务器收到请求后,返回Server Hello消息,包含:
- 选定的 TLS 版本和加密套件
- 服务器生成的随机数
- 服务器证书(由 CA 机构签发,包含服务器公钥、域名、有效期等信息)
(3)客户端验证证书合法性
客户端验证证书有效性:
- 检查证书是否由可信 CA 机构签发(操作系统 / 浏览器内置 CA 根证书)
- 验证证书签名:用 CA 的公钥解密证书中的签名,与证书内容的哈希值比对,确认证书未被篡改
- 检查证书域名是否与请求域名一致、是否在有效期内
(4)客户端生成会话密钥并加密传输
验证通过后,客户端:
- 生成一个预主密钥(Pre-Master Secret)(随机数)
- 用服务器证书中的公钥加密该预主密钥
- 将加密后的预主密钥发送给服务器(
Client Key Exchange消息)
(5)服务器解密获取预主密钥
服务器收到加密的预主密钥后,用自己的私钥解密,得到预主密钥。
(6)双方生成对称会话密钥
客户端和服务器分别使用:
- 客户端随机数 + 服务器随机数 + 预主密钥
- 通过约定的密钥派生算法(如 HKDF)生成相同的对称会话密钥
(7)后续通信使用对称加密
从此刻起,客户端与服务器的所有通信均使用对称加密(如 AES),而非对称加密的使命完成。
3.非对称加密的核心作用
- 身份认证:通过证书和签名确保服务器身份真实(防止中间人伪装)。
- 安全密钥交换:用公钥加密预主密钥,确保只有服务器(持有私钥)能解密,避免密钥在传输中被窃取。
为什么不全程使用非对称加密?
非对称加密算法(如 RSA、ECC)的计算复杂度远高于对称加密(如 AES),全程使用会导致通信效率极低。因此 HTTPS 仅在密钥交换阶段使用非对称加密,后续数据传输使用更高效的对称加密,兼顾安全性和性能。
静态库和动态库如何制作及使用,区别是什么
静态库是将函数和代码编译成一个单独的文件,可以通过链接器静态地将其链接到程序中。当程序被编译时,静态库的代码被复制到最终的可执行文件中,这意味着程序不需要外部库的存在。这样的好处是,程序更容易分发和部署,因为它只需要一个可执行文件。静态库的缺点是它们增加了可执行文件的大小,并且如果多个程序使用相同的静态库,那么这些程序会重复使用相同的代码,浪费磁盘空间。
动态库是将函数和代码编译成一个单独的文件,但是在程序运行时才加载。当程序被编译时,它只包含对动态库的引用,而不是实际的代码。在程序运行时,操作系统会动态地加载动态库,并将其映射到进程的地址空间中。这样,多个程序可以共享相同的动态库,因为它们只需要加载一次。动态库的缺点是,程序依赖于外部库的存在,因此在分发和部署时需要确保库的可用性。
下面是制作和使用静态库和动态库的基本步骤:
制作静态库:
- 编写函数和代码并编译成目标文件(.o文件)
- 使用静态库工具(如ar)将目标文件打包成静态库(.a文件)
使用静态库:
- 在编译时将静态库链接到程序中,例如使用gcc编译器时,使用-l参数指定静态库的名称。
- 在程序中包含头文件,以便可以调用库中的函数。
制作动态库:
- 编写函数和代码并编译成共享目标文件(.so文件)。
- 在编译时使用共享目标文件的位置和名称,例如使用gcc编译器时,使用-L和-l参数分别指定库文件的路径和名称。
使用动态库:
- 在程序中包含头文件,以便可以调用库中的函数。
- 在程序运行时,动态库会被自动加载,无需手动链接。

public int search (int[] nums, int target) {
// write code here
int n=nums.length;
int low=0,hight=n-1;
while(low<=hight){
int mid=low+(hight-low)/2;
if(nums[mid]==target){
return mid;
}
if(nums[0]<=nums[mid]){
if(nums[0]<=target&&target<nums[mid]){
hight=mid-1;
}else{
low=mid+1;
}
}else{
if(nums[mid]<target&&target<=nums[n-1]){
low=mid+1;
}else{
hight=mid-1;
}
}
}
return -1;
}

public static int mysqrt(int x) {
if (x == 0) {
return 0;
}
int left = 1;
int right = x;
while (left <= right) {
// 防止 (left + right) 溢出,改用 left + (right - left) / 2
int mid = left + (right - left) / 2;
// 避免 mid * mid 溢出,改用 x / mid 比较
if (mid == x / mid) {
return mid;
} else if (mid < x / mid) {
left = mid + 1;
} else {
right = mid - 1;
}
}
// 循环结束时,right 是最接近且小于等于平方根的整数
return right;
}
8.7
介绍一下Java中的IO流
-
按流向划分
- 输入流(Input Stream):从外部设备(如文件、网络)读取数据到程序中,使用
read()方法。 - 输出流(Output Stream):从程序将数据写入到外部设备,使用
write()方法。
- 输入流(Input Stream):从外部设备(如文件、网络)读取数据到程序中,使用
-
按操作数据单位划分
-
字节流:以字节(8 位)为单位处理数据,适用于所有类型文件(如文本、图片、音频等)。
- 输入字节流:
InputStream(抽象类) - 输出字节流:
OutputStream(抽象类)
- 输入字节流:
-
字符流:以字符(16 位)为单位处理数据,仅适用于文本文件(如.txt、.java
等)。
- 输入字符流:
Reader(抽象类) - 输出字符流:
Writer(抽象类)
- 输入字符流:
-
-
按流的角色划分
- 节点流:直接连接数据源或目标设备的流(如
FileInputStream、FileReader)。 - 处理流:对节点流进行包装,增强功能(如
BufferedInputStream、ObjectInputStream)。
- 节点流:直接连接数据源或目标设备的流(如
DNS(域名系统)是什么?
DNS(域名系统,Domain Name System)是互联网中用于将人类可读的域名(如www.example.com)转换为计算机可识别的 IP 地址(如192.168.1.1)的分布式数据库系统。
简单来说,它的核心作用是 “翻译”:因为计算机之间通信依赖 IP 地址(类似设备的网络身份证),但人类记忆一串数字(如203.0.113.5)远不如记忆有意义的域名(如example.com)方便。DNS 就像互联网的 “通讯录”,帮助我们通过易记的域名找到对应的服务器 IP。
DNS 的工作原理(简化流程):
- 当你在浏览器输入
www.google.com时,电脑会先查询本地 DNS 缓存(如操作系统或路由器缓存),若有记录直接返回 IP。 - 若本地无缓存,会向你的 ISP(网络服务提供商)的 DNS 服务器查询。
- 若 ISP 的 DNS 服务器也无记录,会逐级向上查询根域名服务器(全球共 13 组)、顶级域名服务器(如
.com、.cn对应的服务器)、权威域名服务器(管理具体域名的服务器),最终获取 IP 地址并返回。 - 浏览器使用获取到的 IP 地址与目标服务器建立连接,加载网页内容。
DNS 的重要性:
- 简化网络访问:无需记忆复杂的 IP 地址。
- 支持负载均衡:同一域名可对应多个 IP,实现流量分配。
- 提供灵活性:域名可以绑定新的 IP,而用户无需感知变化(如服务器迁移时)。
Redis有哪些数据类型
- String(字符串)扩展,sds
- Hash(哈希)哈希过程,渐进式哈希
- List(列表)跳表,压缩列表
- Set(集合)
- Sorted Set(有序集合)
说一说虚拟地址空间有哪些部分
在现代操作系统中,进程的虚拟地址空间(Virtual Address Space)通常划分为以下几个主要部分(以 32 位系统为例,从低地址到高地址排列):
- 代码段(Text Segment)
存储程序的可执行代码(机器指令),通常为只读,防止意外修改。 - 数据段(Data Segment)
存储已初始化的全局变量和静态变量,程序运行期间一直存在。 - BSS 段(Block Started by Symbol)
存储未初始化的全局变量和静态变量,程序启动时会被初始化为 0,占用地址空间但不占用磁盘空间。 - 堆(Heap)
用于动态内存分配(如 C 中的malloc、C++ 中的new),地址从低到高增长,需手动管理释放。 - 内存映射区(Memory Mapping Segment)
映射文件内容或共享内存到虚拟地址,例如动态链接库(.so/.dll)、内存映射文件等。 - 栈(Stack)
存储函数调用的局部变量、返回地址、参数等,地址从高到低增长,由操作系统自动管理(进栈 / 出栈)。 - 内核空间(Kernel Space)
位于地址空间高位(如 32 位系统通常为最高 1GB),存放内核代码和数据,用户进程无法直接访问,需通过系统调用间接交互。
从 Java 角度看,虚拟地址空间的划分与操作系统层面基本一致,但结合 Java 虚拟机(JVM)的内存模型,会有更具体的映射关系。以下是 Java 程序运行时涉及的虚拟地址空间关键部分:
- JVM 方法区(Method Area)
对应操作系统虚拟地址空间的数据段 / 内存映射区,存储类信息(结构、方法、字段)、常量池、静态变量等。在 HotSpot 中,JDK 8 及以上用 “元空间(Metaspace)” 实现,直接使用本地内存(操作系统虚拟地址空间的一部分),不再受 JVM 堆大小限制。 - JVM 堆(Heap)
对应操作系统虚拟地址空间的堆区,是 Java 中最大的内存区域,存储对象实例和数组。由所有线程共享,垃圾回收器主要针对此区域工作(如新生代、老年代的划分)。 - JVM 虚拟机栈(VM Stack)
对应操作系统虚拟地址空间的栈区,每个线程私有,存储栈帧(局部变量表、操作数栈、方法返回地址等)。方法调用时创建栈帧,调用结束后销毁,遵循 “先进后出” 原则。 - 本地方法栈(Native Method Stack)
类似虚拟机栈,但为 Native 方法(如通过 JNI 调用的 C/C++ 代码)服务,同样对应操作系统的栈区。 - 程序计数器(Program Counter Register)
占用极小的内存空间,记录当前线程执行的字节码指令地址,本质上是虚拟地址空间中的一个指针,确保线程切换后能恢复执行位置。 - 直接内存(Direct Memory)
不属于 JVM 内存模型,但 Java 可通过ByteBuffer.allocateDirect()申请,直接对应操作系统虚拟地址空间的堆 / 内存映射区,用于提升 IO 效率(避免 JVM 堆与 native 堆之间的数据拷贝)。 - 操作系统内核空间
JVM 作为用户态进程,无法直接访问,需通过系统调用(如 IO 操作、线程调度)与内核交互,这部分对应虚拟地址空间的高位内核区。
算法题
二分查找升序有重复元素的数组,返回第一个目标元素的下标
public static int findFirst(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
int result = -1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
result = mid; // 记录当前位置
right = mid - 1; // 继续向左查找更早出现的位置
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return result;
}
- 数字位置调换,考虑溢出,前置0
123->321
public static int reverse(int x) {
int result = 0;
while (x != 0) {
// 取出最后一位数字(处理负数时也适用)
int lastDigit = x % 10;
x = x / 10;
// 检查溢出:
// 1. 如果结果已经大于Integer.MAX_VALUE/10,乘以10后必溢出
// 2. 如果结果等于Integer.MAX_VALUE/10,且最后一位大于7(因为2^31-1=2147483647),则溢出
if (result > Integer.MAX_VALUE / 10 || (result == Integer.MAX_VALUE / 10 && lastDigit > 7)) {
return 0;
}
// 检查负数溢出:
// Integer.MIN_VALUE=-2147483648,最后一位是8
if (result < Integer.MIN_VALUE / 10 || (result == Integer.MIN_VALUE / 10 && lastDigit < -8)) {
return 0;
}
// 累加结果(自动处理前置0,因为0乘以10加0还是0)
result = result * 10 + lastDigit;
}
return result;
}
8.8
TCP协议的流量控制和拥塞控制
一、流量控制(Flow Control)
流量控制是点对点的控制机制,用于解决 “发送方发送速度过快,接收方来不及处理” 的问题,防止接收方缓冲区溢出,确保接收方能够有序处理接收到的数据。其核心思想是:限制发送方的发送速率,使其不超过接收方的接收能力。
- 核心依据:接收窗口(rwnd,Receiver Window)
接收方通过 TCP 报文头部的 “窗口字段”(Window Size)告知发送方自己当前的接收能力,这个值被称为 “接收窗口(rwnd)”。
- rwnd 的大小由接收方的缓冲区剩余空间决定:若接收方缓冲区剩余空间为 500 字节,则 rwnd=500,表示发送方可继续发送不超过 500 字节的数据。
- 发送方的 “发送窗口”(实际发送数据的上限)必须小于等于接收方告知的 rwnd,以此保证接收方不会被 “淹没”。
- 实现机制:滑动窗口协议
TCP 通过 “滑动窗口协议” 实现流量控制,其核心是动态调整发送窗口的大小:
发送窗口内的数据是 “已发送但未确认” 或 “可发送但未发送” 的数据,窗口大小由 rwnd 决定(发送窗口 ≤ rwnd)。
当接收方处理完部分数据后,会释放缓冲区空间,并通过确认报文(ACK)更新 rwnd 的值;发送方收到新的 rwnd 后,调整发送窗口大小,继续发送数据。
例如:
- 初始时接收方 rwnd=1000 字节,发送方发送 800 字节后,发送窗口剩余 200 字节。
- 接收方处理完 500 字节,缓冲区剩余空间变为 500+200=700 字节,通过 ACK 告知 rwnd=1000。
- 发送方收到后,发送窗口扩大至 1000 字节,可继续发送更多数据。
- 特殊情况:窗口关闭与探测报文
若接收方缓冲区已满(rwnd=0),发送方会停止发送数据。但为了避免 “接收方缓冲区已释放空间,发送方却不知道” 的死锁问题,发送方会定期发送零窗口探测报文(携带 1 字节数据):
- 接收方收到探测报文后,若缓冲区已释放空间,会在回复中更新 rwnd(如 rwnd=500);
- 发送方收到新的 rwnd 后,恢复数据发送。
二、拥塞控制(Congestion Control)
拥塞控制是全局性的控制机制,用于解决 “网络中数据量过大,超过网络承载能力” 的问题(即 “网络拥塞”)。网络拥塞会导致时延增加、丢包率上升、吞吐量下降,拥塞控制的核心思想是:限制所有发送方的总发送速率,避免网络因过载而崩溃。
- 拥塞的表现与原因
- 表现:数据包传输时延骤增、丢包率上升(如路由器缓冲区溢出导致丢包)。
- 原因:网络中所有发送方的总数据量超过了链路带宽、路由器处理能力等网络资源的上限。
- 核心依据:拥塞窗口(cwnd,Congestion Window)
TCP 引入 “拥塞窗口(cwnd)” 来表示发送方在 “不引发网络拥塞” 的前提下可发送的数据量。cwnd 的大小由网络拥塞程度动态调整,与接收方的 rwnd 无关(发送窗口的实际大小是 min (rwnd, cwnd),即取接收能力和网络承载能力的最小值)。
- 拥塞控制算法:慢开始、拥塞避免、快重传、快恢复
TCP 通过四个阶段的算法动态调整 cwnd,以适应网络拥塞状态:
| 阶段 | 触发条件 | 算法逻辑 |
|---|---|---|
| 慢开始 | 连接建立初期或网络恢复后 | cwnd 从 1 个报文段开始,每经过一个往返时间(RTT)就加倍(指数增长),直到 cwnd 达到 “慢开始门限(ssthresh)”。 |
| 拥塞避免 | cwnd ≥ ssthresh 或从快恢复进入 | cwnd 不再指数增长,而是每经过一个 RTT 增加 1 个报文段(线性增长),避免快速引发拥塞。 |
| 快重传 | 收到 3 个重复的确认报文(ACK) | 无需等待超时,立即重传丢失的报文段(说明网络未严重拥塞,只是个别包丢失)。 |
| 快恢复 | 执行快重传后 | 1. 将 ssthresh 设为当前 cwnd 的一半;2. 将 cwnd 设为 ssthresh(而非重置为 1);3. 直接进入拥塞避免阶段。 |
此外,若因 “超时” 检测到丢包(说明网络严重拥塞),则会执行:1. ssthresh = 当前 cwnd / 2;2. cwnd 重置为 1;3. 重新从慢开始阶段启动。
TCP协议是怎么保证有效传输的?
一、可靠传输的核心机制
- 面向连接(三次握手建立连接)
TCP 是 “面向连接” 的协议,在数据传输前必须通过三次握手建立逻辑连接,确保双方收发能力正常:
- 客户端发送
SYN报文,请求建立连接; - 服务器回复
SYN+ACK报文,确认客户端请求并告知自己已准备好; - 客户端发送
ACK报文,确认服务器的响应。
三次握手后,双方确认彼此的收发缓冲区、初始序列号等信息,为后续传输奠定基础。
- 序列号与确认机制(确保不丢失、不重复)
- 序列号:每个 TCP 报文段都包含 “序列号”(Sequence Number),标识该段数据在整个数据流中的位置(以字节为单位)。例如,发送方第一个报文段序列号为 100,携带 50 字节数据,则下一个报文段序列号为 150。
- 确认机制:接收方收到数据后,会返回 “确认报文(ACK)”,其中的 “确认号”(Acknowledgment Number)表示期望接收的下一字节位置。例如,接收方收到序列号 100~149 的 50 字节数据后,会返回确认号 150,告知发送方 “已正确接收至 149 字节,下次请从 150 开始发送”。
通过序列号和确认号,发送方可判断数据是否丢失,接收方可检测重复数据(如收到序列号小于确认号的报文则直接丢弃)。
- 超时重传(应对数据丢失)
若发送方在一定时间内(超时重传时间 RTO)未收到对某段数据的确认,会认为数据已丢失并重新发送该数据。
RTO 并非固定值,TCP 会根据网络往返时间(RTT)动态调整(如网络延迟大则增大 RTO,避免不必要的重传)。
- 流量控制(避免接收方缓冲区溢出)
发送方可能因速率过快导致接收方缓冲区溢出,TCP 通过滑动窗口机制实现流量控制:
- 接收方在 ACK 报文中通过 “窗口字段” 告知发送方自己的 “接收窗口(rwnd)”(即缓冲区剩余空间)。
- 发送方的 “发送窗口”(可发送但未确认的数据量上限)不得超过 rwnd,确保接收方有足够空间处理数据。
二、高效传输的关键机制
- 拥塞控制(避免网络过载)
网络拥塞(如路由器缓冲区满导致丢包)会严重影响传输效率,TCP 通过拥塞窗口(cwnd) 动态调整发送速率:
- cwnd 表示在不引发拥塞的前提下,发送方可发送的最大数据量,由网络状况决定。
- 结合 “慢开始”“拥塞避免”“快重传”“快恢复” 等算法(详见前文),cwnd 会根据丢包情况(网络拥塞信号)动态收缩或增长,平衡吞吐量与网络稳定性。
发送方实际的发送窗口为min(rwnd, cwnd),即同时受接收方能力和网络拥塞状态限制。
- 滑动窗口与批量发送(提升传输效率)
TCP 的滑动窗口机制不仅用于流量控制,还能实现批量发送以提高效率:
- 窗口内的数据可连续发送,无需等待每段数据的单独确认(只需收到对窗口内最后一段数据的确认,即可认为窗口内所有数据已被接收)。
- 例如,窗口大小为 1000 字节时,发送方可一次性发送多段数据(总大小≤1000 字节),只需等待最终的确认即可滑动窗口继续发送后续数据。
- 数据分片与重组(适配 MTU)
网络层(如 IP 协议)对单次传输的数据包大小有限制(最大传输单元 MTU)。TCP 会根据 MTU 将应用层数据分片为合适大小的报文段,确保能被 IP 层有效传输;接收方则在 TCP 层将分片重组为完整数据,再交付给应用层。
- 拥塞窗口与慢启动优化
连接建立初期,TCP 通过 “慢启动” 机制(cwnd 从 1 个报文段开始,指数增长)逐步增加发送速率,避免突然发送大量数据导致网络拥塞,待达到一定阈值后进入 “拥塞避免” 阶段(线性增长),实现效率与稳定性的平衡。
三、其他辅助机制
- 校验和:每个 TCP 报文段包含校验和字段,接收方通过校验和检测数据在传输中是否被篡改或损坏,若校验失败则丢弃该报文段(触发发送方重传)。
- 紧急指针:支持 “带外数据” 传输,允许发送方标记紧急数据,接收方可优先处理(如中断请求)。
- 四次挥手断开连接:确保双方数据都已传输完毕再释放连接,避免数据残留。
什么是孤儿进程,什么是僵尸进程,如何解决僵尸进程?
一、孤儿进程(Orphan Process)
当一个子进程的父进程先于子进程结束时,这个子进程就会成为孤儿进程。
特点:
- 孤儿进程不会被立即销毁,而是会被操作系统的init 进程(或 systemd 等进程管理器,进程 ID 为 1) 接管。
- init 进程会成为孤儿进程的新父进程,并负责在孤儿进程结束后回收其资源(避免资源泄漏)。
示例:
父进程创建子进程后,父进程提前退出,子进程继续运行,此时子进程就成为孤儿进程,由 init 进程接管。
影响:
孤儿进程本身是正常运行的进程,只是父进程已不存在,由系统进程接管后会被正常回收,通常不会造成问题。
二、僵尸进程(Zombie Process)
当一个子进程已经结束运行(终止),但父进程未及时读取其退出状态时,子进程的进程控制块(PCB)会残留在内存中,成为僵尸进程。
特点:
- 僵尸进程已经终止,不再执行任何代码,但其进程 ID(PID)和退出状态等信息仍保存在内核中(占用少量内存)。
- 僵尸进程无法被
kill命令清除(因为它已经终止,没有运行的代码)。
危害:
- 系统的 PID 数量是有限的,若大量僵尸进程积累,会耗尽可用的 PID 资源,导致新进程无法创建。
- 占用内核内存资源,虽然单个僵尸进程占用资源很少,但积累过多会影响系统性能。
示例:
父进程创建子进程后,子进程完成任务并退出,但父进程未调用wait()或waitpid()等系统调用来获取子进程的退出状态,子进程的 PCB 就会残留,成为僵尸进程。
如何解决僵尸进程?
1.父进程主动调用wait()或waitpid()
父进程通过wait()或waitpid()系统调用阻塞等待子进程结束,并获取其退出状态,从而回收子进程资源。
2.利用信号机制(SIGCHLD)
当子进程退出时,内核会向父进程发送SIGCHLD信号。父进程可以注册该信号的处理函数,在函数中调用waitpid()回收子进程资源,避免阻塞父进程的正常运行。
3.父进程退出,僵尸进程被 init 进程接管
若父进程无法及时回收子进程(如程序设计缺陷),可让父进程先退出,此时僵尸进程会成为孤儿进程,被 init 进程接管,init 进程会自动回收其资源。
4. 使用进程组或会话管理
通过将子进程放入独立的进程组或会话,当父进程退出时,子进程被 init 接管,避免僵尸进程积累。
说说常见信号有哪些,表示什么含义
| 信号编号 | 信号名称 | 含义与触发场景 | 默认处理行为 |
|---|---|---|---|
| 1 | SIGHUP | 终端挂起或控制进程退出(如关闭终端、父进程退出) | 终止进程 |
| 2 | SIGINT | 键盘中断(用户按下 Ctrl+C) | 终止进程 |
| 3 | SIGQUIT | 键盘退出(用户按下 Ctrl+\),比 SIGINT 更强烈 | 终止进程并生成核心转储文件(core dump) |
| 4 | SIGILL | 非法指令(进程执行了无效的机器指令,如错误的代码段) | 终止进程并生成 core dump |
| 5 | SIGTRAP | 调试陷阱(用于调试器捕获进程状态,如断点触发) | 终止进程并生成 core dump |
| 6 | SIGABRT | 进程主动调用 abort() 函数触发(通常表示程序异常) | 终止进程并生成 core dump |
| 7 | SIGBUS | 总线错误(访问无效的内存地址,如硬件层面的内存对齐错误) | 终止进程并生成 core dump |
| 8 | SIGFPE | 浮点异常(如除零操作、浮点运算溢出) | 终止进程并生成 core dump |
| 9 | SIGKILL | 强制终止信号(“必杀信号”) | 立即终止进程(不可被捕获或忽略) |
| 10 | SIGUSR1 | 用户自定义信号 1(可由程序员在程序中定义处理逻辑) | 终止进程 |
| 11 | SIGSEGV | 段错误(访问未分配的内存、越界访问等,最常见的内存错误) | 终止进程并生成 core dump |
| 12 | SIGUSR2 | 用户自定义信号 2(同 SIGUSR1,用于用户自定义场景) | 终止进程 |
| 13 | SIGPIPE | 管道破裂(向已关闭的管道 / 套接字写入数据,如客户端断开连接后服务器仍发送数据) | 终止进程 |
| 14 | SIGALRM | 闹钟信号(由 alarm() 函数设置的定时器到期触发) | 终止进程 |
| 15 | SIGTERM | 终止请求(默认的 “优雅终止” 信号,kill 命令默认发送此信号) | 终止进程(可被捕获或忽略,用于优雅退出) |
说一说你对SQL注入的理解
一、SQL 注入的原理
Web 应用程序通常需要通过 SQL 语句与数据库交互(如查询用户信息、提交表单数据等)。正常情况下,用户输入应作为 “数据” 被嵌入 SQL 语句中;但如果开发者未对输入进行严格过滤或转义,恶意用户可构造特殊输入,将其伪装成 “SQL 命令”,使数据库将这些输入解析为 SQL 语句的一部分并执行。
示例:
假设某登录功能的 SQL 查询逻辑为:
SELECT * FROM users WHERE username = '用户输入的用户名' AND password = '用户输入的密码';
- 正常输入:用户名
alice、密码123,生成的 SQL 为:
SELECT * FROM users WHERE username = 'alice' AND password = '123';(正常查询)。 - 恶意输入:用户名输入
' OR '1'='1,密码任意,生成的 SQL 为:
SELECT * FROM users WHERE username = '' OR '1'='1' -- AND password = 'xxx';
由于'1'='1恒为真,–会注释掉后面的sql语句,该语句会返回数据库中所有用户的信息,导致登录绕过。
二、防御 SQL 注入的方法
-
使用参数化查询(预编译语句)
这是最有效的防御方式。参数化查询将 SQL 语句的结构与用户输入的 “数据” 分离,数据库会将输入视为纯数据,而非 SQL 命令的一部分。
示例(Java JDBC):// 错误:直接拼接字符串 String sql = "SELECT * FROM users WHERE username = '" + username + "'"; // 正确:参数化查询 String sql = "SELECT * FROM users WHERE username = ?"; PreparedStatement pstmt = conn.prepareStatement(sql); pstmt.setString(1, username); // 输入作为参数传入,自动转义 -
严格过滤和转义用户输入
对用户输入的特殊字符(如单引号'、分号;、注释符--等)进行转义或过滤,确保其无法改变 SQL 语句的结构。不同数据库的转义规则不同(如 MySQL 用\'转义单引号),需针对性处理。 -
限制数据库账号权限
应用程序连接数据库时,应使用权限最小的账号(如仅授予SELECT、INSERT等必要权限,禁止DROP、EXEC等高危操作),即使发生注入,也能降低危害范围。 -
使用 ORM 框架
多数 ORM(对象关系映射)框架(如 Hibernate、MyBatis)默认采用参数化查询,可减少手动拼接 SQL 的风险,但需注意避免在框架中使用 “原生 SQL 拼接” 功能。
请描述Spring Boot自动装配的过程
Spring Boot自动装配的过程大致为:通过@SpringBootApplication注解开启自动装配,该注解包含的@EnableAutoConfiguration会触发SpringFactoriesLoader加载类路径下META-INF/spring.factories文件中注册的自动配置类,这些类通过@Conditional系列注解根据类路径下是否存在特定依赖、是否有特定Bean等条件决定是否生效,最终将符合条件的Bean定义加载到Spring容器中,实现自动配置。
你对MySQL的慢查询优化有了解吗
1. 识别慢查询
首先需准确定位慢查询 SQL:
- 开启慢查询日志(
slow_query_log = 1),通过long_query_time设置阈值(默认 10 秒,可根据业务调整为 1-2 秒),记录执行超时的 SQL; - 实时查看:用
show processlist查看当前运行的 SQL,关注Time字段(执行时间)和State字段(当前状态,如Sending data可能表示扫描行数多); - 工具分析:通过
pt-query-digest(Percona Toolkit)或 MySQL Workbench 的 Performance Schema,统计慢查询的频率、耗时分布,锁定高频低效 SQL。
2.分析慢查询原因
常见原因包括:
- 索引问题:无索引、索引失效(如使用函数 / 表达式操作索引列、隐式类型转换、
like '%xxx'左模糊匹配、or连接非索引列等)、索引设计不合理(如选择性低的列建索引); - SQL 写法问题:
select *查询冗余字段、子查询嵌套过深、join表过多或关联条件无索引、order by/group by未使用索引导致文件排序; - 表结构问题:表数据量过大(未分表)、字段类型不合理(如用
varchar存数字)、表碎片过多; - 数据库配置:缓冲池(
innodb_buffer_pool_size)过小导致频繁磁盘 IO、连接数不足等。
3.针对性优化措施
- 索引优化:为过滤条件、排序 / 分组字段、join 关联字段创建合适索引(如联合索引需遵循最左前缀原则);删除冗余 / 无效索引;避免索引失效场景(如
where substr(name,1,3)='abc'改为where name like 'abc%')。 - SQL 改写:避免
select *,只查必要字段;将子查询改为join(减少临时表生成);限制join表数量(建议不超过 3-4 张);order by/group by尽量使用索引排序,避免using filesort。 - 表结构优化:大表分表(水平分表按时间 / ID,垂直分表拆分冷热字段);使用合适字段类型(如用
int存状态而非varchar);定期optimize table清理碎片。 - 配置调优:调大
innodb_buffer_pool_size(建议占可用内存的 50%-70%),减少磁盘 IO;调整sort_buffer_size/join_buffer_size优化排序和连接性能。 - 架构层面:引入缓存(如 Redis)减轻热点查询压力;读写分离(主库写、从库读)分散负载;对超大型表考虑分库或使用时序数据库等专用存储。

public List<String> topKFrequent(String[] words, int k) {
// 1. 统计每个单词的出现频率
Map<String, Integer> countMap = new HashMap<>();
for (String word : words) {
countMap.put(word, countMap.getOrDefault(word, 0) + 1);
}
// 2. 将所有单词放入列表
List<String> candidates = new ArrayList<>(countMap.keySet());
// 3. 自定义排序规则
Collections.sort(candidates, (a, b) -> {
int countA = countMap.get(a);
int countB = countMap.get(b);
// 频率不同时,频率高的排在前面
if (countA != countB) {
return countB - countA;
} else {
// 频率相同时,字典序小的排在前面
return a.compareTo(b);
}
});
// 4. 取前k个元素
return candidates.subList(0, k);
}

public static int[][] findNearerSmallerElements(int[] nums) {
int n = nums.length;
int[][] result = new int[n][2];
Stack<Integer> stack = new Stack<>();
// 找左边最近更小的元素
for (int i = 0; i < n; i++) {
while (!stack.isEmpty() && nums[stack.peek()] >= nums[i]) {
stack.pop();
}
result[i][0] = stack.isEmpty()? -1 : stack.peek();
stack.push(i);
}
stack.clear();
// 找右边最近更小的元素
for (int i = n - 1; i >= 0; i--) {
while (!stack.isEmpty() && nums[stack.peek()] >= nums[i]) {
stack.pop();
}
result[i][1] = stack.isEmpty()? -1 : stack.peek();
stack.push(i);
}
return result;
}

private int checkBalance(TreeNode node) {
if (node == null) {
// 空树的高度为 0
return 0;
}
// 递归检查左子树是否平衡,并获取左子树的高度
int leftHeight = checkBalance(node.left);
if (leftHeight == -1) {
return -1;
}
// 递归检查右子树是否平衡,并获取右子树的高度
int rightHeight = checkBalance(node.right);
if (rightHeight == -1) {
return -1;
}
// 检查当前节点的左右子树高度差是否超过 1
if (Math.abs(leftHeight - rightHeight) > 1) {
return -1;
}
// 返回当前节点为根的二叉树的高度
return Math.max(leftHeight, rightHeight) + 1;
}

private TreeNode buildBST(int[] nums, int left, int right) {
// 递归终止条件,区间无效时返回 null
if (left > right) {
return null;
}
// 取区间中间位置的元素作为根节点(选左中位数,也可选右中位数,影响树的形态但都平衡)
int mid = left + (right - left) / 2;
TreeNode root = new TreeNode(nums[mid]);
// 递归构建左子树,区间为 [left, mid - 1]
root.left = buildBST(nums, left, mid - 1);
// 递归构建右子树,区间为 [mid + 1, right]
root.right = buildBST(nums, mid + 1, right);
return root;
}
8.9
如何打开一个大文件
- 核心原则:避免一次性加载全量数据
大文件(如几十 GB 的日志、数据文件)的特点是体积远超 Java 虚拟机(JVM)的内存容量。若直接使用File类的方法将文件内容全部读入byte[]或String,会瞬间占用大量内存,触发OutOfMemoryError。因此,Java 处理大文件的核心是按需读取,即每次只加载文件的一小部分到内存。
- 核心类与机制:基于 “流” 的读取
Java 的 I/O 体系中,输入流(InputStream) 是处理大文件的基础,其设计初衷就是 “逐字节 / 逐块” 读取数据,而非一次性加载。
- 字节流 vs 字符流:
- 字节流(如
FileInputStream):直接处理原始字节,适用于任意类型文件(二进制文件、文本文件)。 - 字符流(如
FileReader):专为文本文件设计,会自动处理字符编码(需注意编码一致性),本质上是字节流的包装,内部通过缓冲区处理字符转换。
- 字节流(如
- 缓冲机制:
无论是BufferedInputStream还是BufferedReader,都通过内置缓冲区减少磁盘 IO 次数(内存读写远快于磁盘)。例如,BufferedReader的readLine()方法会一次读取一行数据到缓冲区,避免逐字节读取的低效。
- 处理方式:按需求分块读取
- 逐行读取:对于文本文件(如日志、CSV),可使用
BufferedReader的readLine()方法,每次读取一行并处理,处理完后释放该行长的内存,再读取下一行,内存占用始终保持在单行数据的量级。 - 固定大小分块读取:对于二进制文件(如视频、大型压缩包),可通过
InputStream的read(byte[] buffer)方法,指定缓冲区大小(如 8KB、1MB),每次读取固定字节数到缓冲区,处理完后重复读取,直到文件末尾。 - 随机访问:若需跳转到文件特定位置读取(如大文件的中间部分),可使用
RandomAccessFile类,通过seek(long pos)方法定位到目标位置,再进行局部读取,避免读取无关内容。
介绍一下数据库分页
数据库分页是处理大量数据查询时的一种优化技术,用于将查询结果按固定大小拆分,每次只返回一部分数据(一页),而非一次性加载全部结果。
核心目的
- 减少数据传输量:避免一次性返回数万甚至数百万条记录,降低网络带宽消耗。
- 降低内存占用:客户端和服务器无需加载全部数据,减少内存压力。
- 提升响应速度:小批量数据处理更快,用户无需等待全部数据加载完成。
注意事项
偏移量过大的性能问题
当页码过大(如第 10000 页),LIMIT 100000, 10 这类查询需要扫描大量前置数据,效率低下。解决方案:
-
基于游标(Cursor)的分页(不支持跳页,仅支持上下页)
:以上一页最后一条记录的标识(如id)作为下一页的起点,避免计算偏移量。例如:
-- 上一页最后一条id为100,查询下一页10条 SELECT * FROM users WHERE id > 100 ORDER BY id LIMIT 10;
内存溢出问题该如何解决
- 堆内存溢出(Java heap space)
- 若为内存泄漏(对象无用但未回收):
- 找到泄漏点:通过堆分析工具定位持有对象的长期引用(如静态
List未清空、单例中缓存未过期、监听器未移除)。 - 修复引用关系:及时移除无用引用(如
list.clear())、使用弱引用(WeakHashMap)缓存临时对象、避免静态集合无限制存储数据。
- 找到泄漏点:通过堆分析工具定位持有对象的长期引用(如静态
- 若为正常内存需求(无泄漏,但对象确实多):
- 优化对象创建:减少临时对象(如循环中复用对象)、使用基本类型(
int替代Integer)、避免创建过大对象(如超大byte[]可分片处理)。 - 调整堆内存参数:根据应用需求增大堆大小(
-Xms初始堆、-Xmx最大堆,建议两者设为相同避免动态调整),例如:-Xms2G -Xmx2G(堆大小固定为 2GB)。 - 优化 GC:选择适合的垃圾回收器(如大堆用 G1、ZGC),通过
-XX:MaxGCPauseMillis控制 GC 停顿时间。
- 优化对象创建:减少临时对象(如循环中复用对象)、使用基本类型(
- 栈溢出(StackOverflowError)
- 递归过深:重构递归逻辑为循环(如用
while替代递归),或增加递归终止条件,减少调用层级。 - 多线程栈溢出:减少单个线程的栈大小(
-Xss参数,如-Xss256k,默认 1MB),或控制线程数量(使用线程池限制最大线程数)。
- 元空间 / 永久代溢出
- 减少类加载:避免频繁动态生成类(如 CGLib 代理可缓存生成的类)、清理无用的类加载器(如自定义类加载器使用后需释放引用)。
- 调整元空间大小:通过
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m增大元空间(JDK 8+),或-XX:PermSize=256m -XX:MaxPermSize=512m(JDK 7 及之前)。
- 直接内存溢出
- 释放直接内存:
ByteBuffer的直接内存需通过Cleaner机制回收,确保不再使用的ByteBuffer被及时置为null,并触发 GC(可调用System.gc(),但不推荐频繁使用)。 - 调整直接内存上限:通过
-XX:MaxDirectMemorySize=512m增大直接内存限制(默认与堆最大值相同)。
常见的HTTP协议响应头有哪些?
一、通用响应头
适用于所有 HTTP 请求 / 响应场景,描述基本传输信息。
- Date
服务器生成响应的时间,格式为 GMT(格林尼治标准时间)。
示例:Date: Sun, 10 Aug 2025 12:00:00 GMT - Server
服务器的软件信息(可选,可能被隐藏以增强安全性)。
示例:Server: Nginx/1.21.0或Server: Apache - Connection
指示连接状态,如是否保持长连接。
常见值:Connection: keep-alive(保持连接)、Connection: close(关闭连接)。
二、内容相关响应头
描述响应体的类型、编码、长度等信息。
- Content-Type
响应体的 MIME 类型(数据格式),可能包含字符编码。
示例:- 文本:
Content-Type: text/html; charset=UTF-8 - JSON:
Content-Type: application/json - 图片:
Content-Type: image/jpeg
- 文本:
- Content-Length
响应体的字节长度(仅用于非分块传输)。
示例:Content-Length: 1024 - Content-Encoding
响应体的压缩编码方式(客户端需解码)。
常见值:gzip、deflate、br(Brotli)。
示例:Content-Encoding: gzip - Content-Language
响应内容的自然语言(供客户端选择展示)。
示例:Content-Language: zh-CN、Content-Language: en-US
三、缓存控制响应头
控制客户端、代理服务器如何缓存响应内容。
- Cache-Control
最核心的缓存控制头,支持多个指令组合。
常见指令:public:允许任何缓存(客户端、代理)存储。private:仅客户端可缓存,代理不可。max-age=3600:缓存有效时间(秒),优先级高于Expires。no-cache:需验证(如 ETag)后再使用缓存。no-store:禁止缓存(敏感数据)。
示例:Cache-Control: public, max-age=86400
- Expires
缓存过期的具体时间(GMT),优先级低于Cache-Control: max-age。
示例:Expires: Mon, 11 Aug 2025 12:00:00 GMT - ETag
响应体的唯一标识(类似指纹),用于验证缓存有效性。
客户端下次请求时可通过If-None-Match携带此值,服务器判断是否返回 304(未修改)。
示例:ETag: "abc123" - Last-Modified
资源最后修改时间(GMT),类似 ETag 的作用,客户端通过If-Modified-Since验证。
示例:Last-Modified: Sun, 10 Aug 2025 10:00:00 GMT
四、跨域资源共享(CORS)响应头
当客户端跨域请求时,服务器通过这些头允许或限制访问。
- Access-Control-Allow-Origin
允许访问的源(域名),*表示允许所有源(不建议用于带认证的请求)。
示例:Access-Control-Allow-Origin: https://example.com - Access-Control-Allow-Methods
允许的 HTTP 方法(跨域预检请求时返回)。
示例:Access-Control-Allow-Methods: GET, POST, PUT, DELETE - Access-Control-Allow-Headers
允许的请求头(跨域预检请求时返回)。
示例:Access-Control-Allow-Headers: Content-Type, Authorization - Access-Control-Allow-Credentials
指示是否允许客户端携带认证信息(如 Cookie),需与Access-Control-Allow-Origin配合(不能为*)。
示例:Access-Control-Allow-Credentials: true
五、重定向响应头
用于指示客户端跳转至新 URL(配合 3xx 状态码使用)。
- Location
重定向的目标 URL,常见于 301(永久重定向)、302(临时重定向)、307(临时重定向,方法不变)等状态。
示例:Location: https://new-example.com/page

public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
if (headA == null || headB == null) {
return null;
}
ListNode p1 = headA, p2 = headB;
while (p1 != p2) {
// p1 遍历完 headA 后,切换到 headB 继续遍历
p1 = p1 == null ? headB : p1.next;
// p2 遍历完 headB 后,切换到 headA 继续遍历
p2 = p2 == null ? headA : p2.next;
}
return p1;
}

public class SortAlgorithms {
// 冒泡排序
public static int[] bubbleSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
// 交换元素
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
return arr;
}
}
public class SortAlgorithms {
// 快速排序
public static int[] quickSort(int[] arr) {
quickSortHelper(arr, 0, arr.length - 1);
return arr;
}
private static void quickSortHelper(int[] arr, int left, int right) {
if (left < right) {
int pivotIndex = partition(arr, left, right);
quickSortHelper(arr, left, pivotIndex - 1);
quickSortHelper(arr, pivotIndex + 1, right);
}
}
private static int partition(int[] arr, int left, int right) {
int pivot = arr[right];
int i = left - 1;
for (int j = left; j < right; j++) {
if (arr[j] <= pivot) {
i++;
// 交换元素
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
// 交换基准元素和 i+1 位置的元素
int temp = arr[i + 1];
arr[i + 1] = arr[right];
arr[right] = temp;
return i + 1;
}
}
public class SortAlgorithms {
// 希尔排序
public static int[] shellSort(int[] arr) {
int n = arr.length;
for (int gap = n / 2; gap > 0; gap /= 2) {
for (int i = gap; i < n; i++) {
int temp = arr[i];
int j;
for (j = i; j >= gap && arr[j - gap] > temp; j -= gap) {
arr[j] = arr[j - gap];
}
arr[j] = temp;
}
}
return arr;
}
}
public class SortAlgorithms {
// 堆排序
public static int[] heapSort(int[] arr) {
int n = arr.length;
// 构建最大堆
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(arr, n, i);
}
// 从最后一个元素开始,逐个将堆顶元素与末尾元素交换,并调整堆结构
for (int i = n - 1; i > 0; i--) {
int temp = arr[0];
arr[0] = arr[i];
arr[i] = temp;
heapify(arr, i, 0);
}
return arr;
}
private static void heapify(int[] arr, int n, int i) {
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
if (left < n && arr[left] > arr[largest]) {
largest = left;
}
if (right < n && arr[right] > arr[largest]) {
largest = right;
}
if (largest != i) {
int temp = arr[i];
arr[i] = arr[largest];
arr[largest] = temp;
heapify(arr, n, largest);
}
}
}
public class SortAlgorithms {
// 桶排序
public static int[] bucketSort(int[] arr) {
if (arr.length == 0) {
return arr;
}
int minVal = arr[0];
int maxVal = arr[0];
for (int num : arr) {
minVal = Math.min(minVal, num);
maxVal = Math.max(maxVal, num);
}
int bucketCount = (maxVal - minVal) / arr.length + 1;
List<List<Integer>> buckets = new ArrayList<>(bucketCount);
for (int i = 0; i < bucketCount; i++) {
buckets.add(new ArrayList<>());
}
for (int num : arr) {
int bucketIndex = (num - minVal) / arr.length;
buckets.get(bucketIndex).add(num);
}
int index = 0;
for (List<Integer> bucket : buckets) {
Collections.sort(bucket);
for (int num : bucket) {
arr[index++] = num;
}
}
return arr;
}
}
8.10
说一说 select 的原理以及缺点
一、Java 中 Select 的原理
Java NIO 的 select 机制本质上是对操作系统底层 I/O 多路复用(如 Linux 的 select/poll/epoll、Windows 的 IOCP 等)的封装,其核心流程如下:
- 核心组件
Selector:选择器,负责监控多个通道的就绪状态;SelectableChannel:可被选择器监控的通道(如SocketChannel、ServerSocketChannel),必须设置为非阻塞模式(configureBlocking(false))才能注册到选择器;SelectionKey:通道注册到选择器时返回的键,包含通道、选择器、感兴趣的事件(interest set)和就绪事件(ready set)等信息。
-
工作流程
-
创建选择器:通过
Selector.open()创建一个Selector实例; -
注册通道到选择器:
可选择的通道(如SocketChannel)通过register(Selector sel, int ops)方法注册到选择器,其中ops表示感兴趣的事件(通过SelectionKey的常量定义):SelectionKey.OP_READ:通道可读(如接收数据);SelectionKey.OP_WRITE:通道可写(如发送缓冲区空闲);SelectionKey.OP_CONNECT:客户端连接成功;SelectionKey.OP_ACCEPT:服务器接收新连接。
注册后返回SelectionKey,用于后续关联通道和选择器。
-
调用
select()阻塞等待就绪事件:
选择器通过select()方法阻塞等待,直到至少一个注册的通道就绪(或被唤醒)。底层会调用操作系统的 I/O 多路复用接口(如epoll_wait),由内核监控通道状态。 -
处理就绪事件:
select()返回后,通过selectedKeys()获取所有就绪的SelectionKey集合,遍历集合并通过readyOps()判断具体就绪事件(如key.isReadable()),然后进行相应的 I/O 操作(如读取数据)。
二、Java 中 Select 的缺点
尽管 Selector 解决了传统 BIO(阻塞 I/O)中 “一连接一线程” 的资源浪费问题,但仍存在以下局限性:
- 空轮询问题(JDK 早期 bug)
在某些 JDK 版本(如 1.4.x-1.6.x)中,select() 可能会在没有任何通道就绪的情况下返回,导致线程陷入无限空轮询,占用 100% CPU。
这是由于底层操作系统(如 Linux)的 epoll 机制在特定场景下的信号处理缺陷,JDK 后续版本通过引入 “轮询次数限制 + 重建选择器” 的策略修复了该问题,但仍需开发者在代码中额外处理(如检测空轮询并重建 Selector)。
- 通道注册与注销的开销
- 通道注册到选择器时,需要在用户态与内核态之间传递数据(如文件描述符、事件掩码),存在一定开销;
- 注销通道(或取消
SelectionKey)后,选择器需要清理内部数据结构,频繁的注册 / 注销会降低效率。
- 单个线程处理所有事件的瓶颈
虽然 Selector 允许单线程处理多通道,但当并发量极高(如十万级连接)时,单线程处理所有就绪事件会成为瓶颈 ——I/O 操作(即使是非阻塞)和事件分发的累计耗时可能导致响应延迟。
解决方式通常是引入多线程池,将就绪事件分配给工作线程处理,但会增加代码复杂度。
- 对大文件传输的优化有限
Selector 更适合处理 “小数据、高并发” 的场景(如网络通信)。对于大文件传输等 “大数据、低并发” 场景,其优势不明显,甚至可能因频繁的事件通知和用户态 / 内核态切换导致效率低于传统 BIO。
- 编程模型复杂
相比 BIO 的同步阻塞模型,NIO 的 Selector 机制需要开发者手动管理通道注册、事件轮询和就绪事件处理,涉及 SelectionKey 状态维护、非阻塞 I/O 异常处理等细节,容易出现 bugs(如漏处理事件、重复注册通道)。
HTTP(超文本传输协议)是什么?
HTTP(超文本传输协议)是互联网中客户端与服务器间的核心通信协议,基于请求 - 响应模式工作,运行在应用层且多依赖 TCP/IP 传输数据,其核心是实现超文本(包括文本、图片等资源及链接)的传输。
TCP协议三次握手和四次挥手的过程
一、三次握手(建立连接)
三次握手的目的是让客户端和服务器确认彼此的发送和接收能力,协商初始序列号,并建立可靠连接。过程如下:
- 第一次握手(客户端 → 服务器)
客户端主动发起连接,发送一个SYN(同步)报文段:- 标志位:
SYN = 1(表示请求建立连接); - 序号:
seq = x(客户端生成的初始随机序列号)。
此时客户端进入SYN_SENT状态,等待服务器响应。
- 标志位:
- 第二次握手(服务器 → 客户端)
服务器收到SYN后,确认可建立连接,返回SYN + ACK(同步 + 确认)报文段:- 标志位:
SYN = 1,ACK = 1(表示同意连接,并确认收到客户端的请求); - 序号:
seq = y(服务器生成的初始随机序列号); - 确认号:
ack = x + 1(表示已收到客户端的seq = x,下一次期望接收x + 1)。
此时服务器进入SYN_RCVD状态。
- 标志位:
- 第三次握手(客户端 → 服务器)
客户端收到SYN + ACK后,发送一个ACK(确认)报文段:- 标志位:
ACK = 1; - 序号:
seq = x + 1(按客户端序列号递增); - 确认号:
ack = y + 1(表示已收到服务器的seq = y,下一次期望接收y + 1)。
客户端发送后进入ESTABLISHED(连接建立)状态;服务器收到ACK后也进入ESTABLISHED状态,双方开始传输数据。
- 标志位:
二、四次挥手(终止连接)
TCP 是全双工通信(双方可同时发送数据),关闭连接时需双方分别确认 “不再发送数据”,因此需要四次交互:
- 第一次挥手(主动关闭方 → 被动关闭方)
假设客户端主动关闭连接,发送FIN(结束)报文段:- 标志位:
FIN = 1(表示客户端不再发送数据); - 序号:
seq = u(客户端当前的序列号)。
客户端进入FIN_WAIT_1状态,等待服务器确认。
- 标志位:
- 第二次挥手(被动关闭方 → 主动关闭方)
服务器收到FIN后,返回ACK报文段确认:- 标志位:
ACK = 1; - 序号:
seq = v(服务器当前的序列号); - 确认号:
ack = u + 1(表示已收到客户端的终止请求)。
此时服务器进入CLOSE_WAIT状态,客户端收到后进入FIN_WAIT_2状态。
(注:此时服务器可能仍有数据需发送给客户端,客户端需继续接收)
- 标志位:
- 第三次挥手(被动关闭方 → 主动关闭方)
服务器完成所有数据发送后,主动发送FIN报文段:- 标志位:
FIN = 1,ACK = 1(表示服务器也不再发送数据); - 序号:
seq = w(服务器当前的序列号,可能因发送数据而更新); - 确认号:
ack = u + 1(与第二次挥手一致)。
服务器进入LAST_ACK状态,等待客户端确认。
- 标志位:
- 第四次挥手(主动关闭方 → 被动关闭方)
客户端收到服务器的FIN后,发送ACK报文段确认:- 标志位:
ACK = 1; - 序号:
seq = u + 1; - 确认号:
ack = w + 1(表示已收到服务器的终止请求)。
客户端进入TIME_WAIT状态(等待 2MSL 时间,确保服务器收到确认,避免残留报文影响新连接),服务器收到ACK后进入CLOSED状态。客户端等待超时后,也进入CLOSED状态,连接彻底关闭。
- 标志位:
请你说说MySQL的ACID特性分别是怎么实现的?
- 原子性(Atomicity):要么全执行,要么全不执行
原子性确保事务中的所有操作是一个不可分割的整体,要么全部成功提交,要么在发生错误时全部回滚。
实现核心:undo 日志(回滚日志)
- undo 日志记录了事务执行前的数据状态(即 “反向操作”),例如插入操作对应删除记录的日志,更新操作对应恢复原值的日志。
- 当事务执行失败(如异常、手动回滚)时,MySQL 通过 undo 日志逆向执行操作,将数据恢复到事务开始前的状态,保证 “要么全做,要么全不做”。
- 事务提交后,undo 日志会被标记为可删除,不再用于回滚。
- 一致性(Consistency):事务前后数据状态合法一致性指事务执行前后,数据库中的数据必须满足预设的约束(如主键唯一、外键关联、业务规则等),始终处于 “合法状态”。
实现核心:多机制协同保障
- 原子性、隔离性、持久性是一致性的基础:原子性确保错误时回滚到合法状态,隔离性避免并发干扰,持久性确保提交后状态稳定。
- 数据库内置约束:主键、外键、CHECK 约束、唯一索引等,会在事务执行中自动校验(如插入重复主键会直接失败)。
- 应用层逻辑:业务代码需保证事务内操作符合业务规则(如转账时 “转出金额不能大于余额”),这是一致性的上层保障。
- 隔离性(Isolation):并发事务互不干扰
隔离性指多个事务并发执行时,每个事务的操作应与其他事务隔离开,避免 “脏读”“不可重复读”“幻读” 等问题。
实现核心:锁机制 + MVCC(多版本并发控制)
- 锁机制:
- 行锁(InnoDB 基于索引实现):对修改的行加锁,避免并发写冲突(如两个事务同时更新同一行)。
- 表锁:对整个表加锁(如 MyISAM 默认表锁),适用于全表操作,但并发度低。
- 意向锁、间隙锁等:辅助行锁实现更精细的隔离,防止幻读(如间隙锁锁定索引区间,避免插入新行)。
- MVCC:
通过 undo 日志保存数据的多个版本,配合 “Read View”(读视图)控制事务可见的数据版本。
例如,读已提交(RC)级别下,事务每次查询都会生成新的 Read View,只能看到已提交的版本;可重复读(RR)级别下,事务开始时生成一次 Read View,保证多次查询看到相同版本,避免不可重复读。 - 隔离级别:MySQL 通过锁和 MVCC 的组合实现 4 种隔离级别(读未提交、读已提交、可重复读、串行化),级别越高,并发度越低,一致性越强。
- 持久性(Durability):事务提交后数据永久保存
持久性指事务提交后,其修改的数据应永久保存,即使发生数据库崩溃、断电等故障也不会丢失。
实现核心:redo 日志 + 双写缓冲区(Double Write Buffer)
- redo 日志:记录数据页的物理修改(如 “某页某偏移量的值从 A 改为 B”),事务执行时先写入 redo 日志缓冲区,提交时通过 “force log at commit” 机制将日志刷入磁盘(确保日志先于数据持久化)。
若数据库崩溃,重启后可通过 redo 日志重做已提交的事务,恢复数据。 - 双写缓冲区:解决 “部分写页” 问题(如写入数据页时断电,导致页数据不完整)。InnoDB 先将数据页写入双写缓冲区(连续磁盘空间),再刷入数据文件,确保即使写入失败,也能从双写缓冲区恢复完整页数据。

public List<List<Integer>> permuteUnique(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
// 先对数组进行排序,方便后续剪枝去重
Arrays.sort(nums);
boolean[] used = new boolean[nums.length];
backtrack(nums, used, new ArrayList<>(), result);
return result;
}
private void backtrack(int[] nums, boolean[] used, List<Integer> path, List<List<Integer>> result) {
if (path.size() == nums.length) {
result.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < nums.length; i++) {
// 剪枝条件:当前元素和前一个元素相同,且前一个元素未被使用过(说明前一个元素在本次回溯中是同一层被跳过的,会导致重复排列)
if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
continue;
}
if (!used[i]) {
used[i] = true;
path.add(nums[i]);
backtrack(nums, used, path, result);
used[i] = false;
path.remove(path.size() - 1);
}
}
}

public static void rotate(int[] arr, int M) {
int n = arr.length;
if (n == 0 || M == 0) return; // 边界:空数组或移动0位,直接返回
// 1. 优化 M:取模避免重复移动(M 可能大于 n)
M = M % n;
if (M == 0) return; // M 是 n 的倍数时,无需移动
// 2. 三次反转
reverse(arr, 0, n - 1); // 整体反转
reverse(arr, 0, M - 1); // 反转前 M 位
reverse(arr, M, n - 1); // 反转剩余 n-M 位
}
// 辅助方法:反转数组指定区间 [start, end]
private static void reverse(int[] arr, int start, int end) {
while (start < end) {
// 交换 start 和 end 位置的元素
int temp = arr[start];
arr[start] = arr[end];
arr[end] = temp;
start++;
end--;
}
}
8.11
HTTPS协议中间人攻击是什么?
HTTPS 协议的中间人攻击(Man-in-the-Middle Attack,简称 MITM) 是指攻击者通过技术手段,秘密介入客户端与服务器之间的 HTTPS 加密通信,伪装成 “可信第三方” 同时与客户端和服务器建立连接,从而窃取、篡改甚至伪造通信数据的攻击方式。
一、攻击核心原理
HTTPS 的安全基础是SSL/TLS 加密协议和数字证书验证机制:客户端与服务器通过 SSL/TLS 协商加密算法、交换会话密钥,后续数据通过密钥加密传输;同时,服务器需向客户端出示由权威 CA(证书颁发机构)签发的数字证书,证明自身身份,客户端通过验证证书有效性确保通信对象真实。
而中间人攻击的核心是打破 “身份验证” 和 “密钥机密性”:
攻击者通过欺骗手段,让客户端误认为攻击者是服务器,同时让服务器误认为攻击者是客户端,从而在客户端与服务器之间构建 “双向伪装” 的通信链路。具体来说:
- 攻击者拦截客户端向服务器发起的 HTTPS 连接请求;
- 攻击者伪装成 “服务器”,向客户端发送伪造的数字证书,并与客户端协商加密会话(客户端误以为在和真实服务器通信);
- 同时,攻击者伪装成 “客户端”,使用真实服务器的证书(或再次伪造)与服务器建立加密会话(服务器误以为在和真实客户端通信);
- 客户端与服务器之间的加密数据会先发送给攻击者,攻击者解密后获取内容(或篡改),再用另一端的密钥重新加密后转发,最终客户端和服务器均无法察觉中间人的存在。
二、攻击完整流程(以浏览器访问网站为例)
- 客户端发起请求:用户在浏览器输入
https://example.com,向服务器发起 HTTPS 连接请求。 - 攻击者拦截并伪装:
- 攻击者拦截请求,向客户端返回伪造的服务器证书(例如,伪造
example.com的证书,或使用自签证书); - 客户端若未严格验证证书(如用户手动忽略浏览器警告),会与攻击者建立 SSL/TLS 连接,生成客户端与攻击者之间的会话密钥。
- 攻击者拦截请求,向客户端返回伪造的服务器证书(例如,伪造
- 攻击者与服务器建立连接:
- 攻击者同时以 “客户端” 身份向真实服务器发起 HTTPS 请求,获取服务器的真实证书并建立连接,生成攻击者与服务器之间的会话密钥。
- 数据中转与窃取:
- 客户端发送的加密数据(用客户端 - 攻击者的密钥加密)被攻击者解密,攻击者可查看或修改内容;
- 攻击者用攻击者 - 服务器的密钥重新加密数据,转发给服务器;
- 服务器的响应数据则以相反流程经过攻击者,最终客户端和服务器均认为通信是 “直接且加密” 的,但数据已被攻击者掌控。
三、攻击成功的关键条件
中间人攻击能成功,本质是绕过了 HTTPS 的证书验证机制,常见场景包括:
- 客户端主动忽略证书警告:浏览器检测到证书无效(如伪造、过期、签名错误)时会弹出警告,但用户可能点击 “继续访问”,导致攻击者证书被信任。
- 恶意根证书被植入:攻击者通过病毒、恶意软件等在客户端设备(如电脑、手机)中安装自己的 “根证书”(即攻击者自建的 CA 证书)。由于客户端会信任系统中已安装的根证书,攻击者用该根证书签发的伪造服务器证书会被客户端视为 “有效”,直接绕过验证。
- SSL/TLS 协议漏洞:若客户端或服务器使用存在漏洞的 SSL/TLS 版本(如 SSLv3、TLS 1.0)或弱加密算法(如 RC4),攻击者可能通过破解加密密钥直接获取数据(例如 “心脏滴血” 漏洞曾被用于窃取会话密钥)。
四、HTTPS 如何防范中间人攻击?
HTTPS 的核心防御机制是证书链验证,具体包括:
- 验证证书颁发者:服务器证书必须由客户端信任的 CA(如 Let’s Encrypt、DigiCert 等)签发,客户端会检查证书的 “签名链” 是否追溯到系统中预存的可信根证书。
- 验证证书有效性:检查证书是否过期、是否被 CA 吊销(通过 CRL 或 OCSP 协议查询)、证书中的域名是否与访问域名一致(防止 “域名劫持”)。
- 拒绝自签证书:未由权威 CA 签发的自签证书会被客户端直接标记为 “无效”(除非用户手动信任)。
说一说你对MVCC的了解
详见Mysql面试总结。
说一说ConcurrentHashMap的实现原理
详见Java集合面试总结。
UDP(用户数据报协议)是什么?
UDP(User Datagram Protocol,用户数据报协议)是 TCP/IP 协议族中一种无连接、不可靠、面向数据报的传输层协议,与 TCP(传输控制协议)共同承担数据传输的核心功能,但设计理念和适用场景截然不同。
一、UDP 的核心特性
UDP 的设计追求简单、高效和低延迟,牺牲了部分可靠性,具体特性如下:
- 无连接性
- 通信前无需建立连接(如 TCP 的三次握手),也无需断开连接(四次挥手)。客户端直接向目标服务器发送数据报,服务器收到后无需确认 “已准备好接收”。
- 类比:如同发送邮件,无需提前告知收件人 “我要发邮件了”,直接投递即可。
- 不可靠传输
- 不保证数据的完整性:数据在传输中可能丢失、重复或乱序,UDP 自身不提供重传机制。
- 不提供流量控制和拥塞控制:发送方不会根据网络状况调整发送速率,可能导致网络拥塞时数据丢失更严重。
- 仅提供最基本的校验和:用于检测数据在传输中是否被篡改,若校验失败则直接丢弃数据,不通知发送方。
- 面向数据报
- 数据以 “数据报” 为单位传输,每个数据报包含完整的源端口、目标端口、长度、校验和以及数据部分。
- 发送方一次发送一个数据报,接收方一次接收一个完整数据报,不会像 TCP 那样将数据拆分为多个片段再重组(UDP 也可能分片,但由 IP 层处理,而非 UDP 自身)。
- 高效低延迟
- 由于省去了连接建立 / 断开、确认、重传等机制,UDP 的协议开销极小,数据传输速度快,延迟低。
- 头部结构简单(仅 8 字节),远小于 TCP 的 20 字节(最小)头部。
二、UDP 的头部结构
UDP 数据报的头部固定为 8 字节,包含 4 个字段:
| 字段 | 长度(字节) | 作用 |
|---|---|---|
| 源端口号 | 2 | 标识发送端的端口(可选,若为 0 则表示不需要对方回复) |
| 目标端口号 | 2 | 标识接收端的端口,用于将数据交付给正确的应用程序 |
| 数据报长度 | 2 | 整个 UDP 数据报的总长度(头部 + 数据),最大值为 65535 字节(含头部 8 字节) |
| 校验和 | 2 | 用于校验数据报在传输中是否被损坏(可选,部分场景可能不启用) |

public int countK(int[] nums, int k) {
// 寻找第一个等于 k 的位置
int first = findFirst(nums, k);
// 寻找最后一个等于 k 的位置
int last = findLast(nums, k);
if (first == -1 || last == -1) {
return 0;
}
return last - first + 1;
}
// 二分查找第一个等于 k 的位置
private int findFirst(int[] nums, int k) {
int left = 0, right = nums.length - 1;
int result = -1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == k) {
result = mid;
right = mid - 1;
} else if (nums[mid] < k) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return result;
}
// 二分查找最后一个等于 k 的位置
private int findLast(int[] nums, int k) {
int left = 0, right = nums.length - 1;
int result = -1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == k) {
result = mid;
left = mid + 1;
} else if (nums[mid] < k) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return result;
}

public static List<List<Integer>> combinationSum2(int[] candidates, int target) {
List<List<Integer>> results = new ArrayList<>();
// 排序,以便去重和按非递减顺序生成组合
Arrays.sort(candidates);
// 回溯搜索
backtrack(candidates, target, 0, new ArrayList<>(), results);
return results;
}
/**
* 回溯搜索函数
* @param candidates 候选数组(已排序)
* @param target 目标和
* @param start 开始搜索的索引
* @param combination 当前组合
* @param results 所有满足条件的组合
*/
private static void backtrack(int[] candidates, int target, int start, List<Integer> combination, List<List<Integer>> results) {
// 如果当前组合的和等于目标值,加入结果集
if (target == 0) {
results.add(new ArrayList<>(combination));
return;
}
// 遍历候选数
for (int i = start; i < candidates.length; i++) {
// 跳过重复元素,避免生成重复组合
if (i > start && candidates[i] == candidates[i - 1]) {
continue;
}
// 当前数字
int num = candidates[i];
// 如果当前数字大于目标值,后续数字也不可能满足(因为数组已排序)
if (num > target) {
break;
}
// 选择当前数字
combination.add(num);
// 递归搜索,注意 i + 1(每个数字只能使用一次)
backtrack(candidates, target - num, i + 1, combination, results);
// 回溯,移除当前数字
combination.remove(combination.size() - 1);
}
}
8.12
结合OSI模型和TCP/IP模型的五层协议体系结构
| 五层协议模型 | OSI 七层模型 | TCP/IP 四层模型 |
|---|---|---|
| 应用层 | 应用层、表示层、会话层 | 应用层 |
| 运输层 | 运输层 | 运输层 |
| 网络层 | 网络层 | 网际层(IP 层) |
| 数据链路层 | 数据链路层 | 网络接口层 |
| 物理层 | 物理层 | (包含在网络接口层) |
说一说线程的生命周期
1. 新建状态(New)
- 定义:当线程对象被创建(如通过
new Thread())但尚未调用start()方法时,线程处于新建状态。 - 特点:此时线程已分配必要的内存资源,但尚未进入操作系统的线程调度队列,不参与 CPU 调度。
2. 就绪状态(Runnable)
- 定义:调用
start()方法后,线程进入就绪状态(也称为 “可运行状态”)。 - 特点:
- 线程已加入操作系统的调度队列,等待 CPU 分配时间片。
- 具备运行条件,但尚未获得 CPU 资源。
- 转换触发:
- 新建状态调用
start()后进入就绪状态。 - 运行状态的线程因时间片用完(被操作系统抢占)回到就绪状态。
- 阻塞状态 / 等待状态的线程被唤醒后,先进入就绪状态,再等待调度。
- 新建状态调用
3. 运行状态(Running)
- 定义:就绪状态的线程获得 CPU 时间片后,进入运行状态,执行
run()方法中的代码。 - 特点:同一时刻,一个 CPU 核心只能有一个线程处于运行状态(多核心 CPU 可并行运行多个线程)。
- 转换触发:
- 就绪状态的线程被操作系统调度选中,获得 CPU 资源。
- 运行状态的线程因时间片用完,回到就绪状态。
- 运行中调用
sleep()、wait()等方法,或尝试获取同步锁失败时,进入阻塞 / 等待状态。
4. 阻塞 / 等待状态(Blocked/Waiting/Timed Waiting)
线程暂时停止执行,放弃 CPU 资源,等待特定条件满足后才能重新进入就绪状态。根据触发原因,可细分为:
- 阻塞状态(Blocked):
- 因竞争同步锁(如
synchronized代码块)失败而阻塞,等待其他线程释放锁。
- 因竞争同步锁(如
- 等待状态(Waiting):
- 调用
wait()、join()等无超时参数的方法,线程进入无限期等待,需被其他线程显式唤醒(如notify())。
- 调用
- 超时等待状态(Timed Waiting):
- 调用
sleep(ms)、wait(ms)等带超时参数的方法,线程等待指定时间后自动唤醒,或被提前唤醒。
- 调用
- 共同特点:此时线程不参与 CPU 调度,直到等待的条件满足(如锁释放、超时时间到、被唤醒)。
5. 终止状态(Terminated)
- 定义:线程的
run()方法执行完毕,或因异常终止,进入终止状态。 - 特点:线程生命周期结束,无法再转换到其他状态,也不能通过
start()方法重新启动(否则会抛出异常)。 - 终止原因:
run()方法正常执行完毕。- 线程中抛出未捕获的异常,导致线程终止。
- 调用
stop()方法(已废弃,可能导致资源泄露,不推荐使用)。
表跟表是怎么关联的
在关系型数据库中,表与表之间的关联(关系)是通过主键(Primary Key) 和外键(Foreign Key) 来建立的,目的是实现数据的结构化存储、减少冗余,并保证数据一致性。常见的表关联类型有以下几种:
1. 一对一关联(One-to-One)
- 定义:两个表中的记录一一对应,即 A 表中的一条记录仅对应 B 表中的一条记录,反之亦然。
- 实现方式:
- 在其中一个表中添加外键,关联另一个表的主键,且该外键设置为唯一(
UNIQUE)。 - 例如:
用户表(user)和身份证表(id_card),一个用户仅对应一张身份证,一张身份证仅属于一个用户。
- 在其中一个表中添加外键,关联另一个表的主键,且该外键设置为唯一(
2. 一对多关联(One-to-Many)
- 定义:A 表中的一条记录可对应 B 表中的多条记录,但 B 表中的一条记录仅对应 A 表中的一条记录。这是最常见的关联类型。
- 实现方式:
- 在 “多” 的一方(B 表)中添加外键,关联 “一” 的一方(A 表)的主键。
- 例如:
部门表(department)和员工表(employee),一个部门可包含多名员工,一名员工仅属于一个部门。
3. 多对多关联(Many-to-Many)
- 定义:A 表中的一条记录可对应 B 表中的多条记录,同时 B 表中的一条记录也可对应 A 表中的多条记录。
- 实现方式:
- 需要创建一个中间表(关联表),该表中包含两个外键,分别关联 A 表和 B 表的主键,两个外键组合作为中间表的主键(或唯一约束)。
- 例如:
学生表(student)和课程表(course),一个学生可选多门课程,一门课程可被多名学生选择。
介绍一下信号量
信号量的核心概念
信号量本质上是一个整数变量,通常记为 S,其值反映了当前可用资源的数量。它支持两种核心操作:
- P 操作(Proberen,荷兰语 “尝试”):也称为
wait()或down()。- 执行时,先将
S减 1(S = S - 1)。 - 若
S ≥ 0:当前进程 / 线程可继续执行(表示成功获取资源)。 - 若
S < 0:当前进程 / 线程被阻塞,并放入该信号量的等待队列中(表示资源已被占用)。
- 执行时,先将
- V 操作(Verhogen,荷兰语 “增加”):也称为
signal()或up()。- 执行时,先将
S加 1(S = S + 1)。 - 若
S > 0:当前进程 / 线程继续执行(表示释放资源后仍有剩余)。 - 若
S ≤ 0:从该信号量的等待队列中唤醒一个阻塞的进程 / 线程,使其继续执行(表示有进程正在等待资源)。
- 执行时,先将
信号量的分类
根据初始值和用途,信号量可分为两类:
- 二进制信号量(Binary Semaphore)
- 特点:取值只能是
0或1,本质上是一种 “互斥锁(Mutex)”。 - 用途:实现对临界资源的互斥访问(同一时间只允许一个进程 / 线程访问)。
- 示例:
- 初始值
S = 1(资源可用)。 - 进程访问资源前执行
P(S):S变为0,进程可访问,后续进程阻塞。 - 进程释放资源后执行
V(S):S变为1,允许其他进程访问。
- 初始值
- 计数信号量(Counting Semaphore)
- 特点:取值可以是任意非负整数,表示可用资源的数量。
- 用途:控制对有限数量共享资源的并发访问(如连接池、线程池、缓冲区等)。
- 示例:
- 若系统有 3 个打印机,初始值
S = 3。 - 每个进程申请打印机时执行
P(S):S减 1,当S = 0时,后续进程需等待。 - 进程释放打印机时执行
V(S):S加 1,唤醒等待队列中的进程。
- 若系统有 3 个打印机,初始值
信号量的作用
- 实现互斥:通过二进制信号量,确保同一时间只有一个进程 / 线程进入临界区(如修改共享变量),避免数据竞争。
- 实现同步:通过计数信号量,协调多个进程 / 线程的执行顺序。例如:
- 进程 A 生成数据,进程 B 处理数据,可通过信号量确保 B 只能在 A 生成数据后执行。
- 初始时,信号量
S = 0(无数据)。A 生成数据后执行V(S)(S = 1),B 执行P(S)(S = 0)后开始处理。
信号量的实现与注意事项
- 原子性:
P和V操作必须是原子操作(不可中断),否则可能导致信号量状态不一致(如多个进程同时执行P操作时,S的值可能错误)。现代操作系统通过硬件指令(如 Test-and-Set)保证其原子性。 - 死锁风险:若多个进程 / 线程相互等待对方持有的资源,可能导致死锁。例如,进程 A 持有信号量
S1并等待S2,进程 B 持有S2并等待S1,此时两者都会永久阻塞。 - 与互斥锁的区别:
- 互斥锁(Mutex)只能由持有锁的进程 / 线程释放,而信号量可由任意进程 / 线程释放(更灵活,但也易出错)。
- 信号量可用于多个资源的并发控制,互斥锁仅用于单个资源的独占访问。

private boolean isMirror(TreeNode left, TreeNode right) {
// 左右子树都为 null,对称
if (left == null && right == null) {
return true;
}
// 只有一个子树为 null,不对称
if (left == null || right == null) {
return false;
}
// 比较当前节点的值,以及对称位置的子树
return (left.val == right.val) && isMirror(left.left, right.right) && isMirror(left.right, right.left);
}

public int countNodesSimple(TreeNode head) {
if (head == null) {
return 0;
}
// 根节点算1个,加上左子树节点数和右子树节点数
return 1 + countNodesSimple(head.left) + countNodesSimple(head.right);
}
8.13
IP协议的首部结构
IP 协议(以 IPv4 为例)的首部结构由固定部分(20 字节)和可选部分(0~40 字节)组成,共包含 12 个字段,按 32 位(4 字节)一行排列,具体结构如下:
- 版本(Version):4 位,标识 IP 协议版本,IPv4 固定为 0100(十进制 4)。
- 首部长度(IHL):4 位,表示首部总长度(单位为 32 位字),最小值 5(20 字节,仅固定部分),最大值 15(60 字节,含可选部分)。
- 服务类型(TOS):8 位,用于指示服务质量(如延迟、吞吐量优先级),已扩展为区分服务(DS)字段。
- 总长度:16 位,指整个 IP 数据报(首部 + 数据)的总字节数,最大值 65535 字节。
- 标识(Identification):16 位,用于分片重组,同一数据报的分片标识相同。
- 标志(Flags):3 位,前两位有效:DF(1 表示不允许分片)、MF(1 表示后续还有分片)。
- 片偏移:13 位,标识当前分片在原数据报中的偏移量(单位 8 字节),用于重组。
- 生存时间(TTL):8 位,数据包最大存活跳数(每过一个路由器减 1,为 0 则丢弃),防止环路。
- 协议:8 位,指示上层协议(如 TCP=6、UDP=17、ICMP=1)。
- 首部校验和:16 位,仅校验 IP 首部,用于检测传输中的错误。
- 源 IP 地址:32 位,发送方的 IPv4 地址。
- 目的 IP 地址:32 位,接收方的 IPv4 地址。
- 选项(Options):0~40 字节(可选),用于特殊控制(如记录路由),需为 4 字节整数倍。
说一说你对Spring IoC的理解
一、什么是 “控制反转”?
“控制反转” 是相对于 “传统程序设计” 而言的:
- 传统方式:开发者在代码中主动控制对象的创建和依赖关系。例如,在类 A 中需要使用类 B 时,开发者会直接在 A 中通过
new B()创建 B 的实例,即 “我要什么,我自己创建”。 - IoC 方式:对象的创建、依赖关系的维护等 “控制权” 被转移给第三方容器(Spring IoC 容器)。开发者只需定义 “需要什么”,容器会在运行时自动创建对象并注入依赖,即 “我要什么,容器给我什么”。
简言之,IoC 反转的是 “对象的创建权和依赖管理的控制权”—— 从开发者手中转移到了容器手中。
二、Spring IoC 容器:IoC 思想的载体
Spring IoC 的具体实现依赖于IoC 容器,它是一个负责管理对象(Bean)的生命周期(创建、初始化、依赖注入、销毁等)的核心组件。
- 容器的核心功能
- Bean 的定义:通过 XML 配置、注解(如
@Component)或 Java 配置类(@Configuration)定义需要容器管理的对象(称为 “Bean”)。 - Bean 的创建:容器根据定义,在需要时通过反射机制实例化 Bean(开发者无需手动
new)。 - 依赖注入(DI):容器自动将 Bean 所需的依赖对象(其他 Bean)注入到当前 Bean 中(如通过构造器、setter 方法)。
- Bean 的生命周期管理:容器负责 Bean 的初始化(如
@PostConstruct)和销毁(如@PreDestroy)。
- 核心容器接口
Spring 提供了两个主要的容器接口,体现了容器的功能演进:
- BeanFactory:最基础的容器接口,提供了 Bean 的创建、获取等核心功能,采用 “懒加载”(获取 Bean 时才创建)。
- ApplicationContext:BeanFactory 的子接口,扩展了更多功能(如国际化、事件发布、AOP 集成等),采用 “预加载”(容器启动时就创建所有 Bean),是实际开发中最常用的容器类型(如
ClassPathXmlApplicationContext、AnnotationConfigApplicationContext)。
三、依赖注入(DI):IoC 的实现方式
IoC 是思想,依赖注入(Dependency Injection,DI)是实现 IoC 的具体手段—— 即容器通过一定方式(构造器、setter 方法等)将依赖对象 “注入” 到目标 Bean 中。
常见的注入方式:
-
构造器注入:通过 Bean 的构造方法传递依赖,容器在创建 Bean 时调用对应构造器并传入依赖。
@Service public class UserService { private final UserDao userDao; // 构造器注入(推荐,确保依赖不可变) public UserService(UserDao userDao) { this.userDao = userDao; } } -
Setter 方法注入:通过 Bean 的 setter 方法设置依赖,容器在创建 Bean 后调用 setter 传入依赖。
@Service public class UserService { private UserDao userDao; // Setter注入 @Autowired public void setUserDao(UserDao userDao) { this.userDao = userDao; } } -
字段注入:直接在字段上使用
@Autowired注解,容器通过反射直接为字段赋值(简单但不推荐,不利于测试和依赖可见性)。
四、IoC 的核心优势
- 降低耦合度:
传统方式中,类与类之间通过new直接依赖(硬编码),修改一个类可能导致多个类需要调整;而 IoC 中,依赖关系由容器管理,类之间仅通过接口或抽象类关联,实现 “面向接口编程”,耦合度大幅降低。 - 提高代码复用性:
容器管理的 Bean 可以被多个组件共享,无需重复创建。 - 便于测试:
依赖由容器注入,测试时可轻松替换为 Mock 对象(如通过 Spring Test 框架),无需修改业务代码。 - 增强系统扩展性:
当需要替换某个依赖的实现类时,只需修改配置(如换一个@Service的实现),无需修改使用该依赖的类,符合 “开闭原则”。 - 简化开发:
开发者无需关注对象创建和依赖维护的细节,只需专注于业务逻辑,减少重复代码。
说一说你对双亲委派模型的理解
一.双亲委派模型的规则
当一个类加载器需要加载某个类时,它不会先自己尝试加载,而是优先委托给其父类加载器去加载,只有当父类加载器无法加载(即父类加载器的搜索范围内不存在该类)时,才由自己尝试加载。
这里的 “双亲” 并非指继承关系,而是类加载器之间的一种委派层次关系(通常通过 getParent() 方法关联)。
二、工作流程(以加载 com.example.MyClass 为例)
- 应用程序类加载器收到加载请求,首先委托给父类加载器(扩展类加载器)。
- 扩展类加载器收到请求,继续委托给父类加载器(启动类加载器)。
- 启动类加载器在自己的搜索范围(核心类库)中查找
com.example.MyClass,若找不到,返回 “无法加载”。 - 扩展类加载器接收到父类的 “无法加载” 信号,在自己的搜索范围(扩展目录)中查找,若找不到,返回 “无法加载”。
- 应用程序类加载器接收到信号,在
classpath下查找并加载com.example.MyClass,若仍找不到,则抛出ClassNotFoundException。
三、核心作用
- 保证类的唯一性
避免同一个类被不同类加载器重复加载。例如,java.lang.String只会被启动类加载器加载,无论哪个类加载器请求加载,最终都会委派到启动类加载器,确保所有地方使用的String类是同一个类(否则会导致类强转失败等问题)。 - 防止核心类被篡改
假设开发者自定义了一个java.lang.String类,如果没有双亲委派,应用程序类加载器可能会加载这个自定义类,从而替换核心类,引发安全风险。而双亲委派模型下,自定义类会被委派给启动类加载器,而启动类加载器只会加载核心库中的String,从而防止核心类被恶意替换。
进程调度算法有哪些?
- 先来先服务(FCFS):
按进程到达就绪队列的先后顺序调度,先到先执行,直到完成或阻塞。
优点:简单公平;缺点:对短作业不利,长作业会导致短作业等待时间过长(“convoy effect”)。 - 短作业优先(SJF):
优先调度估计运行时间最短的进程。
优点:能有效减少平均等待时间;缺点:需要预先知道作业运行时间,对长作业可能产生 “饥饿”(长期得不到调度)。 - 高响应比优先(HRRN):
综合考虑进程等待时间和估计运行时间,响应比 =(等待时间 + 运行时间)/ 运行时间,优先调度响应比高的进程。
兼顾短作业和长作业,避免 “饥饿”。 - 时间片轮转(RR):
为每个进程分配固定时间片(如 10ms),按顺序轮流执行,时间片用完则切换到下一个进程。
优点:公平性好,响应快,适用于分时系统;缺点:时间片大小影响效率(过短会增加切换开销,过长退化为 FCFS)。 - 优先级调度:
为进程分配优先级,优先调度高优先级进程(优先级可静态分配或动态调整)。
优点:能优先处理紧急任务;缺点:低优先级进程可能 “饥饿”。 - 多级反馈队列调度:
多个优先级队列,进程初始进入低优先级队列,按 RR 调度;若时间片内未完成,升级到高优先级队列。
兼顾短作业(快速完成)、长作业(逐渐提升优先级)和 I/O 密集型进程(频繁阻塞后优先级高),是综合性能较好的算法。

public static int trailingZeroes(int n) {
int count = 0;
while (n > 0) {
n /= 5;
count += n;
}
return count;
}

public static List<Integer> spiralOrder(int[][] matrix) {
List<Integer> result = new ArrayList<>();
if (matrix == null || matrix.length == 0) {
return result;
}
int m = matrix.length, n = matrix[0].length;
int l = 0, r = n - 1, t = 0, b = m - 1;
while (l <= r && t <= b) {
// 从左到右遍历上边界
for (int i = l; i <= r; i++) {
result.add(matrix[t][i]);
}
// 上边界向下移动,如果超过下边界则退出
if (++t > b) break;
// 从上到下遍历右边界
for (int i = t; i <= b; i++) {
result.add(matrix[i][r]);
}
// 右边界向左移动,如果小于左边界则退出
if (--r < l) break;
// 从右到左遍历下边界
if (t <= b) {
for (int i = r; i >= l; i--) {
result.add(matrix[b][i]);
}
// 下边界向上移动
--b;
}
// 从下到上遍历左边界
if (l <= r) {
for (int i = b; i >= t; i--) {
result.add(matrix[i][l]);
}
// 左边界向右移动,如果超过右边界则退出
if (++l > r) break;
}
}
return result;
}
8.14
如何判断MySQL中的索引有没有生效
核心方法:使用 EXPLAIN 分析执行计划
在 SQL 语句前加上 EXPLAIN 关键字,执行后会生成一行或多行信息,通过观察其中的关键字段(如 type、key、key_len 等),即可判断索引是否生效。
示例:
假设有一张 user 表,结构如下:
CREATE TABLE user (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT,
INDEX idx_name (name) -- 为 name 字段创建索引
);
执行带 EXPLAIN 的查询:
EXPLAIN SELECT * FROM user WHERE name = '张三';
关键字段解析(判断索引是否生效)
执行 EXPLAIN 后,重点关注以下字段:
type
表示连接类型,反映查询使用索引的效率,从优到劣常见值为:
system>const>eq_ref>ref>range>index>ALL- 生效标志:
type为const、ref、range等(非ALL),说明使用了索引。 - 失效标志:
type为ALL,表示全表扫描(未使用索引)。
- 生效标志:
key
表示实际使用的索引名称。- 生效标志:
key显示索引名(如idx_name),说明索引被使用。 - 失效标志:
key为NULL,表示未使用任何索引。
- 生效标志:
key_len
表示使用的索引长度(字节数),用于判断索引是否被完全使用(多列索引时尤其重要)。- 生效标志:
key_len大于 0,且长度符合索引定义(如varchar(50)索引的key_len可能为 152,因包含字符集和长度标识)。
- 生效标志:
rows
表示 MySQL 估计需要扫描的行数(非精确值)。- 参考意义:使用索引时,
rows通常远小于表的总记录数;全表扫描时,rows接近表的总记录数。
- 参考意义:使用索引时,
Extra
包含额外执行信息,常见与索引相关的值:Using index:使用了覆盖索引(查询的字段都在索引中,无需回表),效率极高。Using where; Using index:使用索引过滤条件,且覆盖索引。- 失效相关:
Using filesort(需额外排序,未用到索引排序)、Using temporary(使用临时表,未用到索引)、Using where(全表扫描后过滤,未用索引)。
索引生效 / 失效的示例对比
- 索引生效的情况
EXPLAIN SELECT * FROM user WHERE name = '张三';
type=ref(使用了索引)key=idx_name(实际使用name索引)key_len= 152(索引长度有效)
- 索引失效的情况(例如对索引字段做函数操作)
EXPLAIN SELECT * FROM user WHERE SUBSTR(name, 1, 1) = '张'; -- 对 name 做了函数处理
type=ALL(全表扫描)key=NULL(未使用索引)Extra=Using where(全表扫描后过滤)
说一说HashMap的扩容机制
- 扩容的触发条件
HashMap 扩容的核心阈值公式为:
阈值(threshold)= 数组容量(capacity)× 负载因子(loadFactor)
- 容量(capacity):底层桶数组的长度,默认初始值为 16(必须是 2 的幂,如 16、32、64…);
- 负载因子(loadFactor):默认 0.75,是平衡空间与时间效率的阈值(值越高,空间利用率高但冲突概率大,反之则相反);
- 触发时机:当 HashMap 中存储的键值对数量(
size)超过当前阈值时,触发扩容。
- 扩容的核心步骤(以 JDK 1.8 为例)
步骤 1:计算新容量和新阈值
- 新容量 = 原容量 × 2(始终保持 2 的幂,这是通过位运算
newCap = oldCap << 1实现的); - 新阈值 = 新容量 × 负载因子(若原阈值是通过构造函数手动指定的,会特殊处理)。
步骤 2:创建新的桶数组
- 初始化一个长度为新容量的新数组(
newTab),作为扩容后的底层存储结构。
步骤 3:迁移旧数组中的元素到新数组
遍历旧数组(oldTab)中的每个桶,将桶内的元素(链表或红黑树)迁移到新数组,核心是重新计算元素在新数组中的索引:
- 索引计算逻辑:
原索引通过hash & (oldCap - 1)计算,扩容后新索引为hash & (newCap - 1)。
由于新容量是原容量的 2 倍,newCap - 1比oldCap - 1多了一个高位的 1(如 16-1=15 是1111,32-1=31 是11111),因此新索引只有两种可能:- 与原索引相同(当 hash 的新增高位为 0 时);
- 原索引 + 原容量(当 hash 的新增高位为 1 时)。
这种设计避免了复杂的哈希值重新计算,仅通过高位判断即可确定新位置。
- 元素迁移细节:
- 若桶内是链表:遍历链表,按新索引将元素拆分为两个子链表(分别对应 “原索引” 和 “原索引 + 原容量”),直接放入新数组的对应位置;
- 若桶内是红黑树:先尝试将树拆分为两个子链表(若子链表长度 <= 6,则退化为普通链表),再分别放入新数组的对应位置。
步骤 4:替换数组并更新参数
- 将 HashMap 的底层数组引用从旧数组(
oldTab)切换到新数组(newTab); - 更新容量(
capacity)为新容量,更新阈值(threshold)为新阈值。
- 特殊场景的扩容处理
- 初始扩容:当第一次调用
put方法时,若数组未初始化(table == null),会先触发初始化,容量为默认 16 或构造函数指定的初始值(需调整为最接近的 2 的幂); - 容量上限:当容量达到最大阈值(
1 << 30)时,不再扩容,仅将阈值设为Integer.MAX_VALUE,允许元素继续插入(此时冲突概率会增加); - 并发问题:HashMap 是非线程安全的,并发扩容可能导致链表成环(JDK 1.7 及之前),JDK 1.8 虽优化了迁移逻辑,但仍不建议在并发场景使用(需用
ConcurrentHashMap)。
- 扩容的核心目的
- 降低哈希冲突:通过增大数组容量,使元素在桶中的分布更稀疏,减少单个桶内的元素数量(链表 / 红黑树长度);
- 维持高效访问:保证
get、put等操作的时间复杂度接近 O (1)(若不扩容,桶内元素过多会导致查询退化为 O (n) 或 O (log n))。

public static List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
// 先对数组进行排序
Arrays.sort(nums);
int n = nums.length;
// 遍历数组,固定第一个数
for (int i = 0; i < n - 2; i++) {
// 去重:如果当前数和前一个数相同,跳过,避免重复三元组
if (i > 0 && nums[i] == nums[i - 1]) {
continue;
}
int left = i + 1;
int right = n - 1;
// 双指针向中间靠拢
while (left < right) {
int sum = nums[i] + nums[left] + nums[right];
if (sum == 0) {
// 找到满足条件的三元组,加入结果集
result.add(Arrays.asList(nums[i], nums[left], nums[right]));
// 去重:跳过左指针和右指针重复的元素
while (left < right && nums[left] == nums[left + 1]) {
left++;
}
while (left < right && nums[right] == nums[right - 1]) {
right--;
}
// 继续移动指针寻找其他可能的组合
left++;
right--;
} else if (sum < 0) {
// 和小于 0,左指针右移增大和
left++;
} else {
// 和大于 0,右指针左移减小和
right--;
}
}
}
return result;
}

public int maxProfit(int[] prices) {
int n = prices.length;
if (n == 0) {
return 0;
}
// 第一次买入后的现金(负数)
int buy1 = -prices[0];
// 第一次卖出后的现金
int sell1 = 0;
// 第二次买入后的现金(初始为极小值,因为要先完成第一次卖出才能第二次买入 )
int buy2 = Integer.MIN_VALUE;
// 第二次卖出后的现金
int sell2 = 0;
for (int i = 1; i < n; i++) {
// 新的第一次买入状态 = 之前第一次买入状态 或 当天新买入(现金为 -prices[i])
int newBuy1 = Math.max(buy1, -prices[i]);
// 新的第一次卖出状态 = 之前第一次卖出状态 或 之前第一次买入后当天卖出(buy1 + prices[i])
int newSell1 = Math.max(sell1, buy1 + prices[i]);
// 新的第二次买入状态 = 之前第二次买入状态 或 第一次卖出后当天买入(sell1 - prices[i])
int newBuy2 = Math.max(buy2, sell1 - prices[i]);
// 新的第二次卖出状态 = 之前第二次卖出状态 或 之前第二次买入后当天卖出(buy2 + prices[i])
int newSell2 = Math.max(sell2, buy2 + prices[i]);
// 更新状态为新计算的值,用于下一轮循环
buy1 = newBuy1;
sell1 = newSell1;
buy2 = newBuy2;
sell2 = newSell2;
}
// 最大收益是第二次卖出后的现金和第一次卖出后的现金中的较大值(可能只进行一次交易收益更高 )
return Math.max(sell2, sell1);
}
1169

被折叠的 条评论
为什么被折叠?



