From 762e8c71236fdbc88ca2093831374cbf195c4024 Mon Sep 17 00:00:00 2001 From: meowrain Date: Sun, 26 Oct 2025 12:37:25 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=88=E9=9B=86=E7=AC=94=E8=AE=B0=E4=BD=93?= =?UTF-8?q?=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/content/posts/合集/DB.md | 16774 +++++++++++++++++++++++++ src/content/posts/合集/Frame.md | 11390 +++++++++++++++++ src/content/posts/合集/Java.md | 17608 +++++++++++++++++++++++++++ src/content/posts/合集/Prog.md | 15480 ++++++++++++++++++++++++ src/content/posts/合集/SSM.md | 19516 ++++++++++++++++++++++++++++++ src/content/posts/合集/Tool.md | 4066 +++++++ src/content/posts/合集/Web.md | 9191 ++++++++++++++ 7 files changed, 94025 insertions(+) create mode 100644 src/content/posts/合集/DB.md create mode 100644 src/content/posts/合集/Frame.md create mode 100644 src/content/posts/合集/Java.md create mode 100644 src/content/posts/合集/Prog.md create mode 100644 src/content/posts/合集/SSM.md create mode 100644 src/content/posts/合集/Tool.md create mode 100644 src/content/posts/合集/Web.md diff --git a/src/content/posts/合集/DB.md b/src/content/posts/合集/DB.md new file mode 100644 index 0000000..49f3741 --- /dev/null +++ b/src/content/posts/合集/DB.md @@ -0,0 +1,16774 @@ +--- +title: 数据库笔记合集 +published: 2025-10-26 +description: '' +image: '' +tags: [DB] +category: '合集' +draft: false +lang: '' +--- + +# MySQL + +## 简介 + +### 数据库 + +数据库:DataBase,简称 DB,存储和管理数据的仓库 + +数据库的优势: + +- 可以持久化存储数据 +- 方便存储和管理数据 +- 使用了统一的方式操作数据库 SQL + +数据库、数据表、数据的关系介绍: + +- 数据库 + + - 用于存储和管理数据的仓库 + - 一个库中可以包含多个数据表 + +- 数据表 + + - 数据库最重要的组成部分之一 + - 由纵向的列和横向的行组成(类似 excel 表格) + - 可以指定列名、数据类型、约束等 + - 一个表中可以存储多条数据 + +- 数据:想要永久化存储的数据 + + + + +参考视频:https://www.bilibili.com/video/BV1zJ411M7TB + +参考专栏:https://time.geekbang.org/column/intro/139 + +参考书籍:https://book.douban.com/subject/35231266/ + + + +*** + + + +### MySQL + +MySQL 数据库是一个最流行的关系型数据库管理系统之一,关系型数据库是将数据保存在不同的数据表中,而且表与表之间可以有关联关系,提高了灵活性 + +缺点:数据存储在磁盘中,导致读写性能差,而且数据关系复杂,扩展性差 + +MySQL 所使用的 SQL 语句是用于访问数据库最常用的标准化语言 + +MySQL 配置: + +* MySQL 安装:https://www.jianshu.com/p/ba48f1e386f0 + +* MySQL 配置: + + * 修改 MySQL 默认字符集:安装 MySQL 之后第一件事就是修改字符集编码 + + ```mysql + vim /etc/mysql/my.cnf + + 添加如下内容: + [mysqld] + character-set-server=utf8 + collation-server=utf8_general_ci + + [client] + default-character-set=utf8 + ``` + + * 启动 MySQL 服务: + + ```shell + systemctl start/restart mysql + ``` + + * 登录 MySQL: + + ```shell + mysql -u root -p 敲回车,输入密码 + 初始密码查看:cat /var/log/mysqld.log + 在root@localhost: 后面的就是初始密码 + ``` + + * 查看默认字符集命令: + + ```mysql + SHOW VARIABLES LIKE 'char%'; + ``` + + * 修改MySQL登录密码: + + ```mysql + set global validate_password_policy=0; + set global validate_password_length=1; + + set password=password('密码'); + ``` + + * 授予远程连接权限(MySQL 内输入): + + ```mysql + -- 授权 + grant all privileges on *.* to 'root' @'%' identified by '密码'; + -- 刷新 + flush privileges; + ``` + +* 修改 MySQL 绑定 IP: + + ```shell + cd /etc/mysql/mysql.conf.d + sudo chmod 666 mysqld.cnf + vim mysqld.cnf + # bind-address = 127.0.0.1注释该行 + ``` + +* 关闭 Linux 防火墙 + + ```shell + systemctl stop firewalld.service + # 放行3306端口 + ``` + + + + + +*** + + + + + +## 体系架构 + +### 整体架构 + +体系结构详解: + +* 第一层:网络连接层 + * 一些客户端和链接服务,包含本地 Socket 通信和大多数基于客户端/服务端工具实现的 TCP/IP 通信,主要完成一些类似于连接处理、授权认证、及相关的安全方案 + * 在该层上引入了**连接池** Connection Pool 的概念,管理缓冲用户连接,线程处理等需要缓存的需求 + * 在该层上实现基于 SSL 的安全链接,服务器也会为安全接入的每个客户端验证它所具有的操作权限 + +- 第二层:核心服务层 + * 查询缓存、分析器、优化器、执行器等,涵盖 MySQL 的大多数核心服务功能,所有的内置函数(日期、数学、加密函数等) + * Management Serveices & Utilities:系统管理和控制工具,备份、安全、复制、集群等 + * SQL Interface:接受用户的 SQL 命令,并且返回用户需要查询的结果 + * Parser:SQL 语句分析器 + * Optimizer:查询优化器 + * Caches & Buffers:查询缓存,服务器会查询内部的缓存,如果缓存空间足够大,可以在大量读操作的环境中提升系统性能 + * 所有**跨存储引擎的功能**在这一层实现,如存储过程、触发器、视图等 + * 在该层服务器会解析查询并创建相应的内部解析树,并对其完成相应的优化如确定表的查询顺序,是否利用索引等, 最后生成相应的执行操作 + * MySQL 中服务器层不管理事务,**事务是由存储引擎实现的** +- 第三层:存储引擎层 + - Pluggable Storage Engines:存储引擎接口,MySQL 区别于其他数据库的重要特点就是其存储引擎的架构模式是插件式的(存储引擎是基于表的,而不是数据库) + - 存储引擎**真正的负责了 MySQL 中数据的存储和提取**,服务器通过 API 和存储引擎进行通信 + - 不同的存储引擎具有不同的功能,共用一个 Server 层,可以根据开发的需要,来选取合适的存储引擎 +- 第四层:系统文件层 + - 数据存储层,主要是将数据存储在文件系统之上,并完成与存储引擎的交互 + - File System:文件系统,保存配置文件、数据文件、日志文件、错误文件、二进制文件等 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-体系结构.png) + + + +*** + + + +### 建立连接 + +#### 连接器 + +池化技术:对于访问数据库来说,建立连接的代价是比较昂贵的,因为每个连接对应一个用来交互的线程,频繁的创建关闭连接比较耗费资源,有必要建立数据库连接池,以提高访问的性能 + +连接建立 TCP 以后需要做**权限验证**,验证成功后可以进行执行 SQL。如果这时管理员账号对这个用户的权限做了修改,也不会影响已经存在连接的权限,只有再新建的连接才会使用新的权限设置 + +MySQL 服务器可以同时和多个客户端进行交互,所以要保证每个连接会话的隔离性(事务机制部分详解) + +整体的执行流程: + + + + + +*** + + + +#### 权限信息 + +grant 语句会同时修改数据表和内存,判断权限的时候使用的是内存数据 + +flush privileges 语句本身会用数据表(磁盘)的数据重建一份内存权限数据,所以在权限数据可能存在不一致的情况下使用,这种不一致往往是由于直接用 DML 语句操作系统权限表导致的,所以尽量不要使用这类语句 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-权限范围.png) + + + + + + + +**** + + + +#### 连接状态 + +客户端如果长时间没有操作,连接器就会自动断开,时间是由参数 wait_timeout 控制的,默认值是 8 小时。如果在连接被断开之后,客户端**再次发送请求**的话,就会收到一个错误提醒:`Lost connection to MySQL server during query` + +数据库里面,长连接是指连接成功后,如果客户端持续有请求,则一直使用同一个连接;短连接则是指每次执行完很少的几次查询就断开连接,下次查询再重新建立一个 + +为了减少连接的创建,推荐使用长连接,但是**过多的长连接会造成 OOM**,解决方案: + +* 定期断开长连接,使用一段时间,或者程序里面判断执行过一个占用内存的大查询后,断开连接,之后要查询再重连 + + ```mysql + KILL CONNECTION id + ``` + +* MySQL 5.7 版本,可以在每次执行一个比较大的操作后,通过执行 mysql_reset_connection 来重新初始化连接资源,这个过程不需要重连和重新做权限验证,但是会将连接恢复到刚刚创建完时的状态 + +SHOW PROCESSLIST:查看当前 MySQL 在进行的线程,可以实时地查看 SQL 的执行情况,其中的 Command 列显示为 Sleep 的这一行,就表示现在系统里面有一个空闲连接 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-SHOW_PROCESSLIST命令.png) + +| 参数 | 含义 | +| ------- | ------------------------------------------------------------ | +| ID | 用户登录 mysql 时系统分配的 connection_id,可以使用函数 connection_id() 查看 | +| User | 显示当前用户,如果不是 root,这个命令就只显示用户权限范围的 sql 语句 | +| Host | 显示这个语句是从哪个 ip 的哪个端口上发的,可以用来跟踪出现问题语句的用户 | +| db | 显示这个进程目前连接的是哪个数据库 | +| Command | 显示当前连接的执行的命令,一般取值为休眠 Sleep、查询 Query、连接 Connect 等 | +| Time | 显示这个状态持续的时间,单位是秒 | +| State | 显示使用当前连接的 sql 语句的状态,以查询为例,需要经过 copying to tmp table、sorting result、sending data等状态才可以完成 | +| Info | 显示执行的 sql 语句,是判断问题语句的一个重要依据 | + +**Sending data 状态**表示 MySQL 线程开始访问数据行并把结果返回给客户端,而不仅仅只是返回给客户端,是处于执行器过程中的任意阶段。由于在 Sending data 状态下,MySQL 线程需要做大量磁盘读取操作,所以是整个查询中耗时最长的状态 + + + + + +*** + + + +### 执行流程 + +#### 查询缓存 + +##### 工作流程 + +当执行完全相同的 SQL 语句的时候,服务器就会直接从缓存中读取结果,当数据被修改,之前的缓存会失效,修改比较频繁的表不适合做查询缓存 + +查询过程: + +1. 客户端发送一条查询给服务器 +2. 服务器先会检查查询缓存,如果命中了缓存,则立即返回存储在缓存中的结果(一般是 K-V 键值对),否则进入下一阶段 +3. 分析器进行 SQL 分析,再由优化器生成对应的执行计划 +4. 执行器根据优化器生成的执行计划,调用存储引擎的 API 来执行查询 +5. 将结果返回给客户端 + +大多数情况下不建议使用查询缓存,因为查询缓存往往弊大于利 + +* 查询缓存的**失效非常频繁**,只要有对一个表的更新,这个表上所有的查询缓存都会被清空。因此很可能费力地把结果存起来,还没使用就被一个更新全清空了,对于更新压力大的数据库来说,查询缓存的命中率会非常低 +* 除非业务就是有一张静态表,很长时间才会更新一次,比如一个系统配置表,那这张表上的查询才适合使用查询缓存 + + + +*** + + + +##### 缓存配置 + +1. 查看当前 MySQL 数据库是否支持查询缓存: + + ```mysql + SHOW VARIABLES LIKE 'have_query_cache'; -- YES + ``` + +2. 查看当前 MySQL 是否开启了查询缓存: + + ```mysql + SHOW VARIABLES LIKE 'query_cache_type'; -- OFF + ``` + + 参数说明: + + * OFF 或 0:查询缓存功能关闭 + + * ON 或 1:查询缓存功能打开,查询结果符合缓存条件即会缓存,否则不予缓存;可以显式指定 SQL_NO_CACHE 不予缓存 + + * DEMAND 或 2:查询缓存功能按需进行,显式指定 SQL_CACHE 的 SELECT 语句才缓存,其它不予缓存 + + ```mysql + SELECT SQL_CACHE id, name FROM customer; -- SQL_CACHE:查询结果可缓存 + SELECT SQL_NO_CACHE id, name FROM customer;-- SQL_NO_CACHE:不使用查询缓存 + ``` + +3. 查看查询缓存的占用大小: + + ```mysql + SHOW VARIABLES LIKE 'query_cache_size';-- 单位是字节 1048576 / 1024 = 1024 = 1KB + ``` + +4. 查看查询缓存的状态变量: + + ```mysql + SHOW STATUS LIKE 'Qcache%'; + ``` + + + + | 参数 | 含义 | + | ----------------------- | ------------------------------------------------------------ | + | Qcache_free_blocks | 查询缓存中的可用内存块数 | + | Qcache_free_memory | 查询缓存的可用内存量 | + | Qcache_hits | 查询缓存命中数 | + | Qcache_inserts | 添加到查询缓存的查询数 | + | Qcache_lowmen_prunes | 由于内存不足而从查询缓存中删除的查询数 | + | Qcache_not_cached | 非缓存查询的数量(由于 query_cache_type 设置而无法缓存或未缓存) | + | Qcache_queries_in_cache | 查询缓存中注册的查询数 | + | Qcache_total_blocks | 查询缓存中的块总数 | + +5. 配置 my.cnf: + + ```sh + sudo chmod 666 /etc/mysql/my.cnf + vim my.cnf + # mysqld中配置缓存 + query_cache_type=1 + ``` + + 重启服务既可生效,执行 SQL 语句进行验证 ,执行一条比较耗时的 SQL 语句,然后再多执行几次,查看后面几次的执行时间;获取通过查看查询缓存的缓存命中数,来判定是否走查询缓存 + + + +*** + + + +##### 缓存失效 + +查询缓存失效的情况: + +* SQL 语句不一致,要想命中查询缓存,查询的 SQL 语句必须一致,因为**缓存中 key 是查询的语句**,value 是查询结构 + + ```mysql + select count(*) from tb_item; + Select count(*) from tb_item; -- 不走缓存,首字母不一致 + ``` + +* 当查询语句中有一些不确定查询时,则不会缓存,比如:now()、current_date()、curdate()、curtime()、rand()、uuid()、user()、database() + + ```mysql + SELECT * FROM tb_item WHERE updatetime < NOW() LIMIT 1; + SELECT USER(); + SELECT DATABASE(); + ``` + +* 不使用任何表查询语句: + + ```mysql + SELECT 'A'; + ``` + +* 查询 mysql、information_schema、performance_schema 等系统表时,不走查询缓存: + + ```mysql + SELECT * FROM information_schema.engines; + ``` + +* 在**跨存储引擎**的存储过程、触发器或存储函数的主体内执行的查询,缓存失效 + +* 如果表更改,则使用该表的**所有高速缓存查询都将变为无效**并从高速缓存中删除,包括使用 MERGE 映射到已更改表的表的查询,比如:INSERT、UPDATE、DELETE、ALTER TABLE、DROP TABLE、DROP DATABASE + + + +*** + + + +#### 分析器 + +没有命中查询缓存,就开始了 SQL 的真正执行,分析器会对 SQL 语句做解析 + +```sql +SELECT * FROM t WHERE id = 1; +``` + +解析器:处理语法和解析查询,生成一课对应的解析树 + +* 先做**词法分析**,输入的是由多个字符串和空格组成的一条 SQL 语句,MySQL 需要识别出里面的字符串分别是什么代表什么。从输入的 select 这个关键字识别出来这是一个查询语句;把字符串 t 识别成 表名 t,把字符串 id 识别成列 id +* 然后做**语法分析**,根据词法分析的结果,语法分析器会根据语法规则,判断你输入的这个 SQL 语句是否满足 MySQL 语法。如果语句不对,就会收到 `You have an error in your SQL syntax` 的错误提醒 + +预处理器:进一步检查解析树的合法性,比如数据表和数据列是否存在、别名是否有歧义等 + + + +*** + + + +#### 优化器 + +##### 成本分析 + +优化器是在表里面有多个索引的时候,决定使用哪个索引;或者在一个语句有多表关联(join)的时候,决定各个表的连接顺序 + +* 根据搜索条件找出所有可能的使用的索引 +* 成本分析,执行成本由 I/O 成本和 CPU 成本组成,计算全表扫描和使用不同索引执行 SQL 的代价 +* 找到一个最优的执行方案,用最小的代价去执行语句 + +在数据库里面,扫描行数是影响执行代价的因素之一,扫描的行数越少意味着访问磁盘的次数越少,消耗的 CPU 资源越少,优化器还会结合是否使用临时表、是否排序等因素进行综合判断 + + + +*** + + + +##### 统计数据 + +MySQL 中保存着两种统计数据: + +* innodb_table_stats 存储了表的统计数据,每一条记录对应着一个表的统计数据 +* innodb_index_stats 存储了索引的统计数据,每一条记录对应着一个索引的一个统计项的数据 + +MySQL 在真正执行语句之前,并不能精确地知道满足条件的记录有多少条,只能根据统计信息来估算记录,统计信息就是索引的区分度,一个索引上不同的值的个数(比如性别只能是男女,就是 2 ),称之为基数(cardinality),**基数越大说明区分度越好** + +通过**采样统计**来获取基数,InnoDB 默认会选择 N 个数据页,统计这些页面上的不同值得到一个平均值,然后乘以这个索引的页面数,就得到了这个索引的基数 + +在 MySQL 中,有两种存储统计数据的方式,可以通过设置参数 `innodb_stats_persistent` 的值来选择: + +* ON:表示统计信息会持久化存储(默认),采样页数 N 默认为 20,可以通过 `innodb_stats_persistent_sample_pages` 指定,页数越多统计的数据越准确,但消耗的资源更大 +* OFF:表示统计信息只存储在内存,采样页数 N 默认为 8,也可以通过系统变量设置(不推荐,每次重新计算浪费资源) + +数据表是会持续更新的,两种统计信息的更新方式: + +* 设置 `innodb_stats_auto_recalc` 为 1,当发生变动的记录数量超过表大小的 10% 时,自动触发重新计算,不过是**异步进行** +* 调用 `ANALYZE TABLE t` 手动更新统计信息,只对信息做**重新统计**(不是重建表),没有修改数据,这个过程中加了 MDL 读锁并且是同步进行,所以会暂时阻塞系统 + +**EXPLAIN 执行计划在优化器阶段生成**,如果 explain 的结果预估的 rows 值跟实际情况差距比较大,可以执行 analyze 命令重新修正信息 + + + +*** + + + +##### 错选索引 + +采样统计本身是估算数据,或者 SQL 语句中的字段选择有问题时,可能导致 MySQL 没有选择正确的执行索引 + +解决方法: + +* 采用 force index 强行选择一个索引 + + ```sql + SELECT * FROM user FORCE INDEX(name) WHERE NAME='seazean'; + ``` + +* 可以考虑修改 SQL 语句,引导 MySQL 使用期望的索引 + +* 新建一个更合适的索引,来提供给优化器做选择,或删掉误用的索引 + + + +*** + + + +#### 执行器 + +开始执行的时候,要先判断一下当前连接对表有没有**执行查询的权限**,如果没有就会返回没有权限的错误,在工程实现上,如果命中查询缓存,会在查询缓存返回结果的时候,做权限验证。如果有权限,就打开表继续执行,执行器就会根据表的引擎定义,去使用这个引擎提供的接口 + + + +*** + + + +#### 引擎层 + +Server 层和存储引擎层的交互是**以记录为单位的**,存储引擎会将单条记录返回给 Server 层做进一步处理,并不是直接返回所有的记录 + +工作流程: + +* 首先根据二级索引选择扫描范围,获取第一条符合二级索引条件的记录,进行回表查询,将聚簇索引的记录返回 Server 层,由 Server 判断记录是否符合要求 +* 然后在二级索引上继续扫描下一个符合条件的记录 + + + +推荐阅读:https://mp.weixin.qq.com/s/YZ-LckObephrP1f15mzHpA + + + + + +*** + + + +### 终止流程 + +#### 终止语句 + +终止线程中正在执行的语句: + +```mysql +KILL QUERY thread_id +``` + +KILL 不是马上终止的意思,而是告诉执行线程这条语句已经不需要继续执行,可以开始执行停止的逻辑(类似于打断)。因为对表做增删改查操作,会在表上加 MDL 读锁,如果线程被 KILL 时就直接终止,那这个 MDL 读锁就没机会被释放了 + +命令 `KILL QUERYthread_id_A` 的执行流程: + +* 把 session A 的运行状态改成 THD::KILL_QUERY(将变量 killed 赋值为 THD::KILL_QUERY) +* 给 session A 的执行线程发一个信号,让 session A 来处理这个 THD::KILL_QUERY 状态 + +会话处于等待状态(锁阻塞),必须满足是一个可以被唤醒的等待,必须有机会去**判断线程的状态**,如果不满足就会造成 KILL 失败 + +典型场景:innodb_thread_concurrency 为 2,代表并发线程上限数设置为 2 + +* session A 执行事务,session B 执行事务,达到线程上限;此时 session C 执行事务会阻塞等待,session D 执行 kill query C 无效 +* C 的逻辑是每 10 毫秒判断是否可以进入 InnoDB 执行,如果不行就调用 nanosleep 函数进入 sleep 状态,没有去判断线程状态 + +补充:执行 Ctrl+C 的时候,是 MySQL 客户端另外启动一个连接,然后发送一个 KILL QUERY 命令 + + + +*** + + + +#### 终止连接 + +断开线程的连接: + +```mysql +KILL CONNECTION id +``` + +断开连接后执行 SHOW PROCESSLIST 命令,如果这条语句的 Command 列显示 Killed,代表线程的状态是 KILL_CONNECTION,说明这个线程有语句正在执行,当前状态是停止语句执行中,终止逻辑耗时较长 + +* 超大事务执行期间被 KILL,这时回滚操作需要对事务执行期间生成的所有新数据版本做回收操作,耗时很长 +* 大查询回滚,如果查询过程中生成了比较大的临时文件,删除临时文件可能需要等待 IO 资源,导致耗时较长 +* DDL 命令执行到最后阶段被 KILL,需要删除中间过程的临时文件,也可能受 IO 资源影响耗时较久 + +总结:KILL CONNECTION 本质上只是把客户端的 SQL 连接断开,后面的终止流程还是要走 KILL QUERY + +一个事务被 KILL 之后,持续处于回滚状态,不应该强行重启整个 MySQL 进程,应该等待事务自己执行完成,因为重启后依然继续做回滚操作的逻辑 + + + + + +*** + + + +### 常用工具 + +#### mysql + +mysql 不是指 mysql 服务,而是指 mysql 的客户端工具 + +```sh +mysql [options] [database] +``` + +* -u --user=name:指定用户名 +* -p --password[=name]:指定密码 +* -h --host=name:指定服务器IP或域名 +* -P --port=#:指定连接端口 +* -e --execute=name:执行SQL语句并退出,在控制台执行SQL语句,而不用连接到数据库执行 + +示例: + +```sh +mysql -h 127.0.0.1 -P 3306 -u root -p +mysql -uroot -p2143 db01 -e "select * from tb_book"; +``` + + + +*** + + + +#### admin + +mysqladmin 是一个执行管理操作的客户端程序,用来检查服务器的配置和当前状态、创建并删除数据库等 + +通过 `mysqladmin --help` 指令查看帮助文档 + +```sh +mysqladmin -uroot -p2143 create 'test01'; +``` + + + +*** + + + +#### binlog + +服务器生成的日志文件以二进制格式保存,如果需要检查这些文本,就要使用 mysqlbinlog 日志管理工具 + +```sh +mysqlbinlog [options] log-files1 log-files2 ... +``` + +* -d --database=name:指定数据库名称,只列出指定的数据库相关操作 + +* -o --offset=#:忽略掉日志中的前 n 行命令。 + +* -r --result-file=name:将输出的文本格式日志输出到指定文件。 + +* -s --short-form:显示简单格式,省略掉一些信息。 + +* --start-datatime=date1 --stop-datetime=date2:指定日期间隔内的所有日志 + +* --start-position=pos1 --stop-position=pos2:指定位置间隔内的所有日志 + + + +*** + + + +#### dump + +##### 命令介绍 + +mysqldump 客户端工具用来备份数据库或在不同数据库之间进行数据迁移,备份内容包含创建表,及插入表的 SQL 语句 + +```sh +mysqldump [options] db_name [tables] +mysqldump [options] --database/-B db1 [db2 db3...] +mysqldump [options] --all-databases/-A +``` + +连接选项: + +* -u --user=name:指定用户名 +* -p --password[=name]:指定密码 +* -h --host=name:指定服务器 IP 或域名 +* -P --port=#:指定连接端口 + +输出内容选项: + +* --add-drop-database:在每个数据库创建语句前加上 Drop database 语句 +* --add-drop-table:在每个表创建语句前加上 Drop table 语句 , 默认开启,不开启 (--skip-add-drop-table) +* -n --no-create-db:不包含数据库的创建语句 +* -t --no-create-info:不包含数据表的创建语句 +* -d --no-data:不包含数据 +* -T, --tab=name:自动生成两个文件:一个 .sql 文件,创建表结构的语句;一个 .txt 文件,数据文件,相当于 select into outfile + +示例: + +```sh +mysqldump -uroot -p2143 db01 tb_book --add-drop-database --add-drop-table > a +mysqldump -uroot -p2143 -T /tmp test city +``` + + + +*** + + + +##### 数据备份 + +命令行方式: + +* 备份命令:mysqldump -u root -p 数据库名称 > 文件保存路径 +* 恢复 + 1. 登录MySQL数据库:`mysql -u root p` + 2. 删除已经备份的数据库 + 3. 重新创建与备份数据库名称相同的数据库 + 4. 使用该数据库 + 5. 导入文件执行:`source 备份文件全路径` + +更多方式参考:https://time.geekbang.org/column/article/81925 + +图形化界面: + +* 备份 + + ![图形化界面备份](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/图形化界面备份.png) + +* 恢复 + + ![图形化界面恢复](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/图形化界面恢复.png) + + + + + +*** + + + +#### import + +mysqlimport 是客户端数据导入工具,用来导入mysqldump 加 -T 参数后导出的文本文件 + +```sh +mysqlimport [options] db_name textfile1 [textfile2...] +``` + +示例: + +```sh +mysqlimport -uroot -p2143 test /tmp/city.txt +``` + +导入 sql 文件,可以使用 MySQL 中的 source 指令 : + +```mysql +source 文件全路径 +``` + + + +*** + + + +#### show + +mysqlshow 客户端对象查找工具,用来很快地查找存在哪些数据库、数据库中的表、表中的列或者索引 + +```sh +mysqlshow [options] [db_name [table_name [col_name]]] +``` + +* --count:显示数据库及表的统计信息(数据库,表 均可以不指定) + +* -i:显示指定数据库或者指定表的状态信息 + +示例: + +```sh +#查询每个数据库的表的数量及表中记录的数量 +mysqlshow -uroot -p1234 --count +#查询test库中每个表中的字段书,及行数 +mysqlshow -uroot -p1234 test --count +#查询test库中book表的详细情况 +mysqlshow -uroot -p1234 test book --count +``` + + + + + + + +**** + + + + + +## 单表操作 + +### SQL + +- SQL + + - Structured Query Language:结构化查询语言 + - 定义了操作所有关系型数据库的规则,每种数据库操作的方式可能会存在不一样的地方,称为“方言” + +- SQL 通用语法 + + - SQL 语句可以单行或多行书写,以**分号结尾**。 + - 可使用空格和缩进来增强语句的可读性。 + - MySQL 数据库的 SQL 语句不区分大小写,**关键字建议使用大写**。 + - 数据库的注释: + - 单行注释:-- 注释内容 #注释内容(MySQL 特有) + - 多行注释:/* 注释内容 */ + +- SQL 分类 + + - DDL(Data Definition Language)数据定义语言 + + - 用来定义数据库对象:数据库,表,列等。关键字:create、drop,、alter 等 + + - DML(Data Manipulation Language)数据操作语言 + + - 用来对数据库中表的数据进行增删改。关键字:insert、delete、update 等 + + - DQL(Data Query Language)数据查询语言 + + - 用来查询数据库中表的记录(数据)。关键字:select、where 等 + + - DCL(Data Control Language)数据控制语言 + + - 用来定义数据库的访问权限和安全级别,及创建用户。关键字:grant, revoke等 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-SQL分类.png) + + + +*** + + + +### DDL + +#### 数据库 + +* R(Retrieve):查询 + + * 查询所有数据库: + + ```mysql + SHOW DATABASES; + ``` + + * 查询某个数据库的创建语句 + + ```sql + SHOW CREATE DATABASE 数据库名称; -- 标准语法 + + SHOW CREATE DATABASE mysql; -- 查看mysql数据库的创建格式 + ``` + + + +* C(Create):创建 + + * 创建数据库 + + ```mysql + CREATE DATABASE 数据库名称;-- 标准语法 + + CREATE DATABASE db1; -- 创建db1数据库 + ``` + + * 创建数据库(判断,如果不存在则创建) + + ```mysql + CREATE DATABASE IF NOT EXISTS 数据库名称; + ``` + + * 创建数据库,并指定字符集 + + ```mysql + CREATE DATABASE 数据库名称 CHARACTER SET 字符集名称; + ``` + + * 例如:创建db4数据库、如果不存在则创建,指定字符集为gbk + + ```mysql + -- 创建db4数据库、如果不存在则创建,指定字符集为gbk + CREATE DATABASE IF NOT EXISTS db4 CHARACTER SET gbk; + + -- 查看db4数据库的字符集 + SHOW CREATE DATABASE db4; + ``` + + + +* U(Update):修改 + + * 修改数据库的字符集 + + ```mysql + ALTER DATABASE 数据库名称 CHARACTER SET 字符集名称; + ``` + + * 常用字符集: + + ```mysql + --查询所有支持的字符集 + SHOW CHARSET; + --查看所有支持的校对规则 + SHOW COLLATION; + + -- 字符集: utf8,latinI,GBK,,GBK是utf8的子集 + -- 校对规则: ci 大小定不敏感,cs或bin大小写敏感 + ``` + + + +* D(Delete):删除 + + * 删除数据库: + + ```mysql + DROP DATABASE 数据库名称; + ``` + + * 删除数据库(判断,如果存在则删除): + + ```mysql + DROP DATABASE IF EXISTS 数据库名称; + ``` + + + +* 使用数据库: + + * 查询当前正在使用的数据库名称 + + ```mysql + SELECT DATABASE(); + ``` + + * 使用数据库 + + ```mysql + USE 数据库名称; -- 标准语法 + USE db4; -- 使用db4数据库 + ``` + + + + +*** + + + +#### 数据表 + +- R(Retrieve):查询 + + - 查询数据库中所有的数据表 + + ```mysql + USE mysql;-- 使用mysql数据库 + + SHOW TABLES;-- 查询库中所有的表 + ``` + + - 查询表结构 + + ```mysql + DESC 表名; + ``` + + - 查询表字符集 + + ```mysql + SHOW TABLE STATUS FROM 库名 LIKE '表名'; + ``` + + + +- C(Create):创建 + + - 创建数据表 + + ```mysql + CREATE TABLE 表名( + 列名1 数据类型1, + 列名2 数据类型2, + .... + 列名n 数据类型n + ); + -- 注意:最后一列,不需要加逗号 + ``` + + - 复制表 + + ```mysql + CREATE TABLE 表名 LIKE 被复制的表名; -- 标准语法 + + CREATE TABLE product2 LIKE product; -- 复制product表到product2表 + ``` + + - 数据类型 + + | 数据类型 | 说明 | + | --------- | ------------------------------------------------------------ | + | INT | 整数类型 | + | DOUBLE | 小数类型 | + | DATE | 日期,只包含年月日:yyyy-MM-dd | + | DATETIME | 日期,包含年月日时分秒:yyyy-MM-dd HH:mm:ss | + | TIMESTAMP | 时间戳类型,包含年月日时分秒:yyyy-MM-dd HH:mm:ss
如果不给这个字段赋值或赋值为 NULL,则默认使用当前的系统时间 | + | CHAR | 字符串,定长类型 | + | VARCHAR | 字符串,**变长类型**
name varchar(20) 代表姓名最大 20 个字符:zhangsan 8 个字符,张三 2 个字符 | + + `INT(n)`:n 代表位数 + + * 3:int(9)显示结果为 000000010 + * 3:int(3)显示结果为 010 + + `varchar(n)`:n 表示的是字符数 + + - 例如: + + ```mysql + -- 使用db3数据库 + USE db3; + + -- 创建一个product商品表 + CREATE TABLE product( + id INT, -- 商品编号 + NAME VARCHAR(30), -- 商品名称 + price DOUBLE, -- 商品价格 + stock INT, -- 商品库存 + insert_time DATE -- 上架时间 + ); + ``` + +​ + +- U(Update):修改 + + - 修改表名 + + ```mysql + ALTER TABLE 表名 RENAME TO 新的表名; + ``` + + - 修改表的字符集 + + ```mysql + ALTER TABLE 表名 CHARACTER SET 字符集名称; + ``` + + - 添加一列 + + ```mysql + ALTER TABLE 表名 ADD 列名 数据类型; + ``` + + - 修改列数据类型 + + ```mysql + ALTER TABLE 表名 MODIFY 列名 新数据类型; + ``` + + - 修改列名称和数据类型 + + ```mysql + ALTER TABLE 表名 CHANGE 列名 新列名 新数据类型; + ``` + + - 删除列 + + ```mysql + ALTER TABLE 表名 DROP 列名; + ``` + + + +- D(Delete):删除 + + - 删除数据表 + + ```mysql + DROP TABLE 表名; + ``` + + - 删除数据表(判断,如果存在则删除) + + ```mysql + DROP TABLE IF EXISTS 表名; + ``` + + + +*** + + + +### DML + +#### INSERT + +* 新增表数据 + + * 新增格式 1:给指定列添加数据 + + ```mysql + INSERT INTO 表名(列名1,列名2...) VALUES (值1,值2...); + ``` + + * 新增格式 2:默认给全部列添加数据 + + ```mysql + INSERT INTO 表名 VALUES (值1,值2,值3,...); + ``` + + * 新增格式 3:批量添加数据 + + ```mysql + -- 给指定列批量添加数据 + INSERT INTO 表名(列名1,列名2,...) VALUES (值1,值2,...),(值1,值2,...)...; + + -- 默认给所有列批量添加数据 + INSERT INTO 表名 VALUES (值1,值2,值3,...),(值1,值2,值3,...)...; + ``` + +* 字符串拼接 + + ```mysql + CONCAT(string1,string2,'',...) + ``` + + + +* 注意事项 + + - 列名和值的数量以及数据类型要对应 + - 除了数字类型,其他数据类型的数据都需要加引号(单引双引都可以,推荐单引) + + + +*** + + + +#### UPDATE + +* 修改表数据语法 + + * 标准语法 + + ```mysql + UPDATE 表名 SET 列名1 = 值1,列名2 = 值2,... [where 条件]; + ``` + + * 修改电视的价格为1800、库存为36 + + ```mysql + UPDATE product SET price=1800,stock=36 WHERE NAME='电视'; + SELECT * FROM product;-- 查看所有商品信息 + ``` + +* 注意事项 + + - 修改语句中必须加条件 + - 如果不加条件,则将所有数据都修改 + + + +*** + + + +#### DELETE + +* 删除表数据语法 + + ```mysql + DELETE FROM 表名 [WHERE 条件]; + ``` + +* 注意事项 + * 删除语句中必须加条件 + * 如果不加条件,则将所有数据删除 + + + +​ + +*** + + + +### DQL + +#### 查询语法 + +数据库查询遵循条件在前的原则 + +```mysql +SELECT DISTINCT + + +ORDER BY + +LIMIT +``` + + + +*** + + + +#### 查询全部 + +* 查询全部的表数据 + + ```mysql + -- 标准语法 + SELECT * FROM 表名; + + -- 查询product表所有数据(常用) + SELECT * FROM product; + ``` + +* 查询指定字段的表数据 + + ```mysql + SELECT 列名1,列名2,... FROM 表名; + ``` + +* **去除重复查询**:只有值全部重复的才可以去除,需要创建临时表辅助查询 + + ```mysql + SELECT DISTINCT 列名1,列名2,... FROM 表名; + ``` + +* 计算列的值(四则运算) + + ```mysql + SELECT 列名1 运算符(+ - * /) 列名2 FROM 表名; + + /*如果某一列值为null,可以进行替换 + ifnull(表达式1,表达式2) + 表达式1:想替换的列 + 表达式2:想替换的值*/ + ``` + + 例如: + + ```mysql + -- 查询商品名称和库存,库存数量在原有基础上加10 + SELECT NAME,stock+10 FROM product; + + -- 查询商品名称和库存,库存数量在原有基础上加10。进行null值判断 + SELECT NAME,IFNULL(stock,0)+10 FROM product; + ``` + +* **起别名** + + ```mysql + SELECT 列名1,列名2,... AS 别名 FROM 表名; + ``` + + 例如: + + ```mysql + -- 查询商品名称和库存,库存数量在原有基础上加10。进行null值判断,起别名为getSum,AS可以省略。 + SELECT NAME,IFNULL(stock,0)+10 AS getsum FROM product; + SELECT NAME,IFNULL(stock,0)+10 getsum FROM product; + ``` + + + + +*** + + + +#### 条件查询 + +* 条件查询语法 + + ```mysql + SELECT 列名 FROM 表名 WHERE 条件; + ``` + +* 条件分类 + + | 符号 | 功能 | + | ------------------- | ------------------------------------------------------------ | + | > | 大于 | + | < | 小于 | + | >= | 大于等于 | + | <= | 小于等于 | + | = | 等于 | + | <> 或 != | 不等于 | + | BETWEEN ... AND ... | 在某个范围之内(都包含) | + | IN(...) | 多选一 | + | LIKE | **模糊查询**:_单个任意字符、%任意个字符、[] 匹配集合内的字符
`LIKE '[^AB]%' `:不以 A 和 B 开头的任意文本 | + | IS NULL | 是NULL | + | IS NOT NULL | 不是NULL | + | AND 或 && | 并且 | + | OR 或 \|\| | 或者 | + | NOT 或 ! | 非,不是 | + | UNION | 对两个结果集进行**并集操作并进行去重,同时进行默认规则的排序** | + | UNION ALL | 对两个结果集进行并集操作不进行去重,不进行排序 | + +* 例如: + + ```mysql + -- 查询库存大于20的商品信息 + SELECT * FROM product WHERE stock > 20; + + -- 查询品牌为华为的商品信息 + SELECT * FROM product WHERE brand='华为'; + + -- 查询金额在4000 ~ 6000之间的商品信息 + SELECT * FROM product WHERE price >= 4000 AND price <= 6000; + SELECT * FROM product WHERE price BETWEEN 4000 AND 6000; + + -- 查询库存为14、30、23的商品信息 + SELECT * FROM product WHERE stock=14 OR stock=30 OR stock=23; + SELECT * FROM product WHERE stock IN(14,30,23); + + -- 查询库存为null的商品信息 + SELECT * FROM product WHERE stock IS NULL; + -- 查询库存不为null的商品信息 + SELECT * FROM product WHERE stock IS NOT NULL; + + -- 查询名称以'小米'为开头的商品信息 + SELECT * FROM product WHERE NAME LIKE '小米%'; + + -- 查询名称第二个字是'为'的商品信息 + SELECT * FROM product WHERE NAME LIKE '_为%'; + + -- 查询名称为四个字符的商品信息 4个下划线 + SELECT * FROM product WHERE NAME LIKE '____'; + + -- 查询名称中包含电脑的商品信息 + SELECT * FROM product WHERE NAME LIKE '%电脑%'; + ``` + + + + + +*** + + + +#### 函数查询 + +##### 聚合函数 + +聚合函数:将一列数据作为一个整体,进行纵向的计算 + +* 聚合函数语法 + + ```mysql + SELECT 函数名(列名) FROM 表名 [WHERE 条件] + ``` + +* 聚合函数分类 + + | 函数名 | 功能 | + | ----------- | ---------------------------------- | + | COUNT(列名) | 统计数量(一般选用不为 null 的列) | + | MAX(列名) | 最大值 | + | MIN(列名) | 最小值 | + | SUM(列名) | 求和 | + | AVG(列名) | 平均值(会忽略 null 行) | + +* 例如 + + ```mysql + -- 计算product表中总记录条数 7 + SELECT COUNT(*) FROM product; + + -- 获取最高价格 + SELECT MAX(price) FROM product; + -- 获取最高价格的商品名称 + SELECT NAME,price FROM product WHERE price = (SELECT MAX(price) FROM product); + + -- 获取最低库存 + SELECT MIN(stock) FROM product; + -- 获取最低库存的商品名称 + SELECT NAME,stock FROM product WHERE stock = (SELECT MIN(stock) FROM product); + + -- 获取总库存数量 + SELECT SUM(stock) FROM product; + -- 获取品牌为小米的平均商品价格 + SELECT AVG(price) FROM product WHERE brand='小米'; + ``` + + + +*** + + + +##### 文本函数 + +CONCAT():用于连接两个字段 + +```sql +SELECT CONCAT(TRIM(col1), '(', TRIM(col2), ')') AS concat_col FROM mytable +-- 许多数据库会使用空格把一个值填充为列宽,连接的结果出现一些不必要的空格,使用TRIM()可以去除首尾空格 +``` + +| 函数名称 | 作 用 | +| --------- | ------------------------------------------------------------ | +| LENGTH | 计算字符串长度函数,返回字符串的字节长度 | +| CONCAT | 合并字符串函数,返回结果为连接参数产生的字符串,参数可以使一个或多个 | +| INSERT | 替换字符串函数 | +| LOWER | 将字符串中的字母转换为小写 | +| UPPER | 将字符串中的字母转换为大写 | +| LEFT | 从左侧字截取符串,返回字符串左边的若干个字符 | +| RIGHT | 从右侧字截取符串,返回字符串右边的若干个字符 | +| TRIM | 删除字符串左右两侧的空格 | +| REPLACE | 字符串替换函数,返回替换后的新字符串 | +| SUBSTRING | 截取字符串,返回从指定位置开始的指定长度的字符换 | +| REVERSE | 字符串反转(逆序)函数,返回与原始字符串顺序相反的字符串 | + + + +*** + + + +##### 数字函数 + +| 函数名称 | 作 用 | +| --------------- | ---------------------------------------------------------- | +| ABS | 求绝对值 | +| SQRT | 求二次方根 | +| MOD | 求余数 | +| CEIL 和 CEILING | 两个函数功能相同,都是返回不小于参数的最小整数,即向上取整 | +| FLOOR | 向下取整,返回值转化为一个BIGINT | +| RAND | 生成一个0~1之间的随机数,传入整数参数是,用来产生重复序列 | +| ROUND | 对所传参数进行四舍五入 | +| SIGN | 返回参数的符号 | +| POW 和 POWER | 两个函数的功能相同,都是所传参数的次方的结果值 | +| SIN | 求正弦值 | +| ASIN | 求反正弦值,与函数 SIN 互为反函数 | +| COS | 求余弦值 | +| ACOS | 求反余弦值,与函数 COS 互为反函数 | +| TAN | 求正切值 | +| ATAN | 求反正切值,与函数 TAN 互为反函数 | +| COT | 求余切值 | + + + +*** + + + +##### 日期函数 + +| 函数名称 | 作 用 | +| ----------------------- | ------------------------------------------------------------ | +| CURDATE 和 CURRENT_DATE | 两个函数作用相同,返回当前系统的日期值 | +| CURTIME 和 CURRENT_TIME | 两个函数作用相同,返回当前系统的时间值 | +| NOW 和 SYSDATE | 两个函数作用相同,返回当前系统的日期和时间值 | +| MONTH | 获取指定日期中的月份 | +| MONTHNAME | 获取指定日期中的月份英文名称 | +| DAYNAME | 获取指定曰期对应的星期几的英文名称 | +| DAYOFWEEK | 获取指定日期对应的一周的索引位置值 | +| WEEK | 获取指定日期是一年中的第几周,返回值的范围是否为 0〜52 或 1〜53 | +| DAYOFYEAR | 获取指定曰期是一年中的第几天,返回值范围是1~366 | +| DAYOFMONTH | 获取指定日期是一个月中是第几天,返回值范围是1~31 | +| YEAR | 获取年份,返回值范围是 1970〜2069 | +| TIME_TO_SEC | 将时间参数转换为秒数 | +| SEC_TO_TIME | 将秒数转换为时间,与TIME_TO_SEC 互为反函数 | +| DATE_ADD 和 ADDDATE | 两个函数功能相同,都是向日期添加指定的时间间隔 | +| DATE_SUB 和 SUBDATE | 两个函数功能相同,都是向日期减去指定的时间间隔 | +| ADDTIME | 时间加法运算,在原始时间上添加指定的时间 | +| SUBTIME | 时间减法运算,在原始时间上减去指定的时间 | +| DATEDIFF | 获取两个日期之间间隔,返回参数 1 减去参数 2 的值 | +| DATE_FORMAT | 格式化指定的日期,根据参数返回指定格式的值 | +| WEEKDAY | 获取指定日期在一周内的对应的工作日索引 | + + + +**** + + + +#### 正则查询 + +正则表达式(Regular Expression)是指一个用来描述或者匹配一系列符合某个句法规则的字符串的单个字符串 + +```mysql +SELECT * FROM emp WHERE name REGEXP '^T'; -- 匹配以T开头的name值 +SELECT * FROM emp WHERE name REGEXP '2$'; -- 匹配以2结尾的name值 +SELECT * FROM emp WHERE name REGEXP '[uvw]';-- 匹配包含 uvw 的name值 +``` + +| 符号 | 含义 | +| ------ | ----------------------------- | +| ^ | 在字符串开始处进行匹配 | +| $ | 在字符串末尾处进行匹配 | +| . | 匹配任意单个字符, 包括换行符 | +| [...] | 匹配出括号内的任意字符 | +| [^...] | 匹配不出括号内的任意字符 | +| a* | 匹配零个或者多个a(包括空串) | +| a+ | 匹配一个或者多个a(不包括空串) | +| a? | 匹配零个或者一个a | +| a1\|a2 | 匹配a1或a2 | +| a(m) | 匹配m个a | +| a(m,) | 至少匹配m个a | +| a(m,n) | 匹配m个a 到 n个a | +| a(,n) | 匹配0到n个a | +| (...) | 将模式元素组成单一元素 | + + + + + +*** + + + +#### 排序查询 + +* 排序查询语法 + + ```mysql + SELECT 列名 FROM 表名 [WHERE 条件] ORDER BY 列名1 排序方式1,列名2 排序方式2; + ``` + +* 排序方式 + + ```mysql + ASC:升序 + DESC:降序 + ``` + + 注意:多个排序条件,当前边的条件值一样时,才会判断第二条件 + +* 例如 + + ```mysql + -- 按照库存升序排序 + SELECT * FROM product ORDER BY stock ASC; + + -- 查询名称中包含手机的商品信息。按照金额降序排序 + SELECT * FROM product WHERE NAME LIKE '%手机%' ORDER BY price DESC; + + -- 按照金额升序排序,如果金额相同,按照库存降序排列 + SELECT * FROM product ORDER BY price ASC,stock DESC; + ``` + + + +*** + + + +#### 分组查询 + +分组查询会进行去重 + +* 分组查询语法 + + ````mysql + SELECT 列名 FROM 表名 [WHERE 条件] GROUP BY 分组列名 [HAVING 分组后条件过滤] [ORDER BY 排序列名 排序方式]; + ```` + + WHERE 过滤行,HAVING 过滤分组,行过滤应当先于分组过滤 + + 分组规定: + + * GROUP BY 子句出现在 WHERE 子句之后,ORDER BY 子句之前 + * NULL 的行会单独分为一组 + * 大多数 SQL 实现不支持 GROUP BY 列具有可变长度的数据类型 + +* 例如 + + ```mysql + -- 按照品牌分组,获取每组商品的总金额 + SELECT brand,SUM(price) FROM product GROUP BY brand; + + -- 对金额大于4000元的商品,按照品牌分组,获取每组商品的总金额 + SELECT brand,SUM(price) FROM product WHERE price > 4000 GROUP BY brand; + + -- 对金额大于4000元的商品,按照品牌分组,获取每组商品的总金额,只显示总金额大于7000元的 + SELECT brand,SUM(price) AS getSum FROM product WHERE price > 4000 GROUP BY brand HAVING getSum > 7000; + + -- 对金额大于4000元的商品,按照品牌分组,获取每组商品的总金额,只显示总金额大于7000元的、并按照总金额的降序排列 + SELECT brand,SUM(price) AS getSum FROM product WHERE price > 4000 GROUP BY brand HAVING getSum > 7000 ORDER BY getSum DESC; + ``` + + + + +*** + + + +#### 分页查询 + +* 分页查询语法 + + ```mysql + SELECT 列名 FROM 表名 [WHERE 条件] GROUP BY 分组列名 [HAVING 分组后条件过滤] [ORDER BY 排序列名 排序方式] LIMIT 开始索引,查询条数; + ``` + +* 公式:开始索引 = (当前页码-1) * 每页显示的条数 + +* 例如 + + ```mysql + SELECT * FROM product LIMIT 0,2; -- 第一页 开始索引=(1-1) * 2 + SELECT * FROM product LIMIT 2,2; -- 第二页 开始索引=(2-1) * 2 + SELECT * FROM product LIMIT 4,2; -- 第三页 开始索引=(3-1) * 2 + SELECT * FROM product LIMIT 6,2; -- 第四页 开始索引=(4-1) * 2 + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-DQL分页查询图解.png) + + + + + + +*** + + + + + +## 多表操作 + +### 约束分类 + +#### 约束介绍 + +约束:对表中的数据进行限定,保证数据的正确性、有效性、完整性 + +约束的分类: + +| 约束 | 说明 | +| ----------------------------- | -------------- | +| PRIMARY KEY | 主键约束 | +| PRIMARY KEY AUTO_INCREMENT | 主键、自动增长 | +| UNIQUE | 唯一约束 | +| NOT NULL | 非空约束 | +| FOREIGN KEY | 外键约束 | +| FOREIGN KEY ON UPDATE CASCADE | 外键级联更新 | +| FOREIGN KEY ON DELETE CASCADE | 外键级联删除 | + + + +*** + + + +#### 主键约束 + +* 主键约束特点: + + * 主键约束默认包含**非空和唯一**两个功能 + * 一张表只能有一个主键 + * 主键一般用于表中数据的唯一标识 + +* 建表时添加主键约束 + + ```mysql + CREATE TABLE 表名( + 列名 数据类型 PRIMARY KEY, + 列名 数据类型, + ... + ); + ``` + +* 删除主键约束 + + ```mysql + ALTER TABLE 表名 DROP PRIMARY KEY; + ``` + +* 建表后单独添加主键约束 + + ```mysql + ALTER TABLE 表名 MODIFY 列名 数据类型 PRIMARY KEY; + ``` + +* 例如 + + ```mysql + -- 创建student表 + CREATE TABLE student( + id INT PRIMARY KEY -- 给id添加主键约束 + ); + + -- 添加数据 + INSERT INTO student VALUES (1),(2); + -- 主键默认唯一,添加重复数据,会报错 + INSERT INTO student VALUES (2); + -- 主键默认非空,不能添加null的数据 + INSERT INTO student VALUES (NULL); + ``` + + + +*** + + + +#### 主键自增 + +主键自增约束可以为空,并自动增长。删除某条数据不影响自增的下一个数值,依然按照前一个值自增 + +* 建表时添加主键自增约束 + + ```mysql + CREATE TABLE 表名( + 列名 数据类型 PRIMARY KEY AUTO_INCREMENT, + 列名 数据类型, + ... + ); + ``` + +* 删除主键自增约束 + + ```mysql + ALTER TABLE 表名 MODIFY 列名 数据类型; + ``` + +* 建表后单独添加主键自增约束 + + ```mysql + ALTER TABLE 表名 MODIFY 列名 数据类型 AUTO_INCREMENT; + ``` + +* 例如 + + ```mysql + -- 创建student2表 + CREATE TABLE student2( + id INT PRIMARY KEY AUTO_INCREMENT -- 给id添加主键自增约束 + ); + + -- 添加数据 + INSERT INTO student2 VALUES (1),(2); + -- 添加null值,会自动增长 + INSERT INTO student2 VALUES (NULL),(NULL);-- 3,4 + ``` + + + +*** + + + +#### 唯一约束 + +唯一约束:约束不能有重复的数据 + +* 建表时添加唯一约束 + + ```mysql + CREATE TABLE 表名( + 列名 数据类型 UNIQUE, + 列名 数据类型, + ... + ); + ``` + +* 删除唯一约束 + + ```mysql + ALTER TABLE 表名 DROP INDEX 列名; + ``` + +* 建表后单独添加唯一约束 + + ```mysql + ALTER TABLE 表名 MODIFY 列名 数据类型 UNIQUE; + ``` + + + +*** + + + +#### 非空约束 + +* 建表时添加非空约束 + + ```mysql + CREATE TABLE 表名( + 列名 数据类型 NOT NULL, + 列名 数据类型, + ... + ); + ``` + +* 删除非空约束 + + ```mysql + ALTER TABLE 表名 MODIFY 列名 数据类型; + ``` + +* 建表后单独添加非空约束 + + ```mysql + ALTER TABLE 表名 MODIFY 列名 数据类型 NOT NULL; + ``` + + + +*** + + + +#### 外键约束 + + 外键约束:让表和表之间产生关系,从而保证数据的准确性 + +* 建表时添加外键约束 + + ```mysql + CREATE TABLE 表名( + 列名 数据类型 约束, + ... + CONSTRAINT 外键名 FOREIGN KEY (本表外键列名) REFERENCES 主表名(主表主键列名) + ); + ``` + +* 删除外键约束 + + ```mysql + ALTER TABLE 表名 DROP FOREIGN KEY 外键名; + ``` + +* 建表后单独添加外键约束 + + ```mysql + ALTER TABLE 表名 ADD CONSTRAINT 外键名 FOREIGN KEY (本表外键列名) REFERENCES 主表名(主表主键列名); + ``` + +* 例如 + + ```mysql + -- 创建user用户表 + CREATE TABLE USER( + id INT PRIMARY KEY AUTO_INCREMENT, -- id + name VARCHAR(20) NOT NULL -- 姓名 + ); + -- 添加用户数据 + INSERT INTO USER VALUES (NULL,'张三'),(NULL,'李四'),(NULL,'王五'); + + -- 创建orderlist订单表 + CREATE TABLE orderlist( + id INT PRIMARY KEY AUTO_INCREMENT, -- id + number VARCHAR(20) NOT NULL, -- 订单编号 + uid INT, -- 订单所属用户 + CONSTRAINT ou_fk1 FOREIGN KEY (uid) REFERENCES USER(id) -- 添加外键约束 + ); + -- 添加订单数据 + INSERT INTO orderlist VALUES (NULL,'hm001',1),(NULL,'hm002',1), + (NULL,'hm003',2),(NULL,'hm004',2), + (NULL,'hm005',3),(NULL,'hm006',3); + + -- 添加一个订单,但是没有所属用户。无法添加 + INSERT INTO orderlist VALUES (NULL,'hm007',8); + -- 删除王五这个用户,但是订单表中王五还有很多个订单呢。无法删除 + DELETE FROM USER WHERE NAME='王五'; + ``` + + + + +*** + + + +#### 外键级联 + +级联操作:当把主表中的数据进行删除或更新时,从表中有关联的数据的相应操作,包括 RESTRICT、CASCADE、SET NULL 和 NO ACTION + +* RESTRICT 和 NO ACTION相同, 是指限制在子表有关联记录的情况下, 父表不能更新 + +* CASCADE 表示父表在更新或者删除时,更新或者删除子表对应的记录 + +* SET NULL 则表示父表在更新或者删除的时候,子表的对应字段被SET NULL + +级联操作: + +* 添加级联更新 + + ```mysql + ALTER TABLE 表名 ADD CONSTRAINT 外键名 FOREIGN KEY (本表外键列名) REFERENCES 主表名(主表主键列名) ON UPDATE [CASCADE | RESTRICT | SET NULL]; + ``` + +* 添加级联删除 + + ```mysql + ALTER TABLE 表名 ADD CONSTRAINT 外键名 FOREIGN KEY (本表外键列名) REFERENCES 主表名(主表主键列名) ON DELETE CASCADE; + ``` + +* 同时添加级联更新和级联删除 + + ```mysql + ALTER TABLE 表名 ADD CONSTRAINT 外键名 FOREIGN KEY (本表外键列名) REFERENCES 主表名(主表主键列名) ON UPDATE CASCADE ON DELETE CASCADE; + ``` + + + + + +*** + + + + + +### 多表设计 + +#### 一对一 + +多表:有多张数据表,而表与表之间有一定的关联关系,通过外键约束实现,分为一对一、一对多、多对多三类 + +举例:人和身份证 + +实现原则:在任意一个表建立外键,去关联另外一个表的主键 + +```mysql +-- 创建person表 +CREATE TABLE person( + id INT PRIMARY KEY AUTO_INCREMENT, -- 主键id + NAME VARCHAR(20) -- 姓名 +); +-- 添加数据 +INSERT INTO person VALUES (NULL,'张三'),(NULL,'李四'); + +-- 创建card表 +CREATE TABLE card( + id INT PRIMARY KEY AUTO_INCREMENT, -- 主键id + number VARCHAR(20) UNIQUE NOT NULL, -- 身份证号 + pid INT UNIQUE, -- 外键列 + CONSTRAINT cp_fk1 FOREIGN KEY (pid) REFERENCES person(id) +); +-- 添加数据 +INSERT INTO card VALUES (NULL,'12345',1),(NULL,'56789',2); +``` + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/多表设计一对一.png) + + + +*** + + + +#### 一对多 + +举例:用户和订单、商品分类和商品 + +实现原则:在多的一方,建立外键约束,来关联一的一方主键 + +```mysql +-- 创建user表 +CREATE TABLE USER( + id INT PRIMARY KEY AUTO_INCREMENT, -- 主键id + NAME VARCHAR(20) -- 姓名 +); +-- 添加数据 +INSERT INTO USER VALUES (NULL,'张三'),(NULL,'李四'); + +-- 创建orderlist表 +CREATE TABLE orderlist( + id INT PRIMARY KEY AUTO_INCREMENT, -- 主键id + number VARCHAR(20), -- 订单编号 + uid INT, -- 外键列 + CONSTRAINT ou_fk1 FOREIGN KEY (uid) REFERENCES USER(id) +); +-- 添加数据 +INSERT INTO orderlist VALUES (NULL,'hm001',1),(NULL,'hm002',1),(NULL,'hm003',2),(NULL,'hm004',2); +``` + +![多表设计一对多](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/多表设计一对多.png) + + + +*** + + + +#### 多对多 + +举例:学生和课程。一个学生可以选择多个课程,一个课程也可以被多个学生选择 + +实现原则:借助第三张表中间表,中间表至少包含两个列,这两个列作为中间表的外键,分别关联两张表的主键 + +```mysql +-- 创建student表 +CREATE TABLE student( + id INT PRIMARY KEY AUTO_INCREMENT, -- 主键id + NAME VARCHAR(20) -- 学生姓名 +); +-- 添加数据 +INSERT INTO student VALUES (NULL,'张三'),(NULL,'李四'); + +-- 创建course表 +CREATE TABLE course( + id INT PRIMARY KEY AUTO_INCREMENT, -- 主键id + NAME VARCHAR(10) -- 课程名称 +); +-- 添加数据 +INSERT INTO course VALUES (NULL,'语文'),(NULL,'数学'); + +-- 创建中间表 +CREATE TABLE stu_course( + id INT PRIMARY KEY AUTO_INCREMENT, -- 主键id + sid INT, -- 用于和student表中的id进行外键关联 + cid INT, -- 用于和course表中的id进行外键关联 + CONSTRAINT sc_fk1 FOREIGN KEY (sid) REFERENCES student(id), -- 添加外键约束 + CONSTRAINT sc_fk2 FOREIGN KEY (cid) REFERENCES course(id) -- 添加外键约束 +); +-- 添加数据 +INSERT INTO stu_course VALUES (NULL,1,1),(NULL,1,2),(NULL,2,1),(NULL,2,2); +``` + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/多表设计多对多.png) + + + +*** + + + +### 连接查询 + +#### 内外连接 + +##### 内连接 + +连接查询的是两张表有交集的部分数据,两张表分为**驱动表和被驱动表**,如果结果集中的每条记录都是两个表相互匹配的组合,则称这样的结果集为笛卡尔积 + +内连接查询,若驱动表中的记录在被驱动表中找不到匹配的记录时,则该记录不会加到最后的结果集 + +* 显式内连接: + + ```mysql + SELECT 列名 FROM 表名1 [INNER] JOIN 表名2 ON 条件; + ``` + +* 隐式内连接:内连接中 WHERE 子句和 ON 子句是等价的 + + ```mysql + SELECT 列名 FROM 表名1,表名2 WHERE 条件; + ``` + +STRAIGHT_JOIN与 JOIN 类似,只不过左表始终在右表之前读取,只适用于内连接 + + + + +*** + + + +##### 外连接 + +外连接查询,若驱动表中的记录在被驱动表中找不到匹配的记录时,则该记录也会加到最后的结果集,只是对于被驱动表中**不匹配过滤条件**的记录,各个字段使用 NULL 填充 + +应用实例:查学生成绩,也想展示出缺考的人的成绩 + +* 左外连接:选择左侧的表为驱动表,查询左表的全部数据,和左右两张表有交集部分的数据 + + ```mysql + SELECT 列名 FROM 表名1 LEFT [OUTER] JOIN 表名2 ON 条件; + ``` + +* 右外连接:选择右侧的表为驱动表,查询右表的全部数据,和左右两张表有交集部分的数据 + + ```mysql + SELECT 列名 FROM 表名1 RIGHT [OUTER] JOIN 表名2 ON 条件; + ``` + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-JOIN查询图.png) + + + + +*** + + + + + +#### 关联查询 + +自关联查询:同一张表中有数据关联,可以多次查询这同一个表 + +* 数据准备 + + ```mysql + -- 创建员工表 + CREATE TABLE employee( + id INT PRIMARY KEY AUTO_INCREMENT, -- 员工编号 + NAME VARCHAR(20), -- 员工姓名 + mgr INT, -- 上级编号 + salary DOUBLE -- 员工工资 + ); + -- 添加数据 + INSERT INTO employee VALUES (1001,'孙悟空',1005,9000.00),..,(1009,'宋江',NULL,16000.00); + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/自关联查询数据准备.png) + +* 数据查询 + + ```mysql + -- 查询所有员工的姓名及其直接上级的姓名,没有上级的员工也需要查询 + /* + 分析 + 员工信息 employee表 + 条件:employee.mgr = employee.id + 查询左表的全部数据,和左右两张表有交集部分数据,左外连接 + */ + SELECT + e1.id, + e1.name, + e1.mgr, + e2.id, + e2.name + FROM + employee e1 + LEFT OUTER JOIN + employee e2 + ON + e1.mgr = e2.id; + ``` + +* 查询结果 + + ``` + id name mgr id name + 1001 孙悟空 1005 1005 唐僧 + 1002 猪八戒 1005 1005 唐僧 + 1003 沙和尚 1005 1005 唐僧 + 1004 小白龙 1005 1005 唐僧 + 1005 唐僧 NULL NULL NULL + 1006 武松 1009 1009 宋江 + 1007 李逵 1009 1009 宋江 + 1008 林冲 1009 1009 宋江 + 1009 宋江 NULL NULL NULL + ``` + + + +*** + + + +#### 连接原理 + +Index Nested-Loop Join 算法:查询驱动表得到**数据集**,然后根据数据集中的每一条记录的**关联字段再分别**到被驱动表中查找匹配(**走索引**),所以驱动表只需要访问一次,被驱动表要访问多次 + +MySQL 将查询驱动表后得到的记录成为驱动表的扇出,连接查询的成本:单次访问驱动表的成本 + 扇出值 * 单次访问被驱动表的成本,优化器会选择成本最小的表连接顺序(确定谁是驱动表,谁是被驱动表)生成执行计划,进行连接查询,优化方式: + +* 减少驱动表的扇出(让数据量小的表来做驱动表) +* 降低访问被驱动表的成本 + +说明:STRAIGHT_JOIN 是查一条驱动表,然后根据关联字段去查被驱动表,要访问多次驱动表,所以需要优化为 INL 算法 + +Block Nested-Loop Join 算法:一种**空间换时间**的优化方式,基于块的循环连接,执行连接查询前申请一块固定大小的内存作为连接缓冲区 Join Buffer,先把若干条驱动表中的扇出暂存在缓冲区,每一条被驱动表中的记录一次性的与 Buffer 中多条记录进行匹配(扫描全部数据,一条一条的匹配),因为是在内存中完成,所以速度快,并且降低了 I/O 成本 + +Join Buffer 可以通过参数 `join_buffer_size` 进行配置,默认大小是 256 KB + +在成本分析时,对于很多张表的连接查询,连接顺序有非常多,MySQL 如果挨着进行遍历计算成本,会消耗很多资源 + +* 提前结束某种连接顺序的成本评估:维护一个全局变量记录当前成本最小的连接方式,如果一种顺序只计算了一部分就已经超过了最小成本,可以提前结束计算 +* 系统变量 optimizer_search_depth:如果连接表的个数小于该变量,就继续穷举分析每一种连接数量,反之只对数量与 depth 值相同的表进行分析,该值越大成本分析的越精确 + +* 系统变量 optimizer_prune_level:控制启发式规则的启用,这些规则就是根据以往经验指定的,不满足规则的连接顺序不分析成本 + + + +*** + + + +#### 连接优化 + +##### BKA + +Batched Key Access 算法是对 NLJ 算法的优化,在读取被驱动表的记录时使用顺序 IO,Extra 信息中会有 Batched Key Access 信息 + +使用 BKA 的表的 JOIN 过程如下: + +* 连接驱动表将满足条件的记录放入 Join Buffer,并将两表连接的字段放入一个 DYNAMIC_ARRAY ranges 中 +* 在进行表的过接过程中,会将 ranges 相关的信息传入 Buffer 中,进行被驱动表主建的查找及排序操作 +* 调用步骤 2 中产生的有序主建,**顺序读取被驱动表的数据** +* 当缓冲区的数据被读完后,会重复进行步骤 2、3,直到记录被读取完 + +使用 BKA 优化需要设进行设置: + +```mysql +SET optimizer_switch='mrr=on,mrr_cost_based=off,batched_key_access=on'; +``` + +说明:前两个参数的作用是启用 MRR,因为 BKA 算法的优化要依赖于 MRR(系统优化 → 内存优化 → Read 详解) + + + +*** + + + +##### BNL + +###### 问题 + +BNL 即 Block Nested-Loop Join 算法,由于要访问多次被驱动表,会产生两个问题: + +* Join 语句多次扫描一个冷表,并且语句执行时间小于 1 秒,就会在再次扫描冷表时,把冷表的数据页移到 LRU 链表头部,导致热数据被淘汰,影响业务的正常运行 + + 这种情况冷表的数据量要小于整个 Buffer Pool 的 old 区域,能够完全放入 old 区,才会再次被读时加到 young,否则读取下一段时就已经把上一段淘汰 + +* Join 语句在循环读磁盘和淘汰内存页,进入 old 区域的数据页很可能在 1 秒之内就被淘汰,就会导致 MySQL 实例的 Buffer Pool 在这段时间内 young 区域的数据页没有被合理地淘汰 + +大表 Join 操作虽然对 IO 有影响,但是在语句执行结束后对 IO 的影响随之结束。但是对 Buffer Pool 的影响就是持续性的,需要依靠后续的查询请求慢慢恢复内存命中率 + + + +###### 优化 + +将 BNL 算法转成 BKA 算法,优化方向: + +* 在被驱动表上建索引,这样就可以根据索引进行顺序 IO +* 使用临时表,**在临时表上建立索引**,将被驱动表和临时表进行连接查询 + +驱动表 t1,被驱动表 t2,使用临时表的工作流程: + +* 把表 t1 中满足条件的数据放在临时表 tmp_t 中 +* 给临时表 tmp_t 的关联字段加上索引,使用 BKA 算法 +* 让表 t2 和 tmp_t 做 Join 操作(临时表是被驱动表) + +补充:MySQL 8.0 支持 hash join,join_buffer 维护的不再是一个无序数组,而是一个哈希表,查询效率更高,执行效率比临时表更高 + + + + + + +*** + + + +### 嵌套查询 + +#### 查询分类 + +查询语句中嵌套了查询语句,**将嵌套查询称为子查询**,FROM 子句后面的子查询的结果集称为派生表 + +根据结果分类: + +* 结果是单行单列:可以将查询的结果作为另一条语句的查询条件,使用运算符判断 + + ```mysql + SELECT 列名 FROM 表名 WHERE 列名=(SELECT 列名/聚合函数(列名) FROM 表名 [WHERE 条件]); + ``` + +* 结果是多行单列:可以作为条件,使用运算符 IN 或 NOT IN 进行判断 + + ```mysql + SELECT 列名 FROM 表名 WHERE 列名 [NOT] IN (SELECT 列名 FROM 表名 [WHERE 条件]); + ``` + +* 结果是多行多列:查询的结果可以作为一张虚拟表参与查询 + + ```mysql + SELECT 列名 FROM 表名 [别名],(SELECT 列名 FROM 表名 [WHERE 条件]) [别名] [WHERE 条件]; + + -- 查询订单表orderlist中id大于4的订单信息和所属用户USER信息 + SELECT + * + FROM + USER u, + (SELECT * FROM orderlist WHERE id>4) o + WHERE + u.id=o.uid; + ``` + +相关性分类: + +* 不相关子查询:子查询不依赖外层查询的值,可以单独运行出结果 +* 相关子查询:子查询的执行需要依赖外层查询的值 + + + +**** + + + +#### 查询优化 + +不相关子查询的结果集会被写入一个临时表,并且在写入时**去重**,该过程称为**物化**,存储结果集的临时表称为物化表 + +系统变量 tmp_table_size 或者 max_heap_table_size 为表的最值 + +* 小于系统变量时,内存中可以保存,会为建立**基于内存**的 MEMORY 存储引擎的临时表,并建立哈希索引 +* 大于任意一个系统变量时,物化表会使用**基于磁盘**的 InnoDB 存储引擎来保存结果集中的记录,索引类型为 B+ 树 + +物化后,嵌套查询就相当于外层查询的表和物化表进行内连接查询,然后经过优化器选择成本最小的表连接顺序执行查询 + +子查询物化会产生建立临时表的成本,但是将子查询转化为连接查询可以充分发挥优化器的作用,所以引入:半连接 + +* t1 和 t2 表进行半连接,对于 t1 表中的某条记录,只需要关心在 t2 表中是否存在,而不需要关心有多少条记录与之匹配,最终结果集只保留 t1 的记录 +* 半连接只是执行子查询的一种方式,MySQL 并没有提供面向用户的半连接语法 + + + +参考书籍:https://book.douban.com/subject/35231266/ + + + +*** + + + +#### 联合查询 + +UNION 是取这两个子查询结果的并集,并进行去重,同时进行默认规则的排序(union 是行加起来,join 是列加起来) + +UNION ALL 是对两个结果集进行并集操作不进行去重,不进行排序 + +```mysql +(select 1000 as f) union (select id from t1 order by id desc limit 2); #t1表中包含id 为 1-1000 的数据 +``` + +语句的执行流程: + +* 创建一个内存临时表,这个临时表只有一个整型字段 f,并且 f 是主键字段 +* 执行第一个子查询,得到 1000 这个值,并存入临时表中 +* 执行第二个子查询,拿到第一行 id=1000,试图插入临时表中,但由于 1000 这个值已经存在于临时表了,违反了唯一性约束,所以插入失败,然后继续执行 +* 取到第二行 id=999,插入临时表成功 +* 从临时表中按行取出数据,返回结果并删除临时表,结果中包含两行数据分别是 1000 和 999 + + + + + +**** + + + +### 查询练习 + +数据准备: + +```mysql +-- 创建db4数据库 +CREATE DATABASE db4; +-- 使用db4数据库 +USE db4; + +-- 创建user表 +CREATE TABLE USER( + id INT PRIMARY KEY AUTO_INCREMENT, -- 用户id + NAME VARCHAR(20), -- 用户姓名 + age INT -- 用户年龄 +); + +-- 订单表 +CREATE TABLE orderlist( + id INT PRIMARY KEY AUTO_INCREMENT, -- 订单id + number VARCHAR(30), -- 订单编号 + uid INT, -- 外键字段 + CONSTRAINT ou_fk1 FOREIGN KEY (uid) REFERENCES USER(id) +); + +-- 商品分类表 +CREATE TABLE category( + id INT PRIMARY KEY AUTO_INCREMENT, -- 商品分类id + NAME VARCHAR(10) -- 商品分类名称 +); + +-- 商品表 +CREATE TABLE product( + id INT PRIMARY KEY AUTO_INCREMENT, -- 商品id + NAME VARCHAR(30), -- 商品名称 + cid INT, -- 外键字段 + CONSTRAINT cp_fk1 FOREIGN KEY (cid) REFERENCES category(id) +); + +-- 中间表 +CREATE TABLE us_pro( + upid INT PRIMARY KEY AUTO_INCREMENT, -- 中间表id + uid INT, -- 外键字段。需要和用户表的主键产生关联 + pid INT, -- 外键字段。需要和商品表的主键产生关联 + CONSTRAINT up_fk1 FOREIGN KEY (uid) REFERENCES USER(id), + CONSTRAINT up_fk2 FOREIGN KEY (pid) REFERENCES product(id) +); +``` + +![多表练习架构设计](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/多表练习架构设计.png) + + + +**数据查询:** + +1. 查询用户的编号、姓名、年龄、订单编号 + + 数据:用户的编号、姓名、年龄在 user 表,订单编号在 orderlist 表 + + 条件:user.id = orderlist.uid + + ```mysql + SELECT + u.*, + o.number + FROM + USER u, + orderlist o + WHERE + u.id = o.uid; + ``` + +2. 查询所有的用户,显示用户的编号、姓名、年龄、订单编号。 + + ```mysql + SELECT + u.*, + o.number + FROM + USER u + LEFT OUTER JOIN + orderlist o + ON + u.id = o.uid; + ``` + +3. 查询用户年龄大于 23 岁的信息,显示用户的编号、姓名、年龄、订单编号 + + ```mysql + SELECT + u.*, + o.number + FROM + USER u, + orderlist o + WHERE + u.id = o.uid + AND + u.age > 23; + ``` + + ```mysql + SELECT + u.*, + o.number + FROM + (SELECT * FROM USER WHERE age > 23) u,-- 嵌套查询 + orderlist o + WHERE + u.id = o.uid; + ``` + +4. 查询张三和李四用户的信息,显示用户的编号、姓名、年龄、订单编号。 + + ````mysql + SELECT + u.*, + o.number + FROM + USER u, + orderlist o + WHERE + u.id=o.uid + AND + u.name IN ('张三','李四'); + ```` + +5. 查询所有的用户和该用户能查看的所有的商品,显示用户的编号、姓名、年龄、商品名称 + + 数据:用户的编号、姓名、年龄在 user 表,商品名称在 product 表,中间表 us_pro + + 条件:us_pro.uid = user.id AND us_pro.pid = product.id + + ```mysql + SELECT + u.id, + u.name, + u.age, + p.name + FROM + USER u, + product p, + us_pro up + WHERE + up.uid = u.id + AND + up.pid=p.id; + ``` + +6. 查询张三和李四这两个用户可以看到的商品,显示用户的编号、姓名、年龄、商品名称。 + + ```mysql + SELECT + u.id, + u.name, + u.age, + p.name + FROM + USER u, + product p, + us_pro up + WHERE + up.uid=u.id + AND + up.pid=p.id + AND + u.name IN ('张三','李四'); + ``` + + + + + + + +*** + + + + + +## 高级结构 + +### 视图 + +#### 基本介绍 + +视图概念:视图是一种虚拟存在的数据表,这个虚拟的表并不在数据库中实际存在 + +本质:将一条 SELECT 查询语句的结果封装到了一个虚拟表中,所以在创建视图的时候,工作重心要放在这条 SELECT 查询语句上 + +作用:将一些比较复杂的查询语句的结果,封装到一个虚拟表中,再有相同查询需求时,直接查询该虚拟表 + +优点: + +* 简单:使用视图的用户不需要关心表的结构、关联条件和筛选条件,因为虚拟表中已经是过滤好的结果集 +* 安全:使用视图的用户只能访问查询的结果集,对表的权限管理并不能限制到某个行某个列 + +* 数据独立,一旦视图的结构确定,可以屏蔽表结构变化对用户的影响,源表增加列对视图没有影响;源表修改列名,则可以通过修改视图来解决,不会造成对访问者的影响 + + + +*** + + + +#### 视图创建 + +* 创建视图 + + ```mysql + CREATE [OR REPLACE] + VIEW 视图名称 [(列名列表)] + AS 查询语句 + [WITH [CASCADED | LOCAL] CHECK OPTION]; + ``` + + `WITH [CASCADED | LOCAL] CHECK OPTION` 决定了是否允许更新数据使记录不再满足视图的条件: + + * LOCAL:只要满足本视图的条件就可以更新 + * CASCADED:必须满足所有针对该视图的所有视图的条件才可以更新, 默认值 + +* 例如 + + ```mysql + -- 数据准备 city + id NAME cid + 1 深圳 1 + 2 上海 1 + 3 纽约 2 + 4 莫斯科 3 + + -- 数据准备 country + id NAME + 1 中国 + 2 美国 + 3 俄罗斯 + + -- 创建city_country视图,保存城市和国家的信息(使用指定列名) + CREATE + VIEW + city_country (city_id,city_name,country_name) + AS + SELECT + c1.id, + c1.name, + c2.name + FROM + city c1, + country c2 + WHERE + c1.cid=c2.id; + ``` + + + +*** + + + +#### 视图查询 + +* 查询所有数据表,视图也会查询出来 + + ```mysql + SHOW TABLES; + SHOW TABLE STATUS [\G]; + ``` + +* 查询视图 + + ```mysql + SELECT * FROM 视图名称; + ``` + +* 查询某个视图创建 + + ```mysql + SHOW CREATE VIEW 视图名称; + ``` + + + +*** + + + +#### 视图修改 + +视图表数据修改,会**自动修改源表中的数据**,因为更新的是视图中的基表中的数据 + +* 修改视图表中的数据 + + ```mysql + UPDATE 视图名称 SET 列名 = 值 WHERE 条件; + ``` + +* 修改视图的结构 + + ```mysql + ALTER [ALGORITHM = {UNDEFINED | MERGE | TEMPTABLE}] + VIEW 视图名称 [(列名列表)] + AS 查询语句 + [WITH [CASCADED | LOCAL] CHECK OPTION] + + -- 将视图中的country_name修改为name + ALTER + VIEW + city_country (city_id,city_name,name) + AS + SELECT + c1.id, + c1.name, + c2.name + FROM + city c1, + country c2 + WHERE + c1.cid=c2.id; + ``` + + + +*** + + + +#### 视图删除 + +* 删除视图 + + ```mysql + DROP VIEW 视图名称; + ``` + +* 如果存在则删除 + + ```mysql + DROP VIEW IF EXISTS 视图名称; + ``` + + + + + + +*** + + + +### 存储过程 + +#### 基本介绍 + +存储过程和函数:存储过程和函数是事先经过编译并存储在数据库中的一段 SQL 语句的集合 + +存储过程和函数的好处: + +* 提高代码的复用性 +* 减少数据在数据库和应用服务器之间的传输,提高传输效率 +* 减少代码层面的业务处理 +* **一次编译永久有效** + +存储过程和函数的区别: + +* 存储函数必须有返回值 +* 存储过程可以没有返回值 + + + +*** + + + +#### 基本操作 + +DELIMITER: + +* DELIMITER 关键字用来声明 sql 语句的分隔符,告诉 MySQL 该段命令已经结束 + +* MySQL 语句默认的分隔符是分号,但是有时需要一条功能 sql 语句中包含分号,但是并不作为结束标识,这时使用 DELIMITER 来指定分隔符: + + ```mysql + DELIMITER 分隔符 + ``` + +存储过程的创建调用查看和删除: + +* 创建存储过程 + + ```mysql + -- 修改分隔符为$ + DELIMITER $ + + -- 标准语法 + CREATE PROCEDURE 存储过程名称(参数...) + BEGIN + sql语句; + END$ + + -- 修改分隔符为分号 + DELIMITER ; + ``` + +* 调用存储过程 + + ```mysql + CALL 存储过程名称(实际参数); + ``` + +* 查看存储过程 + + ```mysql + SELECT * FROM mysql.proc WHERE db='数据库名称'; + ``` + +* 删除存储过程 + + ```mysql + DROP PROCEDURE [IF EXISTS] 存储过程名称; + ``` + +练习: + +* 数据准备 + + ```mysql + id NAME age gender score + 1 张三 23 男 95 + 2 李四 24 男 98 + 3 王五 25 女 100 + 4 赵六 26 女 90 + ``` + +* 创建 stu_group() 存储过程,封装分组查询总成绩,并按照总成绩升序排序的功能 + + ```mysql + DELIMITER $ + + CREATE PROCEDURE stu_group() + BEGIN + SELECT gender,SUM(score) getSum FROM student GROUP BY gender ORDER BY getSum ASC; + END$ + + DELIMITER ; + + -- 调用存储过程 + CALL stu_group(); + -- 删除存储过程 + DROP PROCEDURE IF EXISTS stu_group; + ``` + + + +*** + + + +#### 存储语法 + +##### 变量使用 + +存储过程是可以进行编程的,意味着可以使用变量、表达式、条件控制语句等,来完成比较复杂的功能 + +* 定义变量:DECLARE 定义的是局部变量,只能用在 BEGIN END 范围之内 + + ```mysql + DECLARE 变量名 数据类型 [DEFAULT 默认值]; + ``` + +* 变量的赋值 + + ```mysql + SET 变量名 = 变量值; + SELECT 列名 INTO 变量名 FROM 表名 [WHERE 条件]; + ``` + +* 数据准备:表 student + + ```mysql + id NAME age gender score + 1 张三 23 男 95 + 2 李四 24 男 98 + 3 王五 25 女 100 + 4 赵六 26 女 90 + ``` + +* 定义两个 int 变量,用于存储男女同学的总分数 + + ```mysql + DELIMITER $ + CREATE PROCEDURE pro_test3() + BEGIN + -- 定义两个变量 + DECLARE men,women INT; + -- 查询男同学的总分数,为men赋值 + SELECT SUM(score) INTO men FROM student WHERE gender='男'; + -- 查询女同学的总分数,为women赋值 + SELECT SUM(score) INTO women FROM student WHERE gender='女'; + -- 使用变量 + SELECT men,women; + END$ + DELIMITER ; + -- 调用存储过程 + CALL pro_test3(); + ``` + + + +*** + + + +##### IF语句 + +* if 语句标准语法 + + ```mysql + IF 判断条件1 THEN 执行的sql语句1; + [ELSEIF 判断条件2 THEN 执行的sql语句2;] + ... + [ELSE 执行的sql语句n;] + END IF; + ``` + +* 数据准备:表 student + + ```mysql + id NAME age gender score + 1 张三 23 男 95 + 2 李四 24 男 98 + 3 王五 25 女 100 + 4 赵六 26 女 90 + ``` + +* 根据总成绩判断:全班 380 分及以上学习优秀、320 ~ 380 学习良好、320 以下学习一般 + + ```mysql + DELIMITER $ + CREATE PROCEDURE pro_test4() + BEGIN + DECLARE total INT; -- 定义总分数变量 + DECLARE description VARCHAR(10); -- 定义分数描述变量 + SELECT SUM(score) INTO total FROM student; -- 为总分数变量赋值 + -- 判断总分数 + IF total >= 380 THEN + SET description = '学习优秀'; + ELSEIF total >=320 AND total < 380 THEN + SET description = '学习良好'; + ELSE + SET description = '学习一般'; + END IF; + END$ + DELIMITER ; + -- 调用pro_test4存储过程 + CALL pro_test4(); + ``` + + + + +*** + + + +##### 参数传递 + +* 参数传递的语法 + + IN:代表输入参数,需要由调用者传递实际数据,默认的 + OUT:代表输出参数,该参数可以作为返回值 + INOUT:代表既可以作为输入参数,也可以作为输出参数 + + ```mysql + DELIMITER $ + + -- 标准语法 + CREATE PROCEDURE 存储过程名称([IN|OUT|INOUT] 参数名 数据类型) + BEGIN + 执行的sql语句; + END$ + + DELIMITER ; + ``` + +* 输入总成绩变量,代表学生总成绩,输出分数描述变量,代表学生总成绩的描述 + + ```mysql + DELIMITER $ + + CREATE PROCEDURE pro_test6(IN total INT, OUT description VARCHAR(10)) + BEGIN + -- 判断总分数 + IF total >= 380 THEN + SET description = '学习优秀'; + ELSEIF total >= 320 AND total < 380 THEN + SET description = '学习不错'; + ELSE + SET description = '学习一般'; + END IF; + END$ + + DELIMITER ; + -- 调用pro_test6存储过程 + CALL pro_test6(310,@description); + CALL pro_test6((SELECT SUM(score) FROM student), @description); + -- 查询总成绩描述 + SELECT @description; + ``` + +* 查看参数方法 + + * @变量名 : **用户会话变量**,代表整个会话过程他都是有作用的,类似于全局变量 + * @@变量名 : **系统变量** + + + +*** + + + +##### CASE + +* 标准语法 1 + + ```mysql + CASE 表达式 + WHEN 值1 THEN 执行sql语句1; + [WHEN 值2 THEN 执行sql语句2;] + ... + [ELSE 执行sql语句n;] + END CASE; + ``` + +* 标准语法 2 + + ```mysql + sCASE + WHEN 判断条件1 THEN 执行sql语句1; + [WHEN 判断条件2 THEN 执行sql语句2;] + ... + [ELSE 执行sql语句n;] + END CASE; + ``` + +* 演示 + + ```mysql + DELIMITER $ + CREATE PROCEDURE pro_test7(IN total INT) + BEGIN + -- 定义变量 + DECLARE description VARCHAR(10); + -- 使用case判断 + CASE + WHEN total >= 380 THEN + SET description = '学习优秀'; + WHEN total >= 320 AND total < 380 THEN + SET description = '学习不错'; + ELSE + SET description = '学习一般'; + END CASE; + + -- 查询分数描述信息 + SELECT description; + END$ + DELIMITER ; + -- 调用pro_test7存储过程 + CALL pro_test7(390); + CALL pro_test7((SELECT SUM(score) FROM student)); + ``` + + + +*** + + + +##### WHILE + +* while 循环语法 + + ```mysql + WHILE 条件判断语句 DO + 循环体语句; + 条件控制语句; + END WHILE; + ``` + +* 计算 1~100 之间的偶数和 + + ```mysql + DELIMITER $ + CREATE PROCEDURE pro_test6() + BEGIN + -- 定义求和变量 + DECLARE result INT DEFAULT 0; + -- 定义初始化变量 + DECLARE num INT DEFAULT 1; + -- while循环 + WHILE num <= 100 DO + IF num % 2 = 0 THEN + SET result = result + num; + END IF; + SET num = num + 1; + END WHILE; + -- 查询求和结果 + SELECT result; + END$ + DELIMITER ; + + -- 调用pro_test6存储过程 + CALL pro_test6(); + ``` + + + +*** + + + +##### REPEAT + +* repeat 循环标准语法 + + ```mysql + 初始化语句; + REPEAT + 循环体语句; + 条件控制语句; + UNTIL 条件判断语句 + END REPEAT; + ``` + +* 计算 1~10 之间的和 + + ```mysql + DELIMITER $ + CREATE PROCEDURE pro_test9() + BEGIN + -- 定义求和变量 + DECLARE result INT DEFAULT 0; + -- 定义初始化变量 + DECLARE num INT DEFAULT 1; + -- repeat循环 + REPEAT + -- 累加 + SET result = result + num; + -- 让num+1 + SET num = num + 1; + -- 停止循环 + UNTIL num > 10 + END REPEAT; + -- 查询求和结果 + SELECT result; + END$ + + DELIMITER ; + -- 调用pro_test9存储过程 + CALL pro_test9(); + ``` + + + + +*** + + + +##### LOOP + +LOOP 实现简单的循环,退出循环的条件需要使用其他的语句定义,通常可以使用 LEAVE 语句实现,如果不加退出循环的语句,那么就变成了死循环 + +* loop 循环标准语法 + + ```mysql + [循环名称:] LOOP + 条件判断语句 + [LEAVE 循环名称;] + 循环体语句; + 条件控制语句; + END LOOP 循环名称; + ``` + +* 计算 1~10 之间的和 + + ```mysql + DELIMITER $ + CREATE PROCEDURE pro_test10() + BEGIN + -- 定义求和变量 + DECLARE result INT DEFAULT 0; + -- 定义初始化变量 + DECLARE num INT DEFAULT 1; + -- loop循环 + l:LOOP + -- 条件成立,停止循环 + IF num > 10 THEN + LEAVE l; + END IF; + -- 累加 + SET result = result + num; + -- 让num+1 + SET num = num + 1; + END LOOP l; + -- 查询求和结果 + SELECT result; + END$ + DELIMITER ; + -- 调用pro_test10存储过程 + CALL pro_test10(); + ``` + + + +*** + + + +##### 游标 + +游标是用来存储查询结果集的数据类型,在存储过程和函数中可以使用光标对结果集进行循环的处理 +* 游标可以遍历返回的多行结果,每次拿到一整行数据 +* 简单来说游标就类似于集合的迭代器遍历 +* MySQL 中的游标只能用在存储过程和函数中 + +游标的语法 + +* 创建游标 + + ```mysql + DECLARE 游标名称 CURSOR FOR 查询sql语句; + ``` + +* 打开游标 + + ```mysql + OPEN 游标名称; + ``` + +* 使用游标获取数据 + + ```mysql + FETCH 游标名称 INTO 变量名1,变量名2,...; + ``` + +* 关闭游标 + + ```mysql + CLOSE 游标名称; + ``` + +* Mysql 通过一个 Error handler 声明来判断指针是否到尾部,并且必须和创建游标的 SQL 语句声明在一起: + + ```mysql + DECLARE EXIT HANDLER FOR NOT FOUND (do some action,一般是设置标志变量) + ``` + + + +游标的基本使用 + +* 数据准备:表 student + + ```mysql + id NAME age gender score + 1 张三 23 男 95 + 2 李四 24 男 98 + 3 王五 25 女 100 + 4 赵六 26 女 90 + ``` + +* 创建 stu_score 表 + + ```mysql + CREATE TABLE stu_score( + id INT PRIMARY KEY AUTO_INCREMENT, + score INT + ); + ``` + +* 将student表中所有的成绩保存到stu_score表中 + + ```mysql + DELIMITER $ + + CREATE PROCEDURE pro_test12() + BEGIN + -- 定义成绩变量 + DECLARE s_score INT; + -- 定义标记变量 + DECLARE flag INT DEFAULT 0; + + -- 创建游标,查询所有学生成绩数据 + DECLARE stu_result CURSOR FOR SELECT score FROM student; + -- 游标结束后,将标记变量改为1 这两个必须声明在一起 + DECLARE EXIT HANDLER FOR NOT FOUND SET flag = 1; + + -- 开启游标 + OPEN stu_result; + -- 循环使用游标 + REPEAT + -- 使用游标,遍历结果,拿到数据 + FETCH stu_result INTO s_score; + -- 将数据保存到stu_score表中 + INSERT INTO stu_score VALUES (NULL,s_score); + UNTIL flag=1 + END REPEAT; + -- 关闭游标 + CLOSE stu_result; + END$ + + DELIMITER ; + + -- 调用pro_test12存储过程 + CALL pro_test12(); + -- 查询stu_score表 + SELECT * FROM stu_score; + ``` + + + + + +*** + + + +#### 存储函数 + +存储函数和存储过程是非常相似的,存储函数可以做的事情,存储过程也可以做到 + +存储函数有返回值,存储过程没有返回值(参数的 out 其实也相当于是返回数据了) + +* 创建存储函数 + + ```mysql + DELIMITER $ + -- 标准语法 + CREATE FUNCTION 函数名称(参数 数据类型) + RETURNS 返回值类型 + BEGIN + 执行的sql语句; + RETURN 结果; + END$ + + DELIMITER ; + ``` + +* 调用存储函数,因为有返回值,所以使用 SELECT 调用 + + ```mysql + SELECT 函数名称(实际参数); + ``` + +* 删除存储函数 + + ```mysql + DROP FUNCTION 函数名称; + ``` + +* 定义存储函数,获取学生表中成绩大于95分的学生数量 + + ```mysql + DELIMITER $ + CREATE FUNCTION fun_test() + RETURN INT + BEGIN + -- 定义统计变量 + DECLARE result INT; + -- 查询成绩大于95分的学生数量,给统计变量赋值 + SELECT COUNT(score) INTO result FROM student WHERE score > 95; + -- 返回统计结果 + SELECT result; + END + DELIMITER ; + -- 调用fun_test存储函数 + SELECT fun_test(); + ``` + + + + + +*** + + + +### 触发器 + +#### 基本介绍 + +触发器是与表有关的数据库对象,在 insert/update/delete 之前或之后触发并执行触发器中定义的 SQL 语句 + +* 触发器的这种特性可以协助应用在数据库端确保数据的完整性 、日志记录 、数据校验等操作 + +- 使用别名 NEW 和 OLD 来引用触发器中发生变化的记录内容,这与其他的数据库是相似的 +- 现在触发器还只支持行级触发,不支持语句级触发 + +| 触发器类型 | OLD的含义 | NEW的含义 | +| --------------- | ------------------------------ | ------------------------------ | +| INSERT 型触发器 | 无 (因为插入前状态无数据) | NEW 表示将要或者已经新增的数据 | +| UPDATE 型触发器 | OLD 表示修改之前的数据 | NEW 表示将要或已经修改后的数据 | +| DELETE 型触发器 | OLD 表示将要或者已经删除的数据 | 无 (因为删除后状态无数据) | + + + +*** + + + +#### 基本操作 + +* 创建触发器 + + ```mysql + DELIMITER $ + + CREATE TRIGGER 触发器名称 + BEFORE|AFTER INSERT|UPDATE|DELETE + ON 表名 + [FOR EACH ROW] -- 行级触发器 + BEGIN + 触发器要执行的功能; + END$ + + DELIMITER ; + ``` + +* 查看触发器的状态、语法等信息 + + ```mysql + SHOW TRIGGERS; + ``` + +* 删除触发器,如果没有指定 schema_name,默认为当前数据库 + + ```mysql + DROP TRIGGER [schema_name.]trigger_name; + ``` + + + +*** + + + +#### 触发演示 + +通过触发器记录账户表的数据变更日志。包含:增加、修改、删除 + +* 数据准备 + + ```mysql + -- 创建db9数据库 + CREATE DATABASE db9; + -- 使用db9数据库 + USE db9; + ``` + + ```mysql + -- 创建账户表account + CREATE TABLE account( + id INT PRIMARY KEY AUTO_INCREMENT, -- 账户id + NAME VARCHAR(20), -- 姓名 + money DOUBLE -- 余额 + ); + -- 添加数据 + INSERT INTO account VALUES (NULL,'张三',1000),(NULL,'李四',2000); + ``` + + ```mysql + -- 创建日志表account_log + CREATE TABLE account_log( + id INT PRIMARY KEY AUTO_INCREMENT, -- 日志id + operation VARCHAR(20), -- 操作类型 (insert update delete) + operation_time DATETIME, -- 操作时间 + operation_id INT, -- 操作表的id + operation_params VARCHAR(200) -- 操作参数 + ); + ``` + +* 创建 INSERT 型触发器 + + ```mysql + DELIMITER $ + + CREATE TRIGGER account_insert + AFTER INSERT + ON account + FOR EACH ROW + BEGIN + INSERT INTO account_log VALUES (NULL,'INSERT',NOW(),new.id,CONCAT('插入后{id=',new.id,',name=',new.name,',money=',new.money,'}')); + END$ + + DELIMITER ; + ``` + + ```mysql + -- 向account表添加记录 + INSERT INTO account VALUES (NULL,'王五',3000); + + -- 查询日志表 + SELECT * FROM account_log; + /* + id operation operation_time operation_id operation_params + 1 INSERT 2021-01-26 19:51:11 3 插入后{id=3,name=王五money=2000} + */ + ``` + + + +* 创建 UPDATE 型触发器 + + ```mysql + DELIMITER $ + + CREATE TRIGGER account_update + AFTER UPDATE + ON account + FOR EACH ROW + BEGIN + INSERT INTO account_log VALUES (NULL,'UPDATE',NOW(),new.id,CONCAT('修改前{id=',old.id,',name=',old.name,',money=',old.money,'}','修改后{id=',new.id,',name=',new.name,',money=',new.money,'}')); + END$ + + DELIMITER ; + ``` + + ```mysql + -- 修改account表 + UPDATE account SET money=3500 WHERE id=3; + + -- 查询日志表 + SELECT * FROM account_log; + /* + id operation operation_time operation_id operation_params + 2 UPDATE 2021-01-26 19:58:54 2 更新前{id=2,name=李四money=1000} + 更新后{id=2,name=李四money=200} + */ + ``` + + + +* 创建 DELETE 型触发器 + + ```mysql + DELIMITER $ + + CREATE TRIGGER account_delete + AFTER DELETE + ON account + FOR EACH ROW + BEGIN + INSERT INTO account_log VALUES (NULL,'DELETE',NOW(),old.id,CONCAT('删除前{id=',old.id,',name=',old.name,',money=',old.money,'}')); + END$ + + DELIMITER ; + ``` + + ```mysql + -- 删除account表数据 + DELETE FROM account WHERE id=3; + + -- 查询日志表 + SELECT * FROM account_log; + /* + id operation operation_time operation_id operation_params + 3 DELETE 2021-01-26 20:02:48 3 删除前{id=3,name=王五money=2000} + */ + ``` + + + + + + + +*** + + + + + +## 存储引擎 + +### 基本介绍 + +对比其他数据库,MySQL 的架构可以在不同场景应用并发挥良好作用,主要体现在存储引擎,插件式的存储引擎架构将查询处理和其他的系统任务以及数据的存储提取分离,可以针对不同的存储需求可以选择最优的存储引擎 + +存储引擎的介绍: + +- MySQL 数据库使用不同的机制存取表文件 , 机制的差别在于不同的存储方式、索引技巧、锁定水平等不同的功能和能力,在 MySQL 中,将这些不同的技术及配套的功能称为存储引擎 +- Oracle、SqlServer 等数据库只有一种存储引擎,MySQL **提供了插件式的存储引擎架构**,所以 MySQL 存在多种存储引擎 , 就会让数据库采取了不同的处理数据的方式和扩展功能 +- 在关系型数据库中数据的存储是以表的形式存进行,所以存储引擎也称为表类型(存储和操作此表的类型) +- 通过选择不同的引擎,能够获取最佳的方案, 也能够获得额外的速度或者功能,提高程序的整体效果。 + +MySQL 支持的存储引擎: + +- MySQL 支持的引擎包括:InnoDB、MyISAM、MEMORY、Archive、Federate、CSV、BLACKHOLE 等 +- MySQL5.5 之前的默认存储引擎是 MyISAM,5.5 之后就改为了 InnoDB + + + +**** + + + +### 引擎对比 + +MyISAM 存储引擎: + +* 特点:不支持事务和外键,读取速度快,节约资源 +* 应用场景:**适用于读多写少的场景**,对事务的完整性要求不高,比如一些数仓、离线数据、支付宝的年度总结之类的场景,业务进行只读操作,查询起来会更快 +* 存储方式: + * 每个 MyISAM 在磁盘上存储成 3 个文件,其文件名都和表名相同,拓展名不同 + * 表的定义保存在 .frm 文件,表数据保存在 .MYD (MYData) 文件中,索引保存在 .MYI (MYIndex) 文件中 + +InnoDB 存储引擎:(MySQL5.5 版本后默认的存储引擎) + +- 特点:**支持事务**和外键操作,支持并发控制。对比 MyISAM 的存储引擎,InnoDB 写的处理效率差一些,并且会占用更多的磁盘空间以保留数据和索引 +- 应用场景:对事务的完整性有比较高的要求,在并发条件下要求数据的一致性,读写频繁的操作 +- 存储方式: + - 使用共享表空间存储, 这种方式创建的表的表结构保存在 .frm 文件中, 数据和索引保存在 innodb_data_home_dir 和 innodb_data_file_path 定义的表空间中,可以是多个文件 + - 使用多表空间存储,创建的表的表结构存在 .frm 文件中,每个表的数据和索引单独保存在 .ibd 中 + +MEMORY 存储引擎: + +- 特点:每个 MEMORY 表实际对应一个磁盘文件 ,该文件中只存储表的结构,表数据保存在内存中,且默认**使用 HASH 索引**,所以数据默认就是无序的,但是在需要快速定位记录可以提供更快的访问,**服务一旦关闭,表中的数据就会丢失**,存储不安全 +- 应用场景:**缓存型存储引擎**,通常用于更新不太频繁的小表,用以快速得到访问结果 +- 存储方式:表结构保存在 .frm 中 + +MERGE 存储引擎: + +* 特点: + + * 是一组 MyISAM 表的组合,这些 MyISAM 表必须结构完全相同,通过将不同的表分布在多个磁盘上 + * MERGE 表本身并没有存储数据,对 MERGE 类型的表可以进行查询、更新、删除操作,这些操作实际上是对内部的 MyISAM 表进行的 + +* 应用场景:将一系列等同的 MyISAM 表以逻辑方式组合在一起,并作为一个对象引用他们,适合做数据仓库 + +* 操作方式: + + * 插入操作是通过 INSERT_METHOD 子句定义插入的表,使用 FIRST 或 LAST 值使得插入操作被相应地作用在第一或者最后一个表上;不定义这个子句或者定义为 NO,表示不能对 MERGE 表执行插入操作 + * 对 MERGE 表进行 DROP 操作,但是这个操作只是删除 MERGE 表的定义,对内部的表是没有任何影响的 + + ```mysql + CREATE TABLE order_1( + )ENGINE = MyISAM DEFAULT CHARSET=utf8; + + CREATE TABLE order_2( + )ENGINE = MyISAM DEFAULT CHARSET=utf8; + + CREATE TABLE order_all( + -- 结构与MyISAM表相同 + )ENGINE = MERGE UNION = (order_1,order_2) INSERT_METHOD=LAST DEFAULT CHARSET=utf8; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-MERGE.png) + +| 特性 | MyISAM | InnoDB | MEMORY | +| ------------ | ------------------------------ | ------------- | -------------------- | +| 存储限制 | 有(平台对文件系统大小的限制) | 64TB | 有(平台的内存限制) | +| **事务安全** | **不支持** | **支持** | **不支持** | +| **锁机制** | **表锁** | **表锁/行锁** | **表锁** | +| B+Tree 索引 | 支持 | 支持 | 支持 | +| 哈希索引 | 不支持 | 不支持 | 支持 | +| 全文索引 | 支持 | 支持 | 不支持 | +| 集群索引 | 不支持 | 支持 | 不支持 | +| 数据索引 | 不支持 | 支持 | 支持 | +| 数据缓存 | 不支持 | 支持 | N/A | +| 索引缓存 | 支持 | 支持 | N/A | +| 数据可压缩 | 支持 | 不支持 | 不支持 | +| 空间使用 | 低 | 高 | N/A | +| 内存使用 | 低 | 高 | 中等 | +| 批量插入速度 | 高 | 低 | 高 | +| **外键** | **不支持** | **支持** | **不支持** | + +只读场景 MyISAM 比 InnoDB 更快: + +* 底层存储结构有差别,MyISAM 是非聚簇索引,叶子节点保存的是数据的具体地址,不用回表查询 +* InnoDB 每次查询需要维护 MVCC 版本状态,保证并发状态下的读写冲突问题 + + + +*** + + + +### 引擎操作 + +* 查询数据库支持的存储引擎 + + ```mysql + SHOW ENGINES; + SHOW VARIABLES LIKE '%storage_engine%'; -- 查看Mysql数据库默认的存储引擎 + ``` + +* 查询某个数据库中所有数据表的存储引擎 + + ```mysql + SHOW TABLE STATUS FROM 数据库名称; + ``` + +* 查询某个数据库中某个数据表的存储引擎 + + ```mysql + SHOW TABLE STATUS FROM 数据库名称 WHERE NAME = '数据表名称'; + ``` + +* 创建数据表,指定存储引擎 + + ```mysql + CREATE TABLE 表名( + 列名,数据类型, + ... + )ENGINE = 引擎名称; + ``` + +* 修改数据表的存储引擎 + + ```mysql + ALTER TABLE 表名 ENGINE = 引擎名称; + ``` + + + + + + + + +*** + + + + + +## 索引机制 + +### 索引介绍 + +#### 基本介绍 + +MySQL 官方对索引的定义为:索引(index)是帮助 MySQL 高效获取数据的一种数据结构,**本质是排好序的快速查找数据结构。**在表数据之外,数据库系统还维护着满足特定查找算法的数据结构,这些数据结构以某种方式指向数据, 这样就可以在这些数据结构上实现高级查找算法,这种数据结构就是索引 + +**索引是在存储引擎层实现的**,所以并没有统一的索引标准,即不同存储引擎的索引的工作方式并不一样 + +索引使用:一张数据表,用于保存数据;一个索引配置文件,用于保存索引;每个索引都指向了某一个数据 +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-索引的介绍.png) + +左边是数据表,一共有两列七条记录,最左边的是数据记录的物理地址(注意逻辑上相邻的记录在磁盘上也并不是一定物理相邻的)。为了加快 Col2 的查找,可以维护一个右边所示的二叉查找树,每个节点分别包含索引键值和一个指向对应数据的物理地址的指针,这样就可以运用二叉查找快速获取到相应数据 + +索引的优点: + +* 类似于书籍的目录索引,提高数据检索的效率,降低数据库的 IO 成本 +* 通过索引列对数据进行排序,降低数据排序的成本,降低 CPU 的消耗 + +索引的缺点: + +* 一般来说索引本身也很大,不可能全部存储在内存中,因此索引往往以索引文件的形式**存储在磁盘**上 +* 虽然索引大大提高了查询效率,同时却也降低更新表的速度。对表进行 INSERT、UPDATE、DELETE 操作,MySQL 不仅要保存数据,还要保存一下索引文件每次更新添加了索引列的字段,还会调整因为更新所带来的键值变化后的索引信息,**但是更新数据也需要先从数据库中获取**,索引加快了获取速度,所以可以相互抵消一下。 +* 索引会影响到 WHERE 的查询条件和排序 ORDER BY 两大功能 + + + +*** + + + +#### 索引分类 + +索引一般的分类如下: + +- 功能分类 + - 主键索引:一种特殊的唯一索引,不允许有空值,一般在建表时同时创建主键索引 + - 单列索引:一个索引只包含单个列,一个表可以有多个单列索引(普通索引) + - 联合索引:顾名思义,就是将单列索引进行组合 + - 唯一索引:索引列的值必须唯一,**允许有空值**,如果是联合索引,则列值组合必须唯一 + * NULL 值可以出现多次,因为两个 NULL 比较的结果既不相等,也不不等,结果仍然是未知 + * 可以声明不允许存储 NULL 值的非空唯一索引 + - 外键索引:只有 InnoDB 引擎支持外键索引,用来保证数据的一致性、完整性和实现级联操作 + +- 结构分类 + - BTree 索引:MySQL 使用最频繁的一个索引数据结构,是 InnoDB 和 MyISAM 存储引擎默认的索引类型,底层基于 B+Tree + - Hash 索引:MySQL中 Memory 存储引擎默认支持的索引类型 + - R-tree 索引(空间索引):空间索引是 MyISAM 引擎的一个特殊索引类型,主要用于地理空间数据类型 + - Full-text 索引(全文索引):快速匹配全部文档的方式。MyISAM 支持, InnoDB 不支持 FULLTEXT 类型的索引,但是 InnoDB 可以使用 sphinx 插件支持全文索引,MEMORY 引擎不支持 + + | 索引 | InnoDB | MyISAM | Memory | + | --------- | ---------------- | ------ | ------ | + | BTREE | 支持 | 支持 | 支持 | + | HASH | 不支持 | 不支持 | 支持 | + | R-tree | 不支持 | 支持 | 不支持 | + | Full-text | 5.6 版本之后支持 | 支持 | 不支持 | + +联合索引图示:根据身高年龄建立的组合索引(height、age) + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-组合索引图.png) + + + + + +*** + + + +### 索引操作 + +索引在创建表的时候可以同时创建, 也可以随时增加新的索引 + +* 创建索引:如果一个表中有一列是主键,那么会**默认为其创建主键索引**(主键列不需要单独创建索引) + + ```mysql + CREATE [UNIQUE|FULLTEXT] INDEX 索引名称 [USING 索引类型] ON 表名(列名...); + -- 索引类型默认是 B+TREE + ``` + +* 查看索引 + + ```mysql + SHOW INDEX FROM 表名; + ``` + +* 添加索引 + + ```mysql + -- 单列索引 + ALTER TABLE 表名 ADD INDEX 索引名称(列名); + + -- 组合索引 + ALTER TABLE 表名 ADD INDEX 索引名称(列名1,列名2,...); + + -- 主键索引 + ALTER TABLE 表名 ADD PRIMARY KEY(主键列名); + + -- 外键索引(添加外键约束,就是外键索引) + ALTER TABLE 表名 ADD CONSTRAINT 外键名 FOREIGN KEY (本表外键列名) REFERENCES 主表名(主键列名); + + -- 唯一索引 + ALTER TABLE 表名 ADD UNIQUE 索引名称(列名); + + -- 全文索引(mysql只支持文本类型) + ALTER TABLE 表名 ADD FULLTEXT 索引名称(列名); + ``` + +* 删除索引 + + ```mysql + DROP INDEX 索引名称 ON 表名; + ``` + +* 案例练习 + + 数据准备:student + + ```mysql + id NAME age score + 1 张三 23 99 + 2 李四 24 95 + 3 王五 25 98 + 4 赵六 26 97 + ``` + + 索引操作: + + ```mysql + -- 为student表中姓名列创建一个普通索引 + CREATE INDEX idx_name ON student(NAME); + + -- 为student表中年龄列创建一个唯一索引 + CREATE UNIQUE INDEX idx_age ON student(age); + ``` + + + + + + +*** + + + +### 聚簇索引 + +#### 索引对比 + +聚簇索引是一种数据存储方式,并不是一种单独的索引类型 + +* 聚簇索引的叶子节点存放的是主键值和数据行,支持覆盖索引 + +* 非聚簇索引的叶子节点存放的是主键值或指向数据行的指针(由存储引擎决定) + +在 Innodb 下主键索引是聚簇索引,在 MyISAM 下主键索引是非聚簇索引 + + + +*** + + + +#### Innodb + +##### 聚簇索引 + +在 Innodb 存储引擎,B+ 树索引可以分为聚簇索引(也称聚集索引、clustered index)和辅助索引(也称非聚簇索引或二级索引、secondary index、non-clustered index) + +InnoDB 中,聚簇索引是按照每张表的主键构造一颗 B+ 树,叶子节点中存放的就是整张表的数据,将聚簇索引的叶子节点称为数据页 + +* 这个特性决定了**数据也是索引的一部分**,所以一张表只能有一个聚簇索引 +* 辅助索引的存在不影响聚簇索引中数据的组织,所以一张表可以有多个辅助索引 + +聚簇索引的优点: + +* 数据访问更快,聚簇索引将索引和数据保存在同一个 B+ 树中,因此从聚簇索引中获取数据比非聚簇索引更快 +* 聚簇索引对于主键的排序查找和范围查找速度非常快 + +聚簇索引的缺点: + +* 插入速度严重依赖于插入顺序,按照主键的顺序(递增)插入是最快的方式,否则将会出现页分裂,严重影响性能,所以对于 InnoDB 表,一般都会定义一个自增的 ID 列为主键 + +* 更新主键的代价很高,将会导致被更新的行移动,所以对于 InnoDB 表,一般定义主键为不可更新 + +* 二级索引访问需要两次索引查找,第一次找到主键值,第二次根据主键值找到行数据 + + + +*** + + + +##### 辅助索引 + +在聚簇索引之上创建的索引称之为辅助索引,非聚簇索引都是辅助索引,像复合索引、前缀索引、唯一索引等 + +辅助索引叶子节点存储的是主键值,而不是数据的物理地址,所以访问数据需要二次查找,推荐使用覆盖索引,可以减少回表查询 + +**检索过程**:辅助索引找到主键值,再通过聚簇索引(二分)找到数据页,最后通过数据页中的 Page Directory(二分)找到对应的数据分组,遍历组内所所有的数据找到数据行 + +补充:无索引走全表查询,查到数据页后和上述步骤一致 + + + +*** + + + +##### 索引实现 + +InnoDB 使用 B+Tree 作为索引结构,并且 InnoDB 一定有索引 + +主键索引: + +* 在 InnoDB 中,表数据文件本身就是按 B+Tree 组织的一个索引结构,这个索引的 key 是数据表的主键,叶子节点 data 域保存了完整的数据记录 + +* InnoDB 的表数据文件**通过主键聚集数据**,如果没有定义主键,会选择非空唯一索引代替,如果也没有这样的列,MySQL 会自动为 InnoDB 表生成一个**隐含字段 row_id** 作为主键,这个字段长度为 6 个字节,类型为长整形 + +辅助索引: + +* InnoDB 的所有辅助索引(二级索引)都引用主键作为 data 域 + +* InnoDB 表是基于聚簇索引建立的,因此 InnoDB 的索引能提供一种非常快速的主键查找性能。不过辅助索引也会包含主键列,所以不建议使用过长的字段作为主键,**过长的主索引会令辅助索引变得过大** + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-InnoDB聚簇和辅助索引结构.png) + + + +*** + + + +#### MyISAM + +##### 非聚簇 + +MyISAM 的主键索引使用的是非聚簇索引,索引文件和数据文件是分离的,**索引文件仅保存数据的地址** + +* 主键索引 B+ 树的节点存储了主键,辅助键索引 B+ 树存储了辅助键,表数据存储在独立的地方,这两颗 B+ 树的叶子节点都使用一个地址指向真正的表数据,对于表数据来说,这两个键没有任何差别 +* 由于索引树是独立的,通过辅助索引检索**无需回表查询**访问主键的索引树 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-聚簇索引和辅助索引检锁数据图.jpg) + + + +*** + + + +##### 索引实现 + +MyISAM 的索引方式也叫做非聚集的,之所以这么称呼是为了与 InnoDB 的聚集索引区分 + +主键索引:MyISAM 引擎使用 B+Tree 作为索引结构,叶节点的 data 域存放的是数据记录的地址 + +辅助索引:MyISAM 中主索引和辅助索引(Secondary key)在结构上没有任何区别,只是主索引要求 key 是唯一的,而辅助索引的 key 可以重复 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-MyISAM主键和辅助索引结构.png) + + + + + +参考文章:https://blog.csdn.net/lm1060891265/article/details/81482136 + + + +*** + + + +### 索引结构 + +#### 数据页 + +文件系统的最小单元是块(block),一个块的大小是 4K,系统从磁盘读取数据到内存时是以磁盘块为基本单位的,位于同一个磁盘块中的数据会被一次性读取出来,而不是需要什么取什么 + +InnoDB 存储引擎中有页(Page)的概念,页是 MySQL 磁盘管理的最小单位 + +* **InnoDB 存储引擎中默认每个页的大小为 16KB,索引中一个节点就是一个数据页**,所以会一次性读取 16KB 的数据到内存 +* InnoDB 引擎将若干个地址连接磁盘块,以此来达到页的大小 16KB +* 在查询数据时如果一个页中的每条数据都能有助于定位数据记录的位置,这将会减少磁盘 I/O 次数,提高查询效率 + +超过 16KB 的一条记录,主键索引页只会存储部分数据和指向**溢出页**的指针,剩余数据都会分散存储在溢出页中 + +数据页物理结构,从上到下: + +* File Header:上一页和下一页的指针、该页的类型(索引页、数据页、日志页等)、**校验和**、LSN(最近一次修改当前页面时的系统 lsn 值,事务持久性部分详解)等信息 +* Page Header:记录状态信息 +* Infimum + Supremum:当前页的最小记录和最大记录(头尾指针),Infimum 所在分组只有一条记录,Supremum 所在分组可以有 1 ~ 8 条记录,剩余的分组可以有 4 ~ 8 条记录 +* User Records:存储数据的记录 +* Free Space:尚未使用的存储空间 +* Page Directory:分组的目录,可以通过目录快速定位(二分法)数据的分组 +* File Trailer:检验和字段,在刷脏过程中,页首和页尾的校验和一致才能说明页面刷新成功,二者不同说明刷新期间发生了错误;LSN 字段,也是用来校验页面的完整性 + +数据页中包含数据行,数据的存储是基于数据行的,数据行有 next_record 属性指向下一个行数据,所以是可以遍历的,但是一组数据至多 8 个行,通过 Page Directory 先定位到组,然后遍历获取所需的数据行即可 + +数据行中有三个隐藏字段:trx_id、roll_pointer、row_id(在事务章节会详细介绍它们的作用) + + + +*** + + + +#### BTree + +BTree 的索引类型是基于 B+Tree 树型数据结构的,B+Tree 又是 BTree 数据结构的变种,用在数据库和操作系统中的文件系统,特点是能够保持数据稳定有序 + +BTree 又叫多路平衡搜索树,一颗 m 叉的 BTree 特性如下: + +- 树中每个节点最多包含 m 个孩子 +- 除根节点与叶子节点外,每个节点至少有 [ceil(m/2)] 个孩子 +- 若根节点不是叶子节点,则至少有两个孩子 +- 所有的叶子节点都在同一层 +- 每个非叶子节点由 n 个 key 与 n+1 个指针组成,其中 [ceil(m/2)-1] <= n <= m-1 + +5 叉,key 的数量 [ceil(m/2)-1] <= n <= m-1 为 2 <= n <=4 ,当 n>4 时中间节点分裂到父节点,两边节点分裂 + +插入 C N G A H E K Q M F W L T Z D P R X Y S 数据的工作流程: + +* 插入前 4 个字母 C N G A + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-BTree工作流程1.png) + +* 插入 H,n>4,中间元素 G 字母向上分裂到新的节点 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-BTree工作流程2.png) + +* 插入 E、K、Q 不需要分裂 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-BTree工作流程3.png) + +* 插入 M,中间元素 M 字母向上分裂到父节点 G + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-BTree工作流程4.png) + +* 插入 F,W,L,T 不需要分裂 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-BTree工作流程5.png) + +* 插入 Z,中间元素 T 向上分裂到父节点中 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-BTree工作流程6.png) + +* 插入 D,中间元素 D 向上分裂到父节点中,然后插入 P,R,X,Y 不需要分裂 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-BTree工作流程7.png) + +* 最后插入 S,NPQR 节点 n>5,中间节点 Q 向上分裂,但分裂后父节点 DGMT 的 n>5,中间节点 M 向上分裂 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-BTree工作流程8.png) + +BTree 树就已经构建完成了,BTree 树和二叉树相比, 查询数据的效率更高, 因为对于相同的数据量来说,**BTree 的层级结构比二叉树少**,所以搜索速度快 + +BTree 结构的数据可以让系统高效的找到数据所在的磁盘块,定义一条记录为一个二元组 [key, data] ,key 为记录的键值,对应表中的主键值,data 为一行记录中除主键外的数据。对于不同的记录,key 值互不相同,BTree 中的每个节点根据实际情况可以包含大量的关键字信息和分支 +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/索引的原理1.png) + +缺点:当进行范围查找时会出现回旋查找 + + + +*** + + + +#### B+Tree + +##### 数据结构 + +BTree 数据结构中每个节点中不仅包含数据的 key 值,还有 data 值。磁盘中每一页的存储空间是有限的,如果 data 数据较大时将会导致每个节点(即一个页)能存储的 key 的数量很小,当存储的数据量很大时同样会导致 B-Tree 的深度较大,增大查询时的磁盘 I/O 次数,进而影响查询效率,所以引入 B+Tree + +B+Tree 为 BTree 的变种,B+Tree 与 BTree 的区别为: + +* n 叉 B+Tree 最多含有 n 个 key(哈希值),而 BTree 最多含有 n-1 个 key + +- 所有**非叶子节点只存储键值 key** 信息,只进行数据索引,使每个非叶子节点所能保存的关键字大大增加 +- 所有**数据都存储在叶子节点**,所以每次数据查询的次数都一样 +- **叶子节点按照 key 大小顺序排列,左边结尾数据都会保存右边节点开始数据的指针,形成一个链表** +- 所有节点中的 key 在叶子节点中也存在(比如 5),**key 允许重复**,B 树不同节点不存在重复的 key + + + +B* 树:是 B+ 树的变体,在 B+ 树的非根和非叶子结点再增加指向兄弟的指针 + + + +*** + + + +##### 优化结构 + +MySQL 索引数据结构对经典的 B+Tree 进行了优化,在原 B+Tree 的基础上,增加一个指向相邻叶子节点的链表指针,就形成了带有顺序指针的 B+Tree,**提高区间访问的性能,防止回旋查找** + +区间访问的意思是访问索引为 5 - 15 的数据,可以直接根据相邻节点的指针遍历 + +B+ 树的**叶子节点是数据页**(page),一个页里面可以存多个数据行 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/索引的原理2.png) + +通常在 B+Tree 上有两个头指针,**一个指向根节点,另一个指向关键字最小的叶子节点**,而且所有叶子节点(即数据节点)之间是一种链式环结构。可以对 B+Tree 进行两种查找运算: + +- 有范围:对于主键的范围查找和分页查找 +- 有顺序:从根节点开始,进行随机查找,顺序查找 + +InnoDB 中每个数据页的大小默认是 16KB, + +* 索引行:一般表的主键类型为 INT(4 字节)或 BIGINT(8 字节),指针大小在 InnoDB 中设置为 6 字节节,也就是说一个页大概存储 16KB/(8B+6B)=1K 个键值(估值)。则一个深度为 3 的 B+Tree 索引可以维护 `10^3 * 10^3 * 10^3 = 10亿` 条记录 +* 数据行:一行数据的大小可能是 1k,一个数据页可以存储 16 行 + +实际情况中每个节点可能不能填充满,因此在数据库中,B+Tree 的高度一般都在 2-4 层。MySQL 的 InnoDB 存储引擎在设计时是**将根节点常驻内存的**,也就是说查找某一键值的行记录时最多只需要 1~3 次磁盘 I/O 操作 + +B+Tree 优点:提高查询速度,减少磁盘的 IO 次数,树形结构较小 + + + +*** + + + +##### 索引维护 + +B+ 树为了保持索引的有序性,在插入新值的时候需要做相应的维护 + +每个索引中每个块存储在磁盘页中,可能会出现以下两种情况: + +* 如果所在的数据页已经满了,这时候需要申请一个新的数据页,然后挪动部分数据过去,这个过程称为**页分裂**,原本放在一个页的数据现在分到两个页中,降低了空间利用率 +* 当相邻两个页由于删除了数据,利用率很低之后,会将数据页做**页合并**,合并的过程可以认为是分裂过程的逆过程 +* 这两个情况都是由 B+ 树的结构决定的 + +一般选用数据小的字段做索引,字段长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小 + +自增主键的插入数据模式,可以让主键索引尽量地保持递增顺序插入,不涉及到挪动其他记录,**避免了页分裂**,页分裂的目的就是保证后一个数据页中的所有行主键值比前一个数据页中主键值大 + + + +参考文章:https://developer.aliyun.com/article/919861 + + + +*** + + + +### 设计原则 + +索引的设计可以遵循一些已有的原则,创建索引的时候请尽量考虑符合这些原则,便于提升索引的使用效率 + +创建索引时的原则: +- 对查询频次较高,且数据量比较大的表建立索引 +- 使用唯一索引,区分度越高,使用索引的效率越高 +- 索引字段的选择,最佳候选列应当从 where 子句的条件中提取,使用覆盖索引 +- 使用短索引,索引创建之后也是使用硬盘来存储的,因此提升索引访问的 I/O 效率,也可以提升总体的访问效率。假如构成索引的字段总长度比较短,那么在给定大小的存储块内可以存储更多的索引值,相应的可以有效的提升 MySQL 访问索引的 I/O 效率 +- 索引可以有效的提升查询数据的效率,但索引数量不是多多益善,索引越多,维护索引的代价越高。对于插入、更新、删除等 DML 操作比较频繁的表来说,索引过多,会引入相当高的维护代价,降低 DML 操作的效率,增加相应操作的时间消耗;另外索引过多的话,MySQL 也会犯选择困难病,虽然最终仍然会找到一个可用的索引,但提高了选择的代价 + +* MySQL 建立联合索引时会遵守**最左前缀匹配原则**,即最左优先,在检索数据时从联合索引的最左边开始匹配 + + N 个列组合而成的组合索引,相当于创建了 N 个索引,如果查询时 where 句中使用了组成该索引的**前**几个字段,那么这条查询 SQL 可以利用组合索引来提升查询效率 + + ```mysql + -- 对name、address、phone列建一个联合索引 + ALTER TABLE user ADD INDEX index_three(name,address,phone); + -- 查询语句执行时会依照最左前缀匹配原则,检索时分别会使用索引进行数据匹配。 + (name,address,phone) + (name,address) + (name,phone) -- 只有name字段走了索引 + (name) + + -- 索引的字段可以是任意顺序的,优化器会帮助我们调整顺序,下面的SQL语句可以命中索引 + SELECT * FROM user WHERE address = '北京' AND phone = '12345' AND name = '张三'; + ``` + + ```mysql + -- 如果联合索引中最左边的列不包含在条件查询中,SQL语句就不会命中索引,比如: + SELECT * FROM user WHERE address = '北京' AND phone = '12345'; + ``` + +哪些情况不要建立索引: + +* 记录太少的表 +* 经常增删改的表 +* 频繁更新的字段不适合创建索引 +* where 条件里用不到的字段不创建索引 + + + +*** + + + +### 索引优化 + +#### 覆盖索引 + +覆盖索引:包含所有满足查询需要的数据的索引(SELECT 后面的字段刚好是索引字段),可以利用该索引返回 SELECT 列表的字段,而不必根据索引去聚簇索引上读取数据文件 + +回表查询:要查找的字段不在非主键索引树上时,需要通过叶子节点的主键值去主键索引上获取对应的行数据 + +使用覆盖索引,防止回表查询: + +* 表 user 主键为 id,普通索引为 age,查询语句: + + ```mysql + SELECT * FROM user WHERE age = 30; + ``` + + 查询过程:先通过普通索引 age=30 定位到主键值 id=1,再通过聚集索引 id=1 定位到行记录数据,需要两次扫描 B+ 树 + +* 使用覆盖索引: + + ```mysql + DROP INDEX idx_age ON user; + CREATE INDEX idx_age_name ON user(age,name); + SELECT id,age FROM user WHERE age = 30; + ``` + + 在一棵索引树上就能获取查询所需的数据,无需回表速度更快 + +使用覆盖索引,要注意 SELECT 列表中只取出需要的列,不可用 SELECT *,所有字段一起做索引会导致索引文件过大,查询性能下降 + + + +*** + + + +#### 索引下推 + +索引条件下推优化(Index Condition Pushdown,ICP)是 MySQL5.6 添加,可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数 + +索引下推充分利用了索引中的数据,在查询出整行数据之前过滤掉无效的数据,再去主键索引树上查找 + +* 不使用索引下推优化时存储引擎通过索引检索到数据,然后回表查询记录返回给 Server 层,**服务器判断数据是否符合条件** + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-不使用索引下推.png) +* 使用索引下推优化时,如果**存在某些被索引的列的判断条件**时,由存储引擎在索引遍历的过程中判断数据是否符合传递的条件,将符合条件的数据进行回表,检索出来返回给服务器,由此减少 IO 次数 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-使用索引下推.png) + +**适用条件**: + +* 需要存储引擎将索引中的数据与条件进行判断(所以**条件列必须都在同一个索引中**),所以优化是基于存储引擎的,只有特定引擎可以使用,适用于 InnoDB 和 MyISAM +* 存储引擎没有调用跨存储引擎的能力,跨存储引擎的功能有存储过程、触发器、视图,所以调用这些功能的不可以进行索引下推优化 +* 对于 InnoDB 引擎只适用于二级索引,InnoDB 的聚簇索引会将整行数据读到缓冲区,不再需要去回表查询了 + +工作过程:用户表 user,(name, age) 是联合索引 + +```mysql +SELECT * FROM user WHERE name LIKE '张%' AND age = 10; -- 头部模糊匹配会造成索引失效 +``` + +* 优化前:在非主键索引树上找到满足第一个条件的行,然后通过叶子节点记录的主键值再回到主键索引树上查找到对应的行数据,再对比 AND 后的条件是否符合,符合返回数据,需要 4 次回表 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-索引下推优化1.png) + +* 优化后:检查索引中存储的列信息是否符合索引条件,然后交由存储引擎用剩余的判断条件判断此行数据是否符合要求,**不满足条件的不去读取表中的数据**,满足下推条件的就根据主键值进行回表查询,2 次回表 + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-索引下推优化2.png) + +当使用 EXPLAIN 进行分析时,如果使用了索引条件下推,Extra 会显示 Using index condition + + + +参考文章:https://blog.csdn.net/sinat_29774479/article/details/103470244 + +参考文章:https://time.geekbang.org/column/article/69636 + + + +*** + + + +#### 前缀索引 + +当要索引的列字符很多时,索引会变大变慢,可以只索引列开始的部分字符串,节约索引空间,提高索引效率 + +注意:使用前缀索引就系统就忽略覆盖索引对查询性能的优化了 + +优化原则:**降低重复的索引值** + +比如地区表: + +```mysql +area gdp code +chinaShanghai 100 aaa +chinaDalian 200 bbb +usaNewYork 300 ccc +chinaFuxin 400 ddd +chinaBeijing 500 eee +``` + +发现 area 字段很多都是以 china 开头的,那么如果以前 1-5 位字符做前缀索引就会出现大量索引值重复的情况,索引值重复性越低,查询效率也就越高,所以需要建立前 6 位字符的索引: + +```mysql +CREATE INDEX idx_area ON table_name(area(7)); +``` + +场景:存储身份证 + +* 直接创建完整索引,这样可能比较占用空间 +* 创建前缀索引,节省空间,但会增加查询扫描次数,并且不能使用覆盖索引 +* 倒序存储,再创建前缀索引,用于绕过字符串本身前缀的区分度不够的问题(前 6 位相同的很多) +* 创建 hash 字段索引,查询性能稳定,有额外的存储和计算消耗,跟第三种方式一样,都不支持范围扫描 + + + +**** + + + +#### 索引合并 + +使用多个索引来完成一次查询的执行方法叫做索引合并 index merge + +* Intersection 索引合并: + + ```sql + SELECT * FROM table_test WHERE key1 = 'a' AND key3 = 'b'; # key1 和 key3 列都是单列索引、二级索引 + ``` + + 从不同索引中扫描到的记录的 id 值取**交集**(相同 id),然后执行回表操作,要求从每个二级索引获取到的记录都是按照主键值排序 + +* Union 索引合并: + + ```sql + SELECT * FROM table_test WHERE key1 = 'a' OR key3 = 'b'; + ``` + + 从不同索引中扫描到的记录的 id 值取**并集**,然后执行回表操作,要求从每个二级索引获取到的记录都是按照主键值排序 + +* Sort-Union 索引合并 + + ```sql + SELECT * FROM table_test WHERE key1 < 'a' OR key3 > 'b'; + ``` + + 先将从不同索引中扫描到的记录的主键值进行排序,再按照 Union 索引合并的方式进行查询 + +索引合并算法的效率并不好,通过将其中的一个索引改成联合索引会优化效率 + + + + + +*** + + + + + +## 系统优化 + +### 表优化 + +#### 分区表 + +##### 基本介绍 + +分区表是将大表的数据按分区字段分成许多小的子集,建立一个以 ftime 年份为分区的表: + +```mysql +CREATE TABLE `t` ( + `ftime` datetime NOT NULL, + `c` int(11) DEFAULT NULL, + KEY (`ftime`) +) ENGINE=InnoDB DEFAULT CHARSET=latin1 +PARTITION BY RANGE (YEAR(ftime)) +(PARTITION p_2017 VALUES LESS THAN (2017) ENGINE = InnoDB, + PARTITION p_2018 VALUES LESS THAN (2018) ENGINE = InnoDB, + PARTITION p_2019 VALUES LESS THAN (2019) ENGINE = InnoDB, + PARTITION p_others VALUES LESS THAN MAXVALUE ENGINE = InnoDB); +INSERT INTO t VALUES('2017-4-1',1),('2018-4-1',1);-- 这两行记录分别落在 p_2018 和 p_2019 这两个分区上 +``` + +这个表包含了一个.frm 文件和 4 个.ibd 文件,每个分区对应一个.ibd 文件 + +* 对于引擎层来说,这是 4 个表,针对每个分区表的操作不会相互影响 +* 对于 Server 层来说,这是 1 个表 + + + +*** + + + +##### 分区策略 + +打开表行为:第一次访问一个分区表时,MySQL 需要**把所有的分区都访问一遍**,如果分区表的数量很多,超过了 open_files_limit 参数(默认值 1024),那么就会在访问这个表时打开所有的文件,导致打开表文件的个数超过了上限而报错 + +通用分区策略:MyISAM 分区表使用的分区策略,每次访问分区都由 Server 层控制,在文件管理、表管理的实现上很粗糙,因此有比较严重的性能问题 + +本地分区策略:从 MySQL 5.7.9 开始,InnoDB 引擎内部自己管理打开分区的行为,InnoDB 引擎打开文件超过 innodb_open_files 时就会**关掉一些之前打开的文件**,所以即使分区个数大于 open_files_limit,也不会报错 + +从 MySQL 8.0 版本开始,就不允许创建 MyISAM 分区表,只允许创建已经实现了本地分区策略的引擎,目前只有 InnoDB 和 NDB 这两个引擎支持了本地分区策略 + + + +*** + + + +##### Server 层 + +从 Server 层看一个分区表就只是一个表 + +* Session A: + + ```mysql + SELECT * FROM t WHERE ftime = '2018-4-1'; + ``` + +* Session B: + + ```mysql + ALTER TABLE t TRUNCATE PARTITION p_2017; -- blocked + ``` + +现象:Session B 只操作 p_2017 分区,但是由于 Session A 持有整个表 t 的 MDL 读锁,就导致 B 的 ALTER 语句获取 MDL 写锁阻塞 + +分区表的特点: + +* 第一次访问的时候需要访问所有分区 +* 在 Server 层认为这是同一张表,因此**所有分区共用同一个 MDL 锁** +* 在引擎层认为这是不同的表,因此 MDL 锁之后的执行过程,会根据分区表规则,只访问需要的分区 + + + +*** + + + +##### 应用场景 + +分区表的优点: + +* 对业务透明,相对于用户分表来说,使用分区表的业务代码更简洁 + +* 分区表可以很方便的清理历史数据。按照时间分区的分区表,就可以直接通过 `alter table t drop partition` 这个语法直接删除分区文件,从而删掉过期的历史数据,与使用 drop 语句删除数据相比,优势是速度快、对系统影响小 + +使用分区表,不建议创建太多的分区,注意事项: + +* 分区并不是越细越好,单表或者单分区的数据一千万行,只要没有特别大的索引,对于现在的硬件能力来说都已经是小表 +* 分区不要提前预留太多,在使用之前预先创建即可。比如是按月分区,每年年底时再把下一年度的 12 个新分区创建上即可,并且对于没有数据的历史分区,要及时的 drop 掉 + + + +参考文档:https://time.geekbang.org/column/article/82560 + + + +*** + + + +#### 临时表 + +##### 基本介绍 + +临时表分为内部临时表和用户临时表 + +* 内部临时表:系统执行 SQL 语句优化时产生的表,例如 Join 连接查询、去重查询等 + +* 用户临时表:用户主动创建的临时表 + + ```mysql + CREATE TEMPORARY TABLE temp_t like table_1; + ``` + +临时表可以是内存表,也可以是磁盘表(多表操作 → 嵌套查询章节提及) + +* 内存表指的是使用 Memory 引擎的表,建立哈希索引,建表语法是 `create table … engine=memory`,这种表的数据都保存在内存里,系统重启时会被清空,但是表结构还在 +* 磁盘表是使用 InnoDB 引擎或者 MyISAM 引擎的临时表,建立 B+ 树索引,写数据的时候是写到磁盘上的 + +临时表的特点: + +* 一个临时表只能被创建它的 session 访问,对其他线程不可见,所以不同 session 的临时表是**可以重名**的 +* 临时表可以与普通表同名,会话内有同名的临时表和普通表时,执行 show create 语句以及增删改查语句访问的都是临时表 +* show tables 命令不显示临时表 +* 数据库发生异常重启不需要担心数据删除问题,临时表会**自动回收** + + + +*** + + + +##### 重名原理 + +执行创建临时表的 SQL: + +```mysql +create temporary table temp_t(id int primary key)engine=innodb; +``` + +MySQL 给 InnoDB 表创建一个 frm 文件保存表结构定义,在 ibd 保存表数据。frm 文件放在临时文件目录下,文件名的后缀是 .frm,**前缀是** `#sql{进程 id}_{线程 id}_ 序列号`,使用 `select @@tmpdir` 命令,来显示实例的临时文件目录 + +MySQL 维护数据表,除了物理磁盘上的文件外,内存里也有一套机制区别不同的表,每个表都对应一个 table_def_key + +* 一个普通表的 table_def_key 的值是由 `库名 + 表名` 得到的,所以如果在同一个库下创建两个同名的普通表,创建第二个表的过程中就会发现 table_def_key 已经存在了 +* 对于临时表,table_def_key 在 `库名 + 表名` 基础上,又加入了 `server_id + thread_id`,所以不同线程之间,临时表可以重名 + +实现原理:每个线程都维护了自己的临时表链表,每次 session 内操作表时,先遍历链表,检查是否有这个名字的临时表,如果有就**优先操作临时表**,如果没有再操作普通表;在 session 结束时对链表里的每个临时表,执行 `DROP TEMPORARY TABLE + 表名` 操作 + +执行 rename table 语句无法修改临时表,因为会按照 `库名 / 表名.frm` 的规则去磁盘找文件,但是临时表文件名的规则是 `#sql{进程 id}_{线程 id}_ 序列号.frm`,因此会报找不到文件名的错误 + + + +**** + + + +##### 主备复制 + +创建临时表的语句会传到备库执行,因此备库的同步线程就会创建这个临时表。主库在线程退出时会自动删除临时表,但备库同步线程是持续在运行的并不会退出,所以这时就需要在主库上再写一个 DROP TEMPORARY TABLE 传给备库执行 + +binlog 日志写入规则: + +* binlog_format=row,跟临时表有关的语句就不会记录到 binlog +* binlog_format=statment/mixed,binlog 中才会记录临时表的操作,也就会记录 `DROP TEMPORARY TABLE` 这条命令 + +主库上不同的线程创建同名的临时表是不冲突的,但是备库只有一个执行线程,所以 MySQL 在记录 binlog 时会把主库执行这个语句的线程 id 写到 binlog 中,在备库的应用线程就可以获取执行每个语句的主库线程 id,并利用这个线程 id 来构造临时表的 table_def_key + +* session A 的临时表 t1,在备库的 table_def_key 就是:`库名 + t1 +“M 的 serverid" + "session A 的 thread_id”` +* session B 的临时表 t1,在备库的 table_def_key 就是 :`库名 + t1 +"M 的 serverid" + "session B 的 thread_id"` + +MySQL 在记录 binlog 的时不论是 create table 还是 alter table 语句都是原样记录,但是如果执行 drop table,系统记录 binlog 就会被服务端改写 + +```mysql +DROP TABLE `t_normal` /* generated by server */ +``` + + + +*** + + + +##### 跨库查询 + +分库分表系统的跨库查询使用临时表不用担心线程之间的重名冲突,分库分表就是要把一个逻辑上的大表分散到不同的数据库实例上 + +比如将一个大表 ht,按照字段 f,拆分成 1024 个分表,分布到 32 个数据库实例上,一般情况下都有一个中间层 proxy 解析 SQL 语句,通过分库规则通过分表规则(比如 N%1024)确定将这条语句路由到哪个分表做查询 + +```mysql +select v from ht where f=N; +``` + +如果这个表上还有另外一个索引 k,并且查询语句: + +```mysql +select v from ht where k >= M order by t_modified desc limit 100; +``` + +查询条件里面没有用到分区字段 f,只能**到所有的分区**中去查找满足条件的所有行,然后统一做 order by 操作,两种方式: + +* 在 proxy 层的进程代码中实现排序,拿到分库的数据以后,直接在内存中参与计算,但是对 proxy 端的压力比较大,很容易出现内存不够用和 CPU 瓶颈问题 +* 把各个分库拿到的数据,汇总到一个 MySQL 实例的一个表中,然后在这个汇总实例上做逻辑操作,执行流程: + * 在汇总库上创建一个临时表 temp_ht,表里包含三个字段 v、k、t_modified + * 在各个分库执行:`select v,k,t_modified from ht_x where k >= M order by t_modified desc limit 100` + * 把分库执行的结果插入到 temp_ht 表中 + * 在临时表上执行:`select v from temp_ht order by t_modified desc limit 100` + + + + + +*** + + + +### 优化步骤 + +#### 执行频率 + +MySQL 客户端连接成功后,查询服务器状态信息: + +```mysql +SHOW [SESSION|GLOBAL] STATUS LIKE ''; +-- SESSION: 显示当前会话连接的统计结果,默认参数 +-- GLOBAL: 显示自数据库上次启动至今的统计结果 +``` + +* 查看 SQL 执行频率: + + ```mysql + SHOW STATUS LIKE 'Com_____'; + ``` + + Com_xxx 表示每种语句执行的次数 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-SQL语句执行频率.png) + +* 查询 SQL 语句影响的行数: + + ```mysql + SHOW STATUS LIKE 'Innodb_rows_%'; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-SQL语句影响的行数.png) + +Com_xxxx:这些参数对于所有存储引擎的表操作都会进行累计 + +Innodb_xxxx:这几个参数只是针对 InnoDB 存储引擎的,累加的算法也略有不同 + +| 参数 | 含义 | +| :------------------- | ------------------------------------------------------------ | +| Com_select | 执行 SELECT 操作的次数,一次查询只累加 1 | +| Com_insert | 执行 INSERT 操作的次数,对于批量插入的 INSERT 操作,只累加一次 | +| Com_update | 执行 UPDATE 操作的次数 | +| Com_delete | 执行 DELETE 操作的次数 | +| Innodb_rows_read | 执行 SELECT 查询返回的行数 | +| Innodb_rows_inserted | 执行 INSERT 操作插入的行数 | +| Innodb_rows_updated | 执行 UPDATE 操作更新的行数 | +| Innodb_rows_deleted | 执行 DELETE 操作删除的行数 | +| Connections | 试图连接 MySQL 服务器的次数 | +| Uptime | 服务器工作时间 | +| Slow_queries | 慢查询的次数 | + + + +**** + + + +#### 定位低效 + +SQL 执行慢有两种情况: + +* 偶尔慢:DB 在刷新脏页(学完事务就懂了) + * redo log 写满了 + * 内存不够用,要从 LRU 链表中淘汰 + * MySQL 认为系统空闲的时候 + * MySQL 关闭时 +* 一直慢的原因:索引没有设计好、SQL 语句没写好、MySQL 选错了索引 + +通过以下两种方式定位执行效率较低的 SQL 语句 + +* 慢日志查询: 慢查询日志在查询结束以后才记录,执行效率出现问题时查询日志并不能定位问题 + + 配置文件修改:修改 .cnf 文件 `vim /etc/mysql/my.cnf`,重启 MySQL 服务器 + + ```sh + slow_query_log=ON + slow_query_log_file=/usr/local/mysql/var/localhost-slow.log + long_query_time=1 #记录超过long_query_time秒的SQL语句的日志 + log-queries-not-using-indexes = 1 + ``` + + 使用命令配置: + + ```mysql + mysql> SET slow_query_log=ON; + mysql> SET GLOBAL slow_query_log=ON; + ``` + + 查看是否配置成功: + + ```mysql + SHOW VARIABLES LIKE '%query%' + ``` + +* SHOW PROCESSLIST:**实时查看**当前 MySQL 在进行的连接线程,包括线程的状态、是否锁表、SQL 的执行情况,同时对一些锁表操作进行优化 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-SHOW_PROCESSLIST命令.png) + + + + + + +*** + + + +#### EXPLAIN + +##### 执行计划 + +通过 EXPLAIN 命令获取执行 SQL 语句的信息,包括在 SELECT 语句执行过程中如何连接和连接的顺序,执行计划在优化器优化完成后、执行器之前生成,然后执行器会调用存储引擎检索数据 + +查询 SQL 语句的执行计划: + +```mysql +EXPLAIN SELECT * FROM table_1 WHERE id = 1; +``` + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-explain查询SQL语句的执行计划.png) + +| 字段 | 含义 | +| ------------- | ------------------------------------------------------------ | +| id | SELECT 的序列号 | +| select_type | 表示 SELECT 的类型 | +| table | 访问数据库中表名称,有时可能是简称或者临时表名称() | +| type | 表示表的连接类型 | +| possible_keys | 表示查询时,可能使用的索引 | +| key | 表示实际使用的索引 | +| key_len | 索引字段的长度 | +| ref | 表示与索引列进行等值匹配的对象,常数、某个列、函数等,type 必须在(range, const] 之间,左闭右开 | +| rows | 扫描出的行数,表示 MySQL 根据表统计信息及索引选用情况,**估算**的找到所需的记录扫描的行数 | +| filtered | 条件过滤的行百分比,单表查询没意义,用于连接查询中对驱动表的扇出进行过滤,查询优化器预测所有扇出值满足剩余查询条件的百分比,相乘以后表示多表查询中还要对被驱动执行查询的次数 | +| extra | 执行情况的说明和描述 | + +MySQL **执行计划的局限**: + +* 只是计划,不是执行 SQL 语句,可以随着底层优化器输入的更改而更改 +* EXPLAIN 不会告诉显示关于触发器、存储过程的信息对查询的影响情况, 不考虑各种 Cache +* EXPLAIN 不能显示 MySQL 在执行查询时的动态,因为执行计划在执行**查询之前生成** +* EXPALIN 只能解释 SELECT 操作,其他操作要重写为 SELECT 后查看执行计划 +* EXPLAIN PLAN 显示的是在解释语句时数据库将如何运行 SQL 语句,由于执行环境和 EXPLAIN PLAN 环境的不同,此计划可能与 SQL 语句**实际的执行计划不同**,部分统计信息是估算的,并非精确值 + +SHOW WARINGS:在使用 EXPALIN 命令后执行该语句,可以查询与执行计划相关的拓展信息,展示出 Level、Code、Message 三个字段,当 Code 为 1003 时,Message 字段展示的信息类似于将查询语句重写后的信息,但是不是等价,不能执行复制过来运行 + +环境准备: + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-执行计划环境准备.png) + + + + + +*** + + + +##### id + +id 代表 SQL 执行的顺序的标识,每个 SELECT 关键字对应一个唯一 id,所以在同一个 SELECT 关键字中的表的 id 都是相同的。SELECT 后的 FROM 可以跟随多个表,每个表都会对应一条记录,这些记录的 id 都是相同的, + +* id 相同时,执行顺序由上至下。连接查询的执行计划,记录的 id 值都是相同的,出现在前面的表为驱动表,后面为被驱动表 + + ```mysql + EXPLAIN SELECT * FROM t_role r, t_user u, user_role ur WHERE r.id = ur.role_id AND u.id = ur.user_id ; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-explain之id相同.png) + +* id 不同时,id 值越大优先级越高,越先被执行 + + ```mysql + EXPLAIN SELECT * FROM t_role WHERE id = (SELECT role_id FROM user_role WHERE user_id = (SELECT id FROM t_user WHERE username = 'stu1')) + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-explain之id不同.png) + +* id 有相同也有不同时,id 相同的可以认为是一组,从上往下顺序执行;在所有的组中,id 的值越大的组,优先级越高,越先执行 + + ```mysql + EXPLAIN SELECT * FROM t_role r , (SELECT * FROM user_role ur WHERE ur.`user_id` = '2') a WHERE r.id = a.role_id ; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-explain之id相同和不同.png) + +* id 为 NULL 时代表的是临时表 + + + +*** + + + +##### select + +表示查询中每个 select 子句的类型(简单 OR 复杂) + +| select_type | 含义 | +| ------------------ | ------------------------------------------------------------ | +| SIMPLE | 简单的 SELECT 查询,查询中不包含子查询或者 UNION | +| PRIMARY | 查询中若包含任何复杂的子查询,最外层(也就是最左侧)查询标记为该标识 | +| UNION | 对于 UNION 或者 UNION ALL 的复杂查询,除了最左侧的查询,其余的小查询都是 UNION | +| UNION RESULT | UNION 需要使用临时表进行去重,临时表的是 UNION RESULT | +| DEPENDENT UNION | 对于 UNION 或者 UNION ALL 的复杂查询,如果各个小查询都依赖外层查询,是相关子查询,除了最左侧的小查询为 DEPENDENT SUBQUERY,其余都是 DEPENDENT UNION | +| SUBQUERY | 子查询不是相关子查询,该子查询第一个 SELECT 代表的查询就是这种类型,会进行物化(该子查询只需要执行一次) | +| DEPENDENT SUBQUERY | 子查询是相关子查询,该子查询第一个 SELECT 代表的查询就是这种类型,不会物化(该子查询需要执行多次) | +| DERIVED | 在 FROM 列表中包含的子查询,被标记为 DERIVED(衍生),也就是生成物化派生表的这个子查询 | +| MATERIALIZED | 将子查询物化后与与外层进行连接查询,生成物化表的子查询 | + +子查询为 DERIVED:`SELECT * FROM (SELECT key1 FROM t1) AS derived_1 WHERE key1 > 10` + +子查询为 MATERIALIZED:`SELECT * FROM t1 WHERE key1 IN (SELECT key1 FROM t2)` + + + +**** + + + +##### type + +对表的访问方式,表示 MySQL 在表中找到所需行的方式,又称访问类型 + +| type | 含义 | +| --------------- | ------------------------------------------------------------ | +| ALL | 全表扫描,如果是 InnoDB 引擎是扫描聚簇索引 | +| index | 可以使用覆盖索引,但需要扫描全部索引 | +| range | 索引范围扫描,常见于 between、<、> 等的查询 | +| index_subquery | 子查询可以普通索引,则子查询的 type 为 index_subquery | +| unique_subquery | 子查询可以使用主键或唯一二级索引,则子查询的 type 为 index_subquery | +| index_merge | 索引合并 | +| ref_or_null | 非唯一性索引(普通二级索引)并且可以存储 NULL,进行等值匹配 | +| ref | 非唯一性索引与常量等值匹配 | +| eq_ref | 唯一性索引(主键或不存储 NULL 的唯一二级索引)进行等值匹配,如果二级索引是联合索引,那么所有联合的列都要进行等值匹配 | +| const | 通过主键或者唯一二级索引与常量进行等值匹配 | +| system | system 是 const 类型的特例,当查询的表只有一条记录的情况下,使用 system | +| NULL | MySQL 在优化过程中分解语句,执行时甚至不用访问表或索引 | + +从上到下,性能从差到好,一般来说需要保证查询至少达到 range 级别, 最好达到 ref + + + +*** + + + +##### key + +possible_keys: + +* 指出 MySQL 能使用哪个索引在表中找到记录,查询涉及到的字段上若存在索引,则该索引将被列出,但不一定被查询使用 +* 如果该列是 NULL,则没有相关的索引 + +key: + +* 显示 MySQL 在查询中实际使用的索引,若没有使用索引,显示为 NULL +* 查询中若使用了**覆盖索引**,则该索引可能出现在 key 列表,不出现在 possible_keys + +key_len: + +* 表示索引中使用的字节数,可通过该列计算查询中使用的索引的长度 +* key_len 显示的值为索引字段的最大可能长度,并非实际使用长度,即 key_len 是根据表定义计算而得,不是通过表内检索出的 +* 在不损失精确性的前提下,长度越短越好 + + + +*** + + + +##### Extra + +其他的额外的执行计划信息,在该列展示: + +* No tables used:查询语句中使用 FROM dual 或者没有 FROM 语句 +* Impossible WHERE:查询语句中的 WHERE 子句条件永远为 FALSE,会导致没有符合条件的行 +* Using index:该值表示相应的 SELECT 操作中使用了**覆盖索引**(Covering Index) +* Using index condition:第一种情况是搜索条件中虽然出现了索引列,但是部分条件无法形成扫描区间(**索引失效**),会根据可用索引的条件先搜索一遍再匹配无法使用索引的条件,回表查询数据;第二种是使用了**索引条件下推**优化 +* Using where:搜索的数据需要在 Server 层判断,无法使用索引下推 +* Using join buffer:连接查询被驱动表无法利用索引,需要连接缓冲区来存储中间结果 +* Using filesort:无法利用索引完成排序(优化方向),需要对数据使用外部排序算法,将取得的数据在内存或磁盘中进行排序 +* Using temporary:表示 MySQL 需要使用临时表来存储结果集,常见于**排序、去重(UNION)、分组**等场景 +* Select tables optimized away:说明仅通过使用索引,优化器可能仅从聚合函数结果中返回一行 +* No tables used:Query 语句中使用 from dual 或不含任何 from 子句 + + + +参考文章:https://www.cnblogs.com/ggjucheng/archive/2012/11/11/2765237.html + + + +**** + + + +#### PROFILES + +SHOW PROFILES 能够在做 SQL 优化时分析当前会话中语句执行的**资源消耗**情况 + +* 通过 have_profiling 参数,能够看到当前 MySQL 是否支持 profile: + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-have_profiling.png) + +* 默认 profiling 是关闭的,可以通过 set 语句在 Session 级别开启 profiling: + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-profiling.png) + + ```mysql + SET profiling=1; #开启profiling 开关; + ``` + +* 执行 SHOW PROFILES 指令, 来查看 SQL 语句执行的耗时: + + ```mysql + SHOW PROFILES; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-查看SQL语句执行耗时.png) + +* 查看到该 SQL 执行过程中每个线程的状态和消耗的时间: + + ```mysql + SHOW PROFILE FOR QUERY query_id; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-SQL执行每个状态消耗的时间.png) + +* 在获取到最消耗时间的线程状态后,MySQL 支持选择 all、cpu、block io 、context switch、page faults 等类型查看 MySQL 在使用什么资源上耗费了过高的时间。例如,选择查看 CPU 的耗费时间: + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-SQL执行每个状态消耗的CPU.png) + + * Status:SQL 语句执行的状态 + * Durationsql:执行过程中每一个步骤的耗时 + * CPU_user:当前用户占有的 CPU + * CPU_system:系统占有的 CPU + + + +*** + + + +#### TRACE + +MySQL 提供了对 SQL 的跟踪, 通过 trace 文件可以查看优化器**生成执行计划的过程** + +* 打开 trace 功能,设置格式为 JSON,并设置 trace 的最大使用内存,避免解析过程中因默认内存过小而不能够完整展示 + + ```mysql + SET optimizer_trace="enabled=on",end_markers_in_json=ON; -- 会话内有效 + SET optimizer_trace_max_mem_size=1000000; + ``` + +* 执行 SQL 语句: + + ```mysql + SELECT * FROM tb_item WHERE id < 4; + ``` + +* 检查 information_schema.optimizer_trace: + + ```mysql + SELECT * FROM information_schema.optimizer_trace \G; -- \G代表竖列展示 + ``` + + 执行信息主要有三个阶段:prepare 阶段、optimize 阶段(成本分析)、execute 阶段(执行) + + + + + +**** + + + +### 索引优化 + +#### 创建索引 + +索引是数据库优化最重要的手段之一,通过索引通常可以帮助用户解决大多数的 MySQL 的性能优化问题 + +```mysql +CREATE TABLE `tb_seller` ( + `sellerid` varchar (100), + `name` varchar (100), + `nickname` varchar (50), + `password` varchar (60), + `status` varchar (1), + `address` varchar (100), + `createtime` datetime, + PRIMARY KEY(`sellerid`) +)ENGINE=INNODB DEFAULT CHARSET=utf8mb4; +INSERT INTO `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('xiaomi','小米科技','小米官方旗舰店','e10adc3949ba59abbe56e057f20f883e','1','西安市','2088-01-01 12:00:00'); +CREATE INDEX idx_seller_name_sta_addr ON tb_seller(name, status, address); # 联合索引 +``` + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL使用索引环境准备.png) + + + +**** + + + +#### 避免失效 + +##### 语句错误 + +* 全值匹配:对索引中所有列都指定具体值,这种情况索引生效,执行效率高 + + ```mysql + EXPLAIN SELECT * FROM tb_seller WHERE name='小米科技' AND status='1' AND address='西安市'; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL使用索引1.png) + +* **最左前缀法则**:联合索引遵守最左前缀法则 + + 匹配最左前缀法则,走索引: + + ```mysql + EXPLAIN SELECT * FROM tb_seller WHERE name='小米科技'; + EXPLAIN SELECT * FROM tb_seller WHERE name='小米科技' AND status='1'; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL使用索引2.png) + + 违法最左前缀法则 , 索引失效: + + ```mysql + EXPLAIN SELECT * FROM tb_seller WHERE status='1'; + EXPLAIN SELECT * FROM tb_seller WHERE status='1' AND address='西安市'; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL使用索引3.png) + + 如果符合最左法则,但是出现跳跃某一列,只有最左列索引生效: + + ```mysql + EXPLAIN SELECT * FROM tb_seller WHERE name='小米科技' AND address='西安市'; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL使用索引4.png) + + 虽然索引列失效,但是系统会**使用了索引下推进行了优化** + +* **范围查询**右边的列,不能使用索引: + + ```mysql + EXPLAIN SELECT * FROM tb_seller WHERE name='小米科技' AND status>'1' AND address='西安市'; + ``` + + 根据前面的两个字段 name , status 查询是走索引的, 但是最后一个条件 address 没有用到索引,使用了索引下推 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL使用索引5.png) + +* 在索引列上**函数或者运算(+ - 数值)操作**, 索引将失效:会破坏索引值的有序性 + + ```mysql + EXPLAIN SELECT * FROM tb_seller WHERE SUBSTRING(name,3,2) = '科技'; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL使用索引6.png) + +* **字符串不加单引号**,造成索引失效:隐式类型转换,当字符串和数字比较时会**把字符串转化为数字** + + 没有对字符串加单引号,查询优化器会调用 CAST 函数将 status 转换为 int 进行比较,造成索引失效 + + ```mysql + EXPLAIN SELECT * FROM tb_seller WHERE name='小米科技' AND status = 1; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL使用索引7.png) + + 如果 status 是 int 类型,SQL 为 `SELECT * FROM tb_seller WHERE status = '1' ` 并不会造成索引失效,因为会将 `'1'` 转换为 `1`,并**不会对索引列产生操作** + +* 多表连接查询时,如果两张表的**字符集不同**,会造成索引失效,因为会进行类型转换 + + 解决方法:CONVERT 函数是加在输入参数上、修改表的字符集 + +* **用 OR 分割条件,索引失效**,导致全表查询: + + OR 前的条件中的列有索引而后面的列中没有索引或 OR 前后两个列是同一个复合索引,都造成索引失效 + + ```mysql + EXPLAIN SELECT * FROM tb_seller WHERE name='阿里巴巴' OR createtime = '2088-01-01 12:00:00'; + EXPLAIN SELECT * FROM tb_seller WHERE name='小米科技' OR status='1'; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL使用索引10.png) + + **AND 分割的条件不影响**: + + ```mysql + EXPLAIN SELECT * FROM tb_seller WHERE name='阿里巴巴' AND createtime = '2088-01-01 12:00:00'; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL使用索引11.png) + +* **以 % 开头的 LIKE 模糊查询**,索引失效: + + 如果是尾部模糊匹配,索引不会失效;如果是头部模糊匹配,索引失效 + + ```mysql + EXPLAIN SELECT * FROM tb_seller WHERE name like '%科技%'; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL使用索引12.png) + + 解决方案:通过覆盖索引来解决 + + ```mysql + EXPLAIN SELECT sellerid,name,status FROM tb_seller WHERE name like '%科技%'; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL使用索引13.png) + + 原因:在覆盖索引的这棵 B+ 数上只需要进行 like 的匹配,或者是基于覆盖索引查询再进行 WHERE 的判断就可以获得结果 + + + +*** + + + +##### 系统优化 + +系统优化为全表扫描: + +* 如果 MySQL 评估使用索引比全表更慢,则不使用索引,索引失效: + + ```mysql + CREATE INDEX idx_address ON tb_seller(address); + EXPLAIN SELECT * FROM tb_seller WHERE address='西安市'; + EXPLAIN SELECT * FROM tb_seller WHERE address='北京市'; + ``` + + 北京市的键值占 9/10(区分度低),所以优化为全表扫描,type = ALL + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL使用索引14.png) + +* IS NULL、IS NOT NULL **有时**索引失效: + + ```mysql + EXPLAIN SELECT * FROM tb_seller WHERE name IS NULL; + EXPLAIN SELECT * FROM tb_seller WHERE name IS NOT NULL; + ``` + + NOT NULL 失效的原因是 name 列全部不是 null,优化为全表扫描,当 NULL 过多时,IS NULL 失效 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL使用索引15.png) + +* IN 肯定会走索引,但是当 IN 的取值范围较大时会导致索引失效,走全表扫描: + + ```mysql + EXPLAIN SELECT * FROM tb_seller WHERE sellerId IN ('alibaba','huawei');-- 都走索引 + EXPLAIN SELECT * FROM tb_seller WHERE sellerId NOT IN ('alibaba','huawei'); + ``` + +* [MySQL 实战 45 讲](https://time.geekbang.org/column/article/74687)该章节最后提出了一种慢查询场景,获取到数据以后 Server 层还会做判断 + + + +*** + + + +#### 底层原理 + +索引失效一般是针对联合索引,联合索引一般由几个字段组成,排序方式是先按照第一个字段进行排序,然后排序第二个,依此类推,图示(a, b)索引,**a 相等的情况下 b 是有序的** + + + +* 最左前缀法则:当不匹配前面的字段的时候,后面的字段都是无序的。这种无序不仅体现在叶子节点,也会**导致查询时扫描的非叶子节点也是无序的**,因为索引树相当于忽略的第一个字段,就无法使用二分查找 + +* 范围查询右边的列,不能使用索引,比如语句: `WHERE a > 1 AND b = 1 `,在 a 大于 1 的时候,b 是无序的,a > 1 是扫描时有序的,但是找到以后进行寻找 b 时,索引树就不是有序的了 + + + +* 以 % 开头的 LIKE 模糊查询,索引失效,比如语句:`WHERE a LIKE '%d'`,前面的不确定,导致不符合最左匹配,直接去索引中搜索以 d 结尾的节点,所以没有顺序 + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-索引失效底层原理3.png) + + + +参考文章:https://mp.weixin.qq.com/s/B_M09dzLe9w7cT46rdGIeQ + + + +*** + + + +#### 查看索引 + +```mysql +SHOW STATUS LIKE 'Handler_read%'; +SHOW GLOBAL STATUS LIKE 'Handler_read%'; +``` + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL查看索引使用情况.png) + +* Handler_read_first:索引中第一条被读的次数,如果较高,表示服务器正执行大量全索引扫描(这个值越低越好) + +* Handler_read_key:如果索引正在工作,这个值代表一个行被索引值读的次数,值越低表示索引不经常使用(这个值越高越好) + +* Handler_read_next:按照键顺序读下一行的请求数,如果范围约束或执行索引扫描来查询索引列,值增加 + +* Handler_read_prev:按照键顺序读前一行的请求数,该读方法主要用于优化 ORDER BY ... DESC + +* Handler_read_rnd:根据固定位置读一行的请求数,如果执行大量查询并对结果进行排序则该值较高,可能是使用了大量需要 MySQL 扫描整个表的查询或连接,这个值较高意味着运行效率低,应该建立索引来解决 + +* Handler_read_rnd_next:在数据文件中读下一行的请求数,如果正进行大量的表扫描,该值较高,说明表索引不正确或写入的查询没有利用索引 + + + + + +*** + + + +### SQL 优化 + +#### 自增主键 + +##### 自增机制 + +自增主键可以让主键索引尽量地保持在数据页中递增顺序插入,不自增需要寻找其他页插入,导致随机 IO 和页分裂的情况 + +表的结构定义存放在后缀名为.frm 的文件中,但是并不会保存自增值,不同的引擎对于自增值的保存策略不同: + +* MyISAM 引擎的自增值保存在数据文件中 +* InnoDB 引擎的自增值保存在了内存里,每次打开表都会去找自增值的最大值 max(id),然后将 max(id)+1 作为当前的自增值;8.0 版本后,才有了自增值持久化的能力,将自增值的变更记录在了 redo log 中,重启的时候依靠 redo log 恢复重启之前的值 + +在插入一行数据的时候,自增值的行为如下: + +* 如果插入数据时 id 字段指定为 0、null 或未指定值,那么就把这个表当前的 AUTO_INCREMENT 值填到自增字段 +* 如果插入数据时 id 字段指定了具体的值,比如某次要插入的值是 X,当前的自增值是 Y + * 如果 X 优化为: + SELECT id,name,statu FROM tb_book; + ``` + +* 插入数据: + + ```mysql + INSERT INTO tb_test VALUES(1,'Tom'); + INSERT INTO tb_test VALUES(2,'Cat'); + INSERT INTO tb_test VALUES(3,'Jerry'); -- 连接三次数据库 + -- >优化为 + INSERT INTO tb_test VALUES(1,'Tom'),(2,'Cat'),(3,'Jerry'); -- 连接一次 + ``` + +* 在事务中进行数据插入: + + ```mysql + start transaction; + INSERT INTO tb_test VALUES(1,'Tom'); + INSERT INTO tb_test VALUES(2,'Cat'); + INSERT INTO tb_test VALUES(3,'Jerry'); + commit; -- 手动提交,分段提交 + ``` + +* 数据有序插入: + + ```mysql + INSERT INTO tb_test VALUES(1,'Tom'); + INSERT INTO tb_test VALUES(2,'Cat'); + INSERT INTO tb_test VALUES(3,'Jerry'); + ``` + +增加 cache 层:在应用中增加缓存层来达到减轻数据库负担的目的。可以部分数据从数据库中抽取出来放到应用端以文本方式存储,或者使用框架(Mybatis)提供的一级缓存 / 二级缓存,或者使用 Redis 数据库来缓存数据 + + + +*** + + + +#### 数据插入 + +当使用 load 命令导入数据的时候,适当的设置可以提高导入的效率: + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL load data.png) + +```mysql +LOAD DATA LOCAL INFILE = '/home/seazean/sql1.log' INTO TABLE `tb_user_1` FIELD TERMINATED BY ',' LINES TERMINATED BY '\n'; -- 文件格式如上图 +``` + +对于 InnoDB 类型的表,有以下几种方式可以提高导入的效率: + +1. **主键顺序插入**:因为 InnoDB 类型的表是按照主键的顺序保存的,所以将导入的数据按照主键的顺序排列,可以有效的提高导入数据的效率,如果 InnoDB 表没有主键,那么系统会自动默认创建一个内部列作为主键 + + 主键是否连续对性能影响不大,只要是递增的就可以,比如雪花算法产生的 ID 不是连续的,但是是递增的,因为递增可以让主键索引尽量地保持顺序插入,**避免了页分裂**,因此索引更紧凑 + + * 插入 ID 顺序排列数据: + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL插入ID顺序排列数据.png) + + * 插入 ID 无序排列数据: + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL插入ID无序排列数据.png) + +2. **关闭唯一性校验**:在导入数据前执行 `SET UNIQUE_CHECKS=0`,关闭唯一性校验;导入结束后执行 `SET UNIQUE_CHECKS=1`,恢复唯一性校验,可以提高导入的效率。 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL插入数据关闭唯一性校验.png) + +3. **手动提交事务**:如果应用使用自动提交的方式,建议在导入前执行`SET AUTOCOMMIT=0`,关闭自动提交;导入结束后再打开自动提交,可以提高导入的效率。 + + 事务需要控制大小,事务太大可能会影响执行的效率。MySQL 有 innodb_log_buffer_size 配置项,超过这个值的日志会写入磁盘数据,效率会下降,所以在事务大小达到配置项数据级前进行事务提交可以提高效率 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL插入数据手动提交事务.png) + + + +**** + + + +#### 分组排序 + +##### ORDER + +数据准备: + +```mysql +CREATE TABLE `emp` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL, + `age` INT(3) NOT NULL, + `salary` INT(11) DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=INNODB DEFAULT CHARSET=utf8mb4; +INSERT INTO `emp` (`id`, `name`, `age`, `salary`) VALUES('1','Tom','25','2300');-- ... +CREATE INDEX idx_emp_age_salary ON emp(age, salary); +``` + +* 第一种是通过对返回数据进行排序,所有不通过索引直接返回结果的排序都叫 FileSort 排序,会在内存中重新排序 + + ```mysql + EXPLAIN SELECT * FROM emp ORDER BY age DESC; -- 年龄降序 + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL ORDER BY排序1.png) + +* 第二种通过有序索引顺序扫描直接返回**有序数据**,这种情况为 Using index,不需要额外排序,操作效率高 + + ```mysql + EXPLAIN SELECT id, age, salary FROM emp ORDER BY age DESC; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL ORDER BY排序2.png) + +* 多字段排序: + + ```mysql + EXPLAIN SELECT id,age,salary FROM emp ORDER BY age DESC, salary DESC; + EXPLAIN SELECT id,age,salary FROM emp ORDER BY salary DESC, age DESC; + EXPLAIN SELECT id,age,salary FROM emp ORDER BY age DESC, salary ASC; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL ORDER BY排序3.png) + + 尽量减少额外的排序,通过索引直接返回有序数据。**需要满足 Order by 使用相同的索引、Order By 的顺序和索引顺序相同、Order by 的字段都是升序或都是降序**,否则需要额外的操作,就会出现 FileSort + +* ORDER BY RAND() 命令用来进行随机排序,会使用了临时内存表,临时内存表排序的时使用 rowid 排序方法 + +优化方式:创建合适的索引能够减少 Filesort 的出现,但是某些情况下条件限制不能让 Filesort 消失,就要加快 Filesort 的排序操作 + +内存临时表,MySQL 有两种 Filesort 排序算法: + +* rowid 排序:首先根据条件取出排序字段和信息,然后在**排序区 sort buffer(Server 层)**中排序,如果 sort buffer 不够,则在临时表 temporary table 中存储排序结果。完成排序后再根据行指针**回表读取记录**,该操作可能会导致大量随机 I/O 操作 + + 说明:对于临时内存表,回表过程只是简单地根据数据行的位置,直接访问内存得到数据,不会导致多访问磁盘,优先选择该方式 + +* 全字段排序:一次性取出满足条件的所有数据,需要回表,然后在排序区 sort buffer 中排序后直接输出结果集。排序时内存开销较大,但是排序效率比两次扫描算法高 + +具体的选择方式: + +* MySQL 通过比较系统变量 max_length_for_sort_data 的大小和 Query 语句取出的字段的大小,来判定使用哪种排序算法。如果前者大,则说明 sort buffer 空间足够,使用第二种优化之后的算法,否则使用第一种。 + +* 可以适当提高 sort_buffer_size 和 max_length_for_sort_data 系统变量,来增大排序区的大小,提高排序的效率 + + ```mysql + SET @@max_length_for_sort_data = 10000; -- 设置全局变量 + SET max_length_for_sort_data = 10240; -- 设置会话变量 + SHOW VARIABLES LIKE 'max_length_for_sort_data'; -- 默认1024 + SHOW VARIABLES LIKE 'sort_buffer_size'; -- 默认262114 + ``` + +磁盘临时表:排序使用优先队列(堆)的方式 + + + +*** + + + +##### GROUP + +GROUP BY 也会进行排序操作,与 ORDER BY 相比,GROUP BY 主要只是多了排序之后的分组操作,所以在 GROUP BY 的实现过程中,与 ORDER BY 一样也可以利用到索引 + +* 分组查询: + + ```mysql + DROP INDEX idx_emp_age_salary ON emp; + EXPLAIN SELECT age,COUNT(*) FROM emp GROUP BY age; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL GROUP BY排序1.png) + + Using temporary:表示 MySQL 需要使用临时表(不是 sort buffer)来存储结果集,常见于排序和分组查询 + +* 查询包含 GROUP BY 但是用户想要避免排序结果的消耗, 则可以执行 ORDER BY NULL 禁止排序: + + ```mysql + EXPLAIN SELECT age,COUNT(*) FROM emp GROUP BY age ORDER BY NULL; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL GROUP BY排序2.png) + +* 创建索引:索引本身有序,不需要临时表,也不需要再额外排序 + + ```mysql + CREATE INDEX idx_emp_age_salary ON emp(age, salary); + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL GROUP BY排序3.png) + +* 数据量很大时,使用 SQL_BIG_RESULT 提示优化器直接使用直接用磁盘临时表 + + + +*** + + + +#### 联合查询 + +对于包含 OR 的查询子句,如果要利用索引,则 OR 之间的**每个条件列都必须用到索引,而且不能使用到条件之间的复合索引**,如果没有索引,则应该考虑增加索引 + +* 执行查询语句: + + ```mysql + EXPLAIN SELECT * FROM emp WHERE id = 1 OR age = 30; -- 两个索引,并且不是复合索引 + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL OR条件查询1.png) + + ```sh + Extra: Using sort_union(idx_emp_age_salary,PRIMARY); Using where + ``` + +* 使用 UNION 替换 OR,求并集: + + 注意:该优化只针对多个索引列有效,如果有列没有被索引,查询效率可能会因为没有选择 OR 而降低 + + ```mysql + EXPLAIN SELECT * FROM emp WHERE id = 1 UNION SELECT * FROM emp WHERE age = 30; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL OR条件查询2.png) + +* UNION 要优于 OR 的原因: + + * UNION 语句的 type 值为 ref,OR 语句的 type 值为 range + * UNION 语句的 ref 值为 const,OR 语句的 ref 值为 null,const 表示是常量值引用,非常快 + + + +**** + + + +#### 嵌套查询 + +MySQL 4.1 版本之后,开始支持 SQL 的子查询 + +* 可以使用 SELECT 语句来创建一个单列的查询结果,然后把结果作为过滤条件用在另一个查询中 +* 使用子查询可以一次性的完成逻辑上需要多个步骤才能完成的 SQL 操作,同时也可以避免事务或者表锁死 +* 在有些情况下,**子查询是可以被更高效的连接(JOIN)替代** + +例如查找有角色的所有的用户信息: + +* 执行计划: + + ```mysql + EXPLAIN SELECT * FROM t_user WHERE id IN (SELECT user_id FROM user_role); + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL嵌套查询1.png) + +* 优化后: + + ```mysql + EXPLAIN SELECT * FROM t_user u , user_role ur WHERE u.id = ur.user_id; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL嵌套查询2.png) + + 连接查询之所以效率更高 ,是因为**不需要在内存中创建临时表**来完成逻辑上需要两个步骤的查询工作 + + + + + +*** + + + +#### 分页查询 + +一般分页查询时,通过创建覆盖索引能够比较好地提高性能 + +一个常见的问题是 `LIMIT 200000,10`,此时需要 MySQL 扫描前 200010 记录,仅仅返回 200000 - 200010 之间的记录,其他记录丢弃,查询排序的代价非常大 + +* 分页查询: + + ```mysql + EXPLAIN SELECT * FROM tb_user_1 LIMIT 200000,10; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL分页查询1.png) + +* 优化方式一:内连接查询,在索引列 id 上完成排序分页操作,最后根据主键关联回原表查询所需要的其他列内容 + + ```mysql + EXPLAIN SELECT * FROM tb_user_1 t,(SELECT id FROM tb_user_1 ORDER BY id LIMIT 200000,10) a WHERE t.id = a.id; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL分页查询2.png) + +* 优化方式二:方案适用于主键自增的表,可以把 LIMIT 查询转换成某个位置的查询 + + ```mysql + EXPLAIN SELECT * FROM tb_user_1 WHERE id > 200000 LIMIT 10; -- 写法 1 + EXPLAIN SELECT * FROM tb_user_1 WHERE id BETWEEN 200000 and 200010; -- 写法 2 + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL分页查询3.png) + + + +**** + + + +#### 使用提示 + +SQL 提示,是优化数据库的一个重要手段,就是在 SQL 语句中加入一些提示来达到优化操作的目的 + +* USE INDEX:在查询语句中表名的后面添加 USE INDEX 来提供 MySQL 去参考的索引列表,可以让 MySQL 不再考虑其他可用的索引 + + ```mysql + CREATE INDEX idx_seller_name ON tb_seller(name); + EXPLAIN SELECT * FROM tb_seller USE INDEX(idx_seller_name) WHERE name='小米科技'; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL使用提示1.png) + +* IGNORE INDEX:让 MySQL 忽略一个或者多个索引,则可以使用 IGNORE INDEX 作为提示 + + ```mysql + EXPLAIN SELECT * FROM tb_seller IGNORE INDEX(idx_seller_name) WHERE name = '小米科技'; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL使用提示2.png) + +* FORCE INDEX:强制 MySQL 使用一个特定的索引 + + ```mysql + EXPLAIN SELECT * FROM tb_seller FORCE INDEX(idx_seller_name_sta_addr) WHERE NAME='小米科技'; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL使用提示3.png) + + + + + +*** + + + +#### 统计计数 + +在不同的 MySQL 引擎中,count(*) 有不同的实现方式: + +* MyISAM 引擎把一个表的总行数存在了磁盘上,因此执行 count(*) 的时候会直接返回这个数,效率很高,但不支持事务 +* show table status 命令通过采样估算可以快速获取,但是不准确 +* InnoDB 表执行 count(*) 会遍历全表,虽然结果准确,但会导致性能问题 + +解决方案: + +* 计数保存在 Redis 中,但是更新 MySQL 和 Redis 的操作不是原子的,会存在数据一致性的问题 + +* 计数直接放到数据库里单独的一张计数表中,利用事务解决计数精确问题: + + + + 会话 B 的读操作在 T3 执行的,这时更新事务还没有提交,所以计数值加 1 这个操作对会话 B 还不可见,因此会话 B 查询的计数值和最近 100 条记录,返回的结果逻辑上就是一致的 + + 并发系统性能的角度考虑,应该先插入操作记录再更新计数表,因为更新计数表涉及到行锁的竞争,**先插入再更新能最大程度地减少事务之间的锁等待,提升并发度** + +count 函数的按照效率排序:`count(字段) < count(主键id) < count(1) ≈ count(*)`,所以建议尽量使用 count(*) + +* count(主键 id):InnoDB 引擎会遍历整张表,把每一行的 id 值都取出来返回给 Server 层,Server 判断 id 不为空就按行累加 +* count(1):InnoDB 引擎遍历整张表但不取值,Server 层对于返回的每一行,放一个数字 1 进去,判断不为空就按行累加 +* count(字段):如果这个字段是定义为 not null 的话,一行行地从记录里面读出这个字段,判断不能为 null,按行累加;如果这个字段定义允许为 null,那么执行的时候,判断到有可能是 null,还要把值取出来再判断一下,不是 null 才累加 +* count(*):不取值,按行累加 + + + +参考文章:https://time.geekbang.org/column/article/72775 + + + + + +*** + + + +### 缓冲优化 + +#### 优化原则 + +三个原则: + +* 将尽量多的内存分配给 MySQL 做缓存,但也要给操作系统和其他程序预留足够内存 +* MyISAM 存储引擎的数据文件读取依赖于操作系统自身的 IO 缓存,如果有 MyISAM 表,就要预留更多的内存给操作系统做 IO 缓存 +* 排序区、连接区等缓存是分配给每个数据库会话(Session)专用的,值的设置要根据最大连接数合理分配,如果设置太大,不但浪费资源,而且在并发数较高时会导致物理内存耗尽 + + + +*** + + + +#### 缓冲内存 + +Buffer Pool 本质上是 InnoDB 向操作系统申请的一段连续的内存空间。InnoDB 的数据是按数据页为单位来读写,每个数据页的大小默认是 16KB。数据是存放在磁盘中,每次读写数据都需要进行磁盘 IO 将数据读入内存进行操作,效率会很低,所以提供了 Buffer Pool 来暂存这些数据页,缓存中的这些页又叫缓冲页 + +工作原理: + +* 从数据库读取数据时,会首先从缓存中读取,如果缓存中没有,则从磁盘读取后放入 Buffer Pool +* 向数据库写入数据时,会写入缓存,缓存中修改的数据会**定期刷新**到磁盘,这一过程称为刷脏 + +Buffer Pool 中每个缓冲页都有对应的控制信息,包括表空间编号、页号、偏移量、链表信息等,控制信息存放在占用的内存称为控制块,控制块与缓冲页是一一对应的,但并不是物理上相连的,都在缓冲池中 + +MySQL 提供了缓冲页的快速查找方式:**哈希表**,使用表空间号和页号作为 Key,缓冲页控制块的地址作为 Value 创建一个哈希表,获取数据页时根据 Key 进行哈希寻址: + +* 如果不存在对应的缓存页,就从 free 链表中选一个空闲缓冲页,把磁盘中的对应页加载到该位置 +* 如果存在对应的缓存页,直接获取使用,提高查询数据的效率 + +当内存数据页跟磁盘数据页内容不一致时,称这个内存页为脏页;内存数据写入磁盘后,内存和磁盘上的数据页一致,称为干净页 + + + +*** + + + +#### 内存管理 + +##### Free 链表 + +MySQL 启动时完成对 Buffer Pool 的初始化,先向操作系统申请连续的内存空间,然后将内存划分为若干对控制块和缓冲页。为了区分空闲和已占用的数据页,将所有空闲缓冲页对应的**控制块作为一个节点**放入一个链表中,就是 Free 链表(**空闲链表**) + + + +基节点:是一块单独申请的内存空间(占 40 字节),并不在 Buffer Pool 的那一大片连续内存空间里 + +磁盘加载页的流程: + +* 从 Free 链表中取出一个空闲的缓冲页 +* 把缓冲页对应的控制块的信息填上(页所在的表空间、页号之类的信息) +* 把缓冲页对应的 Free 链表节点(控制块)从链表中移除,表示该缓冲页已经被使用 + + + +参考文章:https://blog.csdn.net/li1325169021/article/details/121124440 + + + +**** + + + +##### Flush 链表 + +Flush 链表是一个用来**存储脏页**的链表,对于已经修改过的缓冲脏页,第一次修改后加入到**链表头部**,以后每次修改都不会重新加入,只修改部分控制信息,出于性能考虑并不是直接更新到磁盘,而是在未来的某个时间进行刷脏 + + + +**后台有专门的线程每隔一段时间把脏页刷新到磁盘**: + +* 从 Flush 链表中刷新一部分页面到磁盘: + * **后台线程定时**从 Flush 链表刷脏,根据系统的繁忙程度来决定刷新速率,这种方式称为 BUF_FLUSH_LIST + * 线程刷脏的比较慢,导致用户线程加载一个新的数据页时发现没有空闲缓冲页,此时会尝试从 LRU 链表尾部寻找缓冲页直接释放,如果该页面是已经修改过的脏页就**同步刷新**到磁盘,速度较慢,这种方式称为 BUF_FLUSH_SINGLE_PAGE +* 从 LRU 链表的冷数据中刷新一部分页面到磁盘,即:BUF_FLUSH_LRU + * 后台线程会定时从 LRU 链表的尾部开始扫描一些页面,扫描的页面数量可以通过系统变量 `innodb_lru_scan_depth` 指定,如果在 LRU 链表中发现脏页,则把它们刷新到磁盘,这种方式称为 BUF_FLUSH_LRU + * 控制块里会存储该缓冲页是否被修改的信息,所以可以很容易的获取到某个缓冲页是否是脏页 + + + +参考文章:https://blog.csdn.net/li1325169021/article/details/121125765 + + + +*** + + + +##### LRU 链表 + +Buffer Pool 需要保证缓存的命中率,所以 MySQL 创建了一个 LRU 链表,当访问某个页时: + +* 如果该页不在 Buffer Pool 中,把该页从磁盘加载进来后会将该缓冲页对应的控制块作为节点放入 **LRU 链表的头部**,保证热点数据在链表头 +* 如果该页在 Buffer Pool 中,则直接把该页对应的控制块移动到 LRU 链表的头部,所以 LRU 链表尾部就是最近最少使用的缓冲页 + +MySQL 基于局部性原理提供了预读功能: + +* 线性预读:系统变量 `innodb_read_ahead_threshold`,如果顺序访问某个区(extent:16 KB 的页,连续 64 个形成一个区,一个区默认 1MB 大小)的页面数超过了该系统变量值,就会触发一次**异步读取**下一个区中全部的页面到 Buffer Pool 中 +* 随机预读:如果某个区 13 个连续的页面都被加载到 Buffer Pool,无论这些页面是否是顺序读取,都会触发一次**异步读取**本区所有的其他页面到 Buffer Pool 中 + +预读会造成加载太多用不到的数据页,造成那些使用频率很高的数据页被挤到 LRU 链表尾部,所以 InnoDB 将 LRU 链表分成两段,**冷热数据隔离**: + +* 一部分存储使用频率很高的数据页,这部分链表也叫热数据,young 区,靠近链表头部的区域 +* 一部分存储使用频率不高的冷数据,old 区,靠近链表尾部,默认占 37%,可以通过系统变量 `innodb_old_blocks_pct` 指定 + +当磁盘上的某数据页被初次加载到 Buffer Pool 中会被放入 old 区,淘汰时优先淘汰 old 区 + +* 当对 old 区的数据进行访问时,会在控制块记录下访问时间,等待后续的访问时间与第一次访问的时间是否在某个时间间隔内,通过系统变量 `innodb_old_blocks_time` 指定时间间隔,默认 1000ms,成立就**移动到 young 区的链表头部** +* `innodb_old_blocks_time` 为 0 时,每次访问一个页面都会放入 young 区的头部 + + + +*** + + + +#### 参数优化 + +InnoDB 用一块内存区做 IO 缓存池,该缓存池不仅用来缓存 InnoDB 的索引块,也用来缓存 InnoDB 的数据块,可以通过下面的指令查看 Buffer Pool 的状态信息: + +```mysql +SHOW ENGINE INNODB STATUS\G +``` + +`Buffer pool hit rate` 字段代表**内存命中率**,表示 Buffer Pool 对查询的加速效果 + +核心参数: + +* `innodb_buffer_pool_size`:该变量决定了 Innodb 存储引擎表数据和索引数据的最大缓存区大小,默认 128M + + ```mysql + SHOW VARIABLES LIKE 'innodb_buffer_pool_size'; + ``` + + 在保证操作系统及其他程序有足够内存可用的情况下,`innodb_buffer_pool_size` 的值越大,缓存命中率越高,建议设置成可用物理内存的 60%~80% + + ```sh + innodb_buffer_pool_size=512M + ``` + +* `innodb_log_buffer_size`:该值决定了 Innodb 日志缓冲区的大小,保存要写入磁盘上的日志文件数据 + + 对于可能产生大量更新记录的大事务,增加该值的大小,可以避免 Innodb 在事务提交前就执行不必要的日志写入磁盘操作,影响执行效率,通过配置文件修改: + + ```sh + innodb_log_buffer_size=10M + ``` + +在多线程下,访问 Buffer Pool 中的各种链表都需要加锁,所以将 Buffer Pool 拆成若干个小实例,**每个线程对应一个实例**,独立管理内存空间和各种链表(类似 ThreadLocal),多线程访问各自实例互不影响,提高了并发能力 + +MySQL 5.7.5 之前 `innodb_buffer_pool_size` 只支持在系统启动时修改,现在已经支持运行时修改 Buffer Pool 的大小,但是每次调整参数都会重新向操作系统申请一块连续的内存空间,**将旧的缓冲池的内容拷贝到新空间**非常耗时,所以 MySQL 开始以一个 chunk 为单位向操作系统申请内存,所以一个 Buffer Pool 实例可以由多个 chunk 组成 + +* 在系统启动时设置系统变量 `innodb_buffer_pool_instance` 可以指定 Buffer Pool 实例的个数,但是当 Buffer Pool 小于 1GB 时,设置多个实例时无效的 +* 指定系统变量 `innodb_buffer_pool_chunk_size` 来改变 chunk 的大小,只能在启动时修改,运行中不能修改,而且该变量并不包含缓冲页的控制块的内存大小 +* `innodb_buffer_pool_size` 必须是 `innodb_buffer_pool_chunk_size × innodb_buffer_pool_instance` 的倍数,默认值是 `128M × 16 = 2G`,Buffer Pool 必须是 2G 的整数倍,如果指定 5G,会自动调整成 6G +* 如果启动时 `chunk × instances` > `pool_size`,那么 chunk 的值会自动设置为 `pool_size ÷ instances` + + + +*** + + + +### 内存优化 + +#### Change + +InnoDB 管理的 Buffer Pool 中有一块内存叫 Change Buffer 用来对**增删改操作**提供缓存,可以通过参数来动态设置,设置为 50 时表示 Change Buffer 的大小最多占用 Buffer Pool 的 50% + +* 唯一索引的更新不能使用 Change Buffer,需要将数据页读入内存,判断没有冲突在写入 +* 普通索引可以使用 Change Buffer,**直接写入 Buffer 就结束**,不用校验唯一性 + +Change Buffer 并不是数据页,只是对操作的缓存,所以需要将 Change Buffer 中的操作应用到旧数据页,得到新的数据页(脏页)的过程称为 Merge + +* 触发时机:访问数据页时会触发 Merge、后台有定时线程进行 Merge、在数据库正常关闭(shutdown)的过程中也会触发 +* 工作流程:首先从磁盘读入数据页到内存(因为 Buffer Pool 中不一定存在对应的数据页),从 Change Buffer 中找到对应的操作应用到数据页,得到新的数据页即为脏页,然后写入 redo log,等待刷脏即可 + +说明:Change Buffer 中的记录,在事务提交时也会写入 redo log,所以是可以保证不丢失的 + +业务场景: + +* 对于**写多读少**的业务来说,页面在写完以后马上被访问到的概率比较小,此时 Change Buffer 的使用效果最好,常见的就是账单类、日志类的系统 + +* 一个业务的更新模式是写入后马上做查询,那么即使满足了条件,将更新先记录在 Change Buffer,但之后由于马上要访问这个数据页,会立即触发 Merge 过程,这样随机访问 IO 的次数不会减少,并且增加了 Change Buffer 的维护代价 + +补充:Change Buffer 的前身是 Insert Buffer,只能对 Insert 操作优化,后来增加了 Update/Delete 的支持,改为 Change Buffer + + + +*** + + + +#### Net + +Server 层针对优化**查询**的内存为 Net Buffer,内存的大小是由参数 `net_buffer_length`定义,默认 16k,实现流程: + +* 获取一行数据写入 Net Buffer,重复获取直到 Net Buffer 写满,调用网络接口发出去 +* 若发送成功就清空 Net Buffer,然后继续取下一行;若发送函数返回 EAGAIN 或 WSAEWOULDBLOCK,表示本地网络栈 `socket send buffer` 写满了,**进入等待**,直到网络栈重新可写再继续发送 + +MySQL 采用的是边读边发的逻辑,因此对于数据量很大的查询来说,不会在 Server 端保存完整的结果集,如果客户端读结果不及时,会堵住 MySQL 的查询过程,但是**不会把内存打爆导致 OOM** + + + +SHOW PROCESSLIST 获取线程信息后,处于 Sending to client 状态代表服务器端的网络栈写满,等待客户端接收数据 + +假设有一个业务的逻辑比较复杂,每读一行数据以后要处理很久的逻辑,就会导致客户端要过很久才会去取下一行数据,导致 MySQL 的阻塞,一直处于 Sending to client 的状态 + +解决方法:如果一个查询的返回结果很是很多,建议使用 mysql_store_result 这个接口,直接把查询结果保存到本地内存 + + + +参考文章:https://blog.csdn.net/qq_33589510/article/details/117673449 + + + +*** + + + +#### Read + +read_rnd_buffer 是 MySQL 的随机读缓冲区,当按任意顺序读取记录行时将分配一个随机读取缓冲区,进行排序查询时,MySQL 会首先扫描一遍该缓冲,以避免磁盘搜索,提高查询速度,大小是由 read_rnd_buffer_size 参数控制的 + +Multi-Range Read 优化,**将随机 IO 转化为顺序 IO** 以降低查询过程中 IO 开销,因为大多数的数据都是按照主键递增顺序插入得到,所以按照主键的递增顺序查询的话,对磁盘的读比较接近顺序读,能够提升读性能 + +二级索引为 a,聚簇索引为 id,优化回表流程: + +* 根据索引 a,定位到满足条件的记录,将 id 值放入 read_rnd_buffer 中 +* 将 read_rnd_buffer 中的 id 进行**递增排序** +* 排序后的 id 数组,依次回表到主键 id 索引中查记录,并作为结果返回 + +说明:如果步骤 1 中 read_rnd_buffer 放满了,就会先执行步骤 2 和 3,然后清空 read_rnd_buffer,之后继续找索引 a 的下个记录 + +使用 MRR 优化需要设进行设置: + +```mysql +SET optimizer_switch='mrr_cost_based=off' +``` + + + +*** + + + +#### Key + +MyISAM 存储引擎使用 key_buffer 缓存索引块,加速 MyISAM 索引的读写速度。对于 MyISAM 表的数据块没有特别的缓存机制,完全依赖于操作系统的 IO 缓存 + +* key_buffer_size:该变量决定 MyISAM 索引块缓存区的大小,直接影响到 MyISAM 表的存取效率 + + ```mysql + SHOW VARIABLES LIKE 'key_buffer_size'; -- 单位是字节 + ``` + + 在 MySQL 配置文件中设置该值,建议至少将1/4可用内存分配给 key_buffer_size: + + ```sh + vim /etc/mysql/my.cnf + key_buffer_size=1024M + ``` + +* read_buffer_size:如果需要经常顺序扫描 MyISAM 表,可以通过增大 read_buffer_size 的值来改善性能。但 read_buffer_size 是每个 Session 独占的,如果默认值设置太大,并发环境就会造成内存浪费 + +* read_rnd_buffer_size:对于需要做排序的 MyISAM 表的查询,如带有 ORDER BY 子句的语句,适当增加该的值,可以改善此类的 SQL 的性能,但是 read_rnd_buffer_size 是每个 Session 独占的,如果默认值设置太大,就会造成内存浪费 + + + + + +*** + + + + + +### 存储优化 + +#### 数据存储 + +系统表空间是用来放系统信息的,比如数据字典什么的,对应的磁盘文件是 ibdata,数据表空间是一个个的表数据文件,对应的磁盘文件就是表名.ibd + +表数据既可以存在共享表空间里,也可以是单独的文件,这个行为是由参数 innodb_file_per_table 控制的: + +* OFF:表示表的数据放在系统共享表空间,也就是跟数据字典放在一起 +* ON :表示每个 InnoDB 表数据存储在一个以 .ibd 为后缀的文件中(默认) + +一个表单独存储为一个文件更容易管理,在不需要这个表时通过 drop table 命令,系统就会直接删除这个文件;如果是放在共享表空间中,即使表删掉了,空间也是不会回收的 + + + + + +*** + + + +#### 数据删除 + +MySQL 的数据删除就是移除掉某个记录后,该位置就被标记为**可复用**,如果有符合范围条件的数据可以插入到这里。符合范围条件的意思是假设删除记录 R4,之后要再插入一个 ID 在 300 和 600 之间的记录时,就会复用这个位置 + + + +InnoDB 的数据是按页存储的如果删掉了一个数据页上的所有记录,整个数据页就可以被复用了,如果相邻的两个数据页利用率都很小,系统就会把这两个页上的数据合到其中一个页上,另外一个数据页就被标记为可复用 + +删除命令其实只是把记录的位置,或者**数据页标记为了可复用,但磁盘文件的大小是不会变的**,这些可以复用还没有被使用的空间,看起来就像是空洞,造成数据库的稀疏,因此需要进行紧凑处理 + + + +*** + + + +#### 重建数据 + +重建表就是按照主键 ID 递增的顺序,把数据一行一行地从旧表中读出来再插入到新表中,让数据更加紧凑。重建表时 MySQL 会自动完成转存数据、交换表名、删除旧表的操作,线上操作会阻塞大量的线程增删改查的操作 + +重建命令: + +```sql +ALTER TABLE A ENGINE=InnoDB +``` + +工作流程:新建临时表 tmp_table B(在 Server 层创建的),把表 A 中的数据导入到表 B 中,操作完成后用表 B 替换表 A,完成重建 + +重建表的步骤需要 DDL 不是 Online 的,因为在导入数据的过程有新的数据要写入到表 A 的话,就会造成数据丢失 + +MySQL 5.6 版本开始引入的 **Online DDL**,重建表的命令默认执行此步骤: + +* 建立一个临时文件 tmp_file(InnoDB 创建),扫描表 A 主键的所有数据页 +* 用数据页中表 A 的记录生成 B+ 树,存储到临时文件中 +* 生成临时文件的过程中,将所有对 A 的操作记录在一个日志文件(row log)中,对应的是图中 state2 的状态 +* 临时文件生成后,将日志文件中的操作应用到临时文件,得到一个逻辑数据上与表 A 相同的数据文件,对应的就是图中 state3 +* 用临时文件替换表 A 的数据文件 + + + +Online DDL 操作会先获取 MDL 写锁,再退化成 MDL 读锁。但 MDL 写锁持有时间比较短,所以可以称为 Online; 而 MDL 读锁,不阻止数据增删查改,但会阻止其它线程修改表结构(可以对比 `ANALYZE TABLE t` 命令) + +问题:重建表可以收缩表空间,但是执行指令后整体占用空间增大 + +原因:在重建表后 InnoDB 不会把整张表占满,每个页留了 1/16 给后续的更新使用。表在未整理之前页已经占用 15/16 以上,收缩之后需要保持数据占用空间在 15/16,所以文件占用空间更大才能保持 + +注意:临时文件也要占用空间,如果空间不足会重建失败 + + + +**** + + + +#### 原地置换 + +DDL 中的临时表 tmp_table 是在 Server 层创建的,Online DDL 中的临时文件 tmp_file 是 InnoDB 在内部创建出来的,整个 DDL 过程都在 InnoDB 内部完成,对于 Server 层来说,没有把数据挪动到临时表,是一个原地操作,这就是 inplace + +两者的关系: + +* DDL 过程如果是 Online 的,就一定是 inplace 的 +* inplace 的 DDL,有可能不是 Online 的,截止到 MySQL 8.0,全文索引(FULLTEXT)和空间索引(SPATIAL)属于这种情况 + + + + + +*** + + + +### 并发优化 + +MySQL Server 是多线程结构,包括后台线程和客户服务线程。多线程可以有效利用服务器资源,提高数据库的并发性能。在 MySQL 中,控制并发连接和线程的主要参数: + +* max_connections:控制允许连接到 MySQL 数据库的最大连接数,默认值是 151 + + 如果状态变量 connection_errors_max_connections 不为零,并且一直增长,则说明不断有连接请求因数据库连接数已达到允许最大值而失败,这时可以考虑增大 max_connections 的值 + + MySQL 最大可支持的连接数取决于很多因素,包括操作系统平台的线程库的质量、内存大小、每个连接的负荷、CPU的处理速度、期望的响应时间等。在 Linux 平台下,性能好的服务器,可以支持 500-1000 个连接,需要根据服务器性能进行评估设定 + +* innodb_thread_concurrency:并发线程数,代表系统内同时运行的线程数量(已经被移除) + +* back_log:控制 MySQL 监听 TCP 端口时的积压请求栈的大小 + + 如果 Mysql 的连接数达到 max_connections 时,新来的请求将会被存在堆栈中,以等待某一连接释放资源,该堆栈的数量即 back_log。如果等待连接的数量超过 back_log,将不被授予连接资源直接报错 + + 5.6.6 版本之前默认值为 50,之后的版本默认为 `50 + (max_connections/5)`,但最大不超过900,如果需要数据库在较短的时间内处理大量连接请求, 可以考虑适当增大 back_log 的值 + +* table_open_cache:控制所有 SQL 语句执行线程可打开表缓存的数量 + + 在执行 SQL 语句时,每个执行线程至少要打开1个表缓存,该参数的值应该根据设置的最大连接数以及每个连接执行关联查询中涉及的表的最大数量来设定:`max_connections * N` + +* thread_cache_size:可控制 MySQL 缓存客户服务线程的数量 + + 为了加快连接数据库的速度,MySQL 会缓存一定数量的客户服务线程以备重用,池化思想 + +* innodb_lock_wait_timeout:设置 InnoDB 事务等待行锁的时间,默认值是 50ms + + 对于需要快速反馈的业务系统,可以将行锁的等待时间调小,以避免事务被长时间挂起; 对于后台运行的批量处理程序来说,可以将行锁的等待时间调大,以避免发生大的回滚操作 + + + + + +*** + + + + + +## 事务机制 + +### 基本介绍 + +事务(Transaction)是访问和更新数据库的程序执行单元;事务中可能包含一个或多个 SQL 语句,这些语句要么都执行,要么都不执行,作为一个关系型数据库,MySQL 支持事务。 + +单元中的每条 SQL 语句都相互依赖,形成一个整体 + +* 如果某条 SQL 语句执行失败或者出现错误,那么整个单元就会回滚,撤回到事务最初的状态 + +* 如果单元中所有的 SQL 语句都执行成功,则事务就顺利执行 + +事务的四大特征:ACID + +- 原子性 (atomicity) +- 一致性 (consistency) +- 隔离性 (isolaction) +- 持久性 (durability) + +事务的几种状态: + +* 活动的(active):事务对应的数据库操作正在执行中 +* 部分提交的(partially committed):事务的最后一个操作执行完,但是内存还没刷新至磁盘 +* 失败的(failed):当事务处于活动状态或部分提交状态时,如果数据库遇到了错误或刷脏失败,或者用户主动停止当前的事务 +* 中止的(aborted):失败状态的事务回滚完成后的状态 +* 提交的(committed):当处于部分提交状态的事务刷脏成功,就处于提交状态 + + + + + +*** + + + +### 事务管理 + +#### 基本操作 + +事务管理的三个步骤 + +1. 开启事务:记录回滚点,并通知服务器,将要执行一组操作,要么同时成功、要么同时失败 + +2. 执行 SQL 语句:执行具体的一条或多条 SQL 语句 + +3. 结束事务(提交|回滚) + + - 提交:没出现问题,数据进行更新 + - 回滚:出现问题,数据恢复到开启事务时的状态 + + +事务操作: + +* 显式开启事务 + + ```mysql + START TRANSACTION [READ ONLY|READ WRITE|WITH CONSISTENT SNAPSHOT]; #可以跟一个或多个状态,最后的是一致性读 + BEGIN [WORK]; + ``` + + 说明:不填状态默认是读写事务 + +* 回滚事务,用来手动中止事务 + + ```mysql + ROLLBACK; + ``` + +* 提交事务,显示执行是手动提交,MySQL 默认为自动提交 + + ```mysql + COMMIT; + ``` + +* 保存点:在事务的执行过程中设置的还原点,调用 ROLLBACK 时可以指定回滚到哪个点 + + ```mysql + SAVEPOINT point_name; #设置保存点 + RELEASE point_name #删除保存点 + ROLLBACK [WORK] TO [SAVEPOINT] point_name #回滚至某个保存点,不填默认回滚到事务执行之前的状态 + ``` + +* 操作演示 + + ```mysql + -- 开启事务 + START TRANSACTION; + + -- 张三给李四转账500元 + -- 1.张三账户-500 + UPDATE account SET money=money-500 WHERE NAME='张三'; + -- 2.李四账户+500 + UPDATE account SET money=money+500 WHERE NAME='李四'; + + -- 回滚事务(出现问题) + ROLLBACK; + + -- 提交事务(没出现问题) + COMMIT; + ``` + + + +*** + + + +#### 提交方式 + +提交方式的相关语法: + +- 查看事务提交方式 + + ```mysql + SELECT @@AUTOCOMMIT; -- 会话,1 代表自动提交 0 代表手动提交 + SELECT @@GLOBAL.AUTOCOMMIT; -- 系统 + ``` + +- 修改事务提交方式 + + ```mysql + SET @@AUTOCOMMIT=数字; -- 系统 + SET AUTOCOMMIT=数字; -- 会话 + ``` + +- **系统变量的操作**: + + ```sql + SET [GLOBAL|SESSION] 变量名 = 值; -- 默认是会话 + SET @@[(GLOBAL|SESSION).]变量名 = 值; -- 默认是系统 + ``` + + ```sql + SHOW [GLOBAL|SESSION] VARIABLES [LIKE '变量%']; -- 默认查看会话内系统变量值 + ``` + +工作原理: + +* 自动提交:如果没有 START TRANSACTION 显式地开始一个事务,那么**每条 SQL 语句都会被当做一个事务执行提交操作**;显式开启事务后,会在本次事务结束(提交或回滚)前暂时关闭自动提交 +* 手动提交:不需要显式的开启事务,所有的 SQL 语句都在一个事务中,直到执行了提交或回滚,然后进入下一个事务 +* 隐式提交:存在一些特殊的命令,在事务中执行了这些命令会马上**强制执行 COMMIT 提交事务** + * **DDL 语句** (CREATE/DROP/ALTER)、LOCK TABLES 语句、LOAD DATA 导入数据语句、主从复制语句等 + * 当一个事务还没提交或回滚,显式的开启一个事务会隐式的提交上一个事务 + + + +**** + + + +#### 事务 ID + +事务在执行过程中对某个表执行了**增删改操作或者创建表**,就会为当前事务分配一个独一无二的事务 ID(对临时表并不会分配 ID),如果当前事务没有被分配 ID,默认是 0 + +说明:只读事务不能对普通的表进行增删改操作,但是可以对临时表增删改,读写事务可以对数据表执行增删改查操作 + +事务 ID 本质上就是一个数字,服务器在内存中维护一个全局变量: + +* 每当需要为某个事务分配 ID,就会把全局变量的值赋值给事务 ID,然后变量自增 1 +* 每当变量值为 256 的倍数时,就将该变量的值刷新到系统表空间的 Max Trx ID 属性中,该属性占 8 字节 +* 系统再次启动后,会读取表空间的 Max Trx ID 属性到内存,加上 256 后赋值给全局变量,因为关机时的事务 ID 可能并不是 256 的倍数,会比 Max Trx ID 大,所以需要加上 256 保持事务 ID 是一个**递增的数字** + +**聚簇索引**的行记录除了完整的数据,还会自动添加 trx_id、roll_pointer 隐藏列,如果表中没有主键并且没有非空唯一索引,也会添加一个 row_id 的隐藏列作为聚簇索引 + + + + +*** + + + +### 隔离级别 + +#### 四种级别 + +事务的隔离级别:多个客户端操作时,各个客户端的事务之间应该是隔离的,**不同的事务之间不该互相影响**,而如果多个事务操作同一批数据时,则需要设置不同的隔离级别,否则就会产生问题。 + +隔离级别分类: + +| 隔离级别 | 名称 | 会引发的问题 | 数据库默认隔离级别 | +| ---------------- | -------- | ---------------------- | ------------------- | +| Read Uncommitted | 读未提交 | 脏读、不可重复读、幻读 | | +| Read Committed | 读已提交 | 不可重复读、幻读 | Oracle / SQL Server | +| Repeatable Read | 可重复读 | 幻读 | MySQL | +| Serializable | 可串行化 | 无 | | + +一般来说,隔离级别越低,系统开销越低,可支持的并发越高,但隔离性也越差 + +* 脏写 (Dirty Write):当两个或多个事务选择同一行,最初的事务修改的值被后面事务修改的值覆盖,所有的隔离级别都可以避免脏写(又叫丢失更新),因为有行锁 + +* 脏读 (Dirty Reads):在一个事务处理过程中读取了另一个**未提交**的事务中修改过的数据 + +* 不可重复读 (Non-Repeatable Reads):在一个事务处理过程中读取了另一个事务中修改并**已提交**的数据 + + > 可重复读的意思是不管读几次,结果都一样,可以重复的读,可以理解为快照读,要读的数据集不会发生变化 + +* 幻读 (Phantom Reads):在事务中按某个条件先后两次查询数据库,后一次查询查到了前一次查询没有查到的行,**数据条目**发生了变化。比如查询某数据不存在,准备插入此记录,但执行插入时发现此记录已存在,无法插入 + +隔离级别操作语法: + +* 查询数据库隔离级别 + + ```mysql + SELECT @@TX_ISOLATION; -- 会话 + SELECT @@GLOBAL.TX_ISOLATION; -- 系统 + ``` + +* 修改数据库隔离级别 + + ```mysql + SET GLOBAL TRANSACTION ISOLATION LEVEL 级别字符串; + ``` + + + +*** + + + +#### 加锁分析 + +InnoDB 存储引擎支持事务,所以加锁分析是基于该存储引擎 + +* Read Uncommitted 级别,任何操作都不会加锁 + +* Read Committed 级别,增删改操作会加写锁(行锁),读操作不加锁 + + 在 Server 层过滤条件时发现不满足的记录会调用 unlock_row 方法释放该记录的行锁,保证最后只有满足条件的记录加锁,但是扫表过程中每条记录的**加锁操作不能省略**。所以对数据量很大的表做批量修改时,如果无法使用相应的索引(全表扫描),在 Server 过滤数据时就会特别慢,出现虽然没有修改某些行的数据,但是还是被锁住了的现象(锁表),这种情况同样适用于 RR + +* Repeatable Read 级别,增删改操作会加写锁,读操作不加锁。因为读写锁不兼容,**加了读锁后其他事务就无法修改数据**,影响了并发性能,为了保证隔离性和并发性,MySQL 通过 MVCC 解决了读写冲突。RR 级别下的锁有很多种,锁机制章节详解 + +* Serializable 级别,读加共享锁,写加排他锁,读写互斥,使用的悲观锁的理论,实现简单,数据更加安全,但是并发能力非常差 + + * 串行化:让所有事务按顺序单独执行,写操作会加写锁,读操作会加读锁 + * 可串行化:让所有操作相同数据的事务顺序执行,通过加锁实现 + + + +参考文章:https://tech.meituan.com/2014/08/20/innodb-lock.html + + + +*** + + + +### 原子特性 + +#### 实现方式 + +原子性是指事务是一个不可分割的工作单位,事务的操作如果成功就必须要完全应用到数据库,失败则不能对数据库有任何影响。比如事务中一个 SQL 语句执行失败,则已执行的语句也必须回滚,数据库退回到事务前的状态 + +InnoDB 存储引擎提供了两种事务日志:redo log(重做日志)和 undo log(回滚日志) + +* redo log 用于保证事务持久性 +* undo log 用于保证事务原子性和隔离性 + +undo log 属于**逻辑日志**,根据每行操作进行记录,记录了 SQL 执行相关的信息,用来回滚行记录到某个版本 + +当事务对数据库进行修改时,InnoDB 会先记录对应的 undo log,如果事务执行失败或调用了 rollback 导致事务回滚,InnoDB 会根据 undo log 的内容**做与之前相反的操作**: + +* 对于每个 insert,回滚时会执行 delete + +* 对于每个 delete,回滚时会执行 insert + +* 对于每个 update,回滚时会执行一个相反的 update,把数据修改回去 + + + +参考文章:https://www.cnblogs.com/kismetv/p/10331633.html + + + +*** + + + +#### DML 解析 + +##### INSERT + +乐观插入:当前数据页的剩余空间充足,直接将数据进行插入 + +悲观插入:当前数据页的剩余空间不足,需要进行页分裂,申请一个新的页面来插入数据,会造成更多的 redo log,undo log 影响不大 + +当向某个表插入一条记录,实际上需要向聚簇索引和所有二级索引都插入一条记录,但是 undo log **只针对聚簇索引记录**,在回滚时会根据聚簇索引去所有的二级索引进行回滚操作 + +roll_pointer 是一个指针,**指向记录对应的 undo log 日志**,一条记录就是一个数据行,行格式中的 roll_pointer 就指向 undo log + + + +*** + + + +##### DELETE + +插入到页面中的记录会根据 next_record 属性组成一个单向链表,这个链表称为正常链表,被删除的记录也会通过 next_record 组成一个垃圾链表,该链表中所占用的存储空间可以被重新利用,并不会直接清除数据 + +在页面 Page Header 中,PAGE_FREE 属性指向垃圾链表的头节点,删除的工作过程: + +* 将要删除的记录的 delete_flag 位置为 1,其他不做修改,这个过程叫 **delete mark** + +* 在事务提交前,delete_flag = 1 的记录一直都会处于中间状态 + +* 事务提交后,有专门的线程将 delete_flag = 1 的记录从正常链表移除并加入垃圾链表,这个过程叫 **purge** + + purge 线程在执行删除操作时会创建一个 ReadView,根据事务的可见性移除数据(隔离特性部分详解) + +当有新插入的记录时,首先判断 PAGE_FREE 指向的头节点是否足够容纳新纪录: + +* 如果可以容纳新纪录,就会直接重用已删除的记录的存储空间,然后让 PAGE_FREE 指向垃圾链表的下一个节点 +* 如果不能容纳新纪录,就直接向页面申请新的空间存储,并不会遍历垃圾链表 + +重用已删除的记录空间,可能会造成空间碎片,当数据页容纳不了一条记录时,会判断将碎片空间加起来是否可以容纳,判断为真就会重新组织页内的记录: + +* 开辟一个临时页面,将页内记录一次插入到临时页面,此时临时页面时没有碎片的 +* 把临时页面的内容复制到本页,这样就解放出了内存碎片,但是会耗费很大的性能资源 + + + +**** + + + +##### UPDATE + +执行 UPDATE 语句,对于更新主键和不更新主键有两种不同的处理方式 + +不更新主键的情况: + +* 就地更新(in-place update),如果更新后的列和更新前的列占用的存储空间一样大,就可以直接在原记录上修改 + +* 先删除旧纪录,再插入新纪录,这里的删除不是 delete mark,而是直接将记录加入垃圾链表,并且修改页面的相应的控制信息,执行删除的线程不是 purge,是执行更新的用户线程,插入新记录时可能造成页空间不足,从而导致页分裂 + + +更新主键的情况: + +* 将旧纪录进行 delete mark,在更新语句提交后由 purge 线程移入垃圾链表 +* 根据更新的各列的值创建一条新纪录,插入到聚簇索引中 + +在对一条记录修改前会**将记录的隐藏列 trx_id 和 roll_pointer 的旧值记录到当前 undo log 对应的属性中**,这样当前记录的 roll_pointer 指向当前 undo log 记录,当前 undo log 记录的 roll_pointer 指向旧的 undo log 记录,**形成一个版本链** + +UPDATE、DELETE 操作产生的 undo 日志会用于其他事务的 MVCC 操作,所以不能立即删除,INSERT 可以删除的原因是 MVCC 是对现有数据的快照 + + + +*** + + + +#### 回滚日志 + +undo log 是采用段的方式来记录,Rollback Segement 称为回滚段,本质上就是一个类型是 Rollback Segement Header 的页面 + +每个回滚段中有 1024 个 undo slot,每个 slot 存放 undo 链表页面的头节点页号,每个链表对应一个叫 undo log segment 的段 + +* 在以前老版本,只支持 1 个 Rollback Segement,只能记录 1024 个 undo log segment +* MySQL5.5 开始支持 128 个 Rollback Segement,支持 128*1024 个 undo 操作 + +工作流程: + +* 事务执行前需要到系统表空间第 5 号页面中分配一个回滚段(页),获取一个 Rollback Segement Header 页面的地址 +* 回滚段页面有 1024 个 undo slot,首先去回滚段的两个 cached 链表获取缓存的 slot,缓存中没有就在回滚段页面中找一个可用的 undo slot 分配给当前事务 +* 如果是缓存中获取的 slot,则该 slot 对应的 undo log segment 已经分配了,需要重新分配,然后从 undo log segment 中申请一个页面作为日志链表的头节点,并填入对应的 slot 中 +* 每个事务 undo 日志在记录的时候**占用两个 undo 页面的组成链表**,分别为 insert undo 链表和 update undo 链表,链表的头节点页面为 first undo page 会包含一些管理信息,其他页面为 normal undo page + + 说明:事务执行过程的临时表也需要两个 undo 链表,不和普通表共用,这些链表并不是事务开始就分配,而是按需分配 + + + + + +*** + + + +### 隔离特性 + +#### 实现方式 + +隔离性是指,事务内部的操作与其他事务是隔离的,多个并发事务之间要相互隔离,不能互相干扰 + +* 严格的隔离性,对应了事务隔离级别中的 serializable,实际应用中对性能考虑很少使用可串行化 + +* 与原子性、持久性侧重于研究事务本身不同,隔离性研究的是**不同事务**之间的相互影响 + +隔离性让并发情形下的事务之间互不干扰: + +- 一个事务的写操作对另一个事务的写操作(写写):锁机制保证隔离性 +- 一个事务的写操作对另一个事务的读操作(读写):MVCC 保证隔离性 + +锁机制:事务在修改数据之前,需要先获得相应的锁,获得锁之后,事务便可以修改数据;该事务操作期间,这部分数据是锁定的,其他事务如果需要修改数据,需要等待当前事务提交或回滚后释放锁(详解见锁机制) + + + +*** + + + +#### 并发控制 + +MVCC 全称 Multi-Version Concurrency Control,即多版本并发控制,用来**解决读写冲突的无锁并发控制**,可以在发生读写请求冲突时不用加锁解决,这个读是指的快照读(也叫一致性读或一致性无锁读),而不是当前读: + +* 快照读:实现基于 MVCC,因为是多版本并发,所以快照读读到的数据不一定是当前最新的数据,有可能是历史版本的数据 +* 当前读:又叫加锁读,读取数据库记录是当前**最新的版本**(产生幻读、不可重复读),可以对读取的数据进行加锁,防止其他事务修改数据,是悲观锁的一种操作,读写操作加共享锁或者排他锁和串行化事务的隔离级别都是当前读 + +数据库并发场景: + +* 读-读:不存在任何问题,也不需要并发控制 + +* 读-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读 + +* 写-写:有线程安全问题,可能会存在脏写(丢失更新)问题 + +MVCC 的优点: + +* 在并发读写数据库时,做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了并发读写的性能 +* 可以解决脏读,不可重复读等事务隔离问题(加锁也能解决),但不能解决更新丢失问题(写锁会解决) + +提高读写和写写的并发性能: + +* MVCC + 悲观锁:MVCC 解决读写冲突,悲观锁解决写写冲突 +* MVCC + 乐观锁:MVCC 解决读写冲突,乐观锁解决写写冲突 + + + +参考文章:https://www.jianshu.com/p/8845ddca3b23 + + + +*** + + + +#### 实现原理 + +##### 隐藏字段 + +实现原理主要是隐藏字段,undo日志,Read View 来实现的 + +InnoDB 存储引擎,数据库中的**聚簇索引**每行数据,除了自定义的字段,还有数据库隐式定义的字段: + +* DB_TRX_ID:最近修改事务 ID,记录创建该数据或最后一次修改该数据的事务 ID +* DB_ROLL_PTR:回滚指针,**指向记录对应的 undo log 日志**,undo log 中又指向上一个旧版本的 undo log +* DB_ROW_ID:隐含的自增 ID(**隐藏主键**),如果数据表没有主键,InnoDB 会自动以 DB_ROW_ID 作为聚簇索引 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-MVCC版本链隐藏字段.png) + + + + + +*** + + + +##### 版本链 + +undo log 是逻辑日志,记录的是每个事务对数据执行的操作,而不是记录的全部数据,要**根据 undo log 逆推出以往事务的数据** + +undo log 的作用: + +* 保证事务进行 rollback 时的原子性和一致性,当事务进行回滚的时候可以用 undo log 的数据进行恢复 +* 用于 MVCC 快照读,通过读取 undo log 的历史版本数据可以实现不同事务版本号都拥有自己独立的快照数据 + +undo log 主要分为两种: + +* insert undo log:事务在 insert 新记录时产生的 undo log,只在事务回滚时需要,并且在事务提交后可以被立即丢弃 + +* update undo log:事务在进行 update 或 delete 时产生的 undo log,在事务回滚时需要,在快照读时也需要。不能随意删除,只有在当前读或事务回滚不涉及该日志时,对应的日志才会被 purge 线程统一清除 + +每次对数据库记录进行改动,都会产生的新版本的 undo log,随着更新次数的增多,所有的版本都会被 roll_pointer 属性连接成一个链表,把这个链表称之为**版本链**,版本链的头节点就是当前的最新的 undo log,链尾就是最早的旧 undo log + +说明:因为 DELETE 删除记录,都是移动到垃圾链表中,不是真正的删除,所以才可以通过版本链访问原始数据 + + + +注意:undo 是逻辑日志,这里只是直观的展示出来 + +工作流程: + +* 有个事务插入 persion 表一条新记录,name 为 Jerry,age 为 24 +* 事务 1 修改该行数据时,数据库会先对该行加排他锁,然后先记录 undo log,然后修改该行 name 为 Tom,并且修改隐藏字段的事务 ID 为当前事务 1 的 ID(默认为 1 之后递增),回滚指针指向拷贝到 undo log 的副本记录,事务提交后,释放锁 +* 以此类推 + + + +*** + + + +##### 读视图 + +Read View 是事务进行读数据操作时产生的读视图,该事务执行快照读的那一刻会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的 ID,用来做可见性判断,根据视图判断当前事务能够看到哪个版本的数据 + +注意:这里的快照并不是把所有的数据拷贝一份副本,而是由 undo log 记录的逻辑日志,根据库中的数据进行计算出历史数据 + +工作流程:将版本链的头节点的事务 ID(最新数据事务 ID,大概率不是当前线程)DB_TRX_ID 取出来,与系统当前活跃事务的 ID 对比进行可见性分析,不可见就通过 DB_ROLL_PTR 回滚指针去取出 undo log 中的下一个 DB_TRX_ID 比较,直到找到最近的满足可见性的 DB_TRX_ID,该事务 ID 所在的旧记录就是当前事务能看见的最新的记录 + +Read View 几个属性: + +- m_ids:生成 Read View 时当前系统中活跃的事务 id 列表(未提交的事务集合,当前事务也在其中) +- min_trx_id:生成 Read View 时当前系统中活跃的最小的事务 id,也就是 m_ids 中的最小值(已提交的事务集合) +- max_trx_id:生成 Read View 时当前系统应该分配给下一个事务的 id 值,m_ids 中的最大值加 1(未开始事务) +- creator_trx_id:生成该 Read View 的事务的事务 id,就是判断该 id 的事务能读到什么数据 + +creator 创建一个 Read View,进行可见性算法分析:(解决了读未提交) + +* db_trx_id == creator_trx_id:表示这个数据就是当前事务自己生成的,自己生成的数据自己肯定能看见,所以此数据对 creator 是可见的 +* db_trx_id < min_trx_id:该版本对应的事务 ID 小于 Read view 中的最小活跃事务 ID,则这个事务在当前事务之前就已经被提交了,对 creator 可见(因为比已提交的最大事务 ID 小的并不一定已经提交,所以应该判断是否在活跃事务列表) + +* db_trx_id >= max_trx_id:该版本对应的事务 ID 大于 Read view 中当前系统的最大事务 ID,则说明该数据是在当前 Read view 创建之后才产生的,对 creator 不可见 +* min_trx_id<= db_trx_id < max_trx_id:判断 db_trx_id 是否在活跃事务列表 m_ids 中 + * 在列表中,说明该版本对应的事务正在运行,数据不能显示(**不能读到未提交的数据**) + * 不在列表中,说明该版本对应的事务已经被提交,数据可以显示(**可以读到已经提交的数据**) + + + +*** + + + +##### 工作流程 + +表 user 数据 + +```sh +id name age +1 张三 18 +``` + +Transaction 20: + +```mysql +START TRANSACTION; -- 开启事务 +UPDATE user SET name = '李四' WHERE id = 1; +UPDATE user SET name = '王五' WHERE id = 1; +``` + +Transaction 60: + +```mysql +START TRANSACTION; -- 开启事务 +-- 操作表的其他数据 +``` + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-MVCC工作流程1.png) + +ID 为 0 的事务创建 Read View: + +* m_ids:20、60 +* min_trx_id:20 +* max_trx_id:61 +* creator_trx_id:0 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-MVCC工作流程2.png) + +只有红框部分才复合条件,所以只有张三对应的版本的数据可以被看到 + + + +参考视频:https://www.bilibili.com/video/BV1t5411u7Fg + + + +*** + + + +##### 二级索引 + +只有在聚簇索引中才有 trx_id 和 roll_pointer 的隐藏列,对于二级索引判断可见性的方式: + +* 二级索引页面的 Page Header 中有一个 PAGE_MAX_TRX_ID 属性,代表修改当前页面的最大的事务 ID,SELECT 语句访问某个二级索引时会判断 ReadView 的 min_trx_id 是否大于该属性,大于说明该页面的所有属性对 ReadView 可见 +* 如果属性判断不可见,就需要利用二级索引获取主键值进行**回表操作**,得到聚簇索引后按照聚簇索引的可见性判断的方法操作 + + + +*** + + + +#### RC RR + +Read View 用于支持 RC(Read Committed,读已提交)和 RR(Repeatable Read,可重复读)隔离级别的实现,所以 **SELECT 在 RC 和 RR 隔离级别使用 MVCC 读取记录** + +RR、RC 生成时机: + +- RC 隔离级别下,每次读取数据前都会生成最新的 Read View(当前读) +- RR 隔离级别下,在第一次数据读取时才会创建 Read View(快照读) + +RC、RR 级别下的 InnoDB 快照读区别 + +- RC 级别下,事务中每次快照读都会新生成一个 Read View,这就是在 RC 级别下的事务中可以看到别的事务提交的更新的原因 + +- RR 级别下,某个事务的对某条记录的**第一次快照读**会创建一个 Read View, 将当前系统活跃的其他事务记录起来,此后在调用快照读的时候,使用的是同一个 Read View,所以一个事务的查询结果每次都是相同的 + + RR 级别下,通过 `START TRANSACTION WITH CONSISTENT SNAPSHOT` 开启事务,会在执行该语句后立刻生成一个 Read View,不是在执行第一条 SELECT 语句时生成(所以说 `START TRANSACTION` 并不是事务的起点,执行第一条语句才算起点) + +解决幻读问题: + +- 快照读:通过 MVCC 来进行控制的,在可重复读隔离级别下,普通查询是快照读,是不会看到别的事务插入的数据的,但是**并不能完全避免幻读** + + 场景:RR 级别,T1 事务开启,创建 Read View,此时 T2 去 INSERT 新的一行然后提交,然后 T1 去 UPDATE 该行会发现更新成功,并且把这条新记录的 trx_id 变为当前的事务 id,所以对当前事务就是可见的。因为 **Read View 并不能阻止事务去更新数据,更新数据都是先读后写并且是当前读**,读取到的是最新版本的数据 + +- 当前读:通过 next-key 锁(行锁 + 间隙锁)来解决问题 + + + + + +*** + + + +### 持久特性 + +#### 实现方式 + +持久性是指一个事务一旦被提交了,那么对数据库中数据的改变就是永久性的,接下来的其他操作或故障不应该对其有任何影响。 + +Buffer Pool 的使用提高了读写数据的效率,但是如果 MySQL 宕机,此时 Buffer Pool 中修改的数据还没有刷新到磁盘,就会导致数据的丢失,事务的持久性无法保证,所以引入了 redo log 日志: + +* redo log **记录数据页的物理修改**,而不是某一行或某几行的修改,用来恢复提交后的数据页,只能**恢复到最后一次提交**的位置 +* redo log 采用的是 WAL(Write-ahead logging,**预写式日志**),所有修改要先写入日志,再更新到磁盘,保证了数据不会因 MySQL 宕机而丢失,从而满足了持久性要求 +* 简单的 redo log 是纯粹的物理日志,复杂的 redo log 会存在物理日志和逻辑日志 + +工作过程:MySQL 发生了宕机,InnoDB 会判断一个数据页在崩溃恢复时丢失了更新,就会将它读到内存,然后根据 redo log 内容更新内存,更新完成后,内存页变成脏页,然后进行刷脏 + +缓冲池的**刷脏策略**: + +* redo log 文件是固定大小的,如果写满了就要擦除以前的记录,在擦除之前需要把对应的更新持久化到磁盘中 +* Buffer Pool 内存不足,需要淘汰部分数据页(LRU 链表尾部),如果淘汰的是脏页,就要先将脏页写到磁盘(要避免大事务) +* 系统空闲时,后台线程会自动进行刷脏(Flush 链表部分已经详解) +* MySQL 正常关闭时,会把内存的脏页都刷新到磁盘上 + + + +**** + + + +#### 重做日志 + +##### 日志缓冲 + +服务器启动时会向操作系统申请一片连续内存空间作为 redo log buffer(重做日志缓冲区),可以通过 `innodb_log_buffer_size` 系统变量指定 redo log buffer 的大小,默认是 16MB + +log buffer 被划分为若干 redo log block(块,类似数据页的概念),每个默认大小 512 字节,每个 block 由 12 字节的 log block head、496 字节的 log block body、4 字节的 log block trailer 组成 + +* 当数据修改时,先修改 Change Buffer 中的数据,然后在 redo log buffer 记录这次操作,写入 log buffer 的过程是**顺序写入**的(先写入前面的 block,写满后继续写下一个) +* log buffer 中有一个指针 buf_free,来标识该位置之前都是填满的 block,该位置之后都是空闲区域 + +MySQL 规定对底层页面的一次原子访问称为一个 Mini-Transaction(MTR),比如在 B+ 树上插入一条数据就算一个 MTR + +* 一个事务包含若干个 MTR,一个 MTR 对应一组若干条 redo log,一组 redo log 是不可分割的,在进行数据恢复时也把一组 redo log 当作一个不可分割的整体处理 + +* 不是每生成一条 redo 日志就将其插入到 log buffer 中,而是一个 MTR 结束后**将一组 redo 日志写入** + +InnoDB 的 redo log 是**固定大小**的,redo 日志在磁盘中以文件组的形式存储,同一组中的每个文件大小一样格式一样 + +* `innodb_log_group_home_dir` 代表磁盘存储 redo log 的文件目录,默认是当前数据目录 +* `innodb_log_file_size` 代表文件大小,默认 48M,`innodb_log_files_in_group` 代表文件个数,默认 2 最大 100,所以日志的文件大小为 `innodb_log_file_size * innodb_log_files_in_group` + +redo 日志文件也是由若干个 512 字节的 block 组成,日志文件的前 2048 个字节(前 4 个 block)用来存储一些管理信息,以后的用来存储 log buffer 中的 block 镜像 + +注意:block 并不代表一组 redo log,一组日志可能占用不到一个 block 或者几个 block,依赖于 MTR 的大小 + + + +*** + + + +##### 日志刷盘 + +redo log 需要在事务提交时将日志写入磁盘,但是比 Buffer Pool 修改的数据写入磁盘的速度快,原因: + +* 刷脏是随机 IO,因为每次修改的数据位置随机;redo log 和 binlog 都是**顺序写**,磁盘的顺序 IO 比随机 IO 速度要快 +* 刷脏是以数据页(Page)为单位的,一个页上的一个小修改都要整页写入;redo log 中只包含真正需要写入的部分,好几页的数据修改可能只记录在一个 redo log 页中,减少无效 IO +* **组提交机制**,可以大幅度降低磁盘的 IO 消耗 + +InnoDB 引擎会在适当的时候,把内存中 redo log buffer 持久化(fsync)到磁盘,具体的**刷盘策略**: + +* 在事务提交时需要进行刷盘,通过修改参数 `innodb_flush_log_at_trx_commit` 设置: + * 0:表示当提交事务时,并不将缓冲区的 redo 日志写入磁盘,而是等待**后台线程每秒刷新一次** + * 1:在事务提交时将缓冲区的 redo 日志**同步写入**到磁盘,保证一定会写入成功(默认值) + * 2:在事务提交时将缓冲区的 redo 日志异步写入到磁盘,不能保证提交时肯定会写入,只是有这个动作。日志已经在操作系统的缓存,如果操作系统没有宕机而 MySQL 宕机,也是可以恢复数据的 +* 写入 redo log buffer 的日志超过了总容量的一半,就会将日志刷入到磁盘文件,这会影响执行效率,所以开发中应**避免大事务** +* 服务器关闭时 +* 并行的事务提交(组提交)时,会将将其他事务的 redo log 持久化到磁盘。假设事务 A 已经写入 redo log buffer 中,这时另外一个线程的事务 B 提交,如果 innodb_flush_log_at_trx_commit 设置的是 1,那么事务 B 要把 redo log buffer 里的日志全部持久化到磁盘,**因为多个事务共用一个 redo log buffer**,所以一次 fsync 可以刷盘多个事务的 redo log,提升了并发量 + +服务器启动后 redo 磁盘空间不变,所以 redo 磁盘中的日志文件是被**循环使用**的,采用循环写数据的方式,写完尾部重新写头部,所以要确保头部 log 对应的修改已经持久化到磁盘 + + + +*** + + + +##### 日志序号 + +lsn (log sequence number) 代表已经写入的 redo 日志量、flushed_to_disk_lsn 指刷新到磁盘中的 redo 日志量,两者都是**全局变量**,如果两者的值相同,说明 log buffer 中所有的 redo 日志都已经持久化到磁盘 + +工作过程:写入 log buffer 数据时,buf_free 会进行偏移,偏移量就会加到 lsn 上 + +MTR 的执行过程中修改过的页对应的控制块会加到 Buffer Pool 的 flush 链表中,链表中脏页是按照第一次修改的时间进行排序的(头插),控制块中有两个指针用来记录脏页被修改的时间: + +* oldest_modification:第一次修改 Buffer Pool 中某个缓冲页时,将修改该页的 MTR **开始时**对应的 lsn 值写入这个属性 +* newest_modification:每次修改页面,都将 MTR 结束时全局的 lsn 值写入这个属性,所以该值是该页面最后一次修改后的 lsn 值 + +全局变量 checkpoint_lsn 表示**当前系统可以被覆盖的 redo 日志总量**,当 redo 日志对应的脏页已经被刷新到磁盘后,该文件空间就可以被覆盖重用,此时执行一次 checkpoint 来更新 checkpoint_lsn 的值存入管理信息(刷脏和执行一次 checkpoint 并不是同一个线程),该值的增量就代表磁盘文件中当前位置向后可以被覆盖的文件的量,所以该值是一直增大的 + +**checkpoint**:从 flush 链表尾部中找出还未刷脏的页面,该页面是当前系统中最早被修改的脏页,该页面之前产生的脏页都已经刷脏,然后将该页 oldest_modification 值赋值给 checkpoint_lsn,因为 lsn 小于该值时产生的 redo 日志都可以被覆盖了 + +但是在系统忙碌时,后台线程的刷脏操作不能将脏页快速刷出,导致系统无法及时执行 checkpoint ,这时需要用户线程从 flush 链表中把最早修改的脏页刷新到磁盘中,然后执行 checkpoint + +```java +write pos ------- checkpoint_lsn // 两值之间的部分表示可以写入的日志量,当 pos 追赶上 lsn 时必须执行 checkpoint +``` + +使用命令可以查看当前 InnoDB 存储引擎各种 lsn 的值: + +```mysql +SHOW ENGINE INNODB STATUS\G +``` + + + +**** + + + +##### 崩溃恢复 + +恢复的起点:在从 redo 日志文件组的管理信息中获取最近发生 checkpoint 的信息,**从 checkpoint_lsn 对应的日志文件开始恢复** + +恢复的终点:扫描日志文件的 block,block 的头部记录着当前 block 使用了多少字节,填满的 block 总是 512 字节, 如果某个 block 不是 512 字节,说明该 block 就是需要恢复的最后一个 block + +恢复的过程:按照 redo log 依次执行恢复数据,优化方式 + +* 使用哈希表:根据 redo log 的 space id 和 page number 属性计算出哈希值,将对同一页面的修改放入同一个槽里,可以一次性完成对某页的恢复,**避免了随机 IO** +* 跳过已经刷新到磁盘中的页面:数据页的 File Header 中的 FILE_PAGE_LSN 属性(类似 newest_modification)表示最近一次修改页面时的 lsn 值,数据页被刷新到磁盘中,那么该页 lsn 属性肯定大于 checkpoint_lsn + + + +参考书籍:https://book.douban.com/subject/35231266/ + + + +*** + + + +#### 工作流程 + +##### 日志对比 + +MySQL 中还存在 binlog(二进制日志)也可以记录写操作并用于数据的恢复,**保证数据不丢失**,二者的区别是: + +* 作用不同:redo log 是用于 crash recovery (故障恢复),保证 MySQL 宕机也不会影响持久性;binlog 是用于 point-in-time recovery 的,保证服务器可以基于时间点恢复数据,此外 binlog 还用于主从复制 +* 层次不同:redo log 是 InnoDB 存储引擎实现的,而 binlog 是MySQL的 Server 层实现的,同时支持 InnoDB 和其他存储引擎 +* 内容不同:redo log 是物理日志,内容基于磁盘的 Page;binlog 的内容是二进制的,根据 binlog_format 参数的不同,可能基于SQL 语句、基于数据本身或者二者的混合(日志部分详解) +* 写入时机不同:binlog 在事务提交时一次写入;redo log 的写入时机相对多元 + +binlog 为什么不支持崩溃恢复? + +* binlog 记录的是语句,并不记录数据页级的数据(哪个页改了哪些地方),所以没有能力恢复数据页 +* binlog 是追加写,保存全量的日志,没有标志确定从哪个点开始的数据是已经刷盘了,而 redo log 只要在 checkpoint_lsn 后面的就是没有刷盘的 + + + +*** + + + +##### 更新记录 + +更新一条记录的过程:写之前一定先读 + +* 在 B+ 树中定位到该记录,如果该记录所在的页面不在 Buffer Pool 里,先将其加载进内存 + +* 首先更新该记录对应的聚簇索引,更新聚簇索引记录时: + * 更新记录前向 undo 页面写 undo 日志,由于这是更改页面,所以需要记录一下相应的 redo 日志 + + 注意:修改 undo 页面也是在**修改页面**,事务只要修改页面就需要先记录相应的 redo 日志 + + * 然后**记录对应的 redo 日志**(等待 MTR 提交后写入 redo log buffer),**最后进行真正的更新记录** + +* 更新其他的二级索引记录,不会再记录 undo log,只记录 redo log 到 buffer 中 + +* 在一条更新语句执行完成后(也就是将所有待更新记录都更新完了),就会开始记录该语句对应的 binlog 日志,此时记录的 binlog 并没有刷新到硬盘上,还在内存中,在事务提交时才会统一将该事务运行过程中的所有 binlog 日志刷新到硬盘 + +假设表中有字段 id 和 a,存在一条 `id = 1, a = 2` 的记录,此时执行更新语句: + +```sql +update table set a=2 where id=1; +``` + +InnoDB 会真正的去执行把值修改成 (1,2) 这个操作,先加行锁,在去更新,并不会提前判断相同就不修改了 + + + +参考文章:https://mp.weixin.qq.com/s/wcJ2KisSaMnfP4nH5NYaQA + + + +*** + + + +##### 两段提交 + +当客户端执行 COMMIT 语句或者在自动提交的情况下,MySQL 内部开启一个 XA 事务,分两阶段来完成 XA 事务的提交: + +```sql +update T set c=c+1 where ID=2; +``` + + + +流程说明:执行引擎将这行新数据读入到内存中(Buffer Pool)后,先将此次更新操作记录到 redo log buffer 里,然后更新记录。最后将 redo log 刷盘后事务处于 prepare 状态,执行器会生成这个操作的 binlog,并**把 binlog 写入磁盘**,完成提交 + +两阶段: + +* Prepare 阶段:存储引擎将该事务的 **redo 日志刷盘**,并且将本事务的状态设置为 PREPARE,代表执行完成随时可以提交事务 +* Commit 阶段:先将事务执行过程中产生的 binlog 刷新到硬盘,再执行存储引擎的提交工作,引擎把 redo log 改成提交状态 + +存储引擎层的 redo log 和 server 层的 binlog 可以认为是一个分布式事务, 都可以用于表示事务的提交状态,而**两阶段提交就是让这两个状态保持逻辑上的一致**,也有利于主从复制,更好的保持主从数据的一致性 + + + +*** + + + +##### 数据恢复 + +系统崩溃前没有提交的事务的 redo log 可能已经刷盘(定时线程或者 checkpoint),怎么处理崩溃恢复? + +工作流程:获取 undo 链表首节点页面的 undo segement header 中的 TRX_UNDO_STATE 属性,表示当前链表的事务属性,**事务状态是活跃(未提交)的就全部回滚**,如果是 PREPARE 状态,就需要根据 binlog 的状态进行判断: + +* 如果在时刻 A 发生了崩溃(crash),由于此时 binlog 还没完成,所以需要进行回滚 +* 如果在时刻 B 发生了崩溃,redo log 和 binlog 有一个共**同的数据字段叫 XID**,崩溃恢复的时候,会按顺序扫描 redo log: + * 如果 redo log 里面的事务是完整的,也就是已经有了 commit 标识,说明 binlog 也已经记录完整,直接从 redo log 恢复数据 + * 如果 redo log 里面的事务只有 prepare,就根据 XID 去 binlog 中判断对应的事务是否存在并完整,如果完整可以恢复数据 + + +判断一个事务的 binlog 是否完整的方法: + +* statement 格式的 binlog,最后会有 COMMIT +* row 格式的 binlog,最后会有一个 XID event +* MySQL 5.6.2 版本以后,引入了 binlog-checksum 参数用来验证 binlog 内容的正确性(可能日志中间出错) + + + +参考文章:https://time.geekbang.org/column/article/73161 + + + +*** + + + +#### 刷脏优化 + +系统在进行刷脏时会占用一部分系统资源,会影响系统的性能,**产生系统抖动** + +* 一个查询要淘汰的脏页个数太多,会导致查询的响应时间明显变长 +* 日志写满,更新全部堵住,写性能跌为 0,这种情况对敏感业务来说,是不能接受的 + +InnoDB 刷脏页的控制策略: + +* `innodb_io_capacity` 参数代表磁盘的读写能力,建议设置成磁盘的 IOPS(每秒的 IO 次数) +* 刷脏速度参考两个因素:脏页比例和 redo log 写盘速度 + * 参数 `innodb_max_dirty_pages_pct` 是脏页比例上限,默认值是 75%,InnoDB 会根据当前的脏页比例,算出一个范围在 0 到 100 之间的数字 + * InnoDB 每次写入的日志都有一个序号,当前写入的序号跟 checkpoint 对应的序号之间的差值,InnoDB 根据差值算出一个范围在 0 到 100 之间的数字 + * 两者较大的值记为 R,执行引擎按照 innodb_io_capacity 定义的能力乘以 R% 来控制刷脏页的速度 +* `innodb_flush_neighbors` 参数置为 1 代表控制刷脏时检查相邻的数据页,如果也是脏页就一起刷脏,并检查邻居的邻居,这个行为会一直蔓延直到不是脏页,在 MySQL 8.0 中该值的默认值是 0,不建议开启此功能 + + + + + +**** + + + +### 一致特性 + +一致性是指事务执行前后,数据库的完整性约束没有被破坏,事务执行的前后都是合法的数据状态。 + +数据库的完整性约束包括但不限于:实体完整性(如行的主键存在且唯一)、列完整性(如字段的类型、大小、长度要符合要求)、外键约束、用户自定义完整性(如转账前后,两个账户余额的和应该不变) + +实现一致性的措施: + +- 保证原子性、持久性和隔离性,如果这些特性无法保证,事务的一致性也无法保证 +- 数据库本身提供保障,例如不允许向整形列插入字符串值、字符串长度不能超过列的限制等 +- 应用层面进行保障,例如如果转账操作只扣除转账者的余额,而没有增加接收者的余额,无论数据库实现的多么完美,也无法保证状态的一致 + + + + + +**** + + + + + +## 锁机制 + +### 基本介绍 + +锁机制:数据库为了保证数据的一致性,在共享的资源被并发访问时变得安全有序所设计的一种规则 + +利用 MVCC 性质进行读取的操作叫**一致性读**,读取数据前加锁的操作叫**锁定读** + +锁的分类: + +- 按操作分类: + - 共享锁:也叫读锁。对同一份数据,多个事务读操作可以同时加锁而不互相影响 ,但不能修改数据 + - 排他锁:也叫写锁。当前的操作没有完成前,会阻断其他操作的读取和写入 +- 按粒度分类: + - 表级锁:会锁定整个表,开销小,加锁快;不会出现死锁;锁定力度大,发生锁冲突概率高,并发度最低,偏向 MyISAM + - 行级锁:会锁定当前操作行,开销大,加锁慢;会出现死锁;锁定力度小,发生锁冲突概率低,并发度高,偏向 InnoDB + - 页级锁:锁的力度、发生冲突的概率和加锁开销介于表锁和行锁之间,会出现死锁,并发性能一般 +- 按使用方式分类: + - 悲观锁:每次查询数据时都认为别人会修改,很悲观,所以查询时加锁 + - 乐观锁:每次查询数据时都认为别人不会修改,很乐观,但是更新时会判断一下在此期间别人有没有去更新这个数据 + +* 不同存储引擎支持的锁 + + | 存储引擎 | 表级锁 | 行级锁 | 页级锁 | + | -------- | -------- | -------- | ------ | + | MyISAM | 支持 | 不支持 | 不支持 | + | InnoDB | **支持** | **支持** | 不支持 | + | MEMORY | 支持 | 不支持 | 不支持 | + | BDB | 支持 | 不支持 | 支持 | + +从锁的角度来说:表级锁更适合于以查询为主,只有少量按索引条件更新数据的应用,如 Web 应用;而行级锁则更适合于有大量按索引条件并发更新少量不同数据,同时又有并查询的应用,如一些在线事务处理系统 + + + +*** + + + +### 内存结构 + +对一条记录加锁的本质就是**在内存中**创建一个锁结构与之关联,结构包括 + +* 事务信息:锁对应的事务信息,一个锁属于一个事务 +* 索引信息:对于行级锁,需要记录加锁的记录属于哪个索引 +* 表锁和行锁信息:表锁记录着锁定的表,行锁记录了 Space ID 所在表空间、Page Number 所在的页号、n_bits 使用了多少比特 +* type_mode:一个 32 比特的数,被分成 lock_mode、lock_type、rec_lock_type 三个部分 + * lock_mode:锁模式,记录是共享锁、排他锁、意向锁之类 + * lock_type:代表表级锁还是行级锁 + * rec_lock_type:代表行锁的具体类型和 is_waiting 属性,is_waiting = true 时表示当前事务尚未获取到锁,处于等待状态。事务获取锁后的锁结构是 is_waiting 为 false,释放锁时会检查是否与当前记录关联的锁结构,如果有就唤醒对应事务的线程 + +一个事务可能操作多条记录,为了节省内存,满足下面条件的锁使用同一个锁结构: + +* 在同一个事务中的加锁操作 +* 被加锁的记录在同一个页面中 +* 加锁的类型是一样的 +* 加锁的状态是一样的 + + + + + +**** + + + +### Server + +MySQL 里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL) + +MDL 叫元数据锁,主要用来保护 MySQL 内部对象的元数据,保证数据读写的正确性,**当对一个表做增删改查的时候,加 MDL 读锁;当要对表做结构变更操作 DDL 的时候,加 MDL 写锁**,两种锁不相互兼容,所以可以保证 DDL、DML、DQL 操作的安全 + +说明:DDL 操作执行前会隐式提交当前会话的事务,因为 DDL 一般会在若干个特殊事务中完成,开启特殊事务前需要提交到其他事务 + +MDL 锁的特性: + +* MDL 锁不需要显式使用,在访问一个表的时候会被自动加上,在事务开始时申请,整个事务提交后释放(执行完单条语句不释放) + +* MDL 锁是在 Server 中实现,不是 InnoDB 存储引擎层能直接实现的锁 + +* MDL 锁还能实现其他粒度级别的锁,比如全局锁、库级别的锁、表空间级别的锁 + +FLUSH TABLES WITH READ LOCK 简称(FTWRL),全局读锁,让整个库处于只读状态,DDL DML 都被阻塞,工作流程: + +1. 上全局读锁(lock_global_read_lock) +2. 清理表缓存(close_cached_tables) +3. 上全局 COMMIT 锁(make_global_read_lock_block_commit) + +该命令主要用于备份工具做**一致性备份**,由于 FTWRL 需要持有两把全局的 MDL 锁,并且还要关闭所有表对象,因此杀伤性很大 + + + +*** + + + +### MyISAM + +#### 表级锁 + +MyISAM 存储引擎只支持表锁,这也是 MySQL 开始几个版本中唯一支持的锁类型 + +MyISAM 引擎在执行查询语句之前,会**自动**给涉及到的所有表加读锁,在执行增删改之前,会**自动**给涉及的表加写锁,这个过程并不需要用户干预,所以用户一般不需要直接用 LOCK TABLE 命令给 MyISAM 表显式加锁 + +* 加锁命令:(对 InnoDB 存储引擎也适用) + + 读锁:所有连接只能读取数据,不能修改 + + 写锁:其他连接不能查询和修改数据 + + ```mysql + -- 读锁 + LOCK TABLE table_name READ; + + -- 写锁 + LOCK TABLE table_name WRITE; + ``` + +* 解锁命令: + + ```mysql + -- 将当前会话所有的表进行解锁 + UNLOCK TABLES; + ``` + +锁的兼容性: + +* 对 MyISAM 表的读操作,不会阻塞其他用户对同一表的读请求,但会阻塞对同一表的写请求 +* 对 MyISAM 表的写操作,则会阻塞其他用户对同一表的读和写操作 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-MyISAM 锁的兼容性.png) + +锁调度:**MyISAM 的读写锁调度是写优先**,因为写锁后其他线程不能做任何操作,大量的更新会使查询很难得到锁,从而造成永远阻塞,所以 MyISAM 不适合做写为主的表的存储引擎 + + + +*** + + + +#### 锁操作 + +##### 读锁 + +两个客户端操作 Client 1和 Client 2,简化为 C1、C2 + +* 数据准备: + + ```mysql + CREATE TABLE `tb_book` ( + `id` INT(11) AUTO_INCREMENT, + `name` VARCHAR(50) DEFAULT NULL, + `publish_time` DATE DEFAULT NULL, + `status` CHAR(1) DEFAULT NULL, + PRIMARY KEY (`id`) + ) ENGINE=MYISAM DEFAULT CHARSET=utf8 ; + + INSERT INTO tb_book (id, NAME, publish_time, STATUS) VALUES(NULL,'java编程思想','2088-08-01','1'); + INSERT INTO tb_book (id, NAME, publish_time, STATUS) VALUES(NULL,'mysql编程思想','2088-08-08','0'); + ``` + +* C1、C2 加读锁,同时查询可以正常查询出数据 + + ```mysql + LOCK TABLE tb_book READ; -- C1、C2 + SELECT * FROM tb_book; -- C1、C2 + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-MyISAM 读锁1.png) + +* C1 加读锁,C1、C2 查询未锁定的表,C1 报错,C2 正常查询 + + ```mysql + LOCK TABLE tb_book READ; -- C1 + SELECT * FROM tb_user; -- C1、C2 + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-MyISAM 读锁2.png) + + C1、C2 执行插入操作,C1 报错,C2 等待获取 + + ```mysql + INSERT INTO tb_book VALUES(NULL,'Spring高级','2088-01-01','1'); -- C1、C2 + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-MyISAM 读锁3.png) + + 当在 C1 中释放锁指令 UNLOCK TABLES,C2 中的 INSERT 语句立即执行 + + + +*** + + + +##### 写锁 + +两个客户端操作 Client 1和 Client 2,简化为 C1、C2 + +* C1 加写锁,C1、C2查询表,C1 正常查询,C2 需要等待 + + ```mysql + LOCK TABLE tb_book WRITE; -- C1 + SELECT * FROM tb_book; -- C1、C2 + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-MyISAM 写锁1.png) + + 当在 C1 中释放锁指令 UNLOCK TABLES,C2 中的 SELECT 语句立即执行 + +* C1、C2 同时加写锁 + + ```mysql + LOCK TABLE tb_book WRITE; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-MyISAM 写锁2.png) + +* C1 加写锁,C1、C2查询未锁定的表,C1 报错,C2 正常查询 + + + +*** + + + +#### 锁状态 + +* 查看锁竞争: + + ```mysql + SHOW OPEN TABLES; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-锁争用情况查看1.png) + + In_user:表当前被查询使用的次数,如果该数为零,则表是打开的,但是当前没有被使用 + + Name_locked:表名称是否被锁定,名称锁定用于取消表或对表进行重命名等操作 + + ```mysql + LOCK TABLE tb_book READ; -- 执行命令 + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-锁争用情况查看2.png) + +* 查看锁状态: + + ```mysql + SHOW STATUS LIKE 'Table_locks%'; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-MyISAM 锁状态.png) + + Table_locks_immediate:指的是能立即获得表级锁的次数,每立即获取锁,值加 1 + + Table_locks_waited:指的是不能立即获取表级锁而需要等待的次数,每等待一次,该值加 1,此值高说明存在着较为严重的表级锁争用情况 + + + +*** + + + +### InnoDB + +#### 行级锁 + +##### 记录锁 + +InnoDB 与 MyISAM 的最大不同有两点:一是支持事务;二是采用了行级锁,**InnoDB 同时支持表锁和行锁** + +行级锁,也称为记录锁(Record Lock),InnoDB 实现了以下两种类型的行锁: + +- 共享锁 (S):又称为读锁,简称 S 锁,多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改 +- 排他锁 (X):又称为写锁,简称 X 锁,不能与其他锁并存,获取排他锁的事务是可以对数据读取和修改 + +RR 隔离界别下,对于 UPDATE、DELETE 和 INSERT 语句,InnoDB 会**自动给涉及数据集加排他锁**(行锁),在 commit 时自动释放;对于普通 SELECT 语句,不会加任何锁(只是针对 InnoDB 层来说的,因为在 Server 层会**加 MDL 读锁**),通过 MVCC 防止并发冲突 + +在事务中加的锁,并不是不需要了就释放,而是在事务中止或提交时自动释放,这个就是**两阶段锁协议**。所以一般将更新共享资源(并发高)的 SQL 放到事务的最后执行,可以让其他线程尽量的减少等待时间 + +锁的兼容性: + +- 共享锁和共享锁 兼容 +- 共享锁和排他锁 冲突 +- 排他锁和排他锁 冲突 +- 排他锁和共享锁 冲突 + +显式给数据集加共享锁或排他锁:**加锁读就是当前读,读取的是最新数据** + +```mysql +SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE -- 共享锁 +SELECT * FROM table_name WHERE ... FOR UPDATE -- 排他锁 +``` + +注意:**锁默认会锁聚簇索引(锁就是加在索引上)**,但是当使用覆盖索引时,加共享锁只锁二级索引,不锁聚簇索引 + + + +*** + + + +##### 锁操作 + +两个客户端操作 Client 1和 Client 2,简化为 C1、C2 + +* 环境准备 + + ```mysql + CREATE TABLE test_innodb_lock( + id INT(11), + name VARCHAR(16), + sex VARCHAR(1) + )ENGINE = INNODB DEFAULT CHARSET=utf8; + + INSERT INTO test_innodb_lock VALUES(1,'100','1'); + -- .......... + + CREATE INDEX idx_test_innodb_lock_id ON test_innodb_lock(id); + CREATE INDEX idx_test_innodb_lock_name ON test_innodb_lock(name); + ``` + +* 关闭自动提交功能: + + ```mysql + SET AUTOCOMMIT=0; -- C1、C2 + ``` + + 正常查询数据: + + ```mysql + SELECT * FROM test_innodb_lock; -- C1、C2 + ``` + +* 查询 id 为 3 的数据,正常查询: + + ```mysql + SELECT * FROM test_innodb_lock WHERE id=3; -- C1、C2 + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-InnoDB 锁操作1.png) + +* C1 更新 id 为 3 的数据,但不提交: + + ```mysql + UPDATE test_innodb_lock SET name='300' WHERE id=3; -- C1 + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-InnoDB 锁操作2.png) + + C2 查询不到 C1 修改的数据,因为隔离界别为 REPEATABLE READ,C1 提交事务,C2 查询: + + ```mysql + COMMIT; -- C1 + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-InnoDB 锁操作3.png) + + 提交后仍然查询不到 C1 修改的数据,因为隔离级别可以防止脏读、不可重复读,所以 C2 需要提交才可以查询到其他事务对数据的修改: + + ```mysql + COMMIT; -- C2 + SELECT * FROM test_innodb_lock WHERE id=3; -- C2 + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-InnoDB 锁操作4.png) + +* C1 更新 id 为 3 的数据,但不提交,C2 也更新 id 为 3 的数据: + + ```mysql + UPDATE test_innodb_lock SET name='3' WHERE id=3; -- C1 + UPDATE test_innodb_lock SET name='30' WHERE id=3; -- C2 + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-InnoDB 锁操作5.png) + + 当 C1 提交,C2 直接解除阻塞,直接更新 + +* 操作不同行的数据: + + ```mysql + UPDATE test_innodb_lock SET name='10' WHERE id=1; -- C1 + UPDATE test_innodb_lock SET name='30' WHERE id=3; -- C2 + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-InnoDB 锁操作6.png) + + 由于 C1、C2 操作的不同行,获取不同的行锁,所以都可以正常获取行锁 + + + + + +**** + + + +#### 锁分类 + +##### 间隙锁 + +InnoDB 会对间隙(GAP)进行加锁,就是间隙锁 (RR 隔离级别下才有该锁)。间隙锁之间不存在冲突关系,**多个事务可以同时对一个间隙加锁**,但是间隙锁会阻止往这个间隙中插入一个记录的操作 + +InnoDB 加锁的基本单位是 next-key lock,该锁是行锁和 gap lock 的组合(X or S 锁),但是加锁过程是分为间隙锁和行锁两段执行 + +* 可以**保护当前记录和前面的间隙**,遵循左开右闭原则,单纯的间隙锁是左开右开 +* 假设有 10、11、13,那么可能的间隙锁包括:(负无穷,10]、(10,11]、(11,13]、(13,正无穷) + +几种索引的加锁情况: + +* 唯一索引加锁在值存在时是行锁,next-key lock 会退化为行锁,值不存在会变成间隙锁 +* 普通索引加锁会继续向右遍历到不满足条件的值为止,next-key lock 退化为间隙锁 +* 范围查询无论是否是唯一索引,都需要访问到不满足条件的第一个值为止 +* 对于联合索引且是唯一索引,如果 where 条件只包括联合索引的一部分,那么会加间隙锁 + +间隙锁优点:RR 级别下间隙锁可以**解决事务的一部分的幻读问题**,通过对间隙加锁,可以防止读取过程中数据条目发生变化。一部分的意思是不会对全部间隙加锁,只能加锁一部分的间隙 + +间隙锁危害: + +* 当锁定一个范围的键值后,即使某些不存在的键值也会被无辜的锁定,造成在锁定的时候无法插入锁定键值范围内的任何数据,在某些场景下这可能会对性能造成很大的危害,影响并发度 +* 事务 A B 同时锁住一个间隙后,A 往当前间隙插入数据时会被 B 的间隙锁阻塞,B 也执行插入间隙数据的操作时就会**产生死锁** + +现场演示: + +* 关闭自动提交功能: + + ```mysql + SET AUTOCOMMIT=0; -- C1、C2 + ``` + +* 查询数据表: + + ```mysql + SELECT * FROM test_innodb_lock; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-InnoDB 间隙锁1.png) + +* C1 根据 id 范围更新数据,C2 插入数据: + + ```mysql + UPDATE test_innodb_lock SET name='8888' WHERE id < 4; -- C1 + INSERT INTO test_innodb_lock VALUES(2,'200','2'); -- C2 + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-InnoDB 间隙锁2.png) + + 出现间隙锁,C2 被阻塞,等待 C1 提交事务后才能更新 + + + +**** + + + +##### 意向锁 + +InnoDB 为了支持多粒度的加锁,允许行锁和表锁同时存在,支持在不同粒度上的加锁操作,InnoDB 增加了意向锁(Intention Lock) + +意向锁是将锁定的对象分为多个层次,意向锁意味着事务希望在更细粒度上进行加锁,意向锁分为两种: + +* 意向共享锁(IS):事务有意向对表加共享锁 +* 意向排他锁(IX):事务有意向对表加排他锁 + +**IX,IS 是表级锁**,不会和行级的 X,S 锁发生冲突,意向锁是在加表级锁之前添加,为了在加表级锁时可以快速判断表中是否有记录被上锁,比如向一个表添加表级 X 锁的时: + +- 没有意向锁,则需要遍历整个表判断是否有锁定的记录 +- 有了意向锁,首先判断是否存在意向锁,然后判断该意向锁与即将添加的表级锁是否兼容即可,因为意向锁的存在代表有表级锁的存在或者即将有表级锁的存在 + +兼容性如下所示: + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-意向锁兼容性.png) + +**插入意向锁** Insert Intention Lock 是在插入一行记录操作之前设置的一种间隙锁,是行级锁 + +插入意向锁释放了一种插入信号,即多个事务在相同的索引间隙插入时如果不是插入相同的间隙位置就不需要互相等待。假设某列有索引,只要两个事务插入位置不同,如事务 A 插入 3,事务 B 插入 4,那么就可以同时插入 + + + +*** + + + +##### 自增锁 + +系统会自动给 AUTO_INCREMENT 修饰的列进行递增赋值,实现方式: + +* AUTO_INC 锁:表级锁,执行插入语句时会自动添加,在该语句执行完成后释放,并不是事务结束 +* 轻量级锁:为插入语句生成 AUTO_INCREMENT 修饰的列时获取该锁,生成以后释放掉,不需要等到插入语句执行完后释放 + +系统变量 `innodb_autoinc_lock_mode` 控制采取哪种方式: + +* 0:全部采用 AUTO_INC 锁 +* 1:全部采用轻量级锁 +* 2:混合使用,在插入记录的数量确定时采用轻量级锁,不确定时采用 AUTO_INC 锁 + + + +**** + + + +##### 隐式锁 + +一般情况下 INSERT 语句是不需要在内存中生成锁结构的,会进行隐式的加锁,保护的是插入后的安全 + +注意:如果插入的间隙被其他事务加了间隙锁,此次插入会被阻塞,并在该间隙插入一个插入意向锁 + +* 聚簇索引:索引记录有 trx_id 隐藏列,表示最后改动该记录的事务 id,插入数据后事务 id 就是当前事务。其他事务想获取该记录的锁时会判断当前记录的事务 id 是否是活跃的,如果不是就可以正常加锁;如果是就创建一个 X 的锁结构,该锁的 is_waiting 是 false,为自己的事务创建一个锁结构,is_waiting 是 true(类似 Java 中的锁升级) +* 二级索引:获取数据页 Page Header 中的 PAGE_MAX_TRX_ID 属性,代表修改当前页面的最大的事务 ID,如果小于当前活跃的最小事务 id,就证明插入该数据的事务已经提交,否则就需要获取到主键值进行回表操作 + +隐式锁起到了延迟生成锁的效果,如果其他事务与隐式锁没有冲突,就可以避免锁结构的生成,节省了内存资源 + +INSERT 在两种情况下会生成锁结构: + +* 重复键:在插入主键或唯一二级索引时遇到重复的键值会报错,在报错前需要对对应的聚簇索引进行加锁 + * 隔离级别 <= Read Uncommitted,加 S 型 Record Lock + * 隔离级别 >= Repeatable Read,加 S 型 next_key 锁 + +* 外键检查:如果待插入的记录在父表中可以找到,会对父表的记录加 S 型 Record Lock。如果待插入的记录在父表中找不到 + * 隔离级别 <= Read Committed,不加锁 + * 隔离级别 >= Repeatable Read,加间隙锁 + + + + + +*** + + + +#### 锁优化 + +##### 优化锁 + +InnoDB 存储引擎实现了行级锁定,虽然在锁定机制的实现方面带来了性能损耗可能比表锁会更高,但是在整体并发处理能力方面要远远优于 MyISAM 的表锁,当系统并发量较高的时候,InnoDB 的整体性能远远好于 MyISAM + +但是使用不当可能会让 InnoDB 的整体性能表现不仅不能比 MyISAM 高,甚至可能会更差 + +优化建议: + +- 尽可能让所有数据检索都能通过索引来完成,避免无索引行锁升级为表锁 +- 合理设计索引,尽量缩小锁的范围 +- 尽可能减少索引条件及索引范围,避免间隙锁 +- 尽量控制事务大小,减少锁定资源量和时间长度 +- 尽可使用低级别事务隔离(需要业务层面满足需求) + + + +**** + + + +##### 锁升级 + +索引失效造成**行锁升级为表锁**,不通过索引检索数据,全局扫描的过程中 InnoDB 会将对表中的所有记录加锁,实际效果和**表锁**一样,实际开发过程应避免出现索引失效的状况 + +* 查看当前表的索引: + + ```mysql + SHOW INDEX FROM test_innodb_lock; + ``` + +* 关闭自动提交功能: + + ```mysql + SET AUTOCOMMIT=0; -- C1、C2 + ``` + +* 执行更新语句: + + ```mysql + UPDATE test_innodb_lock SET sex='2' WHERE name=10; -- C1 + UPDATE test_innodb_lock SET sex='2' WHERE id=3; -- C2 + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-InnoDB 锁升级.png) + + 索引失效:执行更新时 name 字段为 varchar 类型,造成索引失效,最终行锁变为表锁 + + + +*** + + + +##### 死锁 + +不同事务由于互相持有对方需要的锁而导致事务都无法继续执行的情况称为死锁 + +死锁情况:线程 A 修改了 id = 1 的数据,请求修改 id = 2 的数据,线程 B 修改了 id = 2 的数据,请求修改 id = 1 的数据,产生死锁 + +解决策略: + +* 直接进入等待直到超时,超时时间可以通过参数 innodb_lock_wait_timeout 来设置,默认 50 秒,但是时间的设置不好控制,超时可能不是因为死锁,而是因为事务处理比较慢,所以一般不采取该方式 + +* 主动死锁检测,发现死锁后**主动回滚死锁链条中较小的一个事务**,让其他事务得以继续执行,将参数 `innodb_deadlock_detect` 设置为 on,表示开启该功能(事务较小的意思就是事务执行过程中插入、删除、更新的记录条数) + + 死锁检测并不是每个语句都要检测,只有在加锁访问的行上已经有锁时,当前事务被阻塞了才会检测,也是从当前事务开始进行检测 + +通过执行 `SHOW ENGINE INNODB STATUS` 可以查看最近发生的一次死循环,全局系统变量 `innodb_print_all_deadlocks` 设置为 on,就可以将每个死锁信息都记录在 MySQL 错误日志中 + +死锁一般是行级锁,当表锁发生死锁时,会在事务中访问其他表时**直接报错**,破坏了持有并等待的死锁条件 + + + +*** + + + +#### 锁状态 + +查看锁信息 + +```mysql +SHOW STATUS LIKE 'innodb_row_lock%'; +``` + + + +参数说明: + +* Innodb_row_lock_current_waits:当前正在等待锁定的数量 + +* Innodb_row_lock_time:从系统启动到现在锁定总时间长度 + +* Innodb_row_lock_time_avg:每次等待所花平均时长 + +* Innodb_row_lock_time_max:从系统启动到现在等待最长的一次所花的时间 + +* Innodb_row_lock_waits:系统启动后到现在总共等待的次数 + +当等待的次数很高,而且每次等待的时长也不短的时候,就需要分析系统中为什么会有如此多的等待,然后根据分析结果制定优化计划 + +查看锁状态: + +```mysql +SELECT * FROM information_schema.innodb_locks; #锁的概况 +SHOW ENGINE INNODB STATUS\G; #InnoDB整体状态,其中包括锁的情况 +``` + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-InnoDB查看锁状态.png) + +lock_id 是锁 id;lock_trx_id 为事务 id;lock_mode 为 X 代表排它锁(写锁);lock_type 为 RECORD 代表锁为行锁(记录锁) + + + + + +*** + + + +### 乐观锁 + +悲观锁:在整个数据处理过程中,将数据处于锁定状态,为了保证事务的隔离性,就需要一致性锁定读。读取数据时给加锁,其它事务无法修改这些数据,修改删除数据时也加锁,其它事务同样无法读取这些数据 + +悲观锁和乐观锁使用前提: + +- 对于读的操作远多于写的操作的时候,一个更新操作加锁会阻塞所有的读取操作,降低了吞吐量,最后需要释放锁,锁是需要一些开销的,这时候可以选择乐观锁 +- 如果是读写比例差距不是非常大或者系统没有响应不及时,吞吐量瓶颈的问题,那就不要去使用乐观锁,它增加了复杂度,也带来了业务额外的风险,这时候可以选择悲观锁 + +乐观锁的实现方式:就是 CAS,比较并交换 + +* 版本号 + + 1. 给数据表中添加一个 version 列,每次更新后都将这个列的值加 1 + + 2. 读取数据时,将版本号读取出来,在执行更新的时候,比较版本号 + + 3. 如果相同则执行更新,如果不相同,说明此条数据已经发生了变化 + + 4. 用户自行根据这个通知来决定怎么处理,比如重新开始一遍,或者放弃本次更新 + + ```mysql + -- 创建city表 + CREATE TABLE city( + id INT PRIMARY KEY AUTO_INCREMENT, -- 城市id + NAME VARCHAR(20), -- 城市名称 + VERSION INT -- 版本号 + ); + + -- 添加数据 + INSERT INTO city VALUES (NULL,'北京',1),(NULL,'上海',1),(NULL,'广州',1),(NULL,'深圳',1); + + -- 修改北京为北京市 + -- 1.查询北京的version + SELECT VERSION FROM city WHERE NAME='北京'; + -- 2.修改北京为北京市,版本号+1。并对比版本号 + UPDATE city SET NAME='北京市',VERSION=VERSION+1 WHERE NAME='北京' AND VERSION=1; + ``` + +* 时间戳 + + - 和版本号方式基本一样,给数据表中添加一个列,名称无所谓,数据类型需要是 **timestamp** + - 每次更新后都将最新时间插入到此列 + - 读取数据时,将时间读取出来,在执行更新的时候,比较时间 + - 如果相同则执行更新,如果不相同,说明此条数据已经发生了变化 + +乐观锁的异常情况:如果 version 被其他事务抢先更新,则在当前事务中更新失败,trx_id 没有变成当前事务的 ID,当前事务再次查询还是旧值,就会出现**值没变但是更新不了**的现象(anomaly) + +解决方案:每次 CAS 更新不管成功失败,就结束当前事务;如果失败则重新起一个事务进行查询更新 + + + + + +*** + + + + + +## 主从 + +### 基本介绍 + +主从复制是指将主数据库的 DDL 和 DML 操作通过二进制日志传到从库服务器中,然后在从库上对这些日志重新执行(也叫重做),从而使得从库和主库的数据保持同步 + +MySQL 支持一台主库同时向多台从库进行复制,从库同时也可以作为其他从服务器的主库,实现链状复制 + +MySQL 复制的优点主要包含以下三个方面: + +- 主库出现问题,可以快速切换到从库提供服务 + +- 可以在从库上执行查询操作,从主库中更新,实现读写分离 + +- 可以在从库中执行备份,以避免备份期间影响主库的服务(备份时会加全局读锁) + + + +*** + + + +### 主从复制 + +#### 主从结构 + +MySQL 的主从之间维持了一个**长连接**。主库内部有一个线程,专门用于服务从库的长连接,连接过程: + +* 从库执行 change master 命令,设置主库的 IP、端口、用户名、密码以及要从哪个位置开始请求 binlog,这个位置包含文件名和日志偏移量 +* 从库执行 start slave 命令,这时从库会启动两个线程,就是图中的 io_thread 和 sql_thread,其中 io_thread 负责与主库建立连接 +* 主库校验完用户名、密码后,开始按照从传过来的位置,从本地读取 binlog 发给从库,开始主从复制 + +主从复制原理图: + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-主从复制原理图.jpg) + +主从复制主要依赖的是 binlog,MySQL 默认是异步复制,需要三个线程: + +- binlog thread:在主库事务提交时,把数据变更记录在日志文件 binlog 中,并通知 slave 有数据更新 +- I/O thread:负责从主服务器上**拉取二进制日志**,并将 binlog 日志内容依次写到 relay log 中转日志的最末端,并将新的 binlog 文件名和 offset 记录到 master-info 文件中,以便下一次读取日志时从指定 binlog 日志文件及位置开始读取新的 binlog 日志内容 +- SQL thread:监测本地 relay log 中新增了日志内容,读取中继日志并重做其中的 SQL 语句,从库在 relay-log.info 中记录当前应用中继日志的文件名和位点以便下一次执行 + +同步与异步: + +* 异步复制有数据丢失风险,例如数据还未同步到从库,主库就给客户端响应,然后主库挂了,此时从库晋升为主库的话数据是缺失的 +* 同步复制,主库需要将 binlog 复制到所有从库,等所有从库响应了之后主库才进行其他逻辑,这样的话性能很差,一般不会选择 +* MySQL 5.7 之后出现了半同步复制,有参数可以选择成功同步几个从库就返回响应 + + + +**** + + + +#### 主主结构 + +主主结构就是两个数据库之间总是互为主从关系,这样在切换的时候就不用再修改主从关系 + +循环复制:在库 A 上更新了一条语句,然后把生成的 binlog 发给库 B,库 B 执行完这条更新语句后也会生成 binlog,会再发给 A + +解决方法: + +* 两个库的 server id 必须不同,如果相同则它们之间不能设定为主主关系 +* 一个库接到 binlog 并在重放的过程中,生成与原 binlog 的 server id 相同的新的 binlog +* 每个库在收到从主库发过来的日志后,先判断 server id,如果跟自己的相同,表示这个日志是自己生成的,就直接丢弃这个日志 + + + +*** + + + +### 主从延迟 + +#### 延迟原因 + +正常情况主库执行更新生成的所有 binlog,都可以传到从库并被正确地执行,从库就能达到跟主库一致的状态,这就是最终一致性 + +主从延迟是主从之间是存在一定时间的数据不一致,就是同一个事务在从库执行完成的时间和主库执行完成的时间的差值,即 T2-T1 + +- 主库 A 执行完成一个事务,写入 binlog,该时刻记为 T1 +- 日志传给从库 B,从库 B 执行完这个事务,该时刻记为 T2 + +通过在从库执行 `show slave status` 命令,返回结果会显示 seconds_behind_master 表示当前从库延迟了多少秒 + +- 每一个事务的 binlog 都有一个时间字段,用于记录主库上写入的时间 +- 从库取出当前正在执行的事务的时间字段,跟系统的时间进行相减,得到的就是 seconds_behind_master + +主从延迟的原因: + +* 从库的机器性能比主库的差,导致从库的复制能力弱 +* 从库的查询压力大,建立一主多从的结构 +* 大事务的执行,主库必须要等到事务完成之后才会写入 binlog,导致从节点出现应用 binlog 延迟 +* 主库的 DDL,从库与主库的 DDL 同步是串行进行,DDL 在主库执行时间很长,那么从库也会消耗同样的时间 +* 锁冲突问题也可能导致从节点的 SQL 线程执行慢 + +主从同步问题永远都是**一致性和性能的权衡**,需要根据实际的应用场景,可以采取下面的办法: + +* 优化 SQL,避免慢 SQL,减少批量操作 +* 降低多线程大事务并发的概率,优化业务逻辑 +* 业务中大多数情况查询操作要比更新操作更多,搭建**一主多从**结构,让这些从库来分担读的压力 + +* 尽量采用短的链路,主库和从库服务器的距离尽量要短,提升端口带宽,减少 binlog 传输的网络延时 +* 实时性要求高的业务读强制走主库,从库只做备份 + + + +*** + + + +#### 并行复制 + +##### MySQL5.6 + +高并发情况下,主库的会产生大量的 binlog,在从库中有两个线程 IO Thread 和 SQL Thread 单线程执行,会导致主库延迟变大。为了改善复制延迟问题,MySQL 5.6 版本增加了并行复制功能,以采用多线程机制来促进执行 + +coordinator 就是原来的 SQL Thread,并行复制中它不再直接更新数据,**只负责读取中转日志和分发事务**: + +* 线程分配完成并不是立即执行,为了防止造成更新覆盖,更新同一 DB 的两个事务必须被分发到同一个工作线程 +* 同一个事务不能被拆开,必须放到同一个工作线程 + +MySQL 5.6 版本的策略:每个线程对应一个 hash 表,用于保存当前这个线程的执行队列里的事务所涉及的表,hash 表的 key 是数据库名,value 是一个数字,表示队列中有多少个事务修改这个库,适用于主库上有多个 DB 的情况 + +每个事务在分发的时候,跟线程的**冲突**(事务操作的是同一个库)关系包括以下三种情况: + +* 如果跟所有线程都不冲突,coordinator 线程就会把这个事务分配给最空闲的线程 +* 如果只跟一个线程冲突,coordinator 线程就会把这个事务分配给这个存在冲突关系的线程 +* 如果跟多于一个线程冲突,coordinator 线程就进入等待状态,直到和这个事务存在冲突关系的线程只剩下 1 个 + +优缺点: + +* 构造 hash 值的时候很快,只需要库名,而且一个实例上 DB 数也不会很多,不会出现需要构造很多项的情况 +* 不要求 binlog 的格式,statement 格式的 binlog 也可以很容易拿到库名(日志章节详解了 binlog) +* 主库上的表都放在同一个 DB 里面,这个策略就没有效果了;或者不同 DB 的热点不同,比如一个是业务逻辑库,一个是系统配置库,那也起不到并行的效果,需要**把相同热度的表均匀分到这些不同的 DB 中**,才可以使用这个策略 + + + +*** + + + +##### MySQL5.7 + +MySQL 5.7 由参数 slave-parallel-type 来控制并行复制策略: + +* 配置为 DATABASE,表示使用 MySQL 5.6 版本的**按库(DB)并行策略** +* 配置为 LOGICAL_CLOCK,表示的**按提交状态并行**执行 + +按提交状态并行复制策略的思想是: + +* 所有处于 commit 状态的事务可以并行执行;同时处于 prepare 状态的事务,在从库执行时是可以并行的 +* 处于 prepare 状态的事务,与处于 commit 状态的事务之间,在从库执行时也是可以并行的 + +MySQL 5.7.22 版本里,MySQL 增加了一个新的并行复制策略,基于 WRITESET 的并行复制,新增了一个参数 binlog-transaction-dependency-tracking,用来控制是否启用这个新策略: + +* COMMIT_ORDER:表示根据同时进入 prepare 和 commit 来判断是否可以并行的策略 + +* WRITESET:表示的是对于每个事务涉及更新的每一行,计算出这一行的 hash 值,组成该事务的 writeset 集合,如果两个事务没有操作相同的行,也就是说它们的 writeset 没有交集,就可以并行(**按行并行**) + + 为了唯一标识,这个 hash 表的值是通过 `库名 + 表名 + 索引名 + 值`(表示的是某一行)计算出来的 + +* WRITESET_SESSION:是在 WRITESET 的基础上多了一个约束,即在主库上同一个线程先后执行的两个事务,在备库执行的时候,要保证相同的先后顺序 + + +MySQL 5.7.22 按行并发的优势: + +* writeset 是在主库生成后直接写入到 binlog 里面的,这样在备库执行的时候,不需要解析 binlog 内容,节省了计算量 +* 不需要把整个事务的 binlog 都扫一遍才能决定分发到哪个线程,更省内存 +* 从库的分发策略不依赖于 binlog 内容,所以 binlog 是 statement 格式也可以,更节约内存(因为 row 才记录更改的行) + +MySQL 5.7.22 的并行复制策略在通用性上是有保证的,但是对于表上没主键、唯一和外键约束的场景,WRITESET 策略也是没法并行的,也会暂时退化为单线程模型 + + + +参考文章:https://time.geekbang.org/column/article/77083 + + + +*** + + + +### 读写分离 + +#### 读写延迟 + +读写分离:可以降低主库的访问压力,提高系统的并发能力 + +* 主库不建查询的索引,从库建查询的索引。因为索引需要维护的,比如插入一条数据,不仅要在聚簇索引上面插入,对应的二级索引也得插入 +* 将读操作分到从库了之后,可以在主库把查询要用的索引删了,减少写操作对主库的影响 + +读写分离产生了读写延迟,造成数据的不一致性。假如客户端执行完一个更新事务后马上发起查询,如果查询选择的是从库的话,可能读到的还是以前的数据,叫过期读 + +解决方案: + +* 强制将写之后**立刻读的操作转移到主库**,比如刚注册的用户,直接登录从库查询可能查询不到,先走主库登录 +* **二次查询**,如果从库查不到数据,则再去主库查一遍,由 API 封装,比较简单,但导致主库压力大 +* 更新主库后,读从库之前先 sleep 一下,类似于执行一条 `select sleep(1)` 命令,大多数情况下主备延迟在 1 秒之内 + + + +*** + + + +#### 确保机制 + +##### 无延迟 + +确保主备无延迟的方法: + +* 每次从库执行查询请求前,先判断 seconds_behind_master 是否已经等于 0,如果不等于那就等到参数变为 0 执行查询请求 +* 对比位点,Master_Log_File 和 Read_Master_Log_Pos 表示的是读到的主库的最新位点,Relay_Master_Log_File 和 Exec_Master_Log_Pos 表示的是备库执行的最新位点,这两组值完全相同就说明接收到的日志已经同步完成 +* 对比 GTID 集合,Retrieved_Gtid_Set 是备库收到的所有日志的 GTID 集合,Executed_Gtid_Set 是备库所有已经执行完成的 GTID 集合,如果这两个集合相同也表示备库接收到的日志都已经同步完成 + + + +*** + + + +##### 半同步 + +半同步复制就是 semi-sync replication,适用于一主一备的场景,工作流程: + +* 事务提交的时候,主库把 binlog 发给从库 +* 从库收到 binlog 以后,发回给主库一个 ack,表示收到了 +* 主库收到这个 ack 以后,才能给客户端返回事务完成的确认 + +在一主多从场景中,主库只要等到一个从库的 ack,就开始给客户端返回确认,这时在从库上执行查询请求,有两种情况: + +* 如果查询是落在这个响应了 ack 的从库上,是能够确保读到最新数据 +* 如果查询落到其他从库上,它们可能还没有收到最新的日志,就会产生过期读的问题 + +在业务更新的高峰期,主库的位点或者 GTID 集合更新很快,导致从库来不及处理,那么两个位点等值判断就会一直不成立,很可能出现从库上迟迟无法响应查询请求的情况 + + + +**** + + + +##### 等位点 + +在**从库执行判断位点**的命令,参数 file 和 pos 指的是主库上的文件名和位置,timeout 可选,设置为正整数 N 表示最多等待 N 秒 + +```mysql +SELECT master_pos_wait(file, pos[, timeout]); +``` + +命令正常返回的结果是一个正整数 M,表示从命令开始执行,到应用完 file 和 pos 表示的 binlog 位置,执行了多少事务 + +* 如果执行期间,备库同步线程发生异常,则返回 NULL +* 如果等待超过 N 秒,就返回 -1 +* 如果刚开始执行的时候,就发现已经执行过这个位置了,则返回 0 + +工作流程:先执行 trx1,再执行一个查询请求的逻辑,要**保证能够查到正确的数据** + +* trx1 事务更新完成后,马上执行 `show master status` 得到当前主库执行到的 File 和 Position +* 选定一个从库执行判断位点语句,如果返回值是 >=0 的正整数,说明从库已经同步完事务,可以在这个从库执行查询语句 +* 如果出现其他情况,需要到主库执行查询语句 + +注意:如果所有的从库都延迟超过 timeout 秒,查询压力就都跑到主库上,所以需要进行权衡 + + + +*** + + + +##### 等GTID + +数据库开启了 GTID 模式,MySQL 提供了判断 GTID 的命令 + +```mysql +SELECT wait_for_executed_gtid_set(gtid_set [, timeout]) +``` + +* 等待直到这个库执行的事务中包含传入的 gtid_set,返回 0 +* 超时返回 1 + +工作流程:先执行 trx1,再执行一个查询请求的逻辑,要保证能够查到正确的数据 + +* trx1 事务更新完成后,从返回包直接获取这个事务的 GTID,记为 gtid +* 选定一个从库执行查询语句,如果返回值是 0,则在这个从库执行查询语句,否则到主库执行查询语句 + +对比等待位点方法,减少了一次 `show master status` 的方法,将参数 session_track_gtids 设置为 OWN_GTID,然后通过 API 接口 mysql_session_track_get_first 从返回包解析出 GTID 的值即可 + +总结:所有的等待无延迟的方法,都需要根据具体的业务场景去判断实施 + + + +参考文章:https://time.geekbang.org/column/article/77636 + + + +*** + + + +#### 负载均衡 + +负载均衡是应用中使用非常普遍的一种优化方法,机制就是利用某种均衡算法,将固定的负载量分布到不同的服务器上,以此来降低单台服务器的负载,达到优化的效果 + +* 分流查询:通过 MySQL 的主从复制,实现读写分离,使增删改操作走主节点,查询操作走从节点,从而可以降低单台服务器的读写压力 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-负载均衡主从复制.jpg) + +* 分布式数据库架构:适合大数据量、负载高的情况,具有良好的拓展性和高可用性。通过在多台服务器之间分布数据,可以实现在多台服务器之间的负载均衡,提高访问效率 + + + +**** + + + +### 主从搭建 + +#### master + +1. 在master 的配置文件(/etc/mysql/my.cnf)中,配置如下内容: + + ```sh + #mysql 服务ID,保证整个集群环境中唯一 + server-id=1 + + #mysql binlog 日志的存储路径和文件名 + log-bin=/var/lib/mysql/mysqlbin + + #错误日志,默认已经开启 + #log-err + + #mysql的安装目录 + #basedir + + #mysql的临时目录 + #tmpdir + + #mysql的数据存放目录 + #datadir + + #是否只读,1 代表只读, 0 代表读写 + read-only=0 + + #忽略的数据, 指不需要同步的数据库 + binlog-ignore-db=mysql + + #指定同步的数据库 + #binlog-do-db=db01 + ``` + +2. 执行完毕之后,需要重启 MySQL + +3. 创建同步数据的账户,并且进行授权操作: + + ```mysql + GRANT REPLICATION SLAVE ON *.* TO 'seazean'@'192.168.0.137' IDENTIFIED BY '123456'; + FLUSH PRIVILEGES; + ``` + +4. 查看 master 状态: + + ```mysql + SHOW MASTER STATUS; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-查看master状态.jpg) + + * File:从哪个日志文件开始推送日志文件 + * Position:从哪个位置开始推送日志 + * Binlog_Ignore_DB:指定不需要同步的数据库 + + + +*** + + + +#### slave + +1. 在 slave 端配置文件中,配置如下内容: + + ```sh + #mysql服务端ID,唯一 + server-id=2 + + #指定binlog日志 + log-bin=/var/lib/mysql/mysqlbin + ``` + +2. 执行完毕之后,需要重启 MySQL + +3. 指定当前从库对应的主库的IP地址、用户名、密码,从哪个日志文件开始的那个位置开始同步推送日志 + + ```mysql + CHANGE MASTER TO MASTER_HOST= '192.168.0.138', MASTER_USER='seazean', MASTER_PASSWORD='seazean', MASTER_LOG_FILE='mysqlbin.000001', MASTER_LOG_POS=413; + ``` + +4. 开启同步操作: + + ```mysql + START SLAVE; + SHOW SLAVE STATUS; + ``` + +5. 停止同步操作: + + ```mysql + STOP SLAVE; + ``` + + + +*** + + + +#### 验证 + +1. 在主库中创建数据库,创建表并插入数据: + + ```mysql + CREATE DATABASE db01; + USE db01; + CREATE TABLE user( + id INT(11) NOT NULL AUTO_INCREMENT, + name VARCHAR(50) NOT NULL, + sex VARCHAR(1), + PRIMARY KEY (id) + )ENGINE=INNODB DEFAULT CHARSET=utf8; + + INSERT INTO user(id,NAME,sex) VALUES(NULL,'Tom','1'); + INSERT INTO user(id,NAME,sex) VALUES(NULL,'Trigger','0'); + INSERT INTO user(id,NAME,sex) VALUES(NULL,'Dawn','1'); + ``` + +2. 在从库中查询数据,进行验证: + + 在从库中,可以查看到刚才创建的数据库: + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-主从复制验证1.jpg) + + 在该数据库中,查询表中的数据: + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-主从复制验证2.jpg) + + + +*** + + + +### 主从切换 + +#### 正常切换 + +正常切换步骤: + +* 在开始切换之前先对主库进行锁表 `flush tables with read lock`,然后等待所有语句执行完成,切换完成后可以释放锁 + +* 检查 slave 同步状态,在 slave 执行 `show processlist` + +* 停止 slave io 线程,执行命令 `STOP SLAVE IO_THREAD` + +* 提升 slave 为 master + + ```sql + Stop slave; + Reset master; + Reset slave all; + set global read_only=off; -- 设置为可更新状态 + ``` + +* 将原来 master 变为 slave(参考搭建流程中的 slave 方法) + +**可靠性优先策略**: + +* 判断备库 B 现在的 seconds_behind_master,如果小于某个值(比如 5 秒)继续下一步,否则持续重试这一步 +* 把主库 A 改成只读状态,即把 readonly 设置为 true +* 判断备库 B 的 seconds_behind_master 的值,直到这个值变成 0 为止(该步骤比较耗时,所以步骤 1 中要尽量等待该值变小) +* 把备库 B 改成可读写状态,也就是把 readonly 设置为 false +* 把业务请求切到备库 B + +可用性优先策略:先做最后两步,会造成主备数据不一致的问题 + + + +参考文章:https://time.geekbang.org/column/article/76795 + + + +*** + + + +#### 健康检测 + +主库发生故障后从库会上位,**其他从库指向新的主库**,所以需要一个健康检测的机制来判断主库是否宕机 + +* select 1 判断,但是高并发下检测不出线程的锁等待的阻塞问题 + +* 查表判断,在系统库(mysql 库)里创建一个表,比如命名为 health_check,里面只放一行数据,然后定期执行。但是当 binlog 所在磁盘的空间占用率达到 100%,所有的更新和事务提交语句都被阻塞,查询语句可以继续运行 + +* 更新判断,在健康检测表中放一个 timestamp 字段,用来表示最后一次执行检测的时间 + + ```mysql + UPDATE mysql.health_check SET t_modified=now(); + ``` + + 节点可用性的检测都应该包含主库和备库,为了让主备之间的更新不产生冲突,可以在 mysql.health_check 表上存入多行数据,并用主备的 server_id 做主键,保证主、备库各自的检测命令不会发生冲突 + + + +*** + + + + + +#### 基于位点 + +主库上位后,从库 B 执行 CHANGE MASTER TO 命令,指定 MASTER_LOG_FILE、MASTER_LOG_POS 表示从新主库 A 的哪个文件的哪个位点开始同步,这个位置就是**同步位点**,对应主库的文件名和日志偏移量 + +寻找位点需要找一个稍微往前的,然后再通过判断跳过那些在从库 B 上已经执行过的事务,获取位点方法: + +* 等待新主库 A 把中转日志(relay log)全部同步完成 +* 在 A 上执行 show master status 命令,得到当前 A 上最新的 File 和 Position +* 取原主库故障的时刻 T,用 mysqlbinlog 工具解析新主库 A 的 File,得到 T 时刻的位点 + +通常情况下该值并不准确,在切换的过程中会发生错误,所以要先主动跳过这些错误: + +* 切换过程中,可能会重复执行一个事务,所以需要主动跳过所有重复的事务 + + ```mysql + SET GLOBAL sql_slave_skip_counter=1; + START SLAVE; + ``` + +* 设置 slave_skip_errors 参数,直接设置跳过指定的错误,保证主从切换的正常进行 + + * 1062 错误是插入数据时唯一键冲突 + * 1032 错误是删除数据时找不到行 + + 该方法针对的是主备切换时,由于找不到精确的同步位点,只能采用这种方法来创建从库和新主库的主备关系。等到主备间的同步关系建立完成并稳定执行一段时间后,还需要把这个参数设置为空,以免真的出现了主从数据不一致也跳过了 + + + +**** + + + +#### 基于GTID + +##### GTID + +GTID 的全称是 Global Transaction Identifier,全局事务 ID,是一个事务**在提交时生成**的,是这个事务的唯一标识,组成: + +```mysql +GTID=source_id:transaction_id +``` + +* source_id:是一个实例第一次启动时自动生成的,是一个全局唯一的值 +* transaction_id:初始值是 1,每次提交事务的时候分配给这个事务,并加 1,是连续的(区分事务 ID,事务 ID 是在执行时生成) + +启动 MySQL 实例时,加上参数 `gtid_mode=on` 和 `enforce_gtid_consistency=on` 就可以启动 GTID 模式,每个事务都会和一个 GTID 一一对应,每个 MySQL 实例都维护了一个 GTID 集合,用来存储当前实例**执行过的所有事务** + +GTID 有两种生成方式,使用哪种方式取决于 session 变量 gtid_next: + +* `gtid_next=automatic`:使用默认值,把 source_id:transaction_id (递增)分配给这个事务,然后加入本实例的 GTID 集合 + + ```mysql + @@SESSION.GTID_NEXT = 'source_id:transaction_id'; + ``` + +* `gtid_next=GTID`:指定的 GTID 的值,如果该值已经存在于实例的 GTID 集合中,接下来执行的事务会直接被系统忽略;反之就将该值分配给接下来要执行的事务,系统不需要给这个事务生成新的 GTID,也不用加 1 + + 注意:一个 GTID 只能给一个事务使用,所以执行下一个事务,要把 gtid_next 设置成另外一个 GTID 或者 automatic + +业务场景: + +* 主库 X 和从库 Y 执行一条相同的指令后进行事务同步 + + ```mysql + INSERT INTO t VALUES(1,1); + ``` + +* 当 Y 同步 X 时,会出现主键冲突,导致实例 X 的同步线程停止,解决方法: + + ```mysql + SET gtid_next='(这里是主库 X 的 GTID 值)'; + BEGIN; + COMMIT; + SET gtid_next=automatic; + START SLAVE; + ``` + + 前三条语句通过**提交一个空事务**,把 X 的 GTID 加到实例 Y 的 GTID 集合中,实例 Y 就会直接跳过这个事务 + + + +**** + + + +##### 切换 + +在 GTID 模式下,CHANGE MASTER TO 不需要指定日志名和日志偏移量,指定 `master_auto_position=1` 代表使用 GTID 模式 + +新主库实例 A 的 GTID 集合记为 set_a,从库实例 B 的 GTID 集合记为 set_b,主备切换逻辑: + +* 实例 B 指定主库 A,基于主备协议建立连接,实例 B 并把 set_b 发给主库 A +* 实例 A 算出 set_a 与 set_b 的差集,就是所有存在于 set_a 但不存在于 set_b 的 GTID 的集合,判断 A 本地是否包含了这个**差集**需要的所有 binlog 事务 + * 如果不包含,表示 A 已经把实例 B 需要的 binlog 给删掉了,直接返回错误 + * 如果确认全部包含,A 从自己的 binlog 文件里面,找出第一个不在 set_b 的事务,发给 B +* 实例 A 之后就从这个事务开始,往后读文件,按顺序取 binlog 发给 B 去执行 + + + +参考文章:https://time.geekbang.org/column/article/77427 + + + +*** + + + + + +## 日志 + +### 日志分类 + +在任何一种数据库中,都会有各种各样的日志,记录着数据库工作的过程,可以帮助数据库管理员追踪数据库曾经发生过的各种事件 + +MySQL日志主要包括六种: + +1. 重做日志(redo log) +2. 回滚日志(undo log) +3. 归档日志(binlog)(二进制日志) +4. 错误日志(errorlog) +5. 慢查询日志(slow query log) +6. 一般查询日志(general log) +7. 中继日志(relay log) + + + +*** + + + +### 错误日志 + +错误日志是 MySQL 中最重要的日志之一,记录了当 mysqld 启动和停止时,以及服务器在运行过程中发生任何严重错误时的相关信息。当数据库出现任何故障导致无法正常使用时,可以首先查看此日志 + +该日志是默认开启的,默认位置是:`/var/log/mysql/error.log` + +查看指令: + +```mysql +SHOW VARIABLES LIKE 'log_error%'; +``` + +查看日志内容: + +```sh +tail -f /var/log/mysql/error.log +``` + + + +*** + + + +### 归档日志 + +#### 基本介绍 + +归档日志(BINLOG)也叫二进制日志,是因为采用二进制进行存储,记录了所有的 DDL(数据定义语言)语句和 DML(数据操作语言)语句,但**不包括数据查询语句,在事务提交前的最后阶段写入** + +作用:**灾难时的数据恢复和 MySQL 的主从复制** + +归档日志默认情况下是没有开启的,需要在 MySQL 配置文件中开启,并配置 MySQL 日志的格式: + +```sh +cd /etc/mysql +vim my.cnf + +# 配置开启binlog日志, 日志的文件前缀为 mysqlbin -----> 生成的文件名如: mysqlbin.000001 +log_bin=mysqlbin +# 配置二进制日志的格式 +binlog_format=STATEMENT +``` + +日志存放位置:配置时给定了文件名但是没有指定路径,日志默认写入MySQL 的数据目录 + +日志格式: + +* STATEMENT:该日志格式在日志文件中记录的都是 **SQL 语句**,每一条对数据进行修改的 SQL 都会记录在日志文件中,通过 mysqlbinlog 工具,可以查看到每条语句的文本。主从复制时,从库会将日志解析为原语句,并在从库重新执行一遍 + + 缺点:可能会导致主备不一致,因为记录的 SQL 在不同的环境中可能选择的索引不同,导致结果不同 +* ROW:该日志格式在日志文件中记录的是每一行的**数据变更**,而不是记录 SQL 语句。比如执行 SQL 语句 `update tb_book set status='1'`,如果是 STATEMENT,在日志中会记录一行 SQL 语句; 如果是 ROW,由于是对全表进行更新,就是每一行记录都会发生变更,ROW 格式的日志中会记录每一行的数据变更 + + 缺点:记录的数据比较多,占用很多的存储空间 + +* MIXED:这是 MySQL 默认的日志格式,混合了STATEMENT 和 ROW 两种格式,MIXED 格式能尽量利用两种模式的优点,而避开它们的缺点 + + + +*** + + + +#### 日志刷盘 + +事务执行过程中,先将日志写(write)到 binlog cache,事务提交时再把 binlog cache 写(fsync)到 binlog 文件中,一个事务的 binlog 是不能被拆开的,所以不论这个事务多大也要确保一次性写入 + +事务提交时执行器把 binlog cache 里的完整事务写入到 binlog 中,并清空 binlog cache + +write 和 fsync 的时机由参数 sync_binlog 控制的: + +* sync_binlog=0:表示每次提交事务都只 write,不 fsync +* sync_binlog=1:表示每次提交事务都会执行 fsync +* sync_binlog=N(N>1):表示每次提交事务都 write,但累积 N 个事务后才 fsync,但是如果主机发生异常重启,会丢失最近 N 个事务的 binlog 日志 + + + +*** + + + +#### 日志读取 + +日志文件存储位置:/var/lib/mysql + +由于日志以二进制方式存储,不能直接读取,需要用 mysqlbinlog 工具来查看,语法如下: + +```sh +mysqlbinlog log-file; +``` + +查看 STATEMENT 格式日志: + +* 执行插入语句: + + ```mysql + INSERT INTO tb_book VALUES(NULL,'Lucene','2088-05-01','0'); + ``` + +* `cd /var/lib/mysql`: + + ```sh + -rw-r----- 1 mysql mysql 177 5月 23 21:08 mysqlbin.000001 + -rw-r----- 1 mysql mysql 18 5月 23 21:04 mysqlbin.index + ``` + + mysqlbin.index:该文件是日志索引文件 , 记录日志的文件名; + + mysqlbing.000001:日志文件 + +* 查看日志内容: + + ```sh + mysqlbinlog mysqlbing.000001; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-日志读取1.png) + + 日志结尾有 COMMIT + +查看 ROW 格式日志: + +* 修改配置: + + ```sh + # 配置二进制日志的格式 + binlog_format=ROW + ``` + +* 插入数据: + + ```mysql + INSERT INTO tb_book VALUES(NULL,'SpringCloud实战','2088-05-05','0'); + ``` + +* 查看日志内容:日志格式 ROW,直接查看数据是乱码,可以在 mysqlbinlog 后面加上参数 -vv + + ```mysql + mysqlbinlog -vv mysqlbin.000002 + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-日志读取2.png) + + + + + +*** + + + +#### 日志删除 + +对于比较繁忙的系统,生成日志量大,这些日志如果长时间不清除,将会占用大量的磁盘空间,需要删除日志 + +* Reset Master 指令删除全部 binlog 日志,删除之后,日志编号将从 xxxx.000001重新开始 + + ```mysql + Reset Master -- MySQL指令 + ``` + +* 执行指令 `PURGE MASTER LOGS TO 'mysqlbin.***`,该命令将删除 ` ***` 编号之前的所有日志 + +* 执行指令 `PURGE MASTER LOGS BEFORE 'yyyy-mm-dd hh:mm:ss'` ,该命令将删除日志为 `yyyy-mm-dd hh:mm:ss` 之前产生的日志 + +* 设置参数 `--expire_logs_days=#`,此参数的含义是设置日志的过期天数,过了指定的天数后日志将会被自动删除,这样做有利于减少管理日志的工作量,配置 my.cnf 文件: + + ```sh + log_bin=mysqlbin + binlog_format=ROW + --expire_logs_days=3 + ``` + + + + + +**** + + + +#### 数据恢复 + +误删库或者表时,需要根据 binlog 进行数据恢复 + +一般情况下数据库有定时的全量备份,假如每天 0 点定时备份,12 点误删了库,恢复流程: + +* 取最近一次全量备份,用备份恢复出一个临时库 +* 从日志文件中取出凌晨 0 点之后的日志 +* 把除了误删除数据的语句外日志,全部应用到临时库 + +跳过误删除语句日志的方法: + +* 如果原实例没有使用 GTID 模式,只能在应用到包含 12 点的 binlog 文件的时候,先用 –stop-position 参数执行到误操作之前的日志,然后再用 –start-position 从误操作之后的日志继续执行 +* 如果实例使用了 GTID 模式,假设误操作命令的 GTID 是 gtid1,那么只需要提交一个空事务先将这个 GTID 加到临时实例的 GTID 集合,之后按顺序执行 binlog 的时就会自动跳过误操作的语句 + + + +*** + + + +### 查询日志 + +查询日志中记录了客户端的所有操作语句,而二进制日志不包含查询数据的 SQL 语句 + +默认情况下,查询日志是未开启的。如果需要开启查询日志,配置 my.cnf: + +```sh +# 该选项用来开启查询日志,可选值0或者1,0代表关闭,1代表开启 +general_log=1 +# 设置日志的文件名,如果没有指定,默认的文件名为host_name.log,存放在/var/lib/mysql +general_log_file=mysql_query.log +``` + +配置完毕之后,在数据库执行以下操作: + +```mysql +SELECT * FROM tb_book; +SELECT * FROM tb_book WHERE id = 1; +UPDATE tb_book SET name = 'lucene入门指南' WHERE id = 5; +SELECT * FROM tb_book WHERE id < 8 +``` + +执行完毕之后, 再次来查询日志文件: + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-查询日志.png) + + + +*** + + + +### 慢日志 + +慢查询日志记录所有执行时间超过 long_query_time 并且扫描记录数不小于 min_examined_row_limit 的所有的 SQL 语句的日志long_query_time 默认为 10 秒,最小为 0, 精度到微秒 + +慢查询日志默认是关闭的,可以通过两个参数来控制慢查询日志,配置文件 `/etc/mysql/my.cnf`: + +```sh +# 该参数用来控制慢查询日志是否开启,可选值0或者1,0代表关闭,1代表开启 +slow_query_log=1 + +# 该参数用来指定慢查询日志的文件名,存放在 /var/lib/mysql +slow_query_log_file=slow_query.log + +# 该选项用来配置查询的时间限制,超过这个时间将认为值慢查询,将需要进行日志记录,默认10s +long_query_time=10 +``` + +日志读取: + +* 直接通过 cat 指令查询该日志文件: + + ```sh + cat slow_query.log + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-慢日志读取1.png) + +* 如果慢查询日志内容很多,直接查看文件比较繁琐,可以借助 mysql 自带的 mysqldumpslow 工具对慢查询日志进行分类汇总: + + ```sh + mysqldumpslow slow_query.log + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-慢日志读取2.png) + + + + + +*** + + + +## 范式 + +### 第一范式 + +建立科学的,**规范的数据表**就需要满足一些规则来优化数据的设计和存储,这些规则就称为范式 + +**1NF:**数据库表的每一列都是不可分割的原子数据项,不能是集合、数组等非原子数据项。即表中的某个列有多个值时,必须拆分为不同的列。简而言之,**第一范式每一列不可再拆分,称为原子性** + +基本表: + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/普通表.png) + + +第一范式表: + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/第一范式.png) + + + + + +**** + + + +### 第二范式 + +**2NF:**在满足第一范式的基础上,非主属性完全依赖于主码(主关键字、主键),消除非主属性对主码的部分函数依赖。简而言之,**表中的每一个字段 (所有列)都完全依赖于主键,记录的唯一性** + +作用:遵守第二范式减少数据冗余,通过主键区分相同数据。 + +1. 函数依赖:A → B,如果通过 A 属性(属性组)的值,可以确定唯一 B 属性的值,则称 B 依赖于 A + * 学号 → 姓名;(学号,课程名称) → 分数 +2. 完全函数依赖:A → B,如果A是一个属性组,则 B 属性值的确定需要依赖于 A 属性组的所有属性值 + * (学号,课程名称) → 分数 +3. 部分函数依赖:A → B,如果 A 是一个属性组,则 B 属性值的确定只需要依赖于 A 属性组的某些属性值 + * (学号,课程名称) → 姓名 +4. 传递函数依赖:A → B,B → C,如果通过A属性(属性组)的值,可以确定唯一 B 属性的值,在通过 B 属性(属性组)的值,可以确定唯一 C 属性的值,则称 C 传递函数依赖于 A + * 学号 → 系名,系名 → 系主任 +5. 码:如果在一张表中,一个属性或属性组,被其他所有属性所完全依赖,则称这个属性(属性组)为该表的码 + * 该表中的码:(学号,课程名称) + * 主属性:码属性组中的所有属性 + * 非主属性:除码属性组以外的属性 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/第二范式.png) + + + + + +**** + + + +### 第三范式 + +**3NF:**在满足第二范式的基础上,表中的任何属性不依赖于其它非主属性,消除传递依赖。简而言之,**非主键都直接依赖于主键,而不是通过其它的键来间接依赖于主键**。 + +作用:可以通过主键 id 区分相同数据,修改数据的时候只需要修改一张表(方便修改),反之需要修改多表。 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/第三范式.png) + + + + + + + +*** + + + +### 总结 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/三大范式.png) + + + + + + + + +*** + + + + + +# Redis + +## NoSQL + +### 概述 + +NoSQL(Not-Only SQL):泛指非关系型的数据库,作为关系型数据库的补充 + +MySQL 支持 ACID 特性,保证可靠性和持久性,读取性能不高,因此需要缓存的来减缓数据库的访问压力 + +作用:应对基于海量用户和海量数据前提下的数据处理问题 + +特征: + +* 可扩容,可伸缩,SQL 数据关系过于复杂,Nosql 不存关系,只存数据 +* 大数据量下高性能,数据不存取在磁盘 IO,存取在内存 +* 灵活的数据模型,设计了一些数据存储格式,能保证效率上的提高 +* 高可用,集群 + +常见的 NoSQL:Redis、memcache、HBase、MongoDB + + + +参考书籍:https://book.douban.com/subject/25900156/ + +参考视频:https://www.bilibili.com/video/BV1CJ411m7Gc + + + +*** + + + +### Redis + +Redis (REmote DIctionary Server) :用 C 语言开发的一个开源的高性能键值对(key-value)数据库 + +特征: + +* 数据间没有必然的关联关系,**不存关系,只存数据** +* 数据**存储在内存**,存取速度快,解决了磁盘 IO 速度慢的问题 +* 内部采用**单线程**机制进行工作 +* 高性能,官方测试数据,50 个并发执行 100000 个请求,读的速度是 110000 次/s,写的速度是 81000 次/s +* 多数据类型支持 + * 字符串类型:string(String) + * 列表类型:list(LinkedList) + * 散列类型:hash(HashMap) + * 集合类型:set(HashSet) + * 有序集合类型:zset/sorted_set(TreeSet) +* 支持持久化,可以进行数据灾难恢复 + + + +*** + + + +### 安装启动 + +安装: + +* Redis 5.0 被包含在默认的 Ubuntu 20.04 软件源中 + + ```sh + sudo apt update + sudo apt install redis-server + ``` + +* 检查 Redis 状态 + + ```sh + sudo systemctl status redis-server + ``` + +启动: + +* 启动服务器——参数启动 + + ```sh + redis-server [--port port] + #redis-server --port 6379 + ``` + +* 启动服务器——配置文件启动 + + ```sh + redis-server config_file_name + #redis-server /etc/redis/conf/redis-6397.conf + ``` + +* 启动客户端: + + ```sh + redis-cli [-h host] [-p port] + #redis-cli -h 192.168.2.185 -p 6397 + ``` + + 注意:服务器启动指定端口使用的是--port,客户端启动指定端口使用的是-p + + + +*** + + + +### 基本配置 + +#### 系统目录 + +1. 创建文件结构 + + 创建配置文件存储目录 + + ```sh + mkdir conf + ``` + + 创建服务器文件存储目录(包含日志、数据、临时配置文件等) + + ```sh + mkdir data + ``` + +2. 创建配置文件副本放入 conf 目录,Ubuntu 系统配置文件 redis.conf 在目录 `/etc/redis` 中 + + ```sh + cat redis.conf | grep -v "#" | grep -v "^$" -> /conf/redis-6379.conf + ``` + + 去除配置文件的注释和空格,输出到新的文件,命令方式采用 redis-port.conf + + + +*** + + + +#### 服务器 + +* 设置服务器以守护进程的方式运行,关闭后服务器控制台中将打印服务器运行信息(同日志内容相同): + + ```sh + daemonize yes|no + ``` + +* 绑定主机地址,绑定本地IP地址,否则SSH无法访问: + + ```sh + bind ip + ``` + +* 设置服务器端口: + + ```sh + port port + ``` + +* 设置服务器文件保存地址: + + ```sh + dir path + ``` + +* 设置数据库的数量: + + ```sh + databases 16 + ``` + +* 多服务器快捷配置: + + 导入并加载指定配置文件信息,用于快速创建 redis 公共配置较多的 redis 实例配置文件,便于维护 + + ```sh + include /path/conf_name.conf + ``` + + + +*** + + + +#### 客户端 + +* 服务器允许客户端连接最大数量,默认 0,表示无限制,当客户端连接到达上限后,Redis 会拒绝新的连接: + + ```sh + maxclients count + ``` + +* 客户端闲置等待最大时长,达到最大值后关闭对应连接,如需关闭该功能,设置为 0: + + ```sh + timeout seconds + ``` + + + +*** + + + +#### 日志配置 + +设置日志记录 + +* 设置服务器以指定日志记录级别 + + ```sh + loglevel debug|verbose|notice|warning + ``` + +* 日志记录文件名 + + ```sh + logfile filename + ``` + +注意:日志级别开发期设置为 verbose 即可,生产环境中配置为 notice,简化日志输出量,降低写日志 IO 的频度 + + + +**配置文件:** + +```sh +bind 192.168.2.185 +port 6379 +#timeout 0 +daemonize no +logfile /etc/redis/data/redis-6379.log +dir /etc/redis/data +dbfilename "dump-6379.rdb" +``` + + + +*** + + + +#### 基本指令 + +帮助信息: + +* 获取命令帮助文档 + + ```sh + help [command] + #help set + ``` + +* 获取组中所有命令信息名称 + + ```sh + help [@group-name] + #help @string + ``` + +退出服务 + +* 退出客户端: + + ```sh + quit + exit + ``` + +* 退出客户端服务器快捷键: + + ```sh + Ctrl+C + ``` + + + + + + + + +*** + + + + + +## 数据库 + +### 服务器 + +Redis 服务器将所有数据库保存在**服务器状态 redisServer 结构**的 db 数组中,数组的每一项都是 redisDb 结构,代表一个数据库,每个数据库之间相互独立,**共用 **Redis 内存,不区分大小。在初始化服务器时,根据 dbnum 属性决定创建数据库的数量,该属性由服务器配置的 database 选项决定,默认 16 + +```c +struct redisServer { + // 保存服务器所有的数据库 + redisDB *db; + + // 服务器数据库的数量 + int dbnum; +}; +``` + + + +**在服务器内部**,客户端状态 redisClient 结构的 db 属性记录了目标数据库,是一个指向 redisDb 结构的指针 + +```c +struct redisClient { + // 记录客户端正在使用的数据库,指向 redisServer.db 数组中的某一个 db + redisDB *db; +}; +``` + +每个 Redis 客户端都有目标数据库,执行数据库读写命令时目标数据库就会成为这些命令的操作对象,默认情况下 Redis 客户端的目标数据库为 0 号数据库,客户端可以执行 SELECT 命令切换目标数据库,原理是通过修改 redisClient.db 指针指向服务器中不同数据库 + +命令操作: + +```sh +select index #切换数据库,index从0-15取值 +move key db #数据移动到指定数据库,db是数据库编号 +ping #测试数据库是否连接正常,返回PONG +echo message #控制台输出信息 +``` + +Redis 没有可以返回客户端目标数据库的命令,但是 redis-cli 客户端旁边会提示当前所使用的目标数据库 + +```sh +redis> SELECT 1 +OK +redis[1]> +``` + + + + + +*** + + + +### 键空间 + +#### key space + +Redis 是一个键值对(key-value pair)数据库服务器,每个数据库都由一个 redisDb 结构表示,redisDb.dict **字典中保存了数据库的所有键值对**,将这个字典称为键空间(key space) + +```c +typedef struct redisDB { + // 数据库键空间,保存所有键值对 + dict *dict +} redisDB; +``` + +键空间和用户所见的数据库是直接对应的: + +* 键空间的键就是数据库的键,每个键都是一个字符串对象 +* 键空间的值就是数据库的值,每个值可以是任意一种 Redis 对象 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-数据库键空间.png) + +当使用 Redis 命令对数据库进行读写时,服务器不仅会对键空间执行指定的读写操作,还会**进行一些维护操作**: + +* 在读取一个键后(读操作和写操作都要对键进行读取),服务器会根据键是否存在来更新服务器的键空间命中 hit 次数或键空间不命中 miss 次数,这两个值可以在 `INFO stats` 命令的 keyspace_hits 属性和 keyspace_misses 属性中查看 +* 更新键的 LRU(最后使用)时间,该值可以用于计算键的闲置时间,使用 `OBJECT idletime key` 查看键 key 的闲置时间 +* 如果在读取一个键时发现该键已经过期,服务器会**先删除过期键**,再执行其他操作 +* 如果客户端使用 WATCH 命令监视了某个键,那么服务器在对被监视的键进行修改之后,会将这个键标记为脏(dirty),从而让事务注意到这个键已经被修改过 +* 服务器每次修改一个键之后,都会对 dirty 键计数器的值增1,该计数器会触发服务器的持久化以及复制操作 +* 如果服务器开启了数据库通知功能,那么在对键进行修改之后,服务器将按配置发送相应的数据库通知 + + + +*** + + + +#### 读写指令 + +常见键操作指令: + +* 增加指令 + + ```sh + set key value #添加一个字符串类型的键值对 + +* 删除指令 + + ```sh + del key #删除指定key + unlink key #非阻塞删除key,真正的删除会在后续异步操作 + ``` + +* 更新指令 + + ```sh + rename key newkey #改名 + renamenx key newkey #改名 + ``` + + 值得更新需要参看具体得 Redis 对象得操作方式,比如字符串对象执行 `SET key value` 就可以完成修改 + +* 查询指令 + + ```sh + exists key #获取key是否存在 + randomkey #随机返回一个键 + keys pattern #查询key + ``` + + KEYS 命令需要**遍历存储的键值对**,操作延时高,一般不被建议用于生产环境中 + + 查询模式规则:* 匹配任意数量的任意符号、? 配合一个任意符号、[] 匹配一个指定符号 + + ```sh + keys * #查询所有key + keys aa* #查询所有以aa开头 + keys *bb #查询所有以bb结尾 + keys ??cc #查询所有前面两个字符任意,后面以cc结尾 + keys user:? #查询所有以user:开头,最后一个字符任意 + keys u[st]er:1 #查询所有以u开头,以er:1结尾,中间包含一个字母,s或t + ``` + + +* 其他指令 + + ```sh + type key #获取key的类型 + dbsize #获取当前数据库的数据总量,即key的个数 + flushdb #清除当前数据库的所有数据(慎用) + flushall #清除所有数据(慎用) + ``` + + 在执行 FLUSHDB 这样的危险命令之前,最好先执行一个 SELECT 命令,保证当前所操作的数据库是目标数据库 + + + + + +*** + + + +#### 时效设置 + +客户端可以以秒或毫秒的精度为数据库中的某个键设置生存时间(TimeTo Live, TTL),在经过指定时间之后,服务器就会自动删除生存时间为 0 的键;也可以以 UNIX 时间戳的方式设置过期时间(expire time),当键的过期时间到达,服务器会自动删除这个键 + +```sh +expire key seconds #为指定key设置生存时间,单位为秒 +pexpire key milliseconds #为指定key设置生存时间,单位为毫秒 +expireat key timestamp #为指定key设置过期时间,单位为时间戳 +pexpireat key mil-timestamp #为指定key设置过期时间,单位为毫秒时间戳 +``` + +* 实际上 EXPIRE、EXPIRE、EXPIREAT 三个命令**底层都是转换为 PEXPIREAT 命令**来实现的 +* SETEX 命令可以在设置一个字符串键的同时为键设置过期时间,但是该命令是一个类型限定命令 + +redisDb 结构的 expires 字典保存了数据库中所有键的过期时间,字典称为过期字典: + +* 键是一个指针,指向键空间中的某个键对象(复用键空间的对象,不会产生内存浪费) +* 值是一个 long long 类型的整数,保存了键的过期时间,是一个毫秒精度的 UNIX 时间戳 + +```c +typedef struct redisDB { + // 过期字典,保存所有键的过期时间 + dict *expires +} redisDB; +``` + +客户端执行 PEXPIREAT 命令,服务器会在数据库的过期字典中关联给定的数据库键和过期时间: + +```python +def PEXPIREAT(key, expire_time_in_ms): + # 如果给定的键不存在于键空间,那么不能设置过期时间 + if key not in redisDb.dict: + return 0 + + # 在过期字典中关联键和过期时间 + redisDB.expires[key] = expire_time_in_ms + + # 过期时间设置成功 + return 1 +``` + + + +**** + + + +#### 时效状态 + +TTL 和 PTTL 命令通过计算键的过期时间和当前时间之间的差,返回这个键的剩余生存时间 + +* 返回正数代表该数据在内存中还能存活的时间 +* 返回 -1 代表永久性,返回 -2 代表键不存在 + +```sh +ttl key #获取key的剩余时间,每次获取会自动变化(减小),类似于倒计时 +pttl key #获取key的剩余时间,单位是毫秒,每次获取会自动变化(减小) +``` + +PERSIST 是 PEXPIREAT 命令的反操作,在过期字典中查找给定的键,并解除键和值(过期时间)在过期字典中的关联 + +```sh +persist key #切换key从时效性转换为永久性 +``` + +Redis 通过过期字典可以检查一个给定键是否过期: + +* 检查给定键是否存在于过期字典:如果存在,那么取得键的过期时间 +* 检查当前 UNIX 时间戳是否大于键的过期时间:如果是那么键已经过期,否则键未过期 + +补充:AOF、RDB 和复制功能对过期键的处理 + +* RDB : + * 生成 RDB 文件,程序会对数据库中的键进行检查,已过期的键不会被保存到新创建的 RDB 文件中 + * 载入 RDB 文件,如果服务器以主服务器模式运行,那么在载入时会对键进行检查,过期键会被忽略;如果服务器以从服务器模式运行,会载入所有键,包括过期键,但是主从服务器进行数据同步时就会删除这些键 +* AOF: + * 写入 AOF 文件,如果数据库中的某个键已经过期,但还没有被删除,那么 AOF 文件不会因为这个过期键而产生任何影响;当该过期键被删除,程序会向 AOF 文件追加一条 DEL 命令,显式的删除该键 + * AOF 重写,会对数据库中的键进行检查,忽略已经过期的键 +* 复制:当服务器运行在复制模式下时,从服务器的过期键删除动作由主服务器控制 + * 主服务器在删除一个过期键之后,会显式地向所有从服务器发送一个 DEL 命令,告知从服务器删除这个过期键 + * 从服务器在执行客户端发送的读命令时,即使碰到过期键也不会将过期键删除,会当作未过期键处理,只有在接到主服务器发来的 DEL 命令之后,才会删除过期键(数据不一致) + + + + + +**** + + + +### 过期删除 + +#### 删除策略 + +删除策略就是**针对已过期数据的处理策略**,已过期的数据不一定被立即删除,在不同的场景下使用不同的删除方式会有不同效果,在内存占用与 CPU 占用之间寻找一种平衡,顾此失彼都会造成整体 Redis 性能的下降,甚至引发服务器宕机或内存泄露 + +针对过期数据有三种删除策略: + +- 定时删除 +- 惰性删除(被动删除) +- 定期删除 + +Redis 采用惰性删除和定期删除策略的结合使用 + + + +*** + + + +#### 定时删除 + +在设置键的过期时间的同时,创建一个定时器(timer),让定时器在键的过期时间到达时,立即执行对键的删除操作 + +- 优点:节约内存,到时就删除,快速释放掉不必要的内存占用 +- 缺点:对 CPU 不友好,无论 CPU 此时负载多高均占用 CPU,会影响 Redis 服务器响应时间和指令吞吐量 +- 总结:用处理器性能换取存储空间(拿时间换空间) + +创建一个定时器需要用到 Redis 服务器中的时间事件,而时间事件的实现方式是无序链表,查找一个事件的时间复杂度为 O(N),并不能高效地处理大量时间事件,所以采用这种方式并不现实 + + + +*** + + + +#### 惰性删除 + +数据到达过期时间不做处理,等下次访问到该数据时执行 **expireIfNeeded()** 判断: + +* 如果输入键已经过期,那么 expireIfNeeded 函数将输入键从数据库中删除,接着访问就会返回空 +* 如果输入键未过期,那么 expireIfNeeded 函数不做动作 + +所有的 Redis 读写命令在执行前都会调用 expireIfNeeded 函数进行检查,该函数就像一个过滤器,在命令真正执行之前过滤掉过期键 + +惰性删除的特点: + +* 优点:节约 CPU 性能,删除的目标仅限于当前处理的键,不会在删除其他无关的过期键上花费任何 CPU 时间 +* 缺点:内存压力很大,出现长期占用内存的数据,如果过期键永远不被访问,这种情况相当于内存泄漏 +* 总结:用存储空间换取处理器性能(拿空间换时间) + + + +*** + + + +#### 定期删除 + +定期删除策略是每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响 + +* 如果删除操作执行得太频繁,或者执行时间太长,就会退化成定时删除策略,将 CPU 时间过多地消耗在删除过期键上 +* 如果删除操作执行得太少,或者执行时间太短,定期删除策略又会和惰性删除策略一样,出现浪费内存的情况 + +定期删除是**周期性轮询 Redis 库中的时效性**数据,从过期字典中随机抽取一部分键检查,利用过期数据占比的方式控制删除频度 + +- Redis 启动服务器初始化时,读取配置 server.hz 的值,默认为 10,执行指令 info server 可以查看,每秒钟执行 server.hz 次 `serverCron() → activeExpireCycle()` + +- activeExpireCycle() 对某个数据库中的每个 expires 进行检测,工作模式: + + * 轮询每个数据库,从数据库中取出一定数量的随机键进行检查,并删除其中的过期键,如果过期 key 的比例超过了 25%,则继续重复此过程,直到过期 key 的比例下降到 25% 以下,或者这次任务的执行耗时超过了 25 毫秒 + + * 全局变量 current_db 用于记录 activeExpireCycle() 的检查进度(哪一个数据库),下一次调用时接着该进度处理 + * 随着函数的不断执行,服务器中的所有数据库都会被检查一遍,这时将 current_db 重置为 0,然后再次开始新一轮的检查 + +定期删除特点: + +- CPU 性能占用设置有峰值,检测频度可自定义设置 +- 内存压力不是很大,长期占用内存的**冷数据会被持续清理** +- 周期性抽查存储空间(随机抽查,重点抽查) + + + + + +*** + + + +### 数据淘汰 + +#### 逐出算法 + +数据淘汰策略:当新数据进入 Redis 时,在执行每一个命令前,会调用 **freeMemoryIfNeeded()** 检测内存是否充足。如果内存不满足新加入数据的最低存储要求,Redis 要临时删除一些数据为当前指令清理存储空间,清理数据的策略称为**逐出算法** + +逐出数据的过程不是 100% 能够清理出足够的可使用的内存空间,如果不成功则反复执行,当对所有数据尝试完毕,如不能达到内存清理的要求,**出现 Redis 内存打满异常**: + +```sh +(error) OOM command not allowed when used memory >'maxmemory' +``` + + + +**** + + + +#### 策略配置 + +Redis 如果不设置最大内存大小或者设置最大内存大小为 0,在 64 位操作系统下不限制内存大小,在 32 位操作系统默认为 3GB 内存,一般推荐设置 Redis 内存为最大物理内存的四分之三 + +内存配置方式: + +* 通过修改文件配置(永久生效):修改配置文件 maxmemory 字段,单位为字节 + +* 通过命令修改(重启失效): + + * `config set maxmemory 104857600`:设置 Redis 最大占用内存为 100MB + * `config get maxmemory`:获取 Redis 最大占用内存 + + * `info` :可以查看 Redis 内存使用情况,`used_memory_human` 字段表示实际已经占用的内存,`maxmemory` 表示最大占用内存 + +影响数据淘汰的相关配置如下,配置 conf 文件: + +* 每次选取待删除数据的个数,采用随机获取数据的方式作为待检测删除数据,防止全库扫描,导致严重的性能消耗,降低读写性能 + + ```sh + maxmemory-samples count + ``` + +* 达到最大内存后的,对被挑选出来的数据进行删除的策略 + + ```sh + maxmemory-policy policy + ``` + + 数据删除的策略 policy:3 类 8 种 + + 第一类:检测易失数据(可能会过期的数据集 server.db[i].expires): + + ```sh + volatile-lru # 对设置了过期时间的 key 选择最近最久未使用使用的数据淘汰 + volatile-lfu # 对设置了过期时间的 key 选择最近使用次数最少的数据淘汰 + volatile-ttl # 对设置了过期时间的 key 选择将要过期的数据淘汰 + volatile-random # 对设置了过期时间的 key 选择任意数据淘汰 + ``` + + 第二类:检测全库数据(所有数据集 server.db[i].dict ): + + ```sh + allkeys-lru # 对所有 key 选择最近最少使用的数据淘汰 + allkeLyRs-lfu # 对所有 key 选择最近使用次数最少的数据淘汰 + allkeys-random # 对所有 key 选择任意数据淘汰,相当于随机 + ``` + + 第三类:放弃数据驱逐 + + ```sh + no-enviction #禁止驱逐数据(redis4.0中默认策略),会引发OOM(Out Of Memory) + ``` + +数据淘汰策略配置依据:使用 INFO 命令输出监控信息,查询缓存 hit 和 miss 的次数,根据需求调优 Redis 配置 + + + + + +*** + + + +### 排序机制 + +#### 基本介绍 + +Redis 的 SORT 命令可以对列表键、集合键或者有序集合键的值进行排序,并不更改集合中的数据位置,只是查询 + +```sh +SORT key [ASC/DESC] #对key中数据排序,默认对数字排序,并不更改集合中的数据位置,只是查询 +SORT key ALPHA #对key中字母排序,按照字典序 +``` + + + + + +*** + + + +#### SORT + +`SORT ` 命令可以对一个包含数字值的键 key 进行排序 + +假设 `RPUSH numbers 3 1 2`,执行 `SORT numbers` 的详细步骤: + +* 创建一个和 key 列表长度相同的数组,数组每项都是 redisSortObject 结构 + + ```c + typedef struct redisSortObject { + // 被排序键的值 + robj *obj; + + // 权重 + union { + // 排序数字值时使用 + double score; + // 排序带有 BY 选项的字符串 + robj *cmpobj; + } u; + } + ``` + +* 遍历数组,将各个数组项的 obj 指针分别指向 numbers 列表的各个项 + +* 遍历数组,将 obj 指针所指向的列表项转换成一个 double 类型的浮点数,并将浮点数保存在对应数组项的 u.score 属性里 + +* 根据数组项 u.score 属性的值,对数组进行数字值排序,排序后的数组项按 u.score 属性的值**从小到大排列** + +* 遍历数组,将各个数组项的 obj 指针所指向的值作为排序结果返回给客户端,程序首先访问数组的索引 0,依次向后访问 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-sort排序.png) + +对于 `SORT key [ASC/DESC]` 函数: + +* 在执行升序排序时,排序算法使用的对比函数产生升序对比结果 +* 在执行降序排序时,排序算法使用的对比函数产生降序对比结果 + + + +**** + + + +#### BY + +SORT 命令默认使用被排序键中包含的元素作为排序的权重,元素本身决定了元素在排序之后所处的位置,通过使用 BY 选项,SORT 命令可以指定某些字符串键,或者某个哈希键所包含的某些域(field)来作为元素的权重,对一个键进行排序 + +```sh +SORT BY # 数值 +SORT BY ALPHA # 字符 +``` + +```sh +redis> SADD fruits "apple" "banana" "cherry" +(integer) 3 +redis> SORT fruits ALPHA +1) "apple" +2) "banana" +3) "cherry" +``` + +```sh +redis> MSET apple-price 8 banana-price 5.5 cherry-price 7 +OK +# 使用水果的价钱进行排序 +redis> SORT fruits BY *-price +1) "banana" +2) "cherry" +3) "apple" +``` + +实现原理:排序时的 u.score 属性就会被设置为对应的权重 + + + + + +*** + + + +#### LIMIT + +SORT 命令默认会将排序后的所有元素都返回给客户端,通过 LIMIT 选项可以让 SORT 命令只返回其中一部分已排序的元素 + +```sh +LIMIT +``` + +* offset 参数表示要跳过的已排序元素数量 +* count 参数表示跳过给定数量的元素后,要返回的已排序元素数量 + +```sh +# 对应 a b c d e f g +redis> SORT alphabet ALPHA LIMIT 2 3 +1) "c" +2) "d" +3) "e" +``` + +实现原理:在排序后的 redisSortObject 结构数组中,将指针移动到数组的索引 2 上,依次访问 array[2]、array[3]、array[4] 这 3 个数组项,并将数组项的 obj 指针所指向的元素返回给客户端 + + + + + +*** + + + +#### GET + +SORT 命令默认在对键进行排序后,返回被排序键本身所包含的元素,通过使用 GET 选项, 可以在对键进行排序后,根据被排序的元素以及 GET 选项所指定的模式,查找并返回某些键的值 + +```sh +SORT GET +``` + +```sh +redis> SADD students "tom" "jack" "sea" +#设置全名 +redis> SET tom-name "Tom Li" +OK +redis> SET jack-name "Jack Wang" +OK +redis> SET sea-name "Sea Zhang" +OK +``` + +```sh +redis> SORT students ALPHA GET *-name +1) "Jack Wang" +2) "Sea Zhang" +3) "Tom Li" +``` + +实现原理:对 students 进行排序后,对于 jack 元素和 *-name 模式,查找程序返回键 jack-name,然后获取 jack-name 键对应的值 + + + + + +*** + + + +#### STORE + +SORT 命令默认只向客户端返回排序结果,而不保存排序结果,通过使用 STORE 选项可以将排序结果保存在指定的键里面 + +```sh +SORT STORE +``` + +```sh +redis> SADD students "tom" "jack" "sea" +(integer) 3 +redis> SORT students ALPHA STORE sorted_students +(integer) 3 +``` + +实现原理:排序后,检查 sorted_students 键是否存在,如果存在就删除该键,设置 sorted_students 为空白的列表键,遍历排序数组将元素依次放入 + + + + + +*** + + + +#### 执行顺序 + +调用 SORT 命令,除了 GET 选项之外,改变其他选项的摆放顺序并不会影响命令执行选项的顺序 + +```sh +SORT ALPHA [ASC/DESC] BY LIMIT GET STORE +``` + +执行顺序: + +* 排序:命令会使用 ALPHA 、ASC 或 DESC、BY 这几个选项,对输入键进行排序,并得到一个排序结果集 +* 限制排序结果集的长度:使用 LIMIT 选项,对排序结果集的长度进行限制 +* 获取外部键:根据排序结果集中的元素以及 GET 选项指定的模式,查找并获取指定键的值,并用这些值来作为新的排序结果集 +* 保存排序结果集:使用 STORE 选项,将排序结果集保存到指定的键上面去 +* 向客户端返回排序结果集:最后一步命令遍历排序结果集,并依次向客户端返回排序结果集中的元素 + + + + + +*** + + + +### 通知机制 + +数据库通知是可以让客户端通过订阅给定的频道或者模式,来获知数据库中键的变化,以及数据库中命令的执行情况 + +* 关注某个键执行了什么命令的通知称为键空间通知(key-space notification) +* 关注某个命令被什么键执行的通知称为键事件通知(key-event notification) + +图示订阅 0 号数据库 message 键: + + + +服务器配置的 notify-keyspace-events 选项决定了服务器所发送通知的类型 + +* AKE 代表服务器发送所有类型的键空间通知和键事件通知 +* AK 代表服务器发送所有类型的键空间通知 +* AE 代表服务器发送所有类型的键事件通知 +* K$ 代表服务器只发送和字符串键有关的键空间通知 +* EL 代表服务器只发送和列表键有关的键事件通知 +* ..... + +发送数据库通知的功能是由 notifyKeyspaceEvent 函数实现的: + +* 如果给定的通知类型 type 不是服务器允许发送的通知类型,那么函数会直接返回 +* 如果给定的通知是服务器允许发送的通知 + * 检测服务器是否允许发送键空间通知,允许就会构建并发送事件通知 + * 检测服务器是否允许发送键事件通知,允许就会构建并发送事件通知 + + + + + +*** + + + + + +## 体系架构 + +### 事件驱动 + +#### 基本介绍 + +Redis 服务器是一个事件驱动程序,服务器需要处理两类事件 + +* 文件事件 (file event):服务器通过套接字与客户端(或其他 Redis 服务器)进行连接,而文件事件就是服务器对套接字操作的抽象。服务器与客户端的通信会产生相应的文件事件,服务器通过监听并处理这些事件完成一系列网络通信操作 +* 时间事件 (time event):Redis 服务器中的一些操作(比如 serverCron 函数)需要在指定时间执行,而时间事件就是服务器对这类定时操作的抽象 + + + + + +*** + + + +#### 文件事件 + +##### 基本组成 + +Redis 基于 Reactor 模式开发了网络事件处理器,这个处理器被称为文件事件处理器 (file event handler) + +* 使用 I/O 多路复用 (multiplexing) 程序来同时监听多个套接字,并根据套接字执行的任务来为套接字关联不同的事件处理器 + +* 当被监听的套接字准备好执行连接应答 (accept)、 读取 (read)、 写入 (write)、 关闭 (close) 等操作时,与操作相对应的文件事件就会产生,这时文件事件分派器会调用套接字关联好的事件处理器来处理事件 + +文件事件处理器**以单线程方式运行**,但通过使用 I/O 多路复用程序来监听多个套接字, 既实现了高性能的网络通信模型,又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接,保持了 Redis 内部单线程设计的简单性 + +文件事件处理器的组成结构: + + + +I/O 多路复用程序将所有产生事件的套接字处理请求放入一个**单线程的执行队列**中,通过队列有序、同步的向文件事件分派器传送套接字,上一个套接字产生的事件处理完后,才会继续向分派器传送下一个 + + + +Redis 单线程也能高效的原因: + +* 纯内存操作 +* 核心是基于非阻塞的 IO 多路复用机制,单线程可以高效处理多个请求 +* 底层使用 C 语言实现,C 语言实现的程序距离操作系统更近,执行速度相对会更快 +* 单线程同时也**避免了多线程的上下文频繁切换问题**,预防了多线程可能产生的竞争问题 + + + +**** + + + +##### 多路复用 + +Redis 的 I/O 多路复用程序的所有功能都是通过包装常见的 select 、epoll、 evport 和 kqueue 这些函数库来实现的,Redis 在 I/O 多路复用程序的实现源码中用 #include 宏定义了相应的规则,编译时自动选择系统中**性能最高的多路复用函数**来作为底层实现 + +I/O 多路复用程序监听多个套接字的 AE_READABLE 事件和 AE_WRITABLE 事件,这两类事件和套接字操作之间的对应关系如下: + +* 当套接字变得**可读**时(客户端对套接字执行 write 操作或者 close 操作),或者有新的**可应答**(acceptable)套接字出现时(客户端对服务器的监听套接字执行 connect 连接操作),套接字产生 AE_READABLE 事件 +* 当套接字变得可写时(客户端对套接字执行 read 操作,对于服务器来说就是可以写了),套接字产生 AE_WRITABLE 事件 + +I/O 多路复用程序允许服务器同时监听套接字的 AE_READABLE 和 AE_WRITABLE 事件, 如果一个套接字同时产生了这两种事件,那么文件事件分派器会优先处理 AE_READABLE 事件, 等 AE_READABLE 事件处理完之后才处理 AE_WRITABLE 事件 + + + +*** + + + +##### 处理器 + +Redis 为文件事件编写了多个处理器,这些事件处理器分别用于实现不同的网络通信需求: + +* 连接应答处理器,用于对连接服务器的各个客户端进行应答,Redis 服务器初始化时将该处理器与 AE_READABLE 事件关联 +* 命令请求处理器,用于接收客户端传来的命令请求,执行套接字的读入操作,与 AE_READABLE 事件关联 +* 命令回复处理器,用于向客户端返回命令的执行结果,执行套接字的写入操作,与 AE_WRITABLE 事件关联 +* 复制处理器,当主服务器和从服务器进行复制操作时,主从服务器都需要关联该处理器 + +Redis 客户端与服务器进行连接并发送命令的整个过程: + +* Redis 服务器正在运作监听套接字的 AE_READABLE 事件,关联连接应答处理器 +* 当 Redis 客户端向服务器发起连接,监听套接字将产生 AE_READABLE 事件,触发连接应答处理器执行,对客户端的连接请求进行应答,创建客户端套接字以及客户端状态,并将客户端套接字的 **AE_READABLE 事件与命令请求处理器**进行关联 +* 客户端向服务器发送命令请求,客户端套接字产生 AE_READABLE 事件,引发命令请求处理器执行,读取客户端的命令内容传给相关程序去执行 +* 执行命令会产生相应的命令回复,为了将这些命令回复传送回客户端,服务器会将客户端套接字的 **AE_WRITABLE 事件与命令回复处理器**进行关联 +* 当客户端尝试读取命令回复时,客户端套接字产生 AE_WRITABLE 事件,触发命令回复处理器执行,在命令回复全部写入套接字后,服务器就会解除客户端套接字的 AE_WRITABLE 事件与命令回复处理器之间的关联 + + + + + +*** + + + +#### 时间事件 + +Redis 的时间事件分为以下两类: + +* 定时事件:在指定的时间之后执行一次(Redis 中暂时未使用) +* 周期事件:每隔指定时间就执行一次 + +一个时间事件主要由以下三个属性组成: + +* id:服务器为时间事件创建的全局唯一 ID(标识号),从小到大顺序递增,新事件的 ID 比旧事件的 ID 号要大 +* when:毫秒精度的 UNIX 时间戳,记录了时间事件的到达(arrive)时间 +* timeProc:时间事件处理器,当时间事件到达时,服务器就会调用相应的处理器来处理事件 + +时间事件是定时事件还是周期性事件取决于时间事件处理器的返回值: + +* 定时事件:事件处理器返回 AE_NOMORE,该事件在到达一次后就会被删除 +* 周期事件:事件处理器返回非 AE_NOMORE 的整数值,服务器根据该值对事件的 when 属性更新,让该事件在一段时间后再次交付 + +服务器将所有时间事件都放在一个**无序链表**中,新的时间事件插入到链表的表头: + + + +无序链表指是链表不按 when 属性的大小排序,每当时间事件执行器运行时就必须遍历整个链表,查找所有已到达的时间事件,并调用相应的事件处理器处理 + +无序链表并不影响时间事件处理器的性能,因为正常模式下的 Redis 服务器**只使用 serverCron 一个时间事件**,在 benchmark 模式下服务器也只使用两个时间事件,所以无序链表不会影响服务器的性能,几乎可以按照一个指针处理 + + + +*** + + + +#### 事件调度 + +服务器中同时存在文件事件和时间事件两种事件类型,调度伪代码: + +```python +# 事件调度伪代码 +def aeProcessEvents(): + # 获取到达时间离当前时间最接近的时间事件 + time_event = aeSearchNearestTime() + + # 计算最接近的时间事件距离到达还有多少亳秒 + remaind_ms = time_event.when - unix_ts_now() + # 如果事件已到达,那么 remaind_ms 的值可能为负数,设置为 0 + if remaind_ms < 0: + remaind_ms = 0 + + # 根据 remaind_ms 的值,创建 timeval 结构 + timeval = create_timeval_with_ms(remaind_ms) + # 【阻塞并等待文件事件】产生,最大阻塞时间由传入的timeval结构决定,remaind_ms的值为0时调用后马上返回,不阻塞 + aeApiPoll(timeval) + + # 处理所有已产生的文件事件 + processFileEvents() + # 处理所有已到达的时间事件 + processTimeEvents() +``` + +事件的调度和执行规则: + +* aeApiPoll 函数的最大阻塞时间由到达时间最接近当前时间的时间事件决定,可以避免服务器对时间事件进行频繁的轮询(忙等待),也可以确保 aeApiPoll 函数不会阻塞过长时间 +* 对文件事件和时间事件的处理都是**同步、有序、原子地执行**,服务器不会中途中断事件处理,也不会对事件进行抢占,所以两种处理器都要尽可地减少程序的阻塞时间,并在有需要时**主动让出执行权**,从而降低事件饥饿的可能性 + * 命令回复处理器在写入字节数超过了某个预设常量,就会主动用 break 跳出写入循环,将余下的数据留到下次再写 + * 时间事件也会将非常耗时的持久化操作放到子线程或者子进程执行 +* 时间事件在文件事件之后执行,并且事件之间不会出现抢占,所以时间事件的实际处理时间通常会比设定的到达时间稍晚 + + + + + +**** + + + +#### 多线程 + +Redis6.0 引入多线程主要是为了提高网络 IO 读写性能,因为这是 Redis 的一个性能瓶颈(Redis 的瓶颈主要受限于内存和网络),多线程只是用来**处理网络数据的读写和协议解析**, 执行命令仍然是单线程顺序执行,因此不需要担心线程安全问题。 + +Redis6.0 的多线程默认是禁用的,只使用主线程。如需开启需要修改 redis 配置文件 `redis.conf` : + +```sh +io-threads-do-reads yesCopy to clipboardErrorCopied +``` + +开启多线程后,还需要设置线程数,否则是不生效的,同样需要修改 redis 配置文件 : + +```sh +io-threads 4 #官网建议4核的机器建议设置为2或3个线程,8核的建议设置为6个线程 +``` + + + + + +参考文章:https://mp.weixin.qq.com/s/dqmiR0ECf4lB6Y2OyK-dyA + + + + + +**** + + + +### 客户端 + +#### 基本介绍 + +Redis 服务器是典型的一对多程序,一个服务器可以与多个客户端建立网络连接,服务器对每个连接的客户端建立了相应的 redisClient 结构(客户端状态,**在服务器端的存储结构**),保存了客户端当前的状态信息,以及执行相关功能时需要用到的数据结构 + +Redis 服务器状态结构的 clients 属性是一个链表,这个链表保存了所有与服务器连接的客户端的状态结构: + +```c +struct redisServer { + // 一个链表,保存了所有客户端状态 + list *clients; + + //... +}; +``` + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-服务器clients链表.png) + + + + + +*** + + + +#### 数据结构 + +##### redisClient + +客户端的数据结构: + +```c +typedef struct redisClient { + //... + + // 套接字 + int fd; + // 名字 + robj *name; + // 标志 + int flags; + + // 输入缓冲区 + sds querybuf; + // 输出缓冲区 buf 数组 + char buf[REDIS_REPLY_CHUNK_BYTES]; + // 记录了 buf 数组目前已使用的字节数量 + int bufpos; + // 可变大小的输出缓冲区,链表 + 字符串对象 + list *reply; + + // 命令数组 + rboj **argv; + // 命令数组的长度 + int argc; + // 命令的信息 + struct redisCommand *cmd; + + // 是否通过身份验证 + int authenticated; + + // 创建客户端的时间 + time_t ctime; + // 客户端与服务器最后一次进行交互的时间 + time_t lastinteraction; + // 输出缓冲区第一次到达软性限制 (soft limit) 的时间 + time_t obuf_soft_limit_reached_time; +} +``` + +客户端状态包括两类属性 + +* 一类是比较通用的属性,这些属性很少与特定功能相关,无论客户端执行的是什么工作,都要用到这些属性 +* 另一类是和特定功能相关的属性,比如操作数据库时用到的 db 属性和 dict id 属性,执行事务时用到的 mstate 属性,以及执行 WATCH 命令时用到的 watched_keys 属性等,代码中没有列出 + + + +*** + + + +##### 套接字 + +客户端状态的 fd 属性记录了客户端正在使用的套接字描述符,根据客户端类型的不同,fd 属性的值可以是 -1 或者大于 -1 的整数: + +* 伪客户端 (fake client) 的 fd 属性的值为 -1,命令请求来源于 AOF 文件或者 Lua 脚本,而不是网络,所以不需要套接字连接 +* 普通客户端的 fd 属性的值为大于 -1 的整数,因为合法的套接字描述符不能是 -1 + +执行 `CLIENT list` 命令可以列出目前所有连接到服务器的普通客户端,不包括伪客户端 + + + +*** + + + +##### 名字 + +在默认情况下,一个连接到服务器的客户端是没有名字的,使用 `CLIENT setname` 命令可以为客户端设置一个名字 + + + +*** + + + +##### 标志 + +客户端的标志属性 flags 记录了客户端的角色以及客户端目前所处的状态,每个标志使用一个常量表示 + +* flags 的值可以是单个标志:`flags = ` +* flags 的值可以是多个标志的二进制:`flags = | | ... ` + +一部分标志记录**客户端的角色**: + +* REDIS_MASTER 表示客户端是一个从服务器,REDIS_SLAVE 表示客户端是一个从服务器,在主从复制时使用 +* REDIS_PRE_PSYNC 表示客户端是一个版本低于 Redis2.8 的从服务器,主服务器不能使用 PSYNC 命令与该从服务器进行同步,这个标志只能在 REDIS_ SLAVE 标志处于打开状态时使用 +* REDIS_LUA_CLIENT 表示客户端是专门用于处理 Lua 脚本里面包含的 Redis 命令的伪客户端 + +一部分标志记录目前**客户端所处的状态**: + +* REDIS_UNIX_SOCKET 表示服务器使用 UNIX 套接字来连接客户端 +* REDIS_BLOCKED 表示客户端正在被 BRPOP、BLPOP 等命令阻塞 +* REDIS_UNBLOCKED 表示客户端已经从 REDIS_BLOCKED 所表示的阻塞状态脱离,在 REDIS_BLOCKED 标志打开的情况下使用 +* REDIS_MULTI 标志表示客户端正在执行事务 +* REDIS_DIRTY_CAS 表示事务使用 WATCH 命令监视的数据库键已经被修改 +* ..... + + + + + +**** + + + +##### 缓冲区 + +客户端状态的输入缓冲区用于保存客户端发送的命令请求,输入缓冲区的大小会根据输入内容动态地缩小或者扩大,但最大大小不能超过 1GB,否则服务器将关闭这个客户端,比如执行 `SET key value `,那么缓冲区 querybuf 的内容: + +```sh +*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n # +``` + +输出缓冲区是服务器用于保存执行客户端命令所得的命令回复,每个客户端都有两个输出缓冲区可用: + +* 一个是固定大小的缓冲区,保存长度比较小的回复,比如 OK、简短的字符串值、整数值、错误回复等 +* 一个是可变大小的缓冲区,保存那些长度比较大的回复, 比如一个非常长的字符串值或者一个包含了很多元素的集合等 + +buf 是一个大小为 REDIS_REPLY_CHUNK_BYTES (常量默认 16*1024 = 16KB) 字节的字节数组,bufpos 属性记录了 buf 数组目前已使用的字节数量,当 buf 数组的空间已经用完或者回复数据太大无法放进 buf 数组里,服务器就会开始使用可变大小的缓冲区 + +通过使用 reply 链表连接多个字符串对象,可以为客户端保存一个非常长的命令回复,而不必受到固定大小缓冲区 16KB 大小的限制 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-可变输出缓冲区.png) + + + + + +*** + + + +##### 命令 + +服务器对 querybuf 中的命令请求的内容进行分析,得出的命令参数以及参数的数量分别保存到客户端状态的 argv 和 argc 属性 + +* argv 属性是一个数组,数组中的每项都是字符串对象,其中 argv[0] 是要执行的命令,而之后的其他项则是命令的参数 +* argc 属性负责记录 argv 数组的长度 + + + +服务器将根据项 argv[0] 的值,在命令表中查找命令所对应的命令的 redisCommand,将客户端状态的 cmd 指向该结构 + +命令表是一个字典结构,键是 SDS 结构保存命令的名字;值是命令所对应的 redisCommand 结构,保存了命令的实现函数、命令标志、 命令应该给定的参数个数、命令的总执行次数和总消耗时长等统计信息 + + + + + +**** + + + +##### 验证 + +客户端状态的 authenticated 属性用于记录客户端是否通过了身份验证 + +* authenticated 值为 0,表示客户端未通过身份验证 +* authenticated 值为 1,表示客户端已通过身份验证 + +当客户端 authenticated = 0 时,除了 AUTH 命令之外, 客户端发送的所有其他命令都会被服务器拒绝执行 + +```sh +redis> PING +(error) NOAUTH Authentication required. +redis> AUTH 123321 +OK +redis> PING +PONG +``` + + + +*** + + + +##### 时间 + +ctime 属性记录了创建客户端的时间,这个时间可以用来计算客户端与服务器已经连接了多少秒,`CLIENT list` 命令的 age 域记录了这个秒数 + +lastinteraction 属性记录了客户端与服务器最后一次进行互动 (interaction) 的时间,互动可以是客户端向服务器发送命令请求,也可以是服务器向客户端发送命令回复。该属性可以用来计算客户端的空转 (idle) 时长, 就是距离客户端与服务器最后一次进行互动已经过去了多少秒,`CLIENT list` 命令的 idle 域记录了这个秒数 + +obuf_soft_limit_reached_time 属性记录了**输出缓冲区第一次到达软性限制** (soft limit) 的时间 + + + + + +*** + + + + + +#### 生命周期 + +##### 创建 + +服务器使用不同的方式来创建和关闭不同类型的客户端 + +如果客户端是通过网络连接与服务器进行连接的普通客户端,那么在客户端使用 connect 函数连接到服务器时,服务器就会调用连接应答处理器为客户端创建相应的客户端状态,并将这个新的客户端状态添加到服务器状态结构 clients 链表的末尾 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-服务器clients链表.png) + +服务器会在初始化时创建负责执行 Lua 脚本中包含的 Redis 命令的伪客户端,并将伪客户端关联在服务器状态的 lua_client 属性 + +```c +struct redisServer { + // 保存伪客户端 + redisClient *lua_client; + + //... +}; +``` + +lua_client 伪客户端在服务器运行的整个生命周期会一直存在,只有服务器被关闭时,这个客户端才会被关闭 + +载入 AOF 文件时, 服务器会创建用于执行 AOF 文件包含的 Redis 命令的伪客户端,并在载入完成之后,关闭这个伪客户端 + + + +**** + + + +##### 关闭 + +一个普通客户端可以因为多种原因而被关闭: + +* 客户端进程退出或者被杀死,那么客户端与服务器之间的网络连接将被关闭,从而造成客户端被关闭 +* 客户端向服务器发送了带有不符合协议格式的命令请求,那么这个客户端会**被服务器关闭** +* 客户端是 `CLIENT KILL` 命令的目标 +* 如果用户为服务器设置了 timeout 配置选项,那么当客户端的空转时间超过该值时将被关闭,特殊情况不会被关闭: + * 客户端是主服务器(REDIS_MASTER )或者从服务器(打开了 REDIS_SLAVE 标志) + * 正在被 BLPOP 等命令阻塞(REDIS_BLOCKED) + * 正在执行 SUBSCRIBE、PSUBSCRIBE 等订阅命令 +* 客户端发送的命令请求的大小超过了输入缓冲区的限制大小(默认为 1GB) +* 发送给客户端的命令回复的大小超过了输出缓冲区的限制大小 + +理论上来说,可变缓冲区可以保存任意长的命令回复,但是为了回复过大占用过多的服务器资源,服务器会时刻检查客户端的输出缓冲区的大小,并在缓冲区的大小超出范围时,执行相应的限制操作: + +* 硬性限制 (hard limit):输出缓冲区的大小超过了硬性限制所设置的大小,那么服务器会关闭客户端(serverCron 函数中执行),积存在输出缓冲区中的所有内容会被**直接释放**,不会返回给客户端 +* 软性限制 (soft limit):输出缓冲区的大小超过了软性限制所设置的大小,小于硬性限制的大小,服务器的操作: + * 用属性 obuf_soft_limit_reached_time 记录下客户端到达软性限制的起始时间,继续监视客户端 + * 如果输出缓冲区的大小一直超出软性限制,并且持续时间超过服务器设定的时长,那么服务器将关闭客户端 + * 如果在指定时间内不再超出软性限制,那么客户端就不会被关闭,并且 o_s_l_r_t 属性清零 + +使用 client-output-buffer-limit 选项可以为普通客户端、从服务器客户端、执行发布与订阅功能的客户端分别设置不同的软性限制和硬性限制,格式: + +```sh +client-output-buffer-limit + +client-output-buffer-limit normal 0 0 0 +client-output-buffer-limit slave 256mb 64mb 60 +client-output-buffer-limit pubsub 32mb 8mb 60 +``` + +* 第一行:将普通客户端的硬性限制和软性限制都设置为 0,表示不限制客户端的输出缓冲区大小 +* 第二行:将从服务器客户端的硬性限制设置为 256MB,软性限制设置为 64MB,软性限制的时长为 60 秒 +* 第三行:将执行发布与订阅功能的客户端的硬性限制设置为 32MB,软性限制设置为 8MB,软性限制的时长为 60 秒 + + + + + +**** + + + +### 服务器 + +#### 执行流程 + +Redis 服务器与多个客户端建立网络连接,处理客户端发送的命令请求,在数据库中保存客户端执行命令所产生的数据,并通过资源管理来维持服务器自身的运转,所以一个命令请求从发送到获得回复的过程中,客户端和服务器需要完成一系列操作 + + + +##### 命令请求 + +Redis 服务器的命令请求来自 Redis 客户端,当用户在客户端中键入一个命令请求时,客户端会将这个命令请求转换成协议格式,通过连接到服务器的套接字,将协议格式的命令请求发送给服务器 + +```sh +SET KEY VALUE -> # 命令 +*3\r\nS3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n # 协议格式 +``` + +当客户端与服务器之间的连接套接字因为客户端的写入而变得可读,服务器调用**命令请求处理器**来执行以下操作: + +* 读取套接字中协议格式的命令请求,并将其保存到客户端状态的输入缓冲区里面 +* 对输入缓冲区中的命令请求进行分析,提取出命令请求中包含的命令参数以及命令参数的个数,然后分别将参数和参数个数保存到客户端状态的 argv 属性和 argc 属性里 +* 调用命令执行器,执行客户端指定的命令 + +最后客户端接收到协议格式的命令回复之后,会将这些回复转换成用户可读的格式打印给用户观看,至此整体流程结束 + + + +**** + + + +##### 命令执行 + +命令执行器开始对命令操作: + +* 查找命令:首先根据客户端状态的 argv[0] 参数,在**命令表 (command table)** 中查找参数所指定的命令,并将找到的命令保存到客户端状态的 cmd 属性里面,是一个 redisCommand 结构 + + 命令查找算法与字母的大小写无关,所以命令名字的大小写不影响命令表的查找结果 + +* 执行预备操作: + + * 检查客户端状态的 cmd 指针是否指向 NULL,根据 redisCommand 检查请求参数的数量是否正确 + * 检查客户端是否通过身份验证 + * 如果服务器打开了 maxmemory 功能,执行命令之前要先检查服务器的内存占用,在有需要时进行内存回收(**逐出算法**) + * 如果服务器上一次执行 BGSAVE 命令出错,并且服务器打开了 stop-writes-on-bgsave-error 功能,那么如果本次执行的是写命令,服务会拒绝执行,并返回错误 + * 如果客户端当前正在用 SUBSCRIBE 或 PSUBSCRIBE 命令订阅频道,那么服务器会拒绝除了 SUBSCRIBE、SUBSCRIBE、 UNSUBSCRIBE、PUNSUBSCRIBE 之外的其他命令 + * 如果服务器正在进行载入数据,只有 sflags 带有 1 标识(比如 INFO、SHUTDOWN、PUBLISH等)的命令才会被执行 + * 如果服务器执行 Lua 脚本而超时并进入阻塞状态,那么只会执行客户端发来的 SHUTDOWN nosave 和 SCRIPT KILL 命令 + * 如果客户端正在执行事务,那么服务器只会执行客户端发来的 EXEC、DISCARD、MULTI、WATCH 四个命令,其他命令都会被**放进事务队列**中 + * 如果服务器打开了监视器功能,那么会将要执行的命令和参数等信息发送给监视器 + +* 调用命令的实现函数:被调用的函数会执行指定的操作并产生相应的命令回复,回复会被保存在客户端状态的输出缓冲区里面(buf 和 reply 属性),然后实现函数还会**为客户端的套接字关联命令回复处理器**,这个处理器负责将命令回复返回给客户端 + +* 执行后续工作: + + * 如果服务器开启了慢查询日志功能,那么慢查询日志模块会检查是否需要为刚刚执行完的命令请求添加一条新的慢查询日志 + * 根据执行命令所耗费的时长,更新命令的 redisCommand 结构的 milliseconds 属性,并将命令 calls 计数器的值增一 + * 如果服务器开启了 AOF 持久化功能,那么 AOF 持久化模块会将执行的命令请求写入到 AOF 缓冲区里面 + * 如果有其他从服务器正在复制当前这个服务器,那么服务器会将执行的命令传播给所有从服务器 + +* 将命令回复发送给客户端:客户端**套接字变为可写状态**时,服务器就会执行命令回复处理器,将客户端输出缓冲区中的命令回复发送给客户端,发送完毕之后回复处理器会清空客户端状态的输出缓冲区,为处理下一个命令请求做好准备 + + + +**** + + + +##### Command + +每个 redisCommand 结构记录了一个Redis 命令的实现信息,主要属性 + +```c +struct redisCommand { + // 命令的名字,比如"set" + char *name; + + // 函数指针,指向命令的实现函数,比如setCommand + // redisCommandProc 类型的定义为 typedef void redisCommandProc(redisClient *c) + redisCommandProc *proc; + + // 命令参数的个数,用于检查命令请求的格式是否正确。如果这个值为负数-N, 那么表示参数的数量大于等于N。 + // 注意命令的名字本身也是一个参数,比如 SET msg "hello",命令的参数是"SET"、"msg"、"hello" 三个 + int arity; + + // 字符串形式的标识值,这个值记录了命令的属性,, + // 比如这个命令是写命令还是读命令,这个命令是否允许在载入数据时使用,是否允许在Lua脚本中使用等等 + char *sflags; + + // 对sflags标识进行分析得出的二进制标识,由程序自动生成。服务器对命令标识进行检查时使用的都是 flags 属性 + // 而不是sflags属性,因为对二进制标识的检查可以方便地通过& ^ ~ 等操作来完成 + int flags; + + // 服务器总共执行了多少次这个命令 + long long calls; + + // 服务器执行这个命令所耗费的总时长 + long long milliseconds; +}; +``` + + + + + +**** + + + +#### serverCron + +##### 基本介绍 + +Redis 服务器以周期性事件的方式来运行 serverCron 函数,服务器初始化时读取配置 server.hz 的值,默认为 10,代表每秒钟执行 10 次,即**每隔 100 毫秒执行一次**,执行指令 info server 可以查看 + +serverCron 函数负责定期对自身的资源和状态进行检查和调整,从而确保服务器可以长期、稳定地运行 + +* 更新服务器的各类统计信息,比如时间、内存占用、 数据库占用情况等 +* 清理数据库中的过期键值对 +* 关闭和清理连接失效的客户端 +* 进行 AOF 或 RDB 持久化操作 +* 如果服务器是主服务器,那么对从服务器进行定期同步 +* 如果处于集群模式,对集群进行定期同步和连接测试 + + + +**** + + + +##### 时间缓存 + +Redis 服务器中有很多功能需要获取系统的当前时间,而每次获取系统的当前时间都需要执行一次系统调用,为了减少系统调用的执行次数,服务器状态中的 unixtime 属性和 mstime 属性被用作当前时间的缓存 + +```c +struct redisServer { + // 保存了秒级精度的系统当前UNIX时间戳 + time_t unixtime; + // 保存了毫秒级精度的系统当前UNIX时间戳 + long long mstime; + +}; +``` + +serverCron 函数默认以每 100 毫秒一次的频率更新两个属性,所以属性记录的时间的精确度并不高 + +* 服务器只会在打印日志、更新服务器的 LRU 时钟、决定是否执行持久化任务、计算服务器上线时间(uptime)这类对时间精确度要求不高的功能上 +* 对于为键设置过期时间、添加慢查询日志这种需要高精确度时间的功能来说,服务器还是会再次执行系统调用,从而获得最准确的系统当前时间 + + + +*** + + + +##### LRU 时钟 + +服务器状态中的 lruclock 属性保存了服务器的 LRU 时钟 + +```c +struct redisServer { + // 默认每10秒更新一次的时钟缓存,用于计算键的空转(idle)时长。 + unsigned lruclock:22; +}; +``` + +每个 Redis 对象都会有一个 lru 属性, 这个 lru 属性保存了对象最后一次被命令访问的时间 + +```c +typedef struct redisObiect { + unsigned lru:22; +} robj; +``` + +当服务器要计算一个数据库键的空转时间(即数据库键对应的值对象的空转时间),程序会用服务器的 lruclock 属性记录的时间减去对象的 lru 属性记录的时间 + +serverCron 函数默认以每 100 毫秒一次的频率更新这个属性,所以得出的空转时间也是模糊的 + + + +*** + + + +##### 命令次数 + +serverCron 中的 trackOperationsPerSecond 函数以每 100 毫秒一次的频率执行,函数功能是以**抽样计算**的方式,估算并记录服务器在最近一秒钟处理的命令请求数量,这个值可以通过 INFO status 命令的 instantaneous_ops_per_sec 域查看: + +```sh +redis> INFO stats +# Stats +instantaneous_ops_per_sec:6 +``` + +根据上一次抽样时间 ops_sec_last_sample_time 和当前系统时间,以及上一次已执行的命令数 ops_sec_last_sample_ops 和服务器当前已经执行的命令数,计算出两次函数调用期间,服务器平均每毫秒处理了多少个命令请求,该值乘以 1000 得到每秒内的执行命令的估计值,放入 ops_sec_samples 环形数组里 + +```c +struct redisServer { + // 上一次进行抽样的时间 + long long ops_sec_last_sample_time; + // 上一次抽样时,服务器已执行命令的数量 + long long ops_sec_last_sample_ops; + // REDIS_OPS_SEC_SAMPLES 大小(默认值为16)的环形数组,数组的每一项记录一次的抽样结果 + long long ops_sec_samples[REDIS_OPS_SEC_SAMPLES]; + // ops_sec_samples数组的索引值,每次抽样后将值自增一,值为16时重置为0,让数组成为一个环形数组 + int ops_sec_idx; +}; +``` + + + + + +*** + + + +##### 内存峰值 + +服务器状态里的 stat_peak_memory 属性记录了服务器内存峰值大小,循环函数每次执行时都会查看服务器当前使用的内存数量,并与 stat_peak_memory 保存的数值进行比较,设置为较大的值 + +```c +struct redisServer { + // 已使用内存峰值 + size_t stat_peak_memory; +}; +``` + +INFO memory 命令的 used_memory_peak 和 used_memory_peak_human 两个域分别以两种格式记录了服务器的内存峰值: + +```sh +redis> INFO memory +# Memory +... +used_memory_peak:501824 +used_memory_peak_human:490.06K +``` + + + +*** + + + +##### SIGTERM + +服务器启动时,Redis 会为服务器进程的 SIGTERM 信号关联处理器 sigtermHandler 函数,该信号处理器负责在服务器接到 SIGTERM 信号时,打开服务器状态的 shutdown_asap 标识 + +```c +struct redisServer { + // 关闭服务器的标识:值为1时关闭服务器,值为0时不做操作 + int shutdown_asap; +}; +``` + +每次 serverCron 函数运行时,程序都会对服务器状态的 shutdown_asap 属性进行检查,并根据属性的值决定是否关闭服务器 + +服务器在接到 SIGTERM 信号之后,关闭服务器并打印相关日志的过程: + +```sh +[6794 | signal handler] (1384435690) Received SIGTERM, scheduling shutdown ... +[6794] 14 Nov 21:28:10.108 # User requested shutdown ... +[6794] 14 Nov 21:28:10.108 * Saving the final RDB snapshot before exiting. +[6794) 14 Nov 21:28:10.161 * DB saved on disk +[6794) 14 Nov 21:28:10.161 # Redisis now ready to exit, bye bye ... +``` + + + +*** + + + +##### 管理资源 + +serverCron 函数每次执行都会调用 clientsCron 和 databasesCron 函数,进行管理客户端资源和数据库资源 + +clientsCron 函数对一定数量的客户端进行以下两个检查: + +* 如果客户端与服务器之间的连接巳经超时(很长一段时间客户端和服务器都没有互动),那么程序释放这个客户端 +* 如果客户端在上一次执行命令请求之后,输入缓冲区的大小超过了一定的长度,那么程序会释放客户端当前的输入缓冲区,并重新创建一个默认大小的输入缓冲区,从而防止客户端的输入缓冲区耗费了过多的内存 + +databasesCron 函数会对服务器中的一部分数据库进行检查,删除其中的过期键,并在有需要时对字典进行收缩操作 + + + +*** + + + +##### 持久状态 + +服务器状态中记录执行 BGSAVE 命令和 BGREWRITEAOF 命令的子进程的 ID + +```c +struct redisServer { + // 记录执行BGSAVE命令的子进程的ID,如果服务器没有在执行BGSAVE,那么这个属性的值为-1 + pid_t rdb_child_pid; + // 记录执行BGREWRITEAOF命令的子进程的ID,如果服务器没有在执行那么这个属性的值为-1 + pid_t aof_child_pid +}; +``` + +serverCron 函数执行时,会检查两个属性的值,只要其中一个属性的值不为 -1,程序就会执行一次 wait3 函数,检查子进程是否有信号发来服务器进程: + +* 如果有信号到达,那么表示新的 RDB 文件已经生成或者 AOF 重写完毕,服务器需要进行相应命令的后续操作,比如用新的 RDB 文件替换现有的 RDB 文件,用重写后的 AOF 文件替换现有的 AOF 文件 +* 如果没有信号到达,那么表示持久化操作未完成,程序不做动作 + +如果两个属性的值都为 -1,表示服务器没有进行持久化操作 + +* 查看是否有 BGREWRITEAOF 被延迟,然后执行 AOF 后台重写 + +* 查看服务器的自动保存条件是否已经被满足,并且服务器没有在进行持久化,就开始一次新的 BGSAVE 操作 + + 因为条件 1 可能会引发一次 AOF,所以在这个检查中会再次确认服务器是否已经在执行持久化操作 + +* 检查服务器设置的 AOF 重写条件是否满足,条件满足并且服务器没有进行持久化,就进行一次 AOF 重写 + +如果服务器开启了 AOF 持久化功能,并且 AOF 缓冲区里还有待写入的数据, 那么 serverCron 函数会调用相应的程序,将 AOF 缓冲区中的内容写入到 AOF 文件里 + + + +*** + + + +##### 延迟执行 + +在服务器执行 BGSAVE 命令的期间,如果客户端发送 BGREWRITEAOF 命令,那么服务器会将 BGREWRITEAOF 命令的执行时间延迟到 BGSAVE 命令执行完毕之后,用服务器状态的 aof_rewrite_scheduled 属性标识延迟与否 + +```c +struct redisServer { + // 如果值为1,那么表示有 BGREWRITEAOF命令被延迟了 + int aof_rewrite_scheduled; +}; +``` + +serverCron 函数会检查 BGSAVE 或者 BGREWRITEAOF 命令是否正在执行,如果这两个命令都没在执行,并且 aof_rewrite_scheduled 属性的值为 1,那么服务器就会执行之前被推延的 BGREWRITEAOF 命令 + + + +**** + + + +##### 执行次数 + +服务器状态的 cronloops 属性记录了 serverCron 函数执行的次数 + +```c +struct redisServer { + // serverCron 函数每执行一次,这个属性的值就增 1 + int cronloops; +}; +``` + + + +**** + + + +##### 缓冲限制 + +服务器会关闭那些输入或者输出**缓冲区大小超出限制**的客户端 + + + + + +**** + + + +#### 初始化 + +##### 初始结构 + +一个 Redis 服务器从启动到能够接受客户端的命令请求,需要经过一系列的初始化和设置过程 + +第一步:创建一个 redisServer 类型的实例变量 server 作为服务器的状态,并为结构中的各个属性设置默认值,由 initServerConfig 函数进行初始化一般属性: + +* 设置服务器的运行 ID、默认运行频率、默认配置文件路径、默认端口号、默认 RDB 持久化条件和 AOF 持久化条件 +* 初始化服务器的 LRU 时钟,创建命令表 + +第二步:载入配置选项,用户可以通过给定配置参数或者指定配置文件,对 server 变量相关属性的默认值进行修改 + +第三步:初始化服务器数据结构(除了命令表之外),因为服务器**必须先载入用户指定的配置选项才能正确地对数据结构进行初始化**,所以载入配置完成后才进性数据结构的初始化,服务器将调用 initServer 函数: + +* server.clients 链表,记录了的客户端的状态结构;server.db 数组,包含了服务器的所有数据库 +* 用于保存频道订阅信息的 server.pubsub_channels 字典, 以及保存模式订阅信息的 server.pubsub_patterns 链表 +* 用于执行 Lua 脚本的 Lua 环境 server.lua +* 保存慢查询日志的 server.slowlog 属性 + +initServer 还进行了非常重要的设置操作: + +* 为服务器设置进程信号处理器 +* 创建共享对象,包含 OK、ERR、**整数 1 到 10000 的字符串对象**等 +* **打开服务器的监听端口** +* **为 serverCron 函数创建时间事件**, 等待服务器正式运行时执行 serverCron 函数 +* 如果 AOF 持久化功能已经打开,那么打开现有的 AOF 文件,如果 AOF 文件不存在,那么创建并打开一个新的 AOF 文件 ,为 AOF 写入做好准备 +* **初始化服务器的后台 I/O 模块**(BIO), 为将来的 I/O 操作做好准备 + +当 initServer 函数执行完毕之后, 服务器将用 ASCII 字符在日志中打印出 Redis 的图标, 以及 Redis 的版本号信息 + + + +*** + + + +##### 还原状态 + +在完成了对服务器状态的初始化之后,服务器需要载入RDB文件或者AOF 文件, 并根据文件记录的内容来还原服务器的数据库状态: + +* 如果服务器启用了 AOF 持久化功能,那么服务器使用 AOF 文件来还原数据库状态 +* 如果服务器没有启用 AOF 持久化功能,那么服务器使用 RDB 文件来还原数据库状态 + +当服务器完成数据库状态还原工作之后,服务器将在日志中打印出载入文件并还原数据库状态所耗费的时长 + +```sh +[7171] 22 Nov 22:43:49.084 * DB loaded from disk: 0.071 seconds +``` + + + +*** + + + +##### 驱动循环 + +在初始化的最后一步,服务器将打印出以下日志,并开始**执行服务器的事件循环**(loop) + +```c +[7171] 22 Nov 22:43:49.084 * The server is now ready to accept connections on pert 6379 +``` + +服务器现在开始可以接受客户端的连接请求,并处理客户端发来的命令请求了 + + + + + +***** + + + +### 慢日志 + +#### 基本介绍 + +Redis 的慢查询日志功能用于记录执行时间超过给定时长的命令请求,通过产生的日志来监视和优化查询速度 + +服务器配置有两个和慢查询日志相关的选项: + +* slowlog-log-slower-than 选项指定执行时间超过多少微秒的命令请求会被记录到日志上 +* slowlog-max-len 选项指定服务器最多保存多少条慢查询日志 + +服务器使用先进先出 FIFO 的方式保存多条慢查询日志,当服务器存储的慢查询日志数量等于 slowlog-max-len 选项的值时,在添加一条新的慢查询日志之前,会先将最旧的一条慢查询日志删除 + +配置选项可以通过 CONFIG SET option value 命令进行设置 + +常用命令: + +```sh +SLOWLOG GET [n] # 查看 n 条服务器保存的慢日志 +SLOWLOG LEN # 查看日志数量 +SLOWLOG RESET # 清除所有慢查询日志 +``` + + + +*** + + + +#### 日志保存 + +服务器状态中包含了慢查询日志功能有关的属性: + +```c +struct redisServer { + // 下一条慢查询日志的ID + long long slowlog_entry_id; + + // 保存了所有慢查询日志的链表 + list *slowlog; + + // 服务器配置选项的值 + long long slowlog-log-slower-than; + // 服务器配置选项的值 + unsigned long slowlog_max_len; +} +``` + +slowlog_entry_id 属性的初始值为 0,每当创建一条新的慢查询日志时,这个属性就会用作新日志的 id 值,之后该属性增一 + +slowlog 链表保存了服务器中的所有慢查询日志,链表中的每个节点是一个 slowlogEntry 结构, 代表一条慢查询日志: + +```c +typedef struct slowlogEntry { + // 唯一标识符 + long long id; + // 命令执行时的时间,格式为UNIX时间戳 + time_t time; + // 执行命令消耗的时间,以微秒为单位 + long long duration; + // 命令与命令参数 + robj **argv; + // 命令与命令参数的数量 + int argc; +} +``` + + + + + +*** + + + +#### 添加日志 + +在每次执行命令的前后,程序都会记录微秒格式的当前 UNIX 时间戳,两个时间之差就是执行命令所耗费的时长,函数会检查命令的执行时长是否超过 slowlog-log-slower-than 选项所设置: + +* 如果是的话,就为命令创建一个新的日志,并将新日志添加到 slowlog 链表的表头 +* 检查慢查询日志的长度是否超过 slowlog-max-len 选项所设置的长度,如果是将多出来的日志从 slowlog 链表中删除掉 + +* 将 redisServer. slowlog_entry_id 的值增 1 + + + + + +*** + + + + + +## 数据结构 + +### 字符串 + +#### SDS + +Redis 构建了简单动态字符串(SDS)的数据类型,作为 Redis 的默认字符串表示,包含字符串的键值对在底层都是由 SDS 实现 + +```c +struct sdshdr { + // 记录buf数组中已使用字节的数量,等于 SDS 所保存字符串的长度 + int len; + + // 记录buf数组中未使用字节的数量 + int free; + + // 【字节】数组,用于保存字符串(不是字符数组) + char buf[]; +}; +``` + +SDS 遵循 C 字符串**以空字符结尾**的惯例,保存空字符的 1 字节不计算在 len 属性,SDS 会自动为空字符分配额外的 1 字节空间和添加空字符到字符串末尾,所以空字符对于 SDS 的使用者来说是完全透明的 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-SDS底层结构.png) + + + +*** + + + +#### 对比 + +常数复杂度获取字符串长度: + +* C 字符串不记录自身的长度,获取时需要遍历整个字符串,遇到空字符串为止,时间复杂度为 O(N) +* SDS 获取字符串长度的时间复杂度为 O(1),设置和更新 SDS 长度由函数底层自动完成 + +杜绝缓冲区溢出: + +* C 字符串调用 strcat 函数拼接字符串时,如果字符串内存不够容纳目标字符串,就会造成缓冲区溢出(Buffer Overflow) + + s1 和 s2 是内存中相邻的字符串,执行 `strcat(s1, " Cluster")`(有空格): + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-内存溢出问题.png) + +* SDS 空间分配策略:当对 SDS 进行修改时,首先检查 SDS 的空间是否满足修改所需的要求, 如果不满足会自动将 SDS 的空间扩展至执行修改所需的大小,然后执行实际的修改操作, 避免了缓冲区溢出的问题 + +二进制安全: + +* C 字符串中的字符必须符合某种编码(比如 ASCII)方式,除了字符串末尾以外其他位置不能包含空字符,否则会被误认为是字符串的结尾,所以只能保存文本数据 +* SDS 的 API 都是二进制安全的,使用字节数组 buf 保存一系列的二进制数据,**使用 len 属性来判断数据的结尾**,所以可以保存图片、视频、压缩文件等二进制数据 + +兼容 C 字符串的函数:SDS 会在为 buf 数组分配空间时多分配一个字节来保存空字符,所以可以重用一部分 C 字符串函数库的函数 + + + +*** + + + +#### 内存 + +C 字符串**每次**增长或者缩短都会进行一次内存重分配,拼接操作通过重分配扩展底层数组空间,截断操作通过重分配释放不使用的内存空间,防止出现内存泄露 + +SDS 通过未使用空间解除了字符串长度和底层数组长度之间的关联,在 SDS 中 buf 数组的长度不一定就是字符数量加一, 数组里面可以包含未使用的字节,字节的数量由 free 属性记录 + +内存重分配涉及复杂的算法,需要执行**系统调用**,是一个比较耗时的操作,SDS 的两种优化策略: + +* 空间预分配:当 SDS 需要进行空间扩展时,程序不仅会为 SDS 分配修改所必需的空间, 还会为 SDS 分配额外的未使用空间 + + * 对 SDS 修改之后,SDS 的长度(len 属性)小于 1MB,程序分配和 len 属性同样大小的未使用空间,此时 len 和 free 相等 + + s 为 Redis,执行 `sdscat(s, " Cluster")` 后,len 变为 13 字节,所以也分配了 13 字节的 free 空间,总长度变为 27 字节(额外的一字节保存空字符,13 + 13 + 1 = 27) + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-SDS内存预分配.png) + + * 对 SDS 修改之后,SDS 的长度大于等于 1MB,程序会分配 1MB 的未使用空间 + + 在扩展 SDS 空间前,API 会先检查 free 空间是否足够,如果足够就无需执行内存重分配,所以通过预分配策略,SDS 将连续增长 N 次字符串所需内存的重分配次数从**必定 N 次降低为最多 N 次** + +* 惰性空间释放:当 SDS 缩短字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用 free 属性将这些字节的数量记录起来,并等待将来复用 + + SDS 提供了相应的 API 来真正释放 SDS 的未使用空间,所以不用担心空间惰性释放策略造成的内存浪费问题 + + + + + +**** + + + +### 链表 + +链表提供了高效的节点重排能力,C 语言并没有内置这种数据结构,所以 Redis 构建了链表数据类型 + +链表节点: + +```c +typedef struct listNode { + // 前置节点 + struct listNode *prev; + + // 后置节点 + struct listNode *next; + + // 节点的值 + void *value +} listNode; +``` + +多个 listNode 通过 prev 和 next 指针组成**双端链表**: + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-链表节点底层结构.png) + +list 链表结构:提供了表头指针 head 、表尾指针 tail 以及链表长度计数器 len + +```c +typedef struct list { + // 表头节点 + listNode *head; + // 表尾节点 + listNode *tail; + + // 链表所包含的节点数量 + unsigned long len; + + // 节点值复制函数,用于复制链表节点所保存的值 + void *(*dup) (void *ptr); + // 节点值释放函数,用于释放链表节点所保存的值 + void (*free) (void *ptr); + // 节点值对比函数,用于对比链表节点所保存的值和另一个输入值是否相等 + int (*match) (void *ptr, void *key); +} list; +``` + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-链表底层结构.png) + +Redis 链表的特性: + +* 双端:链表节点带有 prev 和 next 指针,获取某个节点的前置节点和后置节点的时间复杂度都是 O(1) +* 无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问以 NULL 为终点 +* 带表头指针和表尾指针: 通过 list 结构的 head 指针和 tail 指针,获取链表的表头节点和表尾节点的时间复杂度为 O(1) +* 带链表长度计数器:使用 len 属性来对 list 持有的链表节点进行计数,获取链表中节点数量的时间复杂度为 O(1) +* 多态:链表节点使用 void * 指针来保存节点值, 并且可以通过 dup、free 、match 三个属性为节点值设置类型特定函数,所以链表可以保存各种**不同类型的值** + + + + + +**** + + + +### 字典 + +#### 哈希表 + +Redis 字典使用的哈希表结构: + +```c +typedef struct dictht { + // 哈希表数组,数组中每个元素指向 dictEntry 结构 + dictEntry **table; + + // 哈希表大小,数组的长度 + unsigned long size; + + // 哈希表大小掩码,用于计算索引值,总是等于 【size-1】 + unsigned long sizemask; + + // 该哈希表已有节点的数量 + unsigned long used; +} dictht; +``` + +哈希表节点结构: + +```c +typedef struct dictEntry { + // 键 + void *key; + + // 值,可以是一个指针,或者整数 + union { + void *val; // 指针 + uint64_t u64; + int64_t s64; + } + + // 指向下个哈希表节点,形成链表,用来解决冲突问题 + struct dictEntry *next; +} dictEntry; +``` + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-哈希表底层结构.png) + + + +*** + + + +#### 字典结构 + +字典,又称为符号表、关联数组、映射(Map),用于保存键值对的数据结构,字典中的每个键都是独一无二的。底层采用哈希表实现,一个哈希表包含多个哈希表节点,每个节点保存一个键值对 + +```c +typedef struct dict { + // 类型特定函数 + dictType *type; + + // 私有数据 + void *privdata; + + // 哈希表,数组中的每个项都是一个dictht哈希表, + // 一般情况下字典只使用 ht[0] 哈希表, ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用 + dictht ht[2]; + + // rehash 索引,当 rehash 不在进行时,值为 -1 + int rehashidx; +} dict; +``` + +type 属性和 privdata 属性是针对不同类型的键值对, 为创建多态字典而设置的: + +* type 属性是指向 dictType 结构的指针, 每个 dictType 结构保存了一簇用于操作特定类型键值对的函数, Redis 会为用途不同的字典设置不同的类型特定函数 +* privdata 属性保存了需要传给那些类型特定函数的可选参数 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-字典底层结构.png) + + + +**** + + + +#### 哈希冲突 + +Redis 使用 MurmurHash 算法来计算键的哈希值,这种算法的优点在于,即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快 + +将一个新的键值对添加到字典里,需要先根据键 key 计算出哈希值,然后进行取模运算(取余): + +```c +index = hash & dict->ht[x].sizemask +``` + +当有两个或以上数量的键被分配到了哈希表数组的同一个索引上时,就称这些键发生了哈希冲突(collision) + +Redis 的哈希表使用链地址法(separate chaining)来解决键哈希冲突, 每个哈希表节点都有一个 next 指针,多个节点通过 next 指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题 + +dictEntry 节点组成的链表没有指向链表表尾的指针,为了速度考虑,程序总是将新节点添加到链表的表头位置(**头插法**),时间复杂度为 O(1) + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-字典解决哈希冲突.png) + + + +**** + + + +#### 负载因子 + +负载因子的计算方式:哈希表中的**节点数量** / 哈希表的大小(**长度**) + +```c +load_factor = ht[0].used / ht[0].size +``` + +为了让哈希表的负载因子(load factor)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时 ,程序会自动对哈希表的大小进行相应的扩展或者收缩 + +哈希表执行扩容的条件: + +* 服务器没有执行 BGSAVE 或者 BGREWRITEAOF 命令,哈希表的负载因子大于等于 1 + +* 服务器正在执行 BGSAVE 或者 BGREWRITEAOF 命令,哈希表的负载因子大于等于 5 + + 原因:执行该命令的过程中,Redis 需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on­-write)技术来优化子进程的使用效率,通过提高执行扩展操作的负载因子,尽可能地避免在子进程存在期间进行哈希表扩展操作,可以避免不必要的内存写入操作,最大限度地节约内存 + +哈希表执行收缩的条件:负载因子小于 0.1(自动执行,servreCron 中检测) + + + +*** + + + +#### 重新散列 + +扩展和收缩哈希表的操作通过 rehash(重新散列)来完成,步骤如下: + +* 为字典的 ht[1] 哈希表分配空间,空间大小的分配情况: + * 如果执行的是扩展操作,ht[1] 的大小为第一个大于等于 $ht[0].used * 2$ 的 $2^n$ + * 如果执行的是收缩操作,ht[1] 的大小为第一个大于等于 $ht[0].used$ 的 $2^n$ +* 将保存在 ht[0] 中所有的键值对重新计算哈希值和索引值,迁移到 ht[1] 上 +* 当 ht[0] 包含的所有键值对都迁移到了 ht[1] 之后(ht[0] 变为空表),释放 ht[0],将 ht[1] 设置为 ht[0],并在 ht[1] 创建一个新的空白哈希表,为下一次 rehash 做准备 + +如果哈希表里保存的键值对数量很少,rehash 就可以在瞬间完成,但是如果哈希表里数据很多,那么要一次性将这些键值对全部 rehash 到 ht[1] 需要大量计算,可能会导致服务器在一段时间内停止服务 + +Redis 对 rehash 做了优化,使 rehash 的动作并不是一次性、集中式的完成,而是分多次,渐进式的完成,又叫**渐进式 rehash** + +* 为 ht[1] 分配空间,此时字典同时持有 ht[0] 和 ht[1] 两个哈希表 +* 在字典中维护了一个索引计数器变量 rehashidx,并将变量的值设为 0,表示 rehash 正式开始 +* 在 rehash 进行期间,每次对字典执行增删改查操作时,程序除了执行指定的操作以外,还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1],rehash 完成之后**将 rehashidx 属性的值增一** +* 随着字典操作的不断执行,最终在某个时间点 ht[0] 的所有键值对都被 rehash 至 ht[1],将 rehashidx 属性的值设为 -1 + +渐进式 rehash 采用**分而治之**的方式,将 rehash 键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式 rehash 带来的庞大计算量 + +渐进式 rehash 期间的哈希表操作: + +* 字典的查找、删除、更新操作会在两个哈希表上进行,比如查找一个键会先在 ht[0] 上查找,查找不到就去 ht[1] 继续查找 +* 字典的添加操作会直接在 ht[1] 上添加,不在 ht[0] 上进行任何添加 + + + + + +**** + + + +### 跳跃表 + +#### 底层结构 + +跳跃表(skiplist)是一种有序(**默认升序**)的数据结构,在链表的基础上**增加了多级索引以提升查找的效率**,索引是占内存的,所以是一个**空间换时间**的方案,跳表平均 O(logN)、最坏 O(N) 复杂度的节点查找,效率与平衡树相当但是实现更简单 + +原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值和几个指针,并不需要存储对象,因此当节点本身比较大或者元素数量比较多的时候,其优势可以被放大,而缺点(占内存)则可以忽略 + +Redis 只在两个地方应用了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构 + +```c +typedef struct zskiplist { + // 表头节点和表尾节点,O(1) 的时间复杂度定位头尾节点 + struct skiplistNode *head, *tail; + + // 表的长度,也就是表内的节点数量 (表头节点不计算在内) + unsigned long length; + + // 表中层数最大的节点的层数 (表头节点的层高不计算在内) + int level +} zskiplist; +``` + +```c +typedef struct zskiplistNode { + // 层 + struct zskiplistLevel { + // 前进指针 + struct zskiplistNode *forward; + // 跨度 + unsigned int span; + } level[]; + + // 后退指针 + struct zskiplistNode *backward; + + // 分值 + double score; + + // 成员对象 + robj *obj; +} zskiplistNode; +``` + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-跳表底层结构.png) + + + +*** + + + +#### 属性分析 + +层:level 数组包含多个元素,每个元素包含指向其他节点的指针。根据幕次定律(power law,越大的数出现的概率越小)**随机**生成一个介于 1 和 32 之间的值(Redis5 之后最大为 64)作为 level 数组的大小,这个大小就是层的高度,节点的第一层是 level[0] = L1 + +前进指针:forward 用于从表头到表尾方向**正序(升序)遍历节点**,遇到 NULL 停止遍历 + +跨度:span 用于记录两个节点之间的距离,用来计算排位(rank): + +* 两个节点之间的跨度越大相距的就越远,指向 NULL 的所有前进指针的跨度都为 0 + +* 在查找某个节点的过程中,**将沿途访问过的所有层的跨度累计起来,结果就是目标节点在跳跃表中的排位**,按照上图所示: + + 查找分值为 3.0 的节点,沿途经历的层:查找的过程只经过了一个层,并且层的跨度为 3,所以目标节点在跳跃表中的排位为 3 + + 查找分值为 2.0 的节点,沿途经历的层:经过了两个跨度为 1 的节点,因此可以计算出目标节点在跳跃表中的排位为 2 + +后退指针:backward 用于从表尾到表头方向**逆序(降序)遍历节点** + +分值:score 属性一个 double 类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序 + +成员对象:obj 属性是一个指针,指向一个 SDS 字符串对象。同一个跳跃表中,各个节点保存的**成员对象必须是唯一的**,但是多个节点保存的分值可以是相同的,分值相同的节点将按照成员对象在字典序中的大小来进行排序(从小到大) + + + +个人笔记:JUC → 并发包 → ConcurrentSkipListMap 详解跳跃表 + + + +**** + + + +### 整数集合 + +#### 底层结构 + +整数集合(intset)是用于保存整数值的集合数据结构,是 Redis 集合键的底层实现之一 + +```c +typedef struct intset { + // 编码方式 + uint32_t encoding; + + // 集合包含的元素数量,也就是 contents 数组的长度 + uint32_t length; + + // 保存元素的数组 + int8_t contents[]; +} intset; +``` + +encoding 取值为三种:INTSET_ENC_INT16、INTSET_ENC_INT32、INTSET_ENC_INT64 + +整数集合的每个元素都是 contents 数组的一个数组项(item),在数组中按值的大小从小到大**有序排列**,并且数组中**不包含任何重复项**。虽然 contents 属性声明为 int8_t 类型,但实际上数组并不保存任何 int8_t 类型的值, 真正类型取决于 encoding 属性 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-整数集合底层结构.png) + +说明:底层存储结构是数组,所以为了保证有序性和不重复性,每次添加一个元素的时间复杂度是 O(N) + + + +**** + + + +#### 类型升级 + +整数集合添加的新元素的类型比集合现有所有元素的类型都要长时,需要先进行升级(upgrade),升级流程: + +* 根据新元素的类型长度以及集合元素的数量(包括新元素在内),扩展整数集合底层数组的空间大小 + +* 将底层数组现有的所有元素都转换成与新元素相同的类型,并将转换后的元素放入正确的位置,放置过程保证数组的有序性 + + 图示 32 * 4 = 128 位,首先将 3 放入索引 2(64 位 - 95 位),然后将 2 放置索引 1,将 1 放置在索引 0,从后向前依次放置在对应的区间,最后放置 65535 元素到索引 3(96 位- 127 位),修改 length 属性为 4 + +* 将新元素添加到底层数组里 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-整数集合升级.png) + +每次向整数集合添加新元素都可能会引起升级,而每次升级都需要对底层数组中的所有元素进行类型转换,所以向整数集合添加新元素的时间复杂度为 O(N) + +引发升级的新元素的长度总是比整数集合现有所有元素的长度都大,所以这个新元素的值要么就大于所有现有元素,要么就小于所有现有元素,升级之后新元素的摆放位置: + +* 在新元素小于所有现有元素的情况下,新元素会被放置在底层数组的最开头(索引 0) +* 在新元素大于所有现有元素的情况下,新元素会被放置在底层数组的最末尾(索引 length-1) + +整数集合升级策略的优点: + +* 提升整数集合的灵活性:C 语言是静态类型语言,为了避免类型错误通常不会将两种不同类型的值放在同一个数据结构里面,整数集合可以自动升级底层数组来适应新元素,所以可以随意的添加整数 + +* 节约内存:要让数组可以同时保存 int16、int32、int64 三种类型的值,可以直接使用 int64_t 类型的数组作为整数集合的底层实现,但是会造成内存浪费,整数集合可以确保升级操作只会在有需要的时候进行,尽量节省内存 + +整数集合**不支持降级操作**,一旦对数组进行了升级,编码就会一直保持升级后的状态 + + + + + +***** + + + +### 压缩列表 + +#### 底层结构 + +压缩列表(ziplist)是 Redis 为了节约内存而开发的,是列表键和哈希键的底层实现之一。是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构,一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-压缩列表底层结构.png) + +* zlbytes:uint32_t 类型 4 字节,记录整个压缩列表占用的内存字节数,在对压缩列表进行内存重分配或者计算 zlend 的位置时使用 +* zltail:uint32_t 类型 4 字节,记录压缩列表表尾节点距离起始地址有多少字节,通过这个偏移量程序无须遍历整个压缩列表就可以确定表尾节点的地址 +* zllen:uint16_t 类型 2 字节,记录了压缩列表包含的节点数量,当该属性的值小于 UINT16_MAX (65535) 时,该值就是压缩列表中节点的数量;当这个值等于 UINT16_MAX 时节点的真实数量需要遍历整个压缩列表才能计算得出 +* entryX:列表节点,压缩列表中的各个节点,**节点的长度由节点保存的内容决定** +* zlend:uint8_t 类型 1 字节,是一个特殊值 0xFF (255),用于标记压缩列表的末端 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-压缩列表示例.png) + +列表 zlbytes 属性的值为 0x50 (十进制 80),表示压缩列表的总长为 80 字节,列表 zltail 属性的值为 0x3c (十进制 60),假设表的起始地址为 p,计算得出表尾节点 entry3 的地址 p + 60 + + + +**** + + + +#### 列表节点 + +列表节点 entry 的数据结构: + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-压缩列表节点.png) + +previous_entry_length:以字节为单位记录了压缩列表中前一个节点的长度,程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址,完成**从表尾向表头遍历**操作 + +* 如果前一节点的长度小于 254 字节,该属性的长度为 1 字节,前一节点的长度就保存在这一个字节里 +* 如果前一节点的长度大于等于 254 字节,该属性的长度为 5 字节,其中第一字节会被设置为 0xFE(十进制 254),之后的四个字节则用于保存前一节点的长度 + +encoding:记录了节点的 content 属性所保存的数据类型和长度 + +* **长度为 1 字节、2 字节或者 5 字节**,值的最高位为 00、01 或者 10 的是字节数组编码,数组的长度由编码除去最高两位之后的其他位记录,下划线 `_` 表示留空,而 `b`、`x` 等变量则代表实际的二进制数据 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-压缩列表字节数组编码.png) + +* 长度为 1 字节,值的最高位为 11 的是整数编码,整数值的类型和长度由编码除去最高两位之后的其他位记录 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-压缩列表整数编码.png) + +content:每个压缩列表节点可以保存一个字节数组或者一个整数值 + +* 字节数组可以是以下三种长度的其中一种: + + * 长度小于等于 $63 (2^6-1)$ 字节的字节数组 + + * 长度小于等于 $16383(2^{14}-1)$ 字节的字节数组 + + * 长度小于等于 $4294967295(2^{32}-1)$ 字节的字节数组 + +* 整数值则可以是以下六种长度的其中一种: + + * 4 位长,介于 0 至 12 之间的无符号整数 + + * 1 字节长的有符号整数 + + * 3 字节长的有符号整数 + + * int16_t 类型整数 + + * int32_t 类型整数 + + * int64_t 类型整数 + + + +*** + + + +#### 连锁更新 + +Redis 将在特殊情况下产生的连续多次空间扩展操作称之为连锁更新(cascade update) + +假设在一个压缩列表中,有多个连续的、长度介于 250 到 253 字节之间的节点 e1 至 eN。将一个长度大于等于 254 字节的新节点 new 设置为压缩列表的头节点,new 就成为 e1 的前置节点。e1 的 previous_entry_length 属性仅为 1 字节,无法保存新节点 new 的长度,所以要对压缩列表执行空间重分配操作,并将 e1 节点的 previous_entry_length 属性从 1 字节长扩展为 5 字节长。由于 e1 原本的长度介于 250 至 253 字节之间,所以扩展后 e1 的长度就变成了 254 至 257 字节之间,导致 e2 的 previous_entry_length 属性无法保存 e1 的长度,程序需要不断地对压缩列表执行空间重分配操作,直到 eN 为止 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-压缩列表连锁更新1.png) + + 删除节点也可能会引发连锁更新,big.length >= 254,small.length < 254,删除 small 节点 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-压缩列表连锁更新2.png) + +连锁更新在最坏情况下需要对压缩列表执行 N 次空间重分配,每次重分配的最坏复杂度为 O(N),所以连锁更新的最坏复杂度为 O(N^2) + +说明:尽管连锁更新的复杂度较高,但出现的记录是非常低的,即使出现只要被更新的节点数量不多,就不会对性能造成影响 + + + + + +**** + + + + + +## 数据类型 + +### redisObj + +#### 对象系统 + +Redis 使用对象来表示数据库中的键和值,当在 Redis 数据库中新创建一个键值对时至少会创建两个对象,一个对象用作键值对的键(**键对象**),另一个对象用作键值对的值(**值对象**) + +Redis 中对象由一个 redisObject 结构表示,该结构中和保存数据有关的三个属性分别是 type、 encoding、ptr: + +```c +typedef struct redisObiect { + // 类型 + unsigned type:4; + // 编码 + unsigned encoding:4; + // 指向底层数据结构的指针 + void *ptr; + + // .... +} robj; +``` + +Redis 并没有直接使用数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象,而每种对象又通过不同的编码映射到不同的底层数据结构 + +Redis 是一个 Map 类型,其中所有的数据都是采用 key : value 的形式存储,**键对象都是字符串对象**,而值对象有五种基本类型和三种高级类型对象 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-对象编码.png) + +* 对一个数据库键执行 TYPE 命令,返回的结果为数据库键对应的值对象的类型,而不是键对象的类型 +* 对一个数据库键执行 OBJECT ENCODING 命令,查看数据库键对应的值对象的编码 + + + +**** + + + +#### 命令多态 + +Redis 中用于操作键的命令分为两种类型: + +* 一种命令可以对任何类型的键执行,比如说 DEL 、EXPIRE、RENAME、 TYPE 等(基于类型的多态) +* 只能对特定类型的键执行,比如 SET 只能对字符串键执行、HSET 对哈希键执行、SADD 对集合键执行,如果类型步匹配会报类型错误: `(error) WRONGTYPE Operation against a key holding the wrong kind of value` + +Redis 为了确保只有指定类型的键可以执行某些特定的命令,在执行类型特定的命令之前,先通过值对象 redisObject 结构 type 属性检查操作类型是否正确,然后再决定是否执行指定的命令 + +对于多态命令,比如列表对象有 ziplist 和 linkedlist 两种实现方式,通过 redisObject 结构 encoding 属性确定具体的编码类型,底层调用对应的 API 实现具体的操作(基于编码的多态) + + + +*** + + + +#### 内存回收 + +对象的整个生命周期可以划分为创建对象、 操作对象、 释放对象三个阶段 + +C 语言没有自动回收内存的功能,所以 Redis 在对象系统中构建了引用计数(reference counting)技术实现的内存回收机制,程序可以跟踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收 + +```c +typedef struct redisObiect { + // 引用计数 + int refcount; +} robj; +``` + +对象的引用计数信息会随着对象的使用状态而不断变化,创建时引用计数 refcount 初始化为 1,每次被一个新程序使用时引用计数加 1,当对象不再被一个程序使用时引用计数值会被减 1,当对象的引用计数值变为 0 时,对象所占用的内存会被释放 + + + +*** + + + +#### 对象共享 + +对象的引用计数属性带有对象共享的作用,共享对象机制更节约内存,数据库中保存的相同值对象越多,节约的内存就越多 + +让多个键共享一个对象的步骤: + +* 将数据库键的值指针指向一个现有的值对象 + +* 将被共享的值对象的引用计数增一 + + + +Redis 在初始化服务器时创建一万个(配置文件可以修改)字符串对象,包含了**从 0 到 9999 的所有整数值**,当服务器需要用到值为 0 到 9999 的字符串对象时,服务器就会使用这些共享对象,而不是新创建对象 + +比如创建一个值为 100 的键 A,并使用 OBJECT REFCOUNT 命令查看键 A 的值对象的引用计数,会发现值对象的引用计数为 2,引用这个值对象的两个程序分别是持有这个值对象的服务器程序,以及共享这个值对象的键 A + +共享对象在嵌套了字符串对象的对象(linkedlist 编码的列表、hashtable 编码的哈希、zset 编码的有序集合)中也能使用 + +Redis 不共享包含字符串对象的原因:验证共享对象和目标对象是否相同的复杂度越高,消耗的 CPU 时间也会越多 + +* 整数值的字符串对象, 验证操作的复杂度为 O(1) +* 字符串值的字符串对象, 验证操作的复杂度为 O(N) +* 如果共享对象是包含了多个值(或者对象的)对象,比如列表对象或者哈希对象,验证操作的复杂度为 O(N^2) + + + +**** + + + +#### 空转时长 + +redisObject 结构包含一个 lru 属性,该属性记录了对象最后一次被命令程序访问的时间 + +```c +typedef struct redisObiect { + unsigned lru:22; +} robj; +``` + +OBJECT IDLETIME 命令可以打印出给定键的空转时长,该值就是通过将当前时间减去键的值对象的 lru 时间计算得出的,这个命令在访问键的值对象时,不会修改值对象的 lru 属性 + +```sh +redis> OBJECT IDLETIME msg +(integer) 10 +# 等待一分钟 +redis> OBJECT IDLETIME msg +(integer) 70 +# 访问 msg +redis> GET msg +"hello world" +# 键处于活跃状态,空转时长为 0 +redis> OBJECT IDLETIME msg +(integer) 0 +``` + +空转时长的作用:如果服务器开启 maxmemory 选项,并且回收内存的算法为 volatile-lru 或者 allkeys-lru,那么当服务器占用的内存数超过了 maxmemory 所设置的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存(LRU 算法) + + + + + +*** + + + +### string + +#### 简介 + +存储的数据:单个数据,最简单的数据存储类型,也是最常用的数据存储类型,实质上是存一个字符串,string 类型是二进制安全的,可以包含任何数据,比如图片或者序列化的对象 + +存储数据的格式:一个存储空间保存一个数据,每一个空间中只能保存一个字符串信息 + +存储内容:通常使用字符串,如果字符串以整数的形式展示,可以作为数字操作使用 + + + +Redis 所有操作都是**原子性**的,采用**单线程**机制,命令是单个顺序执行,无需考虑并发带来影响,原子性就是有一个失败则都失败 + +字符串对象可以是 int、raw、embstr 三种实现方式 + + + +*** + + + +#### 操作 + +指令操作: + +* 数据操作: + + ```sh + set key value #添加/修改数据添加/修改数据 + del key #删除数据 + setnx key value #判定性添加数据,键值为空则设添加 + mset k1 v1 k2 v2... #添加/修改多个数据,m:Multiple + append key value #追加信息到原始信息后部(如果原始信息存在就追加,否则新建) + ``` + +* 查询操作 + + ```sh + get key #获取数据,如果不存在,返回空(nil) + mget key1 key2... #获取多个数据 + strlen key #获取数据字符个数(字符串长度) + ``` + +* 设置数值数据增加/减少指定范围的值 + + ```sh + incr key #key++ + incrby key increment #key+increment + incrbyfloat key increment #对小数操作 + decr key #key-- + decrby key increment #key-increment + ``` + +* 设置数据具有指定的生命周期 + + ```sh + setex key seconds value #设置key-value存活时间,seconds单位是秒 + psetex key milliseconds value #毫秒级 + ``` + +注意事项: + +1. 数据操作不成功的反馈与数据正常操作之间的差异 + + * 表示运行结果是否成功 + + * (integer) 0 → false ,失败 + + * (integer) 1 → true,成功 + + * 表示运行结果值 + + * (integer) 3 → 3 个 + + * (integer) 1 → 1 个 + +2. 数据未获取到时,对应的数据为(nil),等同于null + +3. **数据最大存储量**:512MB + +4. string 在 Redis 内部存储默认就是一个字符串,当遇到增减类操作 incr,decr 时**会转成数值型**进行计算 + +5. 按数值进行操作的数据,如果原始数据不能转成数值,或超越了Redis 数值上限范围,将报错 + 9223372036854775807(java 中 Long 型数据最大值,Long.MAX_VALUE) + +6. Redis 可用于控制数据库表主键 ID,为数据库表主键提供生成策略,保障数据库表的主键唯一性 + + +单数据和多数据的选择: + +* 单数据执行 3 条指令的过程:3 次发送 + 3 次处理 + 3 次返回 +* 多数据执行 1 条指令的过程:1 次发送 + 3 次处理 + 1 次返回(发送和返回的事件略高于单数据) + + + + + + + +*** + + + +#### 实现 + +字符串对象的编码可以是 int、raw、embstr 三种 + +* int:字符串对象保存的是**整数值**,并且整数值可以用 long 类型来表示,那么对象会将整数值保存在字符串对象结构的 ptr 属性面(将 void * 转换成 long),并将字符串对象的编码设置为 int(浮点数用另外两种方式) + + + +* raw:字符串对象保存的是一个字符串值,并且值的长度大于 39 字节,那么对象将使用简单动态字符串(SDS)来保存该值,并将对象的编码设置为 raw + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-字符串对象raw编码.png) + +* embstr:字符串对象保存的是一个字符串值,并且值的长度小于等于 39 字节,那么对象将使用 embstr 编码的方式来保存这个字符串值,并将对象的编码设置为 embstr + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-字符串对象embstr编码.png) + + 上图所示,embstr 与 raw 都使用了 redisObject 和 sdshdr 来表示字符串对象,但是 raw 需要调用两次内存分配函数分别创建两种结构,embstr 只需要一次内存分配来分配一块**连续的空间** + +embstr 是用于保存短字符串的一种编码方式,对比 raw 的优点: + +* 内存分配次数从两次降低为一次,同样释放内存的次数也从两次变为一次 +* embstr 编码的字符串对象的数据都保存在同一块连续内存,所以比 raw 编码能够更好地利用缓存优势(局部性原理) + +int 和 embstr 编码的字符串对象在条件满足的情况下,会被转换为 raw 编码的字符串对象: + +* int 编码的整数值,执行 APPEND 命令追加一个字符串值,先将整数值转为字符串然后追加,最后得到一个 raw 编码的对象 +* Redis 没有为 embstr 编码的字符串对象编写任何相应的修改程序,所以 embstr 对象实际上**是只读的**,执行修改命令会将对象的编码从 embstr 转换成 raw,操作完成后得到一个 raw 编码的对象 + +某些情况下,程序会将字符串对象里面的字符串值转换回浮点数值,执行某些操作后再将浮点数值转换回字符串值: + +```sh +redis> SET pi 3.14 +OK +redis> OBJECT ENCODING pi +"embstr" +redis> INCRBYFLOAT pi 2.0 # 转为浮点数执行增加的操作 +"5. 14" +redis> OBJECT ENCODING pi +"embstr" +``` + + + + + + + +**** + + + +#### 应用 + +主页高频访问信息显示控制,例如新浪微博大 V 主页显示粉丝数与微博数量 + +* 在 Redis 中为大 V 用户设定用户信息,以用户主键和属性值作为 key,后台设定定时刷新策略 + + ```sh + set user:id:3506728370:fans 12210947 + set user:id:3506728370:blogs 6164 + set user:id:3506728370:focuses 83 + ``` + +* 使用 JSON 格式保存数据 + + ```sh + user:id:3506728370 → {"fans":12210947,"blogs":6164,"focuses":83} + ``` + +* key的设置约定:表名 : 主键名 : 主键值 : 字段名 + + | 表名 | 主键名 | 主键值 | 字段名 | + | ----- | ------ | --------- | ------ | + | order | id | 29437595 | name | + | equip | id | 390472345 | type | + | news | id | 202004150 | title | + + + + + +*** + + + +### hash + +#### 简介 + +数据存储需求:对一系列存储的数据进行编组,方便管理,典型应用存储对象信息 + +数据存储结构:一个存储空间保存多个键值对数据 + +hash 类型:底层使用**哈希表**结构实现数据存储 + + + +Redis 中的 hash 类似于 Java 中的 `Map>`,左边是 key,右边是值,中间叫 field 字段,本质上 **hash 存了一个 key-value 的存储空间** + +hash 是指的一个数据类型,并不是一个数据 + +* 如果 field 数量较少,存储结构优化为**压缩列表结构**(有序) +* 如果 field 数量较多,存储结构使用 HashMap 结构(无序) + + + +*** + + + +#### 操作 + +指令操作: + +* 数据操作 + + ```sh + hset key field value #添加/修改数据 + hdel key field1 [field2] #删除数据,[]代表可选 + hsetnx key field value #设置field的值,如果该field存在则不做任何操作 + hmset key f1 v1 f2 v2... #添加/修改多个数据 + ``` + +* 查询操作 + + ```sh + hget key field #获取指定field对应数据 + hgetall key #获取指定key所有数据 + hmget key field1 field2... #获取多个数据 + hexists key field #获取哈希表中是否存在指定的字段 + hlen key #获取哈希表中字段的数量 + ``` + +* 获取哈希表中所有的字段名或字段值 + + ```sh + hkeys key #获取所有的field + hvals key #获取所有的value + ``` + +* 设置指定字段的数值数据增加指定范围的值 + + ```sh + hincrby key field increment #指定字段的数值数据增加指定的值,increment为负数则减少 + hincrbyfloat key field increment#操作小数 + ``` + + +注意事项 + +1. hash 类型中 value 只能存储字符串,不允许存储其他数据类型,不存在嵌套现象,如果数据未获取到,对应的值为(nil) +2. 每个 hash 可以存储 2^32 - 1 个键值对 +3. hash 类型和对象的数据存储形式相似,并且可以灵活添加删除对象属性。但 hash 设计初衷不是为了存储大量对象而设计的,不可滥用,不可将 hash 作为对象列表使用 +4. hgetall 操作可以获取全部属性,如果内部 field 过多,遍历整体数据效率就很会低,有可能成为数据访问瓶颈 + + + +*** + + + +#### 实现 + +哈希对象的内部编码有两种:ziplist(压缩列表)、hashtable(哈希表、字典) + +* 压缩列表实现哈希对象:同一键值对的节点总是挨在一起,保存键的节点在前,保存值的节点在后 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-哈希对象ziplist.png) + +* 字典实现哈希对象:字典的每一个键都是一个字符串对象,每个值也是 + + + +当存储的数据量比较小的情况下,Redis 才使用压缩列表来实现字典类型,具体需要满足两个条件: + +- 当键值对数量小于 hash-max-ziplist-entries 配置(默认 512 个) +- 所有键和值的长度都小于 hash-max-ziplist-value 配置(默认 64 字节) + +以上两个条件的上限值是可以通过配置文件修改的,当两个条件的任意一个不能被满足时,对象的编码转换操作就会被执行 + +ziplist 使用更加紧凑的结构实现多个元素的连续存储,所以在节省内存方面比 hashtable 更加优秀,当 ziplist 无法满足哈希类型时,Redis 会使用 hashtable 作为哈希的内部实现,因为此时 ziplist 的读写效率会下降,而 hashtable 的读写时间复杂度为 O(1) + + + +*** + + + +#### 应用 + +```sh +user:id:3506728370 → {"name":"春晚","fans":12210862,"blogs":83} +``` + +对于以上数据,使用单条去存的话,存的条数会很多。但如果用 json 格式,存一条数据就够了。 + +假如现在粉丝数量发生了变化,要把整个值都改变,但是用单条存就不存在这个问题,只需要改其中一个就可以 + + + +可以实现购物车的功能,key 对应着每个用户,存储空间存储购物车的信息 + + + + + +*** + + + +### list + +#### 简介 + +数据存储需求:存储多个数据,并对数据进入存储空间的顺序进行区分 + +数据存储结构:一个存储空间保存多个数据,且通过数据可以体现进入顺序,允许重复元素 + +list 类型:保存多个数据,底层使用**双向链表**存储结构实现,类似于 LinkedList + + + +如果两端都能存取数据的话,这就是双端队列,如果只能从一端进一端出,这个模型叫栈 + + + +*** + + + +#### 操作 + +指令操作: + +* 数据操作 + + ```sh + lpush key value1 [value2]...#从左边添加/修改数据(表头) + rpush key value1 [value2]...#从右边添加/修改数据(表尾) + lpop key #从左边获取并移除第一个数据,类似于出栈/出队 + rpop key #从右边获取并移除第一个数据 + lrem key count value #删除指定数据,count=2删除2个,该value可能有多个(重复数据) + ``` + +* 查询操作 + + ```sh + lrange key start stop #从左边遍历数据并指定开始和结束索引,0是第一个索引,-1是终索引 + lindex key index #获取指定索引数据,没有则为nil,没有索引越界 + llen key #list中数据长度/个数 + ``` + +* 规定时间内获取并移除数据 + + ```sh + b #代表阻塞 + blpop key1 [key2] timeout #在指定时间内获取指定key(可以多个)的数据,超时则为(nil) + #可以从其他客户端写数据,当前客户端阻塞读取数据 + brpop key1 [key2] timeout #从右边操作 + ``` + +* 复制操作 + + ```sh + brpoplpush source destination timeout #从source获取数据放入destination,假如在指定时间内没有任何元素被弹出,则返回一个nil和等待时长。反之,返回一个含有两个元素的列表,第一个元素是被弹出元素的值,第二个元素是等待时长 + ``` + +注意事项 + +1. list 中保存的数据都是 string 类型的,数据总容量是有限的,最多 2^32 - 1 个元素(4294967295) +2. list 具有索引的概念,但操作数据时通常以队列的形式进行入队出队,或以栈的形式进行入栈出栈 +3. 获取全部数据操作结束索引设置为 -1 +4. list 可以对数据进行分页操作,通常第一页的信息来自于 list,第 2 页及更多的信息通过数据库的形式加载 + + + +**** + + + +#### 实现 + +在 Redis3.2 版本以前列表对象的内部编码有两种:ziplist(压缩列表)和 linkedlist(链表) + +* 压缩列表实现的列表对象:PUSH 1、three、5 三个元素 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-列表对象ziplist.png) + +* 链表实现的列表对象:为了简化字符串对象的表示,使用了 StringObject 的结构,底层其实是 sdshdr 结构 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-列表对象linkedlist.png) + +列表中存储的数据量比较小的时候,列表就会使用一块连续的内存存储,采用压缩列表的方式实现的条件: + +* 列表对象保存的所有字符串元素的长度都小于 64 字节 +* 列表对象保存的元素数量小于 512 个 + +以上两个条件的上限值是可以通过配置文件修改的,当两个条件的任意一个不能被满足时,对象的编码转换操作就会被执行 + +在 Redis3.2 版本 以后对列表数据结构进行了改造,使用 **quicklist(快速列表)**代替了 linkedlist,quicklist 实际上是 ziplist 和 linkedlist 的混合体,将 linkedlist 按段切分,每一段使用 ziplist 来紧凑存储,多个 ziplist 之间使用双向指针串接起来,既满足了快速的插入删除性能,又不会出现太大的空间冗余 + + + + + +*** + + + +#### 应用 + +企业运营过程中,系统将产生出大量的运营数据,如何保障多台服务器操作日志的统一顺序输出? + +* 依赖 list 的数据具有顺序的特征对信息进行管理,右进左查或者左近左查 +* 使用队列模型解决多路信息汇总合并的问题 +* 使用栈模型解决最新消息的问题 + +微信文章订阅公众号: + +* 比如订阅了两个公众号,它们发布了两篇文章,文章 ID 分别为 666 和 888,可以通过执行 `LPUSH key 666 888` 命令推送给我 + + + + + +*** + + + +### set + +#### 简介 + +数据存储需求:存储大量的数据,在查询方面提供更高的效率 + +数据存储结构:能够保存大量的数据,高效的内部存储机制,便于查询 + +set 类型:与 hash 存储结构哈希表完全相同,只是仅存储键不存储值(nil),所以添加,删除,查找的复杂度都是 O(1),并且**值是不允许重复且无序的** + + + + + +*** + + + +#### 操作 + +指令操作: + +* 数据操作 + + ```sh + sadd key member1 [member2] #添加数据 + srem key member1 [member2] #删除数据 + ``` + +* 查询操作 + + ```sh + smembers key #获取全部数据 + scard key #获取集合数据总量 + sismember key member #判断集合中是否包含指定数据 + ``` + +* 随机操作 + + ```sh + spop key [count] #随机获取集中的某个数据并将该数据移除集合 + srandmember key [count] #随机获取集合中指定(数量)的数据 + +* 集合的交、并、差 + + ```sh + sinter key1 [key2...] #两个集合的交集,不存在为(empty list or set) + sunion key1 [key2...] #两个集合的并集 + sdiff key1 [key2...] #两个集合的差集 + + sinterstore destination key1 [key2...] #两个集合的交集并存储到指定集合中 + sunionstore destination key1 [key2...] #两个集合的并集并存储到指定集合中 + sdiffstore destination key1 [key2...] #两个集合的差集并存储到指定集合中 + ``` + +* 复制 + + ```sh + smove source destination member #将指定数据从原始集合中移动到目标集合中 + ``` + + +注意事项 + +1. set 类型不允许数据重复,如果添加的数据在 set 中已经存在,将只保留一份 +2. set 虽然与 hash 的存储结构相同,但是无法启用 hash 中存储值的空间 + + + +*** + + + +#### 实现 + +集合对象的内部编码有两种:intset(整数集合)、hashtable(哈希表、字典) + +* 整数集合实现的集合对象: + + + +* 字典实现的集合对象:键值对的值为 NULL + + + +当集合对象可以同时满足以下两个条件时,对象使用 intset 编码: + +* 集合中的元素都是整数值 +* 集合中的元素数量小于 set-maxintset-entries配置(默认 512 个) + +以上两个条件的上限值是可以通过配置文件修改的 + + + +**** + + + +#### 应用 + +应用场景: + +1. 黑名单:资讯类信息类网站追求高访问量,但是由于其信息的价值,往往容易被不法分子利用,通过爬虫技术,快速获取信息,个别特种行业网站信息通过爬虫获取分析后,可以转换成商业机密。 + + 注意:爬虫不一定做摧毁性的工作,有些小型网站需要爬虫为其带来一些流量。 + +2. 白名单:对于安全性更高的应用访问,仅仅靠黑名单是不能解决安全问题的,此时需要设定可访问的用户群体, 依赖白名单做更为苛刻的访问验证 + +3. 随机操作可以实现抽奖功能 + +4. 集合的交并补可以实现微博共同关注的查看,可以根据共同关注或者共同喜欢推荐相关内容 + + + + + +*** + + + +### zset + +#### 简介 + +数据存储需求:数据排序有利于数据的有效展示,需要提供一种可以根据自身特征进行排序的方式 + +数据存储结构:新的存储模型,可以保存可排序的数据 + + + +**** + + + +#### 操作 + +指令操作: + +* 数据操作 + + ```sh + zadd key score1 member1 [score2 member2] #添加数据 + zrem key member [member ...] #删除数据 + zremrangebyrank key start stop #删除指定索引范围的数据 + zremrangebyscore key min max #删除指定分数区间内的数据 + zscore key member #获取指定值的分数 + zincrby key increment member #指定值的分数增加increment + ``` + +* 查询操作 + + ```sh + zrange key start stop [WITHSCORES] #获取指定范围的数据,升序,WITHSCORES 代表显示分数 + zrevrange key start stop [WITHSCORES] #获取指定范围的数据,降序 + + zrangebyscore key min max [WITHSCORES] [LIMIT offset count] #按条件获取数据,从小到大 + zrevrangebyscore key max min [WITHSCORES] [...] #从大到小 + + zcard key #获取集合数据的总量 + zcount key min max #获取指定分数区间内的数据总量 + zrank key member #获取数据对应的索引(排名)升序 + zrevrank key member #获取数据对应的索引(排名)降序 + ``` + + * min 与 max 用于限定搜索查询的条件 + * start 与 stop 用于限定查询范围,作用于索引,表示开始和结束索引 + * offset 与 count 用于限定查询范围,作用于查询结果,表示开始位置和数据总量 + +* 集合的交、并操作 + + ```sh + zinterstore destination numkeys key [key ...] #两个集合的交集并存储到指定集合中 + zunionstore destination numkeys key [key ...] #两个集合的并集并存储到指定集合中 + ``` + +注意事项: + +1. score 保存的数据存储空间是 64 位,如果是整数范围是 -9007199254740992~9007199254740992 +2. score 保存的数据也可以是一个双精度的 double 值,基于双精度浮点数的特征可能会丢失精度,慎重使用 +3. sorted_set 底层存储还是基于 set 结构的,因此数据不能重复,如果重复添加相同的数据,score 值将被反复覆盖,保留最后一次修改的结果 + + + +*** + + + +#### 实现 + +有序集合对象的内部编码有两种:ziplist(压缩列表)和 skiplist(跳跃表) + +* 压缩列表实现有序集合对象:ziplist 本身是有序、不可重复的,符合有序集合的特性 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-有序集合对象ziplist.png) + +* 跳跃表实现有序集合对象:**底层是 zset 结构,zset 同时包含字典和跳跃表的结构**,图示字典和跳跃表中重复展示了各个元素的成员和分值,但实际上两者会**通过指针来共享相同元素的成员和分值**,不会产生空间浪费 + + ```c + typedef struct zset { + zskiplist *zsl; + dict *dict; + } zset; + ``` + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-有序集合对象zset.png) + +使用字典加跳跃表的优势: + +* 字典为有序集合创建了一个**从成员到分值的映射**,用 O(1) 复杂度查找给定成员的分值 +* **排序操作使用跳跃表完成**,节省每次重新排序带来的时间成本和空间成本 + +使用 ziplist 格式存储需要满足以下两个条件: + +- 有序集合保存的元素个数要小于 128 个; +- 有序集合保存的所有元素大小都小于 64 字节 + +当元素比较多时,此时 ziplist 的读写效率会下降,时间复杂度是 O(n),跳表的时间复杂度是 O(logn) + +为什么用跳表而不用平衡树? + +* 在做范围查找的时候,跳表操作简单(前进指针或后退指针),平衡树需要回旋查找 +* 跳表比平衡树实现简单,平衡树的插入和删除操作可能引发子树的旋转调整,而跳表的插入和删除只需要修改相邻节点的指针 + + + +*** + + + +#### 应用 + +* 排行榜 +* 对于基于时间线限定的任务处理,将处理时间记录为 score 值,利用排序功能区分处理的先后顺序 +* 当任务或者消息待处理,形成了任务队列或消息队列时,对于高优先级的任务要保障对其优先处理,采用 score 记录权重 + + + + + +*** + + + +### Bitmaps + +#### 基本操作 + +Bitmaps 是二进制位数组(bit array),底层使用 SDS 字符串表示,因为 SDS 是二进制安全的 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-位数组结构.png) + +buf 数组的每个字节用一行表示,buf[1] 是 `'\0'`,保存位数组的顺序和书写位数组的顺序是完全相反的,图示的位数组 0100 1101 + +数据结构的详解查看 Java → Algorithm → 位图 + + + + + +*** + + + +#### 命令实现 + +##### GETBIT + +GETBIT 命令获取位数组 bitarray 在 offset 偏移量上的二进制位的值 + +```sh +GETBIT +``` + +执行过程: + +* 计算 `byte = offset/8`(向下取整), byte 值记录数据保存在位数组中的索引 +* 计算 `bit = (offset mod 8) + 1`,bit 值记录数据在位数组中的第几个二进制位 +* 根据 byte 和 bit 值,在位数组 bitarray 中定位 offset 偏移量指定的二进制位,并返回这个位的值 + +GETBIT 命令执行的所有操作都可以在常数时间内完成,所以时间复杂度为 O(1) + + + +*** + + + +##### SETBIT + +SETBIT 将位数组 bitarray 在 offset 偏移量上的二进制位的值设置为 value,并向客户端返回二进制位的旧值 + +```sh +SETBIT +``` + +执行过程: + +* 计算 `len = offset/8 + 1`,len 值记录了保存该数据至少需要多少个字节 +* 检查 bitarray 键保存的位数组的长度是否小于 len,成立就会将 SDS 扩展为 len 字节(注意空间预分配机制),所有新扩展空间的二进制位的值置为 0 +* 计算 `byte = offset/8`(向下取整), byte 值记录数据保存在位数组中的索引 +* 计算 `bit = (offset mod 8) + 1`,bit 值记录数据在位数组中的第几个二进制位 +* 根据 byte 和 bit 值,在位数组 bitarray 中定位 offset 偏移量指定的二进制位,首先将指定位现存的值保存在 oldvalue 变量,然后将新值 value 设置为这个二进制位的值 +* 向客户端返回 oldvalue 变量的值 + + + +*** + + + +##### BITCOUNT + +BITCOUNT 命令用于统计给定位数组中,值为 1 的二进制位的数量 + +```sh +BITCOUNT [start end] +``` + +二进制位统计算法: + +* 遍历法:遍历位数组中的每个二进制位 +* 查表算法:读取每个字节(8 位)的数据,查表获取数值对应的二进制中有几个 1 +* variable-precision SWAR算法:计算汉明距离 +* Redis 实现: + * 如果二进制位的数量大于等于 128 位, 那么使用 variable-precision SWAR 算法来计算二进制位的汉明重量 + * 如果二进制位的数量小于 128 位,那么使用查表算法来计算二进制位的汉明重量 + + + +**** + + + +##### BITOP + +BITOP 命令对指定 key 按位进行交、并、非、异或操作,并将结果保存到指定的键中 + +```sh +BITOP OPTION destKey key1 [key2...] +``` + +OPTION 有 AND(与)、OR(或)、 XOR(异或)和 NOT(非)四个选项 + +AND、OR、XOR 三个命令可以接受多个位数组作为输入,需要遍历输入的每个位数组的每个字节来进行计算,所以命令的复杂度为 O(n^2);与此相反,NOT 命令只接受一个位数组输入,所以时间复杂度为 O(n) + + + +*** + + + +#### 应用场景 + +- **解决 Redis 缓存穿透**,判断给定数据是否存在, 防止缓存穿透 + + + +- 垃圾邮件过滤,对每一个发送邮件的地址进行判断是否在布隆的黑名单中,如果在就判断为垃圾邮件 + +- 爬虫去重,爬给定网址的时候对已经爬取过的 URL 去重 + +- 信息状态统计 + + + + + +*** + + + +### Hyper + +基数是数据集去重后元素个数,HyperLogLog 是用来做基数统计的,运用了 LogLog 的算法 + +```java +{1, 3, 5, 7, 5, 7, 8} 基数集: {1, 3, 5 ,7, 8} 基数:5 +{1, 1, 1, 1, 1, 7, 1} 基数集: {1,7} 基数:2 +``` + +相关指令: + +* 添加数据 + + ```sh + pfadd key element [element ...] + ``` + +* 统计数据 + + ```sh + pfcount key [key ...] + ``` + +* 合并数据 + + ```sh + pfmerge destkey sourcekey [sourcekey...] + ``` + +应用场景: + +* 用于进行基数统计,不是集合不保存数据,只记录数量而不是具体数据,比如网站的访问量 +* 核心是基数估算算法,最终数值存在一定误差 +* 误差范围:基数估计的结果是一个带有 0.81% 标准错误的近似值 +* 耗空间极小,每个 hyperloglog key 占用了12K的内存用于标记基数 +* pfadd 命令不是一次性分配12K内存使用,会随着基数的增加内存逐渐增大 +* Pfmerge 命令合并后占用的存储空间为12K,无论合并之前数据量多少 + + + +*** + + + +### GEO + +GeoHash 是一种地址编码方法,把二维的空间经纬度数据编码成一个字符串 + +* 添加坐标点 + + ```sh + geoadd key longitude latitude member [longitude latitude member ...] + georadius key longitude latitude radius m|km|ft|mi [withcoord] [withdist] [withhash] [count count] + ``` + +* 获取坐标点 + + ```sh + geopos key member [member ...] + georadiusbymember key member radius m|km|ft|mi [withcoord] [withdist] [withhash] [count count] + ``` + +* 计算距离 + + ```sh + geodist key member1 member2 [unit] #计算坐标点距离 + geohash key member [member ...] #计算经纬度 + ``` + +Redis 应用于地理位置计算 + + + + + +**** + + + + + +## 持久机制 + +### 概述 + +持久化:利用永久性存储介质将数据进行保存,在特定的时间将保存的数据进行恢复的工作机制称为持久化 + +作用:持久化用于防止数据的意外丢失,确保数据安全性,因为 Redis 是内存级,所以需要持久化到磁盘 + +计算机中的数据全部都是二进制,保存一组数据有两种方式 + + +RDB:将当前数据状态进行保存,快照形式,存储数据结果,存储格式简单 + +AOF:将数据的操作过程进行保存,日志形式,存储操作过程,存储格式复杂 + + + +*** + + + +### RDB + +#### 文件创建 + +RDB 持久化功能所生成的 RDB 文件是一个经过压缩的紧凑二进制文件,通过该文件可以还原生成 RDB 文件时的数据库状态,有两个 Redis 命令可以生成 RDB 文件,一个是 SAVE,另一个是 BGSAVE + + + +##### SAVE + +SAVE 指令:手动执行一次保存操作,该指令的执行会阻塞当前 Redis 服务器,客户端发送的所有命令请求都会被拒绝,直到当前 RDB 过程完成为止,有可能会造成长时间阻塞,线上环境不建议使用 + +工作原理:Redis 是个**单线程的工作模式**,会创建一个任务队列,所有的命令都会进到这个队列排队执行。当某个指令在执行的时候,队列后面的指令都要等待,所以这种执行方式会非常耗时 + +配置 redis.conf: + +```sh +dir path #设置存储.rdb文件的路径,通常设置成存储空间较大的目录中,目录名称data +dbfilename "x.rdb" #设置本地数据库文件名,默认值为dump.rdb,通常设置为dump-端口号.rdb +rdbcompression yes|no #设置存储至本地数据库时是否压缩数据,默认yes,设置为no节省CPU运行时间 +rdbchecksum yes|no #设置读写文件过程是否进行RDB格式校验,默认yes +``` + + + +*** + + + +##### BGSAVE + +BGSAVE:bg 是 background,代表后台执行,命令的完成需要两个进程,**进程之间不相互影响**,所以持久化期间 Redis 正常工作 + +工作原理: + + + +流程:客户端发出 BGSAVE 指令,Redis 服务器使用 fork 函数创建一个子进程,然后响应后台已经开始执行的信息给客户端。子进程会异步执行持久化的操作,持久化过程是先将数据写入到一个临时文件中,持久化操作结束再用这个临时文件**替换**上次持久化的文件 + +```python +# 创建子进程 +pid = fork() +if pid == 0: + # 子进程负责创建 RDB 文件 + rdbSave() + # 完成之后向父进程发送信号 + signal_parent() +elif pid > 0: + # 父进程继续处理命令请求,并通过轮询等待子进程的信号 + handle_request_and_wait_signal() +else: + # 处理出错恃况 + handle_fork_error() +``` + +配置 redis.conf + +```sh +stop-writes-on-bgsave-error yes|no #后台存储过程中如果出现错误,是否停止保存操作,默认yes +dbfilename filename +dir path +rdbcompression yes|no +rdbchecksum yes|no +``` + +注意:BGSAVE 命令是针对 SAVE 阻塞问题做的优化,Redis 内部所有涉及到 RDB 操作都采用 BGSAVE 的方式,SAVE 命令放弃使用 + +在 BGSAVE 命令执行期间,服务器处理 SAVE、BGSAVE、BGREWRITEAOF 三个命令的方式会和平时有所不同 + +* SAVE 命令会被服务器拒绝,服务器禁止 SAVE 和 BGSAVE 命令同时执行是为了避免父进程(服务器进程)和子进程同时执行两个 rdbSave 调用,产生竞争条件 +* BGSAVE 命令也会被服务器拒绝,也会产生竞争条件 +* BGREWRITEAOF 和 BGSAVE 两个命令不能同时执行 + * 如果 BGSAVE 命令正在执行,那么 BGREWRITEAOF 命令会被**延迟**到 BGSAVE 命令执行完毕之后执行 + * 如果 BGREWRITEAOF 命令正在执行,那么 BGSAVE 命令会被服务器拒绝 + + + +*** + + + +##### 特殊指令 + +RDB 特殊启动形式的指令(客户端输入) + +* 服务器运行过程中重启 + + ```sh + debug reload + ``` + +* 关闭服务器时指定保存数据 + + ```sh + shutdown save + ``` + + 默认情况下执行 shutdown 命令时,自动执行 bgsave(如果没有开启 AOF 持久化功能) + +* 全量复制:主从复制部分详解 + + + + + +*** + + + +#### 文件载入 + +RDB 文件的载入工作是在服务器启动时自动执行,期间 Redis 会一直处于阻塞状态,直到载入完成 + +Redis 并没有专门用于载入 RDB 文件的命令,只要服务器在启动时检测到 RDB 文件存在,就会自动载入 RDB 文件 + +```sh +[7379] 30 Aug 21:07:01.289 * DB loaded from disk: 0.018 seconds # 服务器在成功载入 RDB 文件之后打印 +``` + +AOF 文件的更新频率通常比 RDB 文件的更新频率高: + +* 如果服务器开启了 AOF 持久化功能,那么会优先使用 AOF 文件来还原数据库状态 +* 只有在 AOF 持久化功能处于关闭状态时,服务器才会使用 RDB 文件来还原数据库状态 + + + + + +**** + + + +#### 自动保存 + +##### 配置文件 + +Redis 支持通过配置服务器的 save 选项,让服务器每隔一段时间自动执行一次 BGSAVE 命令 + +配置 redis.conf: + +```sh +save second changes #设置自动持久化条件,满足限定时间范围内key的变化数量就进行持久化(bgsave) +``` + +* second:监控时间范围 +* changes:监控 key 的变化量 + +默认三个条件: + +```sh +save 900 1 # 900s内1个key发生变化就进行持久化 +save 300 10 +save 60 10000 +``` + +判定 key 变化的依据: + +* 对数据产生了影响,不包括查询 +* 不进行数据比对,比如 name 键存在,重新 set name seazean 也算一次变化 + +save 配置要根据实际业务情况进行设置,频度过高或过低都会出现性能问题,结果可能是灾难性的 + + + +*** + + + +##### 自动原理 + +服务器状态相关的属性: + +```c +struct redisServer { + // 记录了保存条件的数组 + struct saveparam *saveparams; + + // 修改计数器 + long long dirty; + + // 上一次执行保存的时间 + time_t lastsave; +}; +``` + +* Redis 服务器启动时,可以通过指定配置文件或者传入启动参数的方式设置 save 选项, 如果没有自定义就设置为三个默认值(上节提及),设置服务器状态 redisServe.saveparams 属性,该数组每一项为一个 saveparam 结构,代表 save 的选项设置 + + ```c + struct saveparam { + // 秒数 + time_t seconds + // 修改数 + int changes; + }; + ``` + +* dirty 计数器记录距离上一次成功执行 SAVE 或者 BGSAVE 命令之后,服务器中的所有数据库进行了多少次修改(包括写入、删除、更新等操作),当服务器成功执行一个修改指令,该命令修改了多少次数据库, dirty 的值就增加多少 + +* lastsave 属性是一个 UNIX 时间戳,记录了服务器上一次成功执行 SAVE 或者 BGSAVE 命令的时间 + +Redis 的服务器周期性操作函数 serverCron 默认每隔 100 毫秒就会执行一次,该函数用于对正在运行的服务器进行维护 + +serverCron 函数的其中一项工作是检查 save 选项所设置的保存条件是否满足,会遍历 saveparams 数组中的**所有保存条件**,只要有任意一个条件被满足服务器就会执行 BGSAVE 命令 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-BGSAVE执行原理.png) + + + + + +*** + + + +#### 文件结构 + +RDB 的存储结构:图示全大写单词标示常量,用全小写单词标示变量和数据 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-RDB文件结构.png) + +* REDIS:长度为 5 字节,保存着 `REDIS` 五个字符,是 RDB 文件的开头,在载入文件时可以快速检查所载入的文件是否 RDB 文件 +* db_version:长度为 4 字节,是一个用字符串表示的整数,记录 RDB 的版本号 +* database:包含着零个或任意多个数据库,以及各个数据库中的键值对数据 +* EOF:长度为 1 字节的常量,标志着 RDB 文件正文内容的结束,当读入遇到这个值时,代表所有数据库的键值对都已经载入完毕 +* check_sum:长度为 8 字节的无符号整数,保存着一个校验和,该值是通过 REDIS、db_version、databases、EOF 四个部分的内容进行计算得出。服务器在载入 RDB 文件时,会将载入数据所计算出的校验和与 check_sum 所记录的校验和进行对比,来检查 RDB 文件是否有出错或者损坏 + +Redis 本身带有 RDB 文件检查工具 redis-check-dump + + + + + +*** + + + +### AOF + +#### 基本概述 + +AOF(append only file)持久化:以独立日志的方式记录每次写命令(不记录读)来记录数据库状态,**增量保存**只许追加文件但不可以改写文件,**与 RDB 相比可以理解为由记录数据改为记录数据的变化** + +AOF 主要作用是解决了**数据持久化的实时性**,目前已经是 Redis 持久化的主流方式 + +AOF 写数据过程: + + + +Redis 只会将对数据库进行了修改的命令写入到 AOF 文件,并复制到各个从服务器,但是 PUBSUB 和 SCRIPT LOAD 命令例外: + +* PUBSUB 命令虽然没有修改数据库,但 PUBSUB 命令向频道的所有订阅者发送消息这一行为带有副作用,接收到消息的所有客户端的状态都会因为这个命令而改变,所以服务器需要使用 REDIS_FORCE_AOF 标志强制将这个命令写入 AOF 文件。这样在将来载入 AOF 文件时,服务器就可以再次执行相同的 PUBSUB 命令,并产生相同的副作用 +* SCRIPT LOAD 命令虽然没有修改数据库,但它修改了服务器状态,所以也是一个带有副作用的命令,需要使用 REDIS_FORCE_AOF + + + +*** + + + +#### 持久实现 + +AOF 持久化功能的实现可以分为命令追加(append)、文件写入、文件同步(sync)三个步骤 + + + +##### 命令追加 + +启动 AOF 的基本配置: + +```sh +appendonly yes|no #开启AOF持久化功能,默认no,即不开启状态 +appendfilename filename #AOF持久化文件名,默认appendonly.aof,建议设置appendonly-端口号.aof +dir #AOF持久化文件保存路径,与RDB持久化文件路径保持一致即可 +``` + +当 AOF 持久化功能处于打开状态时,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令**追加**到服务器状态的 aof_buf 缓冲区的末尾 + +```c +struct redisServer { + // AOF 缓冲区 + sds aof_buf; +}; +``` + + + +*** + + + +##### 文件写入 + +服务器在处理文件事件时会执行**写命令,追加一些内容到 aof_buf 缓冲区**里,所以服务器每次结束一个事件循环之前,就会执行 flushAppendOnlyFile 函数,判断是否需要**将 aof_buf 缓冲区中的内容写入和保存到 AOF 文件**里 + +flushAppendOnlyFile 函数的行为由服务器配置的 appendfsync 选项的值来决定 + +```sh +appendfsync always|everysec|no #AOF写数据策略:默认为everysec +``` + +- always:每次写入操作都将 aof_buf 缓冲区中的所有内容**写入并同步**到 AOF 文件 + + 特点:安全性最高,数据零误差,但是性能较低,不建议使用 + + +- everysec:先将 aof_buf 缓冲区中的内容写入到操作系统缓存,判断上次同步 AOF 文件的时间距离现在超过一秒钟,再次进行同步 fsync,这个同步操作是由一个(子)线程专门负责执行的 + + 特点:在系统突然宕机的情况下丢失 1 秒内的数据,准确性较高,性能较高,建议使用,也是默认配置 + + +- no:将 aof_buf 缓冲区中的内容写入到操作系统缓存,但并不进行同步,何时同步由操作系统来决定 + + 特点:**整体不可控**,服务器宕机会丢失上次同步 AOF 后的所有写指令 + + + +**** + + + +##### 文件同步 + +在现代操作系统中,当用户调用 write 函数将数据写入文件时,操作系统通常会将写入数据暂时保存在一个内存缓冲区空间,等到缓冲区**写满或者到达特定时间周期**,才真正地将缓冲区中的数据写入到磁盘里面(刷脏) + +* 优点:提高文件的写入效率 +* 缺点:为写入数据带来了安全问题,如果计算机发生停机,那么保存在内存缓冲区里面的写入数据将会丢失 + +系统提供了 fsync 和 fdatasync 两个同步函数做**强制硬盘同步**,可以让操作系统立即将缓冲区中的数据写入到硬盘里面,函数会阻塞到写入硬盘完成后返回,保证了数据持久化 + +异常恢复:AOF 文件损坏,通过 redis-check-aof--fix appendonly.aof 进行恢复,重启 Redis,然后重新加载 + + + + + +*** + + + +#### 文件载入 + +AOF 文件里包含了重建数据库状态所需的所有写命令,所以服务器只要读入并重新执行一遍 AOF 文件里的命令,就还原服务器关闭之前的数据库状态,服务器在启动时,还原数据库状态打印的日志: + +```sh +[8321] 05 Sep 11:58:50.449 * DB loaded from append only file: 0.000 seconds +``` + +AOF 文件里面除了用于指定数据库的 SELECT 命令是服务器自动添加的,其他都是通过客户端发送的命令 + +```sh +* 2\r\n$6\r\nSELECT\r\n$1\r\n0\r\n # 服务器自动添加 +* 3\r\n$3\r\nSET\r\n$3\r\nmsg\r\n$5\r\nhello\r\n +* 5\r\n$4\r\nSADD\r\n$6\r\nfruits\r\n$5\r\napple\r\n$6\r\nbanana\r\n$6\r\ncherry\r\n +``` + +Redis 读取 AOF 文件并还原数据库状态的步骤: + +* 创建一个**不带网络连接的伪客户端**(fake client)执行命令,因为 Redis 的命令只能在客户端上下文中执行, 而载入 AOF 文件时所使用的命令来源于本地 AOF 文件而不是网络连接 +* 从 AOF 文件分析并读取一条写命令 +* 使用伪客户端执行被读出的写命令,然后重复上述步骤 + + + + + +**** + + + +#### 重写实现 + +##### 重写策略 + +AOF 重写:读取服务器当前的数据库状态,**生成新 AOF 文件来替换旧 AOF 文件**,不会对现有的 AOF 文件进行任何读取、分析或者写入操作,而是直接原子替换。新 AOF 文件不会包含任何浪费空间的冗余命令,所以体积通常会比旧 AOF 文件小得多 + +AOF 重写规则: + +- 进程内具有时效性的数据,并且数据已超时将不再写入文件 + + +- 对同一数据的多条写命令合并为一条命令,因为会读取当前的状态,所以直接将当前状态转换为一条命令即可。为防止数据量过大造成客户端缓冲区溢出,对 list、set、hash、zset 等集合类型,**单条指令**最多写入 64 个元素 + + 如 lpushlist1 a、lpush list1 b、lpush list1 c 可以转化为:lpush list1 a b c + +- 非写入类的无效指令将被忽略,只保留最终数据的写入命令,但是 select 指令虽然不更改数据,但是更改了数据的存储位置,此类命令同样需要记录 + +AOF 重写作用: + +- 降低磁盘占用量,提高磁盘利用率 +- 提高持久化效率,降低持久化写时间,提高 IO 性能 +- 降低数据恢复的用时,提高数据恢复效率 + + + +*** + + + +##### 重写原理 + +AOF 重写程序 aof_rewrite 函数可以创建一个新 AOF 文件, 但是该函数会进行大量的写入操作,调用这个函数的线程将被长时间阻塞,所以 Redis 将 AOF 重写程序放到 fork 的子进程里执行,不会阻塞父进程,重写命令: + +```sh +bgrewriteaof +``` + +* 子进程进行 AOF 重写期间,服务器进程(父进程)可以继续处理命令请求 + +* 子进程带有服务器进程的数据副本,使用子进程而不是线程,可以在避免使用锁的情况下, 保证数据安全性 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/Redis-AOF手动重写原理.png) + +子进程在进行 AOF 重写期间,服务器进程还需要继续处理命令请求,而新命令可能会对现有的数据库状态进行修改,从而使得服务器当前的数据库状态和重写后的 AOF 文件所保存的数据库状态不一致,所以 Redis 设置了 AOF 重写缓冲区 + +工作流程: + +* Redis 服务器执行完一个写命令,会同时将该命令追加到 AOF 缓冲区和 AOF 重写缓冲区(从创建子进程后才开始写入) +* 当子进程完成 AOF 重写工作之后,会向父进程发送一个信号,父进程在接到该信号之后, 会调用一个信号处理函数,该函数执行时会**对服务器进程(父进程)造成阻塞**(影响很小,类似 JVM STW),主要工作: + * 将 AOF 重写缓冲区中的所有内容写入到新 AOF 文件中, 这时新 AOF 文件所保存的状态将和服务器当前的数据库状态一致 + * 对新的 AOF 文件进行改名,**原子地(atomic)覆盖**现有的 AOF 文件,完成新旧两个 AOF 文件的替换 + + + + + +*** + + + +##### 自动重写 + +触发时机:Redis 会记录上次重写时的 AOF 大小,默认配置是当 AOF 文件大小是上次重写后大小的一倍且文件大于 64M 时触发 + +```sh +auto-aof-rewrite-min-size size #设置重写的基准值,最小文件 64MB,达到这个值开始重写 +auto-aof-rewrite-percentage percent #触发AOF文件执行重写的增长率,当前AOF文件大小超过上一次重写的AOF文件大小的百分之多少才会重写,比如文件达到 100% 时开始重写就是两倍时触发 +``` + +自动重写触发比对参数( 运行指令 `info Persistence` 获取具体信息 ): + +```sh +aof_current_size #AOF文件当前尺寸大小(单位:字节) +aof_base_size #AOF文件上次启动和重写时的尺寸大小(单位:字节) +``` + +自动重写触发条件公式: + +- aof_current_size > auto-aof-rewrite-min-size +- (aof_current_size - aof_base_size) / aof_base_size >= auto-aof-rewrite-percentage + + + + + +**** + + + +### 对比 + +RDB 的特点 + +* RDB 优点: + - RDB 是一个紧凑压缩的二进制文件,存储效率较高,但存储数据量较大时,存储效率较低 + - RDB 内部存储的是 Redis 在某个时间点的数据快照,非常**适合用于数据备份,全量复制、灾难恢复** + - RDB 恢复数据的速度要比 AOF 快很多,因为是快照,直接恢复 +* RDB 缺点: + + - BGSAVE 指令每次运行要执行 fork 操作创建子进程,会牺牲一些性能 + - RDB 方式无论是执行指令还是利用配置,无法做到实时持久化,具有丢失数据的可能性,最后一次持久化后的数据可能丢失 + - Redis 的众多版本中未进行 RDB 文件格式的版本统一,可能出现各版本之间数据格式无法兼容 + +AOF 特点: + +* AOF 的优点:数据持久化有**较好的实时性**,通过 AOF 重写可以降低文件的体积 +* AOF 的缺点:文件较大时恢复较慢 + +AOF 和 RDB 同时开启,系统默认取 AOF 的数据(数据不会存在丢失) + +应用场景: + +- 对数据**非常敏感**,建议使用默认的 AOF 持久化方案,AOF 持久化策略使用 everysecond,每秒钟 fsync 一次,该策略 Redis 仍可以保持很好的处理性能 + + 注意:AOF 文件存储体积较大,恢复速度较慢,因为要执行每条指令 + +- 数据呈现**阶段有效性**,建议使用 RDB 持久化方案,可以做到阶段内无丢失,且恢复速度较快 + + 注意:利用 RDB 实现紧凑的数据持久化,存储数据量较大时,存储效率较低 + +综合对比: + +- RDB 与 AOF 的选择实际上是在做一种权衡,每种都有利有弊 +- 灾难恢复选用 RDB +- 如不能承受数分钟以内的数据丢失,对业务数据非常敏感,选用 AOF;如能承受数分钟以内的数据丢失,且追求大数据集的恢复速度,选用 RDB +- 双保险策略,同时开启 RDB 和 AOF,重启后 Redis 优先使用 AOF 来恢复数据,降低丢失数据的量 +- 不建议单独用 AOF,因为可能会出现 Bug,如果只是做纯内存缓存,可以都不用 + + + +*** + + + +### fork + +#### 介绍 + +fork() 函数创建一个子进程,子进程与父进程几乎是完全相同的进程,系统先给子进程分配资源,然后把父进程的所有数据都复制到子进程中,只有少数值与父进程的值不同,相当于克隆了一个进程 + +在完成对其调用之后,会产生 2 个进程,且每个进程都会**从 fork() 的返回处开始执行**,这两个进程将执行相同的程序段,但是拥有各自不同的堆段,栈段,数据段,每个子进程都可修改各自的数据段,堆段,和栈段 + +```c +#include +pid_t fork(void); +// 父进程返回子进程的pid,子进程返回0,错误返回负值,根据返回值的不同进行对应的逻辑处理 +``` + +fork 调用一次,却能够**返回两次**,可能有三种不同的返回值: + +* 在父进程中,fork 返回新创建子进程的进程 ID +* 在子进程中,fork 返回 0 +* 如果出现错误,fork 返回一个负值,错误原因: + * 当前的进程数已经达到了系统规定的上限,这时 errno 的值被设置为 EAGAIN + * 系统内存不足,这时 errno 的值被设置为 ENOMEM + +fpid 的值在父子进程中不同:进程形成了链表,父进程的 fpid 指向子进程的进程 id,因为子进程没有子进程,所以其 fpid 为0 + +创建新进程成功后,系统中出现两个基本完全相同的进程,这两个进程执行没有固定的先后顺序,哪个进程先执行要看系统的调度策略 + +每个进程都有一个独特(互不相同)的进程标识符 process ID,可以通过 getpid() 函数获得;还有一个记录父进程 pid 的变量,可以通过 getppid() 函数获得变量的值 + + + +*** + + + +#### 使用 + +基本使用: + +```c +#include +#include +int main () +{ + pid_t fpid; // fpid表示fork函数返回的值 + int count = 0; + fpid = fork(); + if (fpid < 0) + printf("error in fork!"); + else if (fpid == 0) { + printf("i am the child process, my process id is %d/n", getpid()); + count++; + } + else { + printf("i am the parent process, my process id is %d/n", getpid()); + count++; + } + printf("count: %d/n",count);// 1 + return 0; +} +/* 输出内容: + i am the child process, my process id is 5574 + count: 1 + i am the parent process, my process id is 5573 + count: 1 +*/ +``` + +进阶使用: + +```c +#include +#include +int main(void) +{ + int i = 0; + // ppid 指当前进程的父进程pid + // pid 指当前进程的pid, + // fpid 指fork返回给当前进程的值,在这可以表示子进程 + for(i = 0; i < 2; i++){ + pid_t fpid = fork(); + if(fpid == 0) + printf("%d child %4d %4d %4d/n",i, getppid(), getpid(), fpid); + else + printf("%d parent %4d %4d %4d/n",i, getppid(), getpid(),fpid); + } + return 0; +} +/*输出内容: + i 父id id 子id + 0 parent 2043 3224 3225 + 0 child 3224 3225 0 + 1 parent 2043 3224 3226 + 1 parent 3224 3225 3227 + 1 child 1 3227 0 + 1 child 1 3226 0 +*/ +``` + + + +在 p3224 和 p3225 执行完第二个循环后,main 函数退出,进程死亡。所以 p3226,p3227 就没有父进程了,成为孤儿进程,所以 p3226 和 p3227 的父进程就被置为 ID 为 1 的 init 进程(笔记 Tool → Linux → 进程管理详解) + +参考文章:https://blog.csdn.net/love_gaohz/article/details/41727415 + + + +*** + + + +#### 内存 + +fork() 调用之后父子进程的内存关系 + +早期 Linux 的 fork() 实现时,就是全部复制,这种方法效率太低,而且造成了很大的内存浪费,现在 Linux 实现采用了两种方法: + +* 父子进程的代码段是相同的,所以代码段是没必要复制的,只需内核将代码段标记为只读,父子进程就共享此代码段。fork() 之后在进程创建代码段时,子进程的进程级页表项都指向和父进程相同的物理页帧 + + + +* 对于父进程的数据段,堆段,栈段中的各页,由于父子进程相互独立,采用**写时复制 COW** 的技术,来提高内存以及内核的利用率 + + 在 fork 之后两个进程用的是相同的物理空间(内存区),子进程的代码段、数据段、堆栈都是指向父进程的物理空间,**两者的虚拟空间不同,但其对应的物理空间是同一个**,当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间。如果两者的代码完全相同,代码段继续共享父进程的物理空间;而如果两者执行的代码不同,子进程的代码段也会分配单独的物理空间。 + + fork 之后内核会将子进程放在队列的前面,让子进程先执行,以免父进程执行导致写时复制,而后子进程再执行,因无意义的复制而造成效率的下降 + + + +补充知识: + +vfork(虚拟内存 fork virtual memory fork):调用 vfork() 父进程被挂起,子进程使用父进程的地址空间。不采用写时复制,如果子进程修改父地址空间的任何页面,这些修改过的页面对于恢复的父进程是可见的 + + + +参考文章:https://blog.csdn.net/Shreck66/article/details/47039937 + + + + + +**** + + + + + +## 事务机制 + +### 事务特征 + +Redis 事务就是将多个命令请求打包,然后**一次性、按顺序**地执行多个命令的机制,并且在事务执行期间,服务器不会中断事务去执行其他的命令请求,会将事务中的所有命令都执行完毕,然后才去处理其他客户端的命令请求,Redis 事务的特性: + +* Redis 事务**没有隔离级别**的概念,队列中的命令在事务没有提交之前都不会实际被执行 +* Redis 单条命令式保存原子性的,但是事务**不保证原子性**,事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚 + + + + + +*** + + + +### 工作流程 + +事务的执行流程分为三个阶段: + +* 事务开始:MULTI 命令的执行标志着事务的开始,通过在客户端状态的 flags 属性中打开 REDIS_MULTI 标识,将执行该命令的客户端从非事务状态切换至事务状态 + + ```sh + MULTI # 设定事务的开启位置,此指令执行后,后续的所有指令均加入到事务中 + ``` + +* 命令入队:事务队列以先进先出(FIFO)的方式保存入队的命令,每个 Redis 客户端都有事务状态,包含着事务队列: + + ```c + typedef struct redisClient { + // 事务状态 + multiState mstate; /* MULTI/EXEC state */ + } + + typedef struct multiState { + // 事务队列,FIFO顺序 + multiCmd *commands; + + // 已入队命令计数 + int count; + } + ``` + + * 如果命令为 EXEC、DISCARD、WATCH、MULTI 四个命中的一个,那么服务器立即执行这个命令 + * 其他命令服务器不执行,而是将命令放入一个事务队列里面,然后向客户端返回 QUEUED 回复 + +* 事务执行:EXEC 提交事务给服务器执行,服务器会遍历这个客户端的事务队列,执行队列中的命令并将执行结果返回 + + ```sh + EXEC # Commit 提交,执行事务,与multi成对出现,成对使用 + ``` + +事务取消的方法: + +* 取消事务: + + ```sh + DISCARD # 终止当前事务的定义,发生在multi之后,exec之前 + ``` + + 一般用于事务执行过程中输入了错误的指令,直接取消这次事务,类似于回滚 + + + + + +*** + + + +### WATCH + +#### 监视机制 + +WATCH 命令是一个乐观锁(optimistic locking),可以在 EXEC 命令执行之前,监视任意数量的数据库键,并在 EXEC 命令执行时,检查被监视的键是否至少有一个已经被修改过了,如果是服务器将拒绝执行事务,并向客户端返回代表事务执行失败的空回复 + +* 添加监控锁 + + ```sh + WATCH key1 [key2……] #可以监控一个或者多个key + ``` + +* 取消对所有 key 的监视 + + ```sh + UNWATCH + ``` + + + +*** + + + +#### 实现原理 + +每个 Redis 数据库都保存着一个 watched_keys 字典,键是某个被 WATCH 监视的数据库键,值则是一个链表,记录了所有监视相应数据库键的客户端: + +```c +typedef struct redisDb { + // 正在被 WATCH 命令监视的键 + dict *watched_keys; +} +``` + +所有对数据库进行修改的命令,在执行后都会调用 `multi.c/touchWatchKey` 函数对 watched_keys 字典进行检查,是否有客户端正在监视刚被命令修改过的数据库键,如果有的话函数会将监视被修改键的客户端的 REDIS_DIRTY_CAS 标识打开,表示该客户端的事务安全性已经被破坏 + +服务器接收到个客户端 EXEC 命令时,会根据这个客户端是否打开了 REDIS_DIRTY_CAS 标识,如果打开了说明客户端提交事务不安全,服务器会拒绝执行 + + + + + +**** + + + +### ACID + +#### 原子性 + +事务具有原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability) + +原子性指事务队列中的命令要么就全部都执行,要么一个都不执行,但是在命令执行出错时,不会保证原子性(下一节详解) + +Redis 不支持事务回滚机制(rollback),即使事务队列中的某个命令在执行期间出现了错误,整个事务也会继续执行下去,直到将事务队列中的所有命令都执行完毕为止 + +回滚需要程序员在代码中实现,应该尽可能避免: + +* 事务操作之前记录数据的状态 + + * 单数据:string + + * 多数据:hash、list、set、zset + + +* 设置指令恢复所有的被修改的项 + + * 单数据:直接 set(注意周边属性,例如时效) + + * 多数据:修改对应值或整体克隆复制 + + + +*** + + + +#### 一致性 + +事务具有一致性指的是,数据库在执行事务之前是一致的,那么在事务执行之后,无论事务是否执行成功,数据库也应该仍然是一致的 + +一致是数据符合数据库的定义和要求,没有包含非法或者无效的错误数据,Redis 通过错误检测和简单的设计来保证事务的一致性: + +* 入队错误:命令格式输入错误,出现语法错误造成,**整体事务中所有命令均不会执行**,包括那些语法正确的命令 + + + +* 执行错误:命令执行出现错误,例如对字符串进行 incr 操作,事务中正确的命令会被执行,运行错误的命令不会被执行 + + + +* 服务器停机: + + * 如果服务器运行在无持久化的内存模式下,那么重启之后的数据库将是空白的,因此数据库是一致的 + * 如果服务器运行在持久化模式下,重启之后将数据库还原到一致的状态 + + + + +*** + + + +#### 隔离性 + +Redis 是一个单线程的执行原理,所以对于隔离性,分以下两种情况: + +* 并发操作在 EXEC 命令前执行,隔离性的保证要使用 WATCH 机制来实现,否则隔离性无法保证 +* 并发操作在 EXEC 命令后执行,隔离性可以保证 + + + +*** + + + +#### 持久性 + +Redis 并没有为事务提供任何额外的持久化功能,事务的持久性由 Redis 所使用的持久化模式决定 + +配置选项 `no-appendfsync-on-rewrite` 可以配合 appendfsync 选项在 AOF 持久化模式使用: + +* 选项打开时在执行 BGSAVE 或者 BGREWRITEAOF 期间,服务器会暂时停止对 AOF 文件进行同步,从而尽可能地减少 I/O 阻塞 +* 选项打开时运行在 always 模式的 AOF 持久化,事务也不具有持久性,所以该选项默认关闭 + +在一个事务的最后加上 SAVE 命令总可以保证事务的耐久性 + + + + + +*** + + + +## Lua 脚本 + +### 环境创建 + +#### 基本介绍 + +Redis 对 Lua 脚本支持,通过在服务器中嵌入 Lua 环境,客户端可以使用 Lua 脚本直接在服务器端**原子地执行**多个命令 + +```sh +EVAL +
+ + + + +
+ + + ``` + + + + + +*** + + + +### 参数调优 + +#### CONNECT + +参数配置方式: + +* 客户端通过 .option() 方法配置参数,给 SocketChannel 配置参数 +* 服务器端: + * new ServerBootstrap().option(): 给 ServerSocketChannel 配置参数 + * new ServerBootstrap().childOption():给 SocketChannel 配置参数 + +CONNECT_TIMEOUT_MILLIS 参数: + +* 属于 SocketChannal 参数 +* 在客户端建立连接时,如果在指定毫秒内无法连接,会抛出 timeout 异常 + +* SO_TIMEOUT 主要用在阻塞 IO,阻塞 IO 中 accept,read 等都是无限等待的,如果不希望永远阻塞,可以调整超时时间 + +```java +public class ConnectionTimeoutTest { + public static void main(String[] args) { + NioEventLoopGroup group = new NioEventLoopGroup(); + try { + Bootstrap bootstrap = new Bootstrap() + .group(group) + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) + .channel(NioSocketChannel.class) + .handler(new LoggingHandler()); + ChannelFuture future = bootstrap.connect("127.0.0.1", 8080); + future.sync().channel().closeFuture().sync(); + } catch (Exception e) { + e.printStackTrace(); + log.debug("timeout"); + } finally { + group.shutdownGracefully(); + } + } +} +``` + + + +**** + + + +#### SO_BACKLOG + +属于 ServerSocketChannal 参数,通过 `option(ChannelOption.SO_BACKLOG, value)` 来设置大小 + +在 Linux 2.2 之前,backlog 大小包括了两个队列的大小,在 2.2 之后,分别用下面两个参数来控制 + +* sync queue:半连接队列,大小通过 `/proc/sys/net/ipv4/tcp_max_syn_backlog` 指定,在 `syncookies` 启用的情况下,逻辑上没有最大值限制 +* accept queue:全连接队列,大小通过 `/proc/sys/net/core/somaxconn` 指定,在使用 listen 函数时,内核会根据传入的 backlog 参数与系统参数,取二者的较小值。如果 accpet queue 队列满了,server 将**发送一个拒绝连接的错误信息**到 client + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/Netty-TCP三次握手.png) + + + +**** + + + +#### 其他参数 + +ALLOCATOR:属于 SocketChannal 参数,用来分配 ByteBuf, ctx.alloc() + +RCVBUF_ALLOCATOR:属于 SocketChannal 参数 + +* 控制 Netty 接收缓冲区大小 +* 负责入站数据的分配,决定入站缓冲区的大小(并可动态调整),统一采用 direct 直接内存,具体池化还是非池化由 allocator 决定 + + + + + + + +*** + + + + + + + +# RocketMQ + +## 基本介绍 + +### 消息队列 + +#### 应用场景 + +消息队列是一种先进先出的数据结构,常见的应用场景: + +* 应用解耦:系统的耦合性越高,容错性就越低 + + 实例:用户创建订单后,耦合调用库存系统、物流系统、支付系统,任何一个子系统出了故障都会造成下单异常,影响用户使用体验。使用消息队列解耦合,比如物流系统发生故障,需要几分钟恢复,将物流系统要处理的数据缓存到消息队列中,用户的下单操作正常完成。等待物流系统正常后处理存在消息队列中的订单消息即可,终端系统感知不到物流系统发生过几分钟故障 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-解耦.png) + +* 流量削峰:应用系统如果遇到系统请求流量的瞬间猛增,有可能会将系统压垮,使用消息队列可以将大量请求缓存起来,分散到很长一段时间处理,这样可以提高系统的稳定性和用户体验 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-流量削峰.png) + +* 数据分发:让数据在多个系统更加之间进行流通,数据的产生方不需要关心谁来使用数据,只需要将数据发送到消息队列,数据使用方直接在消息队列中直接获取数据 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-数据分发.png) + + + + + +参考视频:https://www.bilibili.com/video/BV1L4411y7mn + + + +*** + + + +#### 技术选型 + +RocketMQ 对比 Kafka 的优点 + +* 支持 Pull和 Push 两种消息模式 + +- 支持延时消息、死信队列、消息重试、消息回溯、消息跟踪、事务消息等高级特性 +- 对消息可靠性做了改进,**保证消息不丢失并且至少消费一次**,与 Kafka 一样是先写 PageCache 再落盘,并且数据有多副本 +- RocketMQ 存储模型是所有的 Topic 都写到同一个 Commitlog 里,是一个 append only 操作,在海量 Topic 下也能将磁盘的性能发挥到极致,并且保持稳定的写入时延。Kafka 的吞吐非常高(零拷贝、操作系统页缓存、磁盘顺序写),但是在多 Topic 下时延不够稳定(顺序写入特性会被破坏从而引入大量的随机 I/O),不适合实时在线业务场景 +- 经过阿里巴巴多年双 11 验证过、可以支持亿级并发的开源消息队列 + +Kafka 比 RocketMQ 吞吐量高: + +* Kafka 将 Producer 端将多个小消息合并,采用异步批量发送的机制,当发送一条消息时,消息并没有发送到 Broker 而是缓存起来,直接向业务返回成功,当缓存的消息达到一定数量时再批量发送 + +* 减少了网络 I/O,提高了消息发送的性能,但是如果消息发送者宕机,会导致消息丢失,降低了可靠性 +* RocketMQ 缓存过多消息会导致频繁 GC,并且为了保证可靠性没有采用这种方式 + +Topic 的 partition 数量过多时,Kafka 的性能不如 RocketMQ: + +* 两者都使用文件存储,但是 Kafka 是一个分区一个文件,Topic 过多时分区的总量也会增加,过多的文件导致对消息刷盘时出现文件竞争磁盘,造成性能的下降。**一个分区只能被一个消费组中的一个消费线程进行消费**,因此可以同时消费的消费端也比较少 + +* RocketMQ 所有队列都存储在一个文件中,每个队列存储的消息量也比较小,因此多 Topic 的对 RocketMQ 的性能的影响较小 + + + +**** + + + +### 安装测试 + +安装需要 Java 环境,下载解压后进入安装目录,进行启动: + +* 启动 NameServer + + ```sh + # 1.启动 NameServer + nohup sh bin/mqnamesrv & + # 2.查看启动日志 + tail -f ~/logs/rocketmqlogs/namesrv.log + ``` + + RocketMQ 默认的虚拟机内存较大,需要编辑如下两个配置文件,修改 JVM 内存大小 + + ```shell + # 编辑runbroker.sh和runserver.sh修改默认JVM大小 + vi runbroker.sh + vi runserver.sh + ``` + + 参考配置:JAVA_OPT="${JAVA_OPT} -server -Xms256m -Xmx256m -Xmn128m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=320m" + +* 启动 Broker + + ```sh + # 1.启动 Broker + nohup sh bin/mqbroker -n localhost:9876 autoCreateTopicEnable=true & + # 2.查看启动日志 + tail -f ~/logs/rocketmqlogs/broker.log + ``` + +* 发送消息: + + ```sh + # 1.设置环境变量 + export NAMESRV_ADDR=localhost:9876 + # 2.使用安装包的 Demo 发送消息 + sh bin/tools.sh org.apache.rocketmq.example.quickstart.Producer + ``` + +* 接受消息: + + ```sh + # 1.设置环境变量 + export NAMESRV_ADDR=localhost:9876 + # 2.接收消息 + sh bin/tools.sh org.apache.rocketmq.example.quickstart.Consumer + +* 关闭 RocketMQ: + + ```sh + # 1.关闭 NameServer + sh bin/mqshutdown namesrv + # 2.关闭 Broker + sh bin/mqshutdown broker + + + +*** + + + +### 相关概念 + +RocketMQ 主要由 Producer、Broker、Consumer 三部分组成,其中 Producer 负责生产消息,Consumer 负责消费消息,Broker 负责存储消息,NameServer 负责管理 Broker + +* 代理服务器(Broker Server):消息中转角色,负责**存储消息、转发消息**。在 RocketMQ 系统中负责接收从生产者发送来的消息并存储、同时为消费者的拉取请求作准备,也存储消息相关的元数据,包括消费者组、消费进度偏移和主题和队列消息等 +* 名字服务(Name Server):充当**路由消息**的提供者。生产者或消费者能够通过名字服务查找各主题相应的 Broker IP 列表 +* 消息生产者(Producer):负责**生产消息**,把业务应用系统里产生的消息发送到 Broker 服务器。RocketMQ 提供多种发送方式,同步发送、异步发送、顺序发送、单向发送,同步和异步方式均需要 Broker 返回确认信息,单向发送不需要;可以通过 MQ 的负载均衡模块选择相应的 Broker 集群队列进行消息投递,投递的过程支持快速失败并且低延迟 +* 消息消费者(Consumer):负责**消费消息**,一般是后台系统负责异步消费,一个消息消费者会从 Broker 服务器拉取消息、并将其提供给应用程序。从用户应用的角度而提供了两种消费形式: + * 拉取式消费(Pull Consumer):应用通主动调用 Consumer 的拉消息方法从 Broker 服务器拉消息,主动权由应用控制,一旦获取了批量消息,应用就会启动消费过程 + * 推动式消费(Push Consumer):该模式下 Broker 收到数据后会主动推送给消费端,实时性较高 +* 生产者组(Producer Group):同一类 Producer 的集合,发送同一类消息且发送逻辑一致。如果发送的是事务消息且原始生产者在发送之后崩溃,**则 Broker 服务器会联系同一生产者组的其他生产者实例以提交或回溯消费** +* 消费者组(Consumer Group):同一类 Consumer 的集合,消费者实例必须订阅完全相同的 Topic,消费同一类消息且消费逻辑一致。消费者组使得在消息消费方面更容易的实现负载均衡和容错。RocketMQ 支持两种消息模式: + * 集群消费(Clustering):相同 Consumer Group 的每个 Consumer 实例平均分摊消息 + * 广播消费(Broadcasting):相同 Consumer Group 的每个 Consumer 实例都接收全量的消息 + +每个 Broker 可以存储多个 Topic 的消息,每个 Topic 的消息也可以分片存储于不同的 Broker,Message Queue(消息队列)是用于存储消息的物理地址,每个 Topic 中的消息地址存储于多个 Message Queue 中 + +* 主题(Topic):表示一类消息的集合,每个主题包含若干条消息,每条消息只属于一个主题,是 RocketMQ 消息订阅的基本单位 + +* 消息(Message):消息系统所传输信息的物理载体,生产和消费数据的最小单位,每条消息必须属于一个主题。RocketMQ 中每个消息拥有唯一的 Message ID,且可以携带具有业务标识的 Key,系统提供了通过 Message ID 和 Key 查询消息的功能 + +* 标签(Tag):为消息设置的标志,用于同一主题下区分不同类型的消息。标签能够有效地保持代码的清晰度和连贯性,并优化 RocketMQ 提供的查询系统,消费者可以根据 Tag 实现对不同子主题的不同消费逻辑,实现更好的扩展性 + +* 普通顺序消息(Normal Ordered Message):消费者通过同一个消息队列(Topic 分区)收到的消息是有顺序的,不同消息队列收到的消息则可能是无顺序的 + +* 严格顺序消息(Strictly Ordered Message):消费者收到的所有消息均是有顺序的 + + + +官方文档:https://github.com/apache/rocketmq/tree/master/docs/cn(基础知识部分的笔记参考官方文档编写) + + + + + +**** + + + + + +## 消息操作 + +### 基本样例 + +#### 订阅发布 + +消息的发布是指某个生产者向某个 Topic 发送消息,消息的订阅是指某个消费者关注了某个 Topic 中带有某些 Tag 的消息,进而从该 Topic 消费数据 + +导入 MQ 客户端依赖: + +```xml + + org.apache.rocketmq + rocketmq-client + 4.4.0 + +``` + +消息发送者步骤分析: + +1. 创建消息生产者 Producer,并制定生产者组名 +2. 指定 Nameserver 地址 +3. 启动 Producer +4. 创建消息对象,指定主题 Topic、Tag 和消息体 +5. 发送消息 +6. 关闭生产者 Producer + +消息消费者步骤分析: + +1. 创建消费者 Consumer,制定消费者组名 +2. 指定 Nameserver 地址 +3. 订阅主题 Topic 和 Tag +4. 设置回调函数,处理消息 +5. 启动消费者 Consumer + + + +官方文档:https://github.com/apache/rocketmq/blob/master/docs/cn/RocketMQ_Example.md + + + +*** + + + +#### 发送消息 + +##### 同步发送 + +使用 RocketMQ 发送三种类型的消息:同步消息、异步消息和单向消息,其中前两种消息是可靠的,因为会有发送是否成功的应答 + +这种可靠性同步地发送方式使用的比较广泛,比如:重要的消息通知,短信通知 + +```java +public class SyncProducer { + public static void main(String[] args) throws Exception { + // 实例化消息生产者Producer + DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name"); + // 设置NameServer的地址 + producer.setNamesrvAddr("localhost:9876"); + // 启动Producer实例 + producer.start(); + for (int i = 0; i < 100; i++) { + // 创建消息,并指定Topic,Tag和消息体 + Message msg = new Message( + "TopicTest" /* Topic */, + "TagA" /* Tag */, + ("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET) /* Message body */); + + // 发送消息到一个Broker + SendResult sendResult = producer.send(msg); + // 通过sendResult返回消息是否成功送达 + System.out.printf("%s%n", sendResult); + } + // 如果不再发送消息,关闭Producer实例。 + producer.shutdown(); + } +} +``` + + + +*** + + + +##### 异步发送 + +异步消息通常用在对响应时间敏感的业务场景,即发送端不能容忍长时间地等待 Broker 的响应 + +```java +public class AsyncProducer { + public static void main(String[] args) throws Exception { + // 实例化消息生产者Producer + DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name"); + // 设置NameServer的地址 + producer.setNamesrvAddr("localhost:9876"); + // 启动Producer实例 + producer.start(); + producer.setRetryTimesWhenSendAsyncFailed(0); + + int messageCount = 100; + // 根据消息数量实例化倒计时计算器 + final CountDownLatch2 countDownLatch = new CountDownLatch2(messageCount); + for (int i = 0; i < messageCount; i++) { + final int index = i; + // 创建消息,并指定Topic,Tag和消息体 + Message msg = new Message("TopicTest", "TagA", "OrderID188", + "Hello world".getBytes(RemotingHelper.DEFAULT_CHARSET)); + + // SendCallback接收异步返回结果的回调 + producer.send(msg, new SendCallback() { + // 发送成功回调函数 + @Override + public void onSuccess(SendResult sendResult) { + countDownLatch.countDown(); + System.out.printf("%-10d OK %s %n", index, sendResult.getMsgId()); + } + + @Override + public void onException(Throwable e) { + countDownLatch.countDown(); + System.out.printf("%-10d Exception %s %n", index, e); + e.printStackTrace(); + } + }); + } + // 等待5s + countDownLatch.await(5, TimeUnit.SECONDS); + // 如果不再发送消息,关闭Producer实例。 + producer.shutdown(); + } +} +``` + + + +*** + + + +##### 单向发送 + +单向发送主要用在不特别关心发送结果的场景,例如日志发送 + +```java +public class OnewayProducer { + public static void main(String[] args) throws Exception{ + // 实例化消息生产者Producer + DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name"); + // 设置NameServer的地址 + producer.setNamesrvAddr("localhost:9876"); + // 启动Producer实例 + producer.start(); + for (int i = 0; i < 100; i++) { + // 创建消息,并指定Topic,Tag和消息体 + Message msg = new Message("TopicTest","TagA", + ("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET)); + // 发送单向消息,没有任何返回结果 + producer.sendOneway(msg); + } + // 如果不再发送消息,关闭Producer实例。 + producer.shutdown(); + } +} +``` + + + +**** + + + +#### 消费消息 + +```java +public class Consumer { + public static void main(String[] args) throws InterruptedException, MQClientException { + // 实例化消费者 + DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name"); + // 设置NameServer的地址 + consumer.setNamesrvAddr("localhost:9876"); + + // 订阅一个或者多个Topic,以及Tag来过滤需要消费的消息 + consumer.subscribe("TopicTest", "*"); + // 注册消息监听器,回调实现类来处理从broker拉取回来的消息 + consumer.registerMessageListener(new MessageListenerConcurrently() { + // 接受消息内容 + @Override + public ConsumeConcurrentlyStatus consumeMessage(List msgs, ConsumeConcurrentlyContext context) { + System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs); + // 标记该消息已经被成功消费 + return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; + } + }); + // 启动消费者实例 + consumer.start(); + System.out.printf("Consumer Started.%n"); + } +} +``` + + + + + +**** + + + +### 顺序消息 + +#### 原理解析 + +消息有序指的是一类消息消费时,能按照发送的顺序来消费。例如:一个订单产生了三条消息分别是订单创建、订单付款、订单完成。消费时要按照这个顺序消费才能有意义,但是同时订单之间是可以并行消费的,RocketMQ 可以严格的保证消息有序。 + +顺序消息分为全局顺序消息与分区顺序消息, + +- 全局顺序:对于指定的一个 Topic,所有消息按照严格的先入先出(FIFO)的顺序进行发布和消费,适用于性能要求不高,所有的消息严格按照 FIFO 原则进行消息发布和消费的场景 +- 分区顺序:对于指定的一个 Topic,所有消息根据 Sharding key 进行分区,同一个分组内的消息按照严格的 FIFO 顺序进行发布和消费。Sharding key 是顺序消息中用来区分不同分区的关键字段,和普通消息的 Key 是完全不同的概念,适用于性能要求高的场景 + +在默认的情况下消息发送会采取 Round Robin 轮询方式把消息发送到不同的 queue(分区队列),而消费消息是从多个 queue 上拉取消息,这种情况发送和消费是不能保证顺序。但是如果控制发送的顺序消息只依次发送到同一个 queue 中,消费的时候只从这个 queue 上依次拉取,则就保证了顺序。当**发送和消费参与的 queue 只有一个**,则是全局有序;如果多个queue 参与,则为分区有序,即相对每个 queue,消息都是有序的 + + + +*** + + + +#### 代码实现 + +一个订单的顺序流程是:创建、付款、推送、完成,订单号相同的消息会被先后发送到同一个队列中,消费时同一个 OrderId 获取到的肯定是同一个队列 + +```java +public class Producer { + public static void main(String[] args) throws Exception { + DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name"); + producer.setNamesrvAddr("127.0.0.1:9876"); + producer.start(); + // 标签集合 + String[] tags = new String[]{"TagA", "TagC", "TagD"}; + + // 订单列表 + List orderList = new Producer().buildOrders(); + + Date date = new Date(); + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + String dateStr = sdf.format(date); + for (int i = 0; i < 10; i++) { + // 加个时间前缀 + String body = dateStr + " Hello RocketMQ " + orderList.get(i); + Message msg = new Message("OrderTopic", tags[i % tags.length], "KEY" + i, body.getBytes()); + /** + * 参数一:消息对象 + * 参数二:消息队列的选择器 + * 参数三:选择队列的业务标识(订单 ID) + */ + SendResult sendResult = producer.send(msg, new MessageQueueSelector() { + @Override + /** + * mqs:队列集合 + * msg:消息对象 + * arg:业务标识的参数 + */ + public MessageQueue select(List mqs, Message msg, Object arg) { + Long id = (Long) arg; + long index = id % mqs.size(); // 根据订单id选择发送queue + return mqs.get((int) index); + } + }, orderList.get(i).getOrderId());//订单id + + System.out.println(String.format("SendResult status:%s, queueId:%d, body:%s", + sendResult.getSendStatus(), + sendResult.getMessageQueue().getQueueId(), + body)); + } + + producer.shutdown(); + } + + // 订单的步骤 + private static class OrderStep { + private long orderId; + private String desc; + // set + get + } + + // 生成模拟订单数据 + private List buildOrders() { + List orderList = new ArrayList(); + + OrderStep orderDemo = new OrderStep(); + orderDemo.setOrderId(15103111039L); + orderDemo.setDesc("创建"); + orderList.add(orderDemo); + + orderDemo = new OrderStep(); + orderDemo.setOrderId(15103111065L); + orderDemo.setDesc("创建"); + orderList.add(orderDemo); + + orderDemo = new OrderStep(); + orderDemo.setOrderId(15103111039L); + orderDemo.setDesc("付款"); + orderList.add(orderDemo); + + orderDemo = new OrderStep(); + orderDemo.setOrderId(15103117235L); + orderDemo.setDesc("创建"); + orderList.add(orderDemo); + + orderDemo = new OrderStep(); + orderDemo.setOrderId(15103111065L); + orderDemo.setDesc("付款"); + orderList.add(orderDemo); + + orderDemo = new OrderStep(); + orderDemo.setOrderId(15103117235L); + orderDemo.setDesc("付款"); + orderList.add(orderDemo); + + orderDemo = new OrderStep(); + orderDemo.setOrderId(15103111065L); + orderDemo.setDesc("完成"); + orderList.add(orderDemo); + + orderDemo = new OrderStep(); + orderDemo.setOrderId(15103111039L); + orderDemo.setDesc("推送"); + orderList.add(orderDemo); + + orderDemo = new OrderStep(); + orderDemo.setOrderId(15103117235L); + orderDemo.setDesc("完成"); + orderList.add(orderDemo); + + orderDemo = new OrderStep(); + orderDemo.setOrderId(15103111039L); + orderDemo.setDesc("完成"); + orderList.add(orderDemo); + + return orderList; + } +} +``` + +```java +// 顺序消息消费,带事务方式(应用可控制Offset什么时候提交) +public class ConsumerInOrder { + public static void main(String[] args) throws Exception { + DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name_3"); + consumer.setNamesrvAddr("127.0.0.1:9876"); + // 设置Consumer第一次启动是从队列头部开始消费还是队列尾部开始消费 + // 如果非第一次启动,那么按照上次消费的位置继续消费 + consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET); + // 订阅三个tag + consumer.subscribe("OrderTopic", "TagA || TagC || TagD"); + consumer.registerMessageListener(new MessageListenerOrderly() { + Random random = new Random(); + @Override + public ConsumeOrderlyStatus consumeMessage(List msgs, ConsumeOrderlyContext context) { + context.setAutoCommit(true); + for (MessageExt msg : msgs) { + // 可以看到每个queue有唯一的consume线程来消费, 订单对每个queue(分区)有序 + System.out.println("consumeThread=" + Thread.currentThread().getName() + "queueId=" + msg.getQueueId() + ", content:" + new String(msg.getBody())); + } + return ConsumeOrderlyStatus.SUCCESS; + } + }); + consumer.start(); + System.out.println("Consumer Started."); + } +} +``` + + + + + +***** + + + +### 延时消息 + +#### 原理解析 + +定时消息(延迟队列)是指消息发送到 Broker 后,不会立即被消费,等待特定时间投递给真正的 Topic + +RocketMQ 并不支持任意时间的延时,需要设置几个固定的延时等级,从 1s 到 2h 分别对应着等级 1 到 18,消息消费失败会进入延时消息队列,消息发送时间与设置的延时等级和重试次数有关,详见代码 `SendMessageProcessor.java` + +```java +private String messageDelayLevel = "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h"; +``` + +Broker 可以配置 messageDelayLevel,该属性是 Broker 的属性,不属于某个 Topic + +发消息时,可以设置延迟等级 `msg.setDelayLevel(level)`,level 有以下三种情况: + +- level == 0:消息为非延迟消息 +- 1<=level<=maxLevel:消息延迟特定时间,例如 level==1,延迟 1s +- level > maxLevel:则 level== maxLevel,例如 level==20,延迟 2h + +定时消息会暂存在名为 SCHEDULE_TOPIC_XXXX 的 Topic 中,并根据 delayTimeLevel 存入特定的 queue,队列的标识 `queueId = delayTimeLevel – 1`,即**一个 queue 只存相同延迟的消息**,保证具有相同发送延迟的消息能够顺序消费。Broker 会为每个延迟级别提交一个定时任务,调度地消费 SCHEDULE_TOPIC_XXXX,将消息写入真实的 Topic + +注意:定时消息在第一次写入和调度写入真实 Topic 时都会计数,因此发送数量、tps 都会变高 + + + +*** + + + +#### 代码实现 + +提交了一个订单就可以发送一个延时消息,1h 后去检查这个订单的状态,如果还是未付款就取消订单释放库存 + +```java +public class ScheduledMessageProducer { + public static void main(String[] args) throws Exception { + // 实例化一个生产者来产生延时消息 + DefaultMQProducer producer = new DefaultMQProducer("ExampleProducerGroup"); + producer.setNamesrvAddr("127.0.0.1:9876"); + // 启动生产者 + producer.start(); + int totalMessagesToSend = 100; + for (int i = 0; i < totalMessagesToSend; i++) { + Message message = new Message("DelayTopic", ("Hello scheduled message " + i).getBytes()); + // 设置延时等级3,这个消息将在10s之后发送(现在只支持固定的几个时间,详看delayTimeLevel) + message.setDelayTimeLevel(3); + // 发送消息 + producer.send(message); + } + // 关闭生产者 + producer.shutdown(); + } +} +``` + +```java +public class ScheduledMessageConsumer { + public static void main(String[] args) throws Exception { + // 实例化消费者 + DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("ExampleConsumer"); + consumer.setNamesrvAddr("127.0.0.1:9876"); + // 订阅Topics + consumer.subscribe("DelayTopic", "*"); + // 注册消息监听者 + consumer.registerMessageListener(new MessageListenerConcurrently() { + @Override + public ConsumeConcurrentlyStatus consumeMessage(List messages, ConsumeConcurrentlyContext context) { + for (MessageExt message : messages) { + // 打印延迟的时间段 + System.out.println("Receive message[msgId=" + message.getMsgId() + "] " + (System.currentTimeMillis() - message.getBornTimestamp()) + "ms later");} + return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; + } + }); + // 启动消费者 + consumer.start(); + } +} +``` + + + +**** + + + +### 批量消息 + +批量发送消息能显著提高传递小消息的性能,限制是这些批量消息应该有相同的 topic,相同的 waitStoreMsgOK,而且不能是延时消息,并且这一批消息的总大小不应超过 4MB + +```java +public class Producer { + + public static void main(String[] args) throws Exception { + DefaultMQProducer producer = new DefaultMQProducer("ExampleProducerGroup") + producer.setNamesrvAddr("127.0.0.1:9876"); + //启动producer + producer.start(); + + List msgs = new ArrayList(); + // 创建消息对象,指定主题Topic、Tag和消息体 + Message msg1 = new Message("BatchTopic", "Tag1", ("Hello World" + 1).getBytes()); + Message msg2 = new Message("BatchTopic", "Tag1", ("Hello World" + 2).getBytes()); + Message msg3 = new Message("BatchTopic", "Tag1", ("Hello World" + 3).getBytes()); + + msgs.add(msg1); + msgs.add(msg2); + msgs.add(msg3); + + // 发送消息 + SendResult result = producer.send(msgs); + System.out.println("发送结果:" + result); + // 关闭生产者producer + producer.shutdown(); + } +} +``` + +当发送大批量数据时,可能不确定消息是否超过了大小限制(4MB),所以需要将消息列表分割一下 + +```java +public class ListSplitter implements Iterator> { + private final int SIZE_LIMIT = 1024 * 1024 * 4; + private final List messages; + private int currIndex; + + public ListSplitter(List messages) { + this.messages = messages; + } + + @Override + public boolean hasNext() { + return currIndex < messages.size(); + } + + @Override + public List next() { + int startIndex = getStartIndex(); + int nextIndex = startIndex; + int totalSize = 0; + for (; nextIndex < messages.size(); nextIndex++) { + Message message = messages.get(nextIndex); + int tmpSize = calcMessageSize(message); + // 单个消息超过了最大的限制 + if (tmpSize + totalSize > SIZE_LIMIT) { + break; + } else { + totalSize += tmpSize; + } + } + List subList = messages.subList(startIndex, nextIndex); + currIndex = nextIndex; + return subList; + } + + private int getStartIndex() { + Message currMessage = messages.get(currIndex); + int tmpSize = calcMessageSize(currMessage); + while (tmpSize > SIZE_LIMIT) { + currIndex += 1; + Message message = messages.get(curIndex); + tmpSize = calcMessageSize(message); + } + return currIndex; + } + + private int calcMessageSize(Message message) { + int tmpSize = message.getTopic().length() + message.getBody().length; + Map properties = message.getProperties(); + for (Map.Entry entry : properties.entrySet()) { + tmpSize += entry.getKey().length() + entry.getValue().length(); + } + tmpSize = tmpSize + 20; // 增加⽇日志的开销20字节 + return tmpSize; + } + + public static void main(String[] args) { + //把大的消息分裂成若干个小的消息 + ListSplitter splitter = new ListSplitter(messages); + while (splitter.hasNext()) { + try { + List listItem = splitter.next(); + producer.send(listItem); + } catch (Exception e) { + e.printStackTrace(); + //处理error + } + } + } +} +``` + + + + + +*** + + + +### 过滤消息 + +#### 基本语法 + +RocketMQ 定义了一些基本语法来支持过滤特性,可以很容易地扩展: + +- 数值比较,比如:>,>=,<,<=,BETWEEN,= +- 字符比较,比如:=,<>,IN +- IS NULL 或者 IS NOT NULL +- 逻辑符号 AND,OR,NOT + +常量支持类型为: + +- 数值,比如 123,3.1415 +- 字符,比如 'abc',必须用单引号包裹起来 +- NULL,特殊的常量 +- 布尔值,TRUE 或 FALSE + +只有使用 push 模式的消费者才能用使用 SQL92 标准的 sql 语句,接口如下: + +```java +public void subscribe(final String topic, final MessageSelector messageSelector) +``` + +例如:消费者接收包含 TAGA 或 TAGB 或 TAGC 的消息 + +```java +DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("CID_EXAMPLE"); +consumer.subscribe("TOPIC", "TAGA || TAGB || TAGC"); +``` + + + +*** + + + +#### 原理解析 + +RocketMQ 分布式消息队列的消息过滤方式是在 Consumer 端订阅消息时再做消息过滤的,所以是在 Broker 端实现的,优点是减少了对于 Consumer 无用消息的网络传输,缺点是增加了 Broker 的负担,而且实现相对复杂 + +RocketMQ 在 Producer 端写入消息和在 Consumer 端订阅消息采用**分离存储**的机制实现,Consumer 端订阅消息是需要通过 ConsumeQueue 这个消息消费的逻辑队列拿到一个索引,然后再从 CommitLog 里面读取真正的消息实体内容 + +ConsumeQueue 的存储结构如下,有 8 个字节存储的 Message Tag 的哈希值,基于 Tag 的消息过滤就是基于这个字段 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-消费队列结构.png) + +* Tag 过滤:Consumer 端订阅消息时指定 Topic 和 TAG,然后将订阅请求构建成一个 SubscriptionData,发送一个 Pull 消息的请求给 Broker 端。Broker 端用这些数据先构建一个 MessageFilter,然后传给文件存储层 Store。Store 从 ConsumeQueue 读取到一条记录后,会用它记录的消息 tag hash 值去做过滤。因为在服务端只是根据 hashcode 进行判断,无法精确对 tag 原始字符串进行过滤,所以消费端拉取到消息后,还需要对消息的原始 tag 字符串进行比对,如果不同,则丢弃该消息,不进行消息消费 + +* SQL92 过滤:工作流程和 Tag 过滤大致一样,只是在 Store 层的具体过滤方式不一样。真正的 SQL expression 的构建和执行由 rocketmq-filter 模块负责,每次过滤都去执行 SQL 表达式会影响效率,所以 RocketMQ 使用了 BloomFilter 来避免了每次都去执行 + + + + + +**** + + + +#### 代码实现 + +发送消息时,通过 putUserProperty 来设置消息的属性,SQL92 的表达式上下文为消息的属性 + +```java +public class Producer { + public static void main(String[] args) throws Exception { + DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name"); + producer.setNamesrvAddr("127.0.0.1:9876"); + producer.start(); + for (int i = 0; i < 10; i++) { + Message msg = new Message("FilterTopic", "tag", + ("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET)); + // 设置一些属性 + msg.putUserProperty("i", String.valueOf(i)); + SendResult sendResult = producer.send(msg); + } + producer.shutdown(); + } +} +``` + +使用 SQL 筛选过滤消息: + +```java +public class Consumer { + public static void main(String[] args) throws Exception { + DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name"); + consumer.setNamesrvAddr("127.0.0.1:9876"); + // 过滤属性大于 5 的消息 + consumer.subscribe("FilterTopic", MessageSelector.bySql("i>5")); + + // 设置回调函数,处理消息 + consumer.registerMessageListener(new MessageListenerConcurrently() { + //接受消息内容 + @Override + public ConsumeConcurrentlyStatus consumeMessage(List msgs, ConsumeConcurrentlyContext context) { + for (MessageExt msg : msgs) { + System.out.println("consumeThread=" + Thread.currentThread().getName() + "," + new String(msg.getBody())); + } + return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; + } + }); + // 启动消费者consumer + consumer.start(); + } +} +``` + + + + + +*** + + + +### 事务消息 + +#### 工作流程 + +RocketMQ 支持分布式事务消息,采用了 2PC 的思想来实现了提交事务消息,同时增加一个**补偿逻辑**来处理二阶段超时或者失败的消息,如下图所示: + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-事务消息.png) + +事务消息的大致方案分为两个流程:正常事务消息的发送及提交、事务消息的补偿流程 + +1. 事务消息发送及提交: + + * 发送消息(Half 消息),服务器将消息的主题和队列改为半消息状态,并放入半消息队列 + + * 服务端响应消息写入结果(如果写入失败,此时 Half 消息对业务不可见) + * 根据发送结果执行本地事务 + * 根据本地事务状态执行 Commit 或者 Rollback + + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-事务工作流程.png) + +2. 补偿机制:用于解决消息 Commit 或者 Rollback 发生超时或者失败的情况,比如出现网络问题 + + * Broker 服务端通过**对比 Half 消息和 Op 消息**,对未确定状态的消息推进 CheckPoint + * 没有 Commit/Rollback 的事务消息,服务端根据根据半消息的生产者组,到 ProducerManager 中获取生产者(同一个 Group 的 Producer)的会话通道,发起一次回查(**单向请求**) + * Producer 收到回查消息,检查事务消息状态表内对应的本地事务的状态 + * 根据本地事务状态,重新 Commit 或者 Rollback + + RocketMQ 并不会无休止的进行事务状态回查,最大回查 15 次,如果 15 次回查还是无法得知事务状态,则默认回滚该消息, + + 回查服务:`TransactionalMessageCheckService#run` + + + +**** + + + +#### 两阶段 + +##### 一阶段 + +事务消息相对普通消息最大的特点就是**一阶段发送的消息对用户是不可见的**,因为对于 Half 消息,会备份原消息的主题与消息消费队列,然后改变主题为 RMQ_SYS_TRANS_HALF_TOPIC,由于消费组未订阅该主题,故消费端无法消费 Half 类型的消息 + +RocketMQ 会开启一个**定时任务**,从 Topic 为 RMQ_SYS_TRANS_HALF_TOPIC 中拉取消息进行消费,根据生产者组获取一个服务提供者发送回查事务状态请求,根据事务状态来决定是提交或回滚消息 + +RocketMQ 的具体实现策略:如果写入的是事务消息,对消息的 Topic 和 Queue 等属性进行替换,同时将原来的 Topic 和 Queue 信息存储到**消息的属性**中,因为消息的主题被替换,所以消息不会转发到该原主题的消息消费队列,消费者无法感知消息的存在,不会消费 + + + +*** + + + +##### 二阶段 + +一阶段写入不可见的消息后,二阶段操作: + +* 如果执行 Commit 操作,则需要让消息对用户可见,构建出 Half 消息的索引。一阶段的 Half 消息写到一个特殊的 Topic,构建索引时需要读取出 Half 消息,然后通过一次普通消息的写入操作将 Topic 和 Queue 替换成真正的目标 Topic 和 Queue,生成一条对用户可见的消息。其实就是利用了一阶段存储的消息的内容,在二阶段时恢复出一条完整的普通消息,然后走一遍消息写入流程 + +* 如果是 Rollback 则需要撤销一阶段的消息,因为消息本就不可见,所以并**不需要真正撤销消息**(实际上 RocketMQ 也无法去删除一条消息,因为是顺序写文件的)。RocketMQ 为了区分这条消息没有确定状态的消息,采用 Op 消息标识已经确定状态的事务消息(Commit 或者 Rollback) + +**事务消息无论是 Commit 或者 Rollback 都会记录一个 Op 操作**,两者的区别是 Commit 相对于 Rollback 在写入 Op 消息前将原消息的主题和队列恢复。如果一条事务消息没有对应的 Op 消息,说明这个事务的状态还无法确定(可能是二阶段失败了) + +RocketMQ 将 Op 消息写入到全局一个特定的 Topic 中,通过源码中的方法 `TransactionalMessageUtil.buildOpTopic()`,这个主题是一个内部的 Topic(像 Half 消息的 Topic 一样),不会被用户消费。Op 消息的内容为对应的 Half 消息的存储的 Offset,这样**通过 Op 消息能索引到 Half 消息** + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-OP消息.png) + + + +**** + + + +#### 基本使用 + +##### 使用方式 + +事务消息共有三种状态,提交状态、回滚状态、中间状态: + +- TransactionStatus.CommitTransaction:提交事务,允许消费者消费此消息。 +- TransactionStatus.RollbackTransaction:回滚事务,代表该消息将被删除,不允许被消费 +- TransactionStatus.Unknown:中间状态,代表需要检查消息队列来确定状态 + +使用限制: + +1. 事务消息不支持延时消息和批量消息 +2. Broker 配置文件中的参数 `transactionTimeout` 为特定时间,事务消息将在特定时间长度之后被检查。当发送事务消息时,还可以通过设置用户属性 `CHECK_IMMUNITY_TIME_IN_SECONDS` 来改变这个限制,该参数优先于 `transactionTimeout` 参数 +3. 为了避免单个消息被检查太多次而导致半队列消息累积,默认将单个消息的检查次数限制为 15 次,开发者可以通过 Broker 配置文件的 `transactionCheckMax` 参数来修改此限制。如果已经检查某条消息超过 N 次(N = `transactionCheckMax`), 则 Broker 将丢弃此消息,在默认情况下会打印错误日志。可以通过重写 `AbstractTransactionalMessageCheckListener` 类来修改这个行为 +4. 事务性消息可能不止一次被检查或消费 +5. 提交给用户的目标主题消息可能会失败,可以查看日志的记录。事务的高可用性通过 RocketMQ 本身的高可用性机制来保证,如果希望事务消息不丢失、并且事务完整性得到保证,可以使用同步的双重写入机制 +6. 事务消息的生产者 ID 不能与其他类型消息的生产者 ID 共享。与其他类型的消息不同,事务消息允许反向查询,MQ 服务器能通过消息的生产者 ID 查询到消费者 + + + +*** + + + +##### 代码实现 + +实现事务的监听接口,当发送半消息成功时: + +* `executeLocalTransaction` 方法来执行本地事务,返回三个事务状态之一 +* `checkLocalTransaction` 方法检查本地事务状态,响应消息队列的检查请求,返回三个事务状态之一 + +```java +public class TransactionListenerImpl implements TransactionListener { + private AtomicInteger transactionIndex = new AtomicInteger(0); + private ConcurrentHashMap localTrans = new ConcurrentHashMap<>(); + + @Override + public LocalTransactionState executeLocalTransaction(Message msg, Object arg) { + int value = transactionIndex.getAndIncrement(); + int status = value % 3; + // 将事务ID和状态存入 map 集合 + localTrans.put(msg.getTransactionId(), status); + return LocalTransactionState.UNKNOW; + } + + @Override + public LocalTransactionState checkLocalTransaction(MessageExt msg) { + // 从 map 集合读出当前事务对应的状态 + Integer status = localTrans.get(msg.getTransactionId()); + if (null != status) { + switch (status) { + case 0: + return LocalTransactionState.UNKNOW; + case 1: + return LocalTransactionState.COMMIT_MESSAGE; + case 2: + return LocalTransactionState.ROLLBACK_MESSAGE; + } + } + return LocalTransactionState.COMMIT_MESSAGE; + } +} +``` + +使用 **TransactionMQProducer** 类创建事务性生产者,并指定唯一的 `ProducerGroup`,就可以设置自定义线程池来处理这些检查请求,执行本地事务后,需要根据执行结果对消息队列进行回复 + +```java +public class Producer { + public static void main(String[] args) throws MQClientException, InterruptedException { + // 创建消息生产者 + TransactionMQProducer producer = new TransactionMQProducer("please_rename_unique_group_name"); + ExecutorService executorService = new ThreadPoolExecutor(2, 5, 100, TimeUnit.SECONDS); + producer.setExecutorService(executorService); + + // 创建事务监听器 + TransactionListener transactionListener = new TransactionListenerImpl(); + // 生产者的监听器 + producer.setTransactionListener(transactionListener); + // 启动生产者 + producer.start(); + String[] tags = new String[] {"TagA", "TagB", "TagC", "TagD", "TagE"}; + for (int i = 0; i < 10; i++) { + try { + Message msg = new Message("TransactionTopic", tags[i % tags.length], "KEY" + i, + ("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET)); + // 发送消息 + SendResult sendResult = producer.sendMessageInTransaction(msg, null); + System.out.printf("%s%n", sendResult); + Thread.sleep(10); + } catch (MQClientException | UnsupportedEncodingException e) { + e.printStackTrace(); + } + } + //Thread.sleep(1000000); + //producer.shutdown();暂时不关闭 + } +} +``` + +消费者代码和前面的实例相同的 + + + + + +**** + + + + + +## 系统特性 + +### 工作流程 + +#### 模块介绍 + +NameServer 是一个简单的 Topic 路由注册中心,支持 Broker 的动态注册与发现,生产者或消费者能够通过名字服务查找各主题相应的 Broker IP 列表 + +NameServer 主要包括两个功能: + +* Broker 管理,NameServer 接受 Broker 集群的注册信息,保存下来作为路由信息的基本数据,提供**心跳检测机制**检查 Broker 是否还存活,每 10 秒清除一次两小时没有活跃的 Broker +* 路由信息管理,每个 NameServer 将保存关于 Broker 集群的整个路由信息和用于客户端查询的队列信息,然后 Producer 和 Conumser 通过 NameServer 就可以知道整个 Broker 集群的路由信息,从而进行消息的投递和消费 + +NameServer 特点: + +* NameServer 通常是集群的方式部署,**各实例间相互不进行信息通讯** +* Broker 向每一台 NameServer(集群)注册自己的路由信息,所以每个 NameServer 实例上面**都保存一份完整的路由信息** +* 当某个 NameServer 因某种原因下线了,Broker 仍可以向其它 NameServer 同步其路由信息 + +BrokerServer 主要负责消息的存储、投递和查询以及服务高可用保证,在 RocketMQ 系统中接收从生产者发送来的消息并存储、同时为消费者的拉取请求作准备,也存储消息相关的元数据,包括消费者组、消费进度偏移和主题和队列消息等 + +Broker 包含了以下几个重要子模块: + +* Remoting Module:整个 Broker 的实体,负责处理来自 Clients 端的请求 + +* Client Manager:负责管理客户端(Producer/Consumer)和维护 Consumer 的 Topic 订阅信息 + +* Store Service:提供方便简单的 API 接口处理消息存储到物理硬盘和查询功能 + +* HA Service:高可用服务,提供 Master Broker 和 Slave Broker 之间的数据同步功能 + +* Index Service:根据特定的 Message key 对投递到 Broker 的消息进行索引服务,以提供消息的快速查询 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-Broker工作流程.png) + + + +*** + + + +#### 总体流程 + +RocketMQ 的工作流程: + +- 启动 NameServer 监听端口,等待 Broker、Producer、Consumer 连上来,相当于一个路由控制中心 +- Broker 启动,跟**所有的 NameServer 保持长连接**,每隔 30s 时间向 NameServer 上报 Topic 路由信息(心跳包)。心跳包中包含当前 Broker 信息(IP、端口等)以及存储所有 Topic 信息。注册成功后,NameServer 集群中就有 Topic 跟 Broker 的映射关系 +- 收发消息前,先创建 Topic,创建 Topic 时需要指定该 Topic 要存储在哪些 Broker 上,也可以在发送消息时自动创建 Topic +- Producer 启动时先跟 NameServer 集群中的**其中一台**建立长连接,并从 NameServer 中获取当前发送的 Topic 存在哪些 Broker 上,同时 Producer 会默认每隔 30s 向 NameServer **定时拉取**一次路由信息 +- Producer 发送消息时,根据消息的 Topic 从本地缓存的 TopicPublishInfoTable 获取路由信息,如果没有则会从 NameServer 上重新拉取并更新,轮询队列列表并选择一个队列 MessageQueue,然后与队列所在的 Broker 建立长连接,向 Broker 发消息 +- Consumer 跟 Producer 类似,跟其中一台 NameServer 建立长连接,**定时获取路由信息**,根据当前订阅 Topic 存在哪些 Broker 上,直接跟 Broker 建立连接通道,在完成客户端的负载均衡后,选择其中的某一个或者某几个 MessageQueue 来拉取消息并进行消费 + + + + + +*** + + + +#### 生产消费 + +At least Once:至少一次,指每个消息必须投递一次,Consumer 先 Pull 消息到本地,消费完成后才向服务器返回 ACK,如果没有消费一定不会 ACK 消息 + +回溯消费:指 Consumer 已经消费成功的消息,由于业务上需求需要重新消费,Broker 在向 Consumer 投递成功消息后,消息仍然需要保留。并且重新消费一般是按照时间维度,例如由于 Consumer 系统故障,恢复后需要重新消费 1 小时前的数据,RocketMQ 支持按照时间回溯消费,时间维度精确到毫秒 + +分布式队列因为有高可靠性的要求,所以数据要进行**持久化存储** + +1. 消息生产者发送消息 +2. MQ 收到消息,将消息进行持久化,在存储中新增一条记录 +3. 返回 ACK 给生产者 +4. MQ push 消息给对应的消费者,然后等待消费者返回 ACK +5. 如果消息消费者在指定时间内成功返回 ACK,那么 MQ 认为消息消费成功,在存储中删除消息;如果 MQ 在指定时间内没有收到 ACK,则认为消息消费失败,会尝试重新 push 消息,重复执行 4、5、6 步骤 +6. MQ 删除消息 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-消息存取.png) + + + + + +*** + + + + + +### 存储机制 + +#### 存储结构 + +RocketMQ 中 Broker 负责存储消息转发消息,所以以下的结构是存储在 Broker Server 上的,生产者和消费者与 Broker 进行消息的收发是通过主题对应的 Message Queue 完成,类似于通道 + +RocketMQ 消息的存储是由 ConsumeQueue 和 CommitLog 配合完成 的,CommitLog 是消息真正的**物理存储**文件,ConsumeQueue 是消息的逻辑队列,类似数据库的**索引节点**,存储的是指向物理存储的地址。**每个 Topic 下的每个 Message Queue 都有一个对应的 ConsumeQueue 文件** + +每条消息都会有对应的索引信息,Consumer 通过 ConsumeQueue 这个结构来读取消息实体内容 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-消息存储结构.png) + +* CommitLog:消息主体以及元数据的存储主体,存储 Producer 端写入的消息内容,消息内容不是定长的。消息主要是**顺序写入**日志文件,单个文件大小默认 1G,偏移量代表下一次写入的位置,当文件写满了就继续写入下一个文件 +* ConsumerQueue:消息消费队列,存储消息在 CommitLog 的索引。RocketMQ 消息消费时要遍历 CommitLog 文件,并根据主题 Topic 检索消息,这是非常低效的。引入 ConsumeQueue 作为消费消息的索引,**保存了指定 Topic 下的队列消息在 CommitLog 中的起始物理偏移量 offset**,消息大小 size 和消息 Tag 的 HashCode 值,每个 ConsumeQueue 文件大小约 5.72M +* IndexFile:为了消息查询提供了一种通过 Key 或时间区间来查询消息的方法,通过 IndexFile 来查找消息的方法**不影响发送与消费消息的主流程**。IndexFile 的底层存储为在文件系统中实现的 HashMap 结构,故 RocketMQ 的索引文件其底层实现为 **hash 索引** + +RocketMQ 采用的是混合型的存储结构,即为 Broker 单个实例下所有的队列共用一个日志数据文件(CommitLog)来存储,多个 Topic 的消息实体内容都存储于一个 CommitLog 中。混合型存储结构针对 Producer 和 Consumer 分别采用了**数据和索引部分相分离**的存储结构,Producer 发送消息至 Broker 端,然后 Broker 端使用同步或者异步的方式对消息刷盘持久化,保存至 CommitLog 中。只要消息被持久化至磁盘文件 CommitLog 中,Producer 发送的消息就不会丢失,Consumer 也就肯定有机会去消费这条消息 + +服务端支持长轮询模式,当消费者无法拉取到消息后,可以等下一次消息拉取,Broker 允许等待 30s 的时间,只要这段时间内有新消息到达,将直接返回给消费端。RocketMQ 的具体做法是,使用 Broker 端的后台服务线程 ReputMessageService 不停地分发请求并异步构建 ConsumeQueue(逻辑消费队列)和 IndexFile(索引文件)数据 + + + +**** + + + +#### 内存映射 + +操作系统分为用户态和内核态,文件操作、网络操作需要涉及这两种形态的切换,需要进行数据复制。一台服务器把本机磁盘文件的内容发送到客户端,分为两个步骤: + +* read:读取本地文件内容 + +* write:将读取的内容通过网络发送出去 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-文件与网络操作.png) + +补充:Prog → NET → I/O → 零拷贝部分的笔记详解相关内容 + +通过使用 mmap 的方式,可以省去向用户态的内存复制,RocketMQ 充分利用**零拷贝技术**,提高消息存盘和网络发送的速度 + +RocketMQ 通过 MappedByteBuffer 对文件进行读写操作,利用了 NIO 中的 FileChannel 模型将磁盘上的物理文件直接映射到用户态的内存地址中,将对文件的操作转化为直接对内存地址进行操作,从而极大地提高了文件的读写效率 + +MappedByteBuffer 内存映射的方式**限制**一次只能映射 1.5~2G 的文件至用户态的虚拟内存,所以 RocketMQ 默认设置单个 CommitLog 日志数据文件为 1G。RocketMQ 的文件存储使用定长结构来存储,方便一次将整个文件映射至内存 + + + +*** + + + +#### 页面缓存 + +页缓存(PageCache)是 OS 对文件的缓存,每一页的大小通常是 4K,用于加速对文件的读写。因为 OS 将一部分的内存用作 PageCache,所以程序对文件进行顺序读写的速度几乎接近于内存的读写速度 + +* 对于数据的写入,OS 会先写入至 Cache 内,随后**通过异步的方式由 pdflush 内核线程将 Cache 内的数据刷盘至物理磁盘上** +* 对于数据的读取,如果一次读取文件时出现未命中 PageCache 的情况,OS 从物理磁盘上访问读取文件的同时,会顺序对其他相邻块的数据文件进行**预读取**(局部性原理,最大 128K) + +在 RocketMQ 中,ConsumeQueue 逻辑消费队列存储的数据较少,并且是顺序读取,在 PageCache 机制的预读取作用下,Consume Queue 文件的读性能几乎接近读内存,即使在有消息堆积情况下也不会影响性能。但是 CommitLog 消息存储的日志数据文件读取内容时会产生较多的随机访问读取,严重影响性能。选择合适的系统 IO 调度算法和固态硬盘,比如设置调度算法为 Deadline,随机读的性能也会有所提升 + + + +*** + + + +#### 刷盘机制 + +两种持久化的方案: + +* 关系型数据库 DB:IO 读写性能比较差,如果 DB 出现故障,则 MQ 的消息就无法落盘存储导致线上故障,可靠性不高 +* 文件系统:消息刷盘至所部署虚拟机/物理机的文件系统来做持久化,分为异步刷盘和同步刷盘两种模式。消息刷盘为消息存储提供了一种高效率、高可靠性和高性能的数据持久化方式,除非部署 MQ 机器本身或是本地磁盘挂了,一般不会出现无法持久化的问题 + +RocketMQ 采用文件系统的方式,无论同步还是异步刷盘,都使用**顺序 IO**,因为磁盘的顺序读写要比随机读写快很多 + +* 同步刷盘:只有在消息真正持久化至磁盘后 RocketMQ 的 Broker 端才会真正返回给 Producer 端一个成功的 ACK 响应,保障 MQ 消息的可靠性,但是性能上会有较大影响,一般适用于金融业务应用该模式较多 + +* 异步刷盘:利用 OS 的 PageCache,只要消息写入内存 PageCache 即可将成功的 ACK 返回给 Producer 端,降低了读写延迟,提高了 MQ 的性能和吞吐量。消息刷盘采用**后台异步线程**提交的方式进行,当内存里的消息量积累到一定程度时,触发写磁盘动作 + +通过 Broker 配置文件里的 flushDiskType 参数设置采用什么方式,可以配置成 SYNC_FLUSH、ASYNC_FLUSH 中的一个 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-刷盘机制.png) + + + +官方文档:https://github.com/apache/rocketmq/blob/master/docs/cn/design.md + + + +**** + + + + + +### 集群设计 + +#### 集群模式 + +常用的以下几种模式: + +* 单 Master 模式:这种方式风险较大,一旦 Broker 重启或者宕机,会导致整个服务不可用 +* 多 Master 模式:一个集群无 Slave,全是 Master + + - 优点:配置简单,单个 Master 宕机或重启维护对应用无影响,在磁盘配置为 RAID10 时,即使机器宕机不可恢复情况下,由于 RAID10 磁盘非常可靠,消息也不会丢(异步刷盘丢失少量消息,同步刷盘一条不丢),性能最高 + + - 缺点:单台机器宕机期间,这台机器上未被消费的消息在机器恢复之前不可订阅,消息实时性会受到影响 +* 多 Master 多 Slave 模式(同步):每个 Master 配置一个 Slave,有多对 Master-Slave,HA 采用**同步双写**方式,即只有主备都写成功,才向应用返回成功 + + * 优点:数据与服务都无单点故障,Master 宕机情况下,消息无延迟,服务可用性与数据可用性都非常高 + * 缺点:性能比异步复制略低(大约低 10% 左右),发送单个消息的 RT 略高,目前不能实现主节点宕机,备机自动切换为主机 +* 多 Master 多 Slave 模式(异步):HA 采用异步复制的方式,会造成主备有短暂的消息延迟(毫秒级别) + + - 优点:即使磁盘损坏,消息丢失的非常少,且消息实时性不会受影响,同时 Master 宕机后,消费者仍然可以从 Slave 消费,而且此过程对应用透明,不需要人工干预,性能同多 Master 模式几乎一样 + - 缺点:Master 宕机,磁盘损坏情况下会丢失少量消息 + + + +*** + + + +#### 集群架构 + +RocketMQ 网络部署特点: + +- NameServer 是一个几乎**无状态节点**,节点之间相互独立,无任何信息同步 + +- Broker 部署相对复杂,Broker 分为 Master 与 Slave,Master 可以部署多个,一个 Master 可以对应多个 Slave,但是一个 Slave 只能对应一个 Master,Master 与 Slave 的对应关系通过指定相同 BrokerName、不同 BrokerId 来定义,BrokerId 为 0 是 Master,非 0 表示 Slave。**每个 Broker 与 NameServer 集群中的所有节点建立长连接**,定时注册 Topic 信息到所有 NameServer + + 说明:部署架构上也支持一 Master 多 Slave,但只有 BrokerId=1 的从服务器才会参与消息的读负载(读写分离) + +- Producer 与 NameServer 集群中的其中**一个节点(随机选择)建立长连接**,定期从 NameServer 获取 Topic 路由信息,并向提供 Topic 服务的 Master 建立长连接,且定时向 Master **发送心跳**。Producer 完全无状态,可集群部署 + +- Consumer 与 NameServer 集群中的其中一个节点(随机选择)建立长连接,定期从 NameServer 获取 Topic 路由信息,并向提供 Topic 服务的 Master、Slave 建立长连接,且定时向 Master、Slave 发送心跳 + + Consumer 既可以从 Master 订阅消息,也可以从 Slave 订阅消息,在向 Master 拉取消息时,Master 服务器会根据拉取偏移量与最大偏移量的距离(判断是否读老消息,产生读 I/O),以及从服务器是否可读等因素建议下一次是从 Master 还是 Slave 拉取 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-集群架构.png) + + + +官方文档:https://github.com/apache/rocketmq/blob/master/docs/cn/architecture.md + + + +**** + + + +#### 高可用性 + +NameServer 节点是无状态的,且各个节点直接的数据是一致的,部分 NameServer 不可用也可以保证 MQ 服务正常运行 + +BrokerServer 的高可用通过 Master 和 Slave 的配合: + +* Slave 只负责读,当 Master 不可用,对应的 Slave 仍能保证消息被正常消费 +* 配置多组 Master-Slave 组,其他的 Master-Slave 组也会保证消息的正常发送和消费 +* **目前不支持把 Slave 自动转成 Master**,需要手动停止 Slave 角色的 Broker,更改配置文件,用新的配置文件启动 Broker + + 所以需要配置多个 Master 保证可用性,否则一个 Master 挂了导致整体系统的写操作不可用 + +生产端的高可用:在创建 Topic 的时候,把 Topic 的**多个 Message Queue 创建在多个 Broker 组**上(相同 Broker 名称,不同 brokerId 的机器),当一个 Broker 组的 Master 不可用后,其他组的 Master 仍然可用,Producer 仍然可以发送消息 + +消费端的高可用:在 Consumer 的配置文件中,并不需要设置是从 Master Broker 读还是从 Slave 读,当 Master 不可用或者繁忙的时候,Consumer 会被自动切换到从 Slave 读。有了自动切换的机制,当一个 Master 机器出现故障后,Consumer 仍然可以从 Slave 读取消息,不影响 Consumer 程序,达到了消费端的高可用性 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-高可用.png) + + + +**** + + + +#### 主从复制 + +如果一个 Broker 组有 Master 和 Slave,消息需要从 Master 复制到 Slave 上,有同步和异步两种复制方式: + +* 同步复制方式:Master 和 Slave 均写成功后才反馈给客户端写成功状态(写 Page Cache)。在同步复制方式下,如果 Master 出故障, Slave 上有全部的备份数据,容易恢复,但是同步复制会增大数据写入延迟,降低系统吞吐量 + +* 异步复制方式:只要 Master 写成功,即可反馈给客户端写成功状态,系统拥有较低的延迟和较高的吞吐量,但是如果 Master 出了故障,有些数据因为没有被写入 Slave,有可能会丢失 + +同步复制和异步复制是通过 Broker 配置文件里的 brokerRole 参数进行设置的,可以设置成 ASYNC_MASTE、RSYNC_MASTER、SLAVE 三个值中的一个 + +一般把刷盘机制配置成 ASYNC_FLUSH,主从复制为 SYNC_MASTER,这样即使有一台机器出故障,仍然能保证数据不丢 + +RocketMQ 支持消息的高可靠,影响消息可靠性的几种情况: + +1. Broker 非正常关闭 +2. Broker 异常 Crash +3. OS Crash +4. 机器掉电,但是能立即恢复供电情况 +5. 机器无法开机(可能是 CPU、主板、内存等关键设备损坏) +6. 磁盘设备损坏 + +前四种情况都属于硬件资源可立即恢复情况,RocketMQ 在这四种情况下能保证消息不丢,或者丢失少量数据(依赖刷盘方式) + +后两种属于单点故障,且无法恢复,一旦发生,在此单点上的消息全部丢失。RocketMQ 在这两种情况下,通过主从异步复制,可保证 99% 的消息不丢,但是仍然会有极少量的消息可能丢失。通过**同步双写技术**可以完全避免单点,但是会影响性能,适合对消息可靠性要求极高的场合,RocketMQ 从 3.0 版本开始支持同步双写 + +一般而言,我们会建议采取同步双写 + 异步刷盘的方式,在消息的可靠性和性能间有一个较好的平衡 + + + +**** + + + +### 负载均衡 + +#### 生产端 + +RocketMQ 中的负载均衡可以分为 Producer 端发送消息时候的负载均衡和 Consumer 端订阅消息的负载均衡 + +Producer 端在发送消息时,会先根据 Topic 找到指定的 TopicPublishInfo,在获取了 TopicPublishInfo 路由信息后,RocketMQ 的客户端在默认方式调用 `selectOneMessageQueue()` 方法从 TopicPublishInfo 中的 messageQueueList 中选择一个队列 MessageQueue 进行发送消息 + +默认会**轮询所有的 Message Queue 发送**,以让消息平均落在不同的 queue 上,而由于 queue可以散落在不同的 Broker,所以消息就发送到不同的 Broker 下,图中箭头线条上的标号代表顺序,发布方会把第一条消息发送至 Queue 0,然后第二条消息发送至 Queue 1,以此类推: + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-producer负载均衡.png) + +容错策略均在 MQFaultStrategy 这个类中定义,有一个 sendLatencyFaultEnable 开关变量: + +* 如果开启,会在**随机(只有初始化索引变量时才随机,正常都是递增)递增取模**的基础上,再过滤掉 not available 的 Broker +* 如果关闭,采用随机递增取模的方式选择一个队列(MessageQueue)来发送消息 + +LatencyFaultTolerance 机制是实现消息发送高可用的核心关键所在,对之前失败的,按一定的时间做退避。例如上次请求的 latency 超过 550Lms,就退避 3000Lms;超过 1000L,就退避 60000L + + + +*** + + + +#### 消费端 + +在 RocketMQ 中,Consumer 端的两种消费模式(Push/Pull)都是基于拉模式来获取消息的,而在 Push 模式只是对 Pull 模式的一种封装,其本质实现为消息拉取线程在从服务器拉取到一批消息,提交到消息消费线程池后,又继续向服务器再次尝试拉取消息,如果未拉取到消息,则延迟一下又继续拉取 + +在两种基于拉模式的消费方式(Push/Pull)中,均需要 Consumer 端在知道从 Broker 端的哪一个消息队列中去获取消息,所以在 Consumer 端来做负载均衡,即 Broker 端中多个 MessageQueue 分配给同一个 Consumer Group 中的哪些 Consumer 消费 + +* 广播模式下要求一条消息需要投递到一个消费组下面所有的消费者实例,所以不存在负载均衡,在实现上,Consumer 分配 queue 时,所有 Consumer 都分到所有的 queue。 + +* 在集群消费模式下,每条消息只需要投递到订阅这个 Topic 的 Consumer Group 下的一个实例即可,RocketMQ 采用主动拉取的方式拉取并消费消息,在拉取的时候需要明确指定拉取哪一条 Message Queue + +集群模式下,每当消费者实例的数量有变更,都会触发一次所有实例的负载均衡,这时候会按照 queue 的数量和实例的数量平均分配 queue 给每个实例。默认的分配算法是 AllocateMessageQueueAveragely: + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-平均队列分配.png) + + 还有一种平均的算法是 AllocateMessageQueueAveragelyByCircle,以环状轮流均分 queue 的形式: + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-平均队列轮流分配.png) + +集群模式下,**queue 都是只允许分配一个实例**,如果多个实例同时消费一个 queue 的消息,由于拉取哪些消息是 Consumer 主动控制的,会导致同一个消息在不同的实例下被消费多次 + +通过增加 Consumer 实例去分摊 queue 的消费,可以起到水平扩展的消费能力的作用。而当有实例下线时,会重新触发负载均衡,这时候原来分配到的 queue 将分配到其他实例上继续消费。但是如果 Consumer 实例的数量比 Message Queue 的总数量还多的话,多出来的 Consumer 实例将无法分到 queue,也就无法消费到消息,也就无法起到分摊负载的作用了,所以需要**控制让 queue 的总数量大于等于 Consumer 的数量** + + + +*** + + + +#### 原理解析 + +在 Consumer 启动后,会通过定时任务不断地向 RocketMQ 集群中的所有 Broker 实例发送心跳包。Broker 端在收到 Consumer 的心跳消息后,会将它维护在 ConsumerManager 的本地缓存变量 consumerTable,同时并将封装后的客户端网络通道信息保存在本地缓存变量 channelInfoTable 中,为 Consumer 端的负载均衡提供可以依据的元数据信息 + +Consumer 端实现负载均衡的核心类 **RebalanceImpl** + +在 Consumer 实例的启动流程中的会启动 MQClientInstance 实例,完成负载均衡服务线程 RebalanceService 的启动(**每隔 20s 执行一次**负载均衡),RebalanceService 线程的 run() 方法最终调用的是 RebalanceImpl 类的 rebalanceByTopic() 方法,该方法是实现 Consumer 端负载均衡的核心。rebalanceByTopic() 方法会根据广播模式还是集群模式做不同的逻辑处理。主要看集群模式: + +* 从 rebalanceImpl 实例的本地缓存变量 topicSubscribeInfoTable 中,获取该 Topic 主题下的消息消费队列集合 mqSet + +* 根据 Topic 和 consumerGroup 为参数调用 `mQClientFactory.findConsumerIdList()` 方法向 Broker 端发送获取该消费组下消费者 ID 列表的 RPC 通信请求(Broker 端基于前面 Consumer 端上报的心跳包数据而构建的 consumerTable 做出响应返回,业务请求码 `GET_CONSUMER_LIST_BY_GROUP`) + +* 先对 Topic 下的消息消费队列、消费者 ID 排序,然后用消息队列分配策略算法(默认是消息队列的平均分配算法),计算出待拉取的消息队列。平均分配算法类似于分页的算法,将所有 MessageQueue 排好序类似于记录,将所有消费端 Consumer 排好序类似页数,并求出每一页需要包含的平均 size 和每个页面记录的范围 range,最后遍历整个 range 而计算出当前 Consumer 端应该分配到的记录(这里即为 MessageQueue) + +* 调用 updateProcessQueueTableInRebalance() 方法,先将分配到的消息队列集合 mqSet 与 processQueueTable 做一个过滤比对 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-负载均衡重新平衡算法.png) + +* processQueueTable 标注的红色部分,表示与分配到的消息队列集合 mqSet 互不包含,将这些队列设置 Dropped 属性为 true,然后查看这些队列是否可以移除出 processQueueTable 缓存变量。具体执行 removeUnnecessaryMessageQueue() 方法,即每隔 1s 查看是否可以获取当前消费处理队列的锁,拿到的话返回 true;如果等待 1s 后,仍然拿不到当前消费处理队列的锁则返回 false。如果返回 true,则从 processQueueTable 缓存变量中移除对应的 Entry + +* processQueueTable 的绿色部分,表示与分配到的消息队列集合 mqSet 的交集,判断该 ProcessQueue 是否已经过期了,在 Pull 模式的不用管,如果是 Push 模式的,设置 Dropped 属性为 true,并且调用 removeUnnecessaryMessageQueue() 方法,像上面一样尝试移除 Entry + +* 为过滤后的消息队列集合 mqSet 中每个 MessageQueue 创建 ProcessQueue 对象存入 RebalanceImpl 的 processQueueTable 队列中(其中调用 RebalanceImpl 实例的 `computePullFromWhere(MessageQueue mq)` 方法获取该 MessageQueue 对象的下一个进度消费值 offset,随后填充至接下来要创建的 pullRequest 对象属性中),并**创建拉取请求对象** pullRequest 添加到拉取列表 pullRequestList 中,最后执行 dispatchPullRequest() 方法,将 Pull 消息的请求对象 PullRequest 放入 PullMessageService 服务线程的**阻塞队列** pullRequestQueue 中,待该服务线程取出后向 Broker 端发起 Pull 消息的请求 + + 对比下 RebalancePushImpl 和 RebalancePullImpl 两个实现类的 dispatchPullRequest() 方法,RebalancePullImpl 类里面的该方法为空 + +消息消费队列在**同一消费组不同消费者之间的负载均衡**,其核心设计理念是在**一个消息消费队列在同一时间只允许被同一消费组内的一个消费者消费,一个消息消费者能同时消费多个消息队列** + + + + + +**** + + + +### 消息查询 + +#### 查询方式 + +RocketMQ 支持按照两种维度进行消息查询:按照 Message ID 查询消息、按照 Message Key 查询消息 + +* RocketMQ 中的 MessageID 的长度总共有 16 字节,其中包含了消息存储主机地址(IP 地址和端口),消息 Commit Log offset + + 实现方式:Client 端从 MessageID 中解析出 Broker 的地址(IP 地址和端口)和 Commit Log 的偏移地址,封装成一个 RPC 请求后通过 Remoting 通信层发送(业务请求码 VIEW_MESSAGE_BY_ID)。Broker 端走的是 QueryMessageProcessor,读取消息的过程用其中的 CommitLog 的 offset 和 size 去 CommitLog 中找到真正的记录并解析成一个完整的消息返回 + +* 按照 Message Key 查询消息,IndexFile 索引文件为提供了通过 Message Key 查询消息的服务 + + 实现方式:通过 Broker 端的 QueryMessageProcessor 业务处理器来查询,读取消息的过程用 **Topic 和 Key** 找到 IndexFile 索引文件中的一条记录,根据其中的 CommitLog Offset 从 CommitLog 文件中读取消息的实体内容 + + + +*** + + + +#### 索引机制 + +RocketMQ 的索引文件逻辑结构,类似 JDK 中 HashMap 的实现,具体结构如下: + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-IndexFile索引文件.png) + +IndexFile 文件的存储在 `$HOME\store\index${fileName}`,文件名 fileName 是以创建时的时间戳命名,文件大小是固定的,等于 `40+500W*4+2000W*20= 420000040` 个字节大小。如果消息的 properties 中设置了 UNIQ_KEY 这个属性,就用 `topic + “#” + UNIQ_KEY` 作为 key 来做写入操作;如果消息设置了 KEYS 属性(多个 KEY 以空格分隔),也会用 `topic + “#” + KEY` 来做索引 + +整个 Index File 的结构如图,40 Byte 的 Header 用于保存一些总的统计信息,`4*500W` 的 Slot Table 并不保存真正的索引数据,而是保存每个槽位对应的单向链表的**头指针**,即一个 Index File 可以保存 2000W 个索引,`20*2000W` 是**真正的索引数据** + +索引数据包含了 Key Hash/CommitLog Offset/Timestamp/NextIndex offset 这四个字段,一共 20 Byte + +* NextIndex offset 即前面读出来的 slotValue,如果有 hash 冲突,就可以用这个字段将所有冲突的索引用链表的方式串起来 +* Timestamp 记录的是消息 storeTimestamp 之间的差,并不是一个绝对的时间 + + + +参考文档:https://github.com/apache/rocketmq/blob/master/docs/cn/design.md + + + + + +*** + + + +### 消息重试 + +#### 消息重投 + +生产者在发送消息时,同步消息和异步消息失败会重投,oneway 没有任何保证。消息重投保证消息尽可能发送成功、不丢失,但当出现消息量大、网络抖动时,可能会造成消息重复;生产者主动重发、Consumer 负载变化也会导致重复消息 + +如下方法可以设置消息重投策略: + +- retryTimesWhenSendFailed:同步发送失败重投次数,默认为 2,因此生产者会最多尝试发送 retryTimesWhenSendFailed + 1 次。不会选择上次失败的 Broker,尝试向其他 Broker 发送,**最大程度保证消息不丢**。超过重投次数抛出异常,由客户端保证消息不丢。当出现 RemotingException、MQClientException 和部分 MQBrokerException 时会重投 +- retryTimesWhenSendAsyncFailed:异步发送失败重试次数,异步重试不会选择其他 Broker,仅在同一个 Broker 上做重试,**不保证消息不丢** +- retryAnotherBrokerWhenNotStoreOK:消息刷盘(主或备)超时或 slave 不可用(返回状态非 SEND_OK),是否尝试发送到其他 Broker,默认 false,十分重要消息可以开启 + +注意点: + +* 如果同步模式发送失败,则选择到下一个 Broker,如果异步模式发送失败,则**只会在当前 Broker 进行重试** +* 发送消息超时时间默认 3000 毫秒,就不会再尝试重试 + + + +*** + + + +#### 消息重试 + +Consumer 消费消息失败后,提供了一种重试机制,令消息再消费一次。Consumer 消费消息失败可以认为有以下几种情况: + +- 由于消息本身的原因,例如反序列化失败,消息数据本身无法处理等。这种错误通常需要跳过这条消息,再消费其它消息,而这条失败的消息即使立刻重试消费,99% 也不成功,所以需要提供一种定时重试机制,即过 10 秒后再重试 +- 由于依赖的下游应用服务不可用,例如 DB 连接不可用,外系统网络不可达等。这种情况即使跳过当前失败的消息,消费其他消息同样也会报错,这种情况建议应用 sleep 30s,再消费下一条消息,这样可以减轻 Broker 重试消息的压力 + +RocketMQ 会为每个消费组都设置一个 Topic 名称为 `%RETRY%+consumerGroup` 的重试队列(这个 Topic 的重试队列是**针对消费组**,而不是针对每个 Topic 设置的),用于暂时保存因为各种异常而导致 Consumer 端无法消费的消息 + +* 顺序消息的重试,当消费者消费消息失败后,消息队列 RocketMQ 会自动不断进行消息重试(每次间隔时间为 1 秒),这时应用会出现消息消费被阻塞的情况。所以在使用顺序消息时,必须保证应用能够及时监控并处理消费失败的情况,避免阻塞现象的发生 + +* 无序消息(普通、定时、延时、事务消息)的重试,可以通过设置返回状态达到消息重试的结果。无序消息的重试只针对集群消费方式生效,广播方式不提供失败重试特性,即消费失败后,失败消息不再重试,继续消费新的消息 + +**无序消息情况下**,因为异常恢复需要一些时间,会为重试队列设置多个重试级别,每个重试级别都有对应的重新投递延时,重试次数越多投递延时就越大。RocketMQ 对于重试消息的处理是先保存至 Topic 名称为 `SCHEDULE_TOPIC_XXXX` 的延迟队列中,后台定时任务**按照对应的时间进行 Delay 后**重新保存至 `%RETRY%+consumerGroup` 的重试队列中 + +消息队列 RocketMQ 默认允许每条消息最多重试 16 次,每次重试的间隔时间如下表示: + +| 第几次重试 | 与上次重试的间隔时间 | 第几次重试 | 与上次重试的间隔时间 | +| :--------: | :------------------: | :--------: | :------------------: | +| 1 | 10 秒 | 9 | 7 分钟 | +| 2 | 30 秒 | 10 | 8 分钟 | +| 3 | 1 分钟 | 11 | 9 分钟 | +| 4 | 2 分钟 | 12 | 10 分钟 | +| 5 | 3 分钟 | 13 | 20 分钟 | +| 6 | 4 分钟 | 14 | 30 分钟 | +| 7 | 5 分钟 | 15 | 1 小时 | +| 8 | 6 分钟 | 16 | 2 小时 | + +如果消息重试 16 次后仍然失败,消息将**不再投递**,如果严格按照上述重试时间间隔计算,某条消息在一直消费失败的前提下,将会在接下来的 4 小时 46 分钟之内进行 16 次重试,超过这个时间范围消息将不再重试投递 + +时间间隔不支持自定义配置,最大重试次数可通过自定义参数 `MaxReconsumeTimes` 取值进行配置,若配置超过 16 次,则超过的间隔时间均为 2 小时 + +说明:一条消息无论重试多少次,**消息的 Message ID 是不会改变的** + + + +*** + + + +#### 重试操作 + +集群消费方式下,消息消费失败后期望消息重试,需要在消息监听器接口的实现中明确进行配置(三种方式任选一种): + +- 返回 Action.ReconsumeLater (推荐) +- 返回 null +- 抛出异常 + +```java +public class MessageListenerImpl implements MessageListener { + @Override + public Action consume(Message message, ConsumeContext context) { + // 处理消息 + doConsumeMessage(message); + //方式1:返回 Action.ReconsumeLater,消息将重试 + return Action.ReconsumeLater; + //方式2:返回 null,消息将重试 + return null; + //方式3:直接抛出异常, 消息将重试 + throw new RuntimeException("Consumer Message exceotion"); + } +} +``` + +集群消费方式下,消息失败后期望消息不重试,需要捕获消费逻辑中可能抛出的异常,最终返回 Action.CommitMessage,此后这条消息将不会再重试 + +```java +public class MessageListenerImpl implements MessageListener { + @Override + public Action consume(Message message, ConsumeContext context) { + try { + doConsumeMessage(message); + } catch (Throwable e) { + // 捕获消费逻辑中的所有异常,并返回 Action.CommitMessage; + return Action.CommitMessage; + } + //消息处理正常,直接返回 Action.CommitMessage; + return Action.CommitMessage; + } +} +``` + +自定义消息最大重试次数,RocketMQ 允许 Consumer 启动的时候设置最大重试次数,重试时间间隔将按照如下策略: + +- 最大重试次数小于等于 16 次,则重试时间间隔同上表描述 +- 最大重试次数大于 16 次,超过 16 次的重试时间间隔均为每次 2 小时 + +```java +Properties properties = new Properties(); +// 配置对应 Group ID 的最大消息重试次数为 20 次 +properties.put(PropertyKeyConst.MaxReconsumeTimes,"20"); +Consumer consumer = ONSFactory.createConsumer(properties); +``` + +注意: + +- 消息最大重试次数的设置对相同 Group ID 下的所有 Consumer 实例有效。例如只对相同 Group ID 下两个 Consumer 实例中的其中一个设置了 MaxReconsumeTimes,那么该配置对两个 Consumer 实例均生效 +- 配置采用覆盖的方式生效,即最后启动的 Consumer 实例会覆盖之前的启动实例的配置 + +消费者收到消息后,可按照如下方式获取消息的重试次数: + +```java +public class MessageListenerImpl implements MessageListener { + @Override + public Action consume(Message message, ConsumeContext context) { + // 获取消息的重试次数 + System.out.println(message.getReconsumeTimes()); + return Action.CommitMessage; + } +} +``` + + + + + +*** + + + +### 死信队列 + +正常情况下无法被消费的消息称为死信消息(Dead-Letter Message),存储死信消息的特殊队列称为死信队列(Dead-Letter Queue) + +当一条消息初次消费失败,消息队列 RocketMQ 会自动进行消息重试,达到最大重试次数后,若消费依然失败,则表明消费者在正常情况下无法正确地消费该消息,此时 RocketMQ 不会立刻将消息丢弃,而是将其发送到该消费者对应的死信队列中 + +死信消息具有以下特性: + +- 不会再被消费者正常消费 +- 有效期与正常消息相同,均为 3 天,3 天后会被自动删除,所以请在死信消息产生后的 3 天内及时处理 + +死信队列具有以下特性: + +- **一个死信队列对应一个 Group ID, 而不是对应单个消费者实例** +- 如果一个 Group ID 未产生死信消息,消息队列 RocketMQ 不会为其创建相应的死信队列 +- 一个死信队列包含了对应 Group ID 产生的所有死信消息,不论该消息属于哪个 Topic + +一条消息进入死信队列,需要排查可疑因素并解决问题后,可以在消息队列 RocketMQ 控制台重新发送该消息,让消费者重新消费一次 + + + + + +*** + + + +### 高可靠性 + +RocketMQ 消息丢失可能发生在以下三个阶段: + +- 生产阶段:消息在 Producer 发送端创建出来,经过网络传输发送到 Broker 存储端 + - 生产者得到一个成功的响应,就可以认为消息的存储和消息的消费都是可靠的 + - 消息重投机制 +- 存储阶段:消息在 Broker 端存储,如果是主备或者多副本,消息会在这个阶段被复制到其他的节点或者副本上 + - 单点:刷盘机制(同步或异步) + - 主从:消息同步机制(异步复制或同步双写,主从复制章节详解) + - 过期删除:操作 CommitLog、ConsumeQueue 文件是基于文件内存映射机制,并且在启动的时候会将所有的文件加载,为了避免内存与磁盘的浪费,让磁盘能够循环利用,防止磁盘不足导致消息无法写入等引入了文件过期删除机制。最终使得磁盘水位保持在一定水平,最终保证新写入消息的可靠存储 +- 消费阶段:Consumer 消费端从 Broker存储端拉取消息,经过网络传输发送到 Consumer 消费端上 + - 消息重试机制来最大限度的保证消息的消费 + - 消费失败的进行消息回退,重试次数过多的消息放入死信队列 + + + +推荐文章:https://cdn.modb.pro/db/394751 + + + +**** + + + +### 幂等消费 + +消息队列 RocketMQ 消费者在接收到消息以后,需要根据业务上的唯一 Key 对消息做幂等处理 + +At least Once 机制保证消息不丢失,但是可能会造成消息重复,RocketMQ 中无法避免消息重复(Exactly-Once),在互联网应用中,尤其在网络不稳定的情况下,几种情况: + +- 发送时消息重复:当一条消息已被成功发送到服务端并完成持久化,此时出现了网络闪断或客户端宕机,导致服务端对客户端应答失败。此时生产者意识到消息发送失败并尝试再次发送消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息 + +- 投递时消息重复:消息消费的场景下,消息已投递到消费者并完成业务处理,当客户端给服务端反馈应答的时候网络闪断。为了保证消息至少被消费一次,消息队列 RocketMQ 的服务端将在网络恢复后再次尝试投递之前已被处理过的消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息 + +- 负载均衡时消息重复:当消息队列 RocketMQ 的 Broker 或客户端重启、扩容或缩容时,会触发 Rebalance,此时消费者可能会收到重复消息 + + +处理方式: + +* 因为 Message ID 有可能出现冲突(重复)的情况,所以真正安全的幂等处理,不建议以 Message ID 作为处理依据,最好的方式是以业务唯一标识作为幂等处理的关键依据,而业务的唯一标识可以通过消息 Key 进行设置: + + ```java + Message message = new Message(); + message.setKey("ORDERID_100"); + SendResult sendResult = producer.send(message); + ``` + +* 订阅方收到消息时可以根据消息的 Key 进行幂等处理: + + ```java + consumer.subscribe("ons_test", "*", new MessageListener() { + public Action consume(Message message, ConsumeContext context) { + String key = message.getKey() + // 根据业务唯一标识的 key 做幂等处理 + } + }); + ``` + + + + + +*** + + + +### 流量控制 + +生产者流控,因为 Broker 处理能力达到瓶颈;消费者流控,因为消费能力达到瓶颈 + +生产者流控: + +- CommitLog 文件被锁时间超过 osPageCacheBusyTimeOutMills 时,参数默认为 1000ms,返回流控 +- 如果开启 transientStorePoolEnable == true,且 Broker 为异步刷盘的主机,且 transientStorePool 中资源不足,拒绝当前 send 请求,返回流控 +- Broker 每隔 10ms 检查 send 请求队列头部请求的等待时间,如果超过 waitTimeMillsInSendQueue,默认 200ms,拒绝当前 send 请求,返回流控。 +- Broker 通过拒绝 send 请求方式实现流量控制 + +注意:生产者流控,不会尝试消息重投 + +消费者流控: + +- 消费者本地缓存消息数超过 pullThresholdForQueue 时,默认 1000 +- 消费者本地缓存消息大小超过 pullThresholdSizeForQueue 时,默认 100MB +- 消费者本地缓存消息跨度超过 consumeConcurrentlyMaxSpan 时,默认 2000 + +消费者流控的结果是降低拉取频率 + + + + + +*** + + + + + +## 原理解析 + +### Namesrv + +#### 服务启动 + +##### 启动方法 + +NamesrvStartup 类中有 Namesrv 服务的启动方法: + +```java +public static void main(String[] args) { + // 如果启动时 使用 -c -p 设置参数了,这些参数存储在 args 中 + main0(args); +} + +public static NamesrvController main0(String[] args) { + try { + // 创建 namesrv 控制器,用来初始化 namesrv 启动 namesrv 关闭 namesrv + NamesrvController controller = createNamesrvController(args); + // 启动 controller + start(controller); + return controller; + } catch (Throwable e) { + // 出现异常,停止系统 + System.exit(-1); + } + return null; +} +``` + +NamesrvStartup#createNamesrvController:读取配置信息,初始化 Namesrv 控制器 + +* `ServerUtil.parseCmdLine("mqnamesrv", args, buildCommandlineOptions(options),..)`:解析启动时的参数信息 + +* `namesrvConfig = new NamesrvConfig()`:创建 Namesrv 配置对象 + + * `private String rocketmqHome`:获取 ROCKETMQ_HOME 值 + * `private boolean orderMessageEnable = false`:**顺序消息**功能是否开启 + +* `nettyServerConfig = new NettyServerConfig()`:Netty 的服务器配置对象 + +* `nettyServerConfig.setListenPort(9876)`:Namesrv 服务器的**监听端口设置为 9876** + +* `if (commandLine.hasOption('c'))`:读取命令行 -c 的参数值 + + `in = new BufferedInputStream(new FileInputStream(file))`:读取指定目录的配置文件 + + `properties.load(in)`:将配置文件信息加载到 properties 对象,相关属性会复写到 Namesrv 配置和 Netty 配置对象 + + `namesrvConfig.setConfigStorePath(file)`:将配置文件的路径保存到配置保存字段 + +* `if (null == namesrvConfig.getRocketmqHome())`:检查 ROCKETMQ_HOME 配置是否是空,是空就报错 + +* `lc = (LoggerContext) LoggerFactory.getILoggerFactory()`:创建日志对象 + +* `controller = new NamesrvController(namesrvConfig, nettyServerConfig)`:**创建 Namesrv 控制器** + +NamesrvStartup#start:启动 Namesrv 控制器 + +* `boolean initResult = controller.initialize()`:初始化方法 + +* ` Runtime.getRuntime().addShutdownHook(new ShutdownHookThread())`:JVM HOOK 平滑关闭的逻辑, 当 JVM 被关闭时,主动调用 controller.shutdown() 方法,让服务器平滑关机 +* `controller.start()`:启动服务器 + + + +源码解析参考视频:https://space.bilibili.com/457326371 + + + +**** + + + + + +##### 控制器类 + +NamesrvController 用来初始化和启动 Namesrv 服务器 + +* 成员变量: + + ```java + private final ScheduledExecutorService scheduledExecutorService; // 调度线程池,用来执行定时任务 + private final RouteInfoManager routeInfoManager; // 管理【路由信息】的对象 + private RemotingServer remotingServer; // 【网络层】封装对象 + private BrokerHousekeepingService brokerHousekeepingService; // 用于监听 channel 状态 + ``` + + `private ExecutorService remotingExecutor`:业务线程池,**netty 线程解析报文成 RemotingCommand 对象,然后将该对象交给业务线程池再继续处理** + +* 初始化: + + ```java + public boolean initialize() { + // 加载本地kv配置(我还不明白 kv 配置是啥) + this.kvConfigManager.load(); + // 创建网络服务器对象,【将 netty 的配置和监听器传入】 + // 监听器监听 channel 状态的改变,会向事件队列发起事件,最后交由 service 处理 + this.remotingServer = new NettyRemotingServer(this.nettyServerConfig, this.brokerHousekeepingService); + // 【创建业务线程池,默认线程数 8】 + this.remotingExecutor = Executors.newFixedThreadPool(nettyServerConfig.getServerWorkerThreads().); + + // 注册协议处理器(缺省协议处理器),【处理器是 DefaultRequestProcessor】,线程使用的是刚创建的业务的线程池 + this.registerProcessor(); + + // 定时任务1:每 10 秒钟检查 broker 存活状态,将 IDLE 状态的 broker 移除【扫描机制,心跳检测】 + this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() { + @Override + public void run() { + // 扫描 brokerLiveTable 表,将两小时没有活动的 broker 关闭, + // 通过 next.getKey() 获取 broker 的地址,然后【关闭服务器与broker物理节点的 channel】 + NamesrvController.this.routeInfoManager.scanNotActiveBroker(); + } + }, 5, 10, TimeUnit.SECONDS); + + // 定时任务2:每 10 分钟打印一遍 kv 配置。 + this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() { + @Override + public void run() { + NamesrvController.this.kvConfigManager.printAllPeriodically(); + } + }, 1, 10, TimeUnit.MINUTES); + + return true; + } + ``` + +* 启动方法: + + ```java + public void start() throws Exception { + // 服务器网络层启动。 + this.remotingServer.start(); + + if (this.fileWatchService != null) { + this.fileWatchService.start(); + } + } + ``` + + + + + +*** + + + +#### 网络通信 + +##### 通信原理 + +RocketMQ 的 RPC 通信采用 Netty 组件作为底层通信库,同样也遵循了 Reactor 多线程模型,NettyRemotingServer 类负责框架的通信服务,同时又在这之上做了一些扩展和优化 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-Reactor设计.png) + +RocketMQ 基于 NettyRemotingServer 的 Reactor 多线程模型: + +* 一个 Reactor 主线程(eventLoopGroupBoss)负责监听 TCP 网络连接请求,建立好连接创建 SocketChannel(RocketMQ 会自动根据 OS 的类型选择 NIO 和 Epoll,也可以通过参数配置),并注册到 Selector 上,然后监听真正的网络数据 + +* 拿到网络数据交给 Worker 线程池(eventLoopGroupSelector,默认设置为 3),在真正执行业务逻辑之前需要进行 SSL 验证、编解码、空闲检查、网络连接管理,这些工作交给 defaultEventExecutorGroup(默认设置为 8)去做 +* 处理业务操作放在业务线程池中执行,根据 RomotingCommand 的**业务请求码 code** 去 processorTable 这个本地缓存变量中找到对应的 processor,封装成 task 任务提交给对应的 processor 处理线程池来执行(sendMessageExecutor,以发送消息为例) +* 从入口到业务逻辑的几个步骤中线程池一直再增加,这跟每一步逻辑复杂性相关,越复杂,需要的并发通道越宽 + +| 线程数 | 线程名 | 线程具体说明 | +| ------ | ------------------------------ | ------------------------- | +| 1 | NettyBoss_%d | Reactor 主线程 | +| N | NettyServerEPOLLSelector_%d_%d | Reactor 线程池 | +| M1 | NettyServerCodecThread_%d | Worker 线程池 | +| M2 | RemotingExecutorThread_%d | 业务 processor 处理线程池 | + +RocketMQ 的异步通信流程: + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-异步通信流程.png) + + + +==todo:后期对 Netty 有了更深的认知后会进行扩充,现在暂时 copy 官方文档== + +官方文档:https://github.com/apache/rocketmq/blob/master/docs/cn/design.md#2-%E9%80%9A%E4%BF%A1%E6%9C%BA%E5%88%B6 + + + +*** + + + +##### 成员属性 + +NettyRemotingServer 类成员变量: + +* 服务器相关属性: + + ```java + private final ServerBootstrap serverBootstrap; // netty 服务端启动对象 + private final EventLoopGroup eventLoopGroupSelector; // netty worker 组线程池,【默认 3 个线程】 + private final EventLoopGroup eventLoopGroupBoss; // netty boss 组线程池,【一般是 1 个线程】 + private final NettyServerConfig nettyServerConfig; // netty 服务端网络配置 + private int port = 0; // 服务器绑定的端口 + ``` + +* 公共线程池:注册处理器时如果未指定线程池,则业务处理使用公共线程池,线程数量默认是 4 + + ```java + private final ExecutorService publicExecutor; + ``` + +* 事件监听器:Nameserver 使用 BrokerHouseKeepingService,Broker 使用 ClientHouseKeepingService + + ```java + private final ChannelEventListener channelEventListener; + ``` + +* 事件处理线程池:默认是 8 + + ```java + private DefaultEventExecutorGroup defaultEventExecutorGroup; + ``` + +* 定时器:执行循环任务,并且将定时器线程设置为守护线程 + + ```java + private final Timer timer = new Timer("ServerHouseKeepingService", true); + ``` + +* 处理器:多个 Channel 共享的处理器 Handler,多个通道使用同一个对象 + +* Netty 配置对象: + + ```java + public class NettyServerConfig implements Cloneable { + // 服务端启动时监听的端口号 + private int listenPort = 8888; + // 【业务线程池】 线程数量 + private int serverWorkerThreads = 8; + // 根据该值创建 remotingServer 内部的一个 publicExecutor + private int serverCallbackExecutorThreads = 0; + // netty 【worker】线程数 + private int serverSelectorThreads = 3; + // 【单向访问】时的并发限制 + private int serverOnewaySemaphoreValue = 256; + // 【异步访问】时的并发限制 + private int serverAsyncSemaphoreValue = 64; + // channel 最大的空闲存活时间 默认是 2min + private int serverChannelMaxIdleTimeSeconds = 120; + // 发送缓冲区大小 65535 + private int serverSocketSndBufSize = NettySystemConfig.socketSndbufSize; + // 接收缓冲区大小 65535 + private int serverSocketRcvBufSize = NettySystemConfig.socketRcvbufSize; + // 是否启用 netty 内存池 默认开启 + private boolean serverPooledByteBufAllocatorEnable = true; + + // 默认 linux 会启用 【epoll】 + private boolean useEpollNativeSelector = false; + } + ``` + + +构造方法: + +* 无监听器构造: + + ```java + public NettyRemotingServer(final NettyServerConfig nettyServerConfig) { + this(nettyServerConfig, null); + } + ``` + +* 有参构造方法: + + ```java + public NettyRemotingServer(final NettyServerConfig nettyServerConfig, + final ChannelEventListener channelEventListener) { + // 服务器对客户端主动发起请求时并发限制。【单向请求和异步请求】的并发限制 + super(nettyServerConfig.getServerOnewaySemaphoreValue(), nettyServerConfig.getServerAsyncSemaphoreValue()); + // Netty 的启动器,负责组装 netty 组件 + this.serverBootstrap = new ServerBootstrap(); + // 成员变量的赋值 + this.nettyServerConfig = nettyServerConfig; + this.channelEventListener = channelEventListener; + + // 公共线程池的线程数量,默认给的0,这里最终修改为4. + int publicThreadNums = nettyServerConfig.getServerCallbackExecutorThreads(); + if (publicThreadNums <= 0) { + publicThreadNums = 4; + } + // 创建公共线程池,指定线程工厂,设置线程名称前缀:NettyServerPublicExecutor_[数字] + this.publicExecutor = Executors.newFixedThreadPool(publicThreadNums, new ThreadFactory(){.}); + + // 创建两个 netty 的线程组,一个是boss组,一个是worker组,【linux 系统默认启用 epoll】 + if (useEpoll()) {...} else {...} + // SSL 相关 + loadSslContext(); + } + ``` + + + + + +*** + + + +##### 启动方法 + +核心方法的解析: + +* start():启动方法,**创建 BootStrap,并添加 NettyServerHandler 处理器** + + ```java + public void start() { + // Channel Pipeline 内的 handler 使用的线程资源,【线程分配给 handler 处理事件】 + this.defaultEventExecutorGroup = new DefaultEventExecutorGroup(...); + + // 创建通用共享的处理器 handler,【非常重要的 NettyServerHandler】 + prepareSharableHandlers(); + + ServerBootstrap childHandler = + // 配置工作组 boss(数量1) 和 worker(数量3) 组 + this.serverBootstrap.group(this.eventLoopGroupBoss, this.eventLoopGroupSelector) + // 设置服务端 ServerSocketChannel 类型, Linux 用 epoll + .channel(useEpoll() ? EpollServerSocketChannel.class : NioServerSocketChannel.class) + // 设置服务端 channel 选项 + .option(ChannelOption.SO_BACKLOG, 1024) + // 客户端 channel 选项 + .childOption(ChannelOption.TCP_NODELAY, true) + // 设置服务器端口 + .localAddress(new InetSocketAddress(this.nettyServerConfig.getListenPort())) + // 向 channel pipeline 添加了很多 handler,【包括 NettyServerHandler】 + .childHandler(new ChannelInitializer() {}); + + // 客户端开启 内存池,使用的内存池是 PooledByteBufAllocator.DEFAULT + if (nettyServerConfig.isServerPooledByteBufAllocatorEnable()) { + childHandler.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT); + } + + try { + // 同步等待建立连接,并绑定端口。 + ChannelFuture sync = this.serverBootstrap.bind().sync(); + InetSocketAddress addr = (InetSocketAddress) sync.channel().localAddress(); + // 将服务器成功绑定的端口号赋值给字段 port。 + this.port = addr.getPort(); + } catch (InterruptedException e1) {} + + // housekeepingService 不为空,则创建【网络异常事件处理器】 + if (this.channelEventListener != null) { + // 线程一直轮询 nettyEvent 状态,根据 CONNECT,CLOSE,IDLE,EXCEPTION 四种事件类型 + // CONNECT 不做操作,其余都是回调 onChannelDestroy 【关闭服务器与 Broker 物理节点的 Channel】 + this.nettyEventExecutor.start(); + } + + // 提交定时任务,每一秒 执行一次。扫描 responseTable 表,将过期的数据移除 + this.timer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + NettyRemotingServer.this.scanResponseTable(); + } + }, 1000 * 3, 1000); + } + ``` + +* registerProcessor():注册业务处理器 + + ```java + public void registerProcessor(int requestCode, NettyRequestProcessor processor, ExecutorService executor) { + ExecutorService executorThis = executor; + if (null == executor) { + // 未指定线程池资源,将公共线程池赋值 + executorThis = this.publicExecutor; + } + // pair 对象,第一个参数代表的是处理器, 第二个参数是线程池,默认是公共的线程池 + Pair pair = new Pair(processor, executorThis); + + // key 是请求码,value 是 Pair 对象 + this.processorTable.put(requestCode, pair); + } + ``` + +* getProcessorPair():**根据请求码获取对应的处理器和线程池资源** + + ```java + public Pair getProcessorPair(int requestCode) { + return processorTable.get(requestCode); + } + ``` + + + +*** + + + +##### 请求方法 + +在 RocketMQ 消息队列中支持通信的方式主要有同步(sync)、异步(async)、单向(oneway)三种,其中单向通信模式相对简单,一般用在发送心跳包场景下,无需关注其 Response + +服务器主动向客户端发起请求时,使用三种方法 + +* invokeSync(): 同步调用,**服务器需要阻塞等待调用的返回结果** + * `int opaque = request.getOpaque()`:获取请求 ID(与请求码不同) + * `responseFuture = new ResponseFuture(...)`:**创建响应对象**,没有回调函数和 Once + * `this.responseTable.put(opaque, responseFuture)`:**加入到响应映射表中**,key 为请求 ID + * `SocketAddress addr = channel.remoteAddress()`:获取客户端的地址信息 + * `channel.writeAndFlush(request).addListener(...)`:将**业务 Command 信息**写入通道,业务线程将数据交给 Netty ,Netty 的 IO 线程接管写刷数据的操作,**监听器由 IO 线程在写刷后回调** + * `if (f.isSuccess())`:写入成功会将响应对象设置为成功状态直接 return,写入失败设置为失败状态 + * `responseTable.remove(opaque)`:将当前请求的 responseFuture **从映射表移除** + * `responseFuture.setCause(f.cause())`:设置错误的信息 + * `responseFuture.putResponse(null)`:响应 Command 设置为 null + * `responseCommand = responseFuture.waitResponse(timeoutMillis)`:当前线程设置超时时间挂起,**同步等待响应** + * `if (null == responseCommand)`:超时或者出现异常,直接报错 + * `return responseCommand`:返回响应 Command 信息 +* invokeAsync():异步调用,有回调对象,无返回值 + * `boolean acquired = this.semaphoreAsync.tryAcquire(timeoutMillis, TimeUnit.MILLISECONDS)`:获取信号量的许可证,信号量用来**限制异步请求**的数量 + * `if (acquired)`:许可证获取失败说明并发较高,会抛出异常 + * `once = new SemaphoreReleaseOnlyOnce(this.semaphoreAsync)`:Once 对象封装了释放信号量的操作 + * `costTime = System.currentTimeMillis() - beginStartTime`:计算一下耗费的时间,超时不再发起请求 + * `responseFuture = new ResponseFuture()`:**创建响应对象,包装了回调函数和 Once 对象** + * `this.responseTable.put(opaque, responseFuture)`:加入到响应映射表中,key 为请求 ID + * `channel.writeAndFlush(request).addListener(...)`:写刷数据 + * `if (f.isSuccess())`:写刷成功,设置 responseFuture 发生状态为 true + * `requestFail(opaque)`:写入失败,使用 publicExecutor **公共线程池异步执行回调对象的函数** + * `responseFuture.release()`:出现异常会释放信号量 + +* invokeOneway():单向调用,不关注响应结果 + * `request.markOnewayRPC()`:设置单向标记,对端检查标记可知该请是单向请求 + * `boolean acquired = this.semaphoreOneway.tryAcquire(timeoutMillis, TimeUnit.MILLISECONDS)`:获取信号量的许可证,信号量用来**限制单向请求**的数量 + + + + + +*** + + + +#### 处理器类 + +##### 协议设计 + +在 Client 和 Server 之间完成一次消息发送时,需要对发送的消息进行一个协议约定,所以自定义 RocketMQ 的消息协议。在 RocketMQ 中,为了高效地在网络中传输消息和对收到的消息读取,就需要对消息进行编解码,RemotingCommand 这个类在消息传输过程中对所有数据内容的封装,不但包含了所有的数据结构,还包含了编码解码操作 + +| Header字段 | 类型 | Request 说明 | Response 说明 | +| ---------- | ----------------------- | ------------------------------------------------------------ | ------------------------------------------- | +| code | int | 请求操作码,应答方根据不同的请求码进行不同的处理 | 应答响应码,0 表示成功,非 0 则表示各种错误 | +| language | LanguageCode | 请求方实现的语言 | 应答方实现的语言 | +| version | int | 请求方程序的版本 | 应答方程序的版本 | +| opaque | int | 相当于 requestId,在同一个连接上的不同请求标识码,与响应消息中的相对应 | 应答不做修改直接返回 | +| flag | int | 区分是普通 RPC 还是 onewayRPC 的标志 | 区分是普通 RPC 还是 onewayRPC的标志 | +| remark | String | 传输自定义文本信息 | 传输自定义文本信息 | +| extFields | HashMap | 请求自定义扩展信息 | 响应自定义扩展信息 | + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-消息协议.png) + +传输内容主要可以分为以下四部分: + +* 消息长度:总长度,四个字节存储,占用一个 int 类型 + +* 序列化类型&消息头长度:同样占用一个 int 类型,第一个字节表示序列化类型,后面三个字节表示消息头长度 + +* 消息头数据:经过序列化后的消息头数据 + +* 消息主体数据:消息主体的二进制字节数据内容 + + + +官方文档:https://github.com/apache/rocketmq/blob/master/docs/cn/design.md + + + +**** + + + +##### 处理方法 + +NettyServerHandler 类用来处理 Channel 上的事件,在 NettyRemotingServer 启动时注册到 Netty 中,可以处理 RemotingCommand 相关的数据,针对某一种类型的**请求处理** + +```java +class NettyServerHandler extends SimpleChannelInboundHandler { + @Override + protected void channelRead0(ChannelHandlerContext ctx, RemotingCommand msg) throws Exception { + // 服务器处理接受到的请求信息 + processMessageReceived(ctx, msg); + } +} +public void processMessageReceived(ChannelHandlerContext ctx, RemotingCommand msg) throws Exception { + final RemotingCommand cmd = msg; + if (cmd != null) { + // 根据请求的类型进行处理 + switch (cmd.getType()) { + case REQUEST_COMMAND:// 客户端发起的请求,走这里 + processRequestCommand(ctx, cmd); + break; + case RESPONSE_COMMAND:// 客户端响应的数据,走这里【当前类本身是服务器类也是客户端类】 + processResponseCommand(ctx, cmd); + break; + default: + break; + } + } +} +``` + +NettyRemotingAbstract#processRequestCommand:**处理请求的数据** + +* `matched = this.processorTable.get(cmd.getCode())`:根据业务请求码获取 Pair 对象,包含**处理器和线程池资源** + +* `pair = null == matched ? this.defaultRequestProcessor : matched`:未找到处理器则使用缺省处理器 + +* `int opaque = cmd.getOpaque()`:获取请求 ID + +* `Runnable run = new Runnable()`:创建任务对象,任务在提交到线程池后开始执行 + + * `doBeforeRpcHooks()`:RPC HOOK 前置处理 + + * `callback = new RemotingResponseCallback()`:**封装响应客户端的逻辑** + + * `doAfterRpcHooks()`:RPC HOOK 后置处理 + * `if (!cmd.isOnewayRPC())`:条件成立说明不是单向请求,需要结果 + * `response.setOpaque(opaque)`:将请求 ID 设置到 response + * `response.markResponseType()`:**设置当前请求是响应** + * `ctx.writeAndFlush(response)`: **将响应数据交给 Netty IO 线程,完成数据写和刷** + + * `if (pair.getObject1() instanceof AsyncNettyRequestProcessor)`:Nameserver 默认使用 DefaultRequestProcessor 处理器,是一个 AsyncNettyRequestProcessor 子类 + + * `processor = (AsyncNettyRequestProcessor)pair.getObject1()`:获取处理器 + + * `processor.asyncProcessRequest(ctx, cmd, callback)`:异步调用,首先 processRequest,然后 callback 响应客户端 + + `DefaultRequestProcessor.processRequest`:**根据业务码处理请求,执行对应的操作** + + `ClientRemotingProcessor.processRequest`:处理事务回查消息,或者回执消息,需要消费者回执一条消息给生产者 + +* `requestTask = new RequestTask(run, ctx.channel(), cmd)`:将任务对象、通道、请求封装成 RequestTask 对象 + +* `pair.getObject2().submit(requestTask)`:**获取处理器对应的线程池,将 task 提交,从 IO 线程切换到业务线程** + +NettyRemotingAbstract#processResponseCommand:**处理响应的数据** + +* `int opaque = cmd.getOpaque()`:获取请求 ID +* `responseFuture = responseTable.get(opaque)`:**从响应映射表中获取对应的对象** +* `responseFuture.setResponseCommand(cmd)`:设置响应的 Command 对象 +* `responseTable.remove(opaque)`:从映射表中移除对象,代表处理完成 +* `if (responseFuture.getInvokeCallback() != null)`:包含回调对象,异步执行回调对象 +* `responseFuture.putResponse(cmd)`:不包含回调对象,**同步调用时,唤醒等待的业务线程** + +流程:客户端 invokeSync → 服务器的 processRequestCommand → 客户端的 processResponseCommand → 结束 + + + +*** + + + +#### 路由信息 + +##### 信息管理 + +RouteInfoManager 类负责管理路由信息,NamesrvController 的构造方法中创建该类的实例对象,管理服务端的路由数据 + +```java +public class RouteInfoManager { + // Broker 两个小时不活跃,视为离线,被定时任务删除 + private final static long BROKER_CHANNEL_EXPIRED_TIME = 1000 * 60 * 2; + // 读写锁,保证线程安全 + private final ReadWriteLock lock = new ReentrantReadWriteLock(); + // 主题队列数据,一个主题对应多个队列 + private final HashMap> topicQueueTable; + // Broker 数据列表 + private final HashMap brokerAddrTable; + // 集群 + private final HashMap> clusterAddrTable; + // Broker 存活信息 + private final HashMap brokerLiveTable; + // 服务过滤 + private final HashMap/* Filter Server */> filterServerTable; +} +``` + + + +*** + + + +##### 路由注册 + +DefaultRequestProcessor REGISTER_BROKER 方法解析: + +```java +public RemotingCommand registerBroker(ChannelHandlerContext ctx, RemotingCommand request) { + // 创建响应请求的对象,设置为响应类型,【先设置响应的状态码时系统错误码】 + // 反射创建 RegisterBrokerResponseHeader 对象设置到 response.customHeader 属性中 + final RemotingCommand response = RemotingCommand.createResponseCommand(RegisterBrokerResponseHeader.class); + + // 获取出反射创建的 RegisterBrokerResponseHeader 用户自定义header对象。 + final RegisterBrokerResponseHeader responseHeader = (RegisterBrokerResponseHeader) response.readCustomHeader(); + + // 反射创建 RegisterBrokerRequestHeader 对象,并且将 request.extFields 中的数据写入到该对象中 + final RegisterBrokerRequestHeader requestHeader = request.decodeCommandCustomHeader(RegisterBrokerRequestHeader.class); + + // CRC 校验,计算请求中的 CRC 值和请求头中包含的是否一致 + if (!checksum(ctx, request, requestHeader)) { + response.setCode(ResponseCode.SYSTEM_ERROR); + response.setRemark("crc32 not match"); + return response; + } + + TopicConfigSerializeWrapper topicConfigWrapper; + if (request.getBody() != null) { + // 【解析请求体 body】,解码出来的数据就是当前机器的主题信息 + topicConfigWrapper = TopicConfigSerializeWrapper.decode(request.getBody(), TopicConfigSerializeWrapper.class); + } else { + topicConfigWrapper = new TopicConfigSerializeWrapper(); + topicConfigWrapper.getDataVersion().setCounter(new AtomicLong(0)); + topicConfigWrapper.getDataVersion().setTimestamp(0); + } + + // 注册方法 + // 参数1 集群、参数2:节点ip地址、参数3:brokerName、参数4:brokerId 注意brokerId=0的节点为主节点 + // 参数5:ha节点ip地址、参数6当前节点主题信息、参数7:过滤服务器列表、参数8:当前服务器和客户端通信的channel + RegisterBrokerResult result = this.namesrvController.getRouteInfoManager().registerBroker(..); + + // 将结果信息 写到 responseHeader 中 + responseHeader.setHaServerAddr(result.getHaServerAddr()); + responseHeader.setMasterAddr(result.getMasterAddr()); + // 获取 kv配置,写入 response body 中,【kv 配置是顺序消息相关的】 + byte[] jsonValue = this.namesrvController.getKvConfigManager().getKVListByNamespace(NamesrvUtil.NAMESPACE_ORDER_TOPIC); + response.setBody(jsonValue); + + // code 设置为 SUCCESS + response.setCode(ResponseCode.SUCCESS); + response.setRemark(null); + // 返回 response ,【返回的 response 由 callback 对象处理】 + return response; +} +``` + +RouteInfoManager#registerBroker:注册 Broker 的信息 + +* `RegisterBrokerResult result = new RegisterBrokerResult()`:返回结果的封装对象 + +* `this.lock.writeLock().lockInterruptibly()`:加写锁后**同步执行** + +* `brokerNames = this.clusterAddrTable.get(clusterName)`:获取当前集群上的 Broker 名称列表,是空就新建列表 + +* `brokerNames.add(brokerName)`:将当前 Broker 名字加入到集群列表 + +* `brokerData = this.brokerAddrTable.get(brokerName)`:获取当前 Broker 的 brokerData,是空就新建放入映射表 + +* `brokerAddrsMap = brokerData.getBrokerAddrs()`:获取当前 Broker 的物理节点 map 表,进行遍历,如果物理节点角色发生变化(slave → master),先将旧数据从物理节点 map 中移除,然后重写放入,**保证节点的唯一性** + +* `if (null != topicConfigWrapper && MixAll.MASTER_ID == brokerId)`:Broker 上的 Topic 不为 null,并且当前物理节点是 Broker 上的 master 节点 + + `tcTable = topicConfigWrapper.getTopicConfigTable()`:获取当前 Broker 信息中的主题映射表 + + `if (tcTable != null)`:映射表不空就加入或者更新到 Namesrv 内 + +* ` prevBrokerLiveInfo = this.brokerLiveTable.put(brokerAddr)`:添加**当前节点的 BrokerLiveInfo** ,返回上一次心跳时当前 Broker 节点的存活对象数据。**NamesrvController 中的定时任务会扫描映射表 brokerLiveTable** + + ```java + BrokerLiveInfo prevBrokerLiveInfo = this.brokerLiveTable.put(brokerAddr, new BrokerLiveInfo( + System.currentTimeMillis(),topicConfigWrapper.getDataVersion(), channel,haServerAddr)); + ``` + +* `if (MixAll.MASTER_ID != brokerId)`:当前 Broker 不是 master 节点,**获取主节点的信息**设置到结果对象 + +* `this.lock.writeLock().unlock()`:释放写锁 + + + + + +**** + + + +### Broker + +#### MappedFile + +##### 成员属性 + +MappedFile 类是最基础的存储类,继承自 ReferenceResource 类,用来**保证线程安全** + +MappedFile 类成员变量: + +* 内存相关: + + ```java + public static final int OS_PAGE_SIZE = 1024 * 4;// 内存页大小:默认是 4k + private AtomicLong TOTAL_MAPPED_VIRTUAL_MEMORY; // 当前进程下所有的 mappedFile 占用的总虚拟内存大小 + private AtomicInteger TOTAL_MAPPED_FILES; // 当前进程下所有的 mappedFile 个数 + ``` + +* 数据位点: + + ```java + protected final AtomicInteger wrotePosition; // 当前 mappedFile 的数据写入点 + protected final AtomicInteger committedPosition;// 当前 mappedFile 的数据提交点 + private final AtomicInteger flushedPosition; // 数据落盘位点,在这之前的数据是持久化的安全数据 + // flushedPosition-wrotePosition 之间的数据属于脏页 + ``` + +* 文件相关:CL 是 CommitLog,CQ 是 ConsumeQueue + + ```java + private String fileName; // 文件名称,CL和CQ文件名是【第一条消息的物理偏移量】,索引文件是【年月日时分秒】 + private long fileFromOffset;// 文件名转long,代表该对象的【起始偏移量】 + private File file; // 文件对象 + ``` + + **MF 中以物理偏移量作为文件名,可以更好的寻址和进行判断** + +* 内存映射: + + ```java + protected FileChannel fileChannel; // 文件通道 + private MappedByteBuffer mappedByteBuffer; // 内存映射缓冲区,访问虚拟内存 + ``` + +ReferenceResource 类成员变量: + +* 引用数量:当 `refCount <= 0` 时,表示该资源可以释放了,没有任何其他程序依赖它了,用原子类保证线程安全 + + ```java + protected final AtomicLong refCount = new AtomicLong(1); // 初始值为 1 + ``` + +* 存活状态:表示资源的存活状态 + + ```java + protected volatile boolean available = true; + ``` + +* 是否清理:默认值 false,当执行完子类对象的 cleanup() 清理方法后,该值置为 true ,表示资源已经全部释放 + + ```java + protected volatile boolean cleanupOver = false; + ``` + +* 第一次关闭资源的时间:用来记录超时时间 + + ```java + private volatile long firstShutdownTimestamp = 0; + ``` + + + +*** + + + +##### 成员方法 + +MappedFile 类核心方法: + +* appendMessage():提供上层向内存映射中追加消息的方法,消息如何追加由 AppendMessageCallback 控制 + + ```java + // 参数一:消息 参数二:追加消息回调 + public AppendMessageResult appendMessage(MessageExtBrokerInner msg, AppendMessageCallback cb) + ``` + + ```java + // 将字节数组写入到文件通道 + public boolean appendMessage(final byte[] data) + ``` + +* flush():刷盘接口,参数 flushLeastPages 代表刷盘的最小页数 ,等于 0 时属于强制刷盘;> 0 时需要脏页(计算方法在数据位点)达到该值才进行物理刷盘;文件写满时强制刷盘 + + ```java + public int flush(final int flushLeastPages) + ``` + +* selectMappedBuffer():该方法以 pos 为开始位点 ,到有效数据为止,创建一个切片 ByteBuffer 作为数据副本,供业务访问数据 + + ```java + public SelectMappedBufferResult selectMappedBuffer(int pos) + ``` + +* destroy():销毁映射文件对象,并删除关联的系统文件,参数是强制关闭资源的时间 + + ```java + public boolean destroy(final long intervalForcibly) + ``` + +* cleanup():**释放堆外内存**,更新总虚拟内存和总内存映射文件数 + + ```java + public boolean cleanup(final long currentRef) + ``` + +* warmMappedFile():内存预热,当要新建的 MappedFile 对象大于 1g 时执行该方法。mappedByteBuffer 已经通过mmap映射,此时操作系统中只是记录了该文件和该 Buffer 的映射关系,而并没有映射到物理内存中,对该 MappedFile 的每个 Page Cache 进行写入一个字节分配内存,**将映射文件全部加载到内存** + + ```java + public void warmMappedFile(FlushDiskType type, int pages) + ``` + +* mlock():锁住指定的内存区域避免被操作系统调到 swap 空间,减少了缺页异常的产生 + + ```java + public void mlock() + ``` + + swap space 是磁盘上的一块区域,可以是一个分区或者一个文件或者是组合。当系统物理内存不足时,Linux 会将内存中不常访问的数据保存到 swap 区域上,这样系统就可以有更多的物理内存为各个进程服务,而当系统需要访问 swap 上存储的内容时,需要通过**缺页中断**将 swap 上的数据加载到内存中 + +ReferenceResource 类核心方法: + +* hold():增加引用记数 refCount,方法加锁 + + ```java + public synchronized boolean hold() + ``` + +* shutdown():关闭资源,参数代表强制关闭资源的时间间隔 + + ```java + // 系统当前时间 - firstShutdownTimestamp 时间 > intervalForcibly 进行【强制关闭】 + public void shutdown(final long intervalForcibly) + ``` + +* release():引用计数减 1,当 refCount 为 0 时,调用子类的 cleanup 方法 + + ```java + public void release() + ``` + + + + + + +*** + + + +#### MapQueue + +##### 成员属性 + +MappedFileQueue 用来管理 MappedFile 文件 + +成员变量: + +* 管理目录:CommitLog 是 `../store/commitlog`, ConsumeQueue 是 `../store/xxx_topic/0` + + ```java + private final String storePath; + ``` + +* 文件属性: + + ```java + private final int mappedFileSize; // 目录下每个文件大小,CL文件默认 1g,CQ文件 默认 600w字节 + private final CopyOnWriteArrayList mappedFiles; //目录下的每个 mappedFile 都加入该集合 + ``` + +* 数据位点: + + ```java + private long flushedWhere = 0; // 目录的刷盘位点,值为 mf.fileName + mf.wrotePosition + private long committedWhere = 0; // 目录的提交位点 + ``` + +* 消息存储: + + ```java + private volatile long storeTimestamp = 0; // 当前目录下最后一条 msg 的存储时间 + ``` + +* 创建服务:新建 MappedFile 实例,继承自 ServiceThread 是一个任务对象,run 方法用来创建实例 + + ```java + private final AllocateMappedFileService allocateMappedFileService; + ``` + + + +*** + + + +##### 成员方法 + +核心方法: + +* load():Broker 启动时,加载本地磁盘数据,该方法读取 storePath 目录下的文件,创建 MappedFile 对象放入集合内 + + ```java + public boolean load() + ``` + +* getLastMappedFile():获取当前正在顺序写入的 MappedFile 对象,如果最后一个 MappedFile 写满了,或者不存在 MappedFile 对象,则创建新的 MappedFile + + ```java + // 参数一:文件起始偏移量;参数二:当list为空时,是否新建 MappedFile + public MappedFile getLastMappedFile(final long startOffset, boolean needCreate) + ``` + +* flush():根据 flushedWhere 属性查找合适的 MappedFile,调用该 MappedFile 的落盘方法,并更新全局的 flushedWhere + + ```java + //参数:0 表示强制刷新, > 0 脏页数据必须达到 flushLeastPages 才刷新 + public boolean flush(final int flushLeastPages) + ``` + +* findMappedFileByOffset():根据偏移量查询对象 + + ```java + public MappedFile findMappedFileByOffset(final long offset, final boolean returnFirstOnNotFound) + ``` + +* deleteExpiredFileByTime():CL 删除过期文件,根据文件的保留时长决定是否删除 + + ```java + // 参数一:过期时间; 参数二:删除两个文件之间的时间间隔; 参数三:mf.destory传递的参数; 参数四:true 强制删除 + public int deleteExpiredFileByTime(final long expiredTime,final int deleteFilesInterval, final long intervalForcibly, final boolean cleanImmediately) + ``` + +* deleteExpiredFileByOffset():CQ 删除过期文件,遍历每个 MF 文件,获取当前文件最后一个数据单元的物理偏移量,小于 offset 说明当前 MF 文件内都是过期数据 + + ```java + // 参数一:consumeLog 目录下最小物理偏移量,就是第一条消息的 offset; + // 参数二:ConsumerQueue 文件内每个数据单元固定大小 + public int deleteExpiredFileByOffset(long offset, int unitSize) + ``` + + + + + + +*** + + + +#### CommitLog + +##### 成员属性 + +成员变量: + +* 魔数: + + ```java + public final static int MESSAGE_MAGIC_CODE = -626843481; // 消息的第一个字段是大小,第二个字段就是魔数 + protected final static int BLANK_MAGIC_CODE = -875286124; // 文件尾消息的魔法值 + ``` + +* MappedFileQueue:用于管理 `../store/commitlog` 目录下的文件 + + ```java + protected final MappedFileQueue mappedFileQueue; + ``` + +* 存储服务: + + ```java + protected final DefaultMessageStore defaultMessageStore; // 存储模块对象,上层服务 + private final FlushCommitLogService flushCommitLogService; // 刷盘服务,默认实现是异步刷盘 + ``` + +* 回调器:控制消息的哪些字段添加到 MappedFile + + ```java + private final AppendMessageCallback appendMessageCallback; + ``` + +* 队列偏移量字典表:key 是主题队列 id,value 是偏移量 + + ```java + protected HashMap topicQueueTable = new HashMap(1024); + ``` + +* 锁相关: + + ```java + private volatile long beginTimeInLock = 0; // 写数据时加锁的开始时间 + protected final PutMessageLock putMessageLock; // 写锁,两个实现类:自旋锁和重入锁 + ``` + + 因为发送消息是需要持久化的,在 Broker 端持久化时会获取该锁,**保证发送的消息的线程安全** + +构造方法: + +* 有参构造: + + ```java + public CommitLog(final DefaultMessageStore defaultMessageStore) { + // 创建 MappedFileQueue 对象 + // 参数1:../store/commitlog; 参数2:【1g】; 参数3:allocateMappedFileService + this.mappedFileQueue = new MappedFileQueue(...); + // 默认 异步刷盘,创建这个对象 + this.flushCommitLogService = new FlushRealTimeService(); + // 控制消息哪些字段追加到 mappedFile,【消息最大是 4M】 + this.appendMessageCallback = new DefaultAppendMessageCallback(...); + // 默认使用自旋锁 + this.putMessageLock = ...; + } + ``` + + + +*** + + + +##### 成员方法 + +CommitLog 类核心方法: + +* start():会启动刷盘服务 + + ```java + public void start() + ``` + +* shutdown():关闭刷盘服务 + + ```java + public void shutdown() + ``` + +* load():加载 CommitLog 目录下的文件 + + ```java + public boolean load() + ``` + +* getMessage():根据 offset 查询单条信息,返回的结果对象内部封装了一个 ByteBuffer,该 Buffer 表示 `[offset, offset + size]` 区间的 MappedFile 的数据 + + ```java + public SelectMappedBufferResult getMessage(final long offset, final int size) + ``` + +* deleteExpiredFile():删除过期文件,方法由 DefaultMessageStore 的定时任务调用 + + ```java + public int deleteExpiredFile() + ``` + +* asyncPutMessage():**存储消息** + + ```java + public CompletableFuture asyncPutMessage(final MessageExtBrokerInner msg) + ``` + + * `msg.setStoreTimestamp(System.currentTimeMillis())`:设置存储时间,后面获取到写锁后这个事件会重写 + * `msg.setBodyCRC(UtilAll.crc32(msg.getBody()))`:获取消息的 CRC 值 + * `topic、queueId`:获取主题和队列 ID + * `if (msg.getDelayTimeLevel() > 0) `:**获取消息的延迟级别,这里是延迟消息实现的关键** + * `topic = TopicValidator.RMQ_SYS_SCHEDULE_TOPIC`:**修改消息的主题为 `SCHEDULE_TOPIC_XXXX`** + * `queueId = ScheduleMessageService.delayLevel2QueueId()`:**队列 ID 为延迟级别 -1** + * `MessageAccessor.putProperty`:**将原来的消息主题和 ID 存入消息的属性 `REAL_TOPIC` 中** + * `mappedFile = this.mappedFileQueue.getLastMappedFile()`:获取当前顺序写的 MappedFile 对象 + * `putMessageLock.lock()`:**获取写锁** + * `msg.setStoreTimestamp(beginLockTimestamp)`:设置消息的存储时间为获取锁的时间 + * `if (null == mappedFile || mappedFile.isFull())`:文件写满了创建新的 MF 对象 + * `result = mappedFile.appendMessage(msg, this.appendMessageCallback)`:**消息追加**,核心逻辑在回调器类 + * `putMessageLock.unlock()`:释放写锁 + * `this.defaultMessageStore.unlockMappedFile(..)`:将 MappedByteBuffer 从 lock 切换为 unlock 状态 + * `putMessageResult = new PutMessageResult(PutMessageStatus.PUT_OK, result)`:结果封装 + * `flushResultFuture = submitFlushRequest(result, msg)`:**唤醒刷盘线程** + * `replicaResultFuture = submitReplicaRequest(result, msg)`:HA 消息同步 + +* recoverNormally():正常关机时的恢复方法,存储模块启动时**先恢复所有的 ConsumeQueue 数据,再恢复 CommitLog 数据** + + ```java + // 参数表示恢复阶段 ConsumeQueue 中已知的最大的消息 offset + public void recoverNormally(long maxPhyOffsetOfConsumeQueue) + ``` + + * `int index = mappedFiles.size() - 3`:从倒数第三个 file 开始向后恢复 + + * `dispatchRequest = this.checkMessageAndReturnSize()`:每次从切片内解析出一条 msg 封装成 DispatchRequest 对象 + + * `size = dispatchRequest.getMsgSize()`:获取消息的大小,检查 DispatchRequest 对象的状态 + + 情况 1:正常数据,则 `mappedFileOffset += size` + + 情况 2:文件尾数据,处理下一个文件,mappedFileOffset 置为 0,magic_code 表示文件尾 + + * `processOffset += mappedFileOffset`:计算出正确的数据存储位点,并设置 MappedFileQueue 的目录刷盘位点 + + * `this.mappedFileQueue.truncateDirtyFiles(processOffset)`:调整 MFQ 中文件的刷盘位点 + + * `if (maxPhyOffsetOfConsumeQueue >= processOffset)`:删除冗余数据,将超过全局位点的 CQ 下的文件删除,将包含全局位点的 CQ 下的文件重新定位 + +* recoverAbnormally():异常关机时的恢复方法 + + ```java + public void recoverAbnormally(long maxPhyOffsetOfConsumeQueue) + ``` + + * `int index = mappedFiles.size() - 1`:从尾部开始遍历 MFQ,验证 MF 的第一条消息,找到第一个验证通过的文件对象 + * `dispatchRequest = this.checkMessageAndReturnSize()`:每次解析出一条 msg 封装成 DispatchRequest 对象 + * `this.defaultMessageStore.doDispatch(dispatchRequest)`:**重建 ConsumerQueue 和 Index,避免上次异常停机导致 CQ 和 Index 与 CommitLog 不对齐** + * 剩余逻辑与正常关机的恢复方法相似 + + + +*** + + + +##### 服务线程 + +AppendMessageCallback 消息追加服务实现类为 DefaultAppendMessageCallback + +* doAppend(): + + ```java + public AppendMessageResult doAppend() + ``` + + * `long wroteOffset = fileFromOffset + byteBuffer.position()`:消息写入的位置,物理偏移量 phyOffset + * `String msgId`:**消息 ID,规则是客户端 IP + 消息偏移量 phyOffset** + * `byte[] topicData`:序列化消息,将消息的字段压入到 msgStoreItemMemory 这个 Buffer 中 + * `byteBuffer.put(this.msgStoreItemMemory.array(), 0, msgLen)`:将 msgStoreItemMemory 中的数据写入 MF 对象的内存映射的 Buffer 中,数据还没落盘 + * `AppendMessageResult result`:构造结果对象,包括存储位点、是否成功、队列偏移量等信息 + * `CommitLog.this.topicQueueTable.put(key, ++queueOffset)`:更新队列偏移量 + +FlushRealTimeService 刷盘 CL 数据,默认是异步刷盘类 FlushRealTimeService + +* run():运行方法 + + ```java + public void run() + ``` + + * `while (!this.isStopped())`:stopped为 true 才跳出循环 + + * `boolean flushCommitLogTimed`:控制线程的休眠方式,默认是 false,使用 `CountDownLatch.await()` 休眠,设置为 true 时使用 `Thread.sleep()` 休眠 + + * `int interval`:获取配置中的刷盘时间间隔 + + * `int flushPhysicQueueLeastPages`:**获取最小刷盘页数,默认是 4 页**,脏页达到指定页数才刷盘 + + * `int flushPhysicQueueThoroughInterval`:获取强制刷盘周期,默认是 10 秒,达到周期后强制刷盘,不考虑脏页 + + * `if (flushCommitLogTimed)`:**休眠逻辑**,避免 CPU 占用太长时间,导致无法执行其他更紧急的任务 + + * `CommitLog.this.mappedFileQueue.flush(flushPhysicQueueLeastPages)`:**刷盘** + + * `for (int i = 0; i < RETRY_TIMES_OVER && !result; i++)`:stopped 停止标记为 true 时,需要确保所有的数据都已经刷盘,所以此处尝试 10 次强制刷盘, + + `result = CommitLog.this.mappedFileQueue.flush(0)`:**强制刷盘** + +同步刷盘类 GroupCommitService + +* run():运行方法 + + ```java + public void run() + ``` + + * `while (!this.isStopped())`:stopped为 true 才跳出循环 + + `this.waitForRunning(10)`:线程休眠 10 毫秒,最后调用 `onWaitEnd()` 进行**请求的交换** `swapRequests()` + + `this.doCommit()`:做提交逻辑 + + * `if (!this.requestsRead.isEmpty()) `:读请求集合不为空 + + `for (GroupCommitRequest req : this.requestsRead)`:遍历所有的读请求,请求中的属性: + + * `private final long nextOffset`:本条消息存储之后,下一条消息开始的 offset + * `private CompletableFuture flushOKFuture`:Future 对象 + + `boolean flushOK = ...`:当前请求关注的数据是否全部落盘,**落盘成功唤醒消费者线程** + + `for (int i = 0; i < 2 && !flushOK; i++)`:尝试进行两次强制刷盘,保证刷盘成功 + + `CommitLog.this.mappedFileQueue.flush(0)`:强制刷盘 + + `req.wakeupCustomer(flushOK ? ...)`:设置 Future 结果,在 Future 阻塞的线程在这里会被唤醒 + + `this.requestsRead.clear()`:清理 reqeustsRead 列表,方便交换时成为 requestsWrite 使用 + + * `else`:读请求集合为空 + + `CommitLog.this.mappedFileQueue.flush(0)`:强制刷盘 + + * `this.swapRequests()`:交换读写请求 + + * `this.doCommit()`:交换后做一次提交 + + + +**** + + + +#### ConsQueue + +##### 成员属性 + +ConsumerQueue 是消息消费队列,存储消息在 CommitLog 的索引,便于快速定位消息 + +成员变量: + +* 数据单元:ConsumerQueueData 数据单元的固定大小是 20 字节,默认申请 20 字节的缓冲区 + + ```java + public static final int CQ_STORE_UNIT_SIZE = 20; + ``` + +* 文件管理: + + ```java + private final MappedFileQueue mappedFileQueue; // 文件管理器,管理 CQ 目录下的文件 + private final String storePath; // 目录,比如../store/consumequeue/xxx_topic/0 + private final int mappedFileSize; // 每一个 CQ 存储文件大小,默认 20 * 30w = 600w byte + ``` + +* 存储主模块:上层的对象 + + ```java + private final DefaultMessageStore defaultMessageStore; + ``` + +* 消息属性: + + ```java + private final String topic; // CQ 主题 + private final int queueId; // CQ 队列,每一个队列都有一个 ConsumeQueue 对象进行管理 + private final ByteBuffer byteBufferIndex; // 临时缓冲区,插新的 CQData 时使用 + private long maxPhysicOffset = -1; // 当前ConsumeQueue内存储的最大消息物理偏移量 + private volatile long minLogicOffset = 0; // 当前ConsumeQueue内存储的最小消息物理偏移量 + ``` + +构造方法: + +* 有参构造: + + ```java + public ConsumeQueue() { + // 申请了一个 20 字节大小的 临时缓冲区 + this.byteBufferIndex = ByteBuffer.allocate(CQ_STORE_UNIT_SIZE); + } + ``` + + + +*** + + + +##### 成员方法 + +ConsumeQueue 启动阶段方法: + +* load():第一步,加载 storePath 目录下的文件,初始化 MappedFileQueue +* recover():第二步,恢复 ConsumeQueue 数据 + * 从倒数第三个 MF 文件开始向后遍历,依次读取 MF 中 20 个字节的 CQData 数据,检查 offset 和 size 是否是有效数据 + * 找到无效的 CQData 的位点,该位点就是 CQ 的刷盘点和数据顺序写入点 + * 删除无效的 MF 文件,调整当前顺序写的 MF 文件的数据位点 + +其他方法: + +* truncateDirtyLogicFiles():CommitLog 恢复阶段调用,将 ConsumeQueue 有效数据文件与 CommitLog 对齐,将超出部分的数据文删除掉,并调整当前文件的数据位点。Broker 启动阶段先恢复 CQ 的数据,再恢复 CL 数据,但是**数据要以 CL 为基准** + + ```java + // 参数是最大消息物理偏移量 + public void truncateDirtyLogicFiles(long phyOffet) + ``` + +* flush():刷盘,调用 MFQ 的刷盘方法 + + ```java + public boolean flush(final int flushLeastPages) + ``` + +* deleteExpiredFile():删除过期文件,将小于 offset 的所有 MF 文件删除,offset 是 CommitLog 目录下最小的物理偏移量,小于该值的 CL 文件已经没有了,所以 CQ 也没有存在的必要 + + ```java + public int deleteExpiredFile(long offset) + ``` + +* putMessagePositionInfoWrapper():**向 CQ 中追加 CQData 数据**,由存储主模块 DefaultMessageStore 内部的异步线程调用,负责构建 ConsumeQueue 文件和 Index 文件的,该线程会持续关注 CommitLog 文件,当 CommitLog 文件内有新数据写入,就读出来封装成 DispatchRequest 对象,转发给 ConsumeQueue 或者 IndexService + + ```java + public void putMessagePositionInfoWrapper(DispatchRequest request) + ``` + +* getIndexBuffer():转换 startIndex 为 offset,获取包含该 offset 的 MappedFile 文件,读取 `[offset%maxSize, mfPos]` 范围的数据,包装成结果对象返回 + + ```java + public SelectMappedBufferResult getIndexBuffer(final long startIndex) + ``` + + + +**** + + + +#### IndexFile + +##### 成员属性 + +IndexFile 类成员属性 + +* 哈希: + + ```java + private static int hashSlotSize = 4; // 每个 hash 桶的大小是 4 字节,【用来存放索引的编号】 + private final int hashSlotNum; // hash 桶的个数,默认 500 万 + ``` + +* 索引: + + ```java + private static int indexSize = 20; // 每个 index 条目的大小是 20 字节 + private static int invalidIndex = 0; // 无效索引编号:0 特殊值 + private final int indexNum; // 默认值:2000w + private final IndexHeader indexHeader; // 索引头 + ``` + +* 映射: + + ```java + private final MappedFile mappedFile; // 【索引文件使用的 MF 文件】 + private final FileChannel fileChannel; // 文件通道 + private final MappedByteBuffer mappedByteBuffer;// 从 MF 中获取的内存映射缓冲区 + ``` + +构造方法: + +* 有参构造 + + ```java + // endPhyOffset 上个索引文件 最后一条消息的 物理偏移量 + // endTimestamp 上个索引文件 最后一条消息的 存储时间 + public IndexFile(final String fileName, final int hashSlotNum, final int indexNum, + final long endPhyOffset, final long endTimestamp) throws IOException { + // 文件大小 40 + 500w * 4 + 2000w * 20 + int fileTotalSize = + IndexHeader.INDEX_HEADER_SIZE + (hashSlotNum * hashSlotSize) + (indexNum * indexSize); + // 创建 mf 对象,会在disk上创建文件 + this.mappedFile = new MappedFile(fileName, fileTotalSize); + // 创建 索引头对象,传递 索引文件mf 的切片数据 + this.indexHeader = new IndexHeader(byteBuffer); + //... + } + ``` + + + +**** + + + +##### 成员方法 + +IndexFile 类方法 + +* load():加载 IndexHeader + + ```java + public void load() + ``` + +* flush():MappedByteBuffer 内的数据强制落盘 + + ```java + public void flush() + ``` + +* isWriteFull():检查当前的 IndexFile 已写索引数是否 >= indexNum,达到该值则当前 IndexFile 不能继续追加 IndexData 了 + + ```java + public boolean isWriteFull() + ``` + +* destroy():删除文件时使用的方法 + + ```java + public boolean destroy(final long intervalForcibly) + ``` + +* putKey():添加索引数据,解决哈希冲突使用**头插法** + + ```java + // 参数一:消息的 key,uniq_key 或者 keys="aaa bbb ccc" 会分别为 aaa bbb ccc 创建索引 + // 参数二:消息的物理偏移量; 参数三:消息存储时间 + public boolean putKey(final String key, final long phyOffset, final long storeTimestamp) + ``` + + * `int slotPos = keyHash % this.hashSlotNum`:对 key 计算哈希后,取模得到对应的哈希槽 slot 下标,然后计算出哈希槽的存储位置 absSlotPos + * `int slotValue = this.mappedByteBuffer.getInt(absSlotPos)`:获取槽中的值,如果是无效值说明没有哈希冲突 + * `timeDiff = timeDiff / 1000`:计算当前 msg 存储时间减去索引文件内第一条消息存储时间的差值,转化为秒进行存储 + * `int absIndexPos`:计算当前索引数据存储的位置,开始填充索引数据到对应的位置 + * `this.mappedByteBuffer.putInt(absIndexPos + 4 + 8 + 4, slotValue)`:**hash 桶的原值,头插法** + * `this.mappedByteBuffer.putInt(absSlotPos, this.indexHeader...)`:在 slot 放入当前索引的索引编号 + * `if (this.indexHeader.getIndexCount() <= 1)`:索引文件插入的第一条数据,需要设置起始偏移量和存储时间 + * `if (invalidIndex == slotValue)`:没有哈希冲突,说明占用了一个新的 hash slot + * `this.indexHeader`:设置索引头的相关属性 + +* selectPhyOffset():从索引文件查询消息的物理偏移量 + + ```java + // 参数一:查询结果全部放到该list内; 参数二:查询key; 参数三:结果最大数限制; 参数四五:时间范围 + public void selectPhyOffset(final List phyOffsets, final String key, final int maxNum,final long begin, final long end, boolean lock) + ``` + + * `if (this.mappedFile.hold())`: MF 的引用记数 +1,查询期间 MF 资源**不能被释放** + * `int slotValue = this.mappedByteBuffer.getInt(absSlotPos)`:获取槽中的值,可能是无效值或者索引编号,如果是无效值说明查询未命中 + * `int absIndexPos`:计算出索引编号对应索引数据的开始位点 + * `this.mappedByteBuffer`:读取索引数据 + * `long timeRead = this.indexHeader.getBeginTimestamp() + timeDiff`:计算出准确的存储时间 + * `boolean timeMatched = (timeRead >= begin) && (timeRead <= end)`:时间范围的匹配 + * `phyOffsets.add(phyOffsetRead)`:将命中的消息索引的消息偏移量加入到 list 集合中 + * `nextIndexToRead = prevIndexRead`:遍历前驱节点 + + + +**** + + + +#### IndexServ + +##### 成员属性 + +IndexService 类用来管理 IndexFile 文件 + +成员变量: + +* 存储主模块: + + ```java + private final DefaultMessageStore defaultMessageStore; + ``` + +* 索引文件存储目录:`../store/index` + + ```java + private final String storePath; + ``` + +* 索引对象集合:目录下的每个文件都有一个 IndexFile 对象 + + ```java + private final ArrayList indexFileList = new ArrayList(); + ``` + +* 索引文件: + + ```java + private final int hashSlotNum; // 每个索引文件包含的 哈希桶数量 :500w + private final int indexNum; // 每个索引文件包含的 索引条目数量 :2000w + ``` + + + +*** + + + +##### 成员方法 + +* load():加载 storePath 目录下的文件,为每个文件创建一个 IndexFile 实例对象,并加载 IndexHeader 信息 + + ```java + public boolean load(final boolean lastExitOK) + ``` + +* deleteExpiredFile():删除过期索引文件 + + ```java + // 参数 offset 表示 CommitLog 内最早的消息的 phyOffset + public void deleteExpiredFile(long offset) + ``` + + * `this.readWriteLock.readLock().lock()`:加锁判断 + * `long endPhyOffset = this.indexFileList.get(0).getEndPhyOffset()`:获取目录中第一个文件的结束偏移量 + * `if (endPhyOffset < offset)`:索引目录内存在过期的索引文件,并且当前的 IndexFile 都是过期的数据 + * `for (int i = 0; i < (files.length - 1); i++)`:遍历文件列表,删除过期的文件 + +* buildIndex():存储主模块 DefaultMessageStore 内部的异步线程调用,构建 Index 数据 + + ```java + public void buildIndex(DispatchRequest req) + ``` + + * `indexFile = retryGetAndCreateIndexFile()`:获取或者创建顺序写的索引文件对象 + + * `buildKey(topic, req.getUniqKey())`:**构建索引 key,`topic + # + uniqKey`** + + * `indexFile = putKey()`:插入索引文件 + + * `if (keys != null && keys.length() > 0)`:消息存在自定义索引 keys + + `for (int i = 0; i < keyset.length; i++)`:遍历每个索引,为每个 key 调用一次 putKey + +* getAndCreateLastIndexFile():获取当前顺序写的 IndexFile,没有就创建 + + ```java + public IndexFile getAndCreateLastIndexFile() + ``` + + + +*** + + + +#### HAService + +##### HAService + +###### Service + +HAService 类成员变量: + +* 主节点属性: + + ```java + // master 节点当前有多少个 slave 节点与其进行数据同步 + private final AtomicInteger connectionCount = new AtomicInteger(0); + // master 节点会给每个发起连接的 slave 节点的通道创建一个 HAConnection,【控制 master 端向 slave 端传输数据】 + private final List connectionList = new LinkedList<>(); + // master 向 slave 节点推送的最大的 offset,表示数据同步的进度 + private final AtomicLong push2SlaveMaxOffset = new AtomicLong(0) + ``` + +* 内部类属性: + + ```java + // 封装了绑定服务器指定端口,监听 slave 的连接的逻辑,没有使用 Netty,使用了原生态的 NIO 去做 + private final AcceptSocketService acceptSocketService; + // 控制生产者线程阻塞等待的逻辑 + private final GroupTransferService groupTransferService; + // slave 节点的客户端对象,【slave 端才会正常运行该实例】 + private final HAClient haClient; + ``` + +* 线程通信对象: + + ```java + private final WaitNotifyObject waitNotifyObject = new WaitNotifyObject() + ``` + +成员方法: + +* start():启动高可用服务 + + ```java + public void start() throws Exception { + // 监听从节点 + this.acceptSocketService.beginAccept(); + // 启动监听服务 + this.acceptSocketService.start(); + // 启动转移服务 + this.groupTransferService.start(); + // 启动从节点客户端实例 + this.haClient.start(); + } + ``` + + + +**** + + + +###### Accept + +AcceptSocketService 类用于**监听从节点的连接**,创建 HAConnection 连接对象 + +成员变量: + +* 端口信息:Master 绑定监听的端口信息 + + ```java + private final SocketAddress socketAddressListen; + ``` + +* 服务端通道: + + ```java + private ServerSocketChannel serverSocketChannel; + ``` + +* 多路复用器: + + ```java + private Selector selector; + ``` + +成员方法: + +* beginAccept():开始监听连接,**NIO** 标准模板 + + ```java + public void beginAccept() + ``` + +* run():服务启动 + + ```java + public void run() + ``` + + * `this.selector.select(1000)`:多路复用器阻塞获取就绪的通道,最多等待 1 秒钟 + * `Set selected = this.selector.selectedKeys()`:获取选择器中所有注册的通道中已经就绪好的事件 + * `for (SelectionKey k : selected)`:遍历所有就绪的事件 + * `if ((k.readyOps() & SelectionKey.OP_ACCEPT) != 0)`:说明 `OP_ACCEPT` 事件就绪 + * `SocketChannel sc = ((ServerSocketChannel) k.channel()).accept()`:**获取到客户端连接的通道** + * `HAConnection conn = new HAConnection(HAService.this, sc)`:**为每个连接 master 服务器的 slave 创建连接对象** + * `conn.start()`:**启动 HAConnection 对象**,内部启动两个服务为读数据服务、写数据服务 + * `HAService.this.addConnection(conn)`:加入到 HAConnection 集合内 + + + +**** + + + +###### Group + +GroupTransferService 用来控制数据同步 + +成员方法: + +* doWaitTransfer():等待主从数据同步 + + ```java + private void doWaitTransfer() + ``` + + * `if (!this.requestsRead.isEmpty())`:读请求不为空 + * `boolean transferOK = HAService.this.push2SlaveMaxOffset... >= req.getNextOffset()`:**主从同步是否完成** + * `req.wakeupCustomer(transferOK ? ...)`:唤醒消费者 + * `this.requestsRead.clear()`:清空读请求 + +* swapRequests():交换读写请求 + + ```java + private void swapRequests() + ``` + + + + + +**** + + + +##### HAClient + +###### 成员属性 + +HAClient 是 slave 端运行的代码,用于**和 master 服务器建立长连接**,上报本地同步进度,消费服务器发来的 msg 数据 + +成员变量: + +* 缓冲区: + + ```java + private static final int READ_MAX_BUFFER_SIZE = 1024 * 1024 * 4; // 默认大小:4 MB + private ByteBuffer byteBufferRead = ByteBuffer.allocate(READ_MAX_BUFFER_SIZE); + private ByteBuffer byteBufferBackup = ByteBuffer.allocate(READ_MAX_BUFFER_SIZE); + ``` + +* 主节点地址:格式为 `ip:port` + + ```java + private final AtomicReference masterAddress = new AtomicReference<>() + ``` + +* NIO 属性: + + ```java + private final ByteBuffer reportOffset; // 通信使用NIO,所以消息使用块传输,上报 slave offset 使用 + private SocketChannel socketChannel; // 客户端与 master 的会话通道 + private Selector selector; // 多路复用器 + ``` + +* 通信时间:上次会话通信时间,用于控制 socketChannel 是否关闭的 + + ````java + private long lastWriteTimestamp = System.currentTimeMillis(); + ```` + +* 进度信息: + + ```java + private long currentReportedOffset = 0; // slave 当前的进度信息 + private int dispatchPosition = 0; // 控制 byteBufferRead position 指针 + ``` + + + +*** + + + +###### 成员方法 + +* run():启动方法 + + ```java + public void run() + ``` + + * `if (this.connectMaster())`:连接主节点,连接失败会休眠 5 秒 + + * `String addr = this.masterAddress.get()`:获取 master 暴露的 HA 地址端口信息 + * `this.socketChannel = RemotingUtil.connect(socketAddress)`:建立连接 + * `this.socketChannel.register(this.selector, SelectionKey.OP_READ)`:注册到多路复用器,**关注读事件** + * `this.currentReportedOffset`: 初始化上报进度字段为 slave 的 maxPhyOffset + + * `if (this.isTimeToReportOffset())`:slave 每 5 秒会上报一次 slave 端的同步进度信息给 master + + `boolean result = this.reportSlaveMaxOffset()`:**上报同步信息**,上报失败关闭连接 + + * `this.selector.select(1000)`:多路复用器阻塞获取就绪的通道,最多等待 1 秒钟,**获取到就绪事件或者超时后结束** + + * `boolean ok = this.processReadEvent()`:处理读事件 + + * `if (!reportSlaveMaxOffsetPlus())`:检查是否重新上报同步进度 + +* reportSlaveMaxOffset():上报 slave 同步进度 + + ```java + private boolean reportSlaveMaxOffset(final long maxOffset) + ``` + + * 首先向缓冲区写入 slave 端最大偏移量,写完以后切换为指定置为初始状态 + + * `for (int i = 0; i < 3 && this.reportOffset.hasRemaining(); i++)`:尝试三次写数据 + + `this.socketChannel.write(this.reportOffset)`:**写数据** + + * `return !this.reportOffset.hasRemaining()`:写成功之后 pos = limit + +* processReadEvent():处理 master 发送给 slave 数据,返回 true 表示处理成功 false 表示 Socket 处于半关闭状态,需要上层重建 haClient + + ```java + private boolean processReadEvent() + ``` + + * `int readSizeZeroTimes = 0`:控制 while 循环的一个条件变量,当值为 3 时跳出循环 + + * `while (this.byteBufferRead.hasRemaining())`:byteBufferRead 有空间可以去 Socket 读缓冲区加载数据 + + * `int readSize = this.socketChannel.read(this.byteBufferRead)`:**从通道读数据** + + * `if (readSize > 0)`:加载成功,有新数据 + + `readSizeZeroTimes = 0`:置为 0 + + `boolean result = this.dispatchReadRequest()`:处理数据的核心逻辑 + + * `else if (readSize == 0) `:连续无新数据 3 次,跳出循环 + + * `else`:readSize = -1 就表示 Socket 处于半关闭状态,对端已经关闭了 + +* dispatchReadRequest():**处理数据的核心逻辑**,master 与 slave 传输的数据格式 `{[phyOffset][size][data...]}`,phyOffset 表示数据区间的开始偏移量,data 代表数据块,最大 32kb,可能包含多条消息的数据 + + ```java + private boolean dispatchReadRequest() + ``` + + * `final int msgHeaderSize = 8 + 4`:协议头大小 12 + + * `int readSocketPos = this.byteBufferRead.position()`:记录缓冲区处理数据前的 pos 位点,用于恢复指针 + + * `int diff = ...`:当前 byteBufferRead 还剩多少 byte 未处理,每处理一条帧数据都会更新 dispatchPosition + + * `if (diff >= msgHeaderSize)`:缓冲区还有完整的协议头 header 数据 + + * `if (diff >= (msgHeaderSize + bodySize))`:说明**缓冲区内是包含当前帧的全部数据的**,开始处理帧数据 + + `HAService...appendToCommitLog(masterPhyOffset, bodyData)`:**存储数据到 CommitLog**,并构建 Index 和 CQ + + `this.byteBufferRead.position(readSocketPos)`:恢复 byteBufferRead 的 pos 指针 + + `this.dispatchPosition += msgHeaderSize + bodySize`:加一帧数据长度,处理下一条数据使用 + + `if (!reportSlaveMaxOffsetPlus())`:上报 slave 同步信息 + + * `if (!this.byteBufferRead.hasRemaining())`:缓冲区写满了,重新分配缓冲区 + +* reallocateByteBuffer():重新分配缓冲区 + + ```java + private void reallocateByteBuffer() + ``` + + * `int remain = READ_MAX_BUFFER_SIZE - this.dispatchPosition`:表示缓冲区尚未处理过的字节数量 + + * `if (remain > 0)`:条件成立,说明缓冲区**最后一帧数据是半包数据**,但是不能丢失数据 + + `this.byteBufferBackup.put(this.byteBufferRead)`:**将半包数据拷贝到 backup 缓冲区** + + * `this.swapByteBuffer()`:交换 backup 成为 read + + * `this.byteBufferRead.position(remain)`:设置 pos 为 remain ,后续加载数据 pos 从remain 开始向后移动 + + * `this.dispatchPosition = 0`:当前缓冲区交换之后,相当于是一个全新的 byteBuffer,所以分配指针归零 + + + +*** + + + +##### HAConn + +###### Connection + +HAConnection 类成员变量: + +* 会话通道:master 和 slave 之间通信的 SocketChannel + + ```java + private final SocketChannel socketChannel; + ``` + +* 客户端地址: + + ```java + private final String clientAddr; + ``` + +* 服务类: + + ```java + private WriteSocketService writeSocketService; // 写数据服务 + private ReadSocketService readSocketService; // 读数据服务 + ``` + +* 请求位点:在 slave 上报本地的进度之后被赋值,该值大于 0 后同步逻辑才会运行,master 如果不知道 slave 节点当前消息的存储进度,就无法给 slave 推送数据 + + ```java + private volatile long slaveRequestOffset = -1; + ``` + +* 应答位点: 保存最新的 slave 上报的 offset 信息,slaveAckOffset 之前的数据都可以认为 slave 已经同步完成 + + ```java + private volatile long slaveAckOffset = -1; + ``` + +核心方法: + +* 构造方法: + + ```java + public HAConnection(final HAService haService, final SocketChannel socketChannel) { + // 初始化一些东西 + // 设置 socket 读写缓冲区为 64kb 大小 + this.socketChannel.socket().setReceiveBufferSize(1024 * 64); + this.socketChannel.socket().setSendBufferSize(1024 * 64); + // 创建读写服务 + this.writeSocketService = new WriteSocketService(this.socketChannel); + this.readSocketService = new ReadSocketService(this.socketChannel); + // 自增 + this.haService.getConnectionCount().incrementAndGet(); + } + ``` + +* 启动方法: + + ```java + public void start() { + this.readSocketService.start(); + this.writeSocketService.start(); + } + ``` + + + +*** + + + +###### ReadSocket + +ReadSocketService 类是一个任务对象,slave 向 master 传输的帧格式为 `[long][long][long]`,上报的是 slave 本地的同步进度,同步进度是一个 long 值 + +成员变量: + +* 读缓冲: + + ```java + private static final int READ_MAX_BUFFER_SIZE = 1024 * 1024; // 默认大小 1MB + private final ByteBuffer byteBufferRead = ByteBuffer.allocate(READ_MAX_BUFFER_SIZE); + ``` + +* NIO 属性: + + ```java + private final Selector selector; // 多路复用器 + private final SocketChannel socketChannel; // master 与 slave 之间的会话 SocketChannel + ``` + +* 处理位点:缓冲区处理位点 + + ```java + private int processPosition = 0; + ``` + +* 上次读操作的时间: + + ```java + private volatile long lastReadTimestamp = System.currentTimeMillis(); + ``` + +核心方法: + +* 构造方法: + + ```java + public ReadSocketService(final SocketChannel socketChannel) + ``` + + * `this.socketChannel.register(this.selector, SelectionKey.OP_READ)`:通道注册到多路复用器,关注读事件 + * `this.setDaemon(true)`:设置为守护线程 + +* 运行方法: + + ```java + public void run() + ``` + + * `this.selector.select(1000)`:多路复用器阻塞获取就绪的通道,最多等待 1 秒钟,获取到就绪事件或者超时后结束 + + * `boolean ok = this.processReadEvent()`:**读数据的核心方法**,返回 true 表示处理成功 false 表示 Socket 处于半关闭状态,需要上层重建 HAConnection 对象 + + * `int readSizeZeroTimes = 0`:控制 while 循环,当连续从 Socket 读取失败 3 次(未加载到数据)跳出循环 + + * `if (!this.byteBufferRead.hasRemaining())`:byteBufferRead 已经全部使用完,需要清理数据并更新位点 + + * `while (this.byteBufferRead.hasRemaining())`:byteBufferRead 有空间可以去 Socket 读缓冲区加载数据 + + * `int readSize = this.socketChannel.read(this.byteBufferRead)`:**从通道读数据** + + * `if (readSize > 0)`:加载成功,有新数据 + + `if ((byteBufferRead.position() - processPosition) >= 8)`:缓冲区的可读数据最少包含一个数据帧 + + * `int pos = ...`:**获取可读帧数据中最后一个完整的帧数据的位点,后面的数据丢弃** + * `long readOffset = ...byteBufferRead.getLong(pos - 8)`:读取最后一帧数据,slave 端当前的同步进度信息 + + * `this.processPosition = pos`:更新处理位点 + * `HAConnection.this.slaveAckOffset = readOffset`:更新应答位点 + * `if (HAConnection.this.slaveRequestOffset < 0)`:条件成立**给 slaveRequestOffset 赋值** + * `HAConnection...notifyTransferSome(slaveAckOffset)`:**唤醒阻塞的生产者线程** + + * `else if (readSize == 0) `:读取 3 次无新数据跳出循环 + + * `else`:readSize = -1 就表示 Socket 处于半关闭状态,对端已经关闭了 + + * `if (interval > 20)`:超过 20 秒未发生通信,直接结束循环 + + + +*** + + + +###### WriteSocket + +WriteSocketService 类是一个任务对象,master 向 slave 传输的数据帧格式为 `{[phyOffset][size][data...]}{[phyOffset][size][data...]}` + +* phyOffset:数据区间的开始偏移量,并不表示某一条具体的消息,表示的数据块开始的偏移量位置 +* size:同步的数据块的大小 +* data:数据块,最大 32kb,可能包含多条消息的数据 + +成员变量: + +* 协议头: + + ```java + private final int headerSize = 8 + 4; // 协议头大小:12 + private final ByteBuffer byteBufferHeader; // 帧头缓冲区 + ``` + +* NIO 属性: + + ```java + private final Selector selector; // 多路复用器 + private final SocketChannel socketChannel; // master 与 slave 之间的会话 SocketChannel + ``` + +* 处理位点:下一次传输同步数据的位置信息,master 给当前 slave 同步的位点 + + ```java + private long nextTransferFromWhere = -1; + ``` + +* 上次写操作: + + ```java + private boolean lastWriteOver = true; // 上一轮数据是否传输完毕 + private long lastWriteTimestamp = System.currentTimeMillis(); // 上次写操作的时间 + ``` + +核心方法: + +* 构造方法: + + ```java + public WriteSocketService(final SocketChannel socketChannel) + ``` + + * `this.socketChannel.register(this.selector, SelectionKey.OP_WRITE)`:通道注册到多路复用器,关注写事件 + * `this.setDaemon(true)`:设置为守护线程 + +* 运行方法: + + ```java + public void run() + ``` + + * `this.selector.select(1000)`:多路复用器阻塞获取就绪的通道,最多等待 1 秒钟,获取到就绪事件或者超时后结束 + + * `if (-1 == HAConnection.this.slaveRequestOffset)`:**等待 slave 同步完数据** + + * `if (-1 == this.nextTransferFromWhere)`:条件成立,需要初始化该变量 + + `if (0 == HAConnection.this.slaveRequestOffset)`:slave 是一个全新节点,从正在顺序写的 MF 开始同步数据 + + `long masterOffset = ...`:获取 master 最大的 offset,并计算归属的 mappedFile 文件的开始 offset + + `this.nextTransferFromWhere = masterOffset`:**赋值给下一次传输同步数据的位置信息** + + `this.nextTransferFromWhere = HAConnection.this.slaveRequestOffset`:大部分情况走这个赋值逻辑 + + * `if (this.lastWriteOver)`:上一次待发送数据全部发送完成 + + `if (interval > 5)`:**超过 5 秒未同步数据,发送一个 header 心跳数据包,维持长连接** + + * `else`:上一轮的待发送数据未全部发送,需要同步数据到 slave 节点 + + * `SelectMappedBufferResult selectResult`:**到 CommitLog 中查询 nextTransferFromWhere 开始位置的数据** + + * `if (size > 32k)`:一次最多同步 32k 数据 + + * `this.nextTransferFromWhere += size`:增加 size,下一轮传输跳过本帧数据 + + * `selectResult.getByteBuffer().limit(size)`:设置 byteBuffer 可访问数据区间为 [pos, size] + + * `this.selectMappedBufferResult = selectResult`:**待发送的数据** + + * `this.byteBufferHeader.put`:**构建帧头数据** + + * `this.lastWriteOver = this.transferData()`:处理数据,返回是否处理完成 + +* 同步方法:**同步数据到 slave 节点**,返回 true 表示本轮数据全部同步完成,false 表示本轮同步未完成(Header 和 Body 其中一个未同步完成都会返回 false) + + ```java + private boolean transferData() + ``` + + * `int writeSizeZeroTimes= 0`:控制 while 循环,当写失败连续 3 次时,跳出循环)跳出循环 + + * `while (this.byteBufferHeader.hasRemaining())`:**帧头数据缓冲区有待发送的数据** + + * `int writeSize = this.socketChannel.write(this.byteBufferHeader)`:向通道写帧头数据 + + * `if (null == this.selectMappedBufferResult)`:说明是心跳数据,返回心跳数据是否发送完成 + + * `if (!this.byteBufferHeader.hasRemaining())`:**Header写成功之后,才进行写 Body** + + * `while (this.selectMappedBufferResult.getByteBuffer().hasRemaining())`:**数据缓冲区有待发送的数据** + + * `int writeSize = this.socketChannel.write(this.selectMappedBufferResult...)`:向通道写帧头数据 + + * `if (writeSize > 0)`:写数据成功,但是不代表 SMBR 中的数据全部写完成 + + * `boolean result`:判断是否发送完成,返回该值 + + + + + +**** + + + +#### MesStore + +##### 生命周期 + +DefaultMessageStore 类核心是整个存储服务的调度类 + +* 构造方法: + + ```java + public DefaultMessageStore() + ``` + + * `this.allocateMappedFileService.start()`:启动**创建 MappedFile 文件服务** + * `this.indexService.start()`:启动索引服务 + +* load():先加载 CommitLog,再加载 ConsumeQueue,最后加载 IndexFile,加载完进入恢复阶段,先恢复 CQ,在恢复 CL + + ```java + public boolean load() + ``` + +* start():核心启动方法 + + ```java + public void start() + ``` + + * `lock = lockFile.getChannel().tryLock(0, 1, false)`:获取文件锁,获取失败说明当前目录已经启动过 Broker + + * `long maxPhysicalPosInLogicQueue = commitLog.getMinOffset()`:遍历全部的 CQ 对象,获取 CQ 中消息的最大偏移量 + + * `this.reputMessageService.start()`:设置分发服务的分发位点,启动**分发服务**,构建 ConsumerQueue 和 IndexFile + + * `if (dispatchBehindBytes() <= 0)`:线程等待分发服务将分发数据全部处理完毕 + + * `this.recoverTopicQueueTable()`:因为修改了 CQ 数据,所以再次构建队列偏移量字段表 + + * `this.haService.start()`:启动 **HA 服务** + + * `this.handleScheduleMessageService()`:启动**消息调度服务** + + * `this.flushConsumeQueueService.start()`:启动 CQ **消费队列刷盘服务** + + * `this.commitLog.start()`:启动 **CL 刷盘服务** + + * `this.storeStatsService.start()`:启动状态存储服务 + + * `this.createTempFile()`:创建 AbortFile,正常关机时 JVM HOOK 会删除该文件,**异常宕机时该文件不会删除**,开机数据恢复阶段根据是否存在该文件,执行不同的恢复策略 + + * `this.addScheduleTask()`:添加定时任务 + + * `DefaultMessageStore.this.cleanFilesPeriodically()`:**定时清理过期文件**,周期是 10 秒 + + * `this.cleanCommitLogService.run()`:启动清理过期的 CL 文件服务 + * `this.cleanConsumeQueueService.run()`:启动清理过期的 CQ 文件服务 + + * `DefaultMessageStore.this.checkSelf()`:每 10 分种进行健康检查 + + * `DefaultMessageStore.this.cleanCommitLogService.isSpaceFull()`:**磁盘预警定时任务**,每 10 秒一次 + + * `if (physicRatio > this.diskSpaceWarningLevelRatio)`:检查磁盘是否到达 waring 阈值,默认 90% + + `boolean diskok = ...runningFlags.getAndMakeDiskFull()`:设置磁盘写满标记 + + * `boolean diskok = ...this.runningFlags.getAndMakeDiskOK()`:设置磁盘可写标记 + + * `this.shutdown = false`:刚启动,设置为 false + +* shutdown():关闭各种服务和线程资源,设置存储模块状态为关闭状态 + + ```java + public void shutdown() + ``` + +* destroy():销毁 Broker 的工作目录 + + ```java + public void destroy() + ``` + + + + + +*** + + + +##### 服务线程 + +ServiceThread 类被很多服务继承,本身是一个 Runnable 任务对象,继承者通过重写 run 方法来实现服务的逻辑 + +* run():一般实现方式 + + ```java + public void run() { + while (!this.isStopped()) { + // 业务逻辑 + } + } + ``` + + 通过参数 stopped 控制服务的停止,使用 volatile 修饰保证可见性 + + ```java + protected volatile boolean stopped = false + ``` + +* shutdown():停止线程,首先设置 stopped 为 true,然后进行唤醒,默认不直接打断线程 + + ```java + public void shutdown() + ``` + +* waitForRunning():挂起线程,设置唤醒标记 hasNotified 为 false + + ```java + protected void waitForRunning(long interval) + ``` + +* wakeup():唤醒线程,设置 hasNotified 为 true + + ```java + public void wakeup() + ``` + + + +*** + + + +##### 构建服务 + +AllocateMappedFileService **创建 MappedFile 服务** + +* mmapOperation():核心服务 + + ```java + private boolean mmapOperation() + ``` + + * `req = this.requestQueue.take()`: **从 requestQueue 阻塞队列(优先级)中获取 AllocateRequest 任务** + * `if (...isTransientStorePoolEnable())`:条件成立使用直接内存写入数据, 从直接内存中 commit 到 FileChannel 中 + * `mappedFile = new MappedFile(req.getFilePath(), req.getFileSize())`:根据请求的路径和大小创建对象 + * `mappedFile.warmMappedFile()`:判断 mappedFile 大小,只有 CommitLog 才进行文件预热 + * `req.setMappedFile(mappedFile)`:将创建好的 MF 对象的赋值给请求对象的成员属性 + * `req.getCountDownLatch().countDown()`:**唤醒请求的阻塞线程** + +* putRequestAndReturnMappedFile():MappedFileQueue 中用来创建 MF 对象的方法 + + ```java + public MappedFile putRequestAndReturnMappedFile(String nextFilePath, String nextNextFilePath, int fileSize) + ``` + + * `AllocateRequest nextReq = new AllocateRequest(...)`:创建 nextFilePath 的 AllocateRequest 对象,放入请求列表和阻塞队列,然后创建 nextNextFilePath 的 AllocateRequest 对象,放入请求列表和阻塞队列 + * `AllocateRequest result = this.requestTable.get(nextFilePath)`:从请求列表获取 nextFilePath 的请求对象 + * `result.getCountDownLatch().await(...)`:**线程挂起**,直到超时或者 nextFilePath 对应的 MF 文件创建完成 + * `return result.getMappedFile()`:返回创建好的 MF 文件对象 + +ReputMessageService 消息分发服务,用于构**建 ConsumerQueue 和 IndexFile 文件** + +* run():**循环执行 doReput 方法**,**所以发送的消息存储进 CL 就可以产生对应的 CQ**,每执行一次线程休眠 1 毫秒 + + ```java + public void run() + ``` + +* doReput():实现分发的核心逻辑 + + ```java + private void doReput() + ``` + + * `for (boolean doNext = true; this.isCommitLogAvailable() && doNext; )`:循环遍历 + * `SelectMappedBufferResult result`: 从 CommitLog 拉取数据,数据范围 `[reputFromOffset, 包含该偏移量的 MF 的最大 Pos]`,封装成结果对象 + * `DispatchRequest dispatchRequest`:从结果对象读取出一条 DispatchRequest 数据 + * `DefaultMessageStore.this.doDispatch(dispatchRequest)`:将数据交给分发器进行分发,用于**构建 CQ 和索引文件** + * `this.reputFromOffset += size`:更新数据范围 + + + +*** + + + +##### 刷盘服务 + +FlushConsumeQueueService 刷盘 CQ 数据 + +* run():每隔 1 秒执行一次刷盘服务,跳出循环后还会执行一次强制刷盘 + + ```java + public void run() + ``` + +* doFlush():刷盘 + + ```java + private void doFlush(int retryTimes) + ``` + + * `int flushConsumeQueueLeastPages`:脏页阈值,默认是 2 + + * `if (retryTimes == RETRY_TIMES_OVER)`:**重试次数是 3** 时设置强制刷盘,设置脏页阈值为 0 + * `int flushConsumeQueueThoroughInterval`:两次刷新的**时间间隔超过 60 秒**会强制刷盘 + * `for (ConsumeQueue cq : maps.values())`:遍历所有的 CQ,进行刷盘 + * `DefaultMessageStore.this.getStoreCheckpoint().flush()`:强制刷盘时将 StoreCheckpoint 瞬时数据刷盘 + +FlushCommitLogService 刷盘 CL 数据,默认是异步刷盘 + +* run():运行方法 + + ```java + public void run() + ``` + + * `while (!this.isStopped())`:stopped为 true 才跳出循环 + + * `boolean flushCommitLogTimed`:控制线程的休眠方式,默认是 false,使用 `CountDownLatch.await()` 休眠,设置为 true 时使用 `Thread.sleep()` 休眠 + + * `int interval`:获取配置中的刷盘时间间隔 + + * `int flushPhysicQueueLeastPages`:获取最小刷盘页数,默认是 4 页,脏页达到指定页数才刷盘 + + * `int flushPhysicQueueThoroughInterval`:获取强制刷盘周期,默认是 10 秒,达到周期后强制刷盘,不考虑脏页 + + * `if (flushCommitLogTimed)`:休眠逻辑,避免 CPU 占用太长时间,导致无法执行其他更紧急的任务 + + * `CommitLog.this.mappedFileQueue.flush(flushPhysicQueueLeastPages)`:**刷盘** + + * `for (int i = 0; i < RETRY_TIMES_OVER && !result; i++)`:stopped 停止标记为 true 时,需要确保所有的数据都已经刷盘,所以此处尝试 10 次强制刷盘, + + `result = CommitLog.this.mappedFileQueue.flush(0)`:**强制刷盘** + + + +*** + + + +##### 清理服务 + +CleanCommitLogService 清理过期的 CL 数据,定时任务 10 秒调用一次,**先清理 CL,再清理 CQ**,因为 CQ 依赖于 CL 的数据 + +* run():运行方法 + + ```java + public void run() + ``` + +* deleteExpiredFiles():删除过期 CL 文件 + + ```java + private void deleteExpiredFiles() + ``` + + * `long fileReservedTime`:默认 72,代表文件的保留时间 + * `boolean timeup = this.isTimeToDelete()`:当前时间是否是凌晨 4 点 + * `boolean spacefull = this.isSpaceToDelete()`:CL 或者 CQ 的目录磁盘使用率达到阈值标准 85% + * `boolean manualDelete = this.manualDeleteFileSeveralTimes > 0`:手动删除文件 + * `fileReservedTime *= 60 * 60 * 1000`:默认保留 72 小时 + * `deleteCount = DefaultMessageStore.this.commitLog.deleteExpiredFile()`:**调用 MFQ 对象的删除方法** + +CleanConsumeQueueService 清理过期的 CQ 数据 + +* run():运行方法 + + ```java + public void run() + ``` + +* deleteExpiredFiles():删除过期 CQ 文件 + + ```java + private void deleteExpiredFiles() + ``` + + * `int deleteLogicsFilesInterval`:清理 CQ 的时间间隔,默认 100 毫秒 + * `long minOffset = DefaultMessageStore.this.commitLog.getMinOffset()`:获取 CL 文件中最小的物理偏移量 + * `if (minOffset > this.lastPhysicalMinOffset)`:CL 最小的偏移量大于 CQ 最小的,说明有过期数据 + * `this.lastPhysicalMinOffset = minOffset`:更新 CQ 的最小偏移量 + * `for (ConsumeQueue logic : maps.values())`:遍历所有的 CQ 文件 + * `logic.deleteExpiredFile(minOffset)`:**调用 MFQ 对象的删除方法** + * `DefaultMessageStore.this.indexService.deleteExpiredFile(minOffset)`:**删除过期的索引文件** + + + +*** + + + +##### 获取消息 + +DefaultMessageStore#getMessage 用于获取消息,在 PullMessageProcessor#processRequest 方法中被调用 (提示:建议学习消费者源码时再阅读) + +```java +// offset: 客户端拉消息使用位点; maxMsgNums: 32; messageFilter: 一般这里是 tagCode 过滤 +public GetMessageResult getMessage(final String group, final String topic, final int queueId, final long offset, final int maxMsgNums, final MessageFilter messageFilter) +``` + +* `if (this.shutdown)`:检查运行状态 + +* `GetMessageResult getResult`:创建查询结果对象 + +* `final long maxOffsetPy = this.commitLog.getMaxOffset()`:**获取 CommitLog 最大物理偏移量** + +* `ConsumeQueue consumeQueue = findConsumeQueue(topic, queueId)`:根据主题和队列 ID 获取 ConsumeQueue对象 + +* `minOffset, maxOffset`:获取当前 ConsumeQueue 的最小 offset 和 最大 offset,**判断是否满足本次 Pull 的 offset** + + `if (maxOffset == 0)`:说明队列内无数据,设置状态为 NO_MESSAGE_IN_QUEUE,外层进行长轮询 + + `else if (offset < minOffset)`:说明 offset 太小了,设置状态为 OFFSET_TOO_SMALL + + `else if (offset == maxOffset)`:消费进度持平,设置状态为 OFFSET_OVERFLOW_ONE,外层进行长轮询 + + `else if (offset > maxOffset)`:说明 offset 越界了,设置状态为 OFFSET_OVERFLOW_BADLY + +* `SelectMappedBufferResult bufferConsumeQueue`:查询 CQData **获取包含该 offset 的 MappedFile 文件**,如果该文件不是顺序写的文件,就读取 `[offset%maxSize, 文件尾]` 范围的数据,反之读取 `[offset%maxSize, 文件名+wrotePosition尾]` + + 先查 CQ 的原因:因为 CQ 时 CL 的索引,通过 CQ 查询 CL 更加快捷 + +* `if (bufferConsumeQueue != null)`:只有再 CQ 删除过期数据的逻辑执行时,条件才不成立,一般都是成立的 + +* `long nextPhyFileStartOffset = Long.MIN_VALUE`:下一个 commitLog 物理文件名,初始值为最小值 + +* `long maxPhyOffsetPulling = 0`:本次拉消息最后一条消息的物理偏移量 + +* `for ()`:**处理数据**,每次处理 20 字节处理字节数大于 16000 时跳出循环 + +* `offsetPy, sizePy, tagsCode`:读取 20 个字节后,获取消息物理偏移量、消息大小、消息 tagCode + +* `boolean isInDisk = checkInDiskByCommitOffset(...)`:**检查消息是热数据还是冷数据**,false 为热数据 + + * `long memory`:Broker 系统 40% 内存的字节数,写数据时内存不够会使用 LRU 算法淘汰数据,将淘汰数据持久化到磁盘 + * `return (maxOffsetPy - offsetPy) > memory`:返回 true 说明数据已经持久化到磁盘,为冷数据 + +* `if (this.isTheBatchFull())`:**控制是否跳出循环** + + * `if (0 == bufferTotal || 0 == messageTotal)`:本次 pull 消息未拉取到任何东西,需要外层 for 循环继续,返回 false + + * `if (maxMsgNums <= messageTotal)`:结果对象内消息数已经超过了最大消息数量,可以结束循环了 + + * `if (isInDisk)`:冷数据 + + `if ((bufferTotal + sizePy) > ...)`:冷数据一次 pull 请求最大允许获取 64kb 的消息 + + `if (messageTotal > ...)`:冷数据一次 pull 请求最大允许获取8 条消息 + + * `else`:热数据 + + `if ((bufferTotal + sizePy) > ...)`:热数据一次 pull 请求最大允许获取 256kb 的消息 + + `if (messageTotal > ...)`:冷数据一次 pull 请求最大允许获取 32 条消息 + +* `if (messageFilter != null)`:按照消息 tagCode 进行过滤 + +* `selectResult = this.commitLog.getMessage(offsetPy, sizePy)`:根据 CQ 消息物理偏移量和消息大小**到 commitLog 中查询这条 msg** + +* `if (null == selectResult)`:条件成立说明 commitLog 执行了删除过期文件的定时任务,因为是先清理的 CL,所以 CQ 还有该索引数据 + +* `nextPhyFileStartOffset = this.commitLog.rollNextFile(offsetPy)`:获取包含该 offsetPy 的下一个数据文件的文件名 + +* `getResult.addMessage(selectResult)`:**将本次循环查询出来的 msg 加入到 getResult 内** + +* `status = GetMessageStatus.FOUND`:查询状态设置为 FOUND + +* `nextPhyFileStartOffset = Long.MIN_VALUE`:设置为最小值,跳过期 CQData 数据的逻辑 + +* `nextBeginOffset = offset + (i / ConsumeQueue.CQ_STORE_UNIT_SIZE)`:计算客户端下一次 pull 时使用的位点信息 + +* `getResult.setSuggestPullingFromSlave(diff > memory)`:**选择主从节点的建议** + + * `diff > memory => true`:表示本轮查询最后一条消息为冷数据,Broker 建议客户端下一次 pull 时到 slave 节点 + * `diff > memory => false`:表示本轮查询最后一条消息为热数据,Broker 建议客户端下一次 pull 时到 master 节点 + +* `getResult.setStatus(status)`:设置结果状态 + +* `getResult.setNextBeginOffset(nextBeginOffset)`:设置客户端下一次 pull 时的 offset + +* `getResult.setMaxOffset(maxOffset)`:设置 queue 的最大 offset 和最小 offset + +* `return getResult`:返回结果对象 + + + +*** + + + +#### Broker + +BrokerStartup 启动方法 + +```java +public static void main(String[] args) { + start(createBrokerController(args)); +} +public static BrokerController start(BrokerController controller) { + controller.start(); // 启动 +} +``` + +BrokerStartup#createBrokerController:构造控制器,并初始化 + +* `final BrokerController controller()`:创建实例对象 +* `boolean initResult = controller.initialize()`:控制器初始化 + * `this.registerProcessor()`:**注册了处理器,包括发送消息、拉取消息、查询消息等核心处理器** + * `initialTransaction()`:初始化了事务服务,用于进行**事务回查** + +BrokerController#start:核心启动方法 + +* `this.messageStore.start()`:**启动存储服务** + +* `this.remotingServer.start()`:启动 Netty 通信服务 + +* `this.fileWatchService.start()`:启动文件监听服务 + +* `startProcessorByHa(messageStoreConfig.getBrokerRole())`:**启动事务回查** + +* `this.scheduledExecutorService.scheduleAtFixedRate()`:每隔 30s 向 NameServer 上报 Topic 路由信息,**心跳机制** + + `BrokerController.this.registerBrokerAll(true, false, brokerConfig.isForceRegister())` + + + +**** + + + +### Producer + +#### 生产者类 + +##### 生产者类 + +DefaultMQProducer 是生产者的默认实现类 + +成员变量: + +* 生产者实现类: + + ```java + protected final transient DefaultMQProducerImpl defaultMQProducerImpl + ``` + +* 生产者组:发送事务消息,Broker 端进行事务回查(补偿机制)时,选择当前生产者组的下一个生产者进行事务回查 + + ```java + private String producerGroup; + ``` + +* 默认主题:isAutoCreateTopicEnable 开启时,当发送消息指定的 Topic 在 Namesrv 未找到路由信息,使用该值创建 Topic 信息 + + ```java + private String createTopicKey = TopicValidator.AUTO_CREATE_TOPIC_KEY_TOPIC; + // 值为【TBW102】,Just for testing or demo program + ``` + +* 消息重投:系统特性消息重试部分详解了三个参数的作用 + + ```java + private int retryTimesWhenSendFailed = 2; // 同步发送失败后重试的发送次数,加上第一次发送,一共三次 + private int retryTimesWhenSendAsyncFailed = 2; // 异步 + private boolean retryAnotherBrokerWhenNotStoreOK = false; // 消息未存储成功,选择其他 Broker 重试 + ``` + +* 消息队列: + + ```java + private volatile int defaultTopicQueueNums = 4; // 默认 Broker 创建的队列数 + ``` + +* 消息属性: + + ```java + private int sendMsgTimeout = 3000; // 发送消息的超时限制 + private int compressMsgBodyOverHowmuch = 1024 * 4; // 压缩阈值,当 msg body 超过 4k 后使用压缩 + private int maxMessageSize = 1024 * 1024 * 4; // 消息体的最大限制,默认 4M + private TraceDispatcher traceDispatcher = null; // 消息轨迹 + +构造方法: + +* 构造方法: + + ```java + public DefaultMQProducer(final String namespace, final String producerGroup, RPCHook rpcHook) { + this.namespace = namespace; + this.producerGroup = producerGroup; + // 创建生产者实现对象 + defaultMQProducerImpl = new DefaultMQProducerImpl(this, rpcHook); + } + ``` + +成员方法: + +* start():启动方法 + + ```java + public void start() throws MQClientException { + // 重置生产者组名,如果传递了命名空间,则 【namespace%group】 + this.setProducerGroup(withNamespace(this.producerGroup)); + // 生产者实现对象启动 + this.defaultMQProducerImpl.start(); + if (null != traceDispatcher) { + // 消息轨迹的逻辑 + traceDispatcher.start(this.getNamesrvAddr(), this.getAccessChannel()); + } + } + ``` + +* send():**发送消息**: + + ```java + public SendResult send(Message msg){ + // 校验消息 + Validators.checkMessage(msg, this); + // 设置消息 Topic + msg.setTopic(withNamespace(msg.getTopic())); + return this.defaultMQProducerImpl.send(msg); + } + ``` + +* request():请求方法,**需要消费者回执消息** + + ```java + public Message request(final Message msg, final MessageQueue mq, final long timeout) { + msg.setTopic(withNamespace(msg.getTopic())); + return this.defaultMQProducerImpl.request(msg, mq, timeout); + } + ``` + + + + +*** + + + +##### 实现者类 + +DefaultMQProducerImpl 类是默认的生产者实现类 + +成员变量: + +* 实例对象: + + ```java + private final DefaultMQProducer defaultMQProducer; // 持有默认生产者对象,用来获取对象中的配置信息 + private MQClientInstance mQClientFactory; // 客户端实例对象,生产者启动后需要注册到该客户端对象内 + ``` + +* 主题发布信息映射表:key 是 Topic,value 是发布信息 + + ```java + private final ConcurrentMap topicPublishInfoTable = new ConcurrentHashMap(); + ``` + +* 异步发送消息:相关信息 + + ```java + private final BlockingQueue asyncSenderThreadPoolQueue;// 异步发送消息,异步线程池使用的队列 + private final ExecutorService defaultAsyncSenderExecutor; // 异步发送消息默认使用的线程池 + private ExecutorService asyncSenderExecutor; // 异步消息发送线程池,指定后就不使用默认线程池了 + ``` + +* 定时器:执行定时任务 + + ```java + private final Timer timer = new Timer("RequestHouseKeepingService", true); // 守护线程 + ``` + +* 状态信息:服务的状态,默认创建状态 + + ```java + private ServiceState serviceState = ServiceState.CREATE_JUST; + ``` + +* 压缩等级:ZIP 压缩算法的等级,默认是 5,越高压缩效果好,但是压缩的更慢 + + ```java + private int zipCompressLevel = Integer.parseInt(System.getProperty..., "5")); + ``` + +* 容错策略:选择队列的容错策略 + + ```java + private MQFaultStrategy mqFaultStrategy = new MQFaultStrategy(); + ``` + +* 钩子:用来进行前置或者后置处理 + + ```java + ArrayList sendMessageHookList; // 发送消息的钩子,留给用户扩展使用 + ArrayList checkForbiddenHookList; // 对比上面的钩子,可以抛异常,控制消息是否可以发送 + private final RPCHook rpcHook; // 传递给 NettyRemotingClient + ``` + +构造方法: + +* 默认构造: + + ```java + public DefaultMQProducerImpl(final DefaultMQProducer defaultMQProducer) { + // 默认 RPC HOOK 是空 + this(defaultMQProducer, null); + } + ``` + +* 有参构造: + + ```java + public DefaultMQProducerImpl(final DefaultMQProducer defaultMQProducer, RPCHook rpcHook) { + // 属性赋值 + this.defaultMQProducer = defaultMQProducer; + this.rpcHook = rpcHook; + + // 创建【异步消息线程池任务队列】,长度是 5w + this.asyncSenderThreadPoolQueue = new LinkedBlockingQueue(50000); + // 创建默认的异步消息任务线程池 + this.defaultAsyncSenderExecutor = new ThreadPoolExecutor( + // 核心线程数和最大线程数都是 系统可用的计算资源(8核16线程的系统就是 16)... + } + ``` + + + +**** + + + +##### 实现方法 + +* start():启动方法,参数默认是 true,代表正常的启动路径 + + ```java + public void start(final boolean startFactory) + ``` + + * `this.serviceState = ServiceState.START_FAILED`:先修改为启动失败,成功后再修改,这种思想很常见 + + * `this.checkConfig()`:判断生产者组名不能是空,也不能是 default_PRODUCER + + * `if (!getProducerGroup().equals(MixAll.CLIENT_INNER_PRODUCER_GROUP))`:条件成立说明当前生产者不是内部产生者,内部生产者是**处理消息回退**的这种情况使用的生产者 + + `this.defaultMQProducer.changeInstanceNameToPID()`:修改生产者实例名称为当前进程的 PID + + * ` this.mQClientFactory = ...`:获取当前进程的 MQ 客户端实例对象,从 factoryTable 中获取 key 为 客户端 ID,格式是`ip@pid`,**一个 JVM 进程只有一个 PID,也只有一个 MQClientInstance** + + * `boolean registerOK = mQClientFactory.registerProducer(...)`:将生产者注册到 RocketMQ 客户端实例内 + + * `this.topicPublishInfoTable.put(...)`:添加一个主题发布信息,key 是 **TBW102** ,value 是一个空对象 + + * `mQClientFactory.start()`:启动 RocketMQ 客户端实例对象 + + * `this.mQClientFactory.sendHeartbeatToAllBrokerWithLock()`:RocketMQ **客户端实例向已知的 Broker 节点发送一次心跳**(也是定时任务) + + * `this.timer.scheduleAtFixedRate()`: request 发送的消息需要消费着回执信息,启动定时任务每秒一次删除超时请求 + + * 生产者 msg 添加信息关联 ID 发送到 Broker + * 消费者从 Broker 拿到消息后会检查 msg 类型是一个需要回执的消息,处理完消息后会根据 msg 关联 ID 和客户端 ID 生成一条响应结果消息发送到 Broker,Broker 判断为回执消息,会根据客户端ID 找到 channel 推送给生产者 + * 生产者拿到回执消息后,读取出来关联 ID 找到对应的 RequestFuture,将阻塞线程唤醒 + +* sendDefaultImpl():发送消息 + + ```java + //参数1:消息;参数2:发送模式(同步异步单向);参数3:回调函数,异步发送时需要;参数4:发送超时时间, 默认 3 秒 + private SendResult sendDefaultImpl(msg, communicationMode, sendCallback,timeout) {} + ``` + + * `this.makeSureStateOK()`:校验生产者状态是运行中,否则抛出异常 + + * `topicPublishInfo = this.tryToFindTopicPublishInfo(msg.getTopic())`:**获取当前消息主题的发布信息** + + * `this.topicPublishInfoTable.get(topic)`:先尝试从本地主题发布信息映射表获取信息,获取不到继续执行 + + * `this.mQClientFactory.update...FromNameServer(topic)`:然后从 Namesrv 更新该 Topic 的路由数据 + + * `this.mQClientFactory.update...FromNameServer(...)`:**路由数据是空,获取默认 TBW102 的数据** + + `return topicPublishInfo`:返回 TBW102 主题的发布信息 + + * `String[] brokersSent = new String[timesTotal]`:下标索引代表第几次发送,值代表这次发送选择 Broker name + + * `for (; times < timesTotal; times++)`:循环发送,**发送成功或者发送尝试次数达到上限,结束循环** + + * `String lastBrokerName = null == mq ? null : mq.getBrokerName()`:获取上次发送失败的 BrokerName + + * `mqSelected = this.selectOneMessageQueue(topicPublishInfo, lastBrokerName)`:从发布信息中选择一个队列,生产者的**负载均衡策略**,参考系统特性章节 + + * `brokersSent[times] = mq.getBrokerName()`:将本次选择的 BrokerName 存入数组 + + * `msg.setTopic(this.defaultMQProducer.withNamespace(msg.getTopic()))`:**产生重投,重投消息需要加上标记** + + * `sendResult = this.sendKernelImpl`:核心发送方法 + + * `switch (communicationMode)`:异步或者单向消息直接返回 null,异步通过回调函数处理,同步发送进入逻辑判断 + + `if (sendResult.getSendStatus() != SendStatus.SEND_OK)`:**服务端 Broker 存储失败,需要重试其他 Broker** + + * `throw new MQClientException()`:未找到当前主题的路由数据,无法发送消息,抛出异常 + +* sendKernelImpl():**核心发送方法** + + ```java + //参数1:消息;参数2:选择的队列;参数3:发送模式(同步异步单向);参数4:回调函数,异步发送时需要;参数5:主题发布信息;参数6:剩余超时时间限制 + private SendResult sendKernelImpl(msg, mq, communicationMode, sendCallback, topicPublishInfo, timeout) + ``` + + * `brokerAddr = this.mQClientFactory(...)`:**获取指定 BrokerName 对应的 mater 节点的地址**,master 节点的 ID 为 0,集群模式下,**发送消息要发到主节点** + + * `brokerAddr = MixAll.brokerVIPChannel()`:Broker 启动时会绑定两个服务器端口,一个是普通端口,一个是 VIP 端口,服务器端根据不同端口创建不同的的 NioSocketChannel + + * `byte[] prevBody = msg.getBody()`:获取消息体 + + * `if (!(msg instanceof MessageBatch))`:非批量消息,需要重新设置消息 ID + + `MessageClientIDSetter.setUniqID(msg)`:**msg id 由两部分组成**,一部分是 ip 地址、进程号、Classloader 的 hashcode,另一部分是时间差(当前时间减去当月一号的时间)和计数器的值 + + * `if (this.tryToCompressMessage(msg))`:判断消息是否压缩,压缩需要设置压缩标记 + + * `hasCheckForbiddenHook、hasSendMessageHook`:执行钩子方法 + + * `requestHeader = new SendMessageRequestHeader()`:设置发送消息的消息头 + + * `if (requestHeader.getTopic().startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX))`:重投的发送消息 + + * `switch (communicationMode)`:异步发送一种处理方式,单向和同步同样的处理逻辑 + + `sendResult = this.mQClientFactory.getMQClientAPIImpl().sendMessage()`:**发送消息** + + * `request = RemotingCommand.createRequestCommand()`:创建一个 RequestCommand 对象 + * `request.setBody(msg.getBody())`:**将消息放入请求体** + * `switch (communicationMode)`:**根据不同的模式 invoke 不同的方法** + +* request():请求方法,消费者回执消息,这种消息是异步消息 + + * `requestResponseFuture = new RequestResponseFuture(correlationId, timeout, null)`:创建请求响应对象 + + * `getRequestFutureTable().put(correlationId, requestResponseFuture)`:放入RequestFutureTable 映射表中 + + * `this.sendDefaultImpl(msg, CommunicationMode.ASYNC, new SendCallback())`:**发送异步消息,有回调函数** + + * `return waitResponse(msg, timeout, requestResponseFuture, cost)`:用来挂起请求的方法 + + ```java + public Message waitResponseMessage(final long timeout) throws InterruptedException { + // 请求挂起 + this.countDownLatch.await(timeout, TimeUnit.MILLISECONDS); + return this.responseMsg; + } + + * 当消息被消费后,客户端处理响应时通过消息的关联 ID,从映射表中获取消息的 RequestResponseFuture,执行下面的方法唤醒挂起线程 + + ```java + public void putResponseMessage(final Message responseMsg) { + this.responseMsg = responseMsg; + this.countDownLatch.countDown(); + } + ``` + + + + +*** + + + +#### 路由信息 + +TopicPublishInfo 类用来存储路由信息 + +成员变量: + +* 顺序消息: + + ```java + private boolean orderTopic = false; + ``` + +* 消息队列: + + ```java + private List messageQueueList = new ArrayList<>(); // 主题全部的消息队列 + private volatile ThreadLocalIndex sendWhichQueue = new ThreadLocalIndex(); // 消息队列索引 + ``` + + ```java + // 【消息队列类】 + public class MessageQueue implements Comparable, Serializable { + private String topic; + private String brokerName; + private int queueId;// 队列 ID + } + ``` + +* 路由数据:主题对应的路由数据 + + ```java + private TopicRouteData topicRouteData; + ``` + + ```java + public class TopicRouteData extends RemotingSerializable { + private String orderTopicConf; + private List queueDatas; // 队列数据 + private List brokerDatas; // Broker 数据 + private HashMap/* Filter Server */> filterServerTable; + } + ``` + + ```java + public class QueueData implements Comparable { + private String brokerName; // 节点名称 + private int readQueueNums; // 读队列数 + private int writeQueueNums; // 写队列数 + private int perm; // 权限 + private int topicSynFlag; + } + ``` + + ```java + public class BrokerData implements Comparable { + private String cluster; // 集群名 + private String brokerName; // Broker节点名称 + private HashMap brokerAddrs; + } + ``` + +核心方法: + +* selectOneMessageQueue():**选择消息队列**使用 + + ```java + // 参数是上次失败时的 brokerName,可以为 null + public MessageQueue selectOneMessageQueue(final String lastBrokerName) { + if (lastBrokerName == null) { + return selectOneMessageQueue(); + } else { + // 遍历消息队列 + for (int i = 0; i < this.messageQueueList.size(); i++) { + // 【获取队列的索引,+1】 + int index = this.sendWhichQueue.getAndIncrement(); + // 获取队列的下标位置 + int pos = Math.abs(index) % this.messageQueueList.size(); + if (pos < 0) + pos = 0; + // 获取消息队列 + MessageQueue mq = this.messageQueueList.get(pos); + // 与上次选择的不同就可以返回 + if (!mq.getBrokerName().equals(lastBrokerName)) { + return mq; + } + } + return selectOneMessageQueue(); + } + } + ``` + + + + +*** + + + +#### 公共配置 + +公共的配置信息类 + +* ClientConfig 类 + + ```java + public class ClientConfig { + // Namesrv 地址配置 + private String namesrvAddr = NameServerAddressUtils.getNameServerAddresses(); + // 客户端的 IP 地址 + private String clientIP = RemotingUtil.getLocalAddress(); + // 客户端实例名称 + private String instanceName = System.getProperty("rocketmq.client.name", "DEFAULT"); + // 客户端回调线程池的数量,平台核心数,8核16线程的电脑返回16 + private int clientCallbackExecutorThreads = Runtime.getRuntime().availableProcessors(); + // 命名空间 + protected String namespace; + protected AccessChannel accessChannel = AccessChannel.LOCAL; + + // 获取路由信息的间隔时间 30s + private int pollNameServerInterval = 1000 * 30; + // 客户端与 broker 之间的心跳周期 30s + private int heartbeatBrokerInterval = 1000 * 30; + // 消费者持久化消费的周期 5s + private int persistConsumerOffsetInterval = 1000 * 5; + private long pullTimeDelayMillsWhenException = 1000; + private boolean unitMode = false; + private String unitName; + // vip 通道,broker 启动时绑定两个端口,其中一个是 vip 通道 + private boolean vipChannelEnabled = Boolean.parseBoolean(); + // 语言,默认是 Java + private LanguageCode language = LanguageCode.JAVA; + } + ``` + +* NettyClientConfig + + ```java + public class NettyClientConfig { + // 客户端工作线程数 + private int clientWorkerThreads = 4; + // 回调处理线程池 线程数:平台核心数 + private int clientCallbackExecutorThreads = Runtime.getRuntime().availableProcessors(); + // 单向请求并发数,默认 65535 + private int clientOnewaySemaphoreValue = NettySystemConfig.CLIENT_ONEWAY_SEMAPHORE_VALUE; + // 异步请求并发数,默认 65535 + private int clientAsyncSemaphoreValue = NettySystemConfig.CLIENT_ASYNC_SEMAPHORE_VALUE; + // 客户端连接服务器的超时时间限制 3秒 + private int connectTimeoutMillis = 3000; + // 客户端未激活周期,60s(指定时间内 ch 未激活,需要关闭) + private long channelNotActiveInterval = 1000 * 60; + // 客户端与服务器 ch 最大空闲时间 2分钟 + private int clientChannelMaxIdleTimeSeconds = 120; + + // 底层 Socket 写和收 缓冲区的大小 65535 64k + private int clientSocketSndBufSize = NettySystemConfig.socketSndbufSize; + private int clientSocketRcvBufSize = NettySystemConfig.socketRcvbufSize; + // 客户端 netty 是否启动内存池 + private boolean clientPooledByteBufAllocatorEnable = false; + // 客户端是否超时关闭 Socket 连接 + private boolean clientCloseSocketIfTimeout = false; + } + ``` + + + +*** + + + +#### 客户端类 + +##### 成员属性 + +MQClientInstance 是 RocketMQ 客户端实例,在一个 JVM 进程中只有一个客户端实例,**既服务于生产者,也服务于消费者** + +成员变量: + +* 配置信息: + + ```java + private final int instanceIndex; // 索引一般是 0,因为客户端实例一般都是一个进程只有一个 + private final String clientId; // 客户端 ID ip@pid + private final long bootTimestamp; // 客户端的启动时间 + private ServiceState serviceState; // 客户端状态 + ``` + +* 生产者消费者的映射表:key 是组名 + + ```java + private final ConcurrentMap producerTable + private final ConcurrentMap consumerTable + private final ConcurrentMap adminExtTable + ``` + +* 网络层配置: + + ```java + private final NettyClientConfig nettyClientConfig; + ``` + +* 核心功能的实现:负责将 MQ 业务层的数据转换为网络层的 RemotingCommand 对象,使用内部持有的 NettyRemotingClient 对象的 invoke 系列方法,完成网络 IO(同步、异步、单向) + + ```java + private final MQClientAPIImpl mQClientAPIImpl; + ``` + +* 本地路由数据:key 是主题名称,value 路由信息 + + ```java + private final ConcurrentMap topicRouteTable = new ConcurrentHashMap<>(); + ``` + +* 锁信息:两把锁,锁不同的数据 + + ```java + private final Lock lockNamesrv = new ReentrantLock(); + private final Lock lockHeartbeat = new ReentrantLock(); + ``` + +* 调度线程池:单线程,执行定时任务 + + ```java + private final ScheduledExecutorService scheduledExecutorService; + ``` + +* Broker 映射表:key 是 BrokerName + + ```java + // 物理节点映射表,value:Long 是 brokerID,【ID=0 的是主节点,其他是从节点】,String 是地址 ip:port + private final ConcurrentMap> brokerAddrTable; + // 物理节点版本映射表,String 是地址 ip:port,Integer 是版本 + ConcurrentMap> brokerVersionTable; + ``` + +* **客户端的协议处理器**:用于处理 IO 事件 + + ```java + private final ClientRemotingProcessor clientRemotingProcessor; + ``` + +* 消息服务: + + ```java + private final PullMessageService pullMessageService; // 拉消息服务 + private final RebalanceService rebalanceService; // 消费者负载均衡服务 + private final ConsumerStatsManager consumerStatsManager; // 消费者状态管理 + ``` + +* 内部生产者实例:处理消费端**消息回退**,用该生产者发送回退消息 + + ```java + private final DefaultMQProducer defaultMQProducer; + ``` + +* 心跳次数统计: + + ```java + private final AtomicLong sendHeartbeatTimesTotal = new AtomicLong(0) + ``` + +构造方法: + +* MQClientInstance 有参构造: + + ```java + public MQClientInstance(ClientConfig clientConfig, int instanceIndex, String clientId, RPCHook rpcHook) { + this.clientConfig = clientConfig; + this.instanceIndex = instanceIndex; + // Netty 相关的配置信息 + this.nettyClientConfig = new NettyClientConfig(); + // 平台核心数 + this.nettyClientConfig.setClientCallbackExecutorThreads(...); + this.nettyClientConfig.setUseTLS(clientConfig.isUseTLS()); + // 【创建客户端协议处理器】 + this.clientRemotingProcessor = new ClientRemotingProcessor(this); + // 创建 API 实现对象 + // 参数一:客户端网络配置 + // 参数二:客户端协议处理器,注册到客户端网络层 + // 参数三:rpcHook,注册到客户端网络层 + // 参数四:客户端配置 + this.mQClientAPIImpl = new MQClientAPIImpl(this.nettyClientConfig, this.clientRemotingProcessor, rpcHook, clientConfig); + + //... + // 内部生产者,指定内部生产者的组 + this.defaultMQProducer = new DefaultMQProducer(MixAll.CLIENT_INNER_PRODUCER_GROUP); + } + ``` + +* MQClientAPIImpl 有参构造: + + ```java + public MQClientAPIImpl(nettyClientConfig, clientRemotingProcessor, rpcHook, clientConfig) { + this.clientConfig = clientConfig; + topAddressing = new TopAddressing(MixAll.getWSAddr(), clientConfig.getUnitName()); + // 创建网络层对象,参数二为 null 说明客户端并不关心 channel event + this.remotingClient = new NettyRemotingClient(nettyClientConfig, null); + // 业务处理器 + this.clientRemotingProcessor = clientRemotingProcessor; + // 注册 RpcHook + this.remotingClient.registerRPCHook(rpcHook); + // ... + // 注册回退消息的请求码 + this.remotingClient.registerProcessor(RequestCode.PUSH_REPLY_MESSAGE_TO_CLIENT, this.clientRemotingProcessor, null); + } + ``` + + + +*** + + + +##### 成员方法 + +* start():启动方法 + + * `synchronized (this)`:加锁保证线程安全,保证只有一个实例对象启动 + * `this.mQClientAPIImpl.start()`:启动客户端网络层,底层调用 RemotingClient 类 + * `this.startScheduledTask()`:启动定时任务 + * `this.pullMessageService.start()`:启动拉取消息服务 + * `this.rebalanceService.start()`:启动负载均衡服务 + * `this.defaultMQProducer...start(false)`:启动内部生产者,参数为 false 代表不启动实例 + +* startScheduledTask():**启动定时任务**,调度线程池是单线程 + + * `if (null == this.clientConfig.getNamesrvAddr())`:Namesrv 地址是空,需要两分钟拉取一次 Namesrv 地址 + + * 定时任务 1:**从 Namesrv 更新客户端本地的路由数据**,周期 30 秒一次 + + ```java + // 获取生产者和消费者订阅的主题集合,遍历集合,对比从 namesrv 拉取最新的主题路由数据和本地数据,是否需要更新 + MQClientInstance.this.updateTopicRouteInfoFromNameServer(); + ``` + + * 定时任务 2:周期 30 秒一次,两个任务 + + * **清理下线的 Broker 节点**,遍历客户端的 Broker 物理节点映射表,将所有主题数据都不包含的 Broker 物理节点清理掉,如果被清理的 Broker 下所有的物理节点都没有了,就将该 Broker 的映射数据删除掉 + * **向在线的所有的 Broker 发送心跳数据**,同步发送的方式,返回值是 Broker 物理节点的版本号,更新版本映射表 + + ```java + MQClientInstance.this.cleanOfflineBroker(); + MQClientInstance.this.sendHeartbeatToAllBrokerWithLock(); + ``` + + ```java + // 心跳数据 + public class HeartbeatData extends RemotingSerializable { + // 客户端 ID ip@pid + private String clientID; + // 存储客户端所有生产者数据 + private Set producerDataSet = new HashSet(); + // 存储客户端所有消费者数据 + private Set consumerDataSet = new HashSet(); + } + ``` + + * 定时任务 3:消费者持久化消费数据,周期 5 秒一次 + + ```java + MQClientInstance.this.persistAllConsumerOffset(); + ``` + + * 定时任务 4:动态调整消费者线程池,周期 1 分钟一次 + + ```java + MQClientInstance.this.adjustThreadPool(); + ``` + +* updateTopicRouteInfoFromNameServer():**更新路由数据**,通过加锁保证当前实例只有一个线程去更新 + + * `if (isDefault && defaultMQProducer != null)`:需要默认数据 + + `topicRouteData = ...getDefaultTopicRouteInfoFromNameServer()`:从 Namesrv 获取默认的 TBW102 的路由数据 + + * `topicRouteData = ...getTopicRouteInfoFromNameServer(topic)`:需要**从 Namesrv 获取**路由数据(同步) + + * `old = this.topicRouteTable.get(topic)`:获取客户端实例本地的该主题的路由数据 + + * `boolean changed = topicRouteDataIsChange(old, topicRouteData)`:对比本地和最新下拉的数据是否一致 + + * `if (changed)`:不一致进入更新逻辑 + + `this.brokerAddrTable.put(...)`:更新客户端 broker 物理**节点映射表** + + `Update Pub info`:更新生产者信息 + + * `publishInfo = topicRouteData2TopicPublishInfo(topic, topicRouteData)`:将主题路由数据转化为发布数据,会**创建消息队列 MQ**,放入发布数据对象的集合中 + * `impl.updateTopicPublishInfo(topic, publishInfo)`:生产者将主题的发布数据保存到它本地,方便发送消息使用 + + `Update sub info`:更新消费者信息,创建 MQ 队列,更新订阅信息,用于负载均衡 + + `this.topicRouteTable.put(topic, cloneTopicRouteData)`:**将数据放入本地路由表** + + + +**** + + + +#### 网络通信 + +##### 成员属性 + +NettyRemotingClient 类负责客户端的网络通信 + +成员变量: + +* Netty 服务相关属性: + + ```java + private final NettyClientConfig nettyClientConfig; // 客户端的网络层配置 + private final Bootstrap bootstrap = new Bootstrap(); // 客户端网络层启动对象 + private final EventLoopGroup eventLoopGroupWorker; // 客户端网络层 Netty IO 线程组 + ``` + +* Channel 映射表: + + ```java + private final ConcurrentMap channelTables;// key 是服务器的地址,value 是通道对象 + private final Lock lockChannelTables = new ReentrantLock(); // 锁,控制并发安全 + ``` + +* 定时器:启动定时任务 + + ```java + private final Timer timer = new Timer("ClientHouseKeepingService", true) + ``` + +* 线程池: + + ```java + private ExecutorService publicExecutor; // 公共线程池 + private ExecutorService callbackExecutor; // 回调线程池,客户端发起异步请求,服务器的响应数据由回调线程池处理 + ``` + +* 事件监听器:客户端这里是 null + + ```java + private final ChannelEventListener channelEventListener; + ``` + +构造方法 + +* 无参构造: + + ```java + public NettyRemotingClient(final NettyClientConfig nettyClientConfig) { + this(nettyClientConfig, null); + } + ``` + +* 有参构造: + + ```java + public NettyRemotingClient(nettyClientConfig, channelEventListener) { + // 父类创建了2个信号量,1、控制单向请求的并发度,2、控制异步请求的并发度 + super(nettyClientConfig.getClientOnewaySemaphoreValue(), nettyClientConfig.getClientAsyncSemaphoreValue()); + this.nettyClientConfig = nettyClientConfig; + this.channelEventListener = channelEventListener; + + // 创建公共线程池 + int publicThreadNums = nettyClientConfig.getClientCallbackExecutorThreads(); + if (publicThreadNums <= 0) { + publicThreadNums = 4; + } + this.publicExecutor = Executors.newFixedThreadPool(publicThreadNums,); + + // 创建 Netty IO 线程,1个线程 + this.eventLoopGroupWorker = new NioEventLoopGroup(1, ); + + if (nettyClientConfig.isUseTLS()) { + sslContext = TlsHelper.buildSslContext(true); + } + } + ``` + + + +**** + + + +##### 成员方法 + +* start():启动方法 + + ```java + public void start() { + // channel pipeline 内的 handler 使用的线程资源,默认 4 个 + this.defaultEventExecutorGroup = new DefaultEventExecutorGroup(); + // 配置 netty 客户端启动类对象 + Bootstrap handler = this.bootstrap.group(this.eventLoopGroupWorker).channel(NioSocketChannel.class) + //... + .handler(new ChannelInitializer() { + @Override + public void initChannel(SocketChannel ch) throws Exception { + ChannelPipeline pipeline = ch.pipeline(); + // 加几个handler + pipeline.addLast( + // 服务端的数据,都会来到这个 + new NettyClientHandler()); + } + }); + // 注意 Bootstrap 只是配置好客户端的元数据了,【在这里并没有创建任何 channel 对象】 + // 定时任务 扫描 responseTable 中超时的 ResponseFuture,避免客户端线程长时间阻塞 + this.timer.scheduleAtFixedRate(() -> { + NettyRemotingClient.this.scanResponseTable(); + }, 1000 * 3, 1000); + // 这里是 null,不启动 + if (this.channelEventListener != null) { + this.nettyEventExecutor.start(); + } + } + ``` + +* 单向通信: + + ```java + public RemotingCommand invokeSync(String addr, final RemotingCommand request, long timeoutMillis) { + // 开始时间 + long beginStartTime = System.currentTimeMillis(); + // 获取或者创建客户端与服务端(addr)的通道 channel + final Channel channel = this.getAndCreateChannel(addr); + // 条件成立说明客户端与服务端 channel 通道正常,可以通信 + if (channel != null && channel.isActive()) { + try { + // 执行 rpcHook 拓展点 + doBeforeRpcHooks(addr, request); + // 计算耗时,如果当前耗时已经超过 timeoutMillis 限制,则直接抛出异常,不再进行系统通信 + long costTime = System.currentTimeMillis() - beginStartTime; + if (timeoutMillis < costTime) { + throw new RemotingTimeoutException("invokeSync call timeout"); + } + // 参数1:客户端-服务端通道channel + // 参数二:网络层传输对象,封装着请求数据 + // 参数三:剩余的超时限制 + RemotingCommand response = this.invokeSyncImpl(channel, request, ...); + // 后置处理 + doAfterRpcHooks(RemotingHelper.parseChannelRemoteAddr(channel), request, response); + // 返回响应数据 + return response; + } catch (RemotingSendRequestException e) {} + } else { + this.closeChannel(addr, channel); + throw new RemotingConnectException(addr); + } + } + ``` + + + + + +*** + + + +#### 延迟消息 + +##### 消息处理 + +BrokerStartup 初始化 BrokerController 调用 `registerProcessor()` 方法将 SendMessageProcessor 注册到 NettyRemotingServer 中,对应的请求 ID 为 `SEND_MESSAGE = 10`,NettyServerHandler 在处理请求时通过 CMD 会获取处理器执行 processRequest + +```java +// 参数一:处理通道的事件; 参数二:客户端 +public RemotingCommand processRequest(ChannelHandlerContext ctx, RemotingCommand request) { + RemotingCommand response = null; + response = asyncProcessRequest(ctx, request).get(); + return response; +} +``` + +SendMessageProcessor#asyncConsumerSendMsgBack:异步发送消费者的回调消息 + +* `final RemotingCommand response`:创建一个服务器响应对象 + +* `final ConsumerSendMsgBackRequestHeader requestHeader`:解析出客户端请求头信息,几个**核心字段**: + + * `private Long offset`:回退消息的 CommitLog offset + * `private Integer delayLevel`:延迟级别,一般是 0 + * `private String originMsgId, originTopic`:原始的消息 ID,主题 + * `private Integer maxReconsumeTimes`:最大重试次数,默认是 16 次 + +* `if ()`:鉴权,是否找到订阅组配置、Broker 是否支持写请求、订阅组是否支持消息重试 + +* `String newTopic = MixAll.getRetryTopic(...)`:**获取消费者组的重试主题**,规则是 `%RETRY%GroupName` + +* `int queueIdInt = Math.abs()`:**重试主题下的队列 ID 是 0** + +* `TopicConfig topicConfig`:获取重试主题的配置信息 + +* `MessageExt msgExt`:根据消息的物理 offset 到存储模块查询,内部先查询出这条消息的 size,然后再根据 offset 和 size 查询出整条 msg + +* `final String retryTopic`:获取消息的原始主题 + +* `if (null == retryTopic)`:条件成立说明**当前消息是第一次被回退**, 添加 `RETRY_TOPIC` 属性 + +* `msgExt.setWaitStoreMsgOK(false)`:异步刷盘 + +* `if (msgExt...() >= maxReconsumeTimes || delayLevel < 0)`:消息重试次数超过最大次数,不支持重试 + + `newTopic = MixAll.getDLQTopic()`:**获取消费者的死信队列**,规则是 `%DLQ%GroupName` + + `queueIdInt, topicConfig`:死信队列 ID 为 0,创建死信队列的配置 + +* `if (0 == delayLevel)`:说明延迟级别由 Broker 控制 + + `delayLevel = 3 + msgExt.getReconsumeTimes()`:**延迟级别默认从 3 级开始**,每重试一次,延迟级别 +1 + +* `msgExt.setDelayTimeLevel(delayLevel)`:**将延迟级别设置进消息属性**,存储时会检查该属性,该属性值 > 0 会**将消息的主题和队列修改为调度主题和调度队列 ID** + +* `MessageExtBrokerInner msgInner`:创建一条空消息,消息属性从 offset 查询出来的 msg 中拷贝 + +* `msgInner.setReconsumeTimes)`:重试次数设置为原 msg 的次数 +1 + +* `UtilAll.isBlank(originMsgId)`:判断消息是否是初次返回到服务器 + + * true:说明 msgExt 消息是第一次被返回到服务器,此时使用该 msg 的 id 作为 originMessageId + * false:说明原始消息已经被重试不止 1 次,此时使用 offset 查询出来的 msg 中的 originMessageId + +* `CompletableFuture putMessageResult = ..asyncPutMessage(msgInner)`:调用存储模块存储消息 + + `DefaultMessageStore#asyncPutMessage`: + + * `PutMessageResult result = this.commitLog.asyncPutMessage(msg)`:**将新消息存储到 CommitLog 中** + + + +*** + + + +##### 调度服务 + +DefaultMessageStore 中有成员属性 ScheduleMessageService,在 start 方法中会启动该调度服务 + +成员变量: + +* 延迟级别属性表: + + ```java + // 存储延迟级别对应的 延迟时间长度 (单位:毫秒) + private final ConcurrentMap delayLevelTable; + // 存储延迟级别 queue 的消费进度 offset,该 table 每 10 秒钟,会持久化一次,持久化到本地磁盘 + private final ConcurrentMap offsetTable; + ``` + +* 最大延迟级别: + + ```java + private int maxDelayLevel; + ``` + +* 模块启动状态: + + ```java + private final AtomicBoolean started = new AtomicBoolean(false); + ``` + +* 定时器:内部有线程资源,可执行调度任务 + + ```java + private Timer timer; + ``` + +成员方法: + +* load():加载调度消息,**初始化 delayLevelTable 和 offsetTable** + + ```java + public boolean load() + ``` + +* start():启动消息调度服务 + + ```java + public void start() + ``` + + * `if (started.compareAndSet(false, true))`:将启动状态设为 true + + * `this.timer`:创建定时器对象 + + * `for (... : this.delayLevelTable.entrySet())`:为**每个延迟级别创建一个延迟任务**提交到 timer ,周期执行,这样就可以**将延迟消息得到及时的消费** + + * `this.timer.scheduleAtFixedRate()`:提交周期型任务,延迟 10 秒执行,周期为 10 秒,持久化延迟队列消费进度任务 + + `ScheduleMessageService.this.persist()`:持久化消费进度 + + + +*** + + + +##### 调度任务 + +DeliverDelayedMessageTimerTask 是一个任务类 + +成员变量: + +* 延迟级别:延迟队列任务处理的延迟级别 + + ```java + private final int delayLevel; + ``` + +* 消费进度:延迟队列任务处理的延迟队列的消费进度 + + ```java + private final long offset; + ``` + +成员方法: + +* run():执行任务 + + ```java + public void run() { + if (isStarted()) { + this.executeOnTimeup(); + } + ``` + +* executeOnTimeup():执行任务 + + ```java + public void executeOnTimeup() + ``` + + * `ConsumeQueue cq`:获取出该延迟队列任务处理的**延迟队列 ConsumeQueue** + + * `SelectMappedBufferResult bufferCQ`:根据消费进度查询出 SMBR 对象 + + * `for (; i < bufferCQ.getSize(); i += ConsumeQueue.CQ_STORE_UNIT_SIZE)`:每次读取 20 各字节的数据 + + * `offsetPy, sizePy`:延迟消息的物理偏移量和消息大小 + + * `long tagsCode`:延迟消息的交付时间,在 ReputMessageService 转发时根据消息的 DELAY 属性是否 >0 ,会在 tagsCode 字段存储交付时间 + + * `long deliver... = this.correctDeliverTimestamp(..)`:**校准交付时间**,延迟时间过长会调整为当前时间立刻执行 + + * `long countdown = deliverTimestamp - now`:计算差值 + + * `if (countdown <= 0)`:**消息已经到达交付时间了** + + `MessageExt msgExt`:根据物理偏移量和消息大小获取这条消息 + + `MessageExtBrokerInner msgInner`:**构建一条新消息**,将原消息的属性拷贝过来 + + * `long tagsCodeValue`:不再是交付时间了 + * `MessageAccessor.clearProperty(msgInner, DELAY..)`:清理新消息的 DELAY 属性,避免存储时重定向到延迟队列 + * `msgInner.setTopic()`:**修改主题为原始的主题 `%RETRY%GroupName`** + * `String queueIdStr`:修改队列 ID 为原始的 ID + + `PutMessageResult putMessageResult`:**将新消息存储到 CommitLog**,消费者订阅的是目标主题,会再次消费该消息 + + * `else`:消息还未到达交付时间 + + `ScheduleMessageService.this.timer.schedule()`:创建该延迟级别的任务,延迟 countDown 毫秒之后再执行 + + `ScheduleMessageService.this.updateOffset()`:更新延迟级别队列的消费进度 + + * `PutMessageResult putMessageResult` + + * `bufferCQ == null`:说明通过消费进度没有获取到数据 + + `if (offset < cqMinOffset)`:如果消费进度比最小位点都小,说明是过期数据,重置为最小位点 + + * `ScheduleMessageService.this.timer.schedule()`:重新提交该延迟级别对应的延迟队列任务,延迟 100 毫秒后执行 + + + +**** + + + +#### 事务消息 + +##### 生产者类 + +TransactionMQProducer 类发送事务消息时使用 + +成员变量: + +* 事务回查线程池资源: + + ```java + private ExecutorService executorService; + +* 事务监听器: + + ```java + private TransactionListener transactionListener; + ``` + +核心方法: + +* start():启动方法 + + ```java + public void start() + ``` + + * `this.defaultMQProducerImpl.initTransactionEnv()`:初始化生产者实例和回查线程池资源 + * `super.start()`:启动生产者实例 + +* sendMessageInTransaction():发送事务消息 + + ```java + public TransactionSendResult sendMessageInTransaction(final Message msg, final Object arg) { + msg.setTopic(NamespaceUtil.wrapNamespace(this.getNamespace(), msg.getTopic())); + // 调用实现类的发送方法 + return this.defaultMQProducerImpl.sendMessageInTransaction(msg, null, arg); + } + ``` + + * `TransactionListener transactionListener = getCheckListener()`:获取监听器 + + * `if (null == localTransactionExecuter && null == transactionListener)`:两者都为 null 抛出异常 + + * `MessageAccessor.putProperty(msg, MessageConst.PROPERTY_TRANSACTION_PREPARED, "true")`:**设置事务标志** + + * `sendResult = this.send(msg)`:发送消息,同步发送 + + * `switch (sendResult.getSendStatus())`:**判断发送消息的结果状态** + + * `case SEND_OK`:消息发送成功 + + `msg.setTransactionId(transactionId)`:**设置事务 ID 为消息的 UNIQ_KEY 属性** + + `localTransactionState = ...executeLocalTransactionBranch(msg, arg)`:**执行本地事务** + + * `case SLAVE_NOT_AVAILABLE`:其他情况都需要回滚事务 + + `localTransactionState = LocalTransactionState.ROLLBACK_MESSAGE`:**事务状态设置为回滚** + + * `this.endTransaction(sendResult, ...)`:结束事务 + + * `EndTransactionRequestHeader requestHeader`:构建事务结束头对象 + * `this.mQClientFactory.getMQClientAPIImpl().endTransactionOneway()`:向 Broker 发起事务结束的单向请求 + + + +*** + + + +##### 接受消息 + +SendMessageProcessor 是服务端处理客户端发送来的消息的处理器,`processRequest()` 方法处理请求 + +核心方法: + +* `asyncProcessRequest()`:处理请求 + + ```java + public CompletableFuture asyncProcessRequest(ChannelHandlerContext ctx, + RemotingCommand request) { + final SendMessageContext mqtraceContext; + switch (request.getCode()) { + // 回调消息回退 + case RequestCode.CONSUMER_SEND_MSG_BACK: + return this.asyncConsumerSendMsgBack(ctx, request); + default: + // 解析出请求头对象 + SendMessageRequestHeader requestHeader = parseRequestHeader(request); + if (requestHeader == null) { + return CompletableFuture.completedFuture(null); + } + // 创建上下文对象 + mqtraceContext = buildMsgContext(ctx, requestHeader); + // 前置处理器 + this.executeSendMessageHookBefore(ctx, request, mqtraceContext); + // 判断是否是批量消息 + if (requestHeader.isBatch()) { + return this.asyncSendBatchMessage(ctx, request, mqtraceContext, requestHeader); + } else { + return this.asyncSendMessage(ctx, request, mqtraceContext, requestHeader); + } + } + } + ``` + +* asyncSendMessage():异步处理发送消息 + + ```java + private CompletableFuture asyncSendMessage(ChannelHandlerContext ctx, RemotingCommand request, SendMessageContext mqtraceContext, SendMessageRequestHeader requestHeader) + ``` + + * `RemotingCommand response`:创建响应对象 + + * `MessageExtBrokerInner msgInner = new MessageExtBrokerInner()`:创建 msgInner 对象,并赋值相关的属性,主题和队列 ID 都是请求头中的 + + * `String transFlag`:**获取事务属性** + + * `if (transFlag != null && Boolean.parseBoolean(transFlag))`:判断事务属性是否是 true,走事务消息的存储流程 + + * `putMessageResult = ...asyncPrepareMessage(msgInner)`:**事务消息处理流程** + + ```java + public CompletableFuture asyncPutHalfMessage(MessageExtBrokerInner messageInner) { + // 调用存储模块,将修改后的 msg 存储进 Broker(CommitLog) + return store.asyncPutMessage(parseHalfMessageInner(messageInner)); + } + ``` + + TransactionalMessageBridge#parseHalfMessageInner: + + * `MessageAccessor.putProperty(...)`:**将消息的原主题和队列 ID 放入消息的属性中** + * `msgInner.setSysFlag(...)`:消息设置为非事务状态 + * `msgInner.setTopic(TransactionalMessageUtil.buildHalfTopic())`:**消息主题设置为半消息主题** + * `msgInner.setQueueId(0)`:**队列 ID 设置为 0** + + * `else`:普通消息存储 + + + +*** + + + +##### 回查处理 + +ClientRemotingProcessor 是客户端用于处理请求,创建 MQClientAPIImpl 时将该处理器注册到 Netty 中,`processRequest()` 方法根据请求的命令码,进行不同的处理,事务回查的处理命令码为 `CHECK_TRANSACTION_STATE` + +Broker 端有定时任务发送回查请求 + +成员方法: + +* checkTransactionState():检查事务状态 + + ```java + public RemotingCommand checkTransactionState(ChannelHandlerContext ctx, RemotingCommand request) + ``` + + * `final CheckTransactionStateRequestHeader requestHeader`:解析出请求头对象 + * `final MessageExt messageExt`:从请求 body 中解析出服务器回查的事务消息 + * `String transactionId`:提取 UNIQ_KEY 字段属性值赋值给事务 ID + * `final String group`:提取生产者组名 + * `MQProducerInner producer = this...selectProducer(group)`:根据生产者组获取生产者对象 + * `String addr = RemotingHelper.parseChannelRemoteAddr()`:解析出要回查的 Broker 服务器的地址 + * `producer.checkTransactionState(addr, messageExt, requestHeader)`:生产者的事务回查 + * `Runnable request = new Runnable()`:**创建回查事务状态任务对象** + * 获取生产者的 TransactionCheckListener 和 TransactionListener,选择一个不为 null 的监听器进行事务状态回查 + * `this.processTransactionState()`:处理回查状态 + * `EndTransactionRequestHeader thisHeader`:构建 EndTransactionRequestHeader 对象 + * `DefaultMQProducerImpl...endTransactionOneway()`:向 Broker 发起结束事务单向请求,**二阶段提交** + * `this.checkExecutor.submit(request)`:提交到线程池运行 + + + +参考图:https://www.processon.com/view/link/61c8257e0e3e7474fb9dcbc0 + +参考视频:https://space.bilibili.com/457326371 + + + +*** + + + +##### 事务提交 + +EndTransactionProcessor 类是服务端用来处理客户端发来的提交或者回滚请求 + +* processRequest():处理请求 + + ```java + public RemotingCommand processRequest(ChannelHandlerContext ctx, RemotingCommand request) + ``` + + * `EndTransactionRequestHeader requestHeader`:从请求中解析出 EndTransactionRequestHeader + + * `if (MessageSysFlag.TRANSACTION_COMMIT_TYPE)`:**事务提交** + + `result = this.brokerController...commitMessage(requestHeader)`:根据 commitLogOffset 提取出 halfMsg 消息 + + `MessageExtBrokerInner msgInner`:根据 result 克隆出一条新消息 + + * `msgInner.setTopic(msgExt.getUserProperty(...))`:**设置回原主题** + + * `msgInner.setQueueId(Integer.parseInt(msgExt.getUserProperty(..)))`:**设置回原队列 ID** + * `MessageAccessor.clearProperty()`:清理上面的两个属性 + + `MessageAccessor.clearProperty(msgInner, ...)`:**清理事务属性** + + `RemotingCommand sendResult = sendFinalMessage(msgInner)`:调用存储模块存储至 Broker + + `this.brokerController...deletePrepareMessage(result.getPrepareMessage())`:**向删除(OP)队列添加消息**,消息体的数据是 halfMsg 的 queueOffset,**表示半消息队列指定的 offset 的消息已被删除** + + * `if (this...putOpMessage(msgExt, TransactionalMessageUtil.REMOVETAG))`:添加一条 OP 数据 + * `MessageQueue messageQueue`:新建一个消息队列,OP 队列 + * `return addRemoveTagInTransactionOp(messageExt, messageQueue)`:添加数据 + * `Message message`:创建 OP 消息 + * `writeOp(message, messageQueue)`:写入 OP 消息 + + * `else if (MessageSysFlag.TRANSACTION_ROLLBACK_TYPE)`:**事务回滚** + + `this.brokerController...deletePrepareMessage(result.getPrepareMessage())`:**也需要向 OP 队列添加消息** + + + +**** + + + +### Consumer + +#### 消费者类 + +##### 默认消费 + +DefaultMQPushConsumer 类是默认的消费者类 + +成员变量: + +* 消费者实现类: + + ```java + protected final transient DefaultMQPushConsumerImpl defaultMQPushConsumerImpl; + ``` + +* 消费属性: + + ```java + private String consumerGroup; // 消费者组 + private MessageModel messageModel = MessageModel.CLUSTERING; // 消费模式,默认集群模式 + ``` + +* 订阅信息:key 是主题,value 是过滤表达式,一般是 tag + + ```java + private Map subscription = new HashMap() + ``` + +* 消息监听器:**消息处理逻辑**,并发消费 MessageListenerConcurrently,顺序(分区)消费 MessageListenerOrderly + + ```java + private MessageListener messageListener; + ``` + +* 消费位点:当从 Broker 获取当前组内该 queue 的 offset 不存在时,consumeFromWhere 才有效,默认值代表从队列的最后 offset 开始消费,当队列内再有一条新的 msg 加入时,消费者才会去消费 + + ```java + private ConsumeFromWhere consumeFromWhere = ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET; + ``` + +* 消费时间戳:当消费位点配置的是 CONSUME_FROM_TIMESTAMP 时,并且服务器 Group 内不存在该 queue 的 offset 时,会使用该时间戳进行消费 + + ```java + private String consumeTimestamp = UtilAll.timeMillisToHumanString3(System.currentTimeMillis() - (1000 * 60 * 30));// 消费者创建时间 - 30秒,转换成 格式: 年月日小时分钟秒,比如 20220203171201 + ``` + +* 队列分配策略:主题下的队列分配策略,RebalanceImpl 对象依赖该算法 + + ```java + private AllocateMessageQueueStrategy allocateMessageQueueStrategy; + ``` + +* 消费进度存储器: + + ```java + private OffsetStore offsetStore; + ``` + +核心方法: + +* start():启动消费者 + + ```java + public void start() + ``` + +* shutdown():关闭消费者 + + ```java + public void shutdown() + ``` + +* registerMessageListener():注册消息监听器 + + ```java + public void registerMessageListener(MessageListener messageListener) + ``` + +* subscribe():添加订阅信息,**将订阅信息放入负载均衡对象的 subscriptionInner 中** + + ```java + public void subscribe(String topic, String subExpression) + ``` + +* unsubscribe():删除订阅指定主题的信息 + + ```java + public void unsubscribe(String topic) + ``` + +* suspend():停止消费 + + ```java + public void suspend() + ``` + +* resume():恢复消费 + + ```java + public void resume() + ``` + + + +*** + + + +##### 默认实现 + +DefaultMQPushConsumerImpl 是默认消费者的实现类 + +成员变量: + +* 客户端实例:整个进程内只有一个客户端实例对象 + + ```java + private MQClientInstance mQClientFactory; + ``` + +* 消费者实例:门面对象 + + ```java + private final DefaultMQPushConsumer defaultMQPushConsumer; + ``` + +* **负载均衡**:分配订阅主题的队列给当前消费者,20 秒钟一个周期执行 Rebalance 算法(客户端实例触发) + + ```java + private final RebalanceImpl rebalanceImpl = new RebalancePushImpl(this); + ``` + +* 消费者信息: + + ```java + private final long consumerStartTimestamp; // 消费者启动时间 + private volatile ServiceState serviceState; // 消费者状态 + private volatile boolean pause = false; // 是否暂停 + private boolean consumeOrderly = false; // 是否顺序消费 + ``` + +* **拉取消息**:封装拉消息的 API,服务器 Broker 返回结果中包含下次 Pull 时推荐的 BrokerId,根据本次请求数据的冷热程度进行推荐 + + ```java + private PullAPIWrapper pullAPIWrapper; + ``` + +* **消息消费**服务:并发消费和顺序消费 + + ```java + private ConsumeMessageService consumeMessageService; + ``` + +* 流控: + + ```java + private long queueFlowControlTimes = 0; // 队列流控次数,默认每1000次流控,进行一次日志打印 + private long queueMaxSpanFlowControlTimes = 0; // 流控使用,控制打印日志 + ``` + +* HOOK:钩子方法 + + ```java + // 过滤消息 hook + private final ArrayList filterMessageHookList; + // 消息执行hook,在消息处理前和处理后分别执行 hook.before hook.after 系列方法 + private final ArrayList consumeMessageHookList; + ``` + +核心方法: + +* start():加锁保证线程安全 + + ```java + public synchronized void start() + ``` + + * `this.checkConfig()`:检查配置,包括组名、消费模式、订阅信息、消息监听器等 + * `this.copySubscription()`:拷贝订阅信息到 RebalanceImpl 对象 + * `this.rebalanceImpl.getSubscriptionInner().put(topic, subscriptionData)`:将订阅信息加入 rbl 的 map 中 + * `this.messageListenerInner = ...getMessageListener()`:将消息监听器保存到实例对象 + * `switch (this.defaultMQPushConsumer.getMessageModel())`:判断消费模式,广播模式下直接返回 + * `final String retryTopic`:创建当前**消费者组重试的主题名**,规则 `%RETRY%ConsumerGroup` + * `SubscriptionData subscriptionData = FilterAPI.buildSubscriptionData()`:创建重试主题的订阅数据对象 + * `this.rebalanceImpl.getSubscriptionInner().put(retryTopic, subscriptionData)`:将创建的重试主题加入到 rbl 对象的 map 中,**消息重试时会加入到该主题,消费者订阅这个主题之后,就有机会再次拿到该消息进行消费处理** + * `this.mQClientFactory = ...getOrCreateMQClientInstance()`:获取客户端实例对象 + * `this.rebalanceImpl.`:初始化负载均衡对象,设置**队列分配策略对象**到属性中 + * `this.pullAPIWrapper = new PullAPIWrapper()`:创建拉消息 API 对象,内部封装了查询推荐主机算法 + * `this.pullAPIWrapper.registerFilterMessageHook(filterMessageHookList)`:将过滤 Hook 列表注册到该对象内,消息拉取下来之后会执行该 Hook,**再进行一次自定义的消息过滤** + * `this.offsetStore = new RemoteBrokerOffsetStore()`:默认集群模式下创建消息进度存储器 + * `this.consumeMessageService = ...`:根据消息监听器的类型创建消费服务 + * `this.consumeMessageService.start()`:启动消费服务 + * `boolean registerOK = mQClientFactory.registerConsumer()`:**将消费者注册到客户端实例中**,客户端提供的服务: + * 心跳服务:把订阅数据同步到订阅主题的 Broker + * 拉消息服务:内部 PullMessageService 启动线程,基于 PullRequestQueue 工作,消费者负载均衡分配到队列后会向该队列提交 PullRequest + * 队列负载服务:每 20 秒调用一次 `consumer.doRebalance()` 接口 + * 消息进度持久化 + * 动态调整消费者、消费服务线程池 + * `mQClientFactory.start()`:启动客户端实例 + * ` this.updateTopic`:从 nameserver 获取主题路由数据,生成主题集合放入 rbl 对象的 table + * `this.mQClientFactory.checkClientInBroker()`:检查服务器是否支持消息过滤模式,一般使用 tag 过滤,服务器默认支持 + * `this.mQClientFactory.sendHeartbeatToAllBrokerWithLock()`:向所有已知的 Broker 节点,**发送心跳数据** + * `this.mQClientFactory.rebalanceImmediately()`:唤醒 rbl 线程,触发负载均衡执行 + + + +*** + + + +#### 负载均衡 + +##### 实现方式 + +MQClientInstance#start 中会启动负载均衡服务 RebalanceService: + +```java +public void run() { + // 检查停止标记 + while (!this.isStopped()) { + // 休眠 20 秒,防止其他线程饥饿,所以【每 20 秒负载均衡一次】 + this.waitForRunning(waitInterval); + // 调用客户端实例的负载均衡方法,底层【会遍历所有消费者,调用消费者的负载均衡】 + this.mqClientFactory.doRebalance(); + } +} +``` + +RebalanceImpl 类成员变量: + +* 分配给当前消费者的处理队列:处理消息队列集合,**ProcessQueue 是 MQ 队列在消费者端的快照** + + ```java + protected final ConcurrentMap processQueueTable; + ``` + +* 消费者订阅主题的队列信息: + + ```java + protected final ConcurrentMap> topicSubscribeInfoTable; + ``` + +* 订阅数据: + + ```java + protected final ConcurrentMap subscriptionInner; + ``` + +* 队列分配策略: + + ```java + protected AllocateMessageQueueStrategy allocateMessageQueueStrategy; + ``` + +成员方法: + +* doRebalance():负载均衡方法,以每个消费者实例为粒度进行负载均衡 + + ```java + public void doRebalance(final boolean isOrder) { + // 获取当前消费者的订阅数据 + Map subTable = this.getSubscriptionInner(); + if (subTable != null) { + // 遍历所有的订阅主题 + for (final Entry entry : subTable.entrySet()) { + // 获取订阅的主题 + final String topic = entry.getKey(); + // 按照主题进行负载均衡 + this.rebalanceByTopic(topic, isOrder); + } + } + // 将分配到当前消费者的队列进行过滤,不属于当前消费者订阅主题的直接移除 + this.truncateMessageQueueNotMyTopic(); + } + ``` + + 集群模式下: + + * `Set mqSet = this.topicSubscribeInfoTable.get(topic)`:订阅的主题下的全部队列信息 + + * `cidAll = this...findConsumerIdList(topic, consumerGroup)`:从服务器获取消费者组下的全部消费者 ID + + * `Collections.sort(mqAll)`:主题 MQ 队列和消费者 ID 都进行排序,**保证每个消费者的视图一致性** + + * `allocateResult = strategy.allocate()`: **调用队列分配策略**,给当前消费者进行分配 MessageQueue(下一节) + + * `boolean changed = this.updateProcessQueueTableInRebalance(...)`:**更新队列处理集合**,mqSet 是 rbl 算法分配到当前消费者的 MQ 集合 + + * `while (it.hasNext())`:遍历当前消费者的所有处理队列 + + * `if (mq.getTopic().equals(topic))`:该 MQ 是 本次 rbl 分配算法计算的主题 + + * `if (!mqSet.contains(mq))`:该 MQ 经过 rbl 计算之后,**被分配到其它 Consumer 节点** + + `pq.setDropped(true)`:将删除状态设置为 true + + `if (this.removeUnnecessaryMessageQueue(mq, pq))`:删除不需要的 MQ 队列 + + * `this...getOffsetStore().persist(mq)`:在 MQ 归属的 Broker 节点持久化消费进度 + + * `this...getOffsetStore().removeOffset(mq)`:删除该 MQ 在本地的消费进度 + + * `if (this.defaultMQPushConsumerImpl.isConsumeOrderly() &&)`:是否是**顺序消费**和集群模式 + + `if (pq.getLockConsume().tryLock(1000, ..))`: 获取锁成功,说明顺序消费任务已经停止消费工作 + + `return this.unlockDelay(mq, pq)`:**释放锁 Broker 端的队列锁,向服务器发起 oneway 的解锁请求** + + * `if (pq.hasTempMessage())`:队列中有消息,延迟 20 秒释放队列分布式锁,确保全局范围内只有一个消费任务 运行中 + * `else`:当前消费者本地该消费任务已经退出,直接释放锁 + + `else`:顺序消费任务正在消费一批消息,不可打断,增加尝试获取锁的次数 + + `it.remove()`:从 processQueueTable 移除该 MQ + + * `else if (pq.isPullExpired())`:说明当前 MQ 还是被当前 Consumer 消费,此时判断一下是否超过 2 分钟未到服务器 拉消息,如果条件成立进行上述相同的逻辑 + + * `for (MessageQueue mq : mqSet)`:开始处理当前主题**新分配到当前节点的队列** + + `if (isOrder && !this.lock(mq))`:**顺序消息为了保证有序性,需要获取队列锁** + + `ProcessQueue pq = new ProcessQueue()`:为每个新分配的消息队列创建快照队列 + + `long nextOffset = this.computePullFromWhere(mq)`:**从服务端获取新分配的 MQ 的消费进度** + + `ProcessQueue pre = this.processQueueTable.putIfAbsent(mq, pq)`:保存到处理队列集合 + + `PullRequest pullRequest = new PullRequest()`:**创建拉取请求对象** + + * `this.dispatchPullRequest(pullRequestList)`:放入 PullMessageService 的**本地阻塞队列**内,用于拉取消息工作 + +* lockAll():续约锁,对消费者的所有队列进行续约 + + ```java + public void lockAll() + ``` + + * `HashMap> brokerMqs`:将分配给当前消费者的全部 MQ 按照 BrokerName 分组 + + * `while (it.hasNext())`:遍历所有的分组 + + * `final Set mqs`:获取该 Broker 上分配给当前消费者的 queue 集合 + + * `FindBrokerResult findBrokerResult`:查询 Broker 主节点信息 + + * `LockBatchRequestBody requestBody`:创建请求对象,填充属性 + + * `Set lockOKMQSet`:**以组为单位向 Broker 发起批量续约锁的同步请求**,返回成功的队列集合 + + * `for (MessageQueue mq : lockOKMQSet)`:遍历续约锁成功的 MQ + + `processQueue.setLocked(true)`:**分布式锁状态设置为 true,表示允许顺序消费** + + `processQueue.setLastLockTimestamp(System.currentTimeMillis())`:设置上次获取锁的时间为当前时间 + + * `for (MessageQueue mq : mqs)`:遍历当前 Broker 上的所有队列集合 + + `if (!lockOKMQSet.contains(mq))`:条件成立说明续约锁失败 + + `processQueue.setLocked(false)`:**分布式锁状态设置为 false,表示不允许顺序消费** + + + + +*** + + + +##### 队列分配 + +AllocateMessageQueueStrategy 类是队列的分配策略 + +* 平均分配:AllocateMessageQueueAveragely 类 + + ```java + // 参数一:消费者组 参数二:当前消费者id + // 参数三:主题的全部队列,包括所有 broker 上该主题的 mq 参数四:全部消费者id集合 + public List allocate(String consumerGroup, String currentCID, List mqAll, List cidAll) { + // 获取当前消费者在全部消费者中的位置,【全部消费者是已经排序好的,排在前面的优先分配更多的队列】 + int index = cidAll.indexOf(currentCID); + // 平均分配完以后,还剩余的待分配的 mq 的数量 + int mod = mqAll.size() % cidAll.size(); + // 首先判断整体的 mq 的数量是否小于消费者的数量,小于消费者的数量就说明不够分的,先分一个 + int averageSize = mqAll.size() <= cidAll.size() ? 1 : + // 成立需要多分配一个队列,因为更靠前 + (mod > 0 && index < mod ? mqAll.size() / cidAll.size() + 1 : mqAll.size() / cidAll.size()); + // 获取起始的分配位置 + int startIndex = (mod > 0 && index < mod) ? index * averageSize : index * averageSize + mod; + // 防止索引越界 + int range = Math.min(averageSize, mqAll.size() - startIndex); + // 开始分配,【挨着分配,是直接就把当前的 消费者分配完成】 + for (int i = 0; i < range; i++) { + result.add(mqAll.get((startIndex + i) % mqAll.size())); + } + return result; + } + ``` + + 队列排序后:Q1 → Q2 → Q3,消费者排序后 C1 → C2 → C3 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-平均队列分配.png) + +* 轮流分配:AllocateMessageQueueAveragelyByCircle + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/RocketMQ-平均队列轮流分配.png) + +* 指定机房平均分配:AllocateMessageQueueByMachineRoom,前提是 Broker 的命名规则为 `机房名@BrokerName` + + + + + +*** + + + +#### 拉取服务 + +##### 实现方式 + +MQClientInstance#start 中会启动消息拉取服务:PullMessageService + +```java +public void run() { + // 检查停止标记,【循环拉取】 + while (!this.isStopped()) { + try { + // 从阻塞队列中获取拉消息请求 + PullRequest pullRequest = this.pullRequestQueue.take(); + // 拉取消息,获取请求对应的使用当前消费者组中的哪个消费者,调用消费者的 pullMessage 方法 + this.pullMessage(pullRequest); + } catch (Exception e) { + log.error("Pull Message Service Run Method exception", e); + } + } +} +``` + +DefaultMQPushConsumerImpl#pullMessage: + +* `ProcessQueue processQueue = pullRequest.getProcessQueue()`:获取请求对应的快照队列,并判断是否是删除状态 + +* `this.executePullRequestLater()`:如果当前消费者不是运行状态,则拉消息任务延迟 3 秒后执行,如果是暂停状态延迟 1 秒 + +* **流控的逻辑**: + + `long cachedMessageCount = processQueue.getMsgCount().get()`:获取消费者本地该 queue 快照内缓存的消息数量,如果大于 1000 条,进行流控,延迟 50 毫秒 + + `long cachedMessageSizeInMiB`: 消费者本地该 queue 快照内缓存的消息容量 size,超过 100m 消息未被消费进行流控 + + `if(processQueue.getMaxSpan() > 2000)`:消费者本地缓存消息第一条消息最后一条消息跨度超过 2000 进行流控 + +* `SubscriptionData subscriptionData`:本次拉消息请求订阅的主题数据,如果调用了 `unsubscribe(主题)` 将会获取为 null + +* `PullCallback pullCallback = new PullCallback()`:**拉消息处理回调对象** + + * `pullResult = ...processPullResult()`:预处理 PullResult 结果,将服务器端指定 MQ 的拉消息**下一次的推荐节点**保存到 pullFromWhichNodeTable 中,**并进行消息过滤** + + * `case FOUND`:正常拉取到消息 + + `pullRequest.setNextOffset(pullResult.getNextBeginOffset())`:更新 pullRequest 对象下一次拉取消息的位点 + + `if (pullResult.getMsgFoundList() == null...)`:消息过滤导致消息全部被过滤掉,需要立马发起下一次拉消息 + + `boolean .. = processQueue.putMessage()`:将服务器拉取的消息集合**加入到消费者本地**的 processQueue 内 + + `DefaultMQPushConsumerImpl...submitConsumeRequest()`:**提交消费任务,分为顺序消费和并发消费** + + `Defaul..executePullRequestImmediately(pullRequest)`:将更新过 nextOffset 字段的 PullRequest 对象,再次放到 pullMessageService 的阻塞队列中,**形成闭环** + + * `case NO_NEW_MSG ||NO_MATCHED_MSG`:**表示本次 pull 没有新的可消费的信息** + + `pullRequest.setNextOffset()`:更新更新 pullRequest 对象下一次拉取消息的位点 + + `Defaul..executePullRequestImmediately(pullRequest)`:再次拉取请求 + + * `case OFFSET_ILLEGAL`:**本次 pull 时使用的 offset 是无效的**,即 offset > maxOffset || offset < minOffset + + `pullRequest.setNextOffset()`:调整 pullRequest.nextOffset 为正确的 offset + + `pullRequest.getProcessQueue().setDropped(true)`:设置该 processQueue 为删除状态,如果有该 queue 的消费任务,消费任务会马上停止 + + `DefaultMQPushConsumerImpl.this.executeTaskLater()`:提交异步任务,10 秒后去执行 + + * `DefaultMQPushConsumerImpl...updateOffset()`:更新 offsetStore 该 MQ 的 offset 为正确值,内部直接替换 + + * `DefaultMQPushConsumerImpl...persist()`:持久化该 messageQueue 的 offset 到 Broker 端 + + * `DefaultMQPushConsumerImpl...removeProcessQueue()`: 删除该消费者该 messageQueue 对应的 processQueue + + * 这里没有再次提交 pullRequest 到 pullMessageService 的队列,那该队列不再拉消息了吗? + + 负载均衡 rbl 程序会重建该队列的 processQueue,重建完之后会为该队列创建新的 PullRequest 对象 + +* `int sysFlag = PullSysFlag.buildSysFlag()`:**构建标志对象**,sysFlag 高 4 位未使用,低 4 位使用,从左到右 0000 0011 + + * 第一位:表示是否提交消费者本地该队列的 offset,一般是 1 + * 第二位:表示是否允许服务器端进行长轮询,一般是 1 + * 第三位:表示是否提交消费者本地该主题的订阅数据,一般是 0 + * 第四位:表示是否为类过滤,一般是 0 + +* `this.pullAPIWrapper.pullKernelImpl()`:拉取消息的核心方法 + + + +*** + + + +##### 封装对象 + +PullAPIWrapper 类封装了拉取消息的 API + +成员变量: + +* 推荐拉消息使用的主机 ID: + + ```java + private ConcurrentMap pullFromWhichNodeTable + ``` + +成员方法: + +* pullKernelImpl():拉消息 + + * `FindBrokerResult findBrokerResult`:**本地查询指定 BrokerName 的地址信息**,推荐节点或者主节点 + + * `if (null == findBrokerResult)`:查询不到,就到 Namesrv 获取指定 topic 的路由数据 + + * `if (findBrokerResult.isSlave())`:成立说明 findBrokerResult 表示的主机为 slave 节点,**slave 不存储 offset 信息** + + `sysFlagInner = PullSysFlag.clearCommitOffsetFlag(sysFlagInner)`:将 sysFlag 标记位中 CommitOffset 的位置为 0 + + * `PullMessageRequestHeader requestHeader`:创建请求头对象,封装所有的参数 + + * `PullResult pullResult = this.mQClientFactory.getMQClientAPIImpl().pullMessage()`:调用客户端实例的方法,核心逻辑就是**将业务数据转化为 RemotingCommand 通过 NettyRemotingClient 的 IO 进行通信** + + * `RemotingCommand request`:创建网络层传输对象 RemotingCommand 对象,**请求 ID 为 `PULL_MESSAGE = 11`** + + * `return this.pullMessageSync(...)`:此处是**异步调用,处理结果放入 ResponseFuture 中**,参考服务端小节的处理器类 `NettyServerHandler#processMessageReceived` 方法 +* `RemotingCommand response = responseFuture.getResponseCommand()`:获取服务器端响应数据 response + + * `PullResult pullResult`:从 response 内提取出来拉消息结果对象,将响应头 PullMessageResponseHeader 对象中信息**填充到 PullResult 中**,列出两个重要的字段: + * `private Long suggestWhichBrokerId`:服务端建议客户端下次 Pull 时选择的 BrokerID + * `private Long nextBeginOffset`:客户端下次 Pull 时使用的 offset 信息 + +* `pullCallback.onSuccess(pullResult)`:将 PullResult 交给拉消息结果处理回调对象,调用 onSuccess 方法 + + + +*** + + + +#### 拉取处理 + +##### 处理器 + +BrokerStartup#createBrokerController 方法中创建了 BrokerController 并进行初始化,调用 `registerProcessor()` 方法将处理器 PullMessageProcessor 注册到 NettyRemotingServer 中,对应的请求 ID 为 `PULL_MESSAGE = 11`,NettyServerHandler 在处理请求时通过请求 ID 会获取处理器执行 processRequest 方法 + +```java +// 参数一:服务器与客户端 netty 通道; 参数二:客户端请求; 参数三:是否允许服务器端长轮询,默认 true +private RemotingCommand processRequest(final Channel channel, RemotingCommand request, boolean brokerAllowSuspend) +``` + +* `RemotingCommand response`:创建响应对象,设置为响应类型的请求,响应头是 PullMessageResponseHeader + +* `final PullMessageResponseHeader responseHeader`:获取响应对象的 header + +* `final PullMessageRequestHeader requestHeader`:解析出请求头 PullMessageRequestHeader + +* `response.setOpaque(request.getOpaque())`:设置 opaque 属性,客户端**根据该字段获取 ResponseFuture** 进行处理 + +* 进行一些鉴权的逻辑:是否允许长轮询、提交 offset、topicConfig 是否是空、队列 ID 是否合理 + +* `ConsumerGroupInfo consumerGroupInfo`:获取消费者组信息,包含全部的消费者和订阅数据 + +* `subscriptionData = consumerGroupInfo.findSubscriptionData()`:**获取指定主题的订阅数据** + +* `if (!ExpressionType.isTagType()`:表达式匹配 + +* `MessageFilter messageFilter`:创建消息过滤器,一般是通过 tagCode 进行过滤 + +* `DefaultMessageStore.getMessage()`:**查询消息的核心逻辑,在 Broker 端查询消息**(存储端笔记详解了该源码) + +* `response.setRemark()`:设置此次响应的状态 + +* `responseHeader.set..`:设置响应头对象的一些字段 + +* `switch (this.brokerController.getMessageStoreConfig().getBrokerRole())`:如果当前主机节点角色为 slave 并且**从节点读**并未开启的话,直接给客户端 一个状态 `PULL_RETRY_IMMEDIATELY`,并设置为下次从主节点读 + +* `if (this.brokerController.getBrokerConfig().isSlaveReadEnable())`:消费太慢,**下次从另一台机器拉取** + +* `switch (getMessageResult.getStatus())`:根据 getMessageResult 的状态设置 response 的 code + + ```java + public enum GetMessageStatus { + FOUND, // 查询成功 + NO_MATCHED_MESSAGE, // 未查询到到消息,服务端过滤 tagCode + MESSAGE_WAS_REMOVING, // 查询时赶上 CommitLog 清理过期文件,导致查询失败,立刻尝试 + OFFSET_FOUND_NULL, // 查询时赶上 ConsumerQueue 清理过期文件,导致查询失败,【进行长轮询】 + OFFSET_OVERFLOW_BADLY, // pullRequest.offset 越界 maxOffset + OFFSET_OVERFLOW_ONE, // pullRequest.offset == CQ.maxOffset,【进行长轮询】 + OFFSET_TOO_SMALL, // pullRequest.offset 越界 minOffset + NO_MATCHED_LOGIC_QUEUE, // 没有匹配到逻辑队列 + NO_MESSAGE_IN_QUEUE, // 空队列,创建队列也是因为查询导致,【进行长轮询】 + } + ``` + +* `switch (response.getCode())`:根据 response 状态做对应的业务处理 + + `case ResponseCode.SUCCESS`:查询成功 + + * `final byte[] r = this.readGetMessageResult()`:本次 pull 出来的全部消息导入 byte 数组 + * `response.setBody(r)`:将消息的 byte 数组保存到 response body 字段 + + `case ResponseCode.PULL_NOT_FOUND`:产生这种情况大部分原因是 `pullRequest.offset == queue.maxOffset`,说明已经没有需要获取的消息,此时如果直接返回给客户端,客户端会立刻重新请求,还是继续返回该状态,频繁拉取服务器导致服务器压力大,所以此处**需要长轮询** + + * `if (brokerAllowSuspend && hasSuspendFlag)`:brokerAllowSuspend = true,当长轮询结束再次执行 processRequest 时该参数为 false,所以**每次 Pull 请求至多在服务器端长轮询控制一次** + * `PullRequest pullRequest = new PullRequest()`:创建长轮询 PullRequest 对象 + * `this.brokerController...suspendPullRequest(topic, queueId, pullRequest)`:将长轮询请求对象交给长轮询服务 + * `String key = this.buildKey(topic, queueId)`:构建一个 `topic@queueId` 的 key + * `ManyPullRequest mpr = this.pullRequestTable.get(key)`:从拉请求表中获取对象 + * `mpr.addPullRequest(pullRequest)`:**将 PullRequest 对象放入到长轮询的请求集合中** + * `response = null`:响应设置为 null 内部的 callBack 就不会给客户端发送任何数据,**不进行通信**,否则就又开始重新请求 + +* `boolean storeOffsetEnable`:允许长轮询、sysFlag 表示提交消费者本地该队列的offset、当前 broker 节点角色为 master 节点三个条件成立,才**在 Broker 端存储消费者组内该主题的指定 queue 的消费进度** + +* `return response`:返回 response,不为 null 时外层 processRequestCommand 的 callback 会将数据写给客户端 + + + +*** + + + +##### 长轮询 + +PullRequestHoldService 类负责长轮询,BrokerController#start 方法中调用了 `this.pullRequestHoldService.start()` 启动该服务 + +核心方法: + +* run():核心运行方法 + + ```java + public void run() { + // 循环运行 + while (!this.isStopped()) { + if (this.brokerController.getBrokerConfig().isLongPollingEnable()) { + // 服务器开启长轮询开关:每次循环休眠5秒 + this.waitForRunning(5 * 1000); + } else { + // 服务器关闭长轮询开关:每次循环休眠1秒 + this.waitForRunning(...); + } + // 检查持有的请求 + this.checkHoldRequest(); + // ..... + } + } + ``` + +* checkHoldRequest():检查所有的请求 + + * `for (String key : this.pullRequestTable.keySet())`:**处理所有的 topic@queueId 的逻辑** + * `String[] kArray = key.split(TOPIC_QUEUEID_SEPARATOR)`:key 按照 @ 拆分,得到 topic 和 queueId + * `long offset = this...getMaxOffsetInQueue(topic, queueId)`: 到存储模块查询该 ConsumeQueue 的**最大 offset** + * `this.notifyMessageArriving(topic, queueId, offset)`:通知消息到达 + +* notifyMessageArriving():**通知消息到达**的逻辑,ReputMessageService 消息分发服务也会调用该方法 + + * `ManyPullRequest mpr = this.pullRequestTable.get(key)`:获取对应的的 manyPullRequest 对象 + * `List requestList`:获取该队列下的所有 PullRequest,并进行遍历 + * `List replayList`:当某个 pullRequest 不超时,并且对应的 `CQ.maxOffset <= pullRequest.offset`,就将该 PullRequest 再放入该列表 + * `long newestOffset`:该值为 CQ 的 maxOffset + * `if (newestOffset > request.getPullFromThisOffset())`:**请求对应的队列内可以 pull 消息了,结束长轮询** + * `boolean match`:进行过滤匹配 + * `this.brokerController...executeRequestWhenWakeup()`:将满足条件的 pullRequest 再次提交到线程池内执行 + * `final RemotingCommand response`:执行 processRequest 方法,并且**不会触发长轮询** + * `channel.writeAndFlush(response).addListene()`:**将结果数据发送给客户端** + * `if (System.currentTimeMillis() >= ...)`:判断该 pullRequest 是否超时,超时后的也是重新提交到线程池,并且不进行长轮询 + * `mpr.addPullRequest(replayList)`:将未满足条件的 PullRequest 对象再次添加到 ManyPullRequest 属性中 + + + +*** + + + +##### 结果类 + +GetMessageResult 类成员信息: + +```java +public class GetMessageResult { + // 查询消息时,最底层都是 mappedFile 支持的查询,查询时返回给外层一个 SelectMappedBufferResult, + // mappedFile 每查询一次都会 refCount++ ,通过SelectMappedBufferResult持有mappedFile,完成资源释放的句柄 + private final List messageMapedList = + new ArrayList(100); + + // 该List内存储消息,每一条消息都被转成 ByteBuffer 表示了 + private final List messageBufferList = new ArrayList(100); + // 查询结果状态 + private GetMessageStatus status; + // 客户端下次再向当前Queue拉消息时,使用的 offset + private long nextBeginOffset; + // 当前queue最小offset + private long minOffset; + // 当前queue最大offset + private long maxOffset; + // 消息总byte大小 + private int bufferTotalSize = 0; + // 服务器建议客户端下次到该 queue 拉消息时是否使用 【从节点】 + private boolean suggestPullingFromSlave = false; +} +``` + + + +*** + + + +#### 队列快照 + +##### 成员属性 + +ProcessQueue 类是消费队列的快照 + +成员变量: + +* 属性字段: + + ```java + private final AtomicLong msgCount = new AtomicLong(); // 队列中消息数量 + private final AtomicLong msgSize = new AtomicLong(); // 消息总大小 + private volatile long queueOffsetMax = 0L; // 快照中最大 offset + private volatile boolean dropped = false; // 快照是否移除 + private volatile long lastPullTimestamp = current; // 上一次拉消息的时间 + private volatile long lastConsumeTimestamp = current; // 上一次消费消息的时间 + private volatile long lastLockTimestamp = current; // 上一次获取锁的时间 + ``` + +* **消息容器**:key 是消息偏移量,val 是消息 + + ```java + private final TreeMap msgTreeMap = new TreeMap(); + ``` + +* **顺序消费临时容器**: + + ```java + private final TreeMap consumingMsgOrderlyTreeMap = new TreeMap(); + ``` + +* 锁: + + ```java + private final ReadWriteLock lockTreeMap; // 读写锁 + private final Lock lockConsume; // 重入锁,【顺序消费使用】 + ``` + +* 顺序消费状态: + + ```java + private volatile boolean locked = false; // 是否是锁定状态 + private volatile boolean consuming = false; // 是否是消费中 + ``` + + + +**** + + + +##### 成员方法 + +核心成员方法 + +* putMessage():将 Broker 拉取下来的 msgs 存储到快照队列内,返回为 true 表示提交顺序消费任务,false 表示不提交 + + ```java + public boolean putMessage(final List msgs) + ``` + + * `this.lockTreeMap.writeLock().lockInterruptibly()`:获取写锁 + + * `for (MessageExt msg : msgs)`:遍历 msgs 全部加入 msgTreeMap,key 是消息的 queueOffset + + * `if (!msgTreeMap.isEmpty() && !this.consuming)`:**消息容器中存在未处理的消息,并且不是消费中的状态** + + `dispatchToConsume = true`:代表需要提交顺序消费任务 + + `this.consuming = true`:设置为顺序消费执行中的状态 + + * `this.lockTreeMap.writeLock().unlock()`:释放写锁 + +* removeMessage():移除已经消费的消息,参数是已经消费的消息集合,并发消费使用 + + ```java + public long removeMessage(final List msgs) + ``` + + * `long result = -1`:结果初始化为 -1 + * `this.lockTreeMap.writeLock().lockInterruptibly()`:获取写锁 + * `this.lastConsumeTimestamp = now`:更新上一次消费消息的时间为现在 + * `if (!msgTreeMap.isEmpty())`:判断消息容器是否是空,**是空直接返回 -1** + * `result = this.queueOffsetMax + 1`:设置结果,**删除完后消息容器为空时返回** + * `for (MessageExt msg : msgs)`:将已经消费的消息全部从 msgTreeMap 移除 + * `if (!msgTreeMap.isEmpty())`:移除后容器内还有待消费的消息,**获取第一条消息 offset 返回** + * `this.lockTreeMap.writeLock().unlock()`:释放写锁 + +* takeMessages():获取一批消息,顺序消费使用 + + ```java + public List takeMessages(final int batchSize) + ``` + + * `this.lockTreeMap.writeLock().lockInterruptibly()`:获取写锁 + * `this.lastConsumeTimestamp = now`:更新上一次消费消息的时间为现在 + * `for (int i = 0; i < batchSize; i++)`:从头节点开始获取消息 + * `result.add(entry.getValue())`:将消息放入结果集合 + * `consumingMsgOrderlyTreeMap.put()`:将消息加入顺序消费容器中 + * `if (result.isEmpty())`:条件成立说明顺序消费容器本地快照内的消息全部处理完了,**当前顺序消费任务需要停止** + * `consuming = false`:消费状态置为 false + * `this.lockTreeMap.writeLock().unlock()`:释放写锁 + +* commit():处理完一批消息后调用,顺序消费使用 + + ```java + public long commit() + ``` + + * `this.lockTreeMap.writeLock().lockInterruptibly()`:获取写锁 + * `Long offset = this.consumingMsgOrderlyTreeMap.lastKey()`:获取顺序消费临时容器最后一条数据的 key + * `msgCount, msgSize`:更新顺序消费相关的字段 + * `this.consumingMsgOrderlyTreeMap.clear()`:清空顺序消费容器的数据 + * `return offset + 1`:**消费者下一条消费的位点** + * `this.lockTreeMap.writeLock().unlock()`:释放写锁 + +* cleanExpiredMsg():清除过期消息 + + ```java + public void cleanExpiredMsg(DefaultMQPushConsumer pushConsumer) + ``` + + * `if (pushConsumer.getDefaultMQPushConsumerImpl().isConsumeOrderly()) `:顺序消费不执行过期清理逻辑 + * `int loop = msgTreeMap.size() < 16 ? msgTreeMap.size() : 16`:最多循环 16 次 + * `if (!msgTreeMap.isEmpty() &&)`:如果容器中第一条消息的消费开始时间与当前系统时间差值 > 15min,则取出该消息 + * `else`:直接跳出循环,因为**快照队列内的消息是有顺序的**,第一条消息不过期,其他消息都不过期 + * `pushConsumer.sendMessageBack(msg, 3)`:**消息回退**到服务器,设置该消息的延迟级别为 3 + * `if (!msgTreeMap.isEmpty() && msg.getQueueOffset() == msgTreeMap.firstKey())`:条件成立说明消息回退期间,该目标消息并没有被消费任务成功消费 + * `removeMessage(Collections.singletonList(msg))`:从 treeMap 将该回退成功的 msg 删除 + + + +**** + + + +#### 并发消费 + +##### 成员属性 + +ConsumeMessageConcurrentlyService 负责并发消费服务 + +成员变量: + +* 消息监听器:封装处理消息的逻辑,该监听器由开发者实现,并注册到 defaultMQPushConsumer + + ```java + private final MessageListenerConcurrently messageListener; + ``` + +* 消费属性: + + ```java + private final BlockingQueue consumeRequestQueue; // 消费任务队列 + private final String consumerGroup; // 消费者组 + ``` + +* 线程池: + + ```java + private final ThreadPoolExecutor consumeExecutor; // 消费任务线程池,默认 20 + private final ScheduledExecutorService scheduledExecutorService;// 调度线程池,延迟提交消费任务 + private final ScheduledExecutorService cleanExpireMsgExecutors; // 清理过期消息任务线程池,15min 一次 + ``` + + + +*** + + + +##### 成员方法 + +ConsumeMessageConcurrentlyService 并发消费核心方法 + +* start():启动消费服务,DefaultMQPushConsumerImpl 启动时会调用该方法 + + ```java + public void start() { + // 提交“清理过期消息任务”任务,延迟15min之后执行,之后每15min执行一次 + this.cleanExpireMsgExecutors.scheduleAtFixedRate(() -> cleanExpireMsg()}, + 15, 15, TimeUnit.MINUTES); + } + ``` + +* cleanExpireMsg():清理过期消息任务 + + ```java + private void cleanExpireMsg() + ``` + + * `Iterator> it `:获取分配给当前消费者的队列 + * `while (it.hasNext())`:遍历所有的队列 + * `pq.cleanExpiredMsg(this.defaultMQPushConsumer)`:调用队列快照 ProcessQueue 清理过期消息的方法 + +* submitConsumeRequest():提交消费请求 + + ```java + // 参数一:从服务器 pull 下来的这批消息 + // 参数二:消息归属 mq 在消费者端的 processQueue,提交消费任务之前,msgs已经加入到该pq内了 + // 参数三:消息归属队列 + // 参数四:并发消息此参数无效 + public void submitConsumeRequest(List msgs, ProcessQueue processQueue, MessageQueue messageQueue, boolean dispatchToConsume) + ``` + + * `final int consumeBatchSize`:**一个消费任务可消费的消息数量**,默认为 1 + + * `if (msgs.size() <= consumeBatchSize)`:判断一个消费任务是否可以提交 + + `ConsumeRequest consumeRequest`:封装为消费请求 + + `this.consumeExecutor.submit(consumeRequest)`:提交消费任务,异步执行消息的处理 + + * `else`:说明消息较多,需要多个消费任务 + + `for (int total = 0; total < msgs.size(); )`:将消息拆分成多个消费任务 + +* processConsumeResult():处理消费结果 + + ```java + // 参数一:消费结果状态; 参数二:消费上下文; 参数三:当前消费任务 + public void processConsumeResult(status, context, consumeRequest) + ``` + + * `switch (status)`:根据消费结果状态进行处理 + + * `case CONSUME_SUCCESS`:消费成功 + + `if (ackIndex >= consumeRequest.getMsgs().size())`:消费成功的话,ackIndex 设置成 `消费消息数 - 1` 的值,比如有 5 条消息,这里就设置为 4 + + `ok, failed`:ok 设置为消息数量,failed 设置为 0 + + * `case RECONSUME_LATER`:消费失败 + + `ackIndex = -1`:设置为 -1 + + * `switch (this.defaultMQPushConsumer.getMessageModel())`:判断消费模式,默认是**集群模式** + + * `for (int i = ackIndex + 1; i < msgs.size(); i++)`:当消费失败时 ackIndex 为 -1,i 的起始值为 0,该消费任务内的**全部消息**都会尝试回退给服务器 + + * `MessageExt msg`:提取一条消息 + + * `boolean result = this.sendMessageBack(msg, context)`:**发送消息回退,同步发送** + + * `if (!result)`:回退失败的消息,将**消息的重试属性加 1**,并加入到回退失败的集合 + + * `if (!msgBackFailed.isEmpty())`:回退失败集合不为空 + + `consumeRequest.getMsgs().removeAll(msgBackFailed)`:将回退失败的消息从当前消费任务的 msgs 集合内移除 + + `this.submitConsumeRequestLater()`:**回退失败的消息会再次提交消费任务**,延迟 5 秒钟后再次尝试消费 + +* `long offset = ...removeMessage(msgs)`:从 pq 中删除已经消费成功的消息,返回 offset + +* `this...getOffsetStore().updateOffset()`:更新消费者本地该 mq 的**消费进度** + + + +*** + + + +##### 消费请求 + +ConsumeRequest 是 ConsumeMessageConcurrentlyService 的内部类,是一个 Runnable 任务对象 + +成员变量: + +* 分配到该消费任务的消息: + + ```java + private final List msgs; + ``` + +* 消息队列: + + ```java + private final ProcessQueue processQueue; // 消息处理队列 + private final MessageQueue messageQueue; // 消息队列 + ``` + +核心方法: + +* run():执行任务 + + ```java + public void run() + ``` + + * `if (this.processQueue.isDropped())`:条件成立说明该 queue 经过 rbl 算法分配到其他的 consumer + * `MessageListenerConcurrently listener`:获取消息监听器 + * `ConsumeConcurrentlyContext context`:创建消费上下文对象 + * `defaultMQPushConsumerImpl.resetRetryAndNamespace()`:重置重试标记 + * `final String groupTopic`:获取当前消费者组的重试主题 `%RETRY%GroupName` + * `for (MessageExt msg : msgs)`:遍历所有的消息 + * `String retryTopic = msg.getProperty(...)`:原主题,一般消息没有该属性,只有被重复消费的消息才有 + * `if (retryTopic != null && groupTopic.equals(...))`:条件成立说明该消息是被重复消费的消息 + * `msg.setTopic(retryTopic)`:将被**重复消费的消息主题修改回原主题** + * `if (ConsumeMessageConcurrentlyService...hasHook())`:前置处理 + * `boolean hasException = false`:消费过程中,是否向外抛出异常 + * `MessageAccessor.setConsumeStartTimeStamp()`:给每条消息设置消费开始时间 + * `status = listener.consumeMessage(Collections.unmodifiableList(msgs), context)`:**消费消息** + * `if (ConsumeMessageConcurrentlyService...hasHook())`:后置处理 + * `...processConsumeResult(status, context, this)`:**处理消费结果** + + + +**** + + + +#### 顺序消费 + +##### 成员属性 + +ConsumeMessageOrderlyService 负责顺序消费服务 + +成员变量: + +* 消息监听器:封装处理消息的逻辑,该监听器由开发者实现,并注册到 defaultMQPushConsumer + + ```java + private final MessageListenerOrderly messageListener; + ``` + +* 消费属性: + + ```java + private final BlockingQueue consumeRequestQueue; // 消费任务队列 + private final String consumerGroup; // 消费者组 + private volatile boolean stopped = false; // 消费停止状态 + ``` + +* 线程池: + + ```java + private final ThreadPoolExecutor consumeExecutor; // 消费任务线程池 + private final ScheduledExecutorService scheduledExecutorService;// 调度线程池,延迟提交消费任务 + ``` + +* 队列锁:消费者本地 MQ 锁,**确保本地对于需要顺序消费的 MQ 同一时间只有一个任务在执行** + + ```java + private final MessageQueueLock messageQueueLock = new MessageQueueLock(); + ``` + + ```java + public class MessageQueueLock { + private ConcurrentMap mqLockTable = new ConcurrentHashMap(); + // 获取本地队列锁对象 + public Object fetchLockObject(final MessageQueue mq) { + Object objLock = this.mqLockTable.get(mq); + if (null == objLock) { + objLock = new Object(); + Object prevLock = this.mqLockTable.putIfAbsent(mq, objLock); + if (prevLock != null) { + objLock = prevLock; + } + } + return objLock; + } + } + ``` + + 已经获取了 Broker 端该 Queue 的独占锁,为什么还要获取本地队列锁对象?(这里我也没太懂,先记录下来,本地多线程?) + + * Broker queue 占用锁的角度是 Client 占用,Client 从 Broker 的某个占用了锁的 queue 拉取下来消息以后,将消息存储到消费者本地的 ProcessQueue 中,快照对象的 consuming 属性置为 true,表示本地的队列正在消费处理中 + * ProcessQueue 调用 takeMessages 方法时会获取下一批待处理的消息,获取不到会修改 `consuming = false`,本消费任务马上停止。 + * 如果此时 Pull 再次拉取一批当前 ProcessQueue 的 msg,会再次向顺序消费服务提交消费任务,此时需要本地队列锁对象同步本地线程 + + + +*** + + + +##### 成员方法 + +* start():启动消费服务,DefaultMQPushConsumerImpl 启动时会调用该方法 + + ```java + public void start() + ``` + + * `this.scheduledExecutorService.scheduleAtFixedRate()`:提交锁续约任务,延迟 1 秒执行,周期为 20 秒钟 + * `ConsumeMessageOrderlyService.this.lockMQPeriodically()`:**锁续约任务** + * `this.defaultMQPushConsumerImpl.getRebalanceImpl().lockAll()`:对消费者的所有队列进行续约 + +* submitConsumeRequest():**提交消费任务请求** + + ```java + // 参数:true 表示创建消费任务并提交,false不创建消费任务,说明消费者本地已经有消费任务在执行了 + public void submitConsumeRequest(...., final boolean dispathToConsume) { + if (dispathToConsume) { + // 当前进程内不存在 顺序消费任务,创建新的消费任务,【提交到消费任务线程池】 + ConsumeRequest consumeRequest = new ConsumeRequest(processQueue, messageQueue); + this.consumeExecutor.submit(consumeRequest); + } + } + ``` + +* processConsumeResult():消费结果处理 + + ```java + // 参数1:msgs 本轮循环消费的消息集合 参数2:status 消费状态 + // 参数3:context 消费上下文 参数4:消费任务 + // 返回值:boolean 决定是否继续循环处理pq内的消息 + public boolean processConsumeResult(final List msgs, final ConsumeOrderlyStatus status, final ConsumeOrderlyContext context, final ConsumeRequest consumeRequest) + ``` + + * `if (context.isAutoCommit()) `:默认自动提交 + + * `switch (status)`:根据消费状态进行不同的处理 + + * `case SUCCESS`:消费成功 + + `commitOffset = ...commit()`:调用 pq 提交方法,会将本次循环处理的消息从顺序消费 map 删除,并且返回消息进度 + + * `case SUSPEND_CURRENT_QUEUE_A_MOMENT`:挂起当前队列 + + `consumeRequest.getProcessQueue().makeMessageToConsumeAgain(msgs)`:**回滚消息** + + * `for (MessageExt msg : msgs)`:遍历所有的消息 + * `this.consumingMsgOrderlyTreeMap.remove(msg.getQueueOffset())`:从顺序消费临时容器中移除 + * `this.msgTreeMap.put(msg.getQueueOffset(), msg)`:添加到消息容器 + + * `this.submitConsumeRequestLater()`:再次提交消费任务,1 秒后执行 + + * `continueConsume = false`:设置为 false,**外层会退出本次的消费任务** + + * `this.defaultMQPushConsumerImpl.getOffsetStore().updateOffset(...)`:更新本地消费进度 + + + +**** + + + +##### 消费请求 + +ConsumeRequest 是 ConsumeMessageOrderlyService 的内部类,是一个 Runnable 任务对象 + +核心方法: + +* run():执行任务 + + ```java + public void run() + ``` + + * `final Object objLock`:获取本地锁对象 + + * `synchronized (objLock)`:本地队列锁,确保每个 MQ 的消费任务只有一个在执行,**确保顺序消费** + + * `if(.. || (this.processQueue.isLocked() && !this.processQueue.isLockExpired())))`:当前队列持有分布式锁,并且锁未过期,持锁时间超过 30 秒算过期 + + * `final long beginTime`:消费开始时间 + + * `for (boolean continueConsume = true; continueConsume; )`:根据是否继续消费的标记判断是否继续 + + * `final int consumeBatchSize`:获取每次循环处理的消息数量,一般是 1 + + * `List msgs = this...takeMessages(consumeBatchSize)`:到**处理队列获取一批消息** + + * `if (!msgs.isEmpty())`:获取到了待消费的消息 + + `final ConsumeOrderlyContext context`:创建消费上下文对象 + + `this.processQueue.getLockConsume().lock()`:**获取 lockConsume 锁**,与 RBL 线程同步使用 + + `status = messageListener.consumeMessage(...)`:监听器处理消息 + + `this.processQueue.getLockConsume().unlock()`:**释放 lockConsume 锁** + + `if (null == status)`:处理消息状态返回 null,设置状态为挂起当前队列 + + `continueConsume = ...processConsumeResult()`:消费结果处理 + + * `else`:获取到的消息是空 + + `continueConsume = false`:结束任务循环 + + * `else`:当前队列未持有分布式锁,或者锁过期 + + `ConsumeMessageOrderlyService.this.tryLockLaterAndReconsume()`:重新提交任务,根据是否获取到队列锁,选择延迟 10 毫秒或者 300 毫秒 + + + +*** + + + +### 生产消费 + +生产流程: + +* 首先获取当前消息主题的发布信息,获取不到去 Namesrv 获取(默认有 TBW102),并将获取的到的路由数据转化为发布数据,**创建 MQ 队列在多个 Broker 组**(一组代表一主多从的 Broker 架构),客户端实例同样更新订阅数据,创建 MQ 队列,放入负载均衡服务 topicSubscribeInfoTable 中 +* 然后从发布数据中选择一个 MQ 队列发送消息 +* Broker 端通过 SendMessageProcessor 对发送的消息进行持久化处理,存储到 CommitLog。将重试次数过多的消息加入**死信队列**,将延迟消息的主题和队列修改为调度主题和调度队列 ID +* Broker 启动 ScheduleMessageService 服务会为每个延迟级别创建一个延迟任务,让延迟消息得到有效的处理,将到达交付时间的消息修改为原始主题的原始 ID 存入 CommitLog,消费者就可以进行消费了 + +消费流程: + +* 消息消费队列 ConsumerQueue 存储消息在 CommitLog 的索引,消费者通过该队列来读取消息实体内容,一个 MQ 就对应一个 CQ +* 首先通过负载均衡服务,将分配到当前消费者实例的 MQ 创建 PullRequest,并放入 PullMessageService 的本地阻塞队列内 +* PullMessageService 循环从阻塞队列获取请求对象,发起拉消息请求,并创建 PullCallback 回调对象,将正常拉取的消息**提交到消费任务线程池**,并设置请求的下一次拉取位点,重新放入阻塞队列,形成闭环 +* 消费任务服务对消费失败的消息进行回退,通过内部生产者实例发送回退消息,回退失败的消息会再次提交消费任务重新消费 +* Broker 端对拉取消息的请求进行处理(processRequestCommand),查询成功将消息放入响应体,通过 Netty 写回客户端,当 `pullRequest.offset == queue.maxOffset` 说明该队列已经没有需要获取的消息,将请求放入长轮询集合等待有新消息 +* PullRequestHoldService 负责长轮询,每 5 秒遍历一次长轮询集合,将满足条件的 PullRequest 再次提交到线程池内处理 + + + + + + + +*** + + + + + + + +# Zookeeper + +## 基本介绍 + +### 框架特征 + +Zookeeper 是 Apache Hadoop 项目子项目,为分布式框架提供协调服务,是一个树形目录服务 + +Zookeeper 是基于观察者模式设计的分布式服务管理框架,负责存储和管理共享数据,接受观察者的注册监控,一旦这些数据的状态发生变化,Zookeeper 会通知观察者 + +* Zookeeper 是一个领导者(Leader),多个跟随者(Follower)组成的集群 +* 集群中只要有半数以上节点存活就能正常服务,所以 Zookeeper 适合部署奇数台服务器 +* **全局数据一致**,每个 Server 保存一份相同的数据副本,Client 无论连接到哪个 Server,数据都是一致 +* 更新的请求顺序执行,来自同一个 Client 的请求按其发送顺序依次执行 +* **数据更新原子性**,一次数据更新要么成功,要么失败 +* 实时性,在一定的时间范围内,Client 能读到最新数据 +* 心跳检测,会定时向各个服务提供者发送一个请求(实际上建立的是一个 Socket 长连接) + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/Zookeeper-框架结构.png) + + + +参考视频:https://www.bilibili.com/video/BV1to4y1C7gw + + + + + +*** + + + +### 应用场景 + +Zookeeper 提供的主要功能包括:统一命名服务、统一配置管理、统一集群管理、服务器节点动态上下线、软负载均衡、分布式锁等 + +* 在分布式环境中,经常对应用/服务进行统一命名,便于识别,例如域名相对于 IP 地址更容易被接收 + + ```sh + /service/www.baidu.com # 节点路径 + 192.168.1.1 192.168.1.2 # 节点值 + ``` + + 如果在节点中记录每台服务器的访问数,让访问数最少的服务器去处理最新的客户端请求,可以实现负载均衡 + + ```sh + 192.168.1.1 10 # 次数 + 192.168.1.1 15 + ``` + +* 配置文件同步可以通过 Zookeeper 实现,将配置信息写入某个 ZNode,其他客户端监视该节点,当节点数据被修改,通知各个客户端服务器 + +* 集群环境中,需要实时掌握每个集群节点的状态,可以将这些信息放入 ZNode,通过监控通知的机制实现 + +* 实现客户端实时观察服务器上下线的变化,通过心跳检测实现 + + + + + +*** + + + + + +## 基本操作 + +### 安装搭建 + +安装步骤: + +* 安装 JDK + +* 拷贝 apache-zookeeper-3.5.7-bin.tar.gz 安装包到 Linux 系统下,并解压到指定目录 + +* conf 目录下的配置文件重命名: + + ``` + mv zoo_sample.cfg zoo.cfg + ``` + +* 修改配置文件: + + ```sh + vim zoo.cfg + # 修改内容 + dataDir=/home/seazean/SoftWare/zookeeper-3.5.7/zkData + ``` + +* 在对应目录创建 zkData 文件夹: + + ```sh + mkdir zkData + ``` + +Zookeeper 中的配置文件 zoo.cfg 中参数含义解读: + +* tickTime = 2000:通信心跳时间,**Zookeeper 服务器与客户端心跳**时间,单位毫秒 +* initLimit = 10:Leader 与 Follower 初始通信时限,初始连接时能容忍的最多心跳次数 +* syncLimit = 5:Leader 与 Follower 同步通信时限,LF 通信时间超过 `syncLimit * tickTime`,Leader 认为 Follwer 下线 +* dataDir:保存 Zookeeper 中的数据目录,默认是 tmp目录,容易被 Linux 系统定期删除,所以建议修改 +* clientPort = 2181:客户端连接端口,通常不做修改 + + + +*** + + + +### 操作命令 + +#### 服务端 + +Linux 命令: + +* 启动 ZooKeeper 服务:`./zkServer.sh start` + +* 查看 ZooKeeper 服务:`./zkServer.sh status` + +* 停止 ZooKeeper 服务:`./zkServer.sh stop` + +* 重启 ZooKeeper 服务:`./zkServer.sh restart ` + +* 查看进程是否启动:`jps` + + + + + +*** + + + +#### 客户端 + +Linux 命令: + +* 连接 ZooKeeper 服务端: + + ```sh + ./zkCli.sh # 直接启动 + ./zkCli.sh –server ip:port # 指定 host 启动 + ``` + +客户端命令: + +* 基础操作: + + ```sh + quit # 停止连接 + help # 查看命令帮助 + ``` + +* 创建命令:**`/` 代表根目录** + + ```sh + create /path value # 创建节点,value 可选 + create -e /path value # 创建临时节点 + create -s /path value # 创建顺序节点 + create -es /path value # 创建临时顺序节点,比如node10000012 删除12后也会继续从13开始,只会增加 + ``` + +* 查询命令: + + ```sh + ls /path # 显示指定目录下子节点 + ls –s /path # 查询节点详细信息 + ls –w /path # 监听子节点数量的变化 + stat /path # 查看节点状态 + get –s /path # 查询节点详细信息 + get –w /path # 监听节点数据的变化 + ``` + + ```sh + # 属性,分为当前节点的属性和子节点属性 + czxid: 节点被创建的事务ID, 是ZooKeeper中所有修改总的次序,每次修改都有唯一的 zxid,谁小谁先发生 + ctime: 被创建的时间戳 + mzxid: 最后一次被更新的事务ID + mtime: 最后修改的时间戳 + pzxid: 子节点列表最后一次被更新的事务ID + cversion: 子节点的变化号,修改次数 + dataversion: 节点的数据变化号,数据的变化次数 + aclversion: 节点的访问控制列表变化号 + ephemeralOwner: 用于临时节点,代表节点拥有者的 session id,如果为持久节点则为0 + dataLength: 节点存储的数据的长度 + numChildren: 当前节点的子节点数量 + ``` + +* 删除命令: + + ```sh + delete /path # 删除节点 + deleteall /path # 递归删除节点 + ``` + + + +*** + + + +### 数据结构 + +ZooKeeper 是一个树形目录服务,类似 Unix 的文件系统,每一个节点都被称为 ZNode,每个 ZNode 默认存储 1MB 的数据,节点上会保存数据和节点信息,每个 ZNode 都可以通过其路径唯一标识 + +节点可以分为四大类: + +* PERSISTENT:持久化节点 +* EPHEMERAL:临时节点,客户端和服务器端**断开连接**后,创建的节点删除 +* PERSISTENT_SEQUENTIAL:持久化顺序节点,创建 znode 时设置顺序标识,节点名称后会附加一个值,**顺序号是一个单调递增的计数器**,由父节点维护 +* EPHEMERAL_SEQUENTIAL:临时顺序节点 + +注意:在分布式系统中,顺序号可以被用于为所有的事件进行全局排序,这样客户端可以通过顺序号推断事件的顺序 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/Zookeeper-节点树形结构.png) + + + +*** + + + +### 代码实现 + +添加 Maven 依赖: + +```xml + + org.apache.zookeeper + zookeeper + 3.5.7 + +``` + +实现代码: + +```java +public static void main(String[] args) { + // 参数一:连接地址 + // 参数二:会话超时时间 + // 参数三:监听器 + ZooKeeper zkClient = new ZooKeeper("192.168.3.128:2181", 20000, new Watcher() { + @Override + public void process(WatchedEvent event) { + System.out.println("监听处理函数"); + } + }); +} +``` + + + + + + + +*** + + + + + +## 集群介绍 + +### 相关概念 + +Zookeepe 集群三个角色: + +* Leader 领导者:处理客户端**事务请求**,负责集群内部各服务器的调度 + +* Follower 跟随者:处理客户端非事务请求,转发事务请求给 Leader 服务器,参与 Leader 选举投票 + +* Observer 观察者:观察集群的最新状态的变化,并将这些状态进行同步;处理非事务性请求,事务性请求会转发给 Leader 服务器进行处理;不会参与任何形式的投票。只提供非事务性的服务,通常用于在不影响集群事务处理能力的前提下,提升集群的非事务处理能力(提高集群读的能力,但是也降低了集群选主的复杂程度) + + +相关属性: + +* SID:服务器 ID,用来唯一标识一台集群中的机器,和 myid 一致 +* ZXID:事务 ID,用来标识一次服务器状态的变更,在某一时刻集群中每台机器的 ZXID 值不一定完全一致,这和 ZooKeeper 服务器对于客户端更新请求的处理逻辑有关 + +* Epoch:每个 Leader 任期的代号,同一轮选举投票过程中的该值是相同的,投完一次票就增加 + +选举机制:半数机制,超过半数的投票就通过 + +* 第一次启动选举规则:投票过半数时,服务器 ID 大的胜出 + +* 第二次启动选举规则: + * EPOCH 大的直接胜出 + * EPOCH 相同,事务 ID 大的胜出(事务 ID 越大,数据越新) + * 事务 ID 相同,服务器 ID 大的胜出 + + + + + +*** + + + +### 初次选举 + +选举过程: + +* 服务器 1 启动,发起一次选举,服务器 1 投自己一票,票数不超过半数,选举无法完成,服务器 1 状态保持为 LOOKING +* 服务器 2 启动,再发起一次选举,服务器 1 和 2 分别投自己一票并**交换选票信息**,此时服务器 1 会发现服务器 2 的 SID 比自己投票推举的(服务器 1)大,更改选票为推举服务器 2。投票结果为服务器 1 票数 0 票,服务器 2 票数 2 票,票数不超过半数,选举无法完成,服务器 1、2 状态保持 LOOKING +* 服务器 3 启动,发起一次选举,此时服务器 1 和 2 都会更改选票为服务器 3,投票结果为服务器 3 票数 3 票,此时服务器 3 的票数已经超过半数,服务器 3 当选 Leader,服务器 1、2 更改状态为 FOLLOWING,服务器 3 更改状态为 LEADING +* 服务器 4 启动,发起一次选举,此时服务器 1、2、3 已经不是 LOOKING 状态,不会更改选票信息,交换选票信息结果后服务器 3 为 3 票,服务器 4 为 1 票,此时服务器 4 更改选票信息为服务器 3,并更改状态为 FOLLOWING +* 服务器 5 启动,同 4 一样 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/Zookeeper-初次选举机制.png) + + + +*** + + + +### 再次选举 + +ZooKeeper 集群中的一台服务器出现以下情况之一时,就会开始进入 Leader 选举: + +* 服务器初始化启动 +* 服务器运行期间无法和 Leader 保持连接 + +当一台服务器进入 Leader 选举流程时,当前集群可能会处于以下两种状态: + +* 集群中本来就已经存在一个 Leader,服务器试图去选举 Leader 时会被告知当前服务器的 Leader 信息,对于该服务器来说,只需要和 Leader 服务器建立连接,并进行状态同步即可 + +* 集群中确实不存在 Leader,假设服务器 3 和 5 出现故障,开始进行 Leader 选举,SID 为 1、2、4 的机器投票情况 + + ```sh + (EPOCH,ZXID,SID): (1, 8, 1), (1, 8, 2), (1, 7, 4) + ``` + + 根据选举规则,服务器 2 胜出 + + + +*** + + + +### 数据写入 + +写操作就是事务请求,写入请求直接发送给 Leader 节点:Leader 会先将数据写入自身,同时通知其他 Follower 写入,**当集群中有半数以上节点写入完成**,Leader 节点就会响应客户端数据写入完成 + + + +写入请求直接发送给 Follower 节点:Follower 没有写入权限,会将写请求转发给 Leader,Leader 将数据写入自身,通知其他 Follower 写入,当集群中有半数以上节点写入完成,Leader 会通知 Follower 写入完成,**由 Follower 响应客户端数据写入完成** + + + + + + + +**** + + + + + +## 底层协议 + +### Paxos + +Paxos 算法:基于消息传递且具有高度容错特性的一致性算法 + +优点:快速正确的在一个分布式系统中对某个数据值达成一致,并且保证不论发生任何异常,都不会破坏整个系统的一致性 + +缺陷:在网络复杂的情况下,可能很久无法收敛,甚至陷入活锁的情况 + + + +*** + + + +### ZAB + +#### 算法介绍 + +ZAB 协议借鉴了 Paxos 算法,是为 Zookeeper 设计的支持崩溃恢复的原子广播协议,基于该协议 Zookeeper 设计为只有一台客户端(Leader)负责处理外部的写事务请求,然后 Leader 将数据同步到其他 Follower 节点 + +Zab 协议包括两种基本的模式:消息广播、崩溃恢复 + + + +*** + + + +#### 消息广播 + +ZAB 协议针对事务请求的处理过程类似于一个**两阶段提交**过程:广播事务阶段、广播提交操作 + +* 客户端发起写操作请求,Leader 服务器将请求转化为事务 Proposal 提案,同时为 Proposal 分配一个全局的 ID,即 ZXID +* Leader 服务器为每个 Follower 分配一个单独的队列,将广播的 Proposal **依次放到队列**中去,根据 FIFO 策略进行消息发送 +* Follower 接收到 Proposal 后,将其以事务日志的方式写入本地磁盘中,写入成功后向 Leader 反馈一个 ACK 响应消息 +* Leader 接收到超过半数以上 Follower 的 ACK 响应消息后,即认为消息发送成功,可以发送 Commit 消息 +* Leader 向所有 Follower 广播 commit 消息,同时自身也会完成事务提交,Follower 接收到 Commit 后,将上一条事务提交 + + + +两阶段提交模型可能因为 Leader 宕机带来数据不一致: + +* Leader 发起一个事务 Proposal 后就宕机,Follower 都没有 Proposal +* Leader 收到半数 ACK 宕机,没来得及向 Follower 发送 Commit + + + +*** + + + +#### 崩溃恢复 + +Leader 服务器出现崩溃或者由于网络原因导致 Leader 服务器失去了与**过半 Follower的联系**,那么就会进入崩溃恢复模式,崩溃恢复主要包括两部分:Leader 选举和数据恢复 + +Zab 协议崩溃恢复要求满足以下两个要求: + +* 已经被 Leader 提交的提案 Proposal,必须最终被所有的 Follower 服务器正确提交 +* 丢弃已经被 Leader 提出的,但是没有被提交的 Proposal + +Zab 协议需要保证选举出来的 Leader 需要满足以下条件: + +* 新选举的 Leader 不能包含未提交的 Proposal,即新 Leader 必须都是已经提交了 Proposal 的 Follower 节点 +* 新选举的 Leader 节点含有**最大的 ZXID**,可以避免 Leader 服务器检查 Proposal 的提交和丢弃工作 + + + +数据恢复阶段: + +* 完成 Leader 选举后,在正式开始工作之前(接收事务请求提出新的 Proposal),Leader 服务器会首先确认事务日志中的所有 Proposal 是否已经被集群中过半的服务器 Commit +* Leader 服务器需要确保所有的 Follower 服务器能够接收到每一条事务的 Proposal,并且能将所有已经提交的事务 Proposal 应用到内存数据中,所以只有当 Follower 将所有尚未同步的事务 Proposal 都**从 Leader 服务器上同步**,并且应用到内存数据后,Leader 才会把该 Follower 加入到真正可用的 Follower 列表中 + + + +**** + + + +#### 异常处理 + +Zab 的事务编号 zxid 设计: + +* zxid 是一个 64 位的数字,低 32 位是一个简单的单增计数器,针对客户端每一个事务请求,Leader 在产生新的 Proposal 事务时,都会对该计数器加 1,而高 32 位则代表了 Leader 周期的 epoch 编号 +* epoch 为当前集群所处的代或者周期,每次 Leader 变更后都会在 epoch 的基础上加 1,Follower 只服从 epoch 最高的 Leader 命令,所以旧的 Leader 崩溃恢复之后,其他 Follower 就不会继续追随 +* 每次选举产生一个新的 Leader,就会从新 Leader 服务器上取出本地事务日志中最大编号 Proposal 的 zxid,从 zxid 中解析得到对应的 epoch 编号,然后再对其加 1 后作为新的 epoch 值,并将低 32 位数字归零,由 0 开始重新生成 zxid + +Zab 协议通过 epoch 编号来区分 Leader 变化周期,能够有效避免不同的 Leader 错误的使用了相同的 zxid 编号提出了不一样的 Proposal 的异常情况 + +Zab 数据同步过程:**数据同步阶段要以 Leader 服务器为准** + +* 一个包含了上个 Leader 周期中尚未提交过的事务 Proposal 的服务器启动时,这台机器加入集群中会以 Follower 角色连上 Leader +* Leader 会根据自己服务器上最后提交的 Proposal 和 Follower 服务器的 Proposal 进行比对,让 Follower 进行一个**回退或者前进操作**,到一个已经被集群中过半机器 Commit 的最新 Proposal(源码解析部分详解) + + + + + +*** + + + +### CAP + +CAP 理论指的是在一个分布式系统中,Consistency(一致性)、Availability(可用性)、Partition Tolerance(分区容错性)不能同时成立,ZooKeeper 保证的是 CP + +* ZooKeeper 不能保证每次服务请求的可用性,在极端环境下可能会丢弃一些请求,消费者程序需要重新请求才能获得结果 +* 进行 Leader 选举时**集群都是不可用** + +CAP 三个基本需求,因为 P 是必须的,因此分布式系统选择就在 CP 或者 AP 中: + +* 一致性:指数据在多个副本之间是否能够保持数据一致的特性,当一个系统在数据一致的状态下执行更新操作后,也能保证系统的数据仍然处于一致的状态 +* 可用性:指系统提供的服务必须一直处于可用的状态,即使集群中一部分节点故障,对于用户的每一个操作请求总是能够在有限的时间内返回结果 +* 分区容错性:分布式系统在遇到任何网络分区故障时,仍然能够保证对外提供服务,不会宕机,除非是整个网络环境都发生了故障 + + + + + + + +*** + + + + + +## 监听机制 + +### 实现原理 + +ZooKeeper 中引入了 Watcher 机制来实现了发布/订阅功能,客户端注册监听目录节点,在特定事件触发时,ZooKeeper 会通知所有关注该事件的客户端,保证 ZooKeeper 保存的任何的数据的任何改变都能快速的响应到监听应用程序 + +监听命令:**只能生效一次**,接收一次通知,再次监听需要重新注册 + +```sh +ls –w /path # 监听【子节点数量】的变化 +get –w /path # 监听【节点数据】的变化 +``` + +工作流程: + +* 在主线程中创建 Zookeeper 客户端,这时就会创建**两个线程**,一个负责网络连接通信(connet),一个负责监听(listener) +* 通过 connect 线程将注册的监听事件发送给 Zookeeper +* 在 Zookeeper 的注册监听器列表中将注册的**监听事件添加到列表**中 +* Zookeeper 监听到有数据或路径变化,将消息发送给 listener 线程 +* listener 线程内部调用 process() 方法 + +Curator 框架引入了 Cache 来实现对 ZooKeeper 服务端事件的监听,三种 Watcher: + +* NodeCache:只是监听某一个特定的节点 +* PathChildrenCache:监控一个 ZNode 的子节点 +* TreeCache:可以监控整个树上的所有节点,类似于 PathChildrenCache 和 NodeCache 的组合 + + + + + +*** + + + +### 监听案例 + +#### 整体架构 + +客户端实时监听服务器动态上下线 + + + + + +*** + + + +#### 代码实现 + +客户端:先启动客户端进行监听 + +```java +public class DistributeClient { + private String connectString = "192.168.3.128:2181"; + private int sessionTimeout = 20000; + private ZooKeeper zk; + + public static void main(String[] args) throws Exception { + DistributeClient client = new DistributeClient(); + + // 1 获取zk连接 + client.getConnect(); + + // 2 监听/servers下面子节点的增加和删除 + client.getServerList(); + + // 3 业务逻辑 + client.business(); + } + + private void business() throws InterruptedException { + Thread.sleep(Long.MAX_VALUE); + } + + private void getServerList() throws KeeperException, InterruptedException { + ArrayList servers = new ArrayList<>(); + // 获取所有子节点,true 代表触发监听操作 + List children = zk.getChildren("/servers", true); + + for (String child : children) { + // 获取子节点的数据 + byte[] data = zk.getData("/servers/" + child, false, null); + servers.add(new String(data)); + } + System.out.println(servers); + } + + private void getConnect() throws IOException { + zk = new ZooKeeper(connectString, sessionTimeout, new Watcher() { + @Override + public void process(WatchedEvent event) { + getServerList(); + } + }); + } +} +``` + +服务端:启动时需要 Program arguments + +```java +public class DistributeServer { + private String connectString = "192.168.3.128:2181"; + private int sessionTimeout = 20000; + private ZooKeeper zk; + + public static void main(String[] args) throws Exception { + DistributeServer server = new DistributeServer(); + + // 1 获取 zookeeper 连接 + server.getConnect(); + + // 2 注册服务器到 zk 集群,注意参数 + server.register(args[0]); + + // 3 启动业务逻辑 + server.business(); + } + + private void business() throws InterruptedException { + Thread.sleep(Long.MAX_VALUE); + } + + private void register(String hostname) throws KeeperException, InterruptedException { + // OPEN_ACL_UNSAFE: ACL 开放 + // EPHEMERAL_SEQUENTIAL: 临时顺序节点 + String create = zk.create("/servers/" + hostname, hostname.getBytes(), + ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL); + System.out.println(hostname + " is online"); + } + + private void getConnect() throws IOException { + zk = new ZooKeeper(connectString, sessionTimeout, new Watcher() { + @Override + public void process(WatchedEvent event) { + } + }); + } +} +``` + + + + + +*** + + + + + +## 分布式锁 + +### 实现原理 + +分布式锁可以实现在分布式系统中多个进程有序的访问该临界资源,多个进程之间不会相互干扰 + +核心思想:当客户端要获取锁,则创建节点,使用完锁,则删除该节点 + +1. 客户端获取锁时,在 /locks 节点下创建**临时顺序**节点 + * 使用临时节点是为了防止当服务器或客户端宕机以后节点无法删除(持久节点),导致锁无法释放 + * 使用顺序节点是为了系统自动编号排序,找最小的节点,防止客户端饥饿现象,保证公平 +2. 获取 /locks 目录的所有子节点,判断自己的**子节点序号是否最小**,成立则客户端获取到锁,使用完锁后将该节点删除 + +3. 反之客户端需要找到比自己小的节点,**对其注册事件监听器,监听删除事件** +4. 客户端的 Watcher 收到删除事件通知,就会重新判断当前节点是否是子节点中序号最小,如果是则获取到了锁, 如果不是则重复以上步骤继续获取到比自己小的一个节点并注册监听 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/Zookeeper-分布式锁原理.png) + + + +*** + + + +### Curator + +Curator 实现分布式锁 API,在 Curator 中有五种锁方案: + +- InterProcessSemaphoreMutex:分布式排它锁(非可重入锁) + +- InterProcessMutex:分布式可重入排它锁 + +- InterProcessReadWriteLock:分布式读写锁 + +- InterProcessMultiLock:将多个锁作为单个实体管理的容器 + +- InterProcessSemaphoreV2:共享信号量 + +```java +public class CuratorLock { + + public static CuratorFramework getCuratorFramework() { + // 重试策略对象 + ExponentialBackoffRetry policy = new ExponentialBackoffRetry(3000, 3); + // 构建客户端 + CuratorFramework client = CuratorFrameworkFactory.builder() + .connectString("192.168.3.128:2181") + .connectionTimeoutMs(2000) // 连接超时时间 + .sessionTimeoutMs(20000) // 会话超时时间 单位ms + .retryPolicy(policy) // 重试策略 + .build(); + + // 启动客户端 + client.start(); + System.out.println("zookeeper 启动成功"); + return client; + } + + public static void main(String[] args) { + // 创建分布式锁1 + InterProcessMutex lock1 = new InterProcessMutex(getCuratorFramework(), "/locks"); + + // 创建分布式锁2 + InterProcessMutex lock2 = new InterProcessMutex(getCuratorFramework(), "/locks"); + + new Thread(new Runnable() { + @Override + public void run() { + lock1.acquire(); + System.out.println("线程1 获取到锁"); + + Thread.sleep(5 * 1000); + + lock1.release(); + System.out.println("线程1 释放锁"); + } + }).start(); + + new Thread(new Runnable() { + @Override + public void run() { + lock2.acquire(); + System.out.println("线程2 获取到锁"); + + Thread.sleep(5 * 1000); + + lock2.release(); + System.out.println("线程2 释放锁"); + + } + }).start(); + } +} +``` + +```xml + + org.apache.curator + curator-framework + 4.3.0 + + + org.apache.curator + curator-recipes + 4.3.0 + + + org.apache.curator + curator-client + 4.3.0 +``` + + + + + +*** + + + + + +## 源码解析 + +### 服务端 + +服务端程序的入口 QuorumPeerMain + +```java +public static void main(String[] args) { + QuorumPeerMain main = new QuorumPeerMain(); + main.initializeAndRun(args); +} +``` + +initializeAndRun 的工作: + +* 解析启动参数 + +* 提交周期任务,定时删除过期的快照 + +* 初始化通信模型,默认是 NIO 通信 + + ```java + // QuorumPeerMain#runFromConfig + public void runFromConfig(QuorumPeerConfig config) { + // 通信信组件初始化,默认是 NIO 通信 + ServerCnxnFactory cnxnFactory = ServerCnxnFactory.createFactory(); + // 初始化NIO 服务端socket,绑定2181 端口,可以接收客户端请求 + cnxnFactory.configure(config.getClientPortAddress(), config.getMaxClientCnxns(), false); + // 启动 zk + quorumPeer.start(); + } + ``` + +* 启动 zookeeper + + ```java + // QuorumPeer#start + public synchronized void start() { + if (!getView().containsKey(myid)) { + throw new RuntimeException("My id " + myid + " not in the peer list"); + } + // 冷启动数据恢复,将快照中数据恢复到 DataTree + loadDataBase(); + // 启动通信工厂实例对象 + startServerCnxnFactory(); + try { + adminServer.start(); + } catch (AdminServerException e) { + LOG.warn("Problem starting AdminServer", e); + System.out.println(e); + } + // 准备选举环境 + startLeaderElection(); + // 执行选举 + super.start(); + } + ``` + + + + + +*** + + + +### 选举机制 + +#### 环境准备 + +QuorumPeer#startLeaderElection 初始化选举环境: + +```java +synchronized public void startLeaderElection() { + try { + // Looking 状态,需要选举 + if (getPeerState() == ServerState.LOOKING) { + // 选票组件: myid (serverid), zxid, epoch + // 开始选票时,serverid 是自己,【先投自己】 + currentVote = new Vote(myid, getLastLoggedZxid(), getCurrentEpoch()); + } + } + if (electionType == 0) { + try { + udpSocket = new DatagramSocket(getQuorumAddress().getPort()); + // 响应投票结果线程 + responder = new ResponderThread(); + responder.start(); + } catch (SocketException e) { + throw new RuntimeException(e); + } + } + // 创建选举算法实例 + this.electionAlg = createElectionAlgorithm(electionType); +} +``` + +```java +// zk总的发送和接收队列准备好 +protected Election createElectionAlgorithm(int electionAlgorithm){ + // 负责选举过程中的所有网络通信,创建各种队列和集合 + QuorumCnxManager qcm = createCnxnManager(); + QuorumCnxManager.Listener listener = qcm.listener; + if(listener != null){ + // 启动监听线程, 调用 client = ss.accept()阻塞,等待处理请求 + listener.start(); + // 准备好发送和接收队列准备 + FastLeaderElection fle = new FastLeaderElection(this, qcm); + // 启动选举线程,【WorkerSender 和 WorkerReceiver】 + fle.start(); + le = fle; + } +} +``` + + + +*** + + + +#### 选举源码 + +当 Zookeeper 启动后,首先都是 Looking 状态,通过选举让其中一台服务器成为 Leader + +执行 `super.start()` 相当于执行 `QuorumPeer#run()` 方法 + +```java +public void run() { + case LOOKING: + // 进行选举,选举结束返回最终成为 Leader 胜选的那张选票 + setCurrentVote(makeLEStrategy().lookForLeader()); +} +``` + +FastLeaderElection 类: + +* lookForLeader:选举 + + ```java + public Vote lookForLeader() { + // 正常启动中其他服务器都会向我发送一个投票,保存每个服务器的最新合法有效的投票 + HashMap recvset = new HashMap(); + // 存储合法选举之外的投票结果 + HashMap outofelection = new HashMap(); + // 一次选举的最大等待时间,默认值是0.2s + int notTimeout = finalizeWait; + // 每发起一轮选举,logicalclock++,在没有合法的epoch 数据之前,都使用逻辑时钟代替 + synchronized(this){ + // 更新逻辑时钟,每进行一次选举,都需要更新逻辑时钟 + logicalclock.incrementAndGet(); + // 更新选票(serverid, zxid, epoch) + updateProposal(getInitId(), getInitLastLoggedZxid(), getPeerEpoch()); + } + // 广播选票,把自己的选票发给其他服务器 + sendNotifications(); + // 一轮一轮的选举直到选举成功 + while ((self.getPeerState() == ServerState.LOOKING) && (!stop)){ } + } + ``` + +* sendNotifications:广播选票 + + ```java + private void sendNotifications() { + // 遍历投票参与者,给每台服务器发送选票 + for (long sid : self.getCurrentAndNextConfigVoters()) { + // 创建发送选票 + ToSend notmsg = new ToSend(...); + // 把发送选票放入发送队列 + sendqueue.offer(notmsg); + } + } + ``` + +FastLeaderElection 中有 WorkerSender 线程: + +* `ToSend m = sendqueue.poll(3000, TimeUnit.MILLISECONDS)`:**阻塞获取要发送的选票** + +* `process(m)`:处理要发送的选票 + + `manager.toSend(m.sid, requestBuffer)`:发送选票 + + * `if (this.mySid == sid)`:如果**消息的接收者 sid 是自己**,直接进入自己的 RecvQueue(自己投自己) + + * `else`:如果接收者是其他服务器,创建对应的发送队列或者复用已经存在的发送队列,把消息放入该队列 + + * `connectOne(sid)`:建立连接 + + * `sock.connect(electionAddr, cnxTO)`:建立与 sid 服务器的连接 + + * `initiateConnection(sock, sid)`:初始化连接 + + `startConnection(sock, sid)`:创建并启动发送器线程和接收器线程 + + * `dout = new DataOutputStream(buf)`:**获取 Socket 输出流**,向服务器发送数据 + * `din = new DataInputStream(new BIS(sock.getInputStream())))`:通过输入流读取对方发送过来的选票 + * `if (sid > self.getId())`:接收者 sid 比我的大,没有资格给对方发送连接请求的,直接关闭自己的客户端 + * `SendWorker sw`:初始化发送器,并启动发送器线程,线程 run 方法 + * `while (running && !shutdown && sock != null)`:连接没有断开就一直运行 + * `ByteBuffer b = pollSendQueue()`:从发送队列 SendQueue 中获取发送消息 + * `lastMessageSent.put(sid, b)`:更新对于 sid 这台服务器的最近一条消息 + * `send(b)`:**执行发送** + * `RecvWorker rw`:初始化接收器,并启动接收器线程 + * `din.readFully(msgArray, 0, length)`:输入流接收消息 + * `addToRecvQueue(new Message(messagg, sid))`:将消息放入接收消息 recvQueue 队列 + +FastLeaderElection 中有 WorkerReceiver 线程 + +* `response = manager.pollRecvQueue()`:从 RecvQueue 中**阻塞获取出选举投票消息**(其他服务器发送过来的) + + + + + + + +*** + + + +#### 状态同步 + +选举结束后,每个节点都需要根据角色更新自己的状态,Leader 更新状态为 Leader,其他节点更新状态为 Follower,整体流程: + +* Follower 需要让 Leader 知道自己的状态 (sid, epoch, zxid) +* Leader 接收到信息,**根据信息构建新的 epoch**,要返回对应的信息给 Follower,Follower 更新自己的 epoch +* Leader 需要根据 Follower 的状态,确定何种方式的数据同步 DIFF、TRUNC、SNAP,就是要**以 Leader 服务器数据为准** + * DIFF:Leader 提交的 zxid 比 Follower 的 zxid 大,发送 Proposal 给 Follower 提交执行 + * TRUNC:Follower 的 zxid 比leader 的 zxid 大,Follower 要进行回滚 + * SNAP:Follower 没有任何数据,直接全量同步 +* 执行数据同步,当 Leader 接收到超过半数 Follower 的 Ack 之后,进入正常工作状态,集群启动完成 + + + +核心函数解析: + +* Leader 更新状态入口:`Leader.lead()` + * `zk.loadData()`:恢复数据到内存 + * `cnxAcceptor = new LearnerCnxAcceptor()`:启动通信组件 + * `s = ss.accept()`:等待其他 Follower 节点向 Leader 节点发送同步状态 + * `LearnerHandler fh `:接收到 Follower 的请求,就创建 LearnerHandler 对象 + * `fh.start()`:启动线程,通过 switch-case 语法判断接收的命令,执行相应的操作 +* Follower 更新状态入口:`Follower.followerLeader()` + * `QuorumServer leaderServer = findLeader()`:查找 Leader + * `connectToLeader(addr, hostname) `:与 Leader 建立连接 + * `long newEpochZxid = registerWithLeader(Leader.FOLLOWERINFO)`:向 Leader 注册 + + + + + +*** + + + +#### 主从工作 + +Leader:主服务的工作流程 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/Zookeeper-Leader启动.png) + +Follower:从服务的工作流程,核心函数为 `Follower#followLeader()` + +* `readPacket(qp)`:读取信息 + +* `processPacket(qp)`:处理信息 + + ```java + protected void processPacket(QuorumPacket qp) throws Exception{ + switch (qp.getType()) { + case Leader.PING: + break; + case Leader.PROPOSAL: + break; + case Leader.COMMIT: + break; + case Leader.COMMITANDACTIVATE: + break; + case Leader.UPTODATE: + break; + case Leader.REVALIDATE: + break; + case Leader.SYNC: + break; + default: + break; + } + } + ``` + + + +*** + + + +### 客户端 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Frame/Zookeeper-客户端初始化.png) + + + + + + + + + + + diff --git a/src/content/posts/合集/Java.md b/src/content/posts/合集/Java.md new file mode 100644 index 0000000..74bca83 --- /dev/null +++ b/src/content/posts/合集/Java.md @@ -0,0 +1,17608 @@ +--- +title: Java笔记合集 +published: 2025-10-26 +description: '' +image: '' +tags: [Java] +category: '合集' +draft: false +lang: '' +--- + +# SE + +## 基础 + +### 数据 + +#### 变量类型 + +| | 成员变量 | 局部变量 | 静态变量 | +| :------: | :------------: | :------------------: | :-------------------------: | +| 定义位置 | 在类中,方法外 | 方法中或者方法的形参 | 在类中,方法外 | +| 初始化值 | 有默认初始化值 | 无,赋值后才能使用 | 有默认初始化值 | +| 调用方法 | 对象调用 | | 对象调用,类名调用 | +| 存储位置 | 堆中 | 栈中 | 方法区(JDK8 以后移到堆中) | +| 生命周期 | 与对象共存亡 | 与方法共存亡 | 与类共存亡 | +| 别名 | 实例变量 | | 类变量,静态成员变量 | + +静态变量只有一个,成员变量是类中的变量,局部变量是方法中的变量 + + + +初学时笔记内容参考视频:https://www.bilibili.com/video/BV1TE41177mP,随着学习的深入又增加很多知识 + + + + + +*** + + + +#### 数据类型 + +##### 基本类型 + +Java 语言提供了八种基本类型。六种数字类型(四个整数型,两个浮点型),一种字符类型,还有一种布尔型 + +**byte:** + +- byte 数据类型是 8 位、有符号的,以二进制补码表示的整数,**8 位一个字节**,首位是符号位 +- 最小值是 -128(-2^7)、最大值是 127(2^7-1) +- 默认值是 `0` +- byte 类型用在大型数组中节约空间,主要代替整数,byte 变量占用的空间只有 int 类型的四分之一 +- 例子:`byte a = 100,byte b = -50` + +**short:** + +- short 数据类型是 16 位、有符号的以二进制补码表示的整数 +- 最小值是 -32768(-2^15)、最大值是 32767(2^15 - 1) +- short 数据类型也可以像 byte 那样节省空间,一个 short 变量是 int 型变量所占空间的二分之一 +- 默认值是 `0` +- 例子:`short s = 1000,short r = -20000` + +**int:** + +- int 数据类型是 32 位 4 字节、有符号的以二进制补码表示的整数 +- 最小值是 -2,147,483,648(-2^31)、最大值是 2,147,483,647(2^31 - 1) +- 一般地整型变量默认为 int 类型 +- 默认值是 `0` +- 例子:`int a = 100000, int b = -200000` + +**long:** + +- long 数据类型是 64 位 8 字节、有符号的以二进制补码表示的整数 +- 最小值是 -9,223,372,036,854,775,808(-2^63)、最大值是 9,223,372,036,854,775,807(2^63 -1) +- 这种类型主要使用在需要比较大整数的系统上 +- 默认值是 ` 0L` +- 例子: `long a = 100000L,Long b = -200000L`,L 理论上不分大小写,但是若写成 I 容易与数字 1 混淆,不容易分辩 + +**float:** + +- float 数据类型是单精度、32 位、符合 IEEE 754 标准的浮点数 +- float 在储存大型浮点数组的时候可节省内存空间 +- 默认值是 `0.0f` +- 浮点数不能用来表示精确的值,如货币 +- 例子:`float f1 = 234.5F` + +**double:** + +- double 数据类型是双精度、64 位、符合 IEEE 754 标准的浮点数 +- 浮点数的默认类型为 double 类型 +- double 类型同样不能表示精确的值,如货币 +- 默认值是 `0.0d` +- 例子:`double d1 = 123.4` + +**boolean:** + +- boolean 数据类型表示一位的信息 +- 只有两个取值:true 和 false +- JVM 规范指出 boolean 当做 int 处理,boolean 数组当做 byte 数组处理,这样可以得出 boolean 类型单独使用占了 4 个字节,在数组中是 1 个字节 +- 默认值是 `false` +- 例子:`boolean one = true` + +**char:** + +- char 类型是一个单一的 16 位**两个字节**的 Unicode 字符 +- 最小值是 `\u0000`(即为 0) +- 最大值是 `\uffff`(即为 65535) +- char 数据类型可以**存储任何字符** +- 例子:`char c = 'A'`,`char c = '张'` + + + +**** + + + +##### 上下转型 + +* float 与 double: + + Java 不能隐式执行**向下转型**,因为这会使得精度降低,但是可以向上转型 + + ```java + //1.1字面量属于double类型,不能直接将1.1直接赋值给 float 变量,因为这是向下转型 + float f = 1.1;//报错 + //1.1f 字面量才是 float 类型 + float f = 1.1f; + ``` + + ```java + float f1 = 1.234f; + double d1 = f1; + + double d2 = 1.23; + float f2 = (float) d2;//向下转型需要强转 + ``` + + ```java + int i1 = 1245; + long l1 = i1; + + long l2 = 1234; + int i2 = (int) l2; + ``` + +* 隐式类型转换: + + 字面量 1 是 int 类型,比 short 类型精度要高,因此不能隐式地将 int 类型向下转型为 short 类型 + + 使用 += 或者 ++ 运算符会执行类型转换: + + ```java + short s1 = 1; + s1 += 1; //s1++; + //上面的语句相当于将 s1 + 1 的计算结果进行了向下转型 + s1 = (short) (s1 + 1); + ``` + + + + + +*** + + + +##### 引用类型 + +引用数据类型:类,接口,数组都是引用数据类型,又叫包装类 + +包装类的作用: + +* 包装类作为类首先拥有了 Object 类的方法 +* 包装类作为引用类型的变量可以**存储 null 值** + + +```java +基本数据类型 包装类(引用数据类型) +byte Byte +short Short +int Integer +long Long + +float Float +double Double +char Character +boolean Boolean +``` +Java 为包装类做了一些特殊功能,具体来看特殊功能主要有: + +* 可以把基本数据类型的值转换成字符串类型的值 + 1. 调用 toString() 方法 + 2. 调用 Integer.toString(基本数据类型的值) 得到字符串 + 3. 直接把基本数据类型 + 空字符串就得到了字符串(推荐使用) + +* 把字符串类型的数值转换成对应的基本数据类型的值(**重要**) + + 1. Xxx.parseXxx("字符串类型的数值") → `Integer.parseInt(numStr)` + 2. Xxx.valueOf("字符串类型的数值") → `Integer.valueOf(numStr)` (推荐使用) + + ```java + public class PackageClass02 { + public static void main(String[] args) { + // 1.把基本数据类型的值转成字符串 + Integer it = 100 ; + // a.调用toString()方法。 + String itStr = it.toString(); + System.out.println(itStr+1);//1001 + // b.调用Integer.toString(基本数据类型的值)得到字符串。 + String itStr1 = Integer.toString(it); + System.out.println(itStr1+1);//1001 + // c.直接把基本数据类型+空字符串就得到了字符串。 + String itStr2 = it + ""; + System.out.println(itStr2+1);// 1001 + + // 2.把字符串类型的数值转换成对应的基本数据类型的值 + String numStr = "23"; + int numInt = Integer.valueOf(numStr); + System.out.println(numInt+1);//24 + + String doubleStr = "99.9"; + double doubleDb = Double.valueOf(doubleStr); + System.out.println(doubleDb+0.1);//100.0 + } + } + ``` + + + + +*** + + + +##### 类型对比 + +* 有了基本数据类型,为什么还要引用数据类型? + + > 引用数据类型封装了数据和处理该数据的方法,比如 Integer.parseInt(String) 就是将 String 字符类型数据转换为 Integer 整型 + > + > Java 中大部分类和方法都是针对引用数据类型,包括泛型和集合 + +* 引用数据类型那么好,为什么还用基本数据类型? + + > 引用类型的对象要多储存对象头,对基本数据类型来说空间浪费率太高。逻辑上来讲,Java 只有包装类就够了,为了运行速度,需要用到基本数据类型;优先考虑运行效率的问题,所以二者同时存在是合乎情理的 + +* Java 集合不能存放基本数据类型,只存放对象的引用? + + > 不能放基本数据类型是因为不是 Object 的子类。泛型思想,如果不用泛型要写很多参数类型不同的但功能相同的函数(方法重载) + +* == + + > == 比较基本数据类型:比较的是具体的值 + > == 比较引用数据类型:比较的是对象地址值 + + + +*** + + + +#### 装箱拆箱 + +**自动装箱**:可以直接把基本数据类型的值或者变量赋值给包装类 + +**自动拆箱**:可以把包装类的变量直接赋值给基本数据类型 + +```java +public class PackegeClass { + public static void main(String[] args) { + int a = 12 ; + Integer a1 = 12 ; // 自动装箱 + Integer a2 = a ; // 自动装箱 + Integer a3 = null; // 引用数据类型的默认值可以为null + + Integer c = 100 ; + int c1 = c ; // 自动拆箱 + + Integer it = Integer.valueOf(12); // 手工装箱! + // Integer it1 = new Integer(12); // 手工装箱! + Integer it2 = 12; + + Integer it3 = 111 ; + int it33 = it3.intValue(); // 手工拆箱 + } +} +``` + +**自动装箱**反编译后底层调用 `Integer.valueOf()` 实现,源码: + +```java +public static Integer valueOf(int i) { + if (i >= IntegerCache.low && i <= IntegerCache.high) + // 【缓存池】,本质上是一个数组 + return IntegerCache.cache[i + (-IntegerCache.low)]; + return new Integer(i); +} +``` + +自动拆箱调用 `java.lang.Integer#intValue`,源码: + +```java +public int intValue() { + return value; +} +``` + + + +*** + + + +#### 缓存池 + +new Integer(123) 与 Integer.valueOf(123) 的区别在于: + +- new Integer(123):每次都会新建一个对象 + +- Integer.valueOf(123):会使用缓存池中的对象,多次调用取得同一个对象的引用 + + ```java + Integer x = new Integer(123); + Integer y = new Integer(123); + System.out.println(x == y); // false + Integer z = Integer.valueOf(123); + Integer k = Integer.valueOf(123); + System.out.println(z == k); // true + ``` + +valueOf() 方法的实现比较简单,就是先判断值是否在缓存池中,如果在的话就直接返回缓存池的内容。编译器会在自动装箱过程调用 valueOf() 方法,因此多个值相同且值在缓存池范围内的 Integer 实例使用自动装箱来创建,那么就会引用相同的对象 + +**基本类型对应的缓存池如下:** + +- Boolean values true and false +- all byte values +- Short values between -128 and 127 +- Long values between -128 and 127 +- Integer values between -128 and 127 +- Character in the range \u0000 to \u007F (0 and 127) + +在 jdk 1.8 所有的数值类缓冲池中,**Integer 的缓存池 IntegerCache 很特殊,这个缓冲池的下界是 -128,上界默认是 127**,但是上界是可调的,在启动 JVM 时通过 `AutoBoxCacheMax=` 来指定这个缓冲池的大小,该选项在 JVM 初始化的时候会设定一个名为 java.lang.Integer.IntegerCache 系统属性,然后 IntegerCache 初始化的时候就会读取该系统属性来决定上界 + +```java +Integer x = 100; // 自动装箱,底层调用 Integer.valueOf(1) +Integer y = 100; +System.out.println(x == y); // true + +Integer x = 1000; +Integer y = 1000; +System.out.println(x == y); // false,因为缓存池最大127 + +int x = 1000; +Integer y = 1000; +System.out.println(x == y); // true,因为 y 会调用 intValue 【自动拆箱】返回 int 原始值进行比较 +``` + + + +*** + + + +#### 输入数据 + +语法:`Scanner sc = new Scanner(System.in)` + +* next():遇到了空格,就不再录入数据了,结束标记:空格、tab 键 +* nextLine():可以将数据完整的接收过来,结束标记:回车换行符 + +一般使用 `sc.nextInt()` 或者 `sc.nextLine()` 接受整型和字符串,然后转成需要的数据类型 + +* Scanner:`BufferedReader br = new BufferedReader(new InputStreamReader(System.in))` +* print:`PrintStream.write()` + +> 使用引用数据类型的API + +```java +public static void main(String[] args) { + Scanner sc = new Scanner(System.in); + while (sc.hasNextLine()) { + String msg = sc.nextLine(); + } +} +``` + + + + + +**** + + + +### 数组 + +#### 初始化 + +数组就是存储数据长度固定的容器,存储多个数据的数据类型要一致,**数组也是一个对象** + +创建数组: + +* 数据类型[] 数组名:`int[] arr` (常用) +* 数据类型 数组名[]:`int arr[]` + +静态初始化: + +* 数据类型[] 数组名 = new 数据类型[]{元素1,元素2,...}:`int[] arr = new int[]{11,22,33}` +* 数据类型[] 数组名 = {元素1,元素2,...}:`int[] arr = {44,55,66}` + +动态初始化 + +* 数据类型[] 数组名 = new 数据类型[数组长度]:`int[] arr = new int[3]` + + + +#### 元素访问 + +* **索引**:每一个存储到数组的元素,都会自动的拥有一个编号,从 **0** 开始。这个自动编号称为数组索引(index),可以通过数组的索引访问到数组中的元素 + +* **访问格式**:数组名[索引],`arr[0]` +* **赋值:**`arr[0] = 10` + + + +*** + + + +#### 内存分配 + +内存是计算机中的重要器件,临时存储区域,作用是运行程序。编写的程序是存放在硬盘中,在硬盘中的程序是不会运行的,必须放进内存中才能运行,运行完毕后会清空内存,Java 虚拟机要运行程序,必须要对内存进行空间的分配和管理 + +| 区域名称 | 作用 | +| ---------- | ---------------------------------------------------------- | +| 寄存器 | 给 CPU 使用 | +| 本地方法栈 | JVM 在使用操作系统功能的时候使用 | +| 方法区 | 存储可以运行的 class 文件 | +| 堆内存 | 存储对象或者数组,new 来创建的,都存储在堆内存 | +| 方法栈 | 方法运行时使用的内存,比如 main 方法运行,进入方法栈中执行 | + +内存分配图:**Java 数组分配在堆内存** + +* 一个数组内存图 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/数组内存分配-一个数组内存图.png) + +* 两个数组内存图 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/数组内存分配-两个数组内存图.png) + +* 多个数组指向相同内存图 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/数组内存分配-多个数组指向一个数组内存图.png) + +*** + + + +#### 数组异常 + +* 索引越界异常:ArrayIndexOutOfBoundsException + +* 空指针异常:NullPointerException + + ```java + public class ArrayDemo { + public static void main(String[] args) { + int[] arr = new int[3]; + //把null赋值给数组 + arr = null; + System.out.println(arr[0]); + } + } + ``` + + arr = null,表示变量 arr 将不再保存数组的内存地址,也就不允许再操作数组,因此运行的时候会抛出空指针异常。在开发中,空指针异常是不能出现的,一旦出现了,就必须要修改编写的代码 + + 解决方案:给数组一个真正的堆内存空间引用即可 + + + +*** + + + +#### 二维数组 + +二维数组也是一种容器,不同于一维数组,该容器存储的都是一维数组容器 + +初始化: + +* 动态初始化:数据类型[][] 变量名 = new 数据类型[m] [n],`int[][] arr = new int[3][3]` + + * m 表示这个二维数组,可以存放多少个一维数组,行 + * n 表示每一个一维数组,可以存放多少个元素,列 +* 静态初始化 + * 数据类型[][] 变量名 = new 数据类型 [][]{{元素1, 元素2...} , {元素1, 元素2...} + * 数据类型[][] 变量名 = {{元素1, 元素2...}, {元素1, 元素2...}...} + * `int[][] arr = {{11,22,33}, {44,55,66}}` + +遍历: + +```java +public class Test1 { + /* + 步骤: + 1. 遍历二维数组,取出里面每一个一维数组 + 2. 在遍历的过程中,对每一个一维数组继续完成遍历,获取内部存储的每一个元素 + */ + public static void main(String[] args) { + int[][] arr = {{11, 22, 33}, {33, 44, 55}}; + // 1. 遍历二维数组,取出里面每一个一维数组 + for (int i = 0; i < arr.length; i++) { + //System.out.println(arr[i]); + // 2. 在遍历的过程中,对每一个一维数组继续完成遍历,获取内部存储的每一个元素 + //int[] temp = arr[i]; + for (int j = 0; j < arr[i].length; j++) { + System.out.println(arr[i][j]); + } + } + } +} +``` + + + + + +**** + + + +### 运算 + +* i++ 与 ++i 的区别? + + i++ 表示先将 i 放在表达式中运算,然后再加 1,++i 表示先将 i 加 1,然后再放在表达式中运算 + +* || 和 |,&& 和& 的区别,逻辑运算符 + + **& 和| 称为布尔运算符,位运算符;&& 和 || 称为条件布尔运算符,也叫短路运算符** + + 如果 && 运算符的第一个操作数是 false,就不需要考虑第二个操作数的值了,因为无论第二个操作数的值是什么,其结果都是 false;同样,如果第一个操作数是 true,|| 运算符就返回 true,无需考虑第二个操作数的值;但 & 和 | 却不是这样,它们总是要计算两个操作数。为了提高性能,**尽可能使用 && 和 || 运算符** + +* 异或 ^:两位相异为 1,相同为 0,又叫不进位加法 + +* 同或:两位相同为 1,相异为 0 + +* switch:从 Java 7 开始,可以在 switch 条件判断语句中使用 String 对象 + + ```java + String s = "a"; + switch (s) { + case "a": + System.out.println("aaa"); + break; + case "b": + System.out.println("bbb"); + break; + default: + break; + } + ``` + + switch 不支持 long、float、double,switch 的设计初衷是对那些只有少数几个值的类型进行等值判断,如果值过于复杂,那么用 if 比较合适 + +* break:跳出一层循环 + +* 移位运算:计算机里一般用**补码表示数字**,正数、负数的表示区别就是最高位是 0 还是 1 + + * 正数的原码反码补码相同,最高位为 0 + + ```java + 100: 00000000 00000000 00000000 01100100 + ``` + + * 负数: + 原码:最高位为 1,其余位置和正数相同 + 反码:保证符号位不变,其余位置取反 + 补码:保证符号位不变,其余位置取反后加 1,即反码 +1 + + ```java + -100 原码: 10000000 00000000 00000000 01100100 //32位 + -100 反码: 11111111 11111111 11111111 10011011 + -100 补码: 11111111 11111111 11111111 10011100 + ``` + + 补码 → 原码:符号位不变,其余位置取反加 1 + + 运算符: + + * `>>` 运算符:将二进制位进行右移操作,相当于除 2 + * `<<` 运算符:将二进制位进行左移操作,相当于乘 2 + * `>>>` 运算符:无符号右移,忽略符号位,空位都以 0 补齐 + + 运算规则: + + * 正数的左移与右移,空位补 0 + + * 负数原码的左移与右移,空位补 0 + + 负数反码的左移与右移,空位补 1 + + 负数补码,左移低位补 0(会导致负数变为正数的问题,因为移动了符号位),右移高位补 1 + + * 无符号移位,空位补 0 + + + +**** + + + +### 参数 + +#### 形参实参 + +形参: + +* 形式参数,用于定义方法的时候使用的参数,只能是变量 +* 形参只有在方法被调用的时候,虚拟机才分配内存单元,方法调用结束之后便会释放所分配的内存单元 + +实参:调用方法时传递的数据可以是常量,也可以是变量 + + + +#### 可变参数 + +可变参数用在形参中可以接收多个数据,在方法内部**本质上就是一个数组** + +格式:数据类型... 参数名称 + +作用:传输参数非常灵活,可以不传输参数、传输一个参数、或者传输一个数组 + +可变参数的注意事项: + +* 一个形参列表中可变参数只能有一个 +* 可变参数必须放在形参列表的**最后面** + +```java +public static void main(String[] args) { + sum(); // 可以不传输参数。 + sum(10); // 可以传输一个参数。 + sum(10,20,30); // 可以传输多个参数。 + sum(new int[]{10,30,50,70,90}); // 可以传输一个数组。 +} + +public static void sum(int... nums){ + int sum = 0; + for(int i : a) { + sum += i; + } + return sum; +} +``` + + + +*** + + + +### 方法 + +#### 方法概述 + +方法(method)是将具有独立功能的代码块组织成为一个整体,使其具有特殊功能的代码集 + +注意:方法必须先创建才可以使用,该过程成为方法定义,方法创建后并不是直接可以运行的,需要手动使用后才执行,该过程成为方法调用 + +在方法内部定义的叫局部变量,局部变量不能加 static,包括 protected、private、public 这些也不能加 + +原因:局部变量是保存在栈中的,而静态变量保存于方法区(JDK8 在堆中),局部变量出了方法就被栈回收了,而静态变量不会,所以**在局部变量前不能加 static 关键字**,静态变量是定义在类中,又叫类变量 + + + +*** + + + +#### 定义调用 + +定义格式: + +```java +public static 返回值类型 方法名(参数) { + //方法体; + return 数据 ; +} +``` + +调用格式: + +```java +数据类型 变量名 = 方法名 (参数) ; +``` + +* 方法名:调用方法时候使用的标识 +* 参数:由数据类型和变量名组成,多个参数之间用逗号隔开 +* 方法体:完成功能的代码块 +* return:如果方法操作完毕,有数据返回,用于把数据返回给调用者 + +如果方法操作完毕 + +* void 类型的方法,直接调用即可,而且方法体中一般不写 return +* 非 void 类型的方法,推荐用变量接收调用 + +原理:每个方法在被调用执行的时候,都会进入栈内存,并且拥有自己独立的内存空间,方法内部代码调用完毕之后,会从栈内存中弹栈消失 + + + +*** + + + +#### 注意事项 + +* 方法不能嵌套定义 + + ```java + public class MethodDemo { + public static void main(String[] args) { + } + public static void methodOne() { + public static void methodTwo() { + // 这里会引发编译错误!!! + } + } + } + ``` + +* void 表示无返回值,可以省略 return,也可以单独的书写 return,后面不加数据 + + ```java + public static void methodTwo() { + //return 100; 编译错误,因为没有具体返回值类型 + return; + //System.out.println(100); return语句后面不能跟数据或代码 + } + ``` + + + +*** + + + +#### 方法重载 + +##### 重载介绍 + +方法重载指同一个类中定义的多个方法之间的关系,满足下列条件的多个方法相互构成重载: + +1. 多个方法在**同一个类**中 +2. 多个方法具有**相同的方法名** +3. 多个方法的**参数不相同**,类型不同或者数量不同 + +重载仅对应方法的定义,与方法的调用无关,调用方式参照标准格式 + +重载仅针对同一个类中方法的名称与参数进行识别,与返回值无关,**不能通过返回值来判定两个方法是否构成重载** + +原理:JVM → 运行机制 → 方法调用 → 多态原理 + +```java +public class MethodDemo { + public static void fn(int a) { + //方法体 + } + + public static int fn(int a) { /*错误原因:重载与返回值无关*/ + //方法体 + } + + public static void fn(int a, int b) {/*正确格式*/ + //方法体 + } +} +``` + + + +*** + + + +##### 方法选取 + +重载的方法在编译过程中即可完成识别,方法调用时 Java 编译器会根据所传入参数的声明类型(注意与实际类型区分)来选取重载方法。选取的过程共分为三个阶段: + +* 一阶段:在不考虑对基本类型自动装拆箱 (auto-boxing,auto-unboxing),以及可变长参数的情况下选取重载方法 +* 二阶段:如果第一阶段中没有找到适配的方法,那么在允许自动装拆箱,但不允许可变长参数的情况下选取重载方法 +* 三阶段:如果第二阶段中没有找到适配的方法,那么在允许自动装拆箱以及可变长参数的情况下选取重载方法 + +如果 Java 编译器在同一个阶段中找到了多个适配的方法,那么会选择一个最为贴切的,而决定贴切程度的一个关键就是形式参数类型的继承关系,**一般会选择形参为参数类型的子类的方法,因为子类时更具体的实现**: + +```java +public class MethodDemo { + void invoke(Object obj, Object... args) { ... } + void invoke(String s, Object obj, Object... args) { ... } + + invoke(null, 1); // 调用第二个invoke方法,选取的第二阶段 + invoke(null, 1, 2); // 调用第二个invoke方法,匹配第一个和第二个,但String是Object的子类 + + invoke(null, new Object[]{1}); // 只有手动绕开可变长参数的语法糖,才能调用第一个invoke方法 + // 可变参数底层是数组,JVM->运行机制->代码优化 +} +``` + +因此不提倡可变长参数方法的重载 + + + +*** + + + +##### 继承重载 + +除了同一个类中的方法,重载也可以作用于这个类所继承而来的方法。如果子类定义了与父类中**非私有方法**同名的方法,而且这两个方法的参数类型不同,那么在子类中,这两个方法同样构成了重载 + +* 如果这两个方法都是静态的,那么子类中的方法隐藏了父类中的方法 +* 如果这两个方法都不是静态的,且都不是私有的,那么子类的方法重写了父类中的方法,也就是**多态** + + + +*** + + + +#### 参数传递 + +Java 的参数是以**值传递**的形式传入方法中 + +值传递和引用传递的区别在于传递后会不会影响实参的值:**值传递会创建副本**,引用传递不会创建副本 + +* 基本数据类型:形式参数的改变,不影响实际参数 + + 每个方法在栈内存中,都会有独立的栈空间,方法运行结束后就会弹栈消失 + + ```java + public class ArgsDemo01 { + public static void main(String[] args) { + int number = 100; + System.out.println("调用change方法前:" + number);//100 + change(number); + System.out.println("调用change方法后:" + number);//100 + } + public static void change(int number) { + number = 200; + } + } + ``` + +* 引用类型:形式参数的改变,影响实际参数的值 + + **引用数据类型的传参,本质上是将对象的地址以值的方式传递到形参中**,内存中会造成两个引用指向同一个内存的效果,所以即使方法弹栈,堆内存中的数据也已经是改变后的结果 + + ```java + public class PassByValueExample { + public static void main(String[] args) { + Dog dog = new Dog("A"); + func(dog); + System.out.println(dog.getName()); // B + } + private static void func(Dog dog) { + dog.setName("B"); + } + } + class Dog { + String name;//..... + } + ``` + + + + + +*** + + + +### 枚举 + +枚举是 Java 中的一种特殊类型,为了做信息的标志和信息的分类 + +定义枚举的格式: + +```java +修饰符 enum 枚举名称{ + 第一行都是罗列枚举实例的名称。 +} +``` + +枚举的特点: + +* 枚举类是用 final 修饰的,枚举类不能被继承 +* 枚举类默认继承了 java.lang.Enum 枚举类 +* 枚举类的第一行都是常量,必须是罗列枚举类的实例名称 +* 枚举类相当于是多例设计模式 +* 每个枚举项都是一个实例,是一个静态成员变量 + +| 方法名 | 说明 | +| ------------------------------------------------- | ------------------------------------ | +| String name() | 获取枚举项的名称 | +| int ordinal() | 返回枚举项在枚举类中的索引值 | +| int compareTo(E o) | 比较两个枚举项,返回的是索引值的差值 | +| String toString() | 返回枚举常量的名称 | +| static T valueOf(Class type,String name) | 获取指定枚举类中的指定名称的枚举值 | +| values() | 获得所有的枚举项 | + +* 源码分析: + + ```java + enum Season { + SPRING , SUMMER , AUTUMN , WINTER; + } + // 枚举类的编译以后源代码: + public final class Season extends java.lang.Enum { + public static final Season SPRING = new Season(); + public static final Season SUMMER = new Season(); + public static final Season AUTUMN = new Season(); + public static final Season WINTER = new Season(); + + public static Season[] values(); + public static Season valueOf(java.lang.String); + } + ``` + +* API 使用 + + ```java + public class EnumDemo { + public static void main(String[] args){ + // 获取索引 + Season s = Season.SPRING; + System.out.println(s); //SPRING + System.out.println(s.ordinal()); // 0,该值代表索引,summer 就是 1 + s.s.doSomething(); + // 获取全部枚举 + Season[] ss = Season.values(); + for(int i = 0; i < ss.length; i++){ + System.out.println(ss[i]); + } + + int result = Season.SPRING.compareTo(Season.WINTER); + System.out.println(result);//-3 + } + } + enum Season { + SPRING , SUMMER , AUTUMN , WINTER; + + public void doSomething() { + System.out.println("hello "); + } + } + ``` + + + + + + +*** + + + +### Debug + +Debug 是供程序员使用的程序调试工具,它可以用于查看程序的执行流程,也可以用于追踪程序执行过程来调试程序。 + +加断点 → Debug 运行 → 单步运行 → 看 Debugger 窗口 → 看 Console 窗口 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/Debug按键说明.png) + +Debug条件断点 + + + + + + + +*** + + + + + +## 对象 + +### 概述 + +Java 是一种面向对象的高级编程语言 + +面向对象三大特征:**封装,继承,多态** + +两个概念:类和对象 + +* 类:相同事物共同特征的描述,类只是学术上的一个概念并非真实存在的,只能描述一类事物 +* 对象:是真实存在的实例, 实例 == 对象,**对象是类的实例化** +* 结论:有了类和对象就可以描述万千世界所有的事物,必须先有类才能有对象 + + + +*** + + + +### 类 + +#### 定义 + +定义格式 + +```java +修饰符 class 类名{ +} +``` + +1. 类名的首字母建议大写,满足驼峰模式,比如 StudentNameCode +2. 一个 Java 代码中可以定义多个类,按照规范一个 Java 文件一个类 +3. 一个 Java 代码文件中,只能有一个类是 public 修饰,**public 修饰的类名必须成为当前 Java 代码的文件名称** + +```java +类中的成分:有且仅有五大成分 +修饰符 class 类名{ + 1.成员变量(Field): 描述类或者对象的属性信息的。 + 2.成员方法(Method): 描述类或者对象的行为信息的。 + 3.构造器(Constructor): 初始化一个对象返回。 + 4.代码块 + 5.内部类 + } +类中有且仅有这五种成分,否则代码报错! +public class ClassDemo { + System.out.println(1);//报错 +} +``` + + + +*** + + + +#### 构造器 + +构造器格式: + +```java +修饰符 类名(形参列表){ + +} +``` + +作用:初始化类的一个对象返回 + +分类:无参数构造器,有参数构造器 + +注意:**一个类默认自带一个无参数构造器**,写了有参数构造器默认的无参数构造器就消失,还需要用无参数构造器就要重新写 + +构造器初始化对象的格式:类名 对象名称 = new 构造器 + +* 无参数构造器的作用:初始化一个类的对象(使用对象的默认值初始化)返回 +* 有参数构造器的作用:初始化一个类的对象(可以在初始化对象的时候为对象赋值)返回 + + + +------ + + + +### 包 + +包:分门别类的管理各种不同的技术,便于管理技术,扩展技术,阅读技术 + +定义包的格式:`package 包名`,必须放在类名的最上面 + +导包格式:`import 包名.类名` + +相同包下的类可以直接访问;不同包下的类必须导包才可以使用 + + + +*** + + + +### 封装 + +封装的哲学思维:合理隐藏,合理暴露 + +封装最初的目的:提高代码的安全性和复用性,组件化 + +封装的步骤: + +1. **成员变量应该私有,用 private 修饰,只能在本类中直接访问** +2. **提供成套的 getter 和 setter 方法暴露成员变量的取值和赋值** + +使用 private 修饰成员变量的原因:实现数据封装,不想让别人使用修改你的数据,比较安全 + + + +*** + + + +### this + +this 关键字的作用: + +* this 关键字代表了当前对象的引用 +* this 出现在方法中:**哪个对象调用这个方法 this 就代表谁** +* this 可以出现在构造器中:代表构造器正在初始化的那个对象 +* this 可以区分变量是访问的成员变量还是局部变量 + + + +------ + + + +### static + +#### 基本介绍 + +Java 是通过成员变量是否有 static 修饰来区分是类的还是属于对象的 + +按照有无 static 修饰,成员变量和方法可以分为: + +* 成员变量: + * 静态成员变量(类变量):static 修饰的成员变量,属于类本身,**与类一起加载一次,只有一个**,直接用类名访问即可 + * 实例成员变量:无 static 修饰的成员变量,属于类的每个对象的,**与类的对象一起加载**,对象有多少个,实例成员变量就加载多少个,必须用类的对象来访问 + +* 成员方法: + * 静态方法:有 static 修饰的成员方法称为静态方法也叫类方法,属于类本身的,直接用类名访问即可 + * 实例方法:无 static 修饰的成员方法称为实例方法,属于类的每个对象的,必须用类的对象来访问 + + + +**** + + + +#### static 用法 + +成员变量的访问语法: + +* 静态成员变量:只有一份可以被类和类的对象**共享访问** + * 类名.静态成员变量(同一个类中访问静态成员变量可以省略类名不写) + * 对象.静态成员变量(不推荐) + +* 实例成员变量: + * 对象.实例成员变量(先创建对象) + +成员方法的访问语法: + +* 静态方法:有 static 修饰,属于类 + + * 类名.静态方法(同一个类中访问静态成员可以省略类名不写) + * 对象.静态方法(不推荐,参考 JVM → 运行机制 → 方法调用) + +* 实例方法:无 static 修饰,属于对象 + + * 对象.实例方法 + + ```java + public class Student { + // 1.静态方法:有static修饰,属于类,直接用类名访问即可! + public static void inAddr(){ } + // 2.实例方法:无static修饰,属于对象,必须用对象访问! + public void eat(){} + + public static void main(String[] args) { + // a.类名.静态方法 + Student.inAddr(); + inAddr(); + // b.对象.实例方法 + // Student.eat(); // 报错了! + Student sea = new Student(); + sea.eat(); + } + } + ``` + + + +*** + + + +#### 两个问题 + +内存问题: + +* 栈内存存放 main 方法和地址 + +* 堆内存存放对象和变量 + +* 方法区存放 class 和静态变量(jdk8 以后移入堆) + +访问问题: + +* 实例方法是否可以直接访问实例成员变量?可以,因为它们都属于对象 +* 实例方法是否可以直接访问静态成员变量?可以,静态成员变量可以被共享访问 +* 实例方法是否可以直接访问实例方法? 可以,实例方法和实例方法都属于对象 +* 实例方法是否可以直接访问静态方法?可以,静态方法可以被共享访问 +* 静态方法是否可以直接访问实例变量? 不可以,实例变量**必须用对象访问**!! +* 静态方法是否可以直接访问静态变量? 可以,静态成员变量可以被共享访问。 +* 静态方法是否可以直接访问实例方法? 不可以,实例方法必须用对象访问!! +* 静态方法是否可以直接访问静态方法?可以,静态方法可以被共享访问!! + + + +------ + + + +### 继承 + +#### 基本介绍 + +继承是 Java 中一般到特殊的关系,是一种子类到父类的关系 + +* 被继承的类称为:父类/超类 +* 继承父类的类称为:子类 + +继承的作用: + +* **提高代码的复用**,相同代码可以定义在父类中 +* 子类继承父类,可以直接使用父类这些代码(相同代码重复利用) +* 子类得到父类的属性(成员变量)和行为(方法),还可以定义自己的功能,子类更强大 + +继承的特点: + +1. 子类的全部构造器默认先访问父类的无参数构造器,再执行自己的构造器 +2. **单继承**:一个类只能继承一个直接父类 +3. 多层继承:一个类可以间接继承多个父类(家谱) +4. 一个类可以有多个子类 +5. 一个类要么默认继承了 Object 类,要么间接继承了 Object 类,**Object 类是 Java 中的祖宗类** + +继承的格式: + +```java +子类 extends 父类{ + +} +``` + +子类不能继承父类的东西: + +* 子类不能继承父类的构造器,子类有自己的构造器 +* 子类是不能可以继承父类的私有成员的,可以反射暴力去访问继承自父类的私有成员 +* 子类是不能继承父类的静态成员,父类静态成员只有一份可以被子类共享访问,**共享并非继承** + +```java +public class ExtendsDemo { + public static void main(String[] args) { + Cat c = new Cat(); + // c.run(); + Cat.test(); + System.out.println(Cat.schoolName); + } +} + +class Cat extends Animal{ +} + +class Animal{ + public static String schoolName ="seazean"; + public static void test(){} + private void run(){} +} +``` + + + +*** + + + +#### 变量访问 + +继承后成员变量的访问特点:**就近原则**,子类有找子类,子类没有找父类,父类没有就报错 + +如果要申明访问父类的成员变量可以使用:super.父类成员变量,super指父类引用 + +```java +public class ExtendsDemo { + public static void wmain(String[] args) { + Wolf w = new Wolf();w + w.showName(); + } +} + +class Wolf extends Animal{ + private String name = "子类狼"; + public void showName(){ + String name = "局部名称"; + System.out.println(name); // 局部name + System.out.println(this.name); // 子类对象的name + System.out.println(super.name); // 父类的 + System.out.println(name1); // 父类的 + //System.out.println(name2); // 报错。子类父类都没有 + } +} + +class Animal{ + public String name = "父类动物名称"; + public String name1 = "父类"; +} +``` + + + +*** + + + +#### 方法访问 + +子类继承了父类就得到了父类的方法,**可以直接调用**,受权限修饰符的限制,也可以重写方法 + +方法重写:子类重写一个与父类申明一样的方法来**覆盖**父类的该方法 + +方法重写的校验注解:@Override + +* 方法加了这个注解,那就必须是成功重写父类的方法,否则报错 +* @Override 优势:可读性好,安全,优雅 + +**子类可以扩展父类的功能,但不能改变父类原有的功能**,重写有以下三个限制: + +- 子类方法的访问权限必须大于等于父类方法 +- 子类方法的返回类型必须是父类方法返回类型或为其子类型 +- 子类方法抛出的异常类型必须是父类抛出异常类型或为其子类型 + +继承中的隐藏问题: + +- 子类和父类方法都是静态的,那么子类中的方法会隐藏父类中的方法 +- 在子类中可以定义和父类成员变量同名的成员变量,此时子类的成员变量隐藏了父类的成员变量,在创建对象为对象分配内存的过程中,**隐藏变量依然会被分配内存** + +```java +public class ExtendsDemo { + public static void main(String[] args) { + Wolf w = new Wolf(); + w.run(); + } +} +class Wolf extends Animal{ + @Override + public void run(){}// +} +class Animal{ + public void run(){} +} +``` + + + +*** + + + +#### 常见问题 + +* 为什么子类构造器会先调用父类构造器? + + 1. 子类的构造器的第一行默认 super() 调用父类的无参数构造器,写不写都存在 + 2. 子类继承父类,子类就得到了父类的属性和行为。调用子类构造器初始化子类对象数据时,必须先调用父类构造器初始化继承自父类的属性和行为 + 3. 参考 JVM → 类加载 → 对象创建 + + ```java + class Animal { + public Animal() { + System.out.println("==父类Animal的无参数构造器=="); + } + } + + class Tiger extends Animal { + public Tiger() { + super(); // 默认存在的,根据参数去匹配调用父类的构造器。 + System.out.println("==子类Tiger的无参数构造器=="); + } + public Tiger(String name) { + //super(); 默认存在的,根据参数去匹配调用父类的构造器。 + System.out.println("==子类Tiger的有参数构造器=="); + } + } + ``` + +* **为什么 Java 是单继承的?** + + 答:反证法,假如 Java 可以多继承,请看如下代码: + + ```java + class A{ + public void test(){ + System.out.println("A"); + } + } + class B{ + public void test(){ + System.out.println("B"); + } + } + class C extends A , B { + public static void main(String[] args){ + C c = new C(); + c.test(); + // 出现了类的二义性!所以Java不能多继承!! + } + } + ``` + + + + + +------ + + + +### super + +继承后 super 调用父类构造器,父类构造器初始化继承自父类的数据。 + + +总结与拓展: + +* this 代表了当前对象的引用(继承中指代子类对象):this.子类成员变量、this.子类成员方法、**this(...)** 可以根据参数匹配访问本类其他构造器 +* super 代表了父类对象的引用(继承中指代了父类对象空间):super.父类成员变量、super.父类的成员方法、super(...) 可以根据参数匹配访问父类的构造器 + +注意: + +* this(...) 借用本类其他构造器,super(...) 调用父类的构造器 +* this(...) 或 super(...) 必须放在构造器的第一行,否则报错 +* this(...) 和 super(...) **不能同时出现**在构造器中,因为构造函数必须出现在第一行上,只能选择一个 + +```java +public class ThisDemo { + public static void main(String[] args) { + // 需求:希望如果不写学校默认就是”张三“! + Student s1 = new Student("天蓬元帅", 1000 ); + Student s2 = new Student("齐天大圣", 2000, "清华大学" ); + } +} +class Study extends Student { + public Study(String name, int age, String schoolName) { + super(name , age , schoolName) ; + // 根据参数匹配调用父类构造器 + } +} + +class Student{ + private String name ; + private int age ; + private String schoolName ; + + public Student() { + } + public Student(String name , int age){ + // 借用兄弟构造器的功能! + this(name , age , "张三"); + } + public Student(String name, int age, String schoolName) { + this.name = name; + this.age = age; + this.schoolName = schoolName; + } + // .......get + set +} +``` + + + +*** + + + +### final + +#### 基本介绍 + +final 用于修饰:类,方法,变量 + +* final 修饰类,类不能被继承了,类中的方法和变量可以使用 +* final 可以修饰方法,方法就不能被重写 +* final 修饰变量总规则:变量有且仅能被赋值一次 + +final 和 abstract 的关系是**互斥关系**,不能同时修饰类或者同时修饰方法 + + + +*** + + + +#### 修饰变量 + +##### 静态变量 + +final 修饰静态成员变量,变量变成了常量 + +常量:有 public static final 修饰,名称字母全部大写,多个单词用下划线连接 + +final 修饰静态成员变量可以在哪些地方赋值: + +1. 定义的时候赋值一次 + +2. 可以在静态代码块中赋值一次 + +```java +public class FinalDemo { + //常量:public static final修饰,名称字母全部大写,下划线连接。 + public static final String SCHOOL_NAME = "张三" ; + public static final String SCHOOL_NAME1; + + static{ + //SCHOOL_NAME = "java";//报错 + SCHOOL_NAME1 = "张三1"; + } +} +``` + + + +*** + + + +##### 实例变量 + +final 修饰变量的总规则:有且仅能被赋值一次 + +final 修饰实例成员变量可以在哪些地方赋值 1 次: + +1. 定义的时候赋值一次 +2. 可以在实例代码块中赋值一次 +3. 可以在每个构造器中赋值一次 + +```java +public class FinalDemo { + private final String name = "张三" ; + private final String name1; + private final String name2; + { + // 可以在实例代码块中赋值一次。 + name1 = "张三1"; + } + //构造器赋值一次 + public FinalDemo(){ + name2 = "张三2"; + } + public FinalDemo(String a){ + name2 = "张三2"; + } + + public static void main(String[] args) { + FinalDemo f1 = new FinalDemo(); + //f1.name = "张三1"; // 第二次赋值 报错! + } +} +``` + + + +*** + + + +### 抽象类 + +#### 基本介绍 + +> 父类知道子类要完成某个功能,但是每个子类实现情况不一样 + +抽象方法:没有方法体,只有方法签名,必须用 abstract 修饰的方法就是抽象方法 + +抽象类:拥有抽象方法的类必须定义成抽象类,必须用 abstract 修饰,**抽象类是为了被继承** + +一个类继承抽象类,**必须重写抽象类的全部抽象方法**,否则这个类必须定义成抽象类 + +```java +public class AbstractDemo { + public static void main(String[] args) { + Dog d = new Dog(); + d.run(); + } +} + +class Dog extends Animal{ + @Override + public void run() { + System.out.println("🐕跑"); + } +} + +abstract class Animal{ + public abstract void run(); +} +``` + + + +*** + + + +#### 常见问题 + +一、抽象类是否有构造器,是否可以创建对象? + +* 抽象类有构造器,但是抽象类不能创建对象,类的其他成分它都具备,构造器提供给子类继承后调用父类构造器使用 +* 抽象类中存在抽象方法,但不能执行,**抽象类中也可没有抽象方法** + +> 抽象在学术上本身意味着不能实例化 + +```java +public class AbstractDemo { + public static void main(String[] args) { + //Animal a = new Animal(); 抽象类不能创建对象! + //a.run(); // 抽象方法不能执行 + } +} +abstract class Animal{ + private String name; + public static String schoolName = "张三"; + public Animal(){ } + + public abstract void run(); + //普通方法 + public void go(){ } +} +``` + +二、static 与 abstract 能同时使用吗? + +答:不能,被 static 修饰的方法属于类,是类自己的东西,不是给子类来继承的,而抽象方法本身没有实现,就是用来给子类继承 + + + +*** + + + +#### 存在意义 + +**被继承**,抽象类就是为了被子类继承,否则抽象类将毫无意义(核心) + +抽象类体现的是"模板思想":**部分实现,部分抽象**,可以使用抽象类设计一个模板模式 + +```java +//作文模板 +public class ExtendsDemo { + public static void main(String[] args) { + Student xiaoMa = new Student(); + xiaoMa.write(); + } +} +class Student extends Template{ + @Override + public String writeText() {return "\t内容"} +} +// 1.写一个模板类:代表了作文模板。 +abstract class Template{ + private String title = "\t\t\t\t\t标题"; + private String start = "\t开头"; + private String last = "\t结尾"; + public void write(){ + System.out.println(title+"\n"+start); + System.out.println(writeText()); + System.out.println(last); + } + // 正文部分定义成抽象方法,交给子类重写!! + public abstract String writeText(); +} +``` + + + +*** + + + +### 接口 + +#### 基本介绍 + +接口是 Java 语言中一种引用类型,是方法的集合。 + +接口是更加彻底的抽象,接口中只有抽象方法和常量,没有其他成分 + +```java + 修饰符 interface 接口名称{ + // 抽象方法 + // 默认方法 + // 静态方法 + // 私有方法 +} +``` + +* 抽象方法:接口中的抽象方法默认会加上 public abstract 修饰,所以可以省略不写 + +* 静态方法:静态方法必须有方法体 + +* 常量:是 public static final 修饰的成员变量,仅能被赋值一次,值不能改变。常量的名称规范上要求全部大写,多个单词下划线连接,public static final 可以省略不写 + + ```java + public interface InterfaceDemo{ + //public static final String SCHOOL_NAME = "张三"; + String SCHOOL_NAME = "张三"; + + //public abstract void run(); + void run();//默认补充 + } + ``` + + + + +*** + + + + +#### 实现接口 + +**接口是用来被类实现的。** + +* 类与类是继承关系:一个类只能直接继承一个父类,单继承 +* 类与接口是实现关系:一个类可以实现多个接口,多实现,接口不能继承类 +* 接口与接口继承关系:**多继承** + +```java +修饰符 class 实现类名称 implements 接口1,接口2,接口3,....{ + +} +修饰符 interface 接口名 extend 接口1,接口2,接口3,....{ + +} +``` + +实现多个接口的使用注意事项: + +1. 当一个类实现多个接口时,多个接口中存在同名的静态方法并不会冲突,只能通过各自接口名访问静态方法 + +2. 当一个类实现多个接口时,多个接口中存在同名的默认方法,实现类必须重写这个方法 + +3. 当一个类既继承一个父类,又实现若干个接口时,父类中成员方法与接口中默认方法重名,子类**就近选择执行父类**的成员方法 + +4. 接口中,没有构造器,**不能创建对象**,接口是更彻底的抽象,连构造器都没有,自然不能创建对象 + + ```java + public class InterfaceDemo { + public static void main(String[] args) { + Student s = new Student(); + s.run(); + s.rule(); + } + } + class Student implements Food, Person{ + @Override + public void eat() {} + + @Override + public void run() {} + } + interface Food{ + void eat(); + } + interface Person{ + void run(); + } + //可以直接 interface Person extend Food, + //然后 class Student implements Person 效果一样 + ``` + + + +*** + + + +#### 新增功能 + +jdk1.8 以后新增的功能: + +* 默认方法(就是普通实例方法) + * 必须用 default 修饰,默认会 public 修饰 + * 必须用接口的实现类的对象来调用 + * 必须有默认实现 +* 静态方法 + * 默认会 public 修饰 + * 接口的静态方法必须用接口的类名本身来调用 + * 调用格式:ClassName.method() + * 必须有默认实现 +* 私有方法:JDK 1.9 才开始有的,只能在**本类中**被其他的默认方法或者私有方法访问 + +```java +public class InterfaceDemo { + public static void main(String[] args) { + // 1.默认方法调用:必须用接口的实现类对象调用。 + Man m = new Man(); + m.run(); + m.work(); + + // 2.接口的静态方法必须用接口的类名本身来调用。 + InterfaceJDK8.inAddr(); + } +} +class Man implements InterfaceJDK8 { + @Override + public void work() { + System.out.println("工作中。。。"); + } +} + +interface InterfaceJDK8 { + //抽象方法!! + void work(); + // a.默认方法(就是之前写的普通实例方法) + // 必须用接口的实现类的对象来调用。 + default void run() { + go(); + System.out.println("开始跑步🏃‍"); + } + + // b.静态方法 + // 注意:接口的静态方法必须用接口的类名本身来调用 + static void inAddr() { + System.out.println("我们在武汉"); + } + + // c.私有方法(就是私有的实例方法): JDK 1.9才开始有的。 + // 只能在本接口中被其他的默认方法或者私有方法访问。 + private void go() { + System.out.println("开始。。"); + } +} +``` + + + +*** + + + +#### 对比抽象类 + +| **参数** | **抽象类** | **接口** | +| ------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | +| 默认的方法实现 | 可以有默认的方法实现 | 接口完全是抽象的,jdk8 以后有默认的实现 | +| 实现 | 子类使用 **extends** 关键字来继承抽象类。如果子类不是抽象类的话,它需要提供抽象类中所有声明的方法的实现。 | 子类使用关键字 **implements** 来实现接口。它需要提供接口中所有声明的方法的实现 | +| 构造器 | 抽象类可以有构造器 | 接口不能有构造器 | +| 与正常Java类的区别 | 除了不能实例化抽象类之外,和普通 Java 类没有任何区别 | 接口是完全不同的类型 | +| 访问修饰符 | 抽象方法有 **public**、**protected** 和 **default** 这些修饰符 | 接口默认修饰符是 **public**,别的修饰符需要有方法体 | +| main方法 | 抽象方法可以有 main 方法并且我们可以运行它 | jdk8 以前接口没有 main 方法,不能运行;jdk8 以后接口可以有 default 和 static 方法,可以运行 main 方法 | +| 多继承 | 抽象方法可以继承一个类和实现多个接口 | 接口可以继承一个或多个其它接口,接口不可继承类 | +| 速度 | 比接口速度要快 | 接口是稍微有点慢的,因为它需要时间去寻找在类中实现的方法 | +| 添加新方法 | 如果往抽象类中添加新的方法,可以给它提供默认的实现,因此不需要改变现在的代码 | 如果往接口中添加方法,那么必须改变实现该接口的类 | + + + + + +------ + + + +### 多态 + +#### 基本介绍 + +多态的概念:同一个实体同时具有多种形式同一个类型的对象,执行同一个行为,在不同的状态下会表现出不同的行为特征 + +多态的格式: + +* 父类类型范围 > 子类类型范围 + +```java +父类类型 对象名称 = new 子类构造器; +接口 对象名称 = new 实现类构造器; +``` + +多态的执行: + +* 对于方法的调用:**编译看左边,运行看右边**(分派机制) +* 对于变量的调用:**编译看左边,运行看左边** + +多态的使用规则: + +* 必须存在继承或者实现关系 +* 必须存在父类类型的变量引用子类类型的对象 +* 存在方法重写 + +多态的优势: +* 在多态形式下,右边对象可以实现组件化切换,便于扩展和维护,也可以实现类与类之间的**解耦** +* 父类类型作为方法形式参数,传递子类对象给方法,可以传入一切子类对象进行方法的调用,更能体现出多态的**扩展性**与便利性 + +多态的劣势: +* 多态形式下,不能直接调用子类特有的功能,因为编译看左边,父类中没有子类独有的功能,所以代码在编译阶段就直接报错了 + +```java +public class PolymorphicDemo { + public static void main(String[] args) { + Animal c = new Cat(); + c.run(); + //c.eat();//报错 编译看左边 需要强转 + go(c); + go(new Dog); + } + //用 Dog或者Cat 都没办法让所有动物参与进来,只能用Anima + public static void go(Animal d){} + +} +class Dog extends Animal{} + +class Cat extends Animal{ + public void eat(); + @Override + public void run(){} +} + +class Animal{ + public void run(){} +} +``` + + + +*** + + + +#### 上下转型 + +>基本数据类型的转换: +> +>1. 小范围类型的变量或者值可以直接赋值给大范围类型的变量 +>2. 大范围类型的变量或者值必须强制类型转换给小范围类型的变量 + +引用数据类型的**自动**类型转换语法:子类类型的对象或者变量可以自动类型转换赋值给父类类型的变量 + +**父类引用指向子类对象** + +- **向上转型 (upcasting)**:通过子类对象(小范围)实例化父类对象(大范围),这种属于自动转换 +- **向下转型 (downcasting)**:通过父类对象(大范围)实例化子类对象(小范围),这种属于强制转换 + +```java +public class PolymorphicDemo { + public static void main(String[] args){ + Animal a = new Cat(); // 向上转型 + Cat c = (Cat)a; // 向下转型 + } +} +class Animal{} +class Cat extends Animal{} +``` + + + +*** + + + +#### instanceof + +instanceof:判断左边的对象是否是右边的类的实例,或者是其直接或间接子类,或者是其接口的实现类 + +* 引用类型强制类型转换:父类类型的变量或者对象强制类型转换成子类类型的变量,否则报错 +* 强制类型转换的格式:**类型 变量名称 = (类型)(对象或者变量)** +* 有继承/实现关系的两个类型就可以进行强制类型转换,编译阶段一定不报错,但是运行阶段可能出现类型转换异常 ClassCastException + +```java +public class Demo{ + public static void main(String[] args){ + Aniaml a = new Dog(); + //Dog d = (Dog)a; + //Cat c = (Cat)a; 编译不报错,运行报ClassCastException错误 + if(a instanceof Cat){ + Cat c = (Cat)a; + } else if(a instanceof Dog) { + Dog d = (Dog)a; + } + } +} +class Dog extends Animal{} +class Cat extends Animal{} +class Animal{} +``` + + + +*** + + + +### 内部类 + +#### 概述 + +内部类是类的五大成分之一:成员变量,方法,构造器,代码块,内部类 + +概念:定义在一个类里面的类就是内部类 + +作用:提供更好的封装性,体现出组件思想,**间接解决类无法多继承引起的一系列问题** + +分类:静态内部类、实例内部类(成员内部类)、局部内部类、**匿名内部类**(重点) + + + +*** + + + +#### 静态内部类 + +定义:有 static 修饰,属于外部类本身,会加载一次 + +静态内部类中的成分研究: + +* 类有的成分它都有,静态内部类属于外部类本身,只会加载一次 +* 特点与外部类是完全一样的,只是位置在别人里面 +* 可以定义静态成员 + +静态内部类的访问格式:外部类名称.内部类名称 + +静态内部类创建对象的格式:外部类名称.内部类名称 对象名称 = new 外部类名称.内部类构造器 + +静态内部类的访问拓展: + +* 静态内部类中是否可以直接访问外部类的静态成员? 可以,外部类的静态成员只有一份,可以被共享 +* 静态内部类中是否可以直接访问外部类的实例成员? 不可以,外部类的成员必须用外部类对象访问 + +```java +public class Demo{ + public static void main(String[] args){ + Outter.Inner in = new Outter.Inner(); + } +} + +static class Outter{ + public static int age; + private double salary; + public static class Inner{ + //拥有类的所有功能 构造器 方法 成员变量 + System.out.println(age); + //System.out.println(salary);报错 + } +} +``` + + + +*** + + + +#### 实例内部类 + +定义:无 static 修饰的内部类,属于外部类的每个对象,跟着外部类对象一起加载 + +实例内部类的成分特点:实例内部类中不能定义静态成员,其他都可以定义 + +实例内部类的访问格式:外部类名称.内部类名称 + +创建对象的格式:外部类名称.内部类名称 对象名称 = new 外部类构造器.new 内部构造器 + +* `Outter.Inner in = new Outter().new Inner()` + +**实例内部类可以访问外部类的全部成员** + +* 实例内部类中可以直接访问外部类的静态成员,外部类的静态成员可以被共享访问 +* 实例内部类中可以访问外部类的实例成员,实例内部类属于外部类对象,可以直接访问外部类对象的实例成员 + + + +*** + + + +#### 局部内部类 + +局部内部类:定义在方法中,在构造器中,代码块中,for 循环中定义的内部类 + +局部内部类中的成分特点:只能定义实例成员,不能定义静态成员 + +```java +public class InnerClass{ + public static void main(String[] args){ + String name; + class{} + } + public static void test(){ + class Animal{} + class Cat extends Animal{} + } +} +``` + + + +*** + + + +#### 匿名内部类 + +匿名内部类:没有名字的局部内部类 + +匿名内部类的格式: + +```java +new 类名|抽象类|接口(形参){ + //方法重写。 +} +``` + 匿名内部类的特点: + +* 匿名内部类不能定义静态成员 +* 匿名内部类一旦写出来,就会立即创建一个匿名内部类的对象返回 +* **匿名内部类的对象的类型相当于是当前 new 的那个的类型的子类类型** +* 匿名内部类引用局部变量必须是**常量**,底层创建为内部类的成员变量(原因:JVM → 运行机制 → 代码优化) + +```java +public class Anonymity { + public static void main(String[] args) { + Animal a = new Animal(){ + @Override + public void run() { + System.out.println("猫跑的贼溜~~"); + //System.out.println(n); + } + }; + a.run(); + a.go(); + } +} +abstract class Animal{ + public abstract void run(); + + public void go(){ + System.out.println("开始go~~~"); + } +} +``` + + + +*** + + + +### 权限符 + +权限修饰符:有四种**(private -> 缺省 -> protected - > public )** +可以修饰成员变量,修饰方法,修饰构造器,内部类,不同修饰符修饰的成员能够被访问的权限将受到限制 + +| 四种修饰符访问权限 | private | 缺省 | protected | public | +| ------------------ | :-----: | :--: | :-------: | :----: | +| 本类中 | √ | √ | √ | √ | +| 本包下的子类中 | X | √ | √ | √ | +| 本包下其他类中 | X | √ | √ | √ | +| 其他包下的子类中 | X | X | √ | √ | +| 其他包下的其他类中 | X | X | X | √ | + +protected 用于修饰成员,表示在继承体系中成员对于子类可见 + +* 基类的 protected 成员是包内可见的,并且对子类可见 +* 若子类与基类不在同一包中,那么子类实例可以访问其从基类继承而来的 protected 方法(重写),而不能访问基类实例的 protected 方法 + + + + + +*** + + + +### 代码块 + +#### 静态代码块 + +静态代码块的格式: + + ```java +static { +} + ``` + +* 静态代码块特点: + * 必须有 static 修饰,只能访问静态资源 + * 会与类一起优先加载,且自动触发执行一次 +* 静态代码块作用: + * 可以在执行类的方法等操作之前先在静态代码块中进行静态资源的初始化 + * **先执行静态代码块,在执行 main 函数里的操作** + +```java +public class CodeDemo { + public static String schoolName ; + public static ArrayList lists = new ArrayList<>(); + + // 静态代码块,属于类,与类一起加载一次! + static { + System.out.println("静态代码块被触发执行~~~~~~~"); + // 在静态代码块中进行静态资源的初始化操作 + schoolName = "张三"; + lists.add("3"); + lists.add("4"); + lists.add("5"); + } + public static void main(String[] args) { + System.out.println("main方法被执行"); + System.out.println(schoolName); + System.out.println(lists); + } +} +/*静态代码块被触发执行~~~~~~~ +main方法被执行 +张三 +[3, 4, 5] */ +``` + + + +*** + + + +#### 实例代码块 + +实例代码块的格式: + +```java +{ + +} +``` + +* 实例代码块的特点: + * 无 static 修饰,属于对象 + * 会与类的对象一起加载,每次创建类的对象的时候,实例代码块都会被加载且自动触发执行一次 + * 实例代码块的代码在底层实际上是提取到每个构造器中去执行的 + +* 实例代码块的作用:实例代码块可以在创建对象之前进行实例资源的初始化操作 + +```java +public class CodeDemo { + private String name; + private ArrayList lists = new ArrayList<>(); + { + name = "代码块"; + lists.add("java"); + System.out.println("实例代码块被触发执行一次~~~~~~~~"); + } + public CodeDemo02(){ }//构造方法 + public CodeDemo02(String name){} + + public static void main(String[] args) { + CodeDemo c = new CodeDemo();//实例代码块被触发执行一次 + System.out.println(c.name); + System.out.println(c.lists); + new CodeDemo02();//实例代码块被触发执行一次 + } +} +``` + + + + + +*** + + + + + + +## API + +### Object + +#### 基本介绍 + +Object 类是 Java 中的祖宗类,一个类或者默认继承 Object 类,或者间接继承 Object 类,Object 类的方法是一切子类都可以直接使用 + +Object 类常用方法: + +* `public String toString()`:默认是返回当前对象在堆内存中的地址信息:类的全限名@内存地址,例:Student@735b478; + * 直接输出对象名称,默认会调用 toString() 方法,所以省略 toString() 不写; + * 如果输出对象的内容,需要重写 toString() 方法,toString 方法存在的意义是为了被子类重写 +* `public boolean equals(Object o)`:默认是比较两个对象的引用是否相同 +* `protected Object clone()`:创建并返回此对象的副本 + +只要两个对象的内容一样,就认为是相等的: + +```java +public boolean equals(Object o) { + // 1.判断是否自己和自己比较,如果是同一个对象比较直接返回true + if (this == o) return true; + // 2.判断被比较者是否为null ,以及是否是学生类型。 + if (o == null || this.getClass() != o.getClass()) return false; + // 3.o一定是学生类型,强制转换成学生,开始比较内容! + Student student = (Student) o; + return age == student.age && + sex == student.sex && + Objects.equals(name, student.name); +} +``` + +**面试题**:== 和 equals 的区别 + +* == 比较的是变量(栈)内存中存放的对象的(堆)内存地址,用来判断两个对象的**地址**是否相同,即是否是指相同一个对象,比较的是真正意义上的指针操作 +* Object 类中的方法,**默认比较两个对象的引用**,重写 equals 方法比较的是两个对象的**内容**是否相等,所有的类都是继承自 java.lang.Object 类,所以适用于所有对象 + +hashCode 的作用: + +* hashCode 的存在主要是用于查找的快捷性,如 Hashtable,HashMap 等,可以在散列存储结构中确定对象的存储地址 +* 如果两个对象相同,就是适用于 equals(java.lang.Object) 方法,那么这两个对象的 hashCode 一定要相同 +* 哈希值相同的数据不一定内容相同,内容相同的数据哈希值一定相同 + + + +*** + + + +#### 深浅克隆 + +Object 的 clone() 是 protected 方法,一个类不显式去重写 clone(),就不能直接去调用该类实例的 clone() 方法 + +深浅拷贝(克隆)的概念: + +* 浅拷贝 (shallowCopy):**对基本数据类型进行值传递,对引用数据类型只是复制了引用**,被复制对象属性的所有的引用仍然指向原来的对象,简而言之就是增加了一个指针指向原来对象的内存地址 + + **Java 中的复制方法基本都是浅拷贝**:Object.clone()、System.arraycopy()、Arrays.copyOf() + +* 深拷贝 (deepCopy):对基本数据类型进行值传递,对引用数据类型是一个整个独立的对象拷贝,会拷贝所有的属性并指向的动态分配的内存,简而言之就是把所有属性复制到一个新的内存,增加一个指针指向新内存。所以使用深拷贝的情况下,释放内存的时候不会出现使用浅拷贝时释放同一块内存的错误 + +Cloneable 接口是一个标识性接口,即该接口不包含任何方法(包括 clone),但是如果一个类想合法的进行克隆,那么就必须实现这个接口,在使用 clone() 方法时,若该类未实现 Cloneable 接口,则抛出异常 + +* Clone & Copy:`Student s = new Student` + + `Student s1 = s`:只是 copy 了一下 reference,s 和 s1 指向内存中同一个 Object,对对象的修改会影响对方 + + `Student s2 = s.clone()`:会生成一个新的 Student 对象,并且和 s 具有相同的属性值和方法 + +* Shallow Clone & Deep Clone: + + 浅克隆:Object 中的 clone() 方法在对某个对象克隆时对其仅仅是简单地执行域对域的 copy + + * 对基本数据类型和包装类的克隆是没有问题的。String、Integer 等包装类型在内存中是**不可以被改变的对象**,所以在使用克隆时可以视为基本类型,只需浅克隆引用即可 + * 如果对一个引用类型进行克隆时只是克隆了它的引用,和原始对象共享对象成员变量 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/Object浅克隆.jpg) + + 深克隆:在对整个对象浅克隆后,对其引用变量进行克隆,并将其更新到浅克隆对象中去 + + ```java + public class Student implements Cloneable{ + private String name; + private Integer age; + private Date date; + + @Override + protected Object clone() throws CloneNotSupportedException { + Student s = (Student) super.clone(); + s.date = (Date) date.clone(); + return s; + } + //..... + } + ``` + +SDP → 创建型 → 原型模式 + + + +*** + + + +### Objects + +Objects 类与 Object 是继承关系 + +Objects 的方法: + +* `public static boolean equals(Object a, Object b)`:比较两个对象是否相同 + + ```java + public static boolean equals(Object a, Object b) { + // 进行非空判断,从而可以避免空指针异常 + return a == b || a != null && a.equals(b); + } + ``` + +* `public static boolean isNull(Object obj)`:判断变量是否为 null ,为 null 返回 true + +* `public static String toString(对象)`:返回参数中对象的字符串表示形式 + +* `public static String toString(对象, 默认字符串)`:返回对象的字符串表示形式 + +```java +public class ObjectsDemo { + public static void main(String[] args) { + Student s1 = null; + Student s2 = new Student(); + System.out.println(Objects.equals(s1 , s2));//推荐使用 + // System.out.println(s1.equals(s2)); // 空指针异常 + + System.out.println(Objects.isNull(s1)); + System.out.println(s1 == null);//直接判断比较好 + } +} + +public class Student { +} +``` + + + +*** + + + +### String + +#### 基本介绍 + +String 被声明为 final,因此不可被继承 **(Integer 等包装类也不能被继承)** + +```java +public final class String implements java.io.Serializable, Comparable, CharSequence { + /** The value is used for character storage. */ + private final char value[]; + /** Cache the hash code for the string */ + private int hash; // Default to 0 +} +``` + +在 Java 9 之后,String 类的实现改用 byte 数组存储字符串,同时使用 `coder` 来标识使用了哪种编码 + +value 数组被声明为 final,这意味着 value 数组初始化之后就不能再引用其它数组,并且 String 内部没有改变 value 数组的方法,因此可以**保证 String 不可变,也保证线程安全** + +注意:不能改变的意思是**每次更改字符串都会产生新的对象**,并不是对原始对象进行改变 + +```java +String s = "abc"; +s = s + "cd"; //s = abccd 新对象 +``` + + + +**** + + + +#### 常用方法 + +常用 API: + +* `public boolean equals(String s)`:比较两个字符串内容是否相同、区分大小写 + +* `public boolean equalsIgnoreCase(String anotherString)`:比较字符串的内容,忽略大小写 +* `public int length()`:返回此字符串的长度 +* `public String trim()`:返回一个字符串,其值为此字符串,并删除任何前导和尾随空格 +* `public String[] split(String regex)`:将字符串按给定的正则表达式分割成字符串数组 +* `public char charAt(int index)`:取索引处的值 +* `public char[] toCharArray()`:将字符串拆分为字符数组后返回 +* `public boolean startsWith(String prefix)`:测试此字符串是否以指定的前缀开头 +* `public int indexOf(String str)`:返回指定子字符串第一次出现的字符串内的索引,没有返回 -1 +* `public int lastIndexOf(String str)`:返回字符串最后一次出现 str 的索引,没有返回 -1 +* `public String substring(int beginIndex)`:返回子字符串,以原字符串指定索引处到结尾 +* `public String substring(int i, int j)`:指定索引处扩展到 j - 1 的位置,字符串长度为 j - i +* `public String toLowerCase()`:将此 String 所有字符转换为小写,使用默认语言环境的规则 +* `public String toUpperCase()`:使用默认语言环境的规则将此 String 所有字符转换为大写 +* `public String replace(CharSequence target, CharSequence replacement)`:使用新值,将字符串中的旧值替换,得到新的字符串 + +```java +String s = 123-78; +s.replace("-","");//12378 +``` + + + +*** + + + +#### 构造方式 + +构造方法: + +* `public String()`:创建一个空白字符串对象,不含有任何内容 +* `public String(char[] chs)`:根据字符数组的内容,来创建字符串对象 +* `public String(String original)`:根据传入的字符串内容,来创建字符串对象 + +直接赋值:`String s = "abc"` 直接赋值的方式创建字符串对象,内容就是 abc + +- 通过构造方法创建:通过 new 创建的字符串对象,每一次 new 都会申请一个内存空间,虽然内容相同,但是地址值不同,**返回堆内存中对象的引用** +- 直接赋值方式创建:以 `" "` 方式给出的字符串,只要字符序列相同(顺序和大小写),无论在程序代码中出现几次,JVM 都只会**在 String Pool 中创建一个字符串对象**,并在字符串池中维护 + +`String str = new String("abc")` 创建字符串对象: + +* 创建一个对象:字符串池中已经存在 abc 对象,那么直接在创建一个对象放入堆中,返回堆内引用 +* 创建两个对象:字符串池中未找到 abc 对象,那么分别在堆中和字符串池中创建一个对象,字符串池中的比较都是采用 equals() + + +`new String("a") + new String("b")` 创建字符串对象: + +* 对象 1:new StringBuilder() + +* 对象 2:new String("a")、对象 3:常量池中的 a + +* 对象 4:new String("b")、对象 5:常量池中的 b + + +* StringBuilder 的 toString(): + + ```java + @Override + public String toString() { + return new String(value, 0, count); + } + ``` + + * 对象 6:new String("ab") + * StringBuilder 的 toString() 调用,**在字符串常量池中没有生成 ab**,new String("ab") 会创建两个对象因为传参数的时候使用字面量创建了对象 ab,当使用数组构造 String 对象时,没有加入常量池的操作 + + + +*** + + + +#### String Pool + +##### 基本介绍 + +字符串常量池(String Pool / StringTable / 串池)保存着所有字符串字面量(literal strings),这些字面量在编译时期就确定,常量池类似于 Java 系统级别提供的**缓存**,存放对象和引用 + +* StringTable,类似 HashTable 结构,通过 `-XX:StringTableSize` 设置大小,JDK 1.8 中默认 60013 +* 常量池中的字符串仅是符号,第一次使用时才变为对象,可以避免重复创建字符串对象 +* 字符串**变量**的拼接的原理是 StringBuilder#append,append 方法比字符串拼接效率高(JDK 1.8) +* 字符串**常量**拼接的原理是编译期优化,拼接结果放入常量池 +* 可以使用 String 的 intern() 方法在运行过程将字符串添加到 String Pool 中 + + + +*** + + + +##### intern() + +JDK 1.8:当一个字符串调用 intern() 方法时,如果 String Pool 中: +* 存在一个字符串和该字符串值相等,就会返回 String Pool 中字符串的引用(需要变量接收) +* 不存在,会把对象的**引用地址**复制一份放入串池,并返回串池中的引用地址,前提是堆内存有该对象,因为 Pool 在堆中,为了节省内存不再创建新对象 + +JDK 1.6:将这个字符串对象尝试放入串池,如果有就不放入,返回已有的串池中的对象的引用;如果没有会把此对象复制一份,放入串池,把串池中的对象返回 + +```java +public class Demo { + // 常量池中的信息都加载到运行时常量池,这时a b ab是常量池中的符号,还不是java字符串对象,是懒惰的 + // ldc #2 会把 a 符号变为 "a" 字符串对象 ldc:反编译后的指令 + // ldc #3 会把 b 符号变为 "b" 字符串对象 + // ldc #4 会把 ab 符号变为 "ab" 字符串对象 + public static void main(String[] args) { + String s1 = "a"; // 懒惰的 + String s2 = "b"; + String s3 = "ab"; // 串池 + + String s4 = s1 + s2; // 返回的是堆内地址 + // 原理:new StringBuilder().append("a").append("b").toString() new String("ab") + + String s5 = "a" + "b"; // javac 在编译期间的优化,结果已经在编译期确定为ab + + System.out.println(s3 == s4); // false + System.out.println(s3 == s5); // true + + String x2 = new String("c") + new String("d"); // new String("cd") + // 虽然 new,但是在字符串常量池没有 cd 对象,因为 toString() 方法 + x2.intern(); + String x1 = "cd"; + + System.out.println(x1 == x2); //true + } +} +``` + +- == 比较基本数据类型:比较的是具体的值 +- == 比较引用数据类型:比较的是对象地址值 + +结论: + +```java +String s1 = "ab"; // 仅放入串池 +String s2 = new String("a") + new String("b"); // 仅放入堆 +// 上面两条指令的结果和下面的 效果 相同 +String s = new String("ab"); +``` + + + +**** + + + +##### 常见问题 + +问题一: + +```java +public static void main(String[] args) { + String s = new String("a") + new String("b");//new String("ab") + //在上一行代码执行完以后,字符串常量池中并没有"ab" + + String s2 = s.intern(); + //jdk6:串池中创建一个字符串"ab" + //jdk8:串池中没有创建字符串"ab",而是创建一个引用指向 new String("ab"),将此引用返回 + + System.out.println(s2 == "ab");//jdk6:true jdk8:true + System.out.println(s == "ab");//jdk6:false jdk8:true +} +``` + +问题二: + +```java +public static void main(String[] args) { + String str1 = new StringBuilder("58").append("tongcheng").toString(); + System.out.println(str1 == str1.intern());//true,字符串池中不存在,把堆中的引用复制一份放入串池 + + String str2 = new StringBuilder("ja").append("va").toString(); + System.out.println(str2 == str2.intern());//false,字符串池中存在,直接返回已经存在的引用 +} +``` + +原因: + +* System 类当调用 Version 的静态方法,导致 Version 初始化: + + ```java + private static void initializeSystemClass() { + sun.misc.Version.init(); + } + ``` + +* Version 类初始化时需要对静态常量字段初始化,被 launcher_name 静态常量字段所引用的 `"java"` 字符串字面量就被放入的字符串常量池: + + ```java + package sun.misc; + + public class Version { + private static final String launcher_name = "java"; + private static final String java_version = "1.8.0_221"; + private static final String java_runtime_name = "Java(TM) SE Runtime Environment"; + private static final String java_profile_name = ""; + private static final String java_runtime_version = "1.8.0_221-b11"; + //... + } + ``` + + + +*** + + + +##### 内存位置 + +Java 7 之前,String Pool 被放在运行时常量池中,属于永久代;Java 7 以后,String Pool 被移到堆中,这是因为永久代的空间有限,在大量使用字符串的场景下会导致 OutOfMemoryError 错误 + +演示 StringTable 位置: + +* `-Xmx10m` 设置堆内存 10m + +* 在 JDK8 下设置: `-Xmx10m -XX:-UseGCOverheadLimit`(运行参数在 Run Configurations VM options) + +* 在 JDK6 下设置: `-XX:MaxPermSize=10m` + + ```java + public static void main(String[] args) throws InterruptedException { + List list = new ArrayList(); + int i = 0; + try { + for (int j = 0; j < 260000; j++) { + list.add(String.valueOf(j).intern()); + i++; + } + } catch (Throwable e) { + e.printStackTrace(); + } finally { + System.out.println(i); + } + } + ``` + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-内存图对比.png) + + + +*** + + + +#### 优化常量池 + +两种方式: + +* 调整 -XX:StringTableSize=桶个数,数量越少,性能越差 + +* intern 将字符串对象放入常量池,通过复用字符串的引用,减少内存占用 + +```java +/** + * 演示 intern 减少内存占用 + * -XX:StringTableSize=200000 -XX:+PrintStringTableStatistics + * -Xsx500m -Xmx500m -XX:+PrintStringTableStatistics -XX:StringTableSize=200000 + */ +public class Demo1_25 { + public static void main(String[] args) throws IOException { + List address = new ArrayList<>(); + System.in.read(); + for (int i = 0; i < 10; i++) { + //很多数据 + try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("linux.words"), "utf-8"))) { + String line = null; + long start = System.nanoTime(); + while (true) { + line = reader.readLine(); + if(line == null) { + break; + } + address.add(line.intern()); + } + System.out.println("cost:" +(System.nanoTime()-start)/1000000); + } + } + System.in.read(); + } +} +``` + + + +*** + + + +#### 不可变好处 + +* 可以缓存 hash 值,例如 String 用做 HashMap 的 key,不可变的特性可以使得 hash 值也不可变,只要进行一次计算 +* String Pool 的需要,如果一个 String 对象已经被创建过了,就会从 String Pool 中取得引用,只有 String 是不可变的,才可能使用 String Pool +* 安全性,String 经常作为参数,String 不可变性可以保证参数不可变。例如在作为网络连接参数的情况下如果 String 是可变的,那么在网络连接过程中,String 被改变,改变 String 的那一方以为现在连接的是其它主机,而实际情况却不一定是 +* String 不可变性天生具备线程安全,可以在多个线程中安全地使用 +* 防止子类继承,破坏 String 的 API 的使用 + + + + + +*** + + + +### StringBuilder + +String StringBuffer 和 StringBuilder 区别: + +* String : **不可变**的字符序列,线程安全 +* StringBuffer : **可变**的字符序列,线程安全,底层方法加 synchronized,效率低 +* StringBuilder : **可变**的字符序列,JDK5.0 新增;线程不安全,效率高 + +相同点:底层使用 char[] 存储 + +构造方法: + +* `public StringBuilder()`:创建一个空白可变字符串对象,不含有任何内容 +* `public StringBuilder(String str)`:根据字符串的内容,来创建可变字符串对象 + +常用API : + +* `public StringBuilder append(任意类型)`:添加数据,并返回对象本身 +* `public StringBuilder reverse()`:返回相反的字符序列 +* `public String toString()`:通过 toString() 就可以实现把 StringBuilder 转换为 String + +存储原理: + +```java +String str = "abc"; +char data[] = {'a', 'b', 'c'}; +StringBuffer sb1 = new StringBuffer();//new byte[16] +sb1.append('a'); //value[0] = 'a'; +``` + +append 源码:扩容为二倍 + +```java +public AbstractStringBuilder append(String str) { + if (str == null) return appendNull(); + int len = str.length(); + ensureCapacityInternal(count + len); + str.getChars(0, len, value, count); + count += len; + return this; +} +private void ensureCapacityInternal(int minimumCapacity) { + // 创建超过数组长度就新的char数组,把数据拷贝过去 + if (minimumCapacity - value.length > 0) { + //int newCapacity = (value.length << 1) + 2;每次扩容2倍+2 + value = Arrays.copyOf(value, newCapacity(minimumCapacity)); + } +} +public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) { + // 将字符串中的字符复制到目标字符数组中 + // 字符串调用该方法,此时value是字符串的值,dst是目标字符数组 + System.arraycopy(value, srcBegin, dst, dstBegin, srcEnd - srcBegin); +} +``` + + + + + +**** + + + +### Arrays + +Array 的工具类 Arrays + +常用API: + +* `public static String toString(int[] a)`:返回指定数组的内容的字符串表示形式 +* `public static void sort(int[] a)`:按照数字顺序排列指定的数组 +* `public static int binarySearch(int[] a, int key)`:利用二分查找返回指定元素的索引 +* `public static List asList(T... a)`:返回由指定数组支持的列表 + +```java +public class MyArraysDemo { + public static void main(String[] args) { + //按照数字顺序排列指定的数组 + int [] arr = {3,2,4,6,7}; + Arrays.sort(arr); + System.out.println(Arrays.toString(arr)); + + int [] arr = {1,2,3,4,5,6,7,8,9,10}; + int index = Arrays.binarySearch(arr, 0); + System.out.println(index); + //1,数组必须有序 + //2.如果要查找的元素存在,那么返回的是这个元素实际的索引 + //3.如果要查找的元素不存在,那么返回的是 (-插入点-1) + //插入点:如果这个元素在数组中,他应该在哪个索引上. + } + } +``` + + + + + +*** + + + +### Random + +用于生成伪随机数。 + +使用步骤: +1. 导入包:`import java.util.Random` +2. 创建对象:`Random r = new Random()` +3. 随机整数:`int num = r.nextInt(10)` + * 解释:10 代表的是一个范围,如果括号写 10,产生的随机数就是 0 - 9,括号写 20 的随机数则是 0 - 19 + * 获取 0 - 10:`int num = r.nextInt(10 + 1)` + +4. 随机小数:`public double nextDouble()` 从范围 `0.0d` 至 `1.0d` (左闭右开),伪随机地生成并返回 + + + +*** + + + +### System + +System 代表当前系统 + +静态方法: + +* `public static void exit(int status)`:终止 JVM 虚拟机,**非 0 是异常终止** + +* `public static long currentTimeMillis()`:获取当前系统此刻时间毫秒值 + +* `static void arraycopy(Object var0, int var1, Object var2, int var3, int var4)`:数组拷贝 + * 参数一:原数组 + * 参数二:从原数组的哪个位置开始赋值 + * 参数三:目标数组 + * 参数四:从目标数组的哪个位置开始赋值 + * 参数五:赋值几个 + +```java +public class SystemDemo { + public static void main(String[] args) { + //System.exit(0); // 0代表正常终止!! + long startTime = System.currentTimeMillis();//定义sdf 按照格式输出 + for(int i = 0; i < 10000; i++){输出i} + long endTime = new Date().getTime(); + System.out.println( (endTime - startTime)/1000.0 +"s");//程序用时 + + int[] arr1 = new int[]{10 ,20 ,30 ,40 ,50 ,60 ,70}; + int[] arr2 = new int[6]; // [ 0 , 0 , 0 , 0 , 0 , 0] + // 变成arrs2 = [0 , 30 , 40 , 50 , 0 , 0 ] + System.arraycopy(arr1, 2, arr2, 1, 3); + } +} +``` + + + + + +*** + + + +### Date + +构造器: + +* `public Date()`:创建当前系统的此刻日期时间对象。 +* `public Date(long time)`:把时间毫秒值转换成日期对象 + +方法: + +* `public long getTime()`:返回自 1970 年 1 月 1 日 00:00:00 GMT 以来总的毫秒数。 + +时间记录的两种方式: + +1. Date 日期对象 +2. 时间毫秒值:从 `1970-01-01 00:00:00` 开始走到此刻的总的毫秒值,1s = 1000ms + +```java +public class DateDemo { + public static void main(String[] args) { + Date d = new Date(); + System.out.println(d);//Fri Oct 16 21:58:44 CST 2020 + long time = d.getTime() + 121*1000;//过121s是什么时间 + System.out.println(time);//1602856875485 + + Date d1 = new Date(time); + System.out.println(d1);//Fri Oct 16 22:01:15 CST 2020 + } +} +``` + +```java +public static void main(String[] args){ + Date d = new Date(); + long startTime = d.getTime(); + for(int i = 0; i < 10000; i++){输出i} + long endTime = new Date().getTime(); + System.out.println( (endTime - startTime) / 1000.0 +"s"); + //运行一万次输出需要多长时间 +} +``` + + + +*** + + + +### DateFormat + +DateFormat 作用: + +1. 可以把“日期对象”或者“时间毫秒值”格式化成我们喜欢的时间形式(格式化时间) +2. 可以把字符串的时间形式解析成日期对象(解析字符串时间) + +DateFormat 是一个抽象类,不能直接使用,使用它的子类:SimpleDateFormat + +SimpleDateFormat 简单日期格式化类: + +* `public SimpleDateFormat(String pattern)`:指定时间的格式创建简单日期对象 +* `public String format(Date date) `:把日期对象格式化成我们喜欢的时间形式,返回字符串 +* `public String format(Object time)`:把时间毫秒值格式化成设定的时间形式,返回字符串! +* `public Date parse(String date)`:把字符串的时间解析成日期对象 + +>yyyy年MM月dd日 HH:mm:ss EEE a" 周几 上午下午 + +```java +public static void main(String[] args){ + Date date = new Date(); + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss); + String time = sdf.format(date); + System.out.println(time);//2020-10-18 19:58:34 + //过121s后是什么时间 + long time = date.getTime(); + time+=121; + System.out.println(sdf.formate(time)); + String d = "2020-10-18 20:20:20";//格式一致 + Date newDate = sdf.parse(d); + System.out.println(sdf.format(newDate)); //按照前面的方法输出 +} +``` + + + + +**** + + + +### Calendar + +Calendar 代表了系统此刻日期对应的日历对象,是一个抽象类,不能直接创建对象 + +Calendar 日历类创建日历对象:`Calendar rightNow = Calendar.getInstance()`(**饿汉单例模式**) + +Calendar 的方法: + +* `public static Calendar getInstance()`:返回一个日历类的对象 +* `public int get(int field)`:取日期中的某个字段信息 +* `public void set(int field,int value)`:修改日历的某个字段信息 +* `public void add(int field,int amount)`:为某个字段增加/减少指定的值 +* `public final Date getTime()`:拿到此刻日期对象 +* `public long getTimeInMillis()`:拿到此刻时间毫秒值 + +```java +public static void main(String[] args){ + Calendar rightNow = Calendar.getInsance(); + int year = rightNow.get(Calendar.YEAR);//获取年 + int month = rightNow.get(Calendar.MONTH) + 1;//月要+1 + int days = rightNow.get(Calendar.DAY_OF_YEAR); + rightNow.set(Calendar.YEAR , 2099);//修改某个字段 + rightNow.add(Calendar.HOUR , 15);//加15小时 -15就是减去15小时 + Date date = rightNow.getTime();//日历对象 + long time = rightNow.getTimeInMillis();//时间毫秒值 + //700天后是什么日子 + rightNow.add(Calendar.DAY_OF_YEAR , 701); + Date date d = rightNow.getTime(); + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + System.out.println(sdf.format(d));//输出700天后的日期 +} +``` + + + +*** + + + +### LocalDateTime + +JDK1.8 新增,线程安全 + ++ LocalDate 表示日期(年月日) ++ LocalTime 表示时间(时分秒) ++ LocalDateTime 表示时间+ 日期 (年月日时分秒) + +构造方法: + +* public static LocalDateTime now():获取当前系统时间 +* public static LocalDateTime of(年, 月 , 日, 时, 分, 秒):使用指定年月日和时分秒初始化一个对象 + +常用API: + +| 方法名 | 说明 | +| --------------------------------------------------------- | ------------------------------------------------------------ | +| public int getYear() | 获取年 | +| public int getMonthValue() | 获取月份(1-12) | +| public int getDayOfMonth() | 获取月份中的第几天(1-31) | +| public int getDayOfYear() | 获取一年中的第几天(1-366) | +| public DayOfWeek getDayOfWeek() | 获取星期 | +| public int getMinute() | 获取分钟 | +| public int getHour() | 获取小时 | +| public LocalDate toLocalDate() | 转换成为一个 LocalDate 对象(年月日) | +| public LocalTime toLocalTime() | 转换成为一个 LocalTime 对象(时分秒) | +| public String format(指定格式) | 把一个 LocalDateTime 格式化成为一个字符串 | +| public LocalDateTime parse(准备解析的字符串, 解析格式) | 把一个日期字符串解析成为一个 LocalDateTime 对象 | +| public static DateTimeFormatter ofPattern(String pattern) | 使用指定的日期模板获取一个日期格式化器 DateTimeFormatter 对象 | + +```java +public class JDK8DateDemo2 { + public static void main(String[] args) { + LocalDateTime now = LocalDateTime.now(); + System.out.println(now); + + LocalDateTime localDateTime = LocalDateTime.of(2020, 11, 11, 11, 11, 11); + System.out.println(localDateTime); + DateTimeFormatter pattern = DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH:mm:ss"); + String s = localDateTime.format(pattern); + LocalDateTime parse = LocalDateTime.parse(s, pattern); + } +} +``` + +| 方法名 | 说明 | +| ------------------------------------------- | -------------- | +| public LocalDateTime plusYears (long years) | 添加或者减去年 | +| public LocalDateTime withYear(int year) | 直接修改年 | + + + +**时间间隔** Duration 类API: + +| 方法名 | 说明 | +| ------------------------------------------------ | -------------------- | +| public static Period between(开始时间,结束时间) | 计算两个“时间"的间隔 | +| public int getYears() | 获得这段时间的年数 | +| public int getMonths() | 获得此期间的总月数 | +| public int getDays() | 获得此期间的天数 | +| public long toTotalMonths() | 获取此期间的总月数 | +| public static Durationbetween(开始时间,结束时间) | 计算两个“时间"的间隔 | +| public long toSeconds() | 获得此时间间隔的秒 | +| public long toMillis() | 获得此时间间隔的毫秒 | +| public long toNanos() | 获得此时间间隔的纳秒 | + +```java +public class JDK8DateDemo9 { + public static void main(String[] args) { + LocalDate localDate1 = LocalDate.of(2020, 1, 1); + LocalDate localDate2 = LocalDate.of(2048, 12, 12); + Period period = Period.between(localDate1, localDate2); + System.out.println(period);//P28Y11M11D + Duration duration = Duration.between(localDateTime1, localDateTime2); + System.out.println(duration);//PT21H57M58S + } +} +``` + + + +*** + + + +### Math + +Math 用于做数学运算 + +Math 类中的方法全部是静态方法,直接用类名调用即可: + +| 方法 | 说明 | +| -------------------------------------------- | --------------------------------- | +| public static int abs(int a) | 获取参数a的绝对值 | +| public static double ceil(double a) | 向上取整 | +| public static double floor(double a) | 向下取整 | +| public static double pow(double a, double b) | 获取 a 的 b 次幂 | +| public static long round(double a) | 四舍五入取整 | +| public static int max(int a,int b) | 返回较大值 | +| public static int min(int a,int b) | 返回较小值 | +| public static double random() | 返回值为 double 的正值,[0.0,1.0) | + +```java +public class MathDemo { + public static void main(String[] args) { + // 1.取绝对值:返回正数。 + System.out.println(Math.abs(10)); + System.out.println(Math.abs(-10.3)); + // 2.向上取整: 5 + System.out.println(Math.ceil(4.00000001)); // 5.0 + System.out.println(Math.ceil(-4.00000001));//4.0 + // 3.向下取整:4 + System.out.println(Math.floor(4.99999999)); // 4.0 + System.out.println(Math.floor(-4.99999999)); // 5.0 + // 4.求指数次方 + System.out.println(Math.pow(2 , 3)); // 2^3 = 8.0 + // 5.四舍五入 10 + System.out.println(Math.round(4.49999)); // 4 + System.out.println(Math.round(4.500001)); // 5 + System.out.println(Math.round(5.5));//6 + } +} +``` + + + +**** + + + +### DecimalFormat + +使任何形式的数字解析和格式化 + +```java +public static void main(String[]args){ + double pi = 3.1415927; //圆周率 + //取一位整数 + System.out.println(new DecimalFormat("0").format(pi));   //3 + //取一位整数和两位小数 + System.out.println(new DecimalFormat("0.00").format(pi)); //3.14 + //取两位整数和三位小数,整数不足部分以0填补。 + System.out.println(new DecimalFormat("00.000").format(pi));// 03.142 + //取所有整数部分 + System.out.println(new DecimalFormat("#").format(pi));   //3 + //以百分比方式计数,并取两位小数 + System.out.println(new DecimalFormat("#.##%").format(pi)); //314.16% + + long c =299792458;  //光速 + //显示为科学计数法,并取五位小数 + System.out.println(new DecimalFormat("#.#####E0").format(c));//2.99792E8 + //显示为两位整数的科学计数法,并取四位小数 + System.out.println(new DecimalFormat("00.####E0").format(c));//29.9792E7 + //每三位以逗号进行分隔。 + System.out.println(new DecimalFormat(",###").format(c));//299,792,458 + //将格式嵌入文本 + System.out.println(new DecimalFormat("光速大小为每秒,###米。").format(c)); + +} +``` + + + + + +*** + + + +### BigDecimal + +Java 在 java.math 包中提供的 API 类,用来对超过16位有效位的数进行精确的运算 + +构造方法: + +* `public static BigDecimal valueOf(double val)`:包装浮点数成为大数据对象。 +* `public BigDecimal(double val)` +* `public BigDecimal(String val)` + +常用API: + +* `public BigDecimal add(BigDecimal value)`:加法运算 +* `public BigDecimal subtract(BigDecimal value)`:减法运算 +* `public BigDecimal multiply(BigDecimal value)`:乘法运算 +* `public BigDecimal divide(BigDecimal value)`:除法运算 +* `public double doubleValue()`:把 BigDecimal 转换成 double 类型 +* `public int intValue()`:转为 int 其他类型相同 +* `public BigDecimal divide (BigDecimal value,精确几位,舍入模式)`:除法 + +```java +public class BigDecimalDemo { + public static void main(String[] args) { + // 浮点型运算的时候直接+ - * / 可能会出现数据失真(精度问题)。 + System.out.println(0.1 + 0.2); + System.out.println(1.301 / 100); + + double a = 0.1 ; + double b = 0.2 ; + double c = a + b ; + System.out.println(c);//0.30000000000000004 + + // 1.把浮点数转换成大数据对象运算 + BigDecimal a1 = BigDecimal.valueOf(a); + BigDecimal b1 = BigDecimal.valueOf(b); + BigDecimal c1 = a1.add(b1);//a1.divide(b1);也可以 + System.out.println(c1); + + // BigDecimal只是解决精度问题的手段,double数据才是我们的目的!! + double d = c1.doubleValue(); + } +} +``` + +总结: + +1. BigDecimal 是用来进行精确计算的 +2. 创建 BigDecimal 的对象,构造方法使用参数类型为字符串的 +3. 四则运算中的除法,如果除不尽请使用 divide 的三个参数的方法 + +```java +BigDecimal divide = bd1.divide(参与运算的对象,小数点后精确到多少位,舍入模式); +//参数1:表示参与运算的BigDecimal 对象。 +//参数2:表示小数点后面精确到多少位 +//参数3:舍入模式 +// BigDecimal.ROUND_UP 进一法 +// BigDecimal.ROUND_FLOOR 去尾法 +// BigDecimal.ROUND_HALF_UP 四舍五入 +``` + + + +*** + + + +### Regex + +#### 概述 + +正则表达式的作用:是一些特殊字符组成的校验规则,可以校验信息的正确性,校验邮箱、电话号码、金额等。 + +比如检验 qq 号: + +```java +public static boolean checkQQRegex(String qq){ + return qq!=null && qq.matches("\\d{4,}");//即是数字 必须大于4位数 +}// 用\\d 是因为\用来告诉它是一个校验类,不是普通的字符 比如 \t \n +``` + +java.util.regex 包主要包括以下三个类: + +- Pattern 类: + + Pattern 对象是一个正则表达式的编译表示。Pattern 类没有公共构造方法,要创建一个 Pattern 对象,必须首先调用其公共静态编译方法,返回一个 Pattern 对象。该方法接受一个正则表达式作为它的第一个参数 + +- Matcher 类: + + Matcher 对象是对输入字符串进行解释和匹配操作的引擎。与Pattern 类一样,Matcher 也没有公共构造方法,需要调用 Pattern 对象的 matcher 方法来获得一个 Matcher 对象 + +- PatternSyntaxException: + + PatternSyntaxException 是一个非强制异常类,它表示一个正则表达式模式中的语法错误。 + + + +*** + + + +#### 字符匹配 + +##### 普通字符 + +字母、数字、汉字、下划线、以及没有特殊定义的标点符号,都是“普通字符”。表达式中的普通字符,在匹配一个字符串的时候,匹配与之相同的一个字符。其他统称**元字符** + + + +*** + + + +##### 特殊字符 + +\r\n 是 Windows 中的文本行结束标签,在 Unix/Linux 则是 \n + +| 元字符 | 说明 | +| ------ | ------------------------------------------------------------ | +| \ | 将下一个字符标记为一个特殊字符或原义字符,告诉它是一个校验类,不是普通字符 | +| \f | 换页符 | +| \n | 换行符 | +| \r | 回车符 | +| \t | 制表符 | +| \\ | 代表 \ 本身 | +| () | 使用 () 定义一个子表达式。子表达式的内容可以当成一个独立元素 | + + + +*** + + + +##### 标准字符 + +能够与多种字符匹配的表达式,注意区分大小写,大写是相反的意思,只能校验**单**个字符。 + +| 元字符 | 说明 | +| ------ | ------------------------------------------------------------ | +| . | 匹配任意一个字符(除了换行符),如果要匹配包括 \n 在内的所有字符,一般用 [\s\S] | +| \d | 数字字符,0~9 中的任意一个,等价于 [0-9] | +| \D | 非数字字符,等价于 [ ^0-9] | +| \w | 大小写字母或数字或下划线,等价于[a-zA-Z_0-9_] | +| \W | 对\w取非,等价于[ ^\w] | +| \s | 空格、制表符、换行符等空白字符的其中任意一个,等价于[\f\n\r\t\v] | +| \S | 对 \s 取非 | + +\x 匹配十六进制字符,\0 匹配八进制,例如 \xA 对应值为 10 的 ASCII 字符 ,即 \n + + + +*** + + + +##### 自定义符 + +自定义符号集合,[ ] 方括号匹配方式,能够匹配方括号中**任意一个**字符 + +| 元字符 | 说明 | +| ------------ | ----------------------------------------- | +| [ab5@] | 匹配 "a" 或 "b" 或 "5" 或 "@" | +| [^abc] | 匹配 "a","b","c" 之外的任意一个字符 | +| [f-k] | 匹配 "f"~"k" 之间的任意一个字母 | +| [^A-F0-3] | 匹配 "A","F","0"~"3" 之外的任意一个字符 | +| [a-d[m-p]] | 匹配 a 到 d 或者 m 到 p:[a-dm-p](并集) | +| [a-z&&[m-p]] | 匹配 a 到 z 并且 m 到 p:[a-dm-p](交集) | +| [^] | 取反 | + +* 正则表达式的特殊符号,被包含到中括号中,则失去特殊意义,除了 ^,- 之外,需要在前面加 \ + +* 标准字符集合,除小数点外,如果被包含于中括号,自定义字符集合将包含该集合。 + 比如:[\d. \ -+] 将匹配:数字、小数点、+、- + + + +*** + + + +##### 量词字符 + +修饰匹配次数的特殊符号。 + +* 匹配次数中的贪婪模式(匹配字符越多越好,默认 !),\* 和 + 都是贪婪型元字符。 +* 匹配次数中的非贪婪模式(匹配字符越少越好,修饰匹配次数的特殊符号后再加上一个 ? 号) + +| 元字符 | 说明 | +| ------ | --------------------------------- | +| X? | X 一次或一次也没,有相当于 {0,1} | +| X* | X 不出现或出现任意次,相当于 {0,} | +| X+ | X 至少一次,相当于 {1,} | +| X{n} | X 恰好 n 次 | +| {n,} | X 至少 n 次 | +| {n,m} | X 至少 n 次,但是不超过 m 次 | + + + +*** + + + +#### 位置匹配 + +##### 字符边界 + +本组标记匹配的不是字符而是位置,符合某种条件的位置 + +| 元字符 | 说明 | +| ------ | ------------------------------------------------------------ | +| ^ | 与字符串开始的地方匹配(在字符集合中用来求非,在字符集合外用作匹配字符串的开头) | +| $ | 与字符串结束的地方匹配 | +| \b | 匹配一个单词边界 | + + + +*** + + + +##### 捕获组 + +捕获组是把多个字符当一个单独单元进行处理的方法,它通过对括号内的字符分组来创建。 + +在表达式 `((A)(B(C)))`,有四个这样的组:((A)(B(C)))、(A)、(B(C))、(C)(按照括号从左到右依次为 group(1)...) + +* 调用 matcher 对象的 groupCount 方法返回一个 int 值,表示 matcher 对象当前有多个捕获组。 +* 特殊的组 group(0)、group(),代表整个表达式,该组不包括在 groupCount 的返回值中。 + +| 表达式 | 说明 | +| ------------------------- | ------------------------------------------------------------ | +| \| (分支结构) | 左右两边表达式之间 "或" 关系,匹配左边或者右边 | +| () (捕获组) | (1) 在被修饰匹配次数的时候,括号中的表达式可以作为整体被修饰
(2) 取匹配结果的时候,括号中的表达式匹配到的内容可以被单独得到
(3) 每一对括号分配一个编号,()的捕获根据左括号的顺序从 1 开始自动编号。捕获元素编号为零的第一个捕获是由整个正则表达式模式匹配的文本 | +| (?:Expression) 非捕获组 | 一些表达式中,不得不使用( ),但又不需要保存 () 中子表达式匹配的内容,这时可以用非捕获组来抵消使用( )带来的副作用。 | + + + +*** + + + +##### 反向引用 + +反向引用(\number),又叫回溯引用: + +* 每一对()会分配一个编号,使用 () 的捕获根据左括号的顺序从1开始自动编号 + +* 通过反向引用,可以对分组已捕获的字符串进行引用,继续匹配 + +* **把匹配到的字符重复一遍在进行匹配** + +* 应用 1: + + ```java + String regex = "((\d)3)\1[0-9](\w)\2{2}"; + ``` + + * 首先匹配 ((\d)3),其次 \1 匹配 ((\d)3) 已经匹配到的内容,\2 匹配 (\d), {2} 指的是 \2 的值出现两次 + * 实例:23238n22(匹配到 2 未来就继续匹配 2) + * 实例:43438n44 + +* 应用 2:爬虫 + + ```java + String regex = "<(h[1-6])>\w*?<\/\1>"; + ``` + + 匹配结果 + + ```java +

x

//匹配 +

x

//匹配 +

x

//不匹配 + ``` + + + +*** + + + +##### 零宽断言 + +预搜索(零宽断言)(环视) + +* 只进行子表达式的匹配,匹配内容不计入最终的匹配结果,是零宽度 + +* 判断当前位置的前后字符,是否符合指定的条件,但不匹配前后的字符,**是对位置的匹配** + +* 正则表达式匹配过程中,如果子表达式匹配到的是字符内容,而非位置,并被保存到最终的匹配结果中,那么就认为这个子表达式是占有字符的;如果子表达式匹配的仅仅是位置,或者匹配的内容并不保存到最终的匹配结果中,那么就认为这个子表达式是**零宽度**的。占有字符还是零宽度,是针对匹配的内容是否保存到最终的匹配结果中而言的 + + | 表达式 | 说明 | + | -------- | --------------------------------------- | + | (?=exp) | 断言自身出现的位置的后面能匹配表达式exp | + | (?<=exp) | 断言自身出现的位置的前面能匹配表达式exp | + | (?!exp) | 断言此位置的后面不能匹配表达式exp | + | (?(接口) + / \ + Set(接口) List(接口) + / \ / \ + HashSet(实现类) TreeSet<>(实现类) ArrayList(实现类) LinekdList<>(实现类) + / +LinkedHashSet<>(实现类) +``` + +**集合的特点:** + +* Set 系列集合:添加的元素是无序,不重复,无索引的 + * HashSet:添加的元素是无序,不重复,无索引的 + * LinkedHashSet:添加的元素是有序,不重复,无索引的 + * TreeSet:不重复,无索引,按照大小默认升序排序 +* List 系列集合:添加的元素是有序,可重复,有索引 + * ArrayList:添加的元素是有序,可重复,有索引 + * LinekdList:添加的元素是有序,可重复,有索引 + + + +*** + + + +#### API + +Collection 是集合的祖宗类,它的功能是全部集合都可以继承使用的,所以要学习它。 + +Collection 子类的构造器都有可以包装其他子类的构造方法,如: + +* `public ArrayList(Collection c)`:构造新集合,元素按照由集合的迭代器返回的顺序 + +* `public HashSet(Collection c)`:构造一个包含指定集合中的元素的新集合 + +Collection API 如下: + +* `public boolean add(E e)`:把给定的对象添加到当前集合中 。 +* `public void clear()`:清空集合中所有的元素。 +* `public boolean remove(E e)`:把给定的对象在当前集合中删除。 +* `public boolean contains(Object obj)`:判断当前集合中是否包含给定的对象。 +* `public boolean isEmpty()`:判断当前集合是否为空。 +* `public int size()`:返回集合中元素的个数。 +* `public Object[] toArray()`:把集合中的元素,存储到数组中 +* `public boolean addAll(Collection c)`:将指定集合中的所有元素添加到此集合 + +```java +public class CollectionDemo { + public static void main(String[] args) { + Collection sets = new HashSet<>(); + sets.add("MyBatis"); + System.out.println(sets.add("Java"));//true + System.out.println(sets.add("Java"));//false + sets.add("Spring"); + sets.add("MySQL"); + System.out.println(sets)//[]无序的; + System.out.println(sets.contains("java"));//true 存在 + Object[] arrs = sets.toArray(); + System.out.println("数组:"+ Arrays.toString(arrs)); + + Collection c1 = new ArrayList<>(); + c1.add("java"); + Collection c2 = new ArrayList<>(); + c2.add("ee"); + c1.addAll(c2);// c1:[java,ee] c2:[ee]; + } +} +``` + + + +*** + + + +#### 遍历 + +Collection 集合的遍历方式有三种: + +集合可以直接输出内容,因为底层重写了 toString() 方法 + +1. 迭代器 + + * `public Iterator iterator()`:获取集合对应的迭代器,用来遍历集合中的元素的 + * `E next()`:获取下一个元素值 + * `boolean hasNext()`:判断是否有下一个元素,有返回 true ,反之返回 false + * `default void remove()`:从底层集合中删除此迭代器返回的最后一个元素,这种方法只能在每次调用 next() 时调用一次 + +2. 增强 for 循环:可以遍历集合或者数组,遍历集合实际上是迭代器遍历的简化写法 + + ```java + for(被遍历集合或者数组中元素的类型 变量名称 : 被遍历集合或者数组){ + + } + ``` + + 缺点:遍历无法知道遍历到了哪个元素了,因为没有索引 + +3. JDK 1.8 开始之后的新技术 Lambda 表达式 + + ```java + public class CollectionDemo { + public static void main(String[] args) { + Collection lists = new ArrayList<>(); + lists.add("aa"); + lists.add("bb"); + lists.add("cc"); + System.out.println(lists); // lists = [aa, bb, cc] + //迭代器流程 + // 1.得到集合的迭代器对象。 + Iterator it = lists.iterator(); + // 2.使用while循环遍历。 + while(it.hasNext()){ + String ele = it.next(); + System.out.println(ele); + } + + //增强for + for (String ele : lists) { + System.out.println(ele); + } + //lambda表达式 + lists.forEach(s -> { + System.out.println(s); + }); + } + } + ``` + + + + + +*** + + + +#### List + +##### 概述 + +List 集合继承了 Collection 集合全部的功能。 + +List 系列集合有索引,所以多了很多按照索引操作元素的功能:for 循环遍历(4 种遍历) + +List 系列集合: + +* ArrayList:添加的元素是有序,可重复,有索引 + +* LinekdList:添加的元素是有序,可重复,有索引 + + + +*** + + + +##### ArrayList + +###### 介绍 + +ArrayList 添加的元素,是有序,可重复,有索引的 + +* `public boolean add(E e)`:将指定的元素追加到此集合的末尾 +* `public void add(int index, E element)`:将指定的元素,添加到该集合中的指定位置上 +* `public E get(int index)`:返回集合中指定位置的元素 +* `public E remove(int index)`:移除列表中指定位置的元素,返回的是被移除的元素 +* `public E set(int index, E element)`:用指定元素替换集合中指定位置的元素,返回更新前的元素值 +* `int indexOf(Object o)`:返回列表中指定元素第一次出现的索引,如果不包含此元素,则返回 -1 + +```java +public static void main(String[] args){ + List lists = new ArrayList<>();//多态 + lists.add("java1"); + lists.add("java1");//可以重复 + lists.add("java2"); + for(int i = 0 ; i < lists.size() ; i++ ) { + String ele = lists.get(i); + System.out.println(ele); + } +} +``` + + + +*** + + + +###### 源码 + +ArrayList 实现类集合底层**基于数组存储数据**的,查询快,增删慢,支持快速随机访问 + +```java +public class ArrayList extends AbstractList + implements List, RandomAccess, Cloneable, java.io.Serializable{} +``` + +- `RandomAccess` 是一个标志接口,表明实现这个这个接口的 List 集合是支持**快速随机访问**的。在 `ArrayList` 中,我们即可以通过元素的序号快速获取元素对象,这就是快速随机访问。 +- `ArrayList` 实现了 `Cloneable` 接口 ,即覆盖了函数 `clone()`,能被克隆 +- `ArrayList` 实现了 `Serializable ` 接口,这意味着 `ArrayList` 支持序列化,能通过序列化去传输 + +核心方法: + +* 构造函数:以无参数构造方法创建 ArrayList 时,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量(惰性初始化),即向数组中添加第一个元素时,**数组容量扩为 10** + +* 添加元素: + + ```java + // e 插入的元素 elementData底层数组 size 插入的位置 + public boolean add(E e) { + ensureCapacityInternal(size + 1); // Increments modCount!! + elementData[size++] = e; // 插入size位置,然后加一 + return true; + } + ``` + + 当 add 第 1 个元素到 ArrayList,size 是 0,进入 ensureCapacityInternal 方法, + + ```java + private void ensureCapacityInternal(int minCapacity) { + ensureExplicitCapacity(calculateCapacity(elementData, minCapacity)); + } + ``` + + ```java + private static int calculateCapacity(Object[] elementData, int minCapacity) { + // 判断elementData是不是空数组 + if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { + // 返回默认值和最小需求容量最大的一个 + return Math.max(DEFAULT_CAPACITY, minCapacity); + } + return minCapacity; + } + ``` + + 如果需要的容量大于数组长度,进行扩容: + + ```java + // 判断是否需要扩容 + private void ensureExplicitCapacity(int minCapacity) { + modCount++; + // 索引越界 + if (minCapacity - elementData.length > 0) + // 调用grow方法进行扩容,调用此方法代表已经开始扩容了 + grow(minCapacity); + } + ``` + + 指定索引插入,**在旧数组上操作**: + + ```java + public void add(int index, E element) { + rangeCheckForAdd(index); + ensureCapacityInternal(size + 1); // Increments modCount!! + // 将指定索引后的数据后移 + System.arraycopy(elementData, index, elementData, index + 1, size - index); + elementData[index] = element; + size++; + } + ``` + +* 扩容:新容量的大小为 `oldCapacity + (oldCapacity >> 1)`,`oldCapacity >> 1` 需要取整,所以新容量大约是旧容量的 1.5 倍左右,即 oldCapacity+oldCapacity/2 + + 扩容操作需要调用 `Arrays.copyOf()`(底层 `System.arraycopy()`)把原数组整个复制到**新数组**中,这个操作代价很高,因此最好在创建 ArrayList 对象时就指定大概的容量大小,减少扩容操作的次数 + + ```java + private void grow(int minCapacity) { + int oldCapacity = elementData.length; + int newCapacity = oldCapacity + (oldCapacity >> 1); + //检查新容量是否大于最小需要容量,若小于最小需要容量,就把最小需要容量当作数组的新容量 + if (newCapacity - minCapacity < 0) + newCapacity = minCapacity;//不需要扩容计算 + //检查新容量是否大于最大数组容量 + if (newCapacity - MAX_ARRAY_SIZE > 0) + //如果minCapacity大于最大容量,则新容量则为`Integer.MAX_VALUE` + //否则,新容量大小则为 MAX_ARRAY_SIZE 即为 `Integer.MAX_VALUE - 8` + newCapacity = hugeCapacity(minCapacity); + elementData = Arrays.copyOf(elementData, newCapacity); + } + ``` + + MAX_ARRAY_SIZE:要分配的数组的最大大小,分配更大的**可能**会导致 + + * OutOfMemoryError:Requested array size exceeds VM limit(请求的数组大小超出 VM 限制) + * OutOfMemoryError: Java heap space(堆区内存不足,可以通过设置 JVM 参数 -Xmx 来调节) + +* 删除元素:需要调用 System.arraycopy() 将 index+1 后面的元素都复制到 index 位置上,在旧数组上操作,该操作的时间复杂度为 O(N),可以看到 ArrayList 删除元素的代价是非常高的 + + ```java + public E remove(int index) { + rangeCheck(index); + modCount++; + E oldValue = elementData(index); + + int numMoved = size - index - 1; + if (numMoved > 0) + System.arraycopy(elementData, index+1, elementData, index, numMoved); + elementData[--size] = null; // clear to let GC do its work + + return oldValue; + } + ``` + +* 序列化:ArrayList 基于数组并且具有动态扩容特性,因此保存元素的数组不一定都会被使用,就没必要全部进行序列化。保存元素的数组 elementData 使用 transient 修饰,该关键字声明数组默认不会被序列化 + + ```java + transient Object[] elementData; + ``` + +* ensureCapacity:增加此实例的容量,以确保它至少可以容纳最小容量参数指定的元素数,减少增量重新分配的次数 + + ```java + public void ensureCapacity(int minCapacity) { + if (minCapacity > elementData.length + && !(elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA + && minCapacity <= DEFAULT_CAPACITY)) { + modCount++; + grow(minCapacity); + } + } + ``` + +* **Fail-Fast**:快速失败,modCount 用来记录 ArrayList **结构发生变化**的次数,结构发生变化是指添加或者删除至少一个元素的操作,或者是调整内部数组的大小,仅仅只是设置元素的值不算结构发生变化 + + 在进行序列化或者迭代等操作时,需要比较操作前后 modCount 是否改变,改变了抛出 ConcurrentModificationException 异常 + + ```java + public Iterator iterator() { + return new Itr(); + } + ``` + + ```java + private class Itr implements Iterator { + int cursor; // index of next element to return + int lastRet = -1; // index of last element returned; -1 if no such + int expectedModCount = modCount; + + Itr() {} + + public boolean hasNext() { + return cursor != size; + } + + // 获取下一个元素时首先判断结构是否发生变化 + public E next() { + checkForComodification(); + // ..... + } + // modCount 被其他线程改变抛出并发修改异常 + final void checkForComodification() { + if (modCount != expectedModCount) + throw new ConcurrentModificationException(); + } + // 【允许删除操作】 + public void remove() { + // ... + checkForComodification(); + // ... + // 删除后重置 expectedModCount + expectedModCount = modCount; + } + } + ``` + + + + + +*** + + + +##### Vector + +同步:Vector 的实现与 ArrayList 类似,但是方法上使用了 synchronized 进行同步 + +构造:默认长度为 10 的数组 + +扩容:Vector 的构造函数可以传入 capacityIncrement 参数,作用是在扩容时使容量 capacity 增长 capacityIncrement,如果这个参数的值小于等于 0(默认0),扩容时每次都令 capacity 为原来的两倍 + +对比 ArrayList + +1. Vector 是同步的,开销比 ArrayList 要大,访问速度更慢。最好使用 ArrayList 而不是 Vector,因为同步操作完全可以由程序来控制 + +2. Vector 每次扩容请求其大小的 2 倍(也可以通过构造函数设置增长的容量),而 ArrayList 是 1.5 倍 + +3. 底层都是 `Object[]` 数组存储 + + + +**** + + + +##### LinkedList + +###### 介绍 + +LinkedList 也是 List 的实现类:基于**双向链表**实现,使用 Node 存储链表节点信息,增删比较快,查询慢 + +LinkedList 除了拥有 List 集合的全部功能还多了很多操作首尾元素的特殊功能: + +* `public boolean add(E e)`:将指定元素添加到此列表的结尾 +* `public E poll()`:检索并删除此列表的头(第一个元素) +* `public void addFirst(E e)`:将指定元素插入此列表的开头 +* `public void addLast(E e)`:将指定元素添加到此列表的结尾 +* `public E pop()`:从此列表所表示的堆栈处弹出一个元素 +* `public void push(E e)`:将元素推入此列表所表示的堆栈 +* `public int indexOf(Object o)`:返回此列表中指定元素的第一次出现的索引,如果不包含返回 -1 +* `public int lastIndexOf(Object o)`:从尾遍历找 +* ` public boolean remove(Object o)`:一次只删除一个匹配的对象,如果删除了匹配对象返回 true +* `public E remove(int index)`:删除指定位置的元素 + +```java +public class ListDemo { + public static void main(String[] args) { + // 1.用LinkedList做一个队列:先进先出,后进后出。 + LinkedList queue = new LinkedList<>(); + // 入队 + queue.addLast("1号"); + queue.addLast("2号"); + queue.addLast("3号"); + System.out.println(queue); // [1号, 2号, 3号] + // 出队 + System.out.println(queue.removeFirst());//1号 + System.out.println(queue.removeFirst());//2号 + System.out.println(queue);//[3号] + + // 做一个栈 先进后出 + LinkedList stack = new LinkedList<>(); + // 压栈 + stack.push("第1颗子弹");//addFirst(e); + stack.push("第2颗子弹"); + stack.push("第3颗子弹"); + System.out.println(stack); // [ 第3颗子弹, 第2颗子弹, 第1颗子弹] + // 弹栈 + System.out.println(stack.pop());//removeFirst(); 第3颗子弹 + System.out.println(stack.pop()); + System.out.println(stack);// [第1颗子弹] + } +} +``` + + + +*** + + + +###### 源码 + +LinkedList 是一个实现了 List 接口的**双端链表**,支持高效的插入和删除操作,另外也实现了 Deque 接口,使得 LinkedList 类也具有队列的特性 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/LinkedList底层结构.png) + +核心方法: + +* 使 LinkedList 变成线程安全的,可以调用静态类 Collections 类中的 synchronizedList 方法: + + ```java + List list = Collections.synchronizedList(new LinkedList(...)); + ``` + +* 私有内部类 Node:这个类代表双端链表的节点 Node + + ```java + private static class Node { + E item; + Node next; + Node prev; + + Node(Node prev, E element, Node next) { + this.item = element; + this.next = next; + this.prev = prev; + } + } + ``` + +* 构造方法:只有无参构造和用已有的集合创建链表的构造方法 + +* 添加元素:默认加到尾部 + + ```java + public boolean add(E e) { + linkLast(e); + return true; + } + ``` + +* 获取元素:`get(int index)` 根据指定索引返回数据 + + * 获取头节点 (index=0):`getFirst()、element()、peek()、peekFirst()` 这四个获取头结点方法的区别在于对链表为空时的处理方式,是抛出异常还是返回NULL,其中 `getFirst() element()` 方法将会在链表为空时,抛出异常 + * 获取尾节点 (index=-1):getLast() 方法在链表为空时,抛出 NoSuchElementException,而 peekLast() 不会,只会返回 null + +* 删除元素: + + * remove()、removeFirst()、pop():删除头节点 + * removeLast()、pollLast():删除尾节点,removeLast()在链表为空时抛出NoSuchElementException,而pollLast()方法返回null + +对比 ArrayList + +1. 是否保证线程安全:ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全 +2. 底层数据结构: + * Arraylist 底层使用的是 `Object` 数组 + * LinkedList 底层使用的是双向链表数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环) +3. 插入和删除是否受元素位置的影响: + * ArrayList 采用数组存储,所以插入和删除元素受元素位置的影响 + * LinkedList采 用链表存储,所以对于`add(E e)`方法的插入,删除元素不受元素位置的影响 +4. 是否支持快速随机访问: + * LinkedList 不支持高效的随机元素访问,ArrayList 支持 + * 快速随机访问就是通过元素的序号快速获取元素对象(对应于 `get(int index)` 方法) +5. 内存空间占用: + * ArrayList 的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间 + * LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList更多的空间(因为要存放直接后继和直接前驱以及数据) + + + +*** + + + +#### Set + +##### 概述 + +Set 系列集合: + +* HashSet:添加的元素是无序,不重复,无索引的 +* LinkedHashSet:添加的元素是有序,不重复,无索引的 +* TreeSet:不重复,无索引,按照大小默认升序排序 + +**注意**:没有索引,不能使用普通 for 循环遍历 + + + +*** + + + +##### HashSet + +哈希值: + +- 哈希值:JDK 根据对象的地址或者字符串或者数字计算出来的数值 + +- 获取哈希值:Object 类中的 public int hashCode() + +- 哈希值的特点 + + - 同一个对象多次调用 hashCode() 方法返回的哈希值是相同的 + - 默认情况下,不同对象的哈希值是不同的,而重写 hashCode() 方法,可以实现让不同对象的哈希值相同 + +**HashSet 底层就是基于 HashMap 实现,值是 PRESENT = new Object()** + +Set 集合添加的元素是无序,不重复的。 + +* 是如何去重复的? + + ```java + 1.对于有值特性的,Set集合可以直接判断进行去重复。 + 2.对于引用数据类型的类对象,Set集合是按照如下流程进行是否重复的判断。 + Set集合会让两两对象,先调用自己的hashCode()方法得到彼此的哈希值(所谓的内存地址) + 然后比较两个对象的哈希值是否相同,如果不相同则直接认为两个对象不重复。 + 如果哈希值相同,会继续让两个对象进行equals比较内容是否相同,如果相同认为真的重复了 + 如果不相同认为不重复。 + + Set集合会先让对象调用hashCode()方法获取两个对象的哈希值比较 + / \ + false true + / \ + 不重复 继续让两个对象进行equals比较 + / \ + false true + / \ + 不重复 重复了 + ``` + +* Set 系列集合元素无序的根本原因 + + Set 系列集合添加元素无序的根本原因是因为**底层采用了哈希表存储元素**。 + + * JDK 1.8 之前:哈希表 = 数组(初始容量16) + 链表 + (哈希算法) + * JDK 1.8 之后:哈希表 = 数组(初始容量16) + 链表 + 红黑树 + (哈希算法) + * 当链表长度超过阈值 8 且当前数组的长度 > 64时,将链表转换为红黑树,减少了查找时间 + * 当链表长度超过阈值 8 且当前数组的长度 < 64时,扩容 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/HashSet底层结构哈希表.png) + + 每个元素的 hashcode() 的值进行响应的算法运算,计算出的值相同的存入一个数组块中,以链表的形式存储,如果链表长度超过8就采取红黑树存储,所以输出的元素是无序的。 + +* 如何设置只要对象内容一样,就希望集合认为重复:**重写 hashCode 和 equals 方法** + + + +**** + + + +##### Linked + +LinkedHashSet 为什么是有序的? + +LinkedHashSet 底层依然是使用哈希表存储元素的,但是每个元素都额外带一个链来维护添加顺序,不光增删查快,还有顺序,缺点是多了一个存储顺序的链会**占内存空间**,而且不允许重复,无索引 + + + +**** + + + +##### TreeSet + +TreeSet 集合自排序的方式: + +1. 有值特性的元素直接可以升序排序(浮点型,整型) +2. 字符串类型的元素会按照首字符的编号排序 +3. 对于自定义的引用数据类型,TreeSet 默认无法排序,执行的时候报错,因为不知道排序规则 + +自定义的引用数据类型,TreeSet 默认无法排序,需要定制排序的规则,方案有 2 种: + + * 直接为**对象的类**实现比较器规则接口 Comparable,重写比较方法: + + 方法:`public int compareTo(Employee o): this 是比较者, o 是被比较者` + + * 比较者大于被比较者,返回正数 + * 比较者小于被比较者,返回负数 + * 比较者等于被比较者,返回 0 + + * 直接为**集合**设置比较器 Comparator 对象,重写比较方法: + + 方法:`public int compare(Employee o1, Employee o2): o1 比较者, o2 被比较者` + + * 比较者大于被比较者,返回正数 + * 比较者小于被比较者,返回负数 + * 比较者等于被比较者,返回 0 + +注意:如果类和集合都带有比较规则,优先使用集合自带的比较规则 + +```java +public class TreeSetDemo{ + public static void main(String[] args){ + Set students = new TreeSet<>(); + Collections.add(students,s1,s2,s3); + System.out.println(students);//按照年龄比较 升序 + + Set s = new TreeSet<>(new Comparator(){ + @Override + public int compare(Student o1, Student o2) { + // o1比较者 o2被比较者 + return o2.getAge() - o1.getAge();//降序 + } + }); + } +} + +public class Student implements Comparable{ + private String name; + private int age; + // 重写了比较方法。 + // e1.compareTo(o) + // 比较者:this + // 被比较者:o + // 需求:按照年龄比较 升序,年龄相同按照姓名 + @Override + public int compareTo(Student o) { + int result = this.age - o.age; + return result == 0 ? this.getName().compareTo(o.getName):result; + } +} +``` + +比较器原理:底层是以第一个元素为基准,加一个新元素,就会和第一个元素比,如果大于,就继续和大于的元素进行比较,直到遇到比新元素大的元素为止,放在该位置的左边(红黑树) + + + + +*** + + + +#### Queue + +Queue:队列,先进先出的特性 + +PriorityQueue 是优先级队列,底层存储结构为 Object[],默认实现为小顶堆,每次出队最小的元素 + +构造方法: + +* `public PriorityQueue()`:构造默认长度为 11 的队列(数组) + +* `public PriorityQueue(Comparator comparator)`:利用比较器自定义堆排序的规则 + + ```java + Queue pq = new PriorityQueue<>((v1, v2) -> v2 - v1);//实现大顶堆 + +常用 API: + +* `public boolean offer(E e)`:将指定的元素插入到此优先级队列的**尾部** +* `public E poll() `:检索并删除此队列的**头元素**,如果此队列为空,则返回 null +* `public E peek()`:检索但不删除此队列的头,如果此队列为空,则返回 null +* `public boolean remove(Object o)`:从该队列中删除指定元素(如果存在),删除元素 e 使用 o.equals(e) 比较,如果队列包含多个这样的元素,删除第一个 + + + +**** + + + +#### Collections + +java.utils.Collections:集合**工具类**,Collections 并不属于集合,是用来操作集合的工具类 + +Collections 有几个常用的API: + +* `public static boolean addAll(Collection c, T... e)`:给集合对象批量添加元素 +* `public static void shuffle(List list)`:打乱集合顺序 +* `public static void sort(List list)`:将集合中元素按照默认规则排序 +* `public static void sort(List list,Comparator )`:集合中元素按照指定规则排序 +* `public static List synchronizedList(List list)`:返回由指定 list 支持的线程安全 list +* `public static Set singleton(T o)`:返回一个只包含指定对象的不可变组 + +```java +public class CollectionsDemo { + public static void main(String[] args) { + Collection names = new ArrayList<>(); + Collections.addAll(names,"张","王","李","赵"); + + List scores = new ArrayList<>(); + Collections.addAll(scores, 98.5, 66.5 , 59.5 , 66.5 , 99.5 ); + Collections.shuffle(scores); + Collections.sort(scores); // 默认升序排序! + System.out.println(scores); + + List students = new ArrayList<>(); + Collections.addAll(students,s1,s2,s3,s4); + Collections.sort(students,new Comparator(){ + + }) + } +} + +public class Student{ + private String name; + private int age; +} +``` + + + + + +*** + + + +### Map + +#### 概述 + +Collection 是单值集合体系,Map集合是一种双列集合,每个元素包含两个值。 + +Map集合的每个元素的格式:key=value(键值对元素),Map集合也被称为键值对集合 + +Map集合的完整格式:`{key1=value1, key2=value2, key3=value3, ...}` + +``` +Map集合的体系: + Map(接口,Map集合的祖宗类) + / \ + TreeMap HashMap(实现类,经典的,用的最多) + \ + LinkedHashMap(实现类) +``` + +Map 集合的特点: + +1. Map 集合的特点都是由键决定的 +2. Map 集合的键是无序,不重复的,无索引的(Set) +3. Map 集合的值无要求(List) +4. Map 集合的键值对都可以为 null +5. Map 集合后面重复的键对应元素会覆盖前面的元素 + +HashMap:元素按照键是无序,不重复,无索引,值不做要求 + +LinkedHashMap:元素按照键是有序,不重复,无索引,值不做要求 + + + +*** + + + +#### 常用API + +Map 集合的常用 API + +* `public V put(K key, V value)`:把指定的键与值添加到 Map 集合中,**重复的键会覆盖前面的值元素** +* `public V remove(Object key)`:把指定的键对应的键值对元素在集合中删除,返回被删除元素的值 +* `public V get(Object key)`:根据指定的键,在 Map 集合中获取对应的值 +* `public Set keySet()`:获取 Map 集合中所有的键,存储到 **Set 集合**中 +* `public Collection values()`:获取全部值的集合,存储到 **Collection 集合** +* `public Set> entrySet()`:获取Map集合中所有的键值对对象的集合 +* `public boolean containsKey(Object key)`:判断该集合中是否有此键 + +```java +public class MapDemo { + public static void main(String[] args) { + Map maps = new HashMap<>(); + maps.put(.....); + System.out.println(maps.isEmpty());//false + Integer value = maps.get("....");//返回键值对象 + Set keys = maps.keySet();//获取Map集合中所有的键, + //Map集合的键是无序不重复的,所以返回的是一个Set集合 + Collection values = maps.values(); + //Map集合的值是不做要求的,可能重复,所以值要用Collection集合接收! + } +} +``` + + + +*** + + + +#### 遍历方式 + +Map集合的遍历方式有:3种。 + +1. “键找值”的方式遍历:先获取 Map 集合全部的键,再根据遍历键找值。 +2. “键值对”的方式遍历:难度较大,采用增强 for 或者迭代器 +3. JDK 1.8 开始之后的新技术:foreach,采用 Lambda 表达式 + +集合可以直接输出内容,因为底层重写了 toString() 方法 + +```java +public static void main(String[] args){ + Map maps = new HashMap<>(); + //(1)键找值 + Set keys = maps.keySet(); + for(String key : keys) { + System.out.println(key + "=" + maps.get(key)); + } + //Iterator iterator = hm.keySet().iterator(); + + //(2)键值对 + //(2.1)普通方式 + Set> entries = maps.entrySet(); + for (Map.Entry entry : entries) { + System.out.println(entry.getKey() + "=>" + entry.getValue()); + } + //(2.2)迭代器方式 + Iterator> iterator = maps.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + System.out.println(entry.getKey() + "=" + entry.getValue()); + + } + //(3) Lamda + maps.forEach((k,v) -> { + System.out.println(k + "==>" + v); + }) +} +``` + + + +*** + + + +#### HashMap + +##### 基本介绍 + +HashMap 基于哈希表的 Map 接口实现,是以 key-value 存储形式存在,主要用来存放键值对 + +特点: + +* HashMap 的实现不是同步的,这意味着它不是线程安全的 +* key 是唯一不重复的,底层的哈希表结构,依赖 hashCode 方法和 equals 方法保证键的唯一 +* key、value 都可以为null,但是 key 位置只能是一个null +* HashMap 中的映射不是有序的,即存取是无序的 +* **key 要存储的是自定义对象,需要重写 hashCode 和 equals 方法,防止出现地址不同内容相同的 key** + +JDK7 对比 JDK8: + +* 7 = 数组 + 链表,8 = 数组 + 链表 + 红黑树 +* 7 中是头插法,多线程容易造成环,8 中是尾插法 +* 7 的扩容是全部数据重新定位,8 中是位置不变或者当前位置 + 旧 size 大小来实现 +* 7 是先判断是否要扩容再插入,8 中是先插入再看是否要扩容 + +底层数据结构: + +* 哈希表(Hash table,也叫散列表),根据关键码值而直接访问的数据结构。通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度,这个映射函数叫做散列函数,存放记录的数组叫做散列表 + +* JDK1.8 之前 HashMap 由数组+链表组成 + + * 数组是 HashMap 的主体 + * 链表则是为了解决哈希冲突而存在的(**拉链法解决冲突**),拉链法就是头插法,两个对象调用的 hashCode 方法计算的哈希码值(键的哈希)一致导致计算的数组索引值相同 + +* JDK1.8 以后 HashMap 由**数组+链表 +红黑树**数据结构组成 + + * 解决哈希冲突时有了较大的变化 + * 当链表长度**超过(大于)阈值**(或者红黑树的边界值,默认为 8)并且当前数组的**长度大于等于 64 时**,此索引位置上的所有数据改为红黑树存储 + * 即使哈希函数取得再好,也很难达到元素百分百均匀分布。当 HashMap 中有大量的元素都存放到同一个桶中时,就相当于一个长的单链表,假如单链表有 n 个元素,遍历的**时间复杂度是 O(n)**,所以 JDK1.8 中引入了 红黑树(查找**时间复杂度为 O(logn)**)来优化这个问题,使得查找效率更高 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/HashMap底层结构.png) + + + +参考视频:https://www.bilibili.com/video/BV1nJ411J7AA + + + +*** + + + +##### 继承关系 + +HashMap 继承关系如下图所示: + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/HashMap继承关系.bmp) + +说明: + +* Cloneable 空接口,表示可以克隆, 创建并返回 HashMap 对象的一个副本。 +* Serializable 序列化接口,属于标记性接口,HashMap 对象可以被序列化和反序列化。 +* AbstractMap 父类提供了 Map 实现接口,以最大限度地减少实现此接口所需的工作 + + + +*** + + + +##### 成员属性 + +1. 序列化版本号 + + ```java + private static final long serialVersionUID = 362498820763181265L; + ``` + +2. 集合的初始化容量(**必须是二的 n 次幂** ) + + ```java + // 默认的初始容量是16 -- 1<<4相当于1*2的4次方---1*16 + static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; + ``` + + HashMap 构造方法指定集合的初始化容量大小: + + ```java + HashMap(int initialCapacity)// 构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap + ``` + + * 为什么必须是 2 的 n 次幂?用位运算替代取余计算,减少 rehash 的代价(移动的节点少) + + HashMap 中添加元素时,需要根据 key 的 hash 值确定在数组中的具体位置。为了减少碰撞,把数据分配均匀,每个链表长度大致相同,实现该方法就是取模 `hash%length`,计算机中直接求余效率不如位移运算, **`hash % length == hash & (length-1)` 的前提是 length 是 2 的 n 次幂** + + 散列平均分布:2 的 n 次方是 1 后面 n 个 0,2 的 n 次方 -1 是 n 个 1,可以**保证散列的均匀性**,减少碰撞 + + ```java + 例如长度为8时候,3&(8-1)=3 2&(8-1)=2 ,不同位置上,不碰撞; + 例如长度为9时候,3&(9-1)=0 2&(9-1)=0 ,都在0上,碰撞了; + ``` + + * 如果输入值不是 2 的幂会怎么样? + + 创建 HashMap 对象时,HashMap 通过位移运算和或运算得到的肯定是 2 的幂次数,并且是大于那个数的最近的数字,底层采用 tableSizeFor() 方法 + +3. 默认的负载因子,默认值是 0.75 + + ```java + static final float DEFAULT_LOAD_FACTOR = 0.75f; + ``` + +4. 集合最大容量 + + ```java + // 集合最大容量的上限是:2的30次幂 + static final int MAXIMUM_CAPACITY = 1 << 30;// 0100 0000 0000 0000 0000 0000 0000 0000 = 2 ^ 30 + ``` + +5. 当链表的值超过 8 则会转红黑树(JDK1.8 新增) + + ```java + // 当桶(bucket)上的结点数大于这个值时会转成红黑树 + static final int TREEIFY_THRESHOLD = 8; + ``` + + 为什么 Map 桶中节点个数大于 8 才转为红黑树? + + * 在 HashMap 中有一段注释说明:**空间和时间的权衡** + + ```java + TreeNodes占用空间大约是普通节点的两倍,所以我们只在箱子包含足够的节点时才使用树节点。当节点变少(由于删除或调整大小)时,就会被转换回普通的桶。在使用分布良好的用户hashcode时,很少使用树箱。理想情况下,在随机哈希码下,箱子中节点的频率服从"泊松分布",默认调整阈值为0.75,平均参数约为0.5,尽管由于调整粒度的差异很大。忽略方差,列表大小k的预期出现次数是(exp(-0.5)*pow(0.5, k)/factorial(k)) + 0: 0.60653066 + 1: 0.30326533 + 2: 0.07581633 + 3: 0.01263606 + 4: 0.00157952 + 5: 0.00015795 + 6: 0.00001316 + 7: 0.00000094 + 8: 0.00000006 + more: less than 1 in ten million + 一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件,所以我们选择8这个数字 + ``` + + * 其他说法 + 红黑树的平均查找长度是 log(n),如果长度为 8,平均查找长度为 log(8)=3,链表的平均查找长度为 n/2,当长度为 8 时,平均查找长度为 8/2=4,这才有转换成树的必要;链表长度如果是小于等于 6,6/2=3,而 log(6)=2.6,虽然速度也很快的,但转化为树结构和生成树的时间并不短 + +6. 当链表的值小于 6 则会从红黑树转回链表 + + ```java + // 当桶(bucket)上的结点数小于这个值时树转链表 + static final int UNTREEIFY_THRESHOLD = 6; + ``` + +7. 当 Map 里面的数量**大于等于**这个阈值时,表中的桶才能进行树形化 ,否则桶内元素超过 8 时会扩容,而不是树形化。为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD (8) + + ```java + // 桶中结构转化为红黑树对应的数组长度最小的值 + static final int MIN_TREEIFY_CAPACITY = 64; + ``` + + 原因:数组比较小的情况下变为红黑树结构,反而会降低效率,红黑树需要进行左旋,右旋,变色这些操作来保持平衡 + +8. table 用来初始化(必须是二的 n 次幂) + + ```java + // 存储元素的数组 + transient Node[] table; + ``` + + 9. HashMap 中**存放元素的个数** + + ```java + // 存放元素的个数,HashMap中K-V的实时数量,不是table数组的长度 + transient int size; + ``` + +10. 记录 HashMap 的修改次数 + + ```java + // 每次扩容和更改map结构的计数器 + transient int modCount; + ``` + +11. 调整大小下一个容量的值计算方式为:容量 * 负载因子,容量是数组的长度 + + ```java + // 临界值,当实际大小(容量*负载因子)超过临界值时,会进行扩容 + int threshold; + ``` + +12. **哈希表的加载因子** + + ```java + final float loadFactor; + ``` + + * 加载因子的概述 + + loadFactor 加载因子,是用来衡量 HashMap 满的程度,表示 HashMap 的疏密程度,影响 hash 操作到同一个数组位置的概率,计算 HashMap 的实时加载因子的方法为 **size/capacity**,而不是占用桶的数量去除以 capacity,capacity 是桶的数量,也就是 table 的长度 length + + 当 HashMap 容纳的元素已经达到数组长度的 75% 时,表示 HashMap 拥挤需要扩容,而扩容这个过程涉及到 rehash、复制数据等操作,非常消耗性能,所以开发中尽量减少扩容的次数,通过创建 HashMap 集合对象时指定初始容量来避免 + + ```java + HashMap(int initialCapacity, float loadFactor)//构造指定初始容量和加载因子的空HashMap + ``` + + * 为什么加载因子设置为 0.75,初始化临界值是 12? + + loadFactor 太大导致查找元素效率低,存放的数据拥挤,太小导致数组的利用率低,存放的数据会很分散。loadFactor 的默认值为 **0.75f 是官方给出的一个比较好的临界值** + + * threshold 计算公式:capacity(数组长度默认16) * loadFactor(默认 0.75)。当 size >= threshold 的时候,那么就要考虑对数组的 resize(扩容),这就是衡量数组是否需要扩增的一个标准, 扩容后的 HashMap 容量是之前容量的**两倍** + + + +*** + + + +##### 构造方法 + +* 构造一个空的 HashMap ,**默认初始容量(16)和默认负载因子(0.75)** + + ```java + public HashMap() { + this.loadFactor = DEFAULT_LOAD_FACTOR; + // 将默认的加载因子0.75赋值给loadFactor,并没有创建数组 + } + ``` + +* 构造一个具有指定的初始容量和默认负载因子(0.75)HashMap + + ```java + // 指定“容量大小”的构造函数 + public HashMap(int initialCapacity) { + this(initialCapacity, DEFAULT_LOAD_FACTOR); + } + ``` + +* 构造一个具有指定的初始容量和负载因子的 HashMap + + ```java + public HashMap(int initialCapacity, float loadFactor) { + // 进行判断 + // 将指定的加载因子赋值给HashMap成员变量的负载因子loadFactor + this.loadFactor = loadFactor; + // 最后调用了tableSizeFor + this.threshold = tableSizeFor(initialCapacity); + } + ``` + + * 对于 `this.threshold = tableSizeFor(initialCapacity)` + + JDK8 以后的构造方法中,并没有对 table 这个成员变量进行初始化,table 的初始化被推迟到了 put 方法中,在 put 方法中会对 threshold 重新计算 + +* 包含另一个 `Map` 的构造函数 + + ```java + // 构造一个映射关系与指定 Map 相同的新 HashMap + public HashMap(Map m) { + // 负载因子loadFactor变为默认的负载因子0.75 + this.loadFactor = DEFAULT_LOAD_FACTOR; + putMapEntries(m, false); + } + ``` + + putMapEntries 源码分析: + + ```java + final void putMapEntries(Map m, boolean evict) { + //获取参数集合的长度 + int s = m.size(); + if (s > 0) { + //判断参数集合的长度是否大于0 + if (table == null) { // 判断table是否已经初始化 + // pre-size + // 未初始化,s为m的实际元素个数 + float ft = ((float)s / loadFactor) + 1.0F; + int t = ((ft < (float)MAXIMUM_CAPACITY) ? + (int)ft : MAXIMUM_CAPACITY); + // 计算得到的t大于阈值,则初始化阈值 + if (t > threshold) + threshold = tableSizeFor(t); + } + // 已初始化,并且m元素个数大于阈值,进行扩容处理 + else if (s > threshold) + resize(); + // 将m中的所有元素添加至HashMap中 + for (Map.Entry e : m.entrySet()) { + K key = e.getKey(); + V value = e.getValue(); + putVal(hash(key), key, value, false, evict); + } + } + } + ``` + + `float ft = ((float)s / loadFactor) + 1.0F` 这一行代码中为什么要加 1.0F ? + + s / loadFactor 的结果是小数,加 1.0F 相当于是对小数做一个向上取整以尽可能的保证更大容量,更大的容量能够减少 resize 的调用次数,这样可以减少数组的扩容 + + + +*** + + + +##### 成员方法 + +* hash():HashMap 是支持 Key 为空的;HashTable 是直接用 Key 来获取 HashCode,key 为空会抛异常 + + * &(按位与运算):相同的二进制数位上,都是 1 的时候,结果为 1,否则为零 + + * ^(按位异或运算):相同的二进制数位上,数字相同,结果为 0,不同为 1,**不进位加法** + + 0 1 相互做 & | ^ 运算,结果出现 0 和 1 的数量分别是 3:1、1:3、1:1,所以异或是最平均的 + + ```java + static final int hash(Object key) { + int h; + // 1)如果key等于null:可以看到当key等于null的时候也是有哈希值的,返回的是0 + // 2)如果key不等于null:首先计算出key的hashCode赋值给h,然后与h无符号右移16位后的二进制进行按位异或 + return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); + } + ``` + + 计算 hash 的方法:将 hashCode 无符号右移 16 位,高 16bit 和低 16bit 做异或,扰动运算 + + 原因:当数组长度很小,假设是 16,那么 n-1 即为 1111 ,这样的值和 hashCode() 直接做按位与操作,实际上只使用了哈希值的后 4 位。如果当哈希值的高位变化很大,低位变化很小,就很容易造成哈希冲突了,所以这里**把高低位都利用起来,让高16 位也参与运算**,从而解决了这个问题 + + 哈希冲突的处理方式: + + * 开放定址法:线性探查法(ThreadLocalMap 使用),平方探查法(i + 1^2、i - 1^2、i + 2^2……)、双重散列(多个哈希函数) + * 链地址法:拉链法 + +* put():jdk1.8 前是头插法 (链地址法),多线程下扩容出现循环链表,jdk1.8 以后引入红黑树,插入方法变成尾插法 + + 第一次调用 put 方法时创建数组 Node[] table,因为散列表耗费内存,为了防止内存浪费,所以**延迟初始化** + + 存储数据步骤(存储过程): + + 1. 先通过 hash 值计算出 key 映射到哪个桶,哈希寻址 + 2. 如果桶上没有碰撞冲突,则直接插入 + 3. 如果出现碰撞冲突:如果该桶使用红黑树处理冲突,则调用红黑树的方法插入数据;否则采用传统的链式方法插入,如果链的长度达到临界值,则把链转变为红黑树 + 4. 如果数组位置相同,通过 equals 比较内容是否相同:相同则新的 value 覆盖旧 value,不相同则将新的键值对添加到哈希表中 + 5. 最后判断 size 是否大于阈值 threshold,则进行扩容 + + ```java + public V put(K key, V value) { + return putVal(hash(key), key, value, false, true); + } + ``` + + putVal() 方法中 key 在这里执行了一下 hash(),在 putVal 函数中使用到了上述 hash 函数计算的哈希值: + + ```java + final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { + //。。。。。。。。。。。。。。 + if ((p = tab[i = (n - 1) & hash]) == null){//这里的n表示数组长度16 + //..... + } else { + if (e != null) { // existing mapping for key + V oldValue = e.value; + //onlyIfAbsent默认为false,所以可以覆盖已经存在的数据,如果为true说明不能覆盖 + if (!onlyIfAbsent || oldValue == null) + e.value = value; + afterNodeAccess(e); + // 如果这里允许覆盖,就直接返回了 + return oldValue; + } + } + // 如果是添加操作,modCount ++,如果不是替换,不会走这里的逻辑,modCount用来记录逻辑的变化 + ++modCount; + // 数量大于扩容阈值 + if (++size > threshold) + resize(); + afterNodeInsertion(evict); + return null; + } + ``` + + * `(n - 1) & hash`:计算下标位置 + + + + * 余数本质是不断做除法,把剩余的数减去,运算效率要比位运算低 + + + +* treeifyBin() + + 节点添加完成之后判断此时节点个数是否大于 TREEIFY_THRESHOLD 临界值 8,如果大于则将链表转换为红黑树,转换红黑树的方法 treeifyBin,整体代码如下: + + ```java + if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st + //转换为红黑树 tab表示数组名 hash表示哈希值 + treeifyBin(tab, hash); + ``` + + 1. 如果当前数组为空或者数组的长度小于进行树形化的阈 MIN_TREEIFY_CAPACITY = 64 就去扩容,而不是将节点变为红黑树 + 2. 如果是树形化遍历桶中的元素,创建相同个数的树形节点,复制内容,建立起联系,类似单向链表转换为双向链表 + 3. 让桶中的第一个元素即数组中的元素指向新建的红黑树的节点,以后这个桶里的元素就是红黑树而不是链表数据结构了 + +* tableSizeFor():创建 HashMap 指定容量时,HashMap 通过位移运算和或运算得到比指定初始化容量大的最小的 2 的 n 次幂 + + ```java + static final int tableSizeFor(int cap) {//int cap = 10 + int n = cap - 1; + n |= n >>> 1; + n |= n >>> 2; + n |= n >>> 4; + n |= n >>> 8; + n |= n >>> 16; + return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; + } + ``` + + 分析算法: + + 1. `int n = cap - 1`:防止 cap 已经是 2 的幂。如果 cap 已经是 2 的幂, 不执行减 1 操作,则执行完后面的无符号右移操作之后,返回的 capacity 将是这个 cap 的 2 倍 + 2. n=0 (cap-1 之后),则经过后面的几次无符号右移依然是 0,返回的 capacity 是 1,最后有 n+1 + 3. |(按位或运算):相同的二进制数位上,都是 0 的时候,结果为 0,否则为 1 + 4. 核心思想:**把最高位是 1 的位以及右边的位全部置 1**,结果加 1 后就是大于指定容量的最小的 2 的 n 次幂 + + 例如初始化的值为 10: + + * 第一次右移 + + ```java + int n = cap - 1;//cap=10 n=9 + n |= n >>> 1; + 00000000 00000000 00000000 00001001 //9 + 00000000 00000000 00000000 00000100 //9右移之后变为4 + -------------------------------------------------- + 00000000 00000000 00000000 00001101 //按位或之后是13 + //使得n的二进制表示中与最高位的1紧邻的右边一位为1 + ``` + + * 第二次右移 + + ```java + n |= n >>> 2;//n通过第一次右移变为了:n=13 + 00000000 00000000 00000000 00001101 // 13 + 00000000 00000000 00000000 00000011 // 13右移之后变为3 + ------------------------------------------------- + 00000000 00000000 00000000 00001111 //按位或之后是15 + //无符号右移两位,会将最高位两个连续的1右移两位,然后再与原来的n做或操作,这样n的二进制表示的高位中会有4个连续的1 + ``` + + 注意:容量最大是 32bit 的正数,因此最后 `n |= n >>> 16`,最多是 32 个 1(但是这已经是负数了)。在执行 tableSizeFor 之前,对 initialCapacity 做了判断,如果大于 MAXIMUM_CAPACITY(2 ^ 30),则取 MAXIMUM_CAPACITY;如果小于 MAXIMUM_CAPACITY(2 ^ 30),会执行移位操作,所以移位操作之后,最大 30 个 1,加 1 之后得 2 ^ 30 + + * 得到的 capacity 被赋值给了 threshold + + ```java + this.threshold = tableSizeFor(initialCapacity);//initialCapacity=10 + ``` + + * JDK 11 + + ```java + static final int tableSizeFor(int cap) { + //无符号右移,高位补0 + //-1补码: 11111111 11111111 11111111 11111111 + int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1); + return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; + } + //返回最高位之前的0的位数 + public static int numberOfLeadingZeros(int i) { + if (i <= 0) + return i == 0 ? 32 : 0; + // 如果i>0,那么就表明在二进制表示中其至少有一位为1 + int n = 31; + // i的最高位1在高16位,把i右移16位,让最高位1进入低16位继续递进判断 + if (i >= 1 << 16) { n -= 16; i >>>= 16; } + if (i >= 1 << 8) { n -= 8; i >>>= 8; } + if (i >= 1 << 4) { n -= 4; i >>>= 4; } + if (i >= 1 << 2) { n -= 2; i >>>= 2; } + return n - (i >>> 1); + } + ``` + + + +* resize(): + + 当 HashMap 中的**元素个数**超过 `(数组长度)*loadFactor(负载因子)` 或者链表过长时(链表长度 > 8,数组长度 < 64),就会进行数组扩容,创建新的数组,伴随一次重新 hash 分配,并且遍历 hash 表中所有的元素非常耗时,所以要尽量避免 resize + + 扩容机制为扩容为原来容量的 2 倍: + + ```java + if (oldCap > 0) { + if (oldCap >= MAXIMUM_CAPACITY) { + // 以前的容量已经是最大容量了,这时调大 扩容阈值 threshold + threshold = Integer.MAX_VALUE; + return oldTab; + } + else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && + oldCap >= DEFAULT_INITIAL_CAPACITY) + newThr = oldThr << 1; // double threshold + } + else if (oldThr > 0) // 初始化的threshold赋值给newCap + newCap = oldThr; + else { + newCap = DEFAULT_INITIAL_CAPACITY; + newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); + } + ``` + + HashMap 在进行扩容后,节点**要么就在原来的位置,要么就被分配到"原位置+旧容量"的位置** + + 判断:e.hash 与 oldCap 对应的有效高位上的值是 1,即当前数组长度 n 二进制为 1 的位为 x 位,如果 key 的哈希值 x 位也为 1,则扩容后的索引为 now + n + + 注意:这里要求**数组长度 2 的幂** + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/HashMap-resize扩容.png) + + 普通节点:把所有节点分成高低位两个链表,转移到数组 + + ```java + // 遍历所有的节点 + do { + next = e.next; + // oldCap 旧数组大小,2 的 n 次幂 + if ((e.hash & oldCap) == 0) { + if (loTail == null) + loHead = e; //指向低位链表头节点 + else + loTail.next = e; + loTail = e; //指向低位链表尾节点 + } + else { + if (hiTail == null) + hiHead = e; + else + hiTail.next = e; + hiTail = e; + } + } while ((e = next) != null); + + if (loTail != null) { + loTail.next = null; // 低位链表的最后一个节点可能在原哈希表中指向其他节点,需要断开 + newTab[j] = loHead; + } + ``` + + 红黑树节点:扩容时 split 方法会将树**拆成高位和低位两个链表**,判断长度是否小于等于 6 + + ```java + //如果低位链表首节点不为null,说明有这个链表存在 + if (loHead != null) { + //如果链表下的元素小于等于6 + if (lc <= UNTREEIFY_THRESHOLD) + //那就从红黑树转链表了,低位链表,迁移到新数组中下标不变,还是等于原数组到下标 + tab[index] = loHead.untreeify(map); + else { + //低位链表,迁移到新数组中下标不变,把低位链表整个赋值到这个下标下 + tab[index] = loHead; + //如果高位首节点不为空,说明原来的红黑树已经被拆分成两个链表了 + if (hiHead != null) + //需要构建新的红黑树了 + loHead.treeify(tab); + } + } + ``` + +​ + +* remove():删除是首先先找到元素的位置,如果是链表就遍历链表找到元素之后删除。如果是用红黑树就遍历树然后找到之后做删除,树小于 6 的时候退化为链表 + + ```java + final Node removeNode(int hash, Object key, Object value, + boolean matchValue, boolean movable) { + Node[] tab; Node p; int n, index; + // 节点数组tab不为空、数组长度n大于0、根据hash定位到的节点对象p, + // 该节点为树的根节点或链表的首节点)不为空,从该节点p向下遍历,找到那个和key匹配的节点对象 + if ((tab = table) != null && (n = tab.length) > 0 && + (p = tab[index = (n - 1) & hash]) != null) { + Node node = null, e; K k; V v;//临时变量,储存要返回的节点信息 + //key和value都相等,直接返回该节点 + if (p.hash == hash && + ((k = p.key) == key || (key != null && key.equals(k)))) + node = p; + + else if ((e = p.next) != null) { + //如果是树节点,调用getTreeNode方法从树结构中查找满足条件的节点 + if (p instanceof TreeNode) + node = ((TreeNode)p).getTreeNode(hash, key); + //遍历链表 + else { + do { + //e节点的键是否和key相等,e节点就是要删除的节点,赋值给node变量 + if (e.hash == hash && + ((k = e.key) == key || + (key != null && key.equals(k)))) { + node = e; + //跳出循环 + break; + } + p = e;//把当前节点p指向e 继续遍历 + } while ((e = e.next) != null); + } + } + //如果node不为空,说明根据key匹配到了要删除的节点 + //如果不需要对比value值或者对比value值但是value值也相等,可以直接删除 + if (node != null && (!matchValue || (v = node.value) == value || + (value != null && value.equals(v)))) { + if (node instanceof TreeNode) + ((TreeNode)node).removeTreeNode(this, tab, movable); + else if (node == p)//node是首节点 + tab[index] = node.next; + else //node不是首节点 + p.next = node.next; + ++modCount; + --size; + //LinkedHashMap + afterNodeRemoval(node); + return node; + } + } + return null; + } + ``` + + + +* get() + + 1. 通过 hash 值获取该 key 映射到的桶 + + 2. 桶上的 key 就是要查找的 key,则直接找到并返回 + + 3. 桶上的 key 不是要找的 key,则查看后续的节点: + + * 如果后续节点是红黑树节点,通过调用红黑树的方法根据 key 获取 value + + * 如果后续节点是链表节点,则通过循环遍历链表根据 key 获取 value + + 4. 红黑树节点调用的是 getTreeNode 方法通过树形节点的 find 方法进行查 + + * 查找红黑树,之前添加时已经保证这个树是有序的,因此查找时就是折半查找,效率更高。 + * 这里和插入时一样,如果对比节点的哈希值相等并且通过 equals 判断值也相等,就会判断 key 相等,直接返回,不相等就从子树中递归查找 + + 5. 时间复杂度 O(1) + + * 若为树,则在树中通过 key.equals(k) 查找,**O(logn)** + * 若为链表,则在链表中通过 key.equals(k) 查找,**O(n)** + + + +**** + + + +##### 并发异常 + +HashMap 和 ArrayList 一样,内部采用 modCount 用来记录集合结构发生变化的次数,结构发生变化是指添加或者删除至少一个元素的所有操作,或者是调整内部数组的大小,仅仅只是设置元素的值不算结构发生变化 + +在进行序列化或者迭代等操作时,需要比较操作前后 modCount 是否改变,如果**其他线程此时修改了集合内部的结构**,就会直接抛出 ConcurrentModificationException 异常 + +```java +HashMap map = new HashMap(); +Iterator iterator = map.keySet().iterator(); +``` + +```java +final class KeySet extends AbstractSet { + // 底层获取的是 KeyIterator + public final Iterator iterator() { + return new KeyIterator(); + } +} +final class KeyIterator extends HashIterator implements Iterator { + // 回调 HashMap.HashIterator#nextNode + public final K next() { + return nextNode().key; + } +} +``` + +```java +abstract class HashIterator { + Node next; // next entry to return + Node current; // current entry + int expectedModCount; // for 【fast-fail】,快速失败 + int index; // current slot + + HashIterator() { + // 把当前 map 的数量赋值给 expectedModCount,迭代时判断 + expectedModCount = modCount; + Node[] t = table; + current = next = null; + index = 0; + if (t != null && size > 0) { // advance to first entry + do {} while (index < t.length && (next = t[index++]) == null); + } + } + + public final boolean hasNext() { + return next != null; + } + // iterator.next() 会调用这个函数 + final Node nextNode() { + Node[] t; + Node e = next; + // 这里会判断 集合的结构是否发生了变化,变化后 modCount 会改变,直接抛出并发异常 + if (modCount != expectedModCount) + throw new ConcurrentModificationException(); + if (e == null) + throw new NoSuchElementException(); + if ((next = (current = e).next) == null && (t = table) != null) { + do {} while (index < t.length && (next = t[index++]) == null); + } + return e; + } + // 迭代器允许删除集合的元素,【删除后会重置 expectedModCount = modCount】 + public final void remove() { + Node p = current; + if (p == null) + throw new IllegalStateException(); + if (modCount != expectedModCount) + throw new ConcurrentModificationException(); + current = null; + K key = p.key; + removeNode(hash(key), key, null, false, false); + // 同步expectedModCount + expectedModCount = modCount; + } +} +``` + + + + + +*** + + + +#### LinkedMap + +##### 原理分析 + +LinkedHashMap 是 HashMap 的子类 + +* 优点:添加的元素按照键有序不重复的,有序的原因是底层维护了一个双向链表 + +* 缺点:会占用一些内存空间 + +对比 Set: + +* HashSet 集合相当于是 HashMap 集合的键,不带值 +* LinkedHashSet 集合相当于是 LinkedHashMap 集合的键,不带值 +* 底层原理完全一样,都是基于哈希表按照键存储数据的,只是 Map 多了一个键的值 + +源码解析: + +* **内部维护了一个双向链表**,用来维护插入顺序或者 LRU 顺序 + + ```java + transient LinkedHashMap.Entry head; + transient LinkedHashMap.Entry tail; + ``` + +* accessOrder 决定了顺序,默认为 false 维护的是插入顺序(先进先出),true 为访问顺序(**LRU 顺序**) + + ```java + final boolean accessOrder; + ``` + +* 维护顺序的函数 + + ```java + void afterNodeAccess(Node p) {} + void afterNodeInsertion(boolean evict) {} + ``` + +* put() + + ```java + // 调用父类HashMap的put方法 + public V put(K key, V value) { + return putVal(hash(key), key, value, false, true); + } + final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) + → afterNodeInsertion(evict);// evict为true + ``` + + afterNodeInsertion方法,当 removeEldestEntry() 方法返回 true 时会移除最近最久未使用的节点,也就是链表首部节点 first + + ```java + void afterNodeInsertion(boolean evict) { + LinkedHashMap.Entry first; + // evict 只有在构建 Map 的时候才为 false,这里为 true + if (evict && (first = head) != null && removeEldestEntry(first)) { + K key = first.key; + removeNode(hash(key), key, null, false, true);//移除头节点 + } + } + ``` + + removeEldestEntry() 默认为 false,如果需要让它为 true,需要继承 LinkedHashMap 并且覆盖这个方法的实现,在实现 LRU 的缓存中特别有用,通过移除最近最久未使用的节点,从而保证缓存空间足够,并且缓存的数据都是热点数据 + + ```java + protected boolean removeEldestEntry(Map.Entry eldest) { + return false; + } + ``` + +* get() + + 当一个节点被访问时,如果 accessOrder 为 true,则会将该节点移到链表尾部。也就是说指定为 LRU 顺序之后,在每次访问一个节点时会将这个节点移到链表尾部,那么链表首部就是最近最久未使用的节点 + + ```java + public V get(Object key) { + Node e; + if ((e = getNode(hash(key), key)) == null) + return null; + if (accessOrder) + afterNodeAccess(e); + return e.value; + } + ``` + + ```java + void afterNodeAccess(Node e) { + LinkedHashMap.Entry last; + if (accessOrder && (last = tail) != e) { + // 向下转型 + LinkedHashMap.Entry p = + (LinkedHashMap.Entry)e, b = p.before, a = p.after; + p.after = null; + // 判断 p 是否是首节点 + if (b == null) + //是头节点 让p后继节点成为头节点 + head = a; + else + //不是头节点 让p的前驱节点的next指向p的后继节点,维护链表的连接 + b.after = a; + // 判断p是否是尾节点 + if (a != null) + // 不是尾节点 让p后继节点指向p的前驱节点 + a.before = b; + else + // 是尾节点 让last指向p的前驱节点 + last = b; + // 判断last是否是空 + if (last == null) + // last为空说明p是尾节点或者只有p一个节点 + head = p; + else { + // last和p相互连接 + p.before = last; + last.after = p; + } + tail = p; + ++modCount; + } + } + ``` + +* remove() + + ```java + //调用HashMap的remove方法 + final Node removeNode(int hash, Object key, Object value,boolean matchValue, boolean movable) + → afterNodeRemoval(node); + ``` + + 当 HashMap 删除一个键值对时调用,会把在 HashMap 中删除的那个键值对一并从链表中删除 + + ```java + void afterNodeRemoval(Node e) { + LinkedHashMap.Entry p = + (LinkedHashMap.Entry)e, b = p.before, a = p.after; + // 让p节点与前驱节点和后继节点断开链接 + p.before = p.after = null; + // 判断p是否是头节点 + if (b == null) + // p是头节点 让head指向p的后继节点 + head = a; + else + // p不是头节点 让p的前驱节点的next指向p的后继节点,维护链表的连接 + b.after = a; + // 判断p是否是尾节点,是就让tail指向p的前驱节点,不是就让p.after指向前驱节点,双向 + if (a == null) + tail = b; + else + a.before = b; + } + ``` + + + +*** + + + +##### LRU + +使用 LinkedHashMap 实现的一个 LRU 缓存: + +- 设定最大缓存空间 MAX_ENTRIES 为 3 +- 使用 LinkedHashMap 的构造函数将 accessOrder 设置为 true,开启 LRU 顺序 +- 覆盖 removeEldestEntry() 方法实现,在节点多于 MAX_ENTRIES 就会将最近最久未使用的数据移除 + +```java +public static void main(String[] args) { + LRUCache cache = new LRUCache<>(); + cache.put(1, "a"); + cache.put(2, "b"); + cache.put(3, "c"); + cache.get(1);//把1放入尾部 + cache.put(4, "d"); + System.out.println(cache.keySet());//[3, 1, 4]只能存3个,移除2 +} + +class LRUCache extends LinkedHashMap { + private static final int MAX_ENTRIES = 3; + + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_ENTRIES; + } + + LRUCache() { + super(MAX_ENTRIES, 0.75f, true); + } +} +``` + + + +*** + + + +#### TreeMap + +TreeMap 实现了 SotredMap 接口,是有序不可重复的键值对集合,基于红黑树(Red-Black tree)实现,每个 key-value 都作为一个红黑树的节点,如果构造 TreeMap 没有指定比较器,则根据 key 执行自然排序(默认升序),如果指定了比较器则按照比较器来进行排序 + +TreeMap 集合指定大小规则有 2 种方式: + +* 直接为对象的类实现比较器规则接口 Comparable,重写比较方法 +* 直接为集合设置比较器 Comparator 对象,重写比较方法 + +说明:TreeSet 集合的底层是基于 TreeMap,只是键的附属值为空对象而已 + +成员属性: + +* Entry 节点 + + ```java + static final class Entry implements Map.Entry { + K key; + V value; + Entry left; //左孩子节点 + Entry right; //右孩子节点 + Entry parent; //父节点 + boolean color = BLACK; //节点的颜色,在红黑树中只有两种颜色,红色和黑色 + } + ``` + +* compare() + + ```java + //如果comparator为null,采用comparable.compartTo进行比较,否则采用指定比较器比较大小 + final int compare(Object k1, Object k2) { + return comparator == null ? ((Comparable)k1).compareTo((K)k2) + : comparator.compare((K)k1, (K)k2); + } + ``` + + + +参考文章:https://blog.csdn.net/weixin_33991727/article/details/91518677 + + + +*** + + + +#### WeakMap + +WeakHashMap 是基于弱引用的,内部的 Entry 继承 WeakReference,被弱引用关联的对象在**下一次垃圾回收时会被回收**,并且构造方法传入引用队列,用来在清理对象完成以后清理引用 + +```java +private static class Entry extends WeakReference implements Map.Entry { + Entry(Object key, V value, ReferenceQueue queue, int hash, Entry next) { + super(key, queue); + this.value = value; + this.hash = hash; + this.next = next; + } +} +``` + +WeakHashMap 主要用来实现缓存,使用 WeakHashMap 来引用缓存对象,由 JVM 对这部分缓存进行回收 + +Tomcat 中的 ConcurrentCache 使用了 WeakHashMap 来实现缓存功能,ConcurrentCache 采取分代缓存: + +* 经常使用的对象放入 eden 中,eden 使用 ConcurrentHashMap 实现,不用担心会被回收(伊甸园) + +* 不常用的对象放入 longterm,longterm 使用 WeakHashMap 实现,这些老对象会被垃圾收集器回收 + +* 当调用 get() 方法时,会先从 eden 区获取,如果没有找到的话再到 longterm 获取,当从 longterm 获取到就把对象放入 eden 中,从而保证经常被访问的节点不容易被回收 + +* 当调用 put() 方法时,如果 eden 的大小超过了 size,那么就将 eden 中的所有对象都放入 longterm 中,利用虚拟机回收掉一部分不经常使用的对象 + + ```java + public final class ConcurrentCache { + private final int size; + private final Map eden; + private final Map longterm; + + public ConcurrentCache(int size) { + this.size = size; + this.eden = new ConcurrentHashMap<>(size); + this.longterm = new WeakHashMap<>(size); + } + + public V get(K k) { + V v = this.eden.get(k); + if (v == null) { + v = this.longterm.get(k); + if (v != null) + this.eden.put(k, v); + } + return v; + } + + public void put(K k, V v) { + if (this.eden.size() >= size) { + this.longterm.putAll(this.eden); + this.eden.clear(); + } + this.eden.put(k, v); + } + } + + + + + +*** + + + +### 泛型 + +#### 概述 + +泛型(Generic): + +* 泛型就是一个标签:<数据类型> +* 泛型可以在编译阶段约束只能操作某种数据类型。 + +注意: + +* JDK 1.7 开始之后,泛型后面的申明可以省略不写 +* **泛型和集合都只能支持引用数据类型,不支持基本数据类型** + +```java +ArrayList lists = new ArrayList<>(); +lists.add(99.9); +lists.add('a'); +lists.add("Java"); +ArrayList list = new ArrayList<>(); +lists1.add(10); +lists1.add(20); +``` + +优点:泛型在编译阶段约束了操作的数据类型,从而不会出现类型转换异常,体现的是 Java 的严谨性和规范性 + + + +**** + + + +#### 自定义 + +##### 泛型类 + +泛型类:使用了泛型定义的类就是泛型类 + +泛型类格式: + +```java +修饰符 class 类名<泛型变量>{ + +} +泛型变量建议使用 E , T , K , V +``` + +```java +public class GenericDemo { + public static void main(String[] args) { + MyArrayList list = new MyArrayList(); + MyArrayList list1 = new MyArrayList(); + list.add("自定义泛型类"); + } +} +class MyArrayList{ + public void add(E e){} + public void remove(E e){} +} +``` + + + +**** + + + +##### 泛型方法 + +泛型方法:定义了泛型的方法就是泛型方法 + +泛型方法的定义格式: + +```java +修饰符 <泛型变量> 返回值类型 方法名称(形参列表){ + +} +``` + +方法定义了是什么泛型变量,后面就只能用什么泛型变量。 + +泛型类的核心思想:把出现泛型变量的地方全部替换成传输的真实数据类型 + +```java +public class GenericDemo { + public static void main(String[] args) { + Integer[] num = {10 , 20 , 30 , 40 , 50}; + String s1 = arrToString(nums); + + String[] name = {"张三","李四","王五"}; + String s2 = arrToString(names); + } + + public static String arrToString(T[] arr){ + -------------- + } +} +``` + + + +自定义泛型接口 + +泛型接口:使用了泛型定义的接口就是泛型接口。 + +泛型接口的格式: + +```java +修饰符 interface 接口名称<泛型变量>{ + +} +``` + +```java +public class GenericDemo { + public static void main(String[] args) { + Data d = new StudentData(); + d.add(new Student()); + ................ + } +} + +public interface Data{ + void add(E e); + void delete(E e); + void update(E e); + E query(int index); +} +class Student{} +class StudentData implements Data{重写所有方法} +``` + + + +**** + + + +#### 通配符 + +通配符:? + +* ? 可以用在使用泛型的时候代表一切类型 +* E、T、K、V 是在定义泛型的时候使用代表一切类型 + +泛型的上下限: + +* ? extends Car:那么 ? 必须是 Car 或者其子类(泛型的上限) +* ? super Car:那么 ? 必须是 Car 或者其父类(泛型的下限,不是很常见) + +```java +//需求:开发一个极品飞车的游戏,所有的汽车都能一起参与比赛。 +public class GenericDemo { + public static void main(String[] args) { + ArrayList bmws = new ArrayList<>(); + ArrayList ads = new ArrayList<>(); + ArrayList dogs = new ArrayList<>(); + run(bmws); + //run(dogs); + } + //public static void run(ArrayList car){}//这样 dou对象也能进入 + public static void run(ArrayList car){} +} + +class Car{} +class BMW extends Car{} +class AD extends Car{} +class Dog{} +``` + + + + + +*** + + + + + +## 异常 + +### 基本介绍 + +异常:程序在编译或者执行的过程中可能出现的问题,Java 为常见的代码异常都设计一个类来代表 + +错误:Error ,程序员无法处理的错误,只能重启系统,比如内存奔溃,JVM 本身的奔溃 + +Java 中异常继承的根类是:Throwable + +``` +异常的体系: + Throwable(根类,不是异常类) + / \ + Error Exception(异常,需要研究和处理) + / \ + 编译时异常 RuntimeException(运行时异常) +``` + +Exception 异常的分类: + +* 编译时异常:继承自 Exception 的异常或者其子类,编译阶段就会报错 +* 运行时异常:继承自 RuntimeException 的异常或者其子类,编译阶段是不会出错的,在运行阶段出错 + + + +*** + + + +### 处理过程 + +异常的产生默认的处理过程解析:(自动处理的过程) + +1. 默认会在出现异常的代码那里自动的创建一个异常对象:ArithmeticException(算术异常) +2. 异常会从方法中出现的点这里抛出给调用者,调用者最终抛出给 JVM 虚拟机 +3. 虚拟机接收到异常对象后,先在控制台直接输出**异常栈**信息数据 +4. 直接从当前执行的异常点终止当前程序 +5. 后续代码没有机会执行了,因为程序已经死亡 + +```java +public class ExceptionDemo { + public static void main(String[] args) { + System.out.println("程序开始。。。。。。。。。。"); + chu( 10 ,0 ); + System.out.println("程序结束。。。。。。。。。。");//不执行 + } + public static void chu(int a , int b){ + int c = a / b ;// 出现了运行时异常,自动创建异常对象:ArithmeticException + System.out.println("结果是:"+c); + } +} +``` + + + +*** + + + +### 编译异常 + +#### 基本介绍 + +编译时异常:继承自 Exception 的异常或者其子类,没有继承 RuntimeException,编译时异常是编译阶段就会报错 + +编译时异常的作用是什么:在编译阶段就爆出一个错误,目的在于提醒,请检查并注意不要出 BUG + +```java +public static void main(String[] args) throws ParseException { + String date = "2015-01-12 10:23:21"; + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + Date d = sdf.parse(date); + System.out.println(d); +} +``` + + + +**** + + + +#### 处理机制 + +##### throws + +在出现编译时异常的地方层层把异常抛出去给调用者,调用者最终抛出给 JVM 虚拟机,JVM 虚拟机输出异常信息,直接终止掉程序,这种方式与默认方式是一样的 + +**Exception 是异常最高类型可以抛出一切异常** + +```java +public static void main(String[] args) throws Exception { + System.out.println("程序开始。。。。"); + String s = "2013-03-23 10:19:23"; + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + Date date = sdf.parse(s); + System.out.println("程序结束。。。。。"); +} +``` + + + +*** + + + +##### try/catch + +可以处理异常,并且出现异常后代码也不会死亡 + +* 捕获异常和处理异常的格式:**捕获处理** + + ```java + try{ + // 监视可能出现异常的代码! + }catch(异常类型1 变量){ + // 处理异常 + }catch(异常类型2 变量){ + // 处理异常 + }...finall{ + //资源释放 + } + ``` + +* 监视捕获处理异常写法:Exception 可以捕获处理一切异常类型 + + ```java + try{ + // 可能出现异常的代码! + }catch (Exception e){ + e.printStackTrace(); // **直接打印异常栈信息** + } + ``` + +**Throwable成员方法:** + +* `public String getMessage()`:返回此 throwable 的详细消息字符串 +* `public String toString()`:返回此可抛出的简短描述 +* `public void printStackTrace()`:把异常的错误信息输出在控制台 + +```java +public static void main(String[] args) { + System.out.println("程序开始。。。。"); + try { + String s = "2013-03-23 10:19:23"; + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + Date date = sdf.parse(s); + InputStream is = new FileInputStream("D:/meinv.png"); + } catch (Exception e) { + e.printStackTrace(); + } + System.out.println("程序结束。。。。。"); +} +``` + + + +*** + + + +##### 规范做法 + +在出现异常的地方把异常一层一层的抛出给最外层调用者,最外层调用者集中捕获处理 + +```java +public class ExceptionDemo{ + public static void main(String[] args){ + System.out.println("程序开始。。。。"); + try { + parseDate("2013-03-23 10:19:23"); + }catch (Exception e){ + e.printStackTrace(); + } + System.out.println("程序结束。。。。"); + } + public static void parseDate(String time) throws Exception{...} +} +``` + + + +*** + + + +### 运行异常 + +#### 基本介绍 + +继承自 RuntimeException 的异常或者其子类,编译阶段是不会出错的,是在运行时阶段可能出现的错误,运行时异常编译阶段可以处理也可以不处理,代码编译都能通过 + +**常见的运行时异常**: + +1. 数组索引越界异常:ArrayIndexOutOfBoundsException +2. 空指针异常:NullPointerException,直接输出没问题,调用空指针的变量的功能就会报错 +3. 类型转换异常:ClassCastException +4. 迭代器遍历没有此元素异常:NoSuchElementException +5. 算术异常(数学操作异常):ArithmeticException +6. 数字转换异常:NumberFormatException + + + +**** + + + +#### 处理机制 + +运行时异常在编译阶段是不会报错,在运行阶段才会出错,运行时出错了程序还是会停止,运行时异常也建议要处理,运行时异常是自动往外抛出的,不需要手工抛出 + +**运行时异常的处理规范**:直接在最外层捕获处理即可,底层会自动抛出 + +```java +public class ExceptionDemo{ + public static void main(String[] args){ + System.out.println("程序开始。。。。"); + try{ + chu(10 / 0);//ArithmeticException: / by zero + System.out.println("操作成功!");//没输出 + }catch (Exception e){ + e.printStackTrace(); + System.out.println("操作失败!");//输出了 + } + System.out.println("程序结束。。。。");//输出了 + } + + public static void chu(int a , int b) { System.out.println( a / b );} +} +``` + + + +*** + + + +### Finally + +用在捕获处理的异常格式中的,放在最后面 + +```java +try{ + // 可能出现异常的代码! +}catch(Exception e){ + e.printStackTrace(); +}finally{ + // 无论代码是出现异常还是正常执行,最终一定要执行这里的代码!! +} +try: 1次。 +catch:0-N次 (如果有finally那么catch可以没有!!) +finally: 0-1次 +``` + +**finally 的作用**:可以在代码执行完毕以后进行资源的释放操作 + +资源:资源都是实现了 Closeable 接口的,都自带 close() 关闭方法 + +注意:如果在 finally 中出现了 return,会吞掉异常 + +```java +public class FinallyDemo { + public static void main(String[] args) { + System.out.println(chu());//一定会输出 finally,优先级比return高 + } + + public static int chu(){ + try{ + int a = 10 / 2 ; + return a ; + }catch (Exception e){ + e.printStackTrace(); + return -1; + }finally { + System.out.println("=====finally被执行"); + //return 111; // 不建议在finally中写return,会覆盖前面所有的return值! + } + } + public static void test(){ + InputStream is = null; + try{ + is = new FileInputStream("D:/cang.png"); + }catch (Exception e){ + e.printStackTrace(); + }finally { + System.out.println("==finally被执行==="); + // 回收资源。用于在代码执行完毕以后进行资源的回收操作! + try { + if(is!=null)is.close(); + } catch (Exception e) { + e.printStackTrace(); + } + } + } +} +``` + + + +*** + + + +### 自定义 + +自定义异常: + +* 自定义编译时异常:定义一个异常类继承 Exception,重写构造器,在出现异常的地方用 throw new 自定义对象抛出 +* 自定义运行时异常:定义一个异常类继承 RuntimeException,重写构造器,在出现异常的地方用 throw new 自定义对象抛出 + +throws:用在方法上,用于抛出方法中的异常 + +throw: 用在出现异常的地方,创建异常对象且立即从此处抛出 + +```java +//需求:认为年龄小于0岁,大于200岁就是一个异常。 +public class ExceptionDemo { + public static void main(String[] args) { + try { + checkAge(101); + } catch (AgeIllegalException e) { + e.printStackTrace(); + } + } + + public static void checkAge(int age) throws ItheimaAgeIllegalException { + if(age < 0 || age > 200){//年龄在0-200之间 + throw new AgeIllegalException("/ age is illegal!"); + //throw new AgeIllegalRuntimeException("/ age is illegal!"); + }else{ + System.out.println("年龄是:" + age); + } + } +} + +public class AgeIllegalException extends Exception{ + Alt + Insert->Constructor +}//编译时异常 +public class AgeIllegalRuntimeException extends RuntimeException{ + public AgeIllegalRuntimeException() { + } + + public AgeIllegalRuntimeException(String message) { + super(message); + } +}//运行时异常 +``` + + + +*** + + + +### 处理规范 + +异常的语法注意: + +1. 运行时异常被抛出可以不处理,可以自动抛出;**编译时异常必须处理**;按照规范都应该处理 +2. **重写方法申明抛出的异常,子类方法抛出的异常类型必须是父类抛出异常类型或为其子类型** +3. 方法默认都可以自动抛出运行时异常, throws RuntimeException 可以省略不写 +4. 当多异常处理时,捕获处理,前面的异常类不能是后面异常类的父类 +5. 在 try/catch 后可以追加 finally 代码块,其中的代码一定会被执行,通常用于资源回收操作 + +异常的作用: + +1. 可以处理代码问题,防止程序出现异常后的死亡 + +2. 提高了程序的健壮性和安全性 + +```java +public class Demo{ + public static void main(String[] args){ + //请输入一个合法的年龄 + while(true){ + try{ + Scanner sc = new Scanner(System.in); + System.out.println("请您输入您的年年龄:"); + int age = sc.nextInt(); + System.out.println("年龄:"+age); + break; + }catch(Exception e){ + System.err.println("您的年龄是瞎输入的!"); + } + } + } +} +``` + + + + + +*** + + + + + +## λ + +### lambda + +#### 基本介绍 + +Lambda 表达式是 JDK1.8 开始之后的新技术,是一种代码的新语法,一种特殊写法 + +作用:为了简化匿名内部类的代码写法 + +Lambda 表达式的格式: + +```java +(匿名内部类被重写方法的形参列表) -> { + //被重写方法的方法体代码 +} +``` + +Lambda 表达式并不能简化所有匿名内部类的写法,只能简化**函数式接口的匿名内部类** + +简化条件:首先必须是接口,接口中只能有一个抽象方法 + +@FunctionalInterface 函数式接口注解:一旦某个接口加上了这个注解,这个接口只能有且仅有一个抽象方法 + + + +*** + + + +#### 简化方法 + +Lambda 表达式的省略写法(进一步在 Lambda 表达式的基础上继续简化) + +* 如果 Lambda 表达式的方法体代码只有一行代码,可以省略大括号不写,同时要省略分号;如果这行代码是 return 语句,必须省略 return 不写 +* 参数类型可以省略不写 +* 如果只有一个参数,参数类型可以省略,同时 `()` 也可以省略 + +```java +List names = new ArrayList<>(); +names.add("a"); +names.add("b"); +names.add("c"); + +names.forEach(new Consumer() { + @Override + public void accept(String s) { + System.out.println(s); + } +}); + +names.forEach((String s) -> { + System.out.println(s); +}); + +names.forEach((s) -> { + System.out.println(s); +}); + +names.forEach(s -> { + System.out.println(s); +}); + +names.forEach(s -> System.out.println(s) ); +``` + + + +*** + + + +#### 常用简化 + +Comparator + +```java +public class CollectionsDemo { + public static void main(String[] args) { + List lists = new ArrayList<>();//...s1 s2 s3 + Collections.addAll(lists , s1 , s2 , s3); + Collections.sort(lists, new Comparator() { + @Override + public int compare(Student s1, Student s2) { + return s1.getAge() - s2.getAge(); + } + }); + + // 简化写法 + Collections.sort(lists ,(Student t1, Student t2) -> { + return t1.getAge() - t2.getAge(); + }); + // 参数类型可以省略,最简单的 + Collections.sort(lists ,(t1,t2) -> t1.getAge()-t2.getAge()); + } +} +``` + + + + + +*** + + + +### 方法引用 + +#### 基本介绍 + +方法引用:方法引用是为了进一步简化 Lambda 表达式的写法 + +方法引用的格式:类型或者对象::引用的方法 + +关键语法是:`::` + +```java +lists.forEach( s -> System.out.println(s)); +// 方法引用! +lists.forEach(System.out::println); +``` + + + +*** + + + +#### 静态方法 + +引用格式:`类名::静态方法` + +简化步骤:定义一个静态方法,把需要简化的代码放到一个静态方法中去 + +静态方法引用的注意事项:被引用的方法的参数列表要和函数式接口中的抽象方法的参数列表一致,才能引用简化 + +```java +//定义集合加入几个Student元素 +// 使用静态方法进行简化! +Collections.sort(lists, (o1, o2) -> Student.compareByAge(o1 , o2)); +// 如果前后参数是一样的,而且方法是静态方法,既可以使用静态方法引用 +Collections.sort(lists, Student::compareByAge); + +public class Student { + private String name ; + private int age ; + + public static int compareByAge(Student o1 , Student o2){ + return o1.getAge() - o2.getAge(); + } +} +``` + + + +*** + + + +#### 实例方法 + +引用格式:`对象::实例方法` + +简化步骤:定义一个实例方法,把需要的代码放到实例方法中去 + +实例方法引用的注意事项:被引用的方法的参数列表要和函数式接口中的抽象方法的参数列表一致。 + +```java +public class MethodDemo { + public static void main(String[] args) { + List lists = new ArrayList<>(); + lists.add("java1"); + lists.add("java2"); + lists.add("java3"); + // 对象是 System.out = new PrintStream(); + // 实例方法:println() + // 前后参数正好都是一个 + lists.forEach(s -> System.out.println(s)); + lists.forEach(System.out::println); + } +} +``` + + + +*** + + + +#### 特定类型 + +特定类型:String,任何类型 + +引用格式:`特定类型::方法` + +注意事项:如果第一个参数列表中的形参中的第一个参数作为了后面的方法的调用者,并且其余参数作为后面方法的形参,那么就可以用特定类型方法引用了 + +```java +public class MethodDemo{ + public static void main(String[] args) { + String[] strs = new String[]{"James", "AA", "John", + "Patricia","Dlei" , "Robert","Boom", "Cao" ,"black" , + "Michael", "Linda","cao","after","sa"}; + + // public static void sort(T[] a, Comparator c) + // 需求:按照元素的首字符(忽略大小写)升序排序!!! + Arrays.sort(strs, new Comparator() { + @Override + public int compare(String s1, String s2) { + return s1.compareToIgnoreCase(s2);//按照元素的首字符(忽略大小写) + } + }); + + Arrays.sort(strs, ( s1, s2 ) -> s1.compareToIgnoreCase(s2)); + + // 特定类型的方法引用: + Arrays.sort(strs, String::compareToIgnoreCase); + System.out.println(Arrays.toString(strs)); + } +} +``` + + + +*** + + + +#### 构造器 + +格式:`类名::new` + +注意事项:前后参数一致的情况下,又在创建对象,就可以使用构造器引用 + +```java +public class ConstructorDemo { + public static void main(String[] args) { + List lists = new ArrayList<>(); + lists.add("java1"); + lists.add("java2"); + lists.add("java3"); + + // 集合默认只能转成Object类型的数组。 + Object[] objs = lists.toArray(); + + // 我们想指定转换成字符串类型的数组!最新的写法可以结合构造器引用实现 + String[] strs = lists.toArray(new IntFunction() { + @Override + public String[] apply(int value) { + return new String[value]; + } + }); + String[] strs1 = lists.toArray(s -> new String[s]); + String[] strs2 = lists.toArray(String[]::new); + + System.out.println("String类型的数组:"+ Arrays.toString(strs2)); + } +} +``` + + + + + +*** + + + + + +## I/O + +### Stream + +#### 概述 + +Stream 流其实就是一根传送带,元素在上面可以被 Stream 流操作 + +* 可以解决已有集合类库或者数组 API 的弊端 +* Stream 流简化集合和数组的操作 +* 链式编程 + +```java +list.stream().filter(new Predicate() { + @Override + public boolean test(String s) { + return s.startsWith("张"); + } + }); + +list.stream().filter(s -> s.startsWith("张")); +``` + + + +*** + + + +#### 获取流 + +集合获取 Stream 流用:`default Stream stream()` + +数组:Arrays.stream(数组) / Stream.of(数组); + +```java +// Collection集合获取Stream流。 +Collection c = new ArrayList<>(); +Stream listStream = c.stream(); + +// Map集合获取流 +// 先获取键的Stream流。 +Stream keysStream = map.keySet().stream(); +// 在获取值的Stream流 +Stream valuesStream = map.values().stream(); +// 获取键值对的Stream流(key=value: Map.Entry) +Stream> keyAndValues = map.entrySet().stream(); + +//数组获取流 +String[] arr = new String[]{"Java", "JavaEE" ,"Spring Boot"}; +Stream arrStream1 = Arrays.stream(arr); +Stream arrStream2 = Stream.of(arr); +``` + + + +**** + + + +#### 常用API + +| 方法名 | 说明 | +| --------------------------------------------------------- | -------------------------------------------------------- | +| void forEach(Consumer action) | 逐一处理(遍历) | +| long count | 返回流中的元素数 | +| Stream filter(Predicate predicate) | 用于对流中的数据进行过滤 | +| Stream limit(long maxSize) | 返回此流中的元素组成的流,截取前指定参数个数的数据 | +| Stream skip(long n) | 跳过指定参数个数的数据,返回由该流的剩余元素组成的流 | +| Stream map(Function mapper) | 加工方法,将当前流中的 T 类型数据转换为另一种 R 类型的流 | +| static Stream concat(Stream a, Stream b) | 合并 a 和 b 两个流为一个,调用 `Stream.concat(s1,s2)` | +| Stream distinct() | 返回由该流的不同元素组成的流 | + +```java +public class StreamDemo { + public static void main(String[] args) { + List list = new ArrayList<>(); + list.add("张无忌"); list.add("周芷若"); list.add("赵敏"); + list.add("张三"); list.add("张三丰"); list.add("张"); + //取以张开头并且名字是三位数的 + list.stream().filter(s -> s.startsWith("张") + .filter(s -> s.length == 3).forEach(System.out::println); + //统计数量 + long count = list.stream().filter(s -> s.startsWith("张") + .filter(s -> s.length == 3).count(); + //取前两个 + list.stream().filter(s -> s.length == 3).limit(2).forEach(...); + //跳过前两个 + list.stream().filter(s -> s.length == 3).skip(2).forEach(...); + + // 需求:把名称都加上“张三的:+xxx” + list.stream().map(s -> "张三的" + s).forEach(System.out::println); + // 需求:把名称都加工厂学生对象放上去!! + // list.stream().map(name -> new Student(name)); + list.stream.map(Student::new).forEach(System.out::println); + + //数组流 + Stream s1 = Stream.of(10,20,30,40,50); + //集合流 + Stream s2 = list.stream(); + //合并流 + Stream s3 = Stream.concat(s1,s2); + s3.forEach(System.out::println); + } +} +class Student{ + private String name; + //...... +} +``` + + + +*** + + + +#### 终结方法 + +终结方法:Stream 调用了终结方法,流的操作就全部终结,不能继续使用,如 foreach,count 方法等 + +非终结方法:每次调用完成以后返回一个新的流对象,可以继续使用,支持**链式编程** + +```java +// foreach终结方法 +list.stream().filter(s -> s.startsWith("张")) + .filter(s -> s.length() == 3).forEach(System.out::println); +``` + + + +*** + + + +#### 收集流 + +收集 Stream:把 Stream 流的数据转回到集合中去 + +* Stream 流:工具 +* 集合:目的 + +Stream 收集方法:`R collect(Collector collector)` 把结果收集到集合中 + +Collectors 方法: + +* `public static Collector toList()`:把元素收集到 List 集合中 +* `public static Collector toSet()`:把元素收集到 Set 集合中 +* `public static Collector toMap(Function keyMapper,Function valueMapper)`:把元素收集到 Map 集合中 +* `Object[] toArray()`:把元素收集数组中 +* `public static Collector groupingBy(Function classifier)`:分组 + +```java +public static void main(String[] args) { + List list = new ArrayList<>(); + Stream stream = list.stream().filter(s -> s.startsWith("张")); + //把stream流转换成Set集合。 + Set set = stream.collect(Collectors.toSet()); + + //把stream流转换成List集合。 + //重新定义,因为资源已经被关闭了 + Stream stream1 = list.stream().filter(s -> s.startsWith("张")); + List list = stream.collect(Collectors.toList()); + + //把stream流转换成数组。 + Stream stream2 = list.stream().filter(s -> s.startsWith("张")); + Object[] arr = stream2.toArray(); + // 可以借用构造器引用申明转换成的数组类型!!! + String[] arr1 = stream2.toArray(String[]::new); +} +``` + + + +*** + + + +### File + +#### 文件类 + +File 类:代表操作系统的文件对象,是用来操作操作系统的文件对象的,删除文件,获取文件信息,创建文件(文件夹),广义来说操作系统认为文件包含(文件和文件夹) + +File 类构造器: + +* `public File(String pathname)`:根据路径获取文件对象 +* `public File(String parent , String child)`:根据父路径和文件名称获取文件对象 + +File 类创建文件对象的格式: + +* `File f = new File("绝对路径/相对路径");` + * 绝对路径:从磁盘的的盘符一路走到目的位置的路径 + * 绝对路径依赖具体的环境,一旦脱离环境,代码可能出错 + * 一般是定位某个操作系统中的某个文件对象 + * **相对路径**:不带盘符的(重点) + * 默认是直接相对到工程目录下寻找文件的。 + * 相对路径只能用于寻找工程下的文件,可以跨平台 + +* `File f = new File("文件对象/文件夹对象")` 广义来说:文件是包含文件和文件夹的 + +```java +public class FileDemo{ + public static void main(String[] args) { + // 1.创建文件对象:使用绝对路径 + // 文件路径分隔符: + // -- a.使用正斜杠: / + // -- b.使用反斜杠: \\ + // -- c.使用分隔符API:File.separator + //File f1 = new File("D:"+File.separator+"it"+File.separator + //+"图片资源"+File.separator+"beautiful.jpg"); + File f1 = new File("D:\\seazean\\图片资源\\beautiful.jpg"); + System.out.println(f1.length()); // 获取文件的大小,字节大小 + + // 2.创建文件对象:使用相对路径 + File f2 = new File("Day09Demo/src/dlei.txt"); + System.out.println(f2.length()); + + // 3.创建文件对象:代表文件夹。 + File f3 = new File("D:\\it\\图片资源"); + System.out.println(f3.exists());// 判断路径是否存在!! + } +} +``` + + + +*** + + + +#### 常用API + +##### 常用方法 + +| 方法 | 说明 | +| ------------------------------ | -------------------------------------- | +| String getAbsolutePath() | 返回此 File 的绝对路径名字符串 | +| String getPath() | 获取创建文件对象的时候用的路径 | +| String getName() | 返回由此 File 表示的文件或目录的名称 | +| long length() | 返回由此 File 表示的文件的长度(大小) | +| long length(FileFilter filter) | 文件过滤器 | + +```java +public class FileDemo { + public static void main(String[] args) { + // 1.绝对路径创建一个文件对象 + File f1 = new File("E:/图片/test.jpg"); + // a.获取它的绝对路径。 + System.out.println(f1.getAbsolutePath()); + // b.获取文件定义的时候使用的路径。 + System.out.println(f1.getPath()); + // c.获取文件的名称:带后缀。 + System.out.println(f1.getName()); + // d.获取文件的大小:字节个数。 + System.out.println(f1.length()); + System.out.println("------------------------"); + + // 2.相对路径 + File f2 = new File("Demo/src/test.txt"); + // a.获取它的绝对路径。 + System.out.println(f2.getAbsolutePath()); + // b.获取文件定义的时候使用的路径。 + System.out.println(f2.getPath()); + // c.获取文件的名称:带后缀。 + System.out.println(f2.getName()); + // d.获取文件的大小:字节个数。 + System.out.println(f2.length()); + } +} + +``` + + + +*** + + + +##### 判断方法 + +方法列表: + +* `boolean exists()`:此 File 表示的文件或目录是否实际存在 +* `boolean isDirectory()`:此 File 表示的是否为目录 +* `boolean isFile()`:此 File 表示的是否为文件 + +```java +File f = new File("Demo/src/test.txt"); +// a.判断文件路径是否存在 +System.out.println(f.exists()); // true +// b.判断文件对象是否是文件,是文件返回true ,反之 +System.out.println(f.isFile()); // true +// c.判断文件对象是否是文件夹,是文件夹返回true ,反之 +System.out.println(f.isDirectory()); // false +``` + + + +**** + + + +##### 创建删除 + +方法列表: + +* `boolean createNewFile()`:当且仅当具有该名称的文件尚不存在时, 创建一个新的空文件 +* `boolean delete()`:删除由此 File 表示的文件或目录(只能删除空目录) +* `boolean mkdir()`:创建由此 File 表示的目录(只能创建一级目录) +* `boolean mkdirs()`:可以创建多级目录(建议使用) + +```java +public class FileDemo { + public static void main(String[] args) throws IOException { + File f = new File("Demo/src/test.txt"); + // a.创建新文件,创建成功返回true ,反之 + System.out.println(f.createNewFile()); + + // b.删除文件或者空文件夹 + System.out.println(f.delete()); + // 不能删除非空文件夹,只能删除空文件夹 + File f1 = new File("E:/it/aaaaa"); + System.out.println(f1.delete()); + + // c.创建一级目录 + File f2 = new File("E:/bbbb"); + System.out.println(f2.mkdir()); + + // d.创建多级目录 + File f3 = new File("D:/it/e/a/d/ds/fas/fas/fas/fas/fas/fas"); + System.out.println(f3.mkdirs()); + } +} +``` + + + +*** + + + +#### 遍历目录 + +- `public String[] list()`:获取当前目录下所有的一级文件名称到一个字符串数组中去返回 +- `public File[] listFiles()`:获取当前目录下所有的一级文件对象到一个**文件对象数组**中去返回(**重点**) +- `public long lastModified`:返回此抽象路径名表示的文件上次修改的时间 + +```java +public class FileDemo { + public static void main(String[] args) { + File dir = new File("D:\\seazean"); + // a.获取当前目录对象下的全部一级文件名称到一个字符串数组返回。 + String[] names = dir.list(); + for (String name : names) { + System.out.println(name); + } + // b.获取当前目录对象下的全部一级文件对象到一个File类型的数组返回。 + File[] files = dir.listFiles(); + for (File file : files) { + System.out.println(file.getAbsolutePath()); + } + + // c + File f1 = new File("D:\\图片资源\\beautiful.jpg"); + long time = f1.lastModified(); // 最后修改时间! + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + System.out.println(sdf.format(time)); + } +} +``` + + + +*** + + + +#### 文件搜索 + +递归实现文件搜索(非规律递归) + +* 定义一个方法用于做搜索 +* 进入方法中进行业务搜索分析 + +```java +/** + * 去某个目录下搜索某个文件 + * @param dir 搜索文件的目录。 + * @param fileName 搜索文件的名称。 + */ +public static void searchFiles(File dir , String fileName){ + // 1.判断是否存在该路径,是否是文件夹 + if(dir.exists() && dir.isDirectory()){ + // 2.提取当前目录下的全部一级文件对象 + File files = dir.listFiles();// 可能是null/也可能是空集合[] + // 3.判断是否存在一级文件对象,判断是否不为空目录 + if(files != null && files.length > 0){ + // 4.判断一级文件对象 + for(File file : files){ + // 5.判断file是文件还是文件夹 + if(file.isFile()){ + // 6.判断该文件是否为我要找的文件对象 + if(f.getName().contains(fileName)){//模糊查找 + sout(f.getAbsolutePath()); + try { + // 启动它(拓展) + Runtime r = Runtime.getRuntime(); + r.exec(f.getAbsolutePath()); + } catch (IOException e) { + e.printStackTrace(); + } + } + } else { + // 7.该文件是文件夹,文件夹要递归进入继续寻找 + searchFiles(file,fileName) + } + } + } + } +} +``` + + + +*** + + + +### Character + +字符集:为字符编制的一套编号规则 + +计算机的底层是不能直接存储字符的,只能存储二进制 010101 + +ASCII 编码:8 个开关一组就可以编码字符,1 个字节 2^8 = 256, 一个字节存储一个字符完全够用,英文和数字在底层存储都是采用 1 个字节存储的 + +``` +a 97 +b 98 + +A 65 +B 66 + +0 48 +1 49 +``` + +中国人:中国人有 9 万左右字符,2 个字节编码一个中文字符,1 个字节编码一个英文字符,这套编码叫:GBK 编码,兼容 ASCII 编码表 + +美国人:收集全球所有的字符,统一编号,这套编码叫 Unicode 编码(万国码),一个英文等于两个字节,一个中文(含繁体)等于两个字节,中文标点占两个字节,英文标点占两个字节 + +* UTF-8 是变种形式,也必须兼容 ASCII 编码表 +* UTF-8 一个中文一般占 3 个字节,中文标点占 3 个,英文字母和数字 1 个字节 + +编码前与编码后的编码集必须一致才不会乱码 + + + +*** + + + +### IOStream + +#### 概述 + +IO 输入输出流:输入/输出流 + +* Input:输入 +* Output:输出 + +引入:File 类只能操作文件对象本身,不能读写文件对象的内容,读写数据内容,应该使用 IO 流 + +IO 流是一个水流模型:IO 理解成水管,把数据理解成水流 + +IO 流的分类: + +* 按照流的方向分为:输入流,输出流。 + * 输出流:以内存为基准,把内存中的数据**写出到磁盘文件**或者网络介质中去的流称为输出流 + * 输入流:以内存为基准,把磁盘文件中的数据或者网络中的数据**读入到内存**中的流称为输入流 +* 按照流的内容分为:字节流,字符流 + * 字节流:流中的数据的最小单位是一个一个的字节,这个流就是字节流 + * 字符流:流中的数据的最小单位是一个一个的字符,这个流就是字符流(**针对于文本内容**) + +流大体分为四大类:字节输入流、字节输出流、字符输入流、字符输出流 + +```java +IO 流的体系: + 字节流 字符流 + 字节输入流 字节输出流 字符输入流 字符输出流 +InputStream OutputStream Reader Writer (抽象类) +FileInputStream FileOutputStream FileReader FileWriter(实现类) +BufferedInputStream BufferedOutputStream BufferedReader BufferedWriter(实现类缓冲流) + InputStreamReader OutputStreamWriter +ObjectInputStream ObjectOutputStream +``` + + + +**** + + + +#### 字节流 + +##### 字节输入 + +FileInputStream 文件字节输入流:以内存为基准,把磁盘文件中的数据按照字节的形式读入到内存中的流 + +构造方法: + +* `public FileInputStream(File path)`:创建一个字节输入流管道与源文件对象接通 +* `public FileInputStream(String pathName)`:创建一个字节输入流管道与文件路径对接,底层实质上创建 File 对象 + +方法: + +* `public int read()`:每次读取一个字节返回,读取完毕会返回 -1 +* `public int read(byte[] buffer)`:从字节输入流中读取字节到字节数组中去,返回读取的字节数量,没有字节可读返回 -1,**byte 中新读取的数据默认是覆盖原数据**,构造 String 需要设定长度 +* `public String(byte[] bytes,int offset,int length)`:构造新的 String +* `public long transferTo(OutputStream out) `:从输入流中读取所有字节,并按读取的顺序,将字节写入给定的输出流 + +```java +public class FileInputStreamDemo01 { + public static void main(String[] args) throws Exception { + // 1.创建文件对象定位dlei01.txt + File file = new File("Demo/src/dlei01.txt"); + // 2.创建一个字节输入流管道与源文件接通 + InputStream is = new FileInputStream(file); + // 3.读取一个字节的编号返回,读取完毕返回-1 + //int code1 = is.read(); // 读取一滴水,一个字节 + //System.out.println((char)code1); + + // 4.使用while读取字节数 + // 定义一个整数变量存储字节 + int ch = 0 ; + while((ch = is.read())!= -1){ + System.out.print((char) ch); + } + } +} +``` + +一个一个字节读取英文和数字没有问题,但是读取中文输出无法避免乱码,因为会截断中文的字节。一个一个字节的读取数据,性能也较差,所以**禁止使用上面的方案** + +采取下面的方案: + +```java +public static void main(String[] args) throws Exception { + //简化写法,底层实质上创建了File对象 + InputStream is = new FileInputStream("Demo/src/test.txt"); + byte[] buffer = new byte[3];//开发中使用byte[1024] + int len; + while((len = is.read(buffer)) !=-1){ + // 读取了多少就倒出多少! + String rs = new String(buffer, 0, len); + System.out.print(rs); + } +} +``` + +```java +File f = new File("Demo/src/test.txt"); +InputStream is = new FileInputStream(f); +// 读取全部的 +byte[] buffer = is.readAllBytes(); +String rs = new String(buffer); +System.out.println(rs); +``` + + + +**** + + + +##### 字节输出 + +FileOutputStream 文件字节输出流:以内存为基准,把内存中的数据,按照字节的形式写出到磁盘文件中去 + +构造方法: + +* `public FileOutputStream(File file)`:创建一个字节输出流管道通向目标文件对象 +* `public FileOutputStream(String file) `:创建一个字节输出流管道通向目标文件路径 +* `public FileOutputStream(File file, boolean append)` : 创建一个追加数据的字节输出流管道到目标文件对象 +* `public FileOutputStream(String file, boolean append)` : 创建一个追加数据的字节输出流管道通向目标文件路径 + +API: + +* `public void write(int a)`:写一个字节出去 +* `public void write(byte[] buffer)`:写一个字节数组出去 +* `public void write(byte[] buffer , int pos , int len)`:写一个字节数组的一部分出去,从 pos 位置,写出 len 长度 + +* FileOutputStream 字节输出流每次启动写数据的时候都会先清空之前的全部数据,重新写入: + * `OutputStream os = new FileOutputStream("Demo/out05")`:覆盖数据管道 + * `OutputStream os = new FileOutputStream("Demo/out05" , true)`:追加数据的管道 + +说明: + +* 字节输出流只能写字节出去,字节输出流默认是**覆盖**数据管道 +* 换行用:**os.write("\r\n".getBytes())** +* 关闭和刷新:刷新流可以继续使用,关闭包含刷新数据但是流就不能使用了 + +```java +OutputStream os = new FileOutputStream("Demo/out05"); +os.write(97);//a +os.write('b'); +os.write("\r\n".getBytes()); +os.write("我爱Java".getBytes()); +os.close(); +``` + + + +##### 文件复制 + +字节是计算机中一切文件的组成,所以字节流适合做一切文件的复制 + +```java +public class CopyDemo01 { + public static void main(String[] args) { + InputStream is = null ; + OutputStream os = null ; + try{ + //(1)创建一个字节输入流管道与源文件接通。 + is = new FileInputStream("D:\\seazean\\图片资源\\test.jpg"); + //(2)创建一个字节输出流与目标文件接通。 + os = new FileOutputStream("D:\\seazean\\test.jpg"); + //(3)创建一个字节数组作为桶 + byte buffer = new byte[1024]; + //(4)从字节输入流管道中读取数据,写出到字节输出流管道即可 + int len = 0; + while((len = is.read(buffer)) != -1){ + os.write(buffer,0,len); + } + System.out.println("复制完成!"); + }catch (Exception e){ + e.printStackTrace(); + } finally { + /**(5)关闭资源! */ + try{ + if(os!=null)os.close(); + if(is!=null)is.close(); + }catch (Exception e){ + e.printStackTrace(); + } + } + } +} +``` + + + +*** + + + +#### 字符流 + +##### 字符输入 + +FileReader:文件字符输入流,以内存为基准,把磁盘文件的数据以字符的形式读入到内存,读取文本文件内容到内存中去 + +构造器: + +* `public FileReader(File file)`:创建一个字符输入流与源文件对象接通。 +* `public FileReader(String filePath)`:创建一个字符输入流与源文件路径接通。 + +方法: + +* `public int read()`:读取一个字符的编号返回,读取完毕返回 -1 +* `public int read(char[] buffer)`:读取一个字符数组,读取多少个就返回多少个,读取完毕返回 -1 + +结论: + +* 字符流一个一个字符的读取文本内容输出,可以解决中文读取输出乱码的问题,适合操作文本文件,但是一个一个字符的读取文本内容性能较差 +* 字符流按照**字符数组循环读取数据**,可以解决中文读取输出乱码的问题,而且性能也较好 + +**字符流不能复制图片,视频等类型的文件**。字符流在读取完了字节数据后并没有直接往目的地写,而是先查编码表,查到对应的数据就将该数据写入目的地。如果查不到,则码表会将一些未知区域中的数据去 map 这些字节数据,然后写到目的地,这样的话就造成了源数据和目的数据的不一致。 + +```java +public class FileReaderDemo01{//字符 + public static void main(String[] args) throws Exception { + // 创建一个字符输入流管道与源文件路径接通 + Reader fr = new FileReader("Demo/src/test.txt"); + int ch; + while((ch = fr.read()) != -1){ + System.out.print((char)ch); + } + } +} +public class FileReaderDemo02 {//字符数组 + public static void main(String[] args) throws Exception { + Reader fr = new FileReader("Demo/src/test.txt"); + + char[] buffer = new char[1024]; + int len; + while((len = fr.read(buffer)) != -1) { + System.out.print(new String(buffer, 0 , len)); + } + } +} +``` + + + +*** + + + +##### 字符输出 + +FileWriter:文件字符输出流,以内存为基准,把内存中的数据按照字符的形式写出到磁盘文件中去 + +构造器: + +* `public FileWriter(File file)`:创建一个字符输出流管道通向目标文件对象(覆盖数据管道) +* `public FileWriter(String filePath)`:创建一个字符输出流管道通向目标文件路径 +* `public FileWriter(File file, boolean append)`:创建一个追加数据的字符输出流管道通向文件对象(追加数据管道) +* `public FileWriter(String filePath, boolean append)`:创建一个追加数据的字符输出流管道通向目标文件路径 + +方法: + +* `public void write(int c)`:写一个字符出去 +* `public void write(char[] buffer)`:写一个字符数组出去 +* `public void write(String c, int pos, int len)`:写字符串的一部分出去 +* `public void write(char[] buffer, int pos, int len)`:写字符数组的一部分出去 +* `fw.write("\r\n")`:换行 + +读写字符文件数据建议使用字符流 + +```java +Writer fw = new FileWriter("Demo/src/test.txt"); +fw.write(97); // 字符a +fw.write('b'); // 字符b +fw.write("Java是最优美的语言!"); +fw.write("\r\n"); +fw.close; +``` + + + +**** + + + +#### 缓冲流 + +##### 基本介绍 + +缓冲流可以提高字节流和字符流的读写数据的性能 + +缓冲流分为四类: + +* BufferedInputStream:字节缓冲输入流,可以提高字节输入流读数据的性能 +* BufferedOutStream:字节缓冲输出流,可以提高字节输出流写数据的性能 +* BufferedReader:字符缓冲输入流,可以提高字符输入流读数据的性能 +* BufferedWriter:字符缓冲输出流,可以提高字符输出流写数据的性能 + + + +*** + + + +##### 字节缓冲输入 + +字节缓冲输入流:BufferedInputStream + +作用:可以把低级的字节输入流包装成一个高级的缓冲字节输入流管道,提高字节输入流读数据的性能 + +构造器:`public BufferedInputStream(InputStream in)` + +原理:缓冲字节输入流管道自带了一个 8KB 的缓冲池,每次可以直接借用操作系统的功能最多提取 8KB 的数据到缓冲池中去,以后我们直接从缓冲池读取数据,所以性能较好 + +```java +public class BufferedInputStreamDemo01 { + public static void main(String[] args) throws Exception { + // 1.定义一个低级的字节输入流与源文件接通 + InputStream is = new FileInputStream("Demo/src/test.txt"); + // 2.把低级的字节输入流包装成一个高级的缓冲字节输入流。 + BufferInputStream bis = new BufferInputStream(is); + // 3.定义一个字节数组按照循环读取。 + byte[] buffer = new byte[1024]; + int len; + while((len = bis.read(buffer)) != -1){ + String rs = new String(buffer, 0 , len); + System.out.print(rs); + } + } +} +``` + + + +*** + + + +##### 字节缓冲输出 + +字节缓冲输出流:BufferedOutputStream + +作用:可以把低级的字节输出流包装成一个高级的缓冲字节输出流,从而提高写数据的性能 + +构造器:`public BufferedOutputStream(OutputStream os)` + +原理:缓冲字节输出流自带了 8KB 缓冲池,数据就直接写入到缓冲池中去,性能提高了 + +```java +public class BufferedOutputStreamDemo02 { + public static void main(String[] args) throws Exception { + // 1.写一个原始的字节输出流 + OutputStream os = new FileOutputStream("Demo/src/test.txt"); + // 2.把低级的字节输出流包装成一个高级的缓冲字节输出流 + BufferedOutputStream bos = new BufferedOutputStream(os); + // 3.写数据出去 + bos.write('a'); + bos.write(100); + bos.write("我爱中国".getBytes()); + bos.close(); + } +} + +``` + + + +##### 字节流性能 + +利用字节流的复制统计各种写法形式下缓冲流的性能执行情况 + +复制流: + +* 使用低级的字节流按照一个一个字节的形式复制文件 +* 使用低级的字节流按照一个一个字节数组的形式复制文件 +* 使用高级的缓冲字节流按照一个一个字节的形式复制文件 +* 使用高级的缓冲字节流按照一个一个字节数组的形式复制文件 + +高级的缓冲字节流按照一个一个字节数组的形式复制文件,性能最高,建议使用 + + + +**** + + + +##### 字符缓冲输入 + +字符缓冲输入流:BufferedReader + +作用:字符缓冲输入流把字符输入流包装成高级的缓冲字符输入流,可以提高字符输入流读数据的性能。 + +构造器:`public BufferedReader(Reader reader)` + +原理:缓冲字符输入流默认会有一个 8K 的字符缓冲池,可以提高读字符的性能 + +按照行读取数据的功能:`public String readLine()` 读取一行数据返回,读取完毕返回 null + +```java +public static void main(String[] args) throws Exception { + // 1.定义一个原始的字符输入流读取源文件 + Reader fr = new FileReader("Demo/src/test.txt"); + // 2.把低级的字符输入流管道包装成一个高级的缓冲字符输入流管道 + BufferedReader br = new BufferedReader(fr); + // 定义一个字符串变量存储每行数据 + String line; + while((line = br.readLine()) != null){ + System.out.println(line); + } + br.close(); + //淘汰数组循环读取 + //char[] buffer = new char[1024]; + //int len; + //while((len = br.read(buffer)) != -1){ + //System.out.println(new String(buffer , 0 , len)); +} +``` + + + +*** + + + +##### 字符缓冲输出 + +符缓冲输出流:BufferedWriter + +作用:把低级的字符输出流包装成一个高级的缓冲字符输出流,提高写字符数据的性能。 + +构造器:`public BufferedWriter(Writer writer)` + + 原理:高级的字符缓冲输出流多了一个 8K 的字符缓冲池,写数据性能极大提高了 + +字符缓冲输出流多了一个换行的特有功能:`public void newLine()` **新建一行** + +```java +public static void main(String[] args) throws Exception { + Writer fw = new FileWriter("Demo/src/test.txt",true);//追加 + BufferedWriter bw = new BufferedWriter(fw); + + bw.write("我爱学习Java"); + bw.newLine();//换行 + bw.close(); +} +``` + + + +*** + + + +##### 高效原因 + +字符型缓冲流高效的原因:(空间换时间) + +* BufferedReader:每次调用 read 方法,只有第一次从磁盘中读取了 8192(**8k**)个字符,存储到该类型对象的缓冲区数组中,将其中一个返回给调用者,再次调用 read 方法时,就不需要访问磁盘,直接从缓冲区中拿出一个数据即可,提升了效率 +* BufferedWriter:每次调用 write 方法,不会直接将字符刷新到文件中,而是存储到字符数组中,等字符数组写满了,才一次性刷新到文件中,减少了和磁盘交互的次数,提升了效率 + +字节型缓冲流高效的原因: + +* BufferedInputStream:在该类型中准备了一个数组,存储字节信息,当外界调用 read() 方法想获取一个字节的时候,该对象从文件中一次性读取了 8192 个字节到数组中,只返回了第一个字节给调用者。将来调用者再次调用 read 方法时,当前对象就不需要再次访问磁盘,只需要从数组中取出一个字节返回给调用者即可,由于读取的是数组,所以速度非常快。当 8192 个字节全都读取完成之后,再需要读取一个字节,就得让该对象到文件中读取下一个 8192 个字节 +* BufferedOutputStream:在该类型中准备了一个数组,存储字节信息,当外界调用 write 方法想写出一个字节的时候,该对象直接将这个字节存储到了自己的数组中,而不刷新到文件中。一直到该数组所有 8192 个位置全都占满,该对象才把这个数组中的所有数据一次性写出到目标文件中。如果最后一次循环没有将数组写满,最终在关闭流对象的时候,也会将该数组中的数据刷新到文件中。 + + + +注意:**字节流和字符流,都是装满时自动写出,或者没满时手动 flush 写出,或 close 时刷新写出** + + + +*** + + + +#### 转换流 + +##### 乱码问题 + +字符流读取: + +``` +代码编码 文件编码 中文情况。 +UTF-8 UTF-8 不乱码! +GBK GBK 不乱码! +UTF-8 GBK 乱码! +``` + +* 如果代码编码和读取的文件编码一致,字符流读取的时候不会乱码 +* 如果代码编码和读取的文件编码不一致,字符流读取的时候会乱码 + + + +*** + + + +##### 字符输入 + +字符输入转换流:InputStreamReader + +作用:解决字符流读取不同编码的乱码问题,把原始的**字节流**按照默认的编码或指定的编码**转换成字符输入流** + +构造器: + +* `public InputStreamReader(InputStream is)`:使用当前代码默认编码 UTF-8 转换成字符流 +* `public InputStreamReader(InputStream is, String charset)`:指定编码把字节流转换成字符流 + +```java +public class InputStreamReaderDemo{ + public static void main(String[] args) throws Exception { + // 1.提取GBK文件的原始字节流 + InputStream is = new FileInputStream("D:\\seazean\\Netty.txt"); + // 2.把原始字节输入流通过转换流,转换成 字符输入转换流InputStreamReader + InputStreamReader isr = new InputStreamReader(is, "GBK"); + // 3.包装成缓冲流 + BufferedReader br = new BufferedReader(isr); + //循环读取 + String line; + while((line = br.readLine()) != null){ + System.out.println(line); + } + } +} +``` + + + +*** + + + +##### 字符输出 + +字符输出转换流:OutputStreamWriter + +作用:可以指定编码**把字节输出流转换成字符输出流**,可以指定写出去的字符的编码 + +构造器: + +* `public OutputStreamWriter(OutputStream os)`:用默认编码 UTF-8 把字节输出流转换成字符输出流 +* `public OutputStreamWriter(OutputStream os, String charset)`:指定编码把字节输出流转换成 + +```Java +OutputStream os = new FileOutputStream("Demo/src/test.txt"); +OutputStreamWriter osw = new OutputStreamWriter(os,"GBK"); +osw.write("我在学习Java"); +osw.close(); +``` + + + +**** + + + +#### 序列化 + +##### 基本介绍 + +对象序列化:把 Java 对象转换成字节序列的过程,将对象写入到 IO 流中,对象 => 文件中 + +对象反序列化:把字节序列恢复为 Java 对象的过程,从 IO 流中恢复对象,文件中 => 对象 + +transient 关键字修饰的成员变量,将不参与序列化 + + + +*** + + + +##### 序列化 + +对象序列化流(对象字节输出流):ObjectOutputStream + +作用:把内存中的 Java 对象数据保存到文件中去 + +构造器:`public ObjectOutputStream(OutputStream out)` + +序列化方法:`public final void writeObject(Object obj)` + +注意:对象如果想参与序列化,对象必须实现序列化接口 **implements Serializable** ,否则序列化失败 + +```java +public class SerializeDemo01 { + public static void main(String[] args) throws Exception { + // 1.创建User用户对象 + User user = new User("seazean","980823","七十一"); + // 2.创建低级的字节输出流通向目标文件 + OutputStream os = new FileOutputStream("Demo/src/obj.dat"); + // 3.把低级的字节输出流包装成高级的对象字节输出流 ObjectOutputStream + ObjectOutputStream oos = new ObjectOutputStream(os); + // 4.通过对象字节输出流序列化对象: + oos.writeObject(user); + // 5.释放资源 + oos.close(); + System.out.println("序列化对象成功~~~~"); + } +} + +class User implements Serializable { + // 加入序列版本号 + private static final long serialVersionUID = 1L; + + private String loginName; + private transient String passWord; + private String userName; + // get+set +} +``` + +```java +// 序列化为二进制数据 +ByteArrayOutputStream bos = new ByteArrayOutputStream(); +ObjectOutputStream oos = new ObjectOutputStream(bos); +oos.writeObject(obj); // 将该对象序列化为二进制数据 +oos.flush(); +byte[] bytes = bos.toByteArray(); +``` + + + + + +**** + + + +##### 反序列 + +对象反序列化(对象字节输入流):ObjectInputStream + +作用:读取序列化的对象文件恢复到 Java 对象中 + +构造器:`public ObjectInputStream(InputStream is)` + +方法:`public final Object readObject()` + +序列化版本号:`private static final long serialVersionUID = 2L` + +注意:序列化使用的版本号和反序列化使用的版本号一致才可以正常反序列化,否则报错 + +```java +public class SerializeDemo02 { + public static void main(String[] args) throws Exception { + InputStream is = new FileInputStream("Demo/src/obj.dat"); + ObjectInputStream ois = new ObjectInputStream(is); + User user = (User)ois.readObject();//反序列化 + System.out.println(user); + System.out.println("反序列化完成!"); + } +} +class User implements Serializable { + // 加入序列版本号 + private static final long serialVersionUID = 1L; + //........ +} +``` + + + +**** + + + +#### 打印流 + +打印流 PrintStream / PrintWriter + +打印流的作用: + +* 可以方便,快速的写数据出去,可以实现打印什么类型,就是什么类型 +* PrintStream/PrintWriter 不光可以打印数据,还可以写字节数据和字符数据出去 +* **System.out.print() 底层基于打印流实现的** + +构造器: + +* `public PrintStream(OutputStream os)` +* `public PrintStream(String filepath)` + +System 类: + +* `public static void setOut(PrintStream out)`:让系统的输出流向打印流 + +```java +public class PrintStreamDemo01 { + public static void main(String[] args) throws Exception { + PrintStream ps = new PrintStream("Demo/src/test.txt"); + ps.println(任何类型的数据); + ps.print(不换行); + ps.write("我爱你".getBytes()); + ps.close(); + } +} +public class PrintStreamDemo02 { + public static void main(String[] args) throws Exception { + System.out.println("==seazean0=="); + PrintStream ps = new PrintStream("Demo/src/log.txt"); + System.setOut(ps); // 让系统的输出流向打印流 + //不输出在控制台,输出到文件里 + System.out.println("==seazean1=="); + System.out.println("==seazean2=="); + } +} +``` + + + +*** + + + +### Close + +try-with-resources: + +```java +try( + // 这里只能放置资源对象,用完会自动调用close()关闭 +){ + +}catch(Exception e){ + e.printStackTrace(); +} +``` + +资源类一定是实现了 Closeable 接口,实现这个接口的类就是资源 + +有 close() 方法,try-with-resources 会自动调用它的 close() 关闭资源 + +```java +try( + /** (1)创建一个字节输入流管道与源文件接通。 */ + InputStream is = new FileInputStream("D:\\seazean\\图片资源\\meinv.jpg"); + /** (2)创建一个字节输出流与目标文件接通。*/ + OutputStream os = new FileOutputStream("D:\\seazean\\meimei.jpg"); + /** (5)关闭资源!是自动进行的 */ +){ + byte[] buffer = new byte[1024]; + int len = 0; + while((len = is.read(buffer)) != -1){ + os.write(buffer, 0 , len); + } + System.out.println("复制完成!"); +}catch (Exception e){ + e.printStackTrace(); +} +``` + + + +*** + + + +### Properties + +Properties:属性集对象。就是一个 Map 集合,一个键值对集合 + +核心作用:Properties 代表的是一个属性文件,可以把键值对数据存入到一个属性文件 + +属性文件:后缀是 `.properties` 结尾的文件,里面的内容都是 key=value + +Properties 方法: + +| 方法名 | 说明 | +| -------------------------------------------- | --------------------------------------------- | +| Object setProperty(String key, String value) | 设置集合的键和值,底层调用 Hashtable 方法 put | +| String getProperty(String key) | 使用此属性列表中指定的键搜索属性 | +| Set stringPropertyNames() | 所有键的名称的集合 | +| synchronized void load(Reader r) | 从输入字符流读取属性列表(键和元素对) | +| synchronized void load(InputStream in) | 加载属性文件的数据到属性集对象中去 | +| void store(Writer w, String comments) | 将此属性列表(键和元素对)写入 Properties 表 | +| void store(OutputStream os, String comments) | 保存数据到属性文件中去 | + +````java +public class PropertiesDemo01 { + public static void main(String[] args) throws Exception { + // a.创建一个属性集对象:Properties的对象。 + Properties properties = new Properties();//{} + properties.setProperty("admin" , "123456"); + // b.把属性集对象的数据存入到属性文件中去(重点) + OutputStream os = new FileOutputStream("Demo/src/users.properties"); + properties.store(os,"i am very happy!!我保存了用户数据!"); + //参数一:被保存数据的输出管道 + //参数二:保存心得。就是对象保存的数据进行解释说明! + } +} +```` + +````java +public class PropertiesDemo02 { + public static void main(String[] args) throws Exception { + Properties properties = new Properties();//底层基于map集合 + properties.load(new FileInputStream("Demo/src/users.properties")); + System.out.println(properties); + System.out.println(properties.getProperty("admin")); + + Set set = properties.stringPropertyNames(); + for (String s : set) { + String value = properties.getProperty(s); + System.out.println(s + value); + } + } +} +```` + + + +*** + + + +### RandomIO + +RandomAccessFile 类:该类的实例支持读取和写入随机访问文件 + +构造器: + +* `RandomAccessFile(File file, String mode)`:创建随机访问文件流,从 File 参数指定的文件读取,可选择写入 +* `RandomAccessFile(String name, String mode)`:创建随机访问文件流,从指定名称文件读取,可选择写入文件 + +常用方法: + +* `public void seek(long pos)`:设置文件指针偏移,从该文件开头测量,发生下一次读取或写入(插入+覆盖) +* `public void write(byte[] b)`:从指定的字节数组写入 b.length 个字节到该文件 +* `public int read(byte[] b)`:从该文件读取最多 b.length 个字节的数据到字节数组 + +```java +public static void main(String[] args) throws Exception { + RandomAccessFile rf = new RandomAccessFile(new File(),"rw"); + rf.write("hello world".getBytes()); + rf.seek(5);//helloxxxxld + rf.write("xxxx".getBytes()); + rf.close(); +} +``` + + + +*** + + + +### Commons + +commons-io 是 apache 提供的一组有关 IO 操作的类库,可以提高 IO 功能开发的效率 + +commons-io 工具包提供了很多有关 IO 操作的类: + +| 包 | 功能描述 | +| ----------------------------------- | :---------------------------------------------- | +| org.apache.commons.io | 有关 Streams、Readers、Writers、Files 的工具类 | +| org.apache.commons.io.input | 输入流相关的实现类,包含 Reader 和 InputStream | +| org.apache.commons.io.output | 输出流相关的实现类,包含 Writer 和 OutputStream | +| org.apache.commons.io.serialization | 序列化相关的类 | + +IOUtils 和 FileUtils 可以方便的复制文件和文件夹 + +```java +public class CommonsIODemo01 { + public static void main(String[] args) throws Exception { + // 1.完成文件复制! + IOUtils.copy(new FileInputStream("Demo/src/books.xml"), + new FileOutputStream("Demo/new.xml")); + // 2.完成文件复制到某个文件夹下! + FileUtils.copyFileToDirectory(new File("Demo/src/books.xml"), + new File("D:/it")); + // 3.完成文件夹复制到某个文件夹下! + FileUtils.copyDirectoryToDirectory(new File("D:\\it\\图片服务器") , + new File("D:\\")); + + // Java从1.7开始提供了一些nio, 自己也有一行代码完成复制的技术。 + Files.copy(Paths.get("Demo/src/books.xml") + , new FileOutputStream("Demo/new11.txt")); + } +} +``` + + + + + +*** + + + + + +## 反射 + +### 测试框架 + +单元测试的经典框架:Junit,是 Java 语言编写的第三方单元测试框架 + +单元测试: +* 单元:在 Java 中,一个类就是一个单元 +* 单元测试:Junit 编写的一小段代码,用来对某个类中的某个方法进行功能测试或业务逻辑测试 + +Junit 单元测试框架的作用: + +* 用来对类中的方法功能进行有目的的测试,以保证程序的正确性和稳定性 +* 能够**独立的**测试某个方法或者所有方法的预期正确性 + +测试方法注意事项:**必须是 public 修饰的,没有返回值,没有参数,使用注解@Test修饰** + +Junit常用注解(Junit 4.xxxx 版本),@Test 测试方法: + +* @Before:用来修饰实例方法,该方法会在每一个测试方法执行之前执行一次 +* @After:用来修饰实例方法,该方法会在每一个测试方法执行之后执行一次 +* @BeforeClass:用来静态修饰方法,该方法会在所有测试方法之前**只**执行一次 +* @AfterClass:用来静态修饰方法,该方法会在所有测试方法之后**只**执行一次 + +Junit 常用注解(Junit5.xxxx 版本),@Test 测试方法: + +* @BeforeEach:用来修饰实例方法,该方法会在每一个测试方法执行之前执行一次 +* @AfterEach:用来修饰实例方法,该方法会在每一个测试方法执行之后执行一次 +* @BeforeAll:用来静态修饰方法,该方法会在所有测试方法之前只执行一次 +* @AfterAll:用来静态修饰方法,该方法会在所有测试方法之后只执行一次 + +作用: + +* 开始执行的方法:初始化资源 +* 执行完之后的方法:释放资源 + +```java +public class UserService { + public String login(String loginName , String passWord){ + if("admin".equals(loginName)&&"123456".equals(passWord)){ + return "success"; + } + return "用户名或者密码错误!"; + } + public void chu(int a , int b){ + System.out.println(a / b); + } +} +``` + +```java +//测试方法的要求:1.必须public修饰 2.没有返回值没有参数 3. 必须使注解@Test修饰 +public class UserServiceTest { + // @Before:用来修饰实例方法,该方法会在每一个测试方法执行之前执行一次。 + @Before + public void before(){ + System.out.println("===before==="); + } + // @After:用来修饰实例方法,该方法会在每一个测试方法执行之后执行一次。 + @After + public void after(){ + System.out.println("===after==="); + } + // @BeforeClass:用来静态修饰方法,该方法会在所有测试方法之前只执行一次。 + @BeforeClass + public static void beforeClass(){ + System.out.println("===beforeClass==="); + } + // @AfterClass:用来静态修饰方法,该方法会在所有测试方法之后只执行一次。 + @AfterClass + public static void afterClass(){ + System.out.println("===afterClass==="); + } + @Test + public void testLogin(){ + UserService userService = new UserService(); + String rs = userService.login("admin","123456"); + /**断言预期结果的正确性。 + * 参数一:测试失败的提示信息。 + * 参数二:期望值。 + * 参数三:实际值 + */ + Assert.assertEquals("登录业务功能方法有错误,请检查!","success",rs); + } + @Test + public void testChu(){ + UserService userService = new UserService(); + userService.chu(10 , 0); + } +} +``` + + + + + +**** + + + +### 介绍反射 + +反射是指对于任何一个类,在"运行的时候"都可以直接得到这个类全部成分 + +* 构造器对象:Constructor +* 成员变量对象:Field + +* 成员方法对象:Method + +核心思想:在运行时获取类编译后的字节码文件对象,然后解析类中的全部成分 + +反射提供了一个 Class 类型:HelloWorld.java → javac → HelloWorld.class + +* `Class c = HelloWorld.class` + +注意:反射是工作在**运行时**的技术,只有运行之后才会有 class 类对象 + +作用:可以在运行时得到一个类的全部成分然后操作,破坏封装性,也可以破坏泛型的约束性。 + +反射的优点: + +- 可扩展性:应用程序可以利用全限定名创建可扩展对象的实例,来使用来自外部的用户自定义类 +- 类浏览器和可视化开发环境:一个类浏览器需要可以枚举类的成员,可视化开发环境(如 IDE)可以从利用反射中可用的类型信息中受益,以帮助程序员编写正确的代码 +- 调试器和测试工具: 调试器需要能够检查一个类里的私有成员,测试工具可以利用反射来自动地调用类里定义的可被发现的 API 定义,以确保一组测试中有较高的代码覆盖率 + +反射的缺点: + +- **性能开销**:反射涉及了动态类型的解析,所以 JVM 无法对这些代码进行优化,反射操作的效率要比那些非射操作低得多,应该避免在经常被执行的代码或对性能要求很高的程序中使用反射 +- 安全限制:使用反射技术要求程序必须在一个没有安全限制的环境中运行,如果一个程序必须在有安全限制的环境中运行 +- 内部暴露:由于反射允许代码执行一些在正常情况下不被允许的操作(比如访问私有的属性和方法),所以使用反射可能会导致意料之外的副作用,这可能导致代码功能失调并破坏可移植性。反射代码破坏了抽象性,因此当平台发生改变的时候,代码的行为就有可能也随着变化 + + + +*** + + + +### 获取元素 + +#### 获取类 + +反射技术的第一步是先得到 Class 类对象,有三种方式获取: + +* 类名.class +* 类的对象.getClass() +* Class.forName("类的全限名"):`public static Class forName(String className) ` + +Class 类下的方法: + +| 方法 | 作用 | +| ---------------------- | ------------------------------------------------------------ | +| String getSimpleName() | 获得类名字符串:类名 | +| String getName() | 获得类全名:包名+类名 | +| T newInstance() | 创建 Class 对象关联类的对象,底层是调用无参数构造器,已经被淘汰 | + +```java +public class ReflectDemo{ + public static void main(String[] args) throws Exception { + // 反射的第一步永远是先得到类的Class文件对象: 字节码文件。 + // 1.类名.class + Class c1 = Student.class; + System.out.println(c1);//class _03反射_获取Class类对象.Student + + // 2.对象.getClass() + Student swk = new Student(); + Class c2 = swk.getClass(); + System.out.println(c2); + + // 3.Class.forName("类的全限名") + // 直接去加载该类的class文件。 + Class c3 = Class.forName("_03反射_获取Class类对象.Student"); + System.out.println(c3); + + System.out.println(c1.getSimpleName()); // 获取类名本身(简名)Student + System.out.println(c1.getName()); //获取类的全限名_03反射_获取Class类对象.Student + } +} +class Student{} +``` + + + +*** + + + +#### 获取构造 + +获取构造器的 API: + +* Constructor getConstructor(Class... parameterTypes):根据参数匹配获取某个构造器,只能拿 public 修饰的构造器 +* Constructor getDeclaredConstructor(Class... parameterTypes):根据参数匹配获取某个构造器,只要申明就可以定位,不关心权限修饰符 +* Constructor[] getConstructors():获取所有的构造器,只能拿 public 修饰的构造器 +* Constructor[] getDeclaredConstructors():获取所有构造器,只要申明就可以定位,不关心权限修饰符 + +Constructor 的常用 API: + +| 方法 | 作用 | +| --------------------------------- | --------------------------------------- | +| T newInstance(Object... initargs) | 创建对象,注入构造器需要的数据 | +| void setAccessible(true) | 修改访问权限,true 攻破权限(暴力反射) | +| String getName() | 以字符串形式返回此构造函数的名称 | +| int getParameterCount() | 返回参数数量 | +| Class[] getParameterTypes | 返回参数类型数组 | + +```java +public class TestStudent01 { + @Test + public void getDeclaredConstructors(){ + // a.反射第一步先得到Class类对象 + Class c = Student.class ; + // b.定位全部构造器,只要申明了就可以拿到 + Constructor[] cons = c.getDeclaredConstructors(); + // c.遍历这些构造器 + for (Constructor con : cons) { + System.out.println(con.getName()+"->"+con.getParameterCount()); + } + } + @Test + public void getDeclaredConstructor() throws Exception { + // a.反射第一步先得到Class类对象 + Class c = Student.class ; + // b.定位某个构造器,根据参数匹配,只要申明了就可以获取 + //Constructor con = c.getDeclaredConstructor(); // 可以拿到!定位无参数构造器! + Constructor con = c.getDeclaredConstructor(String.class, int.class); //有参数的!! + // c.构造器名称和参数 + System.out.println(con.getName()+"->"+con.getParameterCount()); + } +} +``` + +```java +public class Student { + private String name ; + private int age ; + private Student(){ + System.out.println("无参数构造器被执行~~~~"); + } + public Student(String name, int age) { + System.out.println("有参数构造器被执行~~~~"); + this.name = name; + this.age = age; + } +} +``` + +```java +//测试方法 +public class TestStudent02 { + // 1.调用无参数构造器得到一个类的对象返回。 + @Test + public void createObj01() throws Exception { + // a.反射第一步是先得到Class类对象 + Class c = Student.class ; + // b.定位无参数构造器对象 + Constructor constructor = c.getDeclaredConstructor(); + // c.暴力打开私有构造器的访问权限 + constructor.setAccessible(true); + // d.通过无参数构造器初始化对象返回 + Student swk = (Student) constructor.newInstance(); // 最终还是调用无参数构造器的! + System.out.println(swk);//Student{name='null', age=0} + } + + // 2.调用有参数构造器得到一个类的对象返回。 + @Test + public void createObj02() throws Exception { + // a.反射第一步是先得到Class类对象 + Class c = Student.class ; + // b.定位有参数构造器对象 + Constructor constructor = c.getDeclaredConstructor(String.class , int.class); + // c.通过无参数构造器初始化对象返回 + Student swk = (Student) constructor.newInstance("孙悟空",500); // 最终还是调用有参数构造器的! + System.out.println(swk);//Student{name='孙悟空', age=500} + } +} + + +``` + + + +*** + + + +#### 获取变量 + +获取 Field 成员变量 API: + +* Field getField(String name):根据成员变量名获得对应 Field 对象,只能获得 public 修饰 +* Field getDeclaredField(String name):根据成员变量名获得对应 Field 对象,所有申明的变量 +* Field[] getFields():获得所有的成员变量对应的 Field 对象,只能获得 public 的 +* Field[] getDeclaredFields():获得所有的成员变量对应的 Field 对象,只要申明了就可以得到 + +Field 的方法:给成员变量赋值和取值 + +| 方法 | 作用 | +| ---------------------------------- | ----------------------------------------------------------- | +| void set(Object obj, Object value) | 给对象注入某个成员变量数据,**obj 是对象**,value 是值 | +| Object get(Object obj) | 获取指定对象的成员变量的值,**obj 是对象**,没有对象为 null | +| void setAccessible(true) | 暴力反射,设置为可以直接访问私有类型的属性 | +| Class getType() | 获取属性的类型,返回 Class 对象 | +| String getName() | 获取属性的名称 | + +```Java +public class FieldDemo { + //获取全部成员变量 + @Test + public void getDeclaredFields(){ + // a.先获取class类对象 + Class c = Dog.class; + // b.获取全部申明的成员变量对象 + Field[] fields = c.getDeclaredFields(); + for (Field field : fields) { + System.out.println(field.getName()+"->"+field.getType()); + } + } + //获取某个成员变量 + @Test + public void getDeclaredField() throws Exception { + // a.先获取class类对象 + Class c = Dog.class; + // b.定位某个成员变量对象 :根据名称定位!! + Field ageF = c.getDeclaredField("age"); + System.out.println(ageF.getName()+"->"+ageF.getType()); + } +} +``` + +```java +public class Dog { + private String name; + private int age ; + private String color ; + public static String school; + public static final String SCHOOL_1 = "宠物学校"; + + public Dog() { + } + + public Dog(String name, int age, String color) { + this.name = name; + this.age = age; + this.color = color; + } +} +``` + +```java +//测试方法 +public class FieldDemo02 { + @Test + public void setField() throws Exception { + // a.反射的第一步获取Class类对象 + Class c = Dog.class ; + // b.定位name成员变量 + Field name = c.getDeclaredField("name"); + // c.为这个成员变量赋值! + Dog d = new Dog(); + name.setAccessible(true); + name.set(d,"泰迪"); + System.out.println(d);//Dog{name='泰迪', age=0, color='null'} + // d.获取成员变量的值 + String value = name.get(d)+""; + System.out.println(value);//泰迪 + } +} +``` + + + +*** + + + +#### 获取方法 + +获取 Method 方法 API: + +* Method getMethod(String name,Class...args):根据方法名和参数类型获得方法对象,public 修饰 +* Method getDeclaredMethod(String name,Class...args):根据方法名和参数类型获得方法对象,包括 private +* Method[] getMethods():获得类中的所有成员方法对象返回数组,只能获得 public 修饰且包含父类的 +* Method[] getDeclaredMethods():获得类中的所有成员方法对象,返回数组,只获得本类申明的方法 + +Method 常用 API: + +* public Object invoke(Object obj, Object... args):使用指定的参数调用由此方法对象,obj 对象名 + +```java +public class MethodDemo{ + //获得类中的所有成员方法对象 + @Test + public void getDeclaredMethods(){ + // a.先获取class类对象 + Class c = Dog.class ; + // b.获取全部申明的方法! + Method[] methods = c.getDeclaredMethods(); + // c.遍历这些方法 + for (Method method : methods) { + System.out.println(method.getName()+"->" + + method.getParameterCount()+"->" + method.getReturnType()); + } + } + @Test + public void getDeclardMethod() throws Exception { + Class c = Dog.class; + Method run = c.getDeclaredMethod("run"); + // c.触发方法执行! + Dog d = new Dog(); + Object o = run.invoke(d); + System.out.println(o);// 如果方法没有返回值,结果是null + + //参数一:方法名称 参数二:方法的参数个数和类型(可变参数!) + Method eat = c.getDeclaredMethod("eat",String.class); + eat.setAccessible(true); // 暴力反射! + + //参数一:被触发方法所在的对象 参数二:方法需要的入参值 + Object o1 = eat.invoke(d,"肉"); + System.out.println(o1);// 如果方法没有返回值,结果是null + } +} + +public class Dog { + private String name ; + public Dog(){ + } + public void run(){System.out.println("狗跑的贼快~~");} + private void eat(){System.out.println("狗吃骨头");} + private void eat(String name){System.out.println("狗吃"+name);} + public static void inAddr(){System.out.println("在吉山区有一只单身狗!");} +} +``` + + + +*** + + + +### 暴力攻击 + +泛型只能工作在编译阶段,运行阶段泛型就消失了,反射工作在运行时阶段 + +1. 反射可以破坏面向对象的封装性(暴力反射) +2. 同时可以破坏泛型的约束性 + +```java +public class ReflectDemo { + public static void main(String[] args) throws Exception { + List scores = new ArrayList<>(); + scores.add(99.3); + scores.add(199.3); + scores.add(89.5); + // 拓展:通过反射暴力的注入一个其他类型的数据进去。 + // a.先得到集合对象的Class文件对象 + Class c = scores.getClass(); + // b.从ArrayList的Class对象中定位add方法 + Method add = c.getDeclaredMethod("add", Object.class); + // c.触发scores集合对象中的add执行(运行阶段,泛型不能约束了) + add.invoke(scores, "字符串"); + System.out.println(scores); + } +} +``` + + + + + +*** + + + + + +## 注解 + +### 概念 + +注解:类的组成部分,可以给类携带一些额外的信息,提供一种安全的类似注释标记的机制,用来将任何信息或元数据(metadata)与程序元素(类、方法、成员变量等)进行关联 + +* 注解是给编译器或 JVM 看的,编译器或 JVM 可以根据注解来完成对应的功能 +* 注解类似修饰符,应用于包、类型、构造方法、方法、成员变量、参数及本地变量的声明语句中 +* **父类中的注解是不能被子类继承的** + +注解作用: + +* 标记 +* 框架技术多半都是在使用注解和反射,都是属于框架的底层基础技术 +* 在编译时进行格式检查,比如方法重写约束 @Override、函数式接口约束 @FunctionalInterface. + + + +*** + + + +### 注解格式 + +定义格式:自定义注解用 @interface 关键字,注解默认可以标记很多地方 + +```java +修饰符 @interface 注解名{ + // 注解属性 +} +``` + +使用注解的格式:@注解名 + +```java +@Book +@MyTest +public class MyBook { + //方法变量都可以注解 +} + +@interface Book{ +} +@interface MyTest{ +} +``` + + + +*** + + + +### 注解属性 + +#### 普通属性 + +注解可以有属性,**属性名必须带 ()**,在用注解的时候,属性必须赋值,除非属性有默认值 + +属性的格式: + +* 格式 1:数据类型 属性名() +* 格式 2:数据类型 属性名() default 默认值 + +属性适用的数据类型: + +* 八种数据数据类型(int,short,long,double,byte,char,boolean,float)和 String、Class +* 以上类型的数组形式都支持 + +```java +@MyBook(name="《精通Java基础》",authors = {"播仔","Dlei","播妞"} , price = 99.9 ) +public class AnnotationDemo01 { + @MyBook(name="《精通MySQL数据库入门到删库跑路》",authors = {"小白","小黑"} , + price = 19.9 , address = "北京") + public static void main(String[] args) { + } +} +// 自定义一个注解 +@interface MyBook{ + String name(); + String[] authors(); // 数组 + double price(); + String address() default "武汉"; +} + +``` + + + +*** + + + +#### 特殊属性 + +注解的特殊属性名称:value + +* 如果只有一个 value 属性的情况下,使用 value 属性的时候可以省略 value 名称不写 +* 如果有多个属性,且多个属性没有默认值,那么 value 是不能省略的 + +```java +//@Book("/deleteBook.action") +@Book(value = "/deleteBook.action" , age = 12) +public class AnnotationDemo01{ +} + +@interface Book{ + String value(); + int age() default 10; +} +``` + + + +*** + + + +### 元注解 + +元注解是 sun 公司提供的,用来注解自定义注解 + +元注解有四个: + +* @Target:约束自定义注解可以标记的范围,默认值为任何元素,表示该注解用于什么地方,可用值定义在 ElementType 类中: + + - `ElementType.CONSTRUCTOR`:用于描述构造器 + - `ElementType.FIELD`:成员变量、对象、属性(包括 enum 实例) + - `ElementType.LOCAL_VARIABLE`:用于描述局部变量 + - `ElementType.METHOD`:用于描述方法 + - `ElementType.PACKAGE`:用于描述包 + - `ElementType.PARAMETER`:用于描述参数 + - `ElementType.TYPE`:用于描述类、接口(包括注解类型)或 enum 声明 + +* @Retention:定义该注解的生命周期,申明注解的作用范围:编译时,运行时,可使用的值定义在 RetentionPolicy 枚举类中: + + - `RetentionPolicy.SOURCE`:在编译阶段丢弃,这些注解在编译结束之后就不再有任何意义,只作用在源码阶段,生成的字节码文件中不存在,`@Override`、`@SuppressWarnings` 都属于这类注解 + - `RetentionPolicy.CLASS`:在类加载时丢弃,在字节码文件的处理中有用,运行阶段不存在,默认值 + - `RetentionPolicy.RUNTIME` : 始终不会丢弃,运行期也保留该注解,因此可以使用反射机制读取该注解的信息,自定义的注解通常使用这种方式 + +* @Inherited:表示修饰的自定义注解可以被子类继承 + +* @Documented:表示是否将自定义的注解信息添加在 Java 文档中 + +```java +public class AnnotationDemo01{ + // @MyTest // 只能注解方法 + private String name; + + @MyTest + public static void main( String[] args) { + } +} +@Target(ElementType.METHOD) // 申明只能注解方法 +@Retention(RetentionPolicy.RUNTIME) // 申明注解从写代码一直到运行还在,永远存活!! +@interface MyTest{ +} +``` + + + +*** + + + +### 注解解析 + +开发中经常要知道一个类的成分上面到底有哪些注解,注解有哪些属性数据,这都需要进行注解的解析 + +注解解析相关的接口: + +* Annotation:注解类型,该类是所有注解的父类,注解都是一个 Annotation 的对象 +* AnnotatedElement:该接口定义了与注解解析相关的方法 +* Class、Method、Field、Constructor 类成分:实现 AnnotatedElement 接口,拥有解析注解的能力 + +Class 类 API : + +* `Annotation[] getDeclaredAnnotations()`:获得当前对象上使用的所有注解,返回注解数组 +* `T getDeclaredAnnotation(Class annotationClass)`:根据注解类型获得对应注解对象 +* `T getAnnotation(Class annotationClass)`:根据注解类型获得对应注解对象 +* `boolean isAnnotationPresent(Class class)`:判断对象是否使用了指定的注解 +* `boolean isAnnotation()`:此 Class 对象是否表示注释类型 + +注解原理:注解本质是**特殊接口**,继承了 `Annotation` ,其具体实现类是 Java 运行时生成的**动态代理类**,通过反射获取注解时,返回的是运行时生成的动态代理对象 `$Proxy1`,通过代理对象调用自定义注解(接口)的方法,回调 `AnnotationInvocationHandler` 的 `invoke` 方法,该方法会从 `memberValues` 这个 Map 中找出对应的值,而 `memberValues` 的来源是 Java 常量池 + +解析注解数据的原理:注解在哪个成分上,就先拿哪个成分对象,比如注解作用在类上,则要该类的 Class 对象,再来拿上面的注解 + +```java +public class AnnotationDemo{ + @Test + public void parseClass() { + // 1.定位Class类对象 + Class c = BookStore.class; + // 2.判断这个类上是否使用了某个注解 + if(c.isAnnotationPresent(Book.class)){ + // 3.获取这个注解对象 + Book b = (Book)c.getDeclarAnnotation(Book.class); + System.out.println(book.value()); + System.out.println(book.price()); + System.out.println(Arrays.toString(book.authors())); + } + } + @Test + public void parseMethod() throws Exception { + Class c = BookStore.class; + Method run = c.getDeclaredMethod("run"); + if(run.isAnnotationPresent(Book.class)){ + Book b = (Book)run.getDeclaredAnnotation(Book.class); + sout(上面的三个); + } + } +} + +@Book(value = "《Java基础到精通》", price = 99.5, authors = {"张三","李四"}) +class BookStore{ + @Book(value = "《Mybatis持久层框架》", price = 199.5, authors = {"王五","小六"}) + public void run(){ + } +} +@Target({ElementType.TYPE,ElementType.METHOD}) // 类和成员方法上使用 +@Retention(RetentionPolicy.RUNTIME) // 注解永久存活 +@interface Book{ + String value(); + double price() default 100; + String[] authors(); +} +``` + + + + + +**** + + + + + +## XML + +### 概述 + +XML介绍: + +- XML 指可扩展标记语言(EXtensible Markup Language) +- XML 是一种**标记语言**,很类似 HTML,HTML文件也是XML文档 +- XML 的设计宗旨是**传输数据**,而非显示数据 +- XML 标签没有被预定义,需要自行定义标签 +- XML 被设计为具有自我描述性,易于阅读 +- XML 是 W3C 的推荐标准 + +**XML 与 HTML 的区别**: + +* XML 不是 HTML 的替代,XML 和 HTML 为不同的目的而设计 +* XML 被设计为传输和存储数据,其焦点是数据的内容;XMl标签可自定义,便于阅读 +* HTML 被设计用来显示数据,其焦点是数据的外观;HTML标签被预设好,便于浏览器识别 +* HTML 旨在显示信息,而 XML 旨在传输信息 + + + +**** + + + +### 创建 + +person.xml + +```xml + + + 18 + 张三 + + +``` + + + +*** + + + +### 组成 + +XML 文件中常见的组成元素有:文档声明、元素、属性、注释、转义字符、字符区。文件后缀名为 xml + +* **文档声明** + ``,文档声明必须在第一行,以 `` 结束, + + * version:指定 XML 文档版本。必须属性,这里一般选择 1.0 + * enconding:指定当前文档的编码,可选属性,默认值是 utf-8 + * standalone:该属性不是必须的,描述 XML 文件是否依赖其他的 xml 文件,取值为 yes/no + +* **元素** + + * 格式 1:` ` + * 格式 2:`` + * 普通元素的结构由开始标签、元素体、结束标签组成 + * 标签由一对尖括号和合法标识符组成,标签必须成对出现。特殊的标签可以不成对,必须有结束标记 + +* 元素体:可以是元素,也可以是文本,例如:``张三`` + * 空元素:空元素只有标签,而没有结束标签,但**元素必须自己闭合**,例如:```` + * 元素命名:区分大小写、不能使用空格冒号、不建议用 XML、xml、Xml 等开头 + * 必须存在一个根标签,有且只能有一个 + +* **属性**:`` + + * 属性是元素的一部分,它必须出现在元素的开始标签中 + * 属性的定义格式:`属性名=“属性值”`,其中属性值必须使用单引或双引号括起来 + * 一个元素可以有 0~N 个属性,但一个元素中不能出现同名属性 + * 属性名不能使用空格 , 不要使用冒号等特殊字符,且必须以字母开头 + +* **注释**: + XML的注释与HTML相同,既以 `` 结束。 + +* **转义字符** + XML 中的转义字符与 HTML 一样。因为很多符号已经被文档结构所使用,所以在元素体或属性值中想使用这些符号就必须使用转义字符(也叫实体字符),例如:">"、"<"、"'"、"""、"&" + XML 中仅有字符 < 和 & 是非法的。省略号、引号和大于号是合法的,把它们替换为实体引用 + + | 字符 | 预定义的转义字符 | 说明 | + | :--: | :--------------: | :----: | + | < | ``<`` | 小于 | + | > | `` >`` | 大于 | + | " | `` "`` | 双引号 | + | ' | `` '`` | 单引号 | + | & | `` &`` | 和号 | + +* **字符区** + + ```xml + + ``` + + * CDATA 指的是不应由 XML 解析器进行解析的文本数据(Unparsed Character Data) +* CDATA 部分由 "" 结束; + * 大量的转义字符在xml文档中时,会使XML文档的可读性大幅度降低。这时使用CDATA段就会好一些 + + * 规则: + * CDATA 部分不能包含字符串 ]]>,也不允许嵌套的 CDATA 部分 + * 标记 CDATA 部分结尾的 ]]> 不能包含空格或折行 + + ```xml + + + + + + + + + 西门庆 + 32 + + + + select * from student where age < 18 && age > 10; + + + + 10; + ]]> + + + ``` + + + +**** + + + +### 约束 + +#### DTD + +DTD 是文档类型定义(Document Type Definition)。DTD 可以定义在 XML 文档中出现的元素、这些元素出现的次序、它们如何相互嵌套以及 XML 文档结构的其它详细信息。 + +DTD 规则: + +* 约束元素的嵌套层级 + + ```dtd + + ``` + +* 约束元素体里面的数据 + +* 语法 + + ```dtd + + ``` + +* 判断元素 + 简单元素:没有子元素。 + 复杂元素:有子元素的元素; + + * 标签类型 + + | 标签类型 | 代码写法 | 说明 | + | -------- | --------- | -------------------- | + | PCDATA | (#PCDATA) | 被解释的字符串数据 | + | EMPTY | EMPTY | 即空元素,例如\
| + | ANY | ANY | 即任意类型 | + + * 代码 + + ```dtd + + + + + ``` + + * 数量词 + + | 数量词符号 | 含义 | + | ---------- | ---------------------------- | + | 空 | 表示元素出现一次 | + | * | 表示元素可以出现0到多个 | + | + | 表示元素可以出现至少1个 | + | ? | 表示元素可以是0或1个 | + | , | 表示元素需要按照顺序显示 | + | \| | 表示元素需要选择其中的某一个 | + + + +* 属性声明 + + * 语法 + + ```dtd + + ``` + + * 属性类型 + + | 属性类型 | 含义 | + | ---------- | ------------------------------------------------------------ | + | CDATA | 代表属性是文本字符串, eg: | + | ID | 代码该属性值唯一,不能以数字开头, eg: | + | ENUMERATED | 代表属性值在指定范围内进行枚举 Eg: "社科类"是默认值,属性如果不设置默认值就是"社科类" | + + * 属性说明 + + | 属性说明 | 含义 | + | --------- | ----------------------------------------------------------- | + | #REQUIRED | 代表属性是必须有的 | + | #IMPLIED | 代表属性可有可无 | + | #FIXED | 代表属性为固定值,实现方式:book_info CDATA #FIXED "固定值" | + + * 代码 + + ```dtd + + id ID #REQUIRED + 编号 CDATA #IMPLIED + 出版社 (清华|北大) "清华" + type CDATA #FIXED "IT" + > + + ``` + + + +*** + + + +#### Schema + +XSD 定义: + +1. Schema 语言也可作为 XSD(XML Schema Definition) +2. Schema 约束文件本身也是一个 XML 文件,符合 XML 的语法,这个文件的后缀名 .xsd +3. 一个 XML 中可以引用多个 Schema 约束文件,多个 Schema 使用名称空间区分(名称空间类似于 Java 包名) +4. dtd 里面元素类型的取值比较单一常见的是 PCDATA 类型,但是在 Schema 里面可以支持很多个数据类型 +5. **Schema 文件约束 XML 文件的同时也被别的文件约束着** + +XSD 规则: + +1. 创建一个文件,这个文件的后缀名为 .xsd +2. 定义文档声明 +3. schema 文件的根标签为: +4. 在 中定义属性: + * xmlns=http://www.w3.org/2001/XMLSchema + * 代表当前文件时约束别人的,同时这个文件也对该 Schema 进行约束 +5. 在中定义属性 : + * targetNamespace = 唯一的 url 地址,指定当前这个 schema 文件的名称空间。 + * **名称空间**:当其他 xml 使用该 schema 文件,需要引入此空间 +6. 在中定义属性 : + * elementFormDefault="qualified“,表示当前 schema 文件是一个质量良好的文件。 +7. 通过 element 定义元素 +8. **判断当前元素是简单元素还是复杂元素** + +person.xsd + +```scheme + + + targetNamespace="http://www.seazean.cn/javase" + elementFormDefault="qualified" +> + + + + + + + + + + + + + + + + + + +``` + + + + + +*** + + + +### Dom4J + +#### 解析 + +XML 解析就是从 XML 中获取到数据,DOM 是解析思想 + +DOM(Document Object Model):文档对象模型,把文档的各个组成部分看做成对应的对象,把 XML 文件全部加载到内存,在内存中形成一个树形结构,再获取对应的值 + +Dom4J 实现: +* Dom4J 解析器构造方法:`SAXReader saxReader = new SAXReader()` + +* SAXReader 常用 API: + + * `public Document read(File file)`:Reads a Document from the given File + * `public Document read(InputStream in)`:Reads a Document from the given stream using SAX + +* Java Class 类 API: + + * `public InputStream getResourceAsStream(String path)`:加载文件成为一个字节输入流返回 + + + +**** + + + +#### 根元素 + +Document 方法:`Element getRootElement()` 获取根元素 + +```java +// 需求:解析books.xml文件成为一个Document文档树对象,得到根元素对象。 +public class Dom4JDemo { + public static void main(String[] args) throws Exception { + // 1.创建一个dom4j的解析器对象:代表整个dom4j框架。 + SAXReader saxReader = new SAXReader(); + // 2.第一种方式(简单):通过解析器对象去加载xml文件数据,成为一个Document文档树对象。 + //Document document = saxReader.read(new File("Day13Demo/src/books.xml")); + + // 3.第二种方式(代码多点)先把xml文件读成一个字节输入流 + // 这里的“/”是直接去src类路径下寻找文件。 + InputStream is = Dom4JDemo01.class.getResourceAsStream("/books.xml"); + Document document = saxReader.read(is); + System.out.println(document); + //org.dom4j.tree.DefaultDocument@27a5f880 [Document: name null] + // 4.从document文档树对象中提取根元素对象 + Element root = document.getRootElement(); + System.out.println(root.getName());//books + } +} +``` + +```xml + + + + JavaWeb开发教程 + 张三 + 100.00元 + + + 三国演义 + 罗贯中 + 100.00元 + + + + + +``` + + + +**** + + + +#### 子元素 + +Element 元素的 API: + +* String getName():取元素的名称。 +* List elements():获取当前元素下的全部子元素(一级) +* List elements(String name):获取当前元素下的指定名称的全部子元素(一级) +* Element element(String name):获取当前元素下的指定名称的某个子元素,默认取第一个(一级) + +```java +public class Dom4JDemo { + public static void main(String[] args) throws Exception { + SAXReader saxReader = new SAXReader(); + Document document = saxReader.read(new File("Day13Demo/src/books.xml")); + // 3.获取根元素对象 + Element root = document.getRootElement(); + System.out.println(root.getName()); + + // 4.获取根元素下的全部子元素 + List sonElements = root.elements(); + for (Element sonElement : sonElements) { + System.out.println(sonElement.getName()); + } + // 5.获取根源下的全部book子元素 + List sonElements1 = root.elements("book"); + for (Element sonElement : sonElements1) { + System.out.println(sonElement.getName()); + } + + // 6.获取根源下的指定的某个元素 + Element son = root.element("user"); + System.out.println(son.getName()); + // 默认会提取第一个名称一样的子元素对象返回! + Element son1 = root.element("book"); + System.out.println(son1.attributeValue("id")); + } +} + +``` + + + +*** + + + +#### 属性 + +Element 元素的 API: + +* List attributes():获取元素的全部属性对象 +* Attribute attribute(String name):根据名称获取某个元素的属性对象 +* String attributeValue(String var):直接获取某个元素的某个属性名称的值 + +Attribute 对象的 API: + +* String getName():获取属性名称 +* String getValue():获取属性值 + +```java +public class Dom4JDemo { + public static void main(String[] args) throws Exception { + SAXReader saxReader = new SAXReader(); + Document document = saxReader.read(new File("Day13Demo/src/books.xml")); + Element root = document.getRootElement(); + // 4.获取book子元素 + Element bookEle = root.element("book"); + + // 5.获取book元素的全部属性对象 + List attributes = bookEle.attributes(); + for (Attribute attribute : attributes) { + System.out.println(attribute.getName()+"->"+attribute.getValue()); + } + + // 6.获取Book元素的某个属性对象 + Attribute descAttr = bookEle.attribute("desc"); + System.out.println(descAttr.getName()+"->"+descAttr.getValue()); + + // 7.可以直接获取元素的属性值 + System.out.println(bookEle.attributeValue("id")); + System.out.println(bookEle.attributeValue("desc")); + } +} +``` + + + +*** + + + +#### 文本 + +Element: + +* String elementText(String name):可以直接获取当前元素的子元素的文本内容 +* String elementTextTrim(String name):去前后空格,直接获取当前元素的子元素的文本内容 +* String getText():直接获取当前元素的文本内容 +* String getTextTrim():去前后空格,直接获取当前元素的文本内容 + +```java +public class Dom4JDemo { + public static void main(String[] args) throws Exception { + SAXReader saxReader = new SAXReader(); + Document document = saxReader.read(new File("Day13Demo/src/books.xml")); + Element root = document.getRootElement(); + // 4.得到第一个子元素book + Element bookEle = root.element("book"); + + // 5.直接拿到当前book元素下的子元素文本值 + System.out.println(bookEle.elementText("name")); + System.out.println(bookEle.elementTextTrim("name")); // 去前后空格 + System.out.println(bookEle.elementText("author")); + System.out.println(bookEle.elementTextTrim("author")); // 去前后空格 + + // 6.先获取到子元素对象,再获取该文本值 + Element bookNameEle = bookEle.element("name"); + System.out.println(bookNameEle.getText()); + System.out.println(bookNameEle.getTextTrim());// 去前后空格 + } +} +``` + + + + + +**** + + + +### XPath + +Dom4J 可以用于解析整个 XML 的数据,但是如果要检索 XML 中的某些信息,建议使用 XPath + +XPath 常用API: + +* List selectNodes(String var1) : 检索出一批节点集合 +* Node selectSingleNode(String var1) : 检索出一个节点返回 + +XPath 提供的四种检索数据的写法: + +1. 绝对路径:/根元素/子元素/子元素 +2. 相对路径:./子元素/子元素 (.代表了当前元素) +3. 全文搜索: + * //元素:在全文找这个元素 + * //元素1/元素2:在全文找元素1下面的一级元素 2 + * //元素1//元素2:在全文找元素1下面的全部元素 2 +4. 属性查找: + * //@属性名称:在全文检索属性对象 + * //元素[@属性名称]:在全文检索包含该属性的元素对象 + * //元素[@属性名称=值]:在全文检索包含该属性的元素且属性值为该值的元素对象 + +```java +public class XPathDemo { + public static void main(String[] args) throws Exception { + SAXReader saxReader = new SAXReader(); + InputStream is = XPathDemo.class.getResourceAsStream("/Contact.xml"); + Document document = saxReader.read(is); + //1.使用绝对路径定位全部的name名称 + List nameNodes1 = document.selectNodes("/contactList/contact/name"); + for (Node nameNode : nameNodes) { + System.out.println(nameNode.getText()); + } + + //2.相对路径。从根元素开始检索,.代表很根元素 + List nameNodes2 = root.selectNodes("./contact/name"); + + //3.1 在全文中检索name节点 + List nameNodes3 = root.selectNodes("//name");//全部的 + //3.2 在全文中检索所有contact下的所有name节点 //包括sql,不外面的 + List nameNodes3 = root.selectNodes("//contact//name"); + //3.3 在全文中检索所有contact下的直接name节点 + List nameNodes3 = root.selectNodes("//contact/name");//不包括sql和外面 + + //4.1 检索全部属性对象 + List attributes1 = root.selectNodes("//@id");//包括sql4 + //4.2 在全文检索包含该属性的元素对象 + List attributes1 = root.selectNodes("//contact[@id]"); + //4.3 在全文检索包含该属性的元素且属性值为该值的元素对象 + Node nodeEle = document.selectSingleNode("//contact[@id=2]"); + Element ele = (Element)nodeEle; + System.out.println(ele.elementTextTrim("name"));//xi + } +} +``` + +```xml + + + + 小白 + + bai@seazean.cn + + + 小黑 + + hei@seazean.cn + + sql语句 + + + + 小虎 + + hu@seazean.cn + + +外面的名称 + +``` + + + + + +**** + + + + + +## SDP + +### 单例模式 + +#### 基本介绍 + +单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一,提供了一种创建对象的最佳方式 + +单例设计模式分类两种: + +* 饿汉式:类加载就会导致该单实例对象被创建 + +* 懒汉式:类加载不会导致该单实例对象被创建,而是首次使用该对象时才会创建 + + + +*** + + + +#### 饿汉式 + +饿汉式在类加载的过程导致该单实例对象被创建,**虚拟机会保证类加载的线程安全**,但是如果只是为了加载该类不需要实例,则会造成内存的浪费 + +* 静态变量的方式: + + ```java + public final class Singleton { + // 私有构造方法 + private Singleton() {} + // 在成员位置创建该类的对象 + private static final Singleton instance = new Singleton(); + // 对外提供静态方法获取该对象 + public static Singleton getInstance() { + return instance; + } + + // 解决序列化问题 + protected Object readResolve() { + return INSTANCE; + } + } + ``` + + * 加 final 修饰,所以不会被子类继承,防止子类中不适当的行为覆盖父类的方法,破坏了单例 + + * 防止反序列化破坏单例的方式: + + * 对单例声明 transient,然后实现 readObject(ObjectInputStream in) 方法,复用原来的单例 + + 条件:访问权限为 private/protected、返回值必须是 Object、异常可以不抛 + + * 实现 readResolve() 方法,当 JVM 从内存中反序列化地组装一个新对象,就会自动调用 readResolve 方法返回原来单例 + + * 构造方法设置为私有,防止其他类无限创建对象,但是不能防止反射破坏 + + * 静态变量初始化在类加载时完成,**由 JVM 保证线程安全**,能保证单例对象创建时的安全 + + * 提供静态方法而不是直接将 INSTANCE 设置为 public,体现了更好的封装性、提供泛型支持、可以改进成懒汉单例设计 + +* 静态代码块的方式: + + ```java + public class Singleton { + // 私有构造方法 + private Singleton() {} + + // 在成员位置创建该类的对象 + private static Singleton instance; + static { + instance = new Singleton(); + } + + // 对外提供静态方法获取该对象 + public static Singleton getInstance() { + return instance; + } + } + ``` + +* 枚举方式:枚举类型是所用单例实现中**唯一一种不会被破坏**的单例实现模式 + + ```java + public enum Singleton { + INSTANCE; + public void doSomething() { + System.out.println("doSomething"); + } + } + public static void main(String[] args) { + Singleton.INSTANCE.doSomething(); + } + ``` + + * 问题1:枚举单例是如何限制实例个数的?每个枚举项都是一个实例,是一个静态成员变量 + * 问题2:枚举单例在创建时是否有并发问题?否 + * 问题3:枚举单例能否被反射破坏单例?否,反射创建对象时判断是枚举类型就直接抛出异常 + * 问题4:枚举单例能否被反序列化破坏单例?否 + * 问题5:枚举单例属于懒汉式还是饿汉式?**饿汉式** + * 问题6:枚举单例如果希望加入一些单例创建时的初始化逻辑该如何做?添加构造方法 + + 反编译结果: + + ```java + public final class Singleton extends java.lang.Enum { // Enum实现序列化接口 + public static final Singleton INSTANCE = new Singleton(); + } + ``` + + + + + +*** + + + +#### 懒汉式 + +* 线程不安全 + + ```java + public class Singleton { + // 私有构造方法 + private Singleton() {} + + // 在成员位置创建该类的对象 + private static Singleton instance; + + // 对外提供静态方法获取该对象 + public static Singleton getInstance() { + if(instance == null) { + // 多线程环境,会出现线程安全问题,可能多个线程同时进入这里 + instance = new Singleton(); + } + return instance; + } + } + ``` + +* 双端检锁机制 + + 在多线程的情况下,可能会出现空指针问题,出现问题的原因是 JVM 在实例化对象的时候会进行优化和指令重排序操作,所以需要使用 `volatile` 关键字 + + ```java + public class Singleton { + // 私有构造方法 + private Singleton() {} + private static volatile Singleton instance; + + // 对外提供静态方法获取该对象 + public static Singleton getInstance() { + // 第一次判断,如果instance不为null,不进入抢锁阶段,直接返回实例 + if(instance == null) { + synchronized (Singleton.class) { + // 抢到锁之后再次判断是否为null + if(instance == null) { + instance = new Singleton(); + } + } + } + return instance; + } + } + ``` + +* 静态内部类方式 + + ```java + public class Singleton { + // 私有构造方法 + private Singleton() {} + + private static class SingletonHolder { + private static final Singleton INSTANCE = new Singleton(); + } + + // 对外提供静态方法获取该对象 + public static Singleton getInstance() { + return SingletonHolder.INSTANCE; + } + } + ``` + + * 内部类属于懒汉式,类加载本身就是懒惰的,首次调用时加载,然后对单例进行初始化 + + 类加载的时候方法不会被调用,所以不会触发 getInstance 方法调用 invokestatic 指令对内部类进行加载;加载的时候字节码常量池会被加入类的运行时常量池,解析工作是将常量池中的符号引用解析成直接引用,但是解析过程不一定非得在类加载时完成,可以延迟到运行时进行,所以静态内部类实现单例会**延迟加载** + + * 没有线程安全问题,静态变量初始化在类加载时完成,由 JVM 保证线程安全 + + + +*** + + + +#### 破坏单例 + +##### 反序列化 + +将单例对象序列化再反序列化,对象从内存反序列化到程序中会重新创建一个对象,通过反序列化得到的对象是不同的对象,而且得到的对象不是通过构造器得到的,**反序列化得到的对象不执行构造器** + +* Singleton + + ```java + public class Singleton implements Serializable { //实现序列化接口 + // 私有构造方法 + private Singleton() {} + private static class SingletonHolder { + private static final Singleton INSTANCE = new Singleton(); + } + + // 对外提供静态方法获取该对象 + public static Singleton getInstance() { + return SingletonHolder.INSTANCE; + } + } + ``` + +* 序列化 + + ```java + public class Test { + public static void main(String[] args) throws Exception { + //往文件中写对象 + //writeObject2File(); + //从文件中读取对象 + Singleton s1 = readObjectFromFile(); + Singleton s2 = readObjectFromFile(); + //判断两个反序列化后的对象是否是同一个对象 + System.out.println(s1 == s2); + } + + private static Singleton readObjectFromFile() throws Exception { + //创建对象输入流对象 + ObjectInputStream ois = new ObjectInputStream(new FileInputStream("C://a.txt")); + //第一个读取Singleton对象 + Singleton instance = (Singleton) ois.readObject(); + return instance; + } + + public static void writeObject2File() throws Exception { + //获取Singleton类的对象 + Singleton instance = Singleton.getInstance(); + //创建对象输出流 + ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("C://a.txt")); + //将instance对象写出到文件中 + oos.writeObject(instance); + } + } + ``` + +* 解决方法: + + 在 Singleton 类中添加 `readResolve()` 方法,在反序列化时被反射调用,如果定义了这个方法,就返回这个方法的值,如果没有定义,则返回新创建的对象 + + ```java + private Object readResolve() { + return SingletonHolder.INSTANCE; + } + ``` + + ObjectInputStream 类源码分析: + + ```java + public final Object readObject() throws IOException, ClassNotFoundException{ + //... + Object obj = readObject0(false);//重点查看readObject0方法 + } + + private Object readObject0(boolean unshared) throws IOException { + try { + switch (tc) { + case TC_OBJECT: + return checkResolve(readOrdinaryObject(unshared)); + } + } + } + private Object readOrdinaryObject(boolean unshared) throws IOException { + // isInstantiable 返回true,执行 desc.newInstance(),通过反射创建新的单例类 + obj = desc.isInstantiable() ? desc.newInstance() : null; + // 添加 readResolve 方法后 desc.hasReadResolveMethod() 方法执行结果为true + if (obj != null && handles.lookupException(passHandle) == null && desc.hasReadResolveMethod()) { + // 通过反射调用 Singleton 类中的 readResolve 方法,将返回值赋值给rep变量 + // 多次调用ObjectInputStream类中的readObject方法,本质调用定义的readResolve方法,返回的是同一个对象。 + Object rep = desc.invokeReadResolve(obj); + } + return obj; + } + ``` + + + +*** + + + +##### 反射破解 + +* 反射 + + ```java + public class Test { + public static void main(String[] args) throws Exception { + //获取Singleton类的字节码对象 + Class clazz = Singleton.class; + //获取Singleton类的私有无参构造方法对象 + Constructor constructor = clazz.getDeclaredConstructor(); + //取消访问检查 + constructor.setAccessible(true); + + //创建Singleton类的对象s1 + Singleton s1 = (Singleton) constructor.newInstance(); + //创建Singleton类的对象s2 + Singleton s2 = (Singleton) constructor.newInstance(); + + //判断通过反射创建的两个Singleton对象是否是同一个对象 + System.out.println(s1 == s2); //false + } + } + ``` + +* 反射方式破解单例的解决方法: + + ```java + public class Singleton { + private static volatile Singleton instance; + + // 私有构造方法 + private Singleton() { + // 反射破解单例模式需要添加的代码 + if(instance != null) { + throw new RuntimeException(); + } + } + + // 对外提供静态方法获取该对象 + public static Singleton getInstance() { + if(instance != null) { + return instance; + } + synchronized (Singleton.class) { + if(instance != null) { + return instance; + } + instance = new Singleton(); + return instance; + } + } + } + ``` + + + + + +*** + + + +#### Runtime + +Runtime 类就是使用的单例设计模式中的饿汉式 + +```java +public class Runtime { + private static Runtime currentRuntime = new Runtime(); + public static Runtime getRuntime() { + return currentRuntime; + } + private Runtime() {} + ... +} +``` + +使用 Runtime + +```java +public class RuntimeDemo { + public static void main(String[] args) throws IOException { + //获取Runtime类对象 + Runtime runtime = Runtime.getRuntime(); + + //返回 Java 虚拟机中的内存总量。 + System.out.println(runtime.totalMemory()); + //返回 Java 虚拟机试图使用的最大内存量。 + System.out.println(runtime.maxMemory()); + + //创建一个新的进程执行指定的字符串命令,返回进程对象 + Process process = runtime.exec("ipconfig"); + //获取命令执行后的结果,通过输入流获取 + InputStream inputStream = process.getInputStream(); + byte[] arr = new byte[1024 * 1024* 100]; + int b = inputStream.read(arr); + System.out.println(new String(arr,0,b,"gbk")); + } +} +``` + + + + + +**** + + + +### 代理模式 + +#### 静态代理 + +代理模式:由于某些原因需要给某对象提供一个代理以控制对该对象的访问,访问对象不适合或者不能直接引用为目标对象,代理对象作为访问对象和目标对象之间的中介 + +Java 中的代理按照代理类生成时机不同又分为静态代理和动态代理,静态代理代理类在编译期就生成,而动态代理代理类则是在 Java 运行时动态生成,动态代理又有 JDK 代理和 CGLib 代理两种 + +代理(Proxy)模式分为三种角色: + +* 抽象主题(Subject)类:通过接口或抽象类声明真实主题和代理对象实现的业务方法 +* 真实主题(Real Subject)类: 实现了抽象主题中的具体业务,是代理对象所代表的真实对象,是最终要引用的对象 +* 代理(Proxy)类:提供了与真实主题相同的接口,其内部含有对真实主题的引用,可以访问、控制或扩展真实主题的功能 + +买票案例,火车站是目标对象,代售点是代理对象 + +* 卖票接口: + + ```java + public interface SellTickets { + void sell(); + } + ``` + +* 火车站,具有卖票功能,需要实现SellTickets接口 + + ```java + public class TrainStation implements SellTickets { + public void sell() { + System.out.println("火车站卖票"); + } + } + ``` + +* 代售点: + + ```java + public class ProxyPoint implements SellTickets { + private TrainStation station = new TrainStation(); + + public void sell() { + System.out.println("代理点收取一些服务费用"); + station.sell(); + } + } + ``` + +* 测试类: + + ```java + public class Client { + public static void main(String[] args) { + ProxyPoint pp = new ProxyPoint(); + pp.sell(); + } + } + ``` + + 测试类直接访问的是 ProxyPoint 类对象,也就是 ProxyPoint 作为访问对象和目标对象的中介 + + + +**** + + + +#### JDK + +##### 使用方式 + +Java 中提供了一个动态代理类 Proxy,Proxy 并不是代理对象的类,而是提供了一个创建代理对象的静态方法 newProxyInstance() 来获取代理对象 + +`static Object newProxyInstance(ClassLoader loader,Class[] interfaces,InvocationHandler h) ` + +* 参数一:类加载器,负责加载代理类。传入类加载器,代理和被代理对象要用一个类加载器才是父子关系,不同类加载器加载相同的类在 JVM 中都不是同一个类对象 + +* 参数二:被代理业务对象的**全部实现的接口**,代理对象与真实对象实现相同接口,知道为哪些方法做代理 + +* 参数三:代理真正的执行方法,也就是代理的处理逻辑 + +代码实现: + +* 代理工厂:创建代理对象 + + ```java + public class ProxyFactory { + private TrainStation station = new TrainStation(); + //也可以在参数中提供 getProxyObject(TrainStation station) + public SellTickets getProxyObject() { + //使用 Proxy 获取代理对象 + SellTickets sellTickets = (SellTickets) Proxy.newProxyInstance( + station.getClass().getClassLoader(), + station.getClass().getInterfaces(), + new InvocationHandler() { + public Object invoke(Object proxy, Method method, Object[] args) { + System.out.println("代理点(JDK动态代理方式)"); + //执行真实对象 + Object result = method.invoke(station, args); + return result; + } + }); + return sellTickets; + } + } + ``` + +* 测试类: + + ```java + public class Client { + public static void main(String[] args) { + //获取代理对象 + ProxyFactory factory = new ProxyFactory(); + //必须时代理ji + SellTickets proxyObject = factory.getProxyObject(); + proxyObject.sell(); + } + } + ``` + + + +*** + + + +##### 实现原理 + +JDK 动态代理方式的优缺点: + +- 优点:可以为任意的接口实现类对象做代理,也可以为被代理对象的所有接口的所有方法做代理,动态代理可以在不改变方法源码的情况下,实现对方法功能的增强,提高了软件的可扩展性,Java 反射机制可以生成任意类型的动态代理类 +- 缺点:**只能针对接口或者接口的实现类对象做代理对象**,普通类是不能做代理对象的 +- 原因:**生成的代理类继承了 Proxy**,Java 是单继承的,所以 JDK 动态代理只能代理接口 + +ProxyFactory 不是代理模式中的代理类,而代理类是程序在运行过程中动态的在内存中生成的类,可以通过 Arthas 工具查看代理类结构: + +* 代理类($Proxy0)实现了 SellTickets 接口,真实类和代理类实现同样的接口 +* 代理类($Proxy0)将提供了的匿名内部类对象传递给了父类 +* 代理类($Proxy0)的修饰符是 public final + +```java +// 程序运行过程中动态生成的代理类 +public final class $Proxy0 extends Proxy implements SellTickets { + private static Method m3; + + public $Proxy0(InvocationHandler invocationHandler) { + super(invocationHandler);//InvocationHandler对象传递给父类 + } + + static { + m3 = Class.forName("proxy.dynamic.jdk.SellTickets").getMethod("sell", new Class[0]); + } + + public final void sell() { + // 调用InvocationHandler的invoke方法 + this.h.invoke(this, m3, null); + } +} + +// Java提供的动态代理相关类 +public class Proxy implements java.io.Serializable { + protected InvocationHandler h; + + protected Proxy(InvocationHandler h) { + this.h = h; + } +} +``` + +执行流程如下: + +1. 在测试类中通过代理对象调用 sell() 方法 +2. 根据多态的特性,执行的是代理类($Proxy0)中的 sell() 方法 +3. 代理类($Proxy0)中的 sell() 方法中又调用了 InvocationHandler 接口的子实现类对象的 invoke 方法 +4. invoke 方法通过反射执行了真实对象所属类(TrainStation)中的 sell() 方法 + + + +**** + + + +##### 源码解析 + +```java +public static Object newProxyInstance(ClassLoader loader, + Class[] interfaces, + InvocationHandler h){ + // InvocationHandler 为空则抛出异常 + Objects.requireNonNull(h); + + // 复制一份 interfaces + final Class[] intfs = interfaces.clone(); + final SecurityManager sm = System.getSecurityManager(); + if (sm != null) { + checkProxyAccess(Reflection.getCallerClass(), loader, intfs); + } + + // 从缓存中查找 class 类型的代理对象,会调用 ProxyClassFactory#apply 方法 + Class cl = getProxyClass0(loader, intfs); + //proxyClassCache = new WeakCache<>(new KeyFactory(), new ProxyClassFactory()) + + try { + if (sm != null) { + checkNewProxyPermission(Reflection.getCallerClass(), cl); + } + + // 获取代理类的构造方法,根据参数 InvocationHandler 匹配获取某个构造器 + final Constructor cons = cl.getConstructor(constructorParams); + final InvocationHandler ih = h; + // 构造方法不是 pubic 的需要启用权限,暴力p + if (!Modifier.isPublic(cl.getModifiers())) { + AccessController.doPrivileged(new PrivilegedAction() { + public Void run() { + // 设置可访问的权限 + cons.setAccessible(true); + return null; + } + }); + } + // cons 是构造方法,并且内部持有 InvocationHandler,在 InvocationHandler 中持有 target 目标对象 + return cons.newInstance(new Object[]{h}); + } catch (IllegalAccessException|InstantiationException e) {} +} +``` + +Proxy 的静态内部类: + +```java +private static final class ProxyClassFactory { + // 代理类型的名称前缀 + private static final String proxyClassNamePrefix = "$Proxy"; + + // 生成唯一数字使用,结合上面的代理类型名称前缀一起生成 + private static final AtomicLong nextUniqueNumber = new AtomicLong(); + + //参数一:Proxy.newInstance 时传递的 + //参数二:Proxy.newInstance 时传递的接口集合 + @Override + public Class apply(ClassLoader loader, Class[] interfaces) { + + Map, Boolean> interfaceSet = new IdentityHashMap<>(interfaces.length); + // 遍历接口集合 + for (Class intf : interfaces) { + Class interfaceClass = null; + try { + // 加载接口类到 JVM + interfaceClass = Class.forName(intf.getName(), false, loader); + } catch (ClassNotFoundException e) { + } + if (interfaceClass != intf) { + throw new IllegalArgumentException( + intf + " is not visible from class loader"); + } + // 如果 interfaceClass 不是接口 直接报错,保证集合内都是接口 + if (!interfaceClass.isInterface()) { + throw new IllegalArgumentException( + interfaceClass.getName() + " is not an interface"); + } + // 保证接口 interfaces 集合中没有重复的接口 + if (interfaceSet.put(interfaceClass, Boolean.TRUE) != null) { + throw new IllegalArgumentException( + "repeated interface: " + interfaceClass.getName()); + } + } + + // 生成的代理类的包名 + String proxyPkg = null; + // 【生成的代理类访问修饰符 public final】 + int accessFlags = Modifier.PUBLIC | Modifier.FINAL; + + // 检查接口集合内的接口,看看有没有某个接口的访问修饰符不是 public 的 如果不是 public 的接口, + // 生成的代理类 class 就必须和它在一个包下,否则访问出现问题 + for (Class intf : interfaces) { + // 获取访问修饰符 + int flags = intf.getModifiers(); + if (!Modifier.isPublic(flags)) { + accessFlags = Modifier.FINAL; + // 获取当前接口的全限定名 包名.类名 + String name = intf.getName(); + int n = name.lastIndexOf('.'); + // 获取包名 + String pkg = ((n == -1) ? "" : name.substring(0, n + 1)); + if (proxyPkg == null) { + proxyPkg = pkg; + } else if (!pkg.equals(proxyPkg)) { + throw new IllegalArgumentException( + "non-public interfaces from different packages"); + } + } + } + + if (proxyPkg == null) { + // if no non-public proxy interfaces, use com.sun.proxy package + proxyPkg = ReflectUtil.PROXY_PACKAGE + "."; + } + + // 获取唯一的编号 + long num = nextUniqueNumber.getAndIncrement(); + // 包名+ $proxy + 数字,比如 $proxy1 + String proxyName = proxyPkg + proxyClassNamePrefix + num; + + // 【生成二进制字节码,这个字节码写入到文件内】,就是编译好的 class 文件 + byte[] proxyClassFile = ProxyGenerator.generateProxyClass(proxyName, interfaces, accessFlags); + try { + // 【使用加载器加载二进制到 jvm】,并且返回 class + return defineClass0(loader, proxyName, proxyClassFile, 0, proxyClassFile.length); + } catch (ClassFormatError e) { } + } +} +``` + + + + + +*** + + + +#### CGLIB + +CGLIB 是一个功能强大,高性能的代码生成包,为没有实现接口的类提供代理,为 JDK 动态代理提供了补充($$Proxy) + +* CGLIB 是第三方提供的包,所以需要引入 jar 包的坐标: + + ```xml + + cglib + cglib + 2.2.2 + + ``` + +* 代理工厂类: + + ```java + public class ProxyFactory implements MethodInterceptor { + private TrainStation target = new TrainStation(); + + public TrainStation getProxyObject() { + //创建Enhancer对象,类似于JDK动态代理的Proxy类,下一步就是设置几个参数 + Enhancer enhancer = new Enhancer(); + //设置父类的字节码对象 + enhancer.setSuperclass(target.getClass()); + //设置回调函数 + enhancer.setCallback(new MethodInterceptor() { + @Override + public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { + System.out.println("代理点收取一些服务费用(CGLIB动态代理方式)"); + Object o = methodProxy.invokeSuper(obj, args); + return null;//因为返回值为void + } + }); + //创建代理对象 + TrainStation obj = (TrainStation) enhancer.create(); + return obj; + } + } + ``` + +CGLIB 的优缺点 + +* 优点: + * CGLIB 动态代理**不限定**是否具有接口,可以对任意操作进行增强 + * CGLIB 动态代理无需要原始被代理对象,动态创建出新的代理对象 + * **JDKProxy 仅对接口方法做增强,CGLIB 对所有方法做增强**,包括 Object 类中的方法,toString、hashCode 等 +* 缺点:CGLIB 不能对声明为 final 的类或者方法进行代理,因为 CGLIB 原理是**动态生成被代理类的子类,继承被代理类** + + + + + +**** + + + +#### 方式对比 + +三种方式对比: + +* 动态代理和静态代理: + + * 动态代理将接口中声明的所有方法都被转移到一个集中的方法中处理(InvocationHandler.invoke),在接口方法数量比较多的时候,可以进行灵活处理,不需要像静态代理那样每一个方法进行中转 + + * 静态代理是在编译时就已经将接口、代理类、被代理类的字节码文件确定下来 + * 动态代理是程序**在运行后通过反射创建字节码文件**交由 JVM 加载 + +* JDK 代理和 CGLIB 代理: + + JDK 动态代理采用 `ProxyGenerator.generateProxyClass()` 方法在运行时生成字节码;CGLIB 底层采用 ASM 字节码生成框架,使用字节码技术生成代理类。在 JDK1.6之前比使用 Java 反射效率要高,到 JDK1.8 的时候,JDK 代理效率高于 CGLIB 代理。所以如果有接口或者当前类就是接口使用 JDK 动态代理,如果没有接口使用 CGLIB 代理 + +代理模式的优缺点: + +* 优点: + * 代理模式在客户端与目标对象之间起到一个中介作用和保护目标对象的作用 + * **代理对象可以增强目标对象的功能,被用来间接访问底层对象,与原始对象具有相同的 hashCode** + * 代理模式能将客户端与目标对象分离,在一定程度上降低了系统的耦合度 + +* 缺点:增加了系统的复杂度 + +代理模式的使用场景: + +* 远程(Remote)代理:本地服务通过网络请求远程服务,需要实现网络通信,处理其中可能的异常。为了良好的代码设计和可维护性,将网络通信部分隐藏起来,只暴露给本地服务一个接口,通过该接口即可访问远程服务提供的功能 + +* 防火墙(Firewall)代理:当你将浏览器配置成使用代理功能时,防火墙就将你的浏览器的请求转给互联网,当互联网返回响应时,代理服务器再把它转给你的浏览器 + +* 保护(Protect or Access)代理:控制对一个对象的访问,如果需要,可以给不同的用户提供不同级别的使用权限 + + + + + + + +*** + + + + + +# JVM + +## JVM概述 + +### 基本介绍 + +JVM:全称 Java Virtual Machine,即 Java 虚拟机,一种规范,本身是一个虚拟计算机,直接和操作系统进行交互,与硬件不直接交互,而操作系统可以帮我们完成和硬件进行交互的工作 + +特点: + +* Java 虚拟机基于**二进制字节码**执行,由一套字节码指令集、一组寄存器、一个栈、一个垃圾回收堆、一个方法区等组成 +* JVM 屏蔽了与操作系统平台相关的信息,从而能够让 Java 程序只需要生成能够在 JVM 上运行的字节码文件,通过该机制实现的**跨平台性** + +Java 代码执行流程:`Java 程序 --(编译)--> 字节码文件 --(解释执行)--> 操作系统(Win,Linux)` + +JVM 结构: + + + +JVM、JRE、JDK 对比: + +* JDK(Java SE Development Kit):Java 标准开发包,提供了编译、运行 Java 程序所需的各种工具和资源 +* JRE( Java Runtime Environment):Java 运行环境,用于解释执行 Java 的字节码文件 + + + + + +参考书籍:https://book.douban.com/subject/34907497/ + +参考视频:https://www.bilibili.com/video/BV1PJ411n7xZ + +参考视频:https://www.bilibili.com/video/BV1yE411Z7AP + + + +*** + + + +### 架构模型 + +Java 编译器输入的指令流是一种基于栈的指令集架构。因为跨平台的设计,Java 的指令都是根据栈来设计的,不同平台 CPU 架构不同,所以不能设计为基于寄存器架构 + +* 基于栈式架构的特点: + * 设计和实现简单,适用于资源受限的系统 + * 使用零地址指令方式分配,执行过程依赖操作栈,指令集更小,编译器容易实现 + * 零地址指令:机器指令的一种,是指令系统中的一种不设地址字段的指令,只有操作码而没有地址码。这种指令有两种情况:一是无需操作数,另一种是操作数为默认的(隐含的),默认为操作数在寄存器(ACC)中,指令可直接访问寄存器 + * 一地址指令:一个操作码对应一个地址码,通过地址码寻找操作数 + * 不需要硬件的支持,可移植性更好,更好实现跨平台 +* 基于寄存器架构的特点: + * 需要硬件的支持,可移植性差 + * 性能更好,执行更高效,寄存器比内存快 + * 以一地址指令、二地址指令、三地址指令为主 + + + +*** + + + +### 生命周期 + +JVM 的生命周期分为三个阶段,分别为:启动、运行、死亡 + +- **启动**:当启动一个 Java 程序时,通过引导类加载器(bootstrap class loader)创建一个初始类(initial class),对于拥有 main 函数的类就是 JVM 实例运行的起点 +- **运行**: + + - main() 方法是一个程序的初始起点,任何线程均可由在此处启动 + - 在 JVM 内部有两种线程类型,分别为:用户线程和守护线程,**JVM 使用的是守护线程,main() 和其他线程使用的是用户线程**,守护线程会随着用户线程的结束而结束 + - 执行一个 Java 程序时,真真正正在执行的是一个 **Java 虚拟机的进程** + - JVM 有两种运行模式 Server 与 Client,两种模式的区别在于:Client 模式启动速度较快,Server 模式启动较慢;但是启动进入稳定期长期运行之后 Server 模式的程序运行速度比 Client 要快很多 + + Server 模式启动的 JVM 采用的是重量级的虚拟机,对程序采用了更多的优化;Client 模式启动的 JVM 采用的是轻量级的虚拟机 +- **死亡**: + + - 当程序中的用户线程都中止,JVM 才会退出 + - 程序正常执行结束、程序异常或错误而异常终止、操作系统错误导致终止 + - 线程调用 Runtime 类 halt 方法或 System 类 exit 方法,并且 Java 安全管理器允许这次 exit 或 halt 操作 + + + + + +*** + + + + + +## 内存结构 + +### 内存概述 + +内存结构是 JVM 中非常重要的一部分,是非常重要的系统资源,是硬盘和 CPU 的桥梁,承载着操作系统和应用程序的实时运行,又叫运行时数据区 + +JVM 内存结构规定了 Java 在运行过程中内存申请、分配、管理的策略,保证了 JVM 的高效稳定运行 + +* Java1.8 以前的内存结构图: + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-Java7内存结构图.png) + +* Java1.8 之后的内存结果图: + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-Java8内存结构图.png) + +线程运行诊断: + +* 定位:jps 定位进程 ID +* jstack 进程 ID:用于打印出给定的 Java 进程 ID 或 core file 或远程调试服务的 Java 堆栈信息 + +常见 OOM 错误: + +* java.lang.StackOverflowError +* java.lang.OutOfMemoryError:java heap space +* java.lang.OutOfMemoryError:GC overhead limit exceeded +* java.lang.OutOfMemoryError:Direct buffer memory +* java.lang.OutOfMemoryError:unable to create new native thread +* java.lang.OutOfMemoryError:Metaspace + + + +*** + + + +### JVM内存 + +#### 虚拟机栈 + +##### Java 栈 + +Java 虚拟机栈:Java Virtual Machine Stacks,**每个线程**运行时所需要的内存 + +* 每个方法被执行时,都会在虚拟机栈中创建一个栈帧 stack frame(**一个方法一个栈帧**) + +* Java 虚拟机规范允许 **Java 栈的大小是动态的或者是固定不变的** + +* 虚拟机栈是**每个线程私有的**,每个线程只能有一个活动栈帧,对应方法调用到执行完成的整个过程 + +* 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存,每个栈帧中存储着: + + * 局部变量表:存储方法里的 Java 基本数据类型以及对象的引用 + * 动态链接:也叫指向运行时常量池的方法引用 + * 方法返回地址:方法正常退出或者异常退出的定义 + * 操作数栈或表达式栈和其他一些附加信息 + + + +设置栈内存大小:`-Xss size` `-Xss 1024k` + +* 在 JDK 1.4 中默认为 256K,而在 JDK 1.5+ 默认为 1M + +虚拟机栈特点: + +* 栈内存**不需要进行GC**,方法开始执行的时候会进栈,方法调用后自动弹栈,相当于清空了数据 + +* 栈内存分配越大越大,可用的线程数越少(内存越大,每个线程拥有的内存越大) + +* 方法内的局部变量是否**线程安全**: + * 如果方法内局部变量没有逃离方法的作用访问,它是线程安全的(逃逸分析) + * 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全 + +异常: + +* 栈帧过多导致栈内存溢出 (超过了栈的容量),会抛出 OutOfMemoryError 异常 +* 当线程请求的栈深度超过最大值,会抛出 StackOverflowError 异常 + + + +*** + + + +##### 局部变量 + +局部变量表也被称之为局部变量数组或本地变量表,本质上定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量 + +* 表是建立在线程的栈上,是线程私有的数据,因此不存在数据安全问题 +* 表的容量大小是在编译期确定的,保存在方法的 Code 属性的 maximum local variables 数据项中 +* 表中的变量只在当前方法调用中有效,方法结束栈帧销毁,局部变量表也会随之销毁 +* 表中的变量也是重要的垃圾回收根节点,只要被表中数据直接或间接引用的对象都不会被回收 + +局部变量表最基本的存储单元是 **slot(变量槽)**: + +* 参数值的存放总是在局部变量数组的 index0 开始,到数组长度 -1 的索引结束,JVM 为每一个 slot 都分配一个访问索引,通过索引即可访问到槽中的数据 +* 存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress 类型的变量 +* 32 位以内的类型只占一个 slot(包括 returnAddress 类型),64 位的类型(long 和 double)占两个 slot +* 局部变量表中的槽位是可以**重复利用**的,如果一个局部变量过了其作用域,那么之后申明的新的局部变量就可能会复用过期局部变量的槽位,从而达到节省资源的目的 + + + +*** + + + +##### 操作数栈 + +栈:可以使用数组或者链表来实现 + +操作数栈:在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)或出栈(pop) + +* 保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间,是执行引擎的一个工作区 + +* Java 虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈 +* 如果被调用的方法带有返回值的话,其**返回值将会被压入当前栈帧的操作数栈中** + +栈顶缓存技术 ToS(Top-of-Stack Cashing):将栈顶元素全部缓存在 CPU 的寄存器中,以此降低对内存的读/写次数,提升执行的效率 + +基于栈式架构的虚拟机使用的零地址指令更加紧凑,完成一项操作需要使用很多入栈和出栈指令,所以需要更多的指令分派(instruction dispatch)次数和内存读/写次数,由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度,所以需要栈顶缓存技术 + + + +*** + + + +##### 动态链接 + +动态链接是指向运行时常量池的方法引用,涉及到栈操作已经是类加载完成,这个阶段的解析是**动态绑定** + +* 为了支持当前方法的代码能够实现动态链接,每一个栈帧内部都包含一个指向运行时常量池或该栈帧所属方法的引用 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-动态链接符号引用.png) + +* 在 Java 源文件被编译成的字节码文件中,所有的变量和方法引用都作为符号引用保存在 class 的常量池中 + + 常量池的作用:提供一些符号和常量,便于指令的识别 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-动态链接运行时常量池.png) + + + +*** + + + +##### 返回地址 + +Return Address:存放调用该方法的 PC 寄存器的值 + +方法的结束有两种方式:正常执行完成、出现未处理的异常,在方法退出后都返回到该方法被调用的位置 + +* 正常:调用者的 PC 计数器的值作为返回地址,即调用该方法的指令的**下一条指令的地址** +* 异常:返回地址是要通过异常表来确定 + +正常完成出口:执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者 + +异常完成出口:方法执行的过程中遇到了异常(Exception),并且这个异常没有在方法内进行处理,本方法的异常表中没有搜素到匹配的异常处理器,导致方法退出 + +两者区别:通过异常完成出口退出的不会给上层调用者产生任何的返回值 + + + +##### 附加信息 + +栈帧中还允许携带与 Java 虚拟机实现相关的一些附加信息,例如对程序调试提供支持的信息 + + + +*** + + + +#### 本地方法栈 + +本地方法栈是为虚拟机执行本地方法时提供服务的 + +JNI:Java Native Interface,通过使用 Java 本地接口程序,可以确保代码在不同的平台上方便移植 + +* 不需要进行 GC,与虚拟机栈类似,也是线程私有的,有 StackOverFlowError 和 OutOfMemoryError 异常 +* 虚拟机栈执行的是 Java 方法,在 HotSpot JVM 中,直接将本地方法栈和虚拟机栈合二为一 +* 本地方法一般是由其他语言编写,并且被编译为基于本机硬件和操作系统的程序 +* 当某个线程调用一个本地方法时,就进入了不再受虚拟机限制的世界,和虚拟机拥有同样的权限 + + * 本地方法可以通过本地方法接口来**访问虚拟机内部的运行时数据区** + * 直接从本地内存的堆中分配任意数量的内存 + * 可以直接使用本地处理器中的寄存器 + + +原理:将本地的 C 函数(如 foo)编译到一个共享库(foo.so)中,当正在运行的 Java 程序调用 foo 时,Java 解释器利用 dlopen 接口动态链接和加载 foo.so 后再调用该函数 + +* dlopen 函数:Linux 系统加载和链接共享库 +* dlclose 函数:卸载共享库 + + + + + +图片来源:https://github.com/CyC2018/CS-Notes/blob/master/notes/Java%20%E8%99%9A%E6%8B%9F%E6%9C%BA.md + + + +*** + + + +#### 程序计数器 + +Program Counter Register 程序计数器(寄存器) + +作用:内部保存字节码的行号,用于记录正在执行的字节码指令地址(如果正在执行的是本地方法则为空) + +原理: + +* JVM 对于多线程是通过线程轮流切换并且分配线程执行时间,一个处理器只会处理执行一个线程 +* 切换线程需要从程序计数器中来回去到当前的线程上一次执行的行号 + +特点: + +* 是线程私有的 +* **不会存在内存溢出**,是 JVM 规范中唯一一个不出现 OOM 的区域,所以这个空间不会进行 GC + +Java 反编译指令:`javap -v Test.class` + +#20:代表去 Constant pool 查看该地址的指令 + +```java +0: getstatic #20 // PrintStream out = System.out; +3: astore_1 // -- +4: aload_1 // out.println(1); +5: iconst_1 // -- +6: invokevirtual #26 // -- +9: aload_1 // out.println(2); +10: iconst_2 // -- +11: invokevirtual #26 // -- +``` + + + +**** + + + +#### 堆 + +Heap 堆:是 JVM 内存中最大的一块,由所有线程共享,由垃圾回收器管理的主要区域,堆中对象大部分都需要考虑线程安全的问题 + +存放哪些资源: + +* 对象实例:类初始化生成的对象,**基本数据类型的数组也是对象实例**,new 创建对象都使用堆内存 +* 字符串常量池: + * 字符串常量池原本存放于方法区,JDK7 开始放置于堆中 + * 字符串常量池**存储的是 String 对象的直接引用或者对象**,是一张 string table +* 静态变量:静态变量是有 static 修饰的变量,JDK8 时从方法区迁移至堆中 +* 线程分配缓冲区 Thread Local Allocation Buffer:线程私有但不影响堆的共性,可以提升对象分配的效率 + +设置堆内存指令:`-Xmx Size` + +内存溢出:new 出对象,循环添加字符数据,当堆中没有内存空间可分配给实例,也无法再扩展时,就会抛出 OutOfMemoryError 异常 + +堆内存诊断工具:(控制台命令) + +1. jps:查看当前系统中有哪些 Java 进程 +2. jmap:查看堆内存占用情况 `jhsdb jmap --heap --pid 进程id` +3. jconsole:图形界面的,多功能的监测工具,可以连续监测 + +在 Java7 中堆内会存在**年轻代、老年代和方法区(永久代)**: + +* Young 区被划分为三部分,Eden 区和两个大小严格相同的 Survivor 区。Survivor 区某一时刻只有其中一个是被使用的,另外一个留做垃圾回收时复制对象。在 Eden 区变满的时候,GC 就会将存活的对象移到空闲的 Survivor 区间中,根据 JVM 的策略,在经过几次垃圾回收后,仍然存活于 Survivor 的对象将被移动到 Tenured 区间 +* Tenured 区主要保存生命周期长的对象,一般是一些老的对象,当一些对象在 Young 复制转移一定的次数以后,对象就会被转移到 Tenured 区 +* Perm 代主要保存 Class、ClassLoader、静态变量、常量、编译后的代码,在 Java7 中堆内方法区会受到 GC 的管理 + +分代原因:不同对象的生命周期不同,70%-99% 的对象都是临时对象,优化 GC 性能 + +```java +public static void main(String[] args) { + // 返回Java虚拟机中的堆内存总量 + long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024; + // 返回Java虚拟机使用的最大堆内存量 + long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024; + + System.out.println("-Xms : " + initialMemory + "M");//-Xms : 245M + System.out.println("-Xmx : " + maxMemory + "M");//-Xmx : 3641M +} +``` + + + +*** + + + +#### 方法区 + +方法区:是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、即时编译器编译后的代码等数据,虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是也叫 Non-Heap(非堆) + +方法区是一个 JVM 规范,**永久代与元空间都是其一种实现方式** + +方法区的大小不必是固定的,可以动态扩展,加载的类太多,可能导致永久代内存溢出 (OutOfMemoryError) + +方法区的 GC:针对常量池的回收及对类型的卸载,比较难实现 + +为了**避免方法区出现 OOM**,在 JDK8 中将堆内的方法区(永久代)移动到了本地内存上,重新开辟了一块空间,叫做元空间,元空间存储类的元信息,**静态变量和字符串常量池等放入堆中** + +类元信息:在类编译期间放入方法区,存放了类的基本信息,包括类的方法、参数、接口以及常量池表 + +常量池表(Constant Pool Table)是 Class 文件的一部分,存储了**类在编译期间生成的字面量、符号引用**,JVM 为每个已加载的类维护一个常量池 + +- 字面量:基本数据类型、字符串类型常量、声明为 final 的常量值等 +- 符号引用:类、字段、方法、接口等的符号引用 + +运行时常量池是方法区的一部分 + +* 常量池(编译器生成的字面量和符号引用)中的数据会在类加载的加载阶段放入运行时常量池 +* 类在解析阶段将这些符号引用替换成直接引用 +* 除了在编译期生成的常量,还允许动态生成,例如 String 类的 intern() + + + +*** + + + +### 本地内存 + +#### 基本介绍 + +虚拟机内存:Java 虚拟机在执行的时候会把管理的内存分配成不同的区域,受虚拟机内存大小的参数控制,当大小超过参数设置的大小时就会报 OOM + +本地内存:又叫做**堆外内存**,线程共享的区域,本地内存这块区域是不会受到 JVM 的控制的,不会发生 GC;因此对于整个 Java 的执行效率是提升非常大,但是如果内存的占用超出物理内存的大小,同样也会报 OOM + +本地内存概述图: + + + + + +*** + + + +#### 元空间 + +PermGen 被元空间代替,永久代的**类信息、方法、常量池**等都移动到元空间区 + +元空间与永久代区别:元空间不在虚拟机中,使用的本地内存,默认情况下,元空间的大小仅受本地内存限制 + +方法区内存溢出: + +* JDK1.8 以前会导致永久代内存溢出:java.lang.OutOfMemoryError: PerGen space + + ```sh + -XX:MaxPermSize=8m #参数设置 + ``` + +* JDK1.8 以后会导致元空间内存溢出:java.lang.OutOfMemoryError: Metaspace + + ```sh + -XX:MaxMetaspaceSize=8m #参数设置 + ``` + +元空间内存溢出演示: + +```java +public class Demo1_8 extends ClassLoader { // 可以用来加载类的二进制字节码 + public static void main(String[] args) { + int j = 0; + try { + Demo1_8 test = new Demo1_8(); + for (int i = 0; i < 10000; i++, j++) { + // ClassWriter 作用是生成类的二进制字节码 + ClassWriter cw = new ClassWriter(0); + // 版本号, public, 类名, 包名, 父类, 接口 + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null); + // 返回 byte[] + byte[] code = cw.toByteArray(); + // 执行了类的加载 + test.defineClass("Class" + i, code, 0, code.length); // Class 对象 + } + } finally { + System.out.println(j); + } + } +} +``` + + + +*** + + + +#### 直接内存 + +直接内存是 Java 堆外、直接向系统申请的内存区间,不是虚拟机运行时数据区的一部分,也不是《Java 虚拟机规范》中定义的内存区域 + + + +直接内存详解参考:NET → NIO → 直接内存 + + + +*** + + + +### 变量位置 + +变量的位置不取决于它是基本数据类型还是引用数据类型,取决于它的**声明位置** + +静态内部类和其他内部类: + +* **一个 class 文件只能对应一个 public 类型的类**,这个类可以有内部类,但不会生成新的 class 文件 + +* 静态内部类属于类本身,加载到方法区,其他内部类属于内部类的属性,加载到堆(待考证) + +类变量: + +* 类变量是用 static 修饰符修饰,定义在方法外的变量,随着 Java 进程产生和销毁 +* 在 Java8 之前把静态变量存放于方法区,在 Java8 时存放在堆中的静态变量区 + + +实例变量: + +* 实例(成员)变量是定义在类中,没有 static 修饰的变量,随着类的实例产生和销毁,是类实例的一部分 +* 在类初始化的时候,从运行时常量池取出直接引用或者值,**与初始化的对象一起放入堆中** + +局部变量: + +* 局部变量是定义在类的方法中的变量 +* 在所在方法被调用时**放入虚拟机栈的栈帧**中,方法执行结束后从虚拟机栈中弹出, + +类常量池、运行时常量池、字符串常量池有什么关系?有什么区别? + +* 类常量池与运行时常量池都存储在方法区,而字符串常量池在 Jdk7 时就已经从方法区迁移到了 Java 堆中 +* 在类编译过程中,会把类元信息放到方法区,类元信息的其中一部分便是类常量池,主要存放字面量和符号引用,而字面量的一部分便是文本字符 +* **在类加载时将字面量和符号引用解析为直接引用存储在运行时常量池** +* 对于文本字符,会在解析时查找字符串常量池,查出这个文本字符对应的字符串对象的直接引用,将直接引用存储在运行时常量池 + +什么是字面量?什么是符号引用? + +* 字面量:java 代码在编译过程中是无法构建引用的,字面量就是在编译时对于数据的一种表示 + + ```java + int a = 1; //这个1便是字面量 + String b = "iloveu"; //iloveu便是字面量 + ``` + +* 符号引用:在编译过程中并不知道每个类的地址,因为可能这个类还没有加载,如果在一个类中引用了另一个类,无法知道它的内存地址,只能用它的类名作为符号引用,在类加载完后用这个符号引用去获取内存地址 + + + + +*** + + + + + +## 内存管理 + +### 内存分配 + +#### 两种方式 + +不分配内存的对象无法进行其他操作,JVM 为对象分配内存的过程:首先计算对象占用空间大小,接着在堆中划分一块内存给新对象 + +* 如果内存规整,使用指针碰撞(Bump The Pointer)。所有用过的内存在一边,空闲的内存在另外一边,中间有一个指针作为分界点的指示器,分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离 +* 如果内存不规整,虚拟机需要维护一个空闲列表(Free List)分配。已使用的内存和未使用的内存相互交错,虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容 + + + +*** + + + +#### TLAB + +TLAB:Thread Local Allocation Buffer,为每个线程在堆内单独分配了一个缓冲区,多线程分配内存时,使用 TLAB 可以避免线程安全问题,同时还能够提升内存分配的吞吐量,这种内存分配方式叫做**快速分配策略** + +- 栈上分配使用的是栈来进行对象内存的分配 +- TLAB 分配使用的是 Eden 区域进行内存分配,属于堆内存 + +堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据,由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度 + +问题:堆空间都是共享的么? 不一定,因为还有 TLAB,在堆中划分出一块区域,为每个线程所独占 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-TLAB内存分配策略.jpg) + +JVM 是将 TLAB 作为内存分配的首选,但不是所有的对象实例都能够在 TLAB 中成功分配内存,一旦对象在 TLAB 空间分配内存失败时,JVM 就会通过**使用加锁机制确保数据操作的原子性**,从而直接在堆中分配内存 + +栈上分配优先于 TLAB 分配进行,逃逸分析中若可进行栈上分配优化,会优先进行对象栈上直接分配内存 + +参数设置: + +* `-XX:UseTLAB`:设置是否开启 TLAB 空间 + +* `-XX:TLABWasteTargetPercent`:设置 TLAB 空间所占用 Eden 空间的百分比大小,默认情况下 TLAB 空间的内存非常小,仅占有整个 Eden 空间的1% +* `-XX:TLABRefillWasteFraction`:指当 TLAB 空间不足,请求分配的对象内存大小超过此阈值时不会进行 TLAB 分配,直接进行堆内存分配,否则还是会优先进行 TLAB 分配 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-TLAB内存分配过程.jpg) + + + +*** + + + +#### 逃逸分析 + +即时编译(Just-in-time Compilation,JIT)是一种通过在运行时将字节码翻译为机器码,从而改善性能的技术,在 HotSpot 实现中有多种选择:C1、C2 和 C1+C2,分别对应 Client、Server 和分层编译 + +* C1 编译速度快,优化方式比较保守;C2 编译速度慢,优化方式比较激进 +* C1+C2 在开始阶段采用 C1 编译,当代码运行到一定热度之后采用 C2 重新编译 + +逃逸分析并不是直接的优化手段,而是一个代码分析方式,通过动态分析对象的作用域,为优化手段如栈上分配、标量替换和同步消除等提供依据,发生逃逸行为的情况有两种:方法逃逸和线程逃逸 + +* 方法逃逸:当一个对象在方法中定义之后,被外部方法引用 + * 全局逃逸:一个对象的作用范围逃出了当前方法或者当前线程,比如对象是一个静态变量、全局变量赋值、已经发生逃逸的对象、作为当前方法的返回值 + * 参数逃逸:一个对象被作为方法参数传递或者被参数引用 +* 线程逃逸:如类变量或实例变量,可能被其它线程访问到 + +如果不存在逃逸行为,则可以对该对象进行如下优化:同步消除、标量替换和栈上分配 + +* 同步消除 + + 线程同步本身比较耗时,如果确定一个对象不会逃逸出线程,不被其它线程访问到,那对象的读写就不会存在竞争,则可以消除对该对象的**同步锁**,通过 `-XX:+EliminateLocks` 可以开启同步消除 ( - 号关闭) + +* 标量替换 + + * 标量替换:如果把一个对象拆散,将其成员变量恢复到基本类型来访问 + * 标量 (scalar) :不可分割的量,如基本数据类型和 reference 类型 + + 聚合量 (Aggregate):一个数据可以继续分解,对象一般是聚合量 + * 如果逃逸分析发现一个对象不会被外部访问,并且该对象可以被拆散,那么经过优化之后,并不直接生成该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替 + * 参数设置: + + * `-XX:+EliminateAllocations`:开启标量替换 + * `-XX:+PrintEliminateAllocations`:查看标量替换情况 + +* 栈上分配 + + JIT 编译器在编译期间根据逃逸分析的结果,如果一个对象没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收,这样就无需 GC + + User 对象的作用域局限在方法 fn 中,可以使用标量替换的优化手段在栈上分配对象的成员变量,这样就不会生成 User 对象,大大减轻 GC 的压力 + + ```java + public class JVM { + public static void main(String[] args) throws Exception { + int sum = 0; + int count = 1000000; + //warm up + for (int i = 0; i < count ; i++) { + sum += fn(i); + } + System.out.println(sum); + System.in.read(); + } + private static int fn(int age) { + User user = new User(age); + int i = user.getAge(); + return i; + } + } + + class User { + private final int age; + + public User(int age) { + this.age = age; + } + + public int getAge() { + return age; + } + } + ``` + + + + +*** + + + +#### 分代思想 + +##### 分代介绍 + +Java8 时,堆被分为了两份:新生代和老年代(1:2),在 Java7 时,还存在一个永久代 + +- 新生代使用:复制算法 +- 老年代使用:标记 - 清除 或者 标记 - 整理 算法 + +**Minor GC 和 Full GC**: + +- Minor GC:回收新生代,新生代对象存活时间很短,所以 Minor GC 会频繁执行,执行的速度比较快 +- Full GC:回收老年代和新生代,老年代对象其存活时间长,所以 Full GC 很少执行,执行速度会比 Minor GC 慢很多 + + Eden 和 Survivor 大小比例默认为 8:1:1 + + + + + + + +*** + + + +##### 分代分配 + +工作机制: + +* **对象优先在 Eden 分配**:当创建一个对象的时候,对象会被分配在新生代的 Eden 区,当 Eden 区要满了时候,触发 YoungGC +* 当进行 YoungGC 后,此时在 Eden 区存活的对象被移动到 to 区,并且当前对象的年龄会加 1,清空 Eden 区 +* 当再一次触发 YoungGC 的时候,会把 Eden 区中存活下来的对象和 to 中的对象,移动到 from 区中,这些对象的年龄会加 1,清空 Eden 区和 to 区 +* To 区永远是空 Survivor 区,From 区是有数据的,每次 MinorGC 后两个区域互换 +* From 区和 To 区 也可以叫做 S0 区和 S1 区 + +晋升到老年代: + +* **长期存活的对象进入老年代**:为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中 + + `-XX:MaxTenuringThreshold`:定义年龄的阈值,对象头中用 4 个 bit 存储,所以最大值是 15,默认也是 15 + +* **大对象直接进入老年代**:需要连续内存空间的对象,最典型的大对象是很长的字符串以及数组;避免在 Eden 和 Survivor 之间的大量复制;经常出现大对象会提前触发 GC 以获取足够的连续空间分配给大对象 + + `-XX:PretenureSizeThreshold`:大于此值的对象直接在老年代分配 + +* **动态对象年龄判定**:如果在 Survivor 区中相同年龄的对象的所有大小之和超过 Survivor 空间的一半,年龄大于等于该年龄的对象就可以直接进入老年代 + +空间分配担保: + +* 在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的 +* 如果不成立,虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于将尝试着进行一次 Minor GC;如果小于或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC + + + + + +*** + + + +### 回收策略 + +#### 触发条件 + +内存垃圾回收机制主要集中的区域就是线程共享区域:**堆和方法区** + +Minor GC 触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC + +FullGC 同时回收新生代、老年代和方法区,只会存在一个 FullGC 的线程进行执行,其他的线程全部会被**挂起**,有以下触发条件: + +* 调用 System.gc(): + + * 在默认情况下,通过 System.gc() 或 Runtime.getRuntime().gc() 的调用,会显式触发 FullGC,同时对老年代和新生代进行回收,但是虚拟机不一定真正去执行,无法保证对垃圾收集器的调用 + * 不建议使用这种方式,应该让虚拟机管理内存。一般情况下,垃圾回收应该是自动进行的,无须手动触发;在一些特殊情况下,如正在编写一个性能基准,可以在运行之间调用 System.gc() + +* 老年代空间不足: + + * 为了避免引起的 Full GC,应当尽量不要创建过大的对象以及数组 + * 通过 -Xmn 参数调整新生代的大小,让对象尽量在新生代被回收掉不进入老年代,可以通过 `-XX:MaxTenuringThreshold` 调大对象进入老年代的年龄,让对象在新生代多存活一段时间 + +* 空间分配担保失败 + +* JDK 1.7 及以前的永久代(方法区)空间不足 + +* Concurrent Mode Failure:执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC + + +手动 GC 测试,VM参数:`-XX:+PrintGcDetails` + +```java +public void localvarGC1() { + byte[] buffer = new byte[10 * 1024 * 1024];//10MB + System.gc(); //输出: 不会被回收, FullGC时被放入老年代 +} + +public void localvarGC2() { + byte[] buffer = new byte[10 * 1024 * 1024]; + buffer = null; + System.gc(); //输出: 正常被回收 +} + public void localvarGC3() { + { + byte[] buffer = new byte[10 * 1024 * 1024]; + } + System.gc(); //输出: 不会被回收, FullGC时被放入老年代 + } + +public void localvarGC4() { + { + byte[] buffer = new byte[10 * 1024 * 1024]; + } + int value = 10; + System.gc(); //输出: 正常被回收,slot复用,局部变量过了其作用域 buffer置空 +} +``` + + + +*** + + + +#### 安全区域 + +安全点 (Safepoint):程序执行时并非在所有地方都能停顿下来开始 GC,只有在安全点才能停下 + +- Safe Point 的选择很重要,如果太少可能导致 GC 等待的时间太长,如果太多可能导致运行时的性能问题 +- 大部分指令的执行时间都非常短,通常会根据是否具有让程序长时间执行的特征为标准,选择些执行时间较长的指令作为 Safe Point, 如方法调用、循环跳转和异常跳转等 + +在 GC 发生时,让所有线程都在最近的安全点停顿下来的方法: + +- 抢先式中断:没有虚拟机采用,首先中断所有线程,如果有线程不在安全点,就恢复线程让线程运行到安全点 +- 主动式中断:设置一个中断标志,各个线程运行到各个 Safe Point 时就轮询这个标志,如果中断标志为真,则将自己进行中断挂起 + +问题:Safepoint 保证程序执行时,在不太长的时间内就会遇到可进入 GC 的 Safepoint,但是当线程处于 Waiting 状态或 Blocked 状态,线程无法响应 JVM 的中断请求,运行到安全点去中断挂起,JVM 也不可能等待线程被唤醒,对于这种情况,需要安全区域来解决 + +安全区域 (Safe Region):指在一段代码片段中,**对象的引用关系不会发生变化**,在这个区域中的任何位置开始 GC 都是安全的 + +运行流程: + +- 当线程运行到 Safe Region 的代码时,首先标识已经进入了 Safe Region,如果这段时间内发生 GC,JVM 会忽略标识为 Safe Region 状态的线程 + +- 当线程即将离开 Safe Region 时,会检查 JVM 是否已经完成 GC,如果完成了则继续运行,否则线程必须等待 GC 完成,收到可以安全离开 SafeRegion 的信号 + + + +*** + + + +### 垃圾判断 + +#### 垃圾介绍 + +垃圾:**如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾** + +作用:释放没用的对象,清除内存里的记录碎片,碎片整理将所占用的堆内存移到堆的一端,以便 JVM 将整理出的内存分配给新的对象 + +垃圾收集主要是针对堆和方法区进行,程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后就会消失,因此不需要对这三个区域进行垃圾回收 + +在堆里存放着几乎所有的 Java 对象实例,在 GC 执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,GC 才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程可以称为垃圾标记阶段,判断对象存活一般有两种方式:**引用计数算法**和**可达性分析算法** + + + +*** + + + +#### 引用计数法 + +引用计数算法(Reference Counting):对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况。对于一个对象 A,只要有任何一个对象引用了 A,则 A 的引用计数器就加 1;当引用失效时,引用计数器就减 1;当对象 A 的引用计数器的值为 0,即表示对象A不可能再被使用,可进行回收(Java 没有采用) + +优点: + +- 回收没有延迟性,无需等到内存不够的时候才开始回收,运行时根据对象计数器是否为 0,可以直接回收 +- 在垃圾回收过程中,应用无需挂起;如果申请内存时,内存不足,则立刻报 OOM 错误 +- 区域性,更新对象的计数器时,只是影响到该对象,不会扫描全部对象 + +缺点: + +- 每次对象被引用时,都需要去更新计数器,有一点时间开销 + +- 浪费 CPU 资源,即使内存够用,仍然在运行时进行计数器的统计。 + +- **无法解决循环引用问题,会引发内存泄露**(最大的缺点) + + ```java + public class Test { + public Object instance = null; + public static void main(String[] args) { + Test a = new Test();// a = 1 + Test b = new Test();// b = 1 + a.instance = b; // b = 2 + b.instance = a; // a = 2 + a = null; // a = 1 + b = null; // b = 1 + } + } + ``` + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-循环引用.png) + + + +*** + + + +#### 可达性分析 + +##### GC Roots + +可达性分析算法:也可以称为根搜索算法、追踪性垃圾收集 + +GC Roots 对象: + +- 虚拟机栈中局部变量表中引用的对象:各个线程被调用的方法中使用到的参数、局部变量等 +- 本地方法栈中引用的对象 +- 堆中类静态属性引用的对象 +- 方法区中的常量引用的对象 +- 字符串常量池(string Table)里的引用 +- 同步锁 synchronized 持有的对象 + +**GC Roots 是一组活跃的引用,不是对象**,放在 GC Roots Set 集合 + + + +*** + + + +##### 工作原理 + +可达性分析算法以根对象集合(GCRoots)为起始点,从上至下的方式搜索被根对象集合所连接的目标对象 + +分析工作必须在一个保障**一致性的快照**中进行,否则结果的准确性无法保证,这也是导致 GC 进行时必须 Stop The World 的一个原因 + +基本原理: + +- 可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索走过的路径称为引用链 + +- 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象 + +- 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象 + + + + + +*** + + + +##### 三色标记 + +###### 标记算法 + +三色标记法把遍历对象图过程中遇到的对象,标记成以下三种颜色: + +- 白色:尚未访问过 +- 灰色:本对象已访问过,但是本对象引用到的其他对象尚未全部访问 +- 黑色:本对象已访问过,而且本对象引用到的其他对象也全部访问完成 + +当 Stop The World (STW) 时,对象间的引用是不会发生变化的,可以轻松完成标记,遍历访问过程为: + +1. 初始时,所有对象都在白色集合 +2. 将 GC Roots 直接引用到的对象挪到灰色集合 +3. 从灰色集合中获取对象: + * 将本对象引用到的其他对象全部挪到灰色集合中 + * 将本对象挪到黑色集合里面 +4. 重复步骤 3,直至灰色集合为空时结束 +5. 结束后,仍在白色集合的对象即为 GC Roots 不可达,可以进行回收 + + + + + +参考文章:https://www.jianshu.com/p/12544c0ad5c1 + + + +**** + + + +###### 并发标记 + +并发标记时,对象间的引用可能发生变化,多标和漏标的情况就有可能发生 + +**多标情况:**当 E 变为灰色或黑色时,其他线程断开的 D 对 E 的引用,导致这部分对象仍会被标记为存活,本轮 GC 不会回收这部分内存,这部分本应该回收但是没有回收到的内存,被称之为**浮动垃圾** + +* 针对并发标记开始后的**新对象**,通常的做法是直接全部当成黑色,也算浮动垃圾 +* 浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除 + + + +**漏标情况:** + +* 条件一:灰色对象断开了对一个白色对象的引用(直接或间接),即灰色对象原成员变量的引用发生了变化 +* 条件二:其他线程中修改了黑色对象,插入了一条或多条对该白色对象的新引用 +* 结果:导致该白色对象当作垃圾被 GC,影响到了程序的正确性 + + + +代码角度解释漏标: + +```java +Object G = objE.fieldG; // 读 +objE.fieldG = null; // 写 +objD.fieldG = G; // 写 +``` + +为了解决问题,可以操作上面三步,**将对象 G 记录起来,然后作为灰色对象再进行遍历**,比如放到一个特定的集合,等初始的 GC Roots 遍历完(并发标记),再遍历该集合(重新标记) + +> 所以**重新标记需要 STW**,应用程序一直在运行,该集合可能会一直增加新的对象,导致永远都运行不完 + +解决方法:添加读写屏障,读屏障拦截第一步,写屏障拦截第二三步,在读写前后进行一些后置处理: + +* **写屏障 + 增量更新**:黑色对象新增引用,会将黑色对象变成灰色对象,最后对该节点重新扫描 + + 增量更新 (Incremental Update) 破坏了条件二,从而保证了不会漏标 + + 缺点:对黑色变灰的对象重新扫描所有引用,比较耗费时间 + +* **写屏障 (Store Barrier) + SATB**:当原来成员变量的引用发生变化之前,记录下原来的引用对象 + + 保留 GC 开始时的对象图,即原始快照 SATB,当 GC Roots 确定后,对象图就已经确定,那后续的标记也应该是按照这个时刻的对象图走,如果期间对白色对象有了新的引用会记录下来,并且将白色对象变灰(说明可达了,并且原始快照中本来就应该是灰色对象),最后重新扫描该对象的引用关系 + + SATB (Snapshot At The Beginning) 破坏了条件一,从而保证了不会漏标 + +* **读屏障 (Load Barrier)**:破坏条件二,黑色对象引用白色对象的前提是获取到该对象,此时读屏障发挥作用 + +以 Java HotSpot VM 为例,其并发标记时对漏标的处理方案如下: + +- CMS:写屏障 + 增量更新 +- G1:写屏障 + SATB +- ZGC:读屏障 + + + +*** + + + +#### finalization + +Java 语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑 + +垃圾回收此对象之前,会先调用这个对象的 finalize() 方法,finalize() 方法允许在子类中被重写,用于在对象被回收时进行后置处理,通常在这个方法中进行一些资源释放和清理,比如关闭文件、套接字和数据库连接等 + +生存 OR 死亡:如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用,此对象需要被回收。但事实上这时候它们暂时处于缓刑阶段。**一个无法触及的对象有可能在某个条件下复活自己**,所以虚拟机中的对象可能的三种状态: + +- 可触及的:从根节点开始,可以到达这个对象 +- 可复活的:对象的所有引用都被释放,但是对象有可能在 finalize() 中复活 +- 不可触及的:对象的 finalize() 被调用并且没有复活,那么就会进入不可触及状态,不可触及的对象不可能被复活,因为 **finalize() 只会被调用一次**,等到这个对象再被标记为可回收时就必须回收 + +永远不要主动调用某个对象的 finalize() 方法,应该交给垃圾回收机制调用,原因: + +* finalize() 时可能会导致对象复活 +* finalize() 方法的执行时间是没有保障的,完全由 GC 线程决定,极端情况下,若不发生 GC,则 finalize() 方法将没有执行机会,因为优先级比较低,即使主动调用该方法,也不会因此就直接进行回收 +* 一个糟糕的 finalize() 会严重影响 GC 的性能 + + + +*** + + + +#### 引用分析 + +无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关,Java 提供了四种强度不同的引用类型 + +1. 强引用:被强引用关联的对象不会被回收,只有所有 GCRoots 都不通过强引用引用该对象,才能被垃圾回收 + + * 强引用可以直接访问目标对象 + * 虚拟机宁愿抛出 OOM 异常,也不会回收强引用所指向对象 + * 强引用可能导致**内存泄漏** + + ```java + Object obj = new Object();//使用 new 一个新对象的方式来创建强引用 + ``` + +2. 软引用(SoftReference):被软引用关联的对象只有在内存不够的情况下才会被回收 + + * **仅(可能有强引用,一个对象可以被多个引用)**有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次出发垃圾回收,回收软引用对象 + * 配合**引用队列来释放软引用自身**,在构造软引用时,可以指定一个引用队列,当软引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况 + * 软引用通常用来实现内存敏感的缓存,比如高速缓存就有用到软引用;如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时不会耗尽内存 + + ```java + Object obj = new Object(); + SoftReference sf = new SoftReference(obj); + obj = null; // 使对象只被软引用关联 + ``` + +3. 弱引用(WeakReference):被弱引用关联的对象一定会被回收,只能存活到下一次垃圾回收发生之前 + + * 仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象 + * 配合引用队列来释放弱引用自身 + * WeakHashMap 用来存储图片信息,可以在内存不足的时候及时回收,避免了 OOM + + ```java + Object obj = new Object(); + WeakReference wf = new WeakReference(obj); + obj = null; + ``` + +4. 虚引用(PhantomReference):也称为幽灵引用或者幻影引用,是所有引用类型中最弱的一个 + + * 一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象 + * 为对象设置虚引用的唯一目的是在于跟踪垃圾回收过程,能在这个对象被回收时收到一个系统通知 + * 必须配合引用队列使用,主要配合 ByteBuffer 使用,被引用对象回收时会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存 + + ```java + Object obj = new Object(); + PhantomReference pf = new PhantomReference(obj, null); + obj = null; + ``` + +5. 终结器引用(finalization) + + + +*** + + + +#### 无用属性 + +##### 无用类 + +方法区主要回收的是无用的类 + +判定一个类是否是无用的类,需要同时满足下面 3 个条件: + +- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例 +- 加载该类的 `ClassLoader` 已经被回收 +- 该类对应的 `java.lang.Class` 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法 + +虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的**仅仅是可以**,而并不是和对象一样不使用了就会必然被回收 + + + +*** + + + +##### 废弃常量 + +在常量池中存在字符串 "abc",如果当前没有任何 String 对象引用该常量,说明常量 "abc" 是废弃常量,如果这时发生内存回收的话**而且有必要的话**(内存不够用),"abc" 就会被系统清理出常量池 + + + +*** + + + +##### 静态变量 + +类加载时(第一次访问),这个类中所有静态成员就会被加载到静态变量区,该区域的成员一旦创建,直到程序退出才会被回收 + +如果是静态引用类型的变量,静态变量区只存储一份对象的引用地址,真正的对象在堆内,如果要回收该对象可以设置引用为 null + + + +参考文章:https://blog.csdn.net/zhengzhb/article/details/7331354 + + + +*** + + + +### 回收算法 + +#### 复制算法 + +复制算法的核心就是,**将原有的内存空间一分为二,每次只用其中的一块**,在垃圾回收时,将正在使用的对象复制到另一个内存空间中,然后将该内存空间清理,交换两个内存的角色,完成垃圾的回收 + +应用场景:如果内存中的垃圾对象较多,需要复制的对象就较少,这种情况下适合使用该方式并且效率比较高,反之则不适合 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-复制算法.png) + +算法优点: + +- 没有标记和清除过程,实现简单,运行速度快 +- 复制过去以后保证空间的连续性,不会出现碎片问题 + +算法缺点: + +- 主要不足是**只使用了内存的一半** +- 对于 G1 这种分拆成为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象引用关系,不管是内存占用或者时间开销都不小 + +现在的商业虚拟机都采用这种收集算法**回收新生代**,因为新生代 GC 频繁并且对象的存活率不高,但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间 + + + +*** + + + +#### 标记清除 + +标记清除算法,是将垃圾回收分为两个阶段,分别是**标记和清除** + +- **标记**:Collector 从引用根节点开始遍历,标记所有被引用的对象,一般是在对象的 Header 中记录为可达对象,**标记的是引用的对象,不是垃圾** +- **清除**:Collector 对堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header 中没有标记为可达对象,则将其回收,把分块连接到**空闲列表**的单向链表,判断回收后的分块与前一个空闲分块是否连续,若连续会合并这两个分块,之后进行分配时只需要遍历这个空闲列表,就可以找到分块 + +- **分配阶段**:程序会搜索空闲链表寻找空间大于等于新对象大小 size 的块 block,如果找到的块等于 size,会直接返回这个分块;如果找到的块大于 size,会将块分割成大小为 size 与 block - size 的两部分,返回大小为 size 的分块,并把大小为 block - size 的块返回给空闲列表 + +算法缺点: + +- 标记和清除过程效率都不高 +- 会产生大量不连续的内存碎片,导致无法给大对象分配内存,需要维护一个空闲链表 + + + + + +*** + + + +#### 标记整理 + +标记整理(压缩)算法是在标记清除算法的基础之上,做了优化改进的算法 + +标记阶段和标记清除算法一样,也是从根节点开始,对对象的引用进行标记,在清理阶段,并不是简单的直接清理可回收对象,而是**将存活对象都向内存另一端移动**,然后清理边界以外的垃圾,从而**解决了碎片化**的问题 + +优点:不会产生内存碎片 + +缺点:需要移动大量对象,处理效率比较低 + + + +| | Mark-Sweep | Mark-Compact | Copying | +| -------- | ------------------ | ---------------- | --------------------------------------- | +| 速度 | 中等 | 最慢 | 最快 | +| 空间开销 | 少(但会堆积碎片) | 少(不堆积碎片) | 通常需要活对象的 2 倍大小(不堆积碎片) | +| 移动对象 | 否 | 是 | 是 | + + + + + +*** + + + +### 垃圾回收器 + +#### 概述 + +垃圾收集器分类: + +* 按线程数分(垃圾回收线程数),可以分为串行垃圾回收器和并行垃圾回收器 + * 除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行 +* 按照工作模式分,可以分为并发式垃圾回收器和独占式垃圾回收器 + * 并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间 + * 独占式垃圾回收器(Stop the world)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束 +* 按碎片处理方式分,可分为压缩式垃圾回收器和非压缩式垃圾回收器 + * 压缩式垃圾回收器在回收完成后进行压缩整理,消除回收后的碎片,再分配对象空间使用指针碰撞 + * 非压缩式的垃圾回收器不进行这步操作,再分配对象空间使用空闲列表 +* 按工作的内存区间分,又可分为年轻代垃圾回收器和老年代垃圾回收器 + +GC 性能指标: + +- **吞吐量**:程序的运行时间占总运行时间的比例(总运行时间 = 程序的运行时间 + 内存回收的时间) +- 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例 +- 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间 +- 收集频率:相对于应用程序的执行,收集操作发生的频率 +- 内存占用:Java 堆区所占的内存大小 +- 快速:一个对象从诞生到被回收所经历的时间 + +**垃圾收集器的组合关系**: + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-垃圾回收器关系图.png) + +新生代收集器:Serial、ParNew、Parallel Scavenge + +老年代收集器:Serial old、Parallel old、CMS + +整堆收集器:G1 + +* 红色虚线在 JDK9 移除、绿色虚线在 JDK14 弃用该组合、青色虚线在 JDK14 删除 CMS 垃圾回收器 + +查看默认的垃圾收回收器: + +* `-XX:+PrintcommandLineFlags`:查看命令行相关参数(包含使用的垃圾收集器) + +* 使用命令行指令:jinfo -flag 相关垃圾回收器参数 进程 ID + + + +*** + + + +#### Serial + +Serial:串行垃圾收集器,作用于新生代,是指使用单线程进行垃圾回收,采用**复制算法**,新生代基本都是复制算法 + +**STW(Stop-The-World)**:垃圾回收时,只有一个线程在工作,并且 Java 应用中的所有线程都要暂停,等待垃圾回收的完成 + +**Serial old**:执行老年代垃圾回收的串行收集器,内存回收算法使用的是**标记-整理算法**,同样也采用了串行回收和 STW 机制 + +- Serial old 是 Client 模式下默认的老年代的垃圾回收器 +- Serial old 在 Server 模式下主要有两个用途: + - 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用 + - 作为老年代 CMS 收集器的**后备垃圾回收方案**,在并发收集发生 Concurrent Mode Failure 时使用 + +开启参数:`-XX:+UseSerialGC` 等价于新生代用 Serial GC 且老年代用 Serial old GC + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-Serial收集器.png) + +优点:简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,可以获得最高的单线程收集效率 + +缺点:对于交互性较强的应用而言,这种垃圾收集器是不能够接受的,比如 JavaWeb 应用 + + + +**** + + + +#### ParNew + +Par 是 Parallel 并行的缩写,New 是只能处理的是新生代 + +并行垃圾收集器在串行垃圾收集器的基础之上做了改进,**采用复制算法**,将单线程改为了多线程进行垃圾回收,可以缩短垃圾回收的时间 + +对于其他的行为(收集算法、stop the world、对象分配规则、回收策略等)同 Serial 收集器一样,应用在年轻代,除 Serial 外,只有**ParNew GC 能与 CMS 收集器配合工作** + +相关参数: + +* `-XX:+UseParNewGC`:表示年轻代使用并行收集器,不影响老年代 + +* `-XX:ParallelGCThreads`:默认开启和 CPU 数量相同的线程数 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-ParNew收集器.png) + +ParNew 是很多 JVM 运行在 Server 模式下新生代的默认垃圾收集器 + +- 对于新生代,回收次数频繁,使用并行方式高效 +- 对于老年代,回收次数少,使用串行方式节省资源(CPU 并行需要切换线程,串行可以省去切换线程的资源) + + + +*** + + + +#### Parallel + +Parallel Scavenge 收集器是应用于新生代的并行垃圾回收器,**采用复制算法**、并行回收和 Stop the World 机制 + +Parallel Old 收集器:是一个应用于老年代的并行垃圾回收器,**采用标记-整理算法** + +对比其他回收器: + +* 其它收集器目标是尽可能缩短垃圾收集时用户线程的停顿时间 +* Parallel 目标是达到一个可控制的吞吐量,被称为**吞吐量优先**收集器 +* Parallel Scavenge 对比 ParNew 拥有**自适应调节策略**,可以通过一个开关参数打开 GC Ergonomics + +应用场景: + +* 停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验 +* 高吞吐量可以高效率地利用 CPU 时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互 + +停顿时间和吞吐量的关系:新生代空间变小 → 缩短停顿时间 → 垃圾回收变得频繁 → 导致吞吐量下降 + +在注重吞吐量及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge + Parallel Old 收集器,在 Server 模式下的内存回收性能很好,**Java8 默认是此垃圾收集器组合** + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-ParallelScavenge收集器.png) + +参数配置: + +* `-XX:+UseParallelGC`:手动指定年轻代使用 Paralle 并行收集器执行内存回收任务 +* `-XX:+UseParalleloldcc`:手动指定老年代使用并行回收收集器执行内存回收任务 + * 上面两个参数,默认开启一个,另一个也会被开启(互相激活),默认 JDK8 是开启的 +* `-XX:+UseAdaptivesizepplicy`:设置 Parallel Scavenge 收集器具有**自适应调节策略**,在这种模式下,年轻代的大小、Eden 和 Survivor 的比例、晋升老年代的对象年龄等参数会被自动调整,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量 +* `-XX:ParallelGcrhreads`:设置年轻代并行收集器的线程数,一般与 CPU 数量相等,以避免过多的线程数影响垃圾收集性能 + * 在默认情况下,当 CPU 数量小于 8 个,ParallelGcThreads 的值等于 CPU 数量 + * 当 CPU 数量大于 8 个,ParallelGCThreads 的值等于 3+[5*CPU Count]/8] +* `-XX:MaxGCPauseMillis`:设置垃圾收集器最大停顿时间(即 STW 的时间),单位是毫秒 + * 对于用户来讲,停顿时间越短体验越好;在服务器端,注重高并发,整体的吞吐量 + * 为了把停顿时间控制在 MaxGCPauseMillis 以内,收集器在工作时会调整 Java 堆大小或其他一些参数 +* `-XX:GCTimeRatio`:垃圾收集时间占总时间的比例 =1/(N+1),用于衡量吞吐量的大小 + * 取值范围(0,100)。默认值 99,也就是垃圾回收时间不超过 1 + * 与 `-xx:MaxGCPauseMillis` 参数有一定矛盾性,暂停时间越长,Radio 参数就容易超过设定的比例 + + + + + +**** + + + +#### CMS + +CMS 全称 Concurrent Mark Sweep,是一款**并发的、使用标记-清除**算法、针对老年代的垃圾回收器,其最大特点是**让垃圾收集线程与用户线程同时工作** + +CMS 收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,停顿时间越短(**低延迟**)越适合与用户交互的程序,良好的响应速度能提升用户体验 + +分为以下四个流程: + +- 初始标记:使用 STW 出现短暂停顿,仅标记一下 GC Roots 能直接关联到的对象,速度很快 +- 并发标记:进行 GC Roots 开始遍历整个对象图,在整个回收过程中耗时最长,不需要 STW,可以与用户线程并发运行 +- 重新标记:修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象,比初始标记时间长但远比并发标记时间短,需要 STW(不停顿就会一直变化,采用写屏障 + 增量更新来避免漏标情况) +- 并发清除:清除标记为可以回收对象,**不需要移动存活对象**,所以这个阶段可以与用户线程同时并发的 + +Mark Sweep 会造成内存碎片,不把算法换成 Mark Compact 的原因:Mark Compact 算法会整理内存,导致用户线程使用的**对象的地址改变**,影响用户线程继续执行 + +在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-CMS收集器.png) + +优点:并发收集、低延迟 + +缺点: + +- 吞吐量降低:在并发阶段虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,CPU 利用率不够高 +- CMS 收集器**无法处理浮动垃圾**,可能出现 Concurrent Mode Failure 导致另一次 Full GC 的产生 + + 浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾(产生了新对象),这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,CMS 收集需要预留出一部分内存,不能等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS,导致很长的停顿时间 +- 标记 - 清除算法导致的空间碎片,往往出现老年代空间无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC;为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配 + +参数设置: + +* `-XX:+UseConcMarkSweepGC`:手动指定使用 CMS 收集器执行内存回收任务 + + 开启该参数后会自动将 `-XX:+UseParNewGC` 打开,即:ParNew + CMS + Serial old的组合 + +* `-XX:CMSInitiatingoccupanyFraction`:设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收 + + * JDK5 及以前版本的默认值为 68,即当老年代的空间使用率达到 68% 时,会执行一次CMS回收 + * JDK6 及以上版本默认值为 92% + +* `-XX:+UseCMSCompactAtFullCollection`:用于指定在执行完 Full GC 后对内存空间进行压缩整理,以此避免内存碎片的产生,由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长 + +* `-XX:CMSFullGCsBeforecompaction`:**设置在执行多少次 Full GC 后对内存空间进行压缩整理** + +* `-XX:ParallelCMSThreads`:设置 CMS 的线程数量 + + * CMS 默认启动的线程数是 (ParallelGCThreads+3)/4,ParallelGCThreads 是年轻代并行收集器的线程数 + * 收集线程占用的 CPU 资源多于25%,对用户程序影响可能较大;当 CPU 资源比较紧张时,受到 CMS 收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕 + + + +*** + + + +#### G1 + +##### G1 特点 + +G1(Garbage-First)是一款面向服务端应用的垃圾收集器,**应用于新生代和老年代**、采用标记-整理算法、软实时、低延迟、可设定目标(最大 STW 停顿时间)的垃圾回收器,用于代替 CMS,适用于较大的堆(>4 ~ 6G),在 JDK9 之后默认使用 G1 + +G1 对比其他处理器的优点: + +* 并发与并行: + * 并行性:G1 在回收期间,可以有多个 GC 线程同时工作,有效利用多核计算能力,此时用户线程 STW + * 并发性:G1 拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此不会在整个回收阶段发生完全阻塞应用程序的情况 + * 其他的垃圾收集器使用内置的 JVM 线程执行 GC 的多线程操作,而 G1 GC 可以采用应用线程承担后台运行的 GC 工作,JVM 的 GC 线程处理速度慢时,系统会**调用应用程序线程加速垃圾回收**过程 + +* **分区算法**: + * 从分代上看,G1 属于分代型垃圾回收器,区分年轻代和老年代,年轻代依然有 Eden 区和 Survivor 区。从堆结构上看,**新生代和老年代不再物理隔离**,不用担心每个代内存是否足够,这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC + * 将整个堆划分成约 2048 个大小相同的独立 Region 块,每个 Region 块大小根据堆空间的实际大小而定,整体被控制在 1MB 到 32 MB之间且为 2 的 N 次幂,所有 Region 大小相同,在 JVM 生命周期内不会被改变。G1 把堆划分成多个大小相等的独立区域,使得每个小空间可以单独进行垃圾回收 + * **新的区域 Humongous**:本身属于老年代区,当出现了一个巨型对象超出了分区容量的一半,该对象就会进入到该区域。如果一个 H 区装不下一个巨型对象,那么 G1 会寻找连续的 H 分区来存储,为了能找到连续的 H 区,有时候不得不启动 Full GC + * G1 不会对巨型对象进行拷贝,回收时被优先考虑,G1 会跟踪老年代所有 incoming 引用,这样老年代 incoming 引用为 0 的巨型对象就可以在新生代垃圾回收时处理掉 + + * Region 结构图: + + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-G1-Region区域.png) + +- 空间整合: + + - CMS:标记-清除算法、内存碎片、若干次 GC 后进行一次碎片整理 + - G1:整体来看是**基于标记 - 整理算法实现**的收集器,从局部(Region 之间)上来看是基于复制算法实现的,两种算法都可以避免内存碎片 + +- **可预测的停顿时间模型(软实时 soft real-time)**:可以指定在 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒 + + - 由于分块的原因,G1 可以只选取部分区域进行内存回收,这样缩小了回收的范围,对于全局停顿情况也能得到较好的控制 + - G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间,通过过去回收的经验获得),在后台维护一个**优先列表**,每次根据允许的收集时间优先回收价值最大的 Region,保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率 + + * 相比于 CMS GC,G1 未必能做到 CMS 在最好情况下的延时停顿,但是最差情况要好很多 + +G1 垃圾收集器的缺点: + +* 相较于 CMS,G1 还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1 无论是为了垃圾收集产生的内存占用还是程序运行时的额外执行负载都要比 CMS 要高 +* 从经验上来说,在小内存应用上 CMS 的表现大概率会优于 G1,而 G1 在大内存应用上则发挥其优势,平衡点在 6-8GB 之间 + +应用场景: + +* 面向服务端应用,针对具有大内存、多处理器的机器 +* 需要低 GC 延迟,并具有大堆的应用程序提供解决方案 + + + +*** + + + +##### 记忆集 + +记忆集 Remembered Set 在新生代中,每个 Region 都有一个 Remembered Set,用来被哪些其他 Region 里的对象引用(谁引用了我就记录谁) + + + +* 程序对 Reference 类型数据写操作时,产生一个 Write Barrier 暂时中断操作,检查该对象和 Reference 类型数据是否在不同的 Region(跨代引用),不同就将相关引用信息记录到 Reference 类型所属的 Region 的 Remembered Set 之中 +* 进行内存回收时,在 GC 根节点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗漏 + +垃圾收集器在新生代中建立了记忆集这样的数据结构,可以理解为它是一个抽象类,具体实现记忆集的三种方式: + +* 字长精度 +* 对象精度 +* 卡精度(卡表) + +卡表(Card Table)在老年代中,是一种对记忆集的具体实现,主要定义了记忆集的记录精度、与堆内存的映射关系等,卡表中的每一个元素都对应着一块特定大小的内存块,这个内存块称之为卡页(card page),当存在跨代引用时,会将卡页标记为 dirty,JVM 对于卡页的维护也是通过写屏障的方式 + +收集集合 CSet 代表每次 GC 暂停时回收的一系列目标分区,在任意一次收集暂停中,CSet 所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。年轻代收集 CSet 只容纳年轻代分区,而混合收集会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到 CSet 中 + +* CSet of Young Collection +* CSet of Mix Collection + + + +*** + + + +##### 工作原理 + +G1 中提供了三种垃圾回收模式:YoungGC、Mixed GC 和 Full GC,在不同的条件下被触发 + +* 当堆内存使用达到一定值(默认 45%)时,开始老年代并发标记过程 +* 标记完成马上开始混合回收过程 + + + +顺时针:Young GC → Young GC + Concurrent Mark → Mixed GC 顺序,进行垃圾回收 + +* **Young GC**:发生在年轻代的 GC 算法,一般对象(除了巨型对象)都是在 eden region 中分配内存,当所有 eden region 被耗尽无法申请内存时,就会触发一次 Young GC,G1 停止应用程序的执行 STW,把活跃对象放入老年代,垃圾对象回收 + + **回收过程**: + + 1. 扫描根:根引用连同 RSet 记录的外部引用作为扫描存活对象的入口 + 2. 更新 RSet:处理 dirty card queue 更新 RS,此后 RSet 准确的反映对象的引用关系 + * dirty card queue:类似缓存,产生了引用先记录在这里,然后更新到 RSet + * 作用:产生引用直接更新 RSet 需要线程同步开销很大,使用队列性能好 + 3. 处理 RSet:识别被老年代对象指向的 Eden 中的对象,这些被指向的对象被认为是存活的对象,把需要回收的分区放入 Young CSet 中进行回收 + 4. 复制对象:Eden 区内存段中存活的对象会被复制到 survivor 区,survivor 区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阀值会被会被复制到 old 区中空的内存分段,如果 survivor 空间不够,Eden 空间的部分数据会直接晋升到老年代空间 + 5. 处理引用:处理 Soft,Weak,Phantom,JNI Weak 等引用,最终 Eden 空间的数据为空,GC 停止工作 + +* **Concurrent Mark **: + + * 初始标记:标记从根节点直接可达的对象,这个阶段是 STW 的,并且会触发一次年轻代 GC + * 并发标记 (Concurrent Marking):在整个堆中进行并发标记(应用程序并发执行),可能被 YoungGC 中断。会计算每个区域的对象活性,即区域中存活对象的比例,若区域中的所有对象都是垃圾,则这个区域会被立即回收(**实时回收**),给浮动垃圾准备出更多的空间,把需要收集的 Region 放入 CSet 当中 + * 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,这阶段需要停顿线程,但是可并行执行(**防止漏标**) + * 筛选回收:并发清理阶段,首先对 CSet 中各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划,也需要 STW + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-G1收集器.jpg) + +* **Mixed GC**:当很多对象晋升到老年代时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即 Mixed GC,除了回收整个 young region,还会回收一部分的 old region,过程同 YGC + + 注意:**是一部分老年代,而不是全部老年代**,可以选择哪些老年代 region 收集,对垃圾回收的时间进行控制 + + 在 G1 中,Mixed GC 可以通过 `-XX:InitiatingHeapOccupancyPercent` 设置阈值 + +* **Full GC**:对象内存分配速度过快,Mixed GC 来不及回收,导致老年代被填满,就会触发一次 Full GC,G1 的 Full GC 算法就是单线程执行的垃圾回收,会导致异常长时间的暂停时间,需要进行不断的调优,尽可能的避免 Full GC + + 产生 Full GC 的原因: + + * 晋升时没有足够的空间存放晋升的对象 + * 并发处理过程完成之前空间耗尽,浮动垃圾 + + + +*** + + + +##### 相关参数 + +- `-XX:+UseG1GC`:手动指定使用 G1 垃圾收集器执行内存回收任务 +- `-XX:G1HeapRegionSize`:设置每个 Region 的大小。值是 2 的幂,范围是 1MB 到 32MB 之间,目标是根据最小的 Java 堆大小划分出约 2048 个区域,默认是堆内存的 1/2000 +- `-XX:MaxGCPauseMillis`:设置期望达到的最大 GC 停顿时间指标,JVM会尽力实现,但不保证达到,默认值是 200ms +- `-XX:+ParallelGcThread`:设置 STW 时 GC 线程数的值,最多设置为 8 +- `-XX:ConcGCThreads`:设置并发标记线程数,设置为并行垃圾回收线程数 ParallelGcThreads 的1/4左右 +- `-XX:InitiatingHeapoccupancyPercent`:设置触发并发 Mixed GC 周期的 Java 堆占用率阈值,超过此值,就触发 GC,默认值是 45 +- `-XX:+ClassUnloadingWithConcurrentMark`:并发标记类卸载,默认启用,所有对象都经过并发标记后,就可以知道哪些类不再被使用,当一个类加载器的所有类都不再使用,则卸载它所加载的所有类 +- `-XX:G1NewSizePercent`:新生代占用整个堆内存的最小百分比(默认5%) +- `-XX:G1MaxNewSizePercent`:新生代占用整个堆内存的最大百分比(默认60%) +- `-XX:G1ReservePercent=10`:保留内存区域,防止 to space(Survivor中的 to 区)溢出 + + + +*** + + + +##### 调优 + +G1 的设计原则就是简化 JVM 性能调优,只需要简单的三步即可完成调优: + +1. 开启 G1 垃圾收集器 +2. 设置堆的最大内存 +3. 设置最大的停顿时间(STW) + +不断调优暂停时间指标: + +* `XX:MaxGCPauseMillis=x` 可以设置启动应用程序暂停的时间,G1会根据这个参数选择 CSet 来满足响应时间的设置 +* 设置到 100ms 或者 200ms 都可以(不同情况下会不一样),但设置成50ms就不太合理 +* 暂停时间设置的太短,就会导致出现 G1 跟不上垃圾产生的速度,最终退化成 Full GC +* 对这个参数的调优是一个持续的过程,逐步调整到最佳状态 + +不要设置新生代和老年代的大小: + +- 避免使用 -Xmn 或 -XX:NewRatio 等相关选项显式设置年轻代大小,G1 收集器在运行的时候会调整新生代和老年代的大小,从而达到我们为收集器设置的暂停时间目标 +- 设置了新生代大小相当于放弃了 G1 的自动调优,我们只需要设置整个堆内存的大小,剩下的交给 G1 自己去分配各个代的大小 + + + +*** + + + +#### ZGC + +ZGC 收集器是一个可伸缩的、低延迟的垃圾收集器,基于 Region 内存布局的,不设分代,使用了读屏障、染色指针和内存多重映射等技术来实现**可并发的标记压缩算法** + +* 在 CMS 和 G1 中都用到了写屏障,而 ZGC 用到了读屏障 +* 染色指针:直接**将少量额外的信息存储在指针上的技术**,从 64 位的指针中拿高 4 位来标识对象此时的状态 + * 染色指针可以使某个 Region 的存活对象被移走之后,这个 Region 立即就能够被释放和重用 + * 可以直接从指针中看到引用对象的三色标记状态(Marked0、Marked1)、是否进入了重分配集、是否被移动过(Remapped)、是否只能通过 finalize() 方法才能被访问到(Finalizable) + * 可以大幅减少在垃圾收集过程中内存屏障的使用数量,写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作 + * 可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据 +* 内存多重映射:多个虚拟地址指向同一个物理地址 + +可并发的标记压缩算法:染色指针标识对象是否被标记或移动,读屏障保证在每次应用程序或 GC 程序访问对象时先根据染色指针的标识判断是否被移动,如果被移动就根据转发表访问新的移动对象,**并更新引用**,不会像 G1 一样必须等待垃圾回收完成才能访问 + +ZGC 目标: + +- 停顿时间不会超过 10ms +- 停顿时间不会随着堆的增大而增大(不管多大的堆都能保持在 10ms 以下) +- 可支持几百 M,甚至几 T 的堆大小(最大支持4T) + +ZGC 的工作过程可以分为 4 个阶段: + +* 并发标记(Concurrent Mark): 遍历对象图做可达性分析的阶段,也要经过初始标记和最终标记,需要短暂停顿 +* 并发预备重分配(Concurrent Prepare for Relocate):根据特定的查询条件统计得出本次收集过程要清理哪些 Region,将这些 Region 组成重分配集(Relocation Set) +* 并发重分配(Concurrent Relocate): 重分配是 ZGC 执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的 Region 上,并为重分配集中的**每个 Region 维护一个转发表**(Forward Table),记录从旧地址到新地址的转向关系 +* 并发重映射(Concurrent Remap):修正整个堆中指向重分配集中旧对象的所有引用,ZGC 的并发映射并不是一个必须要立即完成的任务,ZGC 很巧妙地把并发重映射阶段要做的工作,合并到下一次垃圾收集循环中的并发标记阶段里去完成,因为都是要遍历所有对象,这样合并节省了一次遍历的开销 + +ZGC 几乎在所有地方并发执行的,除了初始标记的是 STW 的,但这部分的实际时间是非常少的,所以响应速度快,在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟 + +优点:高吞吐量、低延迟 + +缺点:浮动垃圾,当 ZGC 准备要对一个很大的堆做一次完整的并发收集,其全过程要持续十分钟以上,由于应用的对象分配速率很高,将创造大量的新对象产生浮动垃圾 + + + +参考文章:https://www.cnblogs.com/jimoer/p/13170249.html + + + +*** + + + +#### 总结 + +Serial GC、Parallel GC、Concurrent Mark Sweep GC 这三个 GC 不同: + +- 最小化地使用内存和并行开销,选 Serial GC +- 最大化应用程序的吞吐量,选 Parallel GC +- 最小化 GC 的中断或停顿时间,选 CMS GC + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-垃圾回收器总结.png) + + + + + +*** + + + +### 内存泄漏 + +#### 泄露溢出 + +内存泄漏(Memory Leak):是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果 + +可达性分析算法来判断对象是否是不再使用的对象,本质都是判断一个对象是否还被引用。由于代码的实现不同就会出现很多种内存泄漏问题,让 JVM 误以为此对象还在引用中,无法回收,造成内存泄漏 + +内存溢出(out of memory)指的是申请内存时,没有足够的内存可以使用 + +内存泄漏和内存溢出的关系:内存泄漏的越来越多,最终会导致内存溢出 + + + +*** + + + +#### 几种情况 + +##### 静态集合 + +静态集合类的生命周期与 JVM 程序一致,则容器中的对象在程序结束之前将不能被释放,从而造成内存泄漏。原因是**长生命周期的对象持有短生命周期对象的引用**,尽管短生命周期的对象不再使用,但是因为长生命周期对象持有它的引用而导致不能被回收 + +```java +public class MemoryLeak { + static List list = new ArrayList(); + public void oomTest(){ + Object obj = new Object();//局部变量 + list.add(obj); + } +} +``` + + + +*** + + + +##### 单例模式 + +单例模式和静态集合导致内存泄露的原因类似,因为单例的静态特性,它的生命周期和 JVM 的生命周期一样长,所以如果单例对象持有外部对象的引用,那么这个外部对象也不会被回收,那么就会造成内存泄漏 + + + +**** + + + +##### 内部类 + +内部类持有外部类的情况,如果一个外部类的实例对象调用方法返回了一个内部类的实例对象,即使那个外部类实例对象不再被使用,但由于内部类持有外部类的实例对象,这个外部类对象也不会被回收,造成内存泄漏 + + + +*** + + + +##### 连接相关 + +数据库连接、网络连接和 IO 连接等,当不再使用时,需要显式调用 close 方法来释放与连接,垃圾回收器才会回收对应的对象,否则将会造成大量的对象无法被回收,从而引起内存泄漏 + + + +**** + + + +##### 不合理域 + +变量不合理的作用域,一个变量的定义的作用范围大于其使用范围,很有可能会造成内存泄漏;如果没有及时地把对象设置为 null,也有可能导致内存泄漏的发生 + +```java +public class UsingRandom { + private String msg; + public void receiveMsg(){ + msg = readFromNet();// 从网络中接受数据保存到 msg 中 + saveDB(msg); // 把 msg 保存到数据库中 + } +} +``` + +通过 readFromNet 方法把接收消息保存在 msg 中,然后调用 saveDB 方法把内容保存到数据库中,此时 msg 已经可以被回收,但是 msg 的生命周期与对象的生命周期相同,造成 msg 不能回收,产生内存泄漏 + +解决: + +* msg 变量可以放在 receiveMsg 方法内部,当方法使用完,msg 的生命周期也就结束,就可以被回收了 +* 在使用完 msg 后,把 msg 设置为 null,这样垃圾回收器也会回收 msg 的内存空间。 + + + +**** + + + +##### 改变哈希 + +当一个对象被存储进 HashSet 集合中以后,就**不能修改这个对象中的那些参与计算哈希值的字段**,否则对象修改后的哈希值与最初存储进 HashSet 集合中时的哈希值不同,这种情况下使用该对象的当前引用作为的参数去 HashSet 集合中检索对象返回 false,导致无法从 HashSet 集合中单独删除当前对象,造成内存泄漏 + + + +*** + + + +##### 缓存泄露 + +内存泄漏的一个常见来源是缓存,一旦把对象引用放入到缓存中,就会很容易被遗忘 + +使用 WeakHashMap 代表缓存,当除了自身有对 key 的引用外没有其他引用,map 会自动丢弃此值 + + + + + +*** + + + +#### 案例分析 + +```java +public class Stack { + private Object[] elements; + private int size = 0; + private static final int DEFAULT_INITIAL_CAPACITY = 16; + + public Stack() { + elements = new Object[DEFAULT_INITIAL_CAPACITY]; + } + + public void push(Object e) { //入栈 + ensureCapacity(); + elements[size++] = e; + } + + public Object pop() { //出栈 + if (size == 0) + throw new EmptyStackException(); + return elements[--size]; + } + + private void ensureCapacity() { + if (elements.length == size) + elements = Arrays.copyOf(elements, 2 * size + 1); + } +} +``` + +程序并没有明显错误,但 pop 函数存在内存泄漏问题,因为 pop 函数只是把栈顶索引下移一位,并没有把上一个出栈索引处的引用置空,导致**栈数组一直强引用着已经出栈的对象** + +解决方法: + +```java +public Object pop() { + if (size == 0) + throw new EmptyStackException(); + Object result = elements[--size]; + elements[size] = null; + return result; +} +``` + + + + + +*** + + + + + +## 类加载 + +### 对象访存 + +#### 存储结构 + +一个 Java 对象内存中存储为三部分:对象头(Header)、实例数据(Instance Data)和对齐填充 (Padding) + +对象头: + +* 普通对象:分为两部分 + + * **Mark Word**:用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等 + + ```ruby + hash(25) + age(4) + lock(3) = 32bit #32位系统 + unused(25+1) + hash(31) + age(4) + lock(3) = 64bit #64位系统 + ``` + + * **Klass Word**:类型指针,**指向该对象的 Class 类对象的指针**,虚拟机通过这个指针来确定这个对象是哪个类的实例;在 64 位系统中,开启指针压缩(-XX:+UseCompressedOops)或者 JVM 堆的最大值小于 32G,这个指针也是 4byte,否则是 8byte(就是 **Java 中的一个引用的大小**) + + ```ruby + |-----------------------------------------------------| + | Object Header (64 bits) | + |---------------------------|-------------------------| + | Mark Word (32 bits) | Klass Word (32 bits) | + |---------------------------|-------------------------| + ``` + +* 数组对象:如果对象是一个数组,那在对象头中还有一块数据用于记录数组长度(12 字节) + + ```ruby + |-------------------------------------------------------------------------------| + | Object Header (96 bits) | + |-----------------------|-----------------------------|-------------------------| + | Mark Word(32bits) | Klass Word(32bits) | array length(32bits) | + |-----------------------|-----------------------------|-------------------------| + ``` + +实例数据:实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的,都需要记录起来 + +对齐填充:Padding 起占位符的作用。64 位系统,由于 HotSpot VM 的自动内存管理系统要求**对象起始地址必须是 8 字节的整数倍**,就是对象的大小必须是 8 字节的整数倍,而对象头部分正好是 8 字节的倍数(1 倍或者 2 倍),因此当对象实例数据部分没有对齐时,就需要通过对齐填充来补全 + +32 位系统: + +* 一个 int 在 java 中占据 4byte,所以 Integer 的大小为: + + ```java + private final int value; + ``` + + ```ruby + # 需要补位4byte + 4(Mark Word) + 4(Klass Word) + 4(data) + 4(Padding) = 16byte + ``` + +* `int[] arr = new int[10]` + + ```ruby + # 由于需要8位对齐,所以最终大小为56byte + 4(Mark Word) + 4(Klass Word) + 4(length) + 4*10(10个int大小) + 4(Padding) = 56sbyte + ``` + + + +*** + + + +#### 实际大小 + +浅堆(Shallow Heap):**对象本身占用的内存,不包括内部引用对象的大小**,32 位系统中一个对象引用占 4 个字节,每个对象头占用 8 个字节,根据堆快照格式不同,对象的大小会同 8 字节进行对齐 + +JDK7 中的 String:2个 int 值共占 8 字节,value 对象引用占用 4 字节,对象头 8 字节,对齐后占 24 字节,为 String 对象的浅堆大小,与 value 实际取值无关,无论字符串长度如何,浅堆大小始终是 24 字节 + +```java +private final char value[]; +private int hash; +private int hash32; +``` + +保留集(Retained Set):对象 A 的保留集指当对象 A 被垃圾回收后,可以被释放的所有的对象集合(包括 A 本身),所以对象 A 的保留集就是只能通过对象 A 被直接或间接访问到的所有对象的集合,就是仅被对象 A 所持有的对象的集合 + +深堆(Retained Heap):指对象的保留集中所有的对象的浅堆大小之和,一个对象的深堆指只能通过该对象访问到的(直接或间接)所有对象的浅堆之和,即对象被回收后,可以释放的真实空间 + +对象的实际大小:一个对象所能触及的所有对象的浅堆大小之和,也就是通常意义上我们说的对象大小 + +下图显示了一个简单的对象引用关系图,对象 A 引用了 C 和 D,对象 B 引用了 C 和 E。那么对象 A 的浅堆大小只是 A 本身,**A 的实际大小为 A、C、D 三者之和**,A 的深堆大小为 A 与 D 之和,由于对象 C 还可以通过对象 B 访问到 C,因此 C 不在对象 A 的深堆范围内 + + + +内存分析工具 MAT 提供了一种叫支配树的对象图,体现了对象实例间的支配关系 + +基本性质: + +- 对象 A 的子树(所有被对象 A 支配的对象集合)表示对象 A 的保留集(retained set),即深堆 + +- 如果对象 A 支配对象 B,那么对象 A 的直接支配者也支配对象 B + +- 支配树的边与对象引用图的边不直接对应 + +左图表示对象引用图,右图表示左图所对应的支配树: + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-支配树.png) + +比如:对象 F 与对象 D 相互引用,因为到对象 F 的所有路径必然经过对象 D,因此对象 D 是对象 F 的直接支配者 + + + +参考文章:https://www.yuque.com/u21195183/jvm/nkq31c + + + +*** + + + +#### 节约内存 + +* 尽量使用基本数据类型 + +* 满足容量前提下,尽量用小字段 + +* 尽量用数组,少用集合,数组中是可以使用基本类型的,但是集合中只能放包装类型,如果需要使用集合,推荐比较节约内存的集合工具:fastutil + + 一个 ArrayList 集合,如果里面放了 10 个数字,占用多少内存: + + ```java + private transient Object[] elementData; + private int size; + ``` + + Mark Word 占 4byte,Klass Word 占 4byte,一个 int 字段占 4byte,elementData 数组占 12byte,数组中 10 个 Integer 对象占 10×16,所以整个集合空间大小为 184byte(深堆) + +* 时间用 long/int 表示,不用 Date 或者 String + + + +*** + + + +#### 对象访问 + +JVM 是通过**栈帧中的对象引用**访问到其内部的对象实例: + +* 句柄访问:Java 堆中会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息 + + 优点:reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-对象访问-句柄访问.png) + +* 直接指针(HotSpot 采用):Java 堆对象的布局必须考虑如何放置访问类型数据的相关信息,reference 中直接存储的对象地址 + + 优点:速度更快,**节省了一次指针定位的时间开销** + + 缺点:对象被移动时(如进行 GC 后的内存重新排列),对象的 reference 也需要同步更新 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-对象访问-直接指针.png) + + + +参考文章:https://www.cnblogs.com/afraidToForget/p/12584866.html + + + +*** + + + +### 对象创建 + +#### 生命周期 + +在 Java 中,对象的生命周期包括以下几个阶段: + +1. 创建阶段 (Created): +2. 应用阶段 (In Use):对象至少被一个强引用持有着 +3. 不可见阶段 (Invisible):程序的执行已经超出了该对象的作用域,不再持有该对象的任何强引用 +4. 不可达阶段 (Unreachable):该对象不再被任何强引用所持有,包括 GC Root 的强引用 +5. 收集阶段 (Collected):垃圾回收器对该对象的内存空间重新分配做好准备,该对象如果重写了 finalize() 方法,则会去执行该方法 +6. 终结阶段 (Finalized):等待垃圾回收器对该对象空间进行回收,当对象执行完 finalize() 方法后仍然处于不可达状态时进入该阶段 +7. 对象空间重分配阶段 (De-allocated):垃圾回收器对该对象的所占用的内存空间进行回收或者再分配 + + + +参考文章:https://blog.csdn.net/sodino/article/details/38387049 + + + +*** + + + +#### 创建时机 + +类在第一次实例化加载一次,后续实例化不再加载,引用第一次加载的类 + +Java 对象创建时机: + +1. 使用 new 关键字创建对象:由执行类实例创建表达式而引起的对象创建 + +2. 使用 Class 类的 newInstance 方法(反射机制) + +3. 使用 Constructor 类的 newInstance 方法(反射机制) + + ```java + public class Student { + private int id; + public Student(Integer id) { + this.id = id; + } + public static void main(String[] args) throws Exception { + Constructor c = Student.class.getConstructor(Integer.class); + Student stu = c.newInstance(123); + } + } + ``` + + 使用 newInstance 方法的这两种方式创建对象使用的就是 Java 的反射机制,事实上 Class 的 newInstance 方法内部调用的也是 Constructor 的 newInstance 方法 + +4. 使用 Clone 方法创建对象:用 clone 方法创建对象的过程中并不会调用任何构造函数,要想使用 clone 方法,我们就必须先实现 Cloneable 接口并实现其定义的 clone 方法 + +5. 使用(反)序列化机制创建对象:当反序列化一个对象时,JVM 会创建一个**单独的对象**,在此过程中,JVM 并不会调用任何构造函数,为了反序列化一个对象,需要让类实现 Serializable 接口 + +从 Java 虚拟机层面看,除了使用 new 关键字创建对象的方式外,其他方式全部都是通过转变为 invokevirtual 指令直接创建对象的 + + + +*** + + + +#### 创建过程 + +创建对象的过程: + +1. 判断对象对应的类是否加载、链接、初始化 + +2. 为对象分配内存:指针碰撞、空闲链表。当一个对象被创建时,虚拟机就会为其分配内存来存放对象的实例变量及其从父类继承过来的实例变量,即使从**隐藏变量**也会被分配空间(继承部分解释了为什么会隐藏) + +3. 处理并发安全问题: + + * 采用 CAS 配上自旋保证更新的原子性 + * 每个线程预先分配一块 TLAB + +4. 初始化分配的空间:虚拟机将分配到的内存空间都初始化为零值(不包括对象头),保证对象实例字段在不赋值时可以直接使用,程序能访问到这些字段的数据类型所对应的零值 + +5. 设置对象的对象头:将对象的所属类(类的元数据信息)、对象的 HashCode、对象的 GC 信息、锁信息等数据存储在对象头中 + +6. 执行 init 方法进行实例化:实例变量初始化、实例代码块初始化 、构造函数初始化 + + * 实例变量初始化与实例代码块初始化: + + 对实例变量直接赋值或者使用实例代码块赋值,**编译器会将其中的代码放到类的构造函数中去**,并且这些代码会被放在对超类构造函数的调用语句之后(Java 要求构造函数的第一条语句必须是超类构造函数的调用语句),构造函数本身的代码之前 + + * 构造函数初始化: + + **Java 要求在实例化类之前,必须先实例化其超类,以保证所创建实例的完整性**,在准备实例化一个类的对象前,首先准备实例化该类的父类,如果该类的父类还有父类,那么准备实例化该类的父类的父类,依次递归直到递归到 Object 类。然后从 Object 类依次对以下各类进行实例化,初始化父类中的变量和执行构造函数 + + + +*** + + + +#### 承上启下 + +1. 一个实例变量在对象初始化的过程中会被赋值几次?一个实例变量最多可以被初始化 4 次 + + JVM 在为一个对象分配完内存之后,会给每一个实例变量赋予默认值,这个实例变量被第一次赋值;在声明实例变量的同时对其进行了赋值操作,那么这个实例变量就被第二次赋值;在实例代码块中又对变量做了初始化操作,那么这个实例变量就被第三次赋值;;在构造函数中也对变量做了初始化操作,那么这个实例变量就被第四次赋值 + +2. 类的初始化过程与类的实例化过程的异同? + + 类的初始化是指类加载过程中的初始化阶段对类变量按照代码进行赋值的过程;类的实例化是指在类完全加载到内存中后创建对象的过程(类的实例化触发了类的初始化,先初始化才能实例化) + +3. 假如一个类还未加载到内存中,那么在创建一个该类的实例时,具体过程是怎样的?(**经典案例**) + + ```java + public class StaticTest { + public static void main(String[] args) { + staticFunction();//调用静态方法,触发初始化 + } + + static StaticTest st = new StaticTest(); + + static { //静态代码块 + System.out.println("1"); + } + + { // 实例代码块 + System.out.println("2"); + } + + StaticTest() { // 实例构造器 + System.out.println("3"); + System.out.println("a=" + a + ",b=" + b); + } + + public static void staticFunction() { // 静态方法 + System.out.println("4"); + } + + int a = 110; // 实例变量 + static int b = 112; // 静态变量 + }/* Output: + 2 + 3 + a=110,b=0 + 1 + 4 + *///:~ + ``` + + `static StaticTest st = new StaticTest();`: + + * 实例实例化不一定要在类初始化结束之后才开始 + + * 在同一个类加载器下,一个类型只会被初始化一次。所以一旦开始初始化一个类,无论是否完成后续都不会再重新触发该类型的初始化阶段了(只考虑在同一个类加载器下的情形)。因此在实例化上述程序中的 st 变量时,**实际上是把实例化嵌入到了静态初始化流程中,并且在上面的程序中,嵌入到了静态初始化的起始位置**,这就导致了实例初始化完全发生在静态初始化之前,这也是导致 a 为 110,b 为 0 的原因 + + 代码等价于: + + ```java + public class StaticTest { + (){ + a = 110; // 实例变量 + System.out.println("2"); // 实例代码块 + System.out.println("3"); // 实例构造器中代码的执行 + System.out.println("a=" + a + ",b=" + b); // 实例构造器中代码的执行 + 类变量st被初始化 + System.out.println("1"); //静态代码块 + 类变量b被初始化为112 + } + } + ``` + + + + + +*** + + + +### 加载过程 + +#### 生命周期 + +类是在运行期间**第一次使用时动态加载**的(不使用不加载),而不是一次性加载所有类,因为一次性加载会占用很多的内存,加载的类信息存放于一块成为方法区的内存空间 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-类的生命周期.png) + +包括 7 个阶段: + +* 加载(Loading) +* 链接:验证(Verification)、准备(Preparation)、解析(Resolution) +* 初始化(Initialization) +* 使用(Using) +* 卸载(Unloading) + + + +*** + + + +#### 加载阶段 + +加载是类加载的其中一个阶段,注意不要混淆 + +加载过程完成以下三件事: + +- 通过类的完全限定名称获取定义该类的二进制字节流(二进制字节码) +- 将该字节流表示的静态存储结构转换为方法区的运行时存储结构(Java 类模型) +- **将字节码文件加载至方法区后,在堆中生成一个代表该类的 Class 对象,作为该类在方法区中的各种数据的访问入口** + +其中二进制字节流可以从以下方式中获取: + +- 从 ZIP 包读取,成为 JAR、EAR、WAR 格式的基础 +- 从网络中获取,最典型的应用是 Applet +- 由其他文件生成,例如由 JSP 文件生成对应的 Class 类 +- 运行时计算生成,例如动态代理技术,在 java.lang.reflect.Proxy 使用 ProxyGenerator.generateProxyClass 生成字节码 + +方法区内部采用 C++ 的 instanceKlass 描述 Java 类的数据结构: + +* `_java_mirror` 即 Java 的类镜像,例如对 String 来说就是 String.class,作用是把 class 暴露给 Java 使用 +* `_super` 即父类、`_fields` 即成员变量、`_methods` 即方法、`_constants` 即常量池、`_class_loader` 即类加载器、`_vtable` **虚方法表**、`_itable` 接口方法表 + +加载过程: + +* 如果这个类还有父类没有加载,先加载父类 +* 加载和链接可能是交替运行的 +* Class 对象和 _java_mirror 相互持有对方的地址,堆中对象通过 instanceKlass 和元空间进行交互 + + + +创建数组类有些特殊,因为数组类本身并不是由类加载器负责创建,而是由 JVM 在运行时根据需要而直接创建的,但数组的元素类型仍然需要依靠类加载器去创建,创建数组类的过程: + +- 如果数组的元素类型是引用类型,那么遵循定义的加载过程递归加载和创建数组的元素类型 +- JVM 使用指定的元素类型和数组维度来创建新的数组类 +- **基本数据类型由启动类加载器加载** + + + +*** + + + +#### 链接阶段 + +##### 验证 + +确保 Class 文件的字节流中包含的信息是否符合 JVM 规范,保证被加载类的正确性,不会危害虚拟机自身的安全 + +主要包括**四种验证**: + +* 文件格式验证 + +* 语义检查,但凡在语义上不符合规范的,虚拟机不会给予验证通过 + + * 是否所有的类都有父类的存在(除了 Object 外,其他类都应该有父类) + + * 是否一些被定义为 final 的方法或者类被重写或继承了 + + * 非抽象类是否实现了所有抽象方法或者接口方法 + + * 是否存在不兼容的方法 + +* 字节码验证,试图通过对字节码流的分析,判断字节码是否可以被正确地执行 + + * 在字节码的执行过程中,是否会跳转到一条不存在的指令 + * 函数的调用是否传递了正确类型的参数 + * 变量的赋值是不是给了正确的数据类型 + * 栈映射帧(StackMapTable)在这个阶段用于检测在特定的字节码处,其局部变量表和操作数栈是否有着正确的数据类型 + +* 符号引用验证,Class 文件在其常量池会通过字符串记录将要使用的其他类或者方法 + + + +*** + + + +##### 准备 + +准备阶段为**静态变量(类变量)分配内存并设置初始值**,使用的是方法区的内存: + +说明:实例变量不会在这阶段分配内存,它会在对象实例化时随着对象一起被分配在堆中,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次 + +类变量初始化: + +* static 变量分配空间和赋值是两个步骤:**分配空间在准备阶段完成,赋值在初始化阶段完成** +* 如果 static 变量是 final 的基本类型以及字符串常量,那么编译阶段值(方法区)就确定了,准备阶段会显式初始化 +* 如果 static 变量是 final 的,但属于引用类型或者构造器方法的字符串,赋值在初始化阶段完成 + +实例: + +* 初始值一般为 0 值,例如下面的类变量 value 被初始化为 0 而不是 123: + + ```java + public static int value = 123; + ``` + +* 常量 value 被初始化为 123 而不是 0: + + ```java + public static final int value = 123; + ``` + +* Java 并不支持 boolean 类型,对于 boolean 类型,内部实现是 int,由于 int 的默认值是 0,故 boolean 的默认值就是 false + + + +*** + + + +##### 解析 + +将常量池中类、接口、字段、方法的**符号引用替换为直接引用**(内存地址)的过程: + +* 符号引用:一组符号来描述目标,可以是任何字面量,属于编译原理方面的概念,如:包括类和接口的全限名、字段的名称和描述符、方法的名称和**方法描述符**(因为类还没有加载完,很多方法是找不到的) +* 直接引用:直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄,如果有了直接引用,那说明引用的目标必定已经存在于内存之中 + +例如:在 `com.demo.Solution` 类中引用了 `com.test.Quest`,把 `com.test.Quest` 作为符号引用存进类常量池,在类加载完后,**用这个符号引用去方法区找这个类的内存地址** + +解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等 + +* 在类加载阶段解析的是非虚方法,静态绑定 +* 也可以在初始化阶段之后再开始解析,这是为了支持 Java 的**动态绑定** +* 通过解析操作,符号引用就可以转变为目标方法在类的虚方法表中的位置,从而使得方法被成功调用 + +```java +public class Load2 { + public static void main(String[] args) throws Exception{ + ClassLoader classloader = Load2.class.getClassLoader(); + // cloadClass 加载类方法不会导致类的解析和初始化,也不会加载D + Class c = classloader.loadClass("cn.jvm.t3.load.C"); + + // new C();会导致类的解析和初始化,从而解析初始化D + System.in.read(); + } +} +class C { + D d = new D(); +} +class D { +} +``` + + + +**** + + + +#### 初始化 + +##### 介绍 + +初始化阶段才真正开始执行类中定义的 Java 程序代码,在准备阶段,类变量已经赋过一次系统要求的初始值;在初始化阶段,通过程序制定的计划去初始化类变量和其它资源,执行 + +在编译生成 class 文件时,编译器会产生两个方法加于 class 文件中,一个是类的初始化方法 clinit,另一个是实例的初始化方法 init + +类构造器 () 与实例构造器 () 不同,它不需要程序员进行显式调用,在一个类的生命周期中,类构造器最多被虚拟机**调用一次**,而实例构造器则会被虚拟机调用多次,只要程序员创建对象 + +类在第一次实例化加载一次,把 class 读入内存,后续实例化不再加载,引用第一次加载的类 + + + +*** + + + +##### clinit + +():类构造器,由编译器自动收集类中**所有类变量的赋值动作和静态语句块**中的语句合并产生的 + +作用:是在类加载过程中的初始化阶段进行静态变量初始化和执行静态代码块 + +* 如果类中没有静态变量或静态代码块,那么 clinit 方法将不会被生成 +* clinit 方法只执行一次,在执行 clinit 方法时,必须先执行父类的clinit方法 +* static 变量的赋值操作和静态代码块的合并顺序由源文件中出现的顺序决定 +* static 不加 final 的变量都在初始化环节赋值 + +**线程安全**问题: + +* 虚拟机会保证一个类的 () 方法在多线程环境下被正确的加锁和同步,如果多个线程同时初始化一个类,只会有一个线程执行这个类的 () 方法,其它线程都阻塞等待,直到活动线程执行 () 方法完毕 +* 如果在一个类的 () 方法中有耗时的操作,就可能造成多个线程阻塞,在实际过程中此种阻塞很隐蔽 + +特别注意:静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问 + +```java +public class Test { + static { + //i = 0; // 给变量赋值可以正常编译通过 + System.out.print(i); // 这句编译器会提示“非法向前引用” + } + static int i = 1; +} +``` + +接口中不可以使用静态语句块,但仍然有类变量初始化的赋值操作,因此接口与类一样都会生成 () 方法,两者不同的是: + +* 在初始化一个接口时,并不会先初始化它的父接口,所以执行接口的 () 方法不需要先执行父接口的 () 方法 +* 在初始化一个类时,不会先初始化所实现的接口,所以接口的实现类在初始化时不会执行接口的 () 方法 +* 只有当父接口中定义的变量使用时,父接口才会初始化 + + + +**** + + + +##### 时机 + +类的初始化是懒惰的,只有在首次使用时才会被装载,JVM 不会无条件地装载 Class 类型,Java 虚拟机规定,一个类或接口在初次使用前,必须要进行初始化 + +**主动引用**:虚拟机规范中并没有强制约束何时进行加载,但是规范严格规定了有且只有下列情况必须对类进行初始化(加载、验证、准备都会发生): + +* 当创建一个类的实例时,使用 new 关键字,或者通过反射、克隆、反序列化(前文讲述的对象的创建时机) +* 当调用类的静态方法或访问静态字段时,遇到 getstatic、putstatic、invokestatic 这三条字节码指令,如果类没有进行过初始化,则必须先触发其初始化 + * getstatic:程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池) + * putstatic:程序给类的静态变量赋值 + * invokestatic :调用一个类的静态方法 +* 使用 java.lang.reflect 包的方法对类进行反射调用时,如果类没有进行初始化,则需要先触发其初始化 +* 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化,但这条规则并**不适用于接口** +* 当虚拟机启动时,需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类 +* MethodHandle 和 VarHandle 可以看作是轻量级的反射调用机制,而要想使用这两个调用, 就必须先使用 findStaticVarHandle 来初始化要调用的类 +* 补充:当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化 + +**被动引用**:所有引用类的方式都不会触发初始化,称为被动引用 + +* 通过子类引用父类的静态字段,不会导致子类初始化,只会触发父类的初始化 +* 通过数组定义来引用类,不会触发此类的初始化。该过程会对数组类进行初始化,数组类是一个由虚拟机自动生成的、直接继承自 Object 的子类,其中包含了数组的属性和方法 +* 常量(final 修饰)在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化 +* 调用 ClassLoader 类的 loadClass() 方法加载一个类,并不是对类的主动使用,不会导致类的初始化 + + + +*** + + + +##### init + +init 指的是实例构造器,主要作用是在类实例化过程中执行,执行内容包括成员变量初始化和代码块的执行 + +实例化即调用 ()V ,虚拟机会保证这个类的构造方法的线程安全,先为实例变量分配内存空间,再执行赋默认值,然后根据源码中的顺序执行赋初值或代码块,没有成员变量初始化和代码块则不会执行 + +类实例化过程:**父类的类构造器() -> 子类的类构造器() -> 父类的成员变量和实例代码块 -> 父类的构造函数 -> 子类的成员变量和实例代码块 -> 子类的构造函数** + +new 关键字会创建对象并复制 dup 一个对象引用,一个调用 方法,另一个用来赋值给接收者 + + + +*** + + + +#### 卸载阶段 + +时机:执行了 System.exit() 方法,程序正常执行结束,程序在执行过程中遇到了异常或错误而异常终止,由于操作系统出现错误而导致Java 虚拟机进程终止 + +卸载类即该类的 **Class 对象被 GC**,卸载类需要满足3个要求: + +1. 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象 +2. 该类没有在其他任何地方被引用 +3. 该类的类加载器的实例已被 GC,一般是可替换类加载器的场景,如 OSGi、JSP 的重加载等,很难达成 + +在 JVM 生命周期类,由 JVM 自带的类加载器加载的类是不会被卸载的,自定义的类加载器加载的类是可能被卸载。因为 JVM 会始终引用启动、扩展、系统类加载器,这些类加载器始终引用它们所加载的类,这些类始终是可及的 + + + +**** + + + +### 类加载器 + +#### 类加载 + +类加载方式: + +* 隐式加载:不直接在代码中调用 ClassLoader 的方法加载类对象 + * 创建类对象、使用类的静态域、创建子类对象、使用子类的静态域 + * 在 JVM 启动时,通过三大类加载器加载 class +* 显式加载: + * ClassLoader.loadClass(className):只加载和连接,**不会进行初始化** + * Class.forName(String name, boolean initialize, ClassLoader loader):使用 loader 进行加载和连接,根据参数 initialize 决定是否初始化 + +类的唯一性: + +* 在 JVM 中表示两个 class 对象判断为同一个类存在的两个必要条件: + - 类的完整类名必须一致,包括包名 + - 加载这个类的 ClassLoader(指 ClassLoader 实例对象)必须相同 +* 这里的相等,包括类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果为 true,也包括使用 instanceof 关键字做对象所属关系判定结果为 true + +命名空间: + +- 每个类加载器都有自己的命名空间,命名空间由该加载器及所有的父加载器所加载的类组成 +- 在同一命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类 + +基本特征: + +* **可见性**,子类加载器可以访问父加载器加载的类型,但是反过来是不允许的 +* **单一性**,由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,不会在子加载器中重复加载 + + + +*** + + + +#### 加载器 + +类加载器是 Java 的核心组件,用于加载字节码到 JVM 内存,得到 Class 类的对象 + +从 Java 虚拟机规范来讲,只存在以下两种不同的类加载器: + +- 启动类加载器(Bootstrap ClassLoader):使用 C++ 实现,是虚拟机自身的一部分 +- 自定义类加载器(User-Defined ClassLoader):Java 虚拟机规范**将所有派生于抽象类 ClassLoader 的类加载器都划分为自定义类加载器**,使用 Java 语言实现,独立于虚拟机 + +从 Java 开发人员的角度看: + +* 启动类加载器(Bootstrap ClassLoader): + * 处于安全考虑,Bootstrap 启动类加载器只加载包名为 java、javax、sun 等开头的类 + * 类加载器负责加载在 `JAVA_HOME/jre/lib` 或 `sun.boot.class.path` 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的类,并且是虚拟机识别的类库加载到虚拟机内存中 + * 仅按照文件名识别,如 rt.jar 名字不符合的类库即使放在 lib 目录中也不会被加载 + * 启动类加载器无法被 Java 程序直接引用,编写自定义类加载器时,如果要把加载请求委派给启动类加载器,直接使用 null 代替 +* 扩展类加载器(Extension ClassLoader): + * 由 ExtClassLoader (sun.misc.Launcher$ExtClassLoader) 实现,上级为 Bootstrap,显示为 null + * 将 `JAVA_HOME/jre/lib/ext` 或者被 `java.ext.dir` 系统变量所指定路径中的所有类库加载到内存中 + * 开发者可以使用扩展类加载器,创建的 JAR 放在此目录下,会由扩展类加载器自动加载 +* 应用程序类加载器(Application ClassLoader): + * 由 AppClassLoader(sun.misc.Launcher$AppClassLoader) 实现,上级为 Extension + * 负责加载环境变量 classpath 或系统属性 `java.class.path` 指定路径下的类库 + * 这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此称为系统类加载器 + * 可以直接使用这个类加载器,如果应用程序中没有自定义类加载器,这个就是程序中默认的类加载器 +* 自定义类加载器:由开发人员自定义的类加载器,上级是 Application + +```java +public static void main(String[] args) { + //获取系统类加载器 + ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader(); + System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2 + + //获取其上层 扩展类加载器 + ClassLoader extClassLoader = systemClassLoader.getParent(); + System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@610455d6 + + //获取其上层 获取不到引导类加载器 + ClassLoader bootStrapClassLoader = extClassLoader.getParent(); + System.out.println(bootStrapClassLoader);//null + + //对于用户自定义类来说:使用系统类加载器进行加载 + ClassLoader classLoader = ClassLoaderTest.class.getClassLoader(); + System.out.println(classLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2 + + //String 类使用引导类加载器进行加载的 --> java核心类库都是使用启动类加载器加载的 + ClassLoader classLoader1 = String.class.getClassLoader(); + System.out.println(classLoader1);//null + +} +``` + +补充两个类加载器: + +* SecureClassLoader 扩展了 ClassLoader,新增了几个与使用相关的代码源和权限定义类验证(对 class 源码的访问权限)的方法,一般不会直接跟这个类打交道,更多是与它的子类 URLClassLoader 有所关联 +* ClassLoader 是一个抽象类,很多方法是空的没有实现,而 URLClassLoader 这个实现类为这些方法提供了具体的实现,并新增了 URLClassPath 类协助取得 Class 字节流等功能。在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承 URLClassLoader 类,这样就可以避免去编写 findClass() 方法及其获取字节码流的方式,使自定义类加载器编写更加简洁 + + + +*** + + + +#### 常用API + +ClassLoader 类,是一个抽象类,其后所有的类加载器都继承自 ClassLoader(不包括启动类加载器) + +获取 ClassLoader 的途径: + +* 获取当前类的 ClassLoader:`clazz.getClassLoader()` +* 获取当前线程上下文的 ClassLoader:`Thread.currentThread.getContextClassLoader()` +* 获取系统的 ClassLoader:`ClassLoader.getSystemClassLoader()` +* 获取调用者的 ClassLoader:`DriverManager.getCallerClassLoader()` + +ClassLoader 类常用方法: + +* `getParent()`:返回该类加载器的超类加载器 +* `loadclass(String name)`:加载名为 name 的类,返回结果为 Class 类的实例,**该方法就是双亲委派模式** +* `findclass(String name)`:查找二进制名称为 name 的类,返回结果为 Class 类的实例,该方法会在检查完父类加载器之后被 loadClass() 方法调用 +* `findLoadedClass(String name)`:查找名称为 name 的已经被加载过的类,final 修饰无法重写 +* `defineClass(String name, byte[] b, int off, int len)`:将**字节流**解析成 JVM 能够识别的类对象 +* `resolveclass(Class c)`:链接指定的 Java 类,可以使类的 Class 对象创建完成的同时也被解析 +* `InputStream getResourceAsStream(String name)`:指定资源名称获取输入流 + + + +*** + + + +#### 加载模型 + +##### 加载机制 + +在 JVM 中,对于类加载模型提供了三种,分别为全盘加载、双亲委派、缓存机制 + +- **全盘加载:**当一个类加载器负责加载某个 Class 时,该 Class 所依赖和引用的其他 Class 也将由该类加载器负责载入,除非显示指定使用另外一个类加载器来载入 + +- **双亲委派:**某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父加载器,**依次递归**,如果父加载器可以完成类加载任务,就成功返回;只有当父加载器无法完成此加载任务时,才自己去加载 + +- **缓存机制:**会保证所有加载过的 Class 都会被缓存,当程序中需要使用某个 Class 时,类加载器先从缓存区中搜寻该 Class,只有当缓存区中不存在该 Class 对象时,系统才会读取该类对应的二进制数据,并将其转换成 Class 对象存入缓冲区(方法区)中 + - 这就是修改了 Class 后,必须重新启动 JVM,程序所做的修改才会生效的原因 + + + + + +*** + + + +##### 双亲委派 + +双亲委派模型(Parents Delegation Model):该模型要求除了顶层的启动类加载器外,其它类加载器都要有父类加载器,这里的父子关系一般通过组合关系(Composition)来实现,而不是继承关系(Inheritance) + +工作过程:一个类加载器首先将类加载请求转发到父类加载器,只有当父类加载器无法完成时才尝试自己加载 + +双亲委派机制的优点: + +* 可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证全局唯一性 + +* Java 类随着它的类加载器一起具有一种带有优先级的层次关系,从而使得基础类得到统一 + +* 保护程序安全,防止类库的核心 API 被随意篡改 + + 例如:在工程中新建 java.lang 包,接着在该包下新建 String 类,并定义 main 函数 + + ```java + public class String { + public static void main(String[] args) { + System.out.println("demo info"); + } + } + ``` + + 此时执行 main 函数会出现异常,在类 java.lang.String 中找不到 main 方法。因为双亲委派的机制,java.lang.String 的在启动类加载器(Bootstrap)得到加载,启动类加载器优先级更高,在核心 jre 库中有其相同名字的类文件,但该类中并没有 main 方法 + +双亲委派机制的缺点:检查类是否加载的委托过程是单向的,这个方式虽然从结构上看比较清晰,使各个 ClassLoader 的职责非常明确,但**顶层的 ClassLoader 无法访问底层的 ClassLoader 所加载的类**(可见性) + + + + + +*** + + + +##### 源码分析 + +```java +protected Class loadClass(String name, boolean resolve) + throws ClassNotFoundException { + synchronized (getClassLoadingLock(name)) { + // 调用当前类加载器的 findLoadedClass(name),检查当前类加载器是否已加载过指定 name 的类 + Class c = findLoadedClass(name); + + // 当前类加载器如果没有加载过 + if (c == null) { + long t0 = System.nanoTime(); + try { + // 判断当前类加载器是否有父类加载器 + if (parent != null) { + // 如果当前类加载器有父类加载器,则调用父类加载器的 loadClass(name,false) +          // 父类加载器的 loadClass 方法,又会检查自己是否已经加载过 + c = parent.loadClass(name, false); + } else { + // 当前类加载器没有父类加载器,说明当前类加载器是 BootStrapClassLoader +           // 则调用 BootStrap ClassLoader 的方法加载类 + c = findBootstrapClassOrNull(name); + } + } catch (ClassNotFoundException e) { } + + if (c == null) { + // 如果调用父类的类加载器无法对类进行加载,则用自己的 findClass() 方法进行加载 + // 可以自定义 findClass() 方法 + long t1 = System.nanoTime(); + c = findClass(name); + + // this is the defining class loader; record the stats + sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); + sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); + sun.misc.PerfCounter.getFindClasses().increment(); + } + } + if (resolve) { + // 链接指定的 Java 类,可以使类的 Class 对象创建完成的同时也被解析 + resolveClass(c); + } + return c; + } +} +``` + + + +**** + + + +##### 破坏委派 + +双亲委派模型并不是一个具有强制性约束的模型,而是 Java 设计者推荐给开发者的类加载器实现方式 + +破坏双亲委派模型的方式: + +* 自定义 ClassLoader + + * 如果不想破坏双亲委派模型,只需要重写 findClass 方法 + * 如果想要去破坏双亲委派模型,需要去**重写 loadClass **方法 + +* 引入**线程上下文类加载器** + + Java 提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。常见的有 JDBC、JCE、JNDI 等。这些 SPI 接口由 Java 核心库来提供,而 SPI 的实现代码则是作为 Java 应用所依赖的 jar 包被包含进类路径 classpath 里,SPI 接口中的代码需要加载具体的实现类: + + * SPI 的接口是 Java 核心库的一部分,是由引导类加载器来加载的 + * SPI 的实现类是由系统类加载器加载,引导类加载器是无法找到 SPI 的实现类,因为双亲委派模型中 BootstrapClassloader 无法委派 AppClassLoader 来加载类 + + JDK 开发人员引入了线程上下文类加载器(Thread Context ClassLoader),这种类加载器可以通过 Thread 类的 setContextClassLoader 方法进行设置线程上下文类加载器,在执行线程中抛弃双亲委派加载模式,使程序可以逆向使用类加载器,使 Bootstrap 加载器拿到了 Application 加载器加载的类,破坏了双亲委派模型 + +* 实现程序的动态性,如代码热替换(Hot Swap)、模块热部署(Hot Deployment) + + IBM 公司主导的 JSR一291(OSGiR4.2)实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi 中称为 Bundle)都有一个自己的类加载器,当更换一个 Bundle 时,就把 Bundle 连同类加载器一起换掉以实现代码的热替换,在 OSGi 环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构 + + 当收到类加载请求时,OSGi 将按照下面的顺序进行类搜索: + + 1. 将以 java.* 开头的类,委派给父类加载器加载 + 2. 否则,将委派列表名单内的类,委派给父类加载器加载 + 3. 否则,将 Import 列表中的类,委派给 Export 这个类的 Bundle 的类加载器加载 + 4. 否则,查找当前 Bundle 的 ClassPath,使用自己的类加载器加载 + 5. 否则,查找类是否在自己的 Fragment Bundle 中,如果在就委派给 Fragment Bundle 类加载器加载 + 6. 否则,查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器加载 + 7. 否则,类查找失败 + + 热替换是指在程序的运行过程中,不停止服务,只通过替换程序文件来修改程序的行为,**热替换的关键需求在于服务不能中断**,修改必须立即表现正在运行的系统之中 + + + + + +*** + + + +#### 沙箱机制 + +沙箱机制(Sandbox):将 Java 代码限定在虚拟机特定的运行范围中,并且严格限制代码对本地系统资源访问,来保证对代码的有效隔离,防止对本地系统造成破坏 + +沙箱**限制系统资源访问**,包括 CPU、内存、文件系统、网络,不同级别的沙箱对资源访问的限制也不一样 + +* JDK1.0:Java 中将执行程序分成本地代码和远程代码两种,本地代码默认视为可信任的,而远程代码被看作是不受信的。对于授信的本地代码,可以访问一切本地资源,而对于非授信的远程代码不可以访问本地资源,其实依赖于沙箱机制。如此严格的安全机制也给程序的功能扩展带来障碍,比如当用户希望远程代码访问本地系统的文件时候,就无法实现 +* JDK1.1:针对安全机制做了改进,增加了安全策略。允许用户指定代码对本地资源的访问权限 +* JDK1.2:改进了安全机制,增加了代码签名,不论本地代码或是远程代码都会按照用户的安全策略设定,由类加载器加载到虚拟机中权限不同的运行空间,来实现差异化的代码执行权限控制 +* JDK1.6:当前最新的安全机制,引入了域(Domain)的概念。虚拟机会把所有代码加载到不同的系统域和应用域,不同的保护域对应不一样的权限。系统域部分专门负责与关键资源进行交互,而各个应用域部分则通过系统域的部分代理来对各种需要的资源进行访问 + + + + + +*** + + + +#### 自定义 + +对于自定义类加载器的实现,只需要继承 ClassLoader 类,覆写 findClass 方法即可 + +作用:隔离加载类、修改类加载的方式、拓展加载源、防止源码泄漏 + +```java +//自定义类加载器,读取指定的类路径classPath下的class文件 +public class MyClassLoader extends ClassLoader{ + private String classPath; + + public MyClassLoader(String classPath) { + this.classPath = classPath; + } + + public MyClassLoader(ClassLoader parent, String byteCodePath) { + super(parent); + this.classPath = classPath; + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + BufferedInputStream bis = null; + ByteArrayOutputStream baos = null; + try { + // 获取字节码文件的完整路径 + String fileName = classPath + className + ".class"; + // 获取一个输入流 + bis = new BufferedInputStream(new FileInputStream(fileName)); + // 获取一个输出流 + baos = new ByteArrayOutputStream(); + // 具体读入数据并写出的过程 + int len; + byte[] data = new byte[1024]; + while ((len = bis.read(data)) != -1) { + baos.write(data, 0, len); + } + // 获取内存中的完整的字节数组的数据 + byte[] byteCodes = baos.toByteArray(); + // 调用 defineClass(),将字节数组的数据转换为 Class 的实例。 + Class clazz = defineClass(null, byteCodes, 0, byteCodes.length); + return clazz; + } catch (IOException e) { + e.printStackTrace(); + } finally { + try { + if (baos != null) + baos.close(); + } catch (IOException e) { + e.printStackTrace(); + } + try { + if (bis != null) + bis.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + return null; + } +} +``` + +```java +public static void main(String[] args) { + MyClassLoader loader = new MyClassLoader("D:\Workspace\Project\JVM_study\src\java1\"); + + try { + Class clazz = loader.loadClass("Demo1"); + System.out.println("加载此类的类的加载器为:" + clazz.getClassLoader().getClass().getName());//MyClassLoader + + System.out.println("加载当前类的类的加载器的父类加载器为:" + clazz.getClassLoader().getParent().getClass().getName());//sun.misc.Launcher$AppClassLoader + } catch (ClassNotFoundException e) { + e.printStackTrace(); + } +} +``` + + + +**** + + + +#### JDK9 + +为了保证兼容性,JDK9 没有改变三层类加载器架构和双亲委派模型,但为了模块化系统的顺利运行做了一些变动: + +* 扩展机制被移除,扩展类加载器由于**向后兼容性**的原因被保留,不过被重命名为平台类加载器(platform classloader),可以通过 ClassLoader 的新方法 getPlatformClassLoader() 来获取 + +* JDK9 基于模块化进行构建(原来的 rt.jar 和 tools.jar 被拆分成数个 JMOD 文件),其中 Java 类库就满足了可扩展的需求,那就无须再保留 `\lib\ext` 目录,此前使用这个目录或者 `java.ext.dirs` 系统变量来扩展 JDK 功能的机制就不需要再存在 + +* 启动类加载器、平台类加载器、应用程序类加载器全都继承于 `jdk.internal.loader.BuiltinClassLoader` + + + + + +*** + + + + + +## 运行机制 + +### 执行过程 + +Java 文件编译执行的过程: + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-Java文件编译执行的过程.png) + +- 类加载器:用于装载字节码文件(.class文件) +- 运行时数据区:用于分配存储空间 +- 执行引擎:执行字节码文件或本地方法 +- 垃圾回收器:用于对 JVM 中的垃圾内容进行回收 + + + +**** + + + +### 字节码 + +#### 跨平台性 + +Java 语言:跨平台的语言(write once ,run anywhere) + +* 当 Java 源代码成功编译成字节码后,在不同的平台上面运行**无须再次编译** +* 让一个 Java 程序正确地运行在 JVM 中,Java 源码就必须要被编译为符合 JVM 规范的字节码 + +编译过程中的编译器: + +* 前端编译器: Sun 的全量式编译器 javac、 Eclipse 的增量式编译器 ECJ,**把源代码编译为字节码文件 .class** + + * IntelliJ IDEA 使用 javac 编译器 + * Eclipse 中,当开发人员编写完代码后保存时,ECJ 编译器就会把未编译部分的源码逐行进行编译,而非每次都全量编译,因此 ECJ 的编译效率会比 javac 更加迅速和高效 + * 前端编译器并不会直接涉及编译优化等方面的技术,具体优化细节移交给 HotSpot 的 JIT 编译器负责 + +* 后端运行期编译器:HotSpot VM 的 C1、C2 编译器,也就是 JIT 编译器,Graal 编译器 + + * JIT 编译器:执行引擎部分详解 + * Graal 编译器:JDK10 HotSpot 加入的一个全新的即时编译器,编译效果短短几年时间就追平了 C2 + +* 静态提前编译器:AOT (Ahead Of Time Compiler)编译器,直接把源代码编译成本地机器代码 + + * JDK 9 引入,是与即时编译相对立的一个概念,即时编译指的是在程序的运行过程中将字节码转换为机器码,AOT 是程序运行之前便将字节码转换为机器码 + + * 优点:JVM 加载已经预编译成二进制库,可以直接执行,不必等待即时编译器的预热,减少 Java 应用第一次运行慢的现象 + * 缺点: + * 破坏了 Java **一次编译,到处运行**,必须为每个不同硬件编译对应的发行包 + * 降低了 Java 链接过程的动态性,加载的代码在编译期就必须全部已知 + + + + + +*** + + + +#### 语言发展 + +机器码:各种用二进制编码方式表示的指令,与 CPU 紧密相关,所以不同种类的 CPU 对应的机器指令不同 + +指令:指令就是把机器码中特定的 0 和 1 序列,简化成对应的指令,例如 mov,inc 等,可读性稍好,但是不同的硬件平台的同一种指令(比如 mov),对应的机器码也可能不同 + +指令集:不同的硬件平台支持的指令是有区别的,每个平台所支持的指令,称之为对应平台的指令集 + +- x86 指令集,对应的是 x86 架构的平台 +- ARM 指令集,对应的是 ARM 架构的平台 + +汇编语言:用助记符代替机器指令的操作码,用地址符号或标号代替指令或操作数的地址 + +* 在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令 +* 计算机只认识指令码,汇编语言编写的程序也必须翻译成机器指令码,计算机才能识别和执行 + +高级语言:为了使计算机用户编程序更容易些,后来就出现了各种高级计算机语言 + +字节码:是一种中间状态(中间码)的二进制代码,比机器码更抽象,需要直译器转译后才能成为机器码 + +* 字节码为了实现特定软件运行和软件环境,与硬件环境无关 +* 通过编译器和虚拟机器实现,编译器将源码编译成字节码,虚拟机器将字节码转译为可以直接执行的指令 + + + + + +*** + + + + + +#### 类结构 + +##### 文件结构 + +字节码是一种二进制的类文件,是编译之后供虚拟机解释执行的二进制字节码文件,**一个 class 文件对应一个 public 类型的类或接口** + +字节码内容是 **JVM 的字节码指令**,不是机器码,C、C++ 经由编译器直接生成机器码,所以执行效率比 Java 高 + +JVM 官方文档:https://docs.oracle.com/javase/specs/jvms/se8/html/index.html + +根据 JVM 规范,类文件结构如下: + +```java +ClassFile { + u4 magic; + u2 minor_version; + u2 major_version; + u2 constant_pool_count; + cp_info constant_pool[constant_pool_count-1]; + u2 access_flags; + u2 this_class; + u2 super_class; + u2 interfaces_count; + u2 interfaces[interfaces_count]; + u2 fields_count; + field_info fields[fields_count]; + u2 methods_count; + method_info methods[methods_count]; + u2 attributes_count; + attribute_info attributes[attributes_count]; +} +``` + +| 类型 | 名称 | 说明 | 长度 | 数量 | +| -------------- | ------------------- | -------------------- | ------- | --------------------- | +| u4 | magic | 魔数,识别类文件格式 | 4个字节 | 1 | +| u2 | minor_version | 副版本号(小版本) | 2个字节 | 1 | +| u2 | major_version | 主版本号(大版本) | 2个字节 | 1 | +| u2 | constant_pool_count | 常量池计数器 | 2个字节 | 1 | +| cp_info | constant_pool | 常量池表 | n个字节 | constant_pool_count-1 | +| u2 | access_flags | 访问标识 | 2个字节 | 1 | +| u2 | this_class | 类索引 | 2个字节 | 1 | +| u2 | super_class | 父类索引 | 2个字节 | 1 | +| u2 | interfaces_count | 接口计数 | 2个字节 | 1 | +| u2 | interfaces | 接口索引集合 | 2个字节 | interfaces_count | +| u2 | fields_count | 字段计数器 | 2个字节 | 1 | +| field_info | fields | 字段表 | n个字节 | fields_count | +| u2 | methods_count | 方法计数器 | 2个字节 | 1 | +| method_info | methods | 方法表 | n个字节 | methods_count | +| u2 | attributes_count | 属性计数器 | 2个字节 | 1 | +| attribute_info | attributes | 属性表 | n个字节 | attributes_count | + +Class 文件格式采用一种类似于 C 语言结构体的方式进行数据存储,这种结构中只有两种数据类型:无符号数和表 + +* 无符号数属于基本的数据类型,以 u1、u2、u4、u8 来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串 +* 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,表都以 `_info` 结尾,用于描述有层次关系的数据,整个 Class 文件本质上就是一张表,由于表没有固定长度,所以通常会在其前面加上个数说明 + +获取方式: + +* HelloWorld.java 执行 `javac -parameters -d . HellowWorld.java`指令 +* 写入文件指令 `javap -v xxx.class >xxx.txt` +* IDEA 插件 jclasslib + + + +*** + + + +##### 魔数版本 + +魔数:每个 Class 文件开头的 4 个字节的无符号整数称为魔数(Magic Number),是 Class 文件的标识符,代表这是一个能被虚拟机接受的有效合法的 Class 文件, + +* 魔数值固定为 0xCAFEBABE,不符合则会抛出错误 + +* 使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动 + +版本:4 个 字节,5 6两个字节代表的是编译的副版本号 minor_version,而 7 8 两个字节是编译的主版本号 major_version + +* 不同版本的 Java 编译器编译的 Class 文件对应的版本是不一样的,高版本的 Java 虚拟机可以执行由低版本编译器生成的 Class 文件,反之 JVM 会抛出异常 `java.lang.UnsupportedClassVersionError` + +| 主版本(十进制) | 副版本(十进制) | 编译器版本 | +| ---------------- | ---------------- | ---------- | +| 45 | 3 | 1.1 | +| 46 | 0 | 1.2 | +| 47 | 0 | 1.3 | +| 48 | 0 | 1.4 | +| 49 | 0 | 1.5 | +| 50 | 0 | 1.6 | +| 51 | 0 | 1.7 | +| 52 | 0 | 1.8 | +| 53 | 0 | 1.9 | +| 54 | 0 | 1.10 | +| 55 | 0 | 1.11 | + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-类结构.png) + + + +图片来源:https://www.bilibili.com/video/BV1PJ411n7xZ + + + +*** + + + +##### 常量池 + +常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项 u2 类型的无符号数,代表常量池计数器(constant_pool_count),这个容量计数是从 1 而不是 0 开始,是为了满足后面某些指向常量池的索引值的数据在特定情况下需要表达不引用任何一个常量池项目,这种情况可用索引值 0 来表示 + +constant_pool 是一种表结构,以1 ~ constant_pool_count - 1为索引,表明有多少个常量池表项。表项中存放编译时期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池 + +* 字面量(Literal) :基本数据类型、字符串类型常量、声明为 final 的常量值等 + +* 符号引用(Symbolic References):类和接口的全限定名、字段的名称和描述符、方法的名称和描述符 + + * 全限定名:com/test/Demo 这个就是类的全限定名,仅仅是把包名的 `.` 替换成 `/`,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个 `;` 表示全限定名结束 + + * 简单名称:指没有类型和参数修饰的方法或者字段名称,比如字段 x 的简单名称就是 x + + * 描述符:用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值 + + | 标志符 | 含义 | + | ------ | --------------------------------------------------------- | + | B | 基本数据类型 byte | + | C | 基本数据类型 char | + | D | 基本数据类型 double | + | F | 基本数据类型 float | + | I | 基本数据类型 int | + | J | 基本数据类型 long | + | S | 基本数据类型 short | + | Z | 基本数据类型 boolean | + | V | 代表 void 类型 | + | L | 对象类型,比如:`Ljava/lang/Object;`,不同方法间用`;`隔开 | + | [ | 数组类型,代表一维数组。比如:`double[][][] is [[[D` | + +常量类型和结构: + +| 类型 | 标志(或标识) | 描述 | +| -------------------------------- | ------------ | ---------------------- | +| CONSTANT_utf8_info | 1 | UTF-8编码的字符串 | +| CONSTANT_Integer_info | 3 | 整型字面量 | +| CONSTANT_Float_info | 4 | 浮点型字面量 | +| CONSTANT_Long_info | 5 | 长整型字面量 | +| CONSTANT_Double_info | 6 | 双精度浮点型字面量 | +| CONSTANT_Class_info | 7 | 类或接口的符号引用 | +| CONSTANT_String_info | 8 | 字符串类型字面量 | +| CONSTANT_Fieldref_info | 9 | 字段的符号引用 | +| CONSTANT_Methodref_info | 10 | 类中方法的符号引用 | +| CONSTANT_InterfaceMethodref_info | 11 | 接口中方法的符号引用 | +| CONSTANT_NameAndType_info | 12 | 字段或方法的符号引用 | +| CONSTANT_MethodHandle_info | 15 | 表示方法句柄 | +| CONSTANT_MethodType_info | 16 | 标志方法类型 | +| CONSTANT_InvokeDynamic_info | 18 | 表示一个动态方法调用点 | + +18 种常量没有出现 byte、short、char,boolean 的原因:编译之后都可以理解为 Integer + + + +**** + + + +##### 访问标识 + +访问标识(access_flag),又叫访问标志、访问标记,该标识用两个字节表示,用于识别一些类或者接口层次的访问信息,包括这个 Class 是类还是接口,是否定义为 public类型,是否定义为 abstract类型等 + +* 类的访问权限通常为 ACC_ 开头的常量 +* 每一种类型的表示都是通过设置访问标记的 32 位中的特定位来实现的,比如若是 public final 的类,则该标记为 `ACC_PUBLIC | ACC_FINAL` +* 使用 `ACC_SUPER` 可以让类更准确地定位到父类的方法,确定类或接口里面的 invokespecial 指令使用的是哪一种执行语义,现代编译器都会设置并且使用这个标记 + +| 标志名称 | 标志值 | 含义 | +| -------------- | ------ | ------------------------------------------------------------ | +| ACC_PUBLIC | 0x0001 | 标志为 public 类型 | +| ACC_FINAL | 0x0010 | 标志被声明为 final,只有类可以设置 | +| ACC_SUPER | 0x0020 | 标志允许使用 invokespecial 字节码指令的新语义,JDK1.0.2之后编译出来的类的这个标志默认为真,使用增强的方法调用父类方法 | +| ACC_INTERFACE | 0x0200 | 标志这是一个接口 | +| ACC_ABSTRACT | 0x0400 | 是否为 abstract 类型,对于接口或者抽象类来说,次标志值为真,其他类型为假 | +| ACC_SYNTHETIC | 0x1000 | 标志此类并非由用户代码产生(由编译器产生的类,没有源码对应) | +| ACC_ANNOTATION | 0x2000 | 标志这是一个注解 | +| ACC_ENUM | 0x4000 | 标志这是一个枚举 | + + + +*** + + + +##### 索引集合 + +类索引、父类索引、接口索引集合 + +* 类索引用于确定这个类的全限定名 + +* 父类索引用于确定这个类的父类的全限定名,Java 语言不允许多重继承,所以父类索引只有一个,除了Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为0 + +* 接口索引集合就用来描述这个类实现了哪些接口 + * interfaces_count 项的值表示当前类或接口的直接超接口数量 + * interfaces[] 接口索引集合,被实现的接口将按 implements 语句后的接口顺序从左到右排列在接口索引集合中 + +| 长度 | 含义 | +| ---- | ---------------------------- | +| u2 | this_class | +| u2 | super_class | +| u2 | interfaces_count | +| u2 | interfaces[interfaces_count] | + + + +*** + + + +##### 字段表 + +字段 fields 用于描述接口或类中声明的变量,包括类变量以及实例变量,但不包括方法内部、代码块内部声明的局部变量以及从父类或父接口继承。字段叫什么名字、被定义为什么数据类型,都是无法固定的,只能引用常量池中的常量来描述 + +fields_count(字段计数器),表示当前 class 文件 fields 表的成员个数,用两个字节来表示 + +fields[](字段表): + +* 表中的每个成员都是一个 fields_info 结构的数据项,用于表示当前类或接口中某个字段的完整描述 + +* 字段访问标识: + + | 标志名称 | 标志值 | 含义 | + | ------------- | ------ | -------------------------- | + | ACC_PUBLIC | 0x0001 | 字段是否为public | + | ACC_PRIVATE | 0x0002 | 字段是否为private | + | ACC_PROTECTED | 0x0004 | 字段是否为protected | + | ACC_STATIC | 0x0008 | 字段是否为static | + | ACC_FINAL | 0x0010 | 字段是否为final | + | ACC_VOLATILE | 0x0040 | 字段是否为volatile | + | ACC_TRANSTENT | 0x0080 | 字段是否为transient | + | ACC_SYNCHETIC | 0x1000 | 字段是否为由编译器自动产生 | + | ACC_ENUM | 0x4000 | 字段是否为enum | + +* 字段名索引:根据该值查询常量池中的指定索引项即可 + +* 描述符索引:用来描述字段的数据类型、方法的参数列表和返回值 + + | 字符 | 类型 | 含义 | + | ----------- | --------- | ----------------------- | + | B | byte | 有符号字节型树 | + | C | char | Unicode字符,UTF-16编码 | + | D | double | 双精度浮点数 | + | F | float | 单精度浮点数 | + | I | int | 整型数 | + | J | long | 长整数 | + | S | short | 有符号短整数 | + | Z | boolean | 布尔值true/false | + | V | void | 代表void类型 | + | L Classname | reference | 一个名为Classname的实例 | + | [ | reference | 一个一维数组 | + +* 属性表集合:属性个数存放在 attribute_count 中,属性具体内容存放在 attribute 数组中,一个字段还可能拥有一些属性,用于存储更多的额外信息,比如初始化值、一些注释信息等 + + ```java + ConstantValue_attribute{ + u2 attribute_name_index; + u4 attribute_length; + u2 constantvalue_index; + } + ``` + + 对于常量属性而言,attribute_length 值恒为2 + + + +*** + + + +##### 方法表 + +方法表是 methods 指向常量池索引集合,其中每一个 method_info 项都对应着一个类或者接口中的方法信息,完整描述了每个方法的签名 + +* 如果这个方法不是抽象的或者不是 native 的,字节码中就会体现出来 +* methods 表只描述当前类或接口中声明的方法,不包括从父类或父接口继承的方法 +* methods 表可能会出现由编译器自动添加的方法,比如初始化方法 和实例化方法 + +**重载(Overload)**一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,因为返回值不会包含在特征签名之中,因此 Java 语言里无法仅仅依靠返回值的不同来对一个已有方法进行重载。但在 Class 文件格式中,特征签名的范围更大一些,只要描述符不是完全一致的两个方法就可以共存 + +methods_count(方法计数器):表示 class 文件 methods 表的成员个数,使用两个字节来表示 + +methods[](方法表):每个表项都是一个 method_info 结构,表示当前类或接口中某个方法的完整描述 + +* 方法表结构如下: + + | 类型 | 名称 | 含义 | 数量 | + | -------------- | ---------------- | ---------- | ---------------- | + | u2 | access_flags | 访问标志 | 1 | + | u2 | name_index | 字段名索引 | 1 | + | u2 | descriptor_index | 描述符索引 | 1 | + | u2 | attrubutes_count | 属性计数器 | 1 | + | attribute_info | attributes | 属性集合 | attributes_count | + +* 方法表访问标志: + + | 标志名称 | 标志值 | 含义 | + | ------------- | ------ | -------------------------- | + | ACC_PUBLIC | 0x0001 | 字段是否为 public | + | ACC_PRIVATE | 0x0002 | 字段是否为 private | + | ACC_PROTECTED | 0x0004 | 字段是否为 protected | + | ACC_STATIC | 0x0008 | 字段是否为 static | + | ACC_FINAL | 0x0010 | 字段是否为 final | + | ACC_VOLATILE | 0x0040 | 字段是否为 volatile | + | ACC_TRANSTENT | 0x0080 | 字段是否为 transient | + | ACC_SYNCHETIC | 0x1000 | 字段是否为由编译器自动产生 | + | ACC_ENUM | 0x4000 | 字段是否为 enum | + + + +*** + + + +##### 属性表 + +属性表集合,指的是 Class 文件所携带的辅助信息,比如该 Class 文件的源文件的名称,以及任何带有 `RetentionPolicy.CLASS` 或者 `RetentionPolicy.RUNTIME` 的注解,这类信息通常被用于 Java 虚拟机的验证和运行,以及 Java 程序的调试。字段表、方法表都可以有自己的属性表,用于描述某些场景专有的信息 + +attributes_ count(属性计数器):表示当前文件属性表的成员个数 + +attributes[](属性表):属性表的每个项的值必须是 attribute_info 结构 + +* 属性的通用格式: + + ```java + ConstantValue_attribute{ + u2 attribute_name_index; //属性名索引 + u4 attribute_length; //属性长度 + u2 attribute_info; //属性表 + } + ``` + +* 属性类型: + + | 属性名称 | 使用位置 | 含义 | + | ------------------------------------- | ------------------ | ------------------------------------------------------------ | + | Code | 方法表 | Java 代码编译成的字节码指令 | + | ConstantValue | 字段表 | final 关键字定义的常量池 | + | Deprecated | 类、方法、字段表 | 被声明为 deprecated 的方法和字段 | + | Exceptions | 方法表 | 方法抛出的异常 | + | EnclosingMethod | 类文件 | 仅当一个类为局部类或者匿名类是才能拥有这个属性,这个属性用于标识这个类所在的外围方法 | + | InnerClass | 类文件 | 内部类列表 | + | LineNumberTable | Code 属性 | Java 源码的行号与字节码指令的对应关系 | + | LocalVariableTable | Code 属性 | 方法的局部变量描述 | + | StackMapTable | Code 属性 | JDK1.6 中新增的属性,供新的类型检查检验器检查和处理目标方法的局部变量和操作数有所需要的类是否匹配 | + | Signature | 类,方法表,字段表 | 用于支持泛型情况下的方法签名 | + | SourceFile | 类文件 | 记录源文件名称 | + | SourceDebugExtension | 类文件 | 用于存储额外的调试信息 | + | Syothetic | 类,方法表,字段表 | 标志方法或字段为编泽器自动生成的 | + | LocalVariableTypeTable | 类 | 使用特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加 | + | RuntimeVisibleAnnotations | 类,方法表,字段表 | 为动态注解提供支持 | + | RuntimelnvisibleAnnotations | 类,方法表,字段表 | 用于指明哪些注解是运行时不可见的 | + | RuntimeVisibleParameterAnnotation | 方法表 | 作用与 RuntimeVisibleAnnotations 属性类似,只不过作用对象为方法 | + | RuntirmelnvisibleParameterAnniotation | 方法表 | 作用与 RuntimelnvisibleAnnotations 属性类似,作用对象哪个为方法参数 | + | AnnotationDefauit | 方法表 | 用于记录注解类元素的默认值 | + | BootstrapMethods | 类文件 | 用于保存 invokeddynanic 指令引用的引导方式限定符 | + + + + + +**** + + + +#### 编译指令 + +##### javac + +javac:编译命令,将 java 源文件编译成 class 字节码文件 + +`javac xx.java` 不会在生成对应的局部变量表等信息,使用 `javac -g xx.java` 可以生成所有相关信息 + + + +**** + + + +##### javap + +javap 反编译生成的字节码文件,根据 class 字节码文件,反解析出当前类对应的 code 区 (字节码指令)、局部变量表、异常表和代码行偏移量映射表、常量池等信息 + +用法:javap + +```sh +-help --help -? 输出此用法消息 +-version 版本信息 +-public 仅显示公共类和成员 +-protected 显示受保护的/公共类和成员 +-package 显示程序包/受保护的/公共类和成员 (默认) +-p -private 显示所有类和成员 + #常用的以下三个 +-v -verbose 输出附加信息 +-l 输出行号和本地变量表 +-c 对代码进行反汇编 #反编译 + +-s 输出内部类型签名 +-sysinfo 显示正在处理的类的系统信息 (路径, 大小, 日期, MD5 散列) +-constants 显示最终常量 +-classpath 指定查找用户类文件的位置 +-cp 指定查找用户类文件的位置 +-bootclasspath 覆盖引导类文件的位置 +``` + + + +*** + + + +#### 指令集 + +##### 执行指令 + +Java 字节码属于 JVM 基本执行指令。由一个字节长度的代表某种操作的操作码(opcode)以及零至多个代表此操作所需参数的操作数(operand)所构成,虚拟机中许多指令并不包含操作数,只有一个操作码(零地址指令) + +由于限制了 Java 虚拟机操作码的长度为一个字节(0~255),所以指令集的操作码总数不可能超过 256 条 + +在 JVM 的指令集中,大多数的指令都包含了其操作所对应的数据类型信息。例如 iload 指令用于从局部变量表中加载 int 型的数据到操作数栈中,而 fload 指令加载的则是 float 类型的数据 + +* i 代表对 int 类型的数据操作 +* l 代表 long +* s 代表 short +* b 代表 byte +* c 代表 char +* f 代表 float +* d 代表 double + +大部分的指令都没有支持 byte、char、short、boolean 类型,编译器会在编译期或运行期将 byte 和 short 类型的数据带符号扩展(Sign-Extend-)为相应的 int 类型数据,将 boolean 和 char 类型数据零位扩展(Zero-Extend)为相应的 int 类型数据 + +在做值相关操作时: + +- 一个指令,可以从局部变量表、常量池、堆中对象、方法调用、系统调用中等取得数据,这些数据(可能是值,也可能是对象的引用)被压入操作数栈 +- 一个指令,也可以从操作数栈中取出一到多个值(pop 多次),完成赋值、加减乘除、方法传参、系统调用等等操作 + + + +*** + + + +##### 加载存储 + +加载和存储指令用于将数据从栈帧的局部变量表和操作数栈之间来回传递 + +局部变量压栈指令:将给定的局部变量表中的数据压入操作数栈 + +* xload、xload_n,x 表示取值数据类型,为 i、l、f、d、a, n 为 0 到 3 +* 指令 xload_n 表示将第 n 个局部变量压入操作数栈,aload_n 表示将一个对象引用压栈 +* 指令 xload n 通过指定参数的形式,把局部变量压入操作数栈,局部变量数量超过 4 个时使用这个命令 + +常量入栈指令:将常数压入操作数栈,根据数据类型和入栈内容的不同,又分为 const、push、ldc 指令 + +* push:包括 bipush 和 sipush,区别在于接收数据类型的不同,bipush 接收 8 位整数作为参数,sipush 接收 16 位整数 +* ldc:如果以上指令不能满足需求,可以使用 ldc 指令,接收一个 8 位的参数,该参数指向常量池中的 int、 float 或者 String 的索引,将指定的内容压入堆栈。ldc_w 接收两个 8 位参数,能支持的索引范围更大,如果要压入的元素是 long 或 double 类型的,则使用 ldc2_w 指令 +* aconst_null 将 null 对象引用压入栈,iconst_m1 将 int 类型常量 -1 压入栈,iconst_0 将 int 类型常量 0 压入栈 + +出栈装入局部变量表指令:将操作数栈中栈顶元素弹出后,装入局部变量表的指定位置,用于给局部变量赋值 + +* xstore、xstore_n,x 表示取值类型为 i、l、f、d、a, n 为 0 到 3 +* xastore 表示存入数组,x 取值为 i、l、f、d、a、b、c、s + +扩充局部变量表的访问索引的指令:wide + + + +**** + + + +##### 算术指令 + +算术指令用于对两个操作数栈上的值进行某种特定运算,并把计算结果重新压入操作数栈 + +没有直接支持 byte、 short、 char 和 boolean 类型的算术指令,对于这些数据的运算,都使用 int 类型的指令来处理,数组类型也是转换成 int 数组 + +* 加法指令:iadd、ladd、fadd、dadd +* 减法指令:isub、lsub、fsub、dsub +* 乘法指令:imu、lmu、fmul、dmul +* 除法指令:idiv、ldiv、fdiv、ddiv +* 求余指令:irem、lrem、frem、drem(remainder 余数) +* 取反指令:ineg、lneg、fneg、dneg (negation 取反) +* 自增指令:iinc(直接**在局部变量 slot 上进行运算**,不用放入操作数栈) +* 位运算指令,又可分为: + - 位移指令:ishl、ishr、 iushr、lshl、lshr、 lushr + - 按位或指令:ior、lor + - 按位与指令:iand、land + - 按位异或指令:ixor、lxor + +* 比较指令:dcmpg、dcmpl、 fcmpg、fcmpl、lcmp + +运算模式: + +* 向最接近数舍入模式,JVM 在进行浮点数计算时,所有的运算结果都必须舍入到适当的精度,非精确结果必须舍入为可被表示的最接近的精确值,如果有两种可表示形式与该值一样接近,将优先选择最低有效位为零的 +* 向零舍入模式:将浮点数转换为整数时,该模式将在目标数值类型中选择一个最接近但是不大于原值的数字作为最精确的舍入结果 + +NaN 值:当一个操作产生溢出时,将会使用有符号的无穷大表示,如果某个操作结果没有明确的数学定义,将使用 NaN 值来表示 + +```java +double j = i / 0.0; +System.out.println(j);//无穷大,NaN: not a number +``` + +**分析 i++**:从字节码角度分析:a++ 和 ++a 的区别是先执行 iload 还是先执行 iinc + +```java + 4 iload_1 //存入操作数栈 + 5 iinc 1 by 1 //自增i++ + 8 istore_3 //把操作数栈没有自增的数据的存入局部变量表 + 9 iinc 2 by 1 //++i +12 iload_2 //加载到操作数栈 +13 istore 4 //存入局部变量表,这个存入没有 _ 符号,_只能到3 +``` + +```java +public class Demo { + public static void main(String[] args) { + int a = 10; + int b = a++ + ++a + a--; + System.out.println(a); //11 + System.out.println(b); //34 + } +} +``` + +判断结果: + +```java +public class Demo { + public static void main(String[] args) { + int i = 0; + int x = 0; + while (i < 10) { + x = x++; + i++; + } + System.out.println(x); // 结果是 0 + } +} +``` + + + +*** + + + +##### 类型转换 + +类型转换指令可以将两种不同的数值类型进行相互转换,除了 boolean 之外的七种类型 + +宽化类型转换: + +* JVM 支持以下数值的宽化类型转换(widening numeric conversion),小范围类型到大范围类型的安全转换 + * 从 int 类型到 long、float 或者 double 类型,对应的指令为 i2l、i2f、i2d + * 从 long 类型到 float、 double 类型,对应的指令为 l2f、l2d + * 从 float 类型到 double 类型,对应的指令为 f2d + +* 精度损失问题 + * 宽化类型转换是不会因为超过目标类型最大值而丢失信息 + * 从 int 转换到 float 或者 long 类型转换到 double 时,将可能发生精度丢失 + +* 从 byte、char 和 short 类型到 int 类型的宽化类型转换实际上是不存在的,JVM 把它们当作 int 处理 + +窄化类型转换: + +* Java 虚拟机直接支持以下窄化类型转换: + * 从 int 类型至 byte、 short 或者 char 类型,对应的指令有 i2b、i2c、i2s + * 从 long 类型到 int 类型,对应的指令有 l2i + * 从 float 类型到 int 或者 long 类型,对应的指令有:f2i、f2l + * 从 double 类型到 int、long 或 float 者类型,对应的指令有 d2i、d2、d2f + +* 精度损失问题: + * 窄化类型转换可能会导致转换结果具备不同的正负号、不同的数量级,转换过程可能会导致数值丢失精度 + * 将一个浮点值窄化转换为整数类型 T(T 限于 int 或 long 类型之一)时,将遵循以下转换规则: + - 如果浮点值是 NaN,那转换结果就是 int 或 long 类型的 0 + - 如果浮点值不是无穷大的话,浮点值使用 IEEE 754 的向零舍入模式取整,获得整数值 v,如果 v 在目标类型 T 的表示范围之内,那转换结果就是 v,否则将根据 v 的符号,转换为 T 所能表示的最大或者最小正数 + + + +*** + + + +##### 创建访问 + +创建指令: + +* 创建类实例指令:new,接收一个操作数指向常量池的索引,表示要创建的类型,执行完成后将对象的引用压入栈 + + ```java + 0: new #2 // class com/jvm/bytecode/Demo + 3: dup + 4: invokespecial #3 // Method "":()V + ``` + + **dup 是复制操作数栈栈顶的内容**,需要两份引用原因: + + - 一个要配合 invokespecial 调用该对象的构造方法 :()V (会消耗掉栈顶一个引用) + - 一个要配合 astore_1 赋值给局部变量 + +* 创建数组的指令:newarray、anewarray、multianewarray + + * newarray:创建基本类型数组 + * anewarray:创建引用类型数组 + * multianewarray:创建多维数组 + +字段访问指令:对象创建后可以通过对象访问指令获取对象实例或数组实例中的字段或者数组元素 + +* 访问类字段(static字段,或者称为类变量)的指令:getstatic、putstatic +* 访问类实例字段(非static字段,或者称为实例变量)的指令:getfield、 putfield + +类型检查指令:检查类实例或数组类型的指令 + +* checkcast:用于检查类型强制转换是否可以进行,如果可以进行 checkcast 指令不会改变操作数栈,否则它会抛出 ClassCastException 异常 + +* instanceof:判断给定对象是否是某一个类的实例,会将判断结果压入操作数栈 + + + + +**** + + + +##### 方法指令 + +方法调用指令:invokevirtual、 invokeinterface、invokespecial、invokestatic、invokedynamic + +**方法调用章节详解** + + + +*** + + + +##### 操作数栈 + +JVM 提供的操作数栈管理指令,可以用于直接操作操作数栈的指令 + +* pop、pop2:将一个或两个元素从栈顶弹出,并且直接废弃 +* dup、dup2,dup_x1、dup2_x1,dup_x2、dup2_x2:复制栈顶一个或两个数值并重新压入栈顶 + +* swap:将栈最顶端的两个 slot 数值位置交换,JVM 没有提供交换两个 64 位数据类型数值的指令 + +* nop:一个非常特殊的指令,字节码为 0x00,和汇编语言中的 nop 一样,表示什么都不做,一般可用于调试、占位等 + + + +*** + + + +##### 控制转移 + + +比较指令:比较栈顶两个元素的大小,并将比较结果入栈 + +* lcmp:比较两个 long 类型值 +* fcmpl:比较两个 float 类型值(当遇到NaN时,返回-1) +* fcmpg:比较两个 float 类型值(当遇到NaN时,返回1) +* dcmpl:比较两个 double 类型值(当遇到NaN时,返回-1) +* dcmpg:比较两个 double 类型值(当遇到NaN时,返回1) + +条件跳转指令: + +| 指令 | 说明 | +| --------- | -------------------------------------------------- | +| ifeq | equals,当栈顶int类型数值等于0时跳转 | +| ifne | not equals,当栈顶in类型数值不等于0时跳转 | +| iflt | lower than,当栈顶in类型数值小于0时跳转 | +| ifle | lower or equals,当栈顶in类型数值小于等于0时跳转 | +| ifgt | greater than,当栈顶int类型数组大于0时跳转 | +| ifge | greater or equals,当栈顶in类型数值大于等于0时跳转 | +| ifnull | 为 null 时跳转 | +| ifnonnull | 不为 null 时跳转 | + +比较条件跳转指令: + +| 指令 | 说明 | +| --------- | --------------------------------------------------------- | +| if_icmpeq | 比较栈顶两 int 类型数值大小(下同),当前者等于后者时跳转 | +| if_icmpne | 当前者不等于后者时跳转 | +| if_icmplt | 当前者小于后者时跳转 | +| if_icmple | 当前者小于等于后者时跳转 | +| if_icmpgt | 当前者大于后者时跳转 | +| if_icmpge | 当前者大于等于后者时跳转 | +| if_acmpeq | 当结果相等时跳转 | +| if_acmpne | 当结果不相等时跳转 | + +多条件分支跳转指令: + +* tableswitch:用于 switch 条件跳转,case 值连续 +* lookupswitch:用于 switch 条件跳转,case 值不连续 + +无条件跳转指令: + +* goto:用来进行跳转到指定行号的字节码 + +* goto_w:无条件跳转(宽索引) + + + + + +*** + + + +##### 异常处理 + +###### 处理机制 + +抛出异常指令:athrow 指令 + +JVM 处理异常(catch 语句)不是由字节码指令来实现的,而是**采用异常表来完成**的 + +* 代码: + + ```java + public static void main(String[] args) { + int i = 0; + try { + i = 10; + } catch (Exception e) { + i = 20; + } finally { + i = 30; + } + } + ``` + +* 字节码: + + * 多出一个 **Exception table** 的结构,**[from, to) 是前闭后开的检测范围**,一旦这个范围内的字节码执行出现异常,则通过 type 匹配异常类型,如果一致,进入 target 所指示行号 + * 11 行的字节码指令 astore_2 是将异常对象引用存入局部变量表的 slot 2 位置,因为异常出现时,只能进入 Exception table 中一个分支,所以局部变量表 slot 2 位置被共用 + + ```java + 0: iconst_0 + 1: istore_1 // 0 -> i ->赋值 + 2: bipush 10 // try 10 放入操作数栈顶 + 4: istore_1 // 10 -> i 将操作数栈顶数据弹出,存入局部变量表的 slot1 + 5: bipush 30 // 【finally】 + 7: istore_1 // 30 -> i + 8: goto 27 // return ----------------------------------- + 11: astore_2 // catch Exceptin -> e ---------------------- + 12: bipush 20 // + 14: istore_1 // 20 -> i + 15: bipush 30 // 【finally】 + 17: istore_1 // 30 -> i + 18: goto 27 // return ----------------------------------- + 21: astore_3 // catch any -> slot 3 ---------------------- + 22: bipush 30 // 【finally】 + 24: istore_1 // 30 -> i + 25: aload_3 // 将局部变量表的slot 3数据弹出,放入操作数栈栈顶 + 26: athrow // throw 抛出异常 + 27: return + Exception table: + // 任何阶段出现任务异常都会执行 finally + from to target type + 2 5 11 Class java/lang/Exception + 2 5 21 any // 剩余的异常类型,比如 Error + 11 15 21 any // 剩余的异常类型,比如 Error + LineNumberTable: ... + LocalVariableTable: + Start Length Slot Name Signature + 12 3 2 e Ljava/lang/Exception; + 0 28 0 args [Ljava/lang/String; + 2 26 1 i I + ``` + + + +*** + + + +###### finally + +finally 中的代码被**复制了 3 份**,分别放入 try 流程,catch 流程以及 catch 剩余的异常类型流程(上节案例) + +* 代码: + + ```java + public static int test() { + try { + return 10; + } finally { + return 20; + } + } + ``` + +* 字节码: + + ```java + 0: bipush 10 // 10 放入栈顶 + 2: istore_0 // 10 -> slot 0 【从栈顶移除了】 + 3: bipush 20 // 20 放入栈顶 + 5: ireturn // 返回栈顶 int(20) + 6: astore_1 // catch any 存入局部变量表的 slot1 + 7: bipush 20 // 20 放入栈顶 + 9: ireturn // 返回栈顶 int(20) + Exception table: + from to target type + 0 3 6 any + ``` + + + +*** + + + +###### return + +* 吞异常 + + ```java + public static int test() { + try { + return 10; + } finally { + return 20; + } + } + ``` + + ```java + 0: bipush 10 // 10 放入栈顶 + 2: istore_0 // 10 -> slot 0 【从栈顶移除了】 + 3: bipush 20 // 20 放入栈顶 + 5: ireturn // 返回栈顶 int(20) + 6: astore_1 // catch any 存入局部变量表的 slot1 + 7: bipush 20 // 20 放入栈顶 + 9: ireturn // 返回栈顶 int(20) + Exception table: + from to target type + 0 3 6 any + ``` + + * 由于 finally 中的 ireturn 被插入了所有可能的流程,因此返回结果以 finally 的为准 + * 字节码中没有 **athrow** ,表明如果在 finally 中出现了 return,会**吞掉异常** + +* 不吞异常 + + ```java + public class Demo { + public static void main(String[] args) { + int result = test(); + System.out.println(result);//10 + } + public static int test() { + int i = 10; + try { + return i;//返回10 + } finally { + i = 20; + } + } + } + ``` + + ```java + 0: bipush 10 // 10 放入栈顶 + 2: istore_0 // 10 赋值给i,放入slot 0 + 3: iload_0 // i(10)加载至操作数栈 + 4: istore_1 // 10 -> slot 1,【暂存至 slot 1,目的是为了固定返回值】 + 5: bipush 20 // 20 放入栈顶 + 7: istore_0 // 20 slot 0 + 8: iload_1 // slot 1(10) 载入 slot 1 暂存的值 + 9: ireturn // 返回栈顶的 int(10) + 10: astore_2 // catch any -> slot 2 存入局部变量表的 slot2 + 11: bipush 20 + 13: istore_0 + 14: aload_2 + 15: athrow // 不会吞掉异常 + Exception table: + from to target type + 3 5 10 any + ``` + + + +*** + + + +##### 同步控制 + +方法级的同步:是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中,虚拟机可以从方法常量池的方法表结构中的 ACC_SYNCHRONIZED 访问标志得知一个方法是否声明为同步方法 + +方法内指定指令序列的同步:有 monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字的语义 + +* montiorenter:进入并获取对象监视器,即为栈顶对象加锁 +* monitorexit:释放并退出对象监视器,即为栈顶对象解锁 + + + + + + + +*** + + + +#### 执行流程 + +原始 Java 代码: + +```java +public class Demo { + public static void main(String[] args) { + int a = 10; + int b = Short.MAX_VALUE + 1; + int c = a + b; + System.out.println(c); + } +} +``` + +javap -v Demo.class:省略 + +* 常量池载入运行时常量池 + +* 方法区字节码载入方法区 + +* main 线程开始运行,分配栈帧内存:(操作数栈stack=2,局部变量表locals=4) + +* **执行引擎**开始执行字节码 + + `bipush 10`:将一个 byte 压入操作数栈(其长度会补齐 4 个字节),类似的指令 + + * sipush 将一个 short 压入操作数栈(其长度会补齐 4 个字节) + * ldc 将一个 int 压入操作数栈 + * ldc2_w 将一个 long 压入操作数栈(分两次压入,因为 long 是 8 个字节) + * 这里小的数字都是和字节码指令存在一起,超过 short 范围的数字存入了常量池 + + `istore_1`:将操作数栈顶数据弹出,存入局部变量表的 slot 1 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-字节码执行流程1.png) + + `ldc #3`:从常量池加载 #3 数据到操作数栈 + Short.MAX_VALUE 是 32767,所以 32768 = Short.MAX_VALUE + 1 实际是在编译期间计算完成 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-字节码执行流程2.png) + + `istore_2`:将操作数栈顶数据弹出,存入局部变量表的 slot 2 + + `iload_1`:将局部变量表的 slot 1 数据弹出,放入操作数栈栈顶 + + `iload_2`:将局部变量表的 slot 2 数据弹出,放入操作数栈栈顶 + + `iadd`:执行相加操作 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-字节码执行流程3.png) + + `istore_3`:将操作数栈顶数据弹出,存入局部变量表的 slot 3 + + `getstatic #4`:获取静态字段 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-字节码执行流程4.png) + + `iload_3`: + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-字节码执行流程5.png) + + `invokevirtual #5`: + + * 找到常量池 #5 项 + * 定位到方法区 java/io/PrintStream.println:(I)V 方法 + * **生成新的栈帧**(分配 locals、stack等) + * 传递参数,执行新栈帧中的字节码 + * 执行完毕,弹出栈帧 + * 清除 main 操作数栈内容 + + ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-字节码执行流程6.png) + + return:完成 main 方法调用,弹出 main 栈帧,程序结束 + + + + + +*** + + + +### 执行引擎 + +#### 基本介绍 + +执行引擎:Java 虚拟机的核心组成部分之一,类加载主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,需要执行引擎将**字节码指令解释/编译为对应平台上的本地机器指令**,进行执行 + +虚拟机是一个相对于物理机的概念,这两种机器都有代码执行能力: + +* 物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上 +* 虚拟机的执行引擎是由软件自行实现的,可以不受物理条件制约地定制指令集与执行引擎的结构体系 + +Java 是**半编译半解释型语言**,将解释执行与编译执行二者结合起来进行: + +* 解释器:根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容翻译为对应平台的本地机器指令执行 +* 即时编译器(JIT : Just In Time Compiler):虚拟机运行时将源代码直接编译成**和本地机器平台相关的机器码**后再执行,并存入 Code Cache,下次遇到相同的代码直接执行,效率高 + + + +*** + + + +#### 执行方式 + +HotSpot VM 采用**解释器与即时编译器并存的架构**,解释器和即时编译器能够相互协作,去选择最合适的方式来权衡编译本地代码和直接解释执行代码的时间 + +HostSpot JVM 的默认执行方式: + +* 当程序启动后,解释器可以马上发挥作用立即执行,省去编译器编译的时间(解释器存在的**必要性**) +* 随着程序运行时间的推移,即时编译器逐渐发挥作用,根据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率 + +HotSpot VM 可以通过 VM 参数设置程序执行方式: + +- -Xint:完全采用解释器模式执行程序 +- -Xcomp:完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行 +- -Xmixed:采用解释器 + 即时编译器的混合模式共同执行程序 + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-执行引擎工作流程.png) + + + +*** + + + +#### 热点探测 + +热点代码:被 JIT 编译器编译的字节码,根据代码被调用执行的频率而定,一个被多次调用的方法或者一个循环次数较多的循环体都可以被称之为热点代码 + +热点探测:JIT 编译器在运行时会针热点代码做出深度优化,将其直接编译为对应平台的本地机器指令进行缓存,以提升程序执行性能 + +JIT 编译在默认情况是异步进行的,当触发某方法或某代码块的优化时,先将其放入编译队列,然后由编译线程进行编译,编译之后的代码放在 CodeCache 中,通过 `-XX:-BackgroundCompilation` 参数可以关闭异步编译 + +* **CodeCache** 用于缓存编译后的机器码、动态生成的代码和本地方法代码 JNI +* 如果 CodeCache 区域被占满,编译器被停用,字节码将不会编译为机器码,应用程序继续运行,但运行性能会降低很多 + +HotSpot VM 采用的热点探测方式是基于计数器的热点探测,为每一个方法都建立 2 个不同类型的计数器:方法调用计数器(Invocation Counter)和回边计数器(BackEdge Counter) + +* 方法调用计数器:用于统计方法被调用的次数,默认阈值在 Client 模式 下是 1500 次,在 Server 模式下是 10000 次(需要进行激进的优化),超过这个阈值,就会触发 JIT 编译,阈值可以通过虚拟机参数 `-XX:CompileThreshold` 设置 + + 工作流程:当一个方法被调用时, 会先检查该方法是否存在被 JIT 编译过的版本,存在则使用编译后的本地代码来执行;如果不存在则将此方法的调用计数器值加 1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值,如果超过阈值会向即时编译器**提交一个该方法的代码编译请求** + +* 回边计数器:统计一个方法中循环体代码执行的次数,在字节码中控制流向后跳转的指令称为回边 + + 如果一个方法中的循环体需要执行多次,可以优化为为栈上替换,简称 OSR (On StackReplacement) 编译,**OSR 替换循环代码体的入口,C1、C2 替换的是方法调用的入口**,OSR 编译后会出现方法的整段代码被编译了,但是只有循环体部分才执行编译后的机器码,其他部分仍是解释执行 + + + +*** + + + +#### 分层编译 + +HotSpot VM 内嵌两个 JIT 编译器,分别为 Client Compiler 和 Server Compiler,简称 C1 编译器和 C2 编译器 + +C1 编译器会对字节码进行简单可靠的优化,耗时短,以达到更快的编译速度,C1 编译器的优化方法: + +* 方法内联:**将调用的函数代码编译到调用点处**,这样可以减少栈帧的生成,减少参数传递以及跳转过程 + + 方法内联能够消除方法调用的固定开销,任何方法除非被内联,否则调用都会有固定开销,来源于保存程序在该方法中的执行位置,以及新建、压入和弹出新方法所使用的栈帧。 + + ```java + private static int square(final int i) { + return i * i; + } + System.out.println(square(9)); + ``` + + square 是热点方法,会进行内联,把方法内代码拷贝粘贴到调用者的位置: + + ```java + System.out.println(9 * 9); + ``` + + 还能够进行常量折叠(constant folding)的优化: + + ```java + System.out.println(81); + ``` + +* 冗余消除:根据运行时状况进行代码折叠或削除 + +* 内联缓存:是一种加快动态绑定的优化技术(方法调用部分详解) + +C2 编译器进行耗时较长的优化以及激进优化,优化的代码执行效率更高,当激进优化的假设不成立时,再退回使用 C1 编译,这也是使用分层编译的原因 + +C2 的优化主要是在全局层面,逃逸分析是优化的基础:标量替换、栈上分配、同步消除 + +VM 参数设置: + +- -client:指定 Java 虚拟机运行在 Client 模式下,并使用 C1 编译器 +- -server:指定 Java 虚拟机运行在 Server 模式下,并使用 C2 编译器 +- `-server -XX:+TieredCompilation`:在 1.8 之前,分层编译默认是关闭的,可以添加该参数开启 + +分层编译策略 (Tiered Compilation):程序解释执行可以触发 C1 编译,将字节码编译成机器码,加上性能监控,C2 编译会根据性能监控信息进行激进优化,JVM 将执行状态分成了 5 个层次: + +* 0 层,解释执行(Interpreter) + +* 1 层,使用 C1 即时编译器编译执行(不带 profiling) + +* 2 层,使用 C1 即时编译器编译执行(带基本的 profiling) + +* 3 层,使用 C1 即时编译器编译执行(带完全的 profiling) + +* 4 层,使用 C2 即时编译器编译执行(C1 和 C2 协作运行) + + 说明:profiling 是指在运行过程中收集一些程序执行状态的数据,例如方法的调用次数,循环的回边次数等 + + + +参考文章:https://www.jianshu.com/p/20bd2e9b1f03 + + + +*** + + + +### 方法调用 + +#### 方法识别 + +Java 虚拟机识别方法的关键在于类名、方法名以及方法描述符(method descriptor) + +* **方法描述符是由方法的参数类型以及返回类型所构成**,Java 层面叫方法特征签名 +* 在同一个类中,如果同时出现多个名字相同且描述符也相同的方法,那么 Java 虚拟机会在类的验证阶段报错 + +JVM 根据名字和描述符来判断的,只要返回值不一样(方法描述符不一样),其它完全一样,在 JVM 中是允许的,但 Java 语言不允许 + +```java +// 返回值类型不同,编译阶段直接报错 +public static Integer invoke(Object... args) { + return 1; +} +public static int invoke(Object... args) { + return 2; +} +``` + + + +*** + + + +#### 调用机制 + +方法调用并不等于方法执行,方法调用阶段唯一的任务就是**确定被调用方法的版本**,不是方法的具体运行过程 + +在 JVM 中,将符号引用转换为直接引用有两种机制: + +- 静态链接:当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译期可知,且运行期保持不变,将调用方法的符号引用转换为直接引用的过程称之为静态链接(类加载的解析阶段) +- 动态链接:被调用的方法在编译期无法被确定下来,只能在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此被称为动态链接(初始化后的解析阶段) + +* 对应方法的绑定(分配)机制:静态绑定和动态绑定,编译器已经区分了重载的方法(静态绑定和动态绑定),因此可以认为虚拟机中不存在重载 + +非虚方法: + +- 非虚方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的 +- 静态方法、私有方法、final 方法、实例构造器、父类方法都是非虚方法 +- 所有普通成员方法、实例方法、被重写的方法都是虚方法 + +动态类型语言和静态类型语言: + +- 在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之则是动态类型语言 + +- 静态语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息 + +- **Java 是静态类型语言**(尽管 Lambda 表达式为其增加了动态特性),JS,Python 是动态类型语言 + + ```java + String s = "abc"; //Java + info = "abc"; //Python + ``` + + + +*** + + + +#### 调用指令 + +##### 五种指令 + +普通调用指令: + +- invokestatic:调用静态方法 +- invokespecial:调用私有方法、构造器,和父类的实例方法或构造器,以及所实现接口的默认方法 +- invokevirtual:调用所有虚方法(虚方法分派) +- invokeinterface:调用接口方法 + +动态调用指令: + +- invokedynamic:动态解析出需要调用的方法 + - Java7 为了实现动态类型语言支持而引入了该指令,但是并没有提供直接生成 invokedynamic 指令的方法,需要借助 ASM 这种底层字节码工具来产生 invokedynamic 指令 + - Java8 的 lambda 表达式的出现,invokedynamic 指令在 Java 中才有了直接生成方式 + +指令对比: + +- 普通调用指令固化在虚拟机内部,方法的调用执行不可干预,根据方法的符号引用链接到具体的目标方法 +- 动态调用指令支持用户确定方法 +- invokestatic 和 invokespecial 指令调用的方法称为非虚方法,虚拟机能够直接识别具体的目标方法 +- invokevirtual 和 invokeinterface 指令调用的方法称为虚方法,虚拟机需要在执行过程中根据调用者的动态类型来确定目标方法 + +指令说明: + +- 如果虚拟机能够确定目标方法有且仅有一个,比如说目标方法被标记为 final,那么可以不通过动态绑定,直接确定目标方法 +- 普通成员方法是由 invokevirtual 调用,属于**动态绑定**,即支持多态 + + + +*** + + + +##### 符号引用 + +在编译过程中,虚拟机并不知道目标方法的具体内存地址,Java 编译器会暂时用符号引用来表示该目标方法,这一符号引用包括目标方法所在的类或接口的名字,以及目标方法的方法名和方法描述符 + +符号引用存储在方法区常量池中,根据目标方法是否为接口方法,分为接口符号引用和非接口符号引用: + +```java +Constant pool: +... + #16 = InterfaceMethodref #27.#29 // 接口 +... + #22 = Methodref #1.#33 // 非接口 +... +``` + +对于非接口符号引用,假定该符号引用所指向的类为 C,则 Java 虚拟机会按照如下步骤进行查找: + +1. 在 C 中查找符合名字及描述符的方法 +2. 如果没有找到,在 C 的父类中继续搜索,直至 Object 类 +3. 如果没有找到,在 C 所直接实现或间接实现的接口中搜索,这一步搜索得到的目标方法必须是非私有、非静态的。如果有多个符合条件的目标方法,则任意返回其中一个 + +对于接口符号引用,假定该符号引用所指向的接口为 I,则 Java 虚拟机会按照如下步骤进行查找: + +1. 在 I 中查找符合名字及描述符的方法 +2. 如果没有找到,在 Object 类中的公有实例方法中搜索 +3. 如果没有找到,则在 I 的超接口中搜索,这一步的搜索结果的要求与非接口符号引用步骤 3 的要求一致 + + + +*** + + + +##### 执行流程 + +```java +public class Demo { + public Demo() { } + private void test1() { } + private final void test2() { } + + public void test3() { } + public static void test4() { } + + public static void main(String[] args) { + Demo3_9 d = new Demo3_9(); + d.test1(); + d.test2(); + d.test3(); + d.test4(); + Demo.test4(); + } +} +``` + +几种不同的方法调用对应的字节码指令: + +```java +0: new #2 // class cn/jvm/t3/bytecode/Demo +3: dup +4: invokespecial #3 // Method "":()V +7: astore_1 +8: aload_1 +9: invokespecial #4 // Method test1:()V +12: aload_1 +13: invokespecial #5 // Method test2:()V +16: aload_1 +17: invokevirtual #6 // Method test3:()V +20: aload_1 +21: pop +22: invokestatic #7 // Method test4:()V +25: invokestatic #7 // Method test4:()V +28: return +``` + +- invokespecial 调用该对象的构造方法 :()V +- invokevirtual 调用对象的成员方法 +- `d.test4()` 是通过**对象引用**调用一个静态方法,在调用 invokestatic 之前执行了 pop 指令,把对象引用从操作数栈弹掉 + - 不建议使用 `对象.静态方法()` 的方式调用静态方法,多了 aload 和 pop 指令 + - 成员方法与静态方法调用的区别是:执行方法前是否需要对象引用 + + + +*** + + + +#### 多态原理 + +##### 执行原理 + +Java 虚拟机中关于方法重写的判定基于方法描述符,如果子类定义了与父类中非私有、非静态方法同名的方法,只有当这两个方法的参数类型以及返回类型一致,Java 虚拟机才会判定为重写 + +理解多态: + +- 多态有编译时多态和运行时多态,即静态绑定和动态绑定 +- 前者是通过方法重载实现,后者是通过重写实现(子类覆盖父类方法,虚方法表) +- 虚方法:运行时动态绑定的方法,对比静态绑定的非虚方法调用来说,虚方法调用更加耗时 + +方法重写的本质: + +1. 找到操作数栈的第一个元素**所执行的对象的实际类型**,记作 C + +2. 如果在类型 C 中找到与描述符和名称都相符的方法,则进行访问**权限校验**(私有的),如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回 java.lang.IllegalAccessError 异常 + +3. 找不到,就会按照继承关系从下往上依次对 C 的各个父类进行第二步的搜索和验证过程 + +4. 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常 + + + +*** + + + +##### 虚方法表 + +在虚拟机工作过程中会频繁使用到动态绑定,每次动态绑定的过程中都要重新在类的元数据中搜索合适目标,影响到执行效率。为了提高性能,JVM 采取了一种用**空间换取时间**的策略来实现动态绑定,在每个**类的方法区**建立一个虚方法表(virtual method table),实现使用索引表来代替查找,可以快速定位目标方法 + +* invokevirtual 所使用的虚方法表(virtual method table,vtable),执行流程 + 1. 先通过栈帧中的对象引用找到对象,分析对象头,找到对象的实际 Class + 2. Class 结构中有 vtable,查表得到方法的具体地址,执行方法的字节码 +* invokeinterface 所使用的接口方法表(interface method table,itable) + +虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM 会把该类的方法表也初始化完毕 + +虚方法表的执行过程: + +* 对于静态绑定的方法调用而言,实际引用是一个指向方法的指针 +* 对于动态绑定的方法调用而言,实际引用是方法表的索引值,也就是方法的间接地址。Java 虚拟机获取调用者的实际类型,并在该实际类型的虚方法表中,根据索引值获得目标方法内存偏移量(指针) + +为了优化对象调用方法的速度,方法区的类型信息会增加一个指针,该指针指向一个记录该类方法的方法表。每个类中都有一个虚方法表,本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法 + +方法表满足以下的特质: + +* 其一,子类方法表中包含父类方法表中的**所有方法**,并且在方法表中的索引值与父类方法表种的索引值相同 +* 其二,**非重写的方法指向父类的方法表项,与父类共享一个方法表项,重写的方法指向本身自己的实现**,这就是为什么多态情况下可以访问父类的方法。 + + + +Passenger 类的方法表包括两个方法,分别对应 0 号和 1 号。方法表调换了 toString 方法和 passThroughImmigration 方法的位置,是因为 toString 方法的索引值需要与 Object 类中同名方法的索引值一致,为了保持简洁,这里不考虑 Object 类中的其他方法。 + +虚方法表对性能的影响: + +* 使用了方法表的动态绑定与静态绑定相比,仅仅多出几个内存解引用操作:访问栈上的调用者、读取调用者的动态类型、读取该类型的方法表、读取方法表中某个索引值所对应的目标方法,但是相对于创建并初始化 Java 栈帧这操作的开销可以忽略不计 +* 上述优化的效果看上去不错,但实际上**仅存在于解释执行**中,或者即时编译代码的最坏情况。因为即时编译还拥有另外两种性能更好的优化手段:内联缓存(inlining cache)和方法内联(method inlining) + +```java +class Person { + public String toString() { + return "I'm a person."; + } + public void eat() {} + public void speak() {} +} + +class Boy extends Person { + public String toString() { + return "I'm a boy"; + } + public void speak() {} + public void fight() {} +} + +class Girl extends Person { + public String toString() { + return "I'm a girl"; + } + public void speak() {} + public void sing() {} +} +``` + +![](https://seazean.oss-cn-beijing.aliyuncs.com/img/Java/JVM-虚方法表指向.png) + + + +参考文档:https://www.cnblogs.com/kaleidoscope/p/9790766.html + + + +*** + + + +##### 内联缓存 + +内联缓存:是一种加快动态绑定的优化技术,能够缓存虚方法调用中**调用者的动态类型以及该类型所对应的目标方法**。在之后的执行过程中,如果碰到已缓存的类型,便会直接调用该类型所对应的目标方法;反之内联缓存则会退化至使用基于方法表的动态绑定 + +多态的三个术语: + +* 单态 (monomorphic):指的是仅有一种状态的情况 +* 多态 (polymorphic):指的是有限数量种状态的情况,二态(bimorphic)是多态的其中一种 +* 超多态 (megamorphic):指的是更多种状态的情况,通常用一个具体数值来区分多态和超多态,在这个数值之下,称之为多态,否则称之为超多态 + +对于内联缓存来说,有对应的单态内联缓存、多态内联缓存: + +* 单态内联缓存:只缓存了一种动态类型以及所对应的目标方法,实现简单,比较所缓存的动态类型,如果命中,则直接调用对应的目标方法。 +* 多态内联缓存:缓存了多个动态类型及其目标方法,需要逐个将所缓存的动态类型与当前动态类型进行比较,如果命中,则调用对应的目标方法 + +为了节省内存空间,**Java 虚拟机只采用单态内联缓存**,没有命中的处理方法: + +* 替换单态内联缓存中的纪录,类似于 CPU 中的数据缓存,对数据的局部性有要求,即在替换内联缓存之后的一段时间内,方法调用的调用者的动态类型应当保持一致,从而能够有效地利用内联缓存 +* 劣化为超多态状态,这也是 Java 虚拟机的具体实现方式,这种状态实际上放弃了优化的机会,将直接访问方法表来动态绑定目标方法,但是与替换内联缓存纪录相比节省了写缓存的额外开销 + +虽然内联缓存附带内联二字,但是并没有内联目标方法 + + + +参考文章:https://time.geekbang.org/column/intro/100010301 + + + +*** + + + +### 代码优化 + +#### 语法糖 + +语法糖:指 Java 编译器把 *.java 源码编译为 *.class 字节码的过程中,自动生成和转换的一些代码,主要是为了减轻程序员的负担 + + + +#### 构造器 + +```java +public class Candy1 { +} +``` + +```java +public class Candy1 { + // 这个无参构造是编译器帮助我们加上的 + public Candy1() { + super(); // 即调用父类 Object 的无参构造方法,即调用 java/lang/Object." + ":()V + } +} +``` + + + +*** + + + +#### 拆装箱 + +```java +Integer x = 1; +int y = x; +``` + +这段代码在 JDK 5 之前是无法编译通过的,必须改写为代码片段2: + +```java +Integer x = Integer.valueOf(1); +int y = x.intValue(); +``` + +JDK5 以后编译阶段自动转换成上述片段 + + + +*** + + + +#### 泛型擦除 + +泛型也是在 JDK 5 开始加入的特性,但 Java 在编译泛型代码后会执行**泛型擦除**的动作,即泛型信息在编译为字节码之后就丢失了,实际的类型都**当做了 Object 类型**来处理: + +```java +List list = new ArrayList<>(); +list.add(10); // 实际调用的是 List.add(Object e) +Integer x = list.get(0); // 实际调用的是 Object obj = List.get(int index); +``` + +编译器真正生成的字节码中,还要额外做一个类型转换的操作: + +```java +// 需要将 Object 转为 Integer +Integer x = (Integer)list.get(0); +``` + +如果前面的 x 变量类型修改为 int 基本类型那么最终生成的字节码是: + +```java +// 需要将 Object 转为 Integer, 并执行拆箱操作 +int x = ((Integer)list.get(0)).intValue(); +``` + + + +*** + + + +#### 可变参数 + +```java +public class Candy4 { + public static void foo(String... args) { + String[] array = args; // 直接赋值 + System.out.println(array); + } + public static void main(String[] args) { + foo("hello", "world"); + } +} +``` + +可变参数 `String... args` 其实是 `String[] args` , Java 编译器会在编译期间将上述代码变换为: + +```java +public static void main(String[] args) { + foo(new String[]{"hello", "world"}); +} +``` + +注意:如果调用了 `foo()` 则等价代码为 `foo(new String[]{})` ,创建了一个空的数组,而不会传递 null 进去 + + + +**** + + + +#### foreach + +数组的循环: + +```java +int[] array = {1, 2, 3, 4, 5}; // 数组赋初值的简化写法也是语法糖 +for (int e : array) { + System.out.println(e); +} +``` + +编译后为循环取数: + +```java +for(int i = 0; i < array.length; ++i) { + int e = array[i]; + System.out.println(e); +} +``` + +集合的循环: + +```java +List list = Arrays.asList(1,2,3,4,5); +for (Integer i : list) { + System.out.println(i); +} +``` + +编译后转换为对迭代器的调用: + +```java +List list = Arrays.asList(1, 2, 3, 4, 5); +Iterator iter = list.iterator(); +while(iter.hasNext()) { + Integer e = (Integer)iter.next(); + System.out.println(e); +} +``` + +注意:foreach 循环写法,能够配合数组以及所有实现了 Iterable 接口的集合类一起使用,其中 Iterable 用来获取集合的迭代器 + + + +*** + + + +#### switch + +##### 字符串 + +switch 可以作用于字符串和枚举类: + +```java +switch (str) { + case "hello": { + System.out.println("h"); + break; + } + case "world": { + System.out.println("w"); + break; + } +} +``` + +注意:**switch 配合 String 和枚举使用时,变量不能为 null** + +会被编译器转换为: + +```java +byte x = -1; +switch(str.hashCode()) { + case 99162322: // hello 的 hashCode + if (str.equals("hello")) { + x = 0; + } + break; + case 113318802: // world 的 hashCode + if (str.equals("world")) { + x = 1; + } +} +switch(x) { + case 0: + System.out.println("h"); + break; + case 1: + System.out.println("w"); + break; +} +``` + +总结: + +* 执行了两遍 switch,第一遍是根据字符串的 hashCode 和 equals 将字符串的转换为相应 byte 类型,第二遍才是利用 byte 执行进行比较 +* hashCode 是为了提高效率,减少可能的比较;而 equals 是为了防止 hashCode 冲突 + + + +*** + + + +##### 枚举 + +switch 枚举的例子,原始代码: + +```java +enum Sex { + MALE, FEMALE +} +public class Candy7 { + public static void foo(Sex sex) { + switch (sex) { + case MALE: + System.out.println("男"); + break; + case FEMALE: + System.out.println("女"); + break; + } + } +} +``` + +编译转换后的代码: + +```java +/** +* 定义一个合成类(仅 jvm 使用,对我们不可见) +* 用来映射枚举的 ordinal 与数组元素的关系 +* 枚举的 ordinal 表示枚举对象的序号,从 0 开始 +* 即 MALE 的 ordinal()=0,FEMALE 的 ordinal()=1 +*/ +static class $MAP { + // 数组大小即为枚举元素个数,里面存储 case 用来对比的数字 + static int[] map = new int[2]; + static { + map[Sex.MALE.ordinal()] = 1; + map[Sex.FEMALE.ordinal()] = 2; + } +} +public static void foo(Sex sex) { + int x = $MAP.map[sex.ordinal()]; + switch (x) { + case 1: + System.out.println("男"); + break; + case 2: + System.out.println("女"); + break; + } +} +``` + + + +*** + + + +#### 枚举类 + +JDK 7 新增了枚举类: + +```java +enum Sex { + MALE, FEMALE +} +``` + +编译转换后: + +```java +public final class Sex extends Enum { + public static final Sex MALE; + public static final Sex FEMALE; + private static final Sex[] $VALUES; + static { + MALE = new Sex("MALE", 0); + FEMALE = new Sex("FEMALE", 1); + $VALUES = new Sex[]{MALE, FEMALE}; + } + private Sex(String name, int ordinal) { + super(name, ordinal); + } + public static Sex[] values() { + return $VALUES.clone(); + } + public static Sex valueOf(String name) { + return Enum.valueOf(Sex.class, name); + } +} +``` + + + + + +*** + + + +#### try-w-r + +JDK 7 开始新增了对需要关闭的资源处理的特殊语法 `try-with-resources`,格式: + +```java +try(资源变量 = 创建资源对象){ +} catch( ) { +} +``` + +其中资源对象需要实现 **AutoCloseable** 接口,例如 InputStream、OutputStream、Connection、Statement、ResultSet 等接口都实现了 AutoCloseable ,使用 try-withresources可以不用写 finally 语句块,编译器会帮助生成关闭资源代码: + +```java +try(InputStream is = new FileInputStream("d:\\1.txt")) { + System.out.println(is); +} catch (IOException e) { + e.printStackTrace(); +} +``` + +转换成: + +`addSuppressed(Throwable e)`:添加被压制异常,是为了防止异常信息的丢失(**fianlly 中如果抛出了异常**) + +```java +try { + InputStream is = new FileInputStream("d:\\1.txt"); + Throwable t = null; + try { + System.out.println(is); + } catch (Throwable e1) { + // t 是我们代码出现的异常 + t = e1; + throw e1; + } finally { + // 判断了资源不为空 + if (is != null) { + // 如果我们代码有异常 + if (t != null) { + try { + is.close(); + } catch (Throwable e2) { + // 如果 close 出现异常,作为被压制异常添加 + t.addSuppressed(e2); + } + } else { + // 如果我们代码没有异常,close 出现的异常就是最后 catch 块中的 e + is.close(); + } + } + } +} catch (IOException e) { + e.printStackTrace(); +} +``` + + + +*** + + + +#### 方法重写 + +方法重写时对返回值分两种情况: + +* 父子类的返回值完全一致 +* 子类返回值可以是父类返回值的子类 + +```java +class A { + public Number m() { + return 1; + } +} +class B extends A { + @Override + // 子类m方法的返回值是Integer是父类m方法返回值Number的子类 + public Integer m() { + return 2; + } +} +``` + +对于子类,Java 编译器会做如下处理: + +```java +class B extends A { + public Integer m() { + return 2; + } + // 此方法才是真正重写了父类 public Number m() 方法 + public synthetic bridge Number m() { + // 调用 public Integer m() + return m(); + } +} +``` + +其中桥接方法比较特殊,仅对 Java 虚拟机可见,并且与原来的 public Integer m() 没有命名冲突 + + + +*** + + + +#### 匿名内部类 + +##### 无参优化 + +源代码: + +```java +public class Candy11 { + public static void main(String[] args) { + Runnable runnable = new Runnable() { + @Override + public void run() { + System.out.println("ok"); + } + }; + } +} +``` + +转化后代码: + +```java +// 额外生成的类 +final class Candy11$1 implements Runnable { + Candy11$1() { + } + public void run() { + System.out.println("ok"); + } +} +public class Candy11 { + public static void main(String[] args) { + Runnable runnable = new Candy11$1(); + } +} +``` + + + +*** + + + +##### 带参优化 + +引用局部变量的匿名内部类,源代码: + +```java +public class Candy11 { + public static void test(final int x) { + Runnable runnable = new Runnable() { + @Override + public void run() { + System.out.println("ok:" + x); + } + }; + } +} +``` + +转换后代码: + +```java +final class Candy11$1 implements Runnable { + int val$x; + Candy11$1(int x) { + this.val$x = x; + } + public void run() { + System.out.println("ok:" + this.val$x); + } +} +public class Candy11 { + public static void test(final int x) { + Runnable runnable = new Candy11$1(x); + } +} +``` + +局部变量在底层创建为内部类的成员变量,必须是 final 的原因: + +* 在 Java 中方法调用是值传递的,在匿名内部类中对变量的操作都是基于原变量的副本,不会影响到原变量的值,所以**原变量的值的改变也无法同步到副本中** + +* 外部变量为 final 是在编译期以强制手段确保用户不会在内部类中做修改原变量值的操作,也是**防止外部操作修改了变量而内部类无法随之变化**出现的影响 + + 在创建 `Candy11$1 ` 对象时,将 x 的值赋值给了 `Candy11$1` 对象的 val 属性,x 不应该再发生变化了,因为发生变化,this.val$x 属性没有机会再跟着变化 + + + +*** + + + +#### 反射优化 + +```java +public class Reflect1 { + public static void foo() { + System.out.println("foo..."); + } + public static void main(String[] args) throws Exception { + Method foo = Reflect1.class.getMethod("foo"); + for (int i = 0; i <= 16; i++) { + System.out.printf("%d\t", i); + foo.invoke(null); + } + System.in.read(); + } +} +``` + +foo.invoke 0 ~ 15 次调用的是 MethodAccessor 的实现类 `NativeMethodAccessorImpl.invoke0()`,本地方法执行速度慢;当调用到第 16 次时,会采用运行时生成的类 `sun.reflect.GeneratedMethodAccessor1` 代替 + +```java +public Object invoke(Object obj, Object[] args)throws Exception { + // inflationThreshold 膨胀阈值,默认 15 + if (++numInvocations > ReflectionFactory.inflationThreshold() + && !ReflectUtil.isVMAnonymousClass(method.getDeclaringClass())) { + MethodAccessorImpl acc = (MethodAccessorImpl) + new MethodAccessorGenerator(). + generateMethod(method.getDeclaringClass(), + method.getName(), + method.getParameterTypes(), + method.getReturnType(), + method.getExceptionTypes(), + method.getModifiers()); + parent.setDelegate(acc); + } + // 【调用本地方法实现】 + return invoke0(method, obj, args); +} +private static native Object invoke0(Method m, Object obj, Object[] args); +``` + +```java +public class GeneratedMethodAccessor1 extends MethodAccessorImpl { + // 如果有参数,那么抛非法参数异常 + block4 : { + if (arrobject == null || arrobject.length == 0) break block4; + throw new IllegalArgumentException(); + } + try { + // 【可以看到,已经是直接调用方法】 + Reflect1.foo(); + // 因为没有返回值 + return null; + } + //.... +} +``` + +通过查看 ReflectionFactory 源码可知: + +* sun.reflect.noInflation 可以用来禁用膨胀,直接生成 GeneratedMethodAccessor1,但首次生成比较耗时,如果仅反射调用一次,不划算 +* sun.reflect.inflationThreshold 可以修改膨胀阈值 + + + + + +*** + + + + + +## 系统优化 + +### 性能调优 + +#### 性能指标 + +性能指标主要是吞吐量、响应时间、QPS、TPS 等、并发用户数等,而这些性能指标又依赖于系统服务器的资源,如 CPU、内存、磁盘 IO、网络 IO 等,对于这些指标数据的收集,通常可以根据Java本身的工具或指令进行查询 + +几个重要的指标: + +1. 停顿时间(响应时间):提交请求和返回该请求的响应之间使用的时间,比如垃圾回收中 STW 的时间 +2. 吞吐量:对单位时间内完成的工作量(请求)的量度(可以对比 GC 的性能指标) +3. 并发数:同一时刻,对服务器有实际交互的请求数 +4. QPS:Queries Per Second,每秒处理的查询量 +5. TPS:Transactions Per Second,每秒产生的事务数 +6. 内存占用:Java 堆区所占的内存大小 + + + +*** + + + +#### 优化步骤 + +对于一个系统要部署上线时,则一定会对 JVM 进行调整,不经过任何调整直接上线,容易出现线上系统频繁 FullGC 造成系统卡顿、CPU 使用频率过高、系统无反应等问题 + +1. 性能监控:通过运行日志、堆栈信息、线程快照等信息监控是否出现 GC 频繁、OOM、内存泄漏、死锁、响应时间过长等情况 + +2. 性能分析: + + * 打印 GC 日志,通过 GCviewer 或者 http://gceasy.io 来分析异常信息 + + - 运用命令行工具、jstack、jmap、jinfo 等 + + - dump 出堆文件,使用内存分析工具分析文件 + + - 使用阿里 Arthas、jconsole、JVisualVM 来**实时查看 JVM 状态** + + - jstack 查看堆栈信息 + +3. 性能调优: + + * 适当增加内存,根据业务背景选择垃圾回收器 + + - 优化代码,控制内存使用 + + - 增加机器,分散节点压力 + + - 合理设置线程池线程数量 + + - 使用中间件提高程序效率,比如缓存、消息队列等 + + + +*** + + + +#### 参数调优 + +对于 JVM 调优,主要就是调整年轻代、老年代、元空间的内存空间大小及使用的垃圾回收器类型 + +* 设置堆的初始大小和最大大小,为了防止垃圾收集器在初始大小、最大大小之间收缩堆而产生额外的时间,通常把最大、初始大小设置为相同的值 + + ```sh + -Xms:设置堆的初始化大小 + -Xmx:设置堆的最大大小 + ``` + +* 设置年轻代中 Eden 区和两个 Survivor 区的大小比例。该值如果不设置,则默认比例为 8:1:1。Java 官方通过增大 Eden 区的大小,来减少 YGC 发生的次数,虽然次数减少了,但 Eden 区满的时候,由于占用的空间较大,导致释放缓慢,此时 STW 的时间较长,因此需要按照程序情况去调优 + + ```sh + -XX:SurvivorRatio + ``` + +* 年轻代和老年代默认比例为 1:2,可以通过调整二者空间大小比率来设置两者的大小。 + + ```sh + -XX:newSize 设置年轻代的初始大小 + -XX:MaxNewSize 设置年轻代的最大大小, 初始大小和最大大小两个值通常相同 + ``` + +* 线程堆栈的设置:**每个线程默认会开启 1M 的堆栈**,用于存放栈帧、调用参数、局部变量等,但一般 256K 就够用,通常减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统 + + ```sh + -Xss 对每个线程stack大小的调整,-Xss128k + ``` + +* 一般一天超过一次 FullGC 就是有问题,首先通过工具查看是否出现内存泄露,如果出现内存泄露则调整代码,没有的话则调整 JVM 参数 + +* 系统 CPU 持续飙高的话,首先先排查代码问题,如果代码没问题,则咨询运维或者云服务器供应商,通常服务器重启或者服务器迁移即可解决 + +* 如果数据查询性能很低下的话,如果系统并发量并没有多少,则应更加关注数据库的相关问题 + +* 如果服务器配置还不错,JDK8 开始尽量使用 G1 或者新生代和老年代组合使用并行垃圾回收器 + + + + + +**** + + + + + +### 命令行篇 + +#### jps + +jps(Java Process Statu):显示指定系统内所有的 HotSpot 虚拟机进程(查看虚拟机进程信息),可用于查询正在运行的虚拟机进程,进程的本地虚拟机 ID 与操作系统的进程 ID 是一致的,是唯一的 + +使用语法:`jps [options] [hostid]` + +options 参数: + +- -q:仅仅显示 LVMID(local virtual machine id),即本地虚拟机唯一 id,不显示主类的名称等 + +- -l:输出应用程序主类的全类名或如果进程执行的是 jar 包,则输出 jar 完整路径 + +- -m:输出虚拟机进程启动时传递给主类 main()的参数 + +- -v:列出虚拟机进程启动时的JVM参数,比如 -Xms20m -Xmx50m是启动程序指定的 jvm 参数 + +ostid 参数:RMI注册表中注册的主机名,如果想要远程监控主机上的 java 程序,需要安装 jstatd + + + +**** + + + +#### jstat + +jstat(JVM Statistics Monitoring Tool):用于监视 JVM 各种运行状态信息的命令行工具,可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据,在没有 GUI 的图形界面,只提供了纯文本控制台环境的服务器上,它是运行期定位虚拟机性能问题的首选工具,常用于检测垃圾回收问题以及内存泄漏问题 + +使用语法:`jstat -