一、MYSQL
一、深入理解mysql索引底层数据结构与算法
索引是帮助MySQL高效获取数据的排好序的数据结构
查询数据主要是磁盘做交互,走I/O,不同数据存放位置不同,索引是将固定数据存放在数据结构中方便快速查找
索引中的key-value指的是key:数据 value:数据存放在磁盘中的地址
数据结构的址:https://www.cs.usfca.edu/~galles/visualization/Algorithms.html
1、mysql为什么不用:
二叉树因为在逐级递增的数据中,二叉树会看作一个链表,查询时和遍历查找没有区别。
红黑树红黑树是一种变相的二叉树,又叫平衡二叉树,虽说在二叉树的基础上优化实现了平衡,但是当数据量大的时候,树的高度会十分庞大,高度不可控,一样会增加很多的查找次数
B树B树是B+树的前身,为什么不用B树呢,因为数据查找速度影响的因素就是树的高度,B树的每个节点都会存放磁盘地址,这样算来一个节点大概空间在1kb左右,每个节点大约能存放16个元素,如果有2千万条数据,树的高度远远不止于3,和B+树效率上差了很大一截,而且叶子节点相较于B+树缺少了双向指针,对于范围查找不能很好的实现。
HASH树哈希树会将索引通过哈希算法存放在哈希桶中,每个元素对应生成的哈希值都会连接对应的元素和内存地址,有的时候精准查找效率会非常高,但是对于范围查找,没有办法很好的实现
2、为什么使用:
B+树:B+树是B树的延申,树的高度是由非叶子节点的数据量决定的,将数据的地址只存放在叶子节点中,其他节点存放的都是冗余索引,因为底层分配给每个节点的大小为16kb,一个较大空间的bigint类型占用8字节,加上相邻存储的下方节点的内存地址占用6kb,等于根节点可以存放16kb/(8+6)b,大约在1170个数据,假如树的高度为3层,叶子节点的数据一般不会超过1kb的大小,代表叶子节点大概能存放16个元素,这样算下来就是1170*1170*16,大约在2千万个元素,2千万的元素树高度在3行,等于走3次磁盘I/O,所以会非常快。而且B+树的叶子节点之间会有双向指针指向相邻节点的内存地址,在做范围查找的时候可以直接定位范围然后根据双向指针一路从叶子节点查下去。对于范围查找也有很好的效率。
3、MYISAM存储引擎走索引的查找方式:
4、什么叫聚集索引,什么是非聚集索引:
聚集索引的意思就是将索引和行数据存放在叶子节点中,非聚集索引就是将索引(叶子节点中存放是的数据在磁盘中的地址)和行数据存放在不同的文件中
5、为什么建议innodb的表需要建一个自增的整形主键:
因为在innodb存储引擎中,会自动将数据建立一个B+树的结构文件,有主键会用主键索引作为节点元素。如果没有建立主键,mysql会挑选一列所有元素都不相同的数据来维护B+树。假如所有的列没有全部元素都不相同的,这时候mysql会建立一个隐藏列帮你维护主键。
为什么要用整形不用其他类型呢,因为整形比较大小更加快捷,占用空间更小
6、非主键索引的叶子节点为什么只存放主键值:
因为一点是节省空间,innodb只会创建一个聚集索引,有主键用主键,没有主键用隐藏id做,拿到主键值后查找主键B+树节省了非主键B+树的空间。如果表没有建立主键索引,那么mysql会使用自己创建的隐藏id作为叶子节点的储存数据
还有一点是一致性,如果不存放主键值,在修改数据的时候将要给主键的聚集索引进行维护,然后对非主键索引的叶子节点所有数据再维护一次,复杂度较高,如果存放主键值,主键的聚集索引可以先维护,然后将主键值维护到非主键索引树中,更为简单
7、联合索引为什么要走最左匹配原则:
因为联合索引是按照顺序在B+树中排好序构建树结构,先对第一个元素排序,当元素相同时对第二个元素排序,依次类推下去,前提就是按照联合索引的建立顺序来依次排序构建B+树,如果不通过最左匹配,没有第一个排序来定位,那么查找的数据就无法确定后续是否存在,必须进行全表扫描,所以需要走最左匹配原则。
但是mysql执行流程中的优化器可以对联合索引都用到的情况下自动排序查询条件,即使没有最左匹配也会排序到顺序一致,但是这种简单的操作完全可以由我们手动实现,交给mysql再实现会费时间,最重要的不能让联合索引中断。
二、Explain详解与索引最佳实践
1、select_type列:
1)simple:简单查询。查询不包含子查询和union
2)primary:复杂查询中最外层的 select
3)subquery:包含在 select 中的子查询(不在 from 子句中)
4)derived:包含在 from 子句中的子查询。MySQL会将结果存放在一个临时表中,也称为派生表(derived的英文含义**)**
2、type列:
这一列表示关联类型,性能按照从优到略依次是 system>const>eq_ref>ref>range>index>all
system:是效率最高的type,是const的强化版,const中只有一条,默认查出,就是system
const:常量,效率很高,等于是将查询结果当作常量查找出来
eq_ref:是通过主键索引或者唯一索引来查找或关联,在表中唯一,效率也很高
ref:是通过普通索引来查找或关联,不向上述全表只有一个,这个是全表会有多个,查找出一个范围,效率相对没那么高
range:范围查找,通过查询索引的范围来查找,一般优化到这个type就足够了
index:全索引扫描,能拿到结果,一般是扫描某个二级索引,这种扫描不会从索引树根节点开始快速查找,而是直接 对二级索引的叶子节点遍历和扫描,速度还是比较慢的,但是相对于all的全表扫描还是好很多的
all:即全表扫描,扫描你的聚簇索引的所有叶子节点。通常情况下这需要增加索引来进行优化了
3、key_len列:
列显示了mysql在索引里使用的字节数,如果字段允许为 NULL,需要1字节记录是否为 NULL
字符串,char(n)和varchar(n),5.0.3以后版本中,n均代表字符数,而不是字节数,
如果是utf-8,一个数字 或字母占1个字节,一个汉字占3个字节 char(n):如果存汉字长度就是 3n 字节 varchar(n):如果存汉字则长度是 3n + 2 字节,加的2字节用来存储字符串长度,因为 varchar是变长字符串
数值类型 tinyint:1字节
smallint:2字节
int:4字节
bigint:8字节
时间类型 date:3字节
timestamp:4字节
datetime:8字节
三、SQL底层执行原理详解
1、连接器:
客户端连接mysql时,第一步首先是访问mysql的连接器
mysql ‐h host[数据库地址] ‐u root[用户] ‐p root[密码] ‐P 3306
建立连接完成TCP三次握手的时候,连接器会开始认证身份,使用你输入的用户名密码,如果没问题,连接器会将你的用户拥有的权限信息加载到链接器开辟的专属于你这个连接的会话空间中,这时即使有用户修改了你的权限,也不会影响你的操作,而是等下一次连接时才会刷新权限。
2、查询缓存:
一般在mysql配置文件my.cnf中配置,query_cache_type=2。在sql语句中添加SQL_CACHE,意味着将这条sql对应的结果集存在mysql缓存中,将此sql语句作为key,结果集作为value储存。下一次查询时会直接走sql缓存,而不会查表。但是此缓存较为鸡肋,在查找过程中,如果有人修改此数据,变成脏数据以后,mysql需要在缓存中删除此数据,下次sql查询的时候再走插入缓存的操作,比较麻烦,所以在mysql8之后的版本被删除,8以前的版本默认是关闭的,需要在配置文件中手动打开。
3、分析器:
如果没有命中查询缓存,就要开始真正执行语句了。首先,MySQL 需要知道你要做什么,因此需要对 SQL 语句做解析 将你sql输入的关键字进行分析,分析哪些是表,哪些是条件,哪些是查找字段。
SQL语句的分析分为词法分析与语法分析,mysql的词法分析由MySQLLex[MySQL自己实现的]完成,语法分析由Bison生成。
经过分析后,会得到一个语法树:
这个树可以在分库分表时拿到具体的查询条件,不论是取模还是范围判断,路由到固定的表中查询,还可以在分布式事务中检测对应的语句,与之生成一套逆向语句,例如insert语句顺势生成一条delete语句。
4、优化器:
经过了分析器,MySQL 就知道你要做什么了。在开始执行之前,还要先经过优化器的处理。 优化器是在表里面有多个索引的时候,决定使用哪个索引;或者在一个语句有多表关联(join)的时候,决定各个表的连接 顺序。
比如你执行下面这样的语句,这个语句是执行两个表的 join:
select * from test1 join test2 using(ID) where test1.name=yangguo and test2.name=xiaolongnv;
优化器阶段 完成后,这个语句的执行方案就确定下来了,然后进入执行器阶段
5、执行器:
开始执行的时候,要先判断一下你对这个表 T 有没有执行查询的权限,如果没有,就会返回没有权限的错误,如下所示 (在 工程实现上,如果命中查询缓存,会在查询缓存返回结果的时候,做权限验证。查询也会在优化器之前调用 precheck 验证权 限)。
如果有权限,就打开表继续执行。打开表的时候,执行器就会根据表的引擎定义,去使用这个引擎提供的接口。
至此,这个语句就执行完成了。
6、bin-log归档:
删库是不需要跑路的,因为我们的SQL执行时,会将sql语句的执行逻辑记录在我们的bin-log当中,什么是bin-log呢? binlog是Server层实现的二进制日志,他会记录我们的cud操作。
Binlog有以下几个特点:
1、Binlog在MySQL的Server层实现(引擎共用)
2、Binlog为逻辑日志,记录的是一条语句的原始逻辑
3、Binlog不限大小,追加写入,不会覆盖以前的日志
如果,我们误删了数据库,可以使用binlog进行归档!要使用binlog归档,首先我们得记录binlog,因此需要先开启MySQL的 binlog功能。
四、Mysql索引优化实战一
1、联合索引第一个字段用范围查找不会走索引:
mysql内部可能觉得第一个字段就用范围,结果集应该很大,回表效率不高,还不如就全表扫描
2、强制走索引:
在某些sql语句中,实际上用到了索引字段查询,但是最后监控下发现并没有走,这是在mysql执行过程中优化器判断的,mysql认为扫描全表会更快,他就会让语句不走索引。虽然这个判断绝大多数情况下都是没问题的,但是也不包括个别情况会判断错误。所以我们可以在sql中加上force index来让语句强行走索引
EXPLAIN SELECT * FROM employees force index(idx_name_age_position) WHERE name > 'LiLei' AND age = 22 AND position ='manager';
虽然在监控中我们可以看到扫描的行数可能少了一部分,但是实际上的执行时间并没有减少,甚至会增大,这是mysql最后的回表操作可能耗时更多
3、覆盖索引优化:
EXPLAIN SELECT name,age,position FROM employees WHERE name > 'LiLei' AND age = 22 AND position ='manager';
让查询的字段都是存在的索引,这样效率会更高,因为能够命中name,age,position索引,直接查出,无需回表,符合索引覆盖。
select id,name from user where name='shenjian';
能够命中name索引,索引叶子节点存储了主键id,通过name的索引树即可获取id和name,无需回表,符合索引覆盖,效率较高。
select id,name,sex from user where name='shenjian';
能够命中name索引,索引叶子节点存储了主键id,但sex字段是索引没有必须回表查询才能获取到,不符合索引覆盖,需要再次通过id值扫码聚集索引获取sex字段,效率会降低。
4、in和or:
in和or在表数据量比较大的情况会走索引,在表记录不多的情况下会选择全表扫描:
5、like kk% 一般情况下都会走索引:
like其实可以当作一个常量的查找,类似于in或者or,虽然是一个范围查找,但是范围相较于==>,>=,<=,<来说更小,所以这时候mysql会进行一个索引下推==的操作,索引下推的操作是在5.6版本以后引入的,在5.6版本以前,mysql都是通过查找到的范围来拿到对应的id合集,然后回表到主键索引中拿到所有数据,最后在获取其他字段,然后再比对age和position这两个字段的值是否符合。
但是5.6版本以后,可以在索引遍历过程中,对索引中包含的所有字段先做判断,过滤掉不符合条件的记录之后再回表,可以有效的减少回表次数。
6、order by和group by的索引:
①mysql支持两种类型的排序,分别是filesort和index,其中filesort走的是文件排序,效率较低,需要走全表扫描,而using index是指mysql扫描索引本身进行的排序,所以效率更高
②一般order by会有两种情况会走索引排序,1:使用索引最左前缀匹配原则进行order by 2:使用where查询条件和order by共同走最左前缀匹配。
③尽量在索引列上完成排序,遵循索引建立(索引创建的顺序)时的最左前缀法则。
④如果order by条件不在索引上,就会走文件排序
⑤能用覆盖索引就用覆盖索引
⑥group by与order by很类似,其实质是先排序后分组,遵照索引创建顺序的最左前缀法则。对于group by的优化如果不需要排序的可以加上order by null禁止排序。注意,where高于having,能写在where中 的限定条件就不要去having限定了
7、filesort文件排序方式:
**单路排序:**一次性拿取所有满足条件的数据,加载到内存的临时文件中,然后通过排序字段在内存中进行排序。
**双路排序:**通过查询条件拿取所有满足的id和排序字段,将数据放入内存中的临时文件中,对临时文件中的id和排序字段进行排序,排序好后通过id回表查找其他数据。
两者排序没有说谁好谁不好,各有优缺点,单路排序需要将所有字段都放在内存中,对内存的耗费非常大,但是由于拿到了所有字段,无需回表查找,效率较高。双路排序只需要拿到排序字段和确定唯一的id,对内存的压力较小,但是需要通过id回表查找,效率相对更低。
mysql通过max_length_for_sort_data字段来确定是否走单路或者双路,该字段默认值为1kb。如果查找的所有字段加起来大于1kb,将会走双路排序,否则走单路。
8、索引设计原则:
五、Mysql索引优化实战二
1、分页查询优化方案:
根据索引查找
mysql分页查询的底层执行是将分页的第一个参数以前的所有数据全部查询出来,加上过滤的行数。然后把分页前的数据全部舍去,最后出现的就是分页后的数据,但是这个就有很大的问题,在数据量非常大的时候,也会查出来全部的数据然后进行分页,于是分页前期会感到速度正常,页数逐渐增大以后会非常慢。
一种优化思路如下,在主键连续并且自增的情况下,使用排序然后分页,效率会大大提高:
select * from employees where id > 90000 limit 5;
在这种查询语句中id会走索引,在直接分页下会走全表扫描
上述方法的前提就是主键不得中断,还得是连续自增的。
不根据索引查找
不通过索引查找如果使用上述方法,一样因为范围太大,会进行全表扫面,效率较低,这时候可以使用索引覆盖,先查出来id,然后用内连接关联本表查找结果集:
select * from employees e inner join (select id from employees order by name limit 90000,5) ed on e.id = ed.id;
2、sql关联查询优化:
嵌套循环连接 Nested-Loop Join(NLJ) 算法
当使用索引进行联表查找时,会将关联表的索引扫描出来,然后通过索引扫描主表的对应索引,这样扫描效率会非常高,假如主表10000条数据,关联表100条数据,那么一共会扫描200次
基于块的嵌套循环连接 Block Nested-Loop Join(BNL)算法
当不使用索引关联时,会将关联表的所有数据扫描,拿出对应条件放入join_buffer中,然后扫描主表的所有数据和join_buffer中的100条数据进行匹配,这样在磁盘中就会进行10000+100次扫描,然后在内存join_buffer中最多进行100,0000次匹配,虽然效率较低,但是由于使用了内存进行匹配,效率也不会非常慢。
假如没用索引还继续使用NJL算法的话,就等于在关联表的100条数据中,每一条都得匹配主表的10000条数据,就是在磁盘中进行最多100,0000次扫描,效率会非常低下,所以走索引和不走索引对应的算法都有优点。
对于关联sql的优化
关联字段加索引,让mysql做join操作时尽量选择NLJ算法
小表驱动大表,写多表连接sql时如果明确知道哪张表是小表可以用straight_join写法固定连接驱动方式,省去 mysql优化器自己判断的时间,注意此方法只适合join
3、in和exsits优化:
原则:小表驱动大表,即小的数据集驱动大的数据集
in:当B表的数据集小于A表的数据集时,in优于exists
select * from A where id in (select id from B)
#等价于:
for(select id from B){
select * from A where A.id = B.id
}
exists:当A表的数据集小于B表的数据集时,exists优于in
select * from A where exists (select 1 from B where B.id = A.id)
#等价于:
for(select * from A){
select * from B where B.id = A.id
}
#A表与B表的ID字段应建立索引
EXISTS子查询的实际执行过程可能经过了优化而不是我们理解上的逐条对比
EXISTS子查询往往也可以用JOIN来代替,何种最优需要具体问题具体分析
六、深入理解Mysql锁与事务隔离级别
1、事务及其ACID属性:
A:原子性:在操作层面,同一事务中的操作要么全部执行,要么全部不执行
C:一致性:在数据层面,同一事务中的数据从开始到结束都必须保持一致,与原子性差不多,但是涉及的层面不一样
I: 隔离性:代表一个事务的操作不能被其他事务所影响,即使数据被修改,在事务内读取到的也不能改变
D:持久性:事务提交后对数据库的操作是永久的,不能因为其他问题导致数据未修改
并发事务处理带来的问题
脏写:简单的说就是没管其他事务,自顾自的修改数据,导致数据覆盖了别的事务的
脏读:简单的说就是读取到了别人改了又取消的数据,导致数据异常
不可重复读:指的是同一个语句在不同时刻查到的内容是不同的,不符合隔离性
幻读:一个事务读取到了其他事务新加的数据,不符合隔离性
2、锁的分类:
从性能上分为:
乐观锁(用版本对比来实现)和悲观锁
从对数据库操作的类型分:
分为读锁和写锁(都属于悲观锁)
读锁(共享锁,S锁(Shared)):
针对同一份数据,多个读操作可以同时进行而不会互相影响,加了读锁其他事务就无法修改数据,只能读取
写锁(排它锁,X锁(eXclusive)):
当前写操作没有完成前,它会阻断其他写锁和读锁
1、对MyISAM表的读操作(加读锁) ,不会阻寒其他进程对同一表的读请求,但会阻赛对同一表的写请求。只有当读锁释放后,才会执行其它进程的写操作。
2、对MylSAM表的写操作(加写锁) ,会阻塞其他进程对同一表的读和写操作,只有当写锁释放后,才会执行其它进程的读写操作从
对数据操作的粒度分,分为表锁和行锁
表锁开销小,加锁快,因为加锁只需要找到对应的表就可以加锁,所以很快。行锁开销大,加锁慢,因为需要找到具体的那一行才能加锁。
InnoDB与MYISAM的最大不同有两点: InnoDB支持事务(TRANSACTION) InnoDB支持行级锁
MYISAM在执行查询语句的时候会将涉及的表加读锁,在进行insert,update,delete会将涉及的表加写锁。性能非常低下
InnoDB在在执行查询语句的时候不会加锁,但是在执行操作语句的时候会给操作的数据加上行锁。
隔离级别:
读未提交在两个事务中,其中一个事务会读取到其他事务操作但还没提交的数据,读取的数据会有问题,不满足隔离性
读已提交在两个事务中,其中一个事务读取到其他事务操作并提交的数据,不满足隔离性
可重复读这是mysql的默认隔离级别,在同一个事务中只会读取相同的数据,不论其他事务如何修改数据;但是也不能防止幻读,如果有其他事务修改了数据,本事务虽然读取不到,但是修改的时候也可以操作其他事务新增的数据。
可重复读的隔离级别下使用了MVCC(multi-version concurrency control)机制,select操作不会更新版本号, 是快照读(历史版本);insert、update和delete会更新版本号,是当前读(当前版本)。
串行化串行化是指在事务中不论是读还是写,都会对查到或操作的数据进行加锁处理,虽然是最安全的,但是性能非常低。
间隙锁可重复读隔离级别下无法避免幻读,但是当使用间隙锁时,其他事务无法在间隙中进行插入操作,可避免幻读
临键锁临键锁是在间隙锁中间设置了=操作,锁是加在索引中,如果对非索引数据加锁,可能会变成表锁,阻塞所有的操作
InnoDB的行锁是针对索引加的锁,不是针对记录加的锁。并且该索引不能失效,否则都会从行锁升级为表锁。
锁优化建议
尽可能让所有数据检索都通过索引来完成,避免无索引行锁升级为表锁
合理设计索引,尽量缩小锁的范围
尽可能减少检索条件范围,避免间隙锁
尽量控制事务大小,减少锁定资源量和时间长度,涉及事务加锁的sql尽量放在事务最后执行
尽可能低级别事务隔离
3、MVCC多版本并发控制机制
Mysql在可重复读隔离级别下如何保证事务较高的隔离性,同样的数据在同一个事务里面查询到的结果相同,但是在不同的事务里面结果会有所不同。这是为什么呢,就是mysql的mvcc机制来保证的
undo日志版本链和readview机制
undo日志版本链指的是在一条数据被多个事务修改后,mysql底层会对这条数据保存修改前的undo回滚日志,用两个隐藏字段事务id和回滚指针来串联起来成一个版本链。
readview机制指的是在事务开启后,执行任何查询sql时会生成当前事务的一致性视图read-view。查询语句不会更新版本,而增删改都会更新数据的undo版本链。该视图在事务结束之前都不会变化(如果是读已提交隔离级别在每次执行查询sql时都会重新生成)。查询时,由执行查询时所有未提交事务id数组(数组里最小的id为min_id)和已创建的最大事务id(max_id)组成。
版本链比对规则
当查询到的事务id存在上图中绿色区间,代表数据是已经被提交的,所以数据可见,查到的是什么就是什么。如果存在与红色区间,那么指的是将来可能会提交的事务,所以数据不可见。假如在黄色区间就会有两种情况,假如事务id在区间内,说明事务操作的数据可能还未提交,所以不可见,需要根据版本链继续从回滚日志中查找,如果不在区间内,说明事务已经提交,所以可见,查到什么就是什么。
注意:begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个修改操作InnoDB表的语句, 事务才真正启动才会向mysql申请事务id,mysql内部是严格按照事务的启动顺序来分配事务id的
总结: MVCC机制的实现就是通过read-view机制与undo版本链比对机制,使得不同的事务会根据数据版本链对比规则读取 同一条数据在版本链上的不同版本数据
为什么Mysql不能直接更新磁盘上的数据而且设置这么一套复杂的机制来执行SQL了?
因为磁盘随机读写的性能是非常差的,所以直接更新磁盘文件是不能让数据库抗住很高并发的。 Mysql这套机制看起来复杂,但它可以保证每个更新请求都是更新内存BufferPool,然后顺序写日志文件,同时还能 保证各种异常情况下的数据一致性。 更新内存的性能是极高的,然后顺序写磁盘上的日志文件的性能也是非常高的,要远高于随机读写磁盘文件。 正是通过这套机制,才能让我们的MySQL数据库在较高配置的机器上每秒可以抗下几干的读写请求。
二、JVM:
对象会放在栈里面吗?
jvm创建对象的时候会做逃逸分析,如果对象没有逃出方法作用范围,那就会尝试放到栈里面
test1逃出方法作用范围,因为new了之后返回了,就代表别的地方可能会用到。
但是test这个new了之后,方法走完就没了,这种就没逃出方法作用范围
因为方法执行完栈帧就会被回收,所以test这种情况如果对象放在栈帧里面,会和栈帧一起回收,不用等gc
并不是所有对象存放在堆区,有的一部分存在线程栈空间
java类最多能实现多少接口?
Class文件里只用两个字节来对接口计数,16个1二进制转十进制,大约在65535个
三、并发编程:
什么是上下文切换?
多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。
概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。
上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。
Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。
一、JMM&volatile:
JMM是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量
JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据
Java 内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问
但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,如下图所示:
工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成
JMM描述的是一组规则,通 过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性,有序性、可见性展开。
可见性:
volatile可以解决可见性,能否及时看到,主要原理是volatile会通知其他使用变量的线程,变量已经更新,让其他线程丢弃拷贝的变量重新读取。
synchronized和Lock也可以保证可见性,因为它们可以保证任一时刻只有一个线程能访问共享资源,并在其释放锁之前将修改的变量刷新到内存中。
由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见
变量经过volatile修饰后,对此变量进行写操作时,汇编指令中会有一个LOCK前缀指令,这个不需要过多了解,但是加了这个指令后,会引发两件事情:
- 将当前处理器缓存行的数据写回到系统内存
- 这个写回内存的操作会使得在其他处理器缓存了该内存地址无效
原子性:
可以通过 synchronized和Lock实现原子性。因为synchronized和Lock能够保证任一时刻只有一个线程访问该代码块。
synchronized() 原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。
volatile不保证原子性:
首先需要了解的是,Java中只有对基本类型变量的赋值和读取是原子操作,如i = 1的赋值操作,但是像j = i或者i++这样的操作都不是原子操作,因为他们都进行了多次原子操作,比如先读取i的值,再将i的值赋值给j,两个原子操作加起来就不是原子操作了。
有序性:
在程序执行的时候,默认是按照代码的顺序来执行的,但是cpu会根据程序运行的效率来对指令进行重新排序,一般来说这样重排序不会影响正常的逻辑,但是不排除个别情况,或者并发量高的时候会有个别线程会重排序
volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱 序执行的现象,关于指令重排优化前面已详细分析过,这里主要简单说明一下volatile是如 何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)。
volatile是Java并发的轻量级锁机制
为什么除了空循环以外其他的都会执行到log语句
说是线程是基于时间片轮询的,然后空循环的优先级是最高的,发现是空循环,他就不会管任何事,直接空循环下去,但是只要中途做了操作,那这个线程拿到的时间片就可能会失效,然后你失效了就得重新读取,就可能会读取到新的值
二、synchronized原理详解:
synchronized是jvm内置锁,隐式锁
reentrantluck是Java代码实现的显示锁
1、如何解决线程并发安全问题?
实际上在所有并发问题上都是通过序列化访问资源来实现的,就是同一时间只能有一个线程访问资源。
2、加锁的方式:
1、同步实例方法(创建实例才能调用的方法),锁是当前实例对象
2、同步类方法(static修饰的方法),锁是当前类对象
3、同步代码块,锁是括号里面的对象
synchronized是基于JVM中的内部对象monitor实现的,基于进入与退出时添加monitorenter和monitorexit。监视器锁的实现依赖于操作系统的Mutex luck(互斥锁)。这是一个重量级锁,性能比较低,原因是因为jvm虚拟机是运行在操作系统用户态中的,而Mutex luck是存在与内核态,在用户态和内核态交互的时候性能是十分低下的。
一般同步的对象在字节码层面会在前后加上关键字,而同步的方法则会在开始时加上ACC_SYNCHRONIZED标识符。
两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度
锁的标识放在对象头中的markword中
3、jdk1.6以后的锁升级过程:
偏向锁:
偏向锁的意思就是在绝大多数情况下都不会存在多线程竞争,并且多次都只有一个线程获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁,在这种时候就会自动进入偏向锁状态,当一个线程获取锁的时候就会将锁设为对这个线程的偏向模式,当这个线程再次访问这个锁的时候,锁不会进行任何同步操作,即获取锁的过程
创建对象的时候默认是懒加载偏向锁的,刚刚创建不会加载,过了一段时间后会加载,即使没有线程访问,也会加载。这时的偏向锁是匿名偏向,是一种可偏向状态
轻量级锁:
轻量级锁的意思就是在多个线程访问的时候,前提是多个线程交替访问,不会同时抢占资源
自旋锁(和轻量级锁是一个东西):
自旋锁的意思是在轻量级锁的基础上,存在线程之间抢占资源的问题,但是访问资源和释放资源的时间不会很长,在很短的时间内访问并释放,这时候锁会启用一种自旋的状态,就是在拿不到锁的时候进入空循环状态,大约循环50-100次,在经过若干次循环后,如果得到锁,就顺利进入临界区,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,如果等待时间大于这个最大范围,就会默认为需要升级重量级锁
重量级锁:
重量级锁是依赖对象内部的monitor锁来实现的,而monitor又依赖操作系统的MutexLock(互斥锁)来实现的,所以重量级锁也称为互斥锁
锁消除:
Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。JVM会自动将其锁消除。锁消除的依据是逃逸分析的数据支持。锁消除,前提是java必须运行在server模式(server模式会比client模式作更多的优化),同时必须开启逃逸分析
Object object1 = new Object();
synchronized (object1){
System.out.println();
}
//在上述代码中,同一个方法内创建对象,然后进行加锁实际上是没有意义的,因为每个进入方法的线程都会创建一个自己的对象,就不存在竞争关系,Java就会对此情景进行锁的消除
锁粗化:
synchronized (object){
System.out.println("");
}
synchronized (object){
System.out.println("");
}
synchronized (object){
System.out.println("");
}
//在上述代码中,在同一个方法内对同一个对象加锁,实际上并不存在竞争关系,只不过每次用锁都需要进出对象头,这时Java会对此场景进行锁粗化,如下所示=======》
synchronized (object){
System.out.println("");
System.out.println("");
System.out.println("");
}
逃逸分析:
一、同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
二、将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。(详情见上方jvm解析)
三、分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存, 而是存储在CPU寄存器中
三、AQS原理分析:
1、AQS思想:
假如让我们自己实现加锁解锁的过程,实际上就是在加锁后对后面进来的线程进行自旋,然后判断是否加锁成功来进入上锁的逻辑,但 是线程多的时候一直自旋会产生cpu的浪费,这时候我们就需要用到一个缓冲区来存放这些自旋中的线程,将加锁失败的线程放入队列中,使用LockSupport.park();阻塞线程。详情如下:
ReentrantLock lock = new ReentrantLock(true);
//3个线程
//T0 T1 T2
lock.lock();//加锁
while(true){
if(cas加锁成功){
break;//跳出循环执行逻辑
}
//Thread.sleep(5000);线程多的时候一直自旋会产生cpu的浪费
HashSet.add(Thread);//将加锁失败的线程放入队列中
LockSupport.park();//使用此方法使加锁失败的线程阻塞,这是汇编语言的指令
}
//执行逻辑。。。。
lock.unlock();//解锁
Thread t = HashSet.get();//在解锁后拿到队列中存放的线程
LockSupport.unpark();//拿到先入队的线程唤醒,让他重新循环尝试加锁
Lock三大核心原理
自旋(尝试加锁),LockSupport(阻塞线程),CAS(原子改锁状态),QUEUE队列(存放加锁失败的线程)
AQS定义了一套多线程访问共享资源 的同步器框架,是一个==依赖状态(state)==的同步器
2、AQS的实现方式:
AQS的实现主要是通过定义内部类来继承AQS,列入ReentrantLock中的Sync类。
其中的加锁解锁方法都是需要子类实现的,AQS只提供这种思想
//Sync中的加锁方法
final void lock() {
acquire(1);
}
//AQS中的加锁方法
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();//实际上只是提供了加锁的思想,具体实现还得由子类进行
}
//回到子类Sync对此方法进行实现
protected final boolean tryAcquire(int acquires){
.....
}
AQS中的队列使用的是自定义的Node节点,这个对列是双向队列,其中有prev,next指向前后节点。有head,tail指向头尾节点。
同步等待队列
AQS当中的同步等待队列也称CLH队列,是FIFO先入先出线程等待队列,Java中的CLH 队列是原CLH队列的一个变种,线程由原自旋机制改为阻塞机制(为什么说JUC中的实现是基于CLH的“变种”,因为原始CLH队列,一般用于实现自旋锁。而JUC中的实现,获取不到锁的线程,一般会时而阻塞,时而唤醒。)
还有一个state来作为上锁的状态,还有一个thread来作为队列中存放的线程。
AQS继承的AbstractOwnableSynchronizer(AOS)中定义了一个当前占有锁的线程exclusiveOwnerThread。
//加锁
public void lock() {
sync.lock();
}
//加锁
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();//上方如果因为中断唤醒,会清除中断标志然后获取线程是否被中断的信息,在这里会再次打上可中断的标志,因为不希望线程被中断以后就在不能继续阻塞了
}
//公平锁尝试上锁的过程
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();//获取当前线程
int c = getState();//获取当前锁状态
if (c == 0) {//如果锁状态为未上锁,代表当前线程可以尝试上锁
if (!hasQueuedPredecessors() &&//判断Node对列中是否为空,主要判断head,tail指向的是否为空
compareAndSetState(0, acquires)) {//队列中为空则进行CAS的对锁状态的修改
setExclusiveOwnerThread(current);//将AOS中占有锁的线程设置为当前线程
return true;//返回true代表上锁成功
}
}
else if (current == getExclusiveOwnerThread()) {//当前锁状态为已上锁,则需要判断抢占的线程是不是同一个占有锁的线程
int nextc = c + acquires;//如果还是同一个线程,则同一个线程不用考虑原子问题,可以直接对锁状态继续+1
if (nextc < 0){
throw new Error("Maximum lock count exceeded");
}
setState(nextc);//设置当前锁状态
return true;//返回加锁正常
}
return false;//否则尝试加锁失败
}
//非公平锁尝试上锁
final void lock() {
if (compareAndSetState(0, 1))//非公平锁无视对列直接开始原子操作锁状态
setExclusiveOwnerThread(Thread.currentThread());//操作成功的将当前持有锁线程设置为自己
else
acquire(1);//修改锁状态失败则继续自旋阻塞等待唤醒
}
3、AQS加锁失败,入队阻塞与唤醒:
经过第二点所说的尝试加锁(tryAcquire(arg))后,成功的线程T1进入了逻辑语句中,并且将State设置为1,当前加锁线程设置为T1。
那么其他的线程则会进入同步等待对列,addWaiter(Node.EXCLUSIVE)。
Node节点有共享(SHARED)和独占(EXCLUSIVE)两种模式,在入队的时候默认是独占模式。
Waitestate节点的生命状态:信号量/等待状态
SIGNAL = -1 //可被唤醒
CANCELLED = 1 //代表出现异常,中断引起的,需要废弃结束
CONDITION = -2 //条件等待
PROPAGATE = -3 //传播
0 - //初始状态Init状态
//第一个节点入队
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);//新建一个待唤醒线程为当前线程的节点
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;//创建前置节点并赋值为尾节点(此时默认是null)Node pred = null;
if (pred != null) {//第一轮入队不进这个方法
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);//第一轮维护双向对列如下
return node;
}
//第一次入队需要初始化一个双向对列
private Node enq(final Node node) {//这是上方新建的第一个入队的节点 fn
for (;;) {//自旋
Node t = tail;//创建一个t节点为尾节点(第一次进入依然为null)t = null; //初始化链表以后,t赋值为尾节点node
if (t == null) { // Must initialize//第一次必须初始化一个链表
if (compareAndSetHead(new Node()))//将头节点赋值为新建的空节点node
tail = head;//并且将尾节点也赋值为此节点node
} else {
node.prev = t; //将第一个入队的节点的前节点设为node
if (compareAndSetTail(t, node)) { //将尾节点从刚刚的node改为入队的fn
t.next = node; //将初始化的头节点的后置节点设置为fn
return t;
}
}
}
}
//第二个线程加锁失败入队
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);//新建一个待唤醒线程为当前线程的节点fn2
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;//创建前置节点并赋值为尾节点Node pred = fn;
if (pred != null) {//第二轮入队pred不为空,进入
node.prev = pred;//将第二次入队的节点fn2的前节点设置为fn
if (compareAndSetTail(pred, node)) {//将尾节点设置为本次入队的fn2
pred.next = node;//将fn的后置节点设置为fn2
return node;//设置成功返回当前节点fn2
}
}
enq(node);//并发入队时设置尾节点失败继续自旋设为抢占入队的节点fn3的后置节点
return node;
}
//入队成功后所有的节点返回准备阻塞
final boolean acquireQueued(final Node node, int arg) {//入队成功后的各个节点来到此方法进行阻塞
boolean failed = true;
try {
boolean interrupted = false;//默认设置中断为false
for (;;) {//自旋
final Node p = node.predecessor();//获取当前节点的前置节点
if (p == head && tryAcquire(arg)) {//如果前置节点是头节点,则可以再次尝试加锁(这里为了减少阻塞的次数)
setHead(node);//设置头节点为当前节点
p.next = null; // help GC 将原本头节点的后置节点设置为null,这时触发GC垃圾回收
failed = false;
return interrupted;//返回中断状态
}
if (shouldParkAfterFailedAcquire(p, node) &&//加锁失败或不是第一个节点则在这里进行改变等待状态,如下
parkAndCheckInterrupt())//修改等待状态后所有节点在此阻塞,还需判断是否线程由中断信号唤醒
//有参的park方法代表被中断的线程还可以继续被阻塞
//无参的park方法代表被中断的线程不可被阻塞
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
//加锁失败或不是第一个节点则在这里进行改变状态,如下
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;//获取前置节点的等待状态
if (ws == Node.SIGNAL)//初始一定是0,不走这里
/*
* This node has already set status asking a release
* to signal it, so it can safely park.
*/
return true;
if (ws > 0) {//大于0代表异常,需要将这个节点废除
/*
* Predecessor was cancelled. Skip over predecessors and
* indicate retry.
*/
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {//将前置节点等待状态设置为待唤醒
/*
* waitStatus must be 0 or PROPAGATE. Indicate that we
* need a signal, but don't park yet. Caller will need to
* retry to make sure it cannot acquire before parking.
*/
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
上面源码介绍完加锁失败后阻塞,那么唤醒操作在哪里呢,如下所示:
//释放锁
public void unlock() {
sync.release(1);
}
//尝试释放锁
public final boolean release(int arg) {
if (tryRelease(arg)) {//尝试释放锁,如下所示
Node h = head;//拿到头节点
if (h != null && h.waitStatus != 0)//如果头节点不为空,并且头节点的状态不是初始值,则开始唤醒线程
unparkSuccessor(h);//唤醒线程如下下所示
return true;//释放成功
}
return false;//释放失败
}
//尝试释放锁,如下所示
protected final boolean tryRelease(int releases) {//释放锁需要将state -1
int c = getState() - releases;//获取当前状态并且-1,因为到这里的只能是拿到锁的线程,所以不需要考虑原子性
if (Thread.currentThread() != getExclusiveOwnerThread())//如果进来的线程不是加锁的线程,则报错
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {//如果状态为0,则进入可加锁的状态
free = true;//释放锁成功
setExclusiveOwnerThread(null);//设置加锁的线程为null
}
setState(c);//更新AQS状态
return free;//返回是否释放成功标识
}
//唤醒线程如下下所示
private void unparkSuccessor(Node node) {//唤醒线程,,用头节点h进入证明是按照队列顺序唤醒的
/*
* If status is negative (i.e., possibly needing signal) try
* to clear in anticipation of signalling. It is OK if this
* fails or if status is changed by waiting thread.
*/
int ws = node.waitStatus;//获取头节点的等待状态ws
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);//如果wd小于0,则将头节点的wd设置为0
/*
* Thread to unpark is held in successor, which is normally
* just the next node. But if cancelled or apparently null,
* traverse backwards from tail to find the actual
* non-cancelled successor.
*/
Node s = node.next;//拿到头节点的后置节点s,也就是第一个节点
if (s == null || s.waitStatus > 0) {//如果第一个节点为空或者等待状态大于0,则代表异常
s = null;//将异常节点设为空
for (Node t = tail; t != null && t != node; t = t.prev)//拿到尾节点,当尾节点不是null也不是头节点时,从后往前遍历,将头节点的后置节点s设为下一个等待状态为初始值或者待唤醒的节点
if (t.waitStatus <= 0)
s = t;
}
if (s != null)//第一个节点没有问题的时候将他唤醒
LockSupport.unpark(s.thread);
}
Thread的各个中断方法:
//测试此线程是否已被中断。 线程的中断状态不受此方法的影响。
//由于线程在中断时不处于活动状态而被忽略的线程中断将通过此方法返回 false 来反映。
//返回:如果此线程已被中断,则为true ; 否则为false 。
public boolean isInterrupted() {//判断当前线程是否被中断
return isInterrupted(false);
}
//测试当前线程是否被中断。 通过该方法清除线程的中断状态。
//如果当前线程已被中断,则为true ; 否则为false 。
public static boolean interrupted() {
return currentThread().isInterrupted(true);
}
//当前线程中断
Thread.currentThread().interrupt();
4、Semaphore源码:
Semaphore是什么:
它的作用是控制访问特定资源的线程数目,底层依赖AQS的状态State,控制访问数目是多少,state边界就是多少。
构造方法:
public Semaphore(int permits)//permits 表示许可线程的数量
public Semaphore(int permits, boolean fair)//fair表示公平性,如果设为true的话,下次执行的线程会是等待最久的线程
重要方法:
//不支持中断的获取锁方法
public void acquire() throws InterruptedException
//唤醒线程方法
public void release()
//设定获取锁的等待时间,获取到返回true,没获取到返回false
tryAcquire(long timeout, TimeUnit unit)
//支持中断的获取锁方法,线程被中断不影响该线程下一次访问资源
semaphore.acquireUninterruptibly();
//不支持中断的获取锁方法
public void acquire() throws InterruptedException
//这里主要是看不支持的方法,支持的没有什么区别,只是省去了抛异常的步骤
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
if (tryAcquireShared(arg) < 0)//尝试获取资源,如下所示
doAcquireSharedInterruptibly(arg);//获取失败的线程入队阻塞,如下所示
}
//公平模式获取资源,实际上就是加上了判断队列是否为空
protected int tryAcquireShared(int acquires) {
for (;;) {
if (hasQueuedPredecessors())//判断等待队列是否为空
return -1;//不为空就返回-1,和锁的Boolean不同,-1代表的是资源已经没了
int available = getState();//获取当前还有的资源
int remaining = available - acquires;//还有的资源减去设置的访问资源量
if (remaining < 0 ||//资源不够就返回负数
compareAndSetState(available, remaining))//资源够就用cas把新的资源写入state
return remaining;
}
}
//获取失败的线程入队阻塞,如下所示
private void doAcquireSharedInterruptibly(int arg)
throws InterruptedException {
final Node node = addWaiter(Node.SHARED);//新建一个共享模式的节点入队
boolean failed = true;
try {
for (;;) {
final Node p = node.predecessor();//拿到当前节点的前置节点
if (p == head) {//如果前置节点是头节点
int r = tryAcquireShared(arg);//可以直接尝试获取资源
if (r >= 0) {//如果获取了资源还没获取完,这里不像独占锁需要获取成功才会继续执行,配和非公平锁,只要还存在资源就会继续执行唤醒
setHeadAndPropagate(node, r);//将获取了资源的节点设置为头节点,并按照剩余资源数进行传播,如下所示
p.next = null; // help GC 头节点设置空请求GC帮助
failed = false;//获取资源成功就无需走finally将状态设置为异常
return;
}
}
if (shouldParkAfterFailedAcquire(p, node) &&//将正常的节点设置为-1,不正常的断链请求GC帮助
parkAndCheckInterrupt())//将节点阻塞并判断是否中断
throw new InterruptedException();//被中断过的节点抛出异常
}
} finally {
if (failed)
cancelAcquire(node);//出现异常的节点设置状态为异常
}
}
//传播
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // Record old head for check below 获取此时头节点
setHead(node);//将获取了资源的节点设置为头节点
if (propagate > 0//如果资源数还有
|| h == null //头节点为空
|| h.waitStatus < 0 //头节点还是正常的
||(h = head) == null //再次赋值头节点为h并都是空
|| h.waitStatus < 0) {//再次判断赋值后的头节点是正常的
Node s = node.next;//获取头节点的下一个节点
if (s == null || s.isShared())//只要下一个是空的或者下一个是共享模式节点,就将头节点设置为0或广播
doReleaseShared();//走到这里的时候头节点的等待状态一定是0或是-3,
//因为要么是刚入队就直接获取资源,头节点默认是0,要么就是被唤醒的资源,头节点在唤醒方法中设为了0
//或是同时唤醒多个节点,一个节点成功设置头为0的时候,唤醒的其他节点设置头节点为0失败,那就需要将头节点设置为-3广播
}
}
//唤醒方法以及资源存在时执行的继续唤醒
private void doReleaseShared() {
for (;;) {
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))//设置当前头节点状态为初始
continue; // loop to recheck cases//设置失败则继续循环
unparkSuccessor(h);//设置成功唤醒头节点的下一个节点
}
else if (ws == 0 &&//同样的头节点被别人设置为初始
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))//共同唤醒的操作则需要设置头节点为广播模式
continue; // loop on failed CAS//设置失败继续循环
}
if (h == head) // loop if head changed//只要头节点没有被人修改,就会继续循环唤醒
break;
}
}
5、CountDownLatch使用及应用场景例子
CountDownLatch是什么?
使一个线程等待其他线程完成各自的工作后再执行。例如,应用程序的主线程希望在负责启动框架服务的线程已经启动所有的框架服务之后再执行。
6、CyclicBarrier
栅栏屏障,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续运行
7、BlockQueue原理:
一个线程安全的先入先出的队列
BlockingQueue,是java.util.concurrent 包提供的用于解决并发生产者 - 消费者问题的最有用的类,它的特性是在任意时刻只有一个线程可以进行take或者put操作,并且BlockingQueue提供了超时return null的机制,在许多生产场景里都可以看到这个工具的身影
队列类型
-
无限队列 (unbounded queue ) - 几乎可以无限增长
-
有限队列 ( bounded queue ) - 定义了最大容量
public ArrayBlockingQueue(int capacity, boolean fair) {
if (capacity <= 0)
throw new IllegalArgumentException();
this.items = new Object[capacity];
lock = new ReentrantLock(fair);//创建一把非公平锁
notEmpty = lock.newCondition();//创建一个条件对象
notFull = lock.newCondition();//创建一个条件对象
}
往队列中添加消息:
public void put(E e) throws InterruptedException {
checkNotNull(e);//判断空
final ReentrantLock lock = this.lock;//拿到锁
lock.lockInterruptibly();//加不支持中断的锁
try {
while (count == items.length)//判断队列是否已满,满了就加入非满信号的条件队列
notFull.await();//加入条件队列
enqueue(e);//往队列中加消息
} finally {
lock.unlock();//释放锁
}
}
加入条件队列:
public final void await() throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();//如果线程中断就抛出异常
Node node = addConditionWaiter();//加入条件队列
int savedState = fullyRelease(node);//释放锁
int interruptMode = 0;
while (!isOnSyncQueue(node)) {//判断当前节点是不是在同步队列中,因为消费者消费后会通过signal方法将条件队列转移到同步队列中,所有可能此时存在同步队列中
LockSupport.park(this);//不在同步队列中就把当前节点阻塞
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
}
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)//如果在同步队列中就尝试唤醒,或者设置为可唤醒状态并阻塞
interruptMode = REINTERRUPT;
if (node.nextWaiter != null) // clean up if cancelled
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
}
加入条件队列
private Node addConditionWaiter() {
Node t = lastWaiter;//拿到条件队列尾节点
// If lastWaiter is cancelled, clean out.
if (t != null && t.waitStatus != Node.CONDITION) {//第一轮不走 第二轮判断状态是否是条件队列
unlinkCancelledWaiters();//消除所有状态不是条件状态的节点
t = lastWaiter;//重新拿到尾节点
}
Node node = new Node(Thread.currentThread(), Node.CONDITION);//构建一个条件状态的节点
if (t == null)//尾节点为空
firstWaiter = node;//将头节点指向构建的节点
else
t.nextWaiter = node;//尾节点不为空就把他的后置节点指向新构建的节点
lastWaiter = node;//尾节点也指向构建的节点
return node;//返回节点
}
释放锁:
final int fullyRelease(Node node) {
boolean failed = true;
try {
int savedState = getState();//拿到锁状态
if (release(savedState)) {//释放锁
failed = false;//未失败
return savedState;//返回锁状态
} else {
throw new IllegalMonitorStateException();
}
} finally {
if (failed)
node.waitStatus = Node.CANCELLED;//上方指向失败就将当前节点设置为异常
}
}
四、Atomic类&Unsafe魔法类:
1、Atomic
原子操作:
原子(atom)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为”不可被中断的一个或一系列操作”
在Atomic包里一共有12个类,四种原子更新方式,分别是原子更新基本类型,原子更新数组,原子更新引用和原子更新字段。Atomic包里的类基本都是使用Unsafe实现的包装类
原子类是一种无锁的保证原子性的操作,之前也有讲过,synchronized是jvm内置的锁,在升级重量级锁之后就等于从jvm的用户态转移到内核态了,效率起始是十分低的。所以jdk1.5以后引入了无锁的原子操作类Atomic。这个类主要的思想就是CAS。
这个就是比较和替换,对比修改时的元素值和读取的元素值是否相同,不相同就代表被人修改过,相同才能继续修改。这主要是引用unsafe类中的native方法。其中有个参数是偏移量offset。偏移量是属性在实体中在当前实例对象的偏移位置,这个偏移量就是为了能直接找到属性的位置来进行比较,因此也不需要锁
AtomicInteger分析
基于硬件原语-CMPXCHG实现原子操作cas
do {
oldvalue = this.getIntVolatile(var1, var2);//读AtomicInteger的value值
///valueOffset---value属性在对象内存当中的偏移量
} while(!this.compareAndSwapInt(AtomicInteger, valueOffset, oldvalue, oldvalue + 1));
return var5;
什么叫偏移量?
要用cas修改某个对象属性的值->,首先要知道属性在对象的内存空间的哪个位置,必须知道属性的偏移量
可以通过12的偏移量直接找到age属性的位置进行比较。
CAS和ABA的问题听过太多次了,笔记里面就不再做赘述。
2、UnSafe类
Unsafe是位于sun.misc包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升Java运行效率、增强Java语言底层资源操作能力方面起到了很大的作用。但由于Unsafe类使Java语言拥有了类似C语言指针一样操作内存空间的能力,这无疑也增加了程序发生相关指针问题的风险。在程序中过度、不正确使用Unsafe类会使得程序出错的概率变大,使得Java这种安全的语言变得不再“安全”,因此对Unsafe的使用一定要慎重。会绕过Jvm直接操作内存,所以不归Java管理,不再安全。
如何获取Unsafe实例?
1、从getUnsafe方法的使用限制条件出发,通过Java命令行命令-Xbootclasspath/a把调用Unsafe相关方法的类A所在jar包路径追加到默认的bootstrap路径中,使得A被引 导类加载器加载,从而通过Unsafe.getUnsafe方法安全的获取Unsafe实例。
java Xbootclasspath/a:${path} // 其中path为调用Unsafe相关方法的类所在jar包路径
2、通过反射获取单例对象theUnsafe
public class UnsafeInstance {
public static Unsafe reflectGetUnsafe() {
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
return (Unsafe) field.get(null);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
五、hashmap源码:
hashmap在1.7之前使用的数据结构是数组和链表的结合。在1.8以后改为了数组,链表,红黑树的结合。
new HashMap();如果不写构造参数,默认大小16
如果说:写了初始容量:11 ?hashmap的容量就是11?
并不是的,因为hashmap的容量规定是要2的整数次幂,所以在传入其他值会通过算法修改成接近传入的容量,并且还是2的整数次幂
数组所有的元素位是否能够100%被利用起来?
不一定,hash碰
引入链表结构解决hash冲突,采用头部插入链表法(1.7),链表时间复杂度O(n)
/** * The default initial capacity - MUST be a power of two. */必须是2的指数幂?
roundUpToPowerOf2(size),强型将非2的指数次幂的数值转化成2的指数次幂
怎么转化?
1、必须最接近size,11
2、必须大于=size,
3、是2的指数次幂
16
size = 17,capacity = 32
为什么一定要转成2的指数次幂? 为了更加均匀的将hash值排列在数组中,2的整数次幂在32位2进制中只有一个1,其他都是0
计算索引:int i = indexFor(hash, table.length);
static int indexFor(int h, int length) {//计算哈希索引
// key.hashCode % table.lenth
return h & (table.lenth-1);//为什么要用lenth-1,因为-1后2进制数会退1,可以更加均匀的排列在数组中,而且位运算比&效率高了差不多27倍
}
h =
0001 0101 0111 0010 1111
0001 0101 0000 0010 0000
16
0
0000 0000 0000 0000 1111 16-1=15
0000 0000 0000 0000 1010
0-15
bit位运算:1815ms
mod取模运算:22282
效率差10倍
HashMap扩容,
当前hashmap存了多少element,size>=threshold
threshold扩容阈值 = capacity * 扩容阈值比率 0.75 = 16*0.75=12
扩容怎么扩?
扩容为原来的2倍。
转移数据
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);//再一次进行hash计算?
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
链表成环,死锁问题
六、ConcurrentHashMap:
hashmap的是属于线程不安全的,因为在put数据的时候可能会出现多线程同时给一个hash桶中存放数据,这样可能导致数据被挤掉,丢失的情况。这种时候就引入了ConcurrentHashMap,他会在写入数据的时候加锁,拿出数据的时候不加锁,所以不会导致数据丢失,是线程安全的。当然,和普通hashmap一样,1.7版本的jdk和1.8版本的jdk有着天壤之别。
上一章我们知道1.8版本的hashmap是在链表长度大于8的时候会升级为红黑树,那么这个8是怎么来的呢,因为hash算法以及索引计算的时候会取h & (table.lenth-1)。基本啥会均匀的分布在数组中,每一个节点的链表数据长度大于8的几率是非常小的,看源码注释可以知道
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006这里可以知道长度等于8的几率是千万分之6,所以大于8的几率可想而知,如果是不是人为干预导致哈希碰撞或者数据量十分庞大,基本不会转变成红黑树,而会以扩容为主。
在jdk1.7和jdk1.8中的ConcurrentHashMap加锁方式也是有很大不同的。
并发安全控制
Java7 ConcurrentHashMap基于ReentrantLock实现分段锁
这里的主要思想是在插入数据的时候在hash节点上会引入一个新的节点,叫segment,他是继承于ReentrantLock实现的,假如多线程执行插入操作并且产生hash碰撞,在同一个槽位的时候,会去拿取segment的锁,然后在segment内部的一个小的hashentry上再次取hash值进行插入。但是1.7的锁力度较大,会将整个数组节点和下面新的hashentry还有下面的链表节点全部加锁,性能一般,这时就引入了Java8的新思想。
Java8中 ConcurrentHashMap基于分段锁+CAS保证线程安全,分段锁基于synchronized 关键字实现;
在Java8中则是先进行插入,插入以后在有其他线程插入数据并且和已插入的值hash碰撞在同一节点,则会将刚刚插入成功的节点加锁,让其余线程排队插入,这时锁的粒度仅仅在一个hash桶里面。
ConcurrentHashMap由三部分构成, table+链表+红黑树, 其中table是一个数组, 既然是数组, 必须要在使用时确定数组的大小, 当table存放的元素过多时, 就需要扩容, 以减少碰撞发生次数, 本文就讲解扩容的过程。扩容检查主要发生在插入元素(putVal())的过程:
一个线程插完元素后, 检查table使用率, 若超过阈值, 调用transfer进行扩容
一个线程插入数据时, 发现table对应元素的hash=MOVED, 那么调用helpTransfer()协助扩容。
在线程插入拿取锁之前还需要进行一次判断,来判断数组是否需要扩容或者处于扩容状态,假如需要扩容,则会拿取当前线程来帮助扩容,将数据迁移到新的数组中,这时最小帮助的节点数为16
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) { //table扩容
Node<K,V>[] nextTab; int sc;
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
// 根据 length 得到一个标识符号
int rs = resizeStamp(tab.length);
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {//说明还在扩容
//判断是否标志发生了变化|| 扩容结束了
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
//达到最大的帮助线程 || 判断扩容转移下标是否在调整(扩容结束)
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
// 将 sizeCtl + 1, (表示增加了一个线程帮助其扩容)
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
总结
table扩容过程就是将table元素迁移到新的table上, 在元素迁移时, 可以并发完成, 加快了迁移速度, 同时不至于阻塞线程。所有元素迁移完成后, 旧的table直接丢失, 直接使用新的table。
七、CopyOnWrite机制:
核心思想:读写分离,空间换时间,避免为保证并发安全导致的激烈的锁竞争。
1、CopyOnWrite适用于读多写少的情况,最大程度的提高读的效率;
2、CopyOnWrite是最终一致性,在写的过程中,原有的读的数据是不会发生更新的,只有新的读才能读到最新数据;
3、如何使其他线程能够及时读到新的数据,需要使用volatile变量;
4、写的时候不能并发写,需要对写操作进行加锁;
源码原理:
/*
* 添加元素api
*/
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1); //复制一个array副本
newElements[len] = e; //往副本里写入
setArray(newElements); //副本替换原本,成为新的原本
return true;
} finally {
lock.unlock();
}
}
//读api
public E get(int index) {
return get(getArray(), index); //无锁
}
八、Executor线程池原理:
线程
线程是cpu资源的最小单位,线程模型分为KLT模型与ULT模型,JVM使用的KLT模型,Java线程与OS线程保持1:1的映射关系,也就是说有一个java线程也会在操作系统里有一个对应的线程,而且对线程的阻塞等操作会进行上下文切换
NEW,新建
RUNNABLE,运行
BLOCKED,阻塞
WAITING,等待
TIMED_WAITING,超时等待
TERMINATED,终结
线程池
线程池顾名思义就是将线程放在一个池子里面统一管理,因为线程是一个稀缺的资源,每次创建销毁都需要对操作系统进行操作,从用户态到内核态切换,效率是十分低的,所以需要一个池来统一管理。
如果并发的请求数量非常多,但每个线程执行的时间很短,这样就会频繁的创建和销毁线程,如此一来会大大降低系统的效率。可能出现服务器在为每个请求创建新线程和销毁线程上花费的时间和消耗的系统资源要比处理实际的用户请求的时间和资源更多。
线程池优势
重用存在的线程,减少线程创建,消亡的开销,提高性能
提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
线程池重要属性
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int CAPACITY = (1 << COUNT_BITS) - 1;
ctl 是对线程池的运行状态和线程池中有效线程的数量进行控制的一个字段, 它包含两部分的信息: 线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount),这里可以看到,使用了Integer类型来保存,高3位保存runState,低29位保存workerCount
线程池存在5种状态
RUNNING = ‐1 << COUNT_BITS; //高3位为111
SHUTDOWN = 0 << COUNT_BITS; //高3位为000
STOP = 1 << COUNT_BITS; //高3位为001
TIDYING = 2 << COUNT_BITS; //高3位为010
TERMINATED = 3 << COUNT_BITS; //高3位为011
线程池的创建
public ThreadPoolExecutor(int corePoolSize,//核心线程数
int maximumPoolSize,//最大线程数
long keepAliveTime,//非核心线程空闲时间
TimeUnit unit,//时间单位
BlockingQueue<Runnable> workQueue,//任务阻塞队列
ThreadFactory threadFactory,//线程工厂
RejectedExecutionHandler handler)//拒绝策略,线程池提供了4种策略
1、AbortPolicy:直接抛出异常,默认策略;
2、CallerRunsPolicy:用调用者所在的线程来执行任务;
3、DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
4、DiscardPolicy:直接丢弃任务
线程池原理
线程池监控
public long getTaskCount() //线程池已执行与未执行的任务总数
public long getCompletedTaskCount() //已完成的任务数
public int getPoolSize() //线程池当前的线程数
public int getActiveCount() //线程池中正在执行任务的线程数量
源码分析
public void execute(Runnable command) {
if (command == null)//执行任务为空
throw new NullPointerException();//抛出空指针异常
int c = ctl.get();//获取线程池的基础属性 clt记录着runState和workerCount
if (workerCountOf(c) < corePoolSize) {//判断当前线程数是否小于核心线程 workerCountOf方法取出低29位的值,表示当前活动的线程数;
if (addWorker(command, true))//小于的话就创建工人执行当前任务,创建的是核心线程,这个工人如下所示
return;
c = ctl.get();
}
if (isRunning(c) && workQueue.offer(command)) {//判断线程池当前状态是否正常,并且将任务放入任务队列
int recheck = ctl.get();//获取最新的线程基础属性
if (! isRunning(recheck) && remove(command))//如果线程池状态异常,就将该任务从队列中删除
reject(command);//并且执行拒绝策略
else if (workerCountOf(recheck) == 0)//如果非核心线程等于0
addWorker(null, false);//创建非核心线程
}
else if (!addWorker(command, false))//直接创建非核心线程执行当前任务
reject(command);//创建执行失败就执行拒绝策略
}
execute方法执行流程如下:
private boolean addWorker(Runnable firstTask, boolean core) {
retry:
for (;;) {
int c = ctl.get();//获取线程池基础属性
int rs = runStateOf(c);//获取运行状态
/*
* 这个if判断
* 如果rs >= SHUTDOWN,则表示此时不再接收新任务;
* 接着判断以下3个条件,只要有1个不满足,则返回false:
* 1. rs == SHUTDOWN,这时表示关闭状态,不再接受新提交的任务,但却可以继续处理阻塞队列中已保存的任务
* 2. firsTask为空
* 3. 阻塞队列不为空
*
* 首先考虑rs == SHUTDOWN的情况
* 这种情况下不会接受新提交的任务,所以在firstTask不为空的时候会返回false;
* 然后,如果firstTask为空,并且workQueue也为空,则返回false,
* 因为队列中已经没有任务了,不需要再添加线程了
*/
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;
for (;;) {
int wc = workerCountOf(c);//获取活动的线程数
if (wc >= CAPACITY ||//如果活动的线程数大于等于最大容量或者
wc >= (core ? corePoolSize : maximumPoolSize))//活动线程数大于核心线程数或者非核心线程数
return false;//创建失败
if (compareAndIncrementWorkerCount(c))//添加一个工人数量
break retry;//跳出当前循环
c = ctl.get(); // Re-read ctl
if (runStateOf(c) != rs)//判断状态是否正常
continue retry;//不正常重新开始循环
// else CAS failed due to workerCount change; retry inner loop
}
}
boolean workerStarted = false;
boolean workerAdded = false;
Worker w = null;
try {
w = new Worker(firstTask);//创建一个工人,注意工人的线程任务也是自己
final Thread t = w.thread;//拿到工人需要执行的线程
if (t != null) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();//上锁
try {
int rs = runStateOf(ctl.get());
// rs < SHUTDOWN表示是RUNNING状态;
// 如果rs是RUNNING状态或者rs是SHUTDOWN状态并且firstTask为null,向线程池中添加线程。
// 因为在SHUTDOWN时不会在添加新的任务,但还是会执行workQueue中的任务
if (rs < SHUTDOWN ||
(rs == SHUTDOWN && firstTask == null)) {
if (t.isAlive()) // precheck that t is startable
throw new IllegalThreadStateException();
workers.add(w);//将创建的工人放进工作线程的集合中
int s = workers.size();//获取工作线程的总数
if (s > largestPoolSize)//如果最大线程数比工作线程数还少,则工作线程数就是最大线程数
largestPoolSize = s;
workerAdded = true;//创建成功标志
}
} finally {
mainLock.unlock();
}
if (workerAdded) {//创建成功就用当前工人的线程执行任务
t.start();
workerStarted = true;//工作开始标志
}
}
} finally {
if (! workerStarted)
addWorkerFailed(w);//创建失败或者未执行成功就从工作线程中删除,并且终止线程
}
return workerStarted;
}
Worker类
为什么worker继承了AQS却不用AQS的ReentrantLock来实现加锁解锁,因为worker重写的方法是不希望可重入的,而AQS的ReentrantLock是支持重入的,进行中的任务不希望被中断
之所以设置为不可重入,是因为我们不希望任务在调用像setCorePoolSize这样的线程池控制方法时重新获取锁。如果使用ReentrantLock,它是可重入的,这样如果在任务中调用了如setCorePoolSize这类线程池控制的方法,会中断正在运行的线程
final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;// 获取第一个任务
w.firstTask = null;
w.unlock(); // allow interrupts 释放锁的原因是因为需要设置为允许中断,将-1设置为0
boolean completedAbruptly = true;
try {
while (task != null || (task = getTask()) != null) {// 如果task为空,则通过getTask来从阻塞队列获取任务
w.lock();
if ((runStateAtLeast(ctl.get(), STOP) ||
(Thread.interrupted() &&
runStateAtLeast(ctl.get(), STOP))) &&
!wt.isInterrupted())
wt.interrupt();
try {
beforeExecute(wt, task);
Throwable thrown = null;
try {
task.run();//执行任务
} catch (RuntimeException x) {//执行中抛出异常则向外继续抛出
thrown = x; throw x;
} catch (Error x) {
thrown = x; throw x;
} catch (Throwable x) {
thrown = x; throw new Error(x);
} finally {
afterExecute(task, thrown);//方法为空,留给子类实现
}
} finally {
task = null;//捕捉到异常就继续执行
w.completedTasks++;
w.unlock();
}
}
completedAbruptly = false;//无异常会执行,有异常为true
} finally {
processWorkerExit(w, completedAbruptly);
}
}
processWorkerExit方法
private void processWorkerExit(Worker w, boolean completedAbruptly) {
// 如果completedAbruptly值为true,则说明线程执行时出现了异常,需要将workerCount减1;
// 如果线程执行时没有出现异常,说明在getTask()方法中已经已经对workeCount进行了减1操作,这里就不必再减了
if (completedAbruptly) // If abrupt, then workerCount wasn't adjusted
decrementWorkerCount();
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//统计完成的任务数
completedTaskCount += w.completedTasks;
//从workers中移除,也就表示着从线程池中移除了一个工作线程
workers.remove(w);
} finally {
mainLock.unlock();
}
//根据线程池状态进行判断是否结束线程池
tryTerminate();
int c = ctl.get();
/*
* 当线程池是RUNNING或SHUTDOWN状态时,如果worker是异常结束,那么会直接addWorker;
* 如果allowCoreThreadTimeOut=true,并且等待队列有任务,至少保留一个worker;
* 如果allowCoreThreadTimeOut=false,workerCount不少于corePoolSize。
*/
if (runStateLessThan(c, STOP)) {
if (!completedAbruptly) {
int min = allowCoreThreadTimeOut ? 0 : corePoolSize;
if (min == 0 && ! workQueue.isEmpty())
min = 1;
if (workerCountOf(c) >= min)
return; // replacement not needed
}
addWorker(null, false);
}
}
九、Future&ForkJoin:
Future
做个知识拓展:
平时的任务主要分为两种类型,CPU密集型和IO密集型。
在我们平时使用的线程池中,往往存在需要拿到返回结果的情况,因为异步执行的时候正常是和主线程不存在关联的,线程池执行自己的,主线程执行自己的。
上节课我们看过了,execute方法是没有返回值的,就等于需要线程池自己执行需要的逻辑。但是需要返回值的则需要使用submit方法。然后在下方逻辑执行完的时候可以submit.get();,阻塞主线程等待线程池异步执行完任务后拿到返回值继续执行主线程的逻辑,主要实现原理思路如下所示:
public <T> Future<T> submit(Callable<T> task) {
if (task == null) throw new NullPointerException();
RunnableFuture<T> ftask = newTaskFor(task);//关键点在这里,将任务封装成为一个自定义的Future任务
execute(ftask);//执行的是上方封装好的Future任务,所以会执行RunnableFuture的run方法,如下所示
return ftask;
}
public void run() {
if (state != NEW ||
!UNSAFE.compareAndSwapObject(this, runnerOffset,
null, Thread.currentThread()))
return;
try {
Callable<V> c = callable;
if (c != null && state == NEW) {
V result;
boolean ran;
try {
result = c.call();
ran = true;
} catch (Throwable ex) {
result = null;
ran = false;
setException(ex);
}
if (ran)
set(result);//关键点在这里,ran运行成功以后会执行set方法,而set方法会将阻塞的线程释放,如下所示
}
} finally {
runner = null;
int s = state;
if (s >= INTERRUPTING)
handlePossibleCancellationInterrupt(s);
}
}
private void finishCompletion() {
// assert state > COMPLETING;
for (WaitNode q; (q = waiters) != null;) {//将需要拿到返回结果的线程都存放在node节点中
if (UNSAFE.compareAndSwapObject(this, waitersOffset, q, null)) {
for (;;) {
Thread t = q.thread;
if (t != null) {
q.thread = null;
LockSupport.unpark(t);//如果阻塞队列中的线程不为空则唤醒当前线程
}
WaitNode next = q.next;
if (next == null)
break;
q.next = null; // unlink to help gc
q = next;
}
break;
}
}
done();
callable = null; // to reduce footprint
}
//而阻塞线程则需要在get方法中阻塞,如下所示
public V get() throws InterruptedException, ExecutionException {
int s = state;
if (s <= COMPLETING)
s = awaitDone(false, 0L);//关键点在这里,线程执行到这里的时候会将线程阻塞
return report(s);
}
ForkJoin
Fork/Join 框架是 Java7 提供了的一个用于并行执行任务的框架, 是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架
Fork/Jion特性:
- ForkJoinPool 不是为了替代 ExecutorService,而是它的补充,在某些应用场景下性能比 ExecutorService 更好。(见 Java
Tip: When to use ForkJoinPool vs ExecutorService )
- ForkJoinPool 主要用于实现“分而治之”的算法,特别是分治之后递归调用的函数,例如 quick sort 等。
- ForkJoinPool 最适合的是计算密集型的任务,如果存在 I/O,线程间同步,sleep() 等会造成线程长时间阻塞的情况时,最好配合使用 ManagedBlocker
工作窃取算法
- ForkJoinPool 的每个工作线程都维护着一个工作队列(WorkQueue),这是一个双端队列(Deque),里面存放的对象是任务
(ForkJoinTask)。
-
每个工作线程在运行中产生新的任务(通常是因为调用了 fork())时,会放入工作队列的队尾,并且工作线程在处理自己的工作队列时,使用的是 LIFO 方式(栈的方式),也就是说每次从队尾取出任务来执行。
-
每个工作线程在处理自己的工作队列同时,会尝试窃取一个任务(或是来自于刚刚提交到 pool 的任务,或是来自于其他工作线程的工作队列),窃取的任务位于其他线程的工作队列的队首,也就是说工作线程在窃取其他工作线程的任务时,使用的是FIFO方式。
-
在遇到 join() 时,如果需要 join 的任务尚未完成,则会先处理其他任务,并等待其完成。
-
在既没有自己的任务,也没有可以窃取的任务时,进入休眠
构造函数如下所示:
private ForkJoinPool(int parallelism,//并行度,默认情况下跟我们机器的cpu个数保持一致,使用Runtime.getRuntime().availableProcessors()可以得到我们机器运行时可用的CPU个数
ForkJoinWorkerThreadFactory factory,//创建新线程的工厂
UncaughtExceptionHandler handler,//线程异常情况下的处理器
int mode,
String workerNamePrefix) {
this.workerNamePrefix = workerNamePrefix;
this.factory = factory;
this.ueh = handler;
this.config = (parallelism & SMASK) | mode;
long np = (long)(-parallelism); // offset ctl counts
this.ctl = ((np << AC_SHIFT) & AC_MASK) | ((np << TC_SHIFT) & TC_MASK);
}
ForkJoin会在构建的时候初始化一个队列来存放任务,这个队列的大小是2的n次幂。分为基数和偶数队列,在提交任务线程提交任务的时候会将任务提交到偶数队列中,那么为什么不提交到基数队列呢,因为为了防止工作线程和提交线程在处理任务的时候冲突的问题,因为在提交任务的时候刚刚工作线程要去领取,会产生冲突。而ForkJoin的思想就是工作线程执行完本队列中的任务后会去扫描其他队列看看有没有没有完成的任务,这个时候就会将偶数队列闲置的任务领取到执行;
- 偶数索引位的任务属于共享任务,由工作线程去竞争获取,获取方式为FIFO
- 奇数索引位的任务从属于某个工作线程,其内部的任务通常由fork方法添加
- 工作线程可以去偷取其他工作队列中任务,偷取方式为FIFO
- 工作线程执行自身任务的取值方式默认为LIFO(可以修改成FIFO,但是偷取任务是FIFO的,为了减少竞争,我们最好使用默认的LIFO)
在同一个队列中假如任务的粒度还是比较大,这时就会去继续拆分,拆分的任务还是存在本队列,执行不到的就会被其他闲置线程领取执行。这个粒度的大小都是由我们可以自定义的
十、Disruptor
Disruptor是一个开源框架,研发的初衷是为了解决高并发下列队锁的问题,最早由LMAX(一种新型零售金融交易平台)提出并使用,能够在无锁的情况下实现队列的并发操作。
讲到生产消费模型,大家应该马上就能回忆起前面我们已经学习过的BlockingQueue 课程,里面我们学习过多种队列,但是这些队列大多是基于条件阻塞方式的,性能还不够优秀!
核心设计原理
Disruptor通过以下设计来解决队列速度慢的问题: 环形数组结构:
- 为了避免垃圾回收,采用数组而非链表。同时,数组对处理器的缓存机制更加友好(回顾一下:CPU加载空间局部性原则)。
- 在内存中有连续的一块存储区间,cpu会将这一块全部加载到缓存中执行,效率更高
元素位置定位:
- 数组长度2^n,通过位运算,加快定位的速度。下标采取递增的形式。不用担心index溢出的问题。index是long类型,即使100万QPS的处理速度,也需要30万年才能用完。
无锁设计:
- 每个生产者或者消费者线程,会先申请可以操作的元素在数组中的位置,申请到之后,直接在该位置写入或者读取数据。
数据结构使用的是环形数组
数组+序列号设计的优势是什么呢?
回顾一下我们讲HashMap时,在知道索引(index)下标的情况下,存与取数组上的元素时间复杂度只有O(1),而这个index我们可以通过序列号与数组的长度取模来计算得出, index=sequence % table.length。当然也可以用位运算来计算效率更高,此时table.length必须是2的幂次方(原理前面讲过)
四、Spring原理源码:
一、Spring源码整体脉络介绍:
IOC控制反转是思想:用来解决层与层之间,类与类之间的耦合关系,DI是实现的方式
一个类如何作为bean注入到ioc容器中:
- 通过xml配置文件或者@注解配置
- 通过加载spring上下文,applicationContext
- xml: new ClassPathXmlApplicationContext(“xml”) @: new AnnotationConfigApplicationContext
- getBean
BeanFactory是Spring顶层的核心接口,使用了简单工厂模式,用来生成bean
bean的实例化是通过反射或者工厂方法
一个bean定义就是一个bean
修改bean定义,使用BeanFactoryPostProcessor,Bean工厂的后置处理器,直接通过传入的bean工厂来用名字获取一个bean定义,然后当加载spring容器的时候,spring会调用所有实现了BeanFactoryPostProcessor接口的方法,方便对bean进行后置处理
注册bean定义,一个bean定义就是一个bean,可以在此创建新的bean,在spring容器加载的时候会调用所有实现了BeanDefinitionRegistryPostProcessor
上面两个都在在applicationContext中的,这样更能体现出来applicationContexe比BeanFactory的好,可以更加完美的维护spring的生态
BeanPostProcessor,bean的后置处理器,在bean的生命周期中会调用多次bean的后置处理器,主要是用于集成更多的框架,类似于AOP。
BeanFactory和applicationContext的区别:
applicationContext也是为了更好的服务我们的类,来读取创建bean,需要扩展更多的框架,兼容更多的东西,而不只是可以生产bean,而beanFactory仅仅是只能创建bean,顶多可以拿到beanDefended来定制化的生产bean
IOC的加载过程:
首先ioc的加载并不是一步到位的,必须通过一系列的步骤才行:
- 首先加载一个类或者xml先要注册这个bean,不论是@Bean还是xml写配置
- 然后先通过BeanDefinitionReader来读取xml的配置
- 然后用BeanDefinitionSacnner扫描读取配置文件中的包,其中配置注解的类都有哪些
- 在把那些类用BeanDefinitionRegistry注册到Bean定义中去
- bean定义是定制化的生产bean,具体思想下面会讲解
- BeanFactoryPostProcesser bean的后置处理器来做后置处理,例如aop动态代理或者与其他框架集成
- 通过bean定义去简单工厂中生产bean
- 然后就是bean的大致生命周期
- bean的生命周期最后初始化的时候会调用beanFactory的各种各样的Aware回调方法
- 填充属性的时候会产生一个循环依赖的问题
- bean的map就是把创建好的bean存放在单例池中的map一级缓存中
BeanDefinition是什么:
也是spring的顶层核心接口,封装了生产bean的一切原料,不论是bean的任何加载因素,懒加载或者作用范围等等都封装在bean定义中
不同的bean定义需要存放在一个容器beanDefinitionMap中
二、Ioc容器加载过程-Bean的生命周期:
BeanFactory和FactoryBean的区别:
BeanFactory上面也介绍过了,是spring一个顶层的核心接口,使用简单工厂模式来生产bean,主要职责就是生产一个bean
而FactoryBean不同,这是一个接口,实现这个接口的类需要重写getObject方法,在加载ioc流程中会判断注册的bean是不是FactoryBean这个特殊的bean,然后可以通过getObject方法来对bean进行修改。
https://www.processon.com/diagraming/6111d96a7d9c0806e4a0f422
三、AOP源码:
aop中最开始的依赖于接口实现的思路就是通过责任链设计模式实现的,通过递归维护总链路索引,然后一层一层调用完所有增强
后续注解方式实现的aop实际上是将每个增强方法,例如before或者after注解都会声明一个advisor
在ioc执行第一次后置处理器的时候会解析切面,aop的相关注解,按照切面的顺序一次解析,初始化bean的时候会调用一个bean的后置处理器,这个后置处理器会拿到所有解析到的advisor,然后通过自带的matches去匹配,匹配到以后会创建动态代理,这里用到了策略设计模式(不同的匹配调用不同的方法),拿到了所有的advisor后就是使用责任链的方式去调用各个增强方法