背诵

一、网络基础概念

  1. 网络的定义
    网络是多个计算机及通信设备通过通信介质互联的系统,用于信息传输、资源共享、协同工作。
    • 核心目标:可靠、高效、可扩展的数据传输。
    • 设计原则:分层、模块化、接口标准化。
  2. 数据传输方式
    • 单工:单向传输(电视广播)。
    • 半双工:双向传输,但不能同时发送(对讲机)。
    • 全双工:双向同时传输(电话、以太网交换机端口)。
  3. 通信方式
    • 点对点:两个节点直接通信(PPP)。
    • 广播:单点发送,多点接收(Ethernet)。
    • 组播:单点发送,特定组接收(IP 多播)。
  4. 网络分类
    • 按地理范围:LAN、MAN、WAN。
    • 按拓扑结构:星型、总线型、环型、混合型。
    • 按访问控制方式:CSMA/CD(Ethernet)、令牌环(Token Ring)。

二、OSI 七层模型

OSI 模型将网络通信划分为七层,每层负责特定功能,确保模块化与互操作性。

功能 数据单位 典型协议/设备 面试易问点
物理层 比特流传输,电气/光学/机械接口 比特 Bit 网线、光纤、集线器、网卡物理部分、调制解调器 电压、传输速率、双工模式、光纤与铜缆区别
数据链路层 节点间帧传输,差错控制、流量控制、MAC 地址 帧 Frame 以太网、PPP、交换机、网桥 CSMA/CD 工作原理、MAC 地址格式、VLAN
网络层 主机到主机传输,逻辑寻址、路由、拥塞控制 数据包 Packet IP、ICMP、IGMP、路由器 路由选择算法(RIP/OSPF/BGP)、子网掩码计算、NAT
传输层 端到端通信,端口映射、分段重组、流量控制 报文段 Segment (TCP)、数据报 Datagram (UDP) TCP、UDP TCP 三次握手、四次挥手、滑动窗口、端口号分类
会话层 管理应用会话,数据同步 会话数据 NetBIOS、RPC、NFS、SQL 断点续传、会话保持机制
表示层 数据表示、编码、加密压缩 表示数据 JPEG、MPEG、ASCII、Unicode、TLS/SSL 大端/小端、编码转换、加密/解密流程
应用层 提供用户服务,直接与应用交互 应用数据 HTTP、FTP、SMTP、POP3/IMAP、DNS、Telnet、SNMP HTTP 请求/响应报文结构、DNS 查询流程、FTP 主动/被动模式

1. 物理层深度

  • 电气特性:电压幅值、频率、信号调制。
  • 传输介质差异
    • 铜缆(双绞线、同轴): 成本低、易干扰。
    • 光纤: 高速、长距离、抗电磁干扰。
  • 面试常问:光纤单模和多模区别、网卡速率、半双工/全双工。

2. 数据链路层深度

  • MAC 地址:唯一标识网卡,48 位(前 24 位厂商号,后 24 位设备号)。
  • 流量控制
    • 停止等待(Stop-and-Wait)
    • 滑动窗口(Sliding Window)
  • 差错控制:CRC 校验、帧校验序列 (FCS)。
  • VLAN:逻辑分隔网络,减少广播风暴。

3. 网络层深度

  • 逻辑寻址:IPv4/IPv6
  • 子网划分:CIDR、VLSM。
  • 路由协议
    • RIP:跳数最短,收敛慢。
    • OSPF:链路状态,快速收敛。
    • BGP:自治系统间,策略路由。
  • NAT:私网 IP 转换为公网 IP,节约 IP。

4. 传输层深度

TCP

  • 可靠性机制
    • 序列号与确认号。
    • 滑动窗口。
    • 重传机制(超时/快速重传)。
  • 三次握手(建立连接)
    • 第一次 (SYN):客户端请求,seq=client_ISN。
    • 第二次 (SYN-ACK):服务器确认客户端,seq=server_ISN, ack=client_ISN+1。
    • 第三次 (ACK):客户端确认服务器,ack=server_ISN+1。
  • 四次挥手(断开连接)
    • FIN/ACK 交替关闭双向。
    • TIME_WAIT: 2MSL,保证最后 ACK 到达并避免旧报文干扰。
  • 流量控制:滑动窗口
  • 拥塞控制:慢启动、拥塞避免、快速重传、快速恢复。

UDP

  • 特点:无连接、无序、不保证到达。
  • 应用:DNS、VoIP、视频直播。
  • 优点:低延迟、开销小。

5. 会话层深度

  • 会话管理
    • 会话建立、保持、终止。
    • 数据同步点(断点续传)。
  • 面试点:HTTP 是无状态协议,如何实现会话保持?(Cookie/Session/Token)

6. 表示层深度

  • 数据格式化:ASCII、Unicode、EBCDIC。
  • 压缩/解压:JPEG、MPEG、ZIP。
  • 加密/解密:TLS/SSL,实现安全通信。
  • 面试点:大端小端,Base64 编码。

7. 应用层深度

  • HTTP
    • 请求方法:GET/POST/PUT/DELETE/OPTIONS。
    • 状态码:1xx-5xx。
  • DNS
    • 查询类型:A/AAAA/CNAME/MX。
    • 递归/迭代查询。
  • SMTP/POP3/IMAP
    • 发送与接收流程。
  • 面试点
    • FTP 主动/被动模式差异。
    • HTTPS 握手流程(证书交换 + 对称密钥)。

三、TCP/IP 协议族(详细版)

功能 协议 特点 面试易问点
应用层 用户应用服务 HTTP、DNS、FTP、SMTP 与用户交互 HTTP 状态码、DNS 查询过程
传输层 端到端通信 TCP、UDP TCP 可靠、UDP 高效 三次握手、四次挥手、流量控制
网络层 主机到主机 IP、ICMP、ARP IP 无连接、路由选择 子网划分、NAT、ICMP 报文
网络接口层 物理链路传输 Ethernet、PPP 比特传输 MAC 地址、冲突检测

TCP 三次握手总结

  1. 客户端发送 SYN,告知初始序列号。
  2. 服务器返回 SYN-ACK,确认收到 SYN 并告知自身序列号。
  3. 客户端 ACK 确认,双方 ESTABLISHED。

TCP 四次挥手总结

  1. 主动方 FIN,关闭发送。
  2. 被动方 ACK,确认。
  3. 被动方 FIN,关闭发送。
  4. 主动方 ACK,进入 TIME_WAIT,等待 2MSL。

好的,我们可以把“面试重点提示”部分拆解得更深入,每一点都加上原理、实现机制、典型面试问法及回答要点,让你在面试中不仅会答,还能讲出背后的原因。


一、TCP 与 UDP 区别(深入版)

特性 TCP UDP
连接方式 面向连接,需要三次握手建立连接 无连接,直接发送数据报
可靠性 可靠传输,确保数据完整、有序、不丢失 不可靠传输,不能保证顺序和到达
数据单位 报文段 (Segment) 数据报 (Datagram)
速度 较慢,需要建立连接、确认和重传机制 快,无握手和重传,开销小
传输顺序 保证顺序,序列号 + ACK 确认 不保证顺序,独立发送
拥塞控制 有,TCP 拥塞控制算法(慢启动、拥塞避免、快速重传、快速恢复)
应用场景 文件传输 (FTP)、网页访问 (HTTP/HTTPS)、邮件 (SMTP/POP3) 实时视频/语音 (VoIP、直播)、DNS 查询、DHCP
面试拓展点 TCP 适合可靠性要求高的场景 UDP 适合低延迟、可容忍丢包的场景

面试问法示例:

  • “TCP 为什么可靠,UDP 为什么不可靠?”
    回答要点:TCP 使用序列号、ACK 确认、重传机制、流量控制和拥塞控制来保证可靠性;UDP 没有这些机制,直接发送报文,速度快,但不保证到达和顺序。

二、三次握手的意义(深入版)

三次握手步骤复习

  1. 客户端 → 服务器:SYN 报文,请求建立连接,发送 client_ISN。
  2. 服务器 → 客户端:SYN-ACK 报文,确认客户端 SYN,并发送 server_ISN。
  3. 客户端 → 服务器:ACK 报文,确认 server_ISN,连接建立。

为什么需要三次握手?

  1. 防止旧连接报文误建
    • 如果只有两次握手,旧的 SYN 报文可能延迟到达,服务器误认为是新连接。
    • 三次握手确保客户端主动确认,服务器不会因为旧报文进入 ESTABLISHED。
  2. 双向确认收发能力
    • 第一次握手:客户端告诉服务器它能发。
    • 第二次握手:服务器告诉客户端它能发,也确认客户端能发。
    • 第三次握手:客户端确认服务器能收。
    • 面试常问拓展:为什么第一次握手服务器不直接进入 ESTABLISHED?
      答案:服务器不能确定客户端已经准备好接收数据,需第三次握手确认。

三、TIME_WAIT 的意义(深入版)

  • TCP 四次挥手后客户端进入 TIME_WAIT
  • 作用
    1. 确保最后 ACK 能到达服务器
      • 如果 ACK 丢失,服务器会重发 FIN,客户端在 TIME_WAIT 状态可以重新响应。
    2. 防止旧报文干扰新连接
      • 2MSL(Maximum Segment Lifetime)内保证网络中旧报文被清除。
  • 典型面试问法
    • “为什么客户端主动关闭连接进入 TIME_WAIT,而服务器不进入?”
      答案:主动关闭方需要确保 ACK 能到达被动关闭方并防止旧报文干扰新连接;被动关闭方在收到 ACK 后即可关闭。
    • “TIME_WAIT 为什么要等待 2MSL?”
      答案:MSL 是报文在网络中的最大生存时间,等待 2MSL 可以确保所有可能滞留的报文消失。

四、OSI 七层 vs TCP/IP 模型(深入版)

模型 层数 功能 面试关注点
OSI 7 层:应用、表示、会话、传输、网络、数据链路、物理 理论模型,描述网络通信的完整功能 面试常考“每层功能、数据单位、协议、典型设备”
TCP/IP 4 层:应用、传输、网络、网络接口 实际互联网使用的模型,简化 OSI,结合实际协议 面试常考“TCP/IP 与 OSI 的对应关系、核心协议、应用场景”

OSI 与 TCP/IP 对应关系

  • OSI 应用/表示/会话层 → TCP/IP 应用层
  • OSI 传输层 → TCP/IP 传输层
  • OSI 网络层 → TCP/IP 网络层
  • OSI 数据链路层 + 物理层 → TCP/IP 网络接口层

面试拓展点

  1. TCP/IP 为什么只有 4 层?
    • 因为实际应用中,表示层与会话层功能通常由应用层实现,硬件设备的物理和链路层可合并。
  2. OSI 模型为什么重要?
    • 理论指导,帮助理解网络通信分层设计、模块化、互操作性。
  3. 面试常问:OSI 七层模型每层面试问法示例
    • 物理层:双绞线和光纤区别?全双工和半双工区别?
    • 数据链路层:MAC 地址格式?交换机与网桥区别?VLAN 原理?
    • 网络层:IP 地址分类?子网掩码?RIP vs OSPF?
    • 传输层:TCP/UDP 区别?三次握手流程?滑动窗口原理?
    • 会话层:HTTP 无状态如何实现会话保持?
    • 表示层:编码转换?大端小端?TLS 加密流程?
    • 应用层:HTTP 状态码?FTP 主动/被动模式?DNS 查询过程?

1. 物理层

Q1:双绞线和光纤区别?
A1

特性 双绞线 光纤
传输介质 铜线 光纤(玻璃或塑料)
传输方式 电信号 光信号
抗干扰性 容易受电磁干扰 抗电磁干扰强
传输距离 短(100米左右) 长(可达数公里)
传输速率 10Mbps~10Gbps 1Gbps~400Gbps
成本
面试要点:光纤适合高速、长距离、数据中心或核心网,双绞线适合局域网。

Q2:全双工和半双工区别?
A2

  • 半双工:数据双向传输,但同一时间只能一个方向(如对讲机)。
  • 全双工:数据双向传输,可同时收发(如现代交换机端口)。
    面试要点:半双工容易冲突,需要 CSMA/CD,降低效率;全双工无需冲突检测,速率翻倍。

2. 数据链路层

Q1:MAC 地址格式?
A1

  • 48 位二进制,通常写作 6 组十六进制:00:1A:2B:3C:4D:5E
  • 前 24 位:厂商标识(OUI)
  • 后 24 位:网卡序列号
  • 唯一标识网卡设备
    面试要点:MAC 地址用于局域网内通信,交换机通过 MAC 表转发帧。

Q2:交换机与网桥区别?

特性 网桥 (Bridge) 交换机 (Switch)
端口数
功能 转发帧、过滤广播 转发帧、学习 MAC、分割冲突域
性能 较低
面试要点:交换机可以理解为多端口网桥,隔离冲突域,效率更高。

Q3:VLAN 原理?
A3

  • 虚拟局域网(Virtual LAN)在同一物理交换机上划分不同逻辑网段。
  • 通过 802.1Q 标签 给帧打标签,实现逻辑隔离。
  • 优点:隔离广播域,提高安全性和管理性。
    面试要点:同一交换机内不同 VLAN 之间通信需要路由器或三层交换机。

3. 网络层

Q1:IP 地址分类?

类别 起始位 范围 默认子网掩码 用途
A 0 1.0.0.0–126.255.255.255 255.0.0.0 大型网络
B 10 128.0.0.0–191.255.255.255 255.255.0.0 中型网络
C 110 192.0.0.0–223.255.255.255 255.255.255.0 小型网络
D 1110 224.0.0.0–239.255.255.255 N/A 多播
E 1111 240.0.0.0–255.255.255.255 N/A 实验/保留

Q2:子网掩码作用?

  • 用于区分网络号和主机号。
  • 通过按位与运算确定同一网段内主机。
  • 面试问法:给 IP/子网掩码计算可用主机数或广播地址。

Q3:RIP vs OSPF

特性 RIP OSPF
类型 距离矢量 链路状态
收敛速度
度量 跳数 带宽/延迟
缺点 路径可能环路 配置复杂
面试要点:RIP 简单、适合小网络;OSPF 复杂、适合大型网络。

4. 传输层

Q1:TCP/UDP 区别
详见上条回答。

Q2:三次握手流程
详见上条回答,可画图辅助说明。

Q3:滑动窗口原理

  • TCP 使用滑动窗口进行流量控制。
  • 发送方可发送窗口大小内的数据而不必等待 ACK。
  • 接收方通过窗口大小告知可接收缓冲区容量。
  • 好处:提高链路利用率,避免频繁等待确认。
  • 面试问法:滑动窗口与停止等待区别?答案:滑动窗口一次可发送多段,停止等待一次只能发送一段。

5. 会话层

Q1:HTTP 无状态如何实现会话保持?

  • HTTP 本身无状态,每次请求独立。
  • 实现方式
    1. Cookie:客户端保存会话 ID,随请求发送。
    2. Session:服务端保存用户状态,客户端发送标识。
    3. Token/JWT:无状态方式,将用户信息加密放在客户端。
  • 面试要点:讲清楚“状态存放位置”和“优缺点”。

6. 表示层

Q1:编码转换

  • ASCII、UTF-8、Unicode 等,用于不同系统间字符表示兼容。
  • 面试问法:中文 UTF-8 编码占多少字节?
    答案:汉字 3 字节,英文 1 字节。

Q2:大端小端

  • 大端(Big Endian):高位字节存放在低地址。
  • 小端(Little Endian):低位字节存放在低地址。
  • 面试问法:网络传输使用哪种?
    答案:大端(网络字节序)。

Q3:TLS 加密流程

  1. 客户端发送 ClientHello(支持加密算法)。
  2. 服务器返回 ServerHello,发送证书。
  3. 客户端验证证书,生成会话密钥。
  4. 双方用会话密钥加密通信。
  • 面试要点:讲清楚“证书验证”和“对称密钥加密数据”。

7. 应用层

Q1:HTTP 状态码

类型 示例 含义
1xx 100 Continue 信息性响应
2xx 200 OK 成功
3xx 301 Moved Permanently 重定向
4xx 404 Not Found 客户端错误
5xx 500 Internal Server Error 服务器错误

Q2:FTP 主动/被动模式

  • 主动模式:客户端建立控制连接,服务器主动建立数据连接。
  • 被动模式:客户端建立控制连接和数据连接,服务器被动监听。
  • 面试要点:被动模式用于客户端在防火墙后的情况。

Q3:DNS 查询过程

  1. 客户端向本地 DNS 发起递归查询。
  2. 本地 DNS 如果缓存没有,向根 DNS 查询 → 顶级域 → 权威 DNS。
  3. 权威 DNS 返回解析结果到本地 DNS,本地返回给客户端。
  • 面试要点:递归查询 vs 迭代查询区别。


一、前后端交互的基本流程

  1. 前端发起请求
    • 用户在浏览器或客户端操作,比如点击按钮、提交表单。
    • 前端通过 HTTP/HTTPS 或 WebSocket 向后端发送请求。
    • 请求包含:URL、请求方法(GET/POST/PUT/DELETE 等)、请求头(Header)、请求体(Body,POST/PUT 时)和参数。
  2. 后端接收请求
    • 后端服务器(如 Java/Spring、Node.js、Python Flask/Django)解析请求。
    • 后端根据 URL 路径、HTTP 方法匹配路由到对应的处理函数(Controller/Handler)。
  3. 后端处理请求
    • 后端进行业务逻辑处理:
      • 数据库查询/更新(CRUD)
      • 调用其他服务或 API
      • 数据验证、权限校验
    • 处理完成后,生成响应数据(通常为 JSON、XML、HTML 或二进制文件)。
  4. 后端返回响应
    • 响应包含:状态码(HTTP Status Code)、响应头、响应体(Body)。
    • 状态码示例:
      • 200 OK:请求成功
      • 201 Created:资源创建成功
      • 400 Bad Request:请求错误
      • 401 Unauthorized:未授权
      • 500 Internal Server Error:服务器错误
  5. 前端接收响应
    • 前端根据状态码判断请求结果。
    • 前端解析响应体并更新界面(UI),例如渲染数据表格、显示提示信息。
    • 如果是异步请求(AJAX/Fetch/axios),前端可以在回调或 Promise 中处理响应。

二、前后端交互方式

1. HTTP 请求-响应模式

  • 同步请求(传统表单提交):浏览器提交表单,等待响应页面刷新。
  • 异步请求(AJAX/Fetch/axios):页面不刷新,通过 JavaScript 异步获取数据。

2. WebSocket 实时通信

  • 前端与后端建立长连接。
  • 可以实时双向通信,不需要每次都发 HTTP 请求。
  • 适用场景:聊天应用、在线游戏、实时监控。

3. Server-Sent Events(SSE)

  • 后端主动向前端推送事件流。
  • 单向通信(后端 → 前端)。
  • 适用于消息通知、实时数据更新。

4. GraphQL

  • 前端发送一个查询请求,后端根据查询返回精确的数据。
  • 相比 REST,可以减少冗余数据传输。

三、前后端响应示例

前端(JavaScript fetch)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fetch("https://api.example.com/users", {
method: "GET",
headers: {
"Content-Type": "application/json"
}
})
.then(response => {
if (!response.ok) throw new Error("网络错误");
return response.json();
})
.then(data => {
console.log("用户数据:", data);
})
.catch(err => {
console.error("请求失败:", err);
});

后端(Node.js/Express)

1
2
3
4
5
6
7
app.get("/users", (req, res) => {
const users = [
{id: 1, name: "Alice"},
{id: 2, name: "Bob"}
];
res.status(200).json(users);
});
  • 前端发起 GET 请求 → 后端返回 JSON → 前端解析 JSON 更新页面。

四、状态码与响应流程

状态码 含义 前端处理方式
200 OK 请求成功 解析数据更新 UI
201 Created 资源创建成功 可以跳转或提示成功
400 Bad Request 请求参数错误 提示用户检查输入
401 Unauthorized 未授权 跳转登录页或请求授权
404 Not Found 资源不存在 显示 404 页面
500 Internal Server Error 后端错误 显示错误提示或重试

Linux

好的,我来给你整理一份 Linux 常见知识点清单,涵盖基础命令、系统管理、权限、网络、进程、文件系统等方面,适合面试或日常运维参考。我会按模块整理,并给出重点说明。


一、基础命令

1. 文件和目录操作

命令 功能
ls 列出目录内容
cd 切换目录
pwd 查看当前路径
mkdir 创建目录
rm 删除文件/目录(-r递归)
cp 复制文件/目录
mv 移动或重命名
find 查找文件
locate 快速查找文件(需更新索引)

2. 文件查看与编辑

命令 功能
cat 查看文件内容
less / more 分页浏览
head / tail 查看开头/结尾行
grep 文件内容搜索
wc 统计行数、字数、字符数
vim / nano 文本编辑

二、用户与权限管理

1. 用户管理

命令 功能
useradd 添加用户
usermod 修改用户
passwd 修改用户密码
userdel 删除用户
id 查看用户信息

2. 权限管理

命令 功能
chmod 修改文件权限(数字/符号模式)
chown 修改文件所有者
chgrp 修改文件所属组
umask 设置默认权限掩码
sudo 提权执行命令

三、进程管理

命令 功能
ps 查看进程
top / htop 动态查看进程和资源
kill / killall 结束进程
nice / renice 设置进程优先级
jobs / fg / bg 作业控制(后台/前台)

四、网络管理

命令 功能
ifconfig / ip addr 查看网卡IP信息
ping 测试网络连通性
traceroute / tracepath 路由跟踪
netstat / ss 查看端口/连接信息
curl / wget 下载文件或访问URL
scp / rsync 远程复制文件
iptables / firewalld 防火墙管理

五、包管理

  • Debian/Ubuntu 系列
    • apt update / apt upgrade
    • apt install <package> / apt remove <package>
  • RedHat/CentOS 系列
    • yum install <package> / yum remove <package>
    • dnf(新版本替代 yum)

六、磁盘与文件系统管理

命令 功能
df -h 查看磁盘空间
du -h 查看目录/文件大小
mount / umount 挂载/卸载分区
lsblk 查看磁盘分区
fdisk / parted 分区管理
mkfs 格式化文件系统
fsck 检查文件系统

七、日志管理

文件/命令 功能
/var/log/messages 系统日志
/var/log/syslog 系统事件
/var/log/auth.log 用户登录与认证
journalctl 查看 systemd 日志
dmesg 查看内核启动和硬件日志

八、系统管理

命令 功能
uptime 系统运行时间
uname -a 系统信息
free -h 内存使用
vmstat 系统资源统计
df -h 磁盘使用情况
systemctl 管理服务(启动/停止/重启)
crontab 定时任务管理

九、压缩与解压

命令 功能
tar -cvf / tar -xvf 打包/解包
gzip / gunzip 压缩/解压
zip / unzip 压缩/解压

十、Shell 脚本基础(详细版)

1. 变量

  • 定义变量
1
2
NAME="Charlotte"
AGE=25
  • 访问变量
1
echo $NAME
  • 只读变量
1
readonly NAME
  • 环境变量
1
export PATH=$PATH:/new/path

2. 条件判断

  • if 语句
1
2
3
4
5
if [ $AGE -ge 18 ]; then
echo "成年人"
else
echo "未成年人"
fi
  • 常用条件表达式
    表达式 含义
    -eq 等于
    -ne 不等于
    -gt 大于
    -lt 小于
    -ge 大于等于
    -le 小于等于
    -f filename 文件存在且为普通文件
    -d dirname 目录存在
    -x filename 可执行文件

3. 循环语句

  • for 循环
1
2
3
for i in 1 2 3 4 5; do
echo "第 $i 次循环"
done
  • while 循环
1
2
3
4
5
count=1
while [ $count -le 5 ]; do
echo "计数 $count"
count=$((count+1))
done
  • until 循环
1
2
3
4
5
count=1
until [ $count -gt 5 ]; do
echo "计数 $count"
count=$((count+1))
done

4. 函数

1
2
3
4
greet() {
echo "Hello, $1"
}
greet "Charlotte"
  • $1 表示函数传入的第一个参数
  • $@ 表示所有参数
  • return 返回状态码(0表示成功,非0表示失败)

5. 输入输出

  • 标准输入/输出/错误
1
2
3
echo "Hello"         # stdout
read name # stdin
ls nonexist 2> error.log # stderr重定向
  • 管道与重定向
1
cat file.txt | grep "hello" > result.txt

6. 脚本执行权限

1
2
chmod +x script.sh
./script.sh

7. 实用示例

1
2
3
4
#!/bin/bash
# 统计当前目录下所有文件数
count=$(ls -l | grep "^-" | wc -l)
echo "当前目录文件数量: $count"

十一、Linux 面试常问知识点(全面版)


1. 文件权限与权限管理

核心知识点:

  • 权限类型:读(r)、写(w)、执行(x)
  • 三类用户
    • 拥有者(u)
    • 用户组(g)
    • 其他用户(o)
  • 权限表示
    • 符号表示法:rwxr-xr-x
    • 数字表示法:chmod 755 file → 7=rw, x; 5=r-x; 5=r-x

面试常问问题:

  1. 命令 chmod、chown、chgrp 有什么作用?

    • chmod:修改权限
    • chown:修改文件所有者
    • chgrp:修改文件所属组
  2. 硬链接和软链接区别?

    项目 硬链接 软链接
    inode 相同 不同
    跨分区 不行 可以
    删除原文件 内容仍存在 链接失效
    命令 ln source target ln -s source target
  3. 文件描述符和重定向

    • 0 stdin, 1 stdout, 2 stderr
    • 示例:command > out.log 2>&1 → stdout 和 stderr 都写入 out.log

2. 进程与作业管理

核心知识点:

  • 查看进程
    • ps auxtophtop
  • 结束进程
    • kill PIDkill -9 PIDkillall process_name
  • 后台/前台任务
    • &:后台执行
    • jobs:查看后台任务
    • fg %1:切换到前台
  • 优先级
    • nicerenice 设置 CPU 优先级

面试常问问题:

  1. 如何查看端口被哪个进程占用?

    1
    2
    3
    lsof -i :8080
    netstat -tulpn | grep 8080
    ss -tulpn | grep 8080
  2. 如何终止占用端口的进程?

    1
    kill -9 PID

3. 文件系统与磁盘管理

核心知识点:

  • df -h 查看磁盘空间
  • du -h 查看目录大小
  • lsblk 查看分区信息
  • mount / umount 挂载卸载
  • fdisk / parted 分区
  • mkfs 格式化文件系统
  • fsck 检查文件系统

面试常问问题:

  1. 硬链接和软链接在文件系统中有什么区别?

  2. 如何查看磁盘空间和使用情况?

    1
    2
    df -h
    du -sh /path/to/dir

4. 网络管理

核心知识点:

  • 查看 IP 地址:ifconfig / ip addr
  • 测试连通性:ping
  • 路由追踪:traceroute / tracepath
  • 查看端口和连接:netstat / ss
  • 下载文件或请求 URL:wget / curl
  • 远程文件传输:scp / rsync

面试常问问题:

  1. 如何查看网卡信息?

    1
    2
    ip addr show
    ifconfig -a
  2. 如何调试网络连通性?

    1
    2
    ping 8.8.8.8
    traceroute www.baidu.com
  3. 如何查看 8080 端口占用?

    1
    ss -tulpn | grep 8080

5. 用户与组管理

核心知识点:

  • 添加用户:useradd
  • 删除用户:userdel
  • 修改用户:usermod -aG group user
  • 修改密码:passwd user
  • 查看组信息:groups user
  • 提权命令:sudo command

面试常问问题:

  1. 如何给用户加入某个组?

    1
    usermod -aG dev user
  2. 如何限制用户权限?

    • 使用 chmod 设置文件权限
    • 使用 sudoers 配置命令权限

6. Shell 脚本基础

核心知识点:

  • 变量、条件判断、循环、函数
  • 管道、重定向、标准输入输出
  • 常用脚本示例:
1
2
3
4
#!/bin/bash
for file in *.log; do
echo "处理文件: $file"
done

面试常问问题:

  1. 如何判断文件是否存在?
1
2
3
if [ -f "/path/to/file" ]; then
echo "文件存在"
fi
  1. 如何统计目录下文件数量?
1
2
count=$(ls -l | grep "^-" | wc -l)
echo $count

7. 定时任务(Crontab)

核心知识点:

  • 查看:crontab -l
  • 编辑:crontab -e
  • 时间格式:
1
2
* * * * * command
分 时 日 月 星期
  • 示例:每天凌晨 2 点备份
1
0 2 * * * /home/user/backup.sh

8. 系统日志

核心知识点:

  • 常用日志路径:
    文件 内容
    /var/log/messages 系统事件
    /var/log/syslog 系统服务日志
    /var/log/auth.log 登录/认证日志
    /var/log/kern.log 内核日志
    journalctl systemd 日志
  • 查看日志:
1
2
tail -f /var/log/syslog
journalctl -xe

9. 系统管理

核心知识点:

  • 系统信息:uname -a
  • 内存查看:free -h
  • CPU/资源统计:top / vmstat
  • 服务管理:systemctl start/stop/restart/status service
  • 系统启动流程:BIOS → Bootloader → Kernel → init/systemd → 多用户模式

面试常问问题:

  1. 如何查看系统运行时间?
1
shelluptime
  1. 如何管理系统服务?
1
2
3
systemctl restart nginx
systemctl enable nginx
systemctl status nginx

10. 综合示例(面试题)

  1. 如何查找占用 CPU 前 5 的进程?
1
ps aux --sort=-%cpu | head -n 6

2.如何查找 1 小时内修改过的文件?

1
find /path/to/dir -type f -mmin -60

3.如何将错误日志和输出日志写入同一个文件?

1
command > all.log 2>&1

4.如何给用户添加 sudo 权限?

1
usermod -aG sudo username

MySQL 数据库

SQL语句

1. DML (Data Manipulation Language) — 数据操作

用于操作数据库中的数据。

1.1 SELECT 查询

1
2
3
4
5
6
-- 查询指定列,并对结果排序、分页
SELECT column1, column2
FROM table_name
WHERE condition -- 过滤条件
ORDER BY column DESC -- 按列排序,可 ASC 或 DESC
LIMIT offset, count; -- 分页查询,offset: 偏移量, count: 返回行数

聚合函数

1
2
3
SELECT COUNT(*), SUM(column), AVG(column), MAX(column), MIN(column)
FROM table_name
WHERE condition;

分组查询

1
2
3
4
SELECT column, COUNT(*)
FROM table_name
GROUP BY column -- 按 column 分组
HAVING COUNT(*) > 1; -- 分组过滤

联接查询 (JOIN)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-- 内连接,只返回两个表中匹配的行
SELECT a.*, b.*
FROM table_a a
INNER JOIN table_b b ON a.id = b.a_id;

-- 左连接,返回左表所有行及匹配右表的行
SELECT a.*, b.*
FROM table_a a
LEFT JOIN table_b b ON a.id = b.a_id;

-- 右连接,返回右表所有行及匹配左表的行
SELECT a.*, b.*
FROM table_a a
RIGHT JOIN table_b b ON a.id = b.a_id;

-- 全连接 (MySQL 不直接支持,用 UNION ALL 模拟)
SELECT a.*, b.*
FROM table_a a
LEFT JOIN table_b b ON a.id = b.a_id
UNION ALL
SELECT a.*, b.*
FROM table_a a
RIGHT JOIN table_b b ON a.id = b.a_id
WHERE a.id IS NULL;

1.2 INSERT 插入

1
2
3
4
5
6
7
-- 指定列插入
INSERT INTO table_name (column1, column2)
VALUES (value1, value2);

-- 插入所有列
INSERT INTO table_name
VALUES (value1, value2, ...);

1.3 UPDATE 更新

1
2
3
4
UPDATE table_name
SET column1 = value1,
column2 = value2
WHERE condition; -- 必须加 WHERE,否则会更新所有行

1.4 DELETE 删除

1
2
DELETE FROM table_name
WHERE condition; -- 不加 WHERE 会删除所有行,比 TRUNCATE TABLE 慢

2. DDL (Data Definition Language) — 数据定义

用于创建、修改和删除数据库对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- 创建数据库
CREATE DATABASE database_name;

-- 创建表
CREATE TABLE table_name (
column1 datatype constraints,
column2 datatype constraints,
PRIMARY KEY (column_name)
);

-- 修改表结构
ALTER TABLE table_name ADD column_name datatype;
ALTER TABLE table_name DROP COLUMN column_name;
ALTER TABLE table_name MODIFY COLUMN column_name datatype;

-- 删除表
DROP TABLE table_name;

-- 索引操作
CREATE INDEX index_name ON table_name (column_name);
DROP INDEX index_name ON table_name;

3. DCL (Data Control Language) — 数据权限控制

1
2
3
4
5
-- 授权
GRANT privileges ON database.table TO 'user'@'host';

-- 撤销权限
REVOKE privileges ON database.table FROM 'user'@'host';

4. TCL (Transaction Control Language) — 事务控制

1
2
3
4
5
6
7
8
9
10
11
-- 开始事务
START TRANSACTION; -- 或 BEGIN;

-- 提交事务
COMMIT;

-- 回滚事务
ROLLBACK;

-- 设置保存点
SAVEPOINT savepoint_name;

5. EXPLAIN 查询分析 — SQL 优化核心工具

用于分析 SELECT(或 DML)语句的执行计划,判断是否使用索引,以及优化查询性能。

1
EXPLAIN SELECT * FROM table_name WHERE condition;

5.1 EXPLAIN 核心字段解析

字段 说明
id 查询序列号,越大优先级越高;相同 id 按顺序执行。
select_type 查询类型:SIMPLE, PRIMARY, SUBQUERY, DEPENDENT SUBQUERY, UNION, DERIVED 等。
table 正在访问的表名。
partitions 匹配的分区信息(如果有分区表)。
type 连接类型/访问方式,性能从好到差:system → const → eq_ref → ref → range → index → ALL。
possible_keys 查询可能使用的索引。
key 实际使用的索引,NULL 表示未使用索引。
key_len 使用的索引长度(字节),越短越好。
ref 表示索引列与哪个列或常量进行比较。
rows MySQL 估计要扫描的行数,越少越好。
filtered WHERE 条件过滤行的百分比,越高越好。
Extra 额外信息,重要优化指标:- Using filesort:需要额外排序。- Using temporary:需要临时表处理。- Using index:使用覆盖索引,查询效率高。- Using where:使用 WHERE 条件过滤。- Using index condition:索引条件下推(ICP)。- Using join buffer:连接查询未用索引,使用缓存优化。

5.2 type 类型说明(访问方式)

1
2
3
4
5
6
7
system  : 表中只有一行记录,性能极高
const : 通过主键或唯一索引查找,最多一行
eq_ref : 对前表每行组合读取一行,用于主键或唯一索引连接
ref : 非唯一索引等值查找,可能多行
range : 范围查找,使用索引
index : 全索引扫描,优于全表扫描
ALL : 全表扫描,性能最差

5.3 优化建议

  1. 尽量使用索引,避免 ALL 类型。
  2. ORDER BY / GROUP BY 尽量使用索引列,避免 Using filesortUsing temporary
  3. 覆盖索引(Using index)能显著提高性能。
  4. 对复杂查询,使用 EXPLAIN 分析并调整索引或 SQL 结构。
  5. 对 JOIN 查询,保证连接字段有索引。

事务 (Transaction)

事务是一组操作的集合,这些操作要么全部成功,要么全部失败。它具有四大特性 (ACID)。

  • ACID 特性:
    1. 原子性 (Atomicity): 事务是最小的执行单位,不可再分割。事务中的所有操作要么全部成功提交,要么全部失败回滚到事务开始前的状态。
    2. 一致性 (Consistency): 事务执行前后,数据库的完整性约束 (如主键唯一性、外键约束、检查约束、自定义业务规则 ) 没有被破坏,数据从一个一致性状态转换到另一个一致性状态。
    3. 隔离性 (Isolation): 并发执行的事务之间是隔离的,一个事务的执行不应影响其他事务的执行,反之亦然。事务的中间状态对其他事务是不可见的。
    4. 持久性 (Durability): 一旦事务提交,它对数据库中数据的改变就是永久性的,即使系统崩溃也不会丢失。
  • 并发事务带来的问题:
    1. 脏读 (Dirty Read): 一个事务读取了另一个未提交事务的数据。如果这个未提交事务最终回滚,那么之前读取的数据就是“脏数据”。
    2. 不可重复读 (Non-Repeatable Read):一个事务在两次相同的查询中,读取到了不同的数据。通常是因为另一个已提交事务对这些数据进行了 UPDATE 或 DELETE 操作。
    3. 幻读 (Phantom Read):一个事务在两次相同的查询中,读取到了不同数量的行。通常是因为另一个已提交事务对数据进行了 INSERT 操作,导致第二次查询出现了之前没有的行。
    4. 丢失更新 (Lost Update):当两个事务都读取同一数据并进行修改时,其中一个事务的修改覆盖了另一个事务的修改,导致数据丢失。
  • 事务隔离级别 (从低到高):
    1. READ UNCOMMITTED (读未提交):
      • 最低的隔离级别。
      • 允许脏读、不可重复读和幻读。
      • 性能最高,但数据一致性最差。
    2. READ COMMITTED (读已提交):
      • 解决了脏读问题。一个事务只能看到其他事务已经提交的数据。
      • 仍然存在不可重复读和幻读问题。
      • 多数数据库 (如 Oracle, SQL Server) 的默认隔离级别。
    3. REPEATABLE READ (可重复读):
      • MySQL (InnoDB 存储引擎) 的默认隔离级别。
      • 解决了脏读和不可重复读问题。在同一个事务中,多次读取同一数据会得到相同的结果 (通过 MVCC - 多版本并发控制)。
      • 仍然可能存在幻读问题 (但在 InnoDB 默认隔离级别下,通过间隙锁解决了大部分幻读问题)。
    4. SERIALIZABLE (串行化):
      • 最高的隔离级别。
      • 完全解决了脏读、不可重复读和幻读问题。
      • 强制事务串行执行,避免所有并发问题。
      • 性能最低,因为它牺牲了并发性。

索引 (Index)

索引是帮助 MySQL 高效获取数据的数据结构。它类似于书籍的目录,可以快速定位所需数据,而无需扫描整个表。


1. 索引的优缺点

优点

  • 加快数据检索速度:通过索引可以直接定位到数据,避免全表扫描。
  • 减少 I/O 次数:索引结构比数据表小,减少磁盘访问。
  • 优化分组和排序:在 GROUP BYORDER BY 中,可减少 CPU 消耗。

缺点

  • 维护成本:数据增加、更新或删除时,索引也需要更新。
  • 占用存储空间:索引本身也需要占用磁盘。
  • 写入性能影响:虽然查询快,但对 INSERT/UPDATE/DELETE 有一定性能开销。

2. 索引底层实现 — B+ 树

B+ 树特点

  • 数据存储:所有数据都存储在叶子节点,非叶子节点仅存储键值用于导航。
  • 叶子节点链表:方便范围查询和顺序遍历。
  • 树层高低:非叶子节点不存储数据,一个节点可存储更多索引键,降低树高度,减少磁盘 I/O。
  • 适合磁盘存储:节点大小匹配磁盘块大小,最大限度减少磁盘寻道次数。

3. 索引分类

3.1 主键索引 (Primary Key Index)

  • 特殊的唯一索引,一个表只能有一个主键。
  • 不允许 NULL 值。
  • 通常是 聚集索引 (InnoDB 存储引擎)。

3.2 唯一索引 (Unique Index)

  • 列值必须唯一,但允许多个 NULL 值。
  • SQL 示例:
1
CREATE UNIQUE INDEX idx_name ON table_name (column_name);

3.3 普通索引 (Normal Index / Non-Unique Index)

  • 最基本索引,没有唯一性限制。
  • SQL 示例:
1
CREATE INDEX idx_name ON table_name (column_name);

3.4 全文索引 (Fulltext Index)

  • 用于大文本字段的模糊搜索。
  • 仅支持 MyISAM 和 InnoDB。
  • SQL 示例:
1
CREATE FULLTEXT INDEX idx_name ON table_name (column_name);

3.5 复合索引 (Composite / Multi-Column Index)

  • 在多列上创建的索引。
  • 最左前缀原则
    • 查询条件使用复合索引的第一列,则整个索引可用。
    • 仅使用非首列,则索引可能无法完全使用。
  • SQL 示例:
1
CREATE INDEX idx_name ON table_name (column1, column2, column3);

4. 聚集索引与非聚集索引

4.1 聚集索引 (Clustered Index)

  • 叶子节点存储完整数据行
  • 一个表只能有一个聚集索引(通常是主键)。
  • 数据物理顺序与索引逻辑顺序一致。
  • 查询效率高,无需回表。
  • 写入影响:插入、更新、删除需要维护物理顺序。
  • InnoDB 实现
    • 主键索引即聚集索引。
    • 若未定义主键,选择唯一非空索引作为聚集索引;若无,则创建隐藏 6 字节 row_id 作为聚集索引。

4.2 非聚集索引 (Non-Clustered Index)

  • 叶子节点存储主键或指向数据行的指针
  • 表可有多个非聚集索引。
  • 数据物理顺序与索引逻辑顺序无关。
  • 查询需回表:若查询列不在索引中,需要通过索引找到主键,再通过聚集索引获取完整数据。
  • InnoDB 实现:所有非主键索引都是非聚集索引。

5. 索引优化策略

5.1 创建索引的列选择

  • 常用列WHEREJOINORDER BY 中频繁使用的列。
  • 高区分度列:唯一值多的列更适合建立索引。
  • 避免
    • 小表或频繁更新表上的索引。
    • 重复值多的列上建索引。

5.2 遵循最左前缀原则

  • 对复合索引 (a, b, c)
1
2
3
4
WHERE a = 1                -> 索引可用
WHERE a = 1 AND b = 2 -> 索引可用
WHERE b = 2 -> 索引无法完全利用
WHERE c = 3 -> 索引无法完全利用

5.3 避免索引失效

  • 函数或表达式:如 WHERE YEAR(date_column) = 2023
  • 隐式类型转换:索引列类型不同于查询值类型
  • OR 条件:除非两边都有索引,且优化器决定合并索引
  • 左模糊匹配LIKE '%keyword'
  • 不等/非 IN!=NOT IN

5.4 覆盖索引 (Covering Index)

  • 查询所需列都在索引中,无需回表:
1
2
3
SELECT column_in_index
FROM table_name
WHERE indexed_column = value;

5.5 防止回表

  • 尽量让 SELECT 列被索引覆盖,或只查询主键。

5.6 定期维护索引

  • 重建索引或优化碎片化索引,保持性能稳定。


一、B+树的结构特点

  1. 多路平衡查找树:MySQL(InnoDB)使用的是 B+树索引,不同于二叉树,一个节点能存储大量 key。
  2. 非叶子节点:只存储 索引键值 + 子节点指针,不存放真实数据。
  3. 叶子节点:存放 完整的数据行(聚簇索引)主键值(辅助索引),并且通过 双向链表 相连,便于范围查询。
  4. 有序性:索引键在节点内是有序存储的,适合二分查找。

二、定位过程(精确查找某条记录)

假设有一张表:

1
2
3
4
5
CREATE TABLE user (
id INT PRIMARY KEY, -- 聚簇索引
name VARCHAR(50),
age INT
);

索引类型:

  • 聚簇索引(Clustered Index):叶子节点存储整行记录。
  • 辅助索引(Secondary Index):叶子节点存储主键值,再回表查询整行。

1. 精确查找(主键查找)

例如:

1
SELECT * FROM user WHERE id = 25;

定位步骤:

  1. 根节点 开始,根据 id 值进行二分查找,找到对应的子节点指针。
  2. 进入 中间节点,重复二分查找,继续向下。
  3. 进入 叶子节点,找到键值 = 25 的位置。
  4. 因为是 聚簇索引,叶子节点存放了完整行记录,直接返回。

2. 辅助索引查找(非主键查找)

例如:

1
SELECT * FROM user WHERE name = 'Tom';

定位步骤:

  1. 进入 辅助索引 B+树,从根 → 中间节点 → 叶子节点,找到 name = 'Tom' 对应的记录。
  2. 叶子节点存放的不是整行数据,而是 主键值(id)
  3. 根据主键值,再回到 聚簇索引 B+树 中,走一次查找过程,定位到整行数据。

这就是所谓的 回表


三、为什么能“精确定位”

  1. 有序存储 → 节点内可以二分查找,快速缩小范围。
  2. 树高较低 → InnoDB 一般 34 层就能存下百万级数据,每次查找只需磁盘 I/O 34 次。
  3. 聚簇索引 → 直接在叶子节点找到完整行,避免额外跳转。
  4. 辅助索引 + 主键索引 → 通过两次 B+树查找,依然能精确定位。

四、类比理解

B+树就像一个“图书馆目录”:

  • 根节点 = 图书馆的“目录索引页”
  • 中间节点 = 分区目录
  • 叶子节点 = 书架(放书或者书的编号)
  • 聚簇索引 = 直接找到书本
  • 辅助索引 = 先找到书的编号,再去另一个书架找书

👉 总结一句:
MySQL 通过 B+树的有序性和分层查找机制,从根节点到叶子节点逐级定位,最终在叶子节点找到目标记录(主键索引直接拿数据,辅助索引还需回表)。

要不要我帮你画一张 B+树精确查找的图解流程,形象化展示“查找某条记录”的路径?

  • SQL优化

    图片

MySQL 存储引擎

MySQL 支持多种存储引擎,每种存储引擎在事务支持、锁机制、索引类型、适用场景等方面有所不同。

存储引擎 特性 锁机制 事务支持 索引类型 崩溃恢复 适用场景
InnoDB (默认) - 支持事务(ACID)- 支持行级锁- 支持外键约束- 支持崩溃恢复- 默认聚集索引- MVCC(多版本并发控制) 行级锁 支持 聚集索引(叶子节点存储数据行) 支持 redo log / undo log 事务性应用、高并发读写、数据完整性要求高的场景(如电商、金融)
MyISAM - 不支持事务- 不支持外键- 非聚集索引- SELECT COUNT(*) 快 表级锁 不支持 非聚集索引(叶子节点存储数据地址) 不支持 只读或读多写少、对事务性要求不高,需要频繁 COUNT(*) 的应用(如日志、数据仓库)
Memory (HEAP) - 数据存储在内存中,速度快- 重启数据丢失 表级锁 不支持 哈希索引(默认,可用 BTREE) 不支持 临时表、缓存、快速计算
Archive - 高度压缩,节省空间- 支持高速插入和查询- 不支持更新和删除 表级锁 不支持 非聚集索引 不支持 存储历史数据、不常访问的数据仓库
CSV - 数据以 CSV 文件格式存储- 易于与其他应用交换 表级锁 不支持 无索引或简单索引 不支持 数据导入导出、交换数据文件

额外说明

  1. InnoDB 逐渐成为默认引擎:MySQL 5.5 之后,InnoDB 取代 MyISAM,成为事务型应用首选。
  2. 索引和锁机制区别
    • 行级锁:并发性能高,锁粒度小。
    • 表级锁:并发性能低,适合读多写少场景。
  3. 崩溃恢复
    • InnoDB 通过 redo logundo log 保证数据持久性和事务回滚。
    • MyISAM 和 Memory 不支持事务或崩溃恢复。
  4. 存储优化选择
    • 高并发、事务性应用 → InnoDB
    • 只读或日志分析 → MyISAM
    • 临时计算/缓存 → Memory
    • 历史归档数据 → Archive
    • 数据交换 → CSV

MVCC(多版本并发控制):

好的,我们来深入探讨一下 MVCC (Multi-Version Concurrency Control),并分析一些常见的面试题。


什么是 MVCC?

MVCC,即多版本并发控制,是一种在数据库中用于解决并发访问问题的方法。它不是通过加锁的方式来控制并发,而是通过为每个事务生成一个数据快照,让读操作在快照上进行。

你可以把它想象成一个“时光机”。当一个事务开始时,数据库会为它“拍一张照片”,也就是生成一个数据快照。这个事务的所有读操作都只会看到这个快照里的数据,而不会受到其他并发事务修改的影响。这样,读操作就不需要等待写锁释放,从而实现了读写分离,大大提高了并发性能。

简而言之,MVCC 的核心思想是:

  • 读不加锁:读取数据时,直接从数据的历史版本中读取,不需要等待其他事务的写锁。
  • 写不阻塞读:写操作修改数据时,会创建一个新的版本,而旧版本依然保留,供其他读事务使用。

MVCC 的实现原理

MVCC 的实现通常依赖于以下几个核心要素:

  1. 隐藏列(Hidden Columns):

    每个表都会有几个隐藏的列,用于记录版本信息:

    • DB_TRX_ID:事务 ID,记录最近一次修改数据的事务 ID。
    • DB_ROLL_PTR:回滚指针,指向这条记录的上一个版本。
    • DB_ROW_ID:行 ID,是插入新行时分配的隐藏 ID,当主键是字符串时,可能用于辅助索引。
  2. Undo Log (回滚日志):

    Undo Log 记录了数据在被修改之前的值。每次修改数据时,都会将修改前的数据版本记录在 Undo Log 中,并通过回滚指针 DB_ROLL_PTR 将新版本与旧版本连接起来,形成一个版本链。这样,通过版本链,我们就可以追溯到这条数据的历史版本。

  3. Read View (读视图):

    Read View 是 MVCC 的核心,它是一个在事务启动时生成的、用来判断某个数据版本对当前事务是否可见的数据快照。它主要包含以下几个关键信息:

    • m_ids:在生成 Read View 时,当前系统中所有活跃事务的 ID 列表。
    • min_trx_id:在生成 Read View 时,m_ids 中最小的事务 ID。
    • max_trx_id:在生成 Read View 时,系统将要分配给下一个事务的 ID。
    • creator_trx_id:创建 Read View 的事务 ID。

    当一个事务想要读取一条数据时,会根据 Read View 的规则来判断这条数据的**DB_TRX_ID**是否可见。

    • 如果 DB_TRX_ID 小于 min_trx_id,说明这个修改操作在当前事务启动前就已经提交了,数据可见
    • 如果 DB_TRX_ID 大于等于 max_trx_id,说明这个修改操作是在当前事务启动后才发生的,数据不可见
    • 如果 DB_TRX_IDmin_trx_idmax_trx_id 之间,那么需要判断 DB_TRX_ID 是否在 m_ids 列表中。如果在,说明这个修改操作是和当前事务同时启动的,数据不可见;如果不在,说明这个修改操作在当前事务启动前就已经提交了,数据可见

    如果当前版本不可见,事务就会通过回滚指针 DB_ROLL_PTR 沿着版本链找到上一版本,直到找到一个可见的版本。


面试题分析

1. 什么是 MVCC?它解决了什么问题?

回答要点:

  • 概念:多版本并发控制,通过维护数据历史版本实现并发。
  • 解决问题:在数据库隔离级别为**读已提交(RC)可重复读(RR)**时,实现了读写不冲突。它解决了 读写锁冲突脏读 问题,但无法完全解决幻读。
  • 核心思想:读操作读取数据快照,写操作创建新版本。

2. MVCC 是如何实现可重复读(Repeatable Read)的?

回答要点:

  • 核心Read View 的创建时机。
  • 可重复读:事务在第一次读操作时创建 Read View,并且在整个事务的生命周期内都使用这个 Read View。这意味着无论事务中执行多少次读,看到的都是同一个数据快照,所以能保证多次读取结果一致。
  • 读已提交:相比之下,读已提交的隔离级别是每次执行读操作时都重新生成一个 Read View。因此,如果其他事务在两次读操作之间提交了修改,第二次读就能看到新数据,导致不可重复读。

3. MVCC 能解决幻读吗?

回答要点:

  • 部分解决,但不能完全解决。
  • 幻读(Phantom Read):当一个事务在两次查询之间,另一个事务插入了新的数据,导致第一次查询不存在的数据,第二次查询却出现了。
  • MVCC 的作用:MVCC 可以防止更新幻读(即一个事务在两次查询之间,另一个事务更新了数据),因为它总是读取事务启动时的快照。
  • 无法解决:MVCC 无法完全解决插入幻读。例如,事务 A 两次查询 WHERE id > 10,但在两次查询之间,事务 B 插入了一条 id=11 的记录并提交。虽然事务 A 的 Read View 看不到这条新记录,但如果事务 A 执行 UPDATE ... WHERE id > 10 时,它会发现这条新记录并对其加锁,从而更新成功。这会打破可重复读的承诺。
  • InnoDB 的解决方案:InnoDB 数据库在 可重复读 隔离级别下,除了 MVCC,还会结合**间隙锁(Gap Lock)**来彻底解决幻读问题。

4. Undo Log 和 Redo Log 有什么区别?

回答要点:

  • Undo Log (回滚日志)
    • 作用:用于回滚事务和实现 MVCC
    • 记录内容:记录的是数据修改前的版本。
    • 生命周期:在事务提交后,如果数据有其他事务在使用(用于 MVCC),Undo Log 依然保留;如果没有,Undo Log 会被清除。
  • Redo Log (重做日志)
    • 作用:用于保证事务的持久性
    • 记录内容:记录的是数据修改后的日志,比如“某某页的某某偏移量改成了某某值”。
    • 生命周期:在数据同步到磁盘后,Redo Log 就会被清除。
    • 作用点Redo Log 作用于崩溃恢复。当数据库发生宕机时,可以根据 Redo Log 将已提交但尚未写入磁盘的数据重新写入,以保证数据不丢失。

MySQL日志

MySQL的日志系统是其数据库管理系统(DBMS)中至关重要的组成部分,扮演着监控、审计、故障恢复和数据复制等多种关键角色。用户提到的错误日志、查询日志、慢查询日志、事务日志和二进制日志构成了MySQL日志体系的核心。下面将对这些主要日志进行详细的梳理和解析。

1. 错误日志(Error Log)

错误日志是MySQL中最基础的日志之一,它记录了mysqld服务器启动、运行和关闭过程中遇到的所有严重错误和警告。

  • 主要内容:
    • 服务器启动和关闭的详细信息。
    • 运行过程中发生的错误,例如表损坏、无法访问特定文件等。
    • 事件调度器运行出错时的信息。
    • 在主从复制架构中,从服务器上启动和关闭复制线程,连接主服务器时发生的错误等。
  • 作用:错误日志是诊断和解决MySQL服务器问题的首要工具。当数据库无法启动或运行异常时,应首先检查此日志。
  • 配置:默认情况下,错误日志是开启的。其文件名通常为.err,位于数据目录(datadir)下。可以通过在my.cnf或my.ini配置文件中设置log_error变量来指定其路径。

2. 查询日志(Query Log)/通用查询日志(General Query Log)

通用查询日志记录了MySQL服务器接收到的每一个客户端连接和执行的每一条SQL语句。

  • 主要内容:
    • 客户端的连接信息,包括连接时间、用户名和主机。
    • 客户端发送给服务器的所有SQL语句,无论其是否正确执行。
  • 作用:该日志对于数据库的审计和问题排查非常有用,可以精确复现用户的操作序列。然而,由于它会记录所有操作,对系统性能会产生显著影响,并会迅速占用大量磁盘空间。因此,不建议在生产环境中长期开启。
  • 配置:默认关闭。可以通过设置general_log为ON来启用,并使用general_log_file指定日志文件路径。

3. 慢查询日志(Slow Query Log)

慢查询日志用于记录执行时间超过指定阈值的SQL查询语句,是数据库性能优化的关键工具。

  • 主要内容:
    • 执行时间超过long_query_time阈值的SQL语句。
    • 查询执行时的相关信息,如执行时间、锁定时间、扫描的行数、返回的行数以及执行该查询的用户和主机。
  • 作用:通过分析慢查询日志,开发者和数据库管理员(DBA)可以定位到效率低下的SQL语句,并针对性地进行优化,例如添加索引、改写查询等。
  • 配置:默认关闭。需在配置文件中设置slow_query_log为ON开启。long_query_time参数用于设定慢查询的时间阈值(单位:秒),slow_query_log_file用于指定日志文件位置。log_queries_not_using_indexes参数还可以记录未使用索引的查询。

4. 事务日志(Transaction Log)

用户提到的”事务日志”在InnoDB存储引擎中,主要由两种日志构成:重做日志(Redo Log)和回滚日志(Undo Log)。它们共同保证了事务的ACID特性(原子性、一致性、隔离性、持久性)。

  • 重做日志(Redo Log):
    • 作用:保证事务的持久性。它记录了数据被修改后的物理变化。当事务提交后,即使数据尚未完全写入数据文件,只要Redo Log已经持久化,在数据库发生崩溃时,也可以通过重放Redo Log来恢复已提交的事务,确保数据不丢失。这种技术被称为预写日志(Write-Ahead Logging, WAL)。
    • 特点:Redo Log是以循环写的方式记录在连续的物理文件中,大小固定。
  • 回滚日志(Undo Log):
    • 作用:保证事务的原子性和实现多版本并发控制(MVCC)。Undo Log记录的是数据被修改前的状态。当事务需要回滚时,可以通过Undo Log将数据恢复到修改之前的版本。同时,在读已提交(Read Committed)和可重复读(Repeatable Read)隔离级别下,当一个事务需要读取被另一个未提交事务修改的行时,会通过Undo Log读取该行之前的版本,从而实现非锁定读。
    • 特点:Undo Log逻辑上记录了每个修改操作的逆操作。

5. 二进制日志(Binary Log/Binlog)

二进制日志是MySQL中功能最强大、用途最广泛的日志之一。它以二进制格式记录了所有修改数据库数据的操作(DML)以及数据定义语言(DDL)的操作,但不包括SELECT和SHOW等不修改数据的查询。

  • 主要内容:记录了导致数据发生更改的所有事件。根据格式不同,可以记录为SQL语句(STATEMENT格式)、行的变更(ROW格式)或两者的混合(MIXED格式)。
  • 主要作用:
    • 数据恢复(Point-in-Time Recovery):通过备份的数据文件和之后的二进制日志,可以将数据库恢复到过去的任意一个时间点。
    • 主从复制(Replication):在主从架构中,主服务器将二进制日志传送给从服务器,从服务器重放这些日志中的事件,从而实现与主服务器的数据同步。
  • 配置:默认情况下可能关闭,需要通过配置文件中的log_bin选项来启用。启用后,会生成一个索引文件(默认为-bin.index)和一系列的二进制日志文件。

扩展:中继日志(Relay Log)

在主从复制环境中,还有一个重要的日志类型——中继日志。

  • 作用:从服务器的I/O线程从主服务器获取二进制日志,并将其写入本地的中继日志中。然后,从服务器的SQL线程读取中继日志中的事件,并在从服务器上执行,以实现数据同步。
  • 特点:中继日志的格式与二进制日志完全相同。它的存在使得从服务器的I/O和SQL执行可以解耦,即使在网络不稳定的情况下,只要I/O线程将日志拉到本地,SQL线程就可以持续执行。

总结

日志类型 主要作用 生产环境建议
错误日志 记录服务器启停和运行错误 始终开启
查询日志 记录所有连接和SQL语句,用于审计 默认关闭,仅在调试时短期开启
慢查询日志 记录执行缓慢的SQL,用于性能优化 建议开启
事务日志
- Redo Log 保证事务持久性,用于崩溃恢复 InnoDB引擎核心组件,始终开启
- Undo Log 保证事务原子性,支持MVCC InnoDB引擎核心组件,始终开启
二进制日志 数据恢复、主从复制 强烈建议开启,尤其是需要数据恢复和复制的场景
中继日志 主从复制中,从库用于暂存主库的二进制日志 在从服务器上自动创建和管理

MySQL中的锁

第一部分:数据库锁系统

1. 锁的分类体系

1.1 按锁粒度的层次分类

表级锁(Table-Level Lock) 表级锁是最粗粒度的锁机制,一次锁定整个表的所有数据。MyISAM存储引擎主要使用表级锁,其内部维护一个全局的表锁列表。当线程需要访问表时,首先检查表锁状态,如果表已被其他线程以不兼容模式锁定,则当前线程进入等待队列。表级锁的优势在于锁管理开销极小,只需要维护少量的锁对象;缺点是并发度极低,即使访问不同行的操作也会相互阻塞。

页级锁(Page-Level Lock) 页级锁锁定数据页,是表级锁和行级锁的折中方案。BDB存储引擎使用页级锁,每个数据页通常包含多条记录。页级锁的实现需要在页头维护锁信息,包括锁模式、持有者信息等。这种锁粒度在空间局部性较好的应用中表现优秀,因为相关的数据通常存储在相邻的页面中。

行级锁(Row-Level Lock) 行级锁是最细粒度的锁机制,InnoDB存储引擎的核心特性。行锁的实现依赖于索引结构,实际上锁定的是索引记录而不是数据行本身。当查询没有使用索引时,InnoDB会扫描整个表并对所有记录加锁,退化为类似表锁的行为。行级锁提供最高的并发度,但也带来最大的管理开销。

1.2 按锁模式的功能分类

共享锁(Shared Lock, S锁) 共享锁允许多个事务同时读取同一资源,但阻止任何事务修改该资源。在InnoDB中,共享锁通过在锁对象的type_mode字段中设置LOCK_S标志位来标识。多个共享锁可以并存,这是通过锁兼容性矩阵来判断的。共享锁的获取相对简单,只需要检查是否存在冲突的排他锁。

排他锁(Exclusive Lock, X锁) 排他锁提供独占访问,同一时间只能有一个事务持有资源的排他锁。排他锁与任何其他锁都不兼容,包括共享锁和其他排他锁。在InnoDB实现中,排他锁的获取需要等待所有现有的锁释放,这通过等待队列机制来实现。

意向锁(Intention Lock) 意向锁是一种表级锁,用于表明事务在表的某些行上持有或即将请求某种类型的锁。意向共享锁(IS)表示事务意图在某些行上获取共享锁,意向排他锁(IX)表示事务意图在某些行上获取排他锁。意向锁的引入大大简化了表级操作的锁冲突检测,避免了遍历所有行锁的开销。

1.3 按锁算法的实现分类

记录锁(Record Lock) 记录锁锁定索引中的一条具体记录,是最基本的行级锁形式。在InnoDB的实现中,记录锁通过在B+树的叶子节点记录上设置锁标记来实现。锁对象中的heap_no字段精确标识被锁定的记录在页面中的位置。记录锁只能防止其他事务修改或删除该记录,但不能防止在该记录前后插入新记录。

间隙锁(Gap Lock) 间隙锁锁定索引记录之间的间隙,防止其他事务在该间隙中插入新记录。间隙锁的范围是开区间,不包含边界记录本身。InnoDB通过比较索引键值来确定间隙的边界,对于复合索引,间隙的比较需要考虑所有键值列的组合。间隙锁之间不冲突,多个事务可以同时持有相同间隙的间隙锁。

临键锁(Next-Key Lock) 临键锁是记录锁和间隙锁的组合,锁定一个记录以及该记录前面的间隙。这是InnoDB在可重复读隔离级别下的默认锁算法。临键锁有效解决了幻读问题,因为它不仅锁定已存在的记录,还锁定了可能插入新记录的位置。临键锁的范围是左开右闭区间。

2. InnoDB锁系统的深层实现

2.1 锁对象的数据结构设计

1
2
3
4
5
6
7
8
9
10
11
struct lock_t {
trx_t* trx; // 拥有该锁的事务
UT_LIST_NODE_T(lock_t) trx_locks; // 事务锁链表节点
dict_table_t* tab_lock; // 表锁信息
dict_index_t* index; // 索引信息
hash_node_t hash; // 哈希表节点
ulint type_mode; // 锁类型和模式
ulint n_bits; // 位图大小
ulint n_granted_locks; // 已授予的锁数量
ulint n_waiting_locks; // 等待的锁数量
};

锁哈希表的设计: InnoDB使用一个全局的锁哈希表来管理所有的锁对象,哈希函数基于space_id和page_no计算。每个哈希桶包含一个锁对象链表,相同页面的所有锁对象都链接在同一个桶中。这种设计支持快速的锁查找和冲突检测,时间复杂度接近O(1)。

锁位图的精巧设计: 对于页面级别的锁管理,InnoDB使用位图来标识页面中哪些记录被锁定。每个记录对应位图中的一位,设置为1表示该记录被锁定。这种压缩表示法大大节省了内存空间,特别是在锁定大量记录时。位图操作使用高效的位运算指令,支持快速的锁状态查询和更新。

2.2 死锁检测的高级算法

增量式死锁检测: InnoDB不是每次都重新构建整个等待图,而是采用增量式检测。当新的等待关系建立时,算法只检查从新等待者开始的路径是否形成环。这种优化大大减少了检测的计算开销,特别是在高并发场景下效果显著。

死锁检测的优先级调度: 死锁检测器运行在独立的后台线程中,具有较高的调度优先级。检测频率根据系统负载动态调整:在高并发时增加检测频率,在低负载时降低频率以节省CPU资源。检测器还会维护统计信息,包括死锁发生频率、平均解决时间等,用于系统调优。

复杂死锁场景的处理: 实际生产环境中可能出现涉及多个资源类型的复杂死锁,比如同时涉及行锁、表锁、和外键约束锁的死锁。InnoDB的死锁检测器需要构建一个多层次的等待图,包含不同类型的资源节点和等待边。解决这类死锁时,需要考虑不同锁类型的回滚代价。

2.3 MVCC与锁的深度集成

ReadView的内部结构:

1
2
3
4
5
6
7
struct ReadView {
trx_id_t low_limit_id; // 生成ReadView时的下一个事务ID
trx_id_t up_limit_id; // 生成ReadView时最小的活跃事务ID
trx_id_t creator_trx_id; // 创建ReadView的事务ID
trx_ids_t m_ids; // 生成ReadView时的活跃事务ID列表
m_low_limit_no; // 最大的事务编号
};

版本可见性的判断算法: 当事务读取记录时,需要判断记录的某个版本是否对当前事务可见。判断逻辑基于记录的DB_TRX_ID字段和ReadView的信息:如果DB_TRX_ID小于up_limit_id,则该版本对所有事务可见;如果DB_TRX_ID大于等于low_limit_id,则该版本对当前事务不可见;如果DB_TRX_ID在两者之间,则需要检查是否在活跃事务列表中。

undo log的链式结构: 每个事务的undo log形成一个链式结构,记录了该事务的所有修改操作。undo log不仅用于事务回滚,还用于MVCC的版本构建。当需要构建某个历史版本时,InnoDB会从当前版本开始,沿着undo log链向前回溯,逐步应用逆向操作直到达到目标版本。

3. 数据库锁的性能优化策略

3.1 锁等待的自适应策略

等待时间的动态调整: InnoDB实现了自适应的锁等待策略,根据系统负载和锁竞争情况动态调整等待超时时间。在低负载时延长等待时间以减少不必要的回滚,在高负载时缩短等待时间以快速释放资源。这种策略通过维护系统级的统计信息来实现,包括平均锁持有时间、锁竞争频率等。

等待队列的优先级管理: 对于同一资源的多个等待者,InnoDB可以根据事务的重要性、等待时间、资源消耗等因素进行优先级排序。高优先级的事务可以优先获得锁,这对于关键业务操作特别重要。优先级的计算考虑多个维度,包括事务的年龄、修改的数据量、是否为只读事务等。

3.2 索引设计对锁性能的影响

覆盖索引与锁范围优化: 当查询能够通过覆盖索引获取所有需要的数据时,InnoDB只需要在二级索引上加锁,而不需要回表访问聚簇索引。这大大减少了锁的数量和范围,提高了并发性能。覆盖索引的设计需要仔细分析查询模式,将经常一起访问的列组合成复合索引。

索引前缀长度对锁粒度的影响: 对于字符串类型的索引,前缀长度的选择直接影响锁的粒度。较短的前缀可能导致多个不同的值映射到相同的索引前缀,从而扩大锁的范围;较长的前缀提供更精确的锁定,但增加了索引的存储开销。最优前缀长度需要在锁粒度和存储效率之间平衡。

3.3 事务设计对锁性能的优化

事务边界的精细控制: 事务边界的设计直接影响锁的持有时间。长事务会长时间持有锁,阻塞其他事务的执行。优化策略包括:将大事务拆分为多个小事务,减少每个事务的锁持有时间;将只读操作和写操作分离,只读操作使用快照读避免加锁;在事务中优先执行可能失败的操作,减少无效锁持有。

批量操作的锁优化: 对于批量插入、更新、删除操作,可以采用特殊的锁策略。比如批量插入时可以使用INSERT … ON DUPLICATE KEY UPDATE语法,减少锁冲突;批量更新时可以按照索引顺序进行,避免死锁;批量删除时可以分批进行,避免长时间持有大量锁。

行锁(Row Lock)

InnoDB的行锁实现

InnoDB是MySQL中支持行锁的主要存储引擎:
实现原理:

  1. 当事务需要锁定某行时,InnoDB会在该行对应的索引记录上加锁
  2. 如果没有索引,会锁定整个表(实际是锁定所有行)
  3. 锁信息存储在内存中的哈希表结构中

表锁(Table Lock)

MyISAM的表锁实现

MyISAM只支持表级锁

  1. 维护一个全局的表锁队列
  2. 写锁具有更高优先级
  3. 锁信息存储在服务器层面

Java 核心技术

数据类型


八个基本数据类型(Primitive Data Types)

1. 整型

整型用于存储不带小数的整数。Java 提供了四种整型,它们的区别在于所占内存空间的大小,从而影响可存储的数值范围。

  • byte
    • 大小:1 字节(8 位)
    • 范围:−128 到 127
    • 用途:主要用于节省内存,尤其是在处理大量数据时,例如在文件或网络传输中。
  • short
    • 大小:2 字节(16 位)
    • 范围:−32768 到 32767
    • 用途:同样用于节省内存,比 byte 的范围更大。
  • int
    • 大小:4 字节(32 位)
    • 范围:约 −2.1×109 到 2.1×109
    • 用途:这是最常用的整型,通常用来表示普通的整数。
  • long
    • 大小:8 字节(64 位)
    • 范围:约 −9.2×1018 到 9.2×1018
    • 用途:当 int 类型的范围不足以存储一个大数时使用,例如处理时间戳、文件大小等。在数值后面需要加上 Ll 来表示,例如 long myLong = 100L;

2. 浮点型

浮点型用于存储带有小数点的数值。

  • float
    • 大小:4 字节(32 位)
    • 用途:单精度浮点数,有效位数为 6-7 位。在数值后面需要加上 Ff 来表示,例如 float myFloat = 3.14f;
  • double
    • 大小:8 字节(64 位)
    • 用途:双精度浮点数,有效位数为 15-16 位。这是最常用的浮点型,Java 默认的浮点数类型就是 double

注意:浮点数存在精度问题,不适合用于精确的金融计算。如果需要精确计算,应使用 java.math.BigDecimal 类。

3. 字符型

  • char
    • 大小:2 字节(16 位)
    • 用途:存储单个字符。在 Java 中,char 采用 Unicode 编码,可以表示包括中文在内的所有字符。它可以用单引号 ' 括起来,例如 char myChar = 'A';

4. 布尔型

  • boolean
    • 大小:在内存中通常被视为 1 位,但在数组中会占用 1 字节。
    • 用途:用于存储 truefalse,表示逻辑值。

1755509694028

1755509855359

自动拆装箱的本质


自动拆装箱是 Java 编译器在编译时期,根据代码的语法,自动将基本数据类型和其对应的包装类型之间进行转换的一种机制。这在 Java 5 之后被引入,大大简化了代码,让开发者可以像操作基本类型一样操作包装类型。

  • 装箱(Boxing):将基本数据类型(如 int)转换为对应的包装类型(Integer)。
  • 拆箱(Unboxing):将包装类型(Integer)转换为对应的基本数据类型(int)。

自动拆装箱的底层原理


这并非 JVM 的特性,而是编译器的“障眼法”。当你在 Java 代码中使用自动拆装箱时,编译器会在你看不见的地方,自动插入对应的 valueOf()xxxValue() 方法。

1. 自动装箱的原理

当你写下 Integer a = 100; 这样的代码时,编译器会将其自动转换为:

1
Integer a = Integer.valueOf(100);

这就是自动装箱的本质。它调用了包装类的静态方法 valueOf()

Integer.valueOf() 的特殊之处

为了节省内存并提高性能,Integer 类内部维护了一个 缓存。这个缓存的范围通常是 -128 到 127

valueOf() 方法传入的值在这个范围内时,它会直接从缓存中返回对应的 Integer 对象,而不是创建一个新的对象。

所以,当你执行以下代码时:

1
2
3
Integer a = 100;
Integer b = 100;
System.out.println(a == b);

结果是 true,因为 ab 都指向了缓存中的同一个对象。

但如果值超出这个范围:

1
2
3
Integer c = 200;
Integer d = 200;
System.out.println(c == d);

结果就是 false,因为每次都会创建新的 Integer 对象。这个缓存优化机制也存在于 Byte, Short, LongCharacter 类型中。

2. 自动拆箱的原理

当你写下 int i = a; 这样的代码(其中 a 是一个 Integer 对象)时,编译器会将其自动转换为:

1
int i = a.intValue();

这就是自动拆箱的本质。它调用了包装类的实例方法 xxxValue()(如 intValue(), doubleValue() 等)。


注意事项与潜在问题

虽然自动拆装箱让代码更简洁,但如果不了解其原理,可能会引发一些问题:

  • NullPointerException:如果一个包装类型为 null,在进行自动拆箱时,会调用 null 对象的 intValue() 方法,从而抛出 NullPointerException

    1
    2
    Integer i = null;
    int j = i; // 这里会抛出 NullPointerException
  • 性能问题:在循环中频繁地进行自动装箱和拆箱,会不断地创建和销毁对象,这会增加垃圾回收(GC)的负担,影响程序性能。

    1
    2
    3
    4
    5
    // 糟糕的写法
    Integer sum = 0;
    for (int k = 0; k < 10000; k++) {
    sum += k; // 每次循环都会进行一次拆箱和一次装箱
    }

    应该使用基本数据类型来避免这种开销:

    1
    2
    3
    4
    5
    // 更好的写法
    int sum = 0;
    for (int k = 0; k < 10000; k++) {
    sum += k;
    }

好的,我们来详细解析一下静态变量。

什么是静态变量?

在 Java 中,静态变量(Static Variable),也称为类变量(Class Variable),是使用 static 关键字 修饰的变量。它属于 ,而不是类的任何一个 实例对象

这意味着,不管你创建了多少个类的对象,静态变量在内存中都只有一份,并且这唯一的一份数据被该类的所有对象共享。

代码示例:

1
2
3
4
5
6
7
8
9
10
11
public class User {
// 实例变量
public String name;

// 静态变量
public static String company = "Google";

public User(String name) {
this.name = name;
}
}

在这个例子中:

  • name 是一个实例变量。每个 User 对象都有自己独立的 name 属性,user1nameuser2name 互不影响。
  • company 是一个静态变量。无论你创建多少个 User 对象,它们共享同一个 company 变量。如果你修改了 company 的值,所有 User 对象的 company 属性都会跟着改变。

静态变量的内存分配与生命周期

  • 内存分配:静态变量在类加载时,被分配到 方法区(Method Area)静态区 中。这与实例变量不同,实例变量是在创建对象时,随着对象一起分配在 堆(Heap) 中。
  • 生命周期:静态变量的生命周期与类的生命周期相同。当类被加载时,静态变量被创建;当类被卸载时,静态变量才会被销毁。

因为它的生命周期贯穿整个程序运行过程,所以也常常被称为“全局变量”。


如何访问静态变量?

访问静态变量有两种方式,但推荐使用第一种,因为它更符合静态变量“属于类”的特性。

  1. 通过类名访问(推荐)

    这是最常见、最规范的访问方式。

    1
    System.out.println(User.company); // 输出: Google
  2. 通过对象实例访问(不推荐)

    虽然可以通过对象实例来访问静态变量,但编译器会发出警告,因为它可能会造成混淆,让人误以为静态变量是实例特有的。

    1
    2
    User user1 = new User("Alice");
    System.out.println(user1.company); // 输出: Google

静态变量的应用场景

静态变量因其“全局唯一”的特性,非常适合以下场景:

  1. 计数器:需要统计某个类被创建了多少次,可以使用一个静态变量作为计数器。每次创建对象时,就在构造函数中对该静态变量进行自增操作。

    1
    2
    3
    4
    5
    6
    public class Counter {
    public static int count = 0;
    public Counter() {
    count++;
    }
    }
  2. 共享配置:当某些数据是所有对象共有的,比如公司名称、项目名称、数据库连接池等,可以将它们定义为静态变量,方便所有对象共享和访问。

  3. 常量:定义程序中不会改变的常量,通常结合 final 关键字一起使用。

    1
    public static final String SERVER_URL = "http://api.example.com";

    这样定义的常量可以在整个程序中被安全地访问。


静态变量的缺点与注意事项

  • 线程安全问题:由于静态变量是所有线程共享的,当多个线程同时修改它时,可能会发生数据不一致的问题。因此,在使用静态变量时,需要特别注意线程安全,可以考虑使用同步锁或原子类(如 AtomicInteger)来保证并发安全。
  • 过度使用问题:滥用静态变量可能会导致程序耦合度过高,不利于单元测试和代码维护。因为静态变量无法通过依赖注入进行替换,测试时难以模拟或隔离其状态。

new 关键字在 Java 中是用来创建对象实例的。当你在代码中使用 new 关键字时,它会执行以下三个关键步骤:

1. 分配内存

new 关键字会计算新创建对象所需的内存大小,并在 堆(Heap) 上为这个对象分配一块内存空间。这块内存包含了所有实例变量,以及指向该类信息的指针(例如,该类的方法区中的类元信息)。

2. 初始化实例变量

内存分配完成后,Java 会对实例变量进行初始化。

  • 如果在定义时给变量赋了初始值,那么它们会按照代码中的值进行初始化。
  • 如果没有明确赋值,那么它们会被赋予默认值:int 为 0,booleanfalse,引用类型为 null 等。

3. 调用构造方法

初始化实例变量之后,new 关键字会调用类的构造方法。构造方法的主要作用是完成对象的最终初始化工作,例如为实例变量赋上更具体的值。


new 关键字与构造方法的关系

很多人会把 new 关键字和构造方法混为一谈,但它们是两个不同的概念。

  • new 关键字 是一个操作符,负责创建对象分配内存
  • 构造方法 是一个特殊方法,负责初始化对象

你可以把它们想象成这样:

  • new 就像是工厂的流水线,负责制造一个空壳产品(对象)。
  • 构造方法就像是流水线上的工人,负责给这个产品填充内容(初始化)。

new 关键字的作用是实例化,而构造方法的作用是初始化。只有通过 new 关键字,你才能调用构造方法来创建一个新的对象。

new 关键字的局限性

虽然 new 是最常见的对象创建方式,但它也有一些局限性:

  • 强耦合:直接使用 new 会在代码中创建对具体类的依赖,不利于代码解耦和单元测试。
  • 无法灵活创建:你无法通过 new 关键字来动态地决定创建哪个类的实例,也无法在创建对象时进行一些额外的逻辑处理。

因此,在很多设计模式和框架中,会使用其他方式来代替 new,比如:

  • 反射:通过 Class.forName().newInstance() 来创建对象。
  • 工厂模式:通过工厂方法或抽象工厂来创建对象。
  • 依赖注入(DI)框架:如 Spring 框架,通过配置文件或注解自动创建和管理对象。

Object

  •  /**
      * native 方法,用于返回当前运行时对象的 Class 对象,使用了 final 关键字修饰,故不允许子类重写。
      */
     public final native Class<?> getClass()
     /**
      * native 方法,用于返回对象的哈希码,主要使用在哈希表中,比如 JDK 中的HashMap。
      */
     public native int hashCode()
     /**
      * 用于比较 2 个对象的内存地址是否相等,String 类对该方法进行了重写以用于比较字符串的值是否相等。
      */
     public boolean equals(Object obj)
     /**
      * native 方法,用于创建并返回当前对象的一份拷贝。
      */
     protected native Object clone() throws CloneNotSupportedException
     /**
      * 返回类的名字实例的哈希码的 16 进制的字符串。建议 Object 所有的子类都重写这个方法。
      */
     public String toString()
     /**
      * native 方法,并且不能重写。唤醒一个在此对象监视器上等待的线程(监视器相当于就是锁的概念)。如果有多个线程在等待只会任意唤醒一个。
      */
     public final native void notify()
     /**
      * native 方法,并且不能重写。跟 notify 一样,唯一的区别就是会唤醒在此对象监视器上等待的所有线程,而不是一个线程。
      */
     public final native void notifyAll()
     /**
      * native方法,并且不能重写。暂停线程的执行。注意:sleep 方法没有释放锁,而 wait 方法释放了锁 ,timeout 是等待时间。
      */
     public final native void wait(long timeout) throws InterruptedException
     /**
      * 多了 nanos 参数,这个参数表示额外时间(以纳秒为单位,范围是 0-999999)。 所以超时的时间还需要加上 nanos 纳秒。。
      */
     public final void wait(long timeout, int nanos) throws InterruptedException
     /**
      * 跟之前的2个wait方法一样,只不过该方法一直等待,没有超时时间这个概念
      */
     public final void wait() throws InterruptedException
     /**
      * 实例被垃圾回收器回收的时候触发的操作
      */
     protected void finalize() throws Throwable { }
     
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81

    ------

    ## `Object` 类的所有方法

    #### 1. `public final native Class<?> getClass()`

    返回当前正在运行的对象的 **Class** 对象。这个方法是 **final** 的,这意味着你不能重写它。它通常用于反射(Reflection),让你在运行时获取对象的类型信息。

    #### 2. `public native int hashCode()`

    返回对象的 **哈希码** 值。这个哈希值通常用于基于哈希的集合,如 `HashMap`、`HashSet` 和 `HashTable`。**hashCode() 和 equals() 必须保持一致**:如果两个对象 `equals()` 返回 `true`,那么它们的 `hashCode()` 也必须相等。

    #### 3. `public boolean equals(Object obj)`

    比较两个对象是否相等。**Object** 类的默认实现是比较两个对象的**内存地址**。通常情况下,为了比较对象的内容,你需要根据业务逻辑**重写**这个方法。

    #### 4. `protected native Object clone() throws CloneNotSupportedException`

    创建并返回当前对象的**浅拷贝**。要使用 `clone()` 方法,你的类必须实现**`Cloneable`**接口,否则会抛出 `CloneNotSupportedException` 异常。

    #### 5. `public String toString()`

    返回对象的字符串表示。默认返回 `“类名@对象的哈希码的无符号十六进制表示”`。为了提供有意义的字符串描述,通常需要**重写**这个方法。

    #### 6. `public final native void notify()`

    唤醒在此对象监视器(monitor)上等待的**一个**线程。这个方法必须在 `synchronized` 代码块中调用,否则会抛出 `IllegalMonitorStateException`。

    #### 7. `public final native void notifyAll()`

    唤醒在此对象监视器上等待的**所有**线程。与 `notify()` 一样,它也必须在 `synchronized` 代码块中调用。

    #### 8. `public final native void wait(long timeout) throws InterruptedException`

    让当前线程进入等待状态,并释放它所持有的锁,直到其他线程调用 `notify()` 或 `notifyAll()`,或者等待时间超时。

    #### 9. `public final void wait() throws InterruptedException`

    与 `wait(long timeout)` 类似,但它会**无限期**地等待,直到被唤醒。

    #### 10. `public final void wait(long timeout, int nanos) throws InterruptedException`

    更精确的 `wait()` 方法,允许以毫秒和纳秒为单位指定等待时间。

    #### 11. `protected void finalize() throws Throwable`

    当垃圾回收器确定没有对该对象的引用时,垃圾回收器会在该对象上调用此方法。这个方法用于在对象被销毁前执行一些清理工作。**不推荐**使用 `finalize()`,因为它不保证何时执行,甚至可能不执行,并且会影响垃圾回收性能。现代 Java 中应使用 `try-with-resources` 或其他资源管理方式。



    ## **String 类型**

    ### 1. 字节和字符的区别?

    字节是存储容量的基本单位;字符是数字、字母、汉字以及其他语言的各种符号;1 字节 = 8 个二进制单位,一个字符由一个字节或多个字节的二进制单位组成。

    ------

    ### 2. String 为什么要设计为不可变类?

    在 Java 中将 String 设计成不可变的是综合考虑到各种因素的结果。主要的原因主要有以下三点:

    - **字符串常量池的需要**:字符串常量池是 Java 堆内存中一个特殊的存储区域, 当创建一个 String 对象时,假如此字符串值已经存在于常量池中,则不会创建一个新的对象,而是引用已经存在的对象;
    - **允许 String 对象缓存 HashCode**:Java 中 String 对象的哈希码被频繁地使用, 比如在 HashMap 等容器中。字符串不变性保证了 hash 码的唯一性,因此可以放心地进行缓存。这也是一种性能优化手段,意味着不必每次都去计算新的哈希码;
    - **String 被许多的 Java 类(库)用来当做参数**,例如:网络连接地址 URL、文件路径 path、还有反射机制所需要的 String 参数等, 假若 String 不是固定不变的,将会引起各种安全隐患。

    ------

    ### 3. String、StringBuilder、StringBuffer 的区别?

    string类不能被继承,因为被final修饰

    - **String**:用于字符串操作,属于不可变类;【补充:String 不是基本数据类型,是引用类型,底层用 char 数组实现的】
    - **StringBuilder**:与 StringBuffer 类似,都是字符串缓冲区,但线程不安全;
    - **StringBuffer**:也用于字符串操作,不同之处是 StringBuffer 属于可变类,对方法加了同步锁,线程安全

    StringBuffer的补充说明:StringBuffer 中并不是所有方法都使用了 Synchronized 修饰来实现同步:

    ```java
    @Override public StringBuffer insert(int dstOffset, CharSequence s) { // Note, synchronization achieved via invocations of other StringBuffer methods // after narrowing of s to specific type // Ditto for toStringCache clearing super.insert(dstOffset, s); return this; }

执行效率:StringBuilder > StringBuffer > String


4. String 字符串修改实现的原理?

当用 String 类型来对字符串进行修改时,其实现方法是首先创建一个 StringBuffer,其次调用 StringBuffer 的 append() 方法,最后调用 StringBuffer 的 toString() 方法把结果返回。


5. String str = “i” 与 String str = new String(“i”) 一样吗?

不一样,因为内存的分配方式不一样。String str = "i" 的方式,Java 虚拟机会将其分配到常量池中;而 String str = new String("i") 则会被分到堆内存中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class StringTest {
public static void main(String[] args) {
String str1 = "abc";
String str2 = "abc";
String str3 = new String("abc");
String str4 = new String("abc");
System.out.println(str1 == str2);
System.out.println(str1 == str3);
System.out.println(str3 == str4);
}
}
// true
// false
// false
System.out.println(str3.equals(str4)); // true

在执行 String str1 = "abc" 的时候,JVM 会首先检查字符串常量池中是否已经存在该字符串对象,如果已经存在,那么就不会再创建了,直接返回该字符串在字符串常量池中的内存地址;如果该字符串还不存在字符串常量池中,那么就会在字符串常量池中创建该字符串对象,然后再返回。所以在执行 String str2 = "abc" 的时候,因为字符串常量池中已经存在“abc”字符串对象了,就不会在字符串常量池中再次创建了,所以栈内存中 str1 和 str2 的内存地址都是指向 “abc” 在字符串常量池中的位置,所以 str1 = str2 的运行结果为 true。

而在执行 String str3 = new String("abc") 的时候,JVM 会首先检查字符串常量池中是否已经存在“abc”字符串,如果已经存在,则不会在字符串常量池中再创建了;如果不存在,则就会在字符串常量池中创建 “abc” 字符串对象,然后再到堆内存中再创建一份字符串对象,把字符串常量池中的 “abc” 字符串内容拷贝到内存中的字符串对象中,然后返回堆内存中该字符串的内存地址,即栈内存中存储的地址是堆内存中对象的内存地址。String str4 = new String("abc") 是在堆内存中又创建了一个对象,所以 str3 == str4 运行的结果是 false。str1、str2、str3、str4 在内存中的存储状况如下图所示:


6. String 类的常用方法都有那些?

  • indexOf():返回指定字符的索引。
  • charAt():返回指定索引处的字符。
  • replace():字符串替换。
  • trim():去除字符串两端空白。
  • split():分割字符串,返回一个分割后的字符串数组。
  • getBytes():返回字符串的 byte 类型数组。
  • length():返回字符串长度。
  • toLowerCase():将字符串转成小写字母。
  • toUpperCase():将字符串转成大写字符。
  • substring():截取字符串。
  • equals():字符串比较。

7. final 修饰 StringBuffer 后还可以 append 吗?

可以。final 修饰的是一个引用变量,那么这个引用始终只能指向这个对象,但是这个对象内部的属性是可以变化的。

官方文档解释:once a final variable has been assigned, it always contains the same value. If a final variable holds a reference to an object, then the state of the object may be changed by operations on the object, but the variable will always refer to the same object.

String 是一个非常特殊的类型,它不是基本数据类型,而是一个引用数据类型(Reference Data Type)

String 的核心特性

  1. 不可变性(Immutability)
    • String 对象一旦被创建,它的值就不能被改变。
    • 当你对 String 进行修改操作时,比如拼接字符串,实际上是创建了一个新的 String 对象,而原始对象没有改变。
    • 这种不可变性使得 String 对象是线程安全的,可以在多线程环境中被安全地共享。
  2. String 的创建
    • 字面量方式String s = "hello";
      • 这种方式创建的字符串会被存储在**字符串常量池(String Pool)**中。
      • 当创建第二个值为 "hello" 的字符串时,JVM 会直接从常量池中返回已存在的对象引用,而不会创建新的对象。这是一种重要的内存优化。
    • new 关键字方式String s = new String("hello");
      • 这种方式会创建一个新的 String 对象在堆(Heap)中。即使常量池中已经存在 "hello",也会在堆中再创建一个新的对象。

String 的常用方法

  • 拼接
    • 使用 + 运算符:String s3 = s1 + s2;
    • 使用 concat() 方法:s1.concat(s2);
    • 推荐使用 StringBuilderStringBuffer 进行大量字符串拼接,因为它们是可变的,效率更高。
  • 比较
    • equals():比较字符串的内容,返回 boolean 类型。
    • equalsIgnoreCase():忽略大小写比较字符串内容。
    • ==:比较的是对象的引用地址,只有当两个引用指向同一个对象时才为 true
  • 查找
    • indexOf(char):查找字符第一次出现的索引。
    • contains(String):判断是否包含某个子字符串。
  • 修改
    • replace(oldChar, newChar):替换字符串中的字符。
    • substring(beginIndex, endIndex):截取子字符串。

为什么不用 char 数组代替 String?

虽然 String 内部也是用 char 数组实现的,但 String 的不可变性提供了很多优势:

  • 安全性:作为方法参数时,可以防止方法内部修改原始字符串。
  • 线程安全:可以在多线程环境下共享。
  • 哈希码缓存String 的哈希码会被缓存,在作为 HashMap 的键时,性能更好。

基本类型与 String 的转换

  • 基本类型转 String
    • String.valueOf(int i)
    • Integer.toString(int i)
    • "" + i
  • String 转基本类型
    • Integer.parseInt("123")
    • Double.parseDouble("3.14")
    • Float.parseFloat("1.23")
    • 等等。

注意String 转基本类型时,如果字符串格式不正确,会抛出 NumberFormatException

&&& 在 Java 中都是逻辑与运算符,但它们之间有一个至关重要的区别:短路特性


&(逻辑与 / 按位与)

& 运算符有两种不同的用途,取决于它的操作数类型。

1. 逻辑与(当操作数为 boolean 类型时)

& 的两边是布尔表达式时,它执行非短路逻辑与。这意味着无论第一个表达式的结果是 true 还是 false,它都会继续计算第二个表达式。

1
2
3
4
5
6
int a = 10;
if (true & (++a > 10)) {
// 即使第一个表达式是 true,第二个表达式 (++a > 10) 也会被执行。
// a 的值会变为 11。
}
System.out.println(a); // 输出 11

在上面的例子中,++a 总是会被执行,a 的值最终会增加。

2. 按位与(当操作数为整数类型时)

& 的两边是整数类型(如 int, long 等)时,它执行按位与运算。它将两个数的二进制位进行比较,如果对应的位都是 1,则结果位是 1,否则是 0

1
2
3
4
int x = 5;  // 二进制 0101
int y = 3; // 二进制 0011
int result = x & y; // 二进制 0001
System.out.println(result); // 输出 1

&&(短路逻辑与)

&& 运算符只用于布尔类型的操作,它执行短路逻辑与

**短路(Short-circuiting)**是 && 最重要的特性。如果第一个表达式的结果是 false,那么整个表达式的结果必定是 false,因此 && 会立即停止计算,不再执行第二个表达式。

1
2
3
4
5
6
7
int a = 10;
if (false && (++a > 10)) {
// 第一个表达式是 false,整个条件已确定为 false。
// 第二个表达式 (++a > 10) 不会被执行。
// a 的值保持不变。
}
System.out.println(a); // 输出 10

在这个例子中,++a 永远不会被执行,因为它位于短路操作的右侧。


总结

特性 & &&
功能 逻辑与、按位与 仅为逻辑与
短路 不短路 短路
计算 始终计算第二个表达式 当第一个表达式为 false 时,不计算第二个表达式
适用类型 boolean 和整数类型 boolean
性能 可能比 && 慢(如果第一个表达式为 false 通常比 & 快(当第一个表达式为 false 时)

在日常开发中,优先使用 &&。因为它能提高代码效率,并在避免 NullPointerException 等问题时非常有用,比如:

1
2
3
4
// 使用 && 避免空指针异常
if (list != null && list.size() > 0) {
// 如果 list 为 null,第二个表达式不会被执行,程序不会报错。
}

& 通常只在需要两个表达式都必须执行的特定场景下使用,或者用于按位运算。

异常

types-of-exceptions-in-java

Exception 和 Error 有什么区别?

在 Java 中,所有的异常都有一个共同的祖先 java.lang 包中的 Throwable 类。Throwable 类有两个重要的子类:

  • Exception :程序本身可以处理的异常,可以通过 catch 来进行捕获。Exception 又可以分为 Checked Exception (受检查异常,必须处理) 和 Unchecked Exception (不受检查异常,可以不处理)。
  • ErrorError 属于程序无法处理的错误 ,我们没办法通过 catch 来进行捕获不建议通过catch捕获 。例如 Java 虚拟机运行错误(Virtual MachineError)、虚拟机内存不够错误(OutOfMemoryError)、类定义错误(NoClassDefFoundError)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。

⭐️Checked Exception 和 Unchecked Exception 有什么区别?

Checked Exception 即 受检查异常 ,Java 代码在编译过程中,如果受检查异常没有被 catch或者throws 关键字处理的话,就没办法通过编译。

除了RuntimeException及其子类以外,其他的Exception类及其子类都属于受检查异常 。常见的受检查异常有:IO 相关的异常、ClassNotFoundExceptionSQLException…。

Unchecked Exception不受检查异常 ,Java 代码在编译过程中 ,我们即使不处理不受检查异常也可以正常通过编译。

RuntimeException 及其子类都统称为非受检查异常,常见的有(建议记下来,日常开发中会经常用到):

  • NullPointerException(空指针错误)
  • IllegalArgumentException(参数错误比如方法入参类型错误)
  • NumberFormatException(字符串转换为数字格式错误,IllegalArgumentException的子类)
  • ArrayIndexOutOfBoundsException(数组越界错误)
  • ClassCastException(类型转换错误)
  • ArithmeticException(算术错误)
  • SecurityException (安全错误比如权限不够)
  • UnsupportedOperationException(不支持的操作错误比如重复创建同一用户)
  • ……

Throwable 类常用方法有哪些?

  • String getMessage(): 返回异常发生时的详细信息
  • String toString(): 返回异常发生时的简要描述
  • String getLocalizedMessage(): 返回异常对象的本地化信息。使用 Throwable 的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与 getMessage()返回的结果相同
  • void printStackTrace(): 在控制台上打印 Throwable 对象封装的异常信息

try-catch-finally 如何使用?


try块:用于捕获异常。其后可接零个或多个 catch 块,如果没有 catch 块,则必须跟一个 finally 块。

catch块:用于处理 try 捕获到的异常。

finally 块:无论是否捕获或处理异常,finally 块里的语句都会被执行。当在 try 块或 catch 块中遇到 return 语句时,finally 语句块将在方法返回之前被执行。

5. Error 和 Exception 的区别?

Error 类和 Exception 类的父类都是 Throwable 类。主要区别如下:

  • Error 类:一般是指与虚拟机相关的问题,如:系统崩溃、虚拟机错误、内存空间不足、方法调用栈溢出等。这类错误将会导致应用程序中断,仅靠程序本身无法恢复和预防;
  • Exception 类:分为运行时异常和受检查的异常。

6. 运行时异常与受检异常有何异同?

  • 运行时异常:如:空指针异常、指定的类找不到、数组越界、方法传递参数错误、数据类型转换错误。可以编译通过,但是一运行就停止了,程序不会自己处理;
  • 受检查异常:要么用 try ... catch... 捕获,要么用 throws 声明抛出,交给父类处理。

7. throw 和 throws 的区别?

  • throw:在方法体内部,表示抛出异常,由方法体内部的语句处理;throw 是具体向外抛出异常的动作,所以它抛出一个异常实例;
  • throws:在方法声明后面,表示如果抛出异常,由该方法的调用者来处理;表示出现异常的可能性,并不一定会发生这种异常。

8. 常见的异常类有哪些?

  • NullPointerException:当应用程序试图访问空对象时,则抛出该异常。
  • SQLException:提供关于数据库访问错误或其他错误信息的异常。
  • IndexOutOfBoundsException:指示某排序索引(例如对数组、字符串或向量的排序)超出范围时抛出。
  • FileNotFoundException:当试图打开指定路径名表示的文件失败时,抛出此异常。
  • IOException:当发生某种 I/O 异常时,抛出此异常。此类是失败或中断的 I/O 操作生成的异常的通用类。
  • ClassCastException:当试图将对象强制转换为不是实例的子类时,抛出该异常。
  • IllegalArgumentException:抛出的异常表明向方法传递了一个不合法或不正确的参数。

9. 主线程可以捕获到子线程的异常吗?

线程设计的理念:“线程的问题应该线程它自己本身来解决,而不要委托到外部”。

正常情况下,如果不做特殊的处理,在主线程中是不能够捕获到子线程中的异常的。如果想要在主线程中捕获子线程的异常,我们可以用如下的方式进行处理,使用 Thread 的静态方法 Thread.setDefaultUncaughtExceptionHandler(new MyUncaughtExceptionHandle());通常情况下,主线程不能直接捕获到子线程中抛出的异常。这是因为线程是独立的执行流,每个线程都有自己的调用栈。当子线程中发生异常时,如果该异常没有被子线程内部的 try-catch 块捕获,它会沿着子线程的调用栈向上冒泡,最终导致子线程终止。这个异常不会传递到主线程的调用栈中,因此主线程无法感知或捕获它。

这个设计理念被称为“线程的问题应该由线程自己本身来解决,而不是委托到外部”。


捕获子线程异常的方法

虽然主线程无法直接捕获子线程异常,但 Java 提供了两种机制来处理这种情况,允许你在主线程或其他线程中对子线程的异常进行处理:

1. 使用 try-catch 在子线程内部处理

这是最直接也最推荐的方式。在子线程的 run() 方法中,用 try-catch 块将可能抛出异常的代码包围起来。这样,异常就会在子线程内部被捕获并处理,你可以选择记录日志、发送通知,或者执行其他恢复操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class MyRunnable implements Runnable {
@Override
public void run() {
try {
// 可能会抛出异常的代码
throw new RuntimeException("Exception from child thread");
} catch (Exception e) {
// 在子线程中捕获并处理异常
System.err.println("子线程捕获到异常: " + e.getMessage());
}
}
}

public class Main {
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.start();
}
}

2. 设置未捕获异常处理器 (UncaughtExceptionHandler)

当你无法在子线程内部处理所有异常时,可以使用 UncaughtExceptionHandler。这是一个在线程因未捕获异常而突然终止时被调用的回调接口。你可以为每个线程或所有线程设置一个默认的处理器。

A. 为单个线程设置处理器

你可以为特定的 Thread 实例设置一个 UncaughtExceptionHandler。当该线程发生未捕获异常时,JVM 会调用你提供的处理器的 uncaughtException() 方法。

1
2
3
4
5
6
7
8
9
10
11
12
Thread thread = new Thread(() -> {
throw new RuntimeException("Exception from child thread!");
});

thread.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
@Override
public void uncaughtException(Thread t, Throwable e) {
System.err.println("在主线程中捕获到子线程 [" + t.getName() + "] 的异常: " + e.getMessage());
}
});

thread.start();

B. 为所有线程设置默认处理器

如果你想为所有线程(包括线程池中的线程)设置一个通用的异常处理逻辑,可以使用 Thread.setDefaultUncaughtExceptionHandler()。这个处理器会在没有任何特定处理器的线程上被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 在主线程中设置全局默认处理器
Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
@Override
public void uncaughtException(Thread t, Throwable e) {
System.err.println("全局处理器捕获到线程 [" + t.getName() + "] 的异常: " + e.getMessage());
}
});

// 子线程中抛出未处理的异常
Thread thread = new Thread(() -> {
throw new RuntimeException("Global uncaught exception");
});

thread.start();

总结

  • 主线程不能直接捕获子线程的异常,因为它们是独立的执行流。
  • 要处理子线程的异常,最简单的方法是在子线程内部使用 try-catch 块
  • 如果需要在主线程中感知和处理子线程的异常,可以使用 Thread 提供的**UncaughtExceptionHandler**机制。你可以为特定线程或所有线程设置一个处理器,当线程因未捕获异常而终止时,这个处理器会被调用。

创建对象:

好的,Java 中创建对象的方式有多种,除了最常见的 new 关键字,还有其他几种重要的技术。下面我们来详细解析这四种创建对象的方法。


1. 使用 new 关键字

这是最常见、最直接的创建对象方式。当你使用 new 关键字时,它会调用类的构造器来初始化对象。

原理:

  1. 分配内存: JVM 在堆(Heap)上为新对象分配所需的内存空间。
  2. 默认初始化: 成员变量会被赋予默认值(例如,int0booleanfalse,引用类型为 null)。
  3. 调用构造器: JVM 调用指定的构造器来执行初始化代码。构造器中的代码会覆盖之前的默认值。
  4. 返回引用: 对象创建完成后,new 表达式会返回该对象在内存中的引用。

优点:

  • 简单直观,代码可读性好。
  • 可以调用任何可访问的构造器(包括有参和无参)。

缺点:

  • 每次调用 new 都会创建一个新对象,开销较大。

示例:

1
2
3
// 使用 new 关键字创建对象
MyObject obj1 = new MyObject();
MyObject obj2 = new MyObject("hello");

2. 使用 Class 类的 newInstance() 方法(反射)

Class.forName().newInstance() 是通过反射来创建对象的方式。

原理:

  1. 加载类: Class.forName() 方法根据类的全限定名(如 "com.example.MyObject")加载类,并返回对应的 Class 对象。
  2. 创建实例: newInstance() 方法会调用该类的无参构造器来创建对象。

注意:

  • newInstance() 要求类必须有一个无参的构造器,且该构造器必须是可见的(public)。
  • Java 9 之后,Class.newInstance() 被标记为过时(deprecated)
  • 在现代 Java 中,更推荐使用 Constructor 类的 newInstance() 方法,因为它能够调用有参构造器,并且可以处理构造器抛出的异常。

优点:

  • 可以在运行时动态地创建对象,非常灵活。
  • 适用于工厂模式、依赖注入(如 Spring)等场景。

缺点:

  • 性能比 new 关键字差,因为涉及反射机制。
  • 如果类没有无参构造器或构造器不可访问,会抛出异常。
  • 没有编译时检查,错误只能在运行时发现。

示例:

1
2
3
4
5
6
7
// 使用 Class.forName().newInstance() 创建对象
try {
Class<?> clazz = Class.forName("com.example.MyObject");
MyObject obj = (MyObject) clazz.newInstance();
} catch (InstantiationException | IllegalAccessException | ClassNotFoundException e) {
e.printStackTrace();
}

3. 使用 clone() 方法

clone() 方法通过复制现有对象来创建新对象,而不是重新构造。它属于 Object 类的一个 protected 方法。

原理:

  1. 实现接口: 要使用 clone() 方法,你的类必须实现 Cloneable 接口。这个接口是一个标记接口,本身没有任何方法,但它告诉 JVM 这个类可以被克隆。
  2. 重写方法: 你需要重写 Object 类的 clone() 方法,并将其访问修饰符改为 public
  3. 执行克隆: clone() 方法会创建一个新对象,并将现有对象的字段值逐一复制到新对象中。这是一种浅拷贝(shallow copy)。如果你的对象包含引用类型的成员,那么新对象和旧对象将共享同一个引用。

优点:

  • 克隆一个对象比使用 new 关键字创建对象更快,因为它跳过了构造器的初始化过程。
  • 在需要大量创建相同或相似对象时,是一种有效的性能优化手段。

缺点:

  • clone() 方法默认是浅拷贝,如果需要深拷贝(deep copy),需要手动实现。
  • Cloneable 接口和 clone() 方法的设计有些“反直觉”,容易出现问题。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 类需要实现 Cloneable 接口并重写 clone() 方法
class MyObject implements Cloneable {
@Override
public Object clone() throws CloneNotSupportedException {
return super.clone();
}
}

// 使用 clone() 方法创建对象
MyObject original = new MyObject();
try {
MyObject cloned = (MyObject) original.clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}

4. 反序列化(Deserialization)

反序列化是指将字节流恢复成 Java 对象的过程。

原理:

  1. 序列化: 首先,一个对象必须是**可序列化(Serializable)**的,即它实现了 Serializable 接口。
  2. 写入流: 序列化就是将对象的状态(成员变量的值)写入一个字节流中(例如,写入文件或网络)。
  3. 反序列化: 反序列化则是从字节流中读取数据,并重建(re-create)原始对象。这个过程不会调用构造器

优点:

  • 可以将对象持久化到磁盘或在网络中传输,这对于分布式系统和缓存非常重要。
  • 可以跨进程甚至跨机器传输对象。

缺点:

  • 只有实现了 Serializable 接口的类才能进行序列化和反序列化。
  • 如果对象的版本不兼容,反序列化可能会失败。
  • 存在安全风险,因为它能够绕过构造器,如果设计不当可能导致不一致的状态。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 对象必须实现 Serializable 接口
class MyObject implements Serializable {
private String name;
// ...
}

// 序列化
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("object.ser"))) {
oos.writeObject(new MyObject("test"));
}

// 反序列化
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("object.ser"))) {
MyObject obj = (MyObject) ois.readObject();
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}

总结

这四种创建对象的方式各有优劣和适用场景:

  • new:最常用,用于常规的对象创建。
  • 反射:用于运行时动态创建对象,常用于框架开发。
  • clone():用于快速复制现有对象,以提高性能。
  • 反序列化:用于将对象持久化或在网络传输中重建。

理解这些创建对象的方式,可以让你在不同的编程场景中做出更明智的选择。

深拷贝和浅拷贝是 Java 对象复制时需要理解的两个重要概念。它们主要通过 clone() 方法或序列化来实现。

浅拷贝(Shallow Copy)

浅拷贝只复制对象本身,以及其基本数据类型的字段。对于引用类型的字段,它只复制引用地址,新旧对象会共享同一块内存中的引用数据。

实现方式:clone() 方法

Java 中的浅拷贝通常通过实现 Cloneable 接口并重写 Object 类的 clone() 方法来实现。

步骤:

  1. 让你的类实现 java.lang.Cloneable 接口。这是一个标记接口,告诉 JVM 这个类的实例可以被克隆。
  2. 重写 Object 类的 clone() 方法,并将其可见性改为 public
  3. 在重写的方法中调用 super.clone()

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class Address {
String city;

public Address(String city) {
this.city = city;
}
}

class Student implements Cloneable {
String name;
Address address; // 引用类型

public Student(String name, Address address) {
this.name = name;
this.address = address;
}

@Override
public Object clone() throws CloneNotSupportedException {
// 这是浅拷贝的实现
return super.clone();
}
}

public class ShallowCopyExample {
public static void main(String[] args) throws CloneNotSupportedException {
Address addr = new Address("Beijing");
Student originalStudent = new Student("Zhangsan", addr);

// 使用浅拷贝
Student clonedStudent = (Student) originalStudent.clone();

System.out.println("--- 浅拷贝前 ---");
System.out.println("原始学生地址: " + originalStudent.address.city);
System.out.println("克隆学生地址: " + clonedStudent.address.city);

// 修改克隆对象的引用类型字段
clonedStudent.address.city = "Shanghai";

System.out.println("\n--- 浅拷贝后 ---");
System.out.println("原始学生地址: " + originalStudent.address.city); // 原始对象的地址也变了
System.out.println("克隆学生地址: " + clonedStudent.address.city);
}
}

输出:

1
2
3
4
5
6
7
--- 浅拷贝前 ---
原始学生地址: Beijing
克隆学生地址: Beijing

--- 浅拷贝后 ---
原始学生地址: Shanghai
克隆学生地址: Shanghai

可以看到,修改克隆对象的地址,原始对象的地址也随之改变,这正是浅拷贝的特点。


深拷贝(Deep Copy)

深拷贝会创建一个完全独立的新对象,不仅复制基本数据类型,还会递归地复制所有引用类型字段指向的对象。新旧对象之间互不影响。

实现方式一:手动实现递归复制

在重写的 clone() 方法中,对引用类型的字段进行单独的克隆

示例:

修改上面的 Student 类,实现深拷贝:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class Address implements Cloneable {
String city;

public Address(String city) {
this.city = city;
}

// Address 类也必须重写 clone()
@Override
public Object clone() throws CloneNotSupportedException {
return super.clone();
}
}

class Student implements Cloneable {
String name;
Address address;

public Student(String name, Address address) {
this.name = name;
this.address = address;
}

@Override
public Object clone() throws CloneNotSupportedException {
// 先进行浅拷贝
Student clonedStudent = (Student) super.clone();
// 然后对引用类型的字段进行深层拷贝
clonedStudent.address = (Address) this.address.clone();
return clonedStudent;
}
}

public class DeepCopyExample {
public static void main(String[] args) throws CloneNotSupportedException {
Address addr = new Address("Beijing");
Student originalStudent = new Student("Zhangsan", addr);

// 使用深拷贝
Student clonedStudent = (Student) originalStudent.clone();

System.out.println("--- 深拷贝前 ---");
System.out.println("原始学生地址: " + originalStudent.address.city);
System.out.println("克隆学生地址: " + clonedStudent.address.city);

// 修改克隆对象的引用类型字段
clonedStudent.address.city = "Shanghai";

System.out.println("\n--- 深拷贝后 ---");
System.out.println("原始学生地址: " + originalStudent.address.city); // 原始对象没有改变
System.out.println("克隆学生地址: " + clonedStudent.address.city);
}
}

输出:

1
2
3
4
5
6
7
--- 深拷贝前 ---
原始学生地址: Beijing
克隆学生地址: Beijing

--- 深拷贝后 ---
原始学生地址: Beijing
克隆学生地址: Shanghai

这次,修改克隆对象的地址,原始对象没有受到影响。

实现方式二:序列化与反序列化

这是一种更简单、通用的深拷贝方法,特别适用于对象结构复杂的情况。

步骤:

  1. 让所有相关类(包括引用类型)都实现 java.io.Serializable 接口。
  2. 将对象写入一个字节流中(序列化)。
  3. 再从字节流中读出新的对象(反序列化)。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import java.io.*;

class Address implements Serializable {
String city;

public Address(String city) {
this.city = city;
}
}

class Student implements Serializable {
String name;
Address address;

public Student(String name, Address address) {
this.name = name;
this.address = address;
}
}

public class SerializationDeepCopyExample {
public static void main(String[] args) throws Exception {
Address addr = new Address("Beijing");
Student originalStudent = new Student("Zhangsan", addr);

// 序列化和反序列化实现深拷贝
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(originalStudent);
oos.close();

ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bis);
Student clonedStudent = (Student) ois.readObject();
ois.close();

System.out.println("--- 深拷贝前 ---");
System.out.println("原始学生地址: " + originalStudent.address.city);
System.out.println("克隆学生地址: " + clonedStudent.address.city);

// 修改克隆对象的引用类型字段
clonedStudent.address.city = "Shanghai";

System.out.println("\n--- 深拷贝后 ---");
System.out.println("原始学生地址: " + originalStudent.address.city);
System.out.println("克隆学生地址: " + clonedStudent.address.city);
}
}

这个方法代码虽然多,但对于复杂的对象图来说,它比手动递归实现要简单得多,因为它会自动处理所有嵌套对象的拷贝。

接口和抽象类:

在软件开发中,抽象类(abstract class)和接口(interface)都是用于实现多态的重要概念,它们允许你定义一个通用的契约,然后让具体的类去实现这个契约。虽然它们的目的相似,但在用途、设计和实现上存在一些关键区别。


抽象类

抽象类是一个不能被实例化的类,它通常包含抽象方法(没有具体实现的方法)和具体方法(有具体实现的方法)。

主要特点:

  • 语法: 使用 abstract 关键字来声明类和抽象方法。
  • 方法: 可以同时包含抽象方法和具体方法。抽象方法必须在子类中被实现,具体方法则可以选择性地被重写。
  • 成员变量: 可以包含成员变量,包括 privateprotectedpublic 等修饰符。
  • 继承: 一个子类只能继承一个抽象类。如果子类不是抽象类,它必须实现所有父抽象类中的抽象方法。
  • 构造器: 抽象类可以有构造器,但不能直接用 new 关键字来创建实例。它的构造器主要用于在子类中通过 super 关键字调用,以初始化父类的成员。
  • 目的: 通常用于定义一类事物共同的属性和行为。它代表一种“is-a”(是一种)的关系,比如“猫是一种动物”,Cat 类继承 Animal 抽象类。

使用场景:

当你需要定义一个模板,其中一些方法是通用的(已实现),而另一些方法是需要子类根据自身特点去实现的,抽象类是理想选择。例如,一个 Shape 抽象类可以定义一个通用的 draw() 抽象方法,但每个具体的形状(Circle,Rectangle)都有自己独特的绘制方式。同时,它还可以有一个具体的 getColor() 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Java 示例
abstract class Animal {
// 抽象方法,子类必须实现
public abstract void makeSound();

// 具体方法,所有子类都可以直接使用或重写
public void sleep() {
System.out.println("Zzz...");
}
}

class Dog extends Animal {
@Override
public void makeSound() {
System.out.println("Woof woof");
}
}

接口

接口是一个完全抽象的“类”,它定义了一组契约或规范,其中只包含抽象方法和常量。自 Java 8 之后,接口也可以包含默认方法(default method)和静态方法。

主要特点:

  • 语法: 使用 interface 关键字声明。
  • 方法: 在 Java 8 之前,接口中的方法默认都是 public abstract 的,可以省略这两个关键字。自 Java 8 之后,可以有 defaultstatic 方法。自 Java 9 之后,还可以有 private 方法。
  • 成员变量: 接口中的成员变量默认是 public static final 的,也即常量。
  • 实现: 一个类可以实现(implements)多个接口,从而克服了单继承的限制。
  • 目的: 主要用于定义一个能力或行为。它代表一种“can-do”(能做)的关系,比如一个 Car 类可以实现 Driveable 接口,一个 Bird 类可以实现 Flyable 接口。它是一种对行为的抽象。

使用场景:

当你需要定义一种能力,而这种能力可以被不同类别的对象共享时,接口非常有用。例如,Flyable 接口可以被 Airplane、Bird 或 Superman 等完全不相关的类实现,因为它们都具有“飞行”的能力。

Java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Java 示例
interface Flyable {
// 抽象方法,默认 public abstract
void fly();

// Java 8 之后允许的默认方法
default void glide() {
System.out.println("Gliding through the air...");
}
}

class Bird implements Flyable {
@Override
public void fly() {
System.out.println("Flapping wings to fly.");
}
}

核心区别总结

特性 抽象类(Abstract Class) 接口(Interface)
继承/实现 extends,单继承 implements,多实现
方法 可包含抽象方法和具体方法 仅包含抽象方法(Java 8+ 可有 defaultstatic 方法)
成员变量 各种类型变量,包括常量和非常量 只能是 public static final 常量
构造器 可以有 不能有
可访问性 成员可以是 private, protectedpublic 方法默认是 public,变量默认是 public static final
目的 定义一类事物的共同特征和行为,代表“is-a”关系 定义一种能力或行为,代表“can-do”关系

10、抽象类和接口有什么区别?

(1)抽象类中可以定义构造函数,接口不能定义构造函数;

(2)抽象类中可以有抽象方法和具体⽅方法,而接口中只能有抽象方法(public abstract);

(3)抽象类中的成员权限可以是 public、默认、protected(抽象类中抽象方法就是为了重写,所以不能被 private 修饰),而接口中的成员只可以是 public(方法默认:public abstrat、成员变量默认:public static final);

(4)抽象类中可以包含静态方法,而接口中不不可以包含静态方法;

JDK 8 中的改变:

1、在 JDK1.8中,允许在接⼝口中包含带有具体实现的⽅方法,使用 default 修饰,这类⽅方法就是默认方法。 2、抽象类中可以包含静态方法,在 JDK1.8 之前接口中不不能包含静态方法,JDK1.8 以后可以包含。之前不能包含是因为,接口不可以实现方法,只可以定义方法,所以不能使用静态方法(因为静态方法必须实现)。现在可以包 含了,只能直接用接口调用静态⽅方法。JDK1.8 仍然不可以包含静态代码块。

面试官可能会问的深入问题

  • 为什么 Java 要同时保留抽象类和接口?
    • 抽象类侧重于代码复用,通过具体方法减少子类的重复代码,并强制子类实现某些特定行为。
    • 接口侧重于功能扩展和解耦,允许不相关的类拥有相同的行为,实现更灵活的设计,同时避免多继承带来的复杂性。
  • 什么时候用抽象类,什么时候用接口?
    • 如果你想在类层次结构中共享代码,并且这种关系是一种**“is-a”关系**,使用抽象类。
    • 如果你想为不相关的类定义通用的行为,并且希望一个类能拥有多种能力,使用接口。
  • Java 8 的默认方法对接口和抽象类的区别有什么影响?
    • 默认方法的引入模糊了两者的一些界限,接口现在也可以提供方法的默认实现,使得接口的能力更强。
    • 但是,本质区别仍然存在:抽象类可以有状态(非 final 成员变量),而接口不能。 抽象类可以共享状态和具体实现,接口则更关注行为的契约。

简单来说,抽象类是模板,提供了骨架和部分实现;接口是契约,只规定了行为。理解了这一点,就能很好地应对这类面试题。


好的,我帮你系统整理 代理模式(Proxy Pattern) 的概念、分类和特点,并附带易懂示例。


代理模式(Proxy Pattern)

代理模式是一种 结构型设计模式,其核心思想是:为其他对象提供一种代理以控制对这个对象的访问
代理对象(Proxy)充当真实对象(Real Subject)的替身,客户端通过代理访问真实对象,而代理可以在访问前后进行增强操作(如权限校验、延迟加载、日志记录等)。


1. 代理模式的组成

  1. Subject(抽象主题)
    • 定义真实对象和代理对象的公共接口,客户端通过这个接口访问对象。
  2. RealSubject(真实主题)
    • 真正的业务逻辑对象,实现 Subject 接口。
  3. Proxy(代理类)
    • 通过实现 Subject 接口,内部持有 RealSubject 的引用。
    • 可在调用真实对象方法前后添加额外功能。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 示例
interface Subject {
void request();
}

// 真实对象
class RealSubject implements Subject {
@Override
public void request() {
System.out.println("真实对象处理请求");
}
}

// 代理对象
class ProxySubject implements Subject {
private RealSubject real;

public ProxySubject(RealSubject real) {
this.real = real;
}

@Override
public void request() {
System.out.println("代理前处理");
real.request(); // 调用真实对象
System.out.println("代理后处理");
}
}

// 使用
public class Client {
public static void main(String[] args) {
RealSubject real = new RealSubject();
ProxySubject proxy = new ProxySubject(real);
proxy.request();
}
}

2. 代理模式的几种类型及特点

类型 特点 适用场景
静态代理(Static Proxy) - 代理类在编译时就确定- 代理类与真实对象实现相同接口- 优点:简单易懂,编译时检查安全- 缺点:每一个真实对象都需要对应代理类,代理类数量多,扩展性差 权限控制、日志记录等固定功能的增强
动态代理(Dynamic Proxy) - 运行时创建代理类- Java 提供 JDK 动态代理(接口)和 CGLIB 动态代理(继承子类)- 优点:减少代理类数量,增强可扩展性- 缺点:运行时生成字节码,性能稍低 AOP、事务管理、日志记录、RPC 调用
强制代理(Force Proxy) - 真实对象只能通过代理访问- 客户端无法直接访问真实对象 需要严格控制访问权限的场景
远程代理(Remote Proxy) - 为远程对象提供本地代理,隐藏网络通信细节 RPC 调用、分布式系统
虚拟代理(Virtual Proxy) - 延迟创建或加载真实对象- 代理对象在需要时才创建真实对象 大对象延迟加载、懒加载场景
保护代理(Protection Proxy) - 控制真实对象的访问权限- 根据不同用户权限返回不同结果 权限控制、安全管理
缓存代理(Cache Proxy) - 对结果进行缓存,减少真实对象重复计算 频繁查询、计算量大的场景

3. 代理模式的优缺点

优点

  1. 客户端与真实对象解耦,通过代理可以灵活增强功能。
  2. 可以控制访问、延迟加载、记录日志等。
  3. 增强对象功能而不修改原始类。

缺点

  1. 增加系统复杂性,代理类可能增加额外层次。
  2. 对性能有一定影响(尤其是动态代理或远程代理)。

如何实现动态代理?

动态代理的核心思想是在运行时创建一个代理对象,用于在不修改原始类代码的情况下,为对象添加额外的功能(如日志记录、性能监控、事务管理等)。Java 提供了两种主要的动态代理实现方式:JDK 动态代理CGLIB 动态代理

这里我们重点解析 JDK 动态代理的实现,它依赖于接口。


核心组件

JDK 动态代理的实现主要依赖于以下三个核心组件:

  1. 接口 (Interface):代理对象和被代理对象必须实现同一个或一组接口。这是 JDK 动态代理的基础,它强制代理对象和被代理对象具有相同的公共行为。
  2. InvocationHandler:这是动态代理的核心处理逻辑。你需要在其中实现 invoke 方法,这个方法包含了代理对象被调用时要执行的所有逻辑。
  3. Proxy:这是一个工具类,用于动态生成代理对象。你不能直接创建代理对象,而是通过 Proxy 类的静态方法来完成。

实现步骤

动态代理的整个过程可以分为以下几步:

  1. 定义接口:首先,你需要定义一个接口,声明被代理对象和代理对象共同拥有的方法。
  2. 创建被代理对象(目标对象):创建一个类,实现上一步定义的接口,这就是我们真正要代理的对象。
  3. 编写 InvocationHandler:创建一个处理类,它实现 InvocationHandler 接口,并重写 invoke 方法。在这个方法中,你将:
    • 接收被代理对象的实例。
    • 在调用原始方法之前执行一些预处理逻辑。
    • 通过 method.invoke(目标对象, args) 调用被代理对象的原始方法。
    • 在调用原始方法之后执行一些后处理逻辑。
  4. 使用 Proxy 类生成代理对象:调用 Proxy 类的静态方法 newProxyInstance() 来创建代理对象。这个方法需要三个参数:
    • ClassLoader loader:用于加载代理类的类加载器。通常使用被代理对象的类加载器。
    • Class<?>[] interfaces:一个接口数组,代理对象需要实现的接口。
    • InvocationHandler handler:实现了 InvocationHandler 的对象,它包含了代理的逻辑。

invoke() 方法的参数解析

InvocationHandler 接口的 invoke() 方法签名如下:

1
Object invoke(Object proxy, Method method, Object[] args) throws Throwable
  • proxy:这个参数是代理对象本身的引用。在 invoke 方法内部,你应该避免直接调用 proxy 的方法,否则可能导致无限递归调用。
  • method:代表正在被调用的方法,它是 java.lang.reflect.Method 类型的对象。你可以通过它获取方法名、参数类型、返回值类型等信息。
  • args:一个 Object 数组,包含了调用原始方法时传递的所有参数。

通过以上步骤,JVM 会在运行时动态生成一个实现了指定接口、并继承自 Proxy 类的字节码,将其加载到内存中,并最终返回一个代理对象实例。所有对代理对象方法的调用都会被自动转发到 InvocationHandlerinvoke 方法中,从而实现了对原始方法的增强和控制。

Proxy 类的作用是动态创建一个代理对象的类。它提供了许多的方法,但是我们用得最多的就是 newProxyInstance 这个方法:

public static Object newProxyInstance(ClassLoader loader, Class[] interfaces, InvocationHandler handler) throws IllegalArgumentException

  • loader:一个 ClassLoader 对象,定义了由哪个 ClassLoader 对象来对生成的代理对象进行加载;
  • interfaces:一个 Interface 对象的数组,表示的是我将要给我需要代理的对象提供一组什么接口,如果我提供了一组接口给它,那么这个代理对象就宣称实现了该接口(多态),这样我就能调用这组接口中的方法了。
  • handler:一个 InvocationHandler 对象,表示的是当我这个动态代理对象在调用方法的时候,会关联到哪一个 InvocationHandler 对象上。

通过 Proxy.newProxyInstance 创建的代理对象是在 Jvm 运行时动态生成的一个对象,它并不是我们的 InvocationHandler 类型,也不是我们定义的那组接口的类型,而是在运行时动态生成的一个对象。

成员类:

我已经为您将之前关于成员类分类的详细解析和表格总结进行了合并,以提供一个更全面、更易于理解的回答。


在 Java 中,一个类可以定义在另一个类的内部,这种类被称为成员类(Member Class),也被称为嵌套类(Nested Class)。根据其定义方式和特性,成员类主要分为以下四种类型:

1. 静态成员类(Static Member Class)

静态成员类是使用 static 关键字修饰的内部类。它与外部类的实例没有关联,可以像访问静态变量一样,直接通过外部类名来创建它的实例。

  • 特点:
    • 无法直接访问外部类的非静态成员,但可以访问外部类的静态成员。
  • 适用场景:
    • 当一个类与外部类逻辑上紧密相关,但又不需要访问外部类的实例成员时,使用静态成员类。它常用于作为外部类的辅助工具类,比如在一个 LinkedList 类中定义一个 Node 静态成员类来表示链表节点。

2. 内部类(Inner Class)

内部类是没有被 static 关键字修饰的嵌套类。它与外部类的实例紧密相连,必须依附于一个外部类的实例才能被创建。

  • 特点:
    • 可以直接访问外部类的所有成员,包括私有成员。这是内部类最强大的特性。
  • 适用场景:
    • 当一个类的实例需要直接访问外部类的私有状态时,使用内部类。它常用于实现某些特定功能的回调接口。

3. 局部内部类(Local Inner Class)

局部内部类是定义在方法或代码块内部的类。它只能在定义它的方法或代码块内部被访问和实例化。

  • 特点:
    • 无法使用访问修饰符(如 public)。
    • 可以访问外部类的所有成员,但只能访问其所在方法中的 final 或事实上的 final 局部变量。
  • 适用场景:
    • 当某个方法中需要一个只用一次的复杂对象或工具类时,可以使用局部内部类,以避免在类级别创建不必要的成员。

4. 匿名内部类(Anonymous Inner Class)

匿名内部类是一种没有名字的局部内部类,通常用于创建一个只使用一次的类的实例。

  • 特点:
    • 没有名字,因此无法被复用。
    • 必须在创建实例的同时定义类的实现。
    • 无法定义构造器。
    • 可以实现一个接口或继承一个类。
  • 适用场景:
    • 最常用于实现事件监听器或创建线程,尤其是在需要简洁地实现某个接口的单个方法时。

匿名内部类中的局部变量为什么只能用 final

这个限制的根本原因在于生命周期(Lifecycle)

  1. 局部变量的生命周期:局部变量(如方法中的参数或变量)是在方法被调用时创建的,存储在**栈(Stack)**上。当方法执行完毕,这些变量就会被销毁。
  2. 匿名内部类的生命周期:匿名内部类的实例通常会作为一个对象被传递或保存,它的生命周期可能比创建它的方法更长。例如,你可能会将一个实现了事件监听器的匿名内部类实例作为参数传递给一个 UI 组件,当方法返回后,这个监听器对象可能仍然存在并等待事件触发。

问题出现:

如果匿名内部类可以访问和修改一个非 final 的局部变量,那么当创建它的方法执行完毕、局部变量被销毁后,匿名内部类的实例仍然存在。当它试图访问这个已经不存在的变量时,就会引发一个**悬空引用(Dangling Reference)**的错误。

为了解决这个问题,Java 编译器采取了以下策略:

  • 将局部变量复制一份:当匿名内部类访问一个局部变量时,编译器会为它在堆(Heap)上创建一个副本。这个副本会和匿名内部类实例一起存储,从而确保匿名内部类在任何时候都能访问到这个值。
  • 强制 final 保证一致性:如果允许修改原始的局部变量,而匿名内部类使用的是它的副本,那么就会导致原始变量和副本之间的数据不一致。为了避免这种混乱,Java 强制要求被匿名内部类访问的局部变量必须是 final 的,以确保其值不可变。final 关键字保证了原始变量和副本的值始终相同。

Java 8 之后的改变:“事实上的 final”(Effectively Final)

在 Java 8 之后,为了简化代码,这个规则有了一些放松。你不再需要显式地使用 final 关键字。只要一个局部变量在定义后没有被重新赋值,它就会被编译器自动视为“事实上的 final”。

这使得代码看起来更简洁,但本质上的限制并没有改变:匿名内部类仍然只能访问那些值在方法执行期间不会改变的局部变量。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
public void doSomething() {
String name = "Alice"; // 事实上的 final
// name = "Bob"; // 如果在这里重新赋值,则会编译错误

new Thread(new Runnable() {
@Override
public void run() {
// 匿名内部类访问局部变量
System.out.println("Hello, " + name);
}
}).start();
}

在这个例子中,name 变量没有被 final 修饰,但在它被匿名内部类访问后没有被修改,所以编译器会将其视为事实上的 final,代码可以正常编译和运行。但如果你尝试在 doSomething 方法的 new Thread 之后修改 name,编译器就会报错。


总结表格

类型 关键字 定义位置 访问外部类成员 创建方式
静态成员类 static 在外部类内部 只能访问外部类的 静态成员 new Outer.StaticInner();
内部类 在外部类内部 可访问外部类的所有成员 new Outer().new Inner();
局部内部类 在方法或代码块内部 可访问外部成员和 final 局部变量 只能在定义它的方法内部创建
匿名内部类 在方法或代码块内部 同局部内部类 new Interface/Class() { ... };

匿名内部类、函数式接口和 Lambda 表达式是 Java 语言在不断演进过程中,为了简化代码和支持函数式编程而引入的重要特性。它们经常在面试中被一起考察,因为它们之间存在演变和替代的关系。


1. 匿名内部类 (Anonymous Inner Class)

匿名内部类是一种没有名字的局部内部类,通常用于创建一个只使用一次的类的实例。

  • 核心特点:
    • 没有名字: 无法被复用。
    • 创建时定义: 必须在创建实例的同时定义类的实现。
    • 只能实现一个: 它可以实现一个接口或继承一个类,但不能两者都做。
  • 用途:
    • 主要用于简化代码,特别是当一个类只被使用一次,且其实现非常简单时。
    • 常见的应用场景是事件监听器(ActionListener)、线程(Runnable)等。

示例:

创建一个线程,实现 Runnable 接口:

1
2
3
4
5
6
7
Thread t = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Hello from an anonymous inner class!");
}
});
t.start();

2. 函数式接口 (Functional Interface)

函数式接口是指只包含一个抽象方法的接口。它可以包含其他默认方法(default)、静态方法(static)和来自 Object 类的方法(如 equals)。

  • 核心特点:
    • 一个抽象方法: 这是函数式接口的唯一强制要求。
    • @FunctionalInterface 注解: 这是一个可选的注解,但强烈推荐使用。它会帮助编译器检查该接口是否满足函数式接口的定义,如果违反了规则,就会报错。

用途:

  • 它为 Lambda 表达式提供了一个目标类型。Lambda 表达式的本质就是函数式接口的一个匿名实现。
  • java.util.function 包中定义了大量的函数式接口,如 PredicateConsumerFunctionSupplier,它们是 Java 8 Stream API 的基础。

示例:

一个自定义的函数式接口

1
2
3
4
@FunctionalInterface
interface MyFunctionalInterface {
void sayHello();
}

3. Lambda 表达式 (Lambda Expression)

Lambda 表达式是 Java 8 引入的一个新特性,它提供了一种简洁的方式来表示函数式接口的实现。Lambda 表达式可以被看作是匿名内部类的简化版本,用于实现只有一个抽象方法的接口。

  • 语法: (参数列表) -> { 方法体 }
  • 核心特点:
    • 简洁: 极大地减少了代码量,省去了匿名内部类的许多样板代码。
    • 函数式编程: 使得 Java 能够更方便地支持函数式编程范式,如将函数作为参数传递。
    • 类型推断: 编译器可以根据上下文自动推断参数类型。

示例:

使用 Lambda 表达式实现上面的 Runnable 接口:

1
2
Thread t = new Thread(() -> System.out.println("Hello from a Lambda expression!"));
t.start();

常见的面试问题

1. 匿名内部类、Lambda 表达式和函数式接口之间的关系是什么?

  • 演变关系: 匿名内部类是 Java 早期用于实现接口或抽象类的单一方法的笨重方式。函数式接口的出现,为 Lambda 表达式提供了一个目标类型。Lambda 表达式就是对只有一个抽象方法的匿名内部类的一种语法糖,它极大地简化了代码。
  • 本质: Lambda 表达式是函数式接口的一个匿名实现

2. 为什么 Lambda 表达式只能实现函数式接口?

Lambda 表达式的语法((参数) -> {方法体})本身就只适用于实现一个方法。如果一个接口有两个抽象方法,编译器无法确定 Lambda 表达式的 (){} 对应的是哪个方法,因此会报错。

3. Lambda 表达式和匿名内部类在使用 this 关键字时有什么区别?

  • 匿名内部类: 匿名内部类有自己的作用域。它内部的 this 关键字指向的是匿名内部类自身的实例
  • Lambda 表达式: Lambda 表达式没有自己的作用域。它内部的 this 关键字指向的是其定义所在类的实例

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class OuterClass {
private String name = "Outer";

public void testLambda() {
Runnable r = () -> {
System.out.println(this.name); // 这里的 this 指向 OuterClass 实例
};
new Thread(r).start();
}

public void testAnonymous() {
Runnable r = new Runnable() {
@Override
public void run() {
// System.out.println(this.name); // 编译错误,匿名内部类没有 name 字段
System.out.println(OuterClass.this.name); // 必须用 OuterClass.this
}
};
new Thread(r).start();
}
}

4. Lambda 表达式捕获的局部变量为什么必须是 final 的?

和匿名内部类一样,Lambda 表达式会捕获其外部作用域的局部变量。这些变量在 Lambda 表达式的生命周期中是不可变的,因为它们被复制到了堆中。在 Java 8 之前,你需要显式地用 final 修饰;自 Java 8 之后,只要变量没有被重新赋值,它就是**“事实上的 final”**,可以省略 final 关键字。

Lambda表达式:

Lambda 表达式是 Java 8 引入的语法糖,它让你可以用更简洁的方式实现函数式接口。它的底层实现机制比你想象的要复杂,但核心思想是利用了新的字节码指令和方法。


Lambda 表达式的底层实现

Lambda 表达式的底层实现依赖于两个关键技术:

  1. invokedynamic 指令
  2. 方法句柄(Method Handle)

在 Java 7 之前,JVM 中只有四种调用方法的指令:invokevirtualinvokeinterfaceinvokespecialinvokestatic。这些指令都是静态绑定的,意味着在编译时就确定了调用的目标方法。

然而,Lambda 表达式的调用是动态的。编译器在处理 Lambda 表达式时,并不知道它会具体调用哪个方法。因此,Java 7 引入了 invokedynamic 指令,它允许在运行时动态地解析和绑定方法调用。


编译时:生成 invokedynamic 指令

当你编写一个 Lambda 表达式时,例如:

1
Runnable r = () -> System.out.println("Hello");

Java 编译器在编译时并不会直接生成一个匿名内部类。相反,它会做以下事情:

  1. 将 Lambda 表达式体转换为一个私有方法:编译器会把 System.out.println("Hello") 这段代码提取出来,生成一个新的私有方法,通常是 private staticprivate 类型,并带有特殊的命名规则(比如 lambda$main$0)。这个新方法包含了 Lambda 表达式的实际逻辑。
  2. 生成 invokedynamic 指令:在 Lambda 表达式出现的地方,编译器会生成一条 invokedynamic 指令。这条指令不会直接调用上一步生成的私有方法。相反,它指向一个引导方法(Bootstrap Method)

invokedynamic 指令的结构大致如下:

invokedynamic #0:run:()Ljava/lang/Runnable;

  • #0 是一个常量池索引,指向引导方法(通常是 java.lang.invoke.LambdaMetafactory.metafactory)。
  • run 是函数式接口中抽象方法的名称。
  • ()Ljava/lang/Runnable; 是函数式接口的签名。

运行时:动态绑定

当 JVM 执行到 invokedynamic 指令时,它会执行以下步骤:

  1. 调用引导方法:JVM 会调用 LambdaMetafactory.metafactory 引导方法。这个方法是 Lambda 表达式的**“工厂”,它负责生成一个方法句柄(Method Handle)**。
  2. 生成方法句柄:方法句柄是一个轻量级的、类似于 java.lang.reflect.Method 的引用,但它更高效,可以直接调用。它指向编译时生成的那个私有方法(即 Lambda 表达式的实际逻辑)。
  3. 生成调用点对象LambdaMetafactory.metafactory 返回一个**CallSite**对象,这个对象持有一个指向方法句柄的引用。这个 CallSite 会被缓存起来。
  4. 执行方法调用invokedynamic 指令会直接跳转到 CallSite 对象中的方法句柄,然后执行实际的 Lambda 表达式代码。

Lambda 表达式与匿名内部类的区别

理解底层实现后,两者在性能和设计上有一些关键区别:

特性 Lambda 表达式 匿名内部类
生成方式 动态生成私有方法和 invokedynamic 指令 编译时生成独立的 .class 文件
性能 通常更快。invokedynamic 优化了性能,避免了每次都创建新对象和执行 invokespecial 指令。 每次使用都会创建一个新的对象实例,并生成一个额外的类文件。
作用域 没有自己的作用域,this 关键字指向外部类的实例。 有自己的作用域,this 关键字指向匿名内部类实例本身。
内存开销 更小。由于采用了方法句柄,避免了为每个 Lambda 表达式创建独立的匿名类对象。 会产生额外的 .class 文件和对象实例,内存开销更大。

总结来说,Lambda 表达式的底层实现是一种延迟创建动态绑定的机制。编译器在编译时只留下一个调用指令,将真正的实现工作推迟到运行时。这种设计使得 Lambda 表达式在保持代码简洁的同时,也带来了更高的性能。

switch语句支持的数据类型:

switch 语句是 Java 中一个非常实用的控制流语句,它能够根据变量的值来选择不同的执行路径。从 Java 7 开始,switch 语句支持的数据类型有了显著的扩展。

switch 语句作用的数据类型包括:


1. 基本数据类型

  • byte
  • short
  • char
  • int

注意:

  • switch 语句不支持 longfloatdoubleboolean 类型。
  • 如果你的变量是 long 类型,你需要将其强制转换为 int 类型才能在 switch 中使用,但这可能导致数据丢失,通常不推荐。

2. 包装类

  • Byte
  • Short
  • Character
  • Integer

这是因为 Java 编译器在编译时会进行自动拆箱(autounboxing),将包装类对象自动转换为对应的基本数据类型。


3. 枚举类型 (enum)

  • 从 Java 5 开始switch 语句开始支持枚举类型。
  • 使用枚举可以使代码更具可读性和安全性,因为编译器会检查所有 case 标签是否对应了枚举中的有效常量。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enum Day {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}

Day day = Day.MONDAY;
switch (day) {
case MONDAY:
System.out.println("It's Monday.");
break;
case SUNDAY:
System.out.println("It's Sunday.");
break;
default:
System.out.println("It's a weekday.");
}

4. String 类型

  • 从 Java 7 开始switch 语句开始支持 String 类型。
  • 这个特性极大地简化了基于字符串的条件判断代码,避免了大量的 if-else if-else 链。
  • 在底层,编译器通过 String 对象的 hashCode()equals() 方法来执行 switch 逻辑。

示例:

1
2
3
4
5
6
7
8
9
10
11
String fruit = "apple";
switch (fruit) {
case "apple":
System.out.println("You chose an apple.");
break;
case "orange":
System.out.println("You chose an orange.");
break;
default:
System.out.println("Unknown fruit.");
}

总结

支持类型 版本 备注
byte, short, char, int 所有版本 switch 的基础类型。
Byte, Short, Character, Integer 所有版本 自动拆箱机制使其可用。
enum Java 5+ 增强代码的可读性和安全性。
String Java 7+ 极大地简化了字符串判断逻辑。

记住,无论使用哪种类型,case 标签后面的值都必须是编译时常量

1. 内置锁:synchronized 关键字

synchronized是Java语言层面的同步机制,使用起来相对简单。它可以修饰方法或代码块,由JVM负责加锁和释放锁,开发者不需要手动管理。

  • 同步方法: 当你用synchronized修饰一个非静态方法时,锁对象是这个方法的实例对象(this)。当一个线程进入这个同步方法时,它会获得该实例的锁,其他线程就无法进入这个实例的任何同步方法,直到它释放锁。
  • 同步静态方法:synchronized修饰一个静态方法时,锁对象是该类的Class对象。这意味着所有线程在访问这个类的任何同步静态方法时都会争抢同一把锁,无论有多少个实例。
  • 同步代码块: synchronized (object)。这是最灵活的使用方式,你可以指定任何对象作为锁。通过缩小同步范围,可以减少锁的持有时间,从而提高程序的并发性能。

synchronized的特点:

  • 可重入性(Reentrant): 一个线程可以重复获取它已经持有的锁,避免死锁。
  • 非公平性(Unfair): 默认情况下,线程获取锁的顺序是不确定的,先来的不一定先得到。
  • 锁的升级: 为了优化性能,synchronized锁的实现经历了从偏向锁轻量级锁重量级锁的升级过程。这使得在竞争不激烈的情况下,加锁的开销非常小。
  • 内存可见性(Visibility): synchronized可以保证当一个线程释放锁时,它对共享变量所做的修改会立即刷新到主内存,而当另一个线程获取锁时,它会从主内存中读取最新的变量值。这是由Java内存模型(JMM)的happens-before规则保证的。

底层实现机制

synchronized本质上是基于JVM的**内置锁(Intrinsic Lock)**实现的。每个Java对象都天生具备一个监视器锁(Monitor),这是JVM级别的原生支持。

监视器的工作原理:

  • 当线程进入synchronized代码块时,会执行monitorenter指令获取对象的监视器
  • 当线程退出时,执行monitorexit指令释放监视器
  • 如果获取失败,线程会被阻塞在操作系统级别

锁升级的演进过程

synchronized的性能优化经历了一个复杂的演进过程,从最初的重量级锁逐步优化:

偏向锁阶段:
当只有一个线程反复进入同步块时,JVM会将锁偏向于这个线程。此时的开销极低,几乎等同于无锁操作。对象头中会记录偏向的线程ID,后续该线程进入时直接通过简单的比较就能获取锁。

轻量级锁阶段:
当有其他线程开始竞争时,偏向锁会升级为轻量级锁。JVM在当前线程的栈帧中创建锁记录(Lock Record),通过CAS操作尝试将对象头的Mark Word替换为指向锁记录的指针。这个过程避免了操作系统层面的阻塞。

重量级锁阶段:
当CAS操作失败次数过多或者有多个线程同时竞争时,锁会膨胀为重量级锁。此时会创建Monitor对象,利用操作系统的互斥量(Mutex)来实现同步,涉及用户态和内核态的切换。

好的,我帮你把 JVM 锁升级过程 优化排版,整理成条理清晰、易于理解的版本,并加上一些注释说明,让整个流程更直观。


JVM 锁的升级过程

JVM 的对象锁在多线程访问时会经历 偏向锁 → 轻量级锁 → 重量级锁 的升级过程。下面详细说明每一步。


1. 普通对象(无锁状态)

  • 对象未被任何线程持有锁时:
    • Mark Word 中记录 对象的 HashCode
    • 锁标志位:01(表示无锁)。
    • 是否偏向锁位:0

此时对象只是普通对象,没有同步锁。


2. 偏向锁初始化(第一次加锁)

  • 线程 A 获取锁:
    • 锁标志位仍为 01(无锁标志)。
    • 偏向锁标志位改为 1,表示对象进入 偏向锁状态
    • Mark Word 的前 23 bit 保存 线程 A 的线程 ID

偏向锁用于优化单线程访问锁的性能,减少同步开销。


3. 偏向锁重入(线程 A 再次获取锁)

  • 线程 A 再次尝试获取锁:
    • JVM 检查对象标志位:
      • 锁标志位 01
      • 偏向锁位 1
      • Mark Word 中线程 ID = A 的 ID
    • 由于线程 A 已持有偏向锁,直接进入同步代码,无需 CAS 操作。

偏向锁允许线程重复获取锁,性能开销极低。


4. 偏向锁竞争(线程 B 尝试获取锁)

  • 线程 B 尝试获取锁:
    • JVM 检查对象偏向锁:
      • 锁标志位 01
      • 偏向锁位 1
      • Mark Word 中线程 ID ≠ B
    • 线程 B 尝试通过 CAS 抢锁
      • 成功:偏向锁切换到线程 B,Mark Word 更新为 B 的线程 ID
      • 失败:表示竞争存在,进入下一步升级轻量级锁

偏向锁竞争失败时,说明锁开始有多线程访问,需要升级锁状态。


5. 轻量级锁(Lightweight Lock)

  • JVM 为当前线程在 栈帧中分配一块空间,称为 锁记录区(Lock Record)
    • 保存指向对象 Mark Word 的指针
  • 对象 Mark Word 保存指向锁记录区的指针
  • CAS 操作同时更新两处指针:
    • 成功:线程获得轻量级锁,执行同步代码,锁标志位改为 00(轻量级锁状态)
    • 失败:竞争激烈,进入自旋阶段

轻量级锁适合 多线程少量竞争 场景,性能较好。


6. 自旋锁(Spin Lock)

  • 线程尝试获取轻量级锁失败:
    • JVM 自旋重试(循环尝试抢锁)
    • 自旋次数由 JVM 决定(JDK 1.7 默认启用)
    • 成功:执行同步代码
    • 失败:锁升级为重量级锁

自旋锁适合锁竞争短暂的情况,避免线程阻塞带来的开销。


7. 重量级锁(Heavyweight Lock)

  • 自旋失败后,锁升级为 重量级锁
    • 锁标志位改为 10
    • 线程未抢到锁的会被阻塞(挂起)
    • 系统线程调度负责唤醒等待线程

重量级锁性能开销较大,使用操作系统互斥机制。
只有当轻量级锁和自旋锁都无法满足需求时才会升级。


🔑 总结锁升级过程

1
2
3
4
5
6
7
8
9
10
11
12
13
无锁状态(01 + 偏向位0)


偏向锁(01 + 偏向位1 + 线程ID)

▼ 线程竞争
轻量级锁(00 + 指向栈帧锁记录区)

▼ CAS 失败 / 高竞争
自旋锁(不断自旋尝试抢锁)

▼ 自旋失败
重量级锁(10 + 阻塞等待)

JVM 通过 锁膨胀机制(Lock Inflation)逐步升级锁,尽量在多线程争用情况下保证性能。

自动化的特点

synchronized的最大特点是完全自动化。程序员无需关心锁的获取和释放时机,JVM会自动处理所有细节。即使在异常情况下,JVM也能保证锁的正确释放,这大大降低了死锁和资源泄露的风险。

2. 显式锁:java.util.concurrent.locks.Lock

从JDK 1.5开始,java.util.concurrent.locks包提供了更灵活、更强大的锁机制,其核心是Lock接口。最常用的实现类是**ReentrantLock**。

使用Lock需要手动进行加锁和解锁操作,通常是在try...finally块中进行,以确保锁在任何情况下都能被释放,防止死锁。

1
2
3
4
5
6
7
Lock lock = new ReentrantLock();
lock.lock();
try {
// 访问共享资源的代码
} finally {
lock.unlock();
}

Lock相比synchronized的优势:

  • 中断锁(Interruptibly): Lock提供了lockInterruptibly()方法,允许在等待锁的过程中响应中断。而synchronized的线程如果陷入等待锁的状态,是无法被中断的。
  • 尝试获取锁(tryLock): Lock提供了tryLock()tryLock(long timeout, TimeUnit unit)方法,可以尝试获取锁,如果失败则立即返回或在指定时间内放弃,避免无限等待。
  • 公平锁与非公平锁: ReentrantLock可以创建公平锁(Fair Lock)。公平锁会按照线程请求锁的顺序来分配锁,虽然这可能会带来一些性能开销。而synchronized只能是非公平锁。
  • 绑定多个条件(Condition): Lock配合Condition接口,可以实现更灵活的线程等待和唤醒机制,类似Objectwait()notify(),但功能更强大,一个锁可以有多个等待队列。
  • 读写锁(ReadWriteLock): ReadWriteLockLock的另一个重要实现,它维护了一对锁:一个用于读操作,一个用于写操作。在读多写少的场景下,多个线程可以同时获取读锁,大大提高了并发性能,只有写操作才需要获取独占的写锁。ReentrantReadWriteLock是其具体实现。

Lock接口的设计理念

显式锁机制

Lock接口代表了Java并发包中显式锁的设计思想。与synchronized的隐式锁不同,Lock要求程序员明确控制锁的生命周期,这带来了更大的灵活性,同时也增加了使用的复杂性。

AQS框架的核心思想

Lock接口的实现基于AbstractQueuedSynchronizer(AQS)框架,这是Doug Lea设计的一个并发框架的杰作。

同步状态的抽象:
AQS使用一个int值来表示同步状态,不同的锁实现可以赋予这个状态不同的含义。比如ReentrantLock用它表示重入次数,Semaphore用它表示许可证数量。

队列化的等待机制:
当线程无法获取锁时,AQS会将其包装成节点加入到一个FIFO队列中。这个队列使用双向链表实现,每个节点都包含了线程引用和等待状态信息。

自旋与阻塞的平衡:
AQS巧妙地结合了自旋和阻塞两种等待策略。线程在入队后会先进行有限次数的自旋尝试,只有在确定无法获取锁时才会调用LockSupport.park()进入阻塞状态。

3. volatile 关键字

volatile关键字并不是一个锁,它是一种轻量级的同步机制,主要用于保证共享变量的可见性有序性

  • 可见性(Visibility): 当一个变量被volatile修饰后,一个线程对它的修改会立即被其他线程可见。这是通过在写操作后添加内存屏障,强制将修改后的值刷新到主内存,并在读操作前添加内存屏障,强制从主内存中读取最新值来实现的。
  • 有序性(Ordering): volatile可以禁止指令重排序,确保代码的执行顺序不会被打乱。
  • 无法保证原子性(Atomicity): volatile无法保证复合操作(如i++)的原子性,因为i++实际上是读、加、写三个操作的组合,这三个操作并非一次完成。如果要保证原子性,需要使用synchronizedLockjava.util.concurrent.atomic包下的原子类。

总结一下:

特性 synchronized Lock (如ReentrantLock) volatile
功能 独占锁,保证原子性、可见性、有序性 独占锁,功能更强大,保证原子性、可见性、有序性 轻量级同步,只保证可见性和有序性
使用方式 关键字,自动加锁和解锁 接口,需要手动加锁和解锁,必须在finally块中释放 关键字,修饰变量
灵活性 较差,功能固定 强,提供了更多高级功能,如可中断、超时、公平锁等 较差,只针对变量
性能 JVM优化后性能较高,开销相对较小 高性能,在竞争激烈时通常优于synchronized 非常高,几乎没有开销
适用场景 简单的同步需求,大部分情况都适用 高级同步需求,需要灵活控制锁的获取和释放 变量的写操作不依赖于当前值,需要保证变量的可见性。

4. 乐观锁 (Optimistic Locking)

与之前讨论的悲观锁synchronizedLock)不同,乐观锁并非一个具体的Java关键字或类,而是一种并发控制的思想和策略。悲观锁认为“总会有其他线程来修改数据”,所以在访问共享资源前,先对资源加锁,确保独占访问。而乐观锁则认为“数据冲突发生的概率很小”,所以它不加锁,而是假设所有线程都能正常执行,只有在数据更新提交时,才去检查在此期间数据是否被其他线程修改过。

如果检查到数据没有被修改,则更新成功。如果发现数据已经被修改,则更新失败。处理失败的方式通常有两种:

  1. 重试: 循环尝试,直到更新成功为止。
  2. 放弃: 抛出异常或直接返回失败,由调用方处理。

乐观锁的实现方式:

乐观锁的核心在于如何“检查数据是否被修改”。在Java中,常见的实现方式有两种:

  • 版本号(Version Number):

    在数据表中增加一个version字段。每次读取数据时,也把version字段读出来。当要进行数据更新时,带上之前读取的version值,在更新语句中加入WHERE version = <当前版本号>的条件。如果更新成功,同时把version值加1。

    • SQL示例:

      1
      2
      UPDATE products SET stock = 100, version = version + 1
      WHERE id = 123 AND version = <之前读取的版本号>;
    • 原理: 如果在更新时,其他线程已经修改了这条数据,那么version值已经改变,上述UPDATE语句的WHERE条件将不成立,导致更新失败,影响行数为0。此时,你可以选择重试或放弃。

  • CAS (Compare-And-Swap) 算法:

    这是乐观锁在硬件层面的支持,也是Java中实现乐观锁的核心机制。CAS是一种原子操作,它包含三个操作数:

    • V (Value): 内存地址中存放的旧值。
    • A (Expected): 预期的旧值。
    • B (New): 想要写入的新值。

    CAS的操作逻辑是:

    如果内存地址V中的值等于预期值A,那么就将V的值更新为新值B。否则,什么都不做。整个操作是原子性的,由CPU指令直接完成。

    Java中java.util.concurrent.atomic包下的所有原子类,如AtomicIntegerAtomicLong等,都是基于CAS实现的。

    AtomicInteger示例:

    1
    2
    3
    AtomicInteger count = new AtomicInteger();
    // 假设多个线程同时执行以下操作
    count.incrementAndGet(); // 内部就是CAS操作

    incrementAndGet()方法的内部实现类似于一个自旋重试的循环:

    1. 获取当前值current
    2. 计算新值next = current + 1
    3. 使用CAS尝试将current更新为next
    4. 如果更新失败(说明current已经被其他线程修改),则重新回到第一步,再次获取最新值并尝试更新。

悲观锁与乐观锁的比较:

特性 悲观锁 (synchronized, Lock) 乐观锁 (CAS, 版本号)
加锁方式 独占资源时先加锁,阻止其他线程访问 不加锁,只在提交时进行冲突检测
冲突处理 线程排队等待锁,串行执行 线程失败后重试或放弃,并发执行
适用场景 写操作多、竞争激烈的场景。数据冲突概率高。 读操作多、写操作少的场景。数据冲突概率低。
性能 在高竞争环境下,线程切换和上下文开销大,性能下降。 在低竞争环境下,无锁开销,性能极高。在高竞争环境下,大量重试可能导致CPU开销增加。

总结:

在Java中,悲观锁乐观锁是两种截然不同的并发控制策略。悲观锁(synchronized, Lock)适合写多读少的场景,能够保证数据的一致性,但会牺牲一定的性能。而乐观锁(CAS版本号)则适合读多写少的场景,通过无锁的并发操作提高了性能,但在高竞争下可能导致频繁重试,反而降低效率。理解这两种锁的思想,可以帮助你根据具体的业务场景选择最合适的并发控制方案。

好的,我们来继续完善Java中关于锁的介绍,增加死锁及其解决方案的内容。


5. 死锁 (Deadlock)

死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象。若无外力作用,它们都将无法继续执行。

死锁的产生是一个非常经典的多线程问题,通常发生在线程需要同时持有多个锁的场景中。一个简单的死锁场景是:线程A持有锁1,想获取锁2;而线程B持有锁2,想获取锁1。此时两个线程都无法继续执行,从而进入死锁状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 线程A
synchronized (lock1) {
synchronized (lock2) {
// ...
}
}

// 线程B
synchronized (lock2) {
synchronized (lock1) {
// ...
}
}

死锁的四个必要条件

死锁的发生需要同时满足以下四个条件,缺一不可:

  1. 互斥条件(Mutual Exclusion): 至少有一个资源是独占的,即一次只能被一个线程使用。这是锁本身的基本特性。
  2. 请求与保持条件(Hold and Wait): 一个线程因请求资源而阻塞时,它对自己已获得的资源保持不放。
  3. 不剥夺条件(No Preemption): 线程已获得的资源在未使用完之前,不能被强行剥夺,只能由该线程自己释放。
  4. 循环等待条件(Circular Wait): 存在一个线程等待链,其中每个线程都持有下一个线程所需的资源。例如:线程A等待线程B,线程B等待线程C,线程C又等待线程A。

死锁的解决方案

解决死锁的根本思想是破坏上述四个必要条件之一或多个。通常,我们无法破坏互斥条件(因为资源就是独占的),因此主要从其他三个条件入手。

  1. 破坏“请求与保持”条件:
    • 一次性获取所有锁: 线程在开始执行时,就一次性获取所有需要的锁。如果获取不成功,则释放所有已获得的锁,然后等待一段时间后再次尝试。
    • 优点: 简单有效。
    • 缺点: 可能会降低并发性,因为线程在很早就持有了锁,即使这些锁在后面才被使用。
  2. 破坏“不剥夺”条件:
    • 使用可中断的锁: 使用Lock接口提供的tryLock()方法。当一个线程尝试获取锁失败时,它可以选择放弃并释放已持有的锁,而不是一直等待。Lock.tryLock(long time, TimeUnit unit)方法可以在指定时间内尝试获取锁,超时后会放弃。
    • 优点: 提高了灵活性,线程可以响应中断或超时,避免无限等待。
    • 缺点: 实现起来相对复杂,需要开发者手动处理获取锁失败的情况。
  3. 破坏“循环等待”条件:
    • 按顺序获取锁: 对所有的锁进行排序,并强制所有线程都按照相同的顺序获取锁。
    • 示例: 如果线程A和线程B都需要lock1lock2,那么它们都必须先获取lock1,再获取lock2。这样就杜绝了线程A持有lock1等待lock2,同时线程B持有lock2等待lock1的循环。
    • 优点: 这是最常用、最有效的死锁解决方案,实现起来也相对简单。
    • 缺点: 有时很难对所有锁进行全局排序,特别是在代码模块化程度较高、依赖关系复杂的情况下。

总结:

在实际开发中,预防死锁的最佳实践通常是破坏循环等待条件,即统一锁的获取顺序。这是最简单且最有效的方案。如果业务场景需要更灵活的控制,可以考虑使用Lock接口,利用tryLock()方法来破坏“不剥夺”条件,实现更复杂的死锁处理逻辑。

好的,我们来详细介绍银行家算法 (Banker’s Algorithm)


银行家算法概述

银行家算法是一种著名的死锁避免算法,由荷兰计算机科学家Dijkstra在1965年提出。它的核心思想是:在每次分配资源之前,先进行一次安全性检查。如果分配后系统仍然处于安全状态,则分配资源;否则,不予分配,线程需要等待。

  • 优点:比死锁预防更灵活,能提高资源利用率。
  • 缺点:算法复杂,需要预知进程的最大资源需求,并且系统开销大。

之所以叫“银行家算法”,是因为它的工作原理类似于银行管理贷款。银行家在发放贷款时,会先确保这笔贷款发放后,自己还有足够的资金来满足所有客户可能提出的最大取款需求,从而避免因无法支付而破产的风险。

简单来说,银行家算法通过以下两个步骤来避免死锁:

  1. 安全状态的定义: 系统能够找到一个安全序列,使得所有线程都能按照这个序列执行完毕。
  2. 资源分配策略: 当一个线程请求资源时,算法会先假设分配成功,然后检查系统是否仍处于安全状态。如果安全,就真的分配;如果不安全,就拒绝分配。

银行家算法中的几个重要数据结构

为了实现算法,需要维护以下几个关键数据结构,假设系统中有n个线程和m种资源:

  • Available (可用资源矩阵): 一个长度为m的向量。Available[j]表示第j种资源目前可用的数量。
  • Max (最大需求矩阵): 一个n * m的矩阵。Max[i, j]表示线程i最多需要第j种资源多少个。
  • Allocation (已分配资源矩阵): 一个n * m的矩阵。Allocation[i, j]表示线程i目前已拥有第j种资源多少个。
  • Need (需求矩阵): 一个n * m的矩阵。Need[i, j]表示线程i还需要第j种资源多少个才能完成任务。
    • Need[i, j] = Max[i, j] - Allocation[i, j]

银行家算法的核心:安全状态的判断

判断系统是否处于安全状态是银行家算法的核心。一个系统处于安全状态,当且仅当存在一个安全序列<P1, P2, ..., Pn>。这个序列满足:对于序列中每一个线程Pi,它所需要的资源都能由系统中当前可用的资源,以及前面所有已完成的线程释放的资源来满足。

安全性检查算法的步骤:

  1. 初始化:
    • 创建一个Work向量,初始化为Available(即当前可用资源)。
    • 创建一个Finish向量,初始化为false,表示所有线程都未完成。
  2. 寻找安全线程:
    • 从所有线程中找到一个线程i,满足以下两个条件:
      • Finish[i]false
      • Need[i]向量中的每一个值都小于或等于Work向量中对应的值。
      • 换句话说,线程i所需要的资源小于或等于当前可用的资源。
  3. 释放资源:
    • 如果找到了这样的线程i,则认为它可以顺利执行完毕。
    • 模拟该线程执行完毕并释放资源,更新Work向量:Work = Work + Allocation[i]
    • Finish[i]设置为true
  4. 循环检查:
    • 重复步骤2和步骤3,直到找不到满足条件的线程。
  5. 判断结果:
    • 如果最终所有线程的Finish都为true,则说明找到了一个安全序列,系统处于安全状态
    • 如果还有线程的Finishfalse,则说明系统处于不安全状态,可能存在死锁。

银行家算法的流程:资源分配

当一个线程P请求资源时,银行家算法会执行以下步骤:

  1. 请求检查: 检查线程P请求的资源数量是否小于或等于其Need向量中的需求量。如果不是,说明线程P的请求不合理,拒绝分配。
  2. 可用性检查: 检查线程P请求的资源数量是否小于或等于当前Available中的资源数量。如果不是,说明资源不足,线程P需要等待。
  3. 预分配并进行安全性检查:
    • 假设资源可以分配,临时进行以下操作:
      • Available = Available - Request
      • Allocation[P] = Allocation[P] + Request
      • Need[P] = Need[P] - Request
    • 调用上面的安全性检查算法,判断系统是否处于安全状态。
  4. 正式分配或拒绝:
    • 如果安全性检查的结果是安全,则正式分配资源,并保留步骤3中的修改。
    • 如果安全性检查的结果是不安全,则回滚步骤3中的所有临时修改,拒绝分配资源,线程P需要等待。

银行家算法的优缺点

  • 优点:
    • 可以有效地避免死锁的发生,保证系统的安全性。
    • 通过提前检查,可以最大化地利用资源,提高系统的并发性。
  • 缺点:
    • 计算开销大: 每次分配资源都需要运行安全性检查算法,增加了系统的开销。
    • 过于保守: 安全状态不等于无死锁,不安全状态也不等于一定发生死锁。算法为了确保安全,可能会拒绝一些本可以成功分配的请求,从而降低了系统的吞吐量。
    • 条件苛刻: 算法要求线程在开始前就声明其所需的最大资源量,这在实际应用中很难做到。
    • 资源数量固定: 算法假设系统中资源的数量是固定的,不能动态增减。

因此,银行家算法虽然在理论上非常完美,但在实际操作系统中很少被直接完整地实现。然而,它的核心思想——通过安全性检查来避免死锁——仍然是许多并发控制策略的重要理论基础。

实际应用中的死锁处理

在实际的并发编程和数据库系统中,最常见的死锁处理方式是:

  • 死锁预防(通过编程规范):

    • 加锁顺序一致:规定所有线程在获取多个锁时,必须按照相同的顺序。这是最有效的预防死锁的编程实践。
    • 使用超时锁:尝试获取锁时设置一个超时时间,如果超时未获取到锁,则放弃本次操作并释放已持有的锁,然后重试。这破坏了”请求与保持”条件。例如Java的ReentrantLocktryLock(timeout)。
    • 避免嵌套锁:尽量减少持有多个锁的情况。
  • 死锁检测与恢复(数据库系统):

    • 大多数关系型数据库(如MySQL InnoDB)都实现了死锁检测机制。当检测到死锁时,数据库会自动选择一个成本较低的事务(“牺牲品”)进行回滚,从而解除死锁。客户端应用会收到相应的错误码(例如MySQL中的Deadlock found when trying to get lock; try restarting transaction)。应用程序通常需要捕获这个错误并重试事务。理解死锁的四个必要条件是关键,因为解决死锁的根本方法就是破坏其中一个或多个条件。

锁扩展:

18. 谈谈 synchronized 和 ReentrantLock 的区别?

  1. synchronized 是和 forwhile 一样的关键字,ReentrantLock 是类,这是二者的本质区别。既然 ReentrantLock 是类,那么它就提供了比 synchronized 更多更灵活的特性:等待可中断、可实现公平锁、可实现选择性通知(锁可以绑定多个条件)、性能已不是选择标准。
  2. synchronized 依赖于 JVM 而 ReenTrantLock 依赖于 API。synchronized 是依赖于 JVM 实现的,JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReenTrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock()unlock 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。

19. synchronized 和 volatile 的区别是什么?

  1. volatile 本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
  2. volatile 仅能使用在变量级别;synchronized 则可以使用在变量、方法、和类级别的。
  3. volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量的修改可见性和原子性。
  4. volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。
  5. volatile 标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化。

20. 谈一下你对 volatile 关键字的理解?

volatile 关键字是用来保证有序性和可见性的。这跟 Java 内存模型有关。我们所写的代码,不一定是按照我们自己书写的顺序来执行的,编译器会做重排序,CPU 也会做重排序的,这样做是为了减少流水线阻塞,提高 CPU 的执行效率。这就需要有一定的顺序和规则来保证,不然程序员自己写的代码都不知道对不对了,所以有 happens-before 规则,其中有条就是 volatile 变量规则:对一个变量的写操作先行发生于后面面对这个变量的读操作、有序性实现的是通过插入内存屏障来保证的。

volatile 修饰的共享变量,就具有了以下两点特性:

  1. 保证了不同线程对该变量操作的内存可见性;
  2. 禁止指令重排序。

备注:这个题如果扩展了答,可以从 Java 的内存模型入手,下一篇 Java 虚拟机高频面试题中会讲到,这里不做过多赘述。


21. 说下对 ReentrantReadWriteLock 的理解?

ReentrantReadWriteLock 允许多个读线程同时访问,但是不允许写线程和读线程、写线程和写线程同时访问。读写锁内部维护了两个锁:一个是用于读操作的 ReadLock,一个是用于写操作的 WriteLock。读写锁 ReentrantReadWriteLock 可以保证多个线程可以同时读,所以在读操作远大于写操作的时候,读写锁就非常有用了。

ReentrantReadWriteLock 基于 AQS 实现,它的自定义同步器(继承 AQS)需要在同步状态 state 上维护多个读线程和一个写线程,该状态的设计成为实现读写锁的关键。ReentrantReadWriteLock 很好的利用了高低位。来实现一个整型控制两种状态的功能,读写锁将变量切分成了两个部分,高 16 位表示读,低 16 位表示写。

ReentrantReadWriteLock 的特点

  1. 写锁可以降级为读锁,但是读锁不能升级为写锁;
  2. 不管是 ReadLock 还是 WriteLock 都支持 Interrupt,语义与 ReentrantLock 一致;
  3. WriteLock 支持 Condition 并且与 ReentrantLock 语义一致,而 ReadLock 则不能使用 Condition,否则抛出 UnsupportedOperationException 异常;
  4. 默认构造方法为非公平模式 ,开发者也可以通过指定 fairtrue 设置为公平模式 。

升级/降级

  1. 读锁里面加写锁,会导致死锁;
  2. 写锁里面是可以加读锁的,这就是锁的降级。

多线程与线程池

在 Java 中,多线程是实现并发编程的关键技术,它允许程序同时执行多个任务。线程池则是管理和复用线程的重要机制,能有效提升系统性能和资源利用率。

⭐️什么是线程和进程?

何为进程?

进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。

在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。

何为线程?

线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的方法区资源,但每个线程有自己的程序计数器虚拟机栈本地方法栈,所以系统在产生一个线程,或是在各个线程之间做切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。


一个进程中可以有多个线程,多个线程共享进程的方法区 (JDK1.8 之后的元空间)资源,但是每个线程有自己的程序计数器虚拟机栈本地方法栈

总结: 线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。


程序计数器为什么是私有的?

程序计数器主要有下面两个作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。

所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置

虚拟机栈和本地方法栈为什么是私有的?

  • 虚拟机栈: 每个 Java 方法在执行之前会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
  • 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。

一句话简单了解堆和方法区

堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。


Java THREAD 类详解

在 Java 中,Thread 类是多线程编程的核心。它代表了一个线程,并提供了丰富的 API 来创建、控制和管理线程的生命周期。理解 Thread 类及其方法对于编写高效、并发的 Java 应用程序至关重要。


1. THREAD 类的基本概念

Thread 类位于 java.lang 包中。每个 Thread 类的实例都代表一个独立的线程,这个线程能够并行执行代码。有两种主要方式可以创建一个线程:

  1. 继承 Thread 类:创建一个新的类并继承 Thread,然后重写其 run() 方法来定义线程的执行逻辑。
  2. 实现 Runnable 接口:创建一个实现了 Runnable 接口的类,并将其实例作为参数传递给 Thread 构造器。这种方式更灵活,因为它允许你的类继承其他类。

2. THREAD 类的主要方法详解

下面详细介绍 Thread 类中的常用和重要方法,并按其功能进行分类。

线程生命周期控制

这些方法用于启动、中断和协调线程的执行。

  • start(): 启动线程。此方法会创建一个新的线程,并使它进入可运行 (Runnable) 状态。一旦操作系统调度这个线程,它的 run() 方法就会被调用。
  • run(): 线程的执行体。当你调用 start() 方法时,JVM 会自动调用这个方法。如果你直接调用 run(),它只会被当作一个普通方法在当前线程中执行,而不会创建新线程。
  • interrupt(): 向线程发送一个中断信号。此方法并不会立即停止线程。它只是设置一个中断标志。线程需要自己检查这个标志并决定如何响应。对于在 sleep(), join(), 或 wait() 等方法中阻塞的线程,这个方法会抛出 InterruptedException
  • join(): 等待线程终止。调用此方法的线程(通常是主线程)会阻塞,直到被调用 join() 的线程执行结束。
    • join(): 永远等待,直到线程终止。
    • join(long millis): 最多等待指定的毫秒数。

线程属性和状态查询

这些方法用于获取线程的当前状态或设置其属性。

  • getName(): 返回线程的名称。线程可以有一个自定义的名称,如果没有指定,则会有一个默认的名称,如 “Thread-0”。
  • setName(String name): 设置线程的名称。
  • getState(): 返回线程的当前状态。返回值是一个枚举类型 Thread.State,包括 NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED
  • isAlive(): 判断线程是否存活。如果线程已经启动但尚未终止,则返回 true
  • isDaemon(): 判断线程是否为守护线程。守护线程在所有非守护线程终止时会自动终止。例如,JVM 的垃圾回收线程就是一个守护线程。
  • setDaemon(boolean on): 将线程设置为守护线程或非守护线程。这个方法必须在线程启动前调用。
  • getPriority(): 返回线程的优先级。优先级是一个整数,范围从 MIN_PRIORITY (1) 到 MAX_PRIORITY (10)。
  • setPriority(int newPriority): 设置线程的优先级。

静态方法 (影响当前线程)

这些方法直接作用于正在执行的线程。

  • Thread.currentThread(): 返回对当前正在执行的线程对象的引用。这是获取当前线程信息最常用的方法。
  • Thread.sleep(long millis): 使当前线程暂停执行指定的毫秒数。它会释放 CPU 资源,但不会释放锁。
  • Thread.yield(): 提示调度器,当前线程愿意放弃当前的 CPU 使用权,让其他线程有机会运行。这是一个提示,不能保证一定生效。
  • Thread.interrupted(): 测试当前线程是否被中断。此方法会清除中断标志。如果线程在调用时中断标志为 true,则返回 true,然后将中断标志重置为 false

3. THREAD 类方法使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import java.util.concurrent.TimeUnit;

public class ThreadMethodsDemo {
public static void main(String[] args) throws InterruptedException {
// 1. 创建并启动线程
Thread worker = new Thread(() -> {
try {
System.out.println(Thread.currentThread().getName() + " 开始执行。状态:" + Thread.currentThread().getState());

// 2. 模拟耗时操作,并演示 sleep()
System.out.println(Thread.currentThread().getName() + " 休眠 2 秒...");
TimeUnit.SECONDS.sleep(2);

// 3. 检查中断标志
if (Thread.currentThread().isInterrupted()) {
System.out.println(Thread.currentThread().getName() + " 被中断了!");
}
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + " 捕获到 InterruptedException,线程被中断。");
// 捕获异常后重新设置中断标志
Thread.currentThread().interrupt();
}
System.out.println(Thread.currentThread().getName() + " 执行结束。");
}, "WorkerThread");

// 设置线程优先级和守护状态(必须在启动前)
worker.setPriority(Thread.MAX_PRIORITY);
worker.setDaemon(false);

// 获取并打印线程信息
System.out.println("线程创建后,名称:" + worker.getName() + ",优先级:" + worker.getPriority());
System.out.println("线程创建后,状态:" + worker.getState());

// 启动线程
worker.start();

// 4. 使用 join() 等待 worker 线程结束
System.out.println("主线程等待 WorkerThread 结束...");
// worker.join(1000); // 只等待 1 秒
worker.join(); // 无限等待

System.out.println("主线程继续执行。WorkerThread 的最终状态:" + worker.getState());

// 5. 再次检查是否存活
System.out.println("WorkerThread 是否存活:" + worker.isAlive());

// 6. 演示 interrupt()
Thread interruptibleThread = new Thread(() -> {
try {
// 线程在这里阻塞
Thread.sleep(5000);
} catch (InterruptedException e) {
System.out.println("interruptibleThread 被中断,捕获到异常。");
}
});

interruptibleThread.start();
Thread.sleep(100); // 确保线程进入休眠
System.out.println("主线程中断 interruptibleThread...");
interruptibleThread.interrupt();
}
}

好的,这是一个关于 Java 线程控制的非常重要的知识点。我将详细解释守护线程(Daemon Thread),以及 join()yield() 这两个线程控制方法。


一、守护线程 (Daemon Thread)

1. 概念

  • 守护线程:是一种特殊的线程,它为其他线程(非守护线程)提供服务。
  • 特点:如果 JVM 中只剩下守护线程,那么 JVM 会自动退出。换句话说,守护线程的生命周期不依赖于自己,而是依赖于其他非守护线程。

2. 与普通线程的区别

  • 普通线程(User Thread):又称用户线程。如果 JVM 中还有一个用户线程在运行,JVM 就不会退出。
  • 守护线程:当所有用户线程都执行完毕后,JVM 会忽略仍在运行的守护线程,并直接终止。

3. 常见用途

  • 守护线程常用于执行一些后台任务,例如:
    • 垃圾回收器 (Garbage Collector):JVM 的垃圾回收线程就是典型的守护线程。它负责回收内存,但不会阻止 JVM 的退出。
    • JIT (Just-In-Time) 编译器:同样是后台服务线程。
    • 日志记录:一些日志框架的异步日志线程可以设置为守护线程。

4. 如何创建和判断

  • 创建:在线程启动前,调用 setDaemon(true) 方法。
    • 注意:必须在 start() 方法调用之前设置,否则会抛出 IllegalThreadStateException
  • 判断isDaemon() 方法可以判断一个线程是否为守护线程。

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class DaemonThreadExample {
public static void main(String[] args) {
// 创建一个用户线程
Thread userThread = new Thread(() -> {
for (int i = 0; i < 5; i++) {
try {
System.out.println("用户线程正在运行...");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("用户线程执行完毕。");
});

// 创建一个守护线程
Thread daemonThread = new Thread(() -> {
while (true) {
try {
System.out.println("守护线程正在运行...");
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});

// 设置为守护线程,必须在 start() 之前
daemonThread.setDaemon(true);

// 启动线程
userThread.start();
daemonThread.start();

// 观察结果:当用户线程执行完毕后,守护线程也会立即终止。
// 如果把 setDaemon(true) 移除,守护线程会一直运行,导致程序不退出。
}
}

二、join() 方法

1. 概念

  • join() 方法的作用是等待调用该方法的线程执行完毕
  • 比喻:主线程 A 调用了线程 B 的 B.join() 方法,那么主线程 A 会进入等待状态,直到线程 B 执行结束,主线程 A 才会继续执行。

2. 主要用途

  • 确保线程间的执行顺序。当一个线程的执行依赖于另一个线程的执行结果时,join() 方法非常有用。

3. 代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class JoinExample {
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
System.out.println("线程1开始执行...");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程1执行完毕。");
});

Thread thread2 = new Thread(() -> {
System.out.println("线程2开始执行...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程2执行完毕。");
});

// 启动线程
thread1.start();
thread2.start();

// 主线程等待 thread1 和 thread2 执行完毕
System.out.println("主线程等待线程1执行完毕...");
thread1.join(); // 主线程阻塞在这里,直到 thread1 结束

System.out.println("主线程等待线程2执行完毕...");
thread2.join(); // 主线程阻塞在这里,直到 thread2 结束

System.out.println("所有子线程都执行完毕,主线程继续执行。");
}
}

三、yield() 方法

1. 概念

  • yield() 方法的作用是让出当前 CPU 的执行权
  • 比喻:一个线程调用了 yield(),它告诉调度器:“我愿意暂时放弃 CPU,你可以让其他同等优先级的线程先执行。”

2. 主要用途

  • 提示yield() 只是一个提示,调度器不一定会采纳。它可能让出 CPU 给其他线程,也可能立即重新获得 CPU 的执行权。
  • 与其他方法的区别
    • sleep():让线程进入休眠状态强制让出 CPU。
    • wait():让线程进入等待状态必须通过 notify()notifyAll() 唤醒。
  • yield() 不会释放锁

3. 代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class YieldExample {
public static void main(String[] args) {
Runnable task = () -> {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + " 正在运行:" + i);
if (i == 2) {
System.out.println(Thread.currentThread().getName() + " 放弃CPU执行权。");
Thread.yield(); // 让出 CPU
}
}
};

Thread thread1 = new Thread(task, "Thread-1");
Thread thread2 = new Thread(task, "Thread-2");

thread1.start();
thread2.start();
}
}

观察结果:在输出到 “放弃CPU执行权” 后,你可能会看到另一个线程的输出先出现。但这并不绝对,因为 yield() 只是一个建议。

在Java多线程编程中,yield()join()setDaemon() 这些方法都有严格的设置时机,如果使用不当,可能会导致异常或行为不符合预期。

让我们来详细解释一下每个方法的设置时机。


1. setDaemon(boolean on) 方法

  • 作用:将一个线程设置为守护线程或用户线程。

  • 设置时机必须在线程启动前调用,即在 thread.start() 方法之前。

  • 为什么?

    • 线程一旦启动,其类型(用户线程或守护线程)就已经确定,并且不能在运行时改变。
    • 如果在 start() 方法之后调用 setDaemon(true),会抛出 IllegalThreadStateException 运行时异常。
  • 代码示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    Thread daemonThread = new Thread(() -> {
    // ... 守护线程的逻辑
    });

    // 错误示例:先启动再设置
    // daemonThread.start();
    // daemonThread.setDaemon(true); // 这里会抛出 IllegalThreadStateException

    // 正确示例:先设置再启动
    daemonThread.setDaemon(true);
    daemonThread.start();

2. join() 方法

  • 作用:让当前线程等待另一个线程执行完毕。

  • 设置时机必须在被等待的线程启动后调用

  • 为什么?

    • join() 的目的是让一个线程等待另一个线程。如果被等待的线程还没有启动,那么等待它结束是没有意义的。
    • 尽管在启动前调用不会抛出异常,但它也不会有任何效果,因为目标线程根本没有开始运行。
  • 代码示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13


    Thread workerThread = new Thread(() -> {
    // ... 工作线程的逻辑
    });

    // 错误示例:在工作线程启动前调用 join()
    // workerThread.join(); // 无效,因为 workerThread 还没有运行
    // workerThread.start();

    // 正确示例:在工作线程启动后调用 join()
    workerThread.start();
    workerThread.join(); // 主线程将等待 workerThread 运行完毕

3. yield() 方法

  • 作用:向线程调度器发出提示,当前线程愿意放弃 CPU 执行权。

  • 设置时机可以在线程生命周期的任何阶段调用,但通常在线程的 run() 方法中调用。

  • 为什么?

    • yield() 是一个动态的行为,它发生在线程正在执行其任务的过程中。
    • 它的目的是在任务执行过程中,在某个点上主动让出 CPU,让其他线程有机会运行。
  • 代码示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    Thread myThread = new Thread(() -> {
    for (int i = 0; i < 10; i++) {
    System.out.println("我正在运行...");
    if (i == 5) {
    // 在任务执行到一半时,让出 CPU
    Thread.yield();
    }
    }
    });

    myThread.start();

总结

方法 作用 设置时机 备注
setDaemon() 设定线程类型(守护/用户) thread.start() 之前 启动后设置会抛出异常
join() 等待线程执行结束 thread.start() 之后 在启动前调用是无效的
yield() 提示线程调度器让出 CPU 线程运行期间的任何时刻 只是一个建议,不保证生效

1. 深度解析

1. Thread.sleep(long millis)

功能: sleep() 方法是 Thread 类的静态方法,作用是让当前正在执行的线程暂停指定的时间。

核心机制:

  • sleep() 方法会使线程进入阻塞状态(Timed Waiting)
  • 不会释放任何持有的锁(monitor)
  • 当指定的时间过后,线程会回到可运行状态(Runnable),等待 CPU 调度。
  • 如果线程在休眠期间被其他线程调用 interrupt() 方法,sleep() 会立即抛出 InterruptedException
  • sleep() 的实现依赖于操作系统的调度器,实际暂停的时间可能比指定的时间长。

源码解析 (伪代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void sleep(long millis) throws InterruptedException {
if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}
// 检查中断标志
if (Thread.interrupted()) {
throw new InterruptedException();
}
// 调用本地方法,通知操作系统挂起当前线程
VM.nativeSleep(millis);
// 唤醒后再次检查中断标志
if (Thread.interrupted()) {
throw new InterruptedException();
}
}

VM.nativeSleep 是一个本地方法,它将线程挂起的任务交给操作系统内核来完成。

2. Thread.yield()

功能: yield() 方法是 Thread 类的静态方法,作用是让当前正在执行的线程放弃其 CPU 时间片,回到可运行状态(Runnable)

核心机制:

  • yield() 的目的是给其他线程一个执行的机会,但它不保证其他线程会立即运行。
  • 不会使线程进入阻塞或等待状态,线程只是从“正在运行”的状态切换到“可运行”队列的末尾。
  • 调度器可能会选择立即重新调度刚才调用 yield() 的线程。
  • 不会释放任何锁。
  • yield() 主要用于调试优化,通常不应用于核心业务逻辑。因为它依赖于调度器的实现,其行为不可预测。

源码解析 (伪代码):

1
2
3
4
public static void yield() {
// 调用本地方法,向操作系统调度器发出“让步”信号
nativeYield();
}

nativeYield 同样是一个本地方法,它向操作系统调度器发出一个提示,表示当前线程愿意让出 CPU。

3. obj.wait()

功能: wait() 方法是 Object 类的方法,作用是让当前线程进入等待状态(Waiting),并释放它所持有的对象的锁。

核心机制:

  • wait() 必须在**同步块(synchronized block)**内部调用,否则会抛出 IllegalMonitorStateException
  • 当线程调用 wait() 后,它会释放该同步块所关联的对象的锁,进入等待队列。
  • 线程需要等待其他线程调用同一对象的 notify()notifyAll() 方法才能被唤醒。
  • 被唤醒后,线程需要重新竞争对象的锁,才能继续执行。

源码解析 (伪代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public final void wait() throws InterruptedException {
wait(0); // 内部调用带超时参数的 wait 方法
}

public final void wait(long timeout) throws InterruptedException {
if (timeout < 0) {
throw new IllegalArgumentException("timeout value is negative");
}
// 必须持有对象的锁
if (!Thread.holdsLock(this)) {
throw new IllegalMonitorStateException();
}
// 通知虚拟机,当前线程进入等待状态,并释放锁
VM.nativeWait(this, timeout);
// 线程被唤醒后,重新竞争锁
// ...
}

wait() 是线程间通信和协作的关键,通常与 notify()notifyAll() 配合使用,实现生产者-消费者等设计模式。

4. Thread.join()

功能: join() 方法是 Thread 类的方法,作用是让当前线程(例如主线程)等待调用 join() 的线程(例如子线程)执行完毕。

核心机制:

  • join() 的本质是调用 wait() 方法。它在内部使用一个隐式的锁来同步。
  • 当一个线程调用 t.join() 时,当前线程(调用者)会阻塞,直到线程 t 终止。
  • 如果线程 t 终止(无论是正常结束还是异常终止),JVM 会调用 t.notifyAll() 来唤醒所有在 t.join() 上等待的线程。

源码解析 (简化版):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public final synchronized void join(long millis) throws InterruptedException {
// 必须在同步方法中,使用this作为锁
long base = System.currentTimeMillis();
long now = 0;
if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}

// 如果线程已经结束,直接返回
if (isAlive()) {
// 进入等待
wait(millis);
}
}

join() 的实现巧妙地利用了 wait()notifyAll() 的机制。当一个线程的 run() 方法执行完毕时,JVM 会自动调用其 notifyAll() 方法,从而唤醒所有等待它的线程。


2. 区别与使用场景总结

特性/方法 Thread.sleep() Thread.yield() obj.wait() Thread.join()
类/接口 java.lang.Thread (静态) java.lang.Thread (静态) java.lang.Object java.lang.Thread
是否释放锁 不释放 不释放 释放 不释放 (但其内部实现会释放)
状态转换 RUNNING -> TIMED_WAITING RUNNING -> RUNNABLE RUNNING -> WAITING RUNNING -> WAITING/TIMED_WAITING
唤醒方式 时间到了自动唤醒 或 interrupt() 依赖于操作系统调度器,立即或稍后被重新调度 必须由其他线程调用 notify()/notifyAll() 目标线程执行结束时自动唤醒 或 interrupt()
使用场景 模拟耗时操作,或让出 CPU 资源(但不释放锁) 提示调度器给其他线程机会,通常用于调试或低优先级的任务 线程间的协作和通信,实现生产者-消费者模式 等待一个线程执行结束,实现线程间的顺序执行
是否需要同步块 (必须在 synchronized 块内) 否 (但其内部实现依赖于同步机制)

3. 使用场景示例

  • sleep(): 你需要一个定时任务,比如每隔一秒钟打印一次日志。

    1
    2
    3
    4
    for (int i = 0; i < 5; i++) {
    Thread.sleep(1000); // 暂停一秒
    System.out.println("等待了 " + (i + 1) + " 秒");
    }
  • yield(): 你有一个 CPU 密集型任务,希望在每次循环中给其他线程一个运行的机会,以避免饥饿。

    1
    2
    3
    4
    5
    6
    for (int i = 0; i < 1000; i++) {
    // 执行一些计算
    if (i % 100 == 0) {
    Thread.yield(); // 让出 CPU
    }
    }
  • wait(): 你有一个共享队列,当队列为空时,消费者线程需要等待,当生产者放入新元素时,消费者被唤醒。

    1
    2
    3
    4
    5
    6
    synchronized (sharedQueue) {
    while (sharedQueue.isEmpty()) {
    sharedQueue.wait(); // 队列为空,等待并释放锁
    }
    // 消费元素...
    }
  • join(): 你需要确保子线程的数据处理完成后,主线程才能继续使用这个数据。

    1
    2
    3
    4
    Thread dataProcessor = new Thread(() -> { /* 处理数据 */ });
    dataProcessor.start();
    dataProcessor.join(); // 主线程等待数据处理完成
    System.out.println("数据处理完成,主线程可以继续了。");

理解这些方法的细微差别是编写健壮、高效并发代码的基础。特别是 wait()sleep(),它们最大的区别在于是否释放锁,这直接影响着线程间的同步和协作方式。

线程(Thread)的概念与生命周期

线程是操作系统调度的最小单位,是进程中的一个执行路径。一个进程可以包含多个线程,这些线程共享进程的内存空间。

线程的生命周期通常包含以下六种状态(定义在 java.lang.Thread.State 枚举中):

  • NEW(新建):线程被创建但尚未启动。当使用 new Thread() 创建一个线程实例后,它就处于此状态。

    1
    2
    Thread myThread = new Thread(() -> System.out.println("Hello from a new thread!"));
    // 此时 myThread 处于 NEW 状态,尚未执行 start()
  • RUNNABLE(可运行):线程已调用 start() 方法,正在 JVM 中运行(可能正在执行,也可能在等待 CPU 调度)。一个 RUNNABLE 状态的线程可能正在运行,也可能并没有运行,它仅仅是具备了运行的资格。

    1
    2
    myThread.start();
    // 此时 myThread 进入 RUNNABLE 状态,等待 CPU 调度执行 run() 方法
  • BLOCKED (阻塞):线程正在等待获取一个监视器锁(例如,进入 synchronized 块或方法)。当一个线程试图访问被其他线程锁定的资源时,它会进入此状态。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    Object lock = new Object();
    // 线程已经持有 lock 对象的锁
    synchronized (lock) {
    // ... 线程 A 正在执行
    }
    // 线程 B 尝试获取 lock 对象的锁,但被线程 A 占用,会进入 BLOCKED 状态
    synchronized (lock) {
    // ...
    }
  • WAITING (等待):线程无限制地等待另一个线程执行特定操作。例如,调用 Object.wait(), Thread.join() (无参数) 或 LockSupport.park().这些方法会使线程放弃 CPU 使用权,并进入无限制等待,直到被其他线程 notify(), notifyAll() 或 unpark() 唤醒。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    Object sharedObject = new Object();
    // 线程 A:
    synchronized (sharedObject) {
    sharedObject.wait(); // 线程 A 进入 WAITING 状态,并释放 sharedObject 的锁
    }
    // 线程 B:
    Thread threadA = new Thread(() -> { /* ... */ });
    threadA.start();
    threadA.join(); // 线程 B 等待 threadA 执行完毕,进入 WAITING 状态
  • TIMED_WAITING (有时限等待):线程在指定的时间内等待另一个线程执行特定操作,或者休眠。例如,调用 Thread.sleep(long millis)、Object.wait(long timeout)、Thread.join(long millis)、LockSupport.parkNanos() 或 LockSupport.parkUntil()。一旦超时间到达,线程会自动从等待状态唤醒,并尝试重新进入 RUNNABLE 状态。

    1
    2
    Thread.sleep(1000);
    // 线程进入 TIMED_WAITING 状态 1 秒
  • TERMINATED (终止): 线程已执行完毕其 run() 方法, 或者因未捕获的异常退出。线程一旦进入此状态, 就不能再被重新启动。

    1
    2
    // 线程的 run() 方法执行完毕
    // 线程在执行过程中抛出未捕获的异常

什么是线程上下文切换?

线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器,栈信息等。当出现如下情况的时候,线程会从占用 CPU 状态中退出。

  • 主动让出 CPU,比如调用了 sleep(), wait() 等。
  • 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
  • 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
  • 被终止或结束运行

这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换

上下文切换是现代操作系统的基本功能,因其每次需要保存信息恢复信息,这将会占用 CPU,内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下。

Thread#sleep() 方法和 Object#wait() 方法对比


共同点:两者都可以暂停线程的执行。

区别

  • sleep() 方法没有释放锁,而 wait() 方法释放了锁
  • wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停执行。
  • wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。sleep()方法执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout) 超时后线程会自动苏醒。
  • sleep()Thread 类的静态本地方法,wait() 则是 Object 类的本地方法。为什么这样设计呢?下一个问题就会聊到。

为什么 wait() 方法不定义在 Thread 中?

wait() 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(Object)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(Object)而非当前的线程(Thread)。

类似的问题:为什么 sleep() 方法定义在 Thread 中?

因为 sleep() 是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。


可以直接调用 Thread 类的 run 方法吗?

这是另一个非常经典的 Java 多线程面试问题,而且在面试中会经常被问到。很简单,但是很多人都会答不上来!

new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

总结:调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。

创建线程的方式

继承 Thread 类:

  • 通过创建 Thread 类的子类, 并重写其 run() 方法, 在该方法中定义线程执行的任务。
  • 创建 Thread 子类的实例, 并调用其 start() 方法来启动线程。调用 start() 方法会使线程进入 RUNNABLE 状态, 并由 JVM 调度执行 run() 方法; 直接调用 run() 方法则只是在当前线程中执行普通方法, 不会启动新线程。
  • 优点: 实现简单直观, 代码结构清晰。
  • 缺点: Java 是单继承的, 如果你的类已经继承了其他类, 就不能再继承 Thread 类。这限制了类的灵活性。此外, 任务 (run() 方法中的逻辑 ) 与线程本身 (Thread 对象) 紧密耦合, 不利于任务的复用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// MyThread.java
class MyThread extends Thread {
private String threadName;

public MyThread(String name) {
this.threadName = name;
System.out.println("Creating " + threadName );
}

@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " is running via extending Thread.");
try {
for(int i = 4; i > 0; i--) {
System.out.println("Thread: " + threadName + ", " + i);
// 模拟耗时操作
Thread.sleep(50);
}
} catch (InterruptedException e) {
System.out.println("Thread " + threadName + " interrupted.");
Thread.currentThread().interrupt(); // 重新设置中断标志
}
System.out.println("Thread " + threadName + " exiting.");
}
}

// ThreadCreationDemo.java
public class ThreadCreationDemo {
public static void main(String[] args) {
System.out.println("Main thread started.");
MyThread thread1 = new MyThread("MyThread-1");
thread1.start(); // 启动新线程
MyThread thread2 = new MyThread("MyThread-2");
thread2.start(); // 启动另一个新线程
System.out.println("Main thread finished starting other threads.");
}
}

实现 Runnable 接口:

  • 定义一个类实现 Runnable 接口,并实现其抽象方法 public void run()。run() 方法中包含线程执行的具体任务。
  • 创建 Runnable 实现类的实例,然后将其作为参数传入 Thread 类的构造器 (new Thread(Runnable target)),再调用 Thread 实例的 start() 方法。
  • 优点:
    • 推荐方式:避免了 Java 单继承的限制,你的类可以同时继承其他类来实现 Runnable 接口。
    • 任务与线程解耦:Runnable 对象只负责定义任务,而 Thread 对象负责执行任务。这意味着同一个 Runnable 对象可以被多个 Thread 实例共享执行,从而更好地实现资源的共享和任务的复用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// MyRunnable.java
class MyRunnable implements Runnable {
private String taskName;

public MyRunnable(String name) {
this.taskName = name;
System.out.println("Creating " + taskName );
}

@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " is running via implementing Runnable.");
try {
for(int i = 4; i > 0; i--) {
System.out.println("Task: " + taskName + ", " + i);
Thread.sleep(50);
}
} catch (InterruptedException e) {
System.out.println("Task " + taskName + " interrupted.");
Thread.currentThread().interrupt();
}
System.out.println("Task " + taskName + " exiting.");
}
}

// ThreadCreationDemo.java
public class ThreadCreationDemo {
public static void main(String[] args) {
System.out.println("Main thread started.");
Runnable runnable1 = new MyRunnable("Runnable-Task-1");
Thread thread3 = new Thread(runnable1);
thread3.start();

Runnable runnable2 = new MyRunnable("Runnable-Task-2");
Thread thread4 = new Thread(runnable2, "Custom-Thread-Name"); // 可以给线程命名
thread4.start();
System.out.println("Main thread finished starting runnable threads.");
}
}

实现Callable接口

Callable 是 Java 并发编程中一个非常重要的接口,它与 Runnable 类似,都用于定义一个可在线程中执行的任务。但 Callable 提供了更强大的功能,主要体现在两个方面:

  1. 可以返回结果: Callablecall() 方法可以返回一个泛型类型的结果。
  2. 可以抛出异常: Callablecall() 方法可以声明抛出任何 Exception

这与 Runnable 接口形成了鲜明对比,Runnablerun() 方法没有返回值,也不能抛出受检异常(checked exception)。

  • Callable 的基本结构

Callable 接口是一个泛型接口,定义如下:

1
2
3
4
5
6
7
8
9
@FunctionalInterface
public interface Callable<V> {
/**
* 计算并返回结果,或在无法做到时抛出异常。
* @return 计算结果
* @throws Exception 如果无法计算结果
*/
V call() throws Exception;
}
  • <V>:泛型参数,代表 call() 方法将要返回的结果类型。

  • V call()call() 方法的签名,它返回一个 V 类型的结果,并且可以抛出异常。

  • 如何使用 Callable

Callable 接口本身并不能直接作为 Thread 的构造参数。它需要配合 ExecutorService 线程池和 Future 接口一起使用。

典型使用流程:

  1. 创建 Callable 任务: 实现 Callable 接口,并在 call() 方法中编写具体的业务逻辑,返回一个结果。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    import java.util.concurrent.Callable;

    public class MyCallableTask implements Callable<String> {
    @Override
    public String call() throws Exception {
    Thread.sleep(2000); // 模拟耗时操作
    return "任务执行完毕,返回结果";
    }
    }
  2. 创建 ExecutorService 线程池: 使用 Executors 工厂类创建线程池。

    1
    2
    3
    4
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;

    ExecutorService executor = Executors.newFixedThreadPool(2);
  3. 提交 Callable 任务: 使用 ExecutorServicesubmit() 方法提交任务。submit() 方法会返回一个 Future 对象。

    1
    2
    3
    4
    import java.util.concurrent.Future;

    Callable<String> task = new MyCallableTask();
    Future<String> future = executor.submit(task);
  4. 获取任务结果: 通过 Future 对象的 get() 方法来获取 Callable 任务的执行结果。

    • future.get() 是一个阻塞方法,它会一直等待,直到任务执行完毕并返回结果。
    • 如果任务执行过程中抛出了异常,get() 方法也会将这个异常包装在 ExecutionException 中重新抛出。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    import java.util.concurrent.ExecutionException;

    try {
    String result = future.get(); // 阻塞等待结果
    System.out.println(result);
    } catch (InterruptedException e) {
    // 线程被中断
    Thread.currentThread().interrupt();
    } catch (ExecutionException e) {
    // 任务执行过程中抛出的异常
    e.printStackTrace();
    } finally {
    executor.shutdown();
    }
  • CallableRunnable 的区别

特性 Callable Runnable
返回值 call() 方法有返回值(泛型 V run() 方法没有返回值(void
异常处理 call() 方法可以抛出受检异常 run() 方法不能直接抛出受检异常
执行方式 必须配合 ExecutorService.submit() 执行 可以直接作为 Thread 构造函数的参数,也可以通过 ExecutorService.execute()submit() 执行
功能 适用于需要返回计算结果或可能抛出异常的异步任务 适用于简单的异步任务,不需要返回结果

  • Future 接口

Future 接口是 Callable 的重要伴侣,它代表一个异步计算的结果。它提供了检查任务是否完成、等待任务完成以及获取任务结果的方法。

Future 接口的主要方法:

  • V get(): 阻塞式地等待任务完成,并返回结果。

  • V get(long timeout, TimeUnit unit): 在指定时间内等待任务完成,超时则抛出 TimeoutException

  • boolean isDone(): 检查任务是否已经完成。

  • boolean cancel(boolean mayInterruptIfRunning): 尝试取消任务。

  • 总结

Callable 是 Java 并发编程中一个更高级的任务抽象,它解决了 Runnable 接口无法返回结果和处理受检异常的痛点。通过与 ExecutorServiceFuture 接口的组合使用,Callable 使得异步编程变得更加简单和灵活,非常适合那些需要耗时计算并返回结果的场景,比如网络请求、数据处理等。

线程池 (Thread Pool)

线程池是一种基于池化思想的线程管理机制,用于管理和复用线程,而不是在每次需要执行任务时都创建新线程。

  • 为什么使用线程池?

    • 降低资源消耗:通过重复利用已创建的线程,降低线程创建和销毁的开销。
    • 提高响应速度:当任务到达时,任务可以直接执行,无需等待线程创建。
    • 提高线程的可管理性:线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以统一分配、调优和监控。
    • 提供更多功能:如定时执行、周期执行、单线程化等。
  • 线程池的核心参数 (ThreadPoolExecutor 构造方法)

    1
    2
    3
    4
    5
    6
    7
    public ThreadPoolExecutor(int corePoolSize,
    int maximumPoolSize,
    long keepAliveTime,
    TimeUnit unit,
    BlockingQueue<Runnable> workQueue,
    ThreadFactory threadFactory,
    RejectedExecutionHandler handler)
    • corePoolSize: 核心线程数。线程池中始终保持的线程数量,即使它们处于空闲状态,除非设置了 allowCoreThreadTimeOut(true)。

    • maximumPoolSize: 最大线程数。线程池中允许存在的最大线程数量。当工作队列已满且核心线程都在忙碌时,线程池会创建新的非核心线程,直到达到这个数量。

    • keepAliveTime: 当线程池中的线程数量超过 corePoolSize 时,这些空闲的非核心线程在终止之前等待新任务的最长时间。

    • unit: keepAliveTime 参数的时间单位。

    • workQueue: 任务队列 (阻塞队列)。用于存放等待执行的任务。

      • ArrayBlockingQueue: 基于数组的有界阻塞队列,需要指定容量。
      • LinkedBlockingQueue: 基于链表的阻塞队列,容量可以指定,也可以是无界的(默认)。如果使用无界队列,maximumPoolSize 参数将失效。
      • SynchronousQueue: 一个不存储元素的阻塞队列。每个插入操作必须等待一个对应的移除操作,反之亦然。
      • PriorityBlockingQueue: 支持优先级的无界阻塞队列,按照自然顺序或自定义比较器排序。
    • threadFactory: 线程工厂。用于创建新线程,可以自定义线程的命名、优先级等。

    • handler: 拒绝策略 (当线程池和工作队列都已满时,新的任务到来时的处理方式)。

      • ThreadPoolExecutor.AbortPolicy (默认): 直接抛出 RejectedExecutionException 异常。

      • ThreadPoolExecutor.CallerRunsPolicy: 由调用线程 (提交任务的线程) 执行任务。

      • ThreadPoolExecutor.DiscardOldestPolicy: 丢弃队列中最老的任务,然后尝试提交当前任务。

      • ThreadPoolExecutor.DiscardPolicy: 直接丢弃当前新提交的任务。

      • 因为CallerRunsPolicy这个拒绝策略,导致耗时的任务用了主线程执行,导致线程池阻塞,进而导致后续任务无法及时执行,严重的情况下很可能导致 OOM。

        我们从问题的本质入手,调用者采用CallerRunsPolicy是希望所有的任务都能够被执行,暂时无法处理的任务又被保存在阻塞队列BlockingQueue中。这样的话,在内存允许的情况下,我们可以增加阻塞队列BlockingQueue的大小并调整堆内存以容纳更多的任务,确保任务能够被准确执行。

        为了充分利用 CPU,我们还可以调整线程池的maximumPoolSize (最大线程数)参数,这样可以提高任务处理速度,避免累计在 BlockingQueue的任务过多导致内存用完。

  • 线程池的执行流程

    1. 当一个任务提交到线程池时,如果当前运行的线程数小于 corePoolSize,即使有空闲线程,也会创建并启动一个新线程来执行任务。
    2. 如果当前运行的线程数大于或等于 corePoolSize,但任务队列 workQueue 未满,任务会被添加到 workQueue 中等待执行。
    3. 如果 workQueue 已满,但当前运行的线程数小于 maximumPoolSize,线程池会创建新的非核心线程来执行任务。
    4. 如果当前运行的线程数等于 maximumPoolSize 且 workQueue 已满,线程池会根据拒绝策略来处理新提交的任务。
  • Java 内置的四种常用线程池 (通过 Executors 工厂类创建)

    • FixedThreadPool (固定大小线程池)
      • Executors.newFixedThreadPool(int nThreads)
      • corePoolSize = maximumPoolSize = nThreads
      • 使用无界 LinkedBlockingQueue。
      • 特点:可控制并发的线程数,超出的任务会在队列中等待。
      • 问题:当任务提交速度远大于处理速度时,队列会不断增长,可能导致 OOM。
    • SingleThreadExecutor (单线程线程池)
      • Executors.newSingleThreadExecutor()
      • corePoolSize = maximumPoolSize = 1
      • 使用无界 LinkedBlockingQueue。
      • 特点:保证所有任务都在一个线程中按顺序执行。
      • 问题:同 FixedThreadPool,队列无限增长可能导致 OOM。
    • CachedThreadPool(可缓存线程池)
      • Executors.newCachedThreadPool()
      • corePoolSize = 0, maximumPoolSize = Integer.MAX_VALUE
      • 使用 SynchronousQueue。
      • keepAliveTime = 60s
      • 特点:当任务到来时,有空闲线程则复用,无空闲线程则创建新线程。适用于大量短时任务。
      • 问题:maximumPoolSize 过大,当任务并发量极高时,可能创建大量线程,导致系统资源耗尽 (OOM)。
    • ScheduledThreadPool(定时任务线程池)
      • Executors.newScheduledThreadPool(int corePoolSize)
      • 特点:支持定时及周期性任务执行。
      • 内部使用 DelayedWorkQueue,一个无界队列,可以按时间进行排序。
  • 阿里巴巴开发手册建议:不推荐使用 Executors 创建线程池,而是手动通过 ThreadPoolExecutor 的构造方法创建,以明确线程池的运行规则,避免资源耗尽的风险。

好的,这是一个非常核心且重要的 Java 并发编程话题。我将从线程池的创建、生命周期和启动流程三个方面进行详细且深入的解析。


一、线程池的创建 (ThreadPoolExecutor)

Java 中,线程池的核心是 java.util.concurrent.ThreadPoolExecutor 类。创建线程池最推荐的方式是直接使用它的构造函数,而不是使用 Executors 工厂类,因为这能让你更清楚地了解线程池的运行参数。

ThreadPoolExecutor 的构造函数有多个参数,理解它们是掌握线程池的关键:

Java

1
2
3
4
5
6
7
8
9
public ThreadPoolExecutor(
int corePoolSize, // 核心线程数
int maximumPoolSize, // 最大线程数
long keepAliveTime, // 线程空闲存活时间
TimeUnit unit, // 时间单位
BlockingQueue<Runnable> workQueue, // 任务队列
ThreadFactory threadFactory, // 线程工厂
RejectedExecutionHandler handler // 拒绝策略
)

各参数详解:

  1. corePoolSize (核心线程数)
    • 作用:线程池中常驻的线程数量。即使这些线程处于空闲状态,它们也不会被销毁。
    • 用途:当有新任务提交时,如果核心线程数未达到 corePoolSize,线程池会创建新线程来处理任务,直到核心线程数达到上限。
  2. maximumPoolSize (最大线程数)
    • 作用:线程池中允许存在的最大线程数量。
    • 用途:当任务队列已满,且核心线程数已满时,线程池会创建新的线程(非核心线程)来处理任务,直到总线程数达到 maximumPoolSize
  3. keepAliveTime (线程空闲存活时间)
    • 作用:非核心线程的空闲存活时间。
    • 用途:当线程池中的线程数量超过 corePoolSize 时,如果一个非核心线程空闲时间超过 keepAliveTime,它就会被回收。
  4. unit (时间单位)
    • 作用keepAliveTime 的时间单位,如秒、分钟、毫秒。
  5. workQueue (任务队列)
    • 作用:用于存放等待执行的任务。当核心线程都在忙碌时,新提交的任务会进入这个队列。
    • 常见类型
      • ArrayBlockingQueue:基于数组,有界的阻塞队列。
      • LinkedBlockingQueue:基于链表,通常是无界的,也可以指定容量。
      • SynchronousQueue:不存储任务,每个任务提交都会立即被一个线程执行。
  6. ThreadFactory (线程工厂)
    • 作用:用于创建线程。可以自定义工厂来为线程命名、设置守护状态等。
  7. RejectedExecutionHandler (拒绝策略)
    • 作用:当任务队列已满,且线程数达到 maximumPoolSize 时,线程池会根据拒绝策略来处理新提交的任务。
    • 常见策略
      • AbortPolicy (默认):直接抛出 RejectedExecutionException 异常。
      • CallerRunsPolicy:由提交任务的线程(caller)自己来执行任务。
      • DiscardOldestPolicy:丢弃任务队列中最老的任务,然后尝试将当前任务加入队列。
      • DiscardPolicy:直接丢弃当前任务,不抛出异常。

二、线程池的生命周期

线程池有五种状态,这些状态通过内部的 ctl 变量控制,它包含线程池的运行状态和线程数。

  1. RUNNING (运行中)
    • 状态:接受新任务,并处理阻塞队列中的任务。
    • 转换:创建线程池时处于此状态。
  2. SHUTDOWN (关闭)
    • 状态:不接受新任务,但会处理阻塞队列中的已有任务。
    • 转换:调用 shutdown() 方法后进入此状态。
  3. STOP (停止)
    • 状态:不接受新任务,也不处理阻塞队列中的已有任务,会中断所有正在执行的任务。
    • 转换:调用 shutdownNow() 方法后进入此状态。
  4. TIDYING (整理)
    • 状态:所有任务都已终止,线程数为 0。
    • 转换:当线程池处于 SHUTDOWNSTOP 状态,且所有任务都已完成、线程都已销毁时,进入此状态。
  5. TERMINATED (终止)
    • 状态:彻底终止,可以安全地进行回收。
    • 转换TIDYING 状态下,terminated() 方法执行完毕后进入此状态。

三、线程池的启动流程 (任务提交过程)

当调用 executor.execute(Runnable task) 方法提交一个新任务时,线程池会按照以下严格的流程来决定如何处理任务:

  1. 核心线程检查
    • 如果当前线程池中的线程数量小于 corePoolSize,则创建并启动一个新线程来执行任务。即使有空闲的线程,也会创建新线程直到达到核心线程数。
  2. 任务队列检查
    • 如果当前线程池中的线程数量等于或大于 corePoolSize,则将任务放入任务队列 workQueue 中等待。
  3. 最大线程数检查
    • 如果任务队列已满,线程池会再次检查当前线程数。
    • 如果当前线程数量小于 maximumPoolSize,则创建并启动一个新线程(非核心线程)来执行任务。
  4. 拒绝策略
    • 如果任务队列已满,且当前线程数已达到 maximumPoolSize,则根据线程池的拒绝策略来处理这个新任务。

总结:线程池的运行是一个复杂的、多阶段的过程。它优先使用核心线程,然后将任务放入队列,最后才考虑创建非核心线程。这个流程设计旨在最大限度地利用现有线程,减少线程创建和销毁的开销,从而提高性能和资源利用率。

ThreadLocal:

好的,我们来详细聊聊 ThreadLocal

ThreadLocal 是什么?

ThreadLocal(线程本地变量)并不是用来解决线程间共享数据问题的,它的核心作用是为每个使用该变量的线程都提供一个独立的、隔离的副本

你可以把 ThreadLocal 想象成一个“线程专属的储物柜”。每个线程都可以往这个储物柜里存东西(通过 set() 方法),取东西(通过 get() 方法),但它只能看到自己储物柜里的东西,无法访问其他线程的。

ThreadLocal 内部其实是通过一个 ThreadLocalMap 来实现的。这个 Map 的键是 ThreadLocal 对象本身,值就是你存入的那个变量。每个线程都有一个属于自己的 ThreadLocalMap


为什么需要 ThreadLocal

我们通常在开发中会遇到两种数据共享问题:

  1. 多个线程共享一个变量:这种情况下,需要通过 synchronizedvolatileLock 来保证线程安全。
  2. 每个线程需要一个独立的变量:这是 ThreadLocal 的主要应用场景。

如果不用 ThreadLocal,我们可能需要自己手动维护一个 Map<Thread, Object>,每次存取数据时都以当前线程作为键。这样不仅麻烦,还容易出错。ThreadLocal 帮我们封装了这些细节,让使用变得非常简单。


ThreadLocal 的常见应用场景

ThreadLocal 最常见的应用场景是在 Web 开发中,用于存储与当前请求相关的上下文信息

例如,一个 HTTP 请求从进入服务器到返回响应,可能由多个方法或组件来处理,但它们都属于同一个线程。如果需要传递一些请求相关的状态(比如用户身份、事务 ID、数据库连接),我们有很多种做法:

  • 参数传递:将这些信息作为参数层层传递。这会导致方法签名变得臃肿,并且增加了代码的耦合性。
  • 静态变量:如果用静态变量,多个请求同时到达时会互相覆盖,导致线程不安全。
  • ThreadLocal:这是最优雅的解决方案。你可以把这些信息存入 ThreadLocal,然后在任何需要的地方直接通过 get() 方法获取,无需在方法间显式传递。

典型的例子:

  • Spring 的事务管理:Spring 框架在处理事务时,会使用 ThreadLocal 来保存每个线程的数据库连接,确保在同一个事务中的所有操作都使用同一个连接。
  • 上下文信息:例如,在请求处理链中,将用户登录信息、语言偏好等数据存入 ThreadLocal,下游的业务逻辑可以随时获取。

ThreadLocal 可能带来的问题

内存泄漏

ThreadLocal 可能会导致内存泄漏。这是一个非常重要的问题。

ThreadLocalMap 使用的是弱引用(Weak Reference) 作为键。这意味着,当 ThreadLocal 对象没有其他强引用时,即使它还在 ThreadLocalMap 中,垃圾回收器也会回收它。

但是,ThreadLocalMap 的值(也就是你存入的对象)是强引用。如果线程一直存活,但你不再使用 ThreadLocal 对象,ThreadLocalMap 中的键就会变成 null,但值还在。这样,值对象就无法被回收,导致内存泄漏。

如何避免?

解决这个问题的关键在于:在 ThreadLocal 使用完毕后,务必调用 remove() 方法。

在 Web 应用中,请求处理结束后,线程会被放回线程池。如果 ThreadLocal 没有被清除,那么下一次其他请求再拿到这个线程时,它会读取到上一个请求残留的数据,导致业务逻辑出错。因此,正确使用模式通常是:

1
2
3
4
5
6
7
8
9
ThreadLocal<String> threadLocal = new ThreadLocal<>();

try {
threadLocal.set("My Data");
// do some work
} finally {
// 确保在任何情况下都执行清理操作
threadLocal.remove();
}

ThreadLocalMap 使用弱引用作为键,但使用强引用作为值,这样做主要是为了优化内存管理并处理由 ThreadLocal 引起的潜在内存泄漏问题。这种设计虽然能解决一些问题,但也引入了新的挑战。

为什么键是弱引用?

ThreadLocalMap 的键是 ThreadLocal 对象的弱引用。当一个 ThreadLocal 对象没有其他地方(比如一个变量)引用它时,它就会被垃圾回收器回收。

  • 避免键的内存泄漏: 如果键是强引用,即使 ThreadLocal 对象在程序中不再被使用,只要线程还活着,这个 ThreadLocalMap 实例就会一直持有对它的强引用,导致 ThreadLocal 对象本身无法被回收,从而引起内存泄漏。通过使用弱引用,当 ThreadLocal 变量超出作用域后,即使线程依然存活,ThreadLocalMap 中的键也会被自动回收。

为什么值是强引用?

与键不同,ThreadLocalMap 的值(也就是你通过 set 方法存入的对象)是强引用。

  • 确保值的可用性: 如果值也是弱引用,那么当 ThreadLocal 变量超出作用域后,值也可能随时被垃圾回收器回收。这样一来,当你再次通过 get 方法访问时,就会得到 null,这与 ThreadLocal 的使用初衷相悖。ThreadLocal 的目的是为每个线程提供独立的、可以持续访问的变量副本。

这种设计带来的内存泄漏问题

虽然键的弱引用设计解决了 ThreadLocal 对象的内存泄漏,但如果开发者没有正确地处理,仍然会导致值的内存泄漏。

考虑以下场景:

  1. 你在一个线程中创建了一个 ThreadLocal 对象,并设置了一个值。
  2. ThreadLocalMap 中会有一个键值对,键是 ThreadLocal 对象的弱引用,值是你设置的对象。
  3. ThreadLocal 对象在外部不再被强引用,被垃圾回收器回收。
  4. 此时 ThreadLocalMap 中的键变为 null,但值依然存在,因为它是一个强引用。

如果线程一直存活(比如在线程池中),而你没有手动调用 ThreadLocal.remove(),那么这个值为 null 的键值对就会一直占用内存,导致内存泄漏。

解决方案

为了解决这个问题,Java 社区推荐的最佳实践是:无论何时使用完 ThreadLocal 变量,都应该显式地调用 ThreadLocal.remove() 方法。

ThreadLocal.remove() 会从 ThreadLocalMap 中移除对应的键值对,从而释放内存,避免内存泄漏。这个操作通常应该放在 finally 块中,以确保即使发生异常,也能执行清理工作。

例如:

1
2
3
4
5
6
7
8
ThreadLocal<MyObject> threadLocal = new ThreadLocal<>();

try {
threadLocal.set(new MyObject());
// 业务逻辑...
} finally {
threadLocal.remove();
}

总而言之,ThreadLocalMap 的设计是一种平衡。它利用弱引用来自动处理 ThreadLocal 对象的回收,从而减轻了开发者的负担。但与此同时,它把值的清理责任留给了开发者,要求我们通过 remove() 方法来显式地释放内存,从而避免因长期存活的线程而导致的值的内存泄漏。

继承问题

ThreadLocal 的值不会自动传递给子线程。如果你需要父线程创建子线程时,让子线程也能访问父线程的 ThreadLocal 值,你需要使用 InheritableThreadLocal。不过,InheritableThreadLocal 同样需要注意内存泄漏问题,并且在线程池环境下使用时可能会有意外行为,需要格外小心。

Collection (集合框架):

  • 核心接口: Collection (父接口), List, Set, Map。
  • Iterable 接口: Collection 接口继承了 Iterable 接口,使得所有集合都可以通过增强for 循环(foreach)进行遍历。

A. List 接口及其实现类

List 是一种有序集合,可以包含重复元素。

1. ArrayList
  • 底层实现:基于动态数组(Object[] elementData)实现。

  • 特点:

    • 有序:元素有插入顺序,可以通过索引访问(get(index))。
    • 可重复:允许存储重复元素。
    • 随机访问效率高:通过索引访问元素(get(index))速度非常快,时间复杂度为O(1)。这是因为数组在内存中是连续存储的,可以通过基地址和偏移量直接计算出元素的内存地址。
    • 插入和删除效率低:
      • 在数组末尾添加或删除元素效率较高(平均O(1))。
      • 在数组中间插入或删除元素时,需要使用System.arraycopy()移动被影响位置之后的所有元素,时间复杂度为 O(n)。
    • 线程不安全:在多线程环境下,如果一个线程正在修改 ArrayList,而另一个线程正在读取或修改它,可能会导致数据不一致或 ConcurrentModificationException(在使用迭代器时)。
  • 扩容机制:

    • 初始容量:默认情况下,当你创建一个无参的ArrayList时,它的底层数组是空的(DEFAULTCAPACITY_EMPTY_ELEMENTDATA, 即 new Object[0])。首次添加元素时,内部数组会被初始化为默认容量 DEFAULT_CAPACITY (JDK8为10)。如果你在创建时指定了容量(new ArrayList<>(capacity)),则初始容量就是你指定的。
    • 扩容时机:当ArrayList 的当前元素个数(size)等于底层数组的容量(elementData.length)时,就会触发扩容。
    • 扩容方式:扩容逻辑位于grow()方法中。
      • 计算新的容量:newCapacity = oldCapacity + (oldCapacity >> 1),即新容量是旧容量的1.5倍。
      • 如果计算出的新容量仍然小于需要的最小容量(minCapacity,即当前元素个数 size + 1),则直接将 minCapacity 作为新容量。
      • 如果新容量超出了MAX_ARRAY_SIZE(通常是 Integer.MAX_VALUE-8),则会尝试使用 Integer.MAX_VALUE,如果仍不足则抛出 OutOfMemoryError。
      • 创建一个新数组,并将旧数组中的元素复制到新数组中(Arrays.copyOf()内部调用 System.arraycopy())。
  • 为什么这么扩容(1.5倍):

    • 平衡空间与时间:
      • 相比于每次只增加1个元素,1.5倍的扩容策略减少了扩容的次数,从而减少了频繁进行数组复制带来的性能开销(数组复制是O(n)操作)。
      • 相比于2倍扩容,1.5倍的策略在空间利用率上更优,避免了过度分配和浪费过多内存。
    • 这是一个在时间和空间之间权衡的选择,旨在提供一个相对高效且内存友好的动态数组实现。
  • 可能出现的问题:

    • ConcurrentModificationException: 在多线程环境中,如果一个线程正在遍历 ArrayList(通过迭代器或增强 for循环),而另一个线程同时对其进行结构性修改(添加、删除元素等),就会抛出此异常。这是因为ArrayList 的迭代器是快速失败(fail-fast)的,它会检查 modCount(修改次数)是否与迭代器创建时一致。不一致则抛出异常。
    • 内存开销:如果预估容量不准确,频繁扩容会导致多次数组复制,增加 CPU和内存开销。
    • 内存浪费:如果初始容量设置过大,而实际使用的元素很少,会导致内存浪费。
  • 常用方法:

    • add(E e): 在列表末尾添加元素。
    • add(int index, E e): 在指定位置插入元素。
    • remove(int index) / remove(Object o): 删除指定位置或指定元素的第一个匹配项。
    • get(int index):获取指定位置的元素。
    • set(int index, E e): 替换指定位置的元素。
    • size(): 返回列表中元素的个数。
    • indexOf(Object o) / lastIndexOf(Object o):返回元素第一次/最后一次出现的索引。
    • contains(Object o): 判断是否包含某个元素。
    • clear(): 清空列表。
  • 遍历方式:

    1. 传统 for 循环:

      1
      2
      3
      for (int i = 0; i < list.size(); i++) {
      System.out.println(list.get(i));
      }
    2. 增强 for 循环(foreach):

      1
      2
      3
      for (E element: list) { //内部使用迭代器
      System.out.println(element);
      }
    3. 迭代器(Iterator):

      1
      2
      3
      4
      5
      6
      7
      8
      Iterator<E> it = list.iterator();
      while (it.hasNext()) {
      System.out.println(it.next());
      // 如果需要删除元素,必须使用 it.remove(),否则会抛出
      // ConcurrentModificationException
      // it.remove();
      }

    4. Java 8 Stream API:

      1
      2
      3
      4
      list.forEach(System.out::println);
      // 或
      list.stream().forEach(System.out::println);

RandomAccess 是 Java 集合框架中的一个标记接口,它本身不包含任何方法。它的主要作用是告诉 Java 虚拟机(JVM)和开发者,实现了这个接口的 List 集合支持快速随机访问,即通过索引访问元素时,性能是恒定的,通常为 O(1)。


ArrayList 实现 RandomAccess 的作用

ArrayList 的底层是数组。通过索引访问数组元素(如 list.get(i))是一个非常高效的操作,因为它直接通过内存地址偏移量来查找,耗时是固定的。

RandomAccess 接口的作用就是为这种特性提供一个标记。在某些算法或方法中(比如 Collections.binarySearch()),程序会先检查一个 List 是否实现了 RandomAccess

  • 如果实现了,就说明它是 ArrayList 这类基于数组的列表,此时会选择基于索引的 for 循环进行遍历,因为这种方式最快。
  • 如果没实现,就说明它是 LinkedList 这类基于链表的列表,此时会选择迭代器进行遍历,以避免低效的随机访问。

因此,RandomAccess 就像一个性能提示标签,帮助 JVM 和算法选择最合适的遍历或查找策略,从而优化性能。


为什么 LinkedList 没有实现 RandomAccess

LinkedList 的底层是双向链表。要通过索引访问链表中的一个元素(如 list.get(i)),需要从头节点或尾节点开始,逐个遍历到目标位置。这个操作的耗时与索引值 i 成正比,时间复杂度为 O(n)。

因为 LinkedList 不支持快速随机访问,所以它没有实现 RandomAccess 接口。如果用 for 循环来遍历 LinkedList,每次 get(i) 调用都会导致一次从头到尾的遍历,效率极低。相反,使用迭代器则能确保每次只移动到下一个元素,效率更高。

类型 底层数据结构 随机访问性能 (get(i)) 遍历方式 是否实现 RandomAccess
ArrayList 数组 O(1) for 循环
LinkedList 双向链表 O(n) 迭代器

总而言之,RandomAccess 接口是一个重要的设计模式,它利用多态性来指导程序为不同的 List 实现选择最优的算法,从而在不修改代码逻辑的前提下提升了性能。

2. LinkedList
  • 底层实现:基于双向链表(Doubly Linked List)实现。每个节点都包含数据,以及指向前一个节点和后一个节点的引用。

  • 特点:

    • 有序:元素有插入顺序。
    • 可重复:允许存储重复元素。
    • 插入和删除效率高:在链表的任何位置插入或删除元素,只需修改前后节点的引用,时间复杂度为O(1)。
    • 随机访问效率低:get(index)操作需要从头节点或尾节点开始遍历链表直到目标索引,时间复杂度为O(n)。
    • 内存开销大:每个节点除了存储数据本身,还需要额外的内存空间存储两个指针(prev 和 next),因此相比 ArrayList,在存储相同数量元素时, LinkedList 占用更多内存。
    • 线程不安全:与ArrayList 类似,在多线程环境下不安全,可能抛出 ConcurrentModificationException。
  • 扩容机制:

    • LinkedList 基于链表实现,没有固定容量的概念,也无需进行扩容。每次添加元素就是创建一个新节点并连接到链表中。因此不存在 ArrayList 那样的数组复制开销。
  • 可能出现的问题:

    • ConcurrentModification Exception:同样在多线程环境下使用迭代器进行修改时可能发生。
    • 内存碎片/开销:频繁的节点创建和销毁,以及每个节点额外的指针开销,可能导致一定的内存碎片和更高的内存占用。
  • 常用方法:

    • add(E e) / addFirst(E e) / addLast(E e): 添加元素。
    • remove() / removeFirst() / removeLast():删除元素。
    • get(int index) / getFirst() / getLast(): 获取元素(get(int index)效率低)。
    • peek() / peekFirst() / peekLast(): 获取但不移除头部/尾部元素。
    • offer(E e) / offerFirst(E e) / offerLast (E e): 添加元素到队列/双端队列(通常不抛异常)。
    • poll() / pollFirst() / pollLast(): 获取并移除头部/尾部元素(为空返回null)。
    • push(E e) / pop():实现栈的入栈和出栈操作。
    • size(), isEmpty(), contains(Object o), clear().
  • 遍历方式:

    1. 传统 for 循环: for (int i = 0; i < list.size(); i++) { System.out.println(list.get(i)); }(不推荐,效率低)

    2. 增强 for 循环(foreach): for (E element: list) { System.out.println(element); }(推荐)

    3. 迭代器(Iterator):

      Java

      1
      2
      3
      4
      5
      Iterator<E> it = list.iterator();
      while (it.hasNext()) {
      System.out.println(it.next());
      }

    4. Java 8 Stream API: list.forEach(System.out::println);

3. Vector
  • 底层实现:基于动态数组实现,与ArrayList 类似。
  • 特点:
    • 线程安全:所有公共方法都使用了synchronized 关键字进行同步,因此是线程安全的。
    • 效率低:因为所有操作都被同步,在单线程或并发读多写少的场景下,性能比 ArrayList 差。
    • 扩容机制: Vector 的扩容策略与 ArrayList 类似,但默认是翻倍扩容(即新容量是旧容量的2倍)。可以通过构造函数指定扩容增量。
  • 可能出现的问题:
    • 性能瓶颈:全局锁导致并发性能差。
  • 使用场景:已经被 java.util.concurrent 包中的并发集合(如 CopyOnWriteArrayList)取代,基本不再推荐使用。
4. Stack
  • 底层实现:继承自Vector,因此也是基于数组实现,并具有Vector 的线程安全性。
  • 特点:实现了后进先出(LIFO)的栈结构。
  • 常用方法:
    • push(E item):元素入栈。
    • pop():元素出栈。
    • peek(): 查看栈顶元素但不移除。
    • empty(): 判断栈是否为空。
    • search(Object o): 查找元素并返回离栈顶的距离。
  • 使用场景:不推荐使用,因为Stack 继承了Vector,而 Vector 本身有很多不适合栈操作的方法。通常使用 Deque 接口的实现类(如ArrayDeque 或 LinkedList)来代替栈,它们更灵活高效。

小结 List:

  • ArrayList: 随机访问多,插入删除少(尤其末尾操作)的场景。
  • LinkedList: 插入删除多,随机访问少的场景;或需要作为队列/栈使用的场景。
  • Vector / Stack:不推荐在现代Java开发中使用,除非有特殊历史兼容需求。

B. Set 接口及其实现类

Set 是一种无序集合,不允许重复元素。

1. HashSet
  • 底层实现:基于HashMap 实现。HashSet 内部使用一个 HashMap 实例来存储元素,HashSet 的元素作为HashMap的键(Key),而HashMap 的值(Value)则是一个固定的、无关紧要的 PRESENT 静态 Object 对象。

  • 特点:

    • 无序:不保证元素的存储顺序和迭代顺序。
    • 不可重复:元素唯一。通过元素的hashCode() 和 equals() 方法来判断元素的唯一性。当添加元素时,首先计算元素的hashCode(),然后根据哈希值找到对应的“桶”,再在该桶中遍历,如果存在 equals()为true 的元素,则不添加。
    • 允许 null元素:允许且只能存储一个 null 元素。
    • 查询、添加、删除的平均时间复杂度为(1) (在不发生哈希冲突或冲突较少的情况下)。最坏情况下(所有元素哈希冲突到同一个桶),会退化为 O(n)。
    • 线程不安全:与HashMap 类似,非同步。
  • 扩容机制:

    • 由于底层是HashMap,其扩容机制与 HashMap 完全相同。
    • 初始容量:默认初始容量为16。
    • 负载因子:默认负载因子为0.75。
    • 扩容时机:当HashSet 中存储的元素数量达到容量*负载因子时,就会进行扩容,新容量是旧容量的2倍。
    • 扩容过程:创建一个新的两倍大小的底层数组,然后遍历旧数组中的所有元素,重新计算它们的哈希值,并将它们放入新数组的正确位置。
  • 可能出现的问题:

    • 性能下降: 如果自定义类作为元素存储在 HashSet 中,但没有正确重写 hashCode() 和 equals() 方法,可能会导致元素重复,或者哈希冲突严重,从而导致性能急剧下降。
    • ConcurrentModicationException: 同步性问题,在多线程环境下使用迭代器修改集合时会抛出。
  • 常用方法:

    • add(E e): 添加元素。
    • remove(Object o): 删除元素。
    • contains(Object o): 判断是否包含元素。
    • size(): 返回集合中元素的个数。
    • isEmpty(), clear().
  • 遍历方式:

    1. 增强 for 循环 (foreach): for (E element : set) { System.out.println(element); }

    2. 迭代器 (Iterator):

      Java

      1
      2
      3
      4
      5
      Iterator<E> it = set.iterator();
      while (it.hasNext()) {
      System.out.println(it.next());
      }

    3. Java 8 Stream API: set.forEach(System.out::println);

2. LinkedHashSet
  • 底层实现: 继承自 HashSet,内部使用 LinkedHashMap 实现。
  • 特点:
    • 除了具备 HashSet 的所有特性外,最大的特点是保持元素的插入顺序(或者访问顺序,如果配置为 LRU 缓存)。这意味着遍历 LinkedHashSet时,元素的顺序与它们被添加到集合中的顺序一致。
    • 维护了一个双向链表,用于维护元素的插入顺序。
  • 扩容机制: 与 HashSet 和 HashMap 相同。
  • 使用场景: 需要去重,同时又需要保持元素插入顺序的场景。
3. TreeSet
  • 底层实现: 基于 TreeMap 实现。TreeSet 内部使用一个 TreeMap 实例来存储元素,TreeSet 的元素作为 TreeMap 的键,而值则是一个固定的 Object。
  • 特点:
    • 有序: 元素会根据其自然排序(元素必须实现 Comparable 接口)或者在创建 TreeSet 时提供的 Comparator 进行排序。
    • 不可重复: 元素唯一,唯一性通过比较结果判断(compareTo() 或 compare() 方法返回 0)。
    • 不允许 null 元素: 不允许存储 null 元素(因为 null 无法进行比较)。
    • 查询、添加、删除的时间复杂度为 O(log n),因为底层是红黑树。
    • 线程不安全: 非同步。
  • 扩容机制:
    • 由于底层是红黑树,没有传统意义上的扩容机制。每次添加元素就是增加一个节点,并根据红黑树的平衡规则进行调整(旋转和变色)来保持树的平衡。
  • 可能出现的问题:
    • 性能: 相比 HashSet,性能略低,因为涉及比较和树的平衡操作。
    • 元素必须可比较: 如果存储的元素没有实现 Comparable 接口,或者创建 TreeSet 时没有提供 Comparator,则会抛出 ClassCastException。
    • ConcurrentModicationException: 同步性问题。
  • 常用方法:
    • 与 HashSet 类似,但额外提供了与排序相关的方法,如 rst(), last(), headSet(), tailSet(), subSet() 等。

小结 Set:

  • HashSet: 最常用,需要快速查找、去重,不关心元素顺序的场景。
  • LinkedHashSet: 需要去重,同时又需要保持元素插入顺序的场景。
  • TreeSet: 需要去重,并且希望元素自动按照自然顺序或自定义顺序排序的场景。

C. Map 接口及其实现类

Map 存储键值对,键是唯一的,值可以重复。

1. HashMap
  • 底层实现: 基于哈希表实现,JDK 8 及以后是数组 + 链表 + 红黑树。

    • 数组: Node
    • 链表: 用于解决哈希冲突,将哈希到同一个索引位置的键值对以链表形式连接。
    • 红黑树: 当链表长度达到一定阈值(JDK 8 默认为 8)时,为了提高查找效率,该链表会转换为红黑树。当红黑树节点数少于一定阈值(JDK 8 默认为 6)时,会退化为链表。
  • 特点:

    • 无序: 不保证键值对的存储和迭代顺序。
    • 键唯一,值可重复: 键通过 hashCode() 和 equals() 方法确定唯一性。
    • 允许 null 键和 null 值: 只能有一个 null 键(存储在索引 0 的位置),可以有多个 null 值。
    • 查询、添加、删除的平均时间复杂度为 O(1),最坏情况下为 O(n)(链表)或 O(logn)(红黑树,JDK 8 及以后)。
  • 扩容机制:

    • 初始容量 (initialCapacity): 默认值为 16。最好在创建 HashMap 时预估并指定一个合适的初始容量,以减少扩容次数。

    • 负载因子 (loadFactor): 默认值为 0.75。表示哈希表在进行扩容前的填充比例。

    • 扩容时机: 当 HashMap 中存储的元素数量 (size) 达到 容量 * 负载因子 (即 threshold) 时,就会触发扩容。

    • 扩容方式: resize() 方法。

      1. 创建一个新的两倍大小的底层数组。
      2. 遍历旧数组中的所有键值对。
      3. 重新计算每个键的哈希值,并根据新的容量大小,将其放入新数组的正确位置。这个过程被称为再哈希 (rehash)。
      • JDK 8 优化: 在链表转换时,避免了每个节点单独重新计算哈希值,而是根据原索引和新容量的关系,直接判断节点在新数组中的位置,提高了效率。
  • 为什么这么扩容 (2 倍):

    • 位运算优化: 容量始终保持 2 的幂次方,可以利用位运算 (h & (length - 1)) 来替代取模运算 h % length,提高哈希值到索引的映射效率。
    • 减少哈希冲突: 扩容为 2 倍可以有效分散哈希冲突,使得更多的键能够映射到不同的桶,从而降低链表/红黑树的长度,保持 O(1) 的平均性能。
  • 为什么负载因子是 0.75:

    • 这是一个在“空间利用率”和“查询效率”之间的权衡。
    • 如果负载因子过小,会频繁扩容,浪费空间。
    • 如果负载因子过大,哈希冲突会增加,链表/红黑树变长,导致查询效率下降。
    • 0.75 这个值是经过实践验证,在大多数情况下能够提供较好性能的平衡点。
  • 可能出现的问题:

    • 性能下降: 如果自定义类作为键存储在 HashMap 中,但没有正确重写 hashCode() 和 equals() 方法,会导致元素重复,或者哈希冲突严重,从而导致性能急剧下降。
    • 多线程问题: 在多线程环境下,对 HashMap 进行修改操作可能导致数据丢失、死循环(JDK 7 及以前),或 ConcurrentModicationException。这是其最大的问题。
    • 内存开销: 频繁扩容会带来数组复制的开销。
  • 常用方法:

    • put(K key, V value): 关联键值对。
    • get(Object key):
  • 遍历方式:

    1. 遍历键集 (keySet()):

      Java

      1
      2
      3
      4
      5
      for (K key : map.keySet()) {
      V value = map.get(key);
      System.out.println("Key: " + key + ", Value: " + value);
      }

    2. 遍历键值对集 (entrySet()) - 推荐,效率最高:

      Java

      1
      2
      3
      4
      for (Map.Entry<K, V> entry : map.entrySet()) {
      System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
      }

    3. 遍历值集 (values()):

      Java

      1
      2
      3
      4
      for (V value : map.values()) {
      System.out.println("Value: " + value);
      }

    4. 迭代器 (Iterator):

      Java

      1
      2
      3
      4
      5
      6
      Iterator<Map.Entry<K, V>> it = map.entrySet().iterator();
      while (it.hasNext()) {
      Map.Entry<K, V> entry = it.next();
      System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
      }

    5. Java 8 Stream API:

      1
      map.forEach((key, value) -> System.out.println("Key: " + key + ", Value: " + value));
2. LinkedHashMap
  • 底层实现: 继承自 HashMap,额外维护了一个双向链表,用于维护插入顺序或访问顺序。
  • 特点:
    • 除了具备 HashMap 的所有特性外,最大的特点是保持键值对的插入顺序(或访问顺序)。这意味着遍历 LinkedHashMap 时,元素的顺序与它们被添加到 Map 中的顺序一致。
    • 可以用于实现 LRU (Least Recently Used) 缓存策略,通过覆盖 removeEldestEntry 方法并设置访问顺序为 true。
  • 扩容机制: 与 HashMap 相同。
  • 使用场景: 需要快速查找,同时又需要保持插入顺序的场景。
3. TreeMap
  • 底层实现: 基于红黑树(Red-Black Tree)实现。
  • 特点:
    • 有序: 键会根据其自然排序(键的类型必须实现 Comparable 接口)或者在创建 TreeMap 时提供的 Comparator 进行排序。
    • 键唯一,值可重复: 唯一性判断依赖于键的比较结果(compareTo() 或 compare() 方法返回 0)。
    • 不允许 null 键: 不允许存储 null 键(因为 null 无法进行比较),但允许 null 值。
    • 查询、添加、删除的时间复杂度为 O(log n),因为底层是红黑树。
    • 线程不安全: 非同步。
  • 扩容机制:
    • 由于底层是红黑树,没有传统意义上的扩容机制。每次添加键值对就是增加一个节点,并根据红黑树的平衡规则进行调整(旋转和变色)来保持树的平衡。
  • 可能出现的问题:
    • 性能: 相比 HashMap,性能略低,因为涉及比较和树的平衡操作。
    • 键必须可比较: 如果键没有实现 Comparable 接口,或者创建 TreeMap 时没有提供 Comparator,则会抛出 ClassCastException。
    • ConcurrentModicationException: 同步性问题。
  • 常用方法:
    • 与 HashMap 类似,但额外提供了与排序相关的方法,如 rstKey(), lastKey(), ceilingEntry(), oorEntry() 等。
4. Hashtable
  • 底层实现: 基于哈希表实现,与 HashMap 类似,但所有方法都使用了 synchronized 关键字。
  • 特点:
    • 线程安全: 所有公共方法都进行了同步处理。
    • 效率低: 全局锁导致并发性能差。
    • 不允许 null 键和 null 值。
    • 初始容量和扩容机制: 默认初始容量 11,负载因子 0.75。扩容时新容量是旧容量的 2 倍 + 1。
  • 使用场景: 已被 ConcurrentHashMap 取代,基本不再推荐使用。
5. ConcurrentHashMap (JUC 包中的并发集合)
  • 底层实现:

    • JDK 7 及以前: 采用分段锁 (Segment) 的方式,将 HashMap 内部数据分成多个段(Segment),每个段是一个独立的 ReentrantLock。锁住某个段时,不影响其他段的操作。
    • JDK 8 及以后: 放弃了分段锁,改为使用 CAS (Compare-And-Swap) 操作和 synchronized 关键字(只在链表/红黑树头节点发生竞争时才使用,锁住的范围更小)来保证线程安全。
  • 特点:

    • 线程安全: 高并发环境下性能优异。
    • 不允许 null 键和 null 值。
    • 读操作基本无锁。
  • 扩容机制: 与 HashMap 类似,JDK 8 中,每个 Node 数组的扩容是独立的,通过 transfer 方法实现。

  • 使用场景: 高并发场景下替代 HashMap 和 Hashtable 的首选。

  • java.util.concurrent (JUC) 包是 Java 并发编程的高级工具包,它提供了比传统 synchronized 关键字和 wait/notify 机制更强大、更灵活的并发控制手段。

好的 ✅ 我来帮你把前面两个回答 整合成一个完整、系统、逐层递进的 ConcurrentHashMap 解析文档,内容覆盖 实现原理 + 扩容机制 + 高频面试题 + 答题模板,你可以直接拿去当复习资料或面试答题脚本。


🧩 ConcurrentHashMap 超详细解析(JDK8为主,含对比)


一、背景与设计动机

在并发场景下:

  • HashMap 线程不安全,可能出现死循环、数据丢失。
  • HashtableCollections.synchronizedMap 使用 全表锁,吞吐量极差。

ConcurrentHashMap(CHM)

  • 线程安全:保证多线程同时访问时数据一致性。
  • 高性能:通过 分桶锁 + CAS + volatile 提升并发度。
  • 弱一致性迭代器:遍历过程中允许修改,不会抛 ConcurrentModificationException

二、实现演变(JDK7 vs JDK8)

JDK7

  • 数据结构:Segment[] + HashEntry[]
  • Segment:类似小型 HashMap,每个 Segment 有独立的 ReentrantLock。
  • 并发度:由 Segment 数量决定(默认 16)。

缺点:

  • 锁粒度仍然较大;
  • 扩容需要锁定整个 Segment。

JDK8

  • 数据结构:Node[] + 链表/红黑树。
  • 锁机制:CAS + synchronized(桶锁),比 Segment 更细。
  • 改进点:
    1. 无 Segment,更细粒度控制。
    2. 链表冲突过长时转化为 红黑树(树化)。
    3. 扩容为 渐进式 + 多线程协助
    4. 使用 ForwardingNode 路由迁移。

三、核心数据结构

Node 节点

1
2
3
4
5
6
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
}
  • valnextvolatile,保证可见性。

TreeNode(红黑树节点)

1
2
3
4
static final class TreeNode<K,V> extends Node<K,V> {
TreeNode<K,V> parent, left, right, prev;
boolean red;
}
  • 当链表长度 > 8 且容量 ≥ 64 时转化为红黑树。

ForwardingNode

  • 扩容时的占位符,用于标记桶已迁移,引导线程访问新表。

四、核心操作流程

1. get() —— 无锁读

  • 直接计算 hash 定位桶。
  • 如果是链表 → 遍历;
  • 如果是红黑树 → 按树查找;
  • 如果是 ForwardingNode → 去新表查。

特点:无锁,只依赖 volatile 保证可见性。


2. put() —— 写时加锁

  1. 计算索引位置;
  2. 桶为空 → CAS 插入;
  3. 桶非空:
    • ForwardingNode → 协助扩容;
    • 链表/树 → synchronized 锁住桶头,再插入;
  4. 插入后:
    • 如果链表长度 > 8 且容量 ≥ 64 → 树化;
    • 如果总数超过阈值 → 触发扩容。

3. 扩容机制(Transfer)

触发条件

  • size 超过 threshold = capacity * loadFactor(默认 0.75)。

流程

  1. 新建数组:容量翻倍。
  2. 迁移桶
    • 遍历旧桶,将节点分为 低位链(loHead)高位链(hiHead)
    • hash & oldCap == 0 → 留在原位置;
    • 否则 → 放到 i+oldCap
  3. 标记迁移完成
    • 旧桶置为 ForwardingNode。
    • 之后读/写遇到它时,会路由到新表。
  4. 多线程协助
    • 其他线程写入时若发现正在扩容 → 调用 helpTransfer() → 一起分桶迁移。
  5. 渐进式扩容
    • 一次只迁移一个桶,避免阻塞。

五、并发控制机制

  1. CAS:初始化 table、插入第一个节点时。
  2. volatile:保证 val 和 next 的可见性。
  3. synchronized:写操作时锁定单个桶。
  4. ForwardingNode:保证扩容过程数据一致性。

六、性能特性

  • 读操作几乎无锁 → 高性能。
  • 写操作局部锁定 → 粒度小,冲突概率低。
  • 扩容支持并行迁移 → 避免长时间卡顿。
  • 树化避免链表退化 O(n)。

七、常见高频面试题

基础题

  1. ConcurrentHashMap 和 HashMap 的区别?
    • HashMap:线程不安全;
    • ConcurrentHashMap:线程安全,CAS+桶锁;
    • 数据结构都支持链表+红黑树。
  2. JDK7 和 JDK8 的区别?
    • JDK7:Segment 分段锁;
    • JDK8:CAS + synchronized,锁粒度更细。
  3. 为什么 synchronized 而不是 ReentrantLock?
    • JDK8 之后对 synchronized 优化(偏向锁、轻量级锁);
    • 实现简洁,不需手动释放锁。

进阶题

  1. 为什么 get 无需加锁?
    • Node 的 val、next 是 volatile,可见性足够;
    • get 只读,不会修改结构。
  2. 为什么需要红黑树?
    • 避免哈希冲突严重时链表退化为 O(n)。
    • 树化后查询 O(log n)。
  3. 红黑树什么时候转化/退化?
    • 链表长度 > 8 且容量 ≥ 64 → 树化;
    • 树节点数 < 6 → 退化为链表。

扩容相关题

  1. ConcurrentHashMap 扩容过程?为什么说是渐进式?
    • 桶级迁移,每次只迁移一个桶;
    • 旧桶置为 ForwardingNode;
    • 多线程可协助迁移;
    • 不会一次性迁移整个表 → 避免卡顿。
  2. 扩容时,get 如何保证正确性?
    • 遇到 ForwardingNode → 跳转新表继续查;
    • 保证读到的是最新数据。
  3. 为什么不允许 key 或 value 为 null?
    • 无法区分「null 表示没有值」还是「值本身就是 null」,容易歧义。
  4. 为什么迭代器是弱一致性的?
  • 遍历时允许并发修改;
  • 不抛异常,但可能漏数据或读到旧值。
  1. size() 如何实现?为什么不精确?
  • 分段计数,多次尝试累加;
  • 并发下不保证强一致性,只保证大致准确。

高阶题

  1. 为什么扩容时使用 ForwardingNode?
  • 防止重复迁移;
  • 读写遇到它能路由到新表。
  1. ConcurrentHashMap 会死锁吗?
  • 不会。每次只锁单个桶,不存在跨桶死锁。
  1. ConcurrentHashMap 与 CopyOnWriteMap 区别?
  • CHM:适合读多写多,分桶锁+CAS;
  • COW:适合读多写少,写时复制,读完全无锁。

八、答题模板(面试框架)

当被问到「ConcurrentHashMap」时,可以这样答:

  1. 整体实现
    • JDK7:Segment 分段锁;
    • JDK8:Node 数组 + CAS + synchronized(桶锁)。
  2. 核心思想
    • 读操作无锁;
    • 写操作锁单个桶;
    • 扩容渐进式+多线程协助;
    • 冲突过长时树化。
  3. 扩容机制
    • 懒扩容,阈值触发;
    • ForwardingNode 标记已迁移桶;
    • 多线程 helpTransfer;
    • 渐进式迁移,避免阻塞。
  4. 常见问题
    • 不允许 null key/value;
    • 弱一致性迭代器;
    • size() 不精确。

📌 一句话总结口诀
👉 JDK7 Segment 分段锁,JDK8 CAS+桶锁;读无锁,写锁桶,扩容多线程协助;链表转树,弱一致迭代。


🧩 一、并发工具类(协作类)

这些工具类都在 java.util.concurrent 包里,用来解决线程间的 协调/同步/通信问题


1. CountDownLatch(倒计时器)

📝 定义

  • 一个或多个线程等待 其他线程完成操作 后再继续。
  • 内部维护一个 state(计数),初始值设定好,每调用一次 countDown()state--
  • state=0 时,await() 的线程全部唤醒。

⚙️ 底层实现

  • 基于 AQS(AbstractQueuedSynchronizer) 的共享锁实现。
  • await() 会阻塞线程,直到 state==0
  • countDown() 是非阻塞的,只是原子性地减 1。

📌 使用场景

  • 主线程等待多个子线程完成(一等多)。
  • 例如:并行加载多个配置文件,等都加载完再合并处理。

🎯 面试考点

  • 能否重用?不能,计数归零后不能重置。
  • CyclicBarrier 的区别?→ CountDownLatch 是「一等多」,CyclicBarrier 是「多等多」。

2. CyclicBarrier(循环屏障)

📝 定义

  • 一组线程互相等待,直到所有线程都到达屏障点,再统一出发。
  • 支持 循环使用(计数归零后可重置)。

⚙️ 底层实现

  • 基于 ReentrantLock + Condition
  • 每个线程调用 await() → 计数 -1 → 当计数 = 0 → 唤醒所有线程。
  • 还可以指定一个 barrierAction(所有线程到达后执行的回调)。

📌 使用场景

  • 多线程分阶段计算,再统一合并结果。
  • 例如:并行计算矩阵的每一行,所有线程到齐后汇总。

🎯 面试考点

  • CountDownLatch 的区别?→ 可循环,且是「多等多」。
  • BrokenBarrierException 什么时候抛?→ 线程中断或超时导致 barrier 破坏。

3. Semaphore(信号量)

📝 定义

  • 控制同时访问某资源的线程数,像 限流器/许可证

⚙️ 底层实现

  • 基于 AQS 的共享锁实现。
  • 内部维护一个 permits(许可证数)。
  • acquire() 获取许可证(permits–,不足则阻塞);
  • release() 归还许可证(permits++)。

📌 使用场景

  • 控制并发数:数据库连接池、接口限流。
  • 例子:停车场有 3 个车位 → 最多只能停 3 辆车。

🎯 面试考点

  • 公平模式 vs 非公平模式?
    • 公平:FIFO 获取许可证。
    • 非公平:可能插队,吞吐量更高。
  • Lock 的区别?→ Semaphore 控制的是 并发数量,而 Lock互斥(只能 1 个线程)

4. Exchanger(交换器)

📝 定义

  • 两个线程 在同步点交换数据。

⚙️ 底层实现

  • 基于 自旋 + CAS
  • 一个线程调用 exchange() 会等待,直到另一个线程也调用 exchange(),然后两者交换数据。

📌 使用场景

  • 双线程协作:生产者和消费者之间成对交换数据块。
  • 比如:一个线程生成数据,另一个线程写入磁盘。

🎯 面试考点

  • 如果只有一个线程调用 exchange() 会怎样?→ 会阻塞,直到另一方到来。
  • 与队列(BlockingQueue)的区别?→ Exchanger一对一交换,而队列可以一对多。

🧩 二、原子类(Atomic 系列)

这些类在 java.util.concurrent.atomic 包下,提供了 无锁化的线程安全操作


1. 基础原子类

AtomicInteger / AtomicLong / AtomicBoolean

  • 作用:对单个 int/long/boolean 变量的原子操作。
  • 典型方法:
    • getAndIncrement() → 自增(原子性 i++)。
    • compareAndSet(expect, update) → CAS 更新。

⚙️ 底层实现

  • 基于 CAS(Compare-And-Swap) + volatile
  • 内部调用 Unsafe 类compareAndSwapInt 等方法。

📌 使用场景

  • 高并发计数器:网站访问量统计、ID 生成器。

🎯 面试考点

  • CAS 的缺点?
    1. ABA 问题(可用 AtomicStampedReference 解决)。
    2. 自旋开销大。
    3. 只能保证单变量原子性,不能保证复合操作。

2. 数组原子类

AtomicIntegerArray / AtomicLongArray / AtomicReferenceArray

  • 保证数组中的每个元素的原子操作。
  • 例如:统计分片数据时,避免锁整个数组。

3. 引用原子类

AtomicReference

  • 保证对象引用的原子更新。

AtomicStampedReference

  • 解决 ABA 问题,给引用加一个版本号(stamp)。

AtomicMarkableReference

  • 给引用加一个 boolean 标记

4. 累加器类

LongAdder / DoubleAdder

  • AtomicLong 的优化:高并发下热点分离。
  • 内部维护一个 base 值 + 分片 cell 数组
  • 多线程写时分散到不同的 cell,最后求和。

📌 使用场景

  • 高并发计数,如 QPS 统计、热点计数。

🎯 面试考点

  • 为什么 LongAdderAtomicLong 性能好?
    • AtomicLong 所有线程 CAS 同一个值,形成热点;
    • LongAdder 分散冲突到多个 cell,降低竞争。

🧠 三、工具类 & 原子类对比总结

工具类 / 原子类 功能 实现原理 典型场景
CountDownLatch 一等多 AQS 共享锁 主线程等子线程完成
CyclicBarrier 多等多,可循环 ReentrantLock + Condition 分阶段同步,多线程汇总计算
Semaphore 控制并发数(许可证) AQS 共享锁 限流、连接池、资源池
Exchanger 两线程数据交换 CAS + 自旋 双线程协作,数据块交换
AtomicInteger/Long 单变量原子操作 CAS + volatile 高并发计数器,ID 生成
AtomicReference 原子更新引用 CAS 无锁更新对象引用
AtomicStampedReference 带版本号引用,防止 ABA CAS 解决 ABA 问题
LongAdder/DoubleAdder 高并发累加优化 分片 + CAS 高并发计数,性能优于 AtomicLong

🎯 四、面试答题模板(可套用)

如果面试官问 “你了解 JUC 的并发工具类吗?” 可以这样答:

  1. 总体分类
    • JUC 里提供了 协作类工具(CountDownLatch、CyclicBarrier、Semaphore、Exchanger)原子类(AtomicInteger、LongAdder 等)
  2. 逐个解释
    • CountDownLatch:一等多,主线程等子线程。
    • CyclicBarrier:多等多,可循环。
    • Semaphore:控制并发数,限流。
    • Exchanger:两线程交换数据。
    • AtomicInteger/Long:单变量原子操作,基于 CAS。
    • LongAdder:高并发下比 AtomicLong 更高效。
  3. 原理补充
    • 工具类大多基于 AQS 实现;
    • 原子类基于 CAS + volatile
    • LongAdder 用 分段累加 优化热点。
  4. 总结
    👉 工具类解决线程协作,原子类解决无锁更新,都是 JUC 的核心。

小结 Map:

  • HashMap: 最常用,需要快速查找,不关心键值对顺序,且在单线程或由外部同步机制保证线程安全的场景。
  • LinkedHashMap: 需要快速查找,同时需要保持插入顺序或访问顺序的场景(如实现 LRU 缓存)。
  • TreeMap: 需要根据键的自然顺序或自定义顺序排序的场景。
  • ConcurrentHashMap: 高并发场景下对 Map 进行读写操作的首选。
  • Hashtable: 已被淘汰,不推荐使用。

集合部分总结:

遍历 Java 集合有多种方式,每种方式都有其优缺点。选择哪种方式取决于你正在使用的集合类型和具体需求。以下是对几种常见遍历方式的优劣分析和推荐。


1. 迭代器 (Iterator)

这是最通用的遍历方式,适用于所有实现了 Iterable 接口的集合。

  • 优点
    • 通用性强:它适用于所有集合,包括 ListSetQueue
    • 安全:在使用 Iterator 遍历时,可以安全地调用 iterator.remove() 方法移除元素,不会引发 ConcurrentModificationException
    • 性能优越:对于 LinkedList 这类链式存储的集合,迭代器的遍历效率最高。
  • 缺点
    • 语法相对繁琐,需要手动调用 hasNext()next()
  • 推荐
    • 当需要在遍历过程中删除元素时,必须使用迭代器。
    • 作为通用且安全的遍历首选。

2. 增强 for 循环 (For-Each Loop)

增强 for 循环是基于迭代器的一种语法糖,它使遍历代码更简洁易读。

  • 优点
    • 代码简洁:语法简单直观,可读性强。
    • 不易出错:无需手动管理索引或迭代器。
  • 缺点
    • 不支持删除:在遍历过程中直接调用集合的 remove() 方法会抛出 ConcurrentModificationException
    • 性能:对于 LinkedList 来说,它的底层仍然是迭代器,性能很好。但对于 ArrayList,它的性能不如传统的 for 循环,因为每次迭代都需要调用 next() 方法。
  • 推荐
    • 当你只需要读取集合中的元素,且代码简洁性比性能更重要时。

3. 传统 for 循环 (Indexed For Loop)

这种方式只适用于支持通过索引访问的集合,如 ArrayListVector

  • 优点
    • 性能最高:对于 ArrayList,它通过索引直接访问元素,效率极高,性能优于迭代器。
    • 灵活:可以按需向前或向后遍历,也可以跳过元素。
  • 缺点
    • 不通用:不适用于 SetMap 等不按索引存储的集合。
    • 效率低下:对于 LinkedList,每次 get(i) 调用都会从头开始遍历,导致性能极差,时间复杂度为 O(n²)。
  • 推荐
    • 当遍历 ArrayList 并需要高性能时。

4. Lambda 表达式与 Stream API

Java 8 引入的 Stream API 提供了非常强大的函数式编程能力,可以用于遍历和处理集合。

  • 优点
    • 简洁优雅:代码非常简洁,表达力强。
    • 功能强大:支持过滤、映射、排序等各种操作,能以声明式的方式处理数据。
    • 支持并行:可以通过 parallelStream() 开启并行处理,利用多核优势。
  • 缺点
    • 性能开销:通常比传统的 for 循环略高,特别是在处理小规模数据时。
  • 推荐
    • 当你需要对集合进行复杂的操作(如过滤、转换、聚合)时。
    • 需要利用并行处理来提高性能时。

总结与最佳实践

方式 适用集合 优劣 推荐场景
迭代器 所有集合 通用、安全(可删除),对 LinkedList 性能好。 需要在遍历中删除元素,或作为通用选择。
增强 for 所有集合 语法简洁,可读性高。不能在遍历中删除。 仅需读取元素,且追求代码简洁。
传统 for ArrayList ArrayList 性能最高,灵活。对 LinkedList 性能极差。 ArrayList 的高性能遍历,或需要控制索引。
Stream API 所有集合 功能强大,代码优雅,支持并行。 复杂数据处理、并行计算。

简单概括

  • 读取 ArrayList:首选传统 for 循环
  • 读取 LinkedListSet:首选增强 for 循环
  • 删除元素:必须使用迭代器
  • 复杂操作:使用 Stream API

通常来说,在 Java 中反向遍历并删除元素时,使用传统的 for 循环并从列表的末尾开始向前遍历是最安全和高效的方法。

为什么推荐反向遍历?

当你使用正向遍历(从索引 0 开始)并删除元素时,会遇到一个常见问题:

  • 当你删除一个元素后,它后面的所有元素的索引都会向前移动一位。
  • 这会导致在下一次循环迭代时,你可能会跳过紧跟在被删除元素后面的那个元素,因为它现在占据了被删除元素原来的位置,而你的循环索引已经增到了下一个位置。

反向遍历完美地解决了这个问题。当你从列表末尾开始向前遍历时,删除一个元素不会影响你尚未遍历到的任何元素的索引,因为你正在处理的元素及其索引都位于列表的“后半部分”。


实现方式

1. 传统的 for 循环(推荐)

这是反向遍历并删除最直接、最安全、最高效的方式,尤其适用于 ArrayList

Java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.util.ArrayList;
import java.util.List;

public class ReverseRemoveExample {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("a");
list.add("b");
list.add("c");
list.add("d");

// 从后往前遍历,并删除元素
for (int i = list.size() - 1; i >= 0; i--) {
// 假设我们要删除所有元素
list.remove(i);
}

System.out.println("删除后的列表: " + list);
}
}

2. 迭代器(Iterator)

虽然迭代器通常用于正向遍历,但你也可以使用它来从后向前遍历并删除。不过,这种方式的实现相对复杂,并且对于 LinkedList 之外的集合性能并不好。对于 ArrayList,它仍然需要从末尾开始遍历,性能不如传统 for 循环。

注意ListIterator 提供了双向遍历功能,但其 previous() 方法在 Iterator 接口中并不存在。因此,在通用性上,传统 for 循环依然是首选。

Java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 这是一个使用 ListIterator 的例子,只适用于 List
import java.util.LinkedList;
import java.util.ListIterator;

public class ListIteratorExample {
public static void main(String[] args) {
List<String> list = new LinkedList<>();
list.add("a");
list.add("b");
list.add("c");
list.add("d");

// 从列表末尾获取 ListIterator
ListIterator<String> it = list.listIterator(list.size());

while (it.hasPrevious()) {
String element = it.previous();
// 假设我们删除元素 "b" 和 "c"
if (element.equals("b") || element.equals("c")) {
it.remove();
}
}

System.out.println("删除后的列表: " + list);
}
}

总结

  • 推荐:对于 ArrayList 或任何基于数组的列表,使用传统的 for 循环反向遍历是最简单、最安全、最高效的删除方式。
  • 避免:避免在正向遍历时直接使用 remove() 方法,因为它可能导致跳过元素或引发 ConcurrentModificationException
  • 通用性:如果你需要编写一个通用的删除方法,考虑到所有 List 实现,那么 ListIterator 是一个选择,但它的双向遍历特性仅限于 List 接口。

多线程下的hashmap死循环问题:

当多个线程同时对一个 HashMap 进行操作时,尤其是在涉及扩容(resizing)的情况下,可能会导致死循环。这个问题主要出现在 Java 7 及以前的版本,由于其底层数据结构和扩容机制的设计缺陷。


HashMap 的死循环问题

在 Java 7 中,HashMap 采用数组 + 链表的方式存储数据。当链表上的元素数量过多时,为了提高性能,HashMap 会进行扩容。扩容的步骤大致如下:

  1. 创建一个新的、更大的数组。
  2. 遍历旧数组中的所有元素。
  3. 将每个元素重新计算哈希值,并放入新数组的正确位置。

问题就出在第三步:将链表上的元素从旧数组转移到新数组时,如果多个线程同时进行此操作,就可能导致链表中的节点顺序颠倒,形成闭环。

扩容时的死循环过程

假设有两个线程 Thread1Thread2 同时对一个 HashMap 进行扩容。

  1. 初始状态HashMap 扩容前,一个桶位上有一个链表,比如 NodeA -> NodeB
  2. Thread1 执行到一半Thread1 遍历到 NodeA,准备将其移动到新数组。此时 Thread1 被挂起。NodeAnext 引用仍然指向 NodeB
  3. Thread2 开始执行Thread2 完整地完成了扩容过程。它将链表 NodeA -> NodeB 转移到了新数组。但由于是单线程操作,转移后链表顺序保持不变,仍是 NodeA -> NodeB
  4. Thread1 恢复执行Thread1 恢复执行,但它不知道 Thread2 已经完成了扩容。它仍然使用旧的 next 引用信息。
    • Thread1 取出 NodeA,将其放入新数组。
    • Thread1 接着处理 NodeB(因为在 Thread1 的视角,NodeAnext 还是 NodeB)。它将 NodeB 放入新数组。此时,由于哈希冲突,NodeB 被放在了 NodeA 的前面,导致链表变成了 NodeB -> NodeA。
    • NodeAnext 引用(在 Thread1 的视角)仍然指向 NodeB。现在,NodeBnext 指向 NodeA,而 NodeAnext 指向 NodeB形成了一个闭环

当后续线程在这个闭环链表上进行查找时,就会陷入无限循环,导致 CPU 100% 占用。


如何解决这个问题?

Java 提供了多种线程安全的替代方案来解决 HashMap 的多线程问题:

  1. Collections.synchronizedMap()
    • 这个方法可以创建一个线程安全的 Map 包装器。
    • 它通过在每个方法调用上加锁来实现线程安全。
    • 优点:简单易用。
    • 缺点:每次访问都需要加锁,并发性能较差。
  2. Hashtable
    • 一个早期的线程安全 Map 实现,所有公共方法都用 synchronized 修饰。
    • 优点:线程安全。
    • 缺点:性能差,几乎所有操作都锁住整个表,已被 ConcurrentHashMap 取代。
  3. ConcurrentHashMap
    • 这是 Java 推荐的线程安全 Map 实现
    • 在 Java 7 中,它使用了分段锁(Segment Lock),将数据分成多个段,每个段独立加锁。
    • 在 Java 8 中,它进一步优化,采用**CAS(Compare-And-Swap)**和 synchronized 关键字,锁的粒度更细,只锁住当前操作的桶,从而大大提高了并发性能。

因此,在多线程环境下,永远不要使用 HashMap。你应该根据需求,选择 ConcurrentHashMapCollections.synchronizedMap() 来保证线程安全。

集合扩展:

12. HashMap 的 get 方法能否判断某个元素是否在 map 中?

HashMapget 函数的返回值不能判断一个 key 是否包含在 map 中,因为 get 返回 null 有可能是不包含该 key,也有可能该 key 对应的 valuenull。因为 HashMap 中允许 keynull,也允许 valuenull


13. HashMap 与 HashTable 的区别是什么?

  1. HashTable 基于 Dictionary 类,而 HashMap 是基于 AbstractMapDictionary 是任何可将键映射到相应值的类的抽象父类,而 AbstractMap 是基于 Map 接口的实现,它以最大限度地减少实现此接口所需的工作。
  2. HashMapkeyvalue 都允许为 null,而 Hashtablekeyvalue 都不允许为 nullHashMap 遇到 keynull 的时候,调用 putForNullKey 方法进行处理,而对 value 没有处理;Hashtable 遇到 null,直接返回 NullPointerException
  3. Hashtable 是线程安全的,而 HashMap 不是线程安全的,但是我们也可以通过 Collections.synchronizedMap(hashMap),使其实现同步。

HashTable 的补充:

HashTable 和 HashMap 的实现原理几乎一样,差别无非是:

  1. HashTable 不允许 keyvaluenull
  2. HashTable 是线程安全的。但是 HashTable 线程安全的策略实现代价却太大了,简单粗暴,get/put 所有相关操作都是 synchronized 的,这相当于给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。

14. HashMap 与 ConcurrentHashMap 的区别是什么?

HashMap 不是线程安全的,而 ConcurrentHashMap 是线程安全的。

ConcurrentHashMap 采用锁分段技术,将整个Hash桶进行了分段segment,也就是将这个大的数组分成了几个小的片段 segment,而且每个小的片段 segment 上面都有锁存在,那么在插入元素的时候就需要先找到应该插入到哪一个片段 segment,然后再在这个片段上面进行插入,而且这里还需要获取 segment 锁,这样做明显减小了锁的粒度。


15. HashTable 和 ConcurrentHashMap 的区别?

HashTableConcurrentHashMap 相比,效率低。 Hashtable 之所以效率低主要是使用了 synchronized 关键字对 put 等操作进行加锁,而 synchronized 关键字加锁是对整张 Hash 表的,即每次锁住整张表让线程独占,致使效率低下,而 ConcurrentHashMap 在对象中保存了一个 Segment 数组,即将整个 Hash 表划分为多个分段;而每个Segment元素,即每个分段则类似于一个Hashtable;这样,在执行 put 操作时首先根据 hash 算法定位到元素属于哪个 Segment,然后对该 Segment 加锁即可,因此,ConcurrentHashMap 在多线程并发编程中可是实现多线程 put操作。


16. ConcurrentHashMap 的实现原理是什么?

数据结构

  • JDK 7:中 ConcurrentHashMap 采用了数组 + Segment + 分段锁的方式实现。
  • JDK 8:中 ConcurrentHashMap 参考了 JDK 8 HashMap 的实现,采用了数组 + 链表 + 红黑树的实现方式来设计,内部大量采用 CAS 操作。

ConcurrentHashMap 采用了非常精妙的”分段锁”策略,ConcurrentHashMap 的主干是个 Segment 数组。

final Segment[] segments;

Segment 继承了 ReentrantLock,所以它就是一种可重入锁(ReentrantLock)。在 ConcurrentHashMap,一个 Segment 就是一个子哈希表,Segment 里维护了一个 HashEntry 数组,并发环境下,对于不同 Segment 的数据进行操作是不用考虑锁竞争的。就按默认的 ConcurrentLevel 为 16 来讲,理论上就允许 16 个线程并发执行。所以,对于同一个 Segment 的操作才需考虑线程同步,不同的 Segment 则无需考虑。Segment 类似于 HashMap,一个 Segment 维护着一个HashEntry 数组:

transient volatile HashEntry[] table;

HashEntry 是目前我们提到的最小的逻辑处理单元了。一个 ConcurrentHashMap 维护一个 Segment 数组,一个 Segment 维护一个 HashEntry 数组。因此,ConcurrentHashMap 定位一个元素的过程需要进行两次 Hash 操作。第一次 Hash 定位到 Segment,第二次 Hash 定位到元素所在的链表的头部。


17. HashSet 的实现原理?

HashSet 的实现是依赖于 HashMap 的,HashSet 的值都是存储在 HashMap 中的。在 HashSet 的构造法中会初始化一个 HashMap 对象,HashSet 不允许值重复。因此,HashSet 的值是作为 HashMapkey 存储在 HashMap 中的,当存储的值已经存在时返回 false


18. HashSet 怎么保证元素不重复的?

public boolean add(E e) { return map.put(e, PRESENT)==null; }

元素值作为的是 mapkeymapvalue 则是 PRESENT 变量,这个变量只作为放入 map 时的一个占位符而存在,所以没什么实际用处。其实,这时候答案已经出来了:HashMapkey是不能重复的,而这里HashSet的元素又是作为了mapkey`,当然也不能重复了。


19. LinkedHashMap 的实现原理?

LinkedHashMap 也是基于 HashMap 实现的,不同的是它定义了一个 Entry header,这个 header 不是放在 Table 里,它是额外独立出来的。LinkedHashMap 通过继承 hashMap 中的 Entry,并添加两个属性 Entry before, afterheader 结合起来组成一个双向链表,来实现按插入顺序或访问顺序排序。

LinkedHashMap 定义了排序模式 accessOrder,该属性为 boolean 型变量,对于访问顺序,为 true;对于插入顺序,则为 false。一般情况下,不必指定排序模式,其迭代顺序即为默认为插入顺序。


20. Iterator 怎么使用?有什么特点?

迭代器是一种设计模式,它是一个对象,它可以遍历并选择序列中的对象,而开发人员不需要了解该序列的底层结构。迭代器通常被称为“轻量级”对象,因为创建它的代价小。Java 中的 Iterator 功能比较简单,并且只能单向移动:

  1. 使用方法 iterator() 要求容器返回一个 Iterator。第一次调用 Iteratornext() 方法时,它返回序列的第一个元素。注意:iterator() 方法是 java.lang.Iterable 接口,被 Collection 继承。
  2. 使用 next() 获得序列中的下一个元素。
  3. 使用 hasNext() 检查序列中是否还有元素。
  4. 使用 remove() 将迭代器新返回的元素删除。

21. Iterator 和 ListIterator 有什么区别?

Iterator 可用来遍历 SetList 集合,但是 ListIterator 只能用来遍历 ListIterator 对集合只能是前向遍历,ListIterator 既可以前向也可以后向。ListIterator 实现了 Iterator 接口,并包含其他的功能,比如:增加元素,替换元素,获取前一个和后一个元素的索引等等。


22. Iterator 和 Enumeration 接口的区别?

Enumeration 相比,Iterator 更加安全,因为当一个集合正在被遍历的时候,它会阻止其它线程去修改集合。否则会抛出 ConcurrentModificationException 异常。这其实就是 fail-fast 机制。具体区别有三点:

  1. Iterator 的方法名比 Enumeration 更科学;
  2. Iteratorfail-fast 机制,比 Enumeration 更安全;
  3. Iterator 能够删除元素,Enumeration 并不能删除元素。

23. fail-fast 与 fail-safe 有什么区别?

Iteratorfail-fast 属性与当前的集合共同起作用,因此它不会受到集合中任何改动的影响。java.util 包中的所有集合类都被设计为 fail-fast 的,而 java.util.concurrent 中的集合类都为 fail-safe 的。当检测到正在遍历的集合的结构被改变时,fail-fast 迭代器抛出 ConcurrentModificationException,而 fail-safe 迭代器从不抛出 ConcurrentModificationException


24. Collection 和 Collections 有什么区别?

  • Collection:是最基本的集合接口,一个 Collection 代表一组 Object,即 Collection 的元素。它的直接继承接口有 ListSetQueue
  • Collections:不属于 Java 的集合框架的,它是集合类的一个工具类/帮助类。此类不能被实例化, 服务于 Java 的 Collection 框架。它包含有关集合操作的静态多态方法,实现对各种集合的搜索、排序、线程安全等操作。

Java IO 流

Java IO (Input/Output) 流是用于处理计算机与外部设备之间数据传输的抽象概念。它将数据抽象为流 (Stream),通过流可以实现数据的输入和输出。

IO 流的分类

Java IO 流根据不同的标准有多种分类方式:

  • 按数据类型分:
    • 字节流:处理字节数据,所有文件类型(文本、图片、音视频等)都可以用字节流处理。
      • 抽象基类:InputStream(输入流)、OutputStream(输出流)。
      • 常用实现:FileInputStream/FileOutputStream(文件操作),BufferedInputStream/BufferedOutputStream(带缓冲),ObjectInputStream/ObjectOutputStream(对象序列化),ByteArrayInputStream/ByteArrayOutputStream(内存操作),DataInputStream/DataOutputStream(基本数据类型操作)。
    • 字符流:处理字符数据,专门用于处理文本文件。
      • 抽象基类:Reader(输入流)、Writer(输出流)。
      • 常用实现:FileReader/FileWriter(文件操作),BufferedReader/BufferedWriter(带缓冲),InputStreamReader/OutputStreamWriter(字节流与字符流的转换)。
  • 按流向分:
    • 输入流:从数据源读取数据到程序中。
    • 输出流:从程序中写入数据到目的地。
  • 按功能分:
    • 节点流(或源头流):直接与数据源(如文件、内存、网络连接)连接的流。例如FileInputStream、FileReader。
    • 处理流(或包装流):对已存在的节点流进行包装,增加新的功能或提升性能。例如BufferedInputStream、BufferedReader。

1755834271160

1755834290356

常用 IO 操作

  • 文件读写(字节流)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // 写入文件
    try (FileOutputStream fos = new FileOutputStream("output.txt")) {
    fos.write("Hello, World!".getBytes());
    } catch (IOException e) {
    e.printStackTrace();
    }

    // 读取文件
    try (FileInputStream fis = new FileInputStream("output.txt")) {
    int data;
    while ((data = fis.read()) != -1) {
    System.out.print((char) data);
    }
    } catch (IOException e) {
    e.printStackTrace();
    }
  • 文件读写(字符流)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // 写入文件
    try (FileWriter fw = new FileWriter("output_char.txt")) {
    fw.write("你好,世界!");
    } catch (IOException e) {
    e.printStackTrace();
    }

    // 读取文件
    try (FileReader fr = new FileReader("output_char.txt")) {
    int data;
    while ((data = fr.read()) != -1) {
    System.out.print((char) data);
    }
    } catch (IOException e) {
    e.printStackTrace();
    }
  • 缓冲流

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 使用缓冲字节流复制文件
    try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("source.txt"));
    BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("destination.txt"))) {
    byte[] buffer = new byte[1024];
    int bytesRead;
    while ((bytesRead = bis.read(buffer)) != -1) {
    bos.write(buffer, 0, bytesRead);
    }
    } catch (IOException e) {
    e.printStackTrace();
    }
  • 对象序列化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    // 定义一个可序列化的类
    class User implements Serializable {
    private static final long serialVersionUID = 1L; // 序列化版本UID
    String name;
    int age;
    transient String password; // transient 关键字修饰的字段不参与序列化

    public User(String name, int age, String password) {
    this.name = name;
    this.age = age;
    this.password = password;
    }

    @Override
    public String toString() {
    return "User(name=" + name + ", age=" + age + ", password=" + password + ")";
    }
    }

    // 序列化
    try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("user.ser"))) {
    User user = new User("Alice", 30, "123456");
    oos.writeObject(user);
    } catch (IOException e) {
    e.printStackTrace();
    }

    // 反序列化
    try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("user.ser"))) {
    User deserializedUser = (User) ois.readObject();
    System.out.println(deserializedUser); // password 字段将为 null
    } catch (IOException | ClassNotFoundException e) {
    e.printStackTrace();
    }

NIO (New Input/Output)

Java NIO (New I/O) 是在 JDK 1.4 中引入的一套新的 I/O API,它提供了非阻塞 I/O 的能力,并引入了“通道 (Channel)”和“缓冲区 (Buffer)”的概念,与传统的基于流 (Stream) 的 I/O 相比,NIO 更加高效。

核心组件:

  • Channel (通道): 类似于传统 IO 中的流,但可以双向读写。数据总是通过通道读入缓冲区或从缓冲区写入通道。
    • 常用实现: FileChannel (文件), SocketChannel (TCP 客户端), ServerSocketChannel (TCP 服务器), DatagramChannel (UDP)。
  • Buffer (缓冲区): 用于存储数据 (字节数组),与通道进行交互。所有数据读写都是通过缓冲区完成的。缓冲区有多种类型,如 ByteBuffer、CharBuffer、IntBuffer 等。
    • 核心属性:
      • capacity: 缓冲区可容纳的最大数据量。一旦创建,容量不可变。
      • limit: 缓冲区中可读或可写的上限。
      • position: 下一个读或写的位置。
      • mark: 标记当前 position,可以通过 reset() 恢复到 mark 的位置。
    • 主要方法:
      • put(): 向缓冲区写入数据。
      • get(): 从缓冲区读取数据。
      • flip(): 将缓冲区从写模式切换到读模式。limit 会设置为当前的 position, position 会重置为 0。
      • clear(): 清空缓冲区,为新的写入做准备。position 设为 0, limit 设为 capacity。
      • compact(): 压缩缓冲区,将未读的数据移到缓冲区开头,position 设为未读数据数量,limit 设为 capacity。
      • rewind(): 将 position 设为 0,可以重复读取缓冲区中的数据。
  • Selector (选择器): 用于监听多个通道上的事件 (如连接就绪、读就绪、写就绪等)。一个单线程可以管理多个通道,从而实现非阻塞 I/O。

NIO 与传统 IO 的区别:

  • I/O 模式:传统 IO 是阻塞式 I/O, NIO 是非阻塞式 I/O。
  • 流与缓冲区:传统 IO 基于流(单向), NIO 基于通道和缓冲区(双向)。
  • 同步与异步:传统 IO 是同步阻塞的, NIO 是同步非阻塞的(在多路复用模型下)。

NIO 文件复制示例

1
2
3
4
5
6
7
8
9
10
11
12
13
try (FileInputStream fis = new FileInputStream("source.txt");
FileChannel inChannel = fis.getChannel();
FileOutputStream fos = new FileOutputStream("destination_nio.txt");
FileChannel outChannel = fos.getChannel()) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
while (inChannel.read(buffer) != -1) {
buffer.flip(); // 切换到读模式
outChannel.write(buffer); // 从缓冲区写入通道
buffer.clear(); // 清空缓冲区, 为下次写入做准备
}
} catch (IOException e) {
e.printStackTrace();
}

AIO (Asynchronous Input/Output)

Java AIO (Asynchronous I/O) 是在 JDK 7 中引入的,也称为 NIO 2.0。它提供了真正的异步非阻塞 I/O,通过回调机制来处理 I/O 操作的结果。与 NIO 的同步非阻塞不同,AIO 在 I/O 操作完成后会主动通知应用程序。

  • 核心概念:

    • AsynchronousFileChannel:异步文件通道。
    • AsynchronousSocketChannel:异步 Socket 通道。
    • AsynchronousServerSocketChannel:异步 Server Socket 通道。
    • CompletionHandler:回调处理器接口,定义了 completed() (操作成功) 和 failed() (操作失败) 方法。
    • Future:也可以通过返回 Future 对象来获取异步操作的结果。
  • **工作原理:**当发起一个 I/O 操作时,不再需要等待操作完成,而是立即返回。I/O 操作由操作系统在后台完成,完成后通过回调函数通知应用程序。

  • AIO 优势:

    • 真正的异步非阻塞:应用程序不需要等待 I/O 操作,可以将 CPU 资源用于其他任务。
    • 提高并发性:特别适合高并发、长连接的网络应用。
  • AIO 劣势:

    • 复杂性:编程模型相对于 NIO 更复杂,需要处理回调逻辑。
    • 适用场景:对于连接数较多且连接时间长的应用,如聊天服务器,AIO 表现优异。对于短连接、高并发的场景,NIO(基于 Selector)可能表现更好。
  • AIO 读文件示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    Path file = Paths.get("async_read.txt");
    ByteBuffer buffer = ByteBuffer.allocate(1024);

    try (AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(file, StandardOpenOption.READ)) {
    fileChannel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
    @Override
    public void completed(Integer result, ByteBuffer attachment) {
    System.out.println("Read " + result + " bytes.");
    attachment.flip();
    byte[] data = new byte[attachment.remaining()];
    attachment.get(data);
    System.out.println("Content: " + new String(data));
    }

    @Override
    public void failed(Throwable exc, ByteBuffer attachment) {
    System.err.println("Read failed: " + exc.getMessage());
    }
    });
    // 为了让主线程不立即退出,等待异步操作完成
    Thread.sleep(1000);
    } catch (IOException | InterruptedException e) {
    e.printStackTrace();
    }

1. Java IO 流的分类?请说出几个你熟悉的实现类。

Java IO 流根据不同的标准可以进行分类:

  • 按数据流向
    • 输入流(InputStream / Reader):从外部源(如文件、网络)读取数据到程序中。
    • 输出流(OutputStream / Writer):将数据从程序写入到外部目标。
  • 按数据类型
    • 字节流(InputStream / OutputStream):以 8 位字节为单位处理数据,适用于所有类型的文件,包括图片、视频、音频等二进制文件。
      • 实现类FileInputStream, FileOutputStream, BufferedInputStream, BufferedOutputStream
    • 字符流(Reader / Writer):以 16 位字符为单位处理数据,适用于文本文件,能处理各种字符编码。
      • 实现类FileReader, FileWriter, BufferedReader, BufferedWriter

2. 字节流和字符流有什么区别?

字节流和字符流的主要区别在于处理数据的单位和适用场景:

  • 数据单位:字节流以 8 位字节为单位进行读写,而字符流以 16 位字符为单位。
  • 编码转换:字符流是字节流的封装,它在内部处理了字节和字符之间的编码转换。当处理文本文件时,字符流可以根据指定的字符集(如 UTF-8)自动进行编码和解码,避免了乱码问题。字节流不进行编码转换,直接处理原始字节。
  • 最小存储单元:尽管字符流以字符为单位,但文件或网络传输的最小存储单元依然是字节。字符流在底层仍依赖于字节流来完成数据的传输。
  • 适用场景
    • 字节流:适用于所有文件类型,尤其是处理非文本文件(如图片、视频、音频等),因为这些文件不需要进行字符编码转换。
    • 字符流:专门用于处理文本文件,可以避免手动处理字符编码的复杂性。

3. BIO、NIO 和 AIO 有什么区别?

这是 Java IO 发展历程中的三种不同模型,主要区别在于处理 IO 操作的方式(阻塞/非阻塞、同步/异步)和并发能力。

  • BIO (Blocking I/O)
    • 特点同步阻塞。当一个线程发起 IO 操作(如 readaccept),它会一直阻塞,直到数据读写完成或连接建立。
    • 模型:采用“一对一”的连接模型,即一个客户端连接对应一个处理线程。
    • 优点:编程模型简单直观,适用于连接数较少、并发量不高的场景。
    • 缺点:在高并发场景下,每个连接都需要一个线程,会消耗大量系统资源,导致性能瓶颈。
  • NIO (Non-blocking I/O)
    • 特点同步非阻塞。一个线程可以同时处理多个连接。当线程发起 IO 操作时,如果数据没有准备好,它不会阻塞,而是立即返回,去做其他事情,稍后再次检查。
    • 模型:采用多路复用模型。通过一个 Selector(选择器),一个或少数几个线程可以监听多个 Channel(通道)上的 IO 事件(如连接就绪、数据可读),从而高效地处理大量并发连接。
    • 优点:解决了 BIO 的高并发问题,大大提高了系统的吞吐量,适用于高并发的网络应用。
    • 缺点:编程模型相对复杂。尽管 IO 操作是非阻塞的,但数据读写本身仍然是同步的,即线程在进行读写时仍需等待。
  • AIO (Asynchronous I/O)
    • 特点异步非阻塞。应用程序发起 IO 操作后,会立即返回,而不会等待数据读写。操作系统会在后台完成 IO 操作,并在完成后通过回调或事件通知应用程序。
    • 模型:基于事件和回调机制。
    • 优点:进一步提高了并发性能,彻底释放了线程资源,适用于连接数巨大且连接时间长的应用。
    • 缺点:目前在实际应用中,特别是在网络编程方面,使用不如 NIO 广泛,因为其复杂性和对操作系统的依赖性较高。

Java 反射 (Reflection)

Java 反射机制是指在程序运行时,能够动态地获取类的信息(包括类的属性、方法、构造器等),并能够动态地操作类或对象(如创建对象、调用方法、修改属性)。

  • 核心类与接口:

    • Class 类:代表类的字节码文件,是反射的入口。
    • Constructor 类:代表类的构造器。
    • Method 类:代表类的方法。
    • Field 类:代表类的成员变量(属性)。
    • AccessibleObject:Field, Method, Constructor 的共同父类,提供了 setAccessible(true) 方法,用于抑制 Java 语言访问检查,从而访问私有成员。
  • 获取 Class 对象的三种方式:

    1. Class.forName(“全限定类名”):最常用,动态加载类。

      1
      Class<?> clazz = Class.forName("java.lang.String");
    2. 类名.class:已知具体类名时使用,编译时加载。

      1
      Class<?> clazz = String.class;
    3. 对象.getClass():通过对象实例获取,运行时获取。

      1
      2
      String s = "hello";
      Class<?> clazz = s.getClass();
  • 反射的应用:

    • 动态创建对象:

      1
      2
      3
      4
      5
      Class<?> personClass = Class.forName("com.example.Person");
      Object person = personClass.newInstance(); // 调用无参构造器
      // 或者调用指定构造器
      Constructor<?> constructor = personClass.getConstructor(String.class, int.class);
      Object person2 = constructor.newInstance("Alice", 25);
    • 动态调用方法:

      1
      2
      3
      4
      5
      6
      Class<?> personClass = Class.forName("com.example.Person");
      Object person = personClass.newInstance();
      Method setNameMethod = personClass.getMethod("setName", String.class);
      setNameMethod.invoke(person, "Bob"); // 调用 setName 方法
      Method getNameMethod = personClass.getMethod("getName");
      String name = (String) getNameMethod.invoke(person); // 调用 getName 方法
    • 动态操作属性:

      1
      2
      3
      4
      5
      6
      Class<?> personClass = Class.forName("com.example.Person");
      Object person = personClass.newInstance();
      Field nameField = personClass.getDeclaredField("name"); // 获取私有属性
      nameField.setAccessible(true); // 允许访问私有属性
      nameField.set(person, "Charlie"); // 设置属性值
      String name = (String) nameField.get(person); // 获取属性值
  • 反射的优缺点:

    • 优点:
      • 灵活性和动态性:在运行时动态获取类信息和操作对象,大大增强了程序的灵活性,是许多框架(如Spring、ORM框架)和工具(如JSON解析库)的基础。
      • 解耦:允许代码在编译时不知道具体的类,只在运行时加载和使用,实现高度解耦。
    • 缺点:
      • 性能开销:反射操作比直接调用有更高的性能开销,因为涉及到动态解析和查找。
      • 安全性问题:setAccessible(true)可以绕过Java的访问控制,可能破坏封装性。
      • 可维护性差:反射代码通常比直接调用更复杂,更难调试和维护。
      • 编译时检查缺失:反射操作在编译时无法检查类型错误,只能在运行时发现。

JVM内存结构与垃圾收集器

JVM内存结构

JVM 简单介绍

Java 虚拟机(JVM)是 Java 语言跨平台能力的核心。它是一个抽象的计算机,负责将 Java 字节码 (.class 文件) 转换成特定平台的机器指令。下面通过表格的形式,简单介绍 JVM 的主要组成部分、功能和工作流程。


模块名称 核心功能 主要组成部分 简要说明
类加载子系统 加载、链接、初始化 .class 文件 类加载器 (ClassLoader) 负责从文件系统、网络等来源加载 .class 文件到内存,并进行验证、准备和解析。
运行时数据区 存放程序运行时的各种数据 方法区

虚拟机栈
本地方法栈
程序计数器
线程共享:
- 方法区: 存储已加载的类信息、常量、静态变量等。
- : 存储所有对象实例和数组。

线程私有:
- 虚拟机栈: 存储方法调用的局部变量、操作数栈等。
- 本地方法栈: 存储 Native 方法的调用信息。
- 程序计数器: 记录当前线程正在执行的字节码指令地址。
执行引擎 执行字节码指令 解释器
即时编译器 (JIT)
垃圾收集器 (GC)
负责将字节码翻译成机器指令并执行。
- 解释器: 逐行解释执行字节码。
- JIT: 将热点代码编译成高效的本地机器码。
- GC: 自动回收堆中不再使用的对象内存。
本地方法接口 调用 Native 方法 JNI (Java Native Interface) 允许 Java 代码与其他语言(如 C/C++)编写的代码进行交互。
本地方法库 支持 Native 方法 一系列本地方法库 提供了执行引擎调用本地方法所需的库文件。

JVM(Java Virtual Machine)在执行Java程序时,会将内存划分为几个不同的区域,这些区域有各自的用途和生命周期。

  • 程序计数器 (Program Counter Register)

    • 功能:一块较小的内存空间,用于存储当前线程所执行的字节码的行号指示器。
    • 特点:
      • 每个线程私有,生命周期与线程一致。
      • JVM规范中唯一没有规定任何OutOfMemoryError情况的区域。
      • 在多线程切换时,程序计数器记录了当前线程的执行位置,使得线程切换回来后能够知道从哪里继续执行。
  • Java虚拟机栈 (Java Virtual Machine Stacks)

    • 功能:每个线程私有的内存区域,用于存储栈帧(Stack Frame)。每个方法被执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
    • 特点:
      • 生命周期与线程一致。
      • 局部变量表:存储方法参数和方法内部定义的局部变量。
      • 操作数栈:用于存放方法执行时的操作数和中间结果。
      • 动态链接:指向运行时常量池中该栈帧所属方法的引用。
      • 方法出口:记录方法执行完后回到哪里。
      • 可能抛出StackOverflowError(栈深度超过虚拟机允许的深度)或OutOfMemoryError(栈扩展时无法申请到足够的内存)。
  • 本地方法栈 (Native Method Stacks)

    • 功能:与虚拟机栈类似,但是为JVM执行Native方法(即用C/C++等语言实现的方法)服务。
    • 特点:
      • 每个线程私有。
      • 可能抛出 StackOverflowError 或 OutOfMemoryError。
  • Java 堆 (Java Heap)

    • 功能:JVM 管理的最大一块内存区域,被所有线程共享,用于存放对象实例和数组。

    • 特点:

      • 是垃圾收集器管理的主要区域 (GC 堆)。

      • 是 Java 应用程序对象存放的“老家”。

      • 根据垃圾回收的特性,可以分为新生代 (Young Generation) 和老年代 (Old Generation)。

      • 新生代:通常分为 Eden 空间和两个 Survivor 空间 (From 和 To)。新创建的对象优先在 Eden 区分配,经过 Minor GC 后存活的对象进入 Survivor 区,多次 GC 后仍存活的对象进入老年代。

      • 老年代:存放生命周期较长的对象。

      • 在 JVM 内存模型中,新生代被划分为三个区域,默认的比例通常是 8:1:1

        • Eden 区:占比 80%。这是新创建对象的主要分配区域。
        • Survivor S0 区:占比 10%
        • Survivor S1 区:占比 10%

        为什么要这样划分?

        这种划分是为了配合 Minor GC 的垃圾回收流程,从而提高垃圾回收的效率。

        新生代的垃圾回收流程

        1. 对象创建:新创建的对象首先在 Eden 区 分配。
        2. Minor GC:当 Eden 区 满了之后,会触发一次 Minor GC(也叫 Young GC)。
        3. 存活对象转移
          • 在 Eden 区和其中一个 Survivor 区(比如 S0)中,所有存活的对象会被复制到另一个空的 Survivor 区(比如 S1)。
          • 同时,对象的年龄(age)会加一。
        4. 清空 Eden 和 S0:垃圾回收后,Eden 区和 S0 区都会被清空。
        5. 角色互换:下一次 Minor GC 时,Eden 区和 S1 区中存活的对象会被复制到 S0 区。S0 和 S1 两个 Survivor 区会不断地进行角色互换。
        6. 晋升老年代
          • 当对象的年龄达到一个设定的阈值(默认为 15),或者 Survivor 区中同一年龄段的对象大小超过了一定比例,这些对象就会被移动到老年代
          • 这种设计也被称为复制算法(Copying Algorithm),它在新生代存活对象较少的情况下,效率非常高。

        为什么是 8:1:1?

        这个比例是一个经验值,基于大多数 Java 应用的特点:

        • 大多数对象都是朝生夕灭的。因此,将 Eden 区设置得更大,可以容纳更多的新对象,减少 Minor GC 的频率。
        • 两个 Survivor 区只需要用来暂存存活的对象,因此不需要太大。10% 的空间通常足以容纳一次 Minor GC 后存活的对象。

        当然,这个比例不是固定的。在某些特殊情况下,如果新生代存活对象较多,导致 Survivor 区无法容纳所有存活对象,JVM 会发生空间分配担保,将这些对象直接晋升到老年代。

        你可以通过 JVM 参数来调整这个比例,例如: java -Xmn100m -XX:SurvivorRatio=8

        这个命令设置新生代总大小为 100MB,Eden 区和 Survivor 区的比例为 8:1:1。

      • 可能抛出 OutOfMemoryError。

  • 方法区 (Method Area)

    • 功能:被所有线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
    • 特点:
      • 在 JDK 1.7 及之前,方法区被称为“永久代 (Permanent Generation)”,它属于堆内存的一部分。
      • 在 JDK 1.8 及之后,永久代被移除,方法区的实现改为“元空间 (Metaspace)”,并且元空间不使用 JVM 内存,而是直接使用本地内存 (Native Memory)。
      • 可能抛出 OutOfMemoryError。
  • 运行时常量池 (Runtime Constant Pool)

    • 功能:方法区的一部分,用于存放编译期生成的各种字面量和符号引用。
    • 特点:
      • 动态性:Java 语言并不要求常量池在编译期就全部确定,运行时也可以将新的常量放入池中 (如 String.intern())。
      • 可能抛出 OutOfMemoryError。

1. 程序计数器

程序计数器(Program Counter Register):是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。程序的分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中的命令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间的计数器互不影响,独立存储,我们程这块内存区域为“线程私有”的内存。

此区域是唯一一个虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。


2. Java 虚拟机栈

Java 虚拟机栈(Java Virtual Machine Stacks):描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个帧栈(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。它的线程也是私有的,生命周期与线程相同。

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用和 returnAddress 类型(指向了一条字节码指令的地址)。

Java 虚拟机栈的局部变量表的空间单位是槽(Slot),其中 64 位长度的 double 和 long 类型会占用两个 Slot。局部变量表所需内存空间在编译期完成分配,当进入一个方法时,该方法需要在帧中分配多大的局部变量是完全确定的,在方法运行期间不会改变局部变量表的大小。

Java虚拟机栈有两种异常状况:如果线程请求的栈的深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。


3. 本地方法栈

本地方法栈(Native Method Stack):与虚拟机栈所发的作用是非常相似的,它们之间的区别只不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

Java 虚拟机规范没有对本地方法栈中方法使用的语言、使用的方式和数据结构做出强制规定,因此具体的虚拟机可以自由地实现它。比如:Sun HotSpot 虚拟机直接把Java虚拟机栈和本地方法栈合二为一。

与Java虚拟机栈一样,本地方法栈也会抛出StackOverflowErrorOutOfMemoryError 异常。


4. Java 堆

Java堆(Java Heap):是被所有线程所共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是:存放对象实例,几乎所有的对象实例都在这里分配内存。

Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC”堆(Garbage Collected Heap)。从内存回收的角度看,由于现在收集器基本都采用分代收集算法,所以 Java 堆中还可以细分为:新生代和老年代。从内存分配角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)。不过无论如何划分,都与存放的内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。

Java 虚拟机规定,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。在实现时,可以是固定大小的,也可以是可扩展的。如果在堆中没有完成实例分配。并且堆也无法扩展时,将会抛出 OutOfMemoryError 异常。


5. 方法区

方法区(Method Area):与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),其目的应该就是与 Java 堆区分开来。

Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载。

根据Java虚拟机规范规定,当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。

运行时常量池:运行时常量池(Runtime Constant Pool):是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一些信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

Java 虚拟机对 Class 文件每一部分(自然也包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、装载和执行。

直接内存:直接内存(Direct Memory):并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。但是这部分内存也频繁地使用,而且也可能导致 OutOfMemoryError 异常。

本地直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存大小以及处理器寻址空间的限制。如果各个内存区域总和大于物理内存限制,从而导致动态扩展时出现 OutOfMemoryError 异常。


3. 堆和栈的区别是什么?

堆和栈(虚拟机栈)是完全不同的两块内存区域,一个是线程独享的,一个是线程共享的。二者之间最大的区别就是存储的内容不同:堆中主要存放对象实例。栈(局部变量表)中主要存放各种基本数据类型、对象的引用。

从作用来说,栈是运行时的单位,而堆是存储的单位。栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪儿。在 Java 中一个线程就会相应有一个线程栈与之对应,因为不同的线程执行逻辑有所不同,因此需要一个独立的线程栈。而堆则是所有线程共享的。栈因为是运行单位,因此里面存储的信息都是跟当前线程(或程序)相关信息的。包括局部变量、程序运行状态、方法返回值等等;而堆只负责存储对象信息。


4. 堆中存什么?栈中存什么?

堆中存的是对象。栈中存的是基本数据类型和堆中对象的引用。一个对象的大小是不可估计的,或者说是可以动态变化的,但是在栈中,一个对象只对应了一个 4btye 的引用(堆栈分离的好处)。

为什么不把基本类型放堆中呢?

因为基本数据类型占用的空间一般是1~8个字节,需要空间比较少,而且因为是基本类型,所以不会出现动态增长的情况,长度固定,因此栈中存储就够了。如果把它存在堆中是没有什么意义的。基本类型和对象的引用都是存放在栈中,而且都是几个字节的一个数,因此在程序运行时,它们的处理方式是统一的。但是基本类型、对象引用和对象本身就有所区别了,因为一个是栈中的数据一个是堆中的数据。最常见的一个问题就是,Java 中参数传递时的问题。


5. 为什么要把堆和栈区分出来呢?栈中不是也可以存储数据吗?

  1. 从软件设计的角度看,栈代表了处理逻辑,而堆代表了数据。这样分开,使得处理逻辑更为清晰。分而治之的思想。这种隔离、模块化的思想在软件设计的方方面面都有体现。
  2. 堆与栈的分离,使得堆中的内容可以被多个栈共享(也可以理解为多个线程访问同一个对象)。这种共享的收益是很多的。一方面这种共享提供了一种有效的数据交互方式(如:共享内存),另一方面,堆中的共享常量和缓存可以被所有栈访问,节省了空间。
  3. 栈因为运行时的需要,比如:保存系统运行的上下文,需要进行地址段的划分。由于栈只能向上增长,因此就会限制住栈存储内容的能力。而堆不同,堆中的对象是可以根据需要动态增长的,因此栈和堆的拆分,使得动态增长成为可能,相应栈中只需记录堆中的一个地址即可。

6. Java 中的参数传递时传值呢?还是传引用?

要说明这个问题,先要明确两点:

  1. 不要试图与 C 进行类比,Java 中没有指针的概念。
  2. 程序运行永远都是在栈中进行的,因而参数传递时,只存在传递基本类型和对象引用的问题。不会直接传对象本身。

Java 在方法调用传递参数时,因为没有指针,所以它都是进行传值调用。但是传引用的错觉是如何造成的呢?在运行栈中,基本类型和引用的处理是一样的,都是传值。所以,如果是传引用的方法调用,也同时可以理解为“传引用值”的传值调用,即引用的处理跟基本类型是完全一样的。但是当进入被调用方法时,被传递的这个引用的值,被程序解释到堆中的对象,这个时候才对应到真正的对象。如果此时进行修改,修改的是引用对应的对象,而不是引用本身,即:修改的是堆中的数据。所以这个修改是可以保持的了。

对象,从某种意义上说,是由基本类型组成的。可以把一个对象看作为一棵树,对象的属性如果还是对象,则还是一颗树(即非叶子节点),基本类型则为树的叶子节点。程序参数传递时,被传递的值本身都是不能进行修改的,但是,如果这个值是一个非叶子节点(即一个对象引用),则可以修改这个节点下面的所有内容。


7. Java 对象的大小是怎么计算的?

基本数据类型的大小是固定的。对于非基本类型的 Java 对象,其大小就值得商榷。在 Java 中,一个空 Object 对象的大小是 8 byte,这个大小只是保存堆中一个没有任何属性的对象的大小。看下面语句: Object ob = new Object(); 这样在程序中完成了一个 Java 对象的生命,但是它所占的空间为:4 byte + 8 byte4 byte是上面部分所说的 Java 栈中保存引用的所需要的空间。而那8 byte则是 Java 堆中对象的信息。因为所有的 Java 非基本类型的对象都需要默认继承Object对象,因此不论什么样的 Java 对象,其大小都必须是大于8 byte。有了 Object` 对象的大小,我们就可以计算其他对象的大小了。

Java

1
2
3
4
5
Class MaNong {
int count;
boolean flag;
Object obj;
}

MaNong 的大小为:空对象大小(8 byte) + int 大小(4 byte) + Boolean 大小(1 byte) + 空 Object 引用的大小(4 byte) = 17byte。但是因为 Java 在对对象内存分配时都是以 8 的整数倍来分,因此大于 17 byte 的最接近 8 的整数倍的是 24,因此此对象的大小为 24 byte。

这里需要注意一下基本类型的包装类型的大小。因为这种包装类型已经成为对象了,因此需要把它们作为对象来看待。包装类型的大小至少是12 byte(声明一个空 Object 至少需要的空间),而且 12 byte 没有包含任何有效信息,同时,因为 Java 对象大小是 8 的整数倍,因此一个基本类型包装类的大小至少是 16 byte。这个内存占用是很恐怖的,它是使用基本类型的 N 倍(N > 2),有些类型的内存占用更是夸张(随便想下就知道了)。因此,可能的话应尽量少使用包装类。在 JDK5 以后,因为加入了自动类型装换,因此,Java 虚拟机会在存储方面进行相应的优化。


8. 对象的访问定位的两种方式?

Java 程序通过栈上的引用数据来操作堆上的具体对象。目前主流的对象访问方式有:句柄 和 直接指针。

  1. 使用句柄

    如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,引用中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

  2. 直接指针

    如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何防止访问类型数据的相关信息,reference 中存储的直接就是对象的地址。

  3. 各自的优点

  4. 使用句柄来访问的最大好处是引用中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而引用本身不需要修改;

  5. 使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

JVM 调优参数

  • 堆内存大小设置:
    • -Xms:设置 JVM 堆的初始内存大小。
    • -Xmx:设置 JVM 堆的最大内存大小。
    • 最佳实践:通常建议 -Xms 和 -Xmx 设置为相同值,以避免 JVM 在运行时动态调整堆大小带来的额外开销和 GC 停顿。例如 -Xms4g -Xmx4g。
  • 新生代大小设置:
    • -Xmn:设置新生代内存大小。
    • -XX:NewRatio=:设置老年代与新生代的比例,例如 -XX:NewRatio=2 表示老年代:新生代 = 2:1。
    • 考量:
      • 新生代过小:频繁 Minor GC, 导致对象过早进入老年代。
      • 新生代过大:Minor GC 间隔长, 但每次 GC 耗时可能长。
  • 元空间大小设置 (JDK 1.8+):
    • -XX:MetaspaceSize=:设置元空间的初始大小。
    • -XX:MaxMetaspaceSize=:设置元空间的最大大小。
    • 考量:如果应用加载大量类或使用动态代码生成, 可能需要调大。
  • 选择垃圾收集器:
    • -XX:+UseG1GC:启用 G1 垃圾收集器。这是 JDK 9+ 的默认收集器。
    • -XX:MaxGCPauseMillis=:设置 G1 收集器可接受的最大停顿时间(G1 会尽量接近这个目标, 但不保证完全达到)。例如 -XX:MaxGCPauseMillis=200。

10. 垃圾回收是从哪里开始的呢?

垃圾回收从查找哪些对象是正在被当前系统使用的开始。上面分析的堆和栈的区别,其中栈是真正进行程序执行的地方,所以要获取哪些对象正在被使用,则需要从 Java 栈开始。同时,一个栈是与一个线程对应的,因此,如果有多个线程的话,则必须对这些线程对应的所有的栈进行检查。

同时,除了栈外,还有系统运行时的寄存器等,也是存储程序运行数据的。这样,以栈或寄存器中的引用为起点,我们可以找到堆中的对象,又从这些对象找到对堆中其他对象的引用,这种引用逐步扩展,最终以 null 引用或者基本类型结束,这样就形成了一棵以 Java 栈中引用所对应的对象为根节点的一棵对象树。如果栈中有多个引用,则最终会形成多棵对象树。在这些对象树上的对象,都是当前系统运行所需要的对象,不能被垃圾回收。而其他剩余对象,则可以视为无法被引用到的对象,可以被当做垃圾进行回收。


11. 被标记为垃圾的对象一定会被回收吗?

即使在可达性分析算法中不可达的对象,也并非是“非死不可”,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。

  • 第一次标记:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记;
  • 第二次标记:第一次标记后接着会进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。在 finalize() 方法中没有重新与引用链建立关联关系的,将被进行第二次标记。第二次标记成功的对象将真的会被回收,如果对象在 finalize() 方法中重新与引用链建立了关联关系,那么将会逃离本次回收,继续存活。

12. 谈谈对 Java 中引用的了解?

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。在Java语言中,将引用又分为强引用、软引用、弱引用、虚引用 4 种,这四种引用强度依次逐渐减弱。

  1. 强引用

    在程序代码中普遍存在的,类似 Object obj = new Object() 这类引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

  2. 软引用

    用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收后还没有足够的内

  3. 弱引用

    也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

  4. 虚引用

    也叫幽灵引用或幻影引用,是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。它的作用是能在这个对象被收集器回收时收到一个系统通知。


13. 谈谈对内存泄漏的理解?

内存泄露的基本概念

在 Java 中,内存泄漏就是存在一些不会再被使用却没有被回收的对象,这些对象有下面两个特点:

  1. 这些对象是可达的,即在有向图中,存在通路可以与其相连;

  2. 这些对象是无用的,即程序以后不会再使用这些对象。

    如果对象满足这两个条件,这些对象就可以判定为 Java 中的内存泄漏,这些对象不会被 GC 所回收,然而它却占用内存。


14. 内存泄露的根本原因是什么?

长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄漏,尽管短生命周期对象已经不再需要,但是因为长生命周期持有它的引用而导致不能被回收,这就是 Java 中内存泄漏的发生场景。


15. 举几个可能发生内存泄漏的情况?

  1. 静态集合类引起的内存泄漏;
  2. 当集合里面的对象属性被修改后,再调用 remove() 方法时不起作用;
  3. 监听器:释放对象的时候没有删除监听器;
  4. 各种连接:比如数据库连接(dataSourse.getConnection()),网络连接(socket) 和 IO 连接,除非其显式的调用了其 close() 方法将其连接关闭,否则是不会自动被 GC 回收的;
  5. 内部类:内部类的引用是比较容易遗忘的一种,而且一旦没释放可能导致一系列的后继类对象没有释放;
  6. 单例模式:单例对象在初始化后将在 JVM 的整个生命周期中存在(以静态变量的方式),如果单例对象持有外部的引用,那么这个对象将不能被 JVM 正常回收,导致内存泄漏。

16. 尽量避免内存泄漏的方法?

  1. 尽量不要使用 static 成员变量,减少生命周期;
  2. 及时关闭资源;
  3. 不用的对象,可以手动设置为 null

垃圾收集器 (Garbage Collector)

垃圾收集器是 JVM 的一个重要组成部分, 负责自动管理 Java 堆内存中的对象的生命周期, 回收不再使用的对象所占用的内存。

垃圾判断算法:

  • 引用计数算法:当一个对象被引用一次, 计数器加1;引用失效, 计数器减1。当计数器为0时, 对象被判定为可回收。
    • 缺点:难以解决对象之间的循环引用问题。Java 虚拟机不采用此算法。
  • 可达性分析算法 (Root Tracing):通过一系列称为 “GC Roots” 的对象作为起始点, 从这些节点向下搜索, 搜索所走过的路径称为引用链 (Reference Chain)。当一个对象到 GC Roots 没有任何引用链相连时, 则证明此对象是不可用的。
    • 可作为 GC Roots 的对象:
      • 虚拟机栈 (栈帧中的局部变量表) 中引用的对象。
      • 本地方法栈 (Native 方法) 中引用的对象。
      • 方法区中类静态属性引用的对象。
      • 方法区中常量引用的对象。
      • 被同步锁持有的对象。
      • JVM 内部的引用 (如基本数据类型对应的 Class 对象)。

常见垃圾收集器:

  • Serial 收集器:
    • 特点:单线程, 工作时需要停止所有用户线程 (“Stop The World”, STW)。简单高效, 适用于单核 CPU 或内存较小的客户端应用。
    • 新生代使用:复制算法。
    • 老年代使用:标记-整理算法。
  • ParNew 收集器:
    • 特点:Serial 收集器的多线程版本, 用于新生代。并行收集时也需要 STW。
    • 新生代使用:复制算法。
    • 常与 CMS 收集器配合使用。
  • Parallel Scavenge 收集器:
    • 特点:关注吞吐量(Throughput = 用户代码执行时间 / (用户代码执行时间 + GC 时间)),可以设置最大吞吐量或最大 GC 停顿时间。
    • 新生代使用:复制算法。
    • 老年代使用:与 Parallel Old 配合使用,使用标记-整理算法。
  • CMS (Concurrent Mark Sweep) 收集器:
    • 特点:以获取最短回收停顿时间为目标,并发收集(与用户线程一起执行)。适用于对响应时间要求高的应用(如 Web 服务器)。
    • 工作步骤:
      1. 初始标记 (Initial Mark):STW,标记 GC Roots 能直接关联到的对象,速度快。
      2. 并发标记 (Concurrent Mark):与用户线程并发执行,进行 GC Roots Tracing 过程,耗时最长。
      3. 重新标记 (Remark):STW,修正并发标记期间因用户程序继续运行而导致标记产生变动的对象,比初始标记耗时长,但远比并发标记短。
      4. 并发清除 (Concurrent Sweep):与用户线程并发执行,清除已标记为垃圾的对象。
    • 缺点:
      • 对 CPU 资源敏感:并发阶段会占用一部分 CPU。
      • 无法处理浮动垃圾:并发清除阶段产生的垃圾(新生成的对象)只能下次 GC 再处理。
      • 可能产生大量空间碎片:采用“标记-清除”算法,不进行整理,可能导致大对象无法分配空间而提前触发 Full GC。
  • G1 (Garbage-First) 收集器:
    • 特点:JDK 9+ 的默认垃圾收集器。面向服务端应用,分区(将 Java 堆划分为多个独立区域 Region),可预测的停顿时间模型。
    • 工作原理:
      • 将堆内存划分为多个大小相等的 Region。
      • G1 跟踪每个 Region 的垃圾回收价值 (Garbage-First),优先回收垃圾最多的 Region。
      • 年轻化和老年代不再是物理隔离,而是逻辑上的概念,Region 可以动态地成为 Eden、Survivor 或 Old 区域。
      • 并发与并行兼容:并发标记,但回收阶段并行。
      • 基本无碎片:采用复制和标记-整理算法结合。
    • 工作步骤:
      1. 初始标记 (Initial Mark):STW,标记 GC Roots 能直接关联的对象。
      2. 并发标记 (Concurrent Mark):与用户线程并发,遍历对象图。
      3. 最终标记 (Final Mark):STW,处理并发标记阶段结束后仍然存活的对象。
      4. 筛选回收 (Evacuation):STW,对各个 Region 的回收价值进行排序,根据预期停顿时间来回收 Region,采用复制算法将存活对象复制到新的 Region。
    • 优势:在保持高吞吐量的同时,降低了 GC 停顿时间,适合大内存、多核处理器场景。
  • zGC (Z Garbage Collector) 和 Shenandoah 收集器:
    • 特点:低延迟、并发 GC 收集器,旨在实现毫秒级的 GC 停顿。
    • ZGC: JDK 11 引入,支持 TB 级别的堆内存,停顿时间与堆大小无关。
    • Shenandoah: JDK 12 引入,与 G1 类似,但能进一步降低停顿时间。
    • 应用场景:对延迟要求极高的应用

类加载机制 (Class Loading Mechanism)

  • 作用: 将 .class 文件中的字节码加载到 JVM 内存中,并转换为运行时数据结构。

  • 生命周期: 加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 -> 卸载。

  • 主要阶段:

    • 加载 (Loading):
      • 通过类的全限定名获取该类的二进制字节流。
      • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
      • 在内存中生成一个代表该类的 java.lang.Class 对象。
    • 验证 (Verification): 确保 Class 文件的字节流符合 JVM 规范,没有安全问题。
    • 准备 (Preparation): 为类的静态变量(static fields)分配内存并初始化为默认值(如 int 变量为 0,引用类型为 null)。
    • 解析 (Resolution): 将常量池中的符号引用替换为直接引用。
    • 初始化 (Initialization): 执行类的构造器 <clinit>() 方法,真正开始执行类中定义的 Java 程序代码(为静态变量赋予初始值,执行静态代码块)。
  • 类加载是 Java 虚拟机(JVM)将 Class 文件中的二进制数据读取到内存中,并将其转换为可用的 java.lang.Class 对象的过程。这个过程是所有 Java 程序运行的基础。

    类加载的整个生命周期包括以下 7 个阶段:

    1. 加载(Loading)
    2. 验证(Verification)
    3. 准备(Preparation)
    4. 解析(Resolution)
    5. 初始化(Initialization)
    6. 使用(Using)
    7. 卸载(Unloading)

    其中,前五个阶段是类加载的核心过程,后面两个阶段是类生命周期的延伸。下面我们详细解析前五个核心阶段。


    1. 加载(Loading)

    这是类加载过程的第一个阶段,主要任务是查找并导入 Class 文件中的二进制数据。这个阶段主要完成三件事:

    • 通过类的全限定名获取定义此类的二进制字节流:JVM 可以从文件系统、网络、压缩包(JAR/WAR/EAR)甚至数据库中获取字节流。
    • 将字节流中的静态存储结构转换为方法区的运行时数据结构:JVM 会将二进制数据按照虚拟机规范的要求,转化为方法区中可用的数据结构。
    • 在内存中生成一个代表该类的 java.lang.Class 对象:这个对象是访问方法区中该类各种数据(如方法、字段)的入口,也是反射机制的基石。

    2. 验证(Verification)

    验证阶段的目的是确保 Class 文件的字节流符合 JVM 规范,并且不会危害到虚拟机的安全。这个阶段非常重要,因为 Class 文件不一定是由 Java 源码编译而来,它可以是任何来源。验证通常包括以下几个检查:

    • 文件格式验证:检查字节流是否符合 Class 文件格式的规范,例如魔数(0xCAFEBABE)、版本号等。
    • 元数据验证:对类的元数据进行语义分析,确保其符合 Java 语言规范,例如是否继承了不允许继承的 final 类、字段和方法签名是否合理等。
    • 字节码验证:这是验证阶段最复杂的一步,通过数据流和控制流分析,确保程序语义是合法的、符合逻辑的。例如,保证类型转换是有效的、方法调用的参数是正确的。
    • 符号引用验证:在解析阶段发生,确保解析出来的类、字段、方法是真实存在的,并且具有足够的访问权限。

    3. 准备(Preparation)

    准备阶段是为类的静态变量分配内存并设置初始值的阶段。

    • 只为静态变量分配内存:在这个阶段,JVM 只为 static 变量(类变量)分配内存,而不包括实例变量。实例变量会在对象实例化时随着对象一起分配在堆中。
    • 设置初始值:这里的初始值通常是该数据类型的零值(例如 int 的初始值为 0booleanfalseStringnull)。

    注意:如果静态变量被 final 修饰,并且是基本数据类型或字符串字面量,那么在准备阶段就会直接赋值为它在代码中定义的值。

    例如:

    1
    2
    3
    4
    5
    // 准备阶段:value 的初始值为 0
    public static int value = 123;

    // 准备阶段:finalValue 的初始值直接为 123
    public static final int finalValue = 123;

    4. 解析(Resolution)

    解析阶段是将常量池中的符号引用转换为直接引用的过程。

    • 符号引用:一组符号来描述所引用的目标,可以是任何形式的字面量,只要能定位到目标即可。它不依赖于内存布局。
    • 直接引用:直接指向目标的指针、相对偏移量或是一个可以间接定位到目标的句柄。它与内存布局相关。

    解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符七类符号引用进行。这个阶段是动态的,通常会在程序运行时,当符号引用被首次使用时才进行。


    5. 初始化(Initialization)

    初始化是类加载过程的最后一步,在这个阶段,真正执行类中定义的 Java 代码。

    • 执行 () 方法:初始化阶段会执行类构造器 <clinit>() 方法,这个方法由编译器自动生成,它用于收集类中所有静态变量的赋值动作和静态代码块(static {})中的语句。
    • 线程安全:JVM 会确保 <clinit>() 方法在多线程环境下被正确地加锁和同步,保证一个类的 <clinit>() 方法只会被执行一次。
    • 触发时机:只有在以下几种情况中,一个类才会被“主动”初始化:
      • 创建类的实例(new)。
      • 访问类的静态变量(除了在编译时被 final 化的常量)。
      • 调用类的静态方法。
      • 使用 java.lang.reflect 包的方法进行反射调用。
      • 初始化一个子类时,其父类尚未初始化。
      • 虚拟机启动时,被指定为启动类的类(如包含 main() 方法的类)。

    理解这五个阶段对于深入掌握 JVM 的工作原理至关重要。

  • 类加载器 (Class Loaders):

    • 类加载器的分类:

      (1)Bootstrap class loader (使用C++编写的)

      ​ 简称:启动类加载器

      加载路径:JAVA_HOME/jre/lib

      ​ 显示形式:null

      (2)Platform class loader(由Java编写的)

      ​ 简称:扩展类加载器

      加载路径:JAVA_HOME/jre/lib/ext

      ​ 显示形式:ExtClassLoader

      (3)System class loader(由Java编写的)

      ​ 简称:应用程序类加载器

      加载路径:类路径(src目录)

      ​ 显示形式:AppClassLoader //sun.misc.Launcher$AppClassLoader@18b4aac2

      (4)自定义类加载器(由Java编写的)

      ​ 简称:自定义类加载器

      ​ 加载路径:自定义

      1. 启动类加载器 (Bootstrap ClassLoader)

      • 作用:它负责加载 Java 核心库,比如 rt.jar(包含 java.lang.*, java.util.* 等核心类)。
      • 实现:它不是用 Java 写的,而是由 C++ 实现的,是 JVM 自身的一部分。因此,你无法在 Java 代码中直接获取到它的对象,调用 getClassLoader() 得到的会是 null
      • 父加载器:它没有父加载器。它是类加载器层次结构的顶端。

      2. 扩展类加载器 (Extension ClassLoader)

      • 作用:它负责加载 JVM 扩展目录中的所有 jar 包,通常是 JRE/lib/ext 目录下的库。
      • 实现:它是由 Java 语言实现的。
      • 父加载器:它的父加载器是启动类加载器

      3. 应用程序类加载器 (Application ClassLoader)

      • 作用:它负责加载我们自己编写的 Java 程序中的类,也就是你项目中 classpath 路径下的所有类。
      • 实现:它也是由 Java 语言实现的。
      • 父加载器:它的父加载器是扩展类加载器

      自定义类加载器

      除了上面三个,你也可以根据自己的需求创建自定义类加载器

      • 作用:当你需要加载一些特定来源的类时(比如从网络下载的类、对字节码进行加密或解密),自定义类加载器就很有用。例如,Web 服务器(如 Tomcat)就是通过自定义类加载器来隔离不同 Web 应用的类。
      • 如何实现:通常,你需要继承 java.lang.ClassLoader 类,并重写 findClass() 方法。在 findClass() 方法中,你需要自己定义如何获取类的字节码(比如从文件系统、网络或数据库),然后调用 defineClass() 方法将字节码转换为 Class 对象。

33. 有哪些类加载器?分别有什么作用?

  1. 启动类加载器 (Bootstrap ClassLoader):这个类加载器是由 C++ 语言实现的,是虚拟机自身的一部分。负责将存在 \lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的类库加载到虚拟机内存中。启动内加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用 null 即可;
  2. 其他类加载器:由 Java 语言实现,独立于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。如扩展类加载器和应用程序类加载器:
    • 扩展类加载器 (Extension ClassLoader):这个类加载器由 sun.misc.Launcher$ExtClassLoader 实现,它负责加载\lib\ext目录中的,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
    • 应用程序类加载器 (Application ClassLoader):这个类加载器由 sun.misc.Launcher$AppClassLoder 实现。由于个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,所以一般也称之为系统类加载器。它负责加载用户路径(ClassPath)所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
  • 双亲委派模型 (Parents Delegation Model):

    • 原理: 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。
    • 优点: 避免类的重复加载,保证 Java 核心库的类型安全。

面试题:

为什么需要四个类加载器,而不是一个?

这是一个很好的问题。为什么 Java 要设计多个类加载器,而不是用一个大一统的加载器呢?这背后主要有三个核心原因:隔离性安全性可扩展性


1. 隔离性(Isolation)

多类加载器的最大优势就是实现了类隔离

想象一下,你有一个 Web 服务器,上面部署了两个不同的 Web 应用(比如一个博客系统和一个论坛)。这两个应用可能依赖同一个第三方库的不同版本(例如,博客用的是 Spring 框架的 5.x 版本,而论坛用的是 4.x 版本)。

如果只有一个类加载器,它会把所有 classpath 上的类都加载到同一个内存空间。这样一来,两个不同版本的 Spring 框架就会产生冲突,JVM 根本无法区分它们,程序就会报错。

而有了自定义类加载器,服务器就可以为每个 Web 应用创建一个独立的类加载器。每个加载器负责加载自己应用目录下的类,这样两个应用即使使用了同一个库的不同版本,也能在内存中和谐共存,互不干扰。


2. 安全性(Security)

Java 的核心库(如 java.lang, java.util 等)对 JVM 的稳定和安全至关重要。

双亲委派模型正是为了保护这些核心库不被恶意代码或普通用户代码所篡改。

例如,如果你尝试自己写一个 java.lang.String 类并把它放在应用的 classpath 下,双亲委派机制会确保这个类不会被加载。因为当加载请求到达应用程序类加载器时,它会先委派给它的父加载器——扩展类加载器,然后再委派给最顶层的启动类加载器。启动类加载器会优先加载 JDK 自带的 java.lang.String,从而你的恶意代码就永远没有机会被执行。


3. 可扩展性(Extensibility)

Java 是一种非常灵活的语言,需要适应各种复杂的运行时环境。

如果只有一个类加载器,所有的类都必须来自文件系统。但在现实世界中,类可能来自各种地方:

  • 从网络上下载的字节码。
  • 通过数据库存储和加载。
  • 对字节码进行加密和解密。

自定义类加载器让 Java 能够动态地加载处理非标准来源的类。例如,热部署技术(在不重启应用的情况下更新代码)就是通过自定义类加载器实现的。它会创建一个新的加载器来加载新版本的类,而旧的加载器和它的类则可以被垃圾回收。


总结

所以,一个大一统的类加载器虽然简单,但它无法解决复杂的类版本冲突核心库安全动态加载等问题。通过分层设计和双亲委派模型,Java 的类加载器体系变得既安全又灵活,能够满足从简单的桌面应用到复杂的企业级服务器等各种场景的需求。

常见的 OOM (OutOfMemoryError) 和 StackOverflowError

  • Java heap space: 堆内存不足,最常见。通常通过调整 JVM 启动参数 -Xms (初始堆大小) 和 -Xmx (最大堆大小) 来解决。
  • StackOverflowError: 虚拟机栈溢出。通常是递归调用过深导致栈帧不断入栈。
  • PermGen space / Metaspace: 方法区溢出。通常是加载的类过多,或者存在大量字符串常量。通过 -XX:MaxPermSize (JDK 1.7) 或 -XX:MaxMetaspaceSize (JDK 1.8) 调整。
  • Direct buffer memory: 直接内存溢出。通常是使用了 NIO 或 Netty 等直接操作堆外内存的库。
  • unable to create new native thread: 无法创建新线程。通常是系统线程数达到上限或内存不足以分配新线程的栈空间。

OOM常见场景:


1. java.lang.OutOfMemoryError: Java heap space

这是最常见、也最广为人知的内存溢出错误。它表示 Java 堆(Heap) 中没有足够的空间来分配新的对象。

常见场景:

  • 内存泄漏(Memory Leak):这是最主要的原因。你的程序中创建了对象,但本应被垃圾回收器(GC)回收的对象却因为某些原因(比如被一个长生命周期的对象引用着)而无法被回收。例如:
    • 一个静态的 MapList 集合,不断地往里面添加对象,但从不删除。
    • 监听器或回调函数没有正确移除,导致被监听的对象无法被回收。
    • 数据库连接或文件流没有正确关闭,长时间占用资源。
  • 内存使用不当:一次性加载大量数据到内存中。例如,从数据库查询数百万条记录,并把它们全部加载到一个 List 中;或者处理一个超大的图片或文件,导致瞬间占用大量内存。
  • 配置问题:JVM 的堆内存设置得太小,无法满足程序的正常运行需求。这在部署应用时很常见,可以通过调整 -Xmx 参数来解决。

2. java.lang.StackOverflowError

这个错误表示 虚拟机栈(Stack) 溢出。每个线程都有一个独立的栈,用于存储方法调用的栈帧。当栈的深度超过了 JVM 允许的最大深度时,就会抛出此错误。

常见场景:

  • 无限递归(Infinite Recursion):这是最典型的场景。一个方法不断地调用自身,且没有正确的退出条件。例如:

    1
    2
    3
    4
    public void recursiveMethod() {
    // 没有退出条件
    recursiveMethod();
    }

    另一个例子是两个方法互相调用,形成循环:A 调用 B,B 又调用 A。

  • 递归调用层级过深:即使递归有正确的退出条件,如果数据量过大,导致递归调用层级非常深,也可能导致栈溢出。例如,处理一个深度非常大的树形结构。


3. java.lang.OutOfMemoryError: PermGen space 或 Metaspace

这个错误发生在 方法区 溢出。方法区用于存储类的元数据信息,如类的结构、字段、方法、常量池等。

  • JDK 1.7 及之前:方法区在堆中,被称为永久代(PermGen)。溢出错误为 PermGen space
  • JDK 1.8 及之后:永久代被移除,方法区改为使用元空间(Metaspace),并且默认使用本地内存。溢出错误为 Metaspace

常见场景:

  • 动态生成大量类:在运行时生成大量新的类。这在一些使用字节码增强技术的框架(如 CGLib)或动态代理的场景中很常见。
  • 热部署:在像 Tomcat 这样的 Web 服务器中进行频繁的热部署操作,如果没有正确清理旧的类加载器,会导致旧的类元数据无法被回收,从而逐渐耗尽方法区内存。
  • 常量池溢出:在 JDK 1.7 之前,字符串常量池也在永久代中。如果程序创建了大量不同的字符串(例如在循环中不断生成新的字符串),也可能导致永久代溢出。

4. java.lang.OutOfMemoryError: Direct buffer memory

这个错误与 直接内存(Direct Memory) 相关,它不是 Java 堆的一部分,而是通过 ByteBuffer.allocateDirect() 在堆外分配的内存。

常见场景:

  • NIO 和网络编程:在使用 Java NIO、Netty、或者其他依赖堆外内存的库时,如果频繁地分配直接内存但没有及时释放,就可能导致此错误。
  • 内存泄漏:直接内存的回收不像堆内存那样由 GC 自动管理。如果程序中没有调用 ByteBuffercleaner() 方法,或者在没有关闭资源的情况下直接内存泄漏,就会耗尽系统的直接内存。

5. java.lang.OutOfMemoryError: unable to create new native thread

这个错误通常不是因为 Java 堆内存不足,而是因为系统资源耗尽

常见场景:

  • 线程创建过多:程序中创建了大量的线程,导致系统无法为新的线程分配内存空间。每个线程除了 Java 堆中的栈空间外,还需要分配一些本地内存。
  • 系统限制:操作系统对单个进程创建的线程数有限制。如果达到了这个上限,JVM 就会抛出此错误。在 Linux 系统中,你可以通过 ulimit -u 命令查看这个限制。
  • 内存不足:系统内存(包括堆外内存)已经所剩无几,JVM 无法为新的线程栈分配足够的内存。

面试题:

1. 简述 Java 堆内存溢出(Java heap space)的原因及解决方案。

原因: 最主要的原因是内存泄漏,即 GC 无法回收本应被回收的对象。其他常见原因包括:

  • 内存使用不当:一次性加载大量数据到内存中,如从数据库查询海量数据。
  • 配置不当:JVM 启动参数 -Xmx 设置过小,无法满足应用需求。

解决方案:

  • 分析和定位问题:使用 jmapjstatjstack 等 JDK 自带工具或者 Eclipse MAT 等可视化工具分析内存快照(heap dump),找到占用内存最多的对象,并分析是否存在内存泄漏。
  • 代码层面优化
    • 检查并解决内存泄漏问题,比如移除不再需要的静态集合引用。
    • 优化大批量数据处理逻辑,采用分批、分页处理或流式处理,避免一次性加载所有数据。
    • 及时释放不再使用的资源,如关闭数据库连接、文件流等。
  • JVM 参数调整:如果确认没有内存泄漏,只是应用确实需要更多内存,可以适当增大 -Xmx-Xms 参数。

2. 什么是 StackOverflowError?它和 OOM 有什么区别?

StackOverflowError 表示虚拟机栈溢出,通常是由于无限递归递归调用层级过深导致栈帧不断入栈而耗尽了栈空间。

与 OOM 的区别:

  • 发生位置不同StackOverflowError 发生在虚拟机栈,而 OutOfMemoryError 发生在方法区等其他内存区域。
  • 原因不同StackOverflowError 通常是由于代码逻辑问题(如无限递归)导致,与程序处理的数据量大小关系不大(除非递归深度过深)。而 OOM 往往与程序处理的数据量JVM 内存配置内存泄漏有关。
  • 解决方式不同:解决 StackOverflowError 主要需要修改代码,比如增加递归的退出条件或采用非递归(迭代)方式重构。而解决 OOM 则需要定位内存泄漏优化代码调整 JVM 内存参数

3. JDK 8 中 PermGen space 溢出错误为什么变成了 Metaspace?

  • JDK 1.7 及之前:方法区被实现为永久代(PermGen),它属于堆内存的一部分。因此,它的空间大小受限于 JVM 堆内存的设置,容易发生 PermGen space 溢出。
  • JDK 1.8 及之后永久代被移除,方法区改为元空间(Metaspace)。元空间不在 JVM 堆中,而是直接使用本地内存(Native Memory)。

变化的好处:

  • 减少 OOM 可能性:元空间使用本地内存,其大小只受限于系统可用内存,默认情况下比永久代大得多,从而降低了因方法区过小而引发 OOM 的风险。
  • 避免 GC 负担:永久代需要由 GC 进行垃圾回收,而元空间则通过 class loader 引用来管理,当对应的 class loader 被回收时,其加载的类元数据也会被回收。
  • 更灵活:元空间的大小可以通过参数 -XX:MaxMetaspaceSize 来设置上限,如果该参数不设置,元空间会根据需要自动扩容。

4. 除了 Java 堆,还有哪些内存区域可能发生 OOM?如何解决?

  • 直接内存(Direct buffer memory)
    • 原因:通常是使用 NIO 或 Netty 等框架时,频繁分配堆外内存但没有及时释放。
    • 解决:检查代码中堆外内存的使用,确保在不再需要时通过 Buffer.cleaner() 或资源关闭操作来显式释放内存。
  • 无法创建新线程(unable to create new native thread)
    • 原因
      1. 程序创建了过多线程,耗尽了操作系统对单个进程的线程数限制。
      2. 系统内存不足,无法为新线程分配栈空间。
    • 解决
      1. 代码优化:检查业务逻辑,看是否可以减少线程创建数量,或者使用线程池来复用线程。
      2. 系统配置
        • 在 Linux 系统中,使用 ulimit -u 增加线程数限制。
        • 检查系统内存,确保有足够的内存空间。
      3. JVM 参数调整:如果线程确实需要,可以适当减小线程栈的大小(-Xss)来允许创建更多线程,但需要注意防止 StackOverflowError。

Java框架核心知识详解

一、Spring框架深入解析

1.1 Spring核心概念与原理

Spring框架是一个分层的企业级应用开发框架,其核心是控制反转(IoC)和面向切面编程(AOP)。

IoC容器原理深入分析

IoC(控制反转)的本质: 传统开发中,对象的创建和依赖关系由程序代码直接控制,而IoC将这个控制权交给了外部容器。Spring通过依赖注入(DI)来实现IoC。

IoC容器的实现机制:

  1. BeanFactory: 基础容器,提供基本的IoC功能
  2. ApplicationContext: 高级容器,继承BeanFactory,提供更多企业级功能

好的,我们把 Spring IoC容器的实现机制 展开讲清楚,从 BeanFactoryApplicationContext 的层次演进,以及它们在底层是如何运作的。


IoC 容器实现机制

1. BeanFactory —— 基础 IoC 容器

BeanFactory 是 Spring IoC 的最底层容器接口,定义了 获取 Bean 的基本规则

核心特性

  • 懒加载(Lazy loading)
    • 只有在调用 getBean() 时,才会去实例化 Bean。
    • 避免启动时消耗过多资源。
  • 最小依赖
    • 只提供 IoC 的基础功能:
      • Bean 的定义(BeanDefinition)管理
      • Bean 的生命周期(创建、依赖注入、销毁)
      • Bean 的作用域(singleton / prototype)
  • 轻量级
    • 更适合资源受限环境,比如 IoT、移动设备,或 Spring 的底层框架内部。

核心实现机制

  1. 读取 BeanDefinition
    • 配置文件(XML)、注解(@Component)、Java Config( @Bean ) → 转换成 BeanDefinition 对象。
    • BeanDefinition 是 Bean 的抽象描述,包括:
      • Bean 的 class 类型
      • 是否单例 / 原型
      • 是否懒加载
      • 构造参数 / 依赖属性
  2. 存储 BeanDefinition
    • 存入 BeanDefinitionRegistry(通常是 Map 结构)。
  3. 按需实例化 Bean
    • 调用 getBean(name) → 根据 BeanDefinition 反射创建实例。
    • 进行依赖注入(构造器注入 / setter 注入)。
  4. Bean 生命周期管理
    • 初始化前回调(BeanPostProcessor → postProcessBeforeInitialization
    • 调用初始化方法(InitializingBean@PostConstruct
    • 初始化后回调(BeanPostProcessor → postProcessAfterInitialization
    • 销毁前回调(DisposableBean@PreDestroy

2. ApplicationContext —— 高级 IoC 容器

ApplicationContextBeanFactory 的子接口,扩展了很多 企业级特性,是我们在项目中最常用的容器。

核心特性(对比 BeanFactory)

  1. 预实例化(Eager loading)
    • 默认在容器启动时,就会实例化所有单例 Bean。
    • 启动时性能开销更大,但运行时性能更好。
  2. 国际化(I18N)支持
    • 通过 MessageSource 接口,支持不同语言的消息解析。
  3. 事件机制(ApplicationEvent)
    • 内置事件发布 / 监听机制(观察者模式)。
    • 开发者可以自定义事件,实现应用内解耦。
  4. 资源加载
    • 统一的 Resource 抽象,可以加载文件、URL、classpath 下的资源。
  5. 与 AOP、事务管理集成
    • 自动识别 BeanPostProcessor、BeanFactoryPostProcessor,用于扩展 Bean 生命周期逻辑(例如事务代理、AOP 动态代理)。
  6. Profile 环境支持
    • 支持多环境配置(开发、测试、生产),通过 @Profile 注解切换。

3. BeanFactory vs ApplicationContext 总结

特性 BeanFactory ApplicationContext
Bean 加载时机 懒加载(按需创建) 预实例化(启动时创建单例 Bean)
国际化支持
事件机制
AOP / 事务 手动注册 BeanPostProcessor 自动识别并应用
资源加载 基础 API 更强大的 Resource 抽象
适用场景 轻量应用、底层容器 企业级应用,大部分 Spring 项目

4. 常见 ApplicationContext 实现类

  • ClassPathXmlApplicationContext
    • 从 classpath 下的 XML 加载配置。
  • FileSystemXmlApplicationContext
    • 从文件系统路径下加载 XML 配置。
  • AnnotationConfigApplicationContext
    • 基于注解和 Java Config 的容器。
  • WebApplicationContext
    • 专门为 Web 应用准备的 IoC 容器,整合 ServletContext。

👉 可以这样理解:

  • BeanFactory 就是 IoC 的“发动机”,提供最基本的动力。
  • ApplicationContext 就是加了“变速箱、车载娱乐系统、空调、智能导航”的完整汽车,更适合实际企业应用。

Bean的生命周期详解:

1
实例化 → 属性赋值 → 初始化前处理 → 初始化 → 初始化后处理 → 使用 → 销毁前处理 → 销毁

详细生命周期步骤:

  1. Bean元数据解析(XML、注解、Java配置)
  2. 调用Bean构造函数实例化
  3. 依赖注入(setter方法、构造函数、字段注入)
  4. 如果实现了BeanNameAware,调用setBeanName()
  5. 如果实现了BeanFactoryAware,调用setBeanFactory()
  6. 如果实现了ApplicationContextAware,调用setApplicationContext()
  7. 如果有BeanPostProcessor,调用postProcessBeforeInitialization()
  8. 如果实现了InitializingBean,调用afterPropertiesSet()
  9. 如果配置了init-method,调用自定义初始化方法
  10. 如果有BeanPostProcessor,调用postProcessAfterInitialization()
  11. Bean可以被使用
  12. 容器关闭时,如果实现了DisposableBean,调用destroy()
  13. 如果配置了destroy-method,调用自定义销毁方法

DI的三种注入方式对比

  • 构造器注入(Constructor Injection)
    • 优点:强制依赖、依赖清晰、不可变性
    • 缺点:当依赖项过多时构造函数臃肿
    • 最佳实践:官方推荐,特别是当依赖是必需的、不可选的时候
  • Setter注入(Setter Injection)
    • 优点:可选依赖,灵活性高
    • 缺点:对象可能处于不完整状态,无法使用final修饰
    • 最佳实践:用于注入可选的依赖项
  • 字段注入(Field Injection)
    • 优点:代码简洁
    • 缺点:不推荐使用,隐藏依赖、测试困难、无法使用final修饰

Bean 的生命周期

在传统的 Java 应用中,Bean 的生命周期很简单:使用 new 关键字进行实例化,然后 Bean 就能被使用,不再使用时,由 Java 自动进行垃圾回收。

相比之下,Spring 管理 Bean 的生命周期就复杂多了,正确理解 Bean 的生命周期非常重要,因为 Spring 对 Bean 的管理可扩展性非常强。以下是一个 Bean 的构造过程:

  1. Spring 启动:查找并加载需要被 Spring 管理的 Bean,进行 Bean 的实例化。
  2. 属性注入:对 Bean 的引用和值注入到 Bean 的属性中。
  3. BeanNameAware 接口:如果 Bean 实现了 BeanNameAware 接口,Spring 将 Bean 的 Id 传递给 setBeanName() 方法。
  4. BeanFactoryAware 接口:如果 Bean 实现了 BeanFactoryAware 接口,Spring 将调用 setBeanFactory() 方法,将 BeanFactory 容器实例传入。
  5. ApplicationContextAware 接口:如果 Bean 实现了 ApplicationContextAware 接口,Spring 将调用 Bean 的 setApplicationContext() 方法,将 Bean 所在的应用上下文引用传入。
  6. BeanPostProcessor (前):如果 Bean 实现了 BeanPostProcessor 接口,Spring 将调用它们的 postProcessBeforeInitialization() 方法。
  7. InitializingBean 接口与 init-method
    • 如果 Bean 实现了 InitializingBean 接口,Spring 将调用它们的 afterPropertiesSet() 方法。
    • 如果 Bean 使用 init-method 声明了初始化方法,该方法也会被调用。
  8. BeanPostProcessor (后):如果 Bean 实现了 BeanPostProcessor 接口,Spring 将调用它们的 postProcessAfterInitialization() 方法。
  9. Bean 准备就绪:此时,Bean 已经准备就绪,可以被应用程序使用。它们将一直驻留在应用上下文中,直到应用上下文被销毁。
  10. 销毁
    • 如果 Bean 实现了 DisposableBean 接口,Spring 将调用它的 destory() 接口方法。
    • 如果 Bean 使用 destory-method 声明了销毁方法,该方法也会被调用。

Bean 的作用域

  1. singleton:唯一的 Bean 实例,Spring 中的 Bean 默认都是单例的。
  2. prototype:每次请求都会创建一个新的 Bean 实例。
  3. request:每一次 HTTP 请求都会产生一个新的 Bean,该 Bean 仅在当前 HTTP request 内有效。
  4. session:每一次 HTTP 请求都会产生一个新的 Bean,该 Bean 仅在当前 HTTP session 内有效。
  5. global-session:全局 session 作用域,仅仅在基于 portlet 的 web 应用中才有意义,Spring 5 已经没有了。Portlet 是能够生成语义代码(例如:HTML)片段的小型 Java Web 插件。它们基于 portlet 容器,可以像 servlet 一样处理 HTTP 请求。但是,与 servlet 不同,每个 portlet 都有不同的会话。

Spring 中的单例 Bean 的线程安全问题

大多数时候我们并没有在系统中使用多线程,所以很少有人会关注这个问题。单例 Bean 存在线程问题,主要是因为:当多个线程操作同一个对象的时候,对这个对象的非静态成员变量的写操作会存在线程安全问题。常见的有两种解决办法:

  1. 在 Bean 对象中尽量避免定义可变的成员变量(不太现实)。
  2. 在类中定义一个 ThreadLocal 成员变量,将需要的可变成员变量保存在 ThreadLocal 中(推荐的一种方式)。

对 Spring 中的事务的理解

事务是逻辑上的一组操作,要么都执行,要么都不执行。

事务特性

  • 原子性:事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用。
  • 一致性:执行事务前后,数据保持一致。
  • 隔离性:并发访问数据库时,一个用户的事物不被其他事物所干扰,各并发事务之间数据库是独立的。
  • 持久性:一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。

Spring 事务管理接口

  1. PlatformTransactionManager:(平台)事务管理器。
  2. TransactionDefinition:事务定义信息(事务隔离级别、传播行为、超时、只读、回滚规则)。
  3. TransactionStatus:事务运行状态。

所谓事务管理,其实就是“按照给定的事务规则来执行提交或者回滚操作”。


Spring 中的事务隔离级别

TransactionDefinition 接口中定义了五个表示隔离级别的常量:

  • TransactionDefinition.ISOLATION_DEFAULT:使用后端数据库默认的隔离级别。MySQL 默认采用 REPEATABLE_READ 隔离级别,Oracle 默认采用 READ_COMMITTED 隔离级别。
  • TransactionDefinition.ISOLATION_READ_UNCOMMITTED:最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。
  • TransactionDefinition.ISOLATION_READ_COMMITTED:允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。
  • TransactionDefinition.ISOLATION_REPEATABLE_READ:对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
  • TransactionDefinition.ISOLATION_SERIALIZABLE:最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是这将严重影响程序的性能。通常情况下也不会用到该级别。

Spring 中的事物传播行为

事务传播行为是为了解决业务层方法之间互相调用的事务问题。当事务方法被另一个事务方法调用时,必须指定事务应该如何传播。例如:方法可能继续在现有事务中运行,也可能开启一个新事务,并在自己的事务中运行。在 TransactionDefinition 定义中包括了如下几个表示传播行为的常量:

支持当前事务的情况:

  • TransactionDefinition.PROPAGATION_REQUIRED:如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。
  • TransactionDefinition.PROPAGATION_SUPPORTS:如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。
  • TransactionDefinition.PROPAGATION_MANDATORY:如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。

不支持当前事务的情况:

  • TransactionDefinition.PROPAGATION_REQUIRES_NEW:创建一个新的事务,如果当前存在事务,则把当前事务挂起。
  • TransactionDefinition.PROPAGATION_NOT_SUPPORTED:以非事务方式运行,如果当前存在事务,则把当前事务挂起。
  • TransactionDefinition.PROPAGATION_NEVER:以非事务方式运行,如果当前存在事务,则抛出异常。

其他情况:

  • TransactionDefinition.PROPAGATION_NESTED:如果当前存在事务,则创建一个事务作为当前事务的嵌套事务来运行;如果当前没有事务,则该取值等价于 TransactionDefinition.PROPAGATION_REQUIRED

Spring 常用注入方式

  1. 构造器依赖注入:构造器依赖注入通过容器触发一个类的构造器来实现的,该类有一系列参数,每个参数代表一个对其他类的依赖。
  2. Setter 方法注入:Setter 方法注入是容器通过调用无参构造器或无参 static 工厂方法实例化 bean 之后,调用该 bean 的 Setter 方法,即实现了基于 Setter 的依赖注入。
  3. 基于注解的注入:最好的解决方案是使用构造器参数实现强制依赖,Setter 方法实现可选依赖。

Spring 框架中用到了哪些设计模式?

  1. 工厂设计模式:Spring 使用工厂模式通过 BeanFactoryApplicationContext 创建 bean 对象。
  2. 代理设计模式:Spring AOP 功能的实现。
  3. 单例设计模式:Spring 中的 Bean 默认都是单例的。
  4. 模板方法模式:Spring 中 jdbcTemplatehibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。
  5. 包装器设计模式:我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们根据客户的需求能够动态切换不同的数据源。
  6. 观察者模式:Spring 事件驱动模型就是观察者模式很经典的一个应用。
  7. 适配器模式:Spring AOP 的增强或通知(Advice)使用到了适配器模式、Spring MVC 中也是用到了适配器模式适配 Controller。

ApplicationContext 通常的实现有哪些?

  1. FileSystemXmlApplicationContext:此容器从一个 XML 文件中加载 beans 的定义,XML Bean 配置文件`的全路径名必须提供给它的构造函数。
  2. ClassPathXmlApplicationContext:此容器也从一个 XML 文件中加载 beans 的定义,这里,你需要正确设置 classpath 因为这个容器将在 classpath 里找 bean 配置。
  3. WebXmlApplicationContext:此容器加载一个 XML 文件,此文件定义了一个 Web 应用的所有 bean。

AOP原理深入分析

AOP实现机制: Spring AOP基于代理模式实现,支持两种代理方式:

  1. JDK动态代理: 针对实现了接口的类
  2. CGLIB代理: 针对没有实现接口的类

我们来系统性地讲解一下 Spring AOP 的实现机制,包括:

  1. JDK 动态代理
  2. CGLIB 动态代理
  3. 静态代理(对比说明)

🌟 一、Spring AOP 实现机制概述

Spring AOP(面向切面编程)是基于 代理模式 实现的,它通过在目标方法执行前后织入逻辑(增强),实现横切关注点(如日志、安全、事务等)的分离。

Spring AOP 仅支持方法级别的代理(即对方法进行增强),不支持字段、构造器等底层字节码增强(这要用 AspectJ)。


🧩 二、Spring AOP 的两种动态代理方式

1️⃣ JDK 动态代理(基于接口)

✅ 原理:

  • 基于 Java 的 java.lang.reflect.Proxy 类实现。
  • 必须要有接口,Spring 会为接口生成代理类。
  • 代理类在运行时实现接口,并将调用委托给 InvocationHandler。

📦 示例:

1
2
3
4
5
6
7
8
9
public interface UserService {
void addUser();
}

public class UserServiceImpl implements UserService {
public void addUser() {
System.out.println("添加用户");
}
}

Spring AOP 为 UserService 创建代理:

1
2
3
4
5
6
7
8
9
10
11
UserService proxy = (UserService) Proxy.newProxyInstance(
userService.getClass().getClassLoader(),
userService.getClass().getInterfaces(),
new InvocationHandler() {
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("前置增强");
Object result = method.invoke(userService, args);
System.out.println("后置增强");
return result;
}
});

🎯 特点:

特性 描述
是否依赖接口 ✅ 是
性能 较高(比 CGLIB 略快)
生成的代理类 实现接口

2️⃣ CGLIB 动态代理(基于子类)

✅ 原理:

  • 使用 CGLIB 库在运行时 生成目标类的子类,并重写其方法来实现增强。
  • 适用于 没有接口 的类,或者想对类本身增强而非接口。

🧪 示例:

1
2
3
4
5
public class UserService {
public void addUser() {
System.out.println("添加用户");
}
}

Spring 通过 CGLIB 生成子类:

1
2
3
4
5
6
7
8
9
10
11
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(UserService.class);
enhancer.setCallback(new MethodInterceptor() {
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
System.out.println("前置增强");
Object result = proxy.invokeSuper(obj, args);
System.out.println("后置增强");
return result;
}
});
UserService proxy = (UserService) enhancer.create();

🎯 特点:

特性 描述
是否依赖接口 ❌ 否
是否可代理 final 类/方法 ❌ 否(final 无法被继承/重写)
生成的代理类 子类

🏗 三、静态代理(对比动态代理)

✅ 原理:

  • 自己写一个代理类,实现相同接口,将方法调用委托给目标对象,并在调用前后手动添加增强逻辑。

🧪 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public interface UserService {
void addUser();
}

public class UserServiceImpl implements UserService {
public void addUser() {
System.out.println("添加用户");
}
}

// 静态代理类
public class UserServiceProxy implements UserService {
private UserService target;

public UserServiceProxy(UserService target) {
this.target = target;
}

public void addUser() {
System.out.println("前置增强");
target.addUser();
System.out.println("后置增强");
}
}

🎯 特点:

特性 描述
是否自动生成 ❌ 需手动编码
灵活性 差(每个类都要写一个代理类)
是否支持 AOP ❌ 无法动态织入多个切面

🔚 四、三者对比总结

特性 静态代理 JDK 动态代理 CGLIB 动态代理
是否依赖接口 ✅ 是 ✅ 是 ❌ 否
是否自动生成代理类 ❌ 手动编写 ✅ 运行时生成 ✅ 运行时生成
是否可代理类 ❌ 否(接口为主) ❌ 否(接口为主) ✅ 是(类的子类)
性能 一般 较高 略慢(但可接受)
是否支持 final 方法 ❌ 否

✅ Spring 如何选择代理方式?

  • 默认使用 JDK 动态代理(如果目标类实现了接口)。
  • 如果目标类没有接口,Spring 自动切换为 CGLIB。
  • 可以强制使用 CGLIB:
1
@EnableAspectJAutoProxy(proxyTargetClass = true)
AOP核心概念:
  • 切面(Aspect): 横切关注点的模块化
  • 连接点(Joinpoint): 程序执行中的特定点
  • 切点(Pointcut): 连接点的集合
  • 通知(Advice): 切面在特定连接点执行的代码
  • 目标对象(Target): 被代理的对象
  • 代理对象(Proxy): AOP框架创建的对象
AOP通知类型:

1. 前置通知(Before advice):在某个连接点(JoinPoint)之前执行的通知,但它不能阻止连接点之前的执行。在 ApplicationContext 中,可以使用 <aop:before> 元素在 <aop:aspect> 内部进行声明。

2. 后置通知(After advice):当某个连接点退出时执行的通知,无论是正常返回还是异常退出。在 ApplicationContext 中,可以使用 <aop:after> 元素在 <aop:aspect> 内部进行声明。

3. 返回后通知(After return advice):在某个连接点正常完成后执行的通知,不包括抛出异常的情况。在 ApplicationContext 中,可以使用 <aop:after-returning> 元素在 <aop:aspect> 内部进行声明。

4. 环绕通知(Around advice):包围一个连接点的通知,类似于 Web 中 Servlet 规范中的 FilterdoFilter 方法。可以在方法的调用前后完成自定义的行为,也可以选择不执行。在 ApplicationContext 中,可以使用 <aop:around> 元素在 <aop:aspect> 内部进行声明。

5. 抛出异常后通知(After throwing advice):在方法抛出异常退出时执行的通知。在 ApplicationContext 中,可以使用 <aop:after-throwing> 元素在 <aop:aspect> 内部进行声明。

JDK动态代理 vs. CGLIB动态代理

  • JDK动态代理
    • 基于接口实现
    • 使用Proxy.newProxyInstance()创建代理
    • 只能代理实现了接口的类
  • CGLIB动态代理
    • 基于继承实现
    • 使用Enhancer类创建代理
    • 可以代理普通类
    • 不能代理final类和方法

1.2 Spring核心注解详解

基础配置注解

1
2
3
4
5
6
@Configuration  // 表示这是一个配置类
@ComponentScan(basePackages = "com.example") // 组件扫描
@EnableAutoConfiguration // 启用自动配置
@SpringBootApplication // SpringBoot主类注解,包含上述三个
@Import(OtherConfig.class) // 导入其他配置类
@PropertySource("classpath:application.properties") // 加载属性文件

Bean定义注解

1
2
3
4
5
6
7
8
9
10
@Component  // 通用组件
@Service // 业务层组件
@Repository // 数据访问层组件
@Controller // 控制层组件
@RestController // RESTful控制器,相当于@Controller + @ResponseBody
@Bean // 方法级别,定义Bean
@Scope("singleton/prototype/request/session") // Bean作用域
@Lazy // 延迟初始化
@Primary // 优先注入
@Qualifier("beanName") // 指定注入的Bean名称

依赖注入注解

1
2
3
4
5
@Autowired  // 自动装配,可用于构造函数、方法、字段
@Resource // JSR-250标准,按名称注入
@Inject // JSR-330标准
@Value("${property.name}") // 注入配置值
@ConfigurationProperties(prefix = "app") // 绑定配置属性

生命周期注解

1
2
@PostConstruct  // 初始化方法
@PreDestroy // 销毁方法

AOP相关注解

1
2
3
4
5
6
7
@Aspect         // 声明切面
@Pointcut // 定义切点
@Before // 前置通知
@After // 后置通知
@AfterReturning // 返回后通知
@AfterThrowing // 异常通知
@Around // 环绕通知

1.3 Spring常见使用场景

场景1:服务层事务管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Service
@Transactional
public class UserService {

@Autowired
private UserRepository userRepository;

@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
public User createUser(User user) {
// 业务逻辑
return userRepository.save(user);
}

@Transactional(readOnly = true)
public User findById(Long id) {
return userRepository.findById(id);
}
}

场景2:缓存管理

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
public class ProductService {

@Cacheable(value = "products", key = "#id")
public Product getProduct(Long id) {
return productRepository.findById(id);
}

@CacheEvict(value = "products", key = "#product.id")
public Product updateProduct(Product product) {
return productRepository.save(product);
}
}

1.4 Spring常见面试问题

Q1: Spring IoC容器的初始化过程是怎样的?

A: Spring IoC容器初始化分为三个阶段:

  1. Resource定位: 定位配置文件
  2. BeanDefinition载入: 将配置信息转换为Spring内部数据结构
  3. BeanDefinition注册: 将BeanDefinition注册到IoC容器的HashMap中

具体流程:

  • 创建ApplicationContext
  • 加载配置元数据(XML、注解、Java配置)
  • 解析配置,创建BeanDefinition
  • 注册BeanDefinition到BeanDefinitionRegistry
  • 实例化非懒加载的单例Bean

Q2: Spring中的循环依赖是如何解决的?

A: Spring通过三级缓存解决循环依赖:

  1. singletonObjects: 一级缓存,存放完整的Bean实例
  2. earlySingletonObjects: 二级缓存,存放早期的Bean实例
  3. singletonFactories: 三级缓存,存放Bean工厂

解决过程:

  • A依赖B,B依赖A
  • 创建A时,将A的工厂放入三级缓存
  • A需要注入B,开始创建B
  • B需要注入A,从缓存中获取A的早期实例
  • B创建完成,A继续创建完成

Q3: Spring AOP的实现原理?

A: Spring AOP基于代理模式实现:

  1. JDK动态代理: 目标类实现接口时使用,基于反射机制
  2. CGLIB代理: 目标类没有接口时使用,基于字节码技术

代理创建过程:

  • Spring在Bean初始化后,检查是否需要AOP
  • 如果需要,创建代理对象替换原始Bean
  • 代理对象拦截方法调用,执行切面逻辑

二、Spring Boot深入解析

2.1 Spring Boot核心原理

自动配置原理: Spring Boot通过@EnableAutoConfiguration注解启用自动配置机制。

核心类分析:

  1. SpringBootApplication: 组合注解,包含@Configuration、@EnableAutoConfiguration、@ComponentScan
  2. AutoConfigurationImportSelector: 负责导入自动配置类
  3. spring.factories: META-INF/spring.factories文件定义自动配置类

自动配置流程:

  1. SpringBoot启动时扫描所有jar包下的META-INF/spring.factories文件
  2. 加载文件中定义的自动配置类
  3. 根据条件注解(@ConditionalOnClass等)判断是否生效
  4. 生效的配置类会创建相应的Bean

2.2 Spring Boot启动流程详解

SpringApplication.run()方法执行流程:

  1. 准备阶段
    • 创建SpringApplication实例
    • 确定应用类型(SERVLET、REACTIVE、NONE)
    • 加载ApplicationContextInitializer
    • 加载ApplicationListener
  2. 启动阶段
    • 启动计时器
    • 配置Headless模式
    • 获取并启动监听器
    • 准备环境(Environment)
    • 打印Banner
  3. 容器创建阶段
    • 创建ApplicationContext
    • 准备ApplicationContext
    • 刷新ApplicationContext
    • 刷新后处理
  4. 完成阶段
    • 停止计时器
    • 发布启动完成事件
    • 调用Runners

2.3 Spring Boot核心注解

启动类注解

1
2
3
4
5
6
7
8
9
10
@SpringBootApplication
// 等价于以下三个注解的组合
@Configuration
@EnableAutoConfiguration
@ComponentScan

@SpringBootConfiguration // Spring Boot配置类
@EnableScheduling // 启用定时任务
@EnableAsync // 启用异步处理
@EnableCaching // 启用缓存

条件注解

1
2
3
4
5
6
@ConditionalOnClass(DataSource.class)      // 类路径下存在指定类
@ConditionalOnMissingBean(DataSource.class) // 容器中不存在指定Bean
@ConditionalOnProperty(name = "app.enabled", havingValue = "true") // 属性匹配
@ConditionalOnWebApplication // Web应用环境
@ConditionalOnNotWebApplication // 非Web应用环境
@Profile("dev") // 激活的profile

配置属性注解

1
2
3
4
5
6
7
@ConfigurationProperties(prefix = "app.datasource")
public class DataSourceProperties {
private String url;
private String username;
private String password;
// getters and setters
}

2.4 Spring Boot实际应用场景

场景1:微服务架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@SpringBootApplication
@EnableEurekaClient
@EnableFeignClients
public class UserServiceApplication {
public static void main(String[] args) {
SpringApplication.run(UserServiceApplication.class, args);
}
}

@FeignClient(name = "order-service")
public interface OrderServiceClient {
@GetMapping("/orders/{userId}")
List<Order> getOrdersByUserId(@PathVariable Long userId);
}

场景2:数据访问层整合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@SpringBootApplication
@EnableJpaRepositories
public class Application {

@Bean
@Primary
@ConfigurationProperties("spring.datasource.primary")
public DataSourceProperties primaryDataSourceProperties() {
return new DataSourceProperties();
}

@Bean
@ConfigurationProperties("spring.datasource.secondary")
public DataSourceProperties secondaryDataSourceProperties() {
return new DataSourceProperties();
}
}

2.5 Spring Boot面试问题

Q1: Spring Boot的启动原理是什么?

A: Spring Boot启动原理核心在于自动配置:

  1. @SpringBootApplication组合了三个注解,其中@EnableAutoConfiguration是关键
  2. AutoConfigurationImportSelector会扫描所有引入的jar包,查找其META-INF/spring.factories文件中org.springframework.boot.autoconfigure.EnableAutoConfiguration键所对应的配置类全限定名列表
  3. 根据条件注解判断哪些自动配置类生效
  4. 生效的配置类会向容器中注册相应的Bean

Q2: Spring Boot如何实现自动配置?

A: 自动配置通过以下机制实现:

  1. 条件注解: @ConditionalOnClass、@ConditionalOnBean等判断配置是否生效
  2. 配置文件: spring.factories定义自动配置类列表
  3. 配置属性: @ConfigurationProperties绑定配置文件中的属性
  4. 默认配置: 提供合理的默认值,用户可覆盖

Q3: Spring Boot Starter的工作原理?

A: Starter是Spring Boot自动配置的载体:

  1. 依赖管理: 通过Maven/Gradle引入相关依赖
  2. 自动配置: 包含AutoConfiguration类
  3. 属性绑定: 提供ConfigurationProperties类
  4. 条件装配: 使用条件注解控制Bean的创建

三、Spring MVC深入解析

3.1 Spring MVC核心组件

DispatcherServlet处理流程:

  1. 接收请求: DispatcherServlet接收HTTP请求
  2. 查找Handler: HandlerMapping查找处理请求的Handler
  3. 获取HandlerAdapter: 获取能够执行Handler的HandlerAdapter
  4. 执行Handler: HandlerAdapter执行Handler(Controller方法)
  5. 处理结果: 返回ModelAndView
  6. 视图解析: ViewResolver解析视图名称
  7. 渲染视图: View渲染模型数据
  8. 返回响应: 响应结果返回给客户端

核心组件详解:

  • DispatcherServlet: 前端控制器,统一处理请求
  • HandlerMapping: 处理器映射器,URL与Handler的映射
  • HandlerAdapter: 处理器适配器,执行Handler
  • Handler: 处理器,即Controller
  • ViewResolver: 视图解析器
  • View: 视图

3.2 Spring MVC核心注解

控制器注解

1
2
3
4
5
6
7
8
@Controller             // 标记控制器类
@RestController // RESTful控制器
@RequestMapping("/api") // 请求映射
@GetMapping("/users") // GET请求映射
@PostMapping("/users") // POST请求映射
@PutMapping("/users/{id}") // PUT请求映射
@DeleteMapping("/users/{id}") // DELETE请求映射
@PatchMapping("/users/{id}") // PATCH请求映射

参数绑定注解

1
2
3
4
5
6
7
8
@RequestParam("name")           // 请求参数
@PathVariable("id") // 路径变量
@RequestBody // 请求体
@RequestHeader("Content-Type") // 请求头
@CookieValue("sessionId") // Cookie值
@ModelAttribute // 模型属性
@SessionAttribute // Session属性
@RequestPart // 文件上传

响应处理注解

1
2
3
4
5
@ResponseBody              // 响应体
@ResponseStatus(HttpStatus.CREATED) // 响应状态码
@ExceptionHandler // 异常处理
@ControllerAdvice // 全局控制器增强
@RestControllerAdvice // RESTful全局异常处理

3.3 Spring MVC实际应用场景

场景1:RESTful API开发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@RestController
@RequestMapping("/api/users")
@Validated
public class UserController {

@Autowired
private UserService userService;

@GetMapping
public ResponseEntity<PageResult<User>> getUsers(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword) {

PageResult<User> result = userService.findUsers(page, size, keyword);
return ResponseEntity.ok(result);
}

@PostMapping
public ResponseEntity<User> createUser(@Valid @RequestBody UserCreateRequest request) {
User user = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}

@PutMapping("/{id}")
public ResponseEntity<User> updateUser(
@PathVariable Long id,
@Valid @RequestBody UserUpdateRequest request) {
User user = userService.updateUser(id, request);
return ResponseEntity.ok(user);
}

@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return ResponseEntity.noContent().build();
}
}

场景2:全局异常处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(ValidationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidationException(ValidationException e) {
return ErrorResponse.builder()
.code("VALIDATION_ERROR")
.message(e.getMessage())
.timestamp(LocalDateTime.now())
.build();
}

@ExceptionHandler(EntityNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleEntityNotFoundException(EntityNotFoundException e) {
return ErrorResponse.builder()
.code("ENTITY_NOT_FOUND")
.message(e.getMessage())
.timestamp(LocalDateTime.now())
.build();
}

@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleGenericException(Exception e) {
log.error("Unexpected error occurred", e);
return ErrorResponse.builder()
.code("INTERNAL_ERROR")
.message("An unexpected error occurred")
.timestamp(LocalDateTime.now())
.build();
}
}

场景3:文件上传处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@RestController
@RequestMapping("/api/files")
public class FileController {

@PostMapping("/upload")
public ResponseEntity<FileUploadResponse> uploadFile(
@RequestPart("file") MultipartFile file,
@RequestParam(required = false) String description) {

// 文件类型验证
if (!isValidFileType(file.getContentType())) {
throw new InvalidFileTypeException("Invalid file type");
}

// 文件大小验证
if (file.getSize() > MAX_FILE_SIZE) {
throw new FileSizeExceededException("File size exceeds limit");
}

String fileName = fileService.saveFile(file, description);

FileUploadResponse response = FileUploadResponse.builder()
.fileName(fileName)
.originalName(file.getOriginalFilename())
.size(file.getSize())
.contentType(file.getContentType())
.uploadTime(LocalDateTime.now())
.build();

return ResponseEntity.ok(response);
}
}

3.4 Spring MVC面试问题

Q1: Spring MVC的执行流程是怎样的?

A: Spring MVC的执行流程如下:

  1. 用户发送请求到DispatcherServlet
  2. DispatcherServlet调用HandlerMapping查找Handler
  3. HandlerMapping返回HandlerExecutionChain(包含Handler和拦截器)
  4. DispatcherServlet调用HandlerAdapter执行Handler
  5. Handler执行完成后返回ModelAndView
  6. DispatcherServlet调用ViewResolver解析视图名称
  7. ViewResolver返回View对象
  8. DispatcherServlet调用View的render方法渲染视图
  9. 响应结果返回给用户

Q2: Spring MVC中的拦截器是如何工作的?

A: 拦截器基于AOP思想,在Handler执行前后进行处理:

  1. HandlerInterceptor接口提供三个方法:
    • preHandle(): 前置处理,返回false则中断请求
    • postHandle(): 后置处理,Handler执行后调用
    • afterCompletion(): 完成处理,视图渲染后调用
  2. 执行顺序
    • 多个拦截器按配置顺序执行preHandle()
    • 按相反顺序执行postHandle()和afterCompletion()

Q3: @RequestBody和@ResponseBody的工作原理?

A: 这两个注解基于HttpMessageConverter工作:

  1. @RequestBody:
    • 使用HttpMessageConverter将HTTP请求体转换为Java对象
    • 常用的转换器:MappingJackson2HttpMessageConverter处理JSON
  2. @ResponseBody:
    • 使用HttpMessageConverter将Java对象转换为HTTP响应体
    • 根据Accept头选择合适的转换器

四、MyBatis深入解析

4.1 MyBatis核心原理

MyBatis架构分析: MyBatis采用分层架构设计:

  1. API接口层: 提供给外部使用的接口API
  2. 数据处理层: 参数映射、SQL解析、结果映射
  3. 基础支撑层: 连接管理、事务管理、配置加载、缓存处理

MyBatis核心组件:

  • SqlSessionFactory: 会话工厂,负责创建SqlSession
  • SqlSession: 会话,执行SQL的核心接口
  • Executor: 执行器,实际执行SQL
  • StatementHandler: 语句处理器,处理SQL语句
  • ParameterHandler: 参数处理器,处理SQL参数
  • ResultSetHandler: 结果集处理器,处理查询结果
  • MappedStatement: 映射语句,封装SQL配置信息

4.2 MyBatis执行流程详解

SQL执行流程:

  1. 解析配置: 解析mybatis-config.xml和Mapper XML文件
  2. 创建会话: 通过SqlSessionFactory创建SqlSession
  3. 获取Mapper: 通过动态代理创建Mapper接口实例
  4. 执行SQL: 调用Mapper方法,转换为MappedStatement执行
  5. 参数处理: ParameterHandler处理输入参数
  6. 执行查询: StatementHandler执行SQL语句
  7. 结果映射: ResultSetHandler处理结果集
  8. 返回结果: 将结果返回给调用方

动态代理原理: MyBatis使用JDK动态代理为Mapper接口创建代理对象:

1
2
3
4
5
6
7
8
// MapperProxy实现InvocationHandler
public class MapperProxy<T> implements InvocationHandler {
@Override
public Object invoke(Object proxy, Method method, Object[] args) {
// 根据方法签名找到对应的MappedStatement
// 执行SQL并返回结果
}
}

4.3 MyBatis核心注解

基础映射注解

1
2
3
4
5
6
7
8
9
10
11
12
@Select("SELECT * FROM users WHERE id = #{id}")
User findById(@Param("id") Long id);

@Insert("INSERT INTO users(name, email) VALUES(#{name}, #{email})")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insert(User user);

@Update("UPDATE users SET name = #{name} WHERE id = #{id}")
int update(User user);

@Delete("DELETE FROM users WHERE id = #{id}")
int delete(@Param("id") Long id);

高级映射注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 结果映射
@Results({
@Result(column = "user_id", property = "id"),
@Result(column = "user_name", property = "name"),
@Result(column = "create_time", property = "createTime")
})
@Select("SELECT user_id, user_name, create_time FROM users")
List<User> findAll();

// 一对一映射
@One(select = "findUserById")
@Result(column = "user_id", property = "user")
Order findOrderById(@Param("id") Long id);

// 一对多映射
@Many(select = "findOrdersByUserId")
@Result(column = "id", property = "orders")
User findUserWithOrders(@Param("id") Long id);

动态SQL注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@SelectProvider(type = UserSqlProvider.class, method = "findUsers")
List<User> findUsers(@Param("name") String name, @Param("email") String email);

public class UserSqlProvider {
public String findUsers(Map<String, Object> params) {
return new SQL() {{
SELECT("*");
FROM("users");
if (params.get("name") != null) {
WHERE("name LIKE CONCAT('%', #{name}, '%')");
}
if (params.get("email") != null) {
WHERE("email = #{email}");
}
}}.toString();
}
}

4.4 MyBatis缓存机制

一级缓存(默认开启):

  • 作用域:SqlSession级别
  • 生命周期:与SqlSession相同
  • 存储:HashMap结构,key为CacheKey

二级缓存(需要配置):

  • 作用域:Mapper级别
  • 生命周期:与应用程序相同
  • 配置:@CacheNamespace注解或标签
1
2
3
4
5
6
7
8
9
@CacheNamespace(
eviction = LRU.class,
flushInterval = 60000,
size = 1024,
readWrite = true
)
public interface UserMapper {
// mapper methods
}

4.5 MyBatis实际应用场景

场景1:复杂查询场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Mapper
public interface OrderMapper {

// 分页查询订单
@Select("""
SELECT o.*, u.name as user_name, u.email as user_email
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE o.status = #{status}
AND o.create_time BETWEEN #{startTime} AND #{endTime}
ORDER BY o.create_time DESC
LIMIT #{offset}, #{limit}
""")
@Results({
@Result(column = "id", property = "id"),
@Result(column = "user_id", property = "userId"),
@Result(column = "user_name", property = "user.name"),
@Result(column = "user_email", property = "user.email")
})
List<OrderVO> findOrdersWithUser(
@Param("status") String status,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
@Param("offset") int offset,
@Param("limit") int limit
);

// 统计查询
@Select("""
SELECT
COUNT(*) as total_count,
SUM(amount) as total_amount,
AVG(amount) as avg_amount
FROM orders
WHERE status = #{status}
AND create_time >= #{startTime}
""")
OrderStatistics getOrderStatistics(
@Param("status") String status,
@Param("startTime") LocalDateTime startTime
);
}

场景2:批量操作场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Mapper
public interface BatchMapper {

// 批量插入
@Insert("""
<script>
INSERT INTO users (name, email, create_time) VALUES
<foreach collection="users" item="user" separator=",">
(#{user.name}, #{user.email}, #{user.createTime})
</foreach>
</script>
""")
int batchInsertUsers(@Param("users") List<User> users);

// 批量更新
@Update("""
<script>
<foreach collection="users" item="user" separator=";">
UPDATE users SET
name = #{user.name},
email = #{user.email},
update_time = NOW()
WHERE id = #{user.id}
</foreach>
</script>
""")
int batchUpdateUsers(@Param("users") List<User> users);
}

场景3:动态SQL场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
@SelectProvider(type = UserSqlProvider.class, method = "searchUsers")
List<User> searchUsers(UserSearchCriteria criteria);

public class UserSqlProvider {

public String searchUsers(UserSearchCriteria criteria) {
return new SQL() {{
SELECT("u.*, p.name as profile_name");
FROM("users u");
LEFT_OUTER_JOIN("user_profiles p ON u.id = p.user_id");

if (StringUtils.hasText(criteria.getName())) {
WHERE("u.name LIKE CONCAT('%', #{name}, '%')");
}

if (StringUtils.hasText(criteria.getEmail())) {
WHERE("u.email = #{email}");
}

if (criteria.getMinAge() != null) {
WHERE("u.age >= #{minAge}");
}

if (criteria.getMaxAge() != null) {
WHERE("u.age <= #{maxAge}");
}

if (criteria.getCreateTimeStart() != null) {
WHERE("u.create_time >= #{createTimeStart}");
}

if (criteria.getCreateTimeEnd() != null) {
WHERE("u.create_time <= #{createTimeEnd}");
}

if (CollectionUtils.isNotEmpty(criteria.getStatuses())) {
WHERE("u.status IN (" +
criteria.getStatuses().stream()
.map(s -> "'" + s + "'")
.collect(Collectors.joining(",")) + ")");
}

// 排序
if (StringUtils.hasText(criteria.getSortField())) {
if ("desc".equalsIgnoreCase(criteria.getSortDirection())) {
ORDER_BY("u." + criteria.getSortField() + " DESC");
} else {
ORDER_BY("u." + criteria.getSortField() + " ASC");
}
} else {
ORDER_BY("u.create_time DESC");
}

}}.toString();
}
}

4.6 MyBatis性能优化

优化策略1:合理使用缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 开启二级缓存,设置合理的缓存策略
@CacheNamespace(
eviction = LRU.class, // 缓存回收策略
flushInterval = 300000, // 缓存刷新间隔(5分钟)
size = 1024, // 缓存大小
readWrite = true, // 读写缓存
blocking = false // 非阻塞缓存
)
public interface ProductMapper {

// 对于频繁查询且变化不大的数据使用缓存
@Select("SELECT * FROM products WHERE category_id = #{categoryId}")
@Options(useCache = true)
List<Product> findByCategory(@Param("categoryId") Long categoryId);

// 对于实时性要求高的数据禁用缓存
@Select("SELECT * FROM products WHERE id = #{id}")
@Options(useCache = false)
Product findRealTimeById(@Param("id") Long id);
}

优化策略2:批量操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 使用批量插入替代循环插入
@Insert("""
<script>
INSERT INTO order_items (order_id, product_id, quantity, price) VALUES
<foreach collection="items" item="item" separator=",">
(#{item.orderId}, #{item.productId}, #{item.quantity}, #{item.price})
</foreach>
</script>
""")
int batchInsertOrderItems(@Param("items") List<OrderItem> items);

// 批量更新优化
public void batchUpdateProducts(List<Product> products) {
try (SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH)) {
ProductMapper mapper = sqlSession.getMapper(ProductMapper.class);
for (Product product : products) {
mapper.updateProduct(product);
}
sqlSession.commit();
}
}

优化策略3:延迟加载

1
2
3
4
5
6
7
8
9
10
11
12
13
@ResultMap("userResultMap")
@Select("SELECT * FROM users WHERE id = #{id}")
User findUserById(@Param("id") Long id);

// 在ResultMap中配置延迟加载
<resultMap id="userResultMap" type="User">
<id column="id" property="id"/>
<result column="name" property="name"/>
<collection property="orders"
select="findOrdersByUserId"
column="id"
fetchType="lazy"/>
</resultMap>

4.7 MyBatis常见问题与解决方案

问题1:N+1查询问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 问题:会产生N+1次查询
@Select("SELECT * FROM users")
List<User> findAllUsers();

@Select("SELECT * FROM orders WHERE user_id = #{userId}")
List<Order> findOrdersByUserId(@Param("userId") Long userId);

// 解决方案1:使用联表查询
@Select("""
SELECT u.*, o.id as order_id, o.amount, o.status as order_status
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
""")
@Results({
@Result(column = "id", property = "id"),
@Result(column = "name", property = "name"),
@Result(column = "order_id", property = "orders.id"),
@Result(column = "amount", property = "orders.amount"),
@Result(column = "order_status", property = "orders.status")
})
List<User> findUsersWithOrders();

// 解决方案2:使用批量查询
public List<User> findUsersWithOrdersOptimized(List<Long> userIds) {
List<User> users = userMapper.findUsersByIds(userIds);
if (!users.isEmpty()) {
List<Long> ids = users.stream().map(User::getId).collect(Collectors.toList());
List<Order> orders = orderMapper.findOrdersByUserIds(ids);

// 手动组装数据
Map<Long, List<Order>> orderMap = orders.stream()
.collect(Collectors.groupingBy(Order::getUserId));

users.forEach(user -> user.setOrders(orderMap.get(user.getId())));
}
return users;
}

问题2:大数据量查询内存溢出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 问题:一次性加载大量数据导致内存溢出
@Select("SELECT * FROM large_table")
List<LargeData> findAllData(); // 可能导致OOM

// 解决方案1:分页查询
public List<LargeData> findAllDataWithPaging() {
List<LargeData> allData = new ArrayList<>();
int pageSize = 1000;
int offset = 0;

List<LargeData> pageData;
do {
pageData = mapper.findDataWithLimit(offset, pageSize);
allData.addAll(pageData);
offset += pageSize;
} while (pageData.size() == pageSize);

return allData;
}

// 解决方案2:使用游标查询
@Select("SELECT * FROM large_table WHERE process_status = 'PENDING'")
@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = 1000)
Cursor<LargeData> findPendingDataCursor();

public void processLargeData() {
try (Cursor<LargeData> cursor = mapper.findPendingDataCursor()) {
cursor.forEach(this::processData);
}
}

问题3:SQL注入防护

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 危险:直接拼接SQL,容易SQL注入
@Select("SELECT * FROM users WHERE name = '${name}'") // 错误示例
List<User> findByNameUnsafe(@Param("name") String name);

// 安全:使用参数绑定
@Select("SELECT * FROM users WHERE name = #{name}")
List<User> findByNameSafe(@Param("name") String name);

// 动态排序的安全处理
@SelectProvider(type = UserSqlProvider.class, method = "findUsersWithSort")
List<User> findUsersWithSort(@Param("sortField") String sortField,
@Param("sortDirection") String sortDirection);

public class UserSqlProvider {
private static final Set<String> ALLOWED_SORT_FIELDS =
Set.of("id", "name", "email", "create_time", "update_time");

public String findUsersWithSort(Map<String, Object> params) {
String sortField = (String) params.get("sortField");
String sortDirection = (String) params.get("sortDirection");

// 白名单验证
if (!ALLOWED_SORT_FIELDS.contains(sortField)) {
sortField = "id";
}

if (!"DESC".equalsIgnoreCase(sortDirection)) {
sortDirection = "ASC";
}

return "SELECT * FROM users ORDER BY " + sortField + " " + sortDirection;
}
}

4.8 MyBatis面试高频问题

Q1: MyBatis的执行流程是怎样的?

A: MyBatis的执行流程包括以下步骤:

  1. 配置解析: 解析mybatis-config.xml配置文件和Mapper XML文件,创建Configuration对象
  2. SqlSessionFactory创建: 根据Configuration创建SqlSessionFactory
  3. SqlSession创建: 通过SqlSessionFactory.openSession()创建SqlSession
  4. Mapper获取: 通过SqlSession.getMapper()获取Mapper接口的代理对象
  5. 方法调用: 调用Mapper接口方法,通过动态代理转换为SQL执行
  6. SQL执行: 通过Executor执行SQL,包括参数处理、语句执行、结果映射
  7. 结果返回: 将执行结果返回给调用方

Q2: MyBatis的一级缓存和二级缓存有什么区别?

A: 两级缓存的主要区别:

一级缓存(默认开启):

  • 作用域:SqlSession级别
  • 生命周期:与SqlSession相同,SqlSession关闭时缓存清空
  • 存储结构:HashMap,key为CacheKey(由SQL、参数、分页等组成)
  • 失效条件:执行update、insert、delete操作或手动清空

二级缓存(需要配置):

  • 作用域:Mapper级别,多个SqlSession可以共享
  • 生命周期:与应用程序相同
  • 存储结构:可配置(HashMap、LRU、FIFO等)
  • 配置方式:@CacheNamespace注解或标签
  • 注意事项:需要序列化,可能存在脏读问题

Q3: MyBatis如何防止SQL注入?

A: MyBatis通过以下方式防止SQL注入:

  1. 参数绑定: 使用#{}而不是${}
    • #{}:预编译处理,参数作为占位符传递
    • ${}:字符串替换,直接拼接到SQL中(危险)
  2. 类型检查: MyBatis会对参数类型进行检查
  3. 白名单验证: 对于动态排序等场景,使用白名单验证
1
2
3
4
5
6
7
// 安全的写法
@Select("SELECT * FROM users WHERE name = #{name} AND age > #{age}")
List<User> findUsers(@Param("name") String name, @Param("age") Integer age);

// 危险的写法(避免使用)
@Select("SELECT * FROM users WHERE name = '${name}'")
List<User> findUsersUnsafe(@Param("name") String name);

Q4: MyBatis中#{}和${}的区别?

A: 两者的主要区别:

#{}(推荐使用):

  • 预编译处理,生成PreparedStatement
  • 参数会被处理为占位符?
  • 可以防止SQL注入
  • 会进行类型转换
  • 适用于参数值传递

${}(谨慎使用):

  • 字符串替换,直接拼接到SQL中
  • 不会进行预编译
  • 存在SQL注入风险
  • 不会进行类型转换
  • 适用于动态表名、列名等场景

Q5: MyBatis的动态SQL是如何实现的?

A: MyBatis的动态SQL通过以下标签实现:

  1. if标签: 条件判断
  2. choose/when/otherwise: 类似switch-case
  3. where标签: 智能处理WHERE条件
  4. set标签: 智能处理SET语句
  5. foreach标签: 循环处理
  6. trim标签: 去除多余的字符

实现原理:

  • 使用OGNL表达式进行条件判断
  • 在SQL解析阶段根据参数值动态生成SQL
  • 通过SqlNode树结构表示动态SQL
  • 在执行时遍历SqlNode树生成最终SQL

五、框架整合与最佳实践

5.1 Spring Boot + MyBatis整合

完整配置示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 主启动类
@SpringBootApplication
@MapperScan("com.example.mapper")
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}

// 数据源配置
@Configuration
public class DataSourceConfig {

@Bean
@Primary
@ConfigurationProperties("spring.datasource.primary")
public DataSourceProperties primaryDataSourceProperties() {
return new DataSourceProperties();
}

@Bean
@Primary
public DataSource primaryDataSource() {
return primaryDataSourceProperties()
.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
}

@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
factory.setDataSource(dataSource);

// MyBatis配置
org.apache.ibatis.session.Configuration config =
new org.apache.ibatis.session.Configuration();
config.setMapUnderscoreToCamelCase(true);
config.setLogImpl(Slf4jImpl.class);
config.setCacheEnabled(true);
config.setLazyLoadingEnabled(true);
config.setAggressiveLazyLoading(false);

factory.setConfiguration(config);
return factory.getObject();
}
}

事务管理配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
@Service
@Transactional
public class OrderService {

@Autowired
private OrderMapper orderMapper;

@Autowired
private OrderItemMapper orderItemMapper;

@Autowired
private ProductMapper productMapper;

@Transactional(rollbackFor = Exception.class)
public Order createOrder(CreateOrderRequest request) {
// 1. 创建订单
Order order = new Order();
order.setUserId(request.getUserId());
order.setStatus("PENDING");
order.setCreateTime(LocalDateTime.now());
orderMapper.insert(order);

// 2. 创建订单项
BigDecimal totalAmount = BigDecimal.ZERO;
List<OrderItem> orderItems = new ArrayList<>();

for (CreateOrderItemRequest itemRequest : request.getItems()) {
// 检查库存
Product product = productMapper.findById(itemRequest.getProductId());
if (product == null) {
throw new ProductNotFoundException("Product not found: " + itemRequest.getProductId());
}

if (product.getStock() < itemRequest.getQuantity()) {
throw new InsufficientStockException("Insufficient stock for product: " + product.getName());
}

// 减库存
productMapper.decreaseStock(itemRequest.getProductId(), itemRequest.getQuantity());

// 创建订单项
OrderItem orderItem = new OrderItem();
orderItem.setOrderId(order.getId());
orderItem.setProductId(itemRequest.getProductId());
orderItem.setQuantity(itemRequest.getQuantity());
orderItem.setPrice(product.getPrice());
orderItems.add(orderItem);

totalAmount = totalAmount.add(
product.getPrice().multiply(BigDecimal.valueOf(itemRequest.getQuantity()))
);
}

// 3. 批量插入订单项
if (!orderItems.isEmpty()) {
orderItemMapper.batchInsert(orderItems);
}

// 4. 更新订单总金额
order.setTotalAmount(totalAmount);
orderMapper.updateAmount(order.getId(), totalAmount);

return order;
}

@Transactional(readOnly = true)
public OrderDetailVO getOrderDetail(Long orderId) {
Order order = orderMapper.findById(orderId);
if (order == null) {
throw new OrderNotFoundException("Order not found: " + orderId);
}

List<OrderItem> orderItems = orderItemMapper.findByOrderId(orderId);

return OrderDetailVO.builder()
.order(order)
.items(orderItems)
.build();
}
}

5.2 统一异常处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

@ExceptionHandler(ValidationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleValidationException(ValidationException e) {
log.warn("Validation error: {}", e.getMessage());
return ApiResponse.error("VALIDATION_ERROR", e.getMessage());
}

@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleMethodArgumentNotValid(MethodArgumentNotValidException e) {
List<String> errors = e.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.toList());

return ApiResponse.error("VALIDATION_ERROR", String.join(", ", errors));
}

@ExceptionHandler(DataIntegrityViolationException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public ApiResponse<Void> handleDataIntegrityViolation(DataIntegrityViolationException e) {
log.error("Data integrity violation", e);
return ApiResponse.error("DATA_CONFLICT", "Data conflict occurred");
}

@ExceptionHandler(OptimisticLockingFailureException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public ApiResponse<Void> handleOptimisticLockingFailure(OptimisticLockingFailureException e) {
log.warn("Optimistic locking failure: {}", e.getMessage());
return ApiResponse.error("OPTIMISTIC_LOCK_ERROR", "Resource has been modified by another user");
}

@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> handleGenericException(Exception e) {
log.error("Unexpected error occurred", e);
return ApiResponse.error("INTERNAL_ERROR", "An unexpected error occurred");
}
}

5.3 接口文档与验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// 统一响应格式
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
private boolean success;
private String code;
private String message;
private T data;
private Long timestamp;

public static <T> ApiResponse<T> success(T data) {
return ApiResponse.<T>builder()
.success(true)
.code("SUCCESS")
.data(data)
.timestamp(System.currentTimeMillis())
.build();
}

public static <T> ApiResponse<T> error(String code, String message) {
return ApiResponse.<T>builder()
.success(false)
.code(code)
.message(message)
.timestamp(System.currentTimeMillis())
.build();
}
}

// 请求参数验证
@Data
@Valid
public class CreateUserRequest {

@NotBlank(message = "用户名不能为空")
@Size(min = 2, max = 20, message = "用户名长度必须在2-20个字符之间")
@Pattern(regexp = "^[a-zA-Z0-9_\\u4e00-\\u9fa5]+$", message = "用户名只能包含字母、数字、下划线和中文")
private String username;

@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;

@NotBlank(message = "密码不能为空")
@Size(min = 8, max = 20, message = "密码长度必须在8-20个字符之间")
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]+$",
message = "密码必须包含大小写字母、数字和特殊字符"
)
private String password;

@NotNull(message = "年龄不能为空")
@Min(value = 1, message = "年龄必须大于0")
@Max(value = 150, message = "年龄不能超过150")
private Integer age;

@NotEmpty(message = "角色不能为空")
private List<@NotBlank(message = "角色名称不能为空") String> roles;
}

5.4 性能监控与优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// 性能监控切面
@Aspect
@Component
@Slf4j
public class PerformanceMonitorAspect {

@Around("@annotation(org.springframework.web.bind.annotation.RequestMapping) || " +
"@annotation(org.springframework.web.bind.annotation.GetMapping) || " +
"@annotation(org.springframework.web.bind.annotation.PostMapping) || " +
"@annotation(org.springframework.web.bind.annotation.PutMapping) || " +
"@annotation(org.springframework.web.bind.annotation.DeleteMapping)")
public Object monitorPerformance(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
String methodName = joinPoint.getSignature().toShortString();

try {
Object result = joinPoint.proceed();
long endTime = System.currentTimeMillis();
long executionTime = endTime - startTime;

if (executionTime > 1000) { // 超过1秒记录警告
log.warn("Slow API detected: {} took {}ms", methodName, executionTime);
} else {
log.info("API performance: {} took {}ms", methodName, executionTime);
}

return result;
} catch (Exception e) {
long endTime = System.currentTimeMillis();
log.error("API error: {} took {}ms, error: {}",
methodName, endTime - startTime, e.getMessage());
throw e;
}
}
}

// 数据库连接池监控
@Component
public class DataSourceHealthIndicator implements HealthIndicator {

@Autowired
private DataSource dataSource;

@Override
public Health health() {
try {
if (dataSource instanceof HikariDataSource) {
HikariDataSource hikariDataSource = (HikariDataSource) dataSource;
HikariPoolMXBean poolBean = hikariDataSource.getHikariPoolMXBean();

return Health.up()
.withDetail("database", "MySQL")
.withDetail("activeConnections", poolBean.getActiveConnections())
.withDetail("idleConnections", poolBean.getIdleConnections())
.withDetail("totalConnections", poolBean.getTotalConnections())
.withDetail("threadsAwaitingConnection", poolBean.getThreadsAwaitingConnection())
.build();
}

try (Connection connection = dataSource.getConnection()) {
return Health.up()
.withDetail("database", connection.getMetaData().getDatabaseProductName())
.build();
}
} catch (Exception e) {
return Health.down()
.withDetail("error", e.getMessage())
.build();
}
}
}

5.5 高频面试综合题

Q1: 在微服务架构中,如何设计一个高并发的订单系统?

A: 设计高并发订单系统需要考虑以下几个方面:

1. 架构设计:

  • 使用分布式架构,订单服务、库存服务、支付服务分离
  • 引入消息队列处理异步任务
  • 使用Redis做缓存和分布式锁
  • 数据库读写分离,分库分表

2. 并发控制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Service
public class OrderService {

@Autowired
private RedisTemplate<String, String> redisTemplate;

@Transactional(rollbackFor = Exception.class)
public Order createOrder(CreateOrderRequest request) {
String lockKey = "order:lock:" + request.getUserId();

// 分布式锁防止重复下单
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(30));

if (!locked) {
throw new OrderCreationException("Order creation in progress");
}

try {
// 订单创建逻辑
return processOrder(request);
} finally {
redisTemplate.delete(lockKey);
}
}
}

3. 性能优化:

  • 使用批量操作减少数据库交互
  • 合理使用缓存策略
  • 异步处理非核心业务逻辑
  • 数据库连接池优化

Q2: 如何处理分布式事务?

A: 分布式事务处理方案:

1. 2PC/3PC协议:

  • 强一致性,但性能较差
  • 适用于对一致性要求极高的场景

2. TCC模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@TccTransaction
public class OrderTccService {

public void tryCreateOrder(CreateOrderRequest request) {
// Try阶段:预留资源
orderService.reserveOrder(request);
stockService.reserveStock(request.getItems());
paymentService.reservePayment(request.getPaymentInfo());
}

public void confirmCreateOrder(CreateOrderRequest request) {
// Confirm阶段:确认操作
orderService.confirmOrder(request);
stockService.confirmStock(request.getItems());
paymentService.confirmPayment(request.getPaymentInfo());
}

public void cancelCreateOrder(CreateOrderRequest request) {
// Cancel阶段:回滚操作
orderService.cancelOrder(request);
stockService.cancelStock(request.getItems());
paymentService.cancelPayment(request.getPaymentInfo());
}
}

3. 消息队列最终一致性:

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class OrderEventService {

@EventListener
@Async
public void handleOrderCreated(OrderCreatedEvent event) {
// 异步处理订单相关业务
notificationService.sendOrderNotification(event.getOrder());
inventoryService.updateInventory(event.getOrderItems());
pointsService.addPoints(event.getUserId(), event.getAmount());
}
}

Q3: Spring Boot应用如何优化启动速度?

A: Spring Boot启动优化策略:

1. 依赖优化:

  • 移除不必要的依赖
  • 使用spring-boot-starter-web替代完整的spring-web
  • 避免引入大量自动配置类

2. 配置优化:

1
2
3
4
5
6
7
8
9
10
spring:
main:
lazy-initialization: true # 启用懒加载
jpa:
hibernate:
ddl-auto: none # 禁用DDL自动生成
show-sql: false # 禁用SQL日志
devtools:
restart:
enabled: false # 生产环境禁用热重启

3. JVM优化:

1
2
3
4
5
6
java -XX:+UnlockExperimentalVMOptions 
-XX:+UseZGC
-XX:+UseTransparentHugePages
-XX:MaxMetaspaceSize=256m
-Xms512m -Xmx1024m
-jar application.jar

4. 代码优化:

  • 使用@Lazy注解延迟Bean初始化
  • 避免在@PostConstruct中执行耗时操作
  • 合理使用@ConditionalOn*注解

好的,这是一份详细的解释,我们来逐个分析这些 Java 虚拟机(JVM)参数:

1
2
java -XX:+UnlockExperimentalVMOptions -XX:+UseZGC -XX:+UseTransparentHugePages -XX:MaxMetaspaceSize=256m -Xms512m -Xmx1024m -jar application.jar

  • java: 这是启动 Java 程序的命令。

内存管理和垃圾回收

这部分参数主要用于优化 JVM 的内存使用和垃圾回收机制,以获得更好的性能。

  • -XX:+UnlockExperimentalVMOptions: 这个参数是用来解锁实验性(experimental)的 JVM 选项。ZGC 曾经是实验性功能,所以需要这个参数才能使用。
  • -XX:+UseZGC: 这条命令是启用 Z 垃圾回收器(Z Garbage Collector)。ZGC 是一种为超大堆内存(从几GB到几十TB)设计的、低延迟的垃圾回收器。它的主要目标是在垃圾回收时,尽量减少应用程序的停顿时间,这对于需要极低延迟的应用程序非常重要。
  • -XX:+UseTransparentHugePages: 这个参数开启了透明大页(Transparent Huge Pages)的支持。在操作系统层面,通常内存是以 4KB 的小页来管理的。而大页(通常是 2MB 或 1GB)可以减少 CPU 在内存管理上的开销,从而提升性能,尤其对于内存占用较大的应用效果更明显。
  • -XX:MaxMetaspaceSize=256m: 这个参数设置了元空间(Metaspace)的最大大小为 256MB。元空间是 JVM 存储类的元数据(如类的名称、方法、字段等信息)的地方。在 Java 8 之后,它取代了之前的“永久代”(PermGen)。
  • -Xms512m: 这条命令设定了 JVM 堆的初始内存为 512MB。堆是用来存放 Java 对象的地方。-Xms 决定了 JVM 启动时会申请的最小内存。
  • -Xmx1024m: 这条命令设定了 JVM 堆的最大内存为 1024MB(即 1GB)。这是 JVM 运行时可以使用的最大内存。当堆内存用尽时,JVM 会触发垃圾回收,如果依然不足,就会抛出 OutOfMemoryError

好的,我们来更详细地聊聊 JVM 堆内存的结构,特别是分代设计和具体的划分比例。


堆内存的传统分代结构

在绝大多数的垃圾回收器中(例如 CMS、G1 之前的串行和并行 GC),堆内存被分为以下几个代(Generation):

1. 年轻代(Young Generation)

年轻代是新对象的诞生地。 绝大多数对象,比如局部变量创建的临时对象,都会在年轻代被创建。年轻代被设计成较小且垃圾回收频率高的区域。

  • Eden 区:这是年轻代的主要部分,新创建的对象首先被分配到这里。
  • Survivor 区(幸存者区):有两个大小相等的 Survivor 区,通常命名为 S0S1。它们的作用是保存每次垃圾回收后,还存活的对象。

划分比例

在默认情况下,年轻代内部的划分比例通常是:

  • Eden 区 : S0 区 : S1 区 = 8 : 1 : 1

这个比例可以通过 JVM 参数进行调整,比如使用 -XX:SurvivorRatio=8。这意味着 Eden 区的大小是单个 Survivor 区的 8 倍。

2. 老年代(Old Generation)

老年代用于存放生命周期较长的对象。 当一个对象在年轻代经过多次垃圾回收(通常是 15 次,这个次数也可以通过 -XX:MaxTenuringThreshold 参数调整)后仍然存活,或者年轻代放不下的大对象,就会被“晋升”到老年代。

老年代的垃圾回收频率远低于年轻代,但每次回收的开销更大。

划分比例

堆内存中,年轻代和老年代的默认划分比例通常是:

  • 年轻代 : 老年代 = 1 : 2

例如,如果你设置 -Xms1200m -Xmx1200m,那么年轻代大约是 400MB,老年代大约是 800MB。这个比例可以通过 -XX:NewRatio 参数来调整。例如,-XX:NewRatio=2 表示老年代与年轻代的大小比值为 2:1。


垃圾回收过程(以 Minor GC 为例)

理解了这些区域,我们再来看看垃圾回收是怎么进行的:

  1. Eden 区满了,会触发一次 Minor GC
  2. Minor GC 会检查 Eden 区和其中一个 Survivor 区(比如 S0)。
  3. 它会将所有还存活的对象复制到另一个空的 Survivor 区(比如 S1)。
  4. 同时,那些不再被引用的对象则会被清理。
  5. 所有被复制到 S1 区的对象,它们的年龄(age)会加一。
  6. 下一次 Minor GC 发生时,同样会扫描 Eden 区和 S1 区,将存活对象复制到 S0 区,清空 Eden 和 S1 区,并增加对象的年龄。
  7. 当对象的年龄达到某个阈值时,它就会被晋升到老年代。

现代垃圾回收器(如 G1 和 ZGC)

值得注意的是,像 G1 垃圾回收器已经打破了这种严格的分代比例。它将堆划分为一个个大小相等的区域(Region),每个区域都可以是 Eden、Survivor 或者老年代。G1 能够更智能地选择要回收的区域,从而在保证低停顿的同时,提高吞吐量。

ZGC 则更进一步,如我之前所说,它完全没有年轻代、老年代的概念,而是通过着色指针和读屏障技术,在不中断应用程序的情况下并发地进行垃圾回收,实现了更低的停顿。

因此,当你使用 ZGC 时,传统的分代比例就不适用了。不过,了解传统的分代结构对于理解 Java 内存管理的基础依然非常重要。

以下是针对Spring Cloud Alibaba及其他分布式技术的深度补充,包含实现原理和核心组件的详细说明:


六、Spring Cloud与分布式技术详解

6.1 Spring Cloud Alibaba核心组件

Nacos深度解析

服务发现原理

  1. 注册流程

    • 服务启动时向Nacos Server发送注册请求(HTTP/GRPC)
    • 注册信息包含元数据、健康检查方式(TCP/HTTP/MySQL)
    • 客户端本地缓存服务列表(故障转移)
  2. 健康检查机制

    1
    2
    3
    4
    5
    6
    7
    8
    // Nacos健康检查配置示例
    spring:
    cloud:
    nacos:
    discovery:
    health-check-enabled: true
    health-check-interval: 10s
    health-check-timeout: 5s

配置中心实现

  • 长轮询机制(Push+Pull混合模式):
    • 客户端发起长轮询请求(默认30秒超时)
    • 服务端配置变更时立即响应
    • 客户端收到变更后拉取最新配置
1
2
3
4
5
6
7
// 动态配置刷新示例
@RefreshScope
@RestController
public class ConfigController {
@Value("${app.config.item}")
private String configItem;
}

Sentinel核心原理

流量控制规则

  • 滑动窗口算法
    • 统计周期(1秒)分为多个格子(如20个50ms)
    • 实时淘汰过期格子数据
    • QPS计算基于当前窗口总请求数

熔断降级策略

策略类型 计算公式 适用场景
慢调用比例 响应时间 > RT阈值 && 比例 > 阈值 接口性能波动
异常比例 异常数 / 请求数 > 阈值 依赖服务不稳定
异常数 异常数 > 阈值(时间窗口内) 关键业务熔断
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Sentinel规则持久化到Nacos示例
@PostConstruct
public void initRules() {
FlowRuleManager.register2Property(
new NacosDataSourceWrapper(
"nacos-server:8848", "sentinel-flow-rules",
new Converter<String, List<FlowRule>>() {
@Override
public List<FlowRule> convert(String source) {
return JSON.parseArray(source, FlowRule.class);
}
}
)
);
}

RocketMQ集成

消息轨迹追踪

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 生产者配置
@Bean
public RocketMQTemplate rocketMQTemplate() {
RocketMQTemplate template = new RocketMQTemplate();
template.setProducer(new DefaultMQProducer("producer_group") {{
setVipChannelEnabled(false);
setTraceDispatcher(new AsyncTraceDispatcher("trace_topic"));
}});
return template;
}

// 消费者轨迹追踪
@RocketMQMessageListener(
topic = "order_topic",
consumerGroup = "order_consumer",
enableMsgTrace = true
)
public class OrderConsumer implements RocketMQListener<String> {
@Override
public void onMessage(String message) {
// 处理逻辑
}
}

6.2 分布式事务增强(Seata原理)

AT模式工作流程

  1. 一阶段
    • 解析SQL生成前后镜像
    • 注册分支事务到TC(Transaction Coordinator)
    • 本地事务提交前记录undo_log
  2. 二阶段
    • 成功:异步删除undo_log
    • 失败:根据undo_log补偿(反向SQL)

undo_log表示例

1
2
3
4
5
6
7
8
9
10
11
12
CREATE TABLE `undo_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`branch_id` bigint NOT NULL,
`xid` varchar(100) NOT NULL,
`context` varchar(128) NOT NULL,
`rollback_info` longblob NOT NULL,
`log_status` int NOT NULL,
`log_created` datetime NOT NULL,
`log_modified` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
);

TCC模式最佳实践

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 账户服务TCC接口
public interface AccountTccService {
@TwoPhaseBusinessAction(
name = "deduct",
commitMethod = "confirmDeduct",
rollbackMethod = "cancelDeduct"
)
boolean prepareDeduct(
@BusinessActionContextParameter(paramName = "userId") String userId,
@BusinessActionContextParameter(paramName = "amount") BigDecimal amount
);

boolean confirmDeduct(BusinessActionContext context);

boolean cancelDeduct(BusinessActionContext context);
}

// 业务调用方
@GlobalTransactional
public void placeOrder(Order order) {
// 1. 冻结库存
inventoryTccService.prepare(null, order.getProductId(), order.getCount());

// 2. 预扣款
accountTccService.prepareDeduct(order.getUserId(), order.getAmount());

// 3. 创建订单
orderMapper.insert(order);
}

6.3 分布式缓存深度优化

Redis多级缓存架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 本地缓存+Caffeine+Redis实现
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
return new CaffeineRedisCacheManager(
Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(1000),
RedisCacheWriter.nonLockingRedisCacheWriter(factory),
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1))
);
}

// 热点Key探测
public <T> T getWithHotspotProtection(String key, Class<T> type) {
// 1. 本地缓存查询
T value = localCache.getIfPresent(key);
if (value != null) return value;

// 2. Redis集群查询(带随机过期时间)
value = redisTemplate.opsForValue().get(key);
if (value != null) {
localCache.put(key, value);
return value;
}

// 3. 分布式锁防击穿
String lockKey = "lock:" + key;
try {
if (redisLock.tryLock(lockKey, 3, 30)) {
// 4. 数据库查询
value = databaseLoader.load(key);
redisTemplate.opsForValue().set(
key, value,
ThreadLocalRandom.current().nextInt(30) + 30,
TimeUnit.MINUTES
);
localCache.put(key, value);
}
} finally {
redisLock.unlock(lockKey);
}
return value;
}

6.4 消息队列高级特性

RocketMQ事务消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 事务消息生产者
public void sendTransactionMessage(Order order) {
TransactionSendResult result = rocketMQTemplate.sendMessageInTransaction(
"order-tx-group",
MessageBuilder.withPayload(order)
.setHeader(RocketMQHeaders.TRANSACTION_ID, order.getId())
.build(),
order
);
}

// 本地事务执行器
@RocketMQTransactionListener(txProducerGroup = "order-tx-group")
public class OrderTransactionListenerImpl implements RocketMQLocalTransactionListener {
@Override
public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
try {
Order order = (Order) arg;
orderService.createOrder(order); // 本地事务
return RocketMQLocalTransactionState.COMMIT;
} catch (Exception e) {
return RocketMQLocalTransactionState.ROLLBACK;
}
}

@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
String orderId = msg.getHeaders().get("TRANSACTION_ID").toString();
return orderService.exists(orderId) ?
RocketMQLocalTransactionState.COMMIT :
RocketMQLocalTransactionState.ROLLBACK;
}
}

Kafka精确一次语义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 生产者配置
@Bean
public ProducerFactory<String, String> producerFactory() {
Map<String, Object> configs = new HashMap<>();
configs.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
configs.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "tx-producer-1");
return new DefaultKafkaProducerFactory<>(configs);
}

// 消费者配置
@Bean
public ConsumerFactory<String, String> consumerFactory() {
Map<String, Object> configs = new HashMap<>();
configs.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_committed");
return new DefaultKafkaConsumerFactory<>(configs);
}

// 事务性消费
@KafkaListener(topics = "order-topic")
@Transactional
public void processOrder(ConsumerRecord<String, String> record) {
OrderEvent event = parseEvent(record.value());
orderService.process(event); // 数据库操作
}

七、云原生支持(新增)

7.1 Kubernetes集成方案

服务发现适配

1
2
3
4
5
6
7
8
9
10
# Nacos与K8s Service集成
spring:
cloud:
kubernetes:
discovery:
all-namespaces: true
nacos:
discovery:
server-addr: ${NACOS_HOST:nacos-headless}:${NACOS_PORT:8848}
namespace: ${POD_NAMESPACE:default}

配置管理方案

1
2
3
4
5
6
7
8
9
10
11
// 多配置源加载(K8s ConfigMap + Nacos)
@Configuration
@ConfigurationProperties(prefix = "app")
@RefreshScope
public class AppConfig {
@Value("${configFromNacos}")
private String nacosConfig;

@Value("${configFromK8s}")
private String k8sConfig;
}

7.2 Service Mesh整合

Istio流量治理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 金丝雀发布策略
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: order-service
spec:
hosts:
- order-service
http:
- route:
- destination:
host: order-service
subset: v1
weight: 90
- destination:
host: order-service
subset: v2
weight: 10

八、性能优化深度实践

8.1 全链路压测方案

影子库表配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# ShardingSphere影子库配置
spring:
shardingsphere:
datasource:
names: ds-real,ds-shadow
rules:
shadow:
enable: true
data-sources:
shadow-data-source:
source-data-source-name: ds-real
shadow-data-source-name: ds-shadow
tables:
t_order:
shadow-algorithm-names: [simple-hint-algorithm]

流量染色标记

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 通过ThreadLocal传递压测标记
public class PressureTestContext {
private static final ThreadLocal<Boolean> FLAG = ThreadLocal.withInitial(() -> false);

public static void markPressureTest() {
FLAG.set(true);
}

public static boolean isPressureTest() {
return FLAG.get();
}
}

// MyBatis拦截器自动路由
@Intercepts(@Signature(type=Executor.class, method="update", args={MappedStatement.class,Object.class}))
public class ShadowDbInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
if (PressureTestContext.isPressureTest()) {
RoutingContext.setShadowDataSource();
}
return invocation.proceed();
}
}

分布式

1. Spring Cloud 体系

核心思想

Spring Cloud 并非一个全新的框架,而是一套用于构建微服务架构的规范和解决方案的集合。它巧妙地利用了 Spring Boot 的自动配置和快速开发特性,将业界经过广泛验证的优秀微服务组件(如 Netflix OSS、Alibaba Nacos、HashiCorp Consul 等)进行封装和集成,为开发者提供了一站式的分布式系统开发工具箱,涵盖了服务治理、配置管理、熔断降级、智能路由、服务调用等方方面面。

Netflix OSS 常用组件(部分进入维护模式)
  • 服务注册与发现 (Service Discovery): Eureka
    • 作用: 提供一个服务注册中心。每个微服务启动时,将自己的网络地址等信息“注册”到 Eureka Server。其他服务(消费者)则从 Eureka Server “发现”并拉取所需服务提供者的地址列表,从而实现服务间的动态寻址和调用。
    • 使用: 服务端添加 spring-cloud-starter-netflix-eureka-server 依赖,并使用 @EnableEurekaServer 注解。客户端添加 spring-cloud-starter-netflix-eureka-client 依赖,并配置 Eureka Server 地址。
  • 服务调用与负载均衡 (RPC & Load Balancing): OpenFeign + Ribbon
    • 作用: Feign 让远程服务调用变得像调用本地方法一样简单。开发者只需定义一个接口,并使用 @FeignClient 注解,即可完成对远程服务的调用。Ribbon(现已被 Spring Cloud LoadBalancer 替代)则提供了客户端负载均衡能力,当从 Eureka 获取到多个服务实例地址时,Ribbon 会根据配置的策略(如轮询、随机)选择一个实例进行调用。
    • 使用: 添加 spring-cloud-starter-openfeign 依赖,在启动类上加 @EnableFeignClients,创建接口并使用 @FeignClient("service-name") 注解。
  • 熔断与降级 (Circuit Breaker): Hystrix
    • 作用: 当某个下游服务出现故障或响应缓慢时,为了防止故障在系统中蔓延(即“服务雪崩”),熔断器会快速失败,暂时切断对该服务的调用。同时,可以执行一个预定义的降级逻辑(Fallback),例如返回一个缓存的、默认的或友好的提示信息。
    • 状态: Hystrix 已进入维护模式,官方推荐使用 Resilience4j 或其他替代方案。
  • API 网关 (API Gateway): Zuul
    • 作用: 作为系统的统一入口,API 网关负责请求路由、协议转换、权限校验、流量控制、日志监控等。所有外部请求都先经过网关,再由网关分发到后端的各个微服务。
    • 状态: Zuul 1.x 已进入维护模式,官方推荐使用 Spring Cloud Gateway。

Spring Cloud Alibaba 详解

Spring Cloud Alibaba 致力于提供微服务开发的一站式解决方案,是 Spring Cloud 体系的重要实现。它集成了阿里巴巴开源的优秀组件,为开发者提供了更符合国内技术生态的选择。

  • 服务注册与发现 & 分布式配置中心: Nacos
    • 作用: Nacos (Naming and Configuration Service) 是一个功能丰富的平台,完美整合了服务注册发现配置管理两大核心功能。
      • 服务发现: 与 Eureka 类似,提供服务注册、发现和健康检查。但 Nacos 支持基于 DNS 和 RPC 的服务发现,并提供更实时的健康检查机制。
      • 配置管理: 可以作为分布式配置中心,对所有微服务的配置进行集中化管理。支持配置的热更新,即修改配置后无需重启服务即可生效。还支持配置的版本管理、灰度发布等高级功能。
    • 使用:
      1. 引入 spring-cloud-starter-alibaba-nacos-discoveryspring-cloud-starter-alibaba-nacos-config 依赖。
      2. bootstrap.properties (或 .yml) 文件中配置 Nacos 服务器地址和应用名。
      3. 使用 @Value@ConfigurationProperties 注解即可动态获取和刷新配置。
  • 熔断、降级与流量控制: Sentinel
    • 作用: Sentinel 是面向分布式服务架构的“流量的守护者”,以流量为切入点,从流量控制、熔断降级、系统负载保护等多个维度保护服务的稳定性。相较于 Hystrix,Sentinel 功能更强大,提供了可视化的监控和配置平台,并且支持热点参数限流等精细化控制。
    • 核心概念:
      • 资源 (Resource): Sentinel 中一切皆资源,可以是一个方法、一段代码或一个服务 URL。
      • 规则 (Rule): 定义如何保护资源,包括流控规则、降级规则、系统保护规则等。
    • 使用:
      1. 引入 spring-cloud-starter-alibaba-sentinel 依赖。
      2. 配置 Sentinel 控制台地址。
      3. 通过 @SentinelResource 注解来定义资源,并指定 Fallback (降级逻辑) 和 BlockHandler (流控/熔断逻辑)。
  • 分布式事务解决方案: Seata
    • 作用: Seata 是一个开源的分布式事务解决方案,提供了高性能且易于使用的分布式事务服务。它支持多种事务模式,包括 AT(自动补偿)、TCC、Saga 和 XA 模式,旨在解决微服务架构下的数据一致性问题。
    • 使用: 引入 spring-cloud-starter-alibaba-seata 依赖,配置 Seata Server 地址,并使用 @GlobalTransactional 注解开启全局事务。

2. 分布式ID:雪花算法(Snowflake)

原理

Snowflake 是 Twitter 开源的一种分布式 ID 生成算法,它能生成一个 64 位的 long 型数字作为全局唯一 ID。这个 64 位的 ID 由四部分构成:

  • 1位符号位: 最高位,固定为0,表示正数,无实际意义。
  • 41位时间戳 (Timestamp): 精确到毫秒级,是 (当前时间戳 - 起始时间戳) 的差值。41位可以表示 (241−1) 毫秒,大约可以使用 69 年。
  • 10位工作机器ID (Worker ID): 这 10 位可以被灵活划分,例如前 5 位代表数据中心 ID (Datacenter ID),后 5 位代表机器 ID (Machine ID)。这样总共可以支持 210=1024 台机器。
  • 12位序列号 (Sequence): 表示在同一毫秒内,同一台机器上生成的 ID 序列号。12位意味着每台机器每毫秒可以生成 212=4096 个不同的 ID。
优点
  • 全局唯一: 通过时间戳、机器 ID 和序列号的组合,可以保证在分布式环境下的 ID 唯一性。
  • 趋势递增: 由于时间戳在高位,所以生成的 ID 整体上是按时间趋势递增的,这对于数据库索引(特别是 B+树)非常友好,可以减少页分裂,提高插入性能。
  • 高性能: ID 在本地生成,不依赖任何外部服务(如数据库或 Redis),生成效率极高。
  • 高可用: 算法本身不依赖网络,部署简单,具有很高的可用性。
面试题:“雪花算法有时钟回拨问题,如何解决?”

回答要点:

时钟回拨是指服务器时间被同步到一个过去的时间点。如果算法不做处理,可能会生成重复的 ID。解决方案通常是:在生成 ID 时,记录上一次生成 ID 时所使用的时间戳。当发现当前时间戳小于上次记录的时间戳时,就意味着发生了时钟回拨。

  • 方案一(拒绝服务): 直接抛出异常,拒绝生成 ID,等待时钟恢复正常。这种方案简单,但会暂时影响可用性,适合对 ID 连续性要求不高的场景。
  • 方案二(等待追赶): 如果回拨幅度很小(比如几毫秒),程序可以 while(currentTime < lastTimestamp) 这样自旋等待,直到当前时间追赶上上次的时间戳。这会造成短暂的线程阻塞。
  • 方案三(使用备用位): 一些改进版的雪花算法会预留几位作为扩展位,当发生时钟回拨时,在这几位上做自增,从而在短时间回拨内仍能生成不同的 ID。(这种方案实现较为复杂)
  • 业界实践(美团 Leaf): 在发生时钟回拨时,切换到另一种备用 ID 生成策略(如号段模式),或者直接报错。

3. RBAC(基于角色的访问控制)

原理

RBAC (Role-Based Access Control) 是一种主流且灵活的权限管理模型。它的核心思想是在 用户 (User)权限 (Permission) 之间引入一个中间层——角色 (Role)。权限不再直接授予用户,而是授予角色;然后将角色分配给用户。这样,用户与权限实现了解耦,当需要修改大量用户的权限时,只需修改他们共同拥有的角色的权限即可,极大地简化了权限管理和维护。

核心三要素
  • 用户 (User): 系统操作的主体。
  • 角色 (Role): 权限的集合,代表了一组特定的职责或身份,如“管理员”、“文章编辑”、“普通会员”。
  • 权限 (Permission): 对系统中特定资源进行特定操作的许可。通常用一个字符串表示,如 user:createorder:deletearticle:publish
面试题:“请设计一个 RBAC 权限管理系统的数据库表结构。”

回答要点:

一个基础的 RBAC 模型至少需要五张表:

  • 用户表 (t_user): 存储用户信息。
    • user_id (主键), username, password, …
  • 角色表 (t_role): 存储角色信息。
    • role_id (主键), role_name (角色名, 如”管理员”), role_key (角色标识, 如”admin”), …
  • 权限表 (t_permission): 存储具体的权限点信息。
    • permission_id (主键), permission_name (权限名称, 如“新增用户”), permission_code (权限标识, 如 user:add), parent_id (用于菜单层级), …
  • 用户-角色关联表 (t_user_role): 存储用户和角色的多对多关系。
    • user_id (外键), role_id (外键)。(主键是 user_idrole_id 的联合主键)
  • 角色-权限关联表 (t_role_permission): 存储角色和权限的多对多关系。
    • role_id (外键), permission_id (外键)。(主键是 role_idpermission_id 的联合主键)

4. Redis 进阶详解

核心特点
  • 基于内存: Redis 是一个内存数据结构存储系统,所有数据都存放在内存中,因此读写速度极快。
  • 单线程模型: Redis 的核心网络模型处理客户端请求是单线程的。这避免了多线程环境下的上下文切换和锁竞争带来的开销。(注意:Redis 6.0 之后引入了多线程来处理 I/O,但执行命令的核心仍然是单线程)。
  • IO多路复用: 单线程能实现高性能的关键在于它使用了 IO 多路复用技术(如 Linux 下的 epoll)。该技术允许单个线程同时监听多个网络连接上的 IO 事件,当某个连接准备好读或写时,才去处理它,极大地提高了并发处理能力。
  • 丰富的数据类型: 支持 String, Hash, List, Set, Sorted Set, Bitmap, HyperLogLog, GEO 等多种数据结构。
缓存三大问题与解决方案
  • 缓存穿透:
    • 问题: 客户端查询一个数据库和缓存中不存在的数据。这导致每次请求都会绕过缓存,直接打到数据库上,当有大量此类请求时,会给数据库带来巨大压力。
    • 解决:
      1. 缓存空对象: 当从数据库查询不到数据时,也在缓存中存入一个特殊的空值(如 null 或特定字符串),并设置一个较短的过期时间。
      2. 布隆过滤器 (Bloom Filter): 在访问缓存之前,先通过布隆过滤器判断 key 是否可能存在。布隆过滤器可以高效地判断一个元素一定不存在,从而在第一层就拦截掉大量无效请求。
  • 缓存击穿:
    • 问题: 某个热点 Key 在某一时刻突然失效,而此时恰好有大量的并发请求访问这个 Key,这些请求会同时穿透缓存,直接打到数据库上,可能导致数据库瞬间崩溃。
    • 解决:
      1. 设置热点 Key 永不过期: 对于一些访问极其频繁且数据相对固定的热点数据,可以考虑不设置过期时间,通过后台任务定时更新。
      2. 使用分布式锁: 在查询数据库之前,先获取一个该 Key 对应的分布式锁。只有第一个获取到锁的线程才能去查询数据库并回写缓存,其他线程则等待或直接返回。
  • 缓存雪崩:
    • 问题: 大量的缓存 Key 在同一时间集中失效(例如,在应用启动时缓存了大量数据,并设置了相同的过期时间),导致所有请求都瞬间涌向数据库,造成数据库压力剧增甚至宕机。
    • 解决:
      1. 过期时间加随机值: 在设置缓存的过期时间时,在一个基础时间上增加一个随机数,使得 Key 的失效时间点尽可能分散。
      2. 多级缓存: 建立多级缓存体系,如 Nginx 缓存 + Redis 缓存 + JVM 本地缓存(Caffeine/Guava Cache)。
      3. 熔断降级: 使用 Hystrix 或 Sentinel 等组件,当检测到数据库压力过大时,进行熔断或降级处理,暂时不访问数据库,返回一个默认值或提示信息。

5. 消息队列(MQ)

核心作用
  • 异步 (Asynchronous): 将耗时的操作(如发送邮件、生成报表)作为消息放入 MQ,主流程可以立即返回,无需等待这些操作完成,从而提高系统的响应速度和吞吐量。
  • 解耦 (Decoupling): 生产者和消费者之间通过 MQ 进行通信,无需直接相互依赖。任何一方的修改、宕机或升级都不会影响到另一方,增强了系统的灵活性和可维护性。
  • 削峰 (Peak Shaving): 在秒杀、大促等高并发场景下,将瞬时涌入的大量请求暂存在 MQ 中,下游的消费者系统可以按照自己的处理能力,平稳地从 MQ 中拉取并处理请求,避免了流量洪峰直接冲垮下游服务。
面试题:“请列举你使用消息队列时可能遇到的问题,并给出解决方案。”

回答要点:

  • 消息丢失 (Message Loss):
    • 问题: 消息从生产者发出后,由于网络或 MQ 服务故障,未能成功到达消费者。
    • 解决:
      1. 生产者端: 开启生产者的 confirmack 机制,确保消息被 MQ 成功接收。如果发送失败,可以进行重试。
      2. MQ 服务端: 对消息进行持久化,防止 MQ 宕机导致消息丢失(如 RabbitMQ 的持久化队列和消息,Kafka 的磁盘存储)。
      3. 消费者端: 消费者在处理完业务逻辑后,再向 MQ 发送确认应答(ack)。如果处理过程中消费者宕机,MQ 没有收到 ack,会将该消息重新投递给其他消费者。
  • 重复消费 (Duplicate Consumption):
    • 问题: 由于网络抖动、消费者 ack 超时等原因,MQ 可能会重复投递同一条消息。
    • 解决: 核心是保证消费逻辑的幂等性 (Idempotence)。即多次执行同一个操作,结果都是相同的。实现方式有:
      1. 在数据库中为业务操作创建一个唯一键,每次操作前先检查该唯一键是否存在。
      2. 使用一个全局唯一的业务 ID(如订单号),在处理消息前,先查询这个 ID 是否已经被处理过(例如,存入 Redis Set 或数据库)。
  • 消息堆积 (Message Backlog):
    • 问题: 生产者的生产速度远大于消费者的消费速度,导致大量消息在 MQ 中积压,占用资源并可能导致消息超时丢失。
    • 解决:
      1. 水平扩展消费者: 增加消费者实例的数量,并行处理消息。这是最常用的方法。
      2. 优化消费逻辑: 检查消费者代码,看是否有可以优化的慢操作(如 I/O 密集型操作)。
      3. 消息分片/分区: 对 Topic 进行分区(Partitioning),让不同的消费者组处理不同的分区,提高并发度。
      4. 增加预警监控: 对消息堆积数量设置阈值,达到阈值时及时告警,人工介入。

6. 分布式锁详解

作用

在分布式系统环境下,多个进程或服务器上的多个线程需要访问同一个共享资源时,为了保证数据的一致性和操作的原子性,需要一种机制来确保在同一时刻,只有一个客户端能够持有锁并访问该资源。

实现方案对比
实现方式 优点 缺点 适用场景
基于数据库 实现简单,直接利用数据库(如唯一索引、悲观锁 for update)。 性能开销大,有锁库锁表的风险,不可重入,不是阻塞锁,数据库单点故障问题。 并发度不高的简单场景。
基于 ZooKeeper 可靠性高,天然支持阻塞锁和可重入,解决死锁问题(临时节点),无锁过期问题,支持公平锁。 性能不如 Redis,实现复杂,依赖 ZK 集群。 对可靠性要求极高,且能容忍一定性能损耗的场景,如分布式协调。
基于 Redis 性能极高,实现相对简单,有成熟的框架 (Redisson) 可用。 可靠性相对 ZK 稍差,需要处理锁过期和业务未执行完的问题,非公平锁。 互联网高并发、对性能要求高的绝大多数场景。
基于 Redis 的实现进阶
  • 加锁的正确姿势:

    使用 SET key value NX EX time 命令。

    • SET key value: 设置键值。value 通常是一个唯一的随机字符串(如 UUID),用于标识锁的持有者。

    • NX: (if Not eXists),确保只有在 key 不存在时才能设置成功,实现了“加锁”的原子性。

    • EX time: 设置一个自动过期时间(秒),防止因客户端宕机而导致死锁。

      这三个参数必须在一个命令中执行,保证原子性。

  • 解锁的正确姿势:Lua 脚本

    • 为什么需要 Lua: 解锁操作包含“判断”和“删除”两步:1. GET 锁的 value,判断是否与自己加锁时设置的随机字符串相等。2. 如果相等,则 DEL 锁。如果不用 Lua 脚本,在执行完第一步后,若该线程阻塞,此时锁恰好过期,另一个线程获取了锁,那么当原线程恢复执行第二步时,就会误删掉新线程的锁

    • Lua 脚本示例:

      1
      2
      3
      4
      5
      6
      -- 脚本接收两个参数:KEYS[1] 是锁的 key,ARGV[1] 是加锁时设置的唯一值
      if redis.call('get', KEYS[1]) == ARGV[1] then
      return redis.call('del', KEYS[1])
      else
      return 0
      end

    Lua 脚本可以确保多个命令在 Redis 服务端被原子性地执行,杜绝了上述问题。

  • Redis 乐观锁:WATCH 命令

    • 作用: WATCH 命令可以监视一个或多个 key,如果在事务 EXEC 执行之前,任何一个被监视的 key 被其他命令修改了,那么整个事务将被取消,EXEC 返回 nil

    • 原理: 这是一种检查后设置 (Check-And-Set, CAS) 的实现。它不是真正的加锁,而是在更新数据时检查数据是否被修改过。

    • 使用场景: 适用于读多写少的并发场景,可以减少锁的开销。例如,更新商品库存。

      1. WATCH stock_key // 监视库存

      2. current_stock = GET stock_key // 获取当前库存

      3. (在客户端代码中判断 current_stock 是否足够)

      4. MULTI // 开启事务

      5. SET stock_key new_stock // 准备更新库存

      6. EXEC // 执行事务

        如果从 WATCH 到 EXEC 之间 stock_key 被其他客户端修改,EXEC 将失败,此时客户端需要重试整个操作。

面试题:“Redis 分布式锁锁过期了但业务没执行完怎么办?”

回答要点:

这是分布式锁的一个经典问题,被称为锁的超时续期问题。

  • 问题根源: 我们给锁设置了一个过期时间,比如 30 秒,但业务执行了 35 秒。在第 30 秒时锁会自动释放,其他线程就能获取到锁,导致并发问题。
  • 解决方案:“看门狗”(Watchdog)机制
    • 原理: 比如 Java 中的 Redisson 框架就内置了看门狗。当一个线程获取锁成功后,Redisson 会启动一个后台线程(看门狗),定期(例如每 10 秒)检查该线程是否还持有锁。如果持有,并且业务仍在执行,看门狗就会自动为这个锁延长过期时间(续期),比如再续 30 秒。这个过程会一直持续,直到业务执行完毕,线程主动释放锁,看门狗才会停止。
    • 总结: 看门狗机制通过后台线程自动续期,确保了在业务执行完成之前,锁不会因为超时而提前释放,从而保证了锁的可靠性。

7. 分布式事务详解

作用

在微服务架构中,一个业务操作可能需要调用多个独立的服务来共同完成(例如,电商下单操作需要调用订单服务、库存服务、积分服务)。分布式事务旨在保证这些跨服务的数据库操作能够遵循 ACID 原则,要么全部成功,要么全部回滚,以确保数据的最终一致性

解决方案深入分析
  • XA (2PC/3PC): 两阶段/三阶段提交协议。
    • 角色: 事务管理器 (Transaction Manager, TM) 和 资源管理器 (Resource Manager, RM)。
    • 流程 (2PC):
      1. 准备阶段 (Prepare): TM 通知所有 RM 准备提交,RM 执行本地事务并锁定资源,但不提交,然后向 TM 报告准备就绪。
      2. 提交/回滚阶段 (Commit/Rollback): 如果所有 RM 都准备就绪,TM 通知所有 RM 提交;否则,通知所有 RM 回滚。
    • 评价: 是一种强一致性的方案,但协议复杂,性能差,同步阻塞模型会长时间锁定资源,且协调器存在单点故障风险,在互联网高并发场景下很少使用
  • TCC (Try-Confirm-Cancel): 补偿型事务。
    • 核心: 是一种业务层面的柔性事务方案,对代码侵入性强。
    • 流程:
      1. Try: 对业务资源进行检查和预留。例如,扣减库存操作,Try 阶段是冻结库存。
      2. Confirm: 如果所有服务的 Try 阶段都成功,则执行所有服务的 Confirm 操作,真正完成业务。例如,将冻结的库存真实扣减。
      3. Cancel: 如果任何一个服务的 Try 阶段失败,则执行所有已成功服务的 Cancel 操作,释放预留的资源。例如,解冻之前被冻结的库存。
    • 评价: 性能较好,数据一致性高于可靠消息方案。但开发成本极高,需要为每个操作都编写 Try, Confirm, Cancel 三个幂等的方法。
  • Saga 模式: 长事务解决方案。
    • 核心: 将一个大的分布式事务拆分成多个本地事务,每个本地事务都有一个对应的补偿操作
    • 流程:
      • 正向执行: Saga 协调器按顺序调用 T1, T2, T3…Tn。
      • 反向补偿: 如果 Ti 失败,Saga 会按相反顺序调用前面已成功事务的补偿操作 C(i-1)…C2, C1,进行回滚。
    • 与 TCC 对比:
      • TCC 有资源预留阶段,锁资源时间长;Saga 没有预留,直接提交本地事务,锁资源时间短。
      • TCC 的补偿是逆向操作 (Cancel);Saga 的补偿是反向操作
    • 评价: 适合于业务流程长、需要保证最终一致性的场景。同样对代码有侵入性,需要设计好每个子事务的补偿逻辑。
  • 基于可靠消息的最终一致性 (常用):
    • 核心: 这是微服务架构中最常用的柔性事务方案。
    • 原理: 服务 A 在执行完本地事务后,发送一条消息到 MQ。服务 B 订阅该消息,消费消息并执行自己的本地事务。
    • 关键问题: 如何保证本地事务执行消息发送的原子性?
      • 事务消息 (RocketMQ 支持): 生产者先发送一条“半消息”到 MQ,MQ 收到后不投递。然后生产者执行本地事务。如果事务成功,则向 MQ 发送确认,MQ 投递该消息;如果事务失败,则通知 MQ 删除该半消息。
      • 本地消息表: 业务操作和“待发送消息”记录在同一本地事务中。一个后台任务定时扫描这张表,将消息发送到 MQ,发送成功后更新表状态。
    • 评价: 实现了服务间的解耦,性能高,吞吐量大。但它不保证数据的强一致性,只保证最终一致性,存在一个短暂的数据不一致状态窗口。需要处理好消息的可靠投递和幂等消费问题。

Lua 脚本详解 (在 Redis 中的应用)

1. Lua 是什么?

Lua 是一种轻量级、可扩展的脚本语言,被设计用于嵌入到其他应用程序中,从而为应用程序提供灵活的扩展和定制功能。它以其简洁的语法、高效的执行性能和极小的内存占用而闻名。

在 Redis 的上下文中,Lua 脚本提供了一种在 Redis 服务器端执行复杂逻辑的强大能力。

2. 为什么 Redis 要支持 Lua 脚本?

  • 原子性 (Atomicity): 这是在 Redis 中使用 Lua 最核心的原因。Redis 会将整个 Lua 脚本作为一个单独的命令来执行,在脚本执行期间,不会有其他客户端的命令被插入执行。这完美地解决了需要组合多个 Redis 命令才能完成一个业务逻辑时,可能出现的竞态条件问题。例如前面提到的“判断锁并删除锁”的操作,如果分两步执行,就不是原子的,而封装在 Lua 脚本中就是原子的。
  • 减少网络开销: 对于需要多次与 Redis 交互的复杂操作,可以将所有逻辑封装在一个 Lua 脚本中,一次性发送给 Redis 服务器。客户端只需发送一次请求,而不是多次,这显著减少了客户端与服务器之间的网络往返时间(RTT),提升了性能。
  • 代码复用: 编写好的 Lua 脚本可以被缓存(通过 SCRIPT LOAD 命令生成一个 SHA1 校验和),之后客户端可以通过这个简短的 SHA1 校验和(使用 EVALSHA 命令)来调用脚本,避免了每次都发送完整的脚本内容。

3. 如何在 Redis 中使用 Lua 脚本?

通过 EVAL 或 EVALSHA 命令来执行。

EVAL script numkeys key [key …] arg [arg …]

  • script: 要执行的 Lua 脚本字符串。
  • numkeys: 后面跟的 key 参数的数量。这有助于 Redis 正确地将参数区分为键名(KEYS)和普通参数(ARGV),这对于 Redis Cluster 模式下的路由至关重要。
  • key [key ...]:脚本中要操作的 Redis 键,在 Lua 脚本中可以通过全局变量 KEYS table(例如 KEYS[1])来访问。
  • arg [arg ...]:传递给脚本的额外参数,在 Lua 脚本中可以通过全局变量 ARGV table(例如 ARGV[1])来访问。

示例:实现一个安全的库存扣减

1
2
3
4
5
6
7
8
9
10
11
12
13
-- 脚本逻辑:检查库存是否充足,如果充足则扣减并返回1,否则返回0
-- KEYS[1]: 库存的 key,例如 "product:1001:stock"
-- ARGV[1]: 本次要扣减的数量

local stock = tonumber(redis.call('get', KEYS[1]))
local quantity = tonumber(ARGV[1])

if stock >= quantity then
redis.call('decrby', KEYS[1], quantity)
return 1
else
return 0
end

这个脚本保证了“读取库存”和“扣减库存”两个操作的原子性,避免了在高并发下超卖的问题。


Token 认证机制详解

1. Token 是什么?

Token(令牌)是在服务端生成的一串加密字符串,作为客户端进行请求的一个“凭证”。当用户第一次登录成功后,服务端会生成一个 Token 并返回给客户端。之后,客户端在每次请求需要身份认证的接口时,都需要在请求头(通常是 Authorization 字段)中携带这个 Token。服务端接收到请求后,会验证 Token 的有效性,如果验证通过,则处理该请求;否则,拒绝该请求。

一个典型的 Token 是 JWT (JSON Web Token),它由三部分组成,用 . 分隔:

  • Header (头部): 包含了令牌的类型(typ,即 JWT)和所使用的签名算法(alg,如 HMAC SHA256 或 RSA)。
  • Payload (负载): 包含了“声明 (claims)”,是存放实际需要传递的数据的地方。例如用户ID(sub)、签发时间(iat)、过期时间(exp)以及其他自定义的用户信息。注意:Payload 部分是 Base64 编码的,并非加密,因此不应存放敏感信息。
  • Signature (签名): 对前两部分(Header 和 Payload)使用指定的算法和存储在服务端的密钥(secret)进行签名。这个签名的作用是防止数据被篡改。服务端收到 Token 后,会用同样的算法和密钥重新计算签名,并与 Token 中的签名进行比对,若一致,则说明 Token 未被篡改且是可信的。

2. Token 认证原理(工作流程)

  1. 登录: 用户使用用户名和密码发起登录请求。
  2. 验证: 服务端验证用户的凭据是否正确。
  3. 签发 Token: 验证成功后,服务端根据用户ID、角色等信息,结合密钥(secret),生成一个 Token。
  4. 返回 Token: 服务端将生成的 Token 返回给客户端。
  5. 存储 Token: 客户端(浏览器、App)将 Token 存储起来,通常放在 localStoragesessionStorageHttpOnly 的 Cookie 中。
  6. 携带 Token 请求: 客户端在后续每次请求受保护的 API 时,都会在 HTTP 请求头的 Authorization 字段中附上 Token,格式通常为 Bearer <token>
  7. 验证 Token: 服务端收到请求后,从请求头中解析出 Token,然后:
    • 验证签名是否正确,确保 Token 未被篡改。
    • 检查 Token 是否在有效期内(exp 声明)。
    • 如果验证通过,则从 Payload 中获取用户信息,执行业务逻辑并返回结果。
    • 如果验证失败,则返回 401 Unauthorized 错误。

3. 为什么使用 Token?(与 Session 的区别)

在 Web 开发早期,Session-Cookie 机制是主流。服务端在用户登录后创建一个 Session 对象存储在内存或 Redis 中,并生成一个 Session ID,通过 Cookie 返回给浏览器。浏览器后续请求会自动带上这个 Session ID,服务端根据 ID 找到对应的 Session 信息来识别用户。

Token 机制相比 Session 机制,核心优势在于“无状态性 (Statelessness)”,这带来了以下好处:

特性对比 Session 机制 Token 机制 优势说明
状态存储 有状态 (Stateful)。Session 信息需存储在服务端。 无状态 (Stateless)。用户信息包含在 Token 的 Payload 中,服务端无需存储。 减轻服务端压力。服务端不需要为每个在线用户维护一个 Session 对象。
可扩展性 。在分布式或集群环境下,需要解决 Session 共享问题(如 Session Sticky、Session Replication、集中存储)。 。由于服务端不存储状态,任何一台服务器只要有相同的密钥,就能验证 Token,天然适合分布式和微服务架构。 轻松实现水平扩展。增加服务器节点无需额外配置 Session 共享。
跨域支持 有限。基于 Cookie 的 Session 机制在跨域(CORS)场景下处理起来比较麻烦。 优秀。Token 可以通过 HTTP 请求头发送,不受同源策略限制,非常适合前后端分离和跨域 API 调用。 适应现代架构。完美支持 SPA(单页应用)、移动 App 等多种客户端。
安全性 依赖 Cookie 机制,可能遭受 CSRF 攻击。 如果 Token 存储在 localStorage,可能遭受 XSS 攻击。需要综合考虑存储方式。 两者各有安全风险点,需配合其他安全策略。Token 机制不依赖 Cookie,更灵活。
适用性 适合传统的、一体化的 Web 应用。 适合现代的、分布式的、跨终端的(Web, Mobile, IoT)应用架构。 Token 更具通用性和前瞻性。

4. 双令牌策略 (Access Token + Refresh Token)

  • Q: 为什么不用单个 Token?
    • 如果 Token 有效期很长(如一个月): 安全风险高。一旦 Token 在此期间被窃取,攻击者可以长时间冒充用户身份进行操作。
    • 如果 Token 有效期很短(如 15 分钟): 用户体验差。用户需要频繁地重新登录,这是无法接受的。
  • A: 双令牌策略应运而生,完美平衡了安全性和用户体验。
    • Access Token (访问令牌): 它的有效期非常短(如 15 分钟到 1 小时)。它被用于访问受保护的资源,由于其生命周期短,即使被窃取,攻击者能造成的危害也有限。
    • Refresh Token (刷新令牌): 它的有效期很长(如 7 天或 30 天)。它的唯一作用是用来获取新的 Access Token。Refresh Token 本身不包含任何权限信息,不能用于直接访问 API。
  • 双令牌工作流程(静默刷新)
    1. 首次登录: 用户登录成功,服务端返回一个短期的 Access Token 和一个长期的 Refresh Token。客户端将两者都存储起来。
    2. 正常访问: 客户端使用 Access Token 访问 API。服务端验证 Access Token 通过,返回数据。
    3. Access Token 过期: 客户端再次使用过期的 Access Token 访问 API,服务端返回 401 Unauthorized 错误,并可能带上一个特定错误码,告知客户端是“令牌过期”而非“无效令牌”。
    4. 静默刷新: 客户端的请求拦截器捕获到这个 401 错误后,不会立即跳转到登录页。而是自动发起一个特殊的请求,携带那个长期的 Refresh Token 去访问一个专门的刷新接口(如 /api/token/refresh)。
    5. 签发新令牌: 服务端验证 Refresh Token 的有效性(通常会将其存储在 Redis 或数据库中进行比对,以实现强制下线功能)。如果验证通过,就生成一个新的 Access Token(有时也会生成一个新的 Refresh Token,这被称为刷新令牌滚动策略)并返回给客户端。
    6. 重试请求: 客户端收到新的 Access Token 后,用它替换掉本地旧的 Access Token,然后自动重新发送刚才因令牌过期而失败的那个请求
    7. 无感体验: 整个过程对用户是透明的,用户感觉不到令牌已经过期并被刷新,实现了“静默刷新”,体验非常流畅。
    8. Refresh Token 过期: 如果 Refresh Token 也过期了,那么刷新接口会返回错误,此时客户端才会真正清除用户凭证并引导用户重新登录。

5. Token 相关场景与面试题

  • 面试题 1:“Token 应该存储在哪里?localStorage、sessionStorage 还是 Cookie?”
    • 回答要点:

      • localStorage/sessionStorage:
        • 优点: 方便 JavaScript 直接读写,容量较大(5MB)。
        • 缺点: 容易受到 XSS (跨站脚本攻击)。如果网站存在 XSS 漏洞,攻击者可以执行 JS 代码直接窃取存储在其中的 Token。
      • Cookie (HttpOnly):
        • 优点: 设置为 HttpOnly 后,JavaScript 将无法读写该 Cookie,可以有效防御 XSS 攻击。浏览器会自动在同域请求中携带它。
        • 缺点: 容易受到 CSRF (跨站请求伪造) 攻击。攻击者可以诱导用户点击恶意链接,浏览器会自动带上用户的 Cookie 去请求你的网站,执行非用户本意的操作。需要配合 Anti-CSRF Token 等机制来防御。容量较小(4KB)。
      • 最佳实践/结论: 没有绝对完美的选择,需要权衡。
        • 高安全性方案: 将 Refresh Token 存储在 HttpOnly 的 Cookie 中(防止 XSS),将 Access Token 存储在内存中(变量里,页面刷新丢失)或 sessionStorage 中。同时,后端接口必须实施 CSRF 防御策略。
        • 主流实践方案 (前后端分离): 将 Token 存储在 localStorage 中,并在 Authorization 请求头中携带。同时,前端必须严格做好输入过滤和内容转义,尽最大努力防止 XSS 漏洞的出现。

      好的,我们来详细探讨双令牌(Dual Token)的存储方案。这种方案是现代 Web 安全认证的常见模式,主要用于解决传统 JWT(JSON Web Token)方案中的一些安全问题,尤其是防止 XSS(跨站脚本攻击)和 CSRF(跨站请求伪造)攻击

      双令牌方案的核心思想是:将用户认证所需的权限信息分为两个独立的令牌,并采用不同的存储和传输方式,以达到更高的安全性。这两个令牌通常是:

      1. 访问令牌(Access Token)
      2. 刷新令牌(Refresh Token)

      1. 访问令牌 (Access Token)

      访问令牌是用于访问受保护资源的凭证。它通常是一个有效期很短的 JWT。

      • 作用:携带用户的身份信息和权限,用于后端 API 接口的认证。后端在收到请求时,会验证这个令牌的有效性(签名、有效期)。
      • 有效期非常短,通常只有几分钟到几小时。这极大地降低了令牌被盗后造成的损失。
      • 存储方案:为了防止 XSS 攻击,访问令牌通常不应该存储在浏览器本地的 localStoragesessionStorage 中,因为这些存储空间可以被恶意脚本访问。
        • 推荐方案:存储在内存中。当用户关闭浏览器标签页时,令牌就会被清除。这意味着用户每次打开新标签页都需要重新获取令牌,但这是为了安全牺牲一些便利性。
        • 次优方案:存储在HTTP-only Cookie 中。这种 Cookie 只能由服务器设置,无法通过 JavaScript 脚本读取,有效防止了 XSS 攻击。但是,它仍然可能受到 CSRF 攻击的风险。

      2. 刷新令牌 (Refresh Token)

      刷新令牌是用于获取新的访问令牌的凭证。它通常是一个有效期很长的令牌,可以用于在访问令牌过期后,无感地重新获取一个新的。

      • 作用:当访问令牌过期时,客户端携带刷新令牌向认证服务器请求一个新的访问令牌。
      • 有效期很长,通常是几天、几周甚至几个月。
      • 存储方案:由于刷新令牌是获取访问令牌的关键,它的存储必须绝对安全
        • 推荐方案:存储在HTTP-only Cookie 中。HTTP-only 属性可以防止恶意脚本(XSS)读取该令牌。同时,由于刷新令牌只发送给认证服务器,并且不用于访问其他 API,可以采取一些额外的安全措施,如 CSRF 令牌或 SameSite Cookie 属性,来防止 CSRF 攻击。
        • 注意:刷新令牌绝对不应该存储在 localStorage 中。

      双令牌工作流程(结合存储方案)

      1. 首次登录
        • 用户通过用户名/密码登录。
        • 认证服务器验证通过后,生成一个访问令牌(Access Token)和一个刷新令牌(Refresh Token)
        • 服务器将刷新令牌设置到 HTTP-only Cookie 中,发送给浏览器。
        • 服务器将访问令牌返回给前端,前端将其存储在内存中
      2. 访问受保护资源
        • 前端发起 API 请求时,从内存中取出访问令牌,并将其放在请求头(如 Authorization: Bearer <AccessToken>)中发送给后端。
        • 后端 API 服务器验证访问令牌的有效性,如果有效则返回数据。
      3. 访问令牌过期
        • 一段时间后,访问令牌过期。前端再次发起 API 请求时,后端会返回一个 401 Unauthorized 错误。
        • 前端捕获到这个错误后,会发起一个刷新请求(通常是向一个专门的 /refresh 接口),这个请求会自动带上存储在 HTTP-only Cookie 中的刷新令牌。
      4. 刷新令牌工作
        • 认证服务器收到刷新请求,验证刷新令牌的有效性。
        • 如果刷新令牌有效,服务器会生成一个新的访问令牌和(可选)一个新的刷新令牌
        • 服务器将新的访问令牌返回给前端,前端更新内存中的令牌。
        • 服务器将新的刷新令牌设置到 HTTP-only Cookie 中。
        • 前端使用新的访问令牌重新发起之前的 API 请求。

      为什么这个方案更安全?

      1. 防止 XSS 攻击:最敏感的刷新令牌存储在 HTTP-only Cookie 中,脚本无法读取。而访问令牌虽然存储在内存中,但其有效期短,即使被盗,也很快会失效。
      2. 防止 CSRF 攻击:刷新令牌通常只用于一个特定的 /refresh 接口,并且可以与 CSRF 令牌(或 SameSite Cookie)结合使用,进一步降低风险。同时,由于其他 API 接口不依赖于 Cookie 中的刷新令牌,而是依赖于请求头中的访问令牌,因此无法被 CSRF 攻击者利用。
      3. 提升用户体验:尽管访问令牌有效期很短,但用户无需频繁登录,因为刷新令牌可以在后台无感地获取新令牌。

      总之,双令牌方案通过将令牌职责分离和采用不同的存储机制,在保证用户体验的同时,显著提升了系统的安全性,是现代 Web 应用中一种非常成熟和推荐的认证模式。

  • 面试题 2:“用户点击“退出登录”时,Token 如何失效?”
    • 回答要点:
      • 核心观点:无状态 JWT 的特性决定了“退出登录”的本质

        首先,要明确指出 JWT 的核心特点是无状态。这意味着服务器无法主动让一个已经签发出去的令牌失效,这与传统的 Session 机制(服务器可以主动销毁会话)有本质区别。因此,“退出登录”的实现方式也截然不同。

        1. 客户端实现(常规退出登录)

        • 实现方式:这是最简单、最常见的方案,适用于用户主动退出登录的情况。
        • 具体操作
          • 前端代码在用户点击“退出登录”时,执行一个简单的操作:从本地存储(如 localStorage、sessionStorage、内存或 Cookie)中删除 Access Token
          • 一旦令牌被删除,前端后续的请求将无法携带有效的 Authorization 头,服务器会返回 401 错误,从而实现了“退出登录”的效果。
        • 局限性:这种方法只是一个客户端行为。如果令牌被复制或被攻击者截获,它在过期之前仍然是有效的。

        2. 服务端实现(强制下线或即时失效)

        • 引入背景:当需要实现更严格的安全需求时,如“管理员踢人下线”、“用户修改密码后所有设备下线”或“令牌泄露后立即失效”,单纯的客户端清除是不够的。此时,我们必须打破无状态的特性,引入服务端的状态管理。
        • 方案一:黑名单机制 (Blacklist)
          • 工作原理
            1. 服务端维护一个高效、可快速查找的黑名单列表(通常使用 Redis 的 Set 数据结构,因为它具有 O(1) 的查找效率)。
            2. 在用户退出登录或需要强制下线时,将该 JWT 的唯一标识 jti(或整个令牌本身)添加到这个黑名单中。
            3. 为黑名单中的条目设置一个过期时间,该过期时间应与令牌本身的剩余有效期一致。
            4. 在每次验证 JWT 的过程中,除了校验签名和过期时间外,额外增加一步:检查该令牌的 jti 是否存在于黑名单中。如果存在,则认证失败。
        • 方案二:基于 Refresh Token 的管理
          • 工作原理:这种方案是基于双令牌模式设计的,更优雅地解决了问题。
            1. 服务器在签发 Access TokenRefresh Token 时,会将 Refresh Token 存入数据库或 Redis 中。
            2. 当用户点击“退出登录”或需要强制下线时,服务器删除标记失效存储在服务器端的 Refresh Token
            3. 当用户的 Access Token 过期后,它将无法通过 Refresh Token 换取新的 Access Token,因为服务器端的 Refresh Token 已经失效。这样,用户就无法继续保持登录状态。

        总结与对比

        方案 优点 缺点 适用场景
        客户端清除 实现简单,开销小。 安全性最低,无法强制下线。 用户主动退出登录。
        黑名单机制 能够实现即时下线,安全可控。 增加了服务端开销(额外的 Redis 存储和查询)。 强制下线、修改密码、令牌泄露。
        Refresh Token 方案 既实现了即时下线,又无需频繁查询黑名单,架构更优雅。 必须采用双令牌模式,实现相对复杂。 现代前后端分离应用,对安全性要求较高。
  • 面试题 3:“请你设计一个支持 Web 端和 App 端统一登录的认证系统。”
    • 回答要点:
      • 这正是 Token 认证机制的典型应用场景。我会采用基于 OAuth 2.0/OIDC 或自定义的**双令牌(Access/Refresh Token)**方案。
      • 统一认证中心 (UAC): 建立一个独立的认证服务,负责处理所有客户端(Web, iOS, Android)的登录、注册、Token 签发和刷新。
      • API 网关: 所有业务请求都通过 API 网关。网关的核心职责之一就是统一鉴权。它会拦截所有请求,解析 Authorization 头中的 Access Token,调用认证中心或自行验证 Token 的有效性。验证通过后,可以将解析出的用户信息(如用户ID)附加到请求头中,再转发给后端的业务微服务。
      • 业务微服务: 业务微服务本身不再关心 Token 的验证细节,它们信任来自网关的请求,直接从请求头中获取用户信息进行业务处理,实现了业务与认证的解耦。
      • 流程:
        1. Web/App 客户端引导用户到认证中心进行登录。
        2. 登录成功后,认证中心返回 Access TokenRefresh Token
        3. 客户端保存令牌,后续访问业务 API 时,在请求头携带 Access Token
        4. API 网关拦截请求,验证 Access Token
        5. Access Token 过期后,客户端使用 Refresh Token 向认证中心申请新令牌。
      • 这个架构具有高内聚、低耦合、可扩展性强、安全性高的优点。

Redis 详细技术解析

Redis 核心架构与原理

内存模型与数据结构

Redis采用基于内存的存储架构,所有数据都保存在RAM中,这是其高性能的根本原因。Redis使用了多种底层数据结构来实现上层的抽象数据类型:

SDS(Simple Dynamic String) Redis没有直接使用C语言的字符串,而是构建了SDS。SDS在字符串头部记录了长度信息,避免了strlen的O(n)复杂度,同时预分配空间减少内存重分配次数。

跳跃表(Skip List) 有序集合的底层实现之一,是一种随机化的数据结构,通过多层链表实现O(log N)的查找复杂度。相比红黑树,跳跃表实现更简单,且支持范围查询。

压缩列表(Ziplist) 当哈希、列表、有序集合元素较少时使用的紧凑存储结构,所有元素存储在一块连续内存中,节省内存但插入删除效率较低。

字典(Dict) Redis的核心数据结构,使用开放寻址法解决哈希冲突,支持渐进式rehash。当负载因子过高时,会创建新的哈希表并逐步迁移数据。

单线程模型与事件循环

Redis 6.0之前采用单线程模型处理客户端请求,通过I/O多路复用(epoll/kqueue)实现高并发。单线程避免了线程切换开销和并发控制问题,但也限制了CPU利用率。

事件循环机制 Redis使用Reactor模式的事件循环,分为文件事件和时间事件:

  • 文件事件:处理客户端连接、读写请求
  • 时间事件:处理定时任务,如过期键删除、持久化等

Redis 6.0引入了多线程I/O,但命令执行仍是单线程,多线程只用于网络I/O操作的读写,这样既提高了网络处理能力,又保持了数据操作的原子性。

数据类型详解与应用场景

Redis 的数据结构详解


这是 Redis 最有特色的地方,理解这些数据结构是掌握 Redis 的关键。

1. String (字符串)

  • 最基本的数据结构,可以存储任何类型的数据,比如字符串、数字或二进制数据。一个键最多可以存储 512MB 的值。
  • 常用命令:
    • SET key value: 设置键值对。
    • GET key: 获取键对应的值。
    • INCR key: 将键对应的值加 1,常用于计数器。
    • MSET key1 value1 key2 value2: 同时设置多个键值对。
    • MGET key1 key2: 同时获取多个键的值。
  • 应用场景: 缓存、计数器、分布式锁等。

2. Hash (哈希)

  • 类似于一个键值对的集合。一个哈希键可以存储多个字段(field)和对应的值(value),非常适合存储对象。
  • 常用命令:
    • HSET key field value: 设置哈希键中一个字段的值。
    • HGET key field: 获取哈希键中一个字段的值。
    • HGETALL key: 获取哈希键中所有的字段和值。
    • HMSET key field1 value1 field2 value2: 同时设置多个字段。
    • HDEL key field1 field2: 删除一个或多个字段。
  • 应用场景: 存储用户信息(如user:100作为键,nameagecity作为字段),商品信息等。

3. List (列表)

  • 一个有序的、可以重复的字符串集合。可以从列表的头部或尾部添加或删除元素。
  • 常用命令:
    • LPUSH key value1 value2: 从列表的头部插入一个或多个值。
    • RPUSH key value1 value2: 从列表的尾部插入一个或多个值。
    • LPOP key: 从列表的头部移除并返回一个值。
    • RPOP key: 从列表的尾部移除并返回一个值。
    • LRANGE key start stop: 获取列表中指定范围的元素。
  • 应用场景: 消息队列(队列、栈)、最新文章列表、历史记录等。

4. Set (集合)

  • 一个无序的、不重复的字符串集合。集合提供了交集、并集和差集等操作。
  • 常用命令:
    • SADD key member1 member2: 向集合中添加一个或多个成员。
    • SMEMBERS key: 获取集合中的所有成员。
    • SISMEMBER key member: 判断一个成员是否在集合中。
    • SINTER key1 key2: 求两个集合的交集。
    • SUNION key1 key2: 求两个集合的并集。
  • 应用场景: 社交网络中的共同好友、标签系统、去重等。

5. Sorted Set (有序集合)

  • 和集合类似,但每个成员都会关联一个分数(score)。通过这个分数,可以对集合中的成员进行排序。分数可以重复,但成员必须唯一。
  • 常用命令:
    • ZADD key score1 member1 score2 member2: 向有序集合中添加一个或多个带分数的成员。
    • ZRANGE key start stop: 按分数从小到大排序,获取指定范围的成员。
    • ZREVRANGE key start stop: 按分数从大到小排序,获取指定范围的成员。
    • ZSCORE key member: 获取一个成员的分数。
    • ZINCRBY key increment member: 给指定成员的分数增加指定值。
  • 应用场景: 排行榜(游戏分数、热点新闻)、带权重的任务队列等。

Redis 的持久化机制


Redis 提供了两种持久化方式,可以将数据从内存保存到硬盘,确保数据不会因为服务重启而丢失。

1. RDB (Redis Database)

  • 快照方式。它会在指定的时间间隔内,将内存中的数据快照写入到磁盘上一个二进制文件中(dump.rdb)。
  • 优点: RDB 文件是一个非常紧凑的二进制文件,非常适合备份和灾难恢复。
  • 缺点: 每次保存都会丢失从上次快照到现在之间的数据。

2. AOF (Append Only File)

  • 日志方式。它会记录每一次对 Redis 数据库的写操作命令,并以追加(append)的方式写入到文件中。当 Redis 重启时,会重新执行 AOF 文件中的命令来恢复数据。
  • 优点: 数据丢失风险低,可以配置为每秒同步一次,或者每执行一个命令就同步一次。
  • 缺点: AOF 文件通常比 RDB 文件大,且恢复速度可能较慢。

在实际应用中,通常会同时使用 RDB 和 AOF,以获得更高的可靠性。

String类型

String是Redis最基础的数据类型,底层可以是SDS、整数或浮点数。

常用命令:SET、GET、INCR、DECR、APPEND、GETRANGE 应用场景

  • 缓存:存储用户会话、配置信息
  • 计数器:网站访问量、点赞数(INCR原子性保证)
  • 分布式锁:SET key value NX EX seconds
  • 限流:结合EXPIRE实现滑动窗口限流

Hash类型

Hash类型适合存储对象,避免了将对象序列化为JSON字符串的开销。

底层实现:元素较少时使用ziplist,元素较多时使用hashtable 应用场景

  • 存储用户信息:HSET user:1001 name “张三” age 25
  • 购物车:HSET cart:1001 product:123 2
  • 配置管理:分类存储不同模块的配置

List类型

List是双向链表实现,支持在两端进行O(1)的插入和删除操作。

常用命令:LPUSH、RPUSH、LPOP、RPOP、LRANGE、BLPOP 应用场景

  • 消息队列:生产者LPUSH,消费者BRPOP实现阻塞队列
  • 最新列表:朋友圈动态、商品评论
  • 栈和队列:LPUSH+LPOP实现栈,LPUSH+RPOP实现队列

Set类型

Set是无序集合,元素唯一,底层使用hashtable或intset实现。

集合运算:SINTER(交集)、SUNION(并集)、SDIFF(差集) 应用场景

  • 去重:统计网站独立访客
  • 社交关系:共同好友、推荐用户
  • 标签系统:用户标签、文章分类
  • 抽奖系统:SRANDMEMBER随机抽取

Sorted Set类型

有序集合,每个元素关联一个分数,按分数排序。底层使用跳跃表和哈希表。

应用场景

  • 排行榜:游戏积分、热搜榜
  • 延时队列:分数为执行时间戳
  • 范围查询:按时间、按分数范围获取数据

持久化机制深度解析

RDB持久化

RDB通过fork子进程,将内存数据快照写入磁盘。

优点

  • 文件紧凑,适合备份和灾难恢复
  • 恢复速度快
  • 对Redis性能影响小(子进程操作)

缺点

  • 数据丢失风险:两次快照间的数据可能丢失
  • fork过程会阻塞主进程
  • 大数据集fork耗时较长

触发条件

  • 手动执行SAVE或BGSAVE命令
  • 配置自动触发:save 900 1(900秒内至少1个键改变)
  • 主从复制时自动生成RDB

AOF持久化

AOF记录每个写命令,通过重放命令恢复数据。

写入时机

  • always:每个写命令立即同步,安全但性能低
  • everysec:每秒同步一次,平衡安全性和性能
  • no:由操作系统决定同步时机,性能高但安全性低

AOF重写: AOF文件会越来越大,Redis提供重写机制优化:

  • 遍历内存数据,用最少命令重建AOF文件
  • 重写期间的新命令写入AOF重写缓冲区
  • 原子性替换旧AOF文件

混合持久化: Redis 4.0引入RDB+AOF混合模式,重写时以RDB格式写入基础数据,增量命令以AOF格式追加,兼顾了恢复速度和数据安全。

缓存问题与解决方案

缓存穿透

查询不存在的数据,缓存无法生效,请求直达数据库。

解决方案

  1. 空值缓存:查询结果为空时也缓存,设置较短过期时间
1
2
3
if (data == null) {
redis.setex(key, 60, "null"); // 缓存空值60秒
}
  1. 布隆过滤器:预先将所有可能存在的数据哈希到位数组
1
2
3
4
5
BloomFilter<String> filter = BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()), 1000000, 0.01);
// 查询前先检查布隆过滤器
if (!filter.mightContain(key)) {
return null; // 一定不存在
}
  1. 参数校验:在API层面进行参数合法性校验

缓存击穿

热点数据过期瞬间,大量并发请求击穿缓存。

解决方案

  1. 互斥锁:只允许一个线程查询数据库并重建缓存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public String getData(String key) {
String data = redis.get(key);
if (data == null) {
String lockKey = "lock:" + key;
if (redis.setnx(lockKey, "1", 10)) { // 获取锁
try {
data = database.query(key); // 查询数据库
redis.setex(key, 3600, data); // 重建缓存
} finally {
redis.del(lockKey); // 释放锁
}
} else {
Thread.sleep(50); // 等待其他线程重建缓存
return getData(key); // 递归重试
}
}
return data;
}
  1. 热点数据永不过期:逻辑上设置过期时间,物理上不过期,异步更新
  2. 预热机制:系统启动时预先加载热点数据
  3. 二级缓存:L1缓存过期后,先返回L2缓存数据,异步更新L1

缓存雪崩

大量缓存同时过期或Redis宕机,请求涌向数据库。

解决方案

  1. 过期时间随机化:避免同时过期
1
2
int randomExpire = baseExpire + new Random().nextInt(300);  // 基础时间+随机时间
redis.setex(key, randomExpire, data);
  1. 多级缓存架构
    • L1:本地缓存(如Caffeine)
    • L2:Redis分布式缓存
    • L3:数据库
  2. 限流降级:使用Sentinel、Hystrix等组件
  3. Redis高可用:主从复制、哨兵模式、集群部署

缓存预热

系统启动时预先加载热点数据到缓存。

实现方式

  1. 定时任务预热:凌晨低峰期执行
  2. 手动预热:管理后台触发预热任务
  3. 实时预热:监控系统发现热点数据自动预热

数据一致性保证

Cache Aside模式(旁路缓存)

应用程序负责维护缓存和数据库的一致性。

读操作

  1. 先读缓存,命中则返回
  2. 缓存不命中,查询数据库
  3. 将数据写入缓存并返回

写操作

  1. 先更新数据库
  2. 删除缓存(让下次读取时重新加载)

为什么是删除而不是更新缓存?

  • 更新缓存可能存在并发问题
  • 复杂查询的缓存更新成本高
  • 删除缓存更简单可靠

延时双删策略

解决读写并发导致的数据不一致问题。

实现步骤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void updateData(String key, Object data) {
// 1. 删除缓存
redis.del(key);

// 2. 更新数据库
database.update(data);

// 3. 延时删除缓存(异步)
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(500); // 延时500ms
redis.del(key); // 再次删除缓存
} catch (InterruptedException e) {
log.error("延时删除缓存失败", e);
}
});
}

延时时间设置:通常为主从同步时间 + 读数据库时间 + 几十毫秒

基于消息队列的最终一致性

使用消息队列异步处理缓存更新,保证最终一致性。

实现流程

  1. 更新数据库,发送消息到队列
  2. 消息消费者删除相关缓存
  3. 消费失败时重试,保证最终一致性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 发送缓存删除消息
@Transactional
public void updateUser(User user) {
userDao.update(user); // 更新数据库

// 发送缓存删除消息(事务提交后发送)
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
cacheDeleteMQ.send("user:" + user.getId());
}
}
);
}

分布式事务方案

对于强一致性要求高的场景,可以使用分布式事务。

2PC(两阶段提交)

  • 准备阶段:协调者询问参与者是否准备好
  • 提交阶段:所有参与者都准备好则提交,否则回滚

TCC(Try-Confirm-Cancel)

  • Try:尝试执行,预留资源
  • Confirm:确认提交
  • Cancel:取消执行,释放资源

分布式锁实现

基于SET命令的分布式锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class RedisDistributedLock {
private Jedis jedis;

public boolean tryLock(String lockKey, String requestId, int expireTime) {
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
return "OK".equals(result);
}

public boolean releaseLock(String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey),
Collections.singletonList(requestId));
return "1".equals(result.toString());
}
}

使用方式

1
2
3
4
5
6
7
8
9
10
11
String lockKey = "lock:user:1001";
String requestId = UUID.randomUUID().toString();

if (tryLock(lockKey, requestId, 30000)) {
try {
// 执行业务逻辑
doSomething();
} finally {
releaseLock(lockKey, requestId);
}
}

Redlock算法

为了解决单点故障问题,Redis官方提出了Redlock算法。

算法步骤

  1. 获取当前时间戳
  2. 依次向N个Redis实例申请锁
  3. 如果在大多数实例(N/2+1)上获取锁成功,且总耗时小于锁超时时间,则认为获取锁成功
  4. 锁的有效时间 = 初始有效时间 - 获取锁消耗的时间
  5. 释放锁时,向所有Redis实例发送释放命令
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Redlock {
private List<Jedis> jedisInstances;

public boolean lock(String resource, int ttl) {
int quorum = jedisInstances.size() / 2 + 1;
int successCount = 0;
long startTime = System.currentTimeMillis();

for (Jedis jedis : jedisInstances) {
if (lockInstance(jedis, resource, ttl)) {
successCount++;
}
}

long elapsedTime = System.currentTimeMillis() - startTime;
if (successCount >= quorum && elapsedTime < ttl) {
return true;
} else {
unlock(resource); // 释放已获取的锁
return false;
}
}
}

锁的问题与优化

锁超时问题: 业务执行时间超过锁超时时间,锁自动释放,可能导致并发问题。

解决方案

  1. 看门狗机制:定时续期锁的过期时间
1
2
3
4
5
6
7
8
9
10
11
12
13
public class WatchDog {
private ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);

public void startWatchDog(String lockKey, String requestId) {
scheduler.scheduleAtFixedRate(() -> {
// 续期锁
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('expire', KEYS[1], ARGV[2]) else return 0 end";
jedis.eval(script, Arrays.asList(lockKey),
Arrays.asList(requestId, "30"));
}, 10, 10, TimeUnit.SECONDS);
}
}
  1. 合理评估业务执行时间:设置足够的锁超时时间

锁竞争激烈问题: 大量线程竞争同一把锁,导致性能下降。

解决方案

  1. 分段锁:将资源分段,减少锁竞争
  2. 队列锁:使用List实现公平锁
  3. 自旋锁优化:适当的退避算法

悲观锁与乐观锁

悲观锁

假设会发生并发冲突,在操作数据前先获取锁。

Redis实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 使用Redis分布式锁实现悲观锁
public void updateWithPessimisticLock(String userId, int amount) {
String lockKey = "lock:account:" + userId;
String requestId = UUID.randomUUID().toString();

if (tryLock(lockKey, requestId, 30000)) {
try {
// 查询账户余额
int balance = getBalance(userId);
if (balance >= amount) {
// 扣减余额
updateBalance(userId, balance - amount);
} else {
throw new InsufficientBalanceException();
}
} finally {
releaseLock(lockKey, requestId);
}
} else {
throw new LockAcquisitionException();
}
}

乐观锁

假设不会发生冲突,在更新时检查数据是否被修改。

基于版本号的乐观锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public boolean updateWithOptimisticLock(String userId, int amount, int expectedVersion) {
// 使用Lua脚本保证原子性
String script =
"local current = redis.call('hmget', KEYS[1], 'balance', 'version') " +
"if current[2] == ARGV[2] then " +
" if tonumber(current[1]) >= tonumber(ARGV[1]) then " +
" redis.call('hmset', KEYS[1], 'balance', current[1] - ARGV[1], 'version', current[2] + 1) " +
" return 1 " +
" else " +
" return -1 " + // 余额不足
" end " +
"else " +
" return 0 " + // 版本号不匹配
"end";

String key = "account:" + userId;
Object result = jedis.eval(script, Arrays.asList(key),
Arrays.asList(String.valueOf(amount), String.valueOf(expectedVersion)));

int code = ((Long) result).intValue();
if (code == 1) {
return true; // 更新成功
} else if (code == 0) {
throw new OptimisticLockException("数据已被修改");
} else {
throw new InsufficientBalanceException("余额不足");
}
}

基于CAS的乐观锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void updateWithCAS(String key, Function<String, String> updater) {
while (true) {
// 1. 获取当前值
String currentValue = redis.get(key);

// 2. 计算新值
String newValue = updater.apply(currentValue);

// 3. CAS更新
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('set', KEYS[1], ARGV[2]) else return nil end";
Object result = redis.eval(script, Arrays.asList(key),
Arrays.asList(currentValue, newValue));

if (result != null) {
break; // 更新成功
}
// 更新失败,重试
}
}

悲观锁 vs 乐观锁选择

悲观锁适用场景

  • 写操作频繁,冲突概率高
  • 业务逻辑复杂,重试成本高
  • 对数据一致性要求严格

乐观锁适用场景

  • 读多写少,冲突概率低
  • 业务逻辑简单,重试成本低
  • 对性能要求高

高可用架构

主从复制

Master负责写操作,Slave负责读操作,通过复制实现数据同步。

复制原理

  1. Slave向Master发送PSYNC命令
  2. Master执行BGSAVE生成RDB文件
  3. Master将RDB文件发送给Slave
  4. Slave载入RDB文件
  5. Master将缓冲区的写命令发送给Slave
  6. 后续写命令实时同步

部分重同步: 网络断连后,Slave只需要同步断连期间的命令,而不是完整重同步。

哨兵模式(Sentinel)

哨兵负责监控Master状态,在Master故障时自动进行故障转移。

哨兵职责

  • 监控:定期ping Master和Slave
  • 通知:故障时通知管理员和客户端
  • 故障转移:自动将Slave提升为新Master
  • 配置管理:为客户端提供服务发现

故障转移流程

  1. 哨兵发现Master下线(主观下线)
  2. 多个哨兵确认Master下线(客观下线)
  3. 选举领导哨兵执行故障转移
  4. 选择合适的Slave作为新Master
  5. 修改其他Slave的配置指向新Master
  6. 通知客户端Master地址变更

集群模式(Cluster)

Redis Cluster通过分片实现横向扩展和高可用。

分片算法: 使用CRC16算法计算key的哈希值,然后对16384取模得到槽位号。

节点通信: 使用Gossip协议在节点间交换状态信息,包括节点上线/下线、槽位分配等。

故障转移: 当Master节点故障时,其Slave自动提升为新Master,过程对客户端透明。

数据迁移: 集群扩容时,需要将部分槽位从现有节点迁移到新节点。

Redis 哈希槽(Hash Slot)详解

在 Redis Cluster 中,数据不是存储在单个节点上,而是被分散存储在多个节点上。为了实现这种分散存储,Redis 引入了 哈希槽(hash slot) 的概念。

1. 什么是哈希槽?

Redis Cluster 一共有 16384 个哈希槽(slot),编号从 0 到 16383。集群中的每个键(key)都会通过一个固定的算法映射到这 16384 个哈希槽中的一个,然后这些哈希槽会被分配给集群中的不同节点。

2. 键如何映射到哈希槽?

Redis 使用一个简单的算法来决定一个键属于哪个哈希槽:
$$
slot=CRC16(key)(mod16384)
$$

  • CRC16(key):对键名执行 CRC16 校验和计算。
  • mod 16384:将 CRC16 校验和的结果对 16384 取模,得到的结果就是这个键所属的哈希槽编号。

例如,如果你有一个键 mykey,Redis 会计算 CRC16('mykey') % 16384,得到一个 0 到 16383 之间的数字。

3. 哈希槽如何分配给节点?

集群中的每个节点负责管理一部分哈希槽。例如,一个三节点的集群可以这样分配哈希槽:

  • 节点 A:负责哈希槽 0 到 5460。
  • 节点 B:负责哈希槽 5461 到 10922。
  • 节点 C:负责哈希槽 10923 到 16383。

当一个客户端需要操作一个键时,它会首先计算这个键所属的哈希槽,然后根据哈希槽与节点的映射关系,找到负责该哈希槽的节点,并向其发送请求。

如果客户端请求的键不在当前连接的节点上,这个节点会返回一个 MOVED 重定向错误,告诉客户端正确的节点地址,客户端会根据这个地址重新发起请求。

4. 哈希槽的优势

  • 高可用性:当一个节点失效时,它所负责的哈希槽可以被其他节点接管,从而保证数据的可用性。
  • 弹性伸缩
    • 添加节点:当需要扩展集群时,可以添加新节点,并从现有节点上迁移一部分哈希槽到新节点上,这个过程是增量的,不需要停机。
    • 移除节点:当需要缩减集群时,可以将某个节点上的所有哈希槽迁移到其他节点,然后移除该节点。

性能监控与优化

慢查询日志

Redis提供慢查询日志功能,记录执行时间超过阈值的命令。

配置参数

1
2
slowlog-log-slower-than 10000  # 超过10毫秒记录
slowlog-max-len 128 # 最多保存128条记录

查看慢查询

1
SLOWLOG GET 10  # 获取最近10条慢查询

内存分析

使用MEMORY命令分析内存使用情况。

1
2
3
MEMORY USAGE key         # 查看key占用内存
MEMORY STATS # 查看内存统计信息
MEMORY DOCTOR # 内存使用建议

性能优化建议

避免大key

  • 单个key的value不要超过10KB
  • 集合类型元素数量控制在合理范围
  • 使用SCAN代替KEYS命令

合理使用数据结构

  • 小对象使用Hash而不是多个String
  • 合理设置ziplist等压缩结构的阈值
  • 使用位图(bitmap)存储布尔类型大数据集

网络优化

  • 使用Pipeline批量操作
  • 合理设置客户端连接池
  • 启用TCP_NODELAY选项

持久化优化

  • 根据业务需求选择RDB或AOF
  • 合理配置自动保存条件
  • 在从节点上进行持久化操作

这些详细的技术点涵盖了Redis的核心概念、常见问题解决方案和实际应用场景,是Redis技术面试的重要考查内容。掌握这些知识点并能结合实际项目经验进行说明,将大大提高面试通过率。

内存管理与淘汰机制

内存淘汰策略详解

当Redis内存使用达到maxmemory限制时,会根据配置的策略淘汰数据。

8种淘汰策略

1
2
3
4
5
6
7
8
9
10
11
# 针对所有key
noeviction # 不淘汰,写入返回错误
allkeys-lru # 所有key中淘汰最近最少使用
allkeys-lfu # 所有key中淘汰最少频率使用
allkeys-random # 所有key中随机淘汰

# 针对设置了过期时间的key
volatile-lru # 过期key中淘汰最近最少使用
volatile-lfu # 过期key中淘汰最少频率使用
volatile-random # 过期key中随机淘汰
volatile-ttl # 过期key中淘汰即将过期的

LRU vs LFU 实现细节: Redis的LRU并非严格的LRU,而是近似LRU算法:

  • 每个key都有24位的时钟字段记录访问时间
  • 淘汰时随机采样5个key(可配置),选择时钟值最小的

LFU算法维护访问频率:

  • 高16位存储上次访问时间
  • 低8位存储访问频率计数器
  • 计数器采用概率性递增,避免频率无限增长

内存碎片问题

产生原因

  • 频繁的数据更新导致内存分配/释放
  • Redis使用jemalloc内存分配器,存在内存对齐
  • 删除大key后留下内存空洞

检测方法

1
2
3
4
INFO memory
# 关注 mem_fragmentation_ratio 指标
# 比值 > 1.5 表示碎片较多
# 比值 < 1 表示使用了swap,性能严重下降

解决方案

  1. 内存整理(Redis 4.0+):
1
2
3
CONFIG SET activedefrag yes        # 开启自动整理
CONFIG SET active-defrag-threshold-lower 10 # 碎片率超过10%启动
MEMORY PURGE # 手动触发整理
  1. 重启Redis:最彻底但影响服务可用性
  2. 优化数据结构:减少小对象,使用Hash存储相关数据

过期策略与删除机制

三种过期删除策略

定时删除:设置过期时间时创建定时器,到期立即删除

  • 优点:及时释放内存
  • 缺点:消耗CPU资源创建和管理定时器

惰性删除:访问key时检查是否过期,过期则删除

  • 优点:CPU友好,只在必要时删除
  • 缺点:内存不友好,过期key可能长期占用内存

定期删除:定期随机检查部分key,删除过期的

  • Redis的实际策略,平衡CPU和内存使用

Redis过期删除实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 简化的过期删除逻辑
void activeExpireCycle(int type) {
static int current_db = 0;
static int timelimit_exit = 0;

for (int i = 0; i < server.dbnum; i++) {
redisDb *db = server.db + current_db % server.dbnum;

int iteration = 0;
while (iteration < 20) { // 最多检查20个key
long sampled = 0, expired = 0;

// 随机选择key检查过期
for (int i = 0; i < 20; i++) {
dictEntry *de = dictGetRandomKey(db->expires);
if (de == NULL) break;

sampled++;
if (keyIsExpired(db, de)) {
deleteExpiredKey(db, de);
expired++;
}
}

// 如果过期比例超过25%,继续下轮检查
if (expired > 5) iteration++;
else break;
}
current_db++;
}
}

数据结构底层实现深度解析

压缩列表(ZipList)演进

Redis 7.0用ListPack替代了ZipList,解决了级联更新问题。

ZipList问题

1
2
3
// ZipList结构导致的级联更新
// 当插入元素导致某个entry的长度改变时
// 可能引起后续所有entry的重新编码

ListPack优势

  • 每个元素独立编码,避免级联更新
  • 支持从尾部遍历,提高某些操作效率

字典扩容与rehash

Redis字典使用增量式rehash避免阻塞:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 渐进式rehash实现
int dictRehash(dict *d, int n) {
int empty_visits = n * 10;

if (!dictIsRehashing(d)) return 0;

while (n-- && d->ht[0].used != 0) {
// 跳过空桶
while (d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
if (--empty_visits == 0) return 1;
}

// 迁移桶中所有元素
dictEntry *de = d->ht[0].table[d->rehashidx];
while (de) {
dictEntry *nextde = de->next;
unsigned int h = dictHashKey(d, de->key) & d->ht[1].sizemask;
de->next = d->ht[1].table[h];
d->ht[1].table[h] = de;
d->ht[0].used--;
d->ht[1].used++;
de = nextde;
}
d->ht[0].table[d->rehashidx] = NULL;
d->rehashidx++;
}

return 0;
}

网络模型与性能优化

Redis 6.0 多线程I/O

多线程只用于网络I/O,命令执行仍是单线程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 多线程I/O处理流程
void *IOThreadMain(void *myid) {
while (1) {
// 等待主线程分配任务
pthread_mutex_lock(&io_threads_mutex[id]);

while (io_threads_list[id] && io_threads_pending[id] == 0) {
pthread_cond_wait(&io_threads_cond[id], &io_threads_mutex[id]);
}

// 处理读写任务
listNode *ln;
listIter li;
listRewind(io_threads_list[id], &li);

while ((ln = listNext(&li))) {
client *c = listNodeValue(ln);
if (io_threads_op == IO_THREADS_OP_WRITE) {
writeToClient(c, 0);
} else if (io_threads_op == IO_THREADS_OP_READ) {
readQueryFromClient(c->conn);
}
}

io_threads_pending[id] = 0;
pthread_mutex_unlock(&io_threads_mutex[id]);
}
}

客户端连接管理

连接池配置优化

1
2
3
4
5
6
7
8
9
10
11
12
13
// Jedis连接池配置
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(200); // 最大连接数
config.setMaxIdle(50); // 最大空闲连接
config.setMinIdle(10); // 最小空闲连接
config.setTestOnBorrow(true); // 获取连接时测试
config.setTestOnReturn(true); // 归还连接时测试
config.setTestWhileIdle(true); // 空闲时测试
config.setTimeBetweenEvictionRunsMillis(30000); // 检查间隔
config.setNumTestsPerEvictionRun(10); // 每次检查连接数
config.setMinEvictableIdleTimeMillis(60000); // 最小空闲时间

JedisPool pool = new JedisPool(config, "localhost", 6379);

Pipeline优化

1
2
3
4
5
6
7
8
9
10
11
12
13
// Pipeline批量操作
public void batchSet(Map<String, String> data) {
try (Jedis jedis = pool.getResource()) {
Pipeline pipeline = jedis.pipelined();

for (Map.Entry<String, String> entry : data.entrySet()) {
pipeline.set(entry.getKey(), entry.getValue());
}

List<Object> results = pipeline.syncAndReturnAll();
// 处理结果
}
}

高级数据类型与应用

HyperLogLog

用于基数统计,占用内存固定(12KB),误差率0.81%。

实现原理

  • 基于概率算法,通过观察随机数的最大前导零个数估算基数
  • 使用调和平均数减少误差
  • 适用于UV统计等场景
1
2
3
4
# 网站UV统计
PFADD uv:20230815 user1 user2 user3
PFCOUNT uv:20230815 # 获取UV数量
PFMERGE uv:week uv:20230815 uv:20230816 # 合并多日数据

布隆过滤器(Redis Module)

1
2
3
4
5
6
# Redis布隆过滤器模块
BF.RESERVE myfilter 0.01 1000000 # 创建过滤器,误报率0.01%,预期元素100万
BF.ADD myfilter "user123" # 添加元素
BF.EXISTS myfilter "user123" # 检查元素是否存在
BF.MADD myfilter item1 item2 item3 # 批量添加
BF.MEXISTS myfilter item1 item2 item3 # 批量检查

Geo地理位置

基于Sorted Set实现,使用GeoHash算法。

1
2
3
4
5
6
7
8
9
10
11
# 添加地理位置
GEOADD cities 116.397128 39.916527 "北京" 121.473701 31.230416 "上海"

# 计算距离
GEODIST cities "北京" "上海" km

# 范围查询
GEORADIUS cities 116.397128 39.916527 1000 km WITHDIST WITHCOORD

# 根据成员查询
GEORADIUSBYMEMBER cities "北京" 1000 km

安全性问题与防护

常见安全漏洞

命令注入

1
2
3
4
5
6
// 错误做法:直接拼接用户输入
String key = "user:" + userInput; // userInput可能包含恶意命令
jedis.eval("return redis.call('get', '" + key + "')", 0);

// 正确做法:使用参数化查询
jedis.eval("return redis.call('get', KEYS[1])", 1, key);

未授权访问

1
2
3
4
5
6
# redis.conf安全配置
bind 127.0.0.1 # 绑定指定IP
requirepass your_password # 设置密码
protected-mode yes # 开启保护模式
port 0 # 禁用默认端口
port 6380 # 使用非标准端口

危险命令禁用

1
2
3
4
5
# 重命名危险命令
rename-command FLUSHDB "" # 禁用FLUSHDB
rename-command FLUSHALL "" # 禁用FLUSHALL
rename-command SHUTDOWN "REDIS_SHUTDOWN" # 重命名SHUTDOWN
rename-command CONFIG "REDIS_CONFIG" # 重命名CONFIG

ACL访问控制(Redis 6.0+)

1
2
3
4
5
6
7
8
9
10
11
# 创建用户
ACL SETUSER alice on >password123 ~cached:* +get +set

# 查看用户权限
ACL LIST

# 删除用户
ACL DELUSER alice

# 检查权限
AUTH alice password123

监控与运维

关键监控指标

性能指标

1
2
3
4
5
6
7
8
# 通过INFO命令获取
INFO stats
# 关注指标:
# - instantaneous_ops_per_sec: QPS
# - keyspace_hits/keyspace_misses: 命中率
# - used_cpu_sys/used_cpu_user: CPU使用率
# - connected_clients: 连接数
# - blocked_clients: 阻塞连接数

内存指标

1
2
3
4
5
6
INFO memory
# 关注指标:
# - used_memory: 已使用内存
# - used_memory_rss: 物理内存占用
# - mem_fragmentation_ratio: 内存碎片率
# - used_memory_peak: 内存使用峰值

持久化指标

1
2
3
4
5
INFO persistence
# 关注指标:
# - rdb_last_save_time: 最后RDB保存时间
# - aof_last_rewrite_time_sec: AOF重写耗时
# - aof_current_size: AOF文件大小

故障排查常用命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 客户端连接信息
CLIENT LIST
CLIENT INFO

# 慢查询分析
SLOWLOG GET 10
CONFIG GET slowlog-log-slower-than

# 大key查找
MEMORY USAGE keyname
redis-cli --bigkeys

# 热点key分析
redis-cli --hotkeys

# 延迟监控
LATENCY HISTORY command
CONFIG SET latency-monitor-threshold 100

特殊应用场景

分布式限流

固定窗口限流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class RedisRateLimiter {
public boolean isAllowed(String key, int limit, int window) {
long current = System.currentTimeMillis() / 1000 / window;
String windowKey = key + ":" + current;

String script =
"local count = redis.call('incr', KEYS[1]) " +
"if count == 1 then " +
" redis.call('expire', KEYS[1], ARGV[1]) " +
"end " +
"return count";

Long count = (Long) jedis.eval(script, Arrays.asList(windowKey),
Arrays.asList(String.valueOf(window)));

return count <= limit;
}
}

滑动窗口限流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public boolean slidingWindowLimit(String key, int limit, int window) {
long now = System.currentTimeMillis();
long windowStart = now - window * 1000;

String script =
"redis.call('zremrangebyscore', KEYS[1], 0, ARGV[1]) " +
"local count = redis.call('zcard', KEYS[1]) " +
"if count < tonumber(ARGV[2]) then " +
" redis.call('zadd', KEYS[1], ARGV[3], ARGV[3]) " +
" redis.call('expire', KEYS[1], ARGV[4]) " +
" return 1 " +
"else " +
" return 0 " +
"end";

Long result = (Long) jedis.eval(script, Arrays.asList(key),
Arrays.asList(String.valueOf(windowStart), String.valueOf(limit),
String.valueOf(now), String.valueOf(window)));

return result == 1;
}

分布式Session

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Component
public class RedisSessionManager {

@Autowired
private RedisTemplate<String, Object> redisTemplate;

public void createSession(String sessionId, String userId, int timeout) {
String key = "session:" + sessionId;
Map<String, Object> sessionData = new HashMap<>();
sessionData.put("userId", userId);
sessionData.put("createTime", System.currentTimeMillis());

redisTemplate.opsForHash().putAll(key, sessionData);
redisTemplate.expire(key, timeout, TimeUnit.SECONDS);
}

public boolean isValidSession(String sessionId) {
String key = "session:" + sessionId;
return redisTemplate.hasKey(key);
}

public void renewSession(String sessionId, int timeout) {
String key = "session:" + sessionId;
if (redisTemplate.hasKey(key)) {
redisTemplate.expire(key, timeout, TimeUnit.SECONDS);
}
}
}

消息队列实现

基于List的简单队列

1
2
3
4
5
6
7
8
9
10
11
12
public class RedisQueue {
// 生产者
public void produce(String queue, String message) {
jedis.lpush(queue, message);
}

// 消费者(阻塞式)
public String consume(String queue, int timeout) {
List<String> result = jedis.brpop(timeout, queue);
return result != null ? result.get(1) : null;
}
}

基于Stream的消息队列(Redis 5.0+):

1
2
3
4
5
6
7
8
9
10
11
# 生产消息
XADD mystream * field1 value1 field2 value2

# 创建消费者组
XGROUP CREATE mystream mygroup $ MKSTREAM

# 消费消息
XREADGROUP GROUP mygroup consumer1 COUNT 10 STREAMS mystream >

# 确认消息
XACK mystream mygroup message_id

延时队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class RedisDelayQueue {

public void addDelayTask(String task, long delayTime) {
long executeTime = System.currentTimeMillis() + delayTime;
jedis.zadd("delay_queue", executeTime, task);
}

public List<String> getReadyTasks() {
long now = System.currentTimeMillis();
Set<String> tasks = jedis.zrangeByScore("delay_queue", 0, now);

if (!tasks.isEmpty()) {
// 原子性删除已获取的任务
String script =
"local tasks = redis.call('zrangebyscore', KEYS[1], 0, ARGV[1]) " +
"if #tasks > 0 then " +
" redis.call('zremrangebyscore', KEYS[1], 0, ARGV[1]) " +
"end " +
"return tasks";

@SuppressWarnings("unchecked")
List<String> result = (List<String>) jedis.eval(script,
Arrays.asList("delay_queue"), Arrays.asList(String.valueOf(now)));
return result;
}

return Collections.emptyList();
}
}

性能调优技巧

内存优化

使用Hash存储小对象

1
2
3
4
5
6
7
8
9
// 不推荐:为每个用户字段单独设置key
jedis.set("user:1001:name", "张三");
jedis.set("user:1001:age", "25");
jedis.set("user:1001:email", "zhang@example.com");

// 推荐:使用Hash存储
jedis.hset("user:1001", "name", "张三");
jedis.hset("user:1001", "age", "25");
jedis.hset("user:1001", "email", "zhang@example.com");

合理设置过期时间

1
2
3
4
// 避免同时过期导致雪崩
int baseExpire = 3600; // 1小时
int randomExpire = baseExpire + new Random().nextInt(300); // 增加0-5分钟随机时间
jedis.setex(key, randomExpire, value);

网络优化

批量操作优化

1
2
3
4
5
6
7
8
9
10
11
12
// 使用Pipeline
Pipeline pipeline = jedis.pipelined();
for (int i = 0; i < 1000; i++) {
pipeline.set("key" + i, "value" + i);
}
pipeline.sync();

// 或使用原生批量命令
Map<String, String> batch = new HashMap<>();
// ... 填充数据
jedis.mset(flattenMap(batch));

CPU优化

Lua脚本减少网络往返

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 原子性增加库存
local current = redis.call('get', KEYS[1])
if current == false then
return -1 -- key不存在
end

current = tonumber(current)
if current >= tonumber(ARGV[1]) then
redis.call('decrby', KEYS[1], ARGV[1])
return current - tonumber(ARGV[1])
else
return -2 -- 库存不足
end