一、性能调优专题
MySQL相关
一、深入理解MySQL索引底层数据结构与算法
索引概念:索引是帮助MySQL高效获取数据的排好序的数据结构
索引数据结构:
1、二叉树
- 缺点:当索引字段有序的时候,不会自动平衡二叉树,数据越多,层级会越来越深,和链表没什么区别
2、平衡二叉树
- todo:优缺点,图片
3、红黑树
- 优点:相较于二叉树,有自动平衡的功能
- 缺点:当数据量大的时候,树的高度还是会很大,如果要查找的数据在叶子节点,也需要很多次的遍历才行
todo:图片
4、Hash表
- 优点:对索引的key进行一次hash计算就能够定位出数据存储的位置,很多时候Hash索引要比B+Tree索引更高效
- 缺点:仅能满足"=","IN"条件的查找,不支持范围查找,以及存在hash冲突问题
5、B-Tree
- 优点:一个节点(一页)可以放更多的元素,树的高度降低,所有索引元素不重复,节点中的数据索引从左到右递增排列
- 缺点:非叶子节点也存储data,一页能存的索引比B+Tree少,叶子节点之间没有指针,区间访问性能差(叶子节点之间没有指针,跨节点的范围查询需要从根节点再遍历)
6、B+Tree(B-Tree变种)
- 优点:非叶子节点不存储data,指存储索引(冗余),叶子节点中有整张表中所有的索引元素,叶子节点用指针连接,提高区间访问的性能(很好的支持范围查找)
-
经典面试题:为什么建议一个表最大容量2kw
一页的大小16KB,可以通过以下sql查询
SHOW GLOBAL STATUS like 'Innodb_page_size'; # 16384
如果bigInt为索引键,一个索引8个字节,每个索引节点旁边还有地址节点,存储的是对应节点的头部索引地址,占6个字节,总共14字节,一页的大小16KB,16384B/14B≈1170,即一页大约能存放1170个索引,两层就是1170x1170=1368900,叶子节点假设一行记录1KB(实际一行数据大小很少有超过1KB的),即一页大概能放16个元素,所以,3层的B+Tree,可以存1170x1170x16=21902400(2kw),层级再高的话查询速度就没有那么快了,所以建议一个表最大容量2kw,超过的话就建议分库分表了
-
经典面试题:B-Tree和B+Tree的区别
7、跳表
- todo:redis底层使用了,了解下
MySQL安装目录中的data目录即对应库表数据,里面存的索引和数据文件
MyISAM对应的文件:.frm(表结构信息) .MYD(数据) .MYI(索引),MyISAM的索引文件和数据文件是分离的(非聚集)
InnoDB对应的文件:.frm(表结构信息) .ibd(数据+索引),表数据文件本身就是按B+Tree组织的一个索引结构文件(聚集索引-叶节点包含了完整的数据记录)
为什么建议InnoDB表必须建主键,并且推荐使用整型的自增主键?
如果没有主键,InnoDB会选择唯一索引列来维护B+Tree,如果没有,InnoDB会新建一个隐藏列(rowId)来维护B+Tree,数据库资源宝贵,不应该让数据库来完成这种没必要的操作,使用整型的原因是,整型比较的效率要比字符串比较高,且整型的数据占用空间相对较小,节约空间。
自增,每次新增元素,需要的话总会新增新节点,如果不是自增,可能会导致节点分裂,平衡,自增可以有效的减少B+Tree的分裂及平衡
为什么非主键索引结构叶子节点存储的是主键值?
节省存储空间以及一致性更易于维护
联合索引的底层存储结构长什么样?
以联合主键为例:
最左前缀(左列)的原因:
联合索引是按照所有索引字段整体排好序的,如果上图中只根据age和position查询,没有name的话,无法保证age和position是排好序的,数据无法查全
二、Explain详解与索引最佳实践
explain中的列
1、id列
id列的编号是select的序列号,有几个select就有几个id,并且id的顺序是按select出现的顺序增长的。
id列越大,执行优先级越高,id相同则从上往下执行,id为null最后执行
2、select_type列
查询类型,表示对应的是简单还是复杂的查询
1)simple:简单查询。查询不包含子查询和union
2)primary:复杂查询中最外层的select
3)subquery:包含在select中的子查询(不在from子句中)
4)derived:包含在from子句中的子查询。MySQL会将结果存放在一个临时表中,也称为派生表
5)union:在union中的第二个和随后的select
3、table列
表示正在访问的是哪个表
当from子句中有子查询时,table列是格式,表示当前查询依赖id=N的查询,于是先执行id=N的查询。
当有union时,UNION RESULT的table列的值<union1,2>,1和2表示参与union的select行id
4、partitions列
如果查询是基于分区表的话,partitions字段会显示查询将访问的分区。
5、type列
这一列表示关联类型或访问类型,即MySQL决定如何查找表中的行,查找数据行记录的大概范围
依次从最优到最差分别为:system>const>eq_ref>ref>range>index>ALL
一般来说,得保证查询达到range级别,最好达到ref
NULL:MySQL能够在优化阶段分解查询语句,在执行阶段用不着再访问表或索引。例如:在索引列中选取最小值,可以单独查找索引来完成,不需要在执行时访问表
const,system:MySQL能对查询的某部分进行优化并将其转化成成一个常量(可以看show warnings的结果)。用于primary key或unique key的所有列与常数比较时,所以表最多有一个匹配行,读取1次,速度比较快。system是const的特例,表里只有一条元组匹配时为system,主键索引的单值匹配是const
eq_ref:primary key或unique key索引的所有部分被连接(join)使用,最多只会返回一条符合条件的记录。这可能是在const之外最好的连接类型了,简单的select查询不会出现这种type
ref:相比eq_ref,不使用唯一索引,而是使用普通索引或者唯一性索引的部分前缀,索引要和某个值相比较,可能会找到多个符合条件的行
range:范围扫描通常出现在in(),between,>,<,>=等操作中。使用一个索引来检索给定范围的行
index:扫描全索引就能拿到结果,一般是扫描某个二级索引,这种扫描不会从索引树根节点开始快速查找,而是直接对二级索引的叶子节点遍历和扫描,速度还是比较慢的,这种查询一般为使用覆盖索引,二级索引一般比较小,所以这种通常比ALL快一些
ALL:即全表扫描,扫描你的聚簇索引的所有叶子节点。通常进行下这种需要增加索引来进行优化了
6、possible_keys列
这一列显示查询可能使用使用那些索引来查找。
explain时可能出现possible_keys有列,而key显示NULL的情况,这种情况是因为表中数据不多,MySQL认为索引对此查询帮助不大,选择了全表查询
如果该列是NULL,则没有相关的索引。在这种情况下,可以通过检查where子句看是否可以创造一个适当的索引来提高查询性能,然后用explain查看效果
7、key列
这一列显示MySQL实际采用哪个索引来优化对该表的访问
如果没有适用索引,则该列是NULL。如果想强制MySQL使用或忽视possible_keys列中的索引,在查询中使用force index,ignore index
8、key_len列
表示MySQL在索引中使用的字节数,通过这个值可以计算出具体使用到了索引中的哪些列
key_len计算规则:
- 字符串,char(n)和varchar(n),从5.0.3以后的版本中,n代表的是字符数,而不是字节数,如果是utf8,一个英文字母或数字占一个字节,一个汉字占3个字节
- char(n):最多存n个字符,如果是汉字,长度就是3n字节
- varchar(n):最多存n个字符,如果存汉字则长度是3n+2字节,加的2字节用来存储字符串长度,因为varchar是变成字符串
- 数值类型
- tinyint:1字节
- smallInt:2字节
- int:4字节
- bigInt:8字节
- 时间类型
- date:3字节
- timestamp:4字节
- datetime:8字节
- 如果字段允许为NULL,需要1字节记录是否为NULL
索引最大长度是768字节,当字符串过长时,MySQL会做一个类似左前缀索引的处理,将前半部分的字符提取出来做索引
9、ref列
这一列显示了在key列记录的索引中,表查找值所用到的列或常量,常见的有:const(常量),字段名(例:film.id)
10、rows列
这一列是MySQL估计要读取并检测的行数,注意这个不是结果集里的行数
11、filtered列
是一个百分比值,通过rows*filtered/100可以估算出将要和explain中前一个表进行连接的行数(前一个表指explain中的id值比当前表id值小的表)
12、Extra列
这一列展示的是额外信息。常见的重要值如下:
1)Using index:使用覆盖索引
覆盖索引定义:MySQL执行计划explain结果里的key有使用索引,如果select后面查询的字段都可以从这个索引的树中获取,这种情况一般可以说是用到了覆盖索引,extra里一般都有using index;覆盖索引一般针对的是辅助索引,整个查询结果只通过辅助索引就能拿到结果,不需要通过辅助索引树找到主键,在通过主键去主键索引树里获取其他字段值
2)Using where:使用where语句来处理结果,并且查询的列未被索引覆盖(即没用到索引的普通where查询语句,一般需要优化)
3)Using index condition:查询的列不完全被索引覆盖,where条件中是一个前导列的范围(范围查询,用到了部分索引)
4)Using temporary:MySQL需要创建一张临时表来处理查询。出现这种情况一般是要进行优化的,首先是想到用索引来优化
比如,下面的sql,如果没有name列的索引,这里的distinct是通过临时表来去重的,explain出来的extra列就是Using temporary,如果有name列的索引,MySQL会在查询索引的过程中帮我们做掉去重的操作,也就不需要创建临时表来操作了
select distinct name from actor;
5)Using filesort:将用外部排序而不是索引排序,数据较小时从内存排序,否则需要再磁盘完成排序。这种情况下一般也是要考虑使用索引来优化的
比如,下面的sql,如果name没有索引,MySQL会浏览整个actor表,保存排序关键字name和对应的id,然后排序name并检索行记录,如果name有索引,因为索引是有序的,所以extra就是using index
select * from actor order by name;
6)Select tables optimized away:使用某些聚合函数(比如max、min)来访问存在索引的某个字段时会出现这种,这种性能很高,一般不需要优化
比如:
select min(id) from file;
索引最佳实践
1、全值匹配
尽量用索引中的字段
2、最左前缀法则
如果索引了多列,要遵守最左前缀法则,指的是查询从索引的最左前列开始,并且不跳过索引中的列,范围查询之后的索引字段也失效,因为范围查询之后的索引字段并不能保证有序的,比如有联合索引a_b_c,b相同的时候,c是有序的,一旦使用了范围查找条件去筛选b,在b不相同的时候,c并不是有序的,所以范围查找之后的字段,不会用到索引,并且也不会像like 'kk%'那样用到索引下推,因为MySQL可能认为,范围查找的结果数量是远大于like的结果数量的
3、不在索引列上做任何操作(计算、函数、(自动or手动)类型转换),会导致索引失效而转向全表扫描
4、存储引擎不能使用索引中范围条件右边的列
因为范围条件之后的列,在B+树里,并不一定是有序的
5、尽量使用覆盖索引(只访问索引的查询(索引列包含查询列)),减少select *语句
6、MySQL在使用不等于,not in,not exists的时候,无法使用索引会导致全表扫描,小于,大于,小于等于,大于等于,这些MySQL内部优化器会根据检索比例,表大小等多个因素整体评估是否使用索引
7、is null,is not null一般情况下也无法使用索引
8、like以通配符开头的话,MySQL索引失效,会变成全表扫描操作
问:解决like ‘%字符串%’ 索引不能被使用的方法?
使用覆盖索引,查询字段必须是建立覆盖索引的字段
如果不能使用覆盖索引,则可能需要借助搜索引擎了
9、字符串不加单引号,索引失效
10、少用or或in,用它查询时,MySQL不一定使用索引,MySQL内部优化器会根据检索比例、表大小等多个因素整体评估是否使用索引,详见范围索引查询优化
11、范围查询优化
索引使用总结
三、MySQL索引优化实战一
举一些不容易理解的综合例子
1、联合索引第一个字段用范围不会走索引
结论:联合索引第一个字段就用范围查询不会走索引,MySQL内部可能觉得第一个字段就用范围,结果集应该很大,回表效率不高,还不如就全表扫描
2、强制走索引
结论:虽然使用了强制走索引让联合索引第一个字段范围查找也走索引,扫描的行rows看上去也少了点,但是最终查找效率不一定比全表扫描高,因为回表效率不高
select * from employees force index(idx_name_age_position) where name > 'LiLei' and age = 22 and position = 'manager';
3、覆盖索引优化
查询的结果和条件,尽量选择索引中有的字段
4、in和or在表数据量比较大的情况会走索引,在表记录不多的情况下会选择全表扫描
数据量少的时候直接全表扫描,也不用回表,效率很高
5、like KK% 一般情况都会走索引(like 首位不是通配符的,一般可以理解为等值查询,like kk%其实就是用到了索引下推优化)
什么是索引下推?
比如有name,age,position三个字段组成的联合索引,where name like ‘kk%’ and age = 1 and position = ‘北京’,在MySQL 5.6之前,是拿到name like 'kk%'的数据主键id,到主键索引中回表过滤age和position的,但是在MySQL 5.6之后,会在过滤name like 'kk%'的同时,比对索引中的age和position字段是否符合条件,这样就可以提前筛选掉不符合条件的值,即有效的减少了回表次数(注意like 'kk%'也并不一定就会走索引下推,没有100%的情况),但是既然可以索引下推,为什么范围查找的时候,MySQL没有使用索引下推优化呢?见下文
为什么范围查找MySQL没有使用索引下推优化?
估计是MySQL认为范围查找过滤的结果集过大,like KK%在绝大多数情况来看,过滤后的结果集比较小,所以这里MySQL选择给like KK%用了索引下推优化,当然也不是绝对的,有时like KK%也不一定就会走索引下推
MySQL如何选择合适的索引
使用trace工具,可以看MySQL选择索引的一个思路,开启trace工具会影响MySQL性能,所以只能临时分析sql使用,用完之后立即关闭
trace工具用法:
set session optimizer_trace="enabled=on",end_markers_in_json=on; ‐‐开启trace
select * from employees where name > 'a' order by position;
SELECT * FROM information_schema.OPTIMIZER_TRACE;
-- 查看trace字段,分析
set session optimizer_trace="enabled=off"; ‐‐关闭trace
常见sql深入优化
Order by与Group by优化
这种in配合order by,order by的字段是一定不能走索引的,name筛选完之后的age和position是不一定有序的,没有办法走索引
case 8:
优化总结:
1、 MySQL支持两种方式的排序,一个是using index,一个是using filesort,using index是指扫描索引本身完成排序,using filesort就是要把数据放到内存中排序了。index效率高,filesort效率低
2、 order by有下面两种情况会使用using index,尽量在索引列上完成排序,如果order by的条件不在索引列上,就会成产生Using filesort
- order by的字段使用索引的最左前列
- where子句与order by子句条件列组合满足索引最左前列原则
3、 尽量使用覆盖索引,无需回表查询,提高效率
4、 group by和order by类似,where条件中的字段和group by条件中的字段满足索引的最左前列原则即可走索引,group by的实质就是先排序后分组,对于group by的优化,如果不需要排序的话,可以加上order by null禁止排序。注意,where高于having,能写在where中的限定条件就不要去having限定了
5、 范围查找的条件,一般放在联合索引的最后,因为范围查找的结果一般会比较多,等值查询字段的放在索引前面,查询时能有效减少不必要的筛选,在写sql时,where中的条件顺序也尽量满足最左前列原则,虽然MySQL可以自己做优化,进行字段顺序优化以使用所以,但是我们一般认为MySQL的资源比较宝贵,能自己做的就不要让MySQL来帮我们做
Using filesort文件排序原理详解
filesort文件排序,有两种方式,单路排序和双路排序
- 单路排序,是指拿出满足查询条件的所有行,在sort buffer中进行排序
- 双路排序,是先根据查询条件,取出相应的排序字段和能直接定位该行的id,然后在sort buffer中进行排序,排序完成后,根据排序后的行id再次取回其他需要的字段
MySQL通过比较系统变量max_length_for_sort_data(默认1024字节)的大小和需要查询的字段总大小来判断使用哪种排序模式
- 如果字段总长度小于max_length_for_sort_data,那么就使用单路排序
- 如果字段总长度大于max_length_for_sort_data,那么就使用双路排序
示例:
select * from employees where name = 'zhuge' order by position;
-
如果是单路排序:首先会取出所有name='zhuge’的行,用所有字段到sort buffer中排序,再将排序结果返回给客户端
-
如果是双路排序:取数所有name='zhuge’的行id以及position字段,到sort buffer中进行排序,再用排序号的行id字段,回到原表中,取出所有需要的字段,返回给客户端
对比两种排序模式,如果没有条件继续增加sort buffer,那么可以把max_length_for_sort_data配置小点,让优化器选择双路排序算法,这样可以在sort buffer中一次排序更多的行
如果有条件,可以适当增大max_length_for_sort_data的值,让优化器优先选择全字段排序(单路排序),这样就会直接从内存里返回查询结果,更高效一些
注意,如果全部使用sort buffer内存排序,一般情况下效率会高于磁盘文件排序,但不能因为这个就随便增大sort buffer(默认1M),MySQL很多参数设置都是做过优化的,尽量不要轻易调整
索引设计原则
1、 代码先行,索引后上
就是在做项目的时候,一般先将代码写好,再根据相关sql做分析后再建立索引
2、联合索引尽量覆盖条件
可以设计一个或者两三个联合索引,注意要尽量减少单值索引,联合索引要尽量包含sql查询中的where,group by,order by条件字段,并且满足索引的最左前缀原则,查询的条件和结果在可行的时候,尽量也仅选择索引中的字段,这样可以有效的减少回表次数,从而增加查询效率
3、 不要在小基数字段上建立索引
索引基数就是说对应字段在表中的多少种不同的值,比如一张100w行记录的表,其中有性别字段,仅有男女两种,那么这个字段的基数就是2,如果用这种区分度不大的字段建立索引,建立出来没有太大的意义,没有办法进行快速的二分查找,还不如全表扫描
一般建立索引,尽量选择基数表较大的字段,就是值比较多的字段,才能发挥出B+树快速二分查找的优势
4、长字符串我们可以采用前缀索引
设计索引的时候尽量选择小字段,这样节省磁盘空间,并且占用小,查询的速度也就快,但是当业务真的有必要针对varchar(255)这样的字段建立索引的时候,也是必须要建的,这种时候可以考虑选择使用该字段的前缀做索引,比如Key index(name(20), age, position),这样就会使用name字段的前20位建立索引,再进行查询时,如果是根据name来查询,会先用查询的name字段的前20个字符到索引树中查询符合条件的记录,再拿到主键id去聚簇索引中拿到完整的name字段值进行比对
需要注意的是,order by和group by是没有办法用这种索引前缀的,因为前20位字符并不代表该字段整体的一个顺序性,没有顺序性自然也就无法排序
5、where与order by冲突时优先where
一般都是优先where,筛选出尽量少的字段,可以降低order by的排序成本
6、基于慢sql查询做优化
可以开启监控,监控后台的一些慢sql,针对慢sql语句进行特定的索引优化
四、MySQL索引优化实战二
分页查询优化
很多时候我们业务系统实现分页功能可能会用如下sql实现
select * from employees limit 10000,10;
这个sql看似只查询了10条数据,实际上是先读取了10010条数据,然后丢弃前10000条,读到后面10条想要的数据,所以其实查一张大表比较靠后的数据,效率是非常低的
常见的优化技巧
1、根据自增且连续的主键排序的分页查询
说白了就是把上面的sql优化成:
select * from employees where id > 10000 limit 10;
查询结果是一样的,但这个sql走了索引,扫描的行数大大减少,执行效率更高,但一定一定注意的是,主键id一定要是自增且连续的,否则查询出的结果就不符合预期了,比如中间删除了一些数据,这种分页优化方式就不能使用了,所以,满足两个条件:
- 主键是自增且连续
- 结果是需要按照主键排序的
说实话感觉这种条件挺苛刻的,一般很少有这种完美的可以不删数据,主键完全自增且连续的业务场景,所以,知道此方法即可
2、根据非主键字段排序的分页查询
原sql:
select * from employees order by name limit 90000,10;
原sql并没有使用name字段的索引,因为扫描整个索引并查找非索引字段的行(需要回表),成本要比扫描全表更高,所以优化器放弃使用索引,所以稍作修改
现sql:
select * from employees e inner join (select id from employees order by name limit 90000,10) ed on e.id = ed.id;
结果一样,但现sql因为在排序时,返回的字段更少,排序和分页操作先查出了主键,在根据主键去原表做关联,这样优化器就能使用索引了,原sql使用的是filesort排序,优化后的现sql使用的是索引排序
关键在于:让排序时返回的字段更少
Join关联查询优化
MySQL表关联常见有两种算法,NLJ(Nested-Loop Join)和BNL(Block Nested-Loop Join)
join关联语句中,有驱动表和被驱动表之分,一般最好是让小表做驱动表,小表驱动大表,
inner join中,MySQL会选择小表做驱动表,大表做被驱动表(排在前面的表不一定就是驱动表)
left join中,左边的表是驱动表
right join中,右边的表是驱动表
NLJ算法
一次一行的从第一张表(驱动表)中读取行,从这行中拿到关联字段,再使用关联字段到另一张表(被驱动表)中取出满足条件的行,最后取出两张表的合集
从执行计划可以看出,t2是驱动表,t1是被驱动表,使用的是NLJ算法(执行计划中的Extra中未出现Using join buffer则表示使用的join算法是NLJ)
上面的sql执行的大致流程:
- 从t2中取一行数据,拿到a字段(如果t2表有查询过滤条件的,则从过滤结果里取出一行数据)
- 用a字段到t1表中查找满足条件的行
- 将t1表中满足条件的行和t2表中的该行数据合并,作为结果返回给客户端
- 重复上面3步
整个过程会读取t2表中的所有数据(扫描100行),遍历每行数据中的a的值,然后根据a的值,索引扫描t1表,t1也是100行的话,则每次都需要从t2扫描一次,再到t1扫描一次,总共扫描了200行(注意这里是磁盘扫描)
如果被驱动表的关联字段没有索引,使用NLJ算法性能会比较低,MySQL会选择BNL算法
BNL算法
把驱动表的数据读入到join_buffer中,然后扫描被驱动表,把被驱动表的每一行取出来跟join_buffer中的数据作对比
Extra中有了Using join buffer(Block Nested Loop)说明该关联查询使用的是BNL算法
上面sql的大致流程如下:
- 把t2的所有数据放入到join_buffer中
- 把t1表的每一行取出来,跟join_buffer中的数据做对比
- 返回满足join条件的数据
整个过程对表t1和t2都做了一次全表扫描,因此扫描的总行数为10000(表t1的数据总量)+100(表t2的数据总量)=10100。并且join_buffer里的数据是无序的,因此对表t1中的每一行,都要做100次判断,所以在内存中的判断次数是100*10000=100w次
这个例子中,t2才100行,如果t2是一个大表,join_buffer放不下怎么办呢?
join_buffer的大小是由参数join_buffer_size设定的,默认值是256k。如果放不下表t2的所有数据,策略很简单,就是分段放
比如t2表有1000行数据,join_buffer一次只能放800行数据,那么执行过程就是先往join_buffer里放800行记录,然后从t1表中取数据,跟join_buffer中数据对比得到部分结果,然后清空join_buffer,再放入t2表剩余的200行记录,再次从t1表里取数据跟join_buffer中数据作对比,所以就多扫描了一次t1表
被驱动表的关联字段没索引为什么要选择使用BNL算法而不使用NLJ算法呢?
如果上面第二条sql使用NLJ算法,那么扫描行数为100*10000=100w次,注意,这个是磁盘扫描。
很显然,用BNL磁盘扫描次数会少很多,相比于磁盘扫描,BNL的内存计算会快得多
因此MySQL对于被驱动表的关联字段没索引的关联查询,一般都会使用BNL算法,如果有索引一般选择NLJ算法,有索引的情况下NLJ算法比BNL算法性能更高
对于关联sql的优化
-
关联字段加上索引,让MySQL做join操作时尽量选择NLJ算法
-
小表驱动大表,如果明确知道哪张表是小表,可以使用straight_join写法,固定连接驱动方式,省去MySQL优化器自己判断的时间,stratght_join和join功能类似,但是是使用左表驱动右表,写法如下,但是,尽可能的让优化器去判断,因为大部分情况下,MySQL优化器是比人要聪明的,使用straight_join一定要慎重,因为部分情况下人为指定的执行顺序并不一定会比优化引擎靠谱
select * from t2 straight_join t1 on t2.a = t1.a;
对于小表定义的明确
在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与join的各个字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表
in和exists优化
原则:小表驱动大表,即小的数据集驱动大的数据集
in:当B表的数据集小于A表时,in优于exists
select * from A where id in (select id from B);
exists:当A表的数据集小于B表时,exists优于in
select * from A where exists (select 1 from B where B.id = A.id)
exists(subquery)只返回true或false,因此子查询中的select *也可以用select 1替换,官方说法是实际执行会忽略select清单,因此没有区别
count(*)查询优化
四个sql的执行计划一样,说明四个sql的执行效率应该差不多
当字段有索引时,执行效率:
count(*) ≈ count(1) > count(字段) > count(id)
当字段没有索引时,执行效率:
count(*) ≈ count(1) > count(id) > count(字段)
count(1)和count(字段)执行过程类似,不过count(1)不需要取出字段统计,count(字段)还需要取出字段,所以理论上讲count(1)要比count(字段)快一些
count(*)是例外,MySQL专门做了优化,并不会把全部字段取出来,不取值,按行累加,效率更高,所以不需要用count(列名)或count(常量)来代替count(*)
当字段有索引的时候,count(字段)的执行效率大于count(id),是因为count(字段)走辅助索引,辅助索引的大小要小于聚簇索引,所以效率更高一些
常见的优化方法
-
查询MySQL自己维护的总行数
对于myisam存储引擎,做不带where条件的count查询性能是很高的,因为myisam存储引擎的表的总行数会被记录在磁盘上,查询不需要计算
-
show table status
如果只需要知道表总行数的估计值,可以使用以下sql查询,性能很高
show table status like 'employees';
-
将总数维护到Redis里
插入或删除行的时候,同时维护redis里的表总行数key的计数值(用incr或decr命令),但这种方式可能不准,很难保证表操作和redis操作的事务一致性
-
增加数据库计数表
记录一个计数表,计数的时候在同一事务中进行操作
阿里巴巴MySQL规范解读
选择正确的数据类型,对于性能至关重要,一般应该遵循下面两步:
- 确定合适的大类型:数字、字符串、时间、二进制
- 确定具体的类型:有无符号,取值范围,变长定长等
在数据类型设置方面,尽量选择更小的数据类型,它们通常有更好的性能,占用更少的空间。并且,尽量把字段定义为NOT NULL,当一个字段允许为空时,MySQL需要额外的空间来存储NULL值
PS:int显示宽度
在MySQL命令中,字段的类型长度TINYINT(2),INT(11)不会影响数据的插入,只会在使用ZEROFILL时有用,让查询结果前填充0
五、MySQL事务原理与优化实践
事务是一组操作,这组操作,要么全部成功,要么全部失败,目的是要保证数据的最终一致性
事务的4个属性
事务有以下4个属性,称为ACID属性:
-
原子性(Atomicity):即当前事务中的操作,要么全部成功,要么全部失败。原子性由undo log实现(比如执行一个insert,undo log中会记录一个delete操作,事务后面的操作如果报错了,就会根据undo log进行回滚操作)
-
一致性(Consistent):事务的最终目的,由其他三个特性来保证
-
隔离性(Isolation):即两个事务之间是相互隔离的,不能互相干扰,隔离性由MySQL的各种锁以及MVCC机制来实现
-
持久性(Durable):一旦提交事务,它对数据库的改变就是永久性的。持久性由redo log来实现(事务提交的时候,先更新缓冲区(Buffer Pool)的数据,然后会写一份redo log,这个redo log是写在物理磁盘上的,提前分配好了一整块连续的空间,顺序写的,写入速度很快,即使MySQL在中途挂了,我们还可以根据redo log来对数据进行恢复)
为什么不直接写到ibd文件,要写redo log呢?就是因为redo log是顺序写的,速度快,而一个ibd文件对应一张表,在硬盘中是随机位置存放的,速度肯定不如redo log来得快
并发事务处理带来的问题
1、 更新丢失或脏写
当两个事务同时操作同一行数据时,事务B覆盖了事务A更新的数据,即脏写
2、 脏读
事务A读到了事务B已修改但未提交的数据
3、 不可重复读
事务A内部,相同的查询sql,两次的执行结果不一致
4、 幻读
事务A读取到了事务B提交的新增的数据
事务的隔离级别
1、 读未提交
2、 读已提交
3、 可重复读
4、串行化
隔离级别|问题 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
读未提交(Read Uncommitted) | 可能,可以读到其他事务未提交的数据,所以可能发生脏读 | 可能 | 可能 |
读已提交(Read Committed) | 不可能,其他事务未提交的数据,当前事务无法读到,所以不可能发生脏读 | 可能,其他事务提交后,当前事务读到的数据可能不一样,所以可能发生不可重复读 | 可能 |
可重复读(Repeatable Read),MySQL默认的隔离级别 | 不可能,当前事务读的时候是快照读,看不到其他事务修改的数据(即使其他事务已提交),所以不可能发生脏读 | 不可能,快照读,其他事物提交了也看不到,所以不可能发生不可重复读 | 可能,虽然读是快照读,但是其他事务如果新增或删除了一行数据并提交,当前事务是可以根据条件感知到新增或删除的记录的,所以是有可能出现幻读的,MySQL使用了间隙锁(Gap Lock)的机制来防止幻读,但并不能完全解决幻读问题 |
串行化(Serializable) | 不可能,读写操作都会加锁,不可能出现多个事务并发操作同一数据 | 不可能,读写操作都会加锁,不可能出现多个事务并发操作同一数据 | 不可能,读写操作都会加锁,不可能出现多个事务并发操作同一数据 |
上面表格中的隔离级别,**从上往下,性能越来越低,安全性越来越高,**一般企业选择RC和RR隔离级别会比较多,互联网大厂一般选择RC,因为性能更好,传统企业一般选择RR
重点讲一下可重复读
- 可重复读隔离级别,在事务开启的时候,第一次查询,查的是数据库中已提交的数据,可以理解为这个时候全数据库(注意是整个数据库级别,不是数据表级别)会有一个快照(当然MySQL不是真的做了个快照,使用的是MVCC(multi-version-concurrency control)机制),在这之后的任何查询,都是查询的快照,但是,当前事务如果对某一行进行了修改,那么这条被修改的数据在查询的时候,查到的就是修改后的值,但是其他数据依然是从快照里查,不受影响
- 注意在可重复读隔离级别下,乐观锁失效,因为如果当前事务要修改的某行数据,被其他事务修改了,version在数据库总实际已经+1,但是在当前事务没有对该行数据进行修改的情况下,每次读出来的该行数据都是一样的,version永远是快照里的数据不会变,也永远和数据库中的真实version值匹配不上,就无法修改成功
关于串行化,其实就是在select时加了S锁(其他的隔离级别读操作都不会加锁),共享锁,举个例子:
事务A | 事务B |
---|---|
begin; | |
begin; | |
select * from test where id = 1; 可以查到数据,加S锁 | |
select * from test where id = 1; 可以查到数据,加S锁,S锁是共享锁,所以可以查到 | |
update test set name = ‘zhangsan’ where id = 1; 尝试加X锁,但X锁是排他锁,因为其他事务加了S锁,阻塞,超时会失败 | |
update test set name = ‘zhangsan’ where id = 1; 尝试加X锁,但X锁是排他锁,因为其他事务加了S锁,阻塞,超时会失败 |
MVCC原理初识(先简单了解下,后面细说)
读已提交和可重复读的特性,主要是通过MVCC,undo log来实现,后台在每行维护有两个隐藏字段,trx_id(事务id),roll_pointer(回滚指针),还有一个已提交标记,比如新插入id为1的行,并提交,会给该行的trx_id分配为当前事务id,roll_pointer指向undo log,undo log内容是del id = 1的行。如果又开了一个事务更新了id=1的行,会复制一条id为1的行内容进行修改,同时trx_id更新为新事务id,roll_pointer指向之前被复制的行,相应的已提交标记也会移动到最新的这一行上
在RC隔离级别,查询的是版本链中最新已提交的那一行数据,在RR隔离级别中,可以简单理解为查询的是当前事务id对应的快照行(内部其实有算法,后面细说)
事务问题定位
#查询执行时间超过1秒的事务,详细的定位问题方法后面讲完锁课程后会一起讲解
SELECT*
FROMinformation_schema.innodb_trx
WHERETIME_TO_SEC( timediff( now( ), trx_started ) ) > 1;#强制结束事务kill 事务对应的线程id(就是上面语句查出结果里的trx_MySQL_thread_id字段的值)
大事务的影响
- 并发情况下,数据库连接池容易被撑爆
- 锁定太多的数据,造成大量的阻塞和锁超时
- undo log膨胀
- 回滚所需要的时间比较长
- 容易造成数据库主从延迟
- 容易导致死锁
事务优化
- 将查询等数据准备操作放到事务外
- 事务中避免远程调用,防止远程调用超时,防止事务等待时间太久,一定要远程调用时记得设置超时时间
- 事务中避免一次性处理太多数据,可以拆分成多个事务分次处理
- 更新等涉及加锁的操作尽可能的放在时候靠后的位置(有一个小tip,如果一个事务里面有insert和update,将update放在后面,因为insert的数据其他事务不会去操作,不太可能发生锁冲突,update放在后面,update占用锁了,就算有锁冲突导致其他事务阻塞,等update执行完,后面没有其他逻辑了,直接提交当前事务,其他事务也能尽快拿到锁,如果insert放在后面,update冲突了,之后还要进行insert操作的话,其他事务就会等待更长的时间)
- 能异步处理的尽量异步处理
- 应用测(业务代码)保证数据一致性,非事务执行(使用try-catch,程序员根据业务逻辑,自己实现回滚策略,执行效率将非常高,但代码写起来会很复杂,一般需要对业务很有了解)
六、MySQL锁机制与优化实践以及MVCC底层原理剖析
锁机制详解
锁是计算机协调多个进程或线程并发访问统一资源的机制,除了CPU,内存,I/O外,数据也是一种需要用户共享的资源。如何保证数据并发访问的一致性、有效性是所有数据库必须解决的一个问题,锁冲突也是影响数据库并发访问性能的一个重要因素
锁分类
从性能上分
1、 乐观锁
乐观锁适用于读多写少的场景,因为是需要重试的,如果写的并发太高,重试的概率会很高,就白白浪费了CPU资源。
具体实现方式:
select balance from account where id = 1; -- balance:1000 version:2
java: balance = 1000+500;
update account set balance = 5, version = version + 1 where id = 1 and version = 2;
在数据库表里加version字段,在更新时,条件带上version字段,并同步更新version = version + 1,version如果不匹配,进行重试,重新查询最新的version并再次进行更新,可以重试多次,但是要注意,上面有讲过,在可重复读隔离级别,乐观锁不适用
2、 悲观锁
悲观锁适用于写多读少的场景,当写的并发太高,就阻塞排队执行就好了,没必要像乐观锁一样浪费性能。具体实现方式:悲观锁就用不到version了,更新的时候直接使用数据库中的字段进行更新操作,比如:
update account set balance = balance + 500 where id = 1;
这条sql会给id=1的行加锁,其他事务想要更新id=1的行,就要阻塞等待,直到当前事务提交
从对数据操作的粒度分
1、 表锁
就是锁住整张表,开销小,加锁快,不会出现死锁,锁定粒度大,发生锁冲突的概率最高,并发度最低,一般用在整表数据迁移的场景
-- 手动增加表锁
lock tables T1 read(write), T2 read(write)
-- 查看表上加过的锁
show open tables;
-- 删除表锁
unlock tables;
2、 页锁
锁住一页的数据,只有BDB存储引擎才支持页锁,锁定的资源要比行锁多,因为一页中可以有多个行记录。使用页锁的时候,会出现数据浪费的现象,但浪费最多也就是一个页上的数据行。页锁的开销介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般
3、 行锁
锁住一行数据,粒度最小,并发度最高,锁冲突的概率最低,会发生死锁,开销大,加锁慢,默认行锁冲突导致阻塞超时时间是50s(有参数可以控制)。为什么说开销大呢?因为相比表锁来说,行锁需要找到具体的某一行去进行加锁,而表锁只要把整个表锁住就好了
InnoDB和MYISAM的最大不同有两点
- InnoDB支持事务
- InnoDB支持行锁
注意,InnoDB的行锁实际上是针对索引加的锁(在索引对应的索引项上做标记),不是针对整个记录加的锁。并且该索引不能失效,否则可以理解为会从行锁升级为表锁。(RR级别会升级为表锁,RC不会升级为表锁)
比如我们在RR级别下执行如下sql:
select * from account where name = 'zhangsan' for update; -- where条件里的name没有索引
则其他事务对该表的任意一行记录做修改都会被阻塞住,因为name没有索引,锁住了整个表
PS:关于RR级别行锁升级为表锁的原因分析,其实并不是加上表锁,只是可以理解为是表锁
在RR隔离级别下,为了解决不可重复读和幻读问题,在遍历扫描索引记录时,为了防止扫描过的索引被其他事务修改(不可重复读问题)或扫描过的间隙被其他事务插入新数据(幻读问题),从而导致数据不一致,MySQL的解决方案就是将扫描过的所有索引记录和间隙都锁上,这里要注意,并不是直接将整张表加表锁,因为不一定能加上表锁,可能会有其他事务锁住了表里的其他行记录
并不是加上了表锁,而是锁住了所有扫描过的索引项及间隙,效果等同于表锁,如下图
而表锁是这样的:
lock tables mylock read;
从对数据库操作的类型分
1、 读锁(共享锁,S锁(Shared))
select * from T where id = 1 lock in share mode;
在查询语句后加lock in share mode
即加了读锁,多个事务可以对同一份数据多次加读锁,所以又叫共享锁,串行化隔离级别时,select语句,MySQL会默认加上lock in share mode,其他隔离级别,如果不显示声明,select并不会加锁,注意,加了读锁后,不能加写锁,因为写锁是排它锁
2、 写锁(排它锁,X锁(Exclusive))
select * from T where id = 1 for update;
数据库的修改操作都会加写锁,select后加for update
也会加写锁,当前事务的语句如果加了写锁,在没有提交之前,其他事务不能针对该份数据加任何锁
3、意向锁(I锁(Intention Lock))
意向锁是针对表锁的,主要是为了提交加表锁的效率,是MySQL数据库自己加的。当有事务给表的数据加了读锁或写锁,同时会给表设置一个标记位,代表这个表已经有行锁了,其他事务不能对该表加表锁,如果没有这个标记位,其他事务就需要去逐行比对看是否有行锁来判断是否能加表锁了,当表中数据量大的时候,执行效率很低,意向锁就是为了方便是否能加表锁的判断
意向共享锁:IS锁,如果当前事务给表里的数据加的是读锁,那么就会给该表设置一个意向共享锁,其他事务想要给整个表增加共享锁前,就要先获取意向共享锁
意向排它锁:IX锁,原理同上,只不过加的是写锁
间隙锁(Gap Lock)
锁的是两个值之间的空隙,注意间隙锁只有在可重复读隔离级别下才会生效
id的间隙为:(1,8),(8,10),(10,20),(20,+∞),注意都是开区间
当执行:
select * from mylock where id = 18 for update;
就会锁住id为18的前后间隙,即(10,20)这个范围会被锁住,当有其他事务想要在(10,20)之间插入数据时,会阻塞,直到当前事务提交,但是其他的间隙还是可以插入,并且id为10和20的这两条数据也没有被锁住,其他事务还是可以修改的
注意,间隙是会随时变化的,比如当前只有(1,8),(8,10),(10,20),(20,+∞)这四个间隙,执行上面的查询sql,锁住了(10,20)这个间隙,此时如果另一个事务插入了一个id为5的数据,那么当前的间隙就变成了(1,5),(5,8),(8,10),(10,20),(20,+∞),如果执行select * from mylock where id = 7 for update;
,会锁住(5,8)这个间隙,(1,5)间隙并不受影响。
还有就是,insert语句并不会触发增加间隙锁这个动作,只会使间隙有所变更,比如在上图的情况,insert一个id为5的数据,并不会锁住(1,8)的这个间隙,其他事务依然可以插入该间隙中的数据。update和select for update会触发增加间隙锁的动作
临键锁(Next-key Locks)
临键锁就是行锁和间隙锁的组合,间隙锁是开区间的,如果一个查询或者多个查询,使间隙锁的左值或者右值也被锁上了,那么就是临键锁,可以说:
临键锁 = 间隙锁 + 行锁
总结
-
MYISAM在执行查询语句前,会自动给设计的所有表加读锁,在执行update,insert,delete操作时会自动给涉及的所有表加写锁,注意都是表锁级别。
-
InnoDB在执行查询语句是不会加锁(非串行化隔离级别,串行化隔离级别会自动加lock in share mode,即加S锁),但是update,insert,delete操作会加行锁。
-
读锁会阻塞写,不会阻塞读。写锁会把读和写都阻塞
-
InnoDB实现了行级锁定,虽然行级锁定比表级锁定的性能孙高更高,但是整体的并发处理能力还是要远优于MYISAM的标记锁定的,但是InnoDB也有其脆弱的一面,当我们使用不当的时候,整体性能甚至会比MYISAM更差,不能一概而论
锁等待分析
通过检查innodb_row_lock
状态变量来分析系统上的行锁的争夺情况
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: 系统启动后到现在总共等待的次数对于这5个状态变量,比较重要的主要是:
Innodb_row_lock_time_avg (等待平均时长)
Innodb_row_lock_waits (等待总次数)
Innodb_row_lock_time(等待总时长)
尤其当等待次数很高,每次等待时长也不小的时候,就需要分析为什么会有如此多的等待,然后做具体优化
查看INFORMATION_SCHEMA系统库锁相关数据表
-- 查看事务
select * from INFORMATION_SCHEMA.INNODB_TRX;
-- 查看锁,8.0之后需要换成这张表performance_schema.data_locks
select * from INFORMATION_SCHEMA.INNODB_LOCKS;
-- 查看锁等待,8.0之后需要换成这张表performance_schema.data_lock_waits
select * from INFORMATION_SCHEMA.INNODB_LOCK_WAITS; -- 释放锁,trx_MySQL_thread_id可以从INNODB_TRX表里查看到
kill trx_MySQL_thread_id-- 查看锁等待详细信息
show engine innodb status;
死锁问题分析
set tx_isolation='repeatable-read';
Session_1执行:select * from account where id=1 for update;
Session_2执行:select * from account where id=2 for update;
Session_1执行:select * from account where id=2 for update;
Session_2执行:select * from account where id=1 for update;查看近期死锁日志信息:show engine innodb status;
大多数情况MySQL可以自动检测死锁并回滚产生死锁的那个事务,但是有些情况MySQL没法自动检测死锁,这种情况我们可以通过日志分析找到对应事务线程id,可以通过kill杀掉
锁优化实践
- 尽可能让所有数据检索都通过索引来完成,避免无索引行锁升级为表锁(非真正表锁,全数据行锁)
- 合理设计索引,尽量缩小锁的范围
- 尽可能减少检索条件范围,避免间隙锁
- 尽可能控制事务大小,减少锁定资源量和时间长度,设计事务加锁的sql尽量放到事务最后执行
- 尽可能降低事务隔离级别
MVCC多版本并发控制机制
MVCC,Multi-Version Concurrency Control,多版本并发控制机制,旨在在读已提交和可重复读隔离级别下,保证事务的隔离性,对一行数据的读和写两个操作默认是不通过加锁来保证隔离性的,避免了频繁加锁。但是在串行化隔离级别下,为了保证较高的隔离性,是通过将所有操作加锁互斥来实现的。
MySQL在读已提交和可重复读隔离级别下都实现了MVCC机制。
undo日志版本链与read view机制详解
undo log
undo日志版本链是指一行数据被多个事务修改,在每个事务修改完后,MySQL会保留修改前的版本undo日志,并且用两个隐藏字段,**trx_id(事务id)和roll_pointer(回滚指针)**把这些undo日志串联起来形成一个历史记录版本链(如下图)
read-view
在可重复读隔离级别,当事务开启,第一次执行任何查询sql时,都会生成当前事务的一致性视图read-view,注意,该视图在事务结束之前永远都不会变化(如果是读已提交的隔离级别,每次执行查询sql时都会重新生成read-view,这就是两者查到数据不一样的原因),这个视图由两部分组成:执行查询时,所有未提交的事务id数组以及已创建的最大事务id,事务里的任何查询sql都是根据read-view在undo log版本链里从最新数据开始往回逐条作比较得出的快照结果
版本链比较规则(可见性算法)
从版本链中最新的一条开始比较(上图中trx_id为200的row)
-
如果row的trx_id落在绿色部分(trx_id < min_id),即表示这个版本是已提交的事务生成的,是可见的;
-
如果row的trx_id落在黄色部分(min_id <= trx_id <= max_id),就分为两种情况了:
a. 如果trx_id在read-view的视图数组(注意是数组)中,则表示这个版本是由还没有提交的事务生成的,不可见(若row的trx_id就是当前自己的事务,即自己编辑过,是可见的)
b. 如果trx_id不在read-view的视图数组中,则表示这个版本是由已提交的事务生成的,可见
PS:对于删除语句,可以认为是update的特殊情况,会把版本链上最新的数据复制一份,然后将trx_id修改成删除操作的trx_id,同时在该记录的头信息(record header)里的deleted_flag标记为上写上true,来标识该记录已被删除,在查询时按照上面的查询规则,如果查到可见的对应的记录的delete_flag为true,则表示记录已被删除,则不返回数据
关于read-view和可见性算法的原理解释
read-view其实就是记录了sql查询的那个时刻的数据库里提交和未提交事务的状态
在RR隔离级别,read-view是一直不变的,也就是都是以第一次查询时数据库事务的提交状态来比对数据是否可见的,就可以实现可重复读的效果
在RC隔离界别,read-view每次查询都会重新生成,已最新的数据库事务提交状态来比对数据是否可见,就是实现了读已提交的效果
**注意:**begin/start transaction命令并不是一个事务的起点,在执行到他们之后的第一个修改操作或加排它锁的操作语句时,事务才真正启动,才会向MySQL申请真正的事务id,MySQL内部是严格按照事务的启动顺序来分配事务id的。所有事务都结束后,会有清掉版本链的操作,版本链不会一直保留
**总结:**MVCC机制就是通过read-view和undo log版本链比对机制,使得不同事务根据版本链比对规则,读取到同一条数据的不同版本数据
七、Innodb底层原理与MySQL日志机制深入剖析
MySQL内部组件结构
大体来说,MySQL可以分为Server层和引擎层两部分
Server层
主要包括包括连接器、查询缓存、分析器、优化器、执行器等,涵盖MySQL的大多数核心服务功能,以及所有的内置函数(如日期、时间、数字和加密函数等),所有跨存储引擎的功能都在这一层受实现,比如存储过程、触发器、视图等
1、 连接器
客户端连接MySQL,第一步就是请求连接器,在完成经典的TCP握手后,连接器就要开始认证你的身份
- 如果用户名和密码不对,就会收到一个“Access denied for user”的错误,然后客户端程序结束执行
- 如果验证通过,连机器会到权限表里查出你拥有的权限。之后,这个连接里面的权限判断逻辑,都将依赖于此时读到的权限
这就意味着,一个用户成功建立连接后,即使管理员账号对这个用户的权限做了修改,也不会影响已经存在连接的权限。修改完成后,只有再新建的连接才会使用新的权限设值
2、 查询缓存
连接建立完成后,就可以执行select语句了,首先连接器会先去查询缓存,判断这条语句是否之前执行过,在缓存中是否存在,但是有一个点,sql语句必须完全一致才能成功命中缓存,如果缓存中没有拿到,就会走后面的流程,拿到查询结果后,放入缓存
这个查询缓存大多数情况是个鸡肋,为什么呢?
因为这个查询缓存失效的非常频繁,只要有对一个表的更新,这个表上所有的查询缓存就都会被清空,因此查询缓存费劲的存起来,结果还没用呢,就被一个更新全清空了。对于更新压力大的数据库来说,查询缓存的命中率非常低,一般来说,建议在静态表使用查询缓存,就是一般更新极少的表,比如系统配置表,字典表。
Mysql也提供了“按需使用”的方式,可以将my.cnf参数
query_cache_type
设置成DEMAND
my.cnf # query_cahce_type有3个值,0表示关闭查询缓存,1表示开启,2表示当前sql有SQL_CACHE关键词时才走查询缓存 query_cache_type=2
这样对于默认的sql语句都不使用查询缓存。而对于你确定要使用查询缓存的语句,可以用SQL_CACHE显示指定,像下面这个语句一样:
select SQL_CACHE * from test where id = 5;
查看当前mysql是否开启缓存机制
show global variables like '%query_cache_type%';
在mysql 8.0中,已经移除了查询缓存这个功能
3、 分析器
分为词法分析和语法分析,词法分析就是把关键词分析出来,语法分析就是检查语法是否正确,如果不满足则会提示“You have an error in you SQL syntax”,SQL语句经过分析器分析之后,会生成一个语法树
4、 优化器
优化器决定使用什么索引合适,或者一个语句中有join的时候,各个表的连接顺序,以及一些mysql自己内部的优化机制
5、 执行器
就是去调用存储引擎的接口,查询结果了,在开始执行的时候,会先判断下你对这个表有没有执行查询的权限
存储引擎层
存储引擎层负责数据的存储和提取。其架构模式是插件式的,支持InnoDB、MyISAM、Memory等多个存储引擎。现在最常用的存储引擎是InnoDB,它从MySQL 5.5.5版本开始成为了默认存储引擎
InnoDB底层原理
Mysql日志机制
redo log重做日志关键参数
innodb_log_buffer_size:设置redo log buffer的大小,默认16M,最大值是4096M,最小值为1M
show variables like '%innodb_log_buffer_size%';
innodb_log_group_home_dir:设置redo log文件存储位置参数,默认值为".\",即innodb数据文件的存储位置(data目录),其中的ib_logfile0和ib_logfile1即为redo log文件
show variables like '%innodb_log_group_home_dir%';
innodb_log_files_in_group:设置redo log文件的个数,默认2个,最大100个
show variables like '%innodb_log_files_in_group%';
innodb_log_file_size:设置单个redo log文件大小,默认值为48M,最大值为512G,注意最大值指的是整个redo log系列文件之和,即(innodb_log_files_in_group * innodb_log_file_size)不能大于最大值512G。
show variables like '%innodb_log_file_size%';
redo log写入磁盘过程分析
redo log是从头开始写,从第一个文件开始,写完第一个文件写第二个,直到最后一个文件写完,又开始写第一个文件(覆盖写入)
write pos是当前记录的位置,一边写一边右移,写到3号文件之后就会到0号文件开头
check point是当前要擦除的位置,也是往后推移并且循环的,擦除记录前要把记录更新到ibd文件里
write pos和check point之间的部分就是可写的部分,可以用来记录新的操作。如果write pos追上了check point,表示redo log写满了,这时候不能再执行新的修改操作了,得听下来擦掉一些记录,把check point推进一下
innodb_flush_log_at_trx_commit:这个参数控制redo log的写入策略
- 设置为0:表示当执行器把redo log写入到redo log buffer中的时候,就算是事务提交成功了,数据库宕机的时候可能会丢失数据
- 设置为1(默认值):表示当把redo log真正写入到磁盘中后,才算是事务提交成功了,这样基本上是不会丢失数据的,线上也推荐用此默认值
- 设置为2:表示当把redo log写入到操作系统的page Cache的时候,就算是事务提交成功了,这样数据库宕机不会丢失数据,但系统宕机,如果page Cache中的数据还没来得及写入到磁盘,那么也会丢失数据
InnoDB后台有个线程,每隔1秒,会把redo log buffer中的日志,调用操作系统函数write,写入到系统page Cache中,然后调用系统函数fsync,持久化到磁盘文件
redo log写入策略:
# 查看innodb_flush_log_at_trx_commit参数值:
show variables like 'innodb_flush_log_at_trx_commit';
# 设置innodb_flush_log_at_trx_commit参数值(也可以在my.ini或my.cnf文件里配置):
set global innodb_flush_log_at_trx_commit=1;
binlog二进制归档日志
binlog,即二进制日志,保存了所有执行过的修改操作语句,不保存查询语句。如果Mysql服务意外停止,可以通过binlog查看用户操作或表结构操作,从而来回复数据库数据
启动binlog记录功能,会影响数据库性能,但现在一般公司都会打开binlog,因为如果需要恢复数据或者开启主从复制功能,必须要有binlog,综合下来利大于弊,这也是mysql 8.0默认打开了binlog的原因(因为大多数公司都是打开的,mysql5.7默认是关闭的)
# 查看binlog相关参数
show variables like '%log_bin%';
如果要打开binlog功能,需要修改配置文件my.ini(windows)或my.cnf(linux),然后重启数据库。
在配置文件中的[mysqld]部分增加如下配置:
# log-bin设置binlog的存放位置,可以是绝对路径,也可以是相对路径,这里写的相对路径,则binlog文件默认会放在data数据目录下
log-bin=mysql-binlog
# Server Id是数据库服务器id,随便写一个数都可以,这个id用来在mysql集群环境中标记唯一mysql服务器,集群环境中每台mysql服务器的id不能一样,不加启动会报错
server-id=1
# 其他配置
binlog_format = row # 日志文件格式,下面会详细解释
expire_logs_days = 15 # 执行自动删除距离当前15天以前的binlog日志文件的天数, 默认为0, 表示不自动删除
max_binlog_size = 200M # 单个binlog日志文件的大小限制,默认为 1GB
重启数据库后会发现data目录下多了两个文件,一个是binlog日志文件,一个是binlog索引文件,这个文件管理了所有binlog文件的目录
当然也可以执行命令查看有多少binlog文件
show binary logs;
show variables like '%log_bin%';
log_bin:binlog日志是否打开状态
log_bin_basename:是binlog日志的基本文件名,后面会追加标识来表示每一个文件,binlog日志文件会滚动增加
log_bin_index:指定的是binlog文件的索引文件,这个文件管理了所有的binlog文件的目录。
sql_log_bin:sql语句是否写入binlog文件,ON代表需要写入,OFF代表不需要写入。如果想在主库上执行一些操作,但不复制到slave库上,可以通过修改参数sql_log_bin来实现。比如说,模拟主从同步复制异常。
binlog的日志格式
用参数binlog_format可以设置binlog日志的记录格式,mysql支持三种格式类型:
- STATEMENT:记录语句的,开销最低,把每条操作数据库的语句记录下就好了,但是有个问题就是,对于一直执行过程中才能确定结果的函数,如:UUID()、SYSDATE()等函数,如果随着sql同步到slave机器去执行,则结果会跟master机器执行的不一样
- ROW:记录具体改变的行,这个不会有STATEMENT那样主从不一致的问题,但是也有新的问题,就是如果执行一个范围更新,update where id > 10这种,更新行数巨大的,那么记录日志所消耗的性能和时间也就会长一些
- MIXED:混合复制模式,实际就是前两种的结合,当有函数或者需要执行时才知道结果的SQL,就用ROW模式,其他情况用STATEMENT模式记录sql语句就好了,推荐使用MIXED模式
binlog写入磁盘机制
通过sync_binlog参数控制,默认为0
- 为0的时候:表示每次事务提交都只write到page cache,有系统自行判断什么时候执行fsync写入磁盘,虽然性能得到提升,但是机器宕机,page cache里面的binlog会丢失
- 为1的时候:就是每次提交事务都要执行fsync写入磁盘,这种方式最安全
- 为N的时候(N>1):每次还是写到page cache中,累计N个事务后才fsync写入磁盘,如果机器宕机就有可能丢失N个事务的binlog
发生以下任何事件时,binlog日志文件会重新生成:
- 服务器启动或重新启动(生成新的binlog文件,比如当前是000019,重启就生成000020)
- 服务器刷新日志,执行命令flush logs
- 日志大小达到max_binlog_size,默认值为1GB
删除binlog日志文件
删除当前的binlog文件
reset master;
# 删除指定日志文件之前的所有日志文件,下面这个是删除6之前的所有日志文件,当前这个文件不删除
purge master logs to 'mysql-binlog.000006';
# 删除指定日期前的日志索引中binlog日志文件
purge master logs before '2023-01-21 14:00:00';
查看 binlog 日志文件
可以用mysql自带的命令工具 mysqlbinlog 查看binlog日志内容
sudo mysqlbinlog --no-defaults -v --base64-output=decode-rows binlog.000020sudo mysqlbinlog --no-defaults -v --base64-output=decode-rows binlog.000020 start-datetime="2023-01-21 00:00:00" stop-datetime="2023-02-01 00:00:00" start-position="5000" stop-position="20000"
binlog日志文件恢复数据
场景模拟下binlog恢复数据
执行sqlreset master
,恢复删除binlog,之后就会只剩下binlog.000001和binlog.index这两个原始文件
顺便看下原始binlog文件(binlog.000001)
原始表中的数据
新插入两条id为30和40的数据
insert into mylock value (30, 'abc');
insert into mylock value (40, 'def');
现在表里的数据
假如我们误删了两条连续插入的数据
delete from mylock where id > 20;
又变回了这样
用mysqlbinlog命令读取binlog.000001文件,可以发现,每条sql上下都有BEGIN和COMMIT,找到第一条sql BEGIN前面的文件位置标识at 236,再找到第二条sql COMMIT后面的文件位置标识at 747
然后执行以下命令恢复数据
mysqlbinlog --no-defaults --start-position=236 --stop-position=747 --database=note_sys /usr/local/mysql/data/binlog.000001 | mysql -uroot -p123456 -v note_sys# 补充一个根据时间来恢复数据的命令,我们找到第一条sql BEGIN前面的时间戳标记 SET TIMESTAMP=1674833544,再找到第二条sql COMMIT后面的时间戳标记 SET TIMESTAMP=1674833663,转成datetime格式
mysqlbinlog --no-defaults --start-datetime="2023-1-27 23:32:24" --stop-datetime="2023-1-27 23:34:23" --database=test D:/dev/mysql-5.7.25-winx64/data/mysql-binlog.000009 | mysql -uroot -p123456 -v test
数据被成功恢复
**注意:**如果要恢复大量数据,比如被删库了,要怎么恢复?如果数据库之前没有备份,所有的binlog日志都在的话,就从binlog第一个文件开始逐个恢复每个binlog文件里的数据,这种一般不太可能,因为binlog日志比较大,早期的binlog文件会定期删除的(前面的配置expire_logs_days
),所以一般不可能用binlog文件恢复整个数据库的。一般都是给数据库做备份后,结合binlog进行数据恢复
数据库备份
一般我们推荐的是每天(在凌晨后)需要做一次全量数据库备份,那么恢复数据就可以用最近一次的全量备份再加上备份时间点之后的binlog来恢复数据。
备份数据库一般可以用mysqldump命令工具
mysqldump -u root 数据库名>备份文件名; #备份整个数据库
mysqldump -u root 数据库名 表名字>备份文件名; #备份整个表mysql -u root test < 备份文件名 #恢复整个数据库,test为数据库名称,需要自己先建一个数据库test
为什么会有redo log和binlog两份日志呢?
最开始MySQL里并没有innoDB引擎。MySQL自带的引擎是MyISAM,但是MyISAM没有crash-safe能力(crash-safe,数据库发生异常重启,之前提交的记录都不丢失,这个能力称为crash-safe),binlog日志只能用于归档。InnoDB是另一个公司以插件形式引入MySQL的,既然只依靠binlog是没有crash-safe能力的,所以InnoDB使用另外一套日志系统——也就是redo log来实现crash-safe能力
undo log回滚日志
在MySQL 5.x版本,undo log默认保存在data目录下的ibdata1文件里,使用innodb_undo_tablespaces
可以设置undo log文件的数量,这样回滚段就可以较为平均地分布在多个文件中,设置该参数后,会在路径innodb_undo_directory
看到undo为前缀的文件。InnoDB采用段的方式对undo log进行管理,也就是回滚段(rollback segement)。每个回滚段记录了1024个undo log segement,每个事务只会使用一个undo log segement
在MySQL 5.5版本,只有一个回滚段,一个回滚段记录1024个undo log segement,所以最大同时支持的事务数量就是1024个,从MySQL 5.6开始,InnoDB支持最大128个回滚段,所以同时在线的事务限制也就提高到了128*1024个
innodb_undo_directory: 设置undo log文件所在的路径。该参数的默认值为"./",即innodb数据文件存储位置,目录下ibdata1文件就是undo log存储的位置。
innodb_undo_logs: 设置undo log文件内部回滚段的个数,默认值为128。
innodb_undo_tablespaces: 设置undo log文件的数量,这样回滚段可以较为平均地分布在多个文件中。设置该参数后,会在路径innodb_undo_directory看到undo为前缀的文件。
undo log日志什么时候删除
新增类型的,在事务提交后就可以清除掉了
修改类型的,事务在提交后还不能立即清除掉,因为这些日志会用于mvcc。只有当没有事务用到该版本信息的时候才可以清楚
为什么MySQL不能直接更新磁盘上的数据而设置这么一套复杂的机制来执行SQL?
其实就是为了性能,如果来一个请求MySQL就去随机更新磁盘的话,性能可能会相当差,无法抗住高并发的场景
所以每次请求更新的是内存Buffer Pool,然后顺序写日志文件
错误日志
MySQL还有一个比较重要的日志是错误日志,它记录了运行过程中发生任何严重错误时的相关信息,记录了数据库启动和停止。当数据库出现故障无法使用时,建议首先查看此日志。该功能默认开启,且无法关闭
# 查看错误日志存放位置
show variables like '%log_error%';
通用查询日志
记录用户的所有操作,启动关闭MySQL服务,select、show等,无论语句正确与否,都会记录,用于解决疑难杂症的时候开启,但是注意,开启会消耗系统资源并占用磁盘空间,非必要时不建议开启
general_log:是否开启日志参数,默认为OFF,处于关闭状态,因为开启会消耗系统资源并且占用磁盘空间。一般不建议开启,只在需要调试查询问题时开启。
general_log_file:通用查询日志记录的位置参数。
show variables like '%general_log%';
# 打开通用查询日志
SET GLOBAL general_log=on;
八、MySQL全局优化与MySQL 8.0新特性讲解
成本最低的SQL索引优化,带来的优化效果是最好的,所以在工作中应该针对SQL语句多花时间
my.cnf或my.ini中的重要全局参数
假设服务器配置为:
- CPU:32核
- 内存:64G
- DISK:2T SSD
下面参数都是服务端参数,默认在配置文件的 [mysqld] 标签下
1、 MySQL支持的最大连接数
max_connections=3000
要注意的是,连接数设置的过大并不一定就会带来吞吐量的提高,可能只会占用更多的系统资源,一个连接最少占用内存是256K,最大是64M,如果一个连接的请求数据超过64M,比如排序,就会申请临时空间,放到硬盘上,产生硬盘SWAP,此时会影响性能。
2、允许用户连接的最大数量
max_user_connections=2980
广义上讲,这个配置才是真正设置的最大连接数,剩余连接数要用作DBA管理,防止突发情况DBA无法连接
3、暂存的连接数量
back_log=300
有点像线程池队列,当连接数达到max_connections
时,新的请求就会被存在堆栈中,等待其他资源的释放,这个堆栈数量就是back_log,如果等待连接的数量超过back_log,将会被拒绝
4、jdbc连接空闲断开时间
wait_timeout=300
通过jdbc连接空闲300s后自动断开,默认28800,单位秒,8小时
5、mysql client连接空闲断开时间
innodb_thread_concurrency=64
mysql client连接mysql进行操作完毕后,空闲300秒后断开,默认是28800,单位秒,即8个小时
6、InnoDB线程的并发数
innodb_thread_concurrency=64
默认0,表示不限制,建议设置与服务器的CPU核心数相同或者是CPU的核心数的2倍,如果超过配置并发数,则需要排队,这个值不宜太大,不然可能导致线程之间锁争抢严重,影响性能
7、innodb存储引擎buffer pool缓存大小
innodb_buffer_pool_size=40G
一般为物理内存的60%-70%
8、行锁锁定时间
innodb_lock_wait_timeout=10
默认50s,根据公司业务定,没有标准值
9、redo log写入策略控制
innodb_flush_log_at_trx_commit=1
[第七节有讲](#redo log写入磁盘过程分析),这里简单复习一遍
-
为0:表示写入redo log buffer就算事务提交成功,mysql宕机容易丢数据
-
为1(默认):表示写入redo log磁盘文件才算事务提交成功,不会丢失数据,建议使用1
-
为2:表示写入redo log buffer后,写入os的page cache就算事务提交成功,mysql宕机不会丢数据,但系统宕机容易丢数据
10、binlog写入磁盘机制控制
sync_binlog=1
第七节有讲,这里简单复习一遍
- 为0(默认):每次事务提交时,都只调用系统函数write写入page cache,由系统自行判断何时调用fsync刷入磁盘,系统重启容易丢失数据
- 为1:每次事务提交时,都会刷入磁盘,最安全
- 为N(N>1):累计提交N个事务后,调用fsync刷入磁盘,系统宕机也容易丢失数据
11、用于排序的缓冲区大小
sort_buffer_size=4M
每个需要排序的线程分配该大小的一个缓冲区,可以加速order by和group by操作
注意:这是一个connection级的参数,每个connection(session)第一次需要使用这个buffer的时候,一次性分配设置的内存,同样也不是越大越好
12、用于表关联的缓存大小
join_buffer_size=4M
用于表关联缓存的大小,和sort_buffer_size一样,该参数对应的分配内存也是每个连接独享。
MySQL 8.0新特性详解
1、新增降序索引
MySQL虽然在语法上很早就支持降序索引,但实际上创建的还是升序索引,8.0的InnoDB才真正开始支持降序索引,只有InnoDB存储引擎支持降序索引。
注意:排序必须按照每个字段定义的排序或按相反顺序才能充分利用索引
# ====MySQL 5.7演示====
mysql> create table t1(c1 int,c2 int,index idx_c1_c2(c1,c2 desc));
Query OK, 0 rows affected (0.04 sec)mysql> insert into t1 (c1,c2) values(1, 10),(2,50),(3,50),(4,100),(5,80);
Query OK, 5 rows affected (0.02 sec)mysql> show create table t1\G
*************************** 1. row ***************************Table: t1
Create Table: CREATE TABLE `t1` (`c1` int(11) DEFAULT NULL,`c2` int(11) DEFAULT NULL,KEY `idx_c1_c2` (`c1`,`c2`) --注意这里,c2字段是升序
) ENGINE=InnoDB DEFAULT CHARSET=latin1
1 row in set (0.00 sec)mysql> explain select * from t1 order by c1,c2 desc; --5.7也会使用索引,但是Extra字段里有filesort文件排序
+----+-------------+-------+------------+-------+---------------+-----------+---------+------+------+----------+-----------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+-------+---------------+-----------+---------+------+------+----------+-----------------------------+
| 1 | SIMPLE | t1 | NULL | index | NULL | idx_c1_c2 | 10 | NULL | 1 | 100.00 | Using index; Using filesort |
+----+-------------+-------+------------+-------+---------------+-----------+---------+------+------+----------+-----------------------------+
1 row in set, 1 warning (0.01 sec)# ====MySQL 8.0演示====
mysql> create table t1(c1 int,c2 int,index idx_c1_c2(c1,c2 desc));
Query OK, 0 rows affected (0.02 sec)mysql> insert into t1 (c1,c2) values(1, 10),(2,50),(3,50),(4,100),(5,80);
Query OK, 5 rows affected (0.02 sec)mysql> show create table t1\G
*************************** 1. row ***************************Table: t1
Create Table: CREATE TABLE `t1` (`c1` int DEFAULT NULL,`c2` int DEFAULT NULL,KEY `idx_c1_c2` (`c1`,`c2` DESC) --注意这里的区别,降序索引生效了
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)mysql> explain select * from t1 order by c1,c2 desc; --Extra字段里没有filesort文件排序,充分利用了降序索引
+----+-------------+-------+------------+-------+---------------+-----------+---------+------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+-------+---------------+-----------+---------+------+------+----------+-------------+
| 1 | SIMPLE | t1 | NULL | index | NULL | idx_c1_c2 | 10 | NULL | 1 | 100.00 | Using index |
+----+-------------+-------+------------+-------+---------------+-----------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)mysql> explain select * from t1 order by c1 desc,c2; --Extra字段里有Backward index scan,意思是反向扫描索引;
+----+-------------+-------+------------+-------+---------------+-----------+---------+------+------+----------+----------------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+-------+---------------+-----------+---------+------+------+----------+----------------------------------+
| 1 | SIMPLE | t1 | NULL | index | NULL | idx_c1_c2 | 10 | NULL | 1 | 100.00 | Backward index scan; Using index |
+----+-------------+-------+------------+-------+---------------+-----------+---------+------+------+----------+----------------------------------+
1 row in set, 1 warning (0.00 sec)mysql> explain select * from t1 order by c1 desc,c2 desc; --Extra字段里有filesort文件排序,排序必须按照每个字段定义的排序或按相反顺序才能充分利用索引
+----+-------------+-------+------------+-------+---------------+-----------+---------+------+------+----------+-----------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+-------+---------------+-----------+---------+------+------+----------+-----------------------------+
| 1 | SIMPLE | t1 | NULL | index | NULL | idx_c1_c2 | 10 | NULL | 1 | 100.00 | Using index; Using filesort |
+----+-------------+-------+------------+-------+---------------+-----------+---------+------+------+----------+-----------------------------+
1 row in set, 1 warning (0.00 sec)mysql> explain select * from t1 order by c1,c2; --Extra字段里有filesort文件排序,排序必须按照每个字段定义的排序或按相反顺序才能充分利用索引
+----+-------------+-------+------------+-------+---------------+-----------+---------+------+------+----------+-----------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+-------+---------------+-----------+---------+------+------+----------+-----------------------------+
| 1 | SIMPLE | t1 | NULL | index | NULL | idx_c1_c2 | 10 | NULL | 1 | 100.00 | Using index; Using filesort |
+----+-------------+-------+------------+-------+---------------+-----------+---------+------+------+----------+-----------------------------+
1 row in set, 1 warning (0.00 sec)
2、group by不再隐式排序
MySQL 8.0之前的group by是会针对分组字段进行隐式排序的,8.0之后不再进行隐式排序,如果需要排序,必须显式加上order by子句
# ====MySQL 5.7演示====
mysql> select count(*),c2 from t1 group by c2;
+----------+------+
| count(*) | c2 |
+----------+------+
| 1 | 10 |
| 2 | 50 |
| 1 | 80 |
| 1 | 100 |
+----------+------+
4 rows in set (0.00 sec)# ====MySQL 8.0演示====
mysql> select count(*),c2 from t1 group by c2; --8.0版本group by不再默认排序
+----------+------+
| count(*) | c2 |
+----------+------+
| 1 | 10 |
| 2 | 50 |
| 1 | 100 |
| 1 | 80 |
+----------+------+
4 rows in set (0.00 sec)mysql> select count(*),c2 from t1 group by c2 order by c2; --8.0版本group by不再默认排序,需要自己加order by
+----------+------+
| count(*) | c2 |
+----------+------+
| 1 | 10 |
| 2 | 50 |
| 1 | 80 |
| 1 | 100 |
+----------+------+
4 rows in set (0.00 sec)
3、增加隐式索引
使用invisible关键字在创建表或者进行表变更的时候,设置索引为隐式索引。索引隐藏只是不可见,也不可用,即时显式指定该索引页无法使用,但是后台还是会维护隐藏索引的,该索引真实存在,必要时,可以很快的恢复为可见的。注意,主键不能设置invisible
场景,比如我们觉得一个索引没有用了,但是不确定,如果表数据量很大,贸然删除,发现有用,加回来是很耗性能的,这时候就可以用隐式索引,确定真的没什么用,再真正的删除索引
# 创建t2表,里面的c2字段为隐藏索引
mysql> create table t2(c1 int, c2 int, index idx_c1(c1), index idx_c2(c2) invisible);
Query OK, 0 rows affected (0.02 sec)mysql> show index from t2\G
*************************** 1. row ***************************Table: t2Non_unique: 1Key_name: idx_c1Seq_in_index: 1Column_name: c1Collation: ACardinality: 0Sub_part: NULLPacked: NULLNull: YESIndex_type: BTREEComment:
Index_comment: Visible: YESExpression: NULL
*************************** 2. row ***************************Table: t2Non_unique: 1Key_name: idx_c2Seq_in_index: 1Column_name: c2Collation: ACardinality: 0Sub_part: NULLPacked: NULLNull: YESIndex_type: BTREEComment:
Index_comment: Visible: NO --隐藏索引不可见Expression: NULL
2 rows in set (0.00 sec)mysql> explain select * from t2 where c1=1;
+----+-------------+-------+------------+------+---------------+--------+---------+-------+------+----------+-------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------+--------+---------+-------+------+----------+-------+
| 1 | SIMPLE | t2 | NULL | ref | idx_c1 | idx_c1 | 5 | const | 1 | 100.00 | NULL |
+----+-------------+-------+------------+------+---------------+--------+---------+-------+------+----------+-------+
1 row in set, 1 warning (0.00 sec)mysql> explain select * from t2 where c2=1; --隐藏索引c2不会被使用
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+
| 1 | SIMPLE | t2 | NULL | ALL | NULL | NULL | NULL | NULL | 1 | 100.00 | Using where |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)mysql> select @@optimizer_switch\G --查看各种参数
*************************** 1. row ***************************
@@optimizer_switch: index_merge=on,index_merge_union=on,index_merge_sort_union=on,index_merge_intersection=on,engine_condition_pushdown=on,index_condition_pushdown=on,mrr=on,mrr_cost_based=on,block_nested_loop=on,batched_key_access=off,materialization=on,semijoin=on,loosescan=on,firstmatch=on,duplicateweedout=on,subquery_materialization_cost_based=on,use_index_extensions=on,condition_fanout_filter=on,derived_merge=on,use_invisible_indexes=off,skip_scan=on,hash_join=on
1 row in set (0.00 sec)mysql> set session optimizer_switch="use_invisible_indexes=on"; ----在会话级别设置查询优化器可以看到隐藏索引
Query OK, 0 rows affected (0.00 sec)mysql> select @@optimizer_switch\G
*************************** 1. row ***************************
@@optimizer_switch: index_merge=on,index_merge_union=on,index_merge_sort_union=on,index_merge_intersection=on,engine_condition_pushdown=on,index_condition_pushdown=on,mrr=on,mrr_cost_based=on,block_nested_loop=on,batched_key_access=off,materialization=on,semijoin=on,loosescan=on,firstmatch=on,duplicateweedout=on,subquery_materialization_cost_based=on,use_index_extensions=on,condition_fanout_filter=on,derived_merge=on,use_invisible_indexes=on,skip_scan=on,hash_join=on
1 row in set (0.00 sec)mysql> explain select * from t2 where c2=1;
+----+-------------+-------+------------+------+---------------+--------+---------+-------+------+----------+-------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------+--------+---------+-------+------+----------+-------+
| 1 | SIMPLE | t2 | NULL | ref | idx_c2 | idx_c2 | 5 | const | 1 | 100.00 | NULL |
+----+-------------+-------+------------+------+---------------+--------+---------+-------+------+----------+-------+
1 row in set, 1 warning (0.00 sec)mysql> alter table t2 alter index idx_c2 visible;
Query OK, 0 rows affected (0.02 sec)
Records: 0 Duplicates: 0 Warnings: 0mysql> alter table t2 alter index idx_c2 invisible;
Query OK, 0 rows affected (0.01 sec)
Records: 0 Duplicates: 0 Warnings: 0
4、新增函数索引
MySQL 8.0.13开始,支持在索引中使用函数(表达式)的值,函数索引基于虚拟列功能实现,其实就是MySQL在表中新增了一列,维护函数表达式的值,然后使用到函数的时候,使用该列作为索引,一般不推荐在查询中使用函数,函数索引也是有额外消耗的,非必要不建议使用
mysql> create table t3(c1 varchar(10),c2 varchar(10));
Query OK, 0 rows affected (0.02 sec)mysql> create index idx_c1 on t3(c1); --创建普通索引
Query OK, 0 rows affected (0.03 sec)
Records: 0 Duplicates: 0 Warnings: 0mysql> create index func_idx on t3((UPPER(c2))); --创建一个大写的函数索引
Query OK, 0 rows affected (0.03 sec)
Records: 0 Duplicates: 0 Warnings: 0mysql> show index from t3\G
*************************** 1. row ***************************Table: t3Non_unique: 1Key_name: idx_c1Seq_in_index: 1Column_name: c1Collation: ACardinality: 0Sub_part: NULLPacked: NULLNull: YESIndex_type: BTREEComment:
Index_comment: Visible: YESExpression: NULL
*************************** 2. row ***************************Table: t3Non_unique: 1Key_name: func_idxSeq_in_index: 1Column_name: NULLCollation: ACardinality: 0Sub_part: NULLPacked: NULLNull: YESIndex_type: BTREEComment:
Index_comment: Visible: YESExpression: upper(`c2`) --函数表达式
2 rows in set (0.00 sec)mysql> explain select * from t3 where upper(c1)='ZHUGE';
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+
| 1 | SIMPLE | t3 | NULL | ALL | NULL | NULL | NULL | NULL | 1 | 100.00 | Using where |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)mysql> explain select * from t3 where upper(c2)='ZHUGE'; --使用了函数索引
+----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+
| 1 | SIMPLE | t3 | NULL | ref | func_idx | func_idx | 43 | const | 1 | 100.00 | NULL |
+----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+
1 row in set, 1 warning (0.00 sec)
5、InnoDB存储引擎,select for update跳过锁等待
对于select…for share(8.0新加的查询共享锁的语法)或select…for update,在语句后增加NOWAIT、SKIP LOCKED语法可以跳过锁等待,或者跳过锁定,即直接报错或直接返回其他值,在之前的版本是会一直等待,直到配置的innodb_lock_wait_timeout
超时
场景:比如查询余票记录,如果某些记录已经被索引,不对外出售了,用skip locked可以跳过被锁定的记录,只返回没有锁定的记录,提高系统性能
# 先打开一个session1:
mysql> select * from t1;
+------+------+
| c1 | c2 |
+------+------+
| 1 | 10 |
| 2 | 50 |
| 3 | 50 |
| 4 | 100 |
| 5 | 80 |
+------+------+
5 rows in set (0.00 sec)mysql> begin;
Query OK, 0 rows affected (0.00 sec)mysql> update t1 set c2 = 60 where c1 = 2; --锁定第二条记录
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0# 另外一个session2:
mysql> select * from t1 where c1 = 2 for update; --等待超时
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transactionmysql> select * from t1 where c1 = 2 for update nowait; --查询立即返回
ERROR 3572 (HY000): Statement aborted because lock(s) could not be acquired immediately and NOWAIT is set.mysql> select * from t1 for update skip locked; --查询立即返回,过滤掉了第二行记录
+------+------+
| c1 | c2 |
+------+------+
| 1 | 10 |
| 3 | 50 |
| 4 | 100 |
| 5 | 80 |
+------+------+
4 rows in set (0.00 sec)
6、新增innodb_dedicated_server自适应参数
能够让InnoDB根据服务器上检测到的内存大小自动配置innodb_buffer_pool_size,innodb_log_file_size等参数,会尽可能多的占用系统资源来提升性能,解决非专业人员安装数据库后默认初始化数据库参数偏低的问题,但是注意,前提是服务器仅用来做mysql服务器使用,如果还有其他软件或者资源,或者多实例Mysql使用,不建议开启该参数,不然会影响其他程序
mysql> show variables like '%innodb_dedicated_server%'; --默认是OFF关闭,修改为ON打开
+-------------------------+-------+
| Variable_name | Value |
+-------------------------+-------+
| innodb_dedicated_server | OFF |
+-------------------------+-------+
1 row in set (0.02 sec)
7、死锁检查控制
不算是Mysql8.0的新特性,从MySQL 5.7.15开始,增加了一个新的动态变量innodb_deadlock_detect
,用于控制系统是否执行InnoDB死锁检查,默认是打开的,死锁检查会消耗数据库性能,如果是高并发的系统,可以关闭死锁检查以提升性能,但是我们要确保系统极少情况会发生死锁,同时要将锁等待超时参数调小一点,以防出现死锁等待过久的情况
mysql> show variables like '%innodb_deadlock_detect%'; --默认是打开的
+------------------------+-------+
| Variable_name | Value |
+------------------------+-------+
| innodb_deadlock_detect | ON |
+------------------------+-------+
1 row in set, 1 warning (0.01 sec)
8、undo文件不在使用系统表空间
默认创建两个undo表空间,不再使用系统表空间
5.7版本实在ibdata1
,系统表空间文件中
9、binlog日志过期时间精确到秒
之前是精确到天,并且参数名称发生变化,在8.0版本之前,binlog日志过期时间设置都是设置expire_logs_days
参数,而在8.0版本中,MySQL默认使用binlog_expire_logs_seconds
参数
10、窗口函数
MySQL 8.0引入的新概念,有多种函数供使用,可以简单的理解为,原来的group by是聚合后计算得出的一个值,使用了窗口函数,不再需要分组group by,可以根据设置的分区进行分组,查询结果也不是像group by一样合并为一行,而是将结果放回多行之中
# 创建一张账户余额表
CREATE TABLE `account_channel` (`id` int NOT NULL AUTO_INCREMENT,`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '姓名',`channel` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '账户渠道',`balance` int DEFAULT NULL COMMENT '余额',PRIMARY KEY (`id`)
) ENGINE=InnoDB# 插入一些示例数据
INSERT INTO `test`.`account_channel` (`id`, `name`, `channel`, `balance`) VALUES ('1', 'zhuge', 'wx', '100');
INSERT INTO `test`.`account_channel` (`id`, `name`, `channel`, `balance`) VALUES ('2', 'zhuge', 'alipay', '200');
INSERT INTO `test`.`account_channel` (`id`, `name`, `channel`, `balance`) VALUES ('3', 'zhuge', 'yinhang', '300');
INSERT INTO `test`.`account_channel` (`id`, `name`, `channel`, `balance`) VALUES ('4', 'lilei', 'wx', '200');
INSERT INTO `test`.`account_channel` (`id`, `name`, `channel`, `balance`) VALUES ('5', 'lilei', 'alipay', '100');
INSERT INTO `test`.`account_channel` (`id`, `name`, `channel`, `balance`) VALUES ('6', 'hanmeimei', 'wx', '500');mysql> select * from account_channel;
+----+-----------+---------+---------+
| id | name | channel | balance |
+----+-----------+---------+---------+
| 1 | zhuge | wx | 100 |
| 2 | zhuge | alipay | 200 |
| 3 | zhuge | yinhang | 300 |
| 4 | lilei | wx | 200 |
| 5 | lilei | alipay | 100 |
| 6 | hanmeimei | wx | 500 |
+----+-----------+---------+---------+
6 rows in set (0.00 sec)mysql> select name,sum(balance) from account_channel group by name;
+-----------+--------------+
| name | sum(balance) |
+-----------+--------------+
| zhuge | 600 |
| lilei | 300 |
| hanmeimei | 500 |
+-----------+--------------+
3 rows in set (0.00 sec)# 在聚合函数后面加上over()就变成分析函数了,后面可以不用再加group by制定分组,因为在over里已经用partition关键字指明了如何分组计算,这种可以保留原有表数据的结构,不会像分组聚合函数那样每组只返回一条数据
mysql> select name,channel,balance,sum(balance) over(partition by name) as sum_balance from account_channel;
+-----------+---------+---------+-------------+
| name | channel | balance | sum_balance |
+-----------+---------+---------+-------------+
| hanmeimei | wx | 500 | 500 |
| lilei | wx | 200 | 300 |
| lilei | alipay | 100 | 300 |
| zhuge | wx | 100 | 600 |
| zhuge | alipay | 200 | 600 |
| zhuge | yinhang | 300 | 600 |
+-----------+---------+---------+-------------+
6 rows in set (0.00 sec)mysql> select name,channel,balance,sum(balance) over(partition by name order by balance) as sum_balance from account_channel;
+-----------+---------+---------+-------------+
| name | channel | balance | sum_balance |
+-----------+---------+---------+-------------+
| hanmeimei | wx | 500 | 500 |
| lilei | alipay | 100 | 100 |
| lilei | wx | 200 | 300 |
| zhuge | wx | 100 | 100 |
| zhuge | alipay | 200 | 300 |
| zhuge | yinhang | 300 | 600 |
+-----------+---------+---------+-------------+
6 rows in set (0.00 sec)# over()里如果不加条件,则默认使用整个表的数据做运算
mysql> select name,channel,balance,sum(balance) over() as sum_balance from account_channel;
+-----------+---------+---------+-------------+
| name | channel | balance | sum_balance |
+-----------+---------+---------+-------------+
| zhuge | wx | 100 | 1400 |
| zhuge | alipay | 200 | 1400 |
| zhuge | yinhang | 300 | 1400 |
| lilei | wx | 200 | 1400 |
| lilei | alipay | 100 | 1400 |
| hanmeimei | wx | 500 | 1400 |
+-----------+---------+---------+-------------+
6 rows in set (0.00 sec)mysql> select name,channel,balance,avg(balance) over(partition by name) as avg_balance from account_channel;
+-----------+---------+---------+-------------+
| name | channel | balance | avg_balance |
+-----------+---------+---------+-------------+
| hanmeimei | wx | 500 | 500.0000 |
| lilei | wx | 200 | 150.0000 |
| lilei | alipay | 100 | 150.0000 |
| zhuge | wx | 100 | 200.0000 |
| zhuge | alipay | 200 | 200.0000 |
| zhuge | yinhang | 300 | 200.0000 |
+-----------+---------+---------+-------------+
6 rows in set (0.00 sec)
专用窗口函数:
- 序号函数:ROW_NUMBER()、RANK()、DENSE_RANK()
- 分布函数:PERCENT_RANK()、CUME_DIST()
- 前后函数:LAG()、LEAD()
- 头尾函数:FIRST_VALUE()、LAST_VALUE()
- 其它函数:NTH_VALUE()、NTILE()
# 按照balance字段排序,展示序号
mysql> select name,channel,balance,row_number() over(order by balance) as row_number1 from account_channel;
+-----------+---------+---------+-------------+
| name | channel | balance | row_number1 |
+-----------+---------+---------+-------------+
| zhuge | wx | 100 | 1 |
| lilei | alipay | 100 | 2 |
| zhuge | alipay | 200 | 3 |
| lilei | wx | 200 | 4 |
| zhuge | yinhang | 300 | 5 |
| hanmeimei | wx | 500 | 6 |
+-----------+---------+---------+-------------+
6 rows in set (0.00 sec)# 按照balance字段排序,first_value()选出排第一的余额
mysql> select name,channel,balance,first_value(balance) over(order by balance) as first1 from account_channel;
+-----------+---------+---------+--------+
| name | channel | balance | first1 |
+-----------+---------+---------+--------+
| zhuge | wx | 100 | 100 |
| lilei | alipay | 100 | 100 |
| zhuge | alipay | 200 | 100 |
| lilei | wx | 200 | 100 |
| zhuge | yinhang | 300 | 100 |
| hanmeimei | wx | 500 | 100 |
+-----------+---------+---------+--------+
6 rows in set (0.01 sec)
12、默认字符集由latin1变为utf8mb4
在8.0版本之前,默认字符集是latin1,utf8指向的是utf8mb3,8.0版本默认字符集为utf8mb4,utf8默认指向的也变成了utf8mb4
13、原MyISAM系统表全部换成InnoDB表
将系统表(mysql)和数据字典表全部改为InnoDB存储引擎,默认的MySQL实例将不包含MyISAM表,除非手动创建MyISAM表
14、原数据存储变动
MySQL 8.0删除了之前版本的元数据文件,例如表结构.frm等文件,全部集中放入mysql.ibd文件里
15、自增变量持久化
在MySQL 8.0之前,自增主键在MySQL重启后,会自动重置为max(primary key) + 1,会导致某些情况下的主键冲突,比如主键本来有1,2,3,4,手动删除了3和4,重启MySQL服务(注意,这里中途重启貌似是必要的,我没有重启的话,没有发生这个现象,不过也可能是公司对mysql做了优化,后续再验证(测试版本:5.7.36-txsql-log)),AUTO_INCREMENT会自动重置为3,如果此时手动把id为1的行更新为id=5,那么后面自增主键增加到5的时候,就会报主键冲突的异常,MySQL 8.0持久化了这个自增变量,重启的时候,不会重新生成,也会在更新id字段的时候,更新成最新值
# ====MySQL 5.7演示====
mysql> create table t(id int auto_increment primary key,c1 varchar(20));
Query OK, 0 rows affected (0.03 sec)mysql> insert into t(c1) values('zhuge1'),('zhuge2'),('zhuge3');
Query OK, 3 rows affected (0.00 sec)
Records: 3 Duplicates: 0 Warnings: 0mysql> select * from t;
+----+--------+
| id | c1 |
+----+--------+
| 1 | zhuge1 |
| 2 | zhuge2 |
| 3 | zhuge3 |
+----+--------+
3 rows in set (0.00 sec)mysql> delete from t where id = 3;
Query OK, 1 row affected (0.01 sec)mysql> select * from t;
+----+--------+
| id | c1 |
+----+--------+
| 1 | zhuge1 |
| 2 | zhuge2 |
+----+--------+
2 rows in set (0.00 sec)mysql> exit;
Bye# 重启MySQL服务,并重新连接MySQL
mysql> insert into t(c1) values('zhuge4');
Query OK, 1 row affected (0.01 sec)mysql> select * from t;
+----+--------+
| id | c1 |
+----+--------+
| 1 | zhuge1 |
| 2 | zhuge2 |
| 3 | zhuge4 |
+----+--------+
3 rows in set (0.00 sec)mysql> update t set id = 5 where c1 = 'zhuge1';
Query OK, 1 row affected (0.01 sec)
Rows matched: 1 Changed: 1 Warnings: 0mysql> select * from t;
+----+--------+
| id | c1 |
+----+--------+
| 2 | zhuge2 |
| 3 | zhuge4 |
| 5 | zhuge1 |
+----+--------+
3 rows in set (0.00 sec)mysql> insert into t(c1) values('zhuge5');
Query OK, 1 row affected (0.01 sec)mysql> select * from t;
+----+--------+
| id | c1 |
+----+--------+
| 2 | zhuge2 |
| 3 | zhuge4 |
| 4 | zhuge5 |
| 5 | zhuge1 |
+----+--------+
4 rows in set (0.00 sec)mysql> insert into t(c1) values('zhuge6');
ERROR 1062 (23000): Duplicate entry '5' for key 'PRIMARY'# ====MySQL 8.0演示====
mysql> create table t(id int auto_increment primary key,c1 varchar(20));
Query OK, 0 rows affected (0.02 sec)mysql> insert into t(c1) values('zhuge1'),('zhuge2'),('zhuge3');
Query OK, 3 rows affected (0.00 sec)
Records: 3 Duplicates: 0 Warnings: 0mysql> select * from t;
+----+--------+
| id | c1 |
+----+--------+
| 1 | zhuge1 |
| 2 | zhuge2 |
| 3 | zhuge3 |
+----+--------+
3 rows in set (0.00 sec)mysql> delete from t where id = 3;
Query OK, 1 row affected (0.01 sec)mysql> select * from t;
+----+--------+
| id | c1 |
+----+--------+
| 1 | zhuge1 |
| 2 | zhuge2 |
+----+--------+
2 rows in set (0.00 sec)mysql> exit;
Bye
[root@localhost ~]# service mysqld restart
Shutting down MySQL.... SUCCESS!
Starting MySQL... SUCCESS! # 重新连接MySQL
mysql> insert into t(c1) values('zhuge4');
Query OK, 1 row affected (0.00 sec)mysql> select * from t; --生成的id为4,不是3
+----+--------+
| id | c1 |
+----+--------+
| 1 | zhuge1 |
| 2 | zhuge2 |
| 4 | zhuge4 |
+----+--------+
3 rows in set (0.00 sec)mysql> update t set id = 5 where c1 = 'zhuge1';
Query OK, 1 row affected (0.01 sec)
Rows matched: 1 Changed: 1 Warnings: 0mysql> select * from t;
+----+--------+
| id | c1 |
+----+--------+
| 2 | zhuge2 |
| 4 | zhuge4 |
| 5 | zhuge1 |
+----+--------+
3 rows in set (0.00 sec)mysql> insert into t(c1) values('zhuge5');
Query OK, 1 row affected (0.00 sec)mysql> select * from t;
+----+--------+
| id | c1 |
+----+--------+
| 2 | zhuge2 |
| 4 | zhuge4 |
| 5 | zhuge1 |
| 6 | zhuge5 |
+----+--------+
4 rows in set (0.00 sec)
16、DDL原子化
InnoDB表的DDL支持事务原子性,要么全成功,要么全失败
在MySQL 8.0之前,比如,drop tables t1,t2
,如果t1存在,t2不存在,会删除t1,之后报错t2不存在,但是在8.0版本,有了原子性,事务回滚,t1表依然存在,即同时成功,同时失败,这也归功于系统表使用了InnoDB存储引擎(之前的版本使用的是MyISAM引擎)
# MySQL 5.7
mysql> show tables;
+----------------+
| Tables_in_test |
+----------------+
| account |
| actor |
| employee |
| film |
| film_actor |
| leaf_id |
| t1 |
| test_innodb |
| test_myisam |
| test_order_id |
+----------------+
10 rows in set (0.01 sec)mysql> drop table t1,t2; //删除表报错不会回滚,t1表会被删除
ERROR 1051 (42S02): Unknown table 'test.t2'
mysql> show tables;
+----------------+
| Tables_in_test |
+----------------+
| account |
| actor |
| employee |
| film |
| film_actor |
| leaf_id |
| test_innodb |
| test_myisam |
| test_order_id |
+----------------+
9 rows in set (0.00 sec)# MySQL 8.0
mysql> show tables;
+----------------+
| Tables_in_test |
+----------------+
| account |
| actor |
| employee |
| film |
| film_actor |
| leaf_id |
| t1 |
| test_innodb |
| test_myisam |
| test_order_id |
+----------------+
10 rows in set (0.00 sec)mysql> drop table t1,t2; //删除表报错会回滚,t1表依然还在
ERROR 1051 (42S02): Unknown table 'test.t2'
mysql> show tables;
+----------------+
| Tables_in_test |
+----------------+
| account |
| actor |
| employee |
| film |
| film_actor |
| leaf_id |
| t1 |
| test_innodb |
| test_myisam |
| test_order_id |
+----------------+
10 rows in set (0.00 sec)
17、参数修改持久化
MySQL 8.0版本支持在线修改全局参数并持久化,通过加上PERSIST关键字,可以将修改的参数持久化到新的配置文件(mysqld-auto.cnf)中,重启MySQL时,可以从该配置文件获取到最新的配置参数
之前的set global设置的变量参数,虽然是全局的,但是在MySQL重启后会失效,而普通的set是session级别的,换个连接就失效了
mysql> set persist innodb_lock_wait_timeout=25;
系统会在数据目录下生成一个包含json格式的mysqld-auto.cnf 的文件,格式化后如下所示,当my.cnf 和mysqld-auto.cnf 同时存在时,后者具有更高优先级。
{"Version": 1,"mysql_server": {"innodb_lock_wait_timeout": {"Value": "25","Metadata": {"Timestamp": 1675290252103863,"User": "root","Host": "localhost"}}}
}
——MySQL相关至此结束,完结撒花🎉🎉🎉
——JVM相关
九、从JDK源码级别彻底剖析JVM类加载机制
类加载运行全过程
当我们用java命令运行某个类的main函数启动程序时,首先要通过类加载器把主类加载到JVM
package com.tuling.jvm;public class Math {public static final int initData = 666;public static User user = new User();public int compute() { //一个方法对应一块栈帧内存区域int a = 1;int b = 2;int c = (a + b) * 10;return c;}public static void main(String[] args) {Math math = new Math();math.compute();}}
通过Java命令执行代码的大体流程如下:
加载 >> 验证 >> 准备 >> 解析 >> 初始化 >> 使用 >> 卸载
加载:将硬盘上的字节码文件加载到内存,注意,只有在使用到类时才会真正加载,B b = null;
这样的语句不会加载B类
验证:校验字节码文件的正确性
准备:给类的静态变量分配内存,并赋予默认值(注意,是默认值,比如int默认值是0,boolean默认值是false,对象默认值是null,这里不是程序员在代码中赋的值)
解析:做静态链接,就是把符号引用替换成直接引用,注意针对的是静态方法,比如main()方法(符号引用)替换为指向数据所存内存的指针或句柄等(直接引用)
动态链接是在程序运行期间将符号引用替换为直接引用
初始化:将类的静态变量初始化为程序员指定的值,执行静态代码块
类被加载到方法区中后,主要包含运行时常量池、类型信息、字段信息、方法信息、类加载器的引用、对应class实例的引用等信息
**类加载器的引用:**这个类到类加载器实例的引用
**对应class实例的引用:**类加载器加载类信息,把类信息放到方法区中后,会创建一个对应的Class类型的对应实例放到堆(Heap)中,作为开发人员访问方法区中类定义的入口和切入点
注意,主类运行过程中如果使用到其它类,会逐步加载这些类。
jar包或war包里的类不是一次性全部加载的,是使用到时才加载。
public class TestDynamicLoad {static {System.out.println("*************load TestDynamicLoad************");}public static void main(String[] args) {new A();System.out.println("*************load test************");B b = null; //B不会加载,除非这里执行 new B()}
}class A {static {System.out.println("*************load A************");}public A() {System.out.println("*************initial A************");}
}class B {static {System.out.println("*************load B************");}public B() {System.out.println("*************initial B************");}
}运行结果:
*************load TestDynamicLoad************
*************load A************
*************initial A************
*************load test************
类加载器和双亲委派机制
上面的类加载过程主要是通过类加载器来实现的,Java里有如下几种类加载器
- 引导类加载器:负责加载支撑JVM运行的位于jre的lib目录下的核心类库,比如rt.jar,charsets.jar等
- 扩展类加载器:负责加载支撑JVM运行的位于jre的lib目录下的ext扩展目录下的jar包
- 应用程序类加载器:负责加载classpath路径下的类包,主要是加载你自己写的那些类
- 自定义类加载器:负责加载用户自定义路径下的类包
public class TestJDKClassLoader {public static void main(String[] args) {System.out.println(String.class.getClassLoader());System.out.println(com.sun.crypto.provider.DESKeyFactory.class.getClassLoader().getClass().getName());System.out.println(TestJDKClassLoader.class.getClassLoader().getClass().getName());System.out.println();ClassLoader appClassLoader = ClassLoader.getSystemClassLoader();ClassLoader extClassloader = appClassLoader.getParent();ClassLoader bootstrapLoader = extClassloader.getParent();System.out.println("the bootstrapLoader : " + bootstrapLoader);System.out.println("the extClassloader : " + extClassloader);System.out.println("the appClassLoader : " + appClassLoader);System.out.println();System.out.println("bootstrapLoader加载以下文件:");URL[] urls = Launcher.getBootstrapClassPath().getURLs();for (int i = 0; i < urls.length; i++) {System.out.println(urls[i]);}System.out.println();System.out.println("extClassloader加载以下文件:");System.out.println(System.getProperty("java.ext.dirs"));System.out.println();System.out.println("appClassLoader加载以下文件:");System.out.println(System.getProperty("java.class.path"));}
}运行结果:
null
sun.misc.Launcher$ExtClassLoader
sun.misc.Launcher$AppClassLoaderthe bootstrapLoader : null
the extClassloader : sun.misc.Launcher$ExtClassLoader@3764951d
the appClassLoader : sun.misc.Launcher$AppClassLoader@14dad5dcbootstrapLoader加载以下文件:
file:/D:/dev/Java/jdk1.8.0_45/jre/lib/resources.jar
file:/D:/dev/Java/jdk1.8.0_45/jre/lib/rt.jar
file:/D:/dev/Java/jdk1.8.0_45/jre/lib/sunrsasign.jar
file:/D:/dev/Java/jdk1.8.0_45/jre/lib/jsse.jar
file:/D:/dev/Java/jdk1.8.0_45/jre/lib/jce.jar
file:/D:/dev/Java/jdk1.8.0_45/jre/lib/charsets.jar
file:/D:/dev/Java/jdk1.8.0_45/jre/lib/jfr.jar
file:/D:/dev/Java/jdk1.8.0_45/jre/classesextClassloader加载以下文件:
D:\dev\Java\jdk1.8.0_45\jre\lib\ext;C:\Windows\Sun\Java\lib\extappClassLoader加载以下文件:
D:\dev\Java\jdk1.8.0_45\jre\lib\charsets.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\deploy.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\access-bridge-64.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\cldrdata.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\dnsns.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\jaccess.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\jfxrt.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\localedata.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\nashorn.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\sunec.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\sunjce_provider.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\sunmscapi.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\sunpkcs11.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\zipfs.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\javaws.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\jce.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\jfr.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\jfxswt.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\jsse.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\management-agent.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\plugin.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\resources.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\rt.jar;D:\ideaProjects\project-all\target\classes;C:\Users\zhuge\.m2\repository\org\apache\zookeeper\zookeeper\3.4.12\zookeeper-3.4.12.jar;C:\Users\zhuge\.m2\repository\org\slf4j\slf4j-api\1.7.25\slf4j-api-1.7.25.jar;C:\Users\zhuge\.m2\repository\org\slf4j\slf4j-log4j12\1.7.25\slf4j-log4j12-1.7.25.jar;C:\Users\zhuge\.m2\repository\log4j\log4j\1.2.17\log4j-1.2.17.jar;C:\Users\zhuge\.m2\repository\jline\jline\0.9.94\jline-0.9.94.jar;C:\Users\zhuge\.m2\repository\org\apache\yetus\audience-annotations\0.5.0\audience-annotations-0.5.0.jar;C:\Users\zhuge\.m2\repository\io\netty\netty\3.10.6.Final\netty-3.10.6.Final.jar;C:\Users\zhuge\.m2\repository\com\google\guava\guava\22.0\guava-22.0.jar;C:\Users\zhuge\.m2\repository\com\google\code\findbugs\jsr305\1.3.9\jsr305-1.3.9.jar;C:\Users\zhuge\.m2\repository\com\google\errorprone\error_prone_annotations\2.0.18\error_prone_annotations-2.0.18.jar;C:\Users\zhuge\.m2\repository\com\google\j2objc\j2objc-annotations\1.1\j2objc-annotations-1.1.jar;C:\Users\zhuge\.m2\repository\org\codehaus\mojo\animal-sniffer-annotations\1.14\animal-sniffer-annotations-1.14.jar;D:\dev\IntelliJ IDEA 2018.3.2\lib\idea_rt.jar
为什么appClassLoader加载的不只是我们自己写的类,还有jre下的lib核心类库,还包括了lib下的ext下的类库呢?
因为双亲委派机制(后面细讲),默认都是先由appClassLoader进行加载,调用ClassLoader的loadClass方法,然后方法会去判断,如果appClassLoader没有加载过该类(类似缓存机制),则先交由父加载器去加载,也就是extClassLoader,也会调到ClassLoader的loadClass方法,也会判断是否有加载过该类,如果没有,因为他的父加载器是引导类加载器,代码里也就是null,会走到else里的逻辑,调用findBootstrapClassOrNull方法,由系统类加载器去加载,找不到的话,extClassLoader会再调用findClass方法去找,再找不到,则返回,由下级appClassLoader调用findClass方法再去找,找到,会缓存起来,下次再进来就可以直接拿到加载好的类了。
appClassLoader和extClassLoader的父类,URLClassLoader,调用findClass方法的时候就是调用URLClassLoader的findClass方法,内部其实就是拼接字节码文件的路径,然后进行加载
类加载器初始化全过程
参见类加载运行全过程图可知其中会创建JVM启动器实例sun.misc.Launcher
在Launcher构造方法内部,其创建了两个类加载器,分别是sun.misc.Launcher.ExtClassLoader(扩展类加载器)和sun.misc.Launcher.AppClassLoader(应用类加载器)
JVM默认使用Launcher的getClassLoader()方法返回的类加载器AppClassLoader的实例加载我们的应用程序
//Launcher的构造方法
public Launcher() {Launcher.ExtClassLoader var1;try {//构造扩展类加载器,在构造的过程中将其父加载器设置为nullvar1 = Launcher.ExtClassLoader.getExtClassLoader();} catch (IOException var10) {throw new InternalError("Could not create extension class loader", var10);}try {//构造应用类加载器,在构造的过程中将其父加载器设置为ExtClassLoader,//Launcher的loader属性值是AppClassLoader,我们一般都是用这个类加载器来加载我们自己写的应用程序this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);} catch (IOException var9) {throw new InternalError("Could not create application class loader", var9);}Thread.currentThread().setContextClassLoader(this.loader);String var2 = System.getProperty("java.security.manager");。。。 。。。 //省略一些不需关注代码}
双亲委派机制
JVM类加载器是有亲子层级结构的,如下图
这里类加载其实就有一个双亲委派机制,加载某个类时会先委托父加载器寻找目标类,找不到在委托上层父加载器加载,如果所有父加载器在自己的加载类路径下都找不到目标类,则在自己的类加载路径中查找并载入目标类。
比如我们的Math类,最先会找到应用程序类加载器加载,应用程序类加载器会先委托扩展类加载器加载,扩展类加载器再委托引导类加载器加载,顶层引导类加载器在自己的类加载路径里找了半天没找到Math类,则向下退回加载Math类的请求,扩展类加载器收到回复就自己加载,在自己的类加载器路径里找了半天也没找到Math类,又向下退回Math类的加载请求给应用程序类加载器,应用程序类加载器于是在自己的类加载路径里找到Math类,结果找到了就自己加载了
双亲委派机制说简单点就是,先找父亲加载,不行再由儿子自己加载
我们看下应用程序类加载器AppClassLoader加载了类的双亲委派机制源码,AppClassLoader的loadClass方法最终会调用其父类ClassLoader的loadClass方法,该方法的大体逻辑如下:
- 首先检查一下指定名称的类是否已经加载过,如果加载过了,就不需要再加载,直接返回
- 如果此类没有加载过,则在判断下是否有父加载器,如果有,则由父加载器加载(即调用parent.loadClass(name, false);)或者是调用bootstrap类加载器来加载
- 如果父加载器即bootstrap类加载器都没有找到指定的类,那么调用当前类加载器的findClass方法来完成类加载
//ClassLoader的loadClass方法,里面实现了双亲委派机制
protected Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException
{synchronized (getClassLoadingLock(name)) {// 检查当前类加载器是否已经加载了该类Class<?> c = findLoadedClass(name);if (c == null) {long t0 = System.nanoTime();try {if (parent != null) { //如果当前加载器父加载器不为空则委托父加载器加载该类c = parent.loadClass(name, false);} else { //如果当前加载器父加载器为空则委托引导类加载器加载该类c = findBootstrapClassOrNull(name);}} catch (ClassNotFoundException e) {// ClassNotFoundException thrown if class not found// from the non-null parent class loader}if (c == null) {// If still not found, then invoke findClass in order// to find the class.long t1 = System.nanoTime();//都会调用URLClassLoader的findClass方法在加载器的类路径里查找并加载该类c = findClass(name);// this is the defining class loader; record the statssun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);sun.misc.PerfCounter.getFindClasses().increment();}}if (resolve) { //不会执行resolveClass(c);}return c;}
}
为什么要设计双亲委派机制?
沙箱安全机制:自己写的java.lang.String类不会被加载,即使自己写了,也会因为双亲委派,最后交由引导类加载器加载,这样便可以防止核心API库被随意篡改
避免类的重复架子啊:当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次,保证被加载类的唯一性
GPT回答:
如果没有双亲委派机制,即每个类加载器都可以自行加载类而不经过父类加载器的委托,可能会导致类加载的混乱和安全漏洞。
举一个不安全的例子:
假设有两个自定义类加载器 A 和 B,它们都可以加载同一个类com.example.MyClass
。如果没有双亲委派机制,当应用程序中同时使用两个类加载器 A 和 B 加载com.example.MyClass
时,会导致同一个类在内存中存在两个不同的实例,这会破坏类加载的唯一性。这样的话,可能会导致应用程序中出现不可预测的行为、内存泄漏或者安全漏洞。另外,如果没有双亲委派机制,恶意代码可以利用这一点来绕过类加载器的隔离性,直接加载系统类库中的类或者其他应用程序的类,从而访问和操作不应该被访问的资源,造成安全漏洞和系统不稳定。
恶意代码可能会尝试通过自定义类加载器去绕过类加载器的隔离性,直接加载系统类库中的类或者其他应用程序的类,从而访问和操作不应该被访问的资源。
具体来说,恶意代码可能会使用以下方法来绕过类加载器的隔离性:
- 自定义类加载器:恶意代码可以创建自定义的类加载器,该类加载器不受双亲委派机制的限制,可以直接加载系统类库中的类或其他应用程序的类。
- 修改类加载路径:恶意代码可能会尝试通过修改类加载路径,使得其他类加载器可以访问到系统类库中的类或其他应用程序的类,从而绕过了原本的限制。
通过上述方式,恶意代码可以绕过类加载器的隔禽性,直接加载系统类库中的类或其他应用程序的类,从而进行非法的访问和操作。
这种情况下,系统的安全性将受到威胁,因此双亲委派模型的存在是非常重要的,它保证了类加载的有序性和隔离性,防止了恶意代码对系统资源的非法访问和操作。
因此,双亲委派机制的存在确保了类加载的顺序和唯一性,避免了类加载的混乱和安全漏洞。这种层级结构的类加载机制有助于确保 Java 程序的安全性和稳定性。
简而言之就是:1. 使用两个不同的类加载器去加载同一个类,可能会产生两个相同的类实例,破坏了类加载的唯一性;2. 可能被恶意代码直接加载系统类库中的类或其他应用程序的类,不安全,没有了隔离性
看一个类加载实例:
package java.lang;public class String {public static void main(String[] args) {System.out.println("**************My String Class**************");}
}运行结果:
错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为:public static void main(String[] args)
否则 JavaFX 应用程序类必须扩展javafx.application.Application
全盘负责委托机制
指当一个ClassLoader装载一个类时,除非显式的使用另外一个ClassLoader,该类所依赖及引用的类也由这个ClassLoader载入
自定义类加载器示例
自定义类加载器只需要集成java.lang.ClassLoader类,该类有两个核心方法,一个是loadClass(String, boolean),实现了双亲委派机制,还有一个是findClass,默认实现是空方法,我们自定义类加载器主要就是重写findClass方法
public class MyClassLoaderTest {static class MyClassLoader extends ClassLoader {private String classPath;public MyClassLoader(String classPath) {this.classPath = classPath;}private byte[] loadByte(String name) throws Exception {name = name.replaceAll("\\.", "/");FileInputStream fis = new FileInputStream(classPath + "/" + name+ ".class");int len = fis.available();byte[] data = new byte[len];fis.read(data);fis.close();return data;}protected Class<?> findClass(String name) throws ClassNotFoundException {try {// 加载字节码文件成字节数组,用于传给defineClass方法byte[] data = loadByte(name);//defineClass将一个字节数组转为Class对象,这个字节数组是class文件读取后最终的字节数组。return defineClass(name, data, 0, data.length);} catch (Exception e) {e.printStackTrace();throw new ClassNotFoundException();}}}public static void main(String args[]) throws Exception {//初始化自定义类加载器,会先初始化父类ClassLoader,其中会把自定义类加载器的父加载器设置为应用程序类加载器AppClassLoaderMyClassLoader classLoader = new MyClassLoader("D:/test");//D盘创建 test/com/tuling/jvm 几级目录,将User类的复制类User1.class丢入该目录Class clazz = classLoader.loadClass("com.tuling.jvm.User1");Object obj = clazz.newInstance();Method method = clazz.getDeclaredMethod("sout", null);method.invoke(obj, null);System.out.println(clazz.getClassLoader().getClass().getName());}
}运行结果:
=======自己的加载器加载类调用方法=======
com.tuling.jvm.MyClassLoaderTest$MyClassLoader
打破双亲委派机制
双亲委派的逻辑是在loadClass方法里面,所以打破双亲委派机制的方法也很简单,就是继承ClassLoader,重写loadClass方法,针对自己的类,使用自己的类加载器进行加载
再来一个沙箱安全机制示例,尝试打破双亲委派机制,用自定义类加载器加载我们自己实现的 java.lang.String.class(有安全校验,这种java.lang软件包的JVM禁止自己加载)
public class MyClassLoaderTest {static class MyClassLoader extends ClassLoader {private String classPath;public MyClassLoader(String classPath) {this.classPath = classPath;}private byte[] loadByte(String name) throws Exception {name = name.replaceAll("\\.", "/");FileInputStream fis = new FileInputStream(classPath + "/" + name+ ".class");int len = fis.available();byte[] data = new byte[len];fis.read(data);fis.close();return data;}protected Class<?> findClass(String name) throws ClassNotFoundException {try {byte[] data = loadByte(name);return defineClass(name, data, 0, data.length);} catch (Exception e) {e.printStackTrace();throw new ClassNotFoundException();}}/*** 重写类加载方法,实现自己的加载逻辑,不委派给双亲加载* @param name* @param resolve* @return* @throws ClassNotFoundException*/protected Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException {synchronized (getClassLoadingLock(name)) {// First, check if the class has already been loadedClass<?> c = findLoadedClass(name);if (c == null) {// If still not found, then invoke findClass in order// to find the class.long t1 = System.nanoTime();c = findClass(name);// this is the defining class loader; record the statssun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);sun.misc.PerfCounter.getFindClasses().increment();}if (resolve) {resolveClass(c);}return c;}}}public static void main(String args[]) throws Exception {MyClassLoader classLoader = new MyClassLoader("D:/test");//尝试用自己改写类加载机制去加载自己写的java.lang.String.classClass clazz = classLoader.loadClass("java.lang.String");Object obj = clazz.newInstance();Method method= clazz.getDeclaredMethod("sout", null);method.invoke(obj, null);System.out.println(clazz.getClassLoader().getClass().getName());}
}运行结果:
java.lang.SecurityException: Prohibited package name: java.langat java.lang.ClassLoader.preDefineClass(ClassLoader.java:659)at java.lang.ClassLoader.defineClass(ClassLoader.java:758)
Tomcat打破双亲委派机制
我们思考一下,Tomcat是个web容器,他需要解决什么问题?
- 一个web容器可能要部署两个应用程序,不同应用程序可能会依赖同一个第三方类库的不同版本,这里要保证不同应用程序可以引用到相同类库的不同版本,做到隔离性,而双亲委派机制是无法做到加载同一类库的不同版本的,所以必须打破双亲委派机制
- web容器中相同类库的相同版本可以共享,这个双亲委派可以做到,不会重复加载
- web容器也有自己依赖的类库,不能与应用程序的混淆,基于安全考虑,应该让这个容器的类库和程序的类库隔离开,也是双亲委派机制可以做到,让上层去加载容器依赖的类库
- jsp文件修改后,不需要重启容器也能重新加载,这个就需要针对jsp文件做一个文件监听,一旦有变动,就新生成一个类加载器,重新加载jsp文件,并卸载原来的jsp文件对应的类加载器(一个jsp文件对应一个类加载器)
Tomcat自定义类加载器
tomcat的几个主要类加载器:
- commonLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp访问
- catalinaLoader:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可见
- sharedLoader:各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见
- WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见,比如加载war包里相关的类,每个war包应用都有自己的WebappClassLoader,实现相互隔离,比如不同war包应用引入了不同版本的spring,这样实现就能加载各自的spring版本
从图中的委派关系中可以看出:
CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader使用,从而实现了公有类库的共用,而CatalinaClassLoader和SharedClassLoader自己能加载的类则与对方相互隔离。
WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader实例之间相互隔离。
而JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个.Class文件,它出现的目的就是为了被丢弃:当Web容器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的热加载功能。
tomcat 这种类加载机制违背了java 推荐的双亲委派模型了吗?答案是:违背了。
很显然,tomcat 不是这样实现,tomcat 为了实现隔离性,没有遵守这个约定,每个webappClassLoader加载自己的目录下的class文件,不会传递给父类加载器,打破了双亲委派机制。
模拟实现Tomcat的webappClassLoader加载自己war包应用内不同版本类实现相互共存与隔离
其实就是加了个目录识别,识别到是要加载特定目录下的类,就直接用自定义类加载器加载,不委派给父加载器
public class MyClassLoaderTest {static class MyClassLoader extends ClassLoader {private String classPath;public MyClassLoader(String classPath) {this.classPath = classPath;}private byte[] loadByte(String name) throws Exception {name = name.replaceAll("\\.", "/");FileInputStream fis = new FileInputStream(classPath + "/" + name+ ".class");int len = fis.available();byte[] data = new byte[len];fis.read(data);fis.close();return data;}protected Class<?> findClass(String name) throws ClassNotFoundException {try {byte[] data = loadByte(name);return defineClass(name, data, 0, data.length);} catch (Exception e) {e.printStackTrace();throw new ClassNotFoundException();}}/*** 重写类加载方法,实现自己的加载逻辑,不委派给双亲加载* @param name* @param resolve* @return* @throws ClassNotFoundException*/protected Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException {synchronized (getClassLoadingLock(name)) {// First, check if the class has already been loadedClass<?> c = findLoadedClass(name);if (c == null) {// If still not found, then invoke findClass in order// to find the class.long t1 = System.nanoTime();//非自定义的类还是走双亲委派加载if (!name.startsWith("com.tuling.jvm")){c = this.getParent().loadClass(name);}else{c = findClass(name);}// this is the defining class loader; record the statssun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);sun.misc.PerfCounter.getFindClasses().increment();}if (resolve) {resolveClass(c);}return c;}}}public static void main(String args[]) throws Exception {MyClassLoader classLoader = new MyClassLoader("D:/test");Class clazz = classLoader.loadClass("com.tuling.jvm.User1");Object obj = clazz.newInstance();Method method= clazz.getDeclaredMethod("sout", null);method.invoke(obj, null);System.out.println(clazz.getClassLoader());System.out.println();MyClassLoader classLoader1 = new MyClassLoader("D:/test1");Class clazz1 = classLoader1.loadClass("com.tuling.jvm.User1");Object obj1 = clazz1.newInstance();Method method1= clazz1.getDeclaredMethod("sout", null);method1.invoke(obj1, null);System.out.println(clazz1.getClassLoader());}
}运行结果:
=======自己的加载器加载类调用方法=======
com.tuling.jvm.MyClassLoaderTest$MyClassLoader@266474c2=======另外一个User1版本:自己的加载器加载类调用方法=======
com.tuling.jvm.MyClassLoaderTest$MyClassLoader@66d3c617
十、JVM内存模型深度剖析与优化
JDK体系结构
Java语言的跨平台特性
使用针对不同系统打造的不同的JVM版本,将相同的字节码文件解析编译成不同的可执行命令,从而实现一份代码,到处运行的效果
JVM整体结构及内存模型
JVM虚拟机包括:类装载子系统、运行时数据区(内存模型)、字节码执行引擎
其中内存模型,包括,堆,栈,方法区(元空间),本地方法栈,程序计数器
- 堆:年轻代(默认1/3)Eden(8),s0(1),s2(1);老年代(默认2/3)
- 栈:每个线程独有的
- 方法区:常量,静态变量,类元信息
- 本地方法栈:本地方法底层是C语言实现的,本地方法栈执行时使用的栈空间
- 程序计数器:也是每个线程独有的,记录当前执行到哪里了,当CPU被其他线程抢占,再次拿到CPU时,可以用来恢复上下文,直接执行上次执行到的地方
一个方法对应一块栈帧内存区域
栈帧:
局部变量表:放方法内部的局部变量(其实放的是内存地址,指向堆中对象分配的空间)
操作数栈:在程序执行过程中,一块儿用于中转存放的内存空间
动态链接:符号引用转成直接引用,符号转成内存地址
方法出口:调用方法的时候,就记录上了,记录调用方法结束之后,出来的时候,应该到哪里去执行
JVM内存参数设置
SpringBoot程序的JVM参数设置格式(Tomcat启动直接加载bin目录下的catalina.sh文件里):
java -Xms2048M -Xmx2048M -Xmn1024M -Xss512K -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -jar test.jar
-Xss:每个线程的栈大小
-Xms:设置堆的可用大小,默认物理内存的1/64
-Xmx:设置堆的最大可用大小,默认物理内存的1/4
-Xmn:新生代大小
-XX:NewRatio:默认2表示新生代占老年代的1/2,占整个堆内存的1/3
-XX:SurvivorRatio:默认8表示一个survivor区占用1/8的Eden内存,即1/10的新生代内存
关于元空间的JVM参数有两个:-XX:MetaspaceSize=N和-XX:MaxMetaspaceSize=N
-XX:MaxMetaspaceSize:设置元空间最大值,默认是-1,表示不限制,或者说只受限于本地内存大小,元空间使用的是直接内存(即物理内存,不是给JVM分配的内存)
-XX:MetaspaceSize:指定元空间触发FullGC的初始阈值(元空间无固定初始大小),以字节为单位,默认是21M左右,达到该值就会触发FullGC进行类型卸载,同时收集器会对改制进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不少过设置的最大值的情况下,适当提高该值。这个跟早期jdk版本的-XX:PermSize参数意思不一样,-XX:PermSize代表永久代的初始容量
由于调整元空间大小需要FullGC,这是非常昂贵的操作,如果应用在启动的时候发生了大量FullGC,通常都是由于永久代或元空间发生了大小调整,基于这种情况,一般建议在JVM参数中将MetaspaceSize和MaxMetaspaceSize设置成一样的值,并设置得比初始值要大,对于8G物理内存的机器来说,一般会将这两个值设置成256M,方法区一般建议一定要设置值
StackOverflowError示例:
// JVM设置 -Xss128k(默认1M)
public class StackOverflowTest {static int count = 0;static void redo() {count++;redo();}public static void main(String[] args) {try {redo();} catch (Throwable t) {t.printStackTrace();System.out.println(count);}}
}运行结果:
java.lang.StackOverflowErrorat com.tuling.jvm.StackOverflowTest.redo(StackOverflowTest.java:12)at com.tuling.jvm.StackOverflowTest.redo(StackOverflowTest.java:13)at com.tuling.jvm.StackOverflowTest.redo(StackOverflowTest.java:13)......
结论:
-Xss设置越大count值越小,说明一个线程栈里能分配的栈帧就越少,但是对JVM整体来说能开启的线程数会更多
十一、JVM对象创建与内存分配机制深度剖析
对象的创建
——对象创建的主要流程
首先进行类加载检查,如果没有加载过,就去加载,如果已经加载过了,就会去分配内存空间,然后初始化零值,之后设置对象头,最后执行<init>方法
1、 类加载检查
虚拟机遇到一条new指令时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那么必须要先执行相应的类加载过程
2、分配内存
对象所需的内存大小在类加载完后便可以完全确定,虚拟机为新生对象分配内存,即把一块确定大小的内存从java堆中划分出来
两个问题:
- 如何划分内存
- 在并发的情况下,可能出现正在给A对象分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况
划分内存的方法
-
指针碰撞(默认使用指针碰撞)
假设java堆中的内存是绝对规整的,所有用过的内存放一边,没用过的放另一边,中间就放着一个指针作为分界点的指示器,新对象分配内存就从这里开始,分配完之后,指针挪动和对象大小相同的距离即可
-
空闲列表
假设java对中的内存是不规整的,用过的内存和没用过的内存互相交错,虚拟机会维护一个列表,记录那些内存块是可用的,在分配内存的时候,从列表中找到一块儿足够大的空间分配给对象实例,并更新列表上的记录
解决并发问题的方法
-
CAS
虚拟机采用CAS配上失败重试的方式保证更新操作的原子性
-
本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)
把内存分配的动作,按照线程划分在不同的空间之中进行,即每个线程在java堆中预先分配一小块内存,这样就不存在共享访问的问题了。通过
-XX:+/-UseTLAB
参数来设定虚拟机是否使用TLAB,JVM默认会开启TLAB,-XX:TLABSize
指定TLAB大小
3、初始化零值(实例化)
内存分配完之后,虚拟机将分配到的空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在java代码中可以不赋初始值就直接使用。
4、设置对象头
初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希码,对象的GC分代年龄等,这些信息都存放在对象头中。
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头、实例数据和对齐填充。
对象头可以分为两个部分:第一部分是存储对象自身运行时的数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等;另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
5、执行<init>方法
即按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意和上面的赋零值不同,这里是程序员赋的值),和执行构造方法。
对象大小和压缩指针
对象大小
对象大小可以用jol-core包查看,引入依赖
<dependency><groupId>org.openjdk.jol</groupId><artifactId>jol-core</artifactId><version>0.9</version>
</dependency>
import org.openjdk.jol.info.ClassLayout;/*** 计算对象大小*/
public class JOLSample {public static void main(String[] args) {ClassLayout layout = ClassLayout.parseInstance(new Object());System.out.println(layout.toPrintable());System.out.println();ClassLayout layout1 = ClassLayout.parseInstance(new int[]{});System.out.println(layout1.toPrintable());System.out.println();ClassLayout layout2 = ClassLayout.parseInstance(new A());System.out.println(layout2.toPrintable());}// -XX:+UseCompressedOops 默认开启的压缩所有指针// -XX:+UseCompressedClassPointers 默认开启的压缩对象头里的类型指针Klass Pointer// Oops : Ordinary Object Pointerspublic static class A {//8B mark word//4B Klass Pointer 如果关闭压缩-XX:-UseCompressedClassPointers或-XX:-UseCompressedOops,则占用8Bint id; //4BString name; //4B 如果关闭压缩-XX:-UseCompressedOops,则占用8Bbyte b; //1B Object o; //4B 如果关闭压缩-XX:-UseCompressedOops,则占用8B}
}运行结果:
java.lang.Object object internals:OFFSET SIZE TYPE DESCRIPTION VALUE0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) //mark word4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) //mark word,64位,前面有8个字节做mark word8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) //Klass Pointer,类型指针12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total[I object internals:OFFSET SIZE TYPE DESCRIPTION VALUE0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)//mark word4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)//mark word8 4 (object header) 6d 01 00 f8 (01101101 00000001 00000000 11111000) (-134217363)// 类型指针12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)// 数组长度,只有数组对象才有16 0 int [I.<elements> N/A// 实例数据,空数组,无数据
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes totalcom.tuling.jvm.JOLSample$A object internals:OFFSET SIZE TYPE DESCRIPTION VALUE0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)//mark word4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)//mark word8 4 (object header) 61 cc 00 f8 (01100001 11001100 00000000 11111000) (-134165407)//类型指针12 4 int A.id 0//int 4字节16 1 byte A.b 0//byte 1字节17 3 (alignment/padding gap) //对齐填充(填充为8的倍数)20 4 java.lang.String A.name null//String对象,开启了指针压缩,所以占4字节,不开8字节24 4 java.lang.Object A.o null//Object对象,开启了指针压缩,所以占4字节,不开8字节28 4 (loss due to the next object alignment)//对齐填充(填充为8的倍数)
Instance size: 32 bytes
Space losses: 3 bytes internal + 4 bytes external = 7 bytes total
什么是java对象的指针压缩?
- 从jdk1.6 update14开始,在64位操作系统中,JVM支持指针压缩
- jvm配置参数:
UseCompressedOops
,compressed——压缩,oop(ordinary object pointer)——对象指针 - 启用指针压缩:
-XX:+UseCompressedOops
(默认开启),禁止指针压缩:-XX:-UseCompressedOops
为什么要进行指针压缩?
- 为了减少64位平台下内存的消耗,在64位平台的HotSpot中使用32位的类型指针(实际存储用64位),内存使用会多出1.5倍左右,使用较大的指针在主内存和缓存之间移动数据,占用较大带宽,同时GC也会承受较大压力
- 在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针存入堆内存是压缩编码,取出到cpu寄存器后解码的方式进行优化(对象指针在对中是32位,在寄存器中是35位,2的35次方=32G),使得jvm只用32位地址就可以支持更大的内存配置(小于等于32G)
- 堆内存小于4G时,不需要启用指针压缩,因为根本用不到第33位,jvm会直接去除高32位地址,即使用低虚拟地址空间
- 堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节的指针)来对java对象寻址,这样就会出现1的问题,所以堆内存不要大于32G为好
【拓展】Q1:为什么不要大于32G,为什么会失效?
答:由于Java对象存在【对齐填充】,使得Java对象的大小,恒为8字节的整数倍数。这样有什么意义呢?8的倍数有哪些,8,16,24,32… 转换成2进制你会发现,他们的低3位恒等于【000】。既然低3位恒等于这个,那我32位里面真实有效的数字不就是29位了吗?但是这显然有点浪费,所以,我们可以通过【右移】3位获得原32位后的33、34、35补足32位(反正不用白不用),这就是【指针压缩】的奥妙。2的35次方是多少?正好是32G
关于对齐填充
对于大部分处理器,对象以8字节整数倍来对齐填充都是最高效的存取方式,注意,对齐填充并不是向上图那样分配在对象末尾的一整块区域,而是插在各个属性之间的
对象内存分配
对象内存分配流程图
对象栈上分配
JVM通过逃逸分析,确定该对象不会被外部访问,则可以将该对象在栈上分配内存,这样该对象所占用的内存空间就可以随着栈帧出栈而销毁,就减轻了垃圾回收的压力
public User test1() {User user = new User();user.setId(1);user.setName("zhuge");//TODO 保存到数据库return user;
}public void test2() {User user = new User();user.setId(1);user.setName("zhuge");//TODO 保存到数据库
}
test1无法确定外部是否使用User对象,test2可以认为方法结束后,User对象就可以销毁了
JVM对于这种情况可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis
)来优化对象内存分配位置,使其通过标量替换优先分配在栈上(栈上分配),JDK7之后默认开启逃逸分析,如果要关闭,使用参数-XX:-DoEscapeAnalysis
标量替换:通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量成分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。开启标量替换参数(-XX:+EliminateAllocations
),JDK7之后也是默认开启的
标量和聚合量:标量即不可被进一步分解的量,比如java的基本数据类型就是标量,聚合量就是可以被进一步分解的,比如java对象
栈上分配的情况感觉会比较少,new一个对象出来,很少有只在当前方法使用的,要么传递到下个方法,要么返回给上层方法
/*** 栈上分配,标量替换* 代码调用了1亿次alloc(),如果是分配到堆上,大概需要1GB以上堆空间,如果堆空间小于该值,必然会触发GC。* * 使用如下参数不会发生GC* -Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations* 使用如下参数都会发生大量GC* -Xmx15m -Xms15m -XX:-DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations* -Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations*/
public class AllotOnStack {public static void main(String[] args) {long start = System.currentTimeMillis();for (int i = 0; i < 100000000; i++) {alloc();}long end = System.currentTimeMillis();System.out.println(end - start);}private static void alloc() {User user = new User();user.setId(1);user.setName("zhuge");}
}
结论:栈上分配依赖于逃逸分析和标量替换,两者缺一不可
对象在Eden区分配
大多数情况下,对象在新生代的Eden区分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC
- Minor GC/Young GC:指新生代的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快
- Major GC/Full GC:一般会回收老年代,年轻代,方法区的垃圾,Major GC的速度一般会比Minor GC慢10倍以上
Eden与Survivor区默认8:1:1
大量的对象先被分配到Eden区,等Eden区满了之后,就会触发Minor GC,此时99%的对象都会成为垃圾回收掉,剩余存活的对象会被挪到为空的survivor区,下一次eden区又满了之后,再次出发Minor GC,会把Eden区和survivor区的垃圾对象回收,并把存活的对象放到空的survivor区,因为新生代的对象都是朝生夕死的,存活时间很短,所以JVM默认的8:1:1的比例是很合适的,让eden区尽量的大,survivor区够用即可
JVM默认有这个参数-XX+UseAdaptiveSizePolicy
(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变化可以设置参数-XX:-UseAdaptiveSizePolicy
//添加运行JVM参数: -XX:+PrintGCDetails
public class GCTest {public static void main(String[] args) throws InterruptedException {byte[] allocation1, allocation2/*, allocation3, allocation4, allocation5, allocation6*/;allocation1 = new byte[60000*1024];//allocation2 = new byte[8000*1024];/*allocation3 = new byte[1000*1024];allocation4 = new byte[1000*1024];allocation5 = new byte[1000*1024];allocation6 = new byte[1000*1024];*/}
}运行结果:
HeapPSYoungGen total 76288K, used 65536K [0x000000076b400000, 0x0000000770900000, 0x00000007c0000000)eden space 65536K, 100% used [0x000000076b400000,0x000000076f400000,0x000000076f400000)from space 10752K, 0% used [0x000000076fe80000,0x000000076fe80000,0x0000000770900000)to space 10752K, 0% used [0x000000076f400000,0x000000076f400000,0x000000076fe80000)ParOldGen total 175104K, used 0K [0x00000006c1c00000, 0x00000006cc700000, 0x000000076b400000)object space 175104K, 0% used [0x00000006c1c00000,0x00000006c1c00000,0x00000006cc700000)Metaspace used 3342K, capacity 4496K, committed 4864K, reserved 1056768Kclass space used 361K, capacity 388K, committed 512K, reserved 1048576K
此时eden区几乎被完全分配(60M),放开allocation2,会出现什么情况呢?
//添加运行JVM参数: -XX:+PrintGCDetails
public class GCTest {public static void main(String[] args) throws InterruptedException {byte[] allocation1, allocation2/*, allocation3, allocation4, allocation5, allocation6*/;allocation1 = new byte[60000*1024];allocation2 = new byte[8000*1024];/*allocation3 = new byte[1000*1024];allocation4 = new byte[1000*1024];allocation5 = new byte[1000*1024];allocation6 = new byte[1000*1024];*/}
}运行结果:
[GC (Allocation Failure) [PSYoungGen: 65253K->936K(76288K)] 65253K->60944K(251392K), 0.0279083 secs] [Times: user=0.13 sys=0.02, real=0.03 secs]
HeapPSYoungGen total 76288K, used 9591K [0x000000076b400000, 0x0000000774900000, 0x00000007c0000000)eden space 65536K, 13% used [0x000000076b400000,0x000000076bc73ef8,0x000000076f400000)from space 10752K, 8% used [0x000000076f400000,0x000000076f4ea020,0x000000076fe80000)to space 10752K, 0% used [0x0000000773e80000,0x0000000773e80000,0x0000000774900000)ParOldGen total 175104K, used 60008K [0x00000006c1c00000, 0x00000006cc700000, 0x000000076b400000)object space 175104K, 34% used [0x00000006c1c00000,0x00000006c569a010,0x00000006cc700000)Metaspace used 3342K, capacity 4496K, committed 4864K, reserved 1056768Kclass space used 361K, capacity 388K, committed 512K, reserved 1048576K
因为allocation2(8M)分配内存的时候,Eden区已经满了,所以就会触发minor GC,但是发现60M太大,无法放入survivor区,所以直接放到老年代去,老年代足以放下60M,所以也不会触发full GC,后面创建的对象,还是会在eden区分配内存
public class GCTest {public static void main(String[] args) throws InterruptedException {byte[] allocation1, allocation2, allocation3, allocation4, allocation5, allocation6;allocation1 = new byte[60000*1024];allocation2 = new byte[8000*1024];allocation3 = new byte[1000*1024];allocation4 = new byte[1000*1024];allocation5 = new byte[1000*1024];allocation6 = new byte[1000*1024];}
}运行结果:
[GC (Allocation Failure) [PSYoungGen: 65253K->952K(76288K)] 65253K->60960K(251392K), 0.0311467 secs] [Times: user=0.08 sys=0.02, real=0.03 secs]
HeapPSYoungGen total 76288K, used 13878K [0x000000076b400000, 0x0000000774900000, 0x00000007c0000000)eden space 65536K, 19% used [0x000000076b400000,0x000000076c09fb68,0x000000076f400000)from space 10752K, 8% used [0x000000076f400000,0x000000076f4ee030,0x000000076fe80000)to space 10752K, 0% used [0x0000000773e80000,0x0000000773e80000,0x0000000774900000)ParOldGen total 175104K, used 60008K [0x00000006c1c00000, 0x00000006cc700000, 0x000000076b400000)object space 175104K, 34% used [0x00000006c1c00000,0x00000006c569a010,0x00000006cc700000)Metaspace used 3343K, capacity 4496K, committed 4864K, reserved 1056768Kclass space used 361K, capacity 388K, committed 512K, reserved 1048576K
大对象直接进入老年代
大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数-XX:PretenureSizeThreshold
可以设置大对象的阈值,如果对象超过设置大小会直接进入老年代,不会进入年轻代,注意:这个参数只有在Serial和ParNew两个收集器下有效
为什么要这样呢?
避免大对象分配内存时的复制操作降低效率
长期存活的对象将进入老年代
每个对象在对象头中都有个分代年龄,每经历一次minor GC,分代年龄就+1,当他的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同垃圾收集器略有不同),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold
来设置
对象动态年龄判断
当前放对象的那个survivor区,如果一批对象的总大小,大于这块儿Survivor区域内存大小的50%(-XX:TargetSurvivorRatio
可以指定),那么大于等于这批对象年龄最大值的对象,就可以直接进入老年代了,例如Survivor区现在有一批对象,年龄1+年龄2+…+年龄n的对象大小超过了Survivor区域的50%,那么年龄大于n的对象就会把年龄大于n的对象放入老年代。这个规划是为了让那些可能是长期存活的对象,尽早的进入老年代。对象动态年龄判断机制一般是在minor gc之后触发的
老年代空间分配担保机制
年轻代每次minor gc之前,都会计算下老年代剩余可用空间
如果老年代剩余可用空间小于当前年轻代中的所有对象(包括垃圾对象),那么就会看是否设置了参数-XX:-HandlePromotionFailure
(jdk1.8默认就设置了),如果没有这个参数,就直接full gc,如果有这个参数,就会看老年代剩余可用空间,是否大于之前每次minor gc后进入老年代的对象的平均大小,如果小于,就认为minor gc后可以老年代可以放下,则触发一次minor gc,尝试将需要放入老年代的对象放入老年代,如果放不下,还是会触发full gc,如果full gc之后还放不下,就oom了(可以参考上面的对象内存分配流程图
)
对象内存回收
堆中几乎放着所有对象的实例,对堆垃圾回收之前,第一步就是要判断哪些对象已经死亡,怎么判断呢?有两种方法
引用计数法
给对象添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使用的。
这个方法实现简单,效率高,但是有问题,比如,objA和objB互相引用着对方,但把他们都置空,他们的引用计数器都不为0,于是引用计数算法无法通知GC回收器回收它们
public class ReferenceCountingGc {Object instance = null;public static void main(String[] args) {ReferenceCountingGc objA = new ReferenceCountingGc(); // 1ReferenceCountingGc objB = new ReferenceCountingGc(); // 1objA.instance = objB; // 2objB.instance = objA; // 2objA = null; // 1objB = null; // 1}
}
可达性分析算法
将“GC Roots“对象作为起点,从这些起点开始向下搜索引用的对象,找到的都标记为非垃圾对象,其余未标记的对象都是垃圾对象
GC Roots根节点:线程栈的本地变量、静态变量、本地方法栈的变量等等
常见引用类型
java的引用类型一般分为四种:强引用、软引用、弱引用、虚引用
强引用:普通的变量引用
public static User user = new User();
软引用:将对象用SoftReference软引用类型的对象包裹,正常情况下不会被回收,但是GC做完后发现释放不出空间存新的对象,就会把这些软引用回收掉,软引用可以用来实现内存敏感的高速缓存,比如浏览器后退按钮的缓存
public static SoftReference<User> user = new SoftReference<User>(new User());
弱引用:将对象用WeakReference弱引用类型的对象包括,弱引用跟没引用差不多,GC会直接回收掉,很少用
public static WeakReference<User> user = new WeakReference<User>(new User());
虚引用:也被成为幻影引用或幽灵引用,几乎不用
finalize()方法最终判定对象是否存活
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。
标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。
1. 第一次标记并进行一次筛选。
筛选的条件是此对象是否有必要执行finalize()方法。
当对象没有覆盖finalize方法,对象将直接被回收。
2. 第二次标记
如果这个对象覆盖了finalize方法,finalize方法是对象脱逃死亡命运的最后一次机会,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了。
注意:一个对象的finalize()方法只会被执行一次,也就是说通过调用finalize方法自我救命的机会就一次。
示例代码:
public class OOMTest {public static void main(String[] args) {List<Object> list = new ArrayList<>();int i = 0;int j = 0;while (true) {list.add(new User(i++, UUID.randomUUID().toString()));new User(j--, UUID.randomUUID().toString());}}
}//User类需要重写finalize方法
@Override
protected void finalize() throws Throwable {OOMTest.list.add(this);System.out.println("关闭资源,userid=" + id + "即将被回收");
}
finalize()方法的运行代价高昂, 不确定性大, 无法保证各个对象的调用顺序, 如今已被官方明确声明为不推荐使用的语法。 有些资料描述它适合做“关闭外部资源”之类的清理性工作, 这完全是对finalize()方法用途的一种自我安慰。 finalize()能做的所有工作, 使用try-finally或者其他方式都可以做得更好、更及时, 所以建议大家完全可以忘掉Java语言里面的这个方法。
如何判断一个类是无用的类
注意这里说的是类,判断类是无用的类,以回收方法区中的空间,但是无用的类条件挺“苛刻”的,要同时满足以下三点:
- 该类所有对象的实例均已被回收
- 该类对应的类加载器已经被回收(很难满足)
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
日均百万级订单交易系统如何设置JVM参数
将-Xmn调整成了2048M,s0和s1对应的大小也变成了200M左右,这样足以应对每秒产生的60M垃圾对象,不会直接放到老年代,也就不会频繁的fullGC了
结论:尽可能的让对象都在年轻代里分配和回收,尽量别让太多对象频繁进入老年代,避免频繁对老年代进行垃圾回收,同时给系统充足的内存大小,避免新生代频繁的进行垃圾回收
十二、垃圾收集器ParNew&CMS与底层三色标记算法详解
垃圾收集算法
分代收集理论
就是根据对象的生命周期的不同,将内存分为几块。一般将java堆分为新生代和老年代,根据各年代的特点,选择合适的垃圾收集算法
比如新生代中,每次收集都会有大量的对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年的的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。注意,“标记-清除”或“标记-整理”算法会比复制算法慢10倍以上
标记-复制算法
将内存分为相等的两块,每次给对象分配内存只使用其中的一块,清理的时候,标记存活的对象,将存活的对象复制到另一块,之后直接清空当前这一块,下次分配使用另一块内存进行分配,这样就是每次的内存回收都是对内存区间的一半进行回收。
缺点也很明显:只能用一半的空间,有些浪费
标记-清除算法
标记存活对象,然后清除不存活的对象,不会像“标记-复制那样”只使用一半内存
缺点就是效率不高,且有大量不连续的空间碎片
标记-整理算法
根据老年代的特点推出的一种标记算法,标记存活的对象,但不是直接清除,而是将所有存活的对象向一端移动,然后直接清掉边界以外的内存
垃圾收集器
注意:没有完美的垃圾收集器,我们能做的就是根据具体的业务场景选择适合自己的垃圾收集器
Serial收集器
-XX:+UseSerialGc(年轻代)
-XX:+UseSerialOldGc(老年代)
Serial(串行)收集器是最基本最悠久的垃圾收集器了,在垃圾收集的时候,使用的是单线程收集,并且会Stop the world(STW),直到它收集结束。
新生代标记-复制,老年代标记-整理
优点:简单,高效
缺点:STW相对较长,用户体验不好
Parallel收集器
-XX:+UseParallelGC(年轻代)
-XX:+UseParallelOldGC(老年代)
其实就是Serial收集器的多线程版本,使用多线程进行垃圾回收,更关注用户线程的停顿时间(提高用户体验)
新生代标记-复制,老年代标记-整理
ParNew收集器
-XX:UseParNewGC(年轻代)
和Parallel收集器类似,区别主要是它可以和CMS收集器配合使用
CMS收集器
-XX:+UseConcMarkSweepGC(老年代)
CMS(Concurrent Mark Sweep),看名字就知道了,并发标记清除,是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作
优点:并发收集,低停顿
缺点:
- 对CPU资源敏感(会和服务抢资源)
- 有浮动垃圾无法处理(在并发标记和并发清理阶段会产生浮动垃圾,只能等到下一次gc处理了)
- 基于标记-清除算法,会产生大量空间碎片,但是可以通过参数
-XX:+UseCMSComopactAtFullCollection
可以让jvm在执行完标记清除后在做整理 - 如果上一次垃圾回收还没执行完,然后又触发了垃圾回收的话(因为并发标记时间较长),CMS会抛出
concurrent mode failure
,此时会进入stop the world,用serial old垃圾回收器来回收
CMS基于标记-清除算法,整体过程分为五个步骤:
- 初始标记:STW,并记录下GC Roots直接能引用的对象,速度很快(注意是直接能引用的对象)
- 并发标记:从上面标记过的GC Roots直接能引用的对象开始,遍历整个对象图的过程,整个过程耗时较长,但是能与用户线程并发运行。但是因为是与用户线程并发运行的,所以有可能会导致标记过的对象状态发生改变
- 重新标记:STW,因为并发标记阶段有可能有标记过的对象状态发生了改变,为了修正并发标记阶段产生变动的对象(主要是处理漏标问题),这个阶段停顿时间会比初始标记要长一些,但远远小于并发标记的时间。主要使用三色标记法里的增量更新算法做重新标记
- 并发清理:将未标记的对象做清理。这个阶段如果有新增对象会被标记为黑色不做任何处理
- 并发重置:重置本次GC的标记数据
CMS的相关核心参数
- -XX:+UseConcMarkSweepGC:启用CMS
- -XX:ConcGCThreads:并发的GC线程数
- -XX:+UseCMSCompactAtFullCollection:FullGC后做压缩整理(减少碎片)
- -XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认为0,代表每次FullGC都会压缩一次(FullGC一般很长时间才出发一次,可以用默认值,每次清理下)
- -XX:CMSInitiatingOccupancyFraction: 当老年代使用达到该比例时会触发FullGC(默认是92,是百分比)
- -XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值,如果不指定,JVM仅在第一次使用设定值,后续则会自动调整
- -XX:+CMSScavengeBeforeRemark:在CMS GC前启动一次minor gc,降低CMS GC标记阶段时的开销(因为CMS也会对年轻代一起做标记,如果在minor GC就干掉了很多垃圾对象,标记阶段就会减少一些标记时间),一般CMS的GC耗时80%都在标记阶段
- -XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行,缩短STW
- -XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW
垃圾收集算法底层实现
三色标记
在并发标记的过程中,因为应用程序还在跑,对象间的引用可能发生变化,可能会发生多标和漏标,漏标主要是通过三色标记算法来解决
三色标记法,就是把GC Roots可达性分析遍历对象过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:
- 黑色:表示已经扫描过,且这个对象的所有引用都已经扫描过,是安全存活的,不是垃圾对象,黑色对象不可能直接(不经过灰色对象)指向某个白色对象
- 灰色:访问过,但这个对象上至少存在一个引用还没有被扫描过
- 白色:表示对象尚未被垃圾收集器访问过,在访问开始时,所有的对象都是白色,如果分析结束,仍然是白色的对象,即代表不可达,即垃圾
/*** 垃圾收集算法细节之三色标记* 为了简化例子,代码写法可能不规范,请忽略*/
public class ThreeColorRemark {public static void main(String[] args) {A a = new A();//开始做并发标记D d = a.b.d; // 1.读a.b.d = null; // 2.写a.d = d; // 3.写}
}class A {B b = new B();D d = null;
}class B {C c = new C();D d = new D();
}class C {
}class D {
}
多标-浮动垃圾
- 之前已经扫描过的对象(黑色),在运行过程中变成了垃圾,这部分就属于是多标的浮动垃圾,留给下一次GC清理
- 在并发标记(并发清理)开始后产生的新对象,直接全部当成黑色,可能清理过程中也变成垃圾了,这部分也是多标的浮动垃圾,留个下次GC清理
漏标-读写屏障
漏标会导致被引用的对象被当成垃圾误删除,这是严重bug,必须解决,有两种解决方案:增量更新(Incremental Update)和原始快照(SATB, Snapshot at the beginning)
增量更新就是当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次,增量更新底层是通过JVM底层的写后屏障(post_write_barrier)来实现的,将更新值加入到内存队列中去,后面异步操作
**原始快照(SATB)**就是当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色(目的是让这种对象在这轮gc中存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾),原始快照(SATB)是通过JVM底层的写前屏障(pre_write_barrier)来实现的,也是将待删除的值加入到内存队列(satb_mark_queue())中,后面异步操作
写屏障
给某个对象的成员变量赋值时,其底层代码大概长这样:
/**
* @param field 某对象的成员变量,如 a.b.d
* @param new_value 新值,如 null
*/
void oop_field_store(oop* field, oop new_value) { *field = new_value; // 赋值操作
}
所谓的写屏障,其实就是指在赋值操作前后,加入一些处理(可以参考AOP的概念):
void oop_field_store(oop* field, oop new_value) { pre_write_barrier(field); // 写屏障-写前操作*field = new_value; post_write_barrier(field, value); // 写屏障-写后操作
}
- 写屏障实现SATB
当对象B的成员变量的引用发生变化时,比如引用消失(a.b.d = null),我们可以利用写屏障,将B原来成员变量的引用对象D记录下来:
void pre_write_barrier(oop* field) {oop old_value = *field; // 获取旧值remark_set.add(old_value); // 记录原来的引用对象
}
- 写屏障实现增量更新
当对象A的成员变量的引用发生变化时,比如新增引用(a.d = d),我们可以利用写屏障,将A新的成员变量引用对象D记录下来:
void post_write_barrier(oop* field, oop new_value) { remark_set.add(new_value); // 记录新引用的对象
}
读屏障
oop oop_field_load(oop* field) {pre_load_barrier(field); // 读屏障-读取前操作return *field;
}
读屏障是直接针对第一步:D d = a.b.d,当读取成员变量时,一律记录下来:
void pre_load_barrier(oop* field) { oop old_value = *field;remark_set.add(old_value); // 记录读取到的对象
}
现代追踪式(可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等。
对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:
- CMS:写后屏障 + 增量更新
- G1,Shenandoah:写前屏障 + SATB
- ZGC:读屏障
工程实现中,读写屏障还有其他功能,比如写屏障可以用于记录跨代/区引用的变化,读屏障可以用于支持移动对象的并发执行等。功能之外,还有性能的考虑,所以对于选择哪种,每款垃圾回收器都有自己的想法。
为什么G1用SATB?CMS用增量更新?
我的理解:SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描。
记忆集与卡表
在新生代做GC Roots可达性扫描过程中可能会碰到跨代引用的对象,这种如果又去老年代再去扫描效率太低了
为此,在新生代中可以引入记忆集(Remember Set)的数据结构(记录从非收集区到收集区的指针集合)避免把整个老年代加入GC Roots扫描范围
hotspot使用一种叫做卡表(CardTable)的方式实现记忆集,记忆集和卡表的关系,可以类比为Map与HashMap的关系
卡表使用一个字节数组实现:CARD_TABLE[],每个元素对应着其表示的内存区域一块特定大小的内存块,称为“卡页”
hotspot使用的卡页是29大小,即512字节
一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素表示就变成1,表示该元素变脏,否则为0。
GC时,只要筛选本收集区的卡表中变脏的元素加入GC Roots中即可,提高GC效率
问GPT回答说,卡表是存在于老年代中的,与老师讲的不一样,但是无伤大雅,知道是记录跨代引用指针信息的,用于提高GC效率的,避免全盘扫描就行了,至于存在哪里的,以后再研究吧
卡表的维护
HotSpot使用写屏障来维护卡表状态
十三、垃圾收集器G1&ZGC详解
G1垃圾收集器
G1(Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器。以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征
G1将java堆划分为多个大小相等的独立区域,JVM目标是不超过2048个Region(JVM源码里TARGET_REGION_NUMBER定义),实际可以超过该值,但是不推荐
一般Region大小等于堆大小除以2048,比如堆4096M,那么Region大小就是2M,当然也可以用参数-XX:G1HeapRegionSize
手动指定Region大小,但是推荐默认的计算方式
G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是(可以不连续)Region的集合
默认年轻代对堆内存的占比是5%,如果堆大小为4096M,那么年轻代占据200M左右的内存,对应大概是100个Region,可以通过-XX:G1NewSizePercent
设置新生代初始占比,在系统运行中,JVM会不听的给年轻代增加更多的Region,但是年轻代的占比不会超过60%,可以通过-XX:G1MaxNewSizePercent
调整。年轻代中的Eden和Survivor区也跟之前一样,默认8:1:1,假设年轻代现在有1000个Region,那么eden区就有800个,s0对应100个,s1对应100个
一个Region可能之前是年轻代,如果Region进行了垃圾回收,之后可能又会变成老年代,也就是说,Region的区域功能可能会动态变化
G1比较特殊的是对大对象的处理,有一个专门分配大对象的Region叫做Humongous区,大对象的判定规则就是,一个大对象超过了一个Region大小的50%,如果太大,可以横跨多个Region来存放
Full GC时,除了收集年轻代和老年代之外,也会将Humongous区一起回收
G1的垃圾回收过程和CMS很像,大致分为以下几个步骤:
- 初始标记:与CMS一样,STW,标记出GC Roots能直接引用的对象,速度很快
- 并发标记:与CMS一样,标记出上轮标记过的对象引用的所有对象
- 最终标记:与CMS的重新标记一样,STW,修正并发标记可能出现的问题
- 筛选回收:这里和CMS不一样,因为G1有用户自定义设置的期望GC停顿STW的时间(可以用参数
-XX:MaxGCPauseMillis
指定),G1会自动根据此时间来制定回收计划,比如设置200ms,他会计算本次回收需要的时间,选择回收部分,或者回收全部,或者不回收,比如G1发现,垃圾没有那么多,远远不到200ms就可以回收完,并且当下也有足够的空间给新对象分配,那么就会给新对象分配新的Region,不进行垃圾回收;如果回收的时间,根据他的算法得出200ms差不多可以回收完,那就会进行回收;如果他计算得出远远大于200ms,那么就会选择性的回收一部分效益比最高的Region(G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region。)不管是年轻代还是老年代,回收算法主要用的是复制算法,将一个Region中的存活对象复制到另一个Region中,这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。
特点:
- 并行与并发:与CMS一样,可以和用户线程并行执行
- 分代收集:G1不需要与其他垃圾收集器配合就可以管理整个GC堆,但是还是保留了分代的概念
- 空间整合:与CMS的“标记-清理”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器;从局部看是基于“复制”算法实现的
- 可预测的停顿:这是G1相对于CMS的一大优势,他们同样关注低停顿,但G1可以建立可预测的停顿时间模型,能让使用者明确指定期望的STW时间,还有
-XX:G1MixedGCCountTarget
参数可以指定在一次回收过程中做几次筛选回收,G1可以做一会儿,停一会儿,这样不至于让单次停顿时间太长
**注意!**虽然G1可以让用户指定期望的STW时间,但用户也不能设置的太离谱,如果太低,每次收集的垃圾只占很小一部分,最终垃圾慢慢堆积,导致堆满出发Full GC,反而降低性能,所以通常这个期望值默认200ms就很合理了
G1垃圾收集分类
YoungGC
回收年轻代的,但是注意并不是说Eden区满了就会马上触发,如果G1计算回收时间远小于用户设置的期望时间,不会触发GC,会继续开辟新空间给年轻代用
MixedGC
注意,不是FullGC,当老年代的对占有率达到参数-XX:InitiatingHeapOccupancyPercent
设定的值则触发,回收所有的Young和部分Old(根据优先顺序来)以及大对象区,主要是用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次FullGC
FullGC
停止系统程序,然后采用单线程进行标记,清理和压缩整理,好空闲出来一批Region来供下一次MixedGC使用,这个过程是非常耗时的。(Shenandoah优化成多线程收集了)
G1收集器参数设置
-XX:+UseG1GC:使用G1收集器
-XX:ParallelGCThreads:指定GC工作的线程数量
-XX:G1HeapRegionSize:指定分区大小(1MB~32MB,且必须是2的N次幂),默认将整堆划分为2048个分区
-XX:MaxGCPauseMillis:目标暂停时间(默认200ms)
-XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%,值配置整数,默认就是百分比)
-XX:G1MaxNewSizePercent:新生代内存最大空间
-XX:TargetSurvivorRatio:Survivor区的填充容量(默认50%),Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代
-XX:MaxTenuringThreshold:最大年龄阈值(默认15)
-XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合收集(MixedGC),比如我们之前说的堆默认有2048个region,如果有接近1000个region都是老年代的region,则可能就要触发MixedGC了
-XX:G1MixedGCLiveThresholdPercent(默认85%) region中的存活对象低于这个值时才会回收该region,如果超过这个值,存活对象过多,回收的的意义不大。
-XX:G1MixedGCCountTarget:在一次回收过程中指定做几次筛选回收(默认8次),在最后一个筛选回收阶段可以回收一会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长。
-XX:G1HeapWastePercent(默认5%): gc过程中空出来的region是否充足阈值,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉,这样的话在回收过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立即停止混合回收,意味着本次混合回收就结束了。
G1垃圾收集器优化建议
核心还是在于调节-XX:MaxGCPauseMills
这个参数的值,如果太大,可能很久才会执行一次年轻代gc,此时年轻代可能都占到堆内存的60%了,那么存活下来的对象可能就会很多,就会导致Survivor区放不下那么多对象,直接进入老年代中,或者gc之后存活的对象过多,进入Survivor区域后触发了动态年龄判断机制,达到了Survivor区域的50%,也会进入老年代,所以就是,要将-XX:MaxGCPauseMills
调整为合适值,保证年轻代gc不太频繁的同时,还要考虑每次gc过后存活对象有多少,避免存活对象太多快速进入老年代,频繁触发Mixed GC
什么场景适合使用G1
- 8GB以上的堆内存(建议值)
- 50%以上的堆被存活对象占用
- 对象分配和晋升的速度变化非常大
- 垃圾回收时间特别长,超过1s
- 停顿时间是500ms以内
每秒几十万并发的系统如何优化JVM
Kafka类似的支撑高并发消息系统大家肯定不陌生,对于kafka来说,每秒处理几万甚至几十万消息时很正常的,一般来说部署kafka需要用大内存机器(比如64G),也就是说可以给年轻代分配个三四十G的内存用来支撑高并发处理,这里就涉及到一个问题了,我们以前常说的对于eden区的young gc是很快的,这种情况下它的执行还会很快吗?很显然,不可能,因为内存太大,处理还是要花不少时间的,假设三四十G内存回收可能最快也要几秒钟,按kafka这个并发量放满三四十G的eden区可能也就一两分钟吧,那么意味着整个系统每运行一两分钟就会因为young gc卡顿几秒钟没法处理新消息,显然是不行的。那么对于这种情况如何优化了,我们可以使用G1收集器,设置 -XX:MaxGCPauseMills 为50ms,假设50ms能够回收三到四个G内存,然后50ms的卡顿其实完全能够接受,用户几乎无感知,那么整个系统就可以在卡顿几乎无感知的情况下一边处理业务一边收集垃圾。
G1天生就适合这种大内存机器的JVM运行,可以比较完美的解决大内存垃圾回收时间过长的问题。
ZGC垃圾收集器
Z Garbge Collector
参考文章:https://wiki.openjdk.java.net/display/zgc/Main
http://cr.openjdk.java.net/~pliden/slides/ZGC-Jfokus-2018.pdf
特点:支持TB级别的堆内存(JDK15以后可以支持16TB)、颜色指针(染色指针,着色指针)、读屏障、10ms以内STW(JDK16已经达到不超过1ms)、不分代、基于标记-整理算法、不支持指针压缩
ZGC内存布局:
- 小型Region(Small Region):容量固定位2MB,用于放置小于256KB的小对象
- 中型Region(Medium Region):容量固定位32MB,用于放置大于等于256KB但小于4MB的对象
- 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region只会存放一个大对象
为什么这么设计?
Linux Kernel2.6引入标准大页(huge page),分为2MB和1GB,ZGC这么设置也是为了适应现在硬件架构的发展,提升性能
ZGC支持NUMA(了解即可)
简言之,非统一内存访问,之前的CPU是通过系统总线访问一整块内存,现在是CPU核心直接访问各自近端的内存
ZGC核心概念
指针着色技术
从对象指针(注意不是对象头)的高位中借了几位出来搞事情(42~45位)
低42位表示使用中的对空间(242 = 4TB)
ZGC运作过程
ZGC的运作过程大致可以划分为以下阶段:
标记阶段
- 初始标记:与G1一样,从GC Roots出发,找出根集合直接引用的活跃对象,STW,速度很快,实验下来0.03ms左右
- 并发标记:ZGC的标记是在对象指针上而不是在对象头上进行的,标记阶段会更新颜色指针中的Marked 0/Marked 1标志位,首次会更新Marked 0(绿色),下一次GC会更新Marked 1(红色),第二次发现有marked 0标记过的指针(绿色),说明是上次垃圾回收转移走的对象,这是要根据转发表做重映射,之后删除转发表。当然,这个阶段和CMS和G1一样,也是会有漏标问题(通过三色标记解决),ZGC与其他垃圾收集器最大的不同就是,它将前后两次垃圾收集关联起来了。
- 再标记:STW,处理漏标的对象,通过SATB算法解决(与G1一样)
转移阶段
-
并发预备重分配:根据特定的查询条件,统计得出本次收集过程中要清理哪些Region,将这些Region组成重分配集。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本省去G1中记忆集的维护成本 -
并发重分配:核心阶段,把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表,记录从就对象到新对象的专项关系。ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时访问了重分配集中的对象,这次访问将会被预置的内存屏障(读屏障)所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其指向新对象,ZGC将这种行为称之为指针的“自愈”能力因为“自愈”能力,所以只有第一次访问旧对象会变慢,一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配,但是转发表还得留着不能释放掉,因为可能还有访问在使用这个转发表 -
并发重映射:修正整个对中指向重分配集中旧对象的所有引用,但是ZGC中对象引用存在“自愈”功能,所以这个重映射操作并不是很迫切。ZGC很巧妙的把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正他们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正后,原来记录新旧关系的转发表就可以释放掉了
写一个更简单清晰的版本:
- 并发转移准备:分析最有价值GC的分页(无STW)
- 初始转移:转移初始标记的存活对象,同时做对象重定位(有STW)
- 并发转移:对转移并发标记的存活对象做转移(其实会在下次GC的并发标记中做)
ZGC中的读屏障
涉及对象:并发转移但还没做对象重定位的对象(着色指针使用M0和M1可以区分)
触发时机:在两次GC之间业务线程访问这样的对象
触发操作:对象重定位+删除转发表记录(两个一起做原子操作)
读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。
需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。
------------读屏障-----------
Object D = A.instance;// 需要// 以下不需要读屏障(有待证明)
Object E = D;// 没有读引用
D.hashCode();// 不需要
int j = D.i;// 成员变量是基本数据类型的话,也不需要走读屏障
GPT回答:
ZGC是利用读屏障来保证GC之后还没来得及转移的对象的,对吗?
是的,ZGC(Z Garbage Collector)是一种低延迟的垃圾收集器,它使用读屏障(Read Barriers)来处理GC期间还未被转移的对象,确保程序的正确执行。
ZGC是一种并发的、基于区域的、压缩型垃圾收集器,设计目标是支持大堆内存(数十TB级别)并且保持低延迟(目标是停顿时间不超过10ms),同时还能保持较高的吞吐量。ZGC通过将堆内存分割成多个小区域(regions)来管理内存。在进行垃圾收集时,ZGC并发地标记和清理无用的对象,而不是停止应用线程执行这些任务(Stop-The-World,STW)。但是,即使是并发执行,也有可能出现程序访问到了正在被GC移动的对象。
为了处理这个问题,ZGC采用了读屏障技术。当程序尝试访问一个对象时,读屏障会检查这个对象是否正在被GC线程移动。如果是,读屏障会确保返回给程序的是对象的新地址,而不是旧地址。这样,即使在垃圾收集过程中,应用程序也能正常访问所有对象,确保了内存操作的正确性和程序的稳定运行。
通过这种方式,ZGC能够最小化停顿时间,即使是在进行垃圾收集的过程中,也能保证应用程序的响应性。
只有从堆中读取对象引用的时候才需要读屏障,对吗?
是的,读屏障(Read Barrier)主要用于在从堆中读取对象引用时确保访问的是对象的最新副本。在ZGC这样的并发垃圾收集器中,对象可能会在垃圾收集过程中被移动。如果应用线程试图访问一个正被GC线程移动的对象,读屏障会介入,确保应用线程能够访问到对象的新位置,而不是它的旧位置。
读屏障的作用是在读取堆内存中的对象引用时,进行必要的检查和处理,以保证即使在并发垃圾收集期间,应用程序的执行依然是正确的。这种机制对于保持低停顿时间非常重要,因为它允许垃圾收集器在应用线程运行时并行工作,而不需要频繁地暂停应用来执行垃圾收集操作。
总结来说,读屏障确实主要用于处理从堆中读取对象引用的操作,确保这些操作在并发垃圾收集环境下的正确性和安全性。
如果是读取一个对象的成员变量,这个成员变量是一个基本数据类型,还需要读屏障吗?
是的,即使是读取一个对象的成员变量,如果这个成员变量是一个基本数据类型,在使用像ZGC这样的并发垃圾收集器时,仍然需要读屏障。这是因为读屏障的主要目的是确保访问的对象处于其最新、正确的状态,无论是对象的引用还是对象内部的基本数据类型字段。
当垃圾收集过程中对象可能被移动时,对象的整体位置在堆中改变,包括其所有的字段,不论这些字段是对象类型还是基本数据类型。因此,当应用程序代码尝试读取对象的任何成员变量时,读屏障确保了即使对象已经移动,应用程序也能访问到正确的数据。读屏障会自动处理这种重定向,确保访问的是对象在新位置的数据。
这个机制对于保持并发垃圾收集的正确性和应用程序的稳定性至关重要,因为它避免了因对象移动导致的数据不一致问题。
那调用这个对象的hashCode方法呢?
调用一个对象的
hashCode
方法时,是否需要读屏障取决于具体的垃圾收集器实现和对象的状态。在使用ZGC这样的低延迟并发垃圾收集器时,访问对象的任何成员方法(包括hashCode
方法)理论上也会触发读屏障,原因如下:
- 对象访问一致性:读屏障确保无论何时访问对象,都能访问到其最新的、一致的状态。如果对象在垃圾收集过程中被移动了,读屏障会确保调用的是对象新位置上的方法,从而保持访问的一致性。
- 方法执行上下文:当调用对象的
hashCode
方法时,实际上是在访问该对象的特定数据来计算哈希码。如果该对象由于垃圾收集活动而移动,那么确保方法执行的正确性就需要读屏障来重定向到对象的新位置,即使该方法可能不直接修改对象状态。然而,特别是在
hashCode
这种情况下,如果对象的哈希码是固定不变的(例如,在对象首次调用hashCode
时计算并缓存了哈希值),并且这个值被存储在对象的某个字段中,那么实际的hashCode
方法调用可能并不需要通过读屏障来重定向对象访问,因为它访问的是已经计算好并缓存的值。不过,这依赖于具体实现和对象状态。总体而言,从理论上讲,在使用ZGC时,读屏障确保了调用对象方法(包括
hashCode
)的操作正确性,尽管在某些特定情况下,如访问缓存的固定值,这种影响可能是最小的或者不必要的。
颜色指针
ZGC的核心设计之一。以前的垃圾回收器的GC信息都保存在对象头中,而ZGC的GC信息保存在指针中
每个对象有一个64位指针,这64位被分为:
- 18位:预留给以后使用;
- 1位:Finalizable标识,此位与并发引用处理有关,它表示这个对象只能通过finalizer才能访问;
- 1位:Remapped标识,设置此位的值后,对象未指向relocation set中(relocation set表示需要GC的Region集合);
- 1位:Marked1标识;
- 1位:Marked0标识,和上面的Marked1都是标记对象用于辅助GC;
- 42位:对象的地址(所以它可以支持2^42=4T内存)
为什么有2个mark标记?
每一个GC周期开始时,会交换使用的标记位,使上次GC周期中修正的已标记状态失效,所有引用都变成未标记。
GC周期1:使用mark0, 则周期结束所有引用mark标记都会成为01。
GC周期2:使用mark1, 则期待的mark标记10,所有引用都能被重新标记。
通过对配置ZGC后对象指针分析我们可知,对象指针必须是64位,那么ZGC就无法支持32位操作系统,同样的也就无法支持压缩指针了(CompressedOops,压缩指针也是32位)。
颜色指针的三大优势:
- 一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理,这使得理论上只要还有一个空闲Region,ZGC就能完成收集。
- 颜色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,ZGC只使用了读屏障。
- 颜色指针具备强大的扩展性,它可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。
安全点和安全区域
安全点
安全点是指代码中一些特定的位置,当线程运行到这些位置时它的状态是确定的,这样JVM就可以安全的进行一些操作,比如GC等,所以GC不是想什么时候做就能立即触发的,是需要等待所有线程运行到安全点后才能触发。
安全点的位置:
- 方法返回之前
- 调用某个方法之后
- 抛出异常的位置
- 循环的末尾
大体的思想是当垃圾需要中断线程的时候,不直接对线程操作,仅仅设置一个标志位,各个线程执行过程时会不停的去主动轮询这个标志,一旦发现标志为真时,就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的
安全区域
安全点是针对正在运行中的线程设定的
比如一个线程处于Sleep或中断状态,它就不能相应JVM的中断请求,再运行到Safe Point上,所以JVM就引入了安全区域
指一段代码片段中,引用关系不会发生变化,在这个区域内的任意地方开始GC都是安全的
——JVM相关至此结束,完结撒花🎉🎉🎉
二、并发编程专题
一、从0开始深入理解并发、线程与等待通知机制(上)
进程
进程是分配资源(以内存为主)的最小单位
线程
线程是CPU调度的最小单位,一个进程中可以包含多个线程,java线程无处不在,一个main方法就要有6个线程启动
同一台计算机的进程通信称为IPC(Inter-process communication),即内存进程通信。不同计算机之间的进程通信被称为RPC(Remote Process communication),需要通过网络,并遵守共同的协议,比如大家熟悉的Dubbo就是一个RPC框架,而HTTP协议也经常用在RPC上,比如SpringCloud微服务
面试题:进程间的通信有几种方式:
- 信号
- 信号量
- 管道
- 消息队列
- 套接字
- 共享内存
- 文件映射
- 远程过程调用
- 消息传递接口
CPU核心数与线程数的关系
Runtime.getRuntime().availableProcessors(),获取CPU核心数,这个核心数指的是逻辑处理器数
在Intel引入超级线程技术后,产生了逻辑处理器的概念,使核心数与线程数形成1:2的关系
上下文切换
就是CPU在多个进程(线程)之间切换时,要保存进程(线程)内部各自的资源,即上下文,在CPU处理不同进程时,需要将上下文存储&恢复,这个动作叫做上下文切换,一般需要5000~20000的时钟周期
并行和并发
并行就是多个CPU一起执行任务
并发就是只有一个CPU,多个任务交替执行,看起来像是一起执行的
线程的启动和终止
启动
面试题:新奇线程有几种方式?
启动线程的方式有两种(jdk源码注释上写的是两种):
- 继承Thread类,覆盖run方法,然后调用start()方法启动线程
- 实现Runnable接口,实现run方法,然后交给Thread运行
除了官方说的两种,其实还有,实现Callable接口,实现call方法,将实现Callable接口的对象通过FutureTask包装成Runnable,再交给Thread去执行
而线程池的方式,本质上是池化技术,是资源的复用,和新启线程没什么关系
终止
-
自然终止:run执行完了,或者中间抛出异常无法执行了
-
stop:过期方法,强制终止,不优雅
-
中断:优雅的终止,其他线程通过调用线程A的
interrupt()
方法,对其进行中断操作,但是注意,这个interrupt方法,就只是把A线程内部的中断标志位置为true,A线程并不会停止自己的工作,也完全可以不理会这种中断请求,A线程内部可以自己通过isInterrupt()
方法,或者也可已调用静态方法Thread.interrupted()
来判断当前线程是否被中断,这两者的区别就是,Thread.interrupted()
方法除了判断当前中断标记外,还会将其置为false
不建议自定义一个取消标志位来终止线程的运行。因为run方法中会有睡眠之类的阻塞调用,自定义的标志位不能及时的反应出来,而像sleep这种方法,本身就支持中断的检查,如果线程处于了阻塞状态(如调用了thread.sleep,thread.join,thread.wait等),则在线程在检查中断标识时,如果发现中断标识为true,则会在这些阻塞方法调用处,抛出InterruptedException异常,并且在抛出异常后会立即将线程的中断标识位清除,即重新置为false
注意:处于死锁状态的线程无法被中断
sleep方法,捕捉了中断异常之后,为什么建议一定要再次手动调用interrupt方法?
因为中断异常一旦被抛出,中断标识位会被置为false,如果代码中有手动处理interrupt标识的地方,将无法正常判断,例如
while(!isInterrupted()) {try{Thread.sleep(1000);} catch (InterruptedException e) {// 捕捉到异常后,中断标识位变为了falselog.....// 再次调用interrupt方法,将中断标识位置为true,否则无法退出循环interrupt();} }
Thread和Runnable的区别
Thread才是Java里对先线程的唯一抽象,Runnable只是对任务(业务逻辑)的抽象。Thread可以接受任意一个Runnable的实例并执行
run()和start()
run()其实就是一个普通方法,本质上和任意一个类的任意一个方法没有任何区别,可以重复执行,也可以被单独调用
start()是真正的去启动一个线程,让一个线程进入就绪队列等待分配cpu,分到cpu之后,才调用实现的run方法,注意,start()方法不能重复调用,如果重复调用会抛出异常,因为一个线程对应一个对象实例,启动第二个线程的时候,Thread对象实例已经被第一个线程占用了
二、从0开始深入理解并发、线程与等待通知机制(中)
线程的状态/生命周期
- 初始状态(NEW):新new了一个线程对象,但还没有调用start()方法
- 运行(RUNNABLE):Java线程中将就绪(ready)和运行中(running)两种状态统称为“运行”
- 阻塞(BLOCKED):表示线程阻塞于锁(在java中,synchronized获取锁时,或者future.get()等待结果时,可以让线程进入阻塞状态)
- 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)
- 超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定时间后自行返回
- 终止(TERMINATED):表示该线程已经执行完毕
其他线程相关的方法
yield()方法
让当前线程让出CPU占有权,但让出的时间是不可设定的。也不会释放锁资源。同时执行yield()的线程有可能在进入到就绪状态后会被操作系统再次选中又被执行
ConcurrentHashMap#initTable方法中就使用了yield
因为初始化方法,在高并发的情况下,可能会有多个线程来调用,但其实只会允许一个线程做初始化操作,其他的线程就需要阻塞或等待,但是,与其阻塞或等待,不如直接让其他线程执行yield()方法,让出CPU执行权,让初始化操作的线程可以更快的执行完成
线程的优先级
在java线程中,有线程的优先级,通过一个整型成员变量priority控制,范围是1~10,默认是5,用得不多
线程的调度
分为抢占式线程调度和协同式线程调度
抢占式:Java中的线程调度就是抢占式的,由系统分配线程
协同式:依赖其他线程通知来唤醒当前线程
守护线程
Daemon(守护)线程是一种支持型线程,主要被用作程序中后台调度以及执行性工作
当JAVA虚拟机中不存在非Daemon线程的时候,Java虚拟机将会推出。可以通过Thread.setDaemon(true)将先测会给你设置为Daemon线程。我们一般用不上,比如垃圾回收线程就是Daemon线程
线程间的通信和协作
管道输入输出流
Java中的管道输入/输出流主要包括了一下四种具体的实现:
PipedOutputStream、PipedInputStream、PipedReader和PipedWriter,前两种面向字节,后两种面向字符
join方法
可以控制线程的执行顺序,比如想让T1线程在T2线程之前执行,那么就在T2线程的开始调用T1.join()
synchronized内置锁
可以锁对象或类
对象锁是用于对象实例方法或者一个对象实例上的,类锁是用于类的静态方法或一个类的Class对象上的
注意,当对同一个变量操作时,用来做锁的对象必须是同一个,否则加锁毫无作用
注意,锁Integer i的时候,不要使用i++,因为i++底层其实调用的是Integer.valueOf(i) + 1,而Integer.valueOf()会返回new Integer(),将改变i对象
volatile,最轻量的通信/同步机制
注意是通信/同步机制,不是上锁
volatile只保证了不同现成的可见性,不保证原子性(安全性)
适用场景:只有一个线程写,多个线程读
等待/通知机制
wait()和notify(),一定要在同步方法或同步代码块中使用,并且只能锁同一个对象
等待和通知的标准范式
等待方:
- 获取对象的锁
- 如果条件不满足,调用对象的wait方法,被唤醒后仍然要检查条件
- 条件满足则继续执行对应的逻辑
synchronized(对象) {while(条件不满足) {对象.wait();}对应的处理逻辑
}
通知方:
- 获取对象的锁
- 改变条件
- 通知所有等待在对象上的线程
synchronized(对象) {改变条件对象.notifyAll();
}
yield()、sleep()、wait()、notify()对锁有什么影响?
yield()和sleep()是Thread类的方法,对锁没有任何影响,不会释放锁
wait()和notify()是Object类的方法,其中,wait()方法将会释放锁,等他被唤醒后,会重新竞争锁,拿到锁之后才会去执行wait方法后面的代码
notify()对锁无影响,只有在syn代码块执行完之后才会释放锁,所以一般notify()放在syn代码块最后一行
wait()和notify()为什么要在同步块中使用?
首先,代码会报错,其次,如果不在同步代码块中,有可能notify已经尝试唤醒,但wait方法还没睡呢,就会出现问题,这就是所谓的lost wake up问题,所以一定要加锁
为什么要在循环中检查等待条件
当前对象作为锁,可能有多个线程被同时唤醒,资源是有限的,比如3个线程同时去抢连接池中的同一个连接,最终只会有一个能抢到,抢不到的自然就是循环中判断条件不满足,接着等待了
CompletableFuture
在jdk 1.5中,引入了Future这个接口,可以获取单个任务的执行结果,有两个方法:
get():阻塞获取,会无限期的等待运算结果
get(long timeout, TimeUnit unit):在指定时间timeout内等待结果,超时无结果就抛出TimeoutException
在jdk1.8中,引入了CompletableFuture,实现了Future和CompletionStage两个接口,除了可以阻塞获取结果外,还可以编排任务
除了直接new出一个CompletableFuture的实例,还可以用工厂方法创建
Async表示异步,上面的没有返回值,下面的有返回值,Executor传入表示使用我们自己创建的线程池,否则使用默认的ForkJoinPool.commonPool()作为它的线程池(同时执行的线程数量最大为:Runtime.getRunTime().availableProcessors() - 1
,至少会保证有一个线程),一般建议传入自己创建的线程池
编排:
-
thenApply(),接受Function,有入参,有返回值
-
thenAccept(),接受Consumer,有入参,无返回值
-
thenRun(),接受Runnable,不关心上一步结果,无入参,无返回值
-
thenCompose(),将两个异步操作串联起来
-
thenCombine(),将两个异步操作的结果合并起来,两个任务并发执行
-
exceptionally(),处理异步操作中的异常
-
handle(),处理正常的计算结果或异常情况