网络基础
OSI七层模型
OSI (Open Systems Interconnection) 参考模型是一个抽象的、概念性的框架,旨在定义网络通信的功能分层,促进不同系统之间的互操作性。它将网络通信过程分为七个独立的层次,每层负责特定的功能。数据在发送端从上层向下层传输,每层添加自己的控制信息(封装),在接收端则从下层向上层传输,每层剥离对应的控制信息(解封装)。
- 功能:负责传输比特流,即原始的二进制数据。定义了物理媒介的电气特性、机械特性、功能特性和过程特性。
- 工作方式:不关心比特的含义,只负责将其从一个节点传输到另一个节点。例如,规定网络的接口形状、电压、传输速率等。
- 协议/设备:网线 (Ethernet Cable)、光纤 (Fiber Optic)、集线器 (Hub)、中继器 (Repeater)、网卡 (NIC) 的物理部分。
- 数据单位:比特 (Bit)。
- 功能:在物理层提供的不可靠比特流服务的基础上,将比特组合成帧,提供节点到节点之间的数据传输。负责差错控制(检测和纠正帧传输错误)、流量控制(防止发送方传输速度过快)和物理寻址 (MAC 地址)。
- 子层:通常分为 LLC (Logical Link Control) 子层(负责逻辑链路控制,向上层提供服务接口)和 MAC (Media Access Control) 子层(负责媒体访问控制,处理共享介质的访问,例如 CSMA/CD 协议)。
- 工作方式:在局域网内,通过 MAC 地址识别不同的设备,确保数据能够正确地在相邻节点之间传输。
- 协议/设备:Ethernet、PPP (Point-to-Point Protocol)、交换机 (Switch)、网桥 (Bridge)。
- 数据单位:帧 (Frame)。
- 功能:负责将数据包从源主机传输到目标主机,可能需要跨越多个网络(即路由)。主要功能包括逻辑寻址 (IP 地址)、路由选择(确定数据包传输路径)和拥塞控制。
- 工作方式:根据数据包中的 IP 地址,通过路由表决定数据包的转发路径,使其能够跨越不同的网络到达最终目的地。
- 协议/设备:IP (Internet Protocol)、ICMP (Internet Control Message Protocol,用于错误报告和查询)、IGMP (Internet Group Management Protocol,用于多播组管理)、路由器 (Router)。
- 数据单位:数据包 (Packet) 或 数据报 (Datagram)。
- 功能:负责端到端 (End-to-End) 的数据传输,即从源主机上的某个进程到目标主机上的某个进程的通信。提供分段与重组、错误检测、流量控制和拥塞控制。
- 工作方式:通过端口号来标识应用进程,使得数据能够正确地交付给目标主机上的特定应用程序。
- 协议/设备:
- TCP (Transmission Control Protocol):面向连接、可靠的、基于字节流的服务,提供顺序、无损的数据传输。
- UDP (User Datagram Protocol):无连接、不可靠的数据报服务,速度快,开销小,适用于对实时性要求高但允许少量丢包的应用(如音视频传输)。
- 数据单位:报文段 (Segment) (TCP) 或 用户数据报 (Datagram) (UDP)。
- 功能:负责管理和协调应用程序之间的会话(对话)。包括建立、管理和终止会话,以及数据同步(如设置同步点,在网络故障时从上一个同步点恢复)。
- 工作方式:提供了一种机制,使得应用程序可以有条不紊地进行通信,例如,在一次文件传输中,可以在传输中断后从断点续传。
- 协议/设备:NetBIOS, RPC (Remote Procedure Call), SQL, NFS。
- 数据单位:会话数据 (Session Data)。
- 功能:负责处理两个系统之间的数据表示,确保应用程序层接收到的信息是可用的。包括数据格式化、数据加密/解密、数据压缩/解压缩。
- 工作方式:将应用层的数据转换为网络标准格式(如 ASCII 转 EBCDIC),或将网络格式的数据转换为应用层可用的格式。例如,JPEG, MPEG 等图像视频格式的处理,以及 SSL/TLS 加密解密。
- 协议/设备:JPEG, MPEG, ASCII, Unicode, TLS/SSL (通常认为 TLS/SSL 位于会话层与表示层之间,或独立于此)。
- 数据单位:表示数据 (Presentation Data)。
- 功能:最靠近用户的一层,为用户提供网络服务。直接与用户应用程序进行交互,负责处理特定的应用程序细节。
- 工作方式:定义了应用程序如何访问网络服务,以及不同应用程序之间如何交换数据。
- 协议/设备:HTTP (Web 浏览)、FTP (文件传输)、SMTP (电子邮件发送)、POP3/IMAP (电子邮件接收)、DNS (域名解析)、Telnet, SNMP (简单网络管理协议)等。
- 数据单位:应用数据 (Application Data)。
TCP/IP 协议族
TCP/IP 模型是互联网的基础,它简化了 OSI 模型,通常分为四层:应用层、传输层、网络层、网络接口层(数据链路层和物理层合并)。我重点阐述其中的核心协议:
- TCP(传输控制协议):我理解 TCP 是一种可靠的、面向连接的、基于字节流的传输协议。其可靠性通过以下机制实现:
三次握手:这是 TCP 建立连接的过程,旨在确保通信双方都能正常收发数据,并协商初始序列号。这个过程就像打电话:
- 第一次握手 (SYN):
- 发起方:客户端。
- 报文类型:SYN 报文段 (SYN_FLAG=1)。SYN 标志位表示请求建立连接。
- 序列号 (Sequence Number):客户端随机生成一个初始序列号 client_ISN。这个序列号代表客户端接下来发送数据的第一个字节的序号。
- 确认号 (Acknowledgment Number):0(或随机值,但在此次请求中无实际意义,因为是首次请求,尚未收到对方的数据)。
- 窗口大小 (Window Size):客户端告知服务器自己的接收窗口大小 W1,用于流量控制。
- 客户端状态变化:客户端从 CLOSED 状态转换到 SYN_SENT 状态。
- 作用:客户端向服务器发送连接请求,并告知服务器自己发送数据时将使用的起始序列号 client_ISN。此时客户端进入同步发送状态,等待服务器确认。
- 第二次握手 (SYN-ACK):
- 发起方:服务器。
- 报文类型:SYN-ACK 报文段 (SYN_FLAG=1, ACK_FLAG=1)。SYN 标志位表示同意建立连接并发送自己的初始序列号,ACK 标志位表示确认收到客户端的请求。
- 序列号 (Sequence Number):服务器随机生成一个初始序列号 server_ISN。这个序列号代表服务器接下来发送数据的第一个字节的序号。
- 确认号 (Acknowledgment Number):服务器发送 ack = client_ISN + 1。这表示服务器已成功接收到客户端的 SYN 报文(其序列号为 client_ISN),并期望接收客户端的下一个字节的序列号是 client_ISN + 1。
- 窗口大小 (Window Size):服务器告知客户端自己的接收窗口大小 W2,用于流量控制。
- 服务器状态变化:服务器收到客户端的 SYN 后,从 LISTEN 状态转换到 SYN_RCVD 状态。发送 SYN-ACK 后,服务器仍然处于 SYN_RCVD 状态。
- 作用:服务器确认已收到客户端端的 SYN 请求,并同意建立连接,同时告知客户端自己发送数据时将使用的起始序列号 server_ISN,并确认收到了客户端的 SYN 报文。
- 第三次握手(ACK):
- 发起方:客户端。
- 报文类型:ACK 报文段(ACK_FLAG=1)。ACK 标志位表示确认收到服务器的响应。
- 序列号(Sequence Number):客户端发送 seq = client_ISN + 1。这表示客户端的 SYN 报文已经消耗了一个序列号,现在数据将从 client_ISN + 1 开始发送。
- 确认号(Acknowledgment Number):客户端发送 ack = server_ISN + 1。这表示客户端已成功接收到服务器的 SYN-ACK 报文(其序列号为 server_ISN),并期望接收服务器的下一个字节的序列号是 server_ISN + 1。
- 客户端状态变化:客户端收到服务器的 SYN-ACK 后,从 SYN_SENT 状态转换到 ESTABLISHED 状态。
- 服务器状态变化:服务器收到客户端的 ACK 后,从 SYN_RCVD 状态转换到 ESTABLISHED 状态。
- 作用:客户端确认已收到服务器的 SYN-ACK,并告知服务器已经准备好发送和接收数据。至此,客户端和服务器都确认了对方的收发能力,以及彼此的初始序列号。连接正式建立,可以开始数据传输。
为什么需要三次握手?
- 防止已失效的连接请求报文段突然又传到服务器,导致错误建立连接。如果只有两次握手,客户端发送的第一个连接请求(SYN报文)因网络延迟迟迟未到服务器,客户端随时重传并成功建立了连接并传输了数据。旧的SYN报文在某个时刻到达了服务器,服务器收到后会以为是新的连接请求,发送SYN-ACK给客户端。如果只有两次握手,服务器会认为连接建立成功并进入ESTABLISHED状态,但客户端已经处理完之前的连接并关闭,不会理会服务器。这会导致服务器一直等待客户端发送数据,浪费资源。三次握手确保客户端能识别出这是旧的SYN,从而不发送ACK,服务器也就不会进入ESTABLISHED。
- 确保双方都具备发送和接收能力。
- 第一次握手:客户端发送 SYN,服务器收到。服务器能确认客户端能发。
- 第二次握手:服务器发送 SYN-ACK,客户端收到。客户端能确认服务器能收也能发。
- 第三次握手:客户端发送 ACK,服务器收到。服务器能确认客户端能收。
- 至此,双方都确认了对方的“发”和“收”能力,实现了双向的同步确认。
四次握手:这是 TCP 终止连接的过程,目的是确保全双工连接的两个方向都能独立、优雅地关闭,避免数据丢失。由于 TCP 是全双工的,每个方向的数据传输都需要独立关闭。
全双工特性:TCP 连接是全双工的,这意味着数据可以在两个方向上独立传输。当一方完成数据发送时,另一方可能仍然有数据需要发送。
假设客户端主动关闭连接:
- 第一次挥手(FIN):
- 发起方:客户端(数据发送完毕,想关闭发送方向)。
- 报文类型:FIN 报文段(FIN_FLAG=1, ACK_FLAG=1)。FIN 标志位表示发送方没有数据要发送了。
- 序列号(Sequence Number):seq = u(其中 u 是客户端上次发送的最后一个字节的序列号加 1)。
- 确认号(Acknowledgment Number):ack = v(如果之前收到了服务器的数据,这是对服务器最近一次发送数据包的确认号)。
- 客户端状态变化:客户端从 ESTABLISHED 状态转换到 FIN_WAIT_1 状态。
- 作用:客户端告知服务器,它已经没有数据要发送了,它单方面地关闭了发送方向的连接。但是,客户端仍然可以接收服务器发送的数据。
- 第二次挥手(ACK):
- 发起方:服务器。
- 报文类型:ACK 报文段(ACK_FLAG=1)。
- 序列号(Sequence Number):seq = v(服务器上次发送的最后一个字节的序列号加 1)。
- 确认号(Acknowledgment Number):ack = u + 1。这表示服务器已成功接收到客户端的 FIN 报文段,并期望接收客户端的下一个字节的序列号是 u + 1(尽管客户端已经表示不再发送数据,这只是一个确认)。
- 服务器状态变化:服务器收到客户端的 FIN 后,从 ESTABLISHED 状态转换到 CLOSE_WAIT 状态。
- 客户端状态变化:客户端收到服务器的 ACK 后,从 FIN_WAIT_1 状态转换到 FIN_WAIT_2 状态。
- 作用:服务器确认已收到客户端的关闭请求。此时,客户端到服务器方向的连接已半关闭。服务器处于 CLOSE_WAIT 状态,这意味着服务器应用层还在处理数据或者还有数据需要发送给客户端。这个状态会持续到服务器端也准备好关闭连接。
- 第三次挥手(FIN):
- 发起方:服务器(当服务器也发送完所有数据,并且准备关闭自己的发送方向时)。
- 报文类型:FIN 报文段 (FIN_FLAG=1, ACK_FLAG=1)。
- 序列号 (Sequence Number):seq = w (服务器上次发送的最后一个字节的序列号加 1, w 可能等于 v, 如果服务器在发送 ACK 后没有再发送数据)。
- 确认号 (Acknowledgment Number):ack = u + 1 (再次确认客户端 FIN, 表示服务器对客户端发送的数据已经全部接收并确认)。
- 服务器状态变化:服务器发送 FIN 后,从 CLOSE_WAIT 状态转换到 LAST_ACK 状态。
- 作用:服务器告知客户端,它也没有数据要发送了,并请求关闭服务器到客户端方向的连接。
- 第四次挥手 (ACK):
- 发起方:客户端。
- 报文类型:ACK 报文段 (ACK_FLAG=1)。
- 序列号 (Sequence Number):seq = u + 1。
- 确认号 (Acknowledgment Number):ack = w + 1。这表示客户端已成功接收到服务器的 FIN 报文段,并期望接收服务器的下一个字节的序列号是 w + 1 (尽管服务器也表示不再发送数据)。
- 客户端状态变化:客户端收到服务器的 FIN 后,从 FIN_WAIT_2 状态转换到 TIME_WAIT 状态。
- 服务器状态变化:服务器收到客户端的 ACK 后,从 LAST_ACK 状态转换到 CLOSED 状态。
- 作用:客户端确认已收到服务器的 FIN。
TIME_WAIT 状态的必要性:客户端在发送最后一个 ACK 后会进入 TIME_WAIT 状态,并持续等待 2MSL (Maximum Segment Lifetime, 最长报文段寿命) 的时间才进入 CLOSED 状态。MSL 是一个报文在网络中能够存活的最长时间。通常设置为 30 秒或 1 分钟。
- 确保可靠关闭:为了确保最后一个 ACK 报文段能够到达服务器。如果这个 ACK 在网络中丢失了,服务器将因为没有收到确认而超时并重传第三次挥手的 FIN 报文。客户端处于 TIME_WAIT 状态时,可以接收到这个重传的 FIN,并再次发送 ACK,从而保证服务器能够可靠地关闭连接。如果客户端立即进入 CLOSED 状态,它就无法响应服务器重传的 FIN,服务器将永远停留在 LAST_ACK 状态。
- 避免旧连接的报文干扰新连接:在 2MSL 时间内,确保本连接中所有可能在网络中滞留的报文(如迟到的数据包或重传的 FIN)都已从网络中消失。这可以防止它们在连接关闭后被一个新的、使用相同源端口和目标端口的连接接收到,从而避免数据混乱。
MySQL 数据库
我对 MySQL 数据库有丰富的操作经验和深入的理解,包括 SQL 操作、事务特性、索引原理以及存储引擎。
SQL 操作与优化
SQL (Structured Query Language) 是用于管理关系型数据库的标准语言。我熟练掌握各种 SQL 操作,并能进行性能优化。
**DML (Data Manipulation Language):**用于操作数据库中的数据。
- SELECT:查询数据。
SELECT column1, column2 FROM table_name WHERE condition ORDER BY column DESC/ASC LIMIT offset, count;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
- 聚合函数:COUNT(), SUM(), AVG(), MAX(), MIN()。
- 分组查询:GROUP BY, HAVING (用于过滤分组)。
- 联接查询 **(JOIN)**:
- INNER JOIN:只返回两个表中都匹配的行。
- LEFT JOIN / LEFT OUTER JOIN:返回左表所有行,以及右表中匹配的行。如果右表没有匹配,则右表列为 NULL。
- RIGHT JOIN / RIGHT OUTER JOIN:返回右表所有行,以及左表中匹配的行。如果左表没有匹配,则左表列为 NULL。
- FULL JOIN / FULL OUTER JOIN (MySQL 不直接支持,通常通过 LEFT JOIN UNION ALL RIGHT JOIN 模拟):返回两个表中的所有行,不匹配的行用 NULL 填充。
- INSERT:插入数据。
- ```sql
- INSERT INTO table_name (column1, column2) VALUES (value1, value2);
- INSERT INTO table_name VALUES (value1, value2, ...); (所有列)
- UPDATE: 更新数据。
UPDATE table_name SET column1 = value1, column2 = value2 WHERE condition;
1
2
3
4
- DELETE: 删除数据。
- ```sql
DELETE FROM table_name WHERE condition; (不带 WHERE 子句将删除所有行,但不会释放空间,比 TRUNCATE TABLE 慢)
- SELECT:查询数据。
DDL (Data Definition Language): 用于定义数据库对象,如表、索引、视图等。
- 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;
1
2
3
4
5
- **DCL (Data Control Language):** 用于控制数据库的访问权限。
- ```sql
- GRANT privileges ON database.table TO 'user'@'host';
- REVOKE privileges ON database.table FROM 'user'@'host';
TCL (Transaction Control Language): 用于管理事务。
- START TRANSACTION; (或 BEGIN;) - COMMIT; - ROLLBACK; - SAVEPOINT savepoint_name;
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; // 等待的锁数量 };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
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
- **EXPLAIN 命令深入分析:**
- EXPLAIN 是 MySQL SQL 优化的核心工具,它显示了 MySQL 如何执行 SELECT 查询(以及 INSERT, UPDATE, DELETE 语句的执行计划)。通过分析 EXPLAIN 的输出,可以判断查询是否使用了索引,以及索引使用的情况,从而进行性能优化。
- id: SELECT 查询的序列号。一个大的 SELECT 查询可以被分解为多个子查询,每个子查询都有一个唯一的 id。id 越大,执行优先级越高(优先级高的先执行);如果 id 相同,则从上到下顺序执行。
- select_type: 查询的类型。
- SIMPLE: 简单的 SELECT 查询,不包含子查询或 UNION。
- PRIMARY: 最外层的 SELECT 查询(对于包含子查询或 UNION 的查询)。
- SUBQUERY: 子查询中的第一个 SELECT。
- DEPENDENT SUBQUERY: 依赖外部查询结果的子查询。
- UNION: UNION 中的第二个或后面的 SELECT 语句。
- DEPENDENT UNION: UNION 中的第二个或后面的 SELECT 语句,且依赖外部查询。
- UNION RESULT: UNION 的结果。
- DERIVED: 派生表(FROM 子句中的子查询)。table: 正在访问的表名。
- partitions: 匹配到的分区信息(如果表有分区)。
- type: 最重要的指标,表示连接类型,即 MySQL 查找数据的方式。这是评估查询性能的关键,从好到差依次是:
- system: 表中只有一行记录(系统表)。const 的特例,性能极高。
- const: 通过主键或唯一索引查找,最多匹配一行。非常快。例如 SELECT * FROM users WHERE id = 1;
- eq_ref: 对于前一个表的每个行组合,从表中读取一行。通常用于连接操作,主键或唯一索引的所有列都被用于连接条件。例如 SELECT * FROM orders o JOIN users u ON o.user_id = u.id; (假设 u.id 是主键或唯一索引)。
- ref: 非唯一索引的等值查找。可能找到多行。例如 SELECT * FROM orders WHERE user_id = 100; (假设 user_id 是普通索引)。
- range: 范围查找,使用索引来检索给定范围的行。例如 WHERE id > 100 AND id < 200; 或 BETWEEN, IN。
- index: 全索引扫描。遍历整个索引树,比 ALL 好,因为索引通常比数据文件小,且索引是按顺序存储的,可以避免随机 I/O。如果使用了覆盖索引,效率会更高。
- ALL: 全索引扫描。扫描整个表来查找匹配的行。性能最差,数据量大时应极力避免。
- possible_keys: MySQL 认为可能用于查询的索引。
- key: MySQL 实际决定使用的索引。如果为 NULL,表示没有使用索引。
- key_len: 实际使用的索引的长度(字节数)。这个值越小越好,表示使用的索引越短。对于复合索引,可以判断索引哪些列被用到了。
- ref: 表示使用哪个列或常量来和 key 所指的索引列做比较。
- rows: MySQL 估计要扫描的行数。这是最重要的指标之一,越少越好。
- filtered: 表示查询条件过滤掉的行数的百分比。越高越好,表示过滤效率高。
- Extra: 额外信息,对查询的解释提供了额外细节,非常重要。
- Using filesort: 表示 MySQL 需要对结果进行外部排序(即不在索引中排序)。这通常发生在 ORDER BY 或 GROUP BY 的列没有索引或索引无法被有效利用时。会增加 CPU 开销和 I/O,应尽量通过创建合适的索引或调整查询来避免。
- Using temporary: 表示 MySQL 需要创建临时表来处理查询。通常发生在 GROUP BY 或 ORDER BY 中包含不相关列,或者 DISTINCT 和 ORDER BY 混用时。临时表可能在内存中,也可能在磁盘上,都会增加性能开销,应尽量避免。
- Using index: 表示使用了覆盖索引 (Covering Index)。查询所需的所有列都可以在索引中找到,无需回表访问数据行。这是最有效的索引使用方式。
- Using where: 表示使用了 WHERE 子句进行过滤。这是正常的,但如果 type 是 ALL,说明 WHERE 过滤是基于全表扫描的,效率不高。
- Using index condition: MySQL 5.6+ 引入的索引条件下推 (Index Condition Pushdown, ICP) 优化。它将 WHERE 子句中的部分条件下推到存储引擎层,在索引扫描过程中就进行过滤,减少了回表次数和从存储引擎层返回给服务层的数据量。
- Using join buffer (Block Nested Loop): 当关联查询无法使用索引时,MySQL 会使用连接缓存来优化。
## 事务 (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 高效获取数据的数据结构。它类似于书籍的目录,可以快速定位到所需的数据,而无需扫描整个表。
- **索引的优点:**
- 大大加快数据检索速度。
- 减少服务器的 I/O 次数。
- 在分组 (GROUP BY) 和排序 (ORDER BY) 操作中减少 CPU 消耗。
- **索引的缺点:**
- 创建和维护索引需要时间成本,数据增加时需要更新索引。
- 索引需要占用磁盘空间。
- 虽然查询性能提升,但对写入操作有一定性能损耗。
- **索引的底层实现: B+ 树**
- B+ 树 (B+Tree):
- 所有数据都存储在叶子节点上,非叶子节点只存储键值 (索引) 用于导航。
- 叶子节点之间通过链表连接,方便范围查询和遍历。
- 树的层高更低:相比 B 树,B+ 树的非叶子节点不存储数据,因此一个节点可以存储更多的索引键,使得树的高度更低,从而减少磁盘 I/O 次数。
- 适用于磁盘存储:磁盘 I/O 是数据库操作的主要瓶颈,B+ 树的设计(节点大小与磁盘块大小匹配)能够最大程度地减少磁盘寻道次数。
- **索引的分类:**
1. 主键索引 (Primary Key Index):
- 一种特殊的唯一索引,一个表只能有一个主键。
- 不允许有空值 (NULL)。
- 通常是聚集索引 (InnoDB)。
2. 唯一索引 (Unique Index):
- 索引列的值必须唯一,但允许有空值 (NULL),且可以有多个 NULL 值。
- CREATE UNIQUE INDEX index_name ON table_name (column_name):
3. 普通索引 (Normal Index / Non-Unique Index):
- 最基本的索引,没有任何限制。
- CREATE INDEX index_name ON table_name (column_name):
4. 全文索引 (Fulltext Index):
- 用于文本内容的模糊查询,在大文本字段上进行高效搜索。
- 只支持 MyISAM 和 InnoDB 存储引擎。
- CREATE FULLTEXT INDEX index_name ON table_name (column_name):
5. 复合索引 (Composite Index / Multi-Column Index):
- 在多个列上创建的索引。
- 遵循“最左前缀原则”:如果查询条件使用了复合索引的第一个列,则整个索引会被利用;如果只使用了非第一个列,则索引可能无法完全利用。
- CREATE INDEX index_name ON table_name (column1, column2, column3);
- **聚集索引(Clustered Index)与 非聚集索引(Non-Clustered Index):**
- 聚集索引:
- 索引的叶子节点存储的就是完整的数据行。
- 一个表只能有一个聚集索引(通常是主键)。
- 数据物理存储的顺序与索引的逻辑顺序一致。
- 查询效率高,因为找到索引叶子节点就找到了数据。
- 对数据插入、更新和删除有一定影响,因为需要维护数据的物理顺序。
- 在 InnoDB 存储引擎中,主键索引就是聚集索引。如果我没有显式定义主键,InnoDB 会选择一个唯一非空索引作为聚集索引;如果没有,则会自动创建一个隐藏的 6 字节的行 ID 作为聚集索引。
- 非聚集索引:
- 索引的叶子节点存储的是主键值或指向数据行的指针。
- 一个表可以有多个非聚集索引。
- 数据物理存储顺序与非聚集索引的逻辑顺序无关。
- 查询需要回表:如果查询的列不在非聚集索引中,需要通过索引找到主键值,再通过主键索引(聚集索引)找到完整的数据行。
- 在 InnoDB 中,所有非主键索引都是非聚集索引。
- **索引优化策略:**
- 选择合适的列创建索引:
- 在 WHERE 子句、JOIN 子句、ORDER BY 子句中经常出现的列。
- 选择区分度高(唯一值多)的列。
- 不为小型表、频繁更新的表、重复值多的列创建索引。
- 遵循最左前缀原则:对于复合索引 (a, b, c),查询条件 WHERE a = 1,WHERE a = 1 AND b = 2 会使用索引,但 WHERE b = 2 或 WHERE c = 3 不会完全使用索引。
- 避免索引失效:
- 在索引列上进行函数运算或表达式运算(如 WHERE YEAR(date_column) = 2023)。
- 对索引列进行隐式类型转换。
- 使用 OR 连接条件(除非 OR 的两边都有索引,并且优化器认为合并索引更高效)。
- 使用 LIKE '%keyword'(左模糊匹配)会导致全索引扫描或全表扫描。
- 使用 NOT IN 或 != 通常无法使用索引。
- 覆盖索引:查询只从索引中获取数据,无需回表。
- SELECT column_in_index FROM table_name WHERE indexed_column = value;
- 防止回表:尽可能让 SELECT 语句中的列都被索引覆盖,或者只查询主键。
- 定期维护索引:重建或优化碎片化的索引。
- #### SQL优化

## 存储引擎
MySQL 是一个关系型数据库管理系统,它的独特之处在于支持多种可插拔的存储引擎。存储引擎负责 MySQL 中数据的存储和提取。
- **InnoDB (默认):**
- 特性:
- 支持事务(ACID 特性)。
- 支持行级锁定:在并发写入时,只锁定需要修改的行,而不是整个表,大大提高了并发性能。
- 支持外键约束:维护数据完整性。
- 支持崩溃恢复:通过事务日志(redo log 和 undo log)保证数据持久性。
- 默认使用聚集索引:数据存储在 B+ 树的叶子节点中,数据行和主键一起存储,查询效率高。
- MVCC(多版本并发控制):通过快照读取实现非阻塞的读操作,提高并发性能。
- 适用场景:事务性应用、高并发读写、对数据完整性和一致性要求要求高的场景(如电商、金融系统)。
- **MyISAM(非事务性):**
- 特性:
- 不支持事务。
- 支持表级锁定:并发写入时,需要锁定整个表,并发性能差。
- 不支持外键约束。
- 不支持崩溃恢复:可能导致数据丢失。
- 非聚集索引:数据和索引是分离的,索引叶子节点存储的是数据行的地址。
- SELECT COUNT(*) 效率高,因为表的总行数存储在内存中。
- 适用场景:只读或读多写少,对事务性要求不高,需要频繁执行 COUNT(*) 的应用(如一些日志记录、数据仓库)。在 MySQL 5.5 后,InnoDB 逐渐替代 MyISAM 成为默认和首选。
- **其他存储引擎(了解):**
- Memory (HEAP):数据存储在内存中,速度极快,但重启服务器数据会丢失。适用于临时表或缓存。
- Archive:用于存储大量不常访问的历史数据,支持高速插入和查询(不支持更新和删除)。数据会被高度压缩。
- CSV:以 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_ID` 在 `min_trx_id` 和 `max_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服务器问题的首要工具。当数据库无法启动或运行异常时,应首先检查此日志。
- 配置:默认情况下,错误日志是开启的。其文件名通常为<hostname>.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选项来启用。启用后,会生成一个索引文件(默认为<hostname>-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 |
|
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; // 最大的事务编号
};
1 |
|
Lock相比synchronized的优势:
- 中断锁(Interruptibly):
Lock
提供了lockInterruptibly()
方法,允许在等待锁的过程中响应中断。而synchronized
的线程如果陷入等待锁的状态,是无法被中断的。 - 尝试获取锁(tryLock):
Lock
提供了tryLock()
和tryLock(long timeout, TimeUnit unit)
方法,可以尝试获取锁,如果失败则立即返回或在指定时间内放弃,避免无限等待。 - 公平锁与非公平锁:
ReentrantLock
可以创建公平锁(Fair Lock)。公平锁会按照线程请求锁的顺序来分配锁,虽然这可能会带来一些性能开销。而synchronized
只能是非公平锁。 - 绑定多个条件(Condition):
Lock
配合Condition
接口,可以实现更灵活的线程等待和唤醒机制,类似Object
的wait()
和notify()
,但功能更强大,一个锁可以有多个等待队列。 - 读写锁(ReadWriteLock):
ReadWriteLock
是Lock
的另一个重要实现,它维护了一对锁:一个用于读操作,一个用于写操作。在读多写少的场景下,多个线程可以同时获取读锁,大大提高了并发性能,只有写操作才需要获取独占的写锁。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++
实际上是读、加、写三个操作的组合,这三个操作并非一次完成。如果要保证原子性,需要使用synchronized
、Lock
或java.util.concurrent.atomic
包下的原子类。
总结一下:
特性 | synchronized |
Lock (如ReentrantLock ) |
volatile |
---|---|---|---|
功能 | 独占锁,保证原子性、可见性、有序性 | 独占锁,功能更强大,保证原子性、可见性、有序性 | 轻量级同步,只保证可见性和有序性 |
使用方式 | 关键字,自动加锁和解锁 | 接口,需要手动加锁和解锁,必须在finally 块中释放 |
关键字,修饰变量 |
灵活性 | 较差,功能固定 | 强,提供了更多高级功能,如可中断、超时、公平锁等 | 较差,只针对变量 |
性能 | JVM优化后性能较高,开销相对较小 | 高性能,在竞争激烈时通常优于synchronized |
非常高,几乎没有开销 |
适用场景 | 简单的同步需求,大部分情况都适用 | 高级同步需求,需要灵活控制锁的获取和释放 | 变量的写操作不依赖于当前值,需要保证变量的可见性。 |
4. 乐观锁 (Optimistic Locking)
与之前讨论的悲观锁(synchronized
和Lock
)不同,乐观锁并非一个具体的Java关键字或类,而是一种并发控制的思想和策略。悲观锁认为“总会有其他线程来修改数据”,所以在访问共享资源前,先对资源加锁,确保独占访问。而乐观锁则认为“数据冲突发生的概率很小”,所以它不加锁,而是假设所有线程都能正常执行,只有在数据更新提交时,才去检查在此期间数据是否被其他线程修改过。
如果检查到数据没有被修改,则更新成功。如果发现数据已经被修改,则更新失败。处理失败的方式通常有两种:
- 重试: 循环尝试,直到更新成功为止。
- 放弃: 抛出异常或直接返回失败,由调用方处理。
乐观锁的实现方式:
乐观锁的核心在于如何“检查数据是否被修改”。在Java中,常见的实现方式有两种:
版本号(Version Number):
在数据表中增加一个version字段。每次读取数据时,也把version字段读出来。当要进行数据更新时,带上之前读取的version值,在更新语句中加入WHERE version = <当前版本号>的条件。如果更新成功,同时把version值加1。
SQL示例:
1
2UPDATE 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
包下的所有原子类,如AtomicInteger
、AtomicLong
等,都是基于CAS实现的。AtomicInteger示例:
1
2
3AtomicInteger count = new AtomicInteger();
// 假设多个线程同时执行以下操作
count.incrementAndGet(); // 内部就是CAS操作incrementAndGet()
方法的内部实现类似于一个自旋重试的循环:- 获取当前值
current
。 - 计算新值
next = current + 1
。 - 使用CAS尝试将
current
更新为next
。 - 如果更新失败(说明
current
已经被其他线程修改),则重新回到第一步,再次获取最新值并尝试更新。
悲观锁与乐观锁的比较:
特性 | 悲观锁 (synchronized , Lock ) |
乐观锁 (CAS, 版本号) |
---|---|---|
加锁方式 | 独占资源时先加锁,阻止其他线程访问 | 不加锁,只在提交时进行冲突检测 |
冲突处理 | 线程排队等待锁,串行执行 | 线程失败后重试或放弃,并发执行 |
适用场景 | 写操作多、竞争激烈的场景。数据冲突概率高。 | 读操作多、写操作少的场景。数据冲突概率低。 |
性能 | 在高竞争环境下,线程切换和上下文开销大,性能下降。 | 在低竞争环境下,无锁开销,性能极高。在高竞争环境下,大量重试可能导致CPU开销增加。 |
总结:
在Java中,悲观锁和乐观锁是两种截然不同的并发控制策略。悲观锁(synchronized
, Lock
)适合写多读少的场景,能够保证数据的一致性,但会牺牲一定的性能。而乐观锁(CAS、版本号)则适合读多写少的场景,通过无锁的并发操作提高了性能,但在高竞争下可能导致频繁重试,反而降低效率。理解这两种锁的思想,可以帮助你根据具体的业务场景选择最合适的并发控制方案。
好的,我们来继续完善Java中关于锁的介绍,增加死锁及其解决方案的内容。
5. 死锁 (Deadlock)
死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象。若无外力作用,它们都将无法继续执行。
死锁的产生是一个非常经典的多线程问题,通常发生在线程需要同时持有多个锁的场景中。一个简单的死锁场景是:线程A持有锁1,想获取锁2;而线程B持有锁2,想获取锁1。此时两个线程都无法继续执行,从而进入死锁状态。
Java
1 | // 线程A |
死锁的四个必要条件
死锁的发生需要同时满足以下四个条件,缺一不可:
- 互斥条件(Mutual Exclusion): 至少有一个资源是独占的,即一次只能被一个线程使用。这是锁本身的基本特性。
- 请求与保持条件(Hold and Wait): 一个线程因请求资源而阻塞时,它对自己已获得的资源保持不放。
- 不剥夺条件(No Preemption): 线程已获得的资源在未使用完之前,不能被强行剥夺,只能由该线程自己释放。
- 循环等待条件(Circular Wait): 存在一个线程等待链,其中每个线程都持有下一个线程所需的资源。例如:线程A等待线程B,线程B等待线程C,线程C又等待线程A。
死锁的解决方案
解决死锁的根本思想是破坏上述四个必要条件之一或多个。通常,我们无法破坏互斥条件(因为资源就是独占的),因此主要从其他三个条件入手。
- 破坏“请求与保持”条件:
- 一次性获取所有锁: 线程在开始执行时,就一次性获取所有需要的锁。如果获取不成功,则释放所有已获得的锁,然后等待一段时间后再次尝试。
- 优点: 简单有效。
- 缺点: 可能会降低并发性,因为线程在很早就持有了锁,即使这些锁在后面才被使用。
- 破坏“不剥夺”条件:
- 使用可中断的锁: 使用
Lock
接口提供的tryLock()
方法。当一个线程尝试获取锁失败时,它可以选择放弃并释放已持有的锁,而不是一直等待。Lock.tryLock(long time, TimeUnit unit)
方法可以在指定时间内尝试获取锁,超时后会放弃。 - 优点: 提高了灵活性,线程可以响应中断或超时,避免无限等待。
- 缺点: 实现起来相对复杂,需要开发者手动处理获取锁失败的情况。
- 使用可中断的锁: 使用
- 破坏“循环等待”条件:
- 按顺序获取锁: 对所有的锁进行排序,并强制所有线程都按照相同的顺序获取锁。
- 示例: 如果线程A和线程B都需要
lock1
和lock2
,那么它们都必须先获取lock1
,再获取lock2
。这样就杜绝了线程A持有lock1
等待lock2
,同时线程B持有lock2
等待lock1
的循环。 - 优点: 这是最常用、最有效的死锁解决方案,实现起来也相对简单。
- 缺点: 有时很难对所有锁进行全局排序,特别是在代码模块化程度较高、依赖关系复杂的情况下。
总结:
在实际开发中,预防死锁的最佳实践通常是破坏循环等待条件,即统一锁的获取顺序。这是最简单且最有效的方案。如果业务场景需要更灵活的控制,可以考虑使用Lock
接口,利用tryLock()
方法来破坏“不剥夺”条件,实现更复杂的死锁处理逻辑。
好的,我们来详细介绍银行家算法 (Banker’s Algorithm)。
银行家算法概述
银行家算法是一种著名的死锁避免算法,由荷兰计算机科学家Dijkstra在1965年提出。它的核心思想是:在每次分配资源之前,先进行一次安全性检查。如果分配后系统仍然处于安全状态,则分配资源;否则,不予分配,线程需要等待。
- 优点:比死锁预防更灵活,能提高资源利用率。
- 缺点:算法复杂,需要预知进程的最大资源需求,并且系统开销大。
之所以叫“银行家算法”,是因为它的工作原理类似于银行管理贷款。银行家在发放贷款时,会先确保这笔贷款发放后,自己还有足够的资金来满足所有客户可能提出的最大取款需求,从而避免因无法支付而破产的风险。
简单来说,银行家算法通过以下两个步骤来避免死锁:
- 安全状态的定义: 系统能够找到一个安全序列,使得所有线程都能按照这个序列执行完毕。
- 资源分配策略: 当一个线程请求资源时,算法会先假设分配成功,然后检查系统是否仍处于安全状态。如果安全,就真的分配;如果不安全,就拒绝分配。
银行家算法中的几个重要数据结构
为了实现算法,需要维护以下几个关键数据结构,假设系统中有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
,它所需要的资源都能由系统中当前可用的资源,以及前面所有已完成的线程释放的资源来满足。
安全性检查算法的步骤:
- 初始化:
- 创建一个
Work
向量,初始化为Available
(即当前可用资源)。 - 创建一个
Finish
向量,初始化为false
,表示所有线程都未完成。
- 创建一个
- 寻找安全线程:
- 从所有线程中找到一个线程
i
,满足以下两个条件:Finish[i]
为false
。Need[i]
向量中的每一个值都小于或等于Work
向量中对应的值。- 换句话说,线程
i
所需要的资源小于或等于当前可用的资源。
- 从所有线程中找到一个线程
- 释放资源:
- 如果找到了这样的线程
i
,则认为它可以顺利执行完毕。 - 模拟该线程执行完毕并释放资源,更新
Work
向量:Work = Work + Allocation[i]
。 - 将
Finish[i]
设置为true
。
- 如果找到了这样的线程
- 循环检查:
- 重复步骤2和步骤3,直到找不到满足条件的线程。
- 判断结果:
- 如果最终所有线程的
Finish
都为true
,则说明找到了一个安全序列,系统处于安全状态。 - 如果还有线程的
Finish
为false
,则说明系统处于不安全状态,可能存在死锁。
- 如果最终所有线程的
银行家算法的流程:资源分配
当一个线程P
请求资源时,银行家算法会执行以下步骤:
- 请求检查: 检查线程
P
请求的资源数量是否小于或等于其Need
向量中的需求量。如果不是,说明线程P
的请求不合理,拒绝分配。 - 可用性检查: 检查线程
P
请求的资源数量是否小于或等于当前Available
中的资源数量。如果不是,说明资源不足,线程P
需要等待。 - 预分配并进行安全性检查:
- 假设资源可以分配,临时进行以下操作:
Available = Available - Request
Allocation[P] = Allocation[P] + Request
Need[P] = Need[P] - Request
- 调用上面的安全性检查算法,判断系统是否处于安全状态。
- 假设资源可以分配,临时进行以下操作:
- 正式分配或拒绝:
- 如果安全性检查的结果是安全,则正式分配资源,并保留步骤3中的修改。
- 如果安全性检查的结果是不安全,则回滚步骤3中的所有临时修改,拒绝分配资源,线程
P
需要等待。
银行家算法的优缺点
- 优点:
- 可以有效地避免死锁的发生,保证系统的安全性。
- 通过提前检查,可以最大化地利用资源,提高系统的并发性。
- 缺点:
- 计算开销大: 每次分配资源都需要运行安全性检查算法,增加了系统的开销。
- 过于保守: 安全状态不等于无死锁,不安全状态也不等于一定发生死锁。算法为了确保安全,可能会拒绝一些本可以成功分配的请求,从而降低了系统的吞吐量。
- 条件苛刻: 算法要求线程在开始前就声明其所需的最大资源量,这在实际应用中很难做到。
- 资源数量固定: 算法假设系统中资源的数量是固定的,不能动态增减。
因此,银行家算法虽然在理论上非常完美,但在实际操作系统中很少被直接完整地实现。然而,它的核心思想——通过安全性检查来避免死锁——仍然是许多并发控制策略的重要理论基础。
实际应用中的死锁处理
在实际的并发编程和数据库系统中,最常见的死锁处理方式是:
死锁预防(通过编程规范):
- 加锁顺序一致:规定所有线程在获取多个锁时,必须按照相同的顺序。这是最有效的预防死锁的编程实践。
- 使用超时锁:尝试获取锁时设置一个超时时间,如果超时未获取到锁,则放弃本次操作并释放已持有的锁,然后重试。这破坏了”请求与保持”条件。例如Java的ReentrantLocktryLock(timeout)。
- 避免嵌套锁:尽量减少持有多个锁的情况。
死锁检测与恢复(数据库系统):
- 大多数关系型数据库(如MySQL InnoDB)都实现了死锁检测机制。当检测到死锁时,数据库会自动选择一个成本较低的事务(“牺牲品”)进行回滚,从而解除死锁。客户端应用会收到相应的错误码(例如MySQL中的Deadlock found when trying to get lock; try restarting transaction)。应用程序通常需要捕获这个错误并重试事务。理解死锁的四个必要条件是关键,因为解决死锁的根本方法就是破坏其中一个或多个条件。
多线程与线程池
在 Java 中,多线程是实现并发编程的关键技术,它允许程序同时执行多个任务。线程池则是管理和复用线程的重要机制,能有效提升系统性能和资源利用率。
线程(Thread)的概念与生命周期
线程是操作系统调度的最小单位,是进程中的一个执行路径。一个进程可以包含多个线程,这些线程共享进程的内存空间。
线程的生命周期通常包含以下六种状态(定义在 java.lang.Thread.State 枚举中):
NEW(新建):线程被创建但尚未启动。当使用 new Thread() 创建一个线程实例后,它就处于此状态。
1
2Thread myThread = new Thread(() -> System.out.println("Hello from a new thread!"));
// 此时 myThread 处于 NEW 状态,尚未执行 start()RUNNABLE(可运行):线程已调用 start() 方法,正在 JVM 中运行(可能正在执行,也可能在等待 CPU 调度)。一个 RUNNABLE 状态的线程可能正在运行,也可能并没有运行,它仅仅是具备了运行的资格。
1
2myThread.start();
// 此时 myThread 进入 RUNNABLE 状态,等待 CPU 调度执行 run() 方法BLOCKED (阻塞):线程正在等待获取一个监视器锁(例如,进入 synchronized 块或方法)。当一个线程试图访问被其他线程锁定的资源时,它会进入此状态。
1
2
3
4
5
6
7
8
9Object 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
9Object 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
2Thread.sleep(1000);
// 线程进入 TIMED_WAITING 状态 1 秒TERMINATED (终止): 线程已执行完毕其 run() 方法, 或者因未捕获的异常退出。线程一旦进入此状态, 就不能再被重新启动。
1
2// 线程的 run() 方法执行完毕
// 线程在执行过程中抛出未捕获的异常
创建线程的方式
继承 Thread 类:
- 通过创建 Thread 类的子类, 并重写其 run() 方法, 在该方法中定义线程执行的任务。
- 创建 Thread 子类的实例, 并调用其 start() 方法来启动线程。调用 start() 方法会使线程进入 RUNNABLE 状态, 并由 JVM 调度执行 run() 方法; 直接调用 run() 方法则只是在当前线程中执行普通方法, 不会启动新线程。
- 优点: 实现简单直观, 代码结构清晰。
- 缺点: Java 是单继承的, 如果你的类已经继承了其他类, 就不能再继承 Thread 类。这限制了类的灵活性。此外, 任务 (run() 方法中的逻辑 ) 与线程本身 (Thread 对象) 紧密耦合, 不利于任务的复用。
1 | // MyThread.java |
实现 Runnable 接口:
- 定义一个类实现 Runnable 接口,并实现其抽象方法 public void run()。run() 方法中包含线程执行的具体任务。
- 创建 Runnable 实现类的实例,然后将其作为参数传入 Thread 类的构造器 (new Thread(Runnable target)),再调用 Thread 实例的 start() 方法。
- 优点:
- 推荐方式:避免了 Java 单继承的限制,你的类可以同时继承其他类来实现 Runnable 接口。
- 任务与线程解耦:Runnable 对象只负责定义任务,而 Thread 对象负责执行任务。这意味着同一个 Runnable 对象可以被多个 Thread 实例共享执行,从而更好地实现资源的共享和任务的复用。
1 | // MyRunnable.java |
实现Callable接口
Callable
是 Java 并发编程中一个非常重要的接口,它与 Runnable
类似,都用于定义一个可在线程中执行的任务。但 Callable
提供了更强大的功能,主要体现在两个方面:
- 可以返回结果:
Callable
的call()
方法可以返回一个泛型类型的结果。 - 可以抛出异常:
Callable
的call()
方法可以声明抛出任何Exception
。
这与 Runnable
接口形成了鲜明对比,Runnable
的 run()
方法没有返回值,也不能抛出受检异常(checked exception)。
Callable
接口是一个泛型接口,定义如下:
Java
1 | @FunctionalInterface |
Callable
接口本身并不能直接作为 Thread
的构造参数。它需要配合 ExecutorService
线程池和 Future
接口一起使用。
典型使用流程:
创建 Callable 任务: 实现
Callable
接口,并在call()
方法中编写具体的业务逻辑,返回一个结果。1
2
3
4
5
6
7
8
9import java.util.concurrent.Callable;
public class MyCallableTask implements Callable<String> {
public String call() throws Exception {
Thread.sleep(2000); // 模拟耗时操作
return "任务执行完毕,返回结果";
}
}创建 ExecutorService 线程池: 使用
Executors
工厂类创建线程池。1
2
3
4import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
ExecutorService executor = Executors.newFixedThreadPool(2);提交 Callable 任务: 使用
ExecutorService
的submit()
方法提交任务。submit()
方法会返回一个Future
对象。1
2
3
4import java.util.concurrent.Future;
Callable<String> task = new MyCallableTask();
Future<String> future = executor.submit(task);获取任务结果: 通过
Future
对象的get()
方法来获取Callable
任务的执行结果。future.get()
是一个阻塞方法,它会一直等待,直到任务执行完毕并返回结果。- 如果任务执行过程中抛出了异常,
get()
方法也会将这个异常包装在ExecutionException
中重新抛出。
1
2
3
4
5
6
7
8
9
10
11
12
13
14import 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();
}
特性 | Callable |
Runnable |
---|---|---|
返回值 | call() 方法有返回值(泛型 V ) |
run() 方法没有返回值(void ) |
异常处理 | call() 方法可以抛出受检异常 |
run() 方法不能直接抛出受检异常 |
执行方式 | 必须配合 ExecutorService.submit() 执行 |
可以直接作为 Thread 构造函数的参数,也可以通过 ExecutorService.execute() 或 submit() 执行 |
功能 | 适用于需要返回计算结果或可能抛出异常的异步任务 | 适用于简单的异步任务,不需要返回结果 |
Future
接口是 Callable
的重要伴侣,它代表一个异步计算的结果。它提供了检查任务是否完成、等待任务完成以及获取任务结果的方法。
Future
接口的主要方法:
V get()
: 阻塞式地等待任务完成,并返回结果。V get(long timeout, TimeUnit unit)
: 在指定时间内等待任务完成,超时则抛出TimeoutException
。boolean isDone()
: 检查任务是否已经完成。boolean cancel(boolean mayInterruptIfRunning)
: 尝试取消任务。总结
Callable
是 Java 并发编程中一个更高级的任务抽象,它解决了 Runnable
接口无法返回结果和处理受检异常的痛点。通过与 ExecutorService
和 Future
接口的组合使用,Callable
使得异步编程变得更加简单和灵活,非常适合那些需要耗时计算并返回结果的场景,比如网络请求、数据处理等。
线程池 (Thread Pool)
线程池是一种基于池化思想的线程管理机制,用于管理和复用线程,而不是在每次需要执行任务时都创建新线程。
为什么使用线程池?
- 降低资源消耗:通过重复利用已创建的线程,降低线程创建和销毁的开销。
- 提高响应速度:当任务到达时,任务可以直接执行,无需等待线程创建。
- 提高线程的可管理性:线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以统一分配、调优和监控。
- 提供更多功能:如定时执行、周期执行、单线程化等。
线程池的核心参数 (ThreadPoolExecutor 构造方法)
1
2
3
4
5
6
7public 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: 直接丢弃当前新提交的任务。
线程池的执行流程
- 当一个任务提交到线程池时,如果当前运行的线程数小于 corePoolSize,即使有空闲线程,也会创建并启动一个新线程来执行任务。
- 如果当前运行的线程数大于或等于 corePoolSize,但任务队列 workQueue 未满,任务会被添加到 workQueue 中等待执行。
- 如果 workQueue 已满,但当前运行的线程数小于 maximumPoolSize,线程池会创建新的非核心线程来执行任务。
- 如果当前运行的线程数等于 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,一个无界队列,可以按时间进行排序。
- FixedThreadPool (固定大小线程池)
阿里巴巴开发手册建议:不推荐使用 Executors 创建线程池,而是手动通过 ThreadPoolExecutor 的构造方法创建,以明确线程池的运行规则,避免资源耗尽的风险。
ThreadLocal:
好的,我们来详细聊聊 ThreadLocal
。
ThreadLocal
是什么?
ThreadLocal
(线程本地变量)并不是用来解决线程间共享数据问题的,它的核心作用是为每个使用该变量的线程都提供一个独立的、隔离的副本。
你可以把 ThreadLocal
想象成一个“线程专属的储物柜”。每个线程都可以往这个储物柜里存东西(通过 set()
方法),取东西(通过 get()
方法),但它只能看到自己储物柜里的东西,无法访问其他线程的。
ThreadLocal
内部其实是通过一个 ThreadLocalMap
来实现的。这个 Map 的键是 ThreadLocal
对象本身,值就是你存入的那个变量。每个线程都有一个属于自己的 ThreadLocalMap
。
为什么需要 ThreadLocal
?
我们通常在开发中会遇到两种数据共享问题:
- 多个线程共享一个变量:这种情况下,需要通过
synchronized
、volatile
或Lock
来保证线程安全。 - 每个线程需要一个独立的变量:这是
ThreadLocal
的主要应用场景。
如果不用 ThreadLocal
,我们可能需要自己手动维护一个 Map<Thread, Object>
,每次存取数据时都以当前线程作为键。这样不仅麻烦,还容易出错。ThreadLocal
帮我们封装了这些细节,让使用变得非常简单。
ThreadLocal
的常见应用场景
ThreadLocal
最常见的应用场景是在 Web 开发中,用于存储与当前请求相关的上下文信息。
例如,一个 HTTP 请求从进入服务器到返回响应,可能由多个方法或组件来处理,但它们都属于同一个线程。如果需要传递一些请求相关的状态(比如用户身份、事务 ID、数据库连接),我们有很多种做法:
- 参数传递:将这些信息作为参数层层传递。这会导致方法签名变得臃肿,并且增加了代码的耦合性。
- 静态变量:如果用静态变量,多个请求同时到达时会互相覆盖,导致线程不安全。
- ThreadLocal:这是最优雅的解决方案。你可以把这些信息存入
ThreadLocal
,然后在任何需要的地方直接通过get()
方法获取,无需在方法间显式传递。
典型的例子:
- Spring 的事务管理:Spring 框架在处理事务时,会使用
ThreadLocal
来保存每个线程的数据库连接,确保在同一个事务中的所有操作都使用同一个连接。 - 上下文信息:例如,在请求处理链中,将用户登录信息、语言偏好等数据存入
ThreadLocal
,下游的业务逻辑可以随时获取。
ThreadLocal
可能带来的问题
虽然 ThreadLocal
很好用,但如果使用不当,也可能导致一些问题。
内存泄漏
ThreadLocal
可能会导致内存泄漏。这是一个非常重要的问题。
ThreadLocalMap
使用的是弱引用(Weak Reference) 作为键。这意味着,当 ThreadLocal
对象没有其他强引用时,即使它还在 ThreadLocalMap
中,垃圾回收器也会回收它。
但是,ThreadLocalMap
的值(也就是你存入的对象)是强引用。如果线程一直存活,但你不再使用 ThreadLocal
对象,ThreadLocalMap
中的键就会变成 null
,但值还在。这样,值对象就无法被回收,导致内存泄漏。
如何避免?
解决这个问题的关键在于:在 ThreadLocal 使用完毕后,务必调用 remove() 方法。
在 Web 应用中,请求处理结束后,线程会被放回线程池。如果 ThreadLocal
没有被清除,那么下一次其他请求再拿到这个线程时,它会读取到上一个请求残留的数据,导致业务逻辑出错。因此,正确使用模式通常是:
Java
1 | ThreadLocal<String> threadLocal = new ThreadLocal<>(); |
继承问题
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(): 清空列表。
遍历方式:
传统 for 循环:
Java
1
2
3for (int i = 0; i < list.size(); i++) {
System.out.println(list.get(i));
}增强 for 循环(foreach):
Java
1
2
3for (E element: list) { //内部使用迭代器
System.out.println(element);
}迭代器(Iterator):
Java
1
2
3
4
5
6
7Iterator<E> it = list.iterator();
while (it.hasNext()) {
System.out.println(it.next());
// 如果需要删除元素,必须使用 it.remove(),否则会抛出
// ConcurrentModificationException
// it.remove();
}Java 8 Stream API:
Java
1
2
3list.forEach(System.out::println);
// 或
list.stream().forEach(System.out::println);
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().
遍历方式:
传统 for 循环:
for (int i = 0; i < list.size(); i++) { System.out.println(list.get(i)); }
(不推荐,效率低)增强 for 循环(foreach):
for (E element: list) { System.out.println(element); }
(推荐)迭代器(Iterator):
Java
1
2
3
4Iterator<E> it = list.iterator();
while (it.hasNext()) {
System.out.println(it.next());
}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().
遍历方式:
增强 for 循环 (foreach):
for (E element : set) { System.out.println(element); }
迭代器 (Iterator):
Java
1
2
3
4Iterator<E> it = set.iterator();
while (it.hasNext()) {
System.out.println(it.next());
}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() 方法。
- 创建一个新的两倍大小的底层数组。
- 遍历旧数组中的所有键值对。
- 重新计算每个键的哈希值,并根据新的容量大小,将其放入新数组的正确位置。这个过程被称为再哈希 (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):
遍历方式:
遍历键集 (keySet()):
Java
1
2
3
4for (K key : map.keySet()) {
V value = map.get(key);
System.out.println("Key: " + key + ", Value: " + value);
}遍历键值对集 (entrySet()) - 推荐,效率最高:
Java
1
2
3for (Map.Entry<K, V> entry : map.entrySet()) {
System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
}遍历值集 (values()):
Java
1
2
3for (V value : map.values()) {
System.out.println("Value: " + value);
}迭代器 (Iterator):
Java
1
2
3
4
5Iterator<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());
}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
机制更强大、更灵活的并发控制手段。并发工具类
这些工具类都是为了解决特定场景下的线程协作问题而设计的,它们是 JUC 包的精华所在。
- CountDownLatch (倒计时器):就像一个比赛的倒计时牌。主线程在等待,而其他多个子线程在执行自己的任务。每完成一个任务,倒计时就减一。当倒计时减到零时,主线程才能继续执行。这非常适合“一等多”的场景,比如主线程需要等待所有子线程的数据加载完成后再开始处理。
- CyclicBarrier (循环屏障):可以看作是一个“集合点”。多个线程都跑到这里,互相等待。当所有线程都到达集合点后,它们才会一起继续前进。这个过程可以重复使用(“循环”),适合需要多个线程分阶段同步执行的场景。
- Semaphore (信号量):用来控制同时访问某个资源的线程数量。你可以把它想象成一个拥有固定数量许可证的停车场。当一个线程需要访问资源时,它就获取一个许可证。当许可证用完时,其他线程就必须等待。线程用完资源后归还许可证,下一个线程才能进入。这可以用来限制并发连接数,比如数据库连接池。
- Exchanger (交换器):提供一个两个线程间的数据交换点。当两个线程都运行到这个同步点时,它们会互相交换数据。这通常用于两个线程互相传递数据的场景,比如生产者和消费者线程之间的协作。
原子操作类
- AtomicInteger, AtomicLong 等原子类:这些类提供了一些“原子性”的操作,意思是这些操作是不可分割的。例如,
i++
在多线程环境下不是一个原子操作,可能导致数据不一致。而AtomicInteger
的incrementAndGet()
方法就能保证自增操作是线程安全的,并且性能比使用synchronized
关键字要高。它们通常用于需要对单个变量进行安全、高效地更新的场景。
小结 Map:
- HashMap: 最常用,需要快速查找,不关心键值对顺序,且在单线程或由外部同步机制保证线程安全的场景。
- LinkedHashMap: 需要快速查找,同时需要保持插入顺序或访问顺序的场景(如实现 LRU 缓存)。
- TreeMap: 需要根据键的自然顺序或自定义顺序排序的场景。
- ConcurrentHashMap: 高并发场景下对 Map 进行读写操作的首选。
- Hashtable: 已被淘汰,不推荐使用。
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。
常用 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;
}
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 | try (FileInputStream fis = new FileInputStream("source.txt"); |
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
24Path 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>() {
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));
}
public void failed(Throwable exc, ByteBuffer attachment) {
System.err.println("Read failed: " + exc.getMessage());
}
});
// 为了让主线程不立即退出,等待异步操作完成
Thread.sleep(1000);
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
Java 反射 (Reflection)
Java 反射机制是指在程序运行时,能够动态地获取类的信息(包括类的属性、方法、构造器等),并能够动态地操作类或对象(如创建对象、调用方法、修改属性)。
核心类与接口:
- Class 类:代表类的字节码文件,是反射的入口。
- Constructor 类:代表类的构造器。
- Method 类:代表类的方法。
- Field 类:代表类的成员变量(属性)。
- AccessibleObject:Field, Method, Constructor 的共同父类,提供了 setAccessible(true) 方法,用于抑制 Java 语言访问检查,从而访问私有成员。
获取 Class 对象的三种方式:
- Class.forName(“全限定类名”):最常用,动态加载类。
1
Class<?> clazz = Class.forName("java.lang.String");
- 类名.class:已知具体类名时使用,编译时加载。
1
Class<?> clazz = String.class;
- 对象.getClass():通过对象实例获取,运行时获取。
1
2String s = "hello";
Class<?> clazz = s.getClass();反射的应用:
- 动态创建对象:
1
2
3
4
5Class<?> 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
6Class<?> 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
6Class<?> 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 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 的垃圾回收流程,从而提高垃圾回收的效率。
新生代的垃圾回收流程
- 对象创建:新创建的对象首先在 Eden 区 分配。
- Minor GC:当 Eden 区 满了之后,会触发一次 Minor GC(也叫 Young GC)。
- 存活对象转移:
- 在 Eden 区和其中一个 Survivor 区(比如 S0)中,所有存活的对象会被复制到另一个空的 Survivor 区(比如 S1)。
- 同时,对象的年龄(age)会加一。
- 清空 Eden 和 S0:垃圾回收后,Eden 区和 S0 区都会被清空。
- 角色互换:下一次 Minor GC 时,Eden 区和 S1 区中存活的对象会被复制到 S0 区。S0 和 S1 两个 Survivor 区会不断地进行角色互换。
- 晋升老年代:
- 当对象的年龄达到一个设定的阈值(默认为 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。
JVM 调优参数
堆内存大小设置:
- -Xms
:设置 JVM 堆的初始内存大小。 - -Xmx
:设置 JVM 堆的最大内存大小。 - 最佳实践:通常建议 -Xms 和 -Xmx 设置为相同值,以避免 JVM 在运行时动态调整堆大小带来的额外开销和 GC 停顿。例如 -Xms4g -Xmx4g。
- -Xms
新生代大小设置:
- -Xmn
:设置新生代内存大小。 - -XX:NewRatio=
:设置老年代与新生代的比例,例如 -XX:NewRatio=2 表示老年代:新生代 = 2:1。 - 考量:
- 新生代过小:频繁 Minor GC, 导致对象过早进入老年代。
- 新生代过大:Minor GC 间隔长, 但每次 GC 耗时可能长。
- -Xmn
元空间大小设置 (JDK 1.8+):
- -XX:MetaspaceSize=
:设置元空间的初始大小。 - -XX:MaxMetaspaceSize=
:设置元空间的最大大小。 - 考量:如果应用加载大量类或使用动态代码生成, 可能需要调大。
- -XX:MetaspaceSize=
选择垃圾收集器:
- -XX:+UseG1GC:启用 G1 垃圾收集器。这是 JDK 9+ 的默认收集器。
- -XX:MaxGCPauseMillis=
:设置 G1 收集器可接受的最大停顿时间(G1 会尽量接近这个目标, 但不保证完全达到)。例如 -XX:MaxGCPauseMillis=200。
垃圾收集器 (Garbage Collector)
垃圾收集器是 JVM 的一个重要组成部分, 负责自动管理 Java 堆内存中的对象的生命周期, 回收不再使用的对象所占用的内存。
垃圾判断算法:
- 引用计数算法:当一个对象被引用一次, 计数器加1;引用失效, 计数器减1。当计数器为0时, 对象被判定为可回收。
- 缺点:难以解决对象之间的循环引用问题。Java 虚拟机不采用此算法。
- 可达性分析算法 (Root Tracing):通过一系列称为 “GC Roots” 的对象作为起始点, 从这些节点向下搜索, 搜索所走过的路径称为引用链 (Reference Chain)。当一个对象到 GC Roots 没有任何引用链相连时, 则证明此对象是不可用的。
- 可作为 GC Roots 的对象:
- 虚拟机栈 (栈帧中的局部变量表) 中引用的对象。
- 本地方法栈 (Native 方法) 中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 被同步锁持有的对象。
- JVM 内部的引用 (如基本数据类型对应的 Class 对象)。
- 可作为 GC Roots 的对象:
常见垃圾收集器:
Serial 收集器:
- 特点:单线程, 工作时需要停止所有用户线程 (“Stop The World”, STW)。简单高效, 适用于单核 CPU 或内存较小的客户端应用。
- 新生代使用:复制算法。
- 老年代使用:标记-整理算法。
ParNew 收集器:
- 特点:Serial 收集器的多线程版本, 用于新生代。并行收集时也需要 STW。
- 新生代使用:复制算法。
- 常与 CMS 收集器配合使用。
Parallel Scavenge 收集器:
- 特点:关注吞吐量(Throughput = 用户代码执行时间 / (用户代码执行时间 + GC 时间)),可以设置最大吞吐量或最大 GC 停顿时间。
- 新生代使用:复制算法。
- 老年代使用:与 Parallel Old 配合使用,使用标记-整理算法。
CMS (Concurrent Mark Sweep) 收集器:
- 特点:以获取最短回收停顿时间为目标,并发收集(与用户线程一起执行)。适用于对响应时间要求高的应用(如 Web 服务器)。
- 工作步骤:
- 初始标记 (Initial Mark):STW,标记 GC Roots 能直接关联到的对象,速度快。
- 并发标记 (Concurrent Mark):与用户线程并发执行,进行 GC Roots Tracing 过程,耗时最长。
- 重新标记 (Remark):STW,修正并发标记期间因用户程序继续运行而导致标记产生变动的对象,比初始标记耗时长,但远比并发标记短。
- 并发清除 (Concurrent Sweep):与用户线程并发执行,清除已标记为垃圾的对象。
- 缺点:
- 对 CPU 资源敏感:并发阶段会占用一部分 CPU。
- 无法处理浮动垃圾:并发清除阶段产生的垃圾(新生成的对象)只能下次 GC 再处理。
- 可能产生大量空间碎片:采用“标记-清除”算法,不进行整理,可能导致大对象无法分配空间而提前触发 Full GC。
G1 (Garbage-First) 收集器:
- 特点:JDK 9+ 的默认垃圾收集器。面向服务端应用,分区(将 Java 堆划分为多个独立区域 Region),可预测的停顿时间模型。
- 工作原理:
- 将堆内存划分为多个大小相等的 Region。
- G1 跟踪每个 Region 的垃圾回收价值 (Garbage-First),优先回收垃圾最多的 Region。
- 年轻化和老年代不再是物理隔离,而是逻辑上的概念,Region 可以动态地成为 Eden、Survivor 或 Old 区域。
- 并发与并行兼容:并发标记,但回收阶段并行。
- 基本无碎片:采用复制和标记-整理算法结合。
- 工作步骤:
- 初始标记 (Initial Mark):STW,标记 GC Roots 能直接关联的对象。
- 并发标记 (Concurrent Mark):与用户线程并发,遍历对象图。
- 最终标记 (Final Mark):STW,处理并发标记阶段结束后仍然存活的对象。
- 筛选回收 (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 程序代码(为静态变量赋予初始值,执行静态代码块)。
- 加载 (Loading):
类加载器 (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
对象。
- 作用:它负责加载 Java 核心库,比如
双亲委派模型 (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)回收的对象却因为某些原因(比如被一个长生命周期的对象引用着)而无法被回收。例如:
- 一个静态的
Map
或List
集合,不断地往里面添加对象,但从不删除。 - 监听器或回调函数没有正确移除,导致被监听的对象无法被回收。
- 数据库连接或文件流没有正确关闭,长时间占用资源。
- 一个静态的
- 内存使用不当:一次性加载大量数据到内存中。例如,从数据库查询数百万条记录,并把它们全部加载到一个
List
中;或者处理一个超大的图片或文件,导致瞬间占用大量内存。 - 配置问题:JVM 的堆内存设置得太小,无法满足程序的正常运行需求。这在部署应用时很常见,可以通过调整
-Xmx
参数来解决。
2. java.lang.StackOverflowError
这个错误表示 虚拟机栈(Stack) 溢出。每个线程都有一个独立的栈,用于存储方法调用的栈帧。当栈的深度超过了 JVM 允许的最大深度时,就会抛出此错误。
常见场景:
无限递归(Infinite Recursion):这是最典型的场景。一个方法不断地调用自身,且没有正确的退出条件。例如:
Java
1
2
3
4public 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 自动管理。如果程序中没有调用
ByteBuffer
的cleaner()
方法,或者在没有关闭资源的情况下直接内存泄漏,就会耗尽系统的直接内存。
5. java.lang.OutOfMemoryError: unable to create new native thread
这个错误通常不是因为 Java 堆内存不足,而是因为系统资源耗尽。
常见场景:
- 线程创建过多:程序中创建了大量的线程,导致系统无法为新的线程分配内存空间。每个线程除了 Java 堆中的栈空间外,还需要分配一些本地内存。
- 系统限制:操作系统对单个进程创建的线程数有限制。如果达到了这个上限,JVM 就会抛出此错误。在 Linux 系统中,你可以通过
ulimit -u
命令查看这个限制。 - 内存不足:系统内存(包括堆外内存)已经所剩无几,JVM 无法为新的线程栈分配足够的内存 r。