面试官你好,感谢贵公司给我一个面试的机会,我叫薛君宝,来自西安邮电大学计算机学院软件工程专业,现在是一名大三在校生,在大一期间我自学了c语言,加入了我们学校的实验室,选择java后端方向,先后学习了java基础,jdbc,mysql,ssm框架以及springboot框架,之后又学习了一些常用的中间件,比如redis和Kafka。在校期间完成了简历上的两个项目,第一个项目是在大二完成的一个校园订餐管理系统,实现了用户一键登录注册、点餐,餐厅管理菜单处理订单等功能。第二个项目是我在大三上学期的时候写的一个啾咪宠物托管平台,主要实现了用户购买和领养宠物,后台管理宠物以及订单和用户信息的功能。以上就是我的自我介绍。
Hash表:不支持范围查找,每次io只能取一个
二叉搜索树:依赖于它的平衡程度
AVL树:自平衡二叉搜索树,操作时间复杂度都是 O(logn)。需要频繁地进行旋转操作来保持平衡,每次io只能取一个
红黑树:自平衡二叉查找树,大致平衡。可能会导致树的高度较高,红黑树在插入和删除节点时只需进行 O(1) 次数的旋转和变色操作,每次io只能取一个
B+树:B 树也称 B-树,全称为 多路平衡查找树 ,B+ 树是 B 树的一种变体。
只有叶子节点存放 key 和 data,其他内节点只存放 key
页之间是双向链表,同一个页内的数据是单向链表
叶子节点的顺序检索很明显。
B+树的范围查询,只需要对链表进行遍历即可
多叉路衡查找树
数据页中有一个页目录,一个页目录有多个槽,每个槽对应一个分组最大的行记录,所有记录会分组,最小记录单独是一组,最大记录和最后一组在一块
先二分查找页,再二分查找到数据在哪个槽,再遍历槽内所有记录
聚簇索引:索引结构和数据一起存放的索引,如innodb的主键索引
优点:
依赖于有序的数据:如果索引数据不有序,就需要在插入的时候排序
更新代价大:如果索引列的数据被修改,对应的索引也会被修改,修改代价比较大,所以主键一般不可修改
非聚簇索引:索引结构和数据分开存放的索引,如二级索引,MyISAM里,不管主键还是非主键,都是非聚簇索引
优点:
缺点:
依赖于有序的数据:也依赖有序数据
可能会有二次查询(回表):最大缺点,查到索引对应的指针或主键后,可能还需要根据指针或主键到数据文件或表中查询
联合索引,mysql会根据联合索引的字段顺序,从左到右依次到查询条件匹配,如果查询条件存在与最左侧字段相匹配的字段,就会使用该字段过滤一批数据,直到联合索引全部字段匹配完成,或者在执行过程中遇到范围查询(如>或<)才会停止匹配,对于(>=、<=、BETWEEN、like)前缀匹配的范围查询,不会停止匹配,所以在创建联合索引的时候,可以将区分度高的字段放在最左边,可以过滤更多数据
比如联合索引(a,b),在a相同时b是有序的,在a=1 and b=2的情况下是可以走到索引的,而你执行a > 1 and b = 2时,a字段能用到索引,b字段用不到索引。因为a的值此时是一个范围,不是固定的,在这个范围内b值不是有序的,因此b字段用不上索引。
在非聚簇索引遍历过程中,对索引中包含的字段先做判断,过滤掉不符合条件的记录,减少回表次数。
将过滤条件推到存储引擎层处理,减少回表次数
比如select * from t where name like ‘a%’ and age=1,联合索引是name,age,传统会找联合索引以a开头,之后将所有以a开头的记录回表查询,开启索引下推后,会继续查找age=1的索引记录,之后再进行回表操作
创建了联合索引,但查询条件未遵守最左匹配原则
在索引列上计算、函数、类型转换等操作
以%开头的like
查询条件使用or,同时or的前后条件中有一个列没有索引,涉及的索引都不会被使用到
发生隐式转换:索引字段是字符串类型,查询时输入参数是整型的话,就走全表扫描。如果索引是整型,输入参数是字符串,就不会导致索引失效,因为mysql会自动把字符串转为数字(用函数)
物理日志,记录在某个数据页做了什么修改,循环写,边写边擦
innodb独有,让innodb有了崩溃恢复能力
mysql的innodb引擎使用redo log(重做日志)保证事务持久性
将写操作从随机写变为顺序写,写入redo log用了追加操作,所以磁盘操作是顺序写,而写入数据需要先找到写入位置,然后再写到磁盘,所以磁盘操作是随机写,因为顺序写高效,所以redo log写入磁盘开销更小
redo log buffer默认16MB
InnoDB 存储引擎为 redo log 的刷盘支持三种策略:
0:设置为 0 的时候,事务提交时不进行刷盘操作(容忍丢1秒)
MySQL挂了或宕机可能会有1秒数据的丢失。1:设置为 1 的时候,事务提交时都将进行刷盘操作(最安全,默认值)
redo log记录就一定在硬盘里,不会有任何数据丢失2:设置为 2 的时候,事务提交时都只把 redo log buffer 内容写入 page cache(折中,操作系统不宕机就不丢失1秒)
innodb有一个后台线程,每隔1秒,就会将redo log buffer的内容写到文件系统缓存(page cache)然后调用fsync刷盘
redolog刷盘但事务未提交
mysql在重启后执行崩溃恢复,发现redo日志包含未提交的事务的更改,就回滚此事务,确保数据的一致性
redo log 不止一个,以日志文件组形式存在
采用环形数组,写满后,写下一个文件
每次刷盘 redo log 记录到日志文件组中,write pos 位置就会后移更新。
每次 MySQL 加载日志文件组恢复数据时,会清空加载过的 redo log 记录,并把 checkpoint 后移更新。
write pos 和 checkpoint 之间的还空着的部分可以用来写入新的 redo log 记录。
如果 write pos 追上 checkpoint ,表示日志文件组满了,这时候不能再写入新的 redo log 记录,MySQL 得停下来,将缓冲池的脏页刷盘,再标记redo log哪些记录可以被擦除,擦除腾空间后 checkpoint 后移
用于数据备份和主从复制
binlog是逻辑日志,记录语句的原始逻辑,比如给这个字段+1,属于Server层
完成更新操作后,Server都会产生binlog日志,事务提交后,会将事务执行过程的所有binlog统一写入binlog文件
binlog会记录所有涉及更新数据的逻辑操作,并且是顺序写。
binlog 日志有三种格式,可以通过binlog_format参数指定。
事务执行时,先把日志写到binlog cache(Server层的cache),事务提交的时候,再把binlog cache写到binlog文件
一个事务的binlog不能被拆开,确保一次性写入,所以系统会给每个线程分配一个块内存作为binlog cache
每个线程都向page cache里write,再fsync到磁盘,虽然每个线程都有自己的binlog cache,但最终都写入同一个binlog文件
write和fsync的时机,默认是0。
避免两份日志之间的逻辑不一致的问题,内部XA事务,两阶段提交这个事务
事务提交后,redo log和bin log都要持久化到磁盘,但两个独立,造成逻辑不一致
过程:把redo log的写入拆分成准备和提交,中间穿插写入binlog
如果redolog写入磁盘,binlog没写入磁盘,或者redolog和binlog都写入磁盘,但还没commit标识,此时redo log都处于prepare状态,mysql重启后会扫描redo log文件,发现prepare状态的redo log后,会去bin log查看是否存在对应XA事务id,如果存在就说明redolog和binlog都刷盘了,就直接提交事务,不存在就说明只有redolog刷盘,binlog没刷盘,此时回滚事务
redo log 可以在事务没提交之前持久化到磁盘,但是 binlog 必须在事务提交之后,才可以持久化到磁盘。
缺点:
解决方法:组提交:多个事务提交时,会将多个binlog刷盘操作合并为一个,减少磁盘IO,prepare不变,commit分为三个阶段
每个阶段都有一个队列,每个阶段有锁保护,第一个事务是leader,此时,锁只针对每个队列进行保护,不再锁住提交事务的整个过程,锁粒度变小,提高并发效率
逻辑日志
缓存表数据和索引数据,磁盘数据加载到缓冲池,避免每次磁盘io,提高数据库的读写性能
缓冲池是连续的内存空间,里面的页是缓存页,有索引页、数据页、Undo页、插入缓存、自适应哈希索引、锁信息等
查询记录时会缓存整个页的数据
每个缓存页都有控制块(缓存页的表空间,页号,缓存页地址,链表节点),控制块和缓存页之间的部分为碎片空间(每个控制块都有缓存页,剩余不够一对控制块和缓存页的大小就是碎片)
空闲链表:空闲缓存页的控制块作为链表节点,有空闲链表后,每次从磁盘加载页到缓冲池后,就从空闲链表取出一个空闲的缓存页,把缓存页对应的控制块信息填上,再把缓存页对应的控制块从空闲链表删除
Flush链表:快速知道哪些缓存页是脏的,链表节点也是控制块,后台线程就可以遍历Flush链表,将脏页写入磁盘
主要涉及三个线程: binlog 线程、I/O 线程和 SQL 线程。
全同步复制
所有的从库都执行完成后才返回给客户端
半同步复制
从库写入日志成功后返回ACK确认给主库,主库收到至少一个从库的确认就认为写操作完成。
项目业务数据增多,业务发展迅速,单表数据量到1000w或20G、优化解决不了性能问题、IO瓶颈(磁盘、网络)、CPU瓶颈(聚合查询、连接太多)
垂直
水平
sql标准定义的隔离级别
innodb的可重复读可以很大程度解决幻读,有两种情况
多版本并发控制,多个并发事务同时读写数据的时候保证数据的一致性和隔离性,通过在每行维护多个版本的数据实现,当一个事务要修改数据时,MVCC会为该事务创建一个数据快照,而不是直接修改实际数据行
MVCC的实现依赖:隐藏字段、Read View、undo log。在内部实现中,InnoDB 通过数据行的事务id和 Read View 来判断数据的可见性,如不可见,则通过数据行的 回滚指针找到 undo log 中的历史版本。每个事务读到的数据版本可能是不一样的,在同一个事务中,用户只能看到该事务创建 Read View 之前已经提交的修改和该事务本身做的修改
到底清楚不
每行数据有三个隐藏字段
DB_TRX_ID(6字节):表示最后一次插入或更新该行的事务 id。此外,delete 操作在内部被视为更新,只不过会在记录头 Record header 中的 deleted_flag 字段将其标记为已删除DB_ROLL_PTR(7字节) 回滚指针,指向该行的 undo log 。如果该行未被更新,则为空Read View主要用来做可见性判断,里面保存了当前对本事务不可见的其他活跃事务
主要有以下字段:
m_low_limit_id:目前出现过的最大的事务 ID+1,大于等于这个 ID 的数据版本均不可见m_up_limit_id:活跃事务列表 m_ids 中最小的事务 ID,小于这个 ID 的数据版本均可见(已经提交)m_ids:Read View 创建时其他未提交的活跃事务 ID 列表。创建 Read View时,将当前未提交事务 ID 记录下来,后续即使它们修改了记录行的值,对于当前事务也是不可见的。m_ids 不包括当前事务自己和已提交的事务(正在内存中)m_creator_trx_id:创建该 Read View 的事务 ID当读取记录时,若该记录被其他事务占用或当前版本对该事务不可见,则可以通过 undo log 读取之前的版本数据,以此实现非锁定读
在 InnoDB 存储引擎中 undo log 分为两种:insert undo log 和 update undo log:
insert undo log:指在 insert 操作中产生的 undo log。因为 insert 操作的记录只对事务本身可见,对其他事务不可见,故该 undo log 可以在事务提交后直接删除。
update undo log:update 或 delete 操作中产生的 undo log。该 undo log可能需要提供 MVCC 机制,因此不能在事务提交时就进行删除。提交时放入 undo log 链表,等待 purge线程 进行最后的删除
不同事务或者相同事务的对同一记录行的修改,会使该记录行的 undo log 成为一条链表,链首就是最新的记录,链尾就是最早的旧记录。
ReadView中主要就是有个列表来存储我们系统中当前活跃着的读写事务,也就是begin了还未提交的事务。通过这个列表来判断记录的某个版本是否对当前事务可见。假设当前列表里的事务id为[80,100]。
a) 如果你要访问的记录版本的事务id为50,比当前列表最小的id80小,那说明这个事务在之前就提交了,所以对当前活动的事务来说是可访问的。
b) 如果你要访问的记录版本的事务id为90,发现此事务在列表id最大值和最小值之间,那就再判断一下是否在列表内,如果在那就说明此事务还未提交,所以版本不能被访问。如果不在那说明事务已经提交,所以版本可以被访问。
c) 如果你要访问的记录版本的事务id为110,那比事务列表最大id100都大,那说明这个版本是在ReadView生成之后才发生的,所以不能被访问。
这些记录都是去undo log 链里面找的,先找最近记录,如果最近这一条记录事务id不符合条件,不可见的话,再去找上一个版本再比较当前事务的id和这个版本事务id看能不能访问,以此类推直到返回可见的版本或者结束。
每次select 查询前都生成一个Read View (m_ids 列表),后续查询都利用这个Read View,通过这个Read View就可以在undo log版本链找到事务开始时的数据,所以每次查询的数据都一样第一次select 数据前生成一个Read View(m_ids 列表)s
RR级别通过MVCC和临建锁解决幻读
select...for update、插入、修改、删除等当前读:当前读下,读取的都是最新的数据,如果其他事务插入新的数据,并且刚好在事务查询范围里,就会产生幻读,使用临建锁防止幻读,执行当前读时,锁定读取到的记录的同时,锁定间隙,防止其他事务在查询范围插入数据。只要不插入,就不会幻读mysql没有使用传统lru缓冲池,因为有预读失效和缓冲池污染
预读失效:预读把页放到缓冲池,但没被访问
缓冲池污染:sql扫描大量数据时可能把缓冲池所有页替换出去,导致大量热数据被换出,同时新数据很少访问
连接器: 身份认证和权限相关(登录 MySQL 的时候)。
查询缓存: 执行查询语句的时候,会先查询缓存(MySQL 8.0 版本后移除,因为这个功能不太实用)。
分析器: 没有命中缓存的话,SQL 语句就会经过分析器,分析器说白了就是要先看你的 SQL 语句要干嘛,再检查你的 SQL 语句语法是否正确。
优化器: 按照 MySQL 认为最优的方案去执行。
执行器: 执行前会校验用户有没有权限,没有就会返回错误信息,有就会调用引擎的接口,返回接口执行的结果。
mysql分为server层和存储引擎层
定位:慢查询日志记录所有执行时间超过默认10秒的sql,要配置开启慢查询( slow_query_log = 1),查询命令show variables like 'long_query_time';
原因:explain或者desc加上查询sql
执行计划是一条sql在经过mysql查询优化器后具体的执行方式
select_type:查询的类型,主要用于区分普通查询、联合查询、子查询等复杂的查询,常见的值有:
type:查询执行的类型,描述了查询是如何执行的。所有值的顺序从最优到最差排序为:system > const > eq_ref > ref > fulltext > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > ALL
possible_keys:可能使用的索引,没有就为NULL
key:实际用的索引,NULL表示没用到索引
key_len:实际使用索引的最大长度,联合索引时可能是多个列的长度和
rows:估算找到所需记录需要读取的行数,越小越好
Extra:额外信息
表级锁和行级锁对比:
InnoDB 行锁是通过对索引数据页上的记录加锁实现的,MySQL InnoDB 支持三种行锁定方式:
不论是表级锁还是行级锁,都存在共享锁(Share Lock,S 锁)和排他锁(Exclusive Lock,X 锁)这两类:
快速判断是否可以对某个表使用表锁
意向锁是表级锁,共有两种:
意向锁是由数据引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享/排他锁之前,InooDB 会先获取该数据行所在在数据表的对应意向锁。
意向锁之间是互相兼容的。意向锁和共享锁和排它锁互斥(这里指的是表级别的共享锁和排他锁,意向锁不会与行级的共享锁和排他锁互斥)。
系统表空间(共享表空间):数据、索引页,insert buffer
临时表空间:存放用户创建的临时表和磁盘内部的临时表
undo表空间:
段、区、页、行
单个记录过大,一个页存不了一条记录,多的数据会存到溢出页
发生页溢出时,记录的真实数据处只保存该列的一部分数据,剩余数据在溢出页,在真实数据保存溢出页地址,这是Compact行格式的策略。Dynamic和Compressed都是把所有数据都保存在溢出页,然后只保存溢出页地址
延迟binlog和redolog的刷盘时机,降低磁盘IO的频率
表设计优化:数据类型,char(定长效率高)和varchar
索引优化:优化创建原则和索引失效
sql语句优化
主从复制、读写分离:解决数据库的写入,影响查询的效率
分库分表
limit优化:limit 10000,10 和 limit 0,10 性能差距大,因为执行逻辑是
从表读取10条记录到数据集中
重复第一步直到读到第10010调记录
根据offset抛弃前面10000条数
返回剩余10条数据
getBean() 两次,得到的是不同的 Bean 实例。Spring 框架中的 Bean 是否线程安全,取决于其作用域和状态。
单例时可能存在线程安全问题,针对有状态对象(如每个请求都可以修改一个成员变量),可以使用ThreadLocal
spring实例化时,根据 BeanDefinition 创建对象
构造函数
依赖注入
检查Aware相关接口并设置相关依赖(传入bean名称、bean类加载器、bean工厂实例)
BeanPostProcessor后置处理器在初始化方法之前处理
初始化方法
BeanPostProcessor后置处理器在初始化方法之后处理
销毁bean
两个或两个以上的bean互相持有对象,最终形成闭环,比如a依赖b,b依赖a
三级缓存解决大部分循环依赖
先实例化a,a生成一个对象工厂放到三级缓存,注入b的时候需要实例化b,此时b生成一个对象工厂放到三级缓存,b里需要注入a,通过三级缓存里a的对象工厂创建代理对象一个(也可以是指定的其他对象)放入二级缓存,再从二级缓存把a的代理对象注入到b,b创建成功后放入单例池,把b注入a后放入单例池
构造方法的循环依赖需要使用@Lazy进行懒加载,需要对象再进行bean对象的创建,因为bean的生命周期中构造函数第一个执行,框架不能解决构造函数的依赖注入
面向切面编程能将与业务无关,却为业务逻辑调用相同的逻辑封装起来。减少重复代码
基于动态代理,如果要代理的对象实现某个接口,就用jdk动态代理,其他使用cglib动态代理
AspectJ是AOP的框架
前置、后置、返回(方法结束返回结果值之前)、异常、环绕通知
@Order自定义切面顺序
BeanFactory或ApplicationContext 创建 bean 对象。
jdbcTemplate、hibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。Controller。
TransactionTemplate或者 TransactionManager 手动管理事务,实际应用中很少使用,但是对于你理解 Spring 事务管理原理有帮助。@Transactional 的全注解方式使用最多)事务传播行为是为了解决业务层方法之间互相调用的事务问题。
四种正确的行为
三种错误的行为,事务可能不会回滚:
基于AOP动态代理,一个方法添加@Transactional注解之后,spring会基于这个类生成一个代理对象,会把这个代理对象作为bean,使用这个代理对象的时候,如果有事务处理,就先把事务的自动提交关闭,再执行具体的业务,如果业务没有出现异常,spring会提交事务,如果出现异常,会回滚
DispatcherServlet:核心的中央处理器,负责接收请求、分发,并给予客户端响应。HandlerMapping:处理器映射器,根据 URL 去匹配查找能处理的 Handler ,并会将请求涉及到的拦截器和 Handler 一起封装。HandlerAdapter:处理器适配器,根据 HandlerMapping 找到的 Handler ,适配执行对应的 Handler;Handler:请求处理器,处理实际请求的处理器。ViewResolver:视图解析器,根据 Handler 返回的逻辑视图 / 视图,解析并渲染真正的视图,并传递给 DispatcherServlet 响应客户端DispatcherServlet前端控制器拦截请求。DispatcherServlet 根据请求信息调用 HandlerMapping处理器映射器 。HandlerMapping 根据 URL 去匹配查找能处理的 Handler( Controller 控制器) ,并会将请求涉及到的拦截器和 Handler 一起封装。返回处理器执行链DispatcherServlet 调用 HandlerAdapter处理器适配器执行 Handler 。Handler 完成对用户请求的处理后,会返回一个 ModelAndView 对象给DispatcherServlet,Model 是返回的数据对象,View 是个逻辑上的 View。ViewResolver视图解析器 把逻辑视图转换为真正视图返回给前端控制器View 返回给请求者(浏览器)SpringBoot 在启动时会扫描外部引用 jar 包中的META-INF/spring.factories文件,将文件中配置的类型信息加载到 Spring 容器,并执行类中定义的各种操作。对于外部 jar 来说,只需要按照 SpringBoot 定义的标准,就能将自己的功能装置进 SpringBoot。
引入第三方依赖只需要引入一个starter,再通过注解和一些配置就可以使用
核心注解@SpringBootApplication
@EnableAutoConfiguration:启用 SpringBoot 的自动配置机制
AutoConfigurationImportSelector类。实现了 ImportSelector接口,也就实现了这个接口中的 selectImports方法,该方法主要用于获取所有符合条件的类的全限定类名,这些类需要被加载到 IoC 容器中。getAutoConfigurationEntry()方法,这个方法主要负责加载自动配置类的。
META-INF/spring.factories(starter扩展包都有META-INF)@Configuration:允许在上下文中注册额外的 bean 或导入其他配置类@ComponentScan:扫描被@Component (@Service,@Controller)注解的 bean,注解默认会扫描启动类所在的包下所有的类 ,可以自定义不扫描某些 bean。原理:分页插件的基本原理是使用 MyBatis 提供的插件接口,实现自定义插件,在插件的拦截方法内拦截待执行的 sql,然后重写 sql,根据 dialect 方言,添加对应的物理分页语句和物理分页参数。
MyBatis 使用 JDK 的动态代理,为需要拦截的接口生成代理对象以实现接口方法拦截功能
MyBatis支持延迟加载。 延迟加载是指在查询对象时,只加载其基本属性,而将关联对象的数据暂不加载,等到真正需要使用关联对象时再去查询加载
它的原理是,使用 CGLIB 创建目标对象的代理对象,当调用目标方法时,进入拦截器方法,比如调用 a.getB().getName() ,拦截器 invoke() 方法发现 a.getB() 是 null 值,那么就会单独发送事先保存好的查询关联 B 对象的 sql,把 B 查询上来,然后调用 a.setB(b),于是 a 的对象 b 属性就有值了,接着完成 a.getB().getName() 方法的调用。这就是延迟加载的基本原理。
一级缓存的生命周期和SqlSession一致
每个SqlSession中持有了Executor,每个Executor中有一个LocalCache。当用户发起查询时,MyBatis根据当前执行的语句生成MappedStatement,在Local Cache进行查询,如果缓存命中的话,直接返回结果给用户,如果缓存没有命中的话,查询数据库,结果写入Local Cache,最后返回结果给用户。一级缓存只在数据库会话内部共享。
SqlSession向用户提供操作数据库的方法,但和数据库操作有关的职责都会委托给Executor。Local Cache的查询和写入是在Executor内部完成的,BaseExecutor成员变量之一的PerpetualCache,是对Cache接口最基本的实现,其实现非常简单,内部持有HashMap,对一级缓存的操作实则是对HashMap的操作,比如查询操作,会在执行的最后判断一级缓存是不是STATEMENT级别,如果是就清空缓存,所以STATEMENT级别的一级缓存无法共享localCache,其他操作会统一走update流程,执行update前会先清空localCache
只有会话提交或关闭后,一级缓存的数据才会转移到二级缓存
可以配置两个级别
多个sqlsession共享缓存,使用CachingExecutor装饰Executor,进入一级缓存的查询流程前,先在CachingExecutor进行二级缓存的查询
二级缓存开启后,同一个namespace下的所有操作语句,都影响着同一个Cache,即二级缓存被多个SqlSession共享,是一个全局的变量。使用时要配置开启二级缓存
两个sqlsession的相同查询,如果第一个查询没有提交,第二个就不会使用二级缓存,如果更新并提交,之前的缓存还是查不到
默认的设置中SELECT语句不会刷新缓存,insert/update/delte会刷新缓存
-XX:+PrintGCDetails 打印基本 GC 信息
-Xms<size>: 指定JVM的初始堆大小
-Xmx<size>: 指定JVM的最大堆大小
-Xss<size>: 指定每个线程的堆栈大小
-XX:+UseG1GC: 启用G1垃圾收集器
-XX:+PrintFlagsFinal命令来查看当前JVM的所有参数及其默认值。
这些命令在 JDK 安装目录下的 bin 目录下:
jps (JVM Process Status): 类似 UNIX 的 ps 命令。用于查看所有 Java 进程的启动类、传入参数和 Java 虚拟机参数等信息;
jstat(JVM Statistics Monitoring Tool): 用于收集 HotSpot 虚拟机各方面的运行数据;
jinfo (Configuration Info for Java) : Configuration Info for Java,显示虚拟机配置信息;
jmap (Memory Map for Java) : 生成堆转储快照;
jhat (JVM Heap Dump Browser) : 用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上查看分析结果;
jstack (Stack Trace for Java) : 生成虚拟机当前时刻的线程快照,线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合。
JConsole 是基于 JMX 的可视化监视、管理工具。可以很方便的监视本地及远程服务器的 java 进程的内存使用情况。
当前线程所执行的字节码的行号指示器
程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
主要是为了线程切换后能恢复到正确的执行位置
方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。
局部变量表 主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
操作数栈 主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。
动态链接 主要服务一个方法需要调用其他方法的场景。Class 文件的常量池里保存有大量的符号引用比如方法引用的符号引用。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用,这个过程也被称为 动态连接 。
方法返回地址
StackOverFlowError: 若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度./t 的时候,就抛出 StackOverFlowError 错误。
OutOfMemoryError: 如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。hotspot栈容量不能动态扩展,所以不会因为无法扩展导致oom,但如果申请空间就失败,还是会oom
为了保证线程中的局部变量不被别的线程访问到
虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。
此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的一半时,取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值”
java.lang.OutOfMemoryError: GC Overhead Limit Exceeded:当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx参数配置,若没有特别配置,将会使用默认值
方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。
当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)的 常量池表(Constant Pool Table) 。
字面量包括整数、浮点数和字符串字面量。常见的符号引用包括类符号引用、字段符号引用、方法符号引用、接口方法符号。
常量池表会在类加载后存放到方法区的运行时常量池中。
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
JDK1.7 之前,字符串常量池存放在永久代。JDK1.7 字符串常量池和静态变量从永久代移动了 Java 堆中。
主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存
JDK1.4 中新加入的 NIO(Non-Blocking I/O,也被称为 New I/O),引入了一种基于**通道(Channel)**与**缓存区(Buffer)*的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为*避免了在 Java 堆和 Native 堆之间来回复制数据。
直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI (是一种编程框架,使得Java虚拟机中的Java程序可以调用本地应用/或库,也可以被其他程序调用)的方式在本地内存上分配的。
堆外内存就是把内存对象分配在堆(新生代+老年代+永久代)以外的内存,这些内存直接受操作系统管理(而不是虚拟机),这样做的结果就是能够在一定程度上减少垃圾回收对应用程序造成的影响。
类加载检查:先去常量池定位这个类的符号引用,检查这个符号引用的类是否已经被加载过、解析和初始化,没有就先执行对应类加载
分配内存:对象内存大小在类加载完成后就确定了。分配方式有指针碰撞和空闲列表,选择方法由java堆是否规整决定,java堆是否规整由所采用的垃圾收集器是否带有压缩整理功能决定
java堆是否规整,取决于GC收集器是标记-清除还是标记-整理(也叫标记-压缩),复制算法内存是规整的
内存分配的并发问题
初始化零值:不包括对象头,保证对象的实例字段不赋初值就直接使用
设置对象头:对象是哪个类的实例,如何找到类的元数据信息,对象哈希码,对象GC分代年龄等,存放在对象头中,还会根据虚拟机当前运行状态不同,是否启用偏向锁等,会有不同的设置方式
执行init方法:从虚拟机角度看,对象已经产生了,但从java程序看,对象创建刚开始
在HotSpot虚拟机中,对象在内存布局分为3块:对象头,实例数据,对齐填充
对象头:包括两部分数据,第一部分用于存储对象自身的运行时数据(哈希码,GC分代年龄,锁状态标志等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针确定这个对象是哪个类的实例
实例数据:对象真正存储的有效信息,也就是程序定义的各种类型的字段
对其填充:非必须,仅仅占位作用,因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
对象的访问方式由虚拟机实现而定,主流有使用句柄,直接指针
句柄
如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。
直接指针
这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
HotSpot 虚拟机主要使用的就是这种方式来进行对象访问。
对象优先在Eden区分配:Eden区空间不足时,虚拟机发起Minor GC,发现无法存入Survivor空间时,会通过分配担保机制把新生代的对象转移到老年代
大对象直接进入老年代:避免为大对象分配内存时由于分配担保机制带来的复制降低效率
长期存活的对象进入老年代:对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
主要进行GC的区域:
部分收集(Partial GC):
老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
整堆收集 (Full GC):收集整个 Java 堆和方法区。
空间分配担保:确保Minor GC之前老年代本身还有容纳新生代所有对象的剩余空间。JDK 6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。
垃圾回收前要判断哪些对象已经死亡
引用计数法:给对象添加一个引用计数器,被引用就+1,失效就-1,0就不能被使用,实现简单,但存在对象循环引用问题
可达性算法分析:通过一系列GC Roots的对象为起点,从这些节点开始向下搜索,走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连,就证明对象不可用,需要被回收。可以作为GC Roots的对象:
虚拟机栈(栈帧中的本地变量表)中引用的对象
本地方法栈(Native方法)中引用的对象
方法区中类静态属性引用的对象
方法区中常量引用的对象
所有被同步锁持有的对象
即使不可达,但也不一定被回收,对象真正死亡至少经历两次标记,可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。
被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。
jdk9及以后各个类的finalize方法会被逐渐弃用移除
强引用
不回收
软引用
空间够就不回收,空间不够就回收,可以用来实现内存敏感的高速缓存,可以和ReferenceQueue联合使用,如果软引用引用的对象被回收,虚拟机就会把这个软引用加入到与之关联的队列
软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。
弱引用
只要发生垃圾回收,垃圾回收器线程扫描它所管辖的内存区域中,如果发现只具有弱引用的对象,就会直接回收,但垃圾回收器优先级很低的线程,发现会慢一点,弱引用也可以加入ReferenceQueue中,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。
虚引用
如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
虚引用主要用来跟踪对象被垃圾回收的活动
虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
方法区主要回收无用的类,无用类满足3条
满足后仅仅可以被回收,不是必然
首先标记不需要回收的对象,标记完成后统一回收掉没有标记的对象
问题:
为了解决标记清除的效率和碎片问题,将内存分为大小相同的两块,每次使用其中的一块,当这一块内存使用完成后,将还存活的对象复制到另一块,然后再把之前的空间全部清理掉,每次内存回收一半
问题:
根据老年代特点的标记算法,标记过程和标记清除一样,但不是直接回收可回收对象,而是将所有存活对象向一端移动,然后直接清理另外一端,因为多了整理,所以效率不高,适合老年代这种垃圾回收频率不高的场景
根据对象存活周期的不同
一般将java堆分为新生代和老年代
在新生代,可用选择标记复制算法,每次收集都有大量对象死去,只需要付出少量对象的复制成本就可用完成垃圾收集,但老年代对象存活几率比较高,而且没有额外空间进行分配担保,所以使用标记清除或标记整理
垃圾收集器是内存回收的具体体现
默认收集器
串行收集器,单线程
新生代标记复制
老年代标记整理
没有线程交互的开销,简单高效
应用:Client 模式下的虚拟机
本质是Serial收集器的多线程版本,除了使用多线程进行垃圾收集,其他行为无区别
新生代标记复制
老年代标记整理
除了Serial,只有它可用和CMS收集器配合工作
应用:Server 模式下的虚拟机
并行和并发概念补充:
使用标记复制的多线程收集器,看上去和ParNew一样
关注吞吐量(高效率利用CPU)CMS等垃圾收集器关注用户线程的停顿时间(提高用户体验)。吞吐量就是CPU中用于运行用户代码的时间和CPU总消耗时间的比值
Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。
新生代标记复制
老年代标记整理
JDK1.8 默认使用的是 Parallel Scavenge + Parallel Old
serial收集器的老年代版本,单线程
用途:
Parallel Scavenge收集器的老年代版本,使用多线程和标记整理算法,在注重吞吐量以及CPU资源的场合优先考虑Parallel Scavenge收集器和 Parallel Old收集器
以获取最短回收停顿时间为目标,注重用户体验
HotSpot第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程和用户线程基本上同时工作
标记清除算法
步骤:
初始标记和重新标记会stw
优点:并发收集,低延迟
缺点:对CPU资源敏感,无法处理浮动垃圾,标记清除产生大量碎片
面向服务器,极高概率满足GC停顿时间同时具备高吞吐量性能特征
步骤:
内存的回收是以region作为基本单位的;
G1收集器在后台维护优先列表,每次根据允许的收集时间,优先回收价值最大的Region。Region划分内存空间以及有优先级的区域回收方式,保证G1收集器在有限时间尽可能高的收集效率
jdk9成为默认垃圾收集器
和CMS中的ParNew和G1类似,采用标记复制,不过改进了算法,stw更少,java15可以使用
整个虚拟机应用线程暂停工作
确保标记的时候不会有对象的引用被修改
ClassFile {
u4 magic; //Class 文件的标志
u2 minor_version;//Class 的小版本号
u2 major_version;//Class 的大版本号
u2 constant_pool_count;//常量池的数量
cp_info constant_pool[constant_pool_count-1];//常量池
u2 access_flags;//Class 的访问标记
u2 this_class;//当前类
u2 super_class;//父类
u2 interfaces_count;//接口数量
u2 interfaces[interfaces_count];//一个类可以实现多个接口
u2 fields_count;//Class 文件的字段属性数量
field_info fields[fields_count];//一个类可以有多个字段
u2 methods_count;//Class 文件的方法数量
method_info methods[methods_count];//一个类可以有个多个方法
u2 attributes_count;//此类的属性表中的属性数
attribute_info attributes[attributes_count];//属性表集合
}
类从加载到虚拟机内存到卸载出内存,生命周期7个阶段
验证,准备,解析统称为连接
class文件需要加载到虚拟机后才能运行和使用,加载class文件分为3步,加载-连接-初始化,连接又分为验证-准备-解析
通过类加载器完成,具体哪个类加载器由双亲委派模型决定(但也可以打破)
每个类都有一个引用指向加载它的类加载器,但数组类不是通过ClassLoader加载的,而是JVM在需要的时候自动创建的,数组类通过getClassLoader()方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的。
加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。
连接的第一步,目的是确保Class文件的字节流包含的信息符合规范,运行后不会危害虚拟机安全
这一步耗费资源较多,也可以用参数关闭大部分类验证,缩短加载时间,4个阶段
后三个验证不会再读取操作字节流
方法区是JVM运行时数据区的一块逻辑区域,是各个线程共享的内存区域,方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
正式为类变量分配内存并设置初始值,这些内存都在方法区分配
public static final int value=111 ,那么准备阶段 value 的值就被赋值为 111虚拟机讲常量池的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。
初始化阶段是执行初始化方法 方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。
clinit是带锁线程安全
初始化阶段,只有6种情况,必须对类进行初始化
new 指令时会初始化类。即当程序创建一个类的实例对象。getstatic 指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。putstatic 指令时会初始化类。即程序给类的静态变量赋值。invokestatic 指令时会初始化类。即程序调用类的静态方法。java.lang.reflect 包的方法对类进行反射调用时如 Class.forname("..."), newInstance() 等等。如果类没初始化,需要触发其初始化。main 方法的那个类),虚拟机会先初始化这个类。MethodHandle 和 VarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用, 就必须先使用 findStaticVarHandle 来初始化要调用的类。3个要求
由JVM自带的类加载器加载的类不会被卸载,由自定义的类加载器加载的类可能被卸载
JDK 自带的 BootstrapClassLoader, ExtClassLoader, AppClassLoader 负责加载 JDK 提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。
类加载器是一个负责加载类的对象,ClassLoader是一个抽象类。给定类的二进制名称,类加载器会尝试定位和生成构成类定义的数据。典型策略是将名称转换为文件名,然后从文件系统中读取该名称的类文件
主要作用:加载java类的字节码.class文件到jvm中(在内存中生成一个代表该类的class对象),其实还可以加载其他东西(文本,图像等),但只讨论加载类
jvm启动并不会加载所有类,而是根据需要动态加载,用到的时候再加载
已经加载的类会放到classloader中,类加载时,会先判断类是否被加载过,加载过就直接返回,否则才加载,相同二进制名称的类只会被加载一次
jvm内置3个classloader
%JAVA_HOME%/lib目录下的 rt.jar(基础类库)、resources.jar、charsets.jar等 jar 包和类)以及被 -Xbootclasspath参数指定的路径下的所有类。%JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。AppClassLoader(应用程序类加载器):面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。java9时,扩展类加载器改名为平台类加载器,大部分都是平台类加载器加载的
我们可以对 Java 类的字节码( .class 文件)进行加密,加载时再利用自定义的类加载器对其解密。
BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader。如果我们要自定义自己的类加载器,很明显需要继承 ClassLoader抽象类。
protected Class loadClass(String name, boolean resolve):加载指定二进制名称的类,实现了双亲委派机制 。name 为类的二进制名称,resove 如果为 true,在加载时调用 resolveClass(Class> c) 方法解析该类。
protected Class findClass(String name):根据类的二进制名称来查找类,默认实现是空方法。
官方建议 ClassLoader的子类重写 findClass(String name)方法而不是loadClass(String name, boolean resolve) 方法。
不想打破双亲委派模型就重写findClass()方法,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。
ClassLoader类使用委托模型搜索类和资源
双亲委派模型要求除了启动类加载器之外,其他类加载器都有自己的父加载器
ClassLoader实例会在亲自查找类之前,将任务委托给其父类加载器
双亲委派模型是jdk官方推荐的,也可以打破,类加载器的父子关系一般不是以继承实现的,而是组合
public abstract class ClassLoader {
...
// 组合
private final ClassLoader parent;
protected ClassLoader(ClassLoader parent) {
this(checkCreateClassLoader(), parent);
}
...
}
组合优于继承
流程:
jvm判定两个java类是否相同:不仅看类的全名,还看加载该类的类加载器是否一样,都相同时类才相同
保证java程序的稳定运行,避免类的重复加载,保证核心api不被篡改
自定义类加载器,继承ClassLoader,如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法(因为在loadClass()方法里面,首先不会自己加载这个类,而是把这个请求委派给父类加载器完成)。
Tomcat 服务器为了能够优先加载 Web 应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器 WebAppClassLoader 来打破双亲委托机制。这也是 Tomcat 下 Web 应用之间的类实现隔离的具体原理。
如tomcat:因为tomcat是web服务器,上面可能有多个web应用,为了相互实现隔离,使用自定义类加载器,每个web应用程序对应也给类加载器,这样Tomcat中每个应用就可以使用自己的类加载器去加载自己的类,从而达到应用之间的类隔离,不出现冲突。另外,tomcat还利用自定义加载器实现了热加载功能。
无锁:无阻塞不同步,CAS实现原子操作,适用于并发高争抢少,开销较低
偏向锁:适用单线程,获取锁时将线程id标记在锁对象的对象头,适用频繁获取锁的单线程,开销较低,有竞争才释放锁,
轻量级锁:自旋等待,偏向锁撤销或多线程竞争时,CAS替换对象头,适用于短时间的锁竞争,开销中等。
重量级锁:阻塞,线程竞争激烈,适用操作系统的互斥机制,适用长时间的锁竞争,开销高
锁粗化:这是一种将多次连续的锁定操作合并为一次的优化手段。假如一个线程在一段代码中反复对同一个对象进行加锁和解锁,那么 JVM 就会将这些锁的范围扩大(粗化),即在第一次加锁的位置加锁,最后一次解锁的位置解锁,中间的加锁解锁操作则被省略
锁消除:这是一种删除不必要的锁操作的优化手段。在 Java 程序中,有些锁实际上是不必要的,例如在只会被一个线程使用的数据上加的锁。JVM 在 JIT 编译的时候,通过一种叫做逃逸分析的技术,可以检测到这些不必要的锁,然后将其删除。
锁升级:
锁降级:锁通常不会主动降级,但重量级锁在释放时可以降级为轻量级锁,但是jep有个锁降级的草案被撤回了,因为降级时安全暂停时间太长了,尝试了工作线程和空闲列表。现在在实验尝试不在安全点操作,
写锁可以降级为读锁,读锁不能升级为写锁,因为读锁升级为写锁会引起线程的争夺,因为写锁是独占锁
另外,还可能会有死锁问题发生。举个例子:假设两个线程的读锁都想升级写锁,则需要对方都释放自己锁,而双方都不释放,就会产生死锁。
悲观锁:用于写多读少,避免频繁失败重试影响性能
乐观锁:用于写少读多,避免频繁加锁影响性能,但乐观锁主要针对的对象是单个共享变量
总是假设最好情况,认为每次访问共享资源不会出现问题,无需加锁也无需等待,只是在提交修改的时候去验证对应资源是否被其他线程修改了(版本号或cas)
java.util.concurrent.atomic包下的原子变量类就是使用cas实现乐观锁
LongAdder 在高并发场景下会比 AtomicInteger 和 AtomicLong 的性能更好
代价就是会消耗更多的内存空间(空间换时间)
高并发下,乐观锁相比于悲观锁,不存在锁竞争导致的线程阻塞,也不会死锁,性能更好,但如果冲突频繁(写多),会频繁失败和重试,这样会影响性能,导致cpu飙升
LongAdder以空间换时间的方式解决大量失败重试问题
乐观锁一般会使用版本号机制或 CAS 算法实现
一个变量第一次读是A值,在准备赋值的时候还是A值,也不能说明它的值没有被其他线程修改,因为可能被其他线程改完之后又改回去了
解决方法:
在变量前追加版本号或时间戳
JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
cas经常自旋重试,不成功就一直循环,如果长时间不成功,会给cpu带来很大的执行开销
cas只对单个共享变量有效,当操作涉及多个共享变量时cas无效,但是从 JDK 1.5 开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。
JMM(Java 内存模型)主要定义了共享内存中多线程程序读写操作的行为规范,内存分为线程私有工作内存,线程共享主内存,线程之间交互需要主内存
指令重排序就是:系统在执行代码的时候不一定按照编写代码的顺序依次执行
常见指令重排序:
Java 源代码会经历 编译器优化重排 —> 指令并行重排 —> 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。
指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致
变量使用volatie修饰,在对这个变量进行读写操作的时候,会通过插入特定的内存屏障的方式禁止指令重排序
程序运行在操作系统上,操作系统屏蔽了底层硬件的操作细节,将各种硬件资源虚拟化,所以操作系统也同样需要解决内存缓存不一致的问题
操作系统通过内存模型定义一系列规范解决这个问题,不同操作系统内存模型不同,java语言是跨平台的,所以需要提供一套内存模型屏蔽系统差异,还有一个原因是jmm可以看作是java定义的并发编程相关的一组规范,抽象了线程和主内存的关系,规定了java从源代码到cpu可执行指令的转换过程要遵守的原则和规范,目的是为了简化多线程编程,增强程序可移植性
完全不一样
前一个操作的结果对于后一个操作可见,无论这两个操作是否在同一个线程
程序员追求易于理解和编程的强内存模型,遵守规则编码,编译器和处理器追求较少约束的弱内存模型,让他们尽力优化性能
happens-before 原则的定义
如果两个操作不满足上面任意一个规则,那么这两个操作可以重排序
程序员使用happens-before规则,规则的底层由JMM实现
一次操作或多次操作,要么所有操作全部执行不中断,要么都不执行
synchronized和各种Lock可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性
各种原子类利用cas操作保证原子操作
volatile可以保证变量的可见性,不能保证对变量操作是原子性的
自增操作变量++不是原子性,是一个复合操作,先读取变量值,再+1,再将变量值写回内存,即使变量使用volatile修饰,也不能保证原子性
当一个线程对共享变量进行修改,其他线程可以立即看到被修改的最新值
将变量声明为volatile,表明这个变量共享且不稳定,每次使用都到主存中读取
原始意义是禁用cpu缓存,变量使用volatile修饰,表示变量是共享且不稳定的,每次使用都要去主内存读取
内存模型和happens-before规则(监视器锁规则:对同一个监视器的解锁,happens-before于对该监视器的加锁,获取锁之前要先释放锁)
Lock前缀指令会把当前处理器缓存行的数据写回主内存同时会让其他cpu缓存了该内存地址的数据无效
因为指令重排序,所以代码的执行顺序不一定是编写代码的顺序
指令重排序可以保证串行语义一致,但没有保证多线程间的语义一致
volatile可以禁止指令重排序
管理一系列线程的资源池,处理任务直接从线程池获取线程,处理完成之后线程并不会立即被销毁
线程池一般用于执行多个不相关联的耗时任务,没有多线程时,任务顺序执行,用了线程池可以让多个不相关联的任务同时进行
在 Java 5 之后,通过 Executor 来启动线程比使用 Thread 的 start 方法更好,除了更易管理,效率更好(用线程池实现,节约开销)外,还有关键的一点:有助于避免 this 逃逸问题。
this 逃逸是指在构造函数返回之前其他线程就持有该对象的引用,调用尚未构造完全的对象的方法可能引发令人疑惑的错误。
三大部分:
任务(Runnable /Callable)
执行任务都必须实现这两个接口之一
任务的执行(Executor)
核心接口 Executor,以及继承自 Executor 接口的 ExecutorService 接口。ThreadPoolExecutor 和 ScheduledThreadPoolExecutor 这两个关键类实现了 ExecutorService ScheduledThreadPoolExecutor 实际上是继承了 ThreadPoolExecutor 并实现了 ScheduledExecutorService ,而 ScheduledExecutorService 又实现了 ExecutorService
异步计算的结果(Future)
Future 接口以及 Future 接口的实现类 FutureTask 类都可以代表异步计算的结果。
当我们把 Runnable接口 或 Callable 接口 的实现类提交给 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 执行。(调用 submit() 方法时会返回一个 FutureTask 对象)
ExecutorService.execute(Runnable command))或者ExecutorService.submit(Runnable task)execute(任务)流程
在 execute 方法中,多次调用 addWorker 方法。addWorker 这个方法主要用来创建新的工作线程,如果返回 true 说明创建和启动工作线程成功,否则的话返回的就是 false。ReentrantLock都会加锁(类中的全局锁)
Runnable vs CallableCallable在1.5被引入,为了处理Runnable不支持的用例
Runnable不会返回结果或抛出异常,Callable可以
Executors 可以实现将 Runnable 对象转换成 Callable 对象。(Executors.callable(Runnable task) 或 Executors.callable(Runnable task, Object result))。
execute() vs submit()execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;submit()方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功,并且可以通过 Future 的 get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法的话,如果在 timeout 时间内任务还没有执行完,就会抛出 `java.util.concurrent.TimeoutExceptionshutdown()VSshutdownNow()shutdown() :关闭线程池,线程池的状态变为 SHUTDOWN。线程池不再接受新任务了,但是队列里的任务得执行完毕。shutdownNow() :关闭线程池,线程池的状态变为 STOP。线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List。isTerminated() VS isShutdown()isShutDown 当调用 shutdown() 方法后返回为 true。isTerminated 当调用 shutdown() 方法后,并且所有提交的任务完成后返回为 true可重用固定线程数(最大线程数和固定线程数相同,就算最大线程数更大,也只会创建固定线程,因为任务队列是无界的(只有到达任务队列最大值才会创建额外线程))的线程池
FixedThreadPool 使用的是容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue(无界队列)
运行流程:
只有一个线程的线程池
SingleThreadExecutor 的 corePoolSize 和 maximumPoolSize 都被设置为 1,其他参数和 FixedThreadPool 相同,也使用LinkedBlockQueue
执行流程:
根据需要创建新线程
核心线程数为0,最大线程数int最大,意味着如果主线程提交任务的速度高于线程处理任务的速度,CachedThreadPool会不断创建新线程
执行流程:
给定的延迟后运行任务或定期执行任务,基本不用
无界阻塞队列
ScheduledThreadPool 是通过 ScheduledThreadPoolExecutor 创建的,使用的DelayedWorkQueue(延迟阻塞队列)作为线程池的任务队列。
延迟队列按照延迟时间长短对任务进行排序,采用堆,保证每次出队的任务都是当前队列中执行时间最靠前的,添加元素满了之后会自动扩容原来的一般,永远不阻塞,所以最多只会创建核心线程数的线程
ScheduledThreadPoolExecutor 继承了 ThreadPoolExecutor,所以创建 ScheduledThreadExecutor 本质也是创建一个 ThreadPoolExecutor 线程池
FixedThreadPool 和 SingleThreadExecutor:使用无界LinkedBlockingQueue,可能堆积大量请求导致OOMCachedThreadPool:同步队列SynchronousQueue,允许创建线程数量无限,可能创建大量线程导致OMMScheduledThreadPool 和 SingleThreadScheduledExecutor:无界阻塞队列DelayedWorkQueue,可能堆积大量请求导致OOMThreadPoolExecutor 3 个最重要的参数:
corePoolSize : 任务队列未达到队列容量时,最大可以同时运行的线程数量。maximumPoolSize : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。workQueue: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。ThreadPoolExecutor其他常见参数 :
keepAliveTime:线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁;unit : keepAliveTime 参数的时间单位。threadFactory :executor 创建新线程的时候会用到。handler :饱和策略。关于饱和策略下面单独介绍一下。如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时
ThreadPoolExecutor.AbortPolicy: 抛出 RejectedExecutionException来拒绝新任务的处理。默认ThreadPoolExecutor.CallerRunsPolicy: 调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。ThreadPoolExecutor.DiscardPolicy: 不处理新任务,直接丢弃掉。ThreadPoolExecutor.DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求。AbstractQueuedSynchronizer抽象队列同步器
AQS 就是一个抽象类,为了构建锁和同步器,提供了一些通用功能的实现
比如我们提到的 ReentrantLock,Semaphore,其他的诸如 ReentrantReadWriteLock,SynchronousQueue等等皆是基于 AQS 的。
如果请求的共享资源空闲,就将当前请求资源的线程设置为有效的工作线程,再将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是基于 CLH 锁 (Craig, Landin, and Hagersten locks) 实现的。
CLH锁是对自旋锁的改进,是一个虚拟的双向队列(不存在队列实例,只存在节点之间的关联关系),暂时获取不到锁的线程将被加入该队列,AQS将每条请求共享资源的线程封装成一个CLH队列锁的一个节点(Node),在CLH队列,一个节点代表一个线程,保存线程的引用(thread)、当前节点在队列的状态(waitStatus)、前驱节点(prev)、后继节点(next)
state 表示同步状态,由 volatile 修饰
ReentrantLock 为例,它的内部维护了一个 state 变量,用来表示锁的占用状态。state 的初始值为 0,表示锁处于未锁定状态。当线程 A 调用 lock() 方法时,会尝试通过 tryAcquire() 方法独占该锁,并让 state 的值加 1。如果成功了,那么线程 A 就获取到了锁。失败就会被加入到一个等待队列(CLH队列),直到其他线程释放该锁。如果线程A获取锁成功,释放锁之前,A线程可以重复获取该锁(state累加),可重入体现:一个线程可以多次获取同一个锁而不会阻塞
CountDownLatch将任务分为n个子线程执行,state初始化n,让n个子线程执行任务,每执行完一个子线程,就调用一次countDown(),该方法尝试用CAS让state-1,所有子线程执行完毕后,调用unpart(),唤醒主线程,主线程可以从await()返回,继续执行后续操作
两种方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可以同时执行,如Semaphore/CountDownLatch)
也有支持独占和共享两种方式的ReentrantReadWriteLock
synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,而Semaphore(信号量)可以用来**控制同时访问特定资源的线程数量。**其他线程都会阻塞,常用于限流
当初始的资源个数为 1 的时候,Semaphore 退化为排他锁。
Semaphore 有两种模式:。
acquire() 方法的顺序就是获取许可证的顺序,遵循 FIFO;Semaphore 通常用于那些资源有明确访问数量限制的场景比如限流(仅限于单机模式,实际项目中推荐使用 Redis +Lua 来做限流)。
原理:
Semaphore 是共享锁的一种实现,只有拿到许可证的线程才能执行,它默认构造 AQS 的 state 值为 permits(许可证的数量)
acquire时线程尝试获取锁,如果state>0就可以获取成功(尝试使用CAS修改-1,CAS失败了会循环重新获取最新的值尝试获取,如果获取失败就会创建一个Node节点加入阻塞队列,挂起当前线程,自旋判断state是否大于0),释放许可证成功之后,会唤醒同步队列中的一个线程,被唤醒的线程会尝试获取锁,失败就重新进入阻塞队列,挂起线程
Sync 是 CountDownLatch 的内部类 , 继承了 AbstractQueuedSynchronizer ,重写了其中的某些方法。并且,Sync 对应的还有两个子类 NonfairSync(对应非公平模式) 和 FairSync(对应公平模式)。
允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。
CountDownLatch 是一次性的,计数器只能初始化一次
CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 值为 count
线程调用countDown()时,其实是调用CAS操作减少state,state为0时,表示所有线程都调用了countDown方法,那么在CountDownLatch上等待的线程就会被唤醒并继续执行
调用await()等待(加锁)时,如果state不为0,证明任务还没有执行完毕,await()就会一直阻塞,即await()之后的语句不会被执行(main线程被加入等待队列也就是在CLH队列中),然后CountDownLatch会自旋CAS判断state==0,如果为0就会释放所有等待的线程,执行await()之后的语句
典型用法:
和 CountDownLatch 非常类似
CountDownLatch 的实现是基于 AQS 的,而 CycliBarrier 是基于 ReentrantLock(ReentrantLock 也属于 AQS 同步器)和 Condition 的。
可循环使用的屏障:让一组线程到达一个屏障(同步点)时被阻塞,直到最后一个线程到达屏障时才开门,所有被拦截的线程才会继续干活,每个线程调用 await() 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。
原理:
内部通过一个 count 变量作为计数器,count 的初始值为 parties 属性的初始化值,每一个线程到栅栏后就-1计数器,为0表示最后一个线程到达,就尝试执行任务
Atomic是指一个操作不可中断,多个线程在一起执行时,一个操作一旦开始,就不会被其他线程干扰
基本类型
使用原子的方式更新基本类型,优势:多线程环境使用原子类保证线程安全,比如对原子类型变量自增不用加锁。原理:主要利用CAS + volatile和native方法保证原子操作,避免synchronized的高开销,CAS原理是拿期望的值和原本的值作比较,如果相同就更新
Thread类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals
每个线程都有自己的 threadLocals,是ThreadLocal.ThreadLocalMap类型的Map,这个map里有一个内部类Entry, 它的key是ThreadLocal> k ,继承自WeakReference(key是ThreadLocal的弱引用)
ThreadLocal只是一个key,存储ThreadLocal是为了使用多个ThreadLocal时能找到自己想使用的ThreadLocal
如果entry是强引用,key是ThreadLocal是一个static的,ThreadLocal就一直不被gc则entry也不能gc,value也不能gc,就造成内存泄漏
在set过程中,如果遇到了key过期的Entry数据,实际上是会进行一轮探测式清理操作的
ThreadLocalMap的两种过期key数据清理方式:探测式清理(每次操作都会先检查当前线程的ThreadLocalMap中是否有已经过期的key,如果有,就清理掉这些key对应的value,并且把这些key从ThreadLocalMap中移除。)和启发式清理(在ThreadLocalMap中维护一个全局的清理阈值,当已经使用的entry数量超过了这个阈值时,就会进行一次清理操作。清理操作会遍历整个ThreadLocalMap,清理掉已经过期的key对应的value,并且把这些key从ThreadLocalMap中移除)。
启发式清理是在ThreadLocalMap的set, get, remove等操作之外进行的,探测式清理是在操作之内
启发式清理相对于探测式清理来说,可以更快地清理掉已经过期的key,但是会占用一定的系统资源。
staleSlot之前是否还有过期元素。),直到Entry为null结束,接着会以开始过期位置向后迭代,如果找到了key值相同的Entry数据,就更新Entry的值并交换初始过期位置元素,最后进行过期Entry清理工作,如果在向后迭代的过程中没有找到相同key的Entry(直到Entry为null都没找到)就创建新的Entry,替换初始过期位置,替换完成也是进行过期元素的清理工作如果在清理工作完成后,没清理任何数据,且size超过阈值(数组长度2/3),就进行rehash(),rehash()会先进行一轮探测式清理,清理过期key,清理完成后如果size >= threshold - threshold / 4,就执行真正的扩容
在set()方法最后,如果没清理任何数据,且当前size超过len的2/3,就执行rehash():
先进行探测式清理,清理完成之后,table中可能有一些key为null的Entry数据被清理掉,所以此时通过判断size >= threshold - threshold / 4 也就是size >= threshold * 3/4 来决定是否扩容。
扩容大小是之前的2倍,然后重新计算哈希
如果槽位有值但key值不同,就继续向后迭代查找,发现key为null时会触发一次探测式数据回收操作
ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。
ThreadLocalMap 中就会出现 key 为 null 的 Entry,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露,ThreadLocalMap在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后最好手动调用remove()方法
CompletableFuture 同时实现了 Future 和 CompletionStage 接口
boolean cancel(boolean mayInterruptIfRunning):尝试取消执行任务。boolean isCancelled():判断任务是否被取消。boolean isDone():判断任务是否已经被执行完成。get():等待任务执行完成并获取运算结果。get(long timeout, TimeUnit unit):多了一个超时时间。如果你不需要从回调函数中获取返回结果,可以使用 thenAccept() 或者 thenRun()。这两个方法的区别在于 thenRun() 不能访问异步计算的结果。
你可以通过 handle() 方法来处理任务执行过程中可能出现的抛出异常的情况。
那 thenCompose() 和 thenCombine() 有什么区别呢?
thenCompose() 可以链接两个 CompletableFuture 对象,并将前一个任务的返回结果作为下一个任务的参数,它们之间存在着先后顺序。thenCombine() 会在两个任务都执行完成后,把两个任务的结果合并。两个任务是并行执行的,它们之间并没有先后依赖顺序。通过 CompletableFuture 的 allOf()这个静态方法来并行运行多个 CompletableFuture 。
allOf() 方法会等到所有的 CompletableFuture 都运行完成之后再返回
Future 在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get() 方法为阻塞调用。
Java 8 才被引入CompletableFuture 类可以解决Future 的这些缺陷。CompletableFuture 除了提供了更为好用和强大的 Future 特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。
CompletableFuture 同时实现了 Future 和 CompletionStage 接口
并发:两个及两个以上线程在同一时间段执行
并行:两个及两个以上线程在同一时刻执行
关键在于是否同时执行
IO两个阶段:数据准备、内核空间数据复制到用户空间
同步:用户线程发起io操作后需要等待或者轮询内核io完成后才能继续执行
阻塞、非阻塞、io多路复用、信号驱动io都是同步,因为阶段2阻塞
阻塞可以是实现同步的一种手段!例如两个东西需要同步,一旦出现不同步情况,我就阻塞快的一方,使双方达到同步。
同步是两个对象之间的关系,而阻塞是一个对象的状态。
异步:用户线程发起io操作后用户线程仍需要继续执行,内核io操作完成后通知用户线程,或者调用用户线程注册的回调函数
阻塞:io操作需要彻底完成才返回用户空间
非阻塞:io操作被调用后立即返回一个状态值,无需等待io操作彻底完成
阻塞和非阻塞(线程内调用)
同步和异步(线程间调用)
Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:
start() 。start()等待运行的状态。线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。
在操作系统层面,线程有 READY 和 RUNNING 状态;而在 JVM 层面,只能看到 RUNNABLE 状态
JVM没有区分这两种状态是因为线程切换太快了,没必要区分,时间分片
wait()方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态。synchronized 方法/块或者调用 wait 后(被 notify)重新进入 synchronized 方法/块,但是锁被其它线程占有,这个时候线程就会进入 BLOCKED(阻塞) 状态。任务从保存到再加载的过程就是一次上下文切换
线程在执行过程中会有自己的运行条件和状态(上下文),线程切换意味保存当前线程上下文,等到线程下次占用CPU的时候恢复线程,并加载下一个将要占用CPU的线程上下文
因为需要保存信息和恢复信息,就会占用CPU,内存等资源,所以频繁切换会降低效率
当出现如下情况的时候,线程会从占用 CPU 状态中退出。
sleep(), wait() 等。这其中前三种都会发生线程切换
线程死锁是:多个线程同时被阻塞,一个或多个等待某个资源的释放,导致线程无限期阻塞,比如互相持有锁
产生死锁的四个必要条件:
预防死锁
避免死锁
避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态(线程按顺序分配资源。
共同点:两者都可以暂停线程的执行
区别:
wait()是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁,每个对象都有对象锁,所以为了操作对象而不是线程,就使用Object类
因为sleep()方法是让当前线程暂停执行,不涉及对象类,也不需要获得对象锁
主要解决多个线程之间访问资源的同步性,可以保证被它修饰的方法或代码块在任何时刻只能有一个线程执行
java早期,synchronized是重量级锁,效率低。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的(有三个属性,获得锁的线程owner、阻塞的线程entrylist、wait的线程waitset),Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
java6之后,synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。因此, synchronized 还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized 。
不能
构造方法本身属于线程安全,不存在同步的构造方法这一说
synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。
包含一个 monitorenter 指令以及两个 monitorexit 指令,这是为了保证锁在同步代码块代码正常执行以及出现异常的这两种情况下都能被正确释放。
当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。
在Hotspot中,monitor基于c++实现,每个对象都内置了一个ObjectMonitor对象,wait/notify等方法也依赖monitor对象,所以只有在同步块或同步方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常
在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
对象锁的的拥有者线程才可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。
没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。
本质都是获取对象monitor
ReentrantLock实现了Lock接口,是一个可重入的独占锁,比synchronized更灵活强大
ReentrantLock有一个内部类Sync,继承AQS(AbstractQueueSynchronizer),加锁和释放锁的大部分操作在Sync中实现,Sync 有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。默认使用非公平锁
ReentrantLock 的底层就是由 AQS 来实现的
二者都是可重入锁(递归锁:线程可以再次获取自己的锁),Lock实现类和synchronized都是可重入的
synchronized依赖于jvm而ReentrantLock依赖于api
lock.lockInterruptibly() 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。Condition可以实现多路通知功能,就是在一个Lock对象中可以创建多个Condition实例(对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行通知,在调度线程更灵活。如果使用notify()/notifyAll()方法进行通知,被通知的线程是jvm选择的,用ReentrantLock类结合Condition实例可以实现选择性通知,synchronized相当于整个Lock对象中只有一个Condition实例,如果执行notifyAll()就会通知所有等待的线程,Condition的signalAll()方法,只会唤醒注册在该Condition实例的所有等待线程ReentrantReadWriteLock 实现了 ReadWriteLock ,是一个可重入的读写锁,既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。
一般锁是读读互斥,读写互斥,写写互斥,读写锁是读读不互斥
ReentrantReadWriteLock 其实是两把锁,一把是 WriteLock (写锁),一把是 ReadLock(读锁)
读是共享锁,写是独占锁,读锁可以同时被多个线程持有,写锁最多只能同时被一个线程持有
和 ReentrantLock 一样,ReentrantReadWriteLock 底层也是基于 AQS 实现的。也支持公平锁和非公平锁(默认)
ReentrantReadWriteLock既可以保证多个线程同时读的效率,又可以保证写入操作的线程安全,适合读多写少的场景
StampedLock 是 JDK 1.8 引入的性能更好的读写锁,不可重入且不支持条件变量 Conditon。
不同于一般的 Lock 类,StampedLock 并不是直接实现 Lock或 ReadWriteLock接口,而是基于 CLH 锁 独立实现的(AQS 也是基于这玩意)。
StampedLock 提供了三种模式的读写控制模式:读锁、写锁和乐观读。
ReentrantReadWriteLock 的写锁,不过这里的写锁是不可重入的。ReentrantReadWriteLock 的读锁,不过这里的读锁是不可重入的。StampedLock 在获取锁的时候会返回一个 long 型的数据戳,该数据戳用于稍后的锁释放参数,如果返回的数据戳为 0 则表示锁获取失败。当前线程持有了锁再次获取锁还是会返回一个新的数据戳,这也是StampedLock不可重入的原因。
相比于传统读写锁多出来的乐观读是StampedLock比 ReadWriteLock 性能更好的关键原因。StampedLock 的乐观读允许一个写线程获取写锁,所以不会导致所有写线程阻塞,也就是当读多写少的时候,写线程有机会获取写锁,减少了线程饥饿的问题,吞吐量大大提高。
和 ReentrantReadWriteLock 一样,StampedLock 同样适合读多写少的业务场景,可以作为 ReentrantReadWriteLock的替代品,性能更好。
不过,需要注意的是StampedLock不可重入,不支持条件变量 Conditon,对中断操作支持也不友好(使用不当容易导致 CPU 飙升)。如果你需要用到 ReentrantLock 的一些高级性能,就不太建议使用 StampedLock 了。
StampedLock 不是直接实现 Lock或 ReadWriteLock接口,而是基于 CLH 锁 实现的(AQS 也是基于这玩意),CLH 锁是对自旋锁的一种改良,是一种隐式的链表队列。StampedLock 通过 CLH 队列进行线程的管理,通过同步状态值 state 来表示锁的状态和类型。
异步思想
当执行某一耗时任务时,可以将这个耗时任务交给一个子线程异步执行,再通过Future获取耗时任务的执行结果
FutureTask 提供了 Future 接口的基本实现,常用来封装 Callable 和 Runnable,具有取消任务、查看任务是否执行完成以及获取任务执行结果的方法。ExecutorService.submit() 方法返回的其实就是 Future 的实现类 FutureTask 。
FutureTask相当于对Callable 进行了封装
Set:
List:
Queue:
Map:
底层数组,容量动态增长,在添加大量元素之前,主动使用 ensureCapacity 增加容量
可以添加 NULL
默认大小10,构造器也可以指定集合的列表
ensureCapacity(int minCapacity 所需的最小容量),如果最小容量大于已有的最大容量,就调用ensureExplicitCapacity(minCapacity),判断是否需要扩容,调用grow(minCapacity)进行扩容,新容量为旧容量的1.5倍,此时如果新容量还是小于需要的最小容量,就将新容量设置为需要的最小容量, 再检查新容量是否超过最大容量,最后 elementData = Arrays.copyOf(elementData, newCapacity);
添加元素时先 ensureCapacityInternal(size + 1); 得到最小容量(传入最小容量和默认容量最大值),再通过最小容量扩容,ensureExplicitCapacity(minCapacity);,判断是否需要扩容,再调用 grow(minCapacity)
添加第一个元素时,因为长度为0,所以执行ensureCapacityInternal(),此时最小容量为10,一定会进入grow()方法,添加第二个元素时,最小容量为2,就不执行grow,添加第11个元素时,继续grow()
每次扩容之后容量都会变为之前的1.5倍左右(奇数会丢小数)
可以存的null的key和value,但null的key只能有一个
jdk1.8之前使用数组+链表,链表解决哈希冲突,jdk1.8以后,解决哈希冲突时,当链表长度>=8(链表转为红黑树之前会先判断,如果数组长度<64,会先扩容而不是转为红黑树),将链表转化为红黑树,减少搜索时间
初始容量16(过大的话就会导致空间的浪费,太小的话就又会导致频繁扩容),之后每次扩容时,容量变为原来2倍
负载因子0.75(设置过大的话虽然空间利用率高了但是会更容易引发hash碰撞(因为扩容阈值大了),而设置过小的话虽然可以减少hash碰撞的发生但也会导致空间利用率不高以及频繁扩容)
putval时,如果位置没有元素,就直接插入,有元素的话就和key比较,key相同就直接覆盖,key不同就判断p是否是一个树节点,如果是就用树的方法加入元素,不是就遍历链表插入(尾部)
put
扩容resize:resize,伴随一次重新hash分配,并且会遍历hash表所有元素,实际是将table初始化和table扩容进行整合,都是给table赋值一个新的数组
没超过最大值就扩充为原来的2倍
JDK1.7是先扩容再插入,而1.8是先插入再扩容
1.7之前在多线程下扩容可能导致死循环和数据丢失(1.8也存在)
1.8后,多个键值对可能分配到一个桶,并以链表或红黑树形式存在,多个线程的put可能导致线程不安全,会有数据覆盖的风险,可能两个线程同时插入,第1个线程判定不冲突之后被挂起,此时第2个线程插入成功,最后第1个线程的数据会覆盖第二个线程,还有可能导致size值不正确,进一步导致数据覆盖
在HashMap基础上实现了线程安全。其主要是通过应用CAS以及Synchronized实现线程安全。
java7的ConcurrentHashMap使用分段锁,就是每一个segment上同时只有一个线程可以操作,每一个segment都是一个类似HashMap数组的结构,可以扩容,冲突换转换为链表,但是Segment的个数一但初始化就不能改变
java8中ConcurrentHashMap使用Synchronized锁加CAS的机制,Node类似一个HashEntry的结构,冲突过多会转化为红黑树,冲突变小会转换为链表
Synchronized 锁自从引入锁升级策略后,性能不再是问题
分段Segment,给每一个段配一把锁,当一个线程占用锁访问一个段时,其他段的数据也能被其他线程访问,Segment继承ReentrantLock,个数一旦初始化就不能改变,默认16
默认容量16,默认负载因子0.75,默认并发级别16,初始化时,segmentMask为15,初始化segments[0]大小为2,负载因子0.75,扩容阈值1.5,插入第二个值就进行扩容
在put一个数据时
扩容rehash:
Segment 中的链表长度超过阈值(默认为 8)时,会触发该 Segment 的扩容。
扩容时首先获取段的锁,获取成功其他扩容线程会阻塞,再把旧段的元素分批迁移到新段,此时其他线程对该段的写操作会被阻塞,迁移完成后,原始段会指向新段,扩容锁释放。只会扩容到原来的2倍,老数组的数据移动到新数组时,位置要么不变,要么变为 index + oldSize,参数里的 node 会在扩容之后使用链表头插法插入到指定位置。
get
采用 Node + CAS + synchronized 来保证并发安全,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。
CAS是并发更新时修改数据
初始化 initTable
通过自旋和 CAS操作完成,sizeCtl 值决定当前初始化状态,小于0就说明另外线程正在进行初始化,此时主动让出CPU使用权
>0:table已经初始化就表示table扩容的阈值put
根据key计算hashcode
判断是否需要进行初始化
根据当前key定位出的Node,如果为空表示当前位置可以写入数据,用CAS尝试写入,失败就自旋保证成功
如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
如果都不满足,就利用synchronized锁链表首节点或树头节点写入数据
如果数量大于 TREEIFY_THRESHOLD 则要执行树化方法,在 treeifyBin 中会首先判断当前数组长度 ≥64 时才会将链表转换为红黑树。
get
主要体现在线程安全的实现
ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式继承HashMap,在HashMap基础上维护一条双向链表
定义了排序模式 accessOrder(默认false),访问顺序为true(访问一个元素之后会移到后面),插入顺序false
LRU缓存
最近最少使用,确保当存放的元素超过容器容量时,将最近最少访问的元素移除
实现思路
源码
Node设计:Entry增加before和after让节点具备双向链表的特性,HashMap的TreeNode继承了LinkedHashMap的entry,这是为了保证使用LinkedHashMap时树节点具备双向链表的特性
get:accessOrder为true时,会在元素查找之后,将访问的元素移动到链表的末尾
t读取操作完全不加锁,写入也不会阻塞读取操作,只有写写会互斥,读性能提升
线程安全的核心在于采用了 写时复制(Copy-On-Write)
写时复制:如果多个调用者同时请求相同资源,他们会同时获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份副本给调用者,其他调用者不变。这样的优点是如果调用者没有修改资源,就不会有副本被创建,因此多个调用者只是读取操作时可以共享一份资源
当修改CopyOnWriteArrayList的内容时,不会直接修改原数组,而是先创建数组的副本,对副本进行修改,修改完成再将修改后的数组赋值回去
写时复制适合读多写少的场景
缺点:
插入:默认插入尾部,也可以指定位置
addIfAbsent(E e):如果指定元素不存在,那么添加该元素。如果成功添加元素则返回 true。
获取:弱一致性,可能读到旧数据,分为2步,先获取当前数组的引用,再从数组获取下标的元素,没加锁。
所以可能获取数组引用之后,其他线程修改了数组,但本数组的值没有改变(其他线程会设置新数组引用)
删除:先加锁,删除时如果删除的是最后一个元素,就复制之前所有的元素,否则进行分段复制,先复制删除元素之前的,再复制删除元素之后的
接口,继承Queue
常用于生产者-消费者模型
常用实现类
ArrayBlockingQueue:使用数组实现的有界阻塞队列。在创建时需要指定容量大小,并支持公平和非公平两种方式的锁访问机制。LinkedBlockingQueue:使用单向链表实现的可选有界阻塞队列。在创建时可以指定容量大小,如果不指定则默认为Integer.MAX_VALUE。和ArrayBlockingQueue类似, 它也支持公平和非公平的锁访问机制。PriorityBlockingQueue:支持优先级排序的无界阻塞队列。元素必须实现Comparable接口或者在构造函数中传入Comparator对象,并且不能插入 null 元素。SynchronousQueue:同步队列,是一种不存储元素的阻塞队列。每个插入操作都必须等待对应的删除操作,反之删除操作也必须等待插入操作。因此,SynchronousQueue通常用于线程之间的直接传递数据。DelayQueue:延迟队列,其中的元素只有到了其指定的延迟时间,才能够从队列中出队。1.5引入,和Queue区别是元素出队顺序和优先级相关
Comparable 接口实际上是出自java.lang包 它有一个 compareTo(Object obj)方法用来排序
Comparator接口实际上是出自 java.util 包它有一个compare(Object obj1, Object obj2)方法用来排序
**在使用 java.util.stream.Collectors 类的 toMap() 方法转为 Map 集合时,一定要注意当 value 为 null 时会抛 NPE 异常。**内部调用Map接口的merge(),merge首先会判断value是否为null,为空就抛出异常
不要在 foreach 循环里进行元素的 remove/add 操作。remove 元素请使用 Iterator 方式,如果并发操作,需要对 Iterator 对象加锁。
foreach底层还是迭代器,但remove/add调用的是集合自己的方法,不是迭代器的方法,所以导致迭代器莫名其妙发现自己的元素被remove/add,然后就提示用户抛出并发修改异常,这就是单线程状态下产生的 fail-fast 机制(多个线程对fail-fast集合修改的时候,可能抛出ConcurrentModificationException,但单线程也可能抛出这个异常,如上)。
Java8 开始,可以使用 Collection#removeIf()方法删除满足特定条件的元素
利用set的唯一性,而不是List的contains(遍历所有元素),两者的核心差别在于 contains() 方法的实现。
HashSet 的 contains() 方法底部依赖的 HashMap 的 containsKey() 方法,时间复杂度接近于 O(1)
使用集合转数组的方法,必须使用集合的 toArray(T[] array),传入的是类型完全一致、长度为 0 的空数组。
传入对象数组,使用工具类 Arrays.asList() 把数组转换成集合时,不能使用其修改集合相关的方法, 它的 add/remove/clear 方法会抛出 UnsupportedOperationException 异常。如果aslist里参数是一个字符串数组,所以可以修改字符串数组元素影响list
Arrays.asList() 方法返回的并不是 java.util.ArrayList ,而是 java.util.Arrays 的一个内部类,这个内部类并没有实现集合的修改方法或者说并没有重写这些方法。保存时使用数组保存元素,如果传入字符串数组会保存对应引用,所以可以在外部修改字符串数组来影响list,ArrayList的toArray方法会复制整个数组
可以这样转为ArrayList
List list = new ArrayList<>(Arrays.asList("a", "b", "c"))
推荐使用Stream,也可以转基本类型数组
Integer [] myArray = { 1, 2, 3 };
List myList = Arrays.stream(myArray).collect(Collectors.toList());
Exception和Error共同的父类是Throwable
获取Class对象四种方式
Memory Barrier)就是通过阻止屏障两边的指令重排序从而避免编译器和硬件的不正确优化情况。loadFence方法为例,它会禁止读操作重排序,保证在这个屏障之前的所有读操作都已经完成,并且将缓存数据设为无效,重新从主存中进行加载。public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
private final char value[];
//...
}
"+“和”+="是专门为String类重载过的运算符,仅有的两个
StringBuilder的append()方法实现,拼接完成调用toString()得到String对象
StringBuilder,会创建过多的StringBuilder对象makeConcatWithConstants()实现,不是大量创建StringBuilderString str3 = "str" + "ing"; 编译器会给你优化成 String str3 = "string";String 的底层实现由 char[] 改成了 byte[] ?Java 9 之后,String、StringBuilder 与 StringBuffer 的实现改用 byte 数组存储字符串。
新版String支持两个编码方案:Latin-1和UTF-16。如果不超过Latin-1表示范围,就用Latin-1,byte占一个字节,相比于char节省一半内存
将指定的字符串对象的引用保存在字符串常量池
utf8 :英文占 1 字节,中文占 3 字节,unicode:任何字符都占 2 个字节,gbk:英文占 1 字节,中文占 2 字节。
Interface:方法可以用default或static修饰,就可以有方法体,实现类也不必重写此方法
default修饰的方法,是普通实例方法,可以用this调用,可以被子类继承、重写。static修饰的方法,使用上和一般类静态方法一样。但它不能被子类继承,只能用Interface调用。java8中接口和抽象类函数式接口:有且只有一个抽象方法
Lambda表达式:替代匿名内部类
Stream:不存储数据,可以检索和逻辑处理集合数据,分为串行流和并行流,一个Stream只能操作一次,操作完就关闭了
Optional:防止空指针异常,让代码简洁
Date-Time API:java.time类
原理
看处理器:x86不会对写写和读读重排序
IO操作必须通过系统调用间接访问内核空间,应用程序只是发起IO操作的调用,具体IO执行是操作系统内核完成的
应用程序发起IO调用后有两个步骤:
linux下的5中IO模型:同步阻塞 I/O、同步非阻塞 I/O、I/O 多路复用、信号驱动 I/O 和异步 I/O。
BIO:同步阻塞IO,read后会一致阻塞,直到内核把数据拷贝到用户空间
NIO:是同步非阻塞IO,也是IO多路复用基础,提供了Channel、Selector、Buffer抽象
AIO:异步IO,基于事件和回调机制,应用操作之后直接返回不阻塞,处理完成后操作系统通知对应线程进行后续操作
值传递,传递的是副本。引用传递,传递的是实际内存地址
都是值传递,引用类型传递地址,把地址的拷贝传给形参
Cache Aside Pattern(旁路缓存模式):最频繁,适合请求较多的场景
服务端需要同时维护db和cache,以db结果为准,该策略下的缓存读写步骤
写数据时,先更新db,再删除cache就没问题了吗?:不是,假如某个数据不存在,如果请求1在修改数据库并且删除缓存后,再次请求时写入缓存之前,请求2修改数据并删除缓存,之后请求1再写入缓存,此时缓存是旧的,数据库数据是新的,数据就不一致了。但因为缓存的写入很快,所以概率不高
解决缓存不一致:延迟双删:删缓存、更新数据库、睡眠、再删缓存。睡眠确保在请求1睡眠时,请求2在这段时间读取数据并把缺失数据写入缓存。然后再删缓存,下一次重建时就是请求2修改的数据,尽可能一致性,睡眠时间玄学。为了避免第二个删除失败,可以异步操作缓存,如引入消息队列重试删除,或者用Canal订阅binlog再操作缓存(伪造自己是从节点,发现数据库修改后就通知变更情况)
延迟队列
Canal的异步通知
旁路缓存缺陷:
Read/Write Through Pattern(读写穿透):服务端把cache视为主要数据存储,从中读取数据并写入数据,cache服务负责将数据读取和写入db,少见,大概率因为经常使用的分布式缓存Redis没有提供cache将数据写入db的功能
写:
读:
读写穿透实际只是在旁路缓存进行封装,在旁路缓存里,读数据时如果cache不存在,由客户端自己负责把数据写入cache,但读写穿透里,是cache服务自己写入cache
缺陷和旁路缓存一样,首次请求数据时一定不在cache,对于热点数据可以提前放入cache
Write Behind Pattern(异步缓存写入)
异步缓存写入和读写穿透相似,都是由cache服务负责cache和db的读写
但是读写穿透是同步更新cache和db,异步缓存写入只是更新缓存,不直接更新db,而是改为异步批量更新db
很难保证数据一致性,可能cache还没异步更新db,cache服务就挂掉了
非常少见,消息队列中消息的异步写入磁盘、MySQL 的 Innodb Buffer Pool 机制都用到了这种策略。
写性能很高,适合一些数据经常变化但对数据一致性要求没那么高的场景,如浏览器、点赞量
String:SDS
二进制安全,SDS是Redis自己构建的简单动态字符串,不止保存文本,还可以保存二进制,并且获取字符串长度复杂度o(1),SDS的API是安全的,不会造成缓冲区溢出,编码int(long内)、embstr(小于44字节,字符串实际是只读的,修改的时候会先转为raw)、raw基于SDS,上限512mb
应用场景:
List:Redis3.2之前(LinkedList/ZipList) Redis3.2之后QuickList
双向链表,支持反向查找和遍历
应用场景:
Hash:Hash Table、ZipList(元素数量小于默认512个,任意entry大小小于64字节)
类似HashMap(数组+链表),但做了很多优化
应用场景:
Set:Intset(都是整数而且个数小于512) ,hashtable(value是null)
无序集合,类似HashSet,可以很快实现集合的操作,都是整数而且元素不多时使用IntSet
应用场景:
Zset:ZipList(元素数量小于默认128同时每个元素小于默认64字节)、SkipList
Sorted Set类似Set,但增加一个权重参数score,可以根据score排序,也可以根据score范围获取元素的列表
应用场景:
Bitmap:存储连续二进制数字
常用命令:
应用场景;
HyperLogLog:基数计数概率算法,不是Redis特有,是优化Log Log Counting的,Redis只是实现了这个算法并提供了一些API
占用空间非常小(12k)可以存储2^64个不同元素,Redis对HyperLogLog的存储结构进行优化,Redis采用两种方法计数:
基数计数概率算法为了节省内存并不会直接存储数据,而是通过一定的概率统计方法预估基数值(集合中包含元素的个数),所以不是精确值,误差0.81%
常用命令:
应用场景:
Geospatial index:地理空间索引,居于Sorted Set实现,可以轻松实现两个位置距离计算
常用命令:
应用场景:
用于在有序元素集合中进行快速搜索、插入和删除操作。它通过添加多层索引来加速查找,从而降低了算法的时间复杂度。
链表随机访问o(n),链表有序o(n),加速链表,用于元素有序的情况
跳表是对表的平衡树和二分查找,增删查都是o(logn)
redis实现跳表,底层节点有level数组,保存前进节点指针和跨度,数组大小在1-32之间随机(但越大的数出现概率越小),高层的指针越过的元素数量大于等于低层的指针,为了提高查找的效率,程序总是从高层先开始访问,然后随着元素值范围的缩小,慢慢降低层次。
Redis通过创建快照获取存储在内存里面的数据在某个时间点上的副本。可以备份/复制快照,或者留在原地重启服务器时使用
快照持久化是Redis默认的持久化方式,二进制
redis.conf
save 900 1 #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发bgsave命令创建快照。
save 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发bgsave命令创建快照。
save 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发bgsave命令创建快照。
RDB 创建快照
Redis两个命令生成RDB快照
说Redis主线程是因为Redis启动之后通过单线程完成主要工作,也可以叫Redis主进程
默认不开启,Redis6.0之后默认开启
每执行一条会更改Redis中的数据的命令,Redis就会将该命令写入AOF缓冲区中,然后再写入AOF文件里(此时还在系统内核缓存区未同步到磁盘),最后再根据持久化方式(fsync策略)的配置决定什么时候将系统内核缓存区的数据同步到硬盘
fsync策略
appendfsync always:主线程write写操作后,后台线程立刻调用fsync函数同步aof文件(磁盘),fsync完成后线程返回,write+fsyncappendfsync everysec:主线程write写操作后立刻返回,后台线程每秒钟调用fsync函数同步一次aof文件**(write + fsync,fsync间隔1秒)**appendfsync no:主线程调用write写操作后立刻返回,让操作系统决定何时调用(linux一般30秒一次)wirte但不fsync,fsync时机由操作系统决定为了兼顾写入性能,一般选择第二种,即使出现崩溃,用户最多损失1秒之内产生的数据
redis7.0开始,使用 Multi Part AOF 机制,将原来单个aof文件拆分为多个aof文件
AOF变太大64M,Redis在后台自动重写AOF产生了一个新的AOF文件,新的更小,这个fork也是只复制页表,而不是复制内存,父子继承虚拟空间不同但对应的物理空间是一个
AOF重写程序在子进程(使用子进程不用线程是因为多线程共享内存,修改共享内存时需要加锁降低性能,而父子进程共享内存,但只是只读,当任意一方修改共享内存,就会发生写时复制(发生写操作的时候,操作系统才复制物理内存,防止fork创建子进程时因为物理内存数据复制时间太长阻塞),所以父子进程拥有独立数据副本不用加锁)中,重写期间,Redis维护一个 AOF重写缓冲区,缓冲区会在子进程创建新AOF文件期间,记录服务器执行的所有写命令。创建完成之后,服务器会将重写缓冲区所有内容追加到新AOF文件末尾,最后服务器用新的AOF文件替换旧的AOF文件
开启 AOF 重写功能,可以调用 BGREWRITEAOF 命令手动执行,也可以配置触发时机
auto-aof-rewrite-min-size:如果 AOF 文件大小小于该值,则不会触发 AOF 重写。默认值为 64 MB;
auto-aof-rewrite-percentage:执行 AOF 重写时,当前 AOF 大小(aof_current_size)和上一次重写时 AOF 大小(aof_base_size)的比值。如果当前 AOF 文件大小增加了这个百分比值,将触发 AOF 重写。将此值设置为 0 将禁用自动 AOF 重写。默认值为 100
Redis 7.0 版本之前,如果在重写期间有写入命令,AOF 可能会使用大量内存,重写期间到达的所有写入命令都会写入磁盘两次。
过去重写的数据在内存中保留,7.0之后,具体方法是采用 base(全量数据)+inc(增量数据)独立文件存储的方式,彻底解决内存和 IO 资源的浪费,同时也支持对历史 AOF 文件的保存管理
风险:
AOF 校验机制是 Redis 在启动时对 AOF 文件进行检查,以判断文件是否完整,是否有损坏或者丢失的数据
使用校验和验证(对整个AOF文件内容用CRC64算法计算的数字)
4.0开始支持RDB和AOF混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)
AOF重写可以直接把RDB内容写到AOF开头,再把AOF重写缓冲区的数据写到AOF文件尾,好处是可以快速加载同时避免丢失过多数据,缺点是AOF里的RDB部分是压缩格式,可读性差
RDB恢复速度快,AOF丢失数据少
RDB 比 AOF 优秀的地方:
AOF 比 RDB 优秀的地方:
综上:
原子性操作,返回1表示key设置了新值,0表示key已存在,基于Redis的单线程模型和事务,客户端发送setnx到服务器,redis会把命令加入命令队列排队,用watch监视指定的key,执行事务时检测key是否被修改
O(n)命令:比如返回所有key、所有…,还有set的交并差集,有遍历的需求可以使用 HSCAN、SSCAN、ZSCAN 代替。还有O(n)之上的命令,如返回/移除排序set指定排名范围的所有元素,O(logn + m)
**SAVE 创建 RDB 快照:**save同步保存操作,阻塞Redis主线程。bgsave会fork一个子进程,子进程执行不阻塞Redis主线程,默认
AOF 日志记录阻塞:先执行命令再记录日志,可能阻塞后续命令执行
AOF 刷盘阻塞:根据fsync策略,后台线程刷盘时需要等待,直到写入成功,如果磁盘压力太大,刷盘会阻塞,刷盘成功后主线程的write才会成功返回
AOF 重写阻塞:将AOF缓冲区的新数据写到新文件的过程中会产生阻塞
大Key、BigKey:string 类型的 value 超过 10 kb,复合类型的 value 包含的元素超过 5000 个
网络阻塞:获取大Key网络流量较大
使用del删除大Key,会阻塞工作线程,没办法处理后续命令
创建子进程时复制父进程页表阻塞时间长
创建子进程后,如果父进程修改共享数据的大key,就会发生写时复制,拷贝物理内存,但大key占空间大,复制就耗时,阻塞父进程
客户端超时阻塞。Redis单线程执行命令,操作大key耗时长阻塞Redis
查找大Key:用 --bigkeys 查找大Key时,选择在从节点执行,主节点执行会阻塞
Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型 ,这套事件处理模型对应的是 Redis 中的文件事件处理器(file event handler)。由于文件事件处理器(file event handler)是单线程方式运行的,所以我们一般都说 Redis 是单线程模型。
Reactor:基于同步io,事件分发器等待某个事件发生,再把事件传给该事件注册时指定的回调函数处理。关注待完成
Proactor:异步io,事件分发器直接发起一个异步读写操作(操作系统完成),指定数据存放的位置和请求完成的回调函数,操作系统完成数据存入缓冲区操作后通知事件分发器操作完成,事件分发器呼唤处理器,事件处理器处理缓冲区数据。关注已完成
虽然文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字,也实现了高性能
redis 通过 IO 多路复用程序 来监听来自客户端的大量连接
I/O 多路复用技术的使用让 Redis 不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗
文件事件处理器包含四个部分:
Redis6.0 之前为什么不使用多线程?
Redis 在 4.0 之后的版本中就已经加入了对多线程的支持。
主要是针对一些大键值对的删除操作的命令,使用这些命令就会使用主线程之外的其他线程来“异步处理”。
Redis6.0 引入多线程
为了提高网络IO读写性能
Redis 的多线程只是在网络数据的读写这类耗时操作上使用了,执行命令仍然是单线程顺序执行。所以不需要担心线程安全问题。默认不开启多线程,开启后默认只使用多线程进行IO写入writes,即发送数据给客户端,多线程读不能有太大提升,一般不建议开启
主线程生产任务事件,因为每一个后台线程多有对应的事件队列,当有事件处理时,会发送到对应的后台线程队列,后台线程就是消费者,从队列取出任务事件并处理(如果对应线程休眠就先唤醒)
关注队列并发问题(互斥量)生产者投递任务之前先上锁,投递之后立即释放锁
消费者从队列取任务之前,先上锁,取到之后立即释放锁
都是先加锁
操作系统-网络系统下
通过一个过期字典(hash表)保存数据过期时间,过期时间是毫秒精度的UNIX时间戳
利用两个Dict分别记录key-value和key-ttl对
三种策略:
定时删除:设置ttl时,创建一个定时事件,时间到达时事件处理器自动执行key的删除操作
惰性删除:不主动删除,访问的时候检查是否过期,过期就删除
定期删除:每隔一段时间抽一批key执行过期删除,对内存友好,Redis内部维护定时任务,这个定时任务是在Redis主线程执行的
Redis采用定期删除+惰性删除,但还是可能存在过期key堆积在内存,解决方法是内存淘汰机制
Redis提供6中淘汰策略:
volatile-lru:设置了TTL的key使用最近最少使用淘汰
ttl:找将过期的数据淘汰
random:随机设置了TTL的key
allkeys-lru:全体key,移除最少最近使用的key(最常用),当前时间-最后一次访问时间,越大淘汰优先级越高
allkeys-random:随机所有key
no-eviction:禁止淘汰,让写入数据报错。默认使用这个
4.0之后加入:
lfu:最少频率使用,统计每个key的访问频率,越小淘汰优先级越高,保存逻辑访问次数和最后一次访问时间,访问次数越多,计数器累加概率越小,计数器也会随时间衰减
allkey-lfu:当内存不足容纳新数据,移除最不经常使用数据
Redis 事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断
很少使用,不满足原子性和持久性,而且浪费网络资源,不建议使用
MULTI之后可以输入多个命令,Redis会将这些命令放到队列,调用EXEC后,再执行所有命令
DISCARD取消一个事务
WATCH监听指定Key,如果执行事务时,被监听的key被修改,整个事务就不会被执行,但如果是同一个session,就可以执行
Redis 事务在运行错误的情况下,除了执行过程中出现错误的命令外,其他命令都能正常执行。并且,Redis 事务是不支持回滚(roll back)操作的。因此,Redis 事务其实是不满足原子性的。
如果 Redis 没有使用 RDB 或 AOF,那么事务的持久化属性肯定得不到保证。
如果 Redis 使用了 RDB 模式,那么,在一个事务执行后,而下一次的 RDB 快照还未执行前,如果发生了实例宕机,数据丢失,这种情况下,事务修改的数据也是不能保证持久化的。
如果 Redis 采用了 AOF 模式,因为 AOF 模式的配置选项 no、everysec 和 always(基本满足,但性能太差,不适用) 都会存在数据丢失的情况。
所以,事务的持久性属性也还是得不到保证。
不管 Redis 采用什么持久化模式,事务的持久性属性是得不到保证的。
2.6开始支持lua脚本,批量执行多条Redis命令,提交到服务器一次性执行完成
一段Lua脚本可以视为一条命令,保证操作不会被其他插入打扰
但如果lua运行时出错,出错之后的命令不会执行,之前的命令不能回滚,无法实现类似关系型数据库执行失败可以回滚的那种原子性效果。因此, 严格来说的话,通过 Lua 脚本来批量执行 Redis 命令实际也是不完全满足原子性的。
批量操作减少网络传输:减少RTT、减少socket IO成本(上下文切换)成本,大数据导入
大量key集中过期问题:对于过期 key,Redis 采用的是 定期删除+惰性/懒汉式删除 策略。尽量给Key设置随机过期时间,并开启惰性删除,采用异步方式释放key内存
大Key:一般String超过10kb,复合类型value超过5000个
大Key危害:消耗更多内存和带宽,影响性能,使用–bigkeys查找大key,线上执行时-i控制扫描频率
大Key处理:分割(一般不推荐)、手动清理(4.0+可以UNLINK异步清理,4.0以下使用SCAN和DEL分批次删除)、采用合适数据结构(如HyperLogLog统计UV)、开启惰性删除
热Key:某个热点数据访问量暴增,可以使用–hotkeys查找,返回所有key的被访问次数(前设置LFU算法的策略)
热Key危害:占用大量CPU和带宽,如果突然访问热Key的请求超出Redis处理能力,就会直接宕机,大量请求落到数据库
热Key处理:读写分离(主节点处理写请求、从节点读请求)、使用Redis Cluster(将热点数据分布在多个节点上)、二级缓存(将热key存放一份到JVM本地内存里)
慢查询命令:执行耗时超过某个阈值的命令,默认10000微妙(10毫秒),建议1000微妙,慢查询日志长度上限默认128条,建议1000,可以config set修改配置
Redis内存碎片:
持久化配置:Redis持久化虽然可以保证数据安全,但会带来很多额外开销,所以
发生原因:大量请求key不合理,不在缓存中,也不在数据库中
解决方法:
缓存无效key:适用于请求的key变化不频繁,将无效key缓存到redis并设置短一点过期时间
布隆过滤器:把所有可能的请求的值都存放在布隆过滤器,如果请求的值不在过滤器里就直接返回错误信息,如果说在但其实不一定在
原理:一个元素加入过滤器时会先计算哈希值,再把数组对应下标设置1,判断元素时,会先计算哈希,之后判断每个位数组是否都为1,都1才说明可能在,存在不为1就一定不在
发生原因:请求key是热点数据,存在于数据库但不在缓存(通常是缓存数据过期),就可能导致瞬时大量请求落在数据库
解决方法:
发生原因:Redis宕机或数据库大量数据同一时间过期,大量请求直接访问数据库
解决方法:
读写分离,从节点读,主节点写,主节点同步数据给从节点
开启主从关系:从节点执行replicaof或slaveof(5.0之前),有临时(redis-cli连接时执行slaveof)和永久(配置文件),加上主节点的ip和端口
数据同步原理:
第一次主从同步是全量同步
slave重启后同步,执行增量同步
第一阶段判断不是第一次同步后恢复continue,第二阶段master直接去repl_baklog获取offset后的数据,发送命令并执行
repl_baklog本质是数组,大小固定,环形结构,会覆盖之前记录,所以如果slave和master差太多(slave断开太久,导致未备份数据被覆盖),大于环的大小,就无法增量同步,就只能全量同步
优化:
提高全量同步性能
减少全量同步
减少主节点压力
哨兵实现主从集群的自动故障恢复,哨兵也是集群
服务状态监控
哨兵基于心跳机制检测服务状态,每隔1秒向集群每个实例发送ping命令
选举新的master
选举依据
脑裂:脑裂的主要原因其实就是哨兵集群认为主节点已经出现故障了,重新选举其它从节点作为主节点,而原主节点其实是假故障,从而导致短暂的出现两个主节点,那么在主从切换期间客户端一旦给原主节点发送命令,就会造成数据丢失。
解决:无法彻底解决,脑裂最本质的问题是主从集群内部没有共识算法来维护多个节点的强一致性,它不像Zookeeper那样,每次写入必须大多数节点成功后才算成功,当脑裂发生时,Zookeeper节点被孤立,此时无法写入大多数节点,写请求会直接失败,因此Zookeeper才能保证集群的强一致性。
故障转移
选中一个slave为新的master后
Spring的RedisTemplate底层利用lettuce实现节点的感知和自动切换
yml配置文件配置master名称和nodes从节点
配置主从读写分离:加入Bean:LettuceClientConfigurationBuilderCustomizer,指定读取策略(4个,从主节点读和优先从主节点读,主不可用时从节点读,和从从节点读和优先从从节点读,从不可用从主节点读),指定REPLICA_PREFERRED,最后一中
主从和哨兵解决高可用、高并发读的问题,还有海量数据存储和高并发写的问题没解决
使用分片集群解决这两个问题,特征:
cluster-enabled yes开启集群
Redis6.2.4,redis-cli --cluster集群操作,+create。。。再指定每个副本的个数,再加上节点的ip和端口,自动认为前几个就是主节点
散列插槽:
Redis把每个master节点映射到0-16383共16384个插槽,redis中key和插槽绑定,根据key的有效部分(包含{}的里面就是有效部分,不包含的整个都是有效部分)计算插槽(利用CRC得到hash值,再对16384取余得到slot值)
如果想将同一类数据固定的保存在同一个Redsi实例,就可以让这一类商品的key有共同的{},如商品类型{id}
集群伸缩:
添加节点时,要指定自己的ip和端口和集群任意一个节点(ip和端口,为了通知集群),可以再指定自己是从节点或是是谁的从节点
还要给新节点分配插槽,redis-cli --cluster reshard 任意ip端口,再输入想移动多少个插槽,再输入谁接收这些插槽,输入对应id,再指定资源id
故障转移:
一个master宕机后,确定下线后,自动提升一个slave为新的master
数据迁移:
cluster failover可以手动让集群中的某个master宕机,把master切换给执行这个命令的节点,实现无感知的数据迁移
手动的Failover支持三种不同模式:
RedisTemplate访问分片集群:底层同样基于lettuce实现分片集群的支持,配置分片集群地址和读写分离
优化setnx,分布式工具集合
redis集群是ap,zookeeper是cp
sorted set和定时任务
五层是把网络接口层分开的
应用层:为应用程序提供服务
表示层:数据格式转化,数据加密
会话层:建立、管理维护会话
浏览器解析URL,生成发送给服务器的请求信息
查询服务器域名对应的IP地址
域名解析工作流程:
找到ip后,就可以将HTTP的传输工作交给操作系统的协议栈
ip还包括ICMP协议和ARP协议:
HTTP基于TCP传输
TCP报文头部:源端口、目标端口、序号、确认号、状态位、窗口大小(流量控制,双方都声明窗口(缓存大小),标识自己处理能力)、校验和、紧急指针、选项、数据
三次握手:保证双发都有发送和接收的能力
如果HTTP消息超过MSS,TCP就需要把HTTP数据拆为多个段
TCP协议有两个端口(浏览器监听的端口(通常随机生成),服务器监听的端口(HTTP默认80,HTTPS默认443))
IP将数据封装为网络包发送,需要源ip(存在多个网卡时,要根据路由表选择)和目标ip,协议号
MAC用于两点之间的传输,需要源MAC(网卡获取)和目标MAC地址(ARP协议在以太网广播,也有缓存)
网卡将数字信息转换为电信号,在网线上传输,控制网卡需要网卡驱动程序,网卡驱动程序获取网络包后,将其复制到网卡的缓存区,然后在开头加上报头和起始帧分界符,末尾加上用于检错的帧校验序列(FCS)
交换机将网络包转发到目的地,工作在MAC层,通过包尾的FCS校验错误,没问题就放到缓冲区(之后查询MAC地址表看自己有没有目标MAC地址,找不到就转发给除了源端口之外的所有端口),交换机直接接收所有包放到缓冲区,网卡判断不是发给自己的就丢弃,交换机端口没有MAC地址
路由器(基于IP,路由器的各个端口有MAC地址和IP):和交换机(基于以太网,没MAC地址)类似,所以路由器遇到不匹配的包会直接丢弃,查路由表,根据路由表网关列判断对方ip地址,为空表示已经到终点了,否则继续转发,之后ARP查询MAC地址
1xx:提示信息
2xx:服务器成功处理客户端请求
3xx:客户端请求的资源发生变动,需要重定向
301 和 302 都会在响应头里使用字段 Location,指明后续要跳转的 URL,浏览器会自动重定向新的 URL。
4xx:客户端发送的报文有误,服务器无法处理
5xx:客户端请求报文正确,但服务器处理时内部发生错误
请求头和请求体,
字段:
GET请求参数写在URL里,只允许ASCII字符,URL长度也有限制
POST时根据body对指定的资源进行处理
安全指请求方法不会破坏服务器上的资源
幂等指多次执行相同操作,结果相同
GET可以带body
POST可以写URL
只要浏览器判断缓存没过期,就之间使用浏览器本地缓存,浏览器说了算
返回状态码后面跟了 from disk cache就是使用强制缓存
强缓存利用HTTP响应头部实现,都表示资源在客户端缓存的有效期
Cache-Control, 是一个相对时间;优先级高,选项多,精细
实现流程:
Expires,是一个绝对时间;
与服务器协商之后,通过协商结果判断是否使用本地缓存,如响应码304
两种头部方式实现:
If-Modified-Since 字段与响应头部中的 Last-Modified 字段实现
If-None-Match 字段与响应头部中的 ETag 字段
Etag:唯一标识响应资源;第一种基于时间,第二种基于一个唯一表达式,第二个更准确判断文件内容是否被修改,避免由于时间篡改导致的不可靠问题
Etag优先级比Last-Modified高,第二种比第一种高,Etag没变化再看第一种
因为Etag可以解决更多的问题
协商缓存都需要配合强制缓存的Cache-Control使用,只有没命中强制缓存时,才能发起协商缓存
http1.1中header的Range和contentRange,使用范围请求,通过指定HTTP请求报文首部字段Range来请求尚未收到的资源
比如范围请求只请求5000-10000字节的资源,Range:5000-10000,范围请求响应状态码是206, 如果服务器无法响应范围请求,则会返回状态码200 OK和完整的实体内容。
无状态和明文传输都是双刃剑
无状态
明文传输
不安全:用HTTPS解决
HTTP基于TCP/IP,使用请求-应答通信模式
尽量避免发送 HTTP 请求
减少HTTP请求次数
减少服务器的 HTTP 响应的数据大小
解决方法:
具体:
混合加密:HTTPS 采用对称加密(过程全部使用对称加密的会话密钥加密数据)和非对称加密(通信建立前用非对称加密交换会话密钥,之后不用非对称加密)
使用原因:
摘要算法+数字签名:为了保证信息不被修改,对内容计算出一个指纹(摘要算法哈希函数),和内容一块发给对方,对方收到后根据内容也计算一个指纹,用于判断内容是否被更改。现在缺少客户端对收到消息是服务端的证明,所以使用非对称加密解决,两个密钥双向加解密
公钥加密,私钥解密:保证内容传输安全,私钥角度,只有有私钥的人才能解密,一般不会这样,因为耗费性能
私钥加密,公钥解密:保证内容不会被冒充,私钥角度,只有有私钥的人才发送公钥能解密的内容,是非对称加密的主要用途,来确认消息身份,如数字签名算法,私钥加密是对内容的哈希值加密(加密后就是数字签名)
私钥是服务端保管,服务端向客户端颁发对应公钥,如果客户端收到的信息能被公钥解密,就说明该消息是服务器发送的
数字证书:
此时缺少身份验证,因为公钥可能被伪造,所以可以将服务器的公钥注册到CA(服务器公钥+ca数字签名(ca用自己的私钥对其他信息的hash的加密)保存在ca机构,客户端拿到数字证书后,因为浏览器或操作系统内置ca的公钥,就去ca机构验证数字证书是否合法。
数组,不用额外数组空间,
干啥来着
SSL/TLS基本流程:
前两部是建立过程,也是TLS握手过程,握手阶段涉及四次通信,用不同密钥交换算法,握手流程也不一样,常用密钥交换算法有RSA和ECDHE
基于RSA的握手过程:
客户端请求:
客户端发起加密请求,发送TLS版本、客户端随机数、客户端支持的密码套件列表(如RSA加密算法)
服务端请求:
服务器收到请求,发出响应,发送确认TLS版本、服务端随机数、确认的密码套件、服务器数字证书
客户端回应:
客户端通过浏览器或操作系统的CA公钥,确认数字证书,没问题就取出服务器的公钥,用它加密报文,再向服务器发送一个被服务器公钥加密的随机数、改变加密算法的通知(表示之后就使用会话密钥通信)、客户端握手结束通知(同时把之前所有内容的数据做个摘要,供服务端校验),服务器和客户端有了这三个随机数(Client Random、Server Random、pre-master key),接着就用双方协商的加密算法,各自生成本次通信的「会话秘钥」。
服务器回应:
收到第三个随机数后,通过协商的加密算法,计算本次通信的会话密钥,然后向客户端发送加密算法改变的通知、服务器握手结束通知(同时把之前所有内容数据做个摘要,供客户端校验)
接下来,客户端和服务器进入加密通信,用会话密钥加密内容
但RSA存在HTTPS前向安全问题:服务端私钥泄露之后,TLS密文都会被破解,所以一般使用ECDHE密钥协商算法
CA签发证书时:
客户端校验服务端证书时:
但可能证书有层级,验证方式大概是由于用户信任最上层的证书,所以上层证书担保的下层证书可以被信任,一层一层向下,最后因为用户信任操作系统或浏览器,所以都信任,为了确保证书的绝对安全性
TLS在实现上分为握手协议和记录协议:
记录协议主要负责HTTP数据的压缩、加密和数据的人则会那个,过程:
记录协议完成后,最终报文就到TCP层传输
一个场景:客户端向服务端发起HTTPS请求,被假基站转发到一个中间人服务器,于是客户端和中间人服务器完成TLS握手,中间人服务器和真正的服务端完成TLS握手
具体过程是中间人和服务端和客户端都维持了对称加密密钥,但前提在中间人和客户端交互时,中间人要冒充服务端,就要发送自己的公钥证书,但这个伪造证书会被客户端识别出来是非法的(不是受信任的CA颁发的J),但用户还是可以坚持用,选择信任了中间人
中间人作为客户端和服务端建立连接不会有问题,因为服务端不会校验客户端身份
中间人作为服务端和客户端建立连接,会有客户端信任服务端的问题,服务端必须持有对应域名的私钥,中间人要拿到私钥只能1. 去服务端拿私钥、2.去CA拿域名签发私钥、3.自己签发证书,同时要被浏览器信任
所以,HTTPS 协议本身到目前为止还是没有任何漏洞的,即使你成功进行中间人攻击,本质上是利用了客户端的漏洞(用户点击继续访问或者被恶意导入伪造的根证书),并不是 HTTPS 不够安全。
可以使用HTTPS双向认证
TLS握手目的是为了通过非对称加密握手协商或者交换出对称加密密钥,最长花费2RTT,后续传输的数据都使用对称加密密钥加密解密
性能损耗在TLS握手过程和握手后的对称加密报文传输(现在主流加密算法性能不错)
改进:
性能瓶颈:
HTTP/2基于HTTPS,所以安全性也有保障
性能上的改进:
头部压缩:压缩Header,同时发送多个请求,头相似或一样就会清除重复部分,HPACK算法:在客户端和服务器同时维护一张头信息表,所有字段存入这个表,生成一个索引号,同样字段以后只发索引号,提高速度,静态表编码和动态表编码
二进制格式:不是1.1的纯文本,全面二进制,header和body也是,统称帧:头信息帧和数据帧,传输时不用转为二进制,增加传输效率
并发传输:1.1是基于请求-响应模型,同一个连接中,HTTP完成一个请求响应,才能处理下一个请求响应,2.0引入Stream,多个Stream复用一条TCP连接
一个TCP连接包含多个Stream,一个Stream包含1个或多个Message,Message中对应HTTP/1的请求/响应,Message包含一条或多条帧,帧是HTTP/2最小单位,以二进制压缩格式存放HTTP/1的内容(头和体)
不同HTTP请求用唯一Stream ID,接收端可以通过Stream ID有序组装成HTTP消息,不同的Stream可以乱序发送,因此可以并发不同的Stream,并行发送请求和响应,客户端收到后,会根据相同的Stream ID有序组装HTTP消息
服务器推送:改善传统请求-应答模式,服务端不是被动响应,可以主动向客户端发消息,双方可以互相建立Stream,客户端建立的Stream必须是奇数号,服务器建立的Stream必须是偶数号,在1.1客户端访问html时,如果还要css,就必须重发请求,2.0时,客户端发送html时,服务器可以主动再次推送css
HTTP/2通过Stream并发能力解决队头阻塞问题,但并不完全,问题在TCP这一层
HTTP/2基于TCP传输数据,TCP是字节流协议,必须保证接收到的字节数据是完整且连续的,所以如果前一个字节没有到达,后到的字节数据只能存放在内核缓冲区,只有当这个字节到达时,HTTP/2应用层才能从内核拿到数据。这就是2的队头阻塞
内核中的TCP数据不连续,应用层就不能从内核读取到,TCP层面的队头阻塞
所以,一旦发生丢包,就会触发TCP的重传机制,这样在一个TCP连接中所有的HTTP请求都必须等待这个丢了的包被重传回来
1、2都有队头阻塞
HTTP/3把HTTP下层的TCP协议换成UDP
UDP不管顺序、不管丢包,所以不会出现2的队头阻塞,虽然UDP是不可靠传输,但是基于UDP的QUIC协议可以实现类似TCP的可靠传输
QUIC特点:
无队头阻塞:也可以在同一条连接上并发Stream,Stream可以认为是一条HTTP请求
QUIC保证传输可靠性:某个Stream发生丢包时,只会阻塞这个Stream,其他Stream不受影响,因此不存在队头阻塞,2.0如果某个Stream的包丢失,其他Stream也会受影响
QUIC连接上的多个Stream没有依赖,都是独立的,某个Stream发生丢包时,只会影响该Stream
更快的连接建立:对于1/2协议,TCP属于内核实现的传输层,TLS时openssl库实现的表示层,合并时需要分批次握手,先TCP,再TLS。3在传输数据时虽然需要QUIC握手,但只要一个RTT,目的时确认双方的连接ID(连接迁移就是基于连接ID实现的)
但3的QUIC协议不是和TLS分层,而是QUIC包含TLS,它在自己帧里会携带TLS的记录,同时QUIC使用TLS/1.3,需要一个RTT就可以完成建立连接和密钥协商
甚至,在第二次连接的时候,应用数据包可以和 QUIC 握手信息(连接信息 + TLS 信息)一起发送,达到 0-RTT 的效果。
连接迁移:基于TCP的HTTP协议,是通过四元组确定一条TCP连接,但当网络从4g切换到wifi时,意味ip变化,就必须重新建立连接,同时建立连接很慢,成本高
QUIC协议没有用四元组绑定连接,通过连接ID标记通信的两个端点,客户端和服务端可以选择一组ID标识自己,即使IP变化,只要保有上下文信息(如连接ID、TLS密钥),就可以复用连接,清除重连成本,连接迁移
所以, QUIC(基于UDP) 是一个在 UDP 之上的伪 TCP + TLS + HTTP/2 的多路复用的协议。很多网络设备无法识别QUIC包,就会当作UDP的包直接抛弃
TCP特点:面向连接、可靠、基于字节流
RPC是远程过程调用,是一种调用方式,大部分基于TCP,工作在应用层,如gRPC、thrift
比如HTTP调用本地方法,RPC直接调用远程服务器暴露的方法
电脑上的各种联网软件,作为客户端和服务端建立收发信息,可以使用自家的RPC协议C/S,但对于浏览器,不仅要能访问自家公司的服务器,还要访问其他公司的服务器,因此需要统一标准,HTTP用于统一B/S协议
服务发现:建立连接需要ip和端口,找到服务对应的ip和端口的过程,就是服务发现
底层连接形式:HTTP/1.1默认在建立TCP连接之后会一直保持这个连接,之后请求响应会复用这个连接,RPC,也是建立TCP长连接,但一般还会建连接池,请求量大的时候,建立多条连接放入连接池,发数据时就从池中取一条,用完放回去
Go会给HTTP加连接池
传输的内容:基于TCP传输Header和body。RPC,因为它定制化程度更高,可以采用体积更小的 Protobuf 或其他序列化协议去保存结构体数据,同时也不需要像 HTTP 那样考虑各种浏览器行为,比如 302 重定向跳转啥的。因此性能也会更好一些,这也是在公司内部微服务中抛弃 HTTP,选择使用 RPC 的最主要原因。
HTTP/2可能比很多RPC好,gRPC底层就是HTTP/2,但因为HTTP/2出来晚,很多公司内部RPC跑了很多年,就不改了
在用户不做任何操作的情况下,网页能收到消息并发生变更。
扫码登录的简单场景可以用,但网络游戏一般有大量数据从服务器主动推送到客户端
使用WebSocket
全双工:同一时间内,双方都可以主动向对方发送数据
HTTP/1.1,同一时间里,客户端和服务器只能有一方主动发送数据,半双工,因为没必要全双工,不搞游戏
为了支持游戏,需要一个新的应用层协议基于TCP的WebSocket
建立WebSocket连接
本来用HTTP,打开页游后需要切换WebSocket
浏览器会在TCP三次握手建立连接之后,统一使用HTTP协议先进行一次通信
数据包在WebSocket中叫帧
WebSocket的数据格式也是数据头(内含payload长度) + payload data 的形式。因为TCP协议本身是全双工,直接用纯裸TCP会有粘包问题,因为不知道边界,所以一般在消息头包含消息体长度
WebSocket完美继承TCP全双工,适用于服务器和客户端频繁交互的场景,游戏/聊天室/飞书网页协同办公
TCP是面向连接的、可靠的、基于字节流的传输层通信协议
面向连接:一对一连接,UDP可以一个主机同时向多个主机发送消息(一对多)
用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括Socket、序列号和窗口大小称为连接
建立一个TCP连接需要客户端和服务端达成三个信息的共识:
TCP面向字节流,UDP面向报文,是因为操作系统对TCP和UDP协议的发送方机制不同,问题原因在于发送方
UDP面向报文:UDP协议传输消息时,操作系统不会对消息进行拆分,组装好UDP头部后直接交给网络层处理,所以UDP报文中的数据部分就是完整的用户消息,每一个UDP报文就是一个用户消息的边界,这样接收方读一个UDP报文就能读取到完整的用户消息
操作系统接收到UDP报文后,会先加入队列中(队列的每一个元素就是一个UDP报文,用户调用读数据的时候,就会从队列取出一个数据,然后从内核里拷贝给用户缓冲区
TCP面向字节流:TCP协议传输消息时,消息可能被操作系统分组成多个TCP报文进行传输,这是接收方的程序如果不知道消息的长度(边界),就无法读出一个有效的消息,因为用户消息被拆分为多个TCP报文后,就不能像UDP一样一个UDP报文代表一个完整的用户消息
发送不一定立刻,因为还取决于发送窗口、拥塞窗口、当前缓冲区大小等
我们不能认为一个用户消息对应一个 TCP 报文,正因为这样,所以 TCP 是面向字节流的协议。
当两个消息的某个部分内容被分到同一个 TCP 报文时,就是我们常说的 TCP 粘包问题,这时接收方不知道消息的边界的话,是无法读出有效的消息。
要解决这个问题,要交给应用程序。
可靠的:保证一个报文一定能够到达接收端
字节流:用户消息通过TCP传输时,可能会被操作系统分组成多个TCP报文,如果接收方不知道消息边界就读不出一个有效的信息,TCP报文是有序的,前一个报文没收到时,即使收到后面的报文,也不能交给应用层,同时对重复的TCP报文自动丢弃
源端口、目标端口,序列号(建立连接时计算机生成随机数为初始值,每发送一次,就累加一次大小, 解决包乱序的问题、确认应答号(指下一次期望收到的数据序列号,发送端收到这个确认应答之后,可以认为在这个序号之前的数据都已经被正常接收,解决丢包问题,控制位:
此时,如果服务端一直不给客户端发送数据,就永远不知道客户端故障,此时服务端一直ESTABLISH,占用系统资源
所以TCP增加保活机制:原理:
定义一个时间段,在这个时间段如果没有任何连接的活动,TCP保活就开始启用,每隔一个时间间隔,发送一个探测报文,数据很少,如果连续几个探测报文都没得到响应,则认为当前连接已经死亡,系统内核将错误信息通知上层,默认保活2小时(2小时内没连接活动就启用保活),检测间隔75秒,9次无响应就认为是不可达
如果保活发现对方正常响应,就重置保活时间,如果客户端宕机,TCP的保活发送到客户端,会产生一个RST,如果服务端宕机(不是进程崩溃,进程崩溃后操作系统在回收进程资源时,会发送FIN,但宕机就无法感知,所以需要保活探测对方是不是主机宕机),此时多次无响应会报告死亡
因为TCP保活检测时间长,可以在应用层实现一个心跳机制
如果只使用IP分片,当一个IP分片丢失时,整个IP报文的所有分片都要重传,但IP没有超时重传,由TCP负责超时重传,会重发整个TCP报文(头部+数据),也就是该IP分片对应的TCP报文,所以在IP层分片传输没有效率,TCP在建立连接时通常要协商双方的MSS值,TCP层发现数据超过MSS时,就会先分片,此时由它形成的IP包长度不会超过MTU,也就不用IP分片了
TCP分片之后,进行重发也是MSS为单位,而不用重传所有分片
UDP利用ip提供无连接的通信服务
UDP和TCP区别:
TCP 和 UDP 应用场景:
TCP:FTP文件传输、HTTP/HTTPS
UDP:包总量少的通信(如DNS、SNMP),视频音频、广播通信
UDP没有首部长度字段是因为UDP首部长度不会变化,不用记录
TCP没有包长度字段:
TCP数据长度=IP总长度 - IP首部长度 - TCP首部长度
UDP长度也可以这样算,但还是存在包长度字段,可能因为为了网络设备硬件设计方便,保证首部长度是4字节的整数倍,或者因为之前UDP不是基于IP协议
可以
传输层端口的作用是为了区分同一个主机上不同应用程序的数据包
所以并不冲突
第三次握手可以携带数据,前两次握手不能携带数据,三次握手后,都处于ESTABLISHED
因为三次握手可以保证双方都具有接收和发送的能力,主要原因是三次握手可以防止历史连接的建立,还有是为了同步双方的序列号和避免资源浪费:防止历史连接建立是因为如果客户端先发syn后直接宕机,此时syn被阻塞,重启客户端之后再次发syn,此时服务端先收到了之前阻塞的syn,两个syn的序列号不同,然后服务端返回对旧syn的确认,客户端收到之后发现这个确认号不是自己期望收到的,就会返回RST报文,服务端收到RST就断开连接,新syn到了之后就又会建立连接,如果是两次握手的话,服务端第一次收到旧syn就可以给客户端发送连接了,虽然客户端会发送RST,但服务端已经把数据发出去了,服务端肯定会建立这个历史连接然后浪费资源发送数据。同步双方的序列号是因为双方都是一来一回,都要得到应答回应,所以是三次握手;避免资源浪费就是避免2次连接服务端在旧连接基础上直接发送数据,浪费资源
四次连接是因为没有必要,相当于服务端返回syn和ack分两次
两次握手也可以根据上下文信息丢弃 syn 历史报文,我记着两次握手没有具体实现,应该可以这样
初始序列号 ISN 是时钟+对四元组的Hash算法
客户端建立连接时先发SYN,然后进入SYN_SENT状态,之后如果收不到服务端的SYN-ACK,就会触发超时重传,重传SYN(序列号也一样),Linux超时重传默认5次,每次超时时间是上一次二倍,总耗时1分钟,次数到最大次数之后,如果在最后超时时间还是没有SYN-ACK,就会在超时时间之后断开连接
服务端收到客户端第一次握手后,会返回SYN-ACK给客户端,此时服务端进入SYN_RCVD状态
第二次握手的SYN-ACK:
如果第二次握手丢失,客户端可能认为自己的SYN丢了,就触发超时重传,因为第二次握手包含服务端的SYN,所以当客户端收到后,需要给服务端发送ACK,服务端才认为SYN被客户端收到了,但第二次丢失后,对于服务端,会触发超时重传,重传SYN-ACK,和客户端策略一致
所以当第二次握手丢失,客户端和服务端都会重传
客户端收到服务端的SYN-ACK后,会给服务端回一个ACK,此时客户端状态进入ESTABLISH
因为是对第二次SYN的ACK,所以当第三次握手丢失,服务端一直收不到ACK,就会触发服务端的超时重传
ACK不会有重传,ACK丢失,需要对方重传对应报文
攻击者伪造不同IP的SYN报文,服务端每收到一个SYN报文,就进入SYN_RCVD状态,但服务端发出去的ACK+SYN无法得到ACK应答,时间长就会沾满服务端的半连接队列
TCP半连接和全连接
服务端接收到客户端的SYN后,就会创建一个半连接对象,加入内核的SYN队列,接着发送SYN+ACK给客户端,等待客户端回应ACK,服务端收到ACK后,从SYN队列中取出一个半连接对象,创建一个新的连接对象放入Accept队列,应用调用accept()的socket接口,从Accept队列中取出连接对象
半连接和全连接都有最大长度限制,超过限制后,默认会丢弃报文
SYN攻击最直接就是把TCP半连接队列打满,之后的SYN报文就会被丢弃
为了避免SYN攻击,有四种方法:
调大netdev_max_backlog
网卡接收数据包的速度大于内核处理速度时,会有一个队列保存这些数据包,调大该队列的值
增大TCP半连接队列:同时增大3个参数
开启tcp_syncookies:开启就可以在不使用SYN半连接队列的情况下建立连接,绕过半连接,过程是:SYN队列满后,后续的SYN包不丢弃,根据算法算出一个cookie,放到第二次握手报文的序列号里,然后第二次握手给客户端,服务端接到应答后,会检查ACK包合法性,合法就放入Accept队列,最后调用accept()接口从Accept队列取出连接。该参数有三个值,0关闭,1仅当SYN半连接放不下再开启,2无条件开启,应对SYN攻击就设置为1
减少SYN+ACK重传次数:大量处于SYN_REVC的TCP连接,会一直重传SYN+ACK,当重传超过次数达到上限后,就会断开连接,所以减少fwd重传次数,让更快断开连接
开启tcp_tw_recycle参数,并且在NAT环境下,造成SYN报文被丢弃
同时开启recycle和timestamps选项,就开一种叫per-host的 PAWS机制:作用是防止TCP包中的序列号发生绕回
PAWS,所有TCP包发送都会带上发送时的时间戳,PAWS要求双方都维护最近一次收到数据包的时间戳,每收到一个数据包就会和时间戳比较,如果发现收到的数据包中时间戳不是递增的,则表示该数据包是过期的,就会直接丢弃这个数据包。
**per-host:**对【对端IP做PAWS】检查,而不是对四元组做PAWS检查
TCP两个队列满了(半连接队列和全连接队列),造成SYN报文被丢弃
可以
accept不参与三次握手,只负责从TCP全连接队列取出一个已经建立连接的socket,用户层通过accept系统调用拿到了已经建立连接的socket,就可以对该socket进行读写操作
可以
客户端可以自己连接自己,也可以两个客户端同时向对方发出请求建立连接(TCP同时打开),这两个情况都没有服务端参与,没有listen,就TCP连接
已经建立TCP连接,客户端中途宕机,服务端一直处于Established状态,客户端恢复后,向服务端建立连接
TCP连接是由四元组唯一确定的
要伪造一个能关闭 TCP 连接的 RST 报文,必须同时满足「四元组相同」和「序列号是对方期望的」这两个条件。
伪造使用工具
双方都可以主动断开连接
开始都处于ESTABLISHED
客户端准备断开连接,先发FIN,客户端进入FIN_WAIT_1
服务端收到FIN后,发送ACK,服务端进入CLOSE_WAIT
客户端收到ACK后,进入FIN_WAIT_2
服务端处理完数据后,发送FIN,之后服务端进入LAST_ACK
客户端收到FIN,回一个ACK,之后可恢复进入TIME_WAIT
服务端收到ACK,进入CLOSE,服务端完成连接关闭
客户端经过2MSL一段时间后,自动进入CLOSE,客户端完成连接关闭
每个方向都需要一个FIN和一个ACK,主动关闭连接的,才有TIME_WAIT状态
关闭连接时,客户端向服务端发送FIN,仅仅标识客户端不再发送数据,但是还能接收数据
服务端收到客户端的FIN,先回一个ACK,而服务端可能还有数据处理和发送,等服务端不在发送数据,才发送FIN给客户端表示同意现在关闭连接
服务端需要等待完成数据的发送和处理,服务端的ACK一般是分开,所以是四次挥手,但在特定情况下可以三次挥手
是否发送第三次挥手控制权不在内核,在被动关闭方的应用程序,因为应用程序可能还要发送数据,所以由应用程序决定什么时候调用关闭连接函数,调用后内核会发送FIN报文,但FIN报文不一定必须是调用关闭连接的函数才发送,因为可能进程退出了,内核都会发送FIN
关闭
如果客户端是用close关闭,收到服务端发送数据后会回RST
如果用shutdown关闭发送方向,收到服务端数据后可以正常读取,优雅关闭。如果关闭读取方向,内核不会发送FIN,因为发送FIN一位不再发任何数据,但没关发送方向证明还有发送能力
被动关闭方在TCP挥手过程中,如果没数据发送,同时没开启TCP_QUICKACK(默认没开启等于使用TCP延迟确认机制),那么第2、3次挥手就会合并传输,出现三次挥手
客户端先发FIN,表示想断开连接,此时客户端会进入FIN_WAIT_1
如果能收到服务端的ACK就会变成FIN_WAIT2
但如果第一次丢失,客户端收不到服务端的ACK,就会触发超时重传
如果还是没收到第二次挥手,就直接进入CLOSE
服务端收到客户端第一次挥手后,先回一个ACK,此时服务端进入CLOSE_WAIT
因为ACK报文不会重传,所以如果服务端第二次挥手失效,客户端就会触发超时重传,一直都没收到第二次ACK时,客户端就会断开连接
当客户端收到第二次挥手,客户端就会处于FIN_WAIT2状态,此时还要等待服务端的第三次挥手,服务端的FIN,但对于CLOSE函数关闭的连接,由于无法发送和接收数据,所以FIN_WAIT2不会持续太久,默认60秒,对于close关闭的连接,如果60秒后还没有收到FIN,客户端就连接就会直接关闭
但如果主动关闭方使用shutdown关闭连接,指定只关闭发送方向,而接收方向没有关闭,就意味主动关闭方还可以接收数据
此时如果关闭方一直没收到第三次握手,那么主动关闭方就会一直处于FIN_WAIT2的状态,死等
服务端收到客户端的FIN后,内核会自动回复ACK,同时连接处于CLOSE_WAIT,此时,内核没有权力替代进程关闭连接,必须进程主动调用close函数触发服务端发送FIN,同时进入LAST_ACK,等待客户端的ACK来确认连接关闭,如果一直收不到这个ACK,服务端就会重发FIN,如果客户端一直没收到第三次挥手的FIN,因为客户端是close函数关闭连接,处于FIN_WAIT2有时长限制,一直收不到就会断开连接
客户端收到服务端第三次挥手的FIN后,就会回ACK,此时客户端进入TIME_WAIT
在Linux中,TIME_WAIT会持续2MSL后进入关闭状态,服务端在没收到ACK前还是处于LAST_ACK
如果第四次挥手的ACK没到达服务端,服务端就会重发FIN报文
客户端收到第二次挥手后,就会进入TIME_WAIT状态,开启2MSL的定时器,如果途中再次收到第二次挥手FIN,就会重置定时器,2MSL后客户端就会断开连接
MSL是报文最大生存时间
如果重传FIN丢失,说明之前ACK也丢失,没应对这种情况,重传FIN丢失时,客户端不知道服务端是否接收到ACK还是丢失重传FIN,客户端会根据TIMEWATI结束后关闭连接,如果服务器还是重传FIN,并且在客户端结束连接后到达客户端,客户端会返回RST,服务器收到后会异常断开
2MSL相当于至少允许报文丢失一次,例如:如果ACK在一个MSL内丢失,这样服务端的FIN会在第2个MSL内到达
第一个MSL是为了等自己发出的最后一个ACK从网络中消失,第二个MSL为了在对端收到ACK之前等可能重传的FIN报文从网络消失。
重新计时:2MSL是从客户端接收到FIN后发送ACK开始计时的,如果在TIME_WAIT时间内,客户端的ACK没有传输到服务端,客户端又接收到了服务端重发的FIN,那么2MSL就会重新计时,为了保证发第二个FIN的时候客户端的TIME_WAIT还没有结束,还可以ACK,此时重新刷新2MSL
打开 net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_timestamps 选项;
开启后就可以复用处于TIME_WAIT的socket为新连接所用,第一个选项只能用于客户端(发起连接),因为开启后,在调用connect()时,内核会随机找一个time_wait超过一秒的连接给新的连接,但前提是打开第二个参数时间戳,这个字段在TCP头部的选项里,8字节,前4保存发送数据时间,后4表示最近一次接收数据时间,此时重复数据包会因为时间戳过期被丢弃
net.ipv4.tcp_max_tw_buckets
TIME_WAIT一旦超过这个值,会将后面的TIME_WAIT连接状态重置
程序中使用 SO_LINGER ,应用强制使用 RST 关闭。
调用close()后,会立刻发送RST,该TCP会跳过四次挥手,直接关闭,危险
如果服务端要避免过多的 TIME_WAIT 状态的连接,就永远不要主动断开连接,让客户端去断开,由分布在各处的客户端去承受 TIME_WAIT。
这个要看SYN的序列号和时间戳是否合法,因为处于TIME_WAIT的连接收到SYN后,会判断SYN的序列号和时间戳是否合法
都开启TCP时间戳就会再判断时间戳是否合法,否则就只是判断序列号
合法SYN:客户端SYN的序列号比服务端期望的下一个序列号大,并且SYN的时间戳比服务端最后收到报文的时间戳大
TIME_WAIT连接收到合法SYN后,就会重用四元组连接,跳过2MSL转变为SYN_RECV状态,接着建立连接
非法SYN:客户端SYN的序列号比服务端期望的下一个序列号小,或者SYN的时间戳比服务端最后收到报文的时间戳小
会不会断开,关键看 net.ipv4.tcp_rfc1337 这个内核参数(默认情况是为 0):
TCP实现可靠传输方式之一,通过序列号和确认应答号
针对TCP数据包丢失,用重传机制解决
超时重传:发送数据时设定定时器,超过指定时间后没收到对方的ACK确认应答报文,就重发该数据
TCP会在数据包丢失和确认应答丢失时超时重传
RTT是包的往返时间
RTO表示超时重传时间,应该略大于报文往返RTT的值,因为报文往返RTT动态变化,所以RTO也应该动态变化
问题是超时周期可能相对较长,用快速重传解决超时重传的时间等待
快速重传:不以时间为驱动,而以数据为驱动
当收到三个相同的ACK就会在定时器过期之前,重传丢失的报文段
只解决超时问题,但还有重传的时候,是传一个还是传所有
为了解决不知道重传哪些TCP报文,就有SACK方法
SACK:选择性确认
这种方式在TCP头部选项加一个SACK,将已收到的数据信息发给发送方,这样客户端就直到哪些数据收到了,哪些数据没收到,就可以只重传丢失的数据
如果要支持SACK,必须双方都支持
Duplicate SACK:D-SACK,主要使用SACK告诉发送方客户端哪些数据被重复接收了
例如:客户端没收到响应的ACK,就重发数据包,服务端发现数据是重复的,就回SACK,值就是重复接收的包序列号,这样发送方就知道数据没丢,是接收方的ACK丢了
好处是可以让发送方知道是发出的包丢了还是接收方回应的ACK丢了,linux2.4后默认打开
为了解决每次发送的数据都要进行一次确认应答的效率低(包往返时间越长,通信效率越低)
所以引入窗口(实际是操作系统开辟的一个缓存空间,大小会被操作系统调整,发送方在等待确认应答返回之前,必须在缓冲区保留已发送的数据,如果按期收到确认应答,就从缓存区清除数据),窗口大小就是无需等待确认应答,可以继续发送数据的最大值
累积确认/累积应答:例如服务端返回ack600丢失,不会重发,之后返回ack700,只要客户端收到ack700,就意味700之前所有数据都被接收方收到了
TCP的Window字段是窗口大小,是接收方告诉发送端自己还有多少缓冲区可以接收数据,于是发送端就根据这个接收端的处理能力发送数据,不会导致接收端处理不过来,所以,窗口大小是由接收方的窗口大小决定的
发送方发送的数据大小不能超过接收方的窗口大小,否则接收方就无法正常接收到数据
发送方滑动窗口
数据发完之后,可用窗口就为0,在没收到ACK之前就无法继续发送数据
当收到发送窗口的确认应答后,如果发送窗口没有变化,发送窗口就向右移动接收数据的字节,此时可用窗口又增大了
TCP滑动窗口用三个指针跟踪每个类别的字节 ,两个指针是绝对指针(特定序列号),一个是相对指针(需要偏移)
接收方滑动窗口
不相等
接收窗口大小约等于发送窗口大小
因为滑动窗口不是一成不变的
TCP报文发出去后不会立刻从内存删除,因为可能要重传,报文存放在内核缓冲区
如果每发一个数据都接受对应ACK,效率低(网络吞吐量低),解决方法是批量发送报文,批量确认报文
发送方要考虑接收方的接受能力控制发送的数据量
接收窗口不是不变的,接收方会把当前可接收大小放在窗口字段
不考虑拥塞控制时发送方窗口约等于接收方窗口
TCP窗口字段2个字节,最多表达65535字节大小的窗口(64kb),不够用的话,在选项字段定义窗口扩大因子,最大值可以到1GB,使用窗口扩大双方都必须发送这个选项
发送方发送数据时要考虑接收方的处理能力
流量控制:TCP提供,让发送方根据接收方的实际接收能力控制发送的数据量,让接收方指明希望从发送方接收数据的大小来进行流量控制
如果服务端接收到大量字节,但应用程序只读取一少部分字节,剩余字节会占用接收缓冲区,所以接收窗口会收缩,返回确认消息时,会同步窗口大小,多次这样,窗口都会收为0,当发送方窗口变为0时,发送方实际会定时发送窗口探测报文,来判断接收方的窗口是否发生变化
窗口关闭:窗口为0时,就会阻止发送方给接收方传递数据,直到窗口变为非0
接收方通过ACK通告发送方窗口大小,如果发送窗口关闭,接收方处理好数据后,会向发送方通告一个窗口非0的ACK,但如果这个ACK丢失,就会造成死锁(发送方一直等待非0通知,接收方一直等待发送方数据。
解决窗口关闭时的死锁方法:TCP为每个连接设有一个持续定时器,只要TCP连接的一方收到对方的零窗口通知,就启动持续计时器,如果持续计时器超时,就会发送窗口探测报文,对方收到后会返回自己的接收窗口大小,如果接收窗口仍然为0,就重启持续计时器,不是0就打破死锁,探测一般3次,每次30-60秒,如果3次之后接收窗口还是0,有的TCP实现就会发RST报文中断连接
如果服务端资源很紧张,操作系统可能会直接减少缓冲区大小,这时候,如果应用程序无法读取缓冲数据,就可能导致数据包丢失(服务端接收窗口被操作系统减少,在发送方收到接收方通告窗口报文之前发送方此时根据自己的窗口发送数据,服务端接收不了那么大的数据,就会直接丢失数据包。先减少了缓存,再收缩窗口,就会出现丢包现象。为了防止这种情况发生,TCP 规定是不允许同时减少缓存又收缩窗口的,而是采用先收缩窗口,过段时间再减少缓存,这样就可以避免了丢包情况。
糊涂窗口综合症:接收方总是来不及处理数据,在窗口快到0时,发送方还是会发那一点字节 ,但TCP+IP头有40个字节
解决方法:让接收方不通告小窗口(接收方策略窗口大小小于min(MSS、缓存空间/2),就会向发送方通告窗口为0让发送方避免发送小数据(使用 Nagle 算法延时处理,发送方策略只有满足(等到窗口大小大于MSS同时数据大小大于MSS)或(收到之前发送数据的ACK包))才可以发送数据,都不满足就不发数据
两方都满足才能避免糊涂窗口综合症,Nagle默认打开,如果需要小数据包交互的场景需要关闭Nagle算法
流量控制只是避免发送方的数据填满接收方的缓存
在网络拥堵时,如果继续发送大量数据包,可能会导致数据包时延、丢失等,此时TCP就会重传数据,但重传会导致网络负担加重,会导致更大的延迟以及更多的丢包,此时就会恶性循环
拥塞控制:避免发送方的数据填满整个网络
拥塞窗口:为了在发送方调节发送数据的量,是发送方维护的一个状态变量,会根据网络的拥塞程度动态变化
发送窗口的为拥塞窗口和接收窗口的最小值
网络出现拥堵(发送方没在规定时间收到ACK,也就是超时重传),拥塞窗口就减小,反之就增大
拥塞控制4个算法
慢启动
TCP刚建立连接后,首先是慢启动(一点一点提高发送数据包的数量),**当发送方每收到一个 ACK,拥塞窗口 cwnd 的大小就会加 1。**发包指数性增长(1,2,4,8),达到慢启动门限就会使用拥塞避免算法
拥塞避免
一般65535字节,每收到一个ACK,拥塞窗口就增长1,变成线性增长(8,9,10)
这时就会慢慢进入拥塞状态,就会出现丢包现象,就需要对丢失的包进行重传
触发重传就会进入拥塞发生
拥塞发生
重传分为超时重传和快速重传
超时重传会使用拥塞发生,此时慢启动门限会变为拥塞窗口的一半,拥塞窗口重置为1
快速重传的拥塞发生算法:TCP认为不严重,拥塞窗口变为一半,慢启动门限变为拥塞窗口,然后进入快速恢复算法
快速恢复
快速重传和快速恢复一般同时使用,快速恢复是认为你还能收到3个重复ACK说明网络没问题
在快速恢复之前,拥塞窗口变为一半,慢启动门限变为拥塞窗口
步骤:
在 TCP 三次握手的时候,Linux 内核会维护两个队列,分别是:
服务端收到sync之后,内核会将连接存储在半连接队列,返回客户端syn+ack,客户端返回ack,服务端收到ack之后,内核会将连接从半连接队列删除,创建新的连接加入accept队列,等待进程调用accept函数时取出连接、
全连接和半连接队列都有最大长度限制,超过后,内核会直接丢弃或返回RST包
**全连接溢出:**服务端处理大量并发请求时,如果全连接队列过小就容易溢出,后续请求会被丢弃(默认行为,也可以发送RST)
半连接溢出:对服务端一直发syn包,但是不进行第三次握手ack,就可以使服务端有大量处于SYN_RECV的TCP连接, SYN 洪泛、SYN 攻击、DDos 攻击。
策略:
开启 tcp_syncookies 是缓解 SYN 攻击其中一个手段。开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接
syncookies的做法:服务端根据当前状态计算一个值,放在恢复的syn+ack里,客户端收到ack后,取出值验证,合法就认为建立连接成功
syncookies 参数主要有以下三个值:
那么在应对 SYN 攻击时,只需要设置为 1 即可:
客户端优化:SYN 的全称就叫 Synchronize Sequence Numbers(同步序列号),等待服务端返回syn+ack时会一直重传,默认5次,一共1分钟左右,所以可以调整重传次数,比如内网通信时可以适当降低,让赶快出错
服务端优化:主要谈半连接队列,重发次数,syncookies参数,队列大小,连接满之后的策略(丢弃(应对突发流量)还是RST
如何绕过三次握手:三次握手建立连接的结果就是,HTTP请求必须在一个RTT(从客户端到服务端的一个往返时间)后才能发送,linux3.7之后提供了TCP Fast Open功能(直接用Cookie建立连接同时发送数据,首次,客户端发送SYN,包含Fast Open选项,该选项Cookie为空,服务端返回Cookie放到Cookie选项中,客户端缓存Cookie,之后就可以直接发送数据带上Cookie,支持Fast Open的服务端会对Cookie进行校验,有效就返回Syn+ack,随后服务端发送数据,Cookie无效就会直接丢弃数据,随后的Syn+ack只确认序列号。这样服务端可以在握手完成过之前发送数据)
客户端在请求并存储了 Fast Open Cookie 之后,可以不断重复 TCP Fast Open 直至服务器认为 Cookie 无效(通常为过期)
TCP Fast Open 功能需要客户端和服务端同时支持,才有效果。
过程:
优化
主动方优化:如果进程收到RST就直接关闭连接,是暴力关闭。安全关闭连接需要四次挥手,由进程调用close(完全断开连接,不能发也不能收)和shutdown(可以控制关读(接收缓冲区数据被抛弃,后续接收到数据会ACK但会丢弃)还是关写(半关闭,发送缓冲区还有未发送的数据就会直接发送,并发送FIN)函数发起FIN
调整主动方的FIN报文重传次数,当进程调用了 close 函数关闭连接,此时连接就会是「孤儿连接」,孤儿连接过多时会导致系统资源长时间被占用,如果数量大于一个值,新增的孤儿连接就不再走四次挥手而是RST
TIME_WAIT状态:防止历史连接被后面相同四元组接收(2MSL 时长,这个时间足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。),保证被动关闭连接的一方能被正确关闭(等待足够的时间以确保最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭。)
被动方优化:双方同时挥手时,收到FIN后会进入CLOSING,体态了FIN_WAIT_2,接着都回复ACK,进入TIME_WAIT,之后关闭。最后未收到ACK的重发次数
TCP连接是内核维护的,内核为每个连接建立内存缓冲区
带宽是单位时间内的流量(速度)
缓冲区单位是字节,速度*时间得到字节
带宽时延积,决定网络中飞行报文大小,BDP = RTT * 带宽
比如最大带宽是 100 MB/s,网络时延(RTT)是 10ms 时,意味着客户端到服务端的网络一共可以存放 100MB/s * 0.01s = 1MB 的字节。
这个 1MB 是带宽和时延的乘积,所以它就叫「带宽时延积」(缩写为 BDP,Bandwidth Delay Product)。同时,这 1MB 也表示「飞行中」的 TCP 报文大小,它们就在网络线路、路由器等网络设备上。如果飞行报文超过了 1 MB,就会导致网络过载,容易丢包。
由于发送缓冲区大小决定了发送窗口的上限,而发送窗口又决定了「已发送未确认」的飞行报文的上限。因此,发送缓冲区不能超过「带宽时延积」。
发送缓冲区与带宽时延积的关系:
所以,发送缓冲区的大小最好是往带宽时延积靠近。
发送缓冲区是自行调节的,当发送方发送的数据被确认后,并且没有新的数据要发送,就会把发送缓冲区的内存释放掉。
接收缓冲区可以根据系统空闲内存的大小来调节接收窗口:
发送缓冲区的调节功能是自动开启的,而接收缓冲区则需要配置 tcp_moderate_rcvbuf 为 1 来开启调节功能:
TCP 可靠性是通过 ACK 确认报文实现的,又依赖滑动窗口提升了发送速度也兼顾了接收方的处理能力。
可是,默认的滑动窗口最大值只有 64 KB,不满足当今的高速网络的要求,要想提升发送速度必须提升滑动窗口的上限,在 Linux 下是通过设置
tcp_window_scaling为 1 做到的,此时最大值可高达 1GB。滑动窗口定义了网络中飞行报文的最大字节数,当它超过带宽时延积时,网络过载,就会发生丢包。而当它小于带宽时延积时,就无法充分利用网络带宽。因此,滑动窗口的设置,必须参考带宽时延积。
内核缓冲区决定了滑动窗口的上限,缓冲区可分为:发送缓冲区 tcp_wmem 和接收缓冲区 tcp_rmem。
Linux 会对缓冲区动态调节,我们应该把缓冲区的上限设置为带宽时延积。发送缓冲区的调节功能是自动打开的,而接收缓冲区需要把 tcp_moderate_rcvbuf 设置为 1 来开启。其中,调节的依据是 TCP 内存范围 tcp_mem。
但需要注意的是,如果程序中的 socket 设置 SO_SNDBUF 和 SO_RCVBUF,则会关闭缓冲区的动态整功能,所以不建议在程序设置它俩,而是交给内核自动调整比较好。
有效配置这些参数后,既能够最大程度地保持并发性,也能让资源充裕时连接传输速度达到最大值。
粘包问题是不知道用户消息的边界,接收方需要通过边界划分有效的用户消息
解决的三种分包方式:
TLS1.2握手4次,2个RTT
TLS1.3用1个RTT,1.3还可以会话恢复,重连需要0-RTT
「HTTPS 中的 TLS 握手过程可以同时进行三次握手」,这个场景是可能存在到,但是在没有说任何前提条件,而说这句话就等于耍流氓。需要下面这两个条件同时满足才可以:
HTTP 协议采用的是「请求-应答」的模式,客户端发请求,服务端才响应,1.1默认开启
HTTP 的 Keep-Alive 也叫 HTTP 长连接,该功能是由「应用程序」实现的,可以使得用同一个 TCP 连接来发送和接收多个 HTTP 请求/应答,减少了 HTTP 短连接带来的多次 TCP 连接建立和释放的开销。
TCP 的 Keepalive 也叫 TCP 保活机制,该功能是由「内核」实现的,当客户端和服务端长达一定时间没有进行数据交互时,内核为了确保该连接是否还有效,就会发送探测报文,来检测对方是否还在线,然后来决定是否要关闭该连接。
三次握手协商连接ID,后续传输只需要固定连接ID,从而实现连接迁移
日常传数据的时候使用Packet Number严格递增(为了解决TCP重传的歧义问题,TCP重传时序列号和原始报文一样),QUIC支持乱序确认(丢包重传后的号码也递增,就不会因为丢包重传将当前窗口阻塞在原地,解决队头阻塞)
序列号=上一次发送的序列号+len(数据长度),特殊,如果上一次发送的报文是SYN或FIN,len=1
确认号=上一次收到的报文的序列号+len(数据长度),特殊,SYN或FIN,len=1
因为发送的第一个数据报文的序列号和确认号和第三次握手的序列号和确认好一样,同时报文将ACK为1,所以服务端收到这个报文可以正常建立连接,然后正常接收这个数据包
IP是在主机之间通信用的,MAC是实现直连的两个设备之间的通信。IP负责在没直连的两个网络之间进行通信
IP分类:ABC分为网络号和主机号两个部分,主机号全1为广播,全0指某个网络
判断时第1位是0就是A类,不是就继续向下判断,第2位是0就是B类。。。
IP缺点:同一网络下没有地址层次,缺少地址灵活性。C类最大主机太少,B类最大主机太多。用CIDR无分类地址解决
没有地址分类的概念,32位ip被分为网络号和主机号
a.b.c.d/x,/x表示前x位是主机号,或使用子网掩码,和IP与运算
子网掩码还可以划分子网:将主机地址分为子网网络地址和子网主机地址
C类地址从8位主机号取2位作为子网网络地址,可以划分四个子网
以太网的数据链路的最大传输单元MTU是1500字节,重组时由目标主机重组,路由器不重组
128位,16位一组,连续0可以用::隔开,但最多只有1次两个冒号
域名解析
浏览器先查自己缓存,没有就找操作系统缓存,没有就检查本地域名解析文件hosts,没有就向DNS服务器查询
已知IP地址查找下一跳的MAC地址
已知MAC地址求IP地址
DHCP获取动态IP地址:使用UDP广播通信
网络地址转NAT
互联网控制报文协议,报告消息
ICMP 主要的功能包括:确认 IP 包是否成功送达目标地址、报告发送过程中 IP 包被废弃的原因和改善网络设置等。
在 IP 通信中如果某个 IP 包因为某种原因未能达到目标地址,那么这个具体的原因将由 ICMP 负责通知
可以
当发现目标IP是外网IP时,会从"真网卡"发出。
当发现目标IP是回环地址时,就会选择本地网卡
断网的情况下,网卡已经不工作了
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:
uniqueInstance 分配内存空间uniqueInstanceuniqueInstance 指向分配的内存地址但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。
字节流字符流适配器
主要用于接口互不兼容的类的协调工作,IO 流中的字符流和字节流的接口不同,它们之间可以协调工作就是基于适配器模式来做的,更准确点来说是对象适配器。通过适配器,我们可以将字节流对象适配成一个字符流对象,这样我们可以直接通过字节流对象来读取或者写入字符数据。
AOP适配器
AOP中,每个类型的通知都有对应的拦截器,通知要通过对应的适配器,是配成MethodInterceptor接口类型的对象,通过调用getInterceptor,适配成MethodBeforeAdviceInterceptor
不改变原有对象,扩展功能
通过 BufferedInputStream(字节缓冲输入流)来增强 FileInputStream 的功能
通过组合替代继承扩展原始类功能,在继承关系复杂的场景实用
对于字节流来说, FilterInputStream (对应输入流)和FilterOutputStream(对应输出流)是装饰器模式的核心,分别用于增强 InputStream 和OutputStream子类对象的功能。
我们常见的BufferedInputStream(字节缓冲输入流)是FilterInputStream 的子类,BufferedOutputStream(字节缓冲输出流)、DataOutputStream等等都是FilterOutputStream的子类。
装饰器模式 更侧重于动态地增强原始类的功能,装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口。并且,装饰器模式支持对原始类嵌套使用多个装饰器。
适配器模式 更侧重于让接口不兼容而不能交互的类可以一起工作,当我们调用适配器对应的方法时,适配器内部会调用适配者类或者和适配类相关的类的方法,这个过程透明的。就比如说 StreamDecoder (流解码器)和StreamEncoder(流编码器)就是分别基于 InputStream 和 OutputStream 来获取 FileChannel对象并调用对应的 read 方法和 write 方法进行字节数据的读取和写入。
适配器和适配者两者不需要继承相同的抽象类或者实现相同的接口。
发布者发布消息时,参与订阅的订阅者会收到对应消息通知,原理是使用一个集合存储所有订阅类,发布消息的时候遍历这个集合,并调用集合中每个订阅者类的通知方法
Java实现发布订阅模式_java发布订阅模式-CSDN博客
代理模式的主要作用是扩展目标对象的功能,比如说在目标对象的某个方法执行前后你可以增加一些自定义的操作。
静态代理: 静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。
动态代理:动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。
jdk动态代理(InvocationHandler接口和Proxy类)
Proxy类中方法newProxyInstance(),用来生成一个代理对象
public static Object newProxyInstance(ClassLoader loader,
Class<?>[] interfaces, //被代理类实现的一些接口
InvocationHandler h) //实现了InvocationHandler接口的对象
throws IllegalArgumentException
{
......
}
要实现动态代理,需要实现InvocationHandler来自定义处理逻辑,动态代理对象调用方法时,会被转发到实现InvocationHandler接口类的invoke方法调用
public interface InvocationHandler {
/**
* 当你使用代理对象调用方法的时候实际会调用到这个方法
*/
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable;
}
通过Proxy类的newProxyInstance()创建的代理对象在调用方法时,实际会调用到实现InvocationHandler接口类的invoke()方法
步骤:
InvocationHandler并重写invoke方法,在invoke方法中会调用原生方法并自定义逻辑Proxy.newProxyInstance(ClassLoader loader,Class>[] interfaces,InvocationHandler h) 方法创建代理对象;cglib动态代理(通过继承方式),Spring中的AOP模块,如果目标对象实现了接口,默认采用jdk动态代理,否则采用cglib动态代理,cglib中MethodInterceptor接口和Enhancer类是核心
jdk动态代理的问题是只能代理实现了接口的类
你需要自定义 MethodInterceptor 并重写 intercept 方法,intercept 用于拦截增强被代理类的方法。
public interface MethodInterceptor
extends Callback{
// 拦截被代理类中的方法
public Object intercept(Object obj 被代理的对象, java.lang.reflect.Method method 需要增强的方法, Object[] args,MethodProxy proxy 调用原始方法) throws Throwable;
}
你可以通过 Enhancer类来动态获取被代理类,代理类继承了目标类,当代理类调用方法的时候,会被方法拦截器拦截,实际调用的是 MethodInterceptor 中的 intercept 方法。
步骤:
MethodInterceptor 并重写 intercept 方法,intercept 用于拦截增强被代理类的方法,和 JDK 动态代理中的 invoke 方法类似;Enhancer 类的 create()创建代理类的实例,然后调用方法;使用时需要添加依赖

纠正:插入排序的最好时间复杂度为 O(n) 而不是 O(n^2) 。
希尔排序的平均时间复杂度为 O(nlogn)
/*
稳定
每次比较相邻的元素,外层循环控制排序的轮数,每一轮将最大元素移到数组末尾
最佳o(n) 最差o(n^2) 平均o(n^2)
o(1)
*/
public static int[] bubbleSort(int[] arr) {
for (int i = 1; i < arr.length; i++) {
for (int j = 0; j < arr.length - i; j++) {
if (arr[j] > arr[j + 1]) {
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
}
}
}
return arr;
}
/*
不稳定
每次从未排序的部分选择最小的元素,放到已排序部分的末尾
都是o(n^2)
o(1)
*/
public static int[] selectionSort(int[] arr) {
for (int i = 0; i < arr.length - 1; i++) {
int minIndex = i;
for (int j = i + 1; j < arr.length; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
}
if (minIndex != i) {
int tmp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = tmp;
}
}
return arr;
}
/*
稳定
将未排序的元素逐个插入到已排序部分的正确位置,外循环控制排序轮数,从第二个元素开始插入到已排序的位置
最佳o(n) 最差o(n^2) 平均o(n^2)
o(1)
*/
public static int[] insertionSort(int[] arr) {
for (int i = 1; i < arr.length; i++) {
// 用在已排序部分
int preIndex = i - 1;
int current = arr[i];
// 在已排序部分查找正确位置,当前值更小就向前插入
while (preIndex >= 0 && current < arr[preIndex]) {
arr[preIndex + 1] = arr[preIndex];
preIndex -= 1;
}
// 将currentcha'ru'dao
arr[preIndex + 1] = current;
}
return arr;
}
/*
不稳定
也是一种插入排序,先将整个待排序的记录序列分割成若干子序列分别进行直接插入排序,基本有序后再对全体记录依次直接插入排序
最佳o(nlogn) 最差o(n^2) 平均o(nlogn)
o(1)
*/
public static int[] shellSort(int[] arr) {
int n = arr.length;
int gap = n / 2;
while (gap > 0) {
for (int i = gap; i < n; i++) {
int current = arr[i];
int preIndex = i - gap;
// 直接插入排序
while (preIndex >= 0 && arr[preIndex] > current) {
arr[preIndex + gap] = arr[preIndex];
preIndex -= gap;
}
arr[preIndex + gap] = current;
}
gap /= 2;
}
return arr;
}
/*
稳定
分治,先使每个子序列有序,再使子序列间有序
都是o(nlogn)
o(n)
*/
public static int[] mergeSort(int[] arr) {
// 边界,一个元素直接返回
if (arr.length <= 1) {
return arr;
}
int middle = arr.length / 2;
int[] arr1 = Arrays.copyOfRange(arr, 0, middle);
int[] arr2 = Arrays.copyOfRange(arr, middle, arr.length);
return merge(mergeSort(arr1), mergeSort(arr2));
}
public static int[] merge(int[] arr1, int[] arr2) {
int[] ans = new int[arr1.length + arr2.length];
int idx = 0, idx1 = 0, idx2 = 0;
while (idx1 < arr1.length && idx2 < arr2.length) {
if (arr1[idx1] < arr2[idx2]) {
ans[idx] = arr1[idx1];
idx1 += 1;
} else {
ans[idx] = arr2[idx2];
idx2 += 1;
}
idx += 1;
}
if (idx1 < arr1.length) {
while (idx1 < arr1.length) {
ans[idx++] = arr1[idx1++];
}
} else {
while (idx2 < arr2.length) {
ans[idx++] = arr2[idx2++];
}
}
return ans;
}
1.从序列中随机挑出一个元素,做为基准(pivot,这里选择序列的最左边元素作为基准);
2.重新排列序列,将所有比基准值小的元素摆放在基准前面,所有比基准值大的摆在基准的后面。该操作结束之后,该基准就处于数列的中间位置。这个操作称为分区(partition);
3.递归地把小于基准值元素的子序列和大于基准值元素的子序列进行上述操作即可。
public class QuickSort {
public static void quickSort(int[] arr) {
sort(arr, 0, arr.length - 1);
}
private static void sort(int[] arr, int left, int right) {
if (left < right) {
int pivotIdx = partition(arr, left, right);
sort(arr, 0, pivotIdx - 1);
sort(arr, pivotIdx + 1, right);
}
}
private static int partition(int[] arr, int left, int right) {
int idx = left + 1;
for (int i = idx; i <= right; i++) {
if (arr[left] > arr[i]) {
swap(arr, i, idx++);
}
}
swap(arr, left, idx - 1);
return idx - 1;
}
private static void swap(int[] arr, int idx1, int idx2) {
int tmp = arr[idx1];
arr[idx1] = arr[idx2];
arr[idx2] = tmp;
}
}
1.将待排序列(R0, R1, ……, Rn)构建成最大堆(最小堆);
2.将堆顶元素R[0]与最后一个元素R[n]进行交换,此时得到新的无序区(R0, R1, ……, Rn-1)和新的有序区(Rn),且满足R[0, 1, ……, n-1]<=R[n](>=R[n]);
3.由于调整后的新堆可能违反堆的性质,因此需要对当前无序区(R0, R1, ……, Rn-1)进行调整;
4.重复步骤2~3直到有序区的元素个数为n。
public class HeapSort {
private static int heapLen;
public static void heapSort(int[] arr) {
heapLen = arr.length;
for (int i = heapLen - 1; i >= 0; i--) {
heapify(arr, i);
}
for (int i = heapLen - 1; i > 0; i--) {
swap(arr, 0, heapLen - 1);
heapLen--;
heapify(arr, 0);
}
}
private static void heapify(int[] arr, int idx) {
int left = idx * 2 + 1, right = idx * 2 + 2, largest = idx;
if (left < heapLen && arr[left] > arr[largest]) {
largest = left;
}
if (right < heapLen && arr[right] > arr[largest]) {
largest = right;
}
if (largest != idx) {
swap(arr, largest, idx);
heapify(arr, largest);
}
}
private static void swap(int[] arr, int idx1, int idx2) {
int tmp = arr[idx1];
arr[idx1] = arr[idx2];
arr[idx2] = tmp;
}
}
1.找出数组中的最大值maxVal和最小值minVal;
2.创建一个计数数组countArr,其长度是maxVal-minVal+1,元素默认值都为0;
3.遍历原数组arr中的元素arr[i],以arr[i]-minVal作为countArr数组的索引,以arr[i]的值在arr中元素出现次数作为countArr[a[i]-min]的值;
4.遍历countArr数组,只要该数组的某一下标的值不为0则循环将下标值+minVal输出返回到原数组即可。
public class CountingSort {
public static void countingSort(int[] arr) {
int len = arr.length;
if (len < 2) return;
int minVal = arr[0], maxVal = arr[0];
for (int i = 1; i < len; i++) {
if (arr[i] < minVal) {
minVal = arr[i];
} else if (arr[i] > maxVal) {
maxVal = arr[i];
}
}
int[] countArr = new int[maxVal - minVal + 1];
for (int val : arr) {
countArr[val - minVal]++;
}
for (int arrIdx = 0, countIdx = 0; countIdx < countArr.length; countIdx++) {
while (countArr[countIdx]-- > 0) {
arr[arrIdx++] = minVal + countIdx;
}
}
}
}
1.设置一个bucketSize(该数值的选择对性能至关重要,性能最好时每个桶都均匀放置所有数值,反之最差),表示每个桶最多能放置多少个数值;
2.遍历输入数据,并且把数据依次放到到对应的桶里去;
对每个非空的桶进行排序,可以使用其它排序方法(这里递归使用桶排序);
3.从非空桶里把排好序的数据拼接起来即可。
import java.util.ArrayList;
import java.util.List;
public class BucketSort {
private static List<Integer> bucketSort(List<Integer> arr, int bucketSize) {
int len = arr.size();
if (len < 2 || bucketSize == 0) {
return arr;
}
int minVal = arr.get(0), maxVal = arr.get(0);
for (int i = 1; i < len; i++) {
if (arr.get(i) < minVal) {
minVal = arr.get(i);
} else if (arr.get(i) > maxVal) {
maxVal = arr.get(i);
}
}
int bucketNum = (maxVal - minVal) / bucketSize + 1;
List<List<Integer>> bucket = new ArrayList<>();
for (int i = 0; i < bucketNum; i++) {
bucket.add(new ArrayList<>());
}
for (int val : arr) {
int idx = (val - minVal) / bucketSize;
bucket.get(idx).add(val);
}
for (int i = 0; i < bucketNum; i++) {
if (bucket.get(i).size() > 1) {
bucket.set(i, bucketSort(bucket.get(i), bucketSize / 2));
}
}
List<Integer> result = new ArrayList<>();
for (List<Integer> val : bucket) {
result.addAll(val);
}
return result;
}
}
1.取得数组中的最大数,并取得位数,即为迭代次数n(例如:数组中最大数为123,则 n=3);
2.arr为原始数组,从最低位(或最高位)开始根据每位的数字组成radix数组(radix数组是个二维数组,其中一维长度为10),例如123在第一轮时存放在下标为3的radix数组中;
3.将radix数组中的数据从0下标开始依次赋值给原数组;
4.重复2~3步骤n次即可。
import java.util.ArrayList;
import java.util.List;
//基数排序
public class RadixSort {
public static void radixSort(int[] arr) {
if (arr.length < 2) return;
int maxVal = arr[0];//求出最大值
for (int a : arr) {
if (maxVal < a) {
maxVal = a;
}
}
int n = 1;
while (maxVal / 10 != 0) {//求出最大值位数
maxVal /= 10;
n++;
}
for (int i = 0; i < n; i++) {
List<List<Integer>> radix = new ArrayList<>();
for (int j = 0; j < 10; j++) {
radix.add(new ArrayList<>());
}
int index;
for (int a : arr) {
index = (a / (int) Math.pow(10, i)) % 10;
radix.get(index).add(a);
}
index = 0;
for (List<Integer> list : radix) {
for (int a : list) {
arr[index++] = a;
}
}
}
}
}
思路:
定义节点类(Node):每个节点包含键(key)、值(value)、前一个节点(prev)和后一个节点(next)的引用。
定义LRUCache类:
a. 初始化(LRUCache):创建一个双端链表作为缓存数据的存储结构,同时初始化一个HashMap用于快速查找缓存中的节点。head和tail分别表示链表的头部和尾部节点。
b. 添加节点(addNode):将一个新节点添加到链表的头部。
c. 删除节点(removeNode):从链表中删除一个节点。
d. 移动到头部(moveToHead):将某个节点从当前位置移动到链表的头部,表示该节点最近被使用过。
e. 弹出尾部节点(popTail):从链表的尾部弹出一个节点,即最近最少使用的节点。
f. 获取数据(get):根据键从HashMap中查找节点。如果找到,则将该节点移动到链表的头部并返回其值;否则返回-1。
g. 插入或更新数据(put):根据键从HashMap中查找节点。如果找到,则更新节点的值并将其移动到链表的头部;否则创建一个新节点,将其添加到链表的头部,并在HashMap中建立键和节点的映射。如果此时缓存的大小超过了容量,则弹出链表的尾部节点并从HashMap中删除其映射。
import java.util.HashMap;
class LRUCache {
class Node {
int key;
int value;
Node prev;
Node next;
}
private void addNode(Node node) {
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
private void removeNode(Node node) {
Node prev = node.prev;
Node next = node.next;
prev.next = next;
next.prev = prev;
}
private void moveToHead(Node node) {
removeNode(node);
addNode(node);
}
private Node popTail() {
Node res = tail.prev;
removeNode(res);
return res;
}
private HashMap<Integer, Node> cache = new HashMap<>();
private int size;
private int capacity;
private Node head, tail;
public LRUCache(int capacity) {
this.size = 0;
this.capacity = capacity;
head = new Node();
tail = new Node();
head.next = tail;
tail.prev = head;
}
public int get(int key) {
Node node = cache.get(key);
if (node == null) {
return -1;
}
moveToHead(node);
return node.value;
}
public void put(int key, int value) {
Node node = cache.get(key);
if (node == null) {
Node newNode = new Node();
newNode.key = key;
newNode.value = value;
cache.put(key, newNode);
addNode(newNode);
size++;
if (size > capacity) {
Node tail = popTail();
cache.remove(tail.key);
size--;
}
} else {
node.value = value;
moveToHead(node);
}
}
}
都是分布式微服务系统中重要组件之一
RPC 和消息队列本质上是网络通讯的两种不同的实现机制,两者的用途不同
Kafka:开源的分布式流式处理平台,全面的高性能消息队列。
流式处理平台三个关键功能:
Kafka 是一个分布式系统,由通过高性能 TCP 网络协议进行通信的服务器和客户端组成,可以部署在在本地和云环境中的裸机硬件、虚拟机和容器上
Kafka2.8之前重度依赖Zookeeper做元数据管理和集群的高可用,2.8之后,引入基于Raft协议的KRaft模式,不再依赖Zookeeper
RocketMQ:阿里开源的一款云原生“消息、事件、流”实时数据处理平台,借鉴了 Kafka
RabbitMQ:Erlang 语言实现 AMQP,用于在分布式系统中存储转发消息。具体特点
Pulsar:集消息、存储、轻量化函数式计算为一体,采用计算与存储分离架构设计,支持多租户、持久化存储、多机房跨区域数据复制,具有强一致性、高吞吐、低延时及高可扩展性等流数据存储特性,被看作是云原生时代实时消息流传输、存储和计算最佳解决方案。
ActiveMQ:被淘汰
对比方向:
总结:
RabbitMQ 是使用 Erlang 编写的一个开源的消息队列,并发能力很强,性能极其好,延时很低,达到微秒级,其他几个都是 ms 级
特点
生产者与消费者模型,主要负责接收、存储和转发消息。
消息一般由2部分组成:消息头和消息体,消息体是payLoad,不透明,消息头是由一系列的可选属性组成,包括routing-key(路由键)、priority(优先级)、delivery-mode(持久化标志)等,生产者把消息交给RabbitMQ后,RabbitMQ会根据消息头将消息发送给感兴趣的消费者
Producer和 Consumer
Exchange:交换器会将消息分配到对应的消息队列,如果路由不到,可能会返回给生产者,可能会直接丢弃,4种类型,不同类型对应不同路由策略:direct(默认)、fanout、topic、headers。不同类型交换器转发消息的策略不同,生产者发送消息给交换器时,一般会指定一个RoutingKey(路由键),指定这个消息的路由规则,这个 RoutingKey 需要与交换器类型和绑定键(BindingKey)联合使用才能最终生效。RabbitMQ 中通过 Binding(绑定) 将 Exchange(交换器) 与 Queue(消息队列) 关联起来,在绑定的时候一般会指定一个 BindingKey(绑定建) ,交换器和队列可以是多对多的关系,生产者将消息发送给交换器时,需要一个 RoutingKey,当 BindingKey 和 RoutingKey 相匹配时,消息会被路由到对应的队列中,在绑定多个队列到同一个交换器的时候,这些绑定允许使用相同的 BindingKey。BindingKey 并不是在所有的情况下都生效,它依赖于交换器类型,比如 fanout 类型的交换器就会无视,而是将消息路由到所有绑定到该交换器的队列中。
fanout:会把发送到该交换器的消息发送到所有绑定的 Queue种,广播消息,最快
direct:把消息路由到BindingKey和 RountingKey完全匹配的Queue,常用于处理优先级的任务,根据优先级把消息发送到对应的队列
topic:将消息路由到BindingKey和 RountingKey 匹配的队列
. 号分隔字符串,每一段独立的字符串称为一个单词,BindingKey和 RountingKey都是点号
BindingKey可以存在 *和#,用于模糊匹配,星号匹配一个单词,井号匹配多个单词(可以是0个)
headers(不推荐):不依赖路由键的匹配规则路由消息,而是根据消息内容的headers属性进行匹配。在绑定队列和交换器时指定一组键值对,当发送消息到交换器时,RabbitMQ 会获取到该消息的 headers(也是一个键值对的形式),对比其中的键值对是否完全匹配队列和交换器绑定时指定的键值对,如果完全匹配则消息会路由到该队列,否则不会路由到该队列。headers 类型的交换器性能会很差,而且也不实用,基本上不会看到它的存在。
Queue:一个消息可投入一个或多个队列。多个消费者可以订阅同一个队列,这时队列中的消息会被平均分摊(Round-Robin,即轮询)给多个消费者进行处理,而不是每个消费者都收到所有的消息并处理,这样避免消息被重复消费。
Broker:可以将Broker看作一台RabbitMQ服务器
RabbitMQ 就是 AMQP 协议的 Erlang 的实现
AMQP 协议的三层:
AMQP 模型的三大组件:
生产者发送消息速度超过消费者消费消息速度,队列中消息堆积,直到上限,之后消息成为死信,可能被丢弃
解决:
接收到消息后直接存入磁盘而不是内存,消费时才从磁盘加载,支持百万条消息存储
DLX:死信交换器,死信邮箱。当消息在一个队列中变成死信消息之后,能够被重新发送到另一个交换器中,这个交换器就死信交换器,绑定死信交换器的队列就是死信队列
导致死信的原因:
延迟队列指存储对应的延迟消息,消息发送之后,不想让消费者立刻拿到消息,而是等待特定时间后,消费者才拿到这个消息进行消费
RabbitMQ本身没有延迟队列,实现延迟消息,一般有两种方式:
AMQP 协议以及 RabbitMQ 本身没有直接支持延迟队列的功能,但是可以通过 TTL 和 DLX 模拟出延迟队列的功能。
3.5.0有优先级队列实现,优先级高的队列会先被消费
可以通过x-max-priority参数来实现优先级队列。不过,当消费速度大于生产速度且 Broker 没有堆积的情况下,优先级显得没有意义。也就是消费速度过快的话优先级队列没有意义,如果消费速度慢,此时需要先消费优先级高的消息
因为TCP连接的创建和销毁开销较大,且并发数受系统资源限制,会造成性能瓶颈,所以 RabbitMQ使用信道的方式来传输数据
信道(Channel)是生产者、消费者和RabbitMQ通信的渠道,是建立在TCP连接上的虚拟连接,且每条TCP连接上的信道数量没有限制,所以RabbitMQ可以在一条TCP连接上建立大量信道达到多个线程处理,这个TCP被多个线程共享,每个信道在RabbtiMQ都有唯一的ID,保证了信道私有性,每个信道对应一个线程使用
多个线程都使用一条TCP连接,每个线程对应TCP连接中的一条信道
消息到MQ、MQ自己、MQ到消费者
RabbitMQ基于主从(非分布式)做高可用
RabbitMQ有三种模式:单机模式、普通集群模式、镜像集群模式
仲裁队列:3.8后,替代镜像队列,都是主从同步,基于Raft协议,强一直
普通集群模式:共享交换机、队列元信息(可以有引用)、不包含队列的消息,访问集群某节点,如果队列不在该节点,就从数据所在节点传递到当前节点,队列所在节点宕机,队列中消息就丢失
镜像集群模式:本质是主从模式,消息会同步备份,创建队列的节点叫该队列的主节点,备份到其他节点叫做该队列的镜像节点,一个队列的主节点可能是另一个队列的镜像节点,所有操作都是主节点完成,同步给镜像节点,主节点宕机后,镜像节点替代。可能丢失数据,可以使用仲裁队列
RabbitMQ可以设置过期时间(TTL)。如果消息在队列积压超过TTL就会被RabbitMQ清理掉,大量数据会直接丢失,可以在过了高峰之后,查出丢失的数据,
假设 1 万个订单积压在 mq 里面,没有处理,其中 1000 个订单都丢了,你只能手动写程序把那 1000 个订单给查出来,手动发到 mq 里去再补一次。
分布式流处理平台
官方说是CA,原因是,Kafka设计是运行在一个数据中心,网络分区问题基本不会发生,所以是CA系统。
但现实中即使在一个数据中心,还是会有分区问题
kafka可以通过一些配置满足AP或CP或平衡,比如配置写入数据时等待同步到所有节点才返回ACK(acks=all),这样满足CP,如果配置写入数据主节点提交了直接返回ACK,在给其他节点同步之前宕机后,消费者读不到这条消息,满足AP
可以配置ack=all同时配置容忍一个节点宕机时强一致性和整体可用性
除了上面的几个常用配置项,下面这个配置项也跟consistency和availability相关。这个配置项的作用是控制,在所有节点宕机之后,如果有一个节点之前不是在ISR列表里面,启动起来之后是否可以成为leader。当设置成默认值false时,表示不可以,因为这个节点的数据很可能不是最新的,如果它成为了主节点,那么就可能导致一些数据丢失,从而损失consistency,但是却可以保证availability。如果设置成true,则相反。这个配置项让用户可以基于自己的业务需要,在consistency和availability之间做一个选择。
unclean.leader.election.enable=false
replication.factor:默认3,即每个分区只有1个leader副本和2个follow副本acks:必须要有多少个分区副本收到消息,生产者才认为该消息是写入成功的
min.insync.replicas:最小同步副本,消息至少被写入到多少个副本才算是 “真正写入”,默认值为 1,如果同步副本的数量低于该配置值,则生产者会收到错误响应,从而确保消息不丢失,只是一个最低限制,即同步副本少于该配置值,则会抛异常发布订阅模型,解决早期队列模型将生产者发送多个消费者需要创建多个队列的问题
使用主题作为消息通信载体,如果只有一个订阅者,就相当于队列模型
每个broker包含topic和partition
它把所有的消息都变成一个批量的文件,并且进行合理的批量压缩,减少网络IO损耗,通过mmap提高I/O速度,写入数据的时候由于单个Partion是末尾添加所以速度最优;读取数据的时候配合sendfile直接暴力输出。
消息分区:不受单台服务器限制,处理更多数据
顺序读写:磁盘顺序读写效率高
页缓存:磁盘数据缓存到内存
零拷贝:减少上下文切换和数据拷贝
消息压缩:减少磁盘io和网络io
分批发送:将消息打包批量发送、减少网络开销
集群:每个broker是一台kafka实例
kafka为分区引入多副本,分区的多个副本之间有一个leader,其他副本是follower,消息会被发送到leader副本,follower副本从leader副本拉取消息并同步,follower分为ISR副本,使用同步复制,其他副本使用异步复制
选举时优先从ISR选,因为同步,如果都不能就从其他follower
优势:各个分区可以分布在不同broker,提供很好的并发能力,多副本提高消息存储安全性
ZooKeeper 主要为 Kafka 提供元数据的管理的功能
/brokers/topics/my-topic/Partitions/0、/brokers/topics/my-topic/Partitions/12.8之后不依赖Zookeeper,引入基于Raft协议的kRaft模式,3.3.1可以使用
kafka保证分区中的消息有序,消息被追加到分区时会分配一个偏移量,kafka通过偏移量保证消息在分区的顺序
kafka发送消息时可以指定分区或者key
原因:消费后没提交偏移量
解决:
同一个消费者组,分区的所有权改变机制,重新均衡消费者消费。
触发时机:
过程:join和sync
影响:
如何避免:
默认消费异常会进行重试,重试多次后会跳过当前消息,继续进行后续消息的消费,不会一直卡在当前消息,默认重试10次
如果超过重试次数,可以发送到死信队列,进一步分析处理这些消息
文件存储机制:一个分区下存在多个日志文件段,.index索引文件、.log数据文件、.timeindex时间索引文件,分段可以在删除无用文件方方便,提高磁盘利用率,查找数据便捷
数据清理机制:消息默认7天,还有根据topic存储大小,超过一定值后开始删除最久的消息
中断是系统用来响应硬件设备请求的一种机制,操作系统收到硬件中断请求后,会打断正在执行的进程,然后调用内核的中断处理程序响应请求
为了解决中断处理时间过长和中断丢失的问题,将中断分为两个阶段,上半和下半
硬中断是外设引发的,软中断是执行中断指令产生的
操作系统将不同进程的虚拟地址和不同内存的物理地址映射起来,访问虚拟地址时,操作系统转换成不同的物理地址
虚拟地址和物理地址之间的关系有两种方式管理,内存分段和内存分页
内存隔离,多进程使用不会冲突,每个进程可以拥有比物理内存更大的内存,用页表可以管理用户dui’yi
程序由若干个逻辑分段组成
分段机制下的虚拟地址由两部分组成,段选择因子(保存在段寄存器,里面保存段号,是段表的索引)和段内偏移量(段基地址+上就是物理内存地址)
分段有内存碎片(内部和外部碎片,段长度不固定导致出现外内存碎片,解决方法是内存交换)和内存交换效率低的问题
分段可以产生连续内存空间,但会出现内存碎片和内存交换空间大的问题,内存分页少出现内存碎片
把整个虚拟和物理内存空间切成一段段固定尺寸的大小:Linux下1页4kb
虚拟地址和物理地址之间通过页表(在内存)映射
页之间紧凑,不会有外部碎片
但最少只能分配一页,会有内部内存碎片,空间不够时还会将最近没被使用的页换出到磁盘,需要时换入,但页很少,内存交换效率就高
只有在程序运行中,需要用到对应虚拟内存页的指令和数据时,再加载到物理内存中
虚拟地址分为页号和页内偏移:页号是页表的索引,页表包含物理页每页的物理内存地址
总结一下,对于一个内存地址转换,其实就是这样三个步骤:
简单的分页有空间缺陷,每个进程都要存储页表,内存消耗大,使用多级页表解决
多级页表:使用二级分页,有需要时才创建二级页表,以及页表覆盖全部虚拟内存地址空间
64位系统有四级目录
TLB:存放程序最常访问的页表,是cache,页表缓存,CPU寻址时先查TLB,没有就查页表
内存分段和内存分页的组合
段页式内存管理实现的方式:
这样,地址结构就由段号、段内页号和页内位移三部分组成。
malloc申请虚拟内存,当程序读取这段虚拟内存时,CPU访问时发现没有映射到物理内存,就产生缺页中断,进程由用户态切换到内核态,调用缺页中断函数,如果没有空闲物理内存,内核就开始进行内存回收,分为直接和后台
如果回收后还是不够,就触发OOM机制,根据算法选择一个占用物理内存较高的进程杀死,直到足够
这两个都会导致缓存命中率下降,预读失效(读磁盘多读的部分没用到),缓存污染(批量读可能挤出热点数据,热点数据全淘汰)其实要优化LRU
Redis 的缓存淘汰算法则是通过实现 LFU 算法来避免「缓存污染」而导致缓存命中率下降的问题(Redis 没有预读机制)。
MySQL 和 Linux 操作系统是通过改进 LRU 算法来避免「预读失效和缓存污染」而导致缓存命中率下降的问题。
传统的 LRU 算法的实现思路是这样的:
避免预读失效:将数据分为冷数据和热数据,分别进行LRU,让预读页在内存停留时间短
避免缓存污染:提高进入活跃LRU链表的门槛
资源分配的基本单位
运行中的程序,进程切换要记录当前进程运行的状态信息,下次切换回来的时候就可以恢复执行
进程状态7个:
进程间通信方式:
线程间通信方式:
CPU调度的基本单位,线程之间可以并发执行,各个线程可以共享资源,缺点是线程崩溃时,所属进程其他线程都会崩溃,除了java
线程上下文切换时:
线程三种实现方式:
用户线程和内核线程是多对一、一对一、多对多
同步是并发线程可能需要互相等待互通消息,这种相互制约的等待叫同步,就是操作A应该在操作B之前执行等
互斥比如操作A和操作B不能在同一时刻执行
锁和信号量可以实现同步和互斥
两个线程都在等待对方释放锁
四个必要条件
jstack可以检查
预防死锁:
一次性申请所有资源
进程只获得运行初期需要的资源,在运行过程中逐步释放分配已经使用完毕的资源,再去请求新的资源
使用资源有序分配法破坏环路等待
避免死锁:使用前判断,只允许不会产生死锁的进程申请资源
单核CPU:
缺页中断:CPU访问的页面不在物理内存中,会产生一个缺页中断,请求操作系统把缺页调入物理内存
当出现缺页异常,需调入新页面而内存已满时,选择被置换的物理页面
每个文件有索引节点(文件元信息,文件唯一标识)和目录项(记录文件名字,索引指针和其他目录项的层级关联关系)
磁盘读写最小单位是扇区,多个扇区组成一个逻辑块,每次读写最小单位是逻辑块4kb,也就是一次性读取8个扇区
虚拟文件系统:用户层和文件系统层中间,对用户提供统一接口
文件IO:
进行性IO和内存数据传输时,数据搬运交给DMA控制器,CPU去处理其他事务
本来read读的时候cpu将数据从磁盘缓冲区拷贝到内核缓冲区,再把数据从内核缓冲区拷贝到用户缓冲区
使用DMA后由DMA把数据从磁盘缓冲区拷贝到内核缓冲区,发送中断给CPU,CPU再将数据从内核缓冲区拷贝到用户缓冲区
用户缓冲区没必要存在,用DMA传输
磁盘高速缓存,缓存最近被访问的数据,有预读功能
所以,传输文件的时候,我们要根据文件的大小来使用不同的方式:
Reactor:非阻塞同步网络模式,感知的是就绪可读写事件。封装IO多路复用,事件反应。IO多路复用监听事件,收到事件后,根据事件类型分配给某个进程/线程
Reactor 模式主要由 Reactor(数量可变) 和处理资源池(单/多,线程/进程)这两个核心部分组成:
除了多Reactor单线程/进程,其他3个都有使用
单Reactor单进程/线程
单Reactor多线程/进程
Hander对象不再负责业务处理,只负责数据的接收和发送,子线程的Processor对象进行业务处理,处理完后发给Handler对象,充分利用多核
多Reactor多进程/线程
分为主线程和子线程
Proactor:异步网络模式,感知的是已完成的读写事件,在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据。
但linux下的异步是用户空间模拟的,Windows有真正的异步IO
负载均衡算法,为了减少迁移的数据量
一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响。
为了解决不均匀分布的问题,引入虚拟节点,对真实节点做多个副本,将虚拟节点映射到哈希环上,提高均衡度
IO模型是不同的策略,比如用户读取时,内核检查有没有数据,此时内核可以直接返回或等一会,就取决于IO模型
两个阶段(内核获取数据,从内核拷贝数据到用户缓冲区)
IO操作同步还是异步,关键看数据在内核空间和用户空间的拷贝过程,阶段二是同步还是异步
ifconfig或ip
netstat或ss(推荐)
sar,-n可以查看网络统计信息,网口、TCP等
ping
ls -lh查看日志文件大小
不用cat,用less按需加载文件
高性能HTTP和反向代理web服务器,linux的epoll模型
多进程单线程,提高并发率,多进程之间相互独立,一个worker进程挂了不影响其他worker进程,master进程管理worker进程,分发请求
不用多线程:
反向代理:目标服务器与客户端之间的代理,代理服务器接收客户端请求并将其转发到后端的目标服务器上,它是服务器的代理,帮助服务器做负载均衡
正向代理:客户端与目标服务器之间的代理,代理服务器代表客户端发送请求并获取响应,他是客户端的代理,帮客户端访问无法访问的服务器
访问服务器静态资源,本地目录不在nginx根目录下,需要进行目录映射,location配置rewrite跳转
nginx -s reload 重新载入配置文件
# 配置上游服务器 默认轮询,加 weight=数字zhi,加一行ip_hash;就使用ip哈希,根据发送请求的客户端的ip计算访问的服务器(可以使用一致性哈希算法解决因为服务器数量变化导致同一个ip请求到其他服务器的问题)
# 一致性哈希算法:0-2^32-1,圆圈顺时针就近原则,用户访问离自己最近的节点,如果服务器数量改变,只需要改变变化周围的请求节点,保证绝大部分用户请求还是访问原来的节点
# 还有url_hash,加一行hash $request_uri;
# 还有least_conn最小连接数,加一行least_
upstream www.douyin.com {
server ip1:port1;
server ip2:port2;
}
# 配置网关(入口)
server {
listen 80;
location / {
proxy_pass http://www.douyin.com;
}
}
开源分布式协调服务框架
数据保存在内存,不适合保存大数据,适合读多写少,写会同步所有服务器状态
Data model数据模型:层次化多叉树
znode数据节点:stat状态(记录版本)和data内容
version版本:stat记录当前节点版本、当前子节点版本、当前节点的ACL版本
ACL权限控制:创建获取设置等权限,身份认证提供ip限制用户名密码认证
Watcher事件监听器:用户在节点上注册Watcher,在特定事件触发时,zookeeper将事件通知到对应客户端
Session会话:zookeeper和客户端的tcp长连接,可以心跳检测,创建会话之前会给客户端分配sessionId,全局唯一
ZAB协议保持数据一致性
没有使用主从模式,使用leader、follower、observer
leader选举
集群为什么是奇数台?
3台最大允许宕机1台,4台最大允许宕机1台,所以奇数就可以
集群脑裂:多台机器在不同机房,机房间网络线路故障,网络不通,集群被割裂多个集群,子集群各自选leader,使用过半机制解决,不可能产生2个leader
区别
cookie 是不可跨域的:每个 cookie 都会绑定单一的域名,无法在别的域名下获取使用,一级域名和二级域名之间是允许共享使用的(靠的是 domain)。
前端清空吗
流程
Session 是一种记录服务器和客户端会话状态的机制,使服务端有状态化,可以记录会话信息,耗费服务器资源。而 Token 是令牌,访问资源接口(API)时所需要的资源凭证,token存用户信息需要去查询数据库验证,常用的cookie只能是按域名发送对应的cookie,不能实现跨域的功能,会遭受CSRF攻击、存储在客户端不安全。
session 是空间换时间,token 是时间换空间。
服务器不用存储Session,JWT 认证可以有效避免 CSRF 攻击,因为 JWT 一般是存在在 localStorage 中,使用 JWT 进行身份验证的过程中是不会涉及到 Cookie 的
组成
Token 的类型。Header 被转换成 Base64 编码(用64个字符表示任意二进制数据)客户端接收到 JWT 之后,会将其保存在 Cookie 或者 localStorage 里面,以后客户端发出的所有请求都会携带这个令牌。JWT 存放在 localStorage 中而不是 Cookie 中,避免 CSRF 风险。
有了签名之后,即使 JWT 被泄露或者截获,黑客也没办法同时篡改 Signature、Header、Payload。因为服务端拿到 JWT 之后,会解析出其中包含的 Header、Payload 以及 Signature 。服务端会根据 Header、Payload、密钥再次生成一个 Signature。拿新生成的 Signature 和 JWT 中的 Signature 作对比,如果一样就说明 Header 和 Payload 没有被修改。JWT 安全的核心在于签名,签名安全的核心在密钥
在多个应用系统中,只需要登录一次,就可以访问其他相互信任的应用系统。
同域下,sso服务登录后,将cookie域设置为顶级域,所有子域都可以访问顶级域cookie,同时session进行共享,sso系统登录后,再访问顶级域下的其他域,cookie会带到其他域的服务端,再通过共享session找到对应的session
不同域下,cookie不共享,单点登录
之后再访问系统1时,就是登录的,此时用户访问系统2,系统2没登陆跳转到SSO,SSO登录了,所以直接生成ST返回系统2把发给系统2,系统2拿到ST请求SSO是否有效,成功就把登录状态写入session,并在系统2域下写入cookie
系统1和系统2在不同域,session不共享也没关系
不同主机访问共享资源,需要互斥防止彼此干扰
CAP
分布式系统三个指标
BASE
对CAP的一种解决思路
Eureka:AP思想
Zookeeper:CP思想
解决分布式事务的思想和模型
Seate事务管理,XA、AT、TCC
XA模式:强一致、性能差
AT模式:分阶段提交,弥补XA资源锁定周期过长的缺陷,性能好
TCC模式:性能好,但需要人工编码
生成消息到mq,消费者从mq读消息执行本地事务,确保mq和mysql在同一个事务
在a服务写数据时,需要在同一个事务内发送消息到另一个事务,异步,性能好,但实时性差
基于消息传递且具有高度容错特性的一致性算法,解决的问题就是分布式系统中如果就某个值达成一致。
在 Paxos 中主要有三个角色,分别为 Proposer提案者、Acceptor表决者、Learner学习者。
2个阶段
分布式集群里,机器的添加删除或故障自动脱离,如果用常用hash,操作后原有数据可能找不到,违反单调性。
hash环解决单调性,用hash算法把一个key哈希到一个有2^32个桶的空间里,环上也添加对应缓存节点,对于数据的key哈希后顺时针找最近的缓存节点存储数据,缓存节点宕机删除后,原节点数据顺时针找最近节点存储,也可以增加节点
虚拟节点解决平衡性,一个节点宕机后,数据需要落在距离它最近的节点,会导致下一个节点压力增大,可能导致雪崩,整个服务挂掉,虚拟节点是实际节点在hash空间的复制品,一个实际节点对应多个虚拟节点,当节点宕机后,存储流量压力分散在多台节点上,解决雪崩问题。
哈希算法好坏的条件
推特开源分布式id生成,划分命名空间分割64big位,long类型
相当于在一毫秒一个数据中心的一台机器上可产生4096个有序的不重复的ID。强依赖机器时钟
Eureka:注册中心,albb用Nacos做注册中心和配置中心
Ribbon:负载均衡,发出远程调用feign就会使用ribbon,决定选择哪一台服务器,有轮询、权重、随机、区域分类等
Feign:远程调用
Hystrix:服务熔断,albb用sentinel
Zuul/Gateway:网关,albb用Gateway
一个服务失败,导致整条链路的服务都失败,如服务d宕机,服务a不断向服务d请求,调用失败的连接没释放,连接满后服务a也宕机
服务熔断一般是某个服务(下游服务)故障引起,而服务降级一般是从整体负荷考虑。
原因:整体负荷超出整体负载承受能力
目的:保证重要或基本服务正常运行,非重要服务延迟使用或暂停使用
一般与feign接口整合编写降级逻辑
降级方式
原因:当下游服务因访问压力过大而响应变慢或失败,上游服务为了保护系统整体的可用性,可以暂时切断对下游服务的调用。熔断该节点微服务的调用,快速返回”错误”的响应信息。
Hystrix:分布式系统的延迟和容错的开源库,能够保证在一个依赖出问题的情况下,不会导致整个服务失败,避免级联故障,以提高分布式系统的弹性。如果10秒的请求失败率超过50%,就触发熔断,之后每隔5秒重新尝试请求微服务,直到微服务可达再关闭熔断
当高并发或者瞬时高并发时,为了保证系统的稳定性、可用性,系统以牺牲部分请求为代价或者延迟处理请求为代价,保证系统整体服务可用。如nginx漏桶限流、网关令牌桶、tomcat设置最大连接数、自定义拦截器
常见四种限流算法
固定窗口其实就是时间窗口。固定窗口计数器算法 规定了我们单位时间处理的请求数量。
**这种限流算法无法保证限流速率,因而无法保证突然激增的流量。**不精确
就比如说我们限制某个接口 1 分钟只能访问 1000 次,该接口的 QPS 为 500,前 55s 这个接口 1 个请求没有接收,后 1s 突然接收了 1000 个请求。然后,在当前场景下,这 1000 个请求在 1s 内是没办法被处理的,系统直接就被瞬时的大量请求给击垮了
或者可能0.55s - 1.55秒内超过1秒请求数量
固定窗口计数器算法的升级版:优化把时间以一定比例分片 精度高,都不是绝对精准
例如我们的接口限流每分钟处理 60 个请求,我们可以把 1 分钟分为 60 个窗口。每隔 1 秒移动一次,每个窗口一秒只能处理 不大于 60(请求数)/60(窗口数) 的请求, 如果当前窗口的请求计数总和超过了限制的数量的话就不再处理其他请求。
很显然, 当滑动窗口的格子划分的越多,滑动窗口的滚动就越平滑,限流的统计就会越精确。
按照固定速率流出请求
发请求:给桶注水
处理请求:漏桶漏水
往桶中任意速率注水,固定速率流水。水超过桶流量就丢弃,因为桶容量不变,就保证了整体的速率
**实现方法:**准备一个队列保存请求,定期从队列拿请求执行
访问频率超过接口响应速率就拒绝请求,强行限制数据的传输速率
因为漏出速率固定,所以即使网络不阻塞,漏桶也不能接收大量突发请求
漏桶限制的是常量流出速率(即流出速率是一个固定常量值,比如都是1的速率流出,而不能一次是1,下次又是2),从而平滑突发流入速率;
按照固定速率加令牌
桶里装的令牌,请求在被处理之前需要拿到一个令牌,处理完请求之后丢弃令牌
如果桶满了,就不能继续往里面继续添加令牌了,如果一段时间没有请求到来,桶内就积累一些token,下一次的突发流量,只要token足够,也能一次处理
如果没有令牌就拒绝新请求
所以令牌桶的特点是允许突发流量
令牌桶限制的是平均流入速率(允许突发请求,只要有令牌就可以处理,支持一次拿3个令牌,4个令牌),并允许一定程度突发流量;
放库存吗
git commit:保存目录下所有文件的快照,git还保存提交的历史git branch 分支名:创建分支git checkout 分支名:切换分支git checkout -b 分支名:创建分支同时切换git merge 目标分支:把目标分支和并到当前分支git rebase 目标分支:把当前分支的工作移到目标分支下,实际上就是取出一系列提交记录,然后复制它们,最后在另一个地方逐个放下去,可以创造更线性的提交历史git checkout 提交记录名:分离的HEADgit checkout 引用名^:把HEAD指向分支名的上一个git checkout 引用名~数字:把HEAD指向分支名的上几个git branch -f 分支名 引用名^/~数字:让分支强制指向引用的上层级git reset 引用名^/~数字:把当前分支记录回退到引用名,原来指向的提交记录还在,但处于未加入暂存区git revert 引用名^/~数字:把当前分支记录对于引用的撤销更改形成新的引用,此时可以推送更改git clone:在本地创建一个远程仓库的拷贝这一块我们会使用redis共享session来实现
通过本章节,我们会理解缓存击穿,缓存穿透,缓存雪崩等问题,让小伙伴的对于这些概念的理解不仅仅是停留在概念上,更是能在代码中看到对应的内容
通过本章节,我们可以学会Redis的计数器功能, 结合Lua完成高性能的redis操作,同时学会Redis分布式锁的原理,包括Redis的三种消息队列
我们利用Redis的GEOHash来完成对于地理坐标的操作
主要是使用Redis来完成统计功能
使用Redis的BitMap数据统计功能
基于Set集合的关注、取消关注,共同关注等等功能,这一块知识咱们之前就讲过,这次我们在项目中来使用一下
基于List来完成点赞列表的操作,同时基于SortedSet来完成点赞的排行榜功能
以上这些内容咱们统统都会给小伙伴们讲解清楚,让大家充分理解如何使用Redis
发送验证码:
用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号
如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户
短信验证码登录、注册:
用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息
校验登录状态:
用户在请求时候,会从cookie中携带者JsessionId到后台,后台通过JsessionId从session中拿到用户信息,如果没有session信息,则进行拦截,如果有session信息,则将用户信息保存到threadLocal中,并且放行

页面流程

具体代码如下
贴心小提示:
具体逻辑上文已经分析,我们仅仅只需要按照提示的逻辑写出代码即可。
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);
// 4.保存验证码到 session
session.setAttribute("code",code);
// 5.发送验证码
log.debug("发送短信验证码成功,验证码:{}", code);
// 返回ok
return Result.ok();
}
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if(cacheCode == null || !cacheCode.toString().equals(code)){
//3.不一致,报错
return Result.fail("验证码错误");
}
//一致,根据手机号查询用户
User user = query().eq("phone", phone).one();
//5.判断用户是否存在
if(user == null){
//不存在,则创建
user = createUserWithPhone(phone);
}
//7.保存用户信息到session中
session.setAttribute("user",user);
return Result.ok();
}
温馨小贴士:tomcat的运行原理

当用户发起请求时,会访问我们像tomcat注册的端口,任何程序想要运行,都需要有一个线程对当前端口号进行监听,tomcat也不例外,当监听线程知道用户想要和tomcat连接连接时,那会由监听线程创建socket连接,socket都是成对出现的,用户通过socket像互相传递数据,当tomcat端的socket接受到数据后,此时监听线程会从tomcat的线程池中取出一个线程执行用户请求,在我们的服务部署到tomcat后,线程会找到用户想要访问的工程,然后用这个线程转发到工程中的controller,service,dao中,并且访问对应的DB,在用户执行完请求后,再统一返回,再找到tomcat端的socket,再将数据写回到用户端的socket,完成请求和响应
通过以上讲解,我们可以得知 每个用户其实对应都是去找tomcat线程池中的一个线程来完成工作的, 使用完成后再进行回收,既然每个请求都是独立的,所以在每个用户去访问我们的工程时,我们可以使用threadlocal来做到线程隔离,每个线程操作自己的一份数据。
温馨小贴士:关于threadlocal
如果小伙伴们看过threadLocal的源码,你会发现在threadLocal中,无论是他的put方法和他的get方法, 都是先从获得当前用户的线程,然后从线程中取出线程的成员变量map,只要线程不一样,map就不一样,所以可以通过这种方式来做到线程隔离

拦截器代码
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.获取session
HttpSession session = request.getSession();
//2.获取session中的用户
Object user = session.getAttribute("user");
//3.判断用户是否存在
if(user == null){
//4.不存在,拦截,返回401状态码
response.setStatus(401);
return false;
}
//5.存在,保存用户信息到Threadlocal
UserHolder.saveUser((User)user);
//6.放行
return true;
}
}
让拦截器生效
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
// token刷新的拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}
我们通过浏览器观察到此时用户的全部信息都在,这样极为不靠谱,所以我们应当在返回用户信息之前,将用户的敏感信息进行隐藏,采用的核心思路就是书写一个UserDto对象,这个UserDto对象就没有敏感信息了,我们在返回前,将有用户敏感信息的User对象转化成没有敏感信息的UserDto对象,那么就能够避免这个尴尬的问题了
在登录方法处修改
//7.保存用户信息到session中
session.setAttribute("user", BeanUtils.copyProperties(user,UserDTO.class));
在拦截器处:
//5.存在,保存用户信息到Threadlocal
UserHolder.saveUser((UserDTO) user);
在UserHolder处:将user对象换成UserDTO
public class UserHolder {
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
public static void saveUser(UserDTO user){
tl.set(user);
}
public static UserDTO getUser(){
return tl.get();
}
public static void removeUser(){
tl.remove();
}
}
核心思路分析:
每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了
但是这种方案具有两个大问题
1、每台服务器中都有完整的一份session数据,服务器压力过大。
2、session拷贝数据时,可能会出现延迟
所以咱们后来采用的方案都是基于redis来完成,我们把session换成redis,redis数据本身就是共享的,就可以避免session共享的问题了

首先我们要思考一下利用redis来存储数据,那么到底使用哪种结构呢?由于存入的数据比较简单,我们可以考虑使用String,或者是使用哈希,如下图,如果使用String,同学们注意他的value,用多占用一点空间,如果使用哈希,则他的value中只会存储他数据本身,如果不是特别在意内存,其实使用String就可以啦。

所以我们可以使用String结构,就是一个简单的key,value键值对的方式,但是关于key的处理,session他是每个用户都有自己的session,但是redis的key是共享的,咱们就不能使用code了
在设计这个key的时候,我们之前讲过需要满足两点
1、key要具有唯一性
2、key要方便携带
如果我们采用phone:手机号这个的数据来存储当然是可以的,但是如果把这样的敏感数据存储到redis中并且从页面中带过来毕竟不太合适,所以我们在后台生成一个随机串token,然后让前端带来这个token就能完成我们的整体逻辑了
当注册完成后,用户去登录会去校验用户提交的手机号和验证码,是否一致,如果一致,则根据手机号查询用户信息,不存在则新建,最后将用户数据保存到redis,并且生成token作为redis的key,当我们校验用户是否登录时,会去携带着token进行访问,从redis中取出token对应的value,判断是否存在这个数据,如果没有则拦截,如果存在则将其保存到threadLocal中,并且放行。

这里具体逻辑就不分析了,之前咱们已经重点分析过这个逻辑啦。
UserServiceImpl代码
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.从redis获取验证码并校验
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
// 不一致,报错
return Result.fail("验证码错误");
}
// 4.一致,根据手机号查询用户 select * from tb_user where phone = ?
User user = query().eq("phone", phone).one();
// 5.判断用户是否存在
if (user == null) {
// 6.不存在,创建新用户并保存
user = createUserWithPhone(phone);
}
// 7.保存用户信息到 redis中
// 7.1.随机生成token,作为登录令牌
String token = UUID.randomUUID().toString(true);
// 7.2.将User对象转为HashMap存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
// 7.3.存储
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// 7.4.设置token有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.返回token
return Result.ok(token);
}
在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录token令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的

既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能。

RefreshTokenInterceptor
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true;
}
// 2.基于TOKEN获取redis中的用户
String key = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// 3.判断用户是否存在
if (userMap.isEmpty()) {
return true;
}
// 5.将查询到的hash数据转为UserDTO
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6.存在,保存用户信息到 ThreadLocal
UserHolder.saveUser(userDTO);
// 7.刷新token有效期
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
LoginInterceptor
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.判断是否需要拦截(ThreadLocal中是否有用户)
if (UserHolder.getUser() == null) {
// 没有,需要拦截,设置状态码
response.setStatus(401);
// 拦截
return false;
}
// 有用户,则放行
return true;
}
}
前言:什么是缓存?
举个例子:越野车,山地自行车,都拥有"避震器",防止车体加速后因惯性,在酷似"U"字母的地形上飞跃,硬着陆导致的损害,像个弹簧一样;
同样,实际开发中,系统也需要"避震器",防止过高的数据访问猛冲系统,导致其操作线程无法及时处理信息而瘫痪;
这在实际开发中对企业讲,对产品口碑,用户评价都是致命的;所以企业非常重视缓存技术;
缓存(Cache),就是数据交换的缓冲区,俗称的缓存就是缓冲区内的数据,一般从数据库中获取,存储于本地代码(例如:
例1:Static final ConcurrentHashMap<K,V> map = new ConcurrentHashMap<>(); 本地用于高并发
例2:static final Cache<K,V> USER_CACHE = CacheBuilder.newBuilder().build(); 用于redis等缓存
例3:Static final Map<K,V> map = new HashMap(); 本地缓存
由于其被Static修饰,所以随着类的加载而被加载到内存之中,作为本地缓存,由于其又被final修饰,所以其引用(例3:map)和对象(例3:new HashMap())之间的关系是固定的,不能改变,因此不用担心赋值(=)导致缓存失效;
一句话:因为速度快,好用
缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力
实际开发过程中,企业的数据量,少则几十万,多则几千万,这么大数据量,如果没有缓存来作为"避震器",系统是几乎撑不住的,所以企业会大量运用到缓存技术;
但是缓存也会增加代码复杂度和运营的成本:

实际开发中,会构筑多级缓存来使系统运行速度进一步提升,例如:本地缓存与redis中的缓存并发使用
浏览器缓存:主要是存在于浏览器端的缓存
**应用层缓存:**可以分为tomcat本地缓存,比如之前提到的map,或者是使用redis作为缓存
**数据库缓存:**在数据库中有一片空间是 buffer pool,增改查数据都会先加载到mysql的缓存中
**CPU缓存:**当代计算机最大的问题是 cpu性能提升了,但内存读写速度没有跟上,所以为了适应当下的情况,增加了cpu的L1,L2,L3级的缓存

在我们查询商户信息时,我们是直接操作从数据库中去进行查询的,大致逻辑是这样,直接查询数据库那肯定慢咯,所以我们需要增加缓存
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
//这里是直接查询数据库
return shopService.queryById(id);
}
标准的操作方式就是查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入redis。

代码思路:如果缓存有,则直接返回,如果缓存不存在,则查询数据库,然后存入redis。

缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,所以redis会对部分数据进行更新,或者把他叫为淘汰更合适。
**内存淘汰:**redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)
**超时剔除:**当我们给redis设置了过期时间ttl之后,redis会将超时的数据进行删除,方便咱们继续使用缓存
**主动更新:**我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题

由于我们的缓存的数据源来自于数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在,其后果是:
用户使用缓存中的过时数据,就会产生类似多线程数据安全问题,从而影响业务,产品口碑等;怎么解决呢?有如下几种方案
Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案
Read/Write Through Pattern : 由系统本身完成,数据库与缓存的问题交由系统本身去处理
Write Behind Caching Pattern :调用者只操作缓存,其他线程去异步处理数据库,实现最终一致

综合考虑使用方案一,但是方案一调用者如何处理呢?这里有几个问题
操作缓存和数据库时有三个问题需要考虑:
如果采用第一个方案,那么假设我们每次操作数据库后,都操作缓存,但是中间如果没有人查询,那么这个更新动作实际上只有最后一次生效,中间的更新动作意义并不大,我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来
删除缓存还是更新缓存?
如何保证缓存与数据库的操作的同时成功或失败?
应该具体操作缓存还是操作数据库,我们应当是先操作数据库,再删除缓存,原因在于,如果你选择第一种方案,在两个线程并发来访问时,假设线程1先来,他先把缓存删了,此时线程2过来,他查询缓存数据并不存在,此时他写入缓存,当他写入缓存后,线程1再执行更新动作时,实际上写入的就是旧的数据,新的数据被旧数据覆盖了。

核心思路如下:
修改ShopController中的业务逻辑,满足下面的需求:
根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
根据id修改店铺时,先修改数据库,再删除缓存
修改重点代码1:修改ShopServiceImpl的queryById方法
设置redis缓存时添加过期时间

修改重点代码2
代码分析:通过之前的淘汰,我们确定了采用删除策略,来解决双写问题,当我们修改了数据之后,然后把缓存中的数据进行删除,查询时发现缓存中没有数据,则会从mysql中加载最新的数据,从而避免数据库和缓存不一致的问题

缓存穿透 :缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
常见的解决方案有两种:
**缓存空对象思路分析:**当我们客户端访问不存在的数据时,先请求redis,但是此时redis中没有数据,此时会访问到数据库,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库,我们都知道数据库能够承载的并发不如redis这么高,如果大量的请求同时过来访问这种不存在的数据,这些请求就都会访问到数据库,简单的解决方案就是哪怕这个数据在数据库中也不存在,我们也把这个数据存入到redis中去,这样,下次用户过来访问这个不存在的数据,那么在redis中也能找到这个数据就不会进入到缓存了
**布隆过滤:**布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中,
假设布隆过滤器判断这个数据不存在,则直接返回
这种方式优点在于节约内存空间,存在误判,误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突

核心思路如下:
在原来的逻辑中,我们如果发现这个数据在mysql中不存在,直接就返回404了,这样是会存在缓存穿透问题的
现在的逻辑中:如果这个数据不存在,我们不会返回404 ,还是会把这个数据写入到Redis中,并且将value设置为空,欧当再次发起查询时,我们如果发现命中之后,判断这个value是否是null,如果是null,则是之前写入的数据,证明是缓存穿透数据,如果不是,则直接返回数据。

小总结:
缓存穿透产生的原因是什么?
缓存穿透的解决方案有哪些?
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
常见的解决方案有两种:
逻辑分析:假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是假设在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大

解决方案一、使用锁来解决:
因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用tryLock方法 + double check来解决这样的问题。
假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人去执行逻辑,假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。

解决方案二、逻辑过期方案
方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案。
我们把过期时间设置在 redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行 以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。
这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。

进行对比
**互斥锁方案:**由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响
逻辑过期方案: 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦

核心思路:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是进行查询之后,如果从缓存没有查询到数据,则进行互斥锁的获取,获取互斥锁后,判断是否获得到了锁,如果没有获得到,则休眠,过一会再进行尝试,直到获取到锁为止,才能进行查询
如果获取到了锁的线程,再去进行查询,查询后将数据写入redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行操作数据库的逻辑,防止缓存击穿

操作锁的代码:
核心思路就是利用redis的setnx方法来表示获取锁,该方法含义是redis中如果没有这个key,则插入成功,返回1,在stringRedisTemplate中返回true, 如果有这个key则插入失败,则返回0,在stringRedisTemplate返回false,我们可以通过true,或者是false,来表示是否有线程成功插入key,成功插入的key的线程我们认为他就是获得到锁的线程。
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
操作代码:
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1、从redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get("key");
// 2、判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//判断命中的值是否是空值
if (shopJson != null) {
//返回一个错误信息
return null;
}
// 4.实现缓存重构
//4.1 获取互斥锁
String lockKey = "lock:shop:" + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2 判断否获取成功
if(!isLock){
//4.3 失败,则休眠重试
Thread.sleep(50);
return queryWithMutex(id);
}
//4.4 成功,根据id查询数据库
shop = getById(id);
// 5.不存在,返回错误
if(shop == null){
//将空值写入redis
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
//返回错误信息
return null;
}
//6.写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_NULL_TTL,TimeUnit.MINUTES);
}catch (Exception e){
throw new RuntimeException(e);
}
finally {
//7.释放互斥锁
unlock(lockKey);
}
return shop;
}
需求:修改根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题
思路分析:当用户开始查询redis时,判断是否命中,如果没有命中则直接返回空数据,不查询数据库,而一旦命中后,将value取出,判断value中的过期时间是否满足,如果没有过期,则直接返回redis中的数据,如果过期,则在开启独立线程后直接返回之前的数据,独立线程去重构数据,重构完成后释放互斥锁。

如果封装数据:因为现在redis中存储的数据的value需要带上过期时间,此时要么你去修改原来的实体类,要么你
步骤一、
新建一个实体类,我们采用第二个方案,这个方案,对原来代码没有侵入性。
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
步骤二、
在ShopServiceImpl 新增此方法,利用单元测试进行缓存预热

在测试类中

步骤三:正式代码
ShopServiceImpl
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire( Long id ) {
String key = CACHE_SHOP_KEY + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(json)) {
// 3.存在,直接返回
return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if(expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回店铺信息
return shop;
}
// 5.2.已过期,需要缓存重建
// 6.缓存重建
// 6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock){
CACHE_REBUILD_EXECUTOR.submit( ()->{
try{
//重建缓存
this.saveShop2Redis(id,20L);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return shop;
}
基于StringRedisTemplate封装一个缓存工具类,满足下列需求:
存击穿问题
将逻辑进行封装
@Slf4j
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
// 设置逻辑过期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// 写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
public <R,ID> R queryWithPassThrough(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(json)) {
// 3.存在,直接返回
return JSONUtil.toBean(json, type);
}
// 判断命中的是否是空值
if (json != null) {
// 返回一个错误信息
return null;
}
// 4.不存在,根据id查询数据库
R r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,写入redis
this.set(key, r, time, unit);
return r;
}
public <R, ID> R queryWithLogicalExpire(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(json)) {
// 3.存在,直接返回
return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if(expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回店铺信息
return r;
}
// 5.2.已过期,需要缓存重建
// 6.缓存重建
// 6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock){
// 6.3.成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询数据库
R newR = dbFallback.apply(id);
// 重建缓存
this.setWithLogicalExpire(key, newR, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
// 释放锁
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return r;
}
public <R, ID> R queryWithMutex(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
return JSONUtil.toBean(shopJson, type);
}
// 判断命中的是否是空值
if (shopJson != null) {
// 返回一个错误信息
return null;
}
// 4.实现缓存重建
// 4.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
R r = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2.判断是否获取成功
if (!isLock) {
// 4.3.获取锁失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
}
// 4.4.获取锁成功,根据id查询数据库
r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,写入redis
this.set(key, r, time, unit);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
// 7.释放锁
unlock(lockKey);
}
// 8.返回
return r;
}
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}
在ShopServiceImpl 中
@Resource
private CacheClient cacheClient;
@Override
public Result queryById(Long id) {
// 解决缓存穿透
Shop shop = cacheClient
.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 互斥锁解决缓存击穿
// Shop shop = cacheClient
// .queryWithMutex(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 逻辑过期解决缓存击穿
// Shop shop = cacheClient
// .queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, 20L, TimeUnit.SECONDS);
if (shop == null) {
return Result.fail("店铺不存在!");
}
// 7.返回
return Result.ok(shop);
}
每个店铺都可以发布优惠券:
当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:
场景分析:如果我们的id具有太明显的规则,用户或者说商业对手很容易猜测出来我们的一些敏感信息,比如商城在一天时间内,卖出了多少单,这明显不合适。
场景分析二:随着我们商城规模越来越大,mysql的单表的容量不宜超过500W,数据量过大之后,我们要进行拆库拆表,但拆分表了之后,他们从逻辑上讲他们是同一张表,所以他们的id是不能一样的, 于是乎我们需要保证id的唯一性。
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:

为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:
ID的组成部分:符号位:1bit,永远为0
时间戳:31bit,以秒为单位,可以使用69年
序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID
@Component
public class RedisIdWorker {
/**
* 开始时间戳
*/
private static final long BEGIN_TIMESTAMP = 1640995200L;
/**
* 序列号的位数
*/
private static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public long nextId(String keyPrefix) {
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2.生成序列号
// 2.1.获取当前日期,精确到天
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 2.2.自增长
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3.拼接并返回
return timestamp << COUNT_BITS | count;
}
}
测试类
知识小贴士:关于countdownlatch
countdownlatch名为信号枪:主要的作用是同步协调在多线程的等待于唤醒问题
我们如果没有CountDownLatch ,那么由于程序是异步的,当异步程序没有执行完时,主线程就已经执行完了,然后我们期望的是分线程全部走完之后,主线程再走,所以我们此时需要使用到CountDownLatch
CountDownLatch 中有两个最重要的方法
1、countDown
2、await
await 方法 是阻塞方法,我们担心分线程没有执行完时,main线程就先执行,所以使用await可以让main线程阻塞,那么什么时候main线程不再阻塞呢?当CountDownLatch 内部维护的 变量变为0时,就不再阻塞,直接放行,那么什么时候CountDownLatch 维护的变量变为0 呢,我们只需要调用一次countDown ,内部变量就减少1,我们让分线程和变量绑定, 执行完一个分线程就减少一个变量,当分线程全部走完,CountDownLatch 维护的变量就是0,此时await就不再阻塞,统计出来的时间也就是所有分线程执行完后的时间。
@Test
void testIdWorker() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(300);
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id = " + id);
}
latch.countDown();
};
long begin = System.currentTimeMillis();
for (int i = 0; i < 300; i++) {
es.submit(task);
}
latch.await();
long end = System.currentTimeMillis();
System.out.println("time = " + (end - begin));
}
每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购:

tb_voucher:优惠券的基本信息,优惠金额、使用规则等
tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息
平价卷由于优惠力度并不是很大,所以是可以任意领取
而代金券由于优惠力度大,所以像第二种卷,就得限制数量,从表结构上也能看出,特价卷除了具有优惠卷的基本信息以外,还具有库存,抢购时间,结束时间等等字段
**新增普通卷代码: **VoucherController
@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {
voucherService.save(voucher);
return Result.ok(voucher.getId());
}
新增秒杀卷代码:
VoucherController
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
voucherService.addSeckillVoucher(voucher);
return Result.ok(voucher.getId());
}
VoucherServiceImpl
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
// 保存秒杀库存到Redis中
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}
下单核心思路:当我们点击抢购时,会触发右侧的请求,我们只需要编写对应的controller即可

秒杀下单应该思考的内容:
下单时需要判断两点:
下单核心逻辑分析:
当用户开始进行下单,我们应当去查询优惠卷信息,查询到优惠卷信息,判断是否满足秒杀条件
比如时间是否充足,如果时间充足,则进一步判断库存是否足够,如果两者都满足,则扣减库存,创建订单,然后返回订单id,如果有一个条件不满足则直接结束。

VoucherOrderServiceImpl
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足!");
}
//5,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update();
if (!success) {
//扣减库存
return Result.fail("库存不足!");
}
//6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2.用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
}
有关超卖问题分析:在我们原有代码中是这么写的
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足!");
}
//5,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update();
if (!success) {
//扣减库存
return Result.fail("库存不足!");
}
假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。

超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:而对于加锁,我们通常有两种解决方案:见下图:

悲观锁:
悲观锁可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等
乐观锁:
乐观锁:会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过,当然乐观锁还有一些变种的处理方式比如cas
乐观锁的典型代表:就是cas,利用cas进行无锁化机制加锁,var5 是操作前读取的内存值,while中的var1+var2 是预估值,如果预估值 == 内存值,则代表中间没有被人修改过,此时就将新值去替换 内存值
其中do while 是为了在操作失败时,再次进行自旋操作,即把之前的逻辑再操作一次。
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
课程中的使用方式:
课程中的使用方式是没有像cas一样带自旋的操作,也没有对version的版本号+1 ,他的操作逻辑是在操作时,对版本号进行+1 操作,然后要求version 如果是1 的情况下,才能操作,那么第一个线程在操作后,数据库中的version变成了2,但是他自己满足version=1 ,所以没有问题,此时线程2执行,线程2 最后也需要加上条件version =1 ,但是现在由于线程1已经操作过了,所以线程2,操作时就不满足version=1 的条件了,所以线程2无法执行成功

修改代码方案一、
VoucherOrderServiceImpl 在扣减库存时,改为:
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1") //set stock = stock -1
.eq("voucher_id", voucherId).eq("stock",voucher.getStock()).update(); //where id = ? and stock = ?
以上逻辑的核心含义是:只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设100个线程同时都拿到了100的库存,然后大家一起去进行扣减,但是100个人中只有1个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败
修改代码方案二、
之前的方式要修改前后都保持一致,但是这样我们分析过,成功的概率太低,所以我们的乐观锁需要变一下,改成stock大于0 即可
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update().gt("stock",0); //where id = ? and stock > 0
知识小扩展:
针对cas中的自旋压力过大,我们可以使用Longaddr这个类去解决
Java8 提供的一个对AtomicLong改进后的一个类,LongAdder
大量线程并发更新一个原子性的时候,天然的问题就是自旋,会导致并发性问题,当然这也比我们直接使用syn来的好
所以利用这么一个类,LongAdder来进行优化
如果获取某个值,则会对cell和base的值进行递增,最后返回一个完整的值

需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单
现在的问题在于:
优惠卷是为了引流,但是目前的情况是,一个人可以无限制的抢这个优惠卷,所以我们应当增加一层逻辑,让一个用户只能下一个单,而不是让一个用户下多个单
具体操作逻辑如下:比如时间是否充足,如果时间充足,则进一步判断库存是否足够,然后再根据优惠卷id和用户id查询是否已经下过这个订单,如果下过这个订单,则不再下单,否则进行下单

VoucherOrderServiceImpl
初步代码:增加一人一单逻辑
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足!");
}
// 5.一人一单逻辑
// 5.1.用户id
Long userId = UserHolder.getUser().getId();
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
//6,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update();
if (!success) {
//扣减库存
return Result.fail("库存不足!");
}
//7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
// 7.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
}
**存在问题:**现在的问题还是和之前一样,并发过来,查询数据库,都不存在订单,所以我们还是需要加锁,但是乐观锁比较适合更新数据,而现在是插入数据,所以我们需要使用悲观锁操作
**注意:**在这里提到了非常多的问题,我们需要慢慢的来思考,首先我们的初始方案是封装了一个createVoucherOrder方法,同时为了确保他线程安全,在方法上添加了一把synchronized 锁
@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
// 6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
if (!success) {
// 扣减失败
return Result.fail("库存不足!");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2.用户id
voucherOrder.setUserId(userId);
// 7.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
,但是这样添加锁,锁的粒度太粗了,在使用锁过程中,控制锁粒度 是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会锁住,所以我们需要去控制锁的粒度,以下这段代码需要修改为:
intern() 这个方法是从常量池中拿到数据,如果我们直接使用userId.toString() 他拿到的对象实际上是不同的对象,new出来的对象,我们使用锁必须保证锁必须是同一把,所以我们需要使用intern()方法
@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()){
// 5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
// 6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
if (!success) {
// 扣减失败
return Result.fail("库存不足!");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2.用户id
voucherOrder.setUserId(userId);
// 7.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
}
但是以上代码还是存在问题,问题的原因在于当前方法被spring的事务控制,如果你在方法内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放也会导致问题,所以我们选择将当前方法整体包裹起来,确保事务不会出现问题:如下:
在seckillVoucher 方法中,添加以下逻辑,这样就能保证事务的特性,同时也控制了锁的粒度

但是以上做法依然有问题,因为你调用的方法,其实是this.的方式调用的,事务想要生效,还得利用代理来生效,所以这个地方,我们需要获得原始的事务对象, 来操作事务

通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。
1、我们将服务启动两份,端口分别为8081和8082:

2、然后修改nginx的conf目录下的nginx.conf文件,配置反向代理和负载均衡:

具体操作(略)
有关锁失效原因分析
由于现在我们部署了多个tomcat,每个tomcat都有一个属于自己的jvm,那么假设在服务器A的tomcat内部,有两个线程,这两个线程由于使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的,但是如果现在是服务器B的tomcat内部,又有两个线程,但是他们的锁对象写的虽然和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2实现互斥,这就是 集群环境下,syn锁失效的原因,在这种情况下,我们就需要使用分布式锁来解决这个问题。

分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
分布式锁的核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路

那么分布式锁他应该满足一些什么样的条件呢?
可见性:多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思
互斥:互斥是分布式锁的最基本的条件,使得程序串行执行
高可用:程序不易崩溃,时时刻刻都保证较高的可用性
高性能:由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能
安全性:安全也是程序中必不可少的一环

常见的分布式锁有三种
Mysql:mysql本身就带有锁机制,但是由于mysql性能本身一般,所以采用分布式锁的情况下,其实使用mysql作为分布式锁比较少见
Redis:redis作为分布式锁是非常常见的一种使用方式,现在企业级开发中基本都使用redis或者zookeeper作为分布式锁,利用setnx这个方法,如果插入key成功,则表示获得到了锁,如果有人插入成功,其他人插入失败则表示无法获得到锁,利用这套逻辑来实现分布式锁
Zookeeper:zookeeper也是企业级开发中较好的一个实现分布式锁的方案,由于本套视频并不讲解zookeeper的原理和分布式锁的实现,所以不过多阐述

实现分布式锁时需要实现的两个基本方法:
获取锁:
释放锁:

核心思路:
我们利用redis 的setNx 方法,当有多个线程进入时,我们就利用该方法,第一个线程进入时,redis 中就有这个key 了,返回了1,如果结果是1,则表示他抢到了锁,那么他去执行业务,然后再删除锁,退出锁逻辑,没有抢到锁的哥们,等待一定时间后重试即可

锁的基本接口

SimpleRedisLock
利用setnx方法进行加锁,同时增加过期时间,防止死锁,此方法可以保证加锁和增加过期时间具有原子性
private static final String KEY_PREFIX="lock:"
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = Thread.currentThread().getId()
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
SimpleRedisLock
释放锁,防止删除别人的锁
public void unlock() {
//通过del删除锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
//创建锁对象(新增代码)
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//获取锁对象
boolean isLock = lock.tryLock(1200);
//加锁失败
if (!isLock) {
return Result.fail("不允许重复下单");
}
try {
//获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}
}
逻辑说明:
持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是误删别人锁的情况说明
解决方案:解决方案就是在每个线程释放锁的时候,去判断一下当前这把锁是否属于自己,如果属于自己,则不进行锁的删除,假设还是上边的情况,线程1卡顿,锁自动释放,线程2进入到锁的内部执行逻辑,此时线程1反应过来,然后删除锁,但是线程1,一看当前这把锁不是属于自己,于是不进行删除锁逻辑,当线程2走到删除锁逻辑时,如果没有卡过自动释放锁的时间点,则判断当前这把锁是属于自己的,于是删除这把锁。

需求:修改之前的分布式锁实现,满足:在获取锁时存入线程标示(可以用UUID表示)
在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致
核心逻辑:在存入锁时,放入自己线程的标识,在删除锁时,判断当前这把锁的标识是不是自己存入的,如果是,则进行删除,如果不是,则不进行删除。

具体代码如下:加锁
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
释放锁
public void unlock() {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁中的标示
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断标示是否一致
if(threadId.equals(id)) {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
有关代码实操说明:
在我们修改完此处代码后,我们重启工程,然后启动两个线程,第一个线程持有锁后,手动释放锁,第二个线程 此时进入到锁内部,再放行第一个线程,此时第一个线程由于锁的value值并非是自己,所以不能释放锁,也就无法删除别人的锁,此时第二个线程能够正确释放锁,通过这个案例初步说明我们解决了锁误删的问题。
更为极端的误删逻辑说明:
线程1现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,而且已经走到了条件判断的过程中,比如他已经拿到了当前这把锁确实是属于他自己的,正准备删除锁,但是此时他的锁到期了,那么此时线程2进来,但是线程1他会接着往后执行,当他卡顿结束后,他直接就会执行删除锁那行代码,相当于条件判断并没有起到作用,这就是删锁时的原子性问题,之所以有这个问题,是因为线程1的拿锁,比锁,删锁,实际上并不是原子性的,我们要防止刚才的情况发生,

Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,它的基本语法大家可以参考网站:https://www.runoob.com/lua/lua-tutorial.html,这里重点介绍Redis提供的调用函数,我们可以使用lua去操作redis,又能保证他的原子性,这样就可以实现拿锁比锁删锁是一个原子性动作了,作为Java程序员这一块并不作一个简单要求,并不需要大家过于精通,只需要知道他有什么作用即可。
这里重点介绍Redis提供的调用函数,语法如下:
redis.call('命令名称', 'key', '其它参数', ...)
例如,我们要执行set name jack,则脚本是这样:
# 执行 set name jack
redis.call('set', 'name', 'jack')
例如,我们要先执行set name Rose,再执行get name,则脚本如下:
# 先执行 set name jack
redis.call('set', 'name', 'Rose')
# 再执行 get name
local name = redis.call('get', 'name')
# 返回
return name
写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下:

例如,我们要执行 redis.call(‘set’, ‘name’, ‘jack’) 这个脚本,语法如下:

如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:

接下来我们来回一下我们释放锁的逻辑:
释放锁的业务流程是这样的
1、获取锁中的线程标示
2、判断是否与指定的标示(当前线程标示)一致
3、如果一致则释放锁(删除)
4、如果不一致则什么都不做
如果用Lua脚本来表示则是这样的:
最终我们操作redis的拿锁比锁删锁的lua脚本就会变成这样
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
lua脚本本身并不需要大家花费太多时间去研究,只需要知道如何调用,大致是什么意思即可,所以在笔记中并不会详细的去解释这些lua表达式的含义。
我们的RedisTemplate中,可以利用execute方法去执行lua脚本,参数对应关系就如下图股

Java代码
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
public void unlock() {
// 调用lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
经过以上代码改造后,我们就能够实现 拿锁比锁删锁的原子性动作了~
小总结:
基于Redis的分布式锁实现思路:
笔者总结:我们一路走来,利用添加过期时间,防止死锁问题的发生,但是有了过期时间之后,可能出现误删别人锁的问题,这个问题我们开始是利用删之前 通过拿锁,比锁,删锁这个逻辑来解决的,也就是删之前判断一下当前这把锁是否是属于自己的,但是现在还有原子性问题,也就是我们没法保证拿锁比锁删锁是一个原子性的动作,最后通过lua表达式来解决这个问题
但是目前还剩下一个问题锁不住,什么是锁不住呢,你想一想,如果当过期时间到了之后,我们可以给他续期一下,比如续个30s,就好像是网吧上网, 网费到了之后,然后说,来,网管,再给我来10块的,是不是后边的问题都不会发生了,那么续期问题怎么解决呢,可以依赖于我们接下来要学习redission啦
测试逻辑:
第一个线程进来,得到了锁,手动删除锁,模拟锁超时了,其他线程会执行lua来抢锁,当第一天线程利用lua删除锁时,lua能保证他不能删除他的锁,第二个线程删除锁时,利用lua同样可以保证不会删除别人的锁,同时还能保证原子性。
基于setnx实现的分布式锁存在下面的问题:
重入问题:重入问题是指 获得锁的线程可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,比如HashTable这样的代码中,他的方法都是使用synchronized修饰的,假如他在一个方法内,调用另一个方法,那么此时如果是不可重入的,不就死锁了吗?所以可重入锁他的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的。
不可重试:是指目前的分布式只能尝试一次,我们认为合理的情况是:当线程在获得锁失败后,他应该能再次尝试获得锁。
**超时释放:**我们在加锁时增加了过期时间,这样的我们可以防止死锁,但是如果卡顿的时间超长,虽然我们采用了lua表达式防止删锁的时候,误删别人的锁,但是毕竟没有锁住,有安全隐患
主从一致性: 如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题。

那么什么是Redission呢
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
Redission提供了分布式锁的多种多样的功能

引入依赖:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
配置Redisson客户端:
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
// 配置
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.150.101:6379")
.setPassword("123321");
// 创建RedissonClient对象
return Redisson.create(config);
}
}
如何使用Redission的分布式锁
@Resource
private RedissionClient redissonClient;
@Test
void testRedisson() throws Exception{
//获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("anyLock");
//尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
boolean isLock = lock.tryLock(1,10,TimeUnit.SECONDS);
//判断获取锁成功
if(isLock){
try{
System.out.println("执行业务");
}finally{
//释放锁
lock.unlock();
}
}
}
在 VoucherOrderServiceImpl
注入RedissonClient
@Resource
private RedissonClient redissonClient;
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
//创建锁对象 这个代码不用了,因为我们现在要使用分布式锁
//SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
RLock lock = redissonClient.getLock("lock:order:" + userId);
//获取锁对象
boolean isLock = lock.tryLock();
//加锁失败
if (!isLock) {
return Result.fail("不允许重复下单");
}
try {
//获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}
}
在Lock锁中,他是借助于底层的一个voaltile的一个state变量来记录重入的状态的,比如当前没有人持有这把锁,那么state=0,假如有人持有这把锁,那么state=1,如果持有这把锁的人再次持有这把锁,那么state就会+1 ,如果是对于synchronized而言,他在c语言代码中会有一个count,原理和state类似,也是重入一次就加一,释放一次就-1 ,直到减少成0 时,表示当前这把锁没有被人持有。
在redission中,我们的也支持支持可重入锁
在分布式锁中,他采用hash结构用来存储锁,其中大key表示表示这把锁是否存在,用小key表示当前这把锁被哪个线程持有,所以接下来我们一起分析一下当前的这个lua表达式
这个地方一共有3个参数
KEYS[1] : 锁名称
ARGV[1]: 锁失效时间
ARGV[2]: id + “:” + threadId; 锁的小key
exists: 判断数据是否存在 name:是lock是否存在,如果==0,就表示当前这把锁不存在
redis.call(‘hset’, KEYS[1], ARGV[2], 1);此时他就开始往redis里边去写数据 ,写成一个hash结构
Lock{
id + “:” + threadId : 1
}
如果当前这把锁存在,则第一个条件不满足,再判断
redis.call(‘hexists’, KEYS[1], ARGV[2]) == 1
此时需要通过大key+小key判断当前这把锁是否是属于自己的,如果是自己的,则进行
redis.call(‘hincrby’, KEYS[1], ARGV[2], 1)
将当前这个锁的value进行+1 ,redis.call(‘pexpire’, KEYS[1], ARGV[1]); 然后再对其设置过期时间,如果以上两个条件都不满足,则表示当前这把锁抢锁失败,最后返回pttl,即为当前这把锁的失效时间
如果小伙帮们看了前边的源码, 你会发现他会去判断当前这个方法的返回值是否为null,如果是null,则对应则前两个if对应的条件,退出抢锁逻辑,如果返回的不是null,即走了第三个分支,在源码处会进行while(true)的自旋抢锁。
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);"

说明:由于课程中已经说明了有关tryLock的源码解析以及其看门狗原理,所以笔者在这里给大家分析lock()方法的源码解析,希望大家在学习过程中,能够掌握更多的知识
抢锁过程中,获得当前线程,通过tryAcquire进行抢锁,该抢锁逻辑和之前逻辑相同
1、先判断当前这把锁是否存在,如果不存在,插入一把锁,返回null
2、判断当前这把锁是否是属于当前线程,如果是,则返回null
所以如果返回是null,则代表着当前这哥们已经抢锁完毕,或者可重入完毕,但是如果以上两个条件都不满足,则进入到第三个条件,返回的是锁的失效时间,同学们可以自行往下翻一点点,你能发现有个while( true) 再次进行tryAcquire进行抢锁
long threa