2024暑期实习八股笔记

文章目录

自我介绍

面试官你好,感谢贵公司给我一个面试的机会,我叫薛君宝,来自西安邮电大学计算机学院软件工程专业,现在是一名大三在校生,在大一期间我自学了c语言,加入了我们学校的实验室,选择java后端方向,先后学习了java基础,jdbc,mysql,ssm框架以及springboot框架,之后又学习了一些常用的中间件,比如redis和Kafka。在校期间完成了简历上的两个项目,第一个项目是在大二完成的一个校园订餐管理系统,实现了用户一键登录注册、点餐,餐厅管理菜单处理订单等功能。第二个项目是我在大三上学期的时候写的一个啾咪宠物托管平台,主要实现了用户购买和领养宠物,后台管理宠物以及订单和用户信息的功能。以上就是我的自我介绍。

MySQL

索引

索引种类、B+树

  • Hash表:不支持范围查找,每次io只能取一个

  • 二叉搜索树:依赖于它的平衡程度

  • AVL树:自平衡二叉搜索树,操作时间复杂度都是 O(logn)。需要频繁地进行旋转操作来保持平衡,每次io只能取一个

  • 红黑树 :自平衡二叉查找树,大致平衡。可能会导致树的高度较高,红黑树在插入和删除节点时只需进行 O(1) 次数的旋转和变色操作,每次io只能取一个

  • B+树 :B 树也称 B-树,全称为 多路平衡查找树 ,B+ 树是 B 树的一种变体。

    • 只有叶子节点存放 key 和 data,其他内节点只存放 key

    • 页之间是双向链表,同一个页内的数据是单向链表

    • 叶子节点的顺序检索很明显。

    • B+树的范围查询,只需要对链表进行遍历即可

    • 多叉路衡查找树

      数据页中有一个页目录,一个页目录有多个槽,每个槽对应一个分组最大的行记录,所有记录会分组,最小记录单独是一组,最大记录和最后一组在一块

      先二分查找页,再二分查找到数据在哪个槽,再遍历槽内所有记录

聚簇索引、非聚簇索引

  • 聚簇索引:索引结构和数据一起存放的索引,如innodb的主键索引

  • 优点

    • 查询速度快:整个B+树是多叉平衡树,叶子节点有序
    • 对排序查找和范围查找优化:对主键的排序查找和范围查找速度快
    • 缺点
      • 依赖于有序的数据:如果索引数据不有序,就需要在插入的时候排序

      • 更新代价大:如果索引列的数据被修改,对应的索引也会被修改,修改代价比较大,所以主键一般不可修改

  • 非聚簇索引:索引结构和数据分开存放的索引,如二级索引,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的前后条件中有一个列没有索引,涉及的索引都不会被使用到

    • 因为or就是满足一个就可以,因此只有一个条件列是索引列就没意义,只要有条件列不是索引列,就要进行全表扫描
  • 发生隐式转换:索引字段是字符串类型,查询时输入参数是整型的话,就走全表扫描。如果索引是整型,输入参数是字符串,就不会导致索引失效,因为mysql会自动把字符串转为数字(用函数)

索引优化

  • 选择合适的字段
    • 不为NULL:数据为NULL时,数据库较难优化
    • 被频繁查询的字段
    • 被作为条件查询的字段:
    • 高区分度的列,男女低区分度
    • 频繁需要排序的字段
    • 被经常频繁用于连接的字段:提高多表连接查询的效率
  • 被频繁更新的字段应该慎重简建立索引
    • 虽然查询快,但维护成本较高,如果一个字段不经常查询,但经常被修改,就不应该在这种字段上建立索引
  • 限制每张表上的索引数量
    • 单表不超过5个
    • 索引会增加查询效率,同时会降低插入和更新的效率
    • mysql优化器在选择如何优化查询时,会对每一个可以用到的索引进行评估,生成一个最好的执行计划,如果同时有很多个索引都可以用于查询,就会增加mysql优化器生成执行计划的时间,同时降低查询性能
  • 尽可能考虑建立联合索引而不是单列索引
    • 每个索引都对应一颗B+树,如果表的字段过多,索引过多,数据变多时,索引占用空间会很大,修改索引耗费的时间会更多,联合索引会节约磁盘空间,修改数据的操作效率会提升
  • 注意避免冗余索引
    • 索引功能相同,能命中(a,b)就肯定可以命中(a),那么索引(a)就是冗余索引。优先选择扩展索引而不是创建新索引
  • 字符串类型的字段使用前缀索引代替普通索引
    • 前缀索引仅限于字符串,会占用更小的空间
  • 删除长期未使用的索引
  • 直到如何分析语句是否走索引查询
    • EXPLAIN分析sql的执行计划,执行计划是一条sql语句在经过mysql的查询优化器的优化过后,具体的执行方式
  • 避免索引失效

日志、缓冲池

redo log(重做日志)

物理日志,记录在某个数据页做了什么修改,循环写,边写边擦

innodb独有,让innodb有了崩溃恢复能力

  • mysql的innodb引擎使用redo log(重做日志)保证事务持久性

  • 将写操作从随机写变为顺序写 ,写入redo log用了追加操作,所以磁盘操作是顺序写 ,而写入数据需要先找到写入位置,然后再写到磁盘,所以磁盘操作是随机写,因为顺序写高效,所以redo log写入磁盘开销更小

redo log buffer默认16MB

刷盘时机
  • mysql正常关闭
  • redo log buffer写入量大于一半时
  • 后台线程每1秒将redo log buffer持久化
  • 每次事务提交时将redologbuffer的数据直接持久化到磁盘

InnoDB 存储引擎为 redo log 的刷盘支持三种策略:

  • 0 :设置为 0 的时候,事务提交时不进行刷盘操作(容忍丢1秒)

    • 如果MySQL挂了或宕机可能会有1秒数据的丢失。
  • 1 :设置为 1 的时候,事务提交时都将进行刷盘操作(最安全,默认值

    • 只要事务提交成功,redo log记录就一定在硬盘里,不会有任何数据丢失
  • 2 :设置为 2 的时候,事务提交时都只把 redo log buffer 内容写入 page cache(折中,操作系统不宕机就不丢失1秒

    • mysql崩溃不丢失,操作系统崩溃或断电丢失1秒

innodb有一个后台线程,每隔1秒,就会将redo log buffer的内容写到文件系统缓存(page cache)然后调用fsync刷盘

redolog刷盘但事务未提交

mysql在重启后执行崩溃恢复,发现redo日志包含未提交的事务的更改,就回滚此事务,确保数据的一致性

日志文件组

redo log 不止一个,以日志文件组形式存在

采用环形数组,写满后,写下一个文件

  • write pos 是当前记录的位置
  • checkpoint 是当前要擦除的位置

每次刷盘 redo log 记录到日志文件组 中,write pos 位置就会后移更新。

每次 MySQL 加载日志文件组 恢复数据时,会清空加载过的 redo log 记录,并把 checkpoint 后移更新。

write poscheckpoint 之间的还空着的部分可以用来写入新的 redo log 记录。

如果 write pos 追上 checkpoint ,表示日志文件组 满了,这时候不能再写入新的 redo log 记录,MySQL 得停下来,将缓冲池的脏页刷盘,再标记redo log哪些记录可以被擦除,擦除腾空间后 checkpoint 后移

bin log(归档日志)

用于数据备份和主从复制

binlog是逻辑日志,记录语句的原始逻辑,比如给这个字段+1,属于Server层

完成更新操作后,Server都会产生binlog日志,事务提交后,会将事务执行过程的所有binlog统一写入binlog文件

binlog会记录所有涉及更新数据的逻辑操作,并且是顺序写。

记录格式

binlog 日志有三种格式,可以通过binlog_format参数指定。

  • statement默认:记录原始sql,但可能有实时性函数导致不一致
  • row:记录行数据最终被修改成的样子,缺点时每行数据的变化都会记录,批量修改产生大量数据,而在默认情况下只记录一个update
  • mixed:根据不同情况使用上面两个
写入机制

事务执行时,先把日志写到binlog cache(Server层的cache),事务提交的时候,再把binlog cache写到binlog文件

一个事务的binlog不能被拆开,确保一次性写入,所以系统会给每个线程分配一个块内存作为binlog cache

每个线程都向page cache里write ,再fsync到磁盘,虽然每个线程都有自己的binlog cache,但最终都写入同一个binlog文件

writefsync的时机,默认是0

  • 0:提交事务只write,不fsync,由操作系统决定fsync
    • 一旦异常重启,每持久化的数据就丢失
  • 1最安全:每次提交事务都write,然后马上fsync
    • 最多丢失一个事务的binlog
  • n(>1):每次提交事务都write,累积n个事务后fsync
    • 如果能容少量binlog日志丢失的风险,为了提高性能,就设置100-1000

两阶段提交

避免两份日志之间的逻辑不一致的问题,内部XA事务,两阶段提交这个事务

事务提交后,redo log和bin log都要持久化到磁盘,但两个独立,造成逻辑不一致

过程:把redo log的写入拆分成准备和提交,中间穿插写入binlog

  • 准备:写入redo log,同时将redo log对应的事务状态设置为准备,再将redo log持久化到磁盘
  • 提交:写入bin log,然后将binlog持久化到磁盘,接着提交事务,将redo log状态设置为commit,write到page cache,因为只要binlog写磁盘成功,即使redo log的状态还是prepare一样会被认为事务已经执行成功

如果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 必须在事务提交之后,才可以持久化到磁盘。

缺点

  • 磁盘IO次数高:每个事务两次刷盘
  • 锁竞争激烈:多事务需要加锁保证两个日志提交顺序一致

解决方法:组提交:多个事务提交时,会将多个binlog刷盘操作合并为一个,减少磁盘IO,prepare不变,commit分为三个阶段

  • flush:多个事务按顺序将binlog写入page cache
  • sync:对binlog做fsync,合并刷盘
  • commit:各个事务按顺序提交

每个阶段都有一个队列,每个阶段有锁保护,第一个事务是leader,此时,锁只针对每个队列进行保护,不再锁住提交事务的整个过程,锁粒度变小,提高并发效率

undo log(回滚日志)

逻辑日志

  • 实现事务回滚,保证事务的原子性 ,在事务没提交之前,mysql会先记录更新前的操作到undo log日志,事务回滚时利用undo log回滚。并且回滚日志 会先持久化到磁盘上(依靠redo log,对undo的修改会记录到redo log),保证了数据库宕机的情况,用户再次启动数据库时,数据库能够通过查询回滚日志回滚之前未完成的事务
  • 实现MVCC的因素之一:undo log为每条记录保存多份历史数据,快照读时会根据Read View的信息,顺着undo log版本链找到满足可见性的记录

Buffer Pool缓冲池

缓存表数据和索引数据,磁盘数据加载到缓冲池,避免每次磁盘io,提高数据库的读写性能

  • 读数据时,如果数据存在缓冲池,就直接读取缓冲池的数据,否则去磁盘读取
  • 修改数据时,如果数据存在缓冲池,就直接修改缓冲池数据所在的页,设置为脏页,为了减少磁盘io,不会立即将脏页写入磁盘,时机由后台线程选择

缓冲池是连续的内存空间,里面的页是缓存页,有索引页、数据页、Undo页、插入缓存、自适应哈希索引、锁信息等

查询记录时会缓存整个页的数据

每个缓存页都有控制块 (缓存页的表空间,页号,缓存页地址,链表节点),控制块和缓存页之间的部分为碎片空间(每个控制块都有缓存页,剩余不够一对控制块和缓存页的大小就是碎片)

空闲链表:空闲缓存页的控制块作为链表节点,有空闲链表后,每次从磁盘加载页到缓冲池后,就从空闲链表取出一个空闲的缓存页,把缓存页对应的控制块信息填上,再把缓存页对应的控制块从空闲链表删除

Flush链表:快速知道哪些缓存页是脏的,链表节点也是控制块,后台线程就可以遍历Flush链表,将脏页写入磁盘

主从复制、分库分表

主从复制

主要涉及三个线程: binlog 线程、I/O 线程和 SQL 线程。

  • binlog 线程 : 负责将主服务器上的数据更改写入binlog。
  • I/O 线程 : 负责从主服务器上读取binlog,并写入从服务器的Relay log中继日志中。
  • SQL 线程 : 负责读取Relay log中继日志并重放其中的 SQL 语句。

全同步复制

所有的从库都执行完成后才返回给客户端

半同步复制

从库写入日志成功后返回ACK确认给主库,主库收到至少一个从库的确认就认为写操作完成。

分库分表

项目业务数据增多,业务发展迅速,单表数据量到1000w或20G、优化解决不了性能问题、IO瓶颈(磁盘、网络)、CPU瓶颈(聚合查询、连接太多)

垂直

  • 垂直分库:以表为依据,根据业务将不同表拆分到不同库,如不同微服务对应不同的库
    • 按业务对数据分级管理
    • 高并发提高磁盘io和数据量连接数
  • 垂直分表:以字段为依据,根据字段属性将不同字段拆分到不同表。把不常用字段单独放在一张表,如id和描述、id和其他信息
    • 冷热数据分离
    • 减少Io过度争抢,两表互不影响

水平

  • 水平分库:将一个库的数据拆分到多个库中,可以根据Id节点驱魔,将一个业务的库拆分到多个库
    • 解决单库大数量,高并发性能瓶颈
    • 提高系统稳定性和可用性
  • 水平分表:将一个表的数据拆分到多个表(可以在一个库)
    • 优化单一表数据量大的性能维妮塔
    • 避免io较少缩表几率

事务、MVCC

ACID特性

  • 原子性 :一个事务的所有操作,要么全部完成,要么全部不完成,执行时发生错误会回滚
    • undo log(回滚日志)保证
  • 一致性 :事务操作前和操作后,数据满足完整性约束,保持一致性状态
    • 持久性+原子性+隔离性保证
  • 隔离性 :允许多个并发事务同时对数据进行读写和修改的能力,多个事务使用相同数据
    • MVCC或锁机制保证
  • 持久性 :事务提交后,修改是永久的
    • redo log(重做日志)保证

隔离级别

sql标准定义的隔离级别

  • 读未提交:允许读取尚未提交的数据变更
  • 读已提交:允许读取并发事务已经提交的数据
  • 可重复读:对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改
  • 可串行化:所有的事务依次逐个执行

innodb的可重复读可以很大程度解决幻读,有两种情况

  1. 快照读:用MVCC保证不出现幻读,可重复读下,读取一致性数据
  2. 当前读:使用Next-key Lock加锁保证不出现幻读,Next-key Lock是行锁和间隙锁的结合,行锁只能锁住已经存在的行,为了避免插入新行,需要依赖间隙

MVCC

多版本并发控制,多个并发事务同时读写数据的时候保证数据的一致性和隔离性,通过在每行维护多个版本的数据实现,当一个事务要修改数据时,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 。如果该行未被更新,则为空
ReadView

Read View主要用来做可见性判断,里面保存了当前对本事务不可见的其他活跃事务

主要有以下字段:

  • m_low_limit_id:目前出现过的最大的事务 ID+1,大于等于这个 ID 的数据版本均不可见
  • m_up_limit_id:活跃事务列表 m_ids 中最小的事务 ID,小于这个 ID 的数据版本均可见(已经提交)
  • m_idsRead View 创建时其他未提交的活跃事务 ID 列表。创建 Read View时,将当前未提交事务 ID 记录下来,后续即使它们修改了记录行的值,对于当前事务也是不可见的。m_ids 不包括当前事务自己和已提交的事务(正在内存中)
  • m_creator_trx_id:创建该 Read View 的事务 ID
undo-log

当读取记录时,若该记录被其他事务占用或当前版本对该事务不可见,则可以通过 undo log 读取之前的版本数据,以此实现非锁定读

InnoDB 存储引擎中 undo log 分为两种:insert undo logupdate undo log

  1. insert undo log :指在 insert 操作中产生的 undo log。因为 insert 操作的记录只对事务本身可见,对其他事务不可见,故该 undo log 可以在事务提交后直接删除。

  2. update undo logupdatedelete 操作中产生的 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看能不能访问,以此类推直到返回可见的版本或者结束。

RC 和 RR 隔离级别下 MVCC 的差异
  • 在 RC 隔离级别下的 每次select 查询前都生成一个Read View (m_ids 列表),后续查询都利用这个Read View,通过这个Read View就可以在undo log版本链找到事务开始时的数据,所以每次查询的数据都一样
  • 在 RR 隔离级别下只在事务开始后 第一次select 数据前生成一个Read View(m_ids 列表)

s

MVCC+临建锁防止幻读

RR级别通过MVCC和临建锁解决幻读

  • 普通select会以MVCC快照读方式读取数据,RR只会在事务开启的第一次查询生成read view,所以其他事务的更新、插入对当前事务不可见,实现可重复读和防止快照读下的幻读
  • 执行select...for update、插入、修改、删除等当前读:当前读下,读取的都是最新的数据,如果其他事务插入新的数据,并且刚好在事务查询范围里,就会产生幻读,使用临建锁防止幻读,执行当前读时,锁定读取到的记录的同时,锁定间隙,防止其他事务在查询范围插入数据。只要不插入,就不会幻读

可重复读不完全解决幻读

  1. 可重复读时,事务1第一次普通select生成Read View,之后事务2新添加记录并提交,然后事务1对那条记录进行更新,此时这条记录的trx_id就变成事务1的事务id,之后事务1就可以用普通select查询该条记录
  2. 事务1先快照读,事务2插入一条记录,事务1再次当前读就会读到事务2插入的记录,幻读
    1. 解决时:在开启事务之后,立刻执行当前读,因为会对记录加临建锁,避免其他事务在对应范围插入新纪录

InnoDB、插入缓存、预读

四大特性

  • 插入缓存(写缓存) :change buffer(增删改操作有效)是insert buffer(只对insert有效)的增强,提升插入性能,降低磁盘io
    • 对于非唯一普通索引页(唯一索引在插入时需要判断唯一,要读取辅助索引页到缓冲池)才能使用,不在缓冲池,对页进行写操作,不会立刻加载到缓冲池,仅仅记录缓冲变更,等下次读取时再将数据合并恢复到缓冲池(还有后台线程在数据库空闲时、缓冲池不够时、数据库正常关闭、redolog写满时都会触发刷新)
    • 插入时先判断插入的索引页是否在缓冲池,在就直接插入,不在就先放入insert buffer,再进行合并操作,写回磁盘,通常把多个插入合并到一个操作,减少随机io,比如Insert buffer有1,99,2,100,合并之前需要4次插入,合并之后1、2可能一个页,99、100可能一个页,变成两次插入
    • 适合大部分使用非唯一索引,业务写多读少,不是写后立刻读取
  • 二次写:doublewrite缓存在系统表空间,缓存innodb的数据页从buffer pool中flush之后并写入数据文件之前,如果操作系统或数据库在数据页写入磁盘时崩溃,可以在二次写缓存找到数据页备份再恢复,数据页写入二次写缓存快,刷新脏页时会先写入二次写缓存
  • 自适应哈希:innodb监控二级索引的查找,如果发现有二级索引被频繁访问,就给该索引建立哈希索引加速查询,只是等值查询,范围查找不行
  • 预读 :线性预读和随机预读,异步把磁盘的页读取到buffer pool里,预料这些页很快会被读到。
    • mysql没有使用传统lru缓冲池,因为有预读失效和缓冲池污染

    • 预读失效:预读把页放到缓冲池,但没被访问

      • 优化:让预读失败的页在缓冲池lru时间尽量短,让真正被读取的页挪到lru头部
        • 将lru分为新生代和老年代,新生代在前,新页加入老年代头部,如果数据被读取,再加入新生代头部,否则就比新生代的热数据更早淘汰出去
    • 缓冲池污染:sql扫描大量数据时可能把缓冲池所有页替换出去,导致大量热数据被换出,同时新数据很少访问

      • 解决:mysql缓冲池加入一个老年代停留时间窗口机制,数据先插入老年代头部,只有满足被访问并且在老年代停留时间大于1他,才放入新生代头部,此时短时间大量加载的页并不会立刻插入新生代头部,而是优先淘汰短期仅访问一次的页

MyISAM和InnoDB有什么区别?

  • InnoDB 支持行级别的锁粒度,MyISAM 不支持,只支持表级别的锁粒度。
  • MyISAM 不提供事务支持。InnoDB 提供事务支持
  • MyISAM 不支持 MVCC,而 InnoDB 支持。
  • MyISAM 不支持数据库异常崩溃后的安全恢复,而 InnoDB 支持。

sql语句执行过程、mysql架构

sql语句执行过程

  • MySQL 主要分为 Server 层和引擎层,Server 层主要包括连接器、查询缓存、分析器、优化器、执行器,同时还有一个日志模块(binlog),这个日志模块所有执行引擎都可以共用,redolog 只有 InnoDB 有。
  • 引擎层是插件式的,目前主要包括,MyISAM,InnoDB,Memory 等。
  • 查询语句的执行流程如下:权限校验(如果命中缓存)--->查询缓存--->分析器--->优化器--->权限校验--->执行器--->引擎
  • 更新语句执行流程如下:分析器---->权限校验---->执行器--->引擎---redo log(prepare 状态)--->binlog--->redo log(commit 状态)

mysql基本架构概览

  • 连接器: 身份认证和权限相关(登录 MySQL 的时候)。

  • 查询缓存: 执行查询语句的时候,会先查询缓存(MySQL 8.0 版本后移除,因为这个功能不太实用)。

  • 分析器: 没有命中缓存的话,SQL 语句就会经过分析器,分析器说白了就是要先看你的 SQL 语句要干嘛,再检查你的 SQL 语句语法是否正确。

    • 词法分析:sql语句由多个字符串组成,先提取关键字,如select
    • 语法分析:判断sql是否符合语法
  • 优化器: 按照 MySQL 认为最优的方案去执行。

  • 执行器: 执行前会校验用户有没有权限,没有就会返回错误信息,有就会调用引擎的接口,返回接口执行的结果。

mysql分为server层和存储引擎层

  • server层:包括连接器、查询缓存、分析器、优化器、执行器等,跨存储引擎的功能在这一层实现,如存储过程、触发器、视图、函数等,还有一个通用日志模块binlog
  • 存储引擎:负责数据的存储和读取,支持Innodb,MyISAM,Memory,innodb有日志模块redo log,mysql5.5时是默认innodb存储引擎

慢查询、执行计划

定位 :慢查询日志记录所有执行时间超过默认10秒的sql,要配置开启慢查询( slow_query_log = 1 ),查询命令show variables like 'long_query_time';

原因:explain或者desc加上查询sql

执行计划是一条sql在经过mysql查询优化器后具体的执行方式

  1. select_type:查询的类型,主要用于区分普通查询、联合查询、子查询等复杂的查询,常见的值有:

    1. UNION:在 UNION 语句中,UNION 之后出现的 SELECT。
    2. DERIVED:在 FROM 中出现的子查询将被标记为 DERIVED。
  2. type:查询执行的类型,描述了查询是如何执行的。所有值的顺序从最优到最差排序为:system > const > eq_ref > ref > fulltext > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > ALL

  3. possible_keys:可能使用的索引,没有就为NULL

  4. key:实际用的索引,NULL表示没用到索引

  5. key_len:实际使用索引的最大长度,联合索引时可能是多个列的长度和

  6. rows:估算找到所需记录需要读取的行数,越小越好

  7. Extra:额外信息

    1. Using index:表明查询使用了覆盖索引,不用回表,查询效率非常高。
    2. Using index condition:表示查询优化器选择使用了索引下推这个特性。

mysql中的锁

表级锁和行级锁对比

表级锁和行级锁对比

  • 表级锁: MySQL 中锁定粒度最大的一种锁(全局锁除外),是针对非索引字段加的锁,对当前操作的整张表加锁,MyISAM 和 InnoDB 引擎都支持表级锁。
  • 行级锁: MySQL 中锁定粒度最小的一种锁,是 针对索引字段加的锁 ,只针对当前操作的行记录进行加锁。 行级锁能大大减少数据库操作的冲突。其加锁粒度最小,并发度高,但加锁的开销也最大,加锁慢,会出现死锁。行级锁和存储引擎有关,是在存储引擎层面实现的。

记录锁、间隙锁、临建锁、插入意向锁(行锁)

InnoDB 行锁是通过对索引数据页上的记录加锁实现的,MySQL InnoDB 支持三种行锁定方式:

  • 记录锁(Record Lock) :也被称为记录锁,属于单个行记录上的锁。
    • 有共享锁S和排他锁X
  • 间隙锁(Gap Lock) :锁定一个范围,不包括记录本身。锁前开后开
    • 多个事务可以同时持有相同间隙范围的间隙锁,不存在互斥关系
  • 临键锁(Next-Key Lock) :Record Lock+Gap Lock,锁定一个范围,包含记录本身,主要目的是为了解决幻读问题。记录锁只能锁住已经存在的记录 ,为了避免插入新记录,需要依赖间隙锁。是加锁基本单位,锁前开后闭
    • 用唯一索引等值查询时,如果查询记录存在,临建锁会退化为记录锁,如果不存在,临建锁会退化为间隙锁
    • 用唯一索引范围查询时,会对每个扫描的索引加间隙锁,但如果是大于等于就会退化为记录锁,小于或小于等于就会退化为间隙锁
    • 用普通索引等值查询时 ,会同时对主键索引和普通索引加锁,但对主键索引加锁时只有满足查询条件的记录才对主键加锁
      • 如果查询记录存在,由于肯定存在索引值相同的记录,所以是一个扫描的过程,直到第一个不符合条件的二级索引就停止,途中加的是临建锁,第一个不符合条件的退化为间隙锁,符合查询条件的加记录锁
      • 如果查询记录不存在,扫描到第一条不符合条件的二级索引记录,该索引的临建锁退化为间隙锁,因为不存在满足条件的记录,就不会对主键索引加锁
    • 用普通索引范围查询时,临建锁不会退化,都是临建锁
    • 没加索引的查询 ,当前读查询时,没有用索引列作为查询条件或查询语句没走索引,导致全表扫描,那么每一条记录都会加临建锁,相当于锁全表
      • 避免方法:设置安全更新,update必须满足1(用where,并且条件必须有索引列)2(用limit)3(同时用where和limit,where可以没索引列)其中之一才能执行成功,delete必须满足同时用where和limit,where可以没索引列才能执行成功,如果where带了索引但优化器还是选择全表,可以用force index告诉优化器使用哪个索引
  • 插入意向锁 :锁定一个点,是特殊的间隙锁,如事务1在准备插入的时候发现已经被事务2加了间隙锁,插入就会阻塞,此时事务1生成一个插入意向锁(处于等待状态,不意味事务1获取到锁,只有正常状态才能获取到锁),此时事务1发生阻塞,直到事务2提交了事务。注意两个事务在同一时间不能一个拥有间隙锁,另一个拥有对应间隙的插入意向锁

共享锁和排他锁

不论是表级锁还是行级锁,都存在共享锁(Share Lock,S 锁)和排他锁(Exclusive Lock,X 锁)这两类:

  • 共享锁(S 锁):又称读锁,事务在读取记录的时候获取共享锁,允许多个事务同时获取(锁兼容)。
  • 排他锁(X 锁):又称写锁/独占锁,事务在修改记录的时候获取排他锁,不允许多个事务同时获取。如果一个记录已经被加了排他锁,那其他事务不能再对这条事务加任何类型的锁(锁不兼容)。

意向锁(表锁)

快速判断是否可以对某个表使用表锁

意向锁是表级锁,共有两种:

  • 意向共享锁(Intention Shared Lock,IS 锁):事务有意向对表中的某些记录加共享锁(S 锁),加共享锁前必须先取得该表的 IS 锁。
  • 意向排他锁(Intention Exclusive Lock,IX 锁):事务有意向对表中的某些记录加排他锁(X 锁),加排他锁之前必须先取得该表的 IX 锁。

意向锁是由数据引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享/排他锁之前,InooDB 会先获取该数据行所在在数据表的对应意向锁。

意向锁之间是互相兼容的。意向锁和共享锁和排它锁互斥(这里指的是表级别的共享锁和排他锁,意向锁不会与行级的共享锁和排他锁互斥)。

数据存储、行格式

表空间文件结构

系统表空间(共享表空间):数据、索引页,insert buffer

临时表空间:存放用户创建的临时表和磁盘内部的临时表

undo表空间:

段、区、页、行

  • 页:InnoDB以页为单位读写
  • 区:InnoDB的B+树,表中数据量大的时候,给索引分配空间以区为单位1MB,连续64个页一个区,这样相邻页的物理位置相邻,就能用顺序IO
  • 段:
    • 索引段:放B+树的非叶子节点
    • 数据段:放B+树的叶子节点
    • 回滚段:回滚数据集合

行格式

  • Redundant:不紧凑,古老
  • Compact:紧凑,5.1默认,额外信息和真实数据
    • 额外信息 :变长字段列表、空值列表、记录头信息
      • 变长字段:varchar会存实际数据长度,varchar(n)里面n是字符数量,最大65535字节,只出现在有变长字段的时候
      • 空值列表:每个列对应二进制位,只出现在表的字段存在可以空的时候
      • 记录头信息:标识数据是否被删除、下一条记录的位置、当前记录的类型(非叶子节点、最小、最大、普通)
    • 真实数据 :还有三个隐藏字段,主键id、事务id、回滚指针
      • 主键id:没指定同时没唯一约束,就使用这个
      • 事务id:标识数据是哪个事务生成的
      • 回滚指针:记录上一个版本的指针
  • Dynamic:紧凑,5.7默认
  • Compressed:紧凑

行溢出

单个记录过大,一个页存不了一条记录,多的数据会存到溢出页

发生页溢出时,记录的真实数据处只保存该列的一部分数据,剩余数据在溢出页,在真实数据保存溢出页地址,这是Compact行格式的策略。Dynamic和Compressed都是把所有数据都保存在溢出页,然后只保存溢出页地址

磁盘IO过高优化

延迟binlog和redolog的刷盘时机,降低磁盘IO的频率

  • 设置组提交参数延迟binlog刷盘时机
  • 设置累积n个事务再提交binlog,延迟binlog刷盘时机
  • redolog参数设置2,每次写入page cache,由操作系统决定什么时候持久化

mysql、sql语句优化

  • 表设计优化:数据类型,char(定长效率高)和varchar

  • 索引优化:优化创建原则和索引失效

  • sql语句优化

    • select避免使用*,可能不能使用覆盖索引
    • 避免索引失效
    • unionall替代union(多一次过滤)
    • 尽量inner join,以小表为驱动,内连接会优化把小表放到外边
  • 主从复制、读写分离:解决数据库的写入,影响查询的效率

  • 分库分表

  • limit优化:limit 10000,10 和 limit 0,10 性能差距大,因为执行逻辑是

    • 从表读取10条记录到数据集中

    • 重复第一步直到读到第10010调记录

    • 根据offset抛弃前面10000条数

    • 返回剩余10条数据

      • 优化方法1:给该字段加索引,但只是因为查询简单
      • 优化2:SELECT * FROM product WHERE ID > =(select id from product limit 10000, 1) limit 10
      • 优化3:SELECT * FROM product a JOIN (select id from product limit 10000, 20) b ON a.ID = b.id

框架

Spring

IOC

Bean 的作用域
  • singleton : IoC 容器中只有唯一的 bean 实例。Spring 中的 bean 默认都是单例的,是对单例设计模式的应用。
  • prototype : 每次获取都会创建一个新的 bean 实例。也就是说,连续 getBean() 两次,得到的是不同的 Bean 实例。
  • request (仅 Web 应用可用): 每一次 HTTP 请求都会产生一个新的 bean(请求 bean),该 bean 仅在当前 HTTP request 内有效。
  • session (仅 Web 应用可用) : 每一次来自新 session 的 HTTP 请求都会产生一个新的 bean(会话 bean),该 bean 仅在当前 HTTP session 内有效。
  • application/global-session (仅 Web 应用可用):每个 Web 应用在启动时创建一个 Bean(应用 Bean),该 bean 仅在当前应用启动时间内有效。
Bean 是线程安全的吗?

Spring 框架中的 Bean 是否线程安全,取决于其作用域和状态。

单例时可能存在线程安全问题,针对有状态对象(如每个请求都可以修改一个成员变量),可以使用ThreadLocal

Bean 的生命周期

spring实例化时,根据 BeanDefinition 创建对象

  1. 构造函数

  2. 依赖注入

  3. 检查Aware相关接口并设置相关依赖(传入bean名称、bean类加载器、bean工厂实例)

  4. BeanPostProcessor后置处理器在初始化方法之前处理

  5. 初始化方法

    1. 检查是不是InitializingBean来决定是否调用afterPropertiesSet方法
    2. 检查是否配置有自定义的iit-method
  6. BeanPostProcessor后置处理器在初始化方法之后处理

  7. 销毁bean

    1. 如果bean实现了DisposableBean接口, 就执行destroy()
    2. 销毁bean的时候,如果bean在配置文件包含destroy-method属性,就执行对应方法
循环依赖/循环引用

两个或两个以上的bean互相持有对象,最终形成闭环,比如a依赖b,b依赖a

三级缓存解决大部分循环依赖

  • 一级缓存:单例池,缓存已经经历完整生命周期,初始化完成的bean对象
  • 二级缓存:缓存早期的bean对象(生命周期没走完)
    • 避免多次调用对象工厂产生多例,每次生成对象不同,所以使用工厂生产好的对象直接放到二级缓存,使用时都是同一个
    • 只使用一二级缓存可以解决一般对象的循环依赖,但如果是代理对象,就应该注入代理对象,但spring都是在创建完bean之后才创建对应的代理(代理在后置处理的初始化后完成aop代理),不提前创建代理对象,在出现循环依赖被其他对象注入时,才生成代理对象放入二级缓存(设计原则),所以引入三级缓存
  • 三级缓存:缓存ObjectFactory,主要用来创建代理对象,代理对象再放到二级缓存

先实例化a,a生成一个对象工厂放到三级缓存,注入b的时候需要实例化b,此时b生成一个对象工厂放到三级缓存,b里需要注入a,通过三级缓存里a的对象工厂创建代理对象一个(也可以是指定的其他对象)放入二级缓存 ,再从二级缓存把a的代理对象注入到b,b创建成功后放入单例池,把b注入a后放入单例池

构造方法的循环依赖需要使用@Lazy进行懒加载,需要对象再进行bean对象的创建,因为bean的生命周期中构造函数第一个执行,框架不能解决构造函数的依赖注入

AOP

面向切面编程能将与业务无关,却为业务逻辑调用相同的逻辑封装起来。减少重复代码

基于动态代理,如果要代理的对象实现某个接口,就用jdk动态代理,其他使用cglib动态代理

AspectJ是AOP的框架

前置、后置、返回(方法结束返回结果值之前)、异常、环绕通知

@Order自定义切面顺序

Spring 框架的设计模式

  • 工厂设计模式 : Spring 使用工厂模式通过 BeanFactoryApplicationContext 创建 bean 对象。
    • BeanFactory:延迟注入(使用bean的时候注入)
    • ApplicationContext:容器启动的时候一次性创建所有bean,扩展了BeanFactory
  • 代理设计模式 : Spring AOP 功能的实现。
  • 单例设计模式 : Spring 中的 Bean 默认都是单例的。
    • ConcurrentHashMap实现,给这个map添加的时候synchronized这个map
  • 模板方法模式 : Spring 中 jdbcTemplatehibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。
  • 装饰者设计模式 : 我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。
  • 观察者模式: Spring 事件驱动模型就是观察者模式很经典的一个应用。
  • 适配器模式 : Spring AOP 的增强或通知(Advice)使用到了适配器模式、spring MVC 中也是用到了适配器模式适配Controller
    • AOP中,每个类型的通知都有对应的拦截器,通知要通过对应的适配器,是配成MethodInterceptor接口类型的对象,通过调用getInterceptor,适配成MethodBeforeAdviceInterceptor
    • MVC中,DispatcherServlet调用HandlerMapping,解析请求对应的Handler,解析到对应的Handler(Controller),再由HandlerAdapter适配器处理,Controller作为需要适配的类,因为Controller太多,不同Controller要通过不同方法对请求处理,所以让适配器执行处理

Spring 事务

管理事务的方式
  • 编程式事务 :在代码中硬编码(不推荐使用) : 通过 TransactionTemplate或者 TransactionManager 手动管理事务,实际应用中很少使用,但是对于你理解 Spring 事务管理原理有帮助。
  • 声明式事务 :在 XML 配置文件中配置或者直接基于注解(推荐使用) : 实际是通过 AOP 实现(基于@Transactional 的全注解方式使用最多)
事务传播行为

事务传播行为是为了解决业务层方法之间互相调用的事务问题

四种正确的行为

  1. REQUIRED:默认,如果存在事务就加入该事务,如果当前没有事务,就创建一个新事务
  2. REQUIRES_NEW:创建一个新事务,如果存在当前事务,就把当前事务挂起
  3. NESTED:如果当前存在事务,就创建一个事务作为当前事务的嵌套事务运行,如果当前没有事务,就创建一个新事务
  4. MANDATORY:如果当前存在事务,就加入该事务,如果当前没有事务,就抛出异常

三种错误的行为,事务可能不会回滚:

  1. SUPPORTS:如果当前存在事务,就加入该事务,如果当前没有事务,就以非事务的方式继续运行
  2. NOT_SUPPORTED:以非事务方式运行,如果当前存在事务,就把当前事务挂起
  3. NEVER:以非事务方式运行,如果当前存在事务,就抛出异常
工作原理

基于AOP动态代理,一个方法添加@Transactional注解之后,spring会基于这个类生成一个代理对象,会把这个代理对象作为bean,使用这个代理对象的时候,如果有事务处理,就先把事务的自动提交关闭,再执行具体的业务,如果业务没有出现异常,spring会提交事务,如果出现异常,会回滚

事务失效
  • 异常捕获处理:事务通知只有捕捉到目标抛出的异常才能进行回滚,如果目标自己处理,事务通知就无法回滚,可以在catch后再次throw
  • 抛出检查异常:比如读文件编译异常,文件并不存在,因为spring默认只回滚非检查异常,可以配置rollbackFor属性为Exception.class
  • 非public方法导致事务失效:spring为方法创建代理,添加事务通知,前提条件是方法是public的

SpringMVC

核心组件

  • DispatcherServlet核心的中央处理器,负责接收请求、分发,并给予客户端响应。
  • HandlerMapping处理器映射器 ,根据 URL 去匹配查找能处理的 Handler ,并会将请求涉及到的拦截器和 Handler 一起封装。
  • HandlerAdapter处理器适配器 ,根据 HandlerMapping 找到的 Handler ,适配执行对应的 Handler
  • Handler请求处理器,处理实际请求的处理器。
  • ViewResolver视图解析器 ,根据 Handler 返回的逻辑视图 / 视图,解析并渲染真正的视图,并传递给 DispatcherServlet 响应客户端

执行流程/工作原理

  1. 客户端(浏览器)发送请求, DispatcherServlet前端控制器拦截请求。
  2. DispatcherServlet 根据请求信息调用 HandlerMapping处理器映射器HandlerMapping 根据 URL 去匹配查找能处理的 HandlerController 控制器) ,并会将请求涉及到的拦截器和 Handler 一起封装。返回处理器执行链
  3. DispatcherServlet 调用 HandlerAdapter处理器适配器 执行 Handler
  4. Handler 完成对用户请求的处理后,会返回一个 ModelAndView 对象给DispatcherServletModel 是返回的数据对象,View 是个逻辑上的 View
  5. 前端控制器调用 ViewResolver视图解析器 把逻辑视图转换为真正视图返回给前端控制器
  6. 渲染视图
  7. View 返回给请求者(浏览器)

SpringBoot

自动装配/自动配置

SpringBoot 在启动时会扫描外部引用 jar 包中的META-INF/spring.factories文件,将文件中配置的类型信息加载到 Spring 容器,并执行类中定义的各种操作。对于外部 jar 来说,只需要按照 SpringBoot 定义的标准,就能将自己的功能装置进 SpringBoot。

引入第三方依赖只需要引入一个starter,再通过注解和一些配置就可以使用

核心注解@SpringBootApplication

  • @EnableAutoConfiguration:启用 SpringBoot 的自动配置机制
    • 自动装配核心功能的实现实际是通过 AutoConfigurationImportSelector类。实现了 ImportSelector接口,也就实现了这个接口中的 selectImports方法,该方法主要用于获取所有符合条件的类的全限定类名,这些类需要被加载到 IoC 容器中
    • 在selectImports方法里,getAutoConfigurationEntry()方法,这个方法主要负责加载自动配置类的。
      • 先判断自动装配开关是否打开,找@EnableAutoConfiguration注解
      • 再获取该注解排除的类信息
      • 接着获取需要自动装配的所有配置类 ,读取所有Starter下的META-INF/spring.factories(starter扩展包都有META-INF)
      • 接着筛选需要装配的类,通过@ConditionalOnXXX,所有条件都满足该类才生效,比如一些类依赖其他bean,就有这个条件,根据条件决定是否加载bean
  • @Configuration:允许在上下文中注册额外的 bean 或导入其他配置类
  • @ComponentScan:扫描被@Component (@Service,@Controller)注解的 bean,注解默认会扫描启动类所在的包下所有的类 ,可以自定义不扫描某些 bean。

Mybatis

执行流程

  1. 构建会话工厂sqlSessionFactory,全局一个,生产sqlSession
  2. 创建会话SqlSession,项目与数据库的会话,包含执行sql的所有方法,每次操作一次会话
  3. Executor执行器,真正操作数据库接口,维护缓存
  4. MappedStatement对象,读取存储mapper里的一个方法信息,代表某一次数据库的操作
  5. 输入参数映射
  6. 输出结果映射

分页插件

原理:分页插件的基本原理是使用 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

只有会话提交或关闭后,一级缓存的数据才会转移到二级缓存

可以配置两个级别

  • SESSION:默认,一个会话的所有语句共享一个缓存
    • 在修改数据后查询,一级缓存会失效
  • STATEMENT:缓存只对当前执行的这一个Statement有效
二级缓存

多个sqlsession共享缓存,使用CachingExecutor装饰Executor,进入一级缓存的查询流程前,先在CachingExecutor进行二级缓存的查询

二级缓存开启后,同一个namespace下的所有操作语句,都影响着同一个Cache,即二级缓存被多个SqlSession共享,是一个全局的变量。使用时要配置开启二级缓存

两个sqlsession的相同查询,如果第一个查询没有提交,第二个就不会使用二级缓存,如果更新并提交,之前的缓存还是查不到

默认的设置中SELECT语句不会刷新缓存,insert/update/delte会刷新缓存

JVM

jvm参数

sh 复制代码
-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,显示虚拟机配置信息;

    • 加上jps后的进程Id
  • jmap (Memory Map for Java) : 生成堆转储快照;

  • jhat (JVM Heap Dump Browser) : 用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上查看分析结果;

  • jstack (Stack Trace for Java) : 生成虚拟机当前时刻的线程快照,线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合。

    • 加上jps后的进程id

JConsole 是基于 JMX 的可视化监视、管理工具。可以很方便的监视本地及远程服务器的 java 进程的内存使用情况。

java内存区域

运行时数据区

  • 线程共享:堆(字符串常量池)、方法区(运行时常量池)1.7在堆,1.8在本地内存、直接内存
  • 线程私有:虚拟机栈、本地方法栈、程序计数器
程序计数器

当前线程所执行的字节码的行号指示器

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

程序计数器为什么是私有的?

主要是为了线程切换后能恢复到正确的执行位置

java虚拟机栈

方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。

  • 局部变量表 主要存放了编译期可知的各种数据类型(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 堆中。

JDK 1.7 为什么要将字符串常量池移动到堆中?

主要是因为永久代(方法区实现)的 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程序可以调用本地应用/或库,也可以被其他程序调用)的方式在本地内存上分配的。

堆外内存就是把内存对象分配在堆(新生代+老年代+永久代)以外的内存,这些内存直接受操作系统管理(而不是虚拟机),这样做的结果就是能够在一定程度上减少垃圾回收对应用程序造成的影响。

对象创建、布局、访问过程

对象的创建
  1. 类加载检查:先去常量池定位这个类的符号引用,检查这个符号引用的类是否已经被加载过、解析和初始化,没有就先执行对应类加载

  2. 分配内存:对象内存大小在类加载完成后就确定了。分配方式有指针碰撞和空闲列表,选择方法由java堆是否规整决定,java堆是否规整由所采用的垃圾收集器是否带有压缩整理功能决定

    1. 指针碰撞:内存规整,用过的内存和没用的内存中间有一个分界指针,只用往没用的内存方向移动就好,Serial,ParNew
    2. 空闲列表:内存不规整,虚拟机维护一个空闲列表,分配的时候根据列表找,之后更新列表,CMS

    java堆是否规整,取决于GC收集器是标记-清除还是标记-整理(也叫标记-压缩),复制算法内存是规整的

    内存分配的并发问题

    1. CAS+失败重试:冲突失败就重试,虚拟机采用CAS配失败重试保证更新操作的原子性
    2. TLAB:为每一个线程在Eden区分配一块内存,JVM给线程中的对象分配内存时,先在TLAB中分配,当对象大于TLAB中的剩余内存或TLAB内存耗尽时,再用CAS+失败重试
  3. 初始化零值:不包括对象头,保证对象的实例字段不赋初值就直接使用

  4. 设置对象头:对象是哪个类的实例,如何找到类的元数据信息,对象哈希码,对象GC分代年龄等,存放在对象头中,还会根据虚拟机当前运行状态不同,是否启用偏向锁等,会有不同的设置方式

  5. 执行init方法:从虚拟机角度看,对象已经产生了,但从java程序看,对象创建刚开始

对象的内存布局

在HotSpot虚拟机中,对象在内存布局分为3块:对象头,实例数据,对齐填充

对象头:包括两部分数据,第一部分用于存储对象自身的运行时数据(哈希码,GC分代年龄,锁状态标志等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针确定这个对象是哪个类的实例

实例数据:对象真正存储的有效信息,也就是程序定义的各种类型的字段

对其填充:非必须,仅仅占位作用,因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

对象的访问定位

对象的访问方式由虚拟机实现而定,主流有使用句柄,直接指针

句柄

如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。

直接指针

这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

HotSpot 虚拟机主要使用的就是这种方式来进行对象访问。

jvm垃圾回收

内存分配和回收原则

  • 对象优先在Eden区分配:Eden区空间不足时,虚拟机发起Minor GC,发现无法存入Survivor空间时,会通过分配担保机制把新生代的对象转移到老年代

  • 大对象直接进入老年代:避免为大对象分配内存时由于分配担保机制带来的复制降低效率

  • 长期存活的对象进入老年代 :对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

  • 主要进行GC的区域

    • 部分收集(Partial GC):

      • 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
      • 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;

      • 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。

    • 整堆收集 (Full GC):收集整个 Java 堆和方法区。

  • 空间分配担保:确保Minor GC之前老年代本身还有容纳新生代所有对象的剩余空间。JDK 6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。

死亡对象判断方法、GCRoots

垃圾回收前要判断哪些对象已经死亡

  • 引用计数法:给对象添加一个引用计数器,被引用就+1,失效就-1,0就不能被使用,实现简单,但存在对象循环引用问题

  • 可达性算法分析:通过一系列GC Roots的对象为起点,从这些节点开始向下搜索,走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连,就证明对象不可用,需要被回收。可以作为GC Roots的对象:

    • 虚拟机栈(栈帧中的本地变量表)中引用的对象

    • 本地方法栈(Native方法)中引用的对象

    • 方法区中类静态属性引用的对象

    • 方法区中常量引用的对象

    • 所有被同步锁持有的对象

即使不可达,但也不一定被回收,对象真正死亡至少经历两次标记,可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。

被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。

jdk9及以后各个类的finalize方法会被逐渐弃用移除

四种引用类型

  1. 强引用

    不回收

  2. 软引用

    空间够就不回收,空间不够就回收,可以用来实现内存敏感的高速缓存,可以和ReferenceQueue联合使用,如果软引用引用的对象被回收,虚拟机就会把这个软引用加入到与之关联的队列

    软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生

  3. 弱引用

    只要发生垃圾回收,垃圾回收器线程扫描它所管辖的内存区域中,如果发现只具有弱引用的对象,就会直接回收,但垃圾回收器优先级很低的线程,发现会慢一点,弱引用也可以加入ReferenceQueue中,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。

  4. 虚引用

    如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。

    虚引用主要用来跟踪对象被垃圾回收的活动

    虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

如何判断一个类是无用类

方法区主要回收无用的类,无用类满足3条

  1. 该类所有实例已经被回收
  2. 加载该类的ClassLoader已经被回收
  3. 该类对应的Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

满足后仅仅可以被回收,不是必然

垃圾回收算法

标记清除

首先标记不需要回收的对象,标记完成后统一回收掉没有标记的对象

问题:

  1. 效率:标记和清除效率都不高
  2. 空间:会产生大量不连续内存碎片
标记复制

为了解决标记清除的效率和碎片问题,将内存分为大小相同的两块,每次使用其中的一块,当这一块内存使用完成后,将还存活的对象复制到另一块,然后再把之前的空间全部清理掉,每次内存回收一半

问题:

  1. 可用内存变小:可用内存缩小为原来的一半
  2. 不适合老年代:如果存活数量比较大,复制性能会变差
标记整理

根据老年代特点的标记算法,标记过程和标记清除一样,但不是直接回收可回收对象,而是将所有存活对象向一端移动,然后直接清理另外一端,因为多了整理,所以效率不高,适合老年代这种垃圾回收频率不高的场景

分代收集

根据对象存活周期的不同

一般将java堆分为新生代和老年代

在新生代,可用选择标记复制算法,每次收集都有大量对象死去,只需要付出少量对象的复制成本就可用完成垃圾收集,但老年代对象存活几率比较高,而且没有额外空间进行分配担保,所以使用标记清除或标记整理

垃圾收集器

垃圾收集器是内存回收的具体体现

默认收集器

  • JDK 8:Parallel Scavenge(新生代)+ Parallel Old(老年代)
  • JDK 9 ~ JDK20: G1
Serial收集器

串行收集器,单线程

新生代标记复制

老年代标记整理

没有线程交互的开销,简单高效

应用:Client 模式下的虚拟机

ParNew收集器

本质是Serial收集器的多线程版本,除了使用多线程进行垃圾收集,其他行为无区别

新生代标记复制

老年代标记整理

除了Serial,只有它可用和 CMS收集器配合工作

应用:Server 模式下的虚拟机

并行和并发概念补充:

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上。
Parallel Scavenge收集器

使用标记复制的多线程收集器,看上去和ParNew一样

关注吞吐量(高效率利用CPU)CMS等垃圾收集器关注用户线程的停顿时间(提高用户体验)。吞吐量就是CPU中用于运行用户代码的时间和CPU总消耗时间的比值

Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。

新生代标记复制

老年代标记整理

JDK1.8 默认使用的是 Parallel Scavenge + Parallel Old

Serial Old收集器

serial收集器的老年代版本,单线程

用途:

  1. jdk1.5及以上与Parallel Scavenge 收集器搭配
  2. 作为CMS收集器的后备方案
Parallel Old收集器

Parallel Scavenge收集器的老年代版本,使用多线程和标记整理算法,在注重吞吐量以及CPU资源的场合优先考虑Parallel Scavenge收集器和 Parallel Old收集器

CMS收集器

以获取最短回收停顿时间为目标,注重用户体验

HotSpot第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程和用户线程基本上同时工作

标记清除算法

步骤:

  1. 初始标记:暂停所有其他线程,记录直接和root相连的对象,快
  2. 并发标记:同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
  3. 重新标记:为了修正并发标记期间因为用户线程继续运行导致标记产生变动的那部分对象的标记记录,比初始慢,比并发快
  4. 并发清除:开启用户线程,同时GC线程开始对未标记的区域做清扫

初始标记和重新标记会stw

优点:并发收集,低延迟

缺点:对CPU资源敏感,无法处理浮动垃圾,标记清除产生大量碎片

G1收集器

面向服务器,极高概率满足GC停顿时间同时具备高吞吐量性能特征

  • 并行与并发:G1充分利用CPU和多核环境的硬件优势,使用多个CPU缩短stw时间。G1可用通过并发方式让java程序和gc同时执行
  • 分代收集:不需要和其他收集器配合
  • 空间整合:和CMS的标记清除不同,G1整体看是标记整理,局部看是标记复制
  • 可预测的停顿:建立可预测的停顿时间模型,明确在一个长度M毫秒的时间内,消耗在垃圾回收上的时间不超过N毫秒

步骤:

  1. 初始标记
  2. 并发标记
  3. 最终标记
  4. 筛选回收

内存的回收是以region作为基本单位的

G1收集器在后台维护优先列表,每次根据允许的收集时间,优先回收价值最大的Region。Region划分内存空间以及有优先级的区域回收方式,保证G1收集器在有限时间尽可能高的收集效率

jdk9成为默认垃圾收集器

ZGC收集器

和CMS中的ParNew和G1类似,采用标记复制,不过改进了算法,stw更少,java15可以使用

STW

整个虚拟机应用线程暂停工作

确保标记的时候不会有对象的引用被修改

类文件结构

Class文件结构

java 复制代码
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个阶段

  1. 加载
  2. 验证
  3. 准备
  4. 解析
  5. 初始化
  6. 使用
  7. 卸载

验证,准备,解析统称为连接

类加载过程

class文件需要加载到虚拟机后才能运行和使用,加载class文件分为3步,加载-连接-初始化 ,连接又分为验证-准备-解析

加载
  1. 通过全类名获取该类的二进制字节流(无要求,zip,jar,网络,动态代理等)
  2. 将字节流的静态存储结构转换为方法区的运行时数据结构
  3. 在内存生成一个代表该类的Class对象,作为方法区这些数据的访问入口

通过类加载器 完成,具体哪个类加载器由双亲委派模型决定(但也可以打破)

每个类都有一个引用指向加载它的类加载器,但数组类不是通过ClassLoader加载的,而是JVM在需要的时候自动创建的,数组类通过getClassLoader()方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的。

加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。

验证

连接的第一步,目的是确保Class文件的字节流包含的信息符合规范,运行后不会危害虚拟机安全

这一步耗费资源较多,也可以用参数关闭大部分类验证,缩短加载时间,4个阶段

  1. 文件格式验证(Class 文件格式检查)是否符合Class文件格式规范,如是否魔数开头...
  2. 元数据验证(字节码语义检查)类似这个类是否有父类,是否继承了不能继承的类...
  3. 字节码验证(程序语义检查)类似参数类型,类型转换是否正确...
  4. 符号引用验证(类的正确性检查)类似该类使用的其他类,方法,字段是否存在,还有访问权限,这一步发生在解析阶段,在 JVM将符号引用转化为直接引用的时候。确保解析阶段正常执行

后三个验证不会再读取操作字节流

方法区是JVM运行时数据区的一块逻辑区域,是各个线程共享的内存区域,方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据

准备

正式为类变量分配内存并设置初始值,这些内存都在方法区分配

  1. 此时只分配类变量(静态变量,static修饰),不包括实例变量。实例变量会在对象实例化时随着对象一块分配在堆
  2. 概念上讲,类变量使用的内存应当在方法区,但jdk7之前使用永久代实现方法区时符合这个概念。但在jdk7及之后,Hotspot把原来在永久代的字符串常量池,静态变量等移动到堆,此时类变量会随着Class对象存放在堆
  3. 初始值是默认零值,但如果是static final的变量,特殊情况:比如给 value 变量加上了 final 关键字public static final int value=111 ,那么准备阶段 value 的值就被赋值为 111
解析

虚拟机讲常量池的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。

初始化

初始化阶段是执行初始化方法 <clinit> ()方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。

clinit是带锁线程安全

初始化阶段,只有6种情况,必须对类进行初始化

  1. 当遇到 new、getstatic、putstatic、invokestatic 这 4 条字节码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。
    • 当 jvm 执行 new 指令时会初始化类。即当程序创建一个类的实例对象。
    • 当 jvm 执行 getstatic 指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。
    • 当 jvm 执行 putstatic 指令时会初始化类。即程序给类的静态变量赋值。
    • 当 jvm 执行 invokestatic 指令时会初始化类。即程序调用类的静态方法。
  2. 使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forname("..."), newInstance() 等等。如果类没初始化,需要触发其初始化。
  3. 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
  4. 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。
  5. MethodHandleVarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用, 就必须先使用 findStaticVarHandle 来初始化要调用的类。
  6. 「补充,来自issue745open in new window 当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

类卸载

3个要求

  1. 该类所有实例对象已回收
  2. 该类没有被其他地方引用
  3. 该类的类加载器实例被回收

由JVM自带的类加载器加载的类不会被卸载,由自定义的类加载器加载的类可能被卸载

JDK 自带的 BootstrapClassLoader, ExtClassLoader, AppClassLoader 负责加载 JDK 提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。

类加载器

类加载器是一个负责加载类的对象,ClassLoader是一个抽象类。给定类的二进制名称,类加载器会尝试定位和生成构成类定义的数据。典型策略是将名称转换为文件名,然后从文件系统中读取该名称的类文件

主要作用:加载java类的字节码.class文件到jvm中(在内存中生成一个代表该类的class对象),其实还可以加载其他东西(文本,图像等),但只讨论加载类

类加载器加载规则

jvm启动并不会加载所有类,而是根据需要动态加载,用到的时候再加载

已经加载的类会放到classloader中,类加载时,会先判断类是否被加载过,加载过就直接返回,否则才加载,相同二进制名称的类只会被加载一次

3个内置类加载器

jvm内置3个classloader

  1. BootstrapClassLoader (启动类加载器):最顶层加载类,c++实现,主要加载jdk核心类库,( %JAVA_HOME%/lib目录下的 rt.jar(基础类库)、resources.jarcharsets.jar等 jar 包和类)以及被 -Xbootclasspath参数指定的路径下的所有类。
  2. ExtensionClassLoader (扩展类加载器):主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。
  3. 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官方推荐的,也可以打破,类加载器的父子关系一般不是以继承实现的,而是组合

java 复制代码
public abstract class ClassLoader {
  ...
  // 组合
  private final ClassLoader parent;
  protected ClassLoader(ClassLoader parent) {
       this(checkCreateClassLoader(), parent);
  }
  ...
}

组合优于继承

执行流程

流程:

  1. 类加载时,先判断当前类是否被加载过,加载过就会直接返回,否则才尝试加载
  2. 加载时,首先不会自己区尝试加载这个类,而是调用父加载器的loadClass(),所有请求都会传送到顶层启动类加载器
  3. 只有父加载器无法加载时,子加载器才会尝试自己加载(自己的findClass())

jvm判定两个java类是否相同:不仅看类的全名,还看加载该类的类加载器是否一样,都相同时类才相同

好处

保证java程序的稳定运行,避免类的重复加载,保证核心api不被篡改

打破双亲委派、tomcat

自定义类加载器,继承ClassLoader,如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法(因为在loadClass()方法里面,首先不会自己加载这个类,而是把这个请求委派给父类加载器完成)。

Tomcat 服务器为了能够优先加载 Web 应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器 WebAppClassLoader 来打破双亲委托机制。这也是 Tomcat 下 Web 应用之间的类实现隔离的具体原理。

如tomcat:因为tomcat是web服务器,上面可能有多个web应用,为了相互实现隔离,使用自定义类加载器,每个web应用程序对应也给类加载器,这样Tomcat中每个应用就可以使用自己的类加载器去加载自己的类,从而达到应用之间的类隔离,不出现冲突。另外,tomcat还利用自定义加载器实现了热加载功能。

JUC

四大锁、锁升级、锁降级、锁粗化、锁消除

  • 无锁无阻塞不同步,CAS实现原子操作,适用于并发高争抢少,开销较低

    • 转为偏向锁:无锁状态下被另一个线程访问
  • 偏向锁适用单线程 ,获取锁时将线程id标记在锁对象的对象头,适用频繁获取锁的单线程,开销较低,有竞争才释放锁

    • 撤销:撤销需要等到全局安全点(没有正在执行的字节码),先暂停拥有偏向锁的线程,再检查拥有偏向锁线程是否存活,不存活就将对象头设置无锁,存活就变更锁标识,最后唤醒暂停线程
    • 只有一个线程访问同步代码块时,对象标记为偏向锁,之后该线程进入该同步代码块直接进入同步状态
  • 轻量级锁自旋等待,偏向锁撤销或多线程竞争时,CAS替换对象头,适用于短时间的锁竞争,开销中等。

    • 加锁:线程执行同步代码块之前,jvm先在当前线程栈帧创建存储锁记录的空间,把对象头的martword复制到锁记录,然后尝试cas替换对象头的markword为指向锁记录的指针,成功就获取到锁,失败就表示其他线程竞争锁,当前线程就自旋
    • 解锁 :cas将markword替换对象头,成功就表示没竞争,失败就膨胀为重量级锁
  • 重量级锁阻塞,线程竞争激烈,适用操作系统的互斥机制,适用长时间的锁竞争,开销高

    • 此时其他线程试图获取锁时,都会被阻塞,当持有锁的线程释放锁才唤醒这些线程,再竞争
    • 多个线程激烈竞争时,对象标记为重量级锁,需要操作系统的介入

锁粗化:这是一种将多次连续的锁定操作合并为一次的优化手段。假如一个线程在一段代码中反复对同一个对象进行加锁和解锁,那么 JVM 就会将这些锁的范围扩大(粗化),即在第一次加锁的位置加锁,最后一次解锁的位置解锁,中间的加锁解锁操作则被省略

锁消除:这是一种删除不必要的锁操作的优化手段。在 Java 程序中,有些锁实际上是不必要的,例如在只会被一个线程使用的数据上加的锁。JVM 在 JIT 编译的时候,通过一种叫做逃逸分析的技术,可以检测到这些不必要的锁,然后将其删除。

锁升级

  1. 偏向锁升级:当一个线程访问同步块时,首先会尝试获取偏向锁。如果当前对象没有被其他线程竞争过,并且持有偏向锁的线程仍然存活,那么当前线程可以直接获取偏向锁,不会发生锁升级。
  2. 轻量级锁(自旋锁)升级:如果获取偏向锁失败,表示当前对象存在竞争,那么偏向锁会升级为轻量级锁。这时,JVM会通过CAS操作将对象头中的锁标记改为指向线程栈中的锁记录(Lock Record)的指针,并将对象的内容复制到锁记录中。如果轻量级锁获取失败,即有多个线程竞争同一个对象的锁,那么轻量级锁会升级为自旋锁。自旋锁不会使线程阻塞,而是让线程执行忙等待,尝试反复获取锁。这样可以避免线程切换带来的性能损失。
  3. 重量级锁升级:当自旋锁尝试获取锁的次数达到一定阈值,或者等待时间超过一定限制时,自旋锁会升级为重量级锁。重量级锁会使线程阻塞,将竞争锁的线程放入等待队列,等待锁释放后进行唤醒。

锁降级:锁通常不会主动降级,但重量级锁在释放时可以降级为轻量级锁,但是jep有个锁降级的草案被撤回了,因为降级时安全暂停时间太长了,尝试了工作线程和空闲列表。现在在实验尝试不在安全点操作,

读锁为什么不能升级为写锁?

写锁可以降级为读锁,读锁不能升级为写锁,因为读锁升级为写锁会引起线程的争夺,因为写锁是独占锁

另外,还可能会有死锁问题发生。举个例子:假设两个线程的读锁都想升级写锁,则需要对方都释放自己锁,而双方都不释放,就会产生死锁。

共享锁和独占锁

  • 共享锁:一把锁可以被多个线程同时获得
  • 独占锁:一把锁只能被一个线程获得

可中断锁和不可中断锁

  • 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后才能进行其他逻辑处理,如ReetrantLock
  • 不可中断锁:一旦线程申请了锁,就只能等到拿到锁之后才能进行其他逻辑处理,synchronized属于不可中断锁

公平锁和非公平锁

  • 公平锁:锁被释放之后,先申请的线程先得到锁,性能差一些,但保证时间上绝对顺序,上下文切换更频繁
  • 非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,性能更好,但可能导致某些线程永远无法获取锁

乐观锁和悲观锁

悲观锁:用于写多读少,避免频繁失败重试影响性能

  • 悲观锁总是假设最坏情况,认为每次访问共享资源都会被修改,所以每次访问资源的时候都会加锁。保证共享资源每次只给一个线程使用,其他线程阻塞。
  • synchronized和ReentrantLock等独占锁就是悲观锁的实现
  • 高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。

乐观锁:用于写少读多,避免频繁加锁影响性能,但乐观锁主要针对的对象是单个共享变量

  • 总是假设最好情况,认为每次访问共享资源不会出现问题,无需加锁也无需等待,只是在提交修改的时候去验证对应资源是否被其他线程修改了(版本号或cas)

    java.util.concurrent.atomic包下的原子变量类就是使用cas实现乐观锁

    LongAdder 在高并发场景下会比 AtomicInteger 和 AtomicLong 的性能更好
    代价就是会消耗更多的内存空间(空间换时间)
    
  • 高并发下,乐观锁相比于悲观锁,不存在锁竞争导致的线程阻塞,也不会死锁,性能更好,但如果冲突频繁(写多),会频繁失败和重试,这样会影响性能,导致cpu飙升

  • LongAdder以空间换时间的方式解决大量失败重试问题

  • 乐观锁一般会使用版本号机制或 CAS 算法实现

  • CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
  • 乐观锁的问题:ABA 问题、循环时间长开销大、只能保证一个共享变量的原子操作。

乐观锁存在哪些问题

  • ABA是乐观锁常见问题

一个变量第一次读是A值,在准备赋值的时候还是A值,也不能说明它的值没有被其他线程修改,因为可能被其他线程改完之后又改回去了

解决方法:

在变量前追加版本号或时间戳

JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

  • 循环时间长开销大

cas经常自旋重试,不成功就一直循环,如果长时间不成功,会给cpu带来很大的执行开销

  • 只能保证一个共享变量的原子操作

cas只对单个共享变量有效,当操作涉及多个共享变量时cas无效,但是从 JDK 1.5 开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。

JMM、指令重排、并发三特性

JMM(Java 内存模型)主要定义了共享内存中多线程程序读写操作的行为规范,内存分为线程私有工作内存,线程共享主内存,线程之间交互需要主内存

指令重排序

指令重排序就是:系统在执行代码的时候不一定按照编写代码的顺序依次执行

常见指令重排序:

  1. 编译器优化重排:编译器在不改变单线程程序语义的前提下,重新安排语句的执行顺序。通过禁止特定类型的编译器重排序的方式禁止重排序
  2. 指令并行重排:现代处理器的指令级并行技术,将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。通过插入**内存屏障(一种cpu指令,禁止重排序,保障有序性,也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性)**的方式禁止特定类型的处理器重排序。指令并行重排和内存系统重排都是处理器级别的指令重排序

Java 源代码会经历 编译器优化重排 ---> 指令并行重排 ---> 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。

指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致

volatile如何禁止指令重排序?

变量使用volatie修饰,在对这个变量进行读写操作的时候,会通过插入特定的内存屏障的方式禁止指令重排序

JMM、happens-before

程序运行在操作系统上,操作系统屏蔽了底层硬件的操作细节,将各种硬件资源虚拟化,所以操作系统也同样需要解决内存缓存不一致的问题

操作系统通过内存模型 定义一系列规范解决这个问题,不同操作系统内存模型不同,java语言是跨平台的,所以需要提供一套内存模型屏蔽系统差异 ,还有一个原因是jmm可以看作是java定义的并发编程相关的一组规范,抽象了线程和主内存的关系 ,规定了java从源代码到cpu可执行指令的转换过程要遵守的原则和规范,目的是为了简化多线程编程,增强程序可移植性

Java 内存区域和 JMM 有何区别?

完全不一样

  • Java内存区域和Java虚拟机的运行时区域相关,定义了JVM在运行时如何分区存储数据,如堆存放对象实例
  • Java内存模型(JMM)和Java并发编程有关,抽象了线程和主内存之间的管理,如线程的共享变量必须在主内存,规定Java源代码到CPU可执行指令的转化要遵守的原则和规范,目的是为了简化多线程编程、增强程序可移植性
happens-before 原则

前一个操作的结果对于后一个操作可见,无论这两个操作是否在同一个线程

程序员追求易于理解和编程的强内存模型,遵守规则编码,编译器和处理器追求较少约束的弱内存模型,让他们尽力优化性能

  • 为了对编译器和处理器的约束尽可能少,只要不改变结果,编译器和处理器可以进行重排序优化
  • 对于会改变程序运行结果的重排序,JMM要求编译器和处理器必须禁止

happens-before 原则的定义

  • 如果一个操作happens-before另一个操作,那么第一个操作的结果对第二个操作可见,并且执行顺序排在第二个操作之前
  • 两个操作之间存在happens-before 关系,并不意味java平台具体实现必须按照happens-before 指定的顺序执行。如果重排序之后的结果和按happens-before执行的结果一致,那么JMM也允许这样的操作
happens-before 常见规则有哪些?
  • 程序顺序规则:一个线程内,按照代码顺序,前面的操作happens-before于后面的操作
  • 解锁规则:解锁happens-before于加锁
  • volatile变量规则:对一个volatile的写操作happens-before后面对这个变量的读操作。即写操作的结果对于后面的操作可见
  • 传递规则:
  • 线程启动规则:Thread对象的start()方法happens-before该线程的每一个动作

如果两个操作不满足上面任意一个规则,那么这两个操作可以重排序

happens-before 和 JMM 什么关系?

程序员使用happens-before规则,规则的底层由JMM实现

并发三个特性

原子性

一次操作或多次操作,要么所有操作全部执行不中断,要么都不执行

synchronized和各种Lock可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性

各种原子类利用cas操作保证原子操作

volatile 可以保证原子性么?

volatile可以保证变量的可见性,不能保证对变量操作是原子性的

自增操作变量++不是原子性,是一个复合操作,先读取变量值,再+1,再将变量值写回内存,即使变量使用volatile修饰,也不能保证原子性

可见性

当一个线程对共享变量进行修改,其他线程可以立即看到被修改的最新值

将变量声明为volatile,表明这个变量共享且不稳定,每次使用都到主存中读取

volatile如何保证变量的可见性?

原始意义是禁用cpu缓存,变量使用volatile修饰,表示变量是共享且不稳定的,每次使用都要去主内存读取

内存模型和happens-before规则(监视器锁规则:对同一个监视器的解锁,happens-before于对该监视器的加锁,获取锁之前要先释放锁)

Lock前缀指令会把当前处理器缓存行的数据写回主内存同时会让其他cpu缓存了该内存地址的数据无效

有序性

因为指令重排序,所以代码的执行顺序不一定是编写代码的顺序

指令重排序可以保证串行语义一致,但没有保证多线程间的语义一致

volatile可以禁止指令重排序

线程池

管理一系列线程的资源池,处理任务直接从线程池获取线程,处理完成之后线程并不会立即被销毁

线程池一般用于执行多个不相关联的耗时任务,没有多线程时,任务顺序执行,用了线程池可以让多个不相关联的任务同时进行

  • 降低资源损耗:重复利用已创建的线程降低线程创建和销毁的消耗
  • 提高响应速度:任务到达时不用等到线程创建就可以立即执行
  • 提高线程可管理性:统一分配、调优和监控

Executor框架

在 Java 5 之后,通过 Executor 来启动线程比使用 Threadstart 方法更好,除了更易管理,效率更好(用线程池实现,节约开销)外,还有关键的一点:有助于避免 this 逃逸问题。

this 逃逸是指在构造函数返回之前其他线程就持有该对象的引用,调用尚未构造完全的对象的方法可能引发令人疑惑的错误。

三大部分:

  1. 任务(Runnable /Callable)

    执行任务都必须实现这两个接口之一

  2. 任务的执行(Executor)

    核心接口 Executor ,以及继承自 Executor 接口的 ExecutorService 接口。ThreadPoolExecutorScheduledThreadPoolExecutor 这两个关键类实现了 ExecutorService ScheduledThreadPoolExecutor 实际上是继承了 ThreadPoolExecutor 并实现了 ScheduledExecutorService ,而 ScheduledExecutorService 又实现了 ExecutorService

  3. 异步计算的结果(Future)

    Future 接口以及 Future 接口的实现类 FutureTask 类都可以代表异步计算的结果。

    当我们把 Runnable接口Callable 接口 的实现类提交给 ThreadPoolExecutorScheduledThreadPoolExecutor 执行。(调用 submit() 方法时会返回一个 FutureTask 对象)

    1. 主线程创建实现Runnable或Callable接口的任务对象
    2. 把任务对象提交给ExecutorService执行,ExecutorService.execute(Runnable command))或者ExecutorService.submit(Runnable task)
    3. 如果执行submit,就返回一个实现Future接口的对象
    4. 主线程执行FutureTask.get()方法等待任务执行成功,也可以取消任务执行

工作原理/流程

execute(任务)流程

  1. 如果当前运行的线程数小于核心线程数,就会新建一个线程执行任务
  2. 如果当前运行的线程数大于或等于核心线程数,但是小于最大线程数,就把该任务放入任务队列里
  3. 如果任务队列满,但是当前运行的线程数小于最大线程数,就新建一个线程执行任务
  4. 如果当前运行的线程数已经是最大线程数,就会执行拒绝策略

execute 方法中,多次调用 addWorker 方法。addWorker 这个方法主要用来创建新的工作线程,如果返回 true 说明创建和启动工作线程成功,否则的话返回的就是 false。ReentrantLock都会加锁(类中的全局锁)

Runnable vs Callable

Callable在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 对象可以判断任务是否执行成功,并且可以通过 Futureget()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法的话,如果在 timeout 时间内任务还没有执行完,就会抛出 `java.util.concurrent.TimeoutException
shutdown()VSshutdownNow()
  • shutdown() :关闭线程池,线程池的状态变为 SHUTDOWN。线程池不再接受新任务了,但是队列里的任务得执行完毕。
  • shutdownNow() :关闭线程池,线程池的状态变为 STOP。线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List。
isTerminated() VS isShutdown()
  • isShutDown 当调用 shutdown() 方法后返回为 true。
  • isTerminated 当调用 shutdown() 方法后,并且所有提交的任务完成后返回为 true

常见内置线程池

FixedThreadPool

可重用固定线程数(最大线程数和固定线程数相同,就算最大线程数更大,也只会创建固定线程,因为任务队列是无界的(只有到达任务队列最大值才会创建额外线程))的线程池

FixedThreadPool 使用的是容量为 Integer.MAX_VALUELinkedBlockingQueue(无界队列)

运行流程:

  1. 如果当前运行线程数小于核心线程数,新任务会创建新线程
  2. 如果运行线程数等于核心线程数,新任务会加入 LinkedBlockingQueue
  3. 线程池中的线程执行完任务后,会循环从队列中取任务
SingleThreadExecutor

只有一个线程的线程池

SingleThreadExecutorcorePoolSizemaximumPoolSize 都被设置为 1,其他参数和 FixedThreadPool 相同,也使用LinkedBlockQueue

执行流程

  1. 运行线程数小于核心线程数,创建新线程执行任务
  2. 有一个运行的线程之后,任务加入LinkedBlockingQueue
  3. 线程执行完任务之后,会反复从队列中获取任务
CachedThreadPool

根据需要创建新线程

核心线程数为0,最大线程数int最大,意味着如果主线程提交任务的速度高于线程处理任务的速度,CachedThreadPool会不断创建新线程

执行流程

  1. 先提交任务到任务队列,如果空闲线程还未销毁,主线程就把任务交给空闲线程,否则执行2
  2. 初始线程为0或没有空闲线程时,此时会创建新线程来执行任务
ScheduledThreadPool

给定的延迟后运行任务或定期执行任务,基本不用

无界阻塞队列

ScheduledThreadPool 是通过 ScheduledThreadPoolExecutor 创建的,使用的DelayedWorkQueue(延迟阻塞队列)作为线程池的任务队列。

延迟队列按照延迟时间长短对任务进行排序,采用堆,保证每次出队的任务都是当前队列中执行时间最靠前的,添加元素满了之后会自动扩容原来的一般,永远不阻塞,所以最多只会创建核心线程数的线程

ScheduledThreadPoolExecutor 继承了 ThreadPoolExecutor,所以创建 ScheduledThreadExecutor 本质也是创建一个 ThreadPoolExecutor 线程池

为什么不推荐使用内置线程池?

  1. FixedThreadPoolSingleThreadExecutor:使用无界LinkedBlockingQueue,可能堆积大量请求导致OOM
  2. CachedThreadPool:同步队列SynchronousQueue,允许创建线程数量无限,可能创建大量线程导致OMM
  3. ScheduledThreadPoolSingleThreadScheduledExecutor:无界阻塞队列DelayedWorkQueue,可能堆积大量请求导致OOM

线程池常见参数

ThreadPoolExecutor 3 个最重要的参数:

  • corePoolSize : 任务队列未达到队列容量时,最大可以同时运行的线程数量。
  • maximumPoolSize : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
  • workQueue: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

ThreadPoolExecutor其他常见参数 :

  • keepAliveTime :线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁;
  • unit : keepAliveTime 参数的时间单位。
  • threadFactory :executor 创建新线程的时候会用到。
  • handler :饱和策略。关于饱和策略下面单独介绍一下。

线程池饱和策略(拒绝策略)

如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时

  • ThreadPoolExecutor.AbortPolicy 抛出 RejectedExecutionException来拒绝新任务的处理。默认
  • ThreadPoolExecutor.CallerRunsPolicy 调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
  • ThreadPoolExecutor.DiscardPolicy 不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy 此策略将丢弃最早的未处理的任务请求。

AQS

AbstractQueuedSynchronizer抽象队列同步器

AQS 就是一个抽象类,为了构建锁和同步器,提供了一些通用功能的实现

比如我们提到的 ReentrantLockSemaphore,其他的诸如 ReentrantReadWriteLockSynchronousQueue等等皆是基于 AQS 的。

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()返回,继续执行后续操作

AQS 资源共享方式

两种方式:Exclusive (独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可以同时执行,如Semaphore/CountDownLatch)

也有支持独占和共享两种方式的ReentrantReadWriteLock

常见同步工具类

Semaphore(信号量)

synchronizedReentrantLock 都是一次只允许一个线程访问某个资源,而Semaphore(信号量)可以用来**控制同时访问特定资源的线程数量。**其他线程都会阻塞,常用于限流

当初始的资源个数为 1 的时候,Semaphore 退化为排他锁。

Semaphore 有两种模式:。

  • 公平模式: 调用 acquire() 方法的顺序就是获取许可证的顺序,遵循 FIFO;
  • 非公平模式: 抢占式的。

Semaphore 通常用于那些资源有明确访问数量限制的场景比如限流(仅限于单机模式,实际项目中推荐使用 Redis +Lua 来做限流)。

原理

Semaphore 是共享锁的一种实现,只有拿到许可证的线程才能执行,它默认构造 AQS 的 state 值为 permits(许可证的数量)

acquire时线程尝试获取锁,如果state>0就可以获取成功(尝试使用CAS修改-1,CAS失败了会循环重新获取最新的值尝试获取,如果获取失败就会创建一个Node节点加入阻塞队列,挂起当前线程,自旋判断state是否大于0),释放许可证成功之后,会唤醒同步队列中的一个线程,被唤醒的线程会尝试获取锁,失败就重新进入阻塞队列,挂起线程

SyncCountDownLatch 的内部类 , 继承了 AbstractQueuedSynchronizer ,重写了其中的某些方法。并且,Sync 对应的还有两个子类 NonfairSync(对应非公平模式) 和 FairSync(对应公平模式)。

CountDownLatch(倒计时器)

允许 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()之后的语句

典型用法:

  1. 启动一个服务时,主线程需要等待多个组件加载完毕,之后继续执行,将计数器设为n,等到0的时候,在CountDownLatch上await()的线程就会被唤醒
  2. 实现多个线程执行任务的最大并行性,强调多个线程在同一时刻同时开始执行。类似将多个线程放到起点,同时开跑,做法是初始化一个共享的CountDownLatch对象时将计数器初始化1,多个线程开始执行任务前首先await(),当主线程调用countDown()时,计数器变为0,此时多个线程同时被唤醒
CyclicBarrier(循环栅栏)

CountDownLatch 非常类似

CountDownLatch 的实现是基于 AQS 的,而 CycliBarrier 是基于 ReentrantLock(ReentrantLock 也属于 AQS 同步器)和 Condition 的。

可循环使用的屏障:让一组线程到达一个屏障(同步点)时被阻塞,直到最后一个线程到达屏障时才开门,所有被拦截的线程才会继续干活,每个线程调用 await() 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。

原理:

内部通过一个 count 变量作为计数器,count 的初始值为 parties 属性的初始化值,每一个线程到栅栏后就-1计数器,为0表示最后一个线程到达,就尝试执行任务

Atomic原子类

Atomic是指一个操作不可中断,多个线程在一起执行时,一个操作一旦开始,就不会被其他线程干扰

基本类型

使用原子的方式更新基本类型,优势:多线程环境使用原子类保证线程安全,比如对原子类型变量自增不用加锁。原理:主要利用CAS + volatile和native方法保证原子操作,避免synchronized的高开销,CAS原理是拿期望的值和原本的值作比较,如果相同就更新

ThreadLocal

数据结构

Thread类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals

每个线程都有自己的 threadLocals ,是ThreadLocal.ThreadLocalMap 类型的Map,这个map里有一个内部类Entry, 它的keyThreadLocal<?> k ,继承自WeakReference(key是ThreadLocal的弱引用)

ThreadLocal只是一个key ,存储ThreadLocal 是为了使用多个ThreadLocal 时能找到自己想使用的ThreadLocal

如果entry是强引用,key是ThreadLocal是一个static的,ThreadLocal就一直不被gc则entry也不能gc,value也不能gc,就造成内存泄漏

Hash 冲突、过期清理

set过程中,如果遇到了key过期的Entry数据,实际上是会进行一轮探测式清理操作的

ThreadLocalMap的两种过期key数据清理方式:探测式清理 (每次操作都会先检查当前线程的ThreadLocalMap中是否有已经过期的key,如果有,就清理掉这些key对应的value,并且把这些key从ThreadLocalMap中移除。)和启发式清理(在ThreadLocalMap中维护一个全局的清理阈值,当已经使用的entry数量超过了这个阈值时,就会进行一次清理操作。清理操作会遍历整个ThreadLocalMap,清理掉已经过期的key对应的value,并且把这些key从ThreadLocalMap中移除)。

启发式清理是在ThreadLocalMap的set, get, remove等操作之外进行的,探测式清理是在操作之内

启发式清理相对于探测式清理来说,可以更快地清理掉已经过期的key,但是会占用一定的系统资源。

set()

  1. 哈希计算后的槽位对应的Entry为空时,直接设置数据
  2. 槽位不为空,key值和当前ThreadLocal的哈希值相同,更新
  3. 槽位不为空,key值不同,继续向后遍历,遍历到Entry为null之前没有过期Entry(key为null),将数据放入Entry为null
  4. 遍历到Entry为null之前遇到key过期的Entry,就会执行replaceStateEntry()方法替换过期数据 ,从过期位置开始向前(下标变小)进行探测式清理,找到过期数据就更新起始清理位置(用来判断当前过期槽位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中可能有一些keynullEntry数据被清理掉,所以此时通过判断size >= threshold - threshold / 4 也就是size >= threshold * 3/4 来决定是否扩容。

扩容大小是之前的2倍,然后重新计算哈希

get()详解

如果槽位有值但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

CompletableFuture 同时实现了 FutureCompletionStage 接口

  • boolean cancel(boolean mayInterruptIfRunning):尝试取消执行任务。
  • boolean isCancelled():判断任务是否被取消。
  • boolean isDone():判断任务是否已经被执行完成。
  • get():等待任务执行完成并获取运算结果。
  • get(long timeout, TimeUnit unit):多了一个超时时间。

如果你不需要从回调函数中获取返回结果,可以使用 thenAccept() 或者 thenRun()。这两个方法的区别在于 thenRun() 不能访问异步计算的结果。

你可以通过 handle() 方法来处理任务执行过程中可能出现的抛出异常的情况。

thenCompose()thenCombine() 有什么区别呢?

  • thenCompose() 可以链接两个 CompletableFuture 对象,并将前一个任务的返回结果作为下一个任务的参数,它们之间存在着先后顺序。
  • thenCombine() 会在两个任务都执行完成后,把两个任务的结果合并。两个任务是并行执行的,它们之间并没有先后依赖顺序。

通过 CompletableFutureallOf()这个静态方法来并行运行多个 CompletableFuture

allOf() 方法会等到所有的 CompletableFuture 都运行完成之后再返回

CompletableFuture 类有什么用?

Future 在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get() 方法为阻塞调用。

Java 8 才被引入CompletableFuture 类可以解决Future 的这些缺陷。CompletableFuture 除了提供了更为好用和强大的 Future 特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。

CompletableFuture 同时实现了 FutureCompletionStage 接口

并发和并行

并发:两个及两个以上线程在同一时间段执行

并行:两个及两个以上线程在同一时刻执行

关键在于是否同时执行

同步、异步、阻塞、非阻塞

IO两个阶段:数据准备、内核空间数据复制到用户空间

  • 同步:用户线程发起io操作后需要等待或者轮询内核io完成后才能继续执行

    • 阻塞、非阻塞、io多路复用、信号驱动io都是同步,因为阶段2阻塞

    • 阻塞可以是实现同步的一种手段!例如两个东西需要同步,一旦出现不同步情况,我就阻塞快的一方,使双方达到同步。

      同步是两个对象之间的关系,而阻塞是一个对象的状态。

  • 异步:用户线程发起io操作后用户线程仍需要继续执行,内核io操作完成后通知用户线程,或者调用用户线程注册的回调函数

  • 阻塞:io操作需要彻底完成才返回用户空间

  • 非阻塞:io操作被调用后立即返回一个状态值,无需等待io操作彻底完成

阻塞和非阻塞(线程内调用)

  • 阻塞和非阻塞区别:阶段1的io请求是否被阻塞,不阻塞就是非阻塞
  • 一个线程在某个时刻要么阻塞要么非阻塞
  • 关注程序在等待调用结果(返回值)时的状态
    • 阻塞调用是调用结果返回之前,当前线程被挂起,调用线程只有在得到结果之后才会返回
    • 非阻塞调用是在不能立刻得到结果之前,该调用不会阻塞当前线程

同步和异步(线程间调用)

  • 同步和异步区别在于第二步是否阻塞,如果是不阻塞,操作系统返回结果,就是异步io
  • 两个线程间要么同步要么异步
  • 同步时,调用者需要等待被调用者返回结果才进行下一步
  • 异步时,调用者不需要等待被调用者返回结果,直接进行下一步,被调用者通过回调通知调用者结果
  • 同步是调用返回就知道结果,异步是返回不一定知道结果,通过回调函数等获取结果
  • 发送方和接收方是否步调一致

四种组合

  • 同步阻塞:发送方发送请求一直等待响应,接收方等待准备好结果后才响应发送方,期间不能进行其他工作
  • 同步非阻塞:接收方处理时如果不能马上响应,就立刻返回,做其他事情,但不响应,操作完成后再响应
  • 异步阻塞:发送方发送请求后不等待响应,继续处理自己的。接收方等待准备好结果后才响应,期间不能进行其他工作(不使用)
  • 异步非阻塞:接收方处理时如果不能马上响应,就立即返回,做其他事情,但不响应,操作完成后再响应

线程间协作方式

  • join():b线程调用a线程的join方法后,b线程会等待a线程结束再继续执行
  • wait():使线程在等待,等待时会被挂起,其他线程调用notify或notifyAll唤醒挂起线程,线程会释放锁,因为,如果没有释放锁,那么其它线程就无法进入对象的同步方法或者同步控制块中,那么就无法执行 notify() 或者 notifyAll() 来唤醒挂起的线程,造成死锁。notify是通知在该对象上的其他线程,告诉他们可以尝试重新竞争锁并继续执行了
  • await() signal() signalAll():可以在 Condition 上调用 await() 方法使线程等待,其它线程调用 signal() 或 signalAll() 方法唤醒等待的线程。相比于 wait() 这种等待方式,await() 可以指定等待的条件,因此更加灵活。

线程生命周期和状态

Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:

  • NEW: 初始状态,线程被创建出来但没有被调用 start()
  • RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态。
  • BLOCKED:阻塞状态,需要等待锁释放。
  • WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
  • TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
  • TERMINATED:终止状态,表示该线程已经运行完毕。

线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。

在操作系统层面,线程有 READY 和 RUNNING 状态;而在 JVM 层面,只能看到 RUNNABLE 状态

JVM没有区分这两种状态是因为线程切换太快了,没必要区分,时间分片

  • 当线程执行 wait()方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态。
  • 当线程进入 synchronized 方法/块或者调用 wait 后(被 notify)重新进入 synchronized 方法/块,但是锁被其它线程占有,这个时候线程就会进入 BLOCKED(阻塞) 状态。

线程上下文切换

任务从保存到再加载的过程就是一次上下文切换

线程在执行过程中会有自己的运行条件和状态(上下文),线程切换意味保存当前线程上下文,等到线程下次占用CPU的时候恢复线程,并加载下一个将要占用CPU的线程上下文

因为需要保存信息和恢复信息,就会占用CPU,内存等资源,所以频繁切换会降低效率

当出现如下情况的时候,线程会从占用 CPU 状态中退出。

  • 主动让出 CPU,比如调用了 sleep(), wait() 等。
  • 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
  • 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
  • 被终止或结束运行

这其中前三种都会发生线程切换

什么是线程死锁?如何避免死锁?

线程死锁是:多个线程同时被阻塞,一个或多个等待某个资源的释放,导致线程无限期阻塞,比如互相持有锁

产生死锁的四个必要条件:

  1. 互斥条件:该资源任意一个时刻只由一个线程占用。
  2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

预防死锁

  • 破坏请求与保持条件:一次性申请所有的资源。
  • 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  • 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

避免死锁

避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态(线程按顺序分配资源。

sleep() 方法和 wait() 方法

共同点:两者都可以暂停线程的执行

区别:

  • sleep()没有释放锁,wait()释放锁
  • wait()通常用于线程间通信,sleep()通常用于暂停执行
  • wait()被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()或notifyAll()方法。sleep()方法执行完成后,线程会自动苏醒
  • sleep()是Thread的静态本地方法,wait()是Object的本地方法

wait()是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁,每个对象都有对象锁,所以为了操作对象而不是线程,就使用Object类

因为sleep()方法是让当前线程暂停执行,不涉及对象类,也不需要获得对象锁

Sychronized

概述

主要解决多个线程之间访问资源的同步性,可以保证被它修饰的方法或代码块在任何时刻只能有一个线程执行

java早期,synchronized是重量级锁,效率低。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的(有三个属性,获得锁的线程owner、阻塞的线程entrylist、wait的线程waitset),Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

java6之后,synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。因此, synchronized 还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized

构造方法可以用synchronized修饰吗?

不能

构造方法本身属于线程安全,不存在同步的构造方法这一说

synchronized底层原理

  • 修饰代码块时

synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 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

ReentrantLock实现了Lock接口,是一个可重入的独占锁,比synchronized更灵活强大

ReentrantLock有一个内部类Sync,继承AQS(AbstractQueueSynchronizer),加锁和释放锁的大部分操作在Sync中实现,Sync 有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。默认使用非公平锁

ReentrantLock 的底层就是由 AQS 来实现的

synchronized 和 ReentrantLock 有什么区别?

二者都是可重入锁(递归锁:线程可以再次获取自己的锁),Lock实现类和synchronized都是可重入的

synchronized依赖于jvm而ReentrantLock依赖于api

  • synchronized依赖于jvm实现
  • ReetrantLock是jdk层面实现的,比synchronized增加了一些高级功能
    1. 等待可中断:可以中断等待锁的线程,lock.lockInterruptibly() 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
    2. 可实现公平锁:可以指定公平还是非公平,synchronized只是非公平
    3. 可实现选择性通知(锁绑定多个条件):synchronized关键字与wait()notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。Condition可以实现多路通知功能,就是在一个Lock对象中可以创建多个Condition实例(对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行通知,在调度线程更灵活。如果使用notify()/notifyAll()方法进行通知,被通知的线程是jvm选择的,用ReentrantLock类结合Condition实例可以实现选择性通知,synchronized相当于整个Lock对象中只有一个Condition实例,如果执行notifyAll()就会通知所有等待的线程,Condition的signalAll()方法,只会唤醒注册在该Condition实例的所有等待线程

ReentrantReadWriteLock

ReentrantReadWriteLock 实现了 ReadWriteLock ,是一个可重入的读写锁,既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。

一般锁是读读互斥,读写互斥,写写互斥,读写锁是读读不互斥

ReentrantReadWriteLock 其实是两把锁,一把是 WriteLock (写锁),一把是 ReadLock(读锁)

读是共享锁,写是独占锁,读锁可以同时被多个线程持有,写锁最多只能同时被一个线程持有

ReentrantLock 一样,ReentrantReadWriteLock 底层也是基于 AQS 实现的。也支持公平锁和非公平锁(默认)

ReentrantReadWriteLock 适合什么场景?

ReentrantReadWriteLock既可以保证多个线程同时读的效率,又可以保证写入操作的线程安全,适合读多写少的场景

StampedLock

StampedLock 是 JDK 1.8 引入的性能更好的读写锁,不可重入且不支持条件变量 Conditon

不同于一般的 Lock 类,StampedLock 并不是直接实现 LockReadWriteLock接口,而是基于 CLH 锁 独立实现的(AQS 也是基于这玩意)。

StampedLock 提供了三种模式的读写控制模式:读锁、写锁和乐观读。

  • 写锁 :独占锁,一把锁只能被一个线程获得。当一个线程获取写锁后,其他请求读锁和写锁的线程必须等待。类似于 ReentrantReadWriteLock 的写锁,不过这里的写锁是不可重入的。
  • 读锁 (悲观读):共享锁,没有线程获取写锁的情况下,多个线程可以同时持有读锁。如果己经有线程持有写锁,则其他线程请求获取该读锁会被阻塞。类似于 ReentrantReadWriteLock 的读锁,不过这里的读锁是不可重入的。
  • 乐观读:允许多个线程获取乐观读以及读锁。同时允许一个写线程获取写锁。

StampedLock 在获取锁的时候会返回一个 long 型的数据戳,该数据戳用于稍后的锁释放参数,如果返回的数据戳为 0 则表示锁获取失败。当前线程持有了锁再次获取锁还是会返回一个新的数据戳,这也是StampedLock不可重入的原因。

StampedLock 的性能为什么更好?

相比于传统读写锁多出来的乐观读是StampedLockReadWriteLock 性能更好的关键原因。StampedLock 的乐观读允许一个写线程获取写锁,所以不会导致所有写线程阻塞,也就是当读多写少的时候,写线程有机会获取写锁,减少了线程饥饿的问题,吞吐量大大提高。

StampedLock 适合什么场景?

ReentrantReadWriteLock 一样,StampedLock 同样适合读多写少的业务场景,可以作为 ReentrantReadWriteLock的替代品,性能更好。

不过,需要注意的是StampedLock不可重入,不支持条件变量 Conditon,对中断操作支持也不友好(使用不当容易导致 CPU 飙升)。如果你需要用到 ReentrantLock 的一些高级性能,就不太建议使用 StampedLock 了。

StampedLock 的底层原理

StampedLock 不是直接实现 LockReadWriteLock接口,而是基于 CLH 锁 实现的(AQS 也是基于这玩意),CLH 锁是对自旋锁的一种改良,是一种隐式的链表队列。StampedLock 通过 CLH 队列进行线程的管理,通过同步状态值 state 来表示锁的状态和类型。

Future

Future有什么用?

异步思想

当执行某一耗时任务时,可以将这个耗时任务交给一个子线程异步执行,再通过Future获取耗时任务的执行结果

Callable 和 Future 有什么关系?

FutureTask 提供了 Future 接口的基本实现,常用来封装 CallableRunnable,具有取消任务、查看任务是否执行完成以及获取任务执行结果的方法。ExecutorService.submit() 方法返回的其实就是 Future 的实现类 FutureTask

FutureTask相当于对Callable 进行了封装

集合

Set

  • TreeSet:红黑树,查找效率 o(logN)
  • HashSet:哈希表
  • LinkedHashSet:链表,哈希表,继承HashSet,内部用双向链表维护元素插入顺序

List

  • ArrayList:数组
  • Vector:数组,线程安全
  • LinkedList:链表,双向链表(1.6之前是循环链表)

Queue

  • LinkedList:链表,双向链表
  • PriorityQueue:堆,可实现优先队列

Map

  • TreeMap:红黑树(自平衡排序二叉树)
  • HashMap:哈希表
  • Hashtable:哈希表,线程安全
  • LinkedHashMap:链表,双向链表

ArrayList

底层数组,容量动态增长,在添加大量元素之前,主动使用 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倍左右(奇数会丢小数)

ArrayList和LinkedList区别

  • 线程安全:都不保证线程安全
  • 数据结构:ArrayList 底层Object 数组,LinkedList底层双向链表(jdk1.6之前是循环链表,1.7取消循环)
  • 插入和删除是否受元素位置影响:
    • ArrayList受影响,数组,不指定位置添加o(1),指定位置添加o(n),因为要移位
    • LinkedList不受影响,链表,指定位置增删o(n),其他o(1)
  • 快速随机访问:ArrayList 实现了RandomAccess接口,可以随机访问
  • 内存占用:ArrayList 结尾会留空间,LinkedList每个元素都放前后驱和数据

HashMap

可以存的null的key和value,但null的key只能有一个

jdk1.8之前使用数组+链表,链表解决哈希冲突,jdk1.8以后,解决哈希冲突时,当链表长度>=8(链表转为红黑树之前会先判断,如果数组长度<64,会先扩容而不是转为红黑树),将链表转化为红黑树,减少搜索时间

初始容量16(过大的话就会导致空间的浪费,太小的话就又会导致频繁扩容),之后每次扩容时,容量变为原来2倍

负载因子0.75(设置过大的话虽然空间利用率高了但是会更容易引发hash碰撞(因为扩容阈值大了),而设置过小的话虽然可以减少hash碰撞的发生但也会导致空间利用率不高以及频繁扩容)

添加和扩容

putval时,如果位置没有元素,就直接插入,有元素的话就和key比较,key相同就直接覆盖,key不同就判断p是否是一个树节点,如果是就用树的方法加入元素,不是就遍历链表插入(尾部)

put

  1. 如果定位的数组位置没有元素就直接插入
  2. 有元素就和插入的key比较,相同就直接覆盖,不相同就判断p是否是一个树节点,是就调用树的插入方法,否则就遍历链表插入尾部

扩容resize:resize,伴随一次重新hash分配,并且会遍历hash表所有元素,实际是将table初始化和table扩容进行整合,都是给table赋值一个新的数组

没超过最大值就扩充为原来的2倍

扩容时先插入再扩容还是先扩容再插入

JDK1.7是先扩容再插入,而1.8是先插入再扩容

  • 1.7先扩容,然后使用头插法,直接把要插入的Entry插入到扩容后数组中,头插法不需要遍历扩容后的数组或链表
  • 1.8先插入,再扩容,因为如果先扩容后插入,尾插法,扩容后还有再遍历一遍找到尾部位置插入,浪费性能。同时因为可能要树化,所以先获取长度

HashMap 为什么线程不安全?

1.7之前在多线程下扩容可能导致死循环和数据丢失(1.8也存在)

1.8后,多个键值对可能分配到一个桶,并以链表或红黑树形式存在,多个线程的put可能导致线程不安全,会有数据覆盖的风险,可能两个线程同时插入,第1个线程判定不冲突之后被挂起,此时第2个线程插入成功,最后第1个线程的数据会覆盖第二个线程,还有可能导致size值不正确,进一步导致数据覆盖

ConcurrentHashMap

在HashMap基础上实现了线程安全。其主要是通过应用CAS以及Synchronized实现线程安全。

java7的ConcurrentHashMap使用分段锁,就是每一个segment上同时只有一个线程可以操作,每一个segment都是一个类似HashMap数组的结构,可以扩容,冲突换转换为链表,但是Segment的个数一但初始化就不能改变

java8中ConcurrentHashMap使用Synchronized锁加CAS的机制,Node类似一个HashEntry的结构,冲突过多会转化为红黑树,冲突变小会转换为链表

Synchronized 锁自从引入锁升级策略后,性能不再是问题

ConcurrentHashMap 1.7

分段Segment,给每一个段配一把锁,当一个线程占用锁访问一个段时,其他段的数据也能被其他线程访问,Segment继承ReentrantLock,个数一旦初始化就不能改变,默认16

默认容量16,默认负载因子0.75,默认并发级别16,初始化时,segmentMask为15,初始化segments[0]大小为2,负载因子0.75,扩容阈值1.5,插入第二个值就进行扩容

在put一个数据时

  1. 计算key的位置,获取指定位置的segment
  2. 如果指定位置的segment为空,就初始化这个segment。初始化流程:
    1. 检查计算得到的segment是否为null
    2. null就继续初始化,用segment[0]的容量和负载因子创建一个HashEntry数组
    3. 再次检查计算得到指定位置的segment是否为null
    4. 用创建的HashEntry数组初始化这个Segment
    5. 自旋判断计算得到指定位置的segment是否为null,为null就用cas在这个位置赋值Segment,直到赋值成功
  3. Segment.put插入key,value,下面是真正Put(因为Segment继承了ReentrantLock,所以Segment内部也可以方便获取锁,)
  4. tryLock()获取锁,获取不到使用scanAndLockForPut方法(不断自旋tryLock()获取锁,次数大于指定次数时,使用lock()阻塞获取锁,顺便获取对应HashEntry)继续获取
  5. 计算put数据放入的index位置,然后获取这个位置上的HashEntry
  6. 遍历put新元素,因为可能获取的 HashEntry是一个空元素或者一个链表
    1. 如果这个位置上的HashEntry不存在
      1. 如果当前容量大于扩容阈值,小于最大容量,进行扩容
      2. 头插法插入
    2. 如果这个位置上的HashEntry存在
      1. 判断链表当前元素key和hash值是否和put的key的hash值一致,一致就替换
      2. 不一致,就获取链表下一个节点,直到发现相同值进行替换,如果没有相同的
        1. 如果当前容量大于扩容阈值,小于最大容量,进行扩容
        2. 直接头插法插入
  7. 如果插入的位置之前就存在,替换之后返回旧值,否则返回null

扩容rehash

Segment 中的链表长度超过阈值(默认为 8)时,会触发该 Segment 的扩容。

扩容时首先获取段的锁,获取成功其他扩容线程会阻塞,再把旧段的元素分批迁移到新段,此时其他线程对该段的写操作会被阻塞,迁移完成后,原始段会指向新段,扩容锁释放。只会扩容到原来的2倍,老数组的数据移动到新数组时,位置要么不变,要么变为 index + oldSize,参数里的 node 会在扩容之后使用链表头插法插入到指定位置。

get

  1. 计算key的存放位置
  2. 遍历指定位置查找相同key的value值

ConcurrentHashMap 1.8

采用 Node + CAS + synchronized 来保证并发安全,锁粒度更细synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。

CAS是并发更新时修改数据

初始化 initTable

通过自旋和 CAS 操作完成,sizeCtl 值决定当前初始化状态,小于0就说明另外线程正在进行初始化,此时主动让出CPU使用权

  • -1:正在初始化
  • -N:有N - 1个线程正在进行扩容
  • 0:table没初始化就表示table初始化大小
  • >0:table已经初始化就表示table扩容的阈值

put

  1. 根据key计算hashcode

  2. 判断是否需要进行初始化

  3. 根据当前key定位出的Node,如果为空表示当前位置可以写入数据,用CAS尝试写入,失败就自旋保证成功

  4. 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。

    1. 多线程扩容用cas修改获得其他线程状态
  5. 如果都不满足,就利用synchronized锁链表首节点或树头节点写入数据

  6. 如果数量大于 TREEIFY_THRESHOLD 则要执行树化方法,在 treeifyBin 中会首先判断当前数组长度 ≥64 时才会将链表转换为红黑树。

get

  1. 根据hash值计算位置
  2. 查找指定位置,如果头节点就是要找的,就直接返回它的value
  3. 如果头节点hash值小于0,说明正在扩容或者是红黑树,进行查找
  4. 如果是链表,遍历查找

ConcurrentHashMap 和 Hashtable 的区别?

主要体现在线程安全的实现

  • 数据结构:jDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式
  • 实现线程安全的方式:
    • 1.7时的ConcurrentHashMap对桶数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据,多线程访问容器不同数据段的数据就不存在锁竞争
    • 1.8ConcurrentHashMap直接用 Node数组+链表+红黑树,并发控制使用synchronized和CAS,整体看起来像优化过线程安全的HashMap,TreeNode被TreeBin包装,waiter属性维护当前使用这颗红黑树的线程,防止其他线程的进入(红黑树旋转时,根节点可能被原来的子节点替换)
    • Hashtable(同一把锁):用synchronized保证线程安全,效率低,put时其他线程不能get

LinkedHashMap

继承HashMap,在HashMap基础上维护一条双向链表

定义了排序模式 accessOrder(默认false),访问顺序为true(访问一个元素之后会移到后面),插入顺序false

  1. 支持遍历时按照插入顺序有序,遍历顺序和插入顺序一致
  2. 支持按照元素访问顺序排序,适用于封装LRU缓存工具
  3. 遍历效率和元素个数成正比,HashMap和容量成正比,LinkedHashMap迭代效率会高

LRU缓存

最近最少使用,确保当存放的元素超过容器容量时,将最近最少访问的元素移除

实现思路

  • 继承 LinkedHashMap
  • 构造方法指定 accessOrder 为true(访问一个元素之后会移到最后),链表首元素就是最近最少被访问的元素
  • 重写 removeEldstEntry方法,返回一个boolean,告诉 LinkedHashMap是否需要移除链表首元素

源码

Node设计:Entry增加before和after让节点具备双向链表的特性,HashMap的TreeNode继承了LinkedHashMap的entry,这是为了保证使用LinkedHashMap时树节点具备双向链表的特性

get:accessOrder为true时,会在元素查找之后,将访问的元素移动到链表的末尾

CopyOnWriteArrayList

t读取操作完全不加锁,写入也不会阻塞读取操作,只有写写会互斥,读性能提升

线程安全的核心在于采用了 写时复制(Copy-On-Write)

写时复制:如果多个调用者同时请求相同资源,他们会同时获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份副本给调用者,其他调用者不变。这样的优点是如果调用者没有修改资源,就不会有副本被创建,因此多个调用者只是读取操作时可以共享一份资源

当修改CopyOnWriteArrayList的内容时,不会直接修改原数组,而是先创建数组的副本,对副本进行修改,修改完成再将修改后的数组赋值回去

写时复制适合读多写少的场景

缺点

  1. 内存占用:每次写操作都要复制一份原数据,占用额外空间
  2. 写操作开销:写操作都需要复制一份原数据,然后进行修改和替换,写入频繁时开销大
  3. 数据一致性问题:修改操作需要等待复制完成,可能导致一定数据一致性问题

插入:默认插入尾部,也可以指定位置

addIfAbsent(E e):如果指定元素不存在,那么添加该元素。如果成功添加元素则返回 true。

  • add时先上锁,避免多线程写会复制多个副本
  • 创建一个新数组容纳新元素,在新数组写操作,最后将新数组复制给底层数组的引用,线程安全核心在于写时复制
  • 每次写操作都要Arrays.copyOf(底层调用系统级别的拷贝指令,所以性能优秀)复制底层数组,o(n),占用额外内存空间,所以适用于读多写少的场景,写操作不频繁
  • 没有扩容grow操作

获取:弱一致性,可能读到旧数据,分为2步,先获取当前数组的引用,再从数组获取下标的元素,没加锁。

所以可能获取数组引用之后,其他线程修改了数组,但本数组的值没有改变(其他线程会设置新数组引用)

删除:先加锁,删除时如果删除的是最后一个元素,就复制之前所有的元素,否则进行分段复制,先复制删除元素之前的,再复制删除元素之后的

BlockingQueue

接口,继承Queue

常用于生产者-消费者模型

常用实现类

  • ArrayBlockingQueue:使用数组实现的有界阻塞队列。在创建时需要指定容量大小,并支持公平和非公平两种方式的锁访问机制。
  • LinkedBlockingQueue:使用单向链表实现的可选有界阻塞队列。在创建时可以指定容量大小,如果不指定则默认为Integer.MAX_VALUE。和ArrayBlockingQueue类似, 它也支持公平和非公平的锁访问机制。
  • PriorityBlockingQueue:支持优先级排序的无界阻塞队列。元素必须实现Comparable接口或者在构造函数中传入Comparator对象,并且不能插入 null 元素。
  • SynchronousQueue:同步队列,是一种不存储元素的阻塞队列。每个插入操作都必须等待对应的删除操作,反之删除操作也必须等待插入操作。因此,SynchronousQueue通常用于线程之间的直接传递数据。
  • DelayQueue:延迟队列,其中的元素只有到了其指定的延迟时间,才能够从队列中出队。

ArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别?

  • 底层实现:数组和链表
  • 是否有界:数组的有界,必须在创建时指定容量大小,链表的可以不指定大小,默认无界
  • 锁是否分离:数组的锁不分离,生产和消费使用一把锁;链表的锁是分离的,生产用putLock,消费用takeLock,可以防止生产者和消费者之间的锁争夺
  • 内存占用:数组需要提前分配内存,占用内存大,链表动态分配内存,根据元素增加逐步占用内存

PriorityQueue

1.5引入,和Queue区别是元素出队顺序和优先级相关

  • 二叉堆,底层是可变长数组
  • 删除o(logn)
  • 非线程安全,不能存NULL和不可比较的对象
  • 默认小顶堆,可以自定义排序

Comparable 和 Comparator 的区别

  • Comparable 接口实际上是出自java.lang包 它有一个 compareTo(Object obj)方法用来排序

    • 比如Integer类实现这个接口
  • Comparator接口实际上是出自 java.util 包它有一个compare(Object obj1, Object obj2)方法用来排序

    • 自定义比较

集合转换

集合转 Map

**在使用 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() 方法的实现。

HashSetcontains() 方法底部依赖的 HashMapcontainsKey() 方法,时间复杂度接近于 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方法会复制整个数组

java 复制代码
可以这样转为ArrayList
List list = new ArrayList<>(Arrays.asList("a", "b", "c"))

推荐使用Stream,也可以转基本类型数组
Integer [] myArray = { 1, 2, 3 };
List myList = Arrays.stream(myArray).collect(Collectors.toList());

Java

异常

Exception和Error共同的父类是Throwable

  • 不要在finally使用return,当try和finally语句都有return时,try语句的return会被忽略。因为try语句中的return返回值会先被暂存到一个本地变量中,当执行finally中的return之后,这个本地变量的值就变成了finally语句中的return返回值
  • 使用日志打印异常之后就不要再抛出异常

反射

获取Class对象四种方式

  1. 类名.class
  2. Class.forName()传入类的全路径
  3. 对象.getClass()
  4. xxxClassLoader.loadClass()传入类的全路径
    1. 通过类加载器获取Class对象不会进行初始化,静态代码块和静态对象不会得到执行

Unsafe

  • 只有启动类加载器加载的类才可以调用Unsafe类中的方法
  1. 内存操作
  2. 内存屏障
    1. 编译器和 CPU 会在保证程序输出结果一致的情况下,会对代码进行重排序,从指令优化角度提升性能。但指令重排可能导致CPU高速缓存和内存数据不一致,内存屏障(Memory Barrier)就是通过阻止屏障两边的指令重排序从而避免编译器和硬件的不正确优化情况。
    2. loadFence方法为例,它会禁止读操作重排序,保证在这个屏障之前的所有读操作都已经完成,并且将缓存数据设为无效,重新从主存中进行加载。
  3. 对象操作
  4. 数据操作
  5. CAS 操作
  6. 线程调度
  7. Class 操作
  8. 系统信息

深拷贝、浅拷贝、引用拷贝

  • 浅拷贝:堆上创建新对象,但如果原对象内部属性是引用类型,浅拷贝会复制内部对象的引用地址,共用一个内部对象
  • 深拷贝:完全复制整个对象
  • 引用拷贝:两个不同的引用指向同一个对象

String

String为什么不可变?

java 复制代码
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
    private final char value[];
	//...
}
  1. 保存字符串的数组被final修饰并且私有,并且String没有提供/暴露修改这个字符串的方法
  2. String类被final修饰导致不能被继承,避免了子类破坏String不可变

字符串拼接+

"+"和"+="是专门为String类重载过的运算符,仅有的两个

  • +:实际上通过StringBuilderappend()方法实现,拼接完成调用toString()得到String对象
    • 但如果在循环内使用+号,编译器不会复用StringBuilder,会创建过多的StringBuilder对象
    • 但这个问题在jdk9得到解决,+号改为了用动态方法makeConcatWithConstants()实现,不是大量创建StringBuilder
  • 编译时,Javac编译器会进行常量折叠
    • 常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中,这是 Javac 编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行),只有编译器在程序编译期就可以确定值的常量才可以
    • 对于 String str3 = "str" + "ing"; 编译器会给你优化成 String str3 = "string";

Java 9 为何要将 String 的底层实现由 char[] 改成了 byte[] ?

Java 9 之后,StringStringBuilderStringBuffer 的实现改用 byte 数组存储字符串。

新版String支持两个编码方案:Latin-1和UTF-16。如果不超过Latin-1表示范围,就用Latin-1,byte占一个字节,相比于char节省一半内存

  • 绝大多数字符串对象只包含Latin-1可表示的字符

String#intern 方法有什么作用?

将指定的字符串对象的引用保存在字符串常量池

常用字符编码所占字节数?

utf8 :英文占 1 字节,中文占 3 字节,unicode:任何字符都占 2 个字节,gbk:英文占 1 字节,中文占 2 字节。

新特性

  • Interface:方法可以用default或static修饰,就可以有方法体,实现类也不必重写此方法

    1. default修饰的方法,是普通实例方法,可以用this调用,可以被子类继承、重写。
    2. static修饰的方法,使用上和一般类静态方法一样。但它不能被子类继承,只能用Interface调用。java8中接口和抽象类
  • 函数式接口:有且只有一个抽象方法

  • Lambda表达式:替代匿名内部类

  • Stream:不存储数据,可以检索和逻辑处理集合数据,分为串行流和并行流,一个Stream只能操作一次,操作完就关闭了

  • Optional:防止空指针异常,让代码简洁

  • Date-Time API:java.time类

final

原理

  • 写final域会要求编译器在final域写之后,构造函数返回前插入一个写写屏障
    • 不会被重排序到构造函数外
  • 读final域的重排序规则会要求编译器在读final域的操作前插入一个读读屏障
    • 先读对象引用,再读对象的final域

看处理器:x86不会对写写和读读重排序

IO模型(BIO、NIO、AIO)

IO操作必须通过系统调用间接访问内核空间,应用程序只是发起IO操作的调用,具体IO执行是操作系统内核完成的

应用程序发起IO调用后有两个步骤:

  1. 内核等待IO设备准备好数据
  2. 内核将数据从内核空间拷贝到用户空间

linux下的5中IO模型同步阻塞 I/O同步非阻塞 I/OI/O 多路复用信号驱动 I/O异步 I/O

BIO:同步阻塞IO,read后会一致阻塞,直到内核把数据拷贝到用户空间

NIO:是同步非阻塞IO,也是IO多路复用基础,提供了Channel、Selector、Buffer抽象

  • 应用程序不断发起read,等待数据从内核空间拷贝到用户空间这段时间依然是阻塞的,轮询操作,避免了一直阻塞。
  • IO多路复用中,线程首先select调用,询问内核数据是否准备就绪,就绪后用户线程再次发起read调用,read 调用的过程(数据从内核空间 -> 用户空间)还是阻塞的。通过减少无效的系统调用,减少了对 CPU 资源的消耗。
    • NIO有一个选择器Selector,称为多路复用器,只需要一个线程管理多个客户端连接,客户端数据到了之后才服务

AIO:异步IO,基于事件和回调机制,应用操作之后直接返回不阻塞,处理完成后操作系统通知对应线程进行后续操作

值传递还是引用传递

  • 值传递:调用方法时传实际参数的拷贝,这样在修改形参时不影响实参
  • 引用传递:地址传递,调用方法时传实参地址,这样修改形参会影响实参

值传递,传递的是副本。引用传递,传递的是实际内存地址

都是值传递,引用类型传递地址,把地址的拷贝传给形参

Redis

缓存一致性、旁路缓存

旁路缓存

Cache Aside Pattern(旁路缓存模式):最频繁,适合请求较多的场景

服务端需要同时维护db和cache,以db结果为准,该策略下的缓存读写步骤

写数据时,先更新db,再删除cache就没问题了吗? :不是,假如某个数据不存在,如果请求1在修改数据库并且删除缓存后,再次请求时写入缓存之前,请求2修改数据并删除缓存,之后请求1再写入缓存,此时缓存是旧的,数据库数据是新的,数据就不一致了。但因为缓存的写入很快,所以概率不高

解决缓存不一致延迟双删 :删缓存、更新数据库、睡眠、再删缓存。睡眠确保在请求1睡眠时,请求2在这段时间读取数据并把缺失数据写入缓存。然后再删缓存,下一次重建时就是请求2修改的数据,尽可能一致性,睡眠时间玄学。为了避免第二个删除失败,可以异步操作缓存,如引入消息队列重试删除,或者用Canal订阅binlog再操作缓存(伪造自己是从节点,发现数据库修改后就通知变更情况)

延迟队列

Canal的异步通知

  • Canal监听mysql的binlog,发现数据库修改之后就通知数据变更情况来更新缓存,时效性更强
  • 基于mysql的主从同步实现
  • Canal就是把自己伪装成mysql的一个slave节点,监听master的二进制日志变化,再把变化信息通知Canal的客户端

旁路缓存缺陷

  1. 首次请求的数据一定不在cache:解决方法:提前将热点数据放入cache
  2. 写操作频繁时导致cache的数据会被频繁删除,影响命中率,解决方法:
    1. cache和db强一致性场景:更新db时更新cache,但需要加锁保证同一时间只有一个请求更新缓存
    2. 可以短暂允许db和cache数据不一致的场景:更新db时更新cache,更新完成给cache加一个短的过期时间,保证即使数据不一致影响也不大

读写穿透

Read/Write Through Pattern(读写穿透):服务端把cache视为主要数据存储,从中读取数据并写入数据,cache服务负责将数据读取和写入db,少见,大概率因为经常使用的分布式缓存Redis没有提供cache将数据写入db的功能

  • 先查cache,cache不存在就直接更新db
  • cache存在,就先更新cache,然后cache服务自己更新db(同步更新cache和db)

  • 从cache读,读到直接返回
  • 读不到,先从db加载,写入cache后返回

读写穿透实际只是在旁路缓存进行封装,在旁路缓存里,读数据时如果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

    应用场景

    • 需要存储常规数据:缓存session、token、图片地址、序列化的对象:SET、GET
    • 需要计数:用户单位时间的请求数(简单限流)、页面单位时间的访问数:SET、GET、INCR、DECR
    • 分布式锁:SETNX KEY VALUE,存在一些缺陷,不建议
  • List:Redis3.2之前(LinkedList/ZipList) Redis3.2之后QuickList

    双向链表,支持反向查找和遍历

    应用场景

    • 信息流展示:最新文章、最新动态:LPUSH、LRANGE
    • 消息队列:不建议,可以用Stream,还是不建议
  • Hash:Hash Table、ZipList(元素数量小于默认512个,任意entry大小小于64字节)

    类似HashMap(数组+链表),但做了很多优化

    应用场景

    • 对象数据存储:用户、商品信息存储:HSET、HMSET、HGET、HMGET,字段频繁变动用Hash存储而不是json的String
  • Set:Intset(都是整数而且个数小于512) ,hashtable(value是null)

    无序集合,类似HashSet,可以很快实现集合的操作,都是整数而且元素不多时使用IntSet

    应用场景

    • 需要存放的数据不能重复:网站UV统计(数据零太大就使用HyperLogLog)、文章点赞:SCARD
    • 需要获取多个数据源交集、并集、差集:共同好友,好友推荐:SINTER、SINTERSTORE、SUNION、SUNIONSTORE、SDIFF、SDIFFSTORE
    • 需要随机获取数据源元素:抽奖系统:SPOP(随机获取元素并移除,适合不允许重复中奖的场景)、SRANDMEMBER(随机获取元素,适合允许重复中奖的场景)
  • Zset:ZipList(元素数量小于默认128同时每个元素小于默认64字节)、SkipList

    Sorted Set类似Set,但增加一个权重参数score,可以根据score排序,也可以根据score范围获取元素的列表

    应用场景

    • 需要随机获取元素根据某个权重进行排序:排行榜、微信步数排行榜:ZRANGE、ZREVRANGE、ZREVRANK
    • 需要存储的数据有优先级或重要程度:优先级任务队列:ZRANGE、ZREVRANGE、ZREVRANK

底层数据结构

  • 动态字符串SDS :会保存长度,自动扩展
    • 新字符串小于1M,扩展后长度2倍+1
    • 新字符串大于1M,扩展后长度+1M+1,内存预分配
  • IntSet :set的一种实现,基于整数数组,长度可变、有序,支持2、4、8字节编码
    • 所有整数升序保存在数组
    • 如果加入的数字超出之前数字的编码大小范围,会自动升级编码到合适大小,按照新编码扩容数组,倒序将数组元素拷贝到新位置,最后将待添加的元素加入末尾
    • 底层采用二分查找查询
  • Dict :键值对,三部分组成,哈希表、哈希节点、字典,一个字典包含两个哈希表,一个是空,rehash时使用
    • 哈希表是数组和单向链表
    • 新增会检查负载因子,如果大于1并且每执行后台进程或者大于5,都会触发扩容,扩容到第一个大于等于used+1的2^n
    • 删除时也检查负载因子,小于0.1会收缩
    • rehash是根据哈希表的每个key计算索引加入新的哈希表,会先计算新的size,如果是扩容,新size是第一个大于等于used+1的2^n,收缩新size是第一个大于等于used的2^n
  • ZipList :双端链表,一系列特殊编码连续内存块组成,o(1),节省内存,但申请的必须是连续内存
    • entry包含前一个节点的长度、编码、内容
      • 如果前一个长度小于254字节,就用1个字节保存长度,大于254就用5+
      • 个字节保存长度,第一个0xfe,后四个是真实长度
      • 编码前两位00/01/10表示字符串,对应1、2、5字节编码长度,11开头表示整数,1字节编码
    • 连锁更新问题:如果大量250-253字节的entry,就一直用1字节表示长度,此时插入中间一个254字节的entry,此时用5字节记录,刚好后面是250字节,+4(1变5)之后刚好254字节,所以又变5字节,又加+4...,连续多次空间扩展(频繁申请内存,销毁,内核态切换),新增和删除都可能导致连锁更新
  • QuickList:双端链表,每个节点都是ziplist,限制每个ziplist的大小,解决连续空间申请效率问题
  • SkipList:跳表 ,升序排列,节点可能包含多个指针,记录最高层级,层级(1-32之间的随机数)越高,跨度越大,效率和红黑树基本一致,但实现更简单
  • RedisObject:Redis任意的键值都会被封装为一个RedisObject,有对象引用计数器,为0就回收,还有lru表示对象最后一次被访问的时间

3种特殊数据结构

  • Bitmap:存储连续二进制数字

    常用命令

    • SETBIT key offset value 设置指定 offset 位置的值
    • BITCOUNT key start end 获取 start 和 end 之前值为 1 的元素个数

    应用场景;

    • 需要保存状态信息:用户签到、活跃用户:SETBIT、GETBIT、BITCOUNT、BITOP
  • HyperLogLog:基数计数概率算法,不是Redis特有,是优化Log Log Counting的,Redis只是实现了这个算法并提供了一些API

    占用空间非常小(12k)可以存储2^64个不同元素,Redis对HyperLogLog的存储结构进行优化,Redis采用两种方法计数:

    • 稀疏矩阵:计算少的时候空间少
    • 稠密矩阵:计算达到某个阈值时,占用12k

    基数计数概率算法为了节省内存并不会直接存储数据,而是通过一定的概率统计方法预估基数值(集合中包含元素的个数),所以不是精确值,误差0.81%

    常用命令

    • PFADD key element1 element2 ... 添加一个或多个元素到 HyperLogLog 中
    • PFCOUNT key1 key2 获取一个或者多个 HyperLogLog 的唯一计数。
    • PFMERGE destkey sourcekey1 sourcekey2 ... 将多个 HyperLogLog 合并到 destkey 中,destkey 会结合多个源,算出对应的唯一计数。

    应用场景

    • 数据量巨大的计数:热门网站的ip数统计:PFADD、PFCOUNT
  • Geospatial index:地理空间索引,居于Sorted Set实现,可以轻松实现两个位置距离计算

    常用命令

    • GEOADD key longitude1 latitude1 member1 ... 添加一个或多个元素对应的经纬度信息到 GEO 中
    • GEODIST key member1 member2 M/KM/FT/MI 返回两个给定元素之间的距离

    应用场景

    • 需要管理使用地理空间数据:附近的人

跳表

用于在有序元素集合中进行快速搜索、插入和删除操作。它通过添加多层索引来加速查找,从而降低了算法的时间复杂度。

链表随机访问o(n),链表有序o(n),加速链表,用于元素有序的情况

跳表是对表的平衡树和二分查找,增删查都是o(logn)

redis实现跳表,底层节点有level数组,保存前进节点指针和跨度,数组大小在1-32之间随机(但越大的数出现概率越小),高层的指针越过的元素数量大于等于低层的指针,为了提高查找的效率,程序总是从高层先开始访问,然后随着元素值范围的缩小,慢慢降低层次。

RDB

Redis通过创建快照获取存储在内存里面的数据在某个时间点上的副本。可以备份/复制快照,或者留在原地重启服务器时使用

快照持久化是Redis默认的持久化方式,二进制

redis.conf

clo 复制代码
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快照

  • save:同步保存,会阻塞Redis主线程
  • bgsave:fork一个子进程,子进程执行,不阻塞Redis主线程,默认
    • 此时主进程也可以处理命令,写时复制技术,fork子进程时复制页表,指向同一个物理内存,修改的时候才复制物理内存(减少创建子进程的性能损耗),主进程修改共享数据时就复制一份对应数据去修改,不影响子进程

说Redis主线程是因为Redis启动之后通过单线程完成主要工作,也可以叫Redis主进程

AOF

默认不开启,Redis6.0之后默认开启

每执行一条会更改Redis中的数据的命令,Redis就会将该命令写入AOF缓冲区中,然后再写入AOF文件里(此时还在系统内核缓存区未同步到磁盘),最后再根据持久化方式(fsync策略)的配置决定什么时候将系统内核缓存区的数据同步到硬盘

工作基本流程

  1. 命令追加(append):所有写命令会追加到AOF缓冲区
  2. 文件写入(write):将AOF缓冲区数据写入AOF文件(磁盘 ),需要调用write函数,将数据写入系统内核缓冲区 之后直接返回(延迟写),此时没有同步到磁盘(同步磁盘操作依赖系统调度机制)
  3. 文件同步(fsync):AOF缓冲区根据对应的持久化方式(fsync策略)向磁盘做同步操作,需要调用fsync函数,fsync针对单个文件强制进行磁盘同步并阻塞,直到写入磁盘完成后返回(强制刷新系统内核缓冲区同步到磁盘)
  4. 文件重写(rewrite):aof文件变太大时,需要定期对aof文件进行重写,达到压缩目的
  5. 重启加载(load):redis重启后,可以加载aof文件进行数据回复

fsync策略

  • appendfsync always:主线程write写操作后,后台线程立刻调用fsync函数同步aof文件(磁盘),fsync完成后线程返回,write+fsync
  • appendfsync 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 重写

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为什么是在执行完命令之后记录日志?

  • 避免额外检查开销,aof记录日志不会对命令进行语法检查
  • 命令执行完之后再记录,不会阻塞当前的命令执行

风险:

  • 刚执行完命令就宕机,对应数据丢失
  • 可能阻塞后续的命令执行,AOF记录日志是在Redis主线程中进行的)

AOF校验机制了解吗?

AOF 校验机制是 Redis 在启动时对 AOF 文件进行检查,以判断文件是否完整,是否有损坏或者丢失的数据

使用校验和验证(对整个AOF文件内容用CRC64算法计算的数字)

RDB和AOF混合持久化

4.0开始支持RDB和AOF混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)

AOF重写可以直接把RDB内容写到AOF开头,再把AOF重写缓冲区的数据写到AOF文件尾,好处是可以快速加载同时避免丢失过多数据,缺点是AOF里的RDB部分是压缩格式,可读性差

RDB恢复速度快,AOF丢失数据少

如何选择 RDB 和 AOF?

RDB 比 AOF 优秀的地方

  • RDB存储压缩的二进制数据,合做数据的备份,灾难恢复。Redis 7.0 版本之前,如果在重写期间有写入命令,AOF 可能会使用大量内存,重写期间到达的所有写入命令都会写入磁盘两次。
  • RDB直接可以还原数据,AOF需要依次执行写命令,数据太多时速度慢

AOF 比 RDB 优秀的地方

  • 更安全,实时持久化数据,生成RDB文件过程繁重,就算是子进程写入RDB也占用机器CPU和内存,AOF支持秒级数据丢失(取决fsync策略,everysec最多丢失1秒数据),仅仅追加命令,操作量小
  • RDB已二进制格式保存,存在老版本Redis不兼容新版本RDB格式的问题
  • 可以直接分析操作AOF文件

综上:

  • Redis 保存的数据丢失一些也没什么影响的话,可以选择使用 RDB。
  • 不建议单独使用 AOF,因为时不时地创建一个 RDB 快照可以进行数据库备份、更快的重启以及解决 AOF 引擎错误。
  • 如果保存的数据要求安全性比较高的话,建议同时开启 RDB 和 AOF 持久化或者开启 RDB 和 AOF 混合持久化。

分布式锁setnx

原子性操作,返回1表示key设置了新值,0表示key已存在,基于Redis的单线程模型和事务,客户端发送setnx到服务器,redis会把命令加入命令队列排队,用watch监视指定的key,执行事务时检测key是否被修改

常见阻塞原因、大Key

  • O(n)命令 :比如返回所有key、所有...,还有set的交并差集,有遍历的需求可以使用 HSCANSSCANZSCAN 代替。还有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不会记录在慢日志中:因为慢日志只记录一个命令真正操作内存数据的耗时,如果Redis主动删除过期key是在命令真正执行之前执行的
    • 创建子进程时复制父进程页表阻塞时间长

    • 创建子进程后,如果父进程修改共享数据的大key,就会发生写时复制,拷贝物理内存,但大key占空间大,复制就耗时,阻塞父进程

    • 客户端超时阻塞。Redis单线程执行命令,操作大key耗时长阻塞Redis

    • 查找大Key:用 --bigkeys 查找大Key时,选择在从节点执行,主节点执行会阻塞

单线程、IO模型、性能

Redis 为什么这么快、高性能?

  1. Redis基于内存,内存访问速度快,并发10w+,mysql是1w
  2. 基于Reactor模式开发一套高效事件处理模型,主要是单线程事件循环和IO多路复用
  3. 内置多种优化过后的数据结构实现,性能高

单线程模型、Reactor 模式

Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型 ,这套事件处理模型对应的是 Redis 中的文件事件处理器(file event handler)。由于文件事件处理器(file event handler)是单线程方式运行的,所以我们一般都说 Redis 是单线程模型。

  • 文件事件处理器采用IO多路复用程序同时监听多个套接字,并根据套接字目前执行的任务为套接字关联不同的事件处理器
  • 当被监听的套接字准备好执行操作时,与操作对应的文件事件就会产生,此时文件事件处理器就调用套接字之前关联的事件处理器处理这些操作

Reactor :基于同步io,事件分发器等待某个事件发生,再把事件传给该事件注册时指定的回调函数处理。关注待完成

Proactor :异步io,事件分发器直接发起一个异步读写操作(操作系统完成),指定数据存放的位置和请求完成的回调函数,操作系统完成数据存入缓冲区操作后通知事件分发器操作完成,事件分发器呼唤处理器,事件处理器处理缓冲区数据。关注已完成

虽然文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字,也实现了高性能

redis 通过 IO 多路复用程序 来监听来自客户端的大量连接

I/O 多路复用技术的使用让 Redis 不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗

文件事件处理器包含四个部分:

  • 多个socket(客户端连接)
  • IO多路复用程序
  • 文件事件分派器(将socket关联到对应的事件处理器)
  • 事件处理器(连接应答处理器,命令请求处理器,命令回复处理器)

多线程

Redis6.0 之前为什么不使用多线程?

Redis 在 4.0 之后的版本中就已经加入了对多线程的支持。

主要是针对一些大键值对的删除操作的命令,使用这些命令就会使用主线程之外的其他线程来"异步处理"。

  • 单线程编程容易而且容易维护
  • Redis性能瓶颈不在cpu,主要在内存和网络
  • 多线程存在死锁,线程上下文切换问题,甚至影响性能

Redis6.0 引入多线程

为了提高网络IO读写性能

Redis 的多线程只是在网络数据的读写这类耗时操作上使用了,执行命令仍然是单线程顺序执行。所以不需要担心线程安全问题。默认不开启多线程,开启后默认只使用多线程进行IO写入writes,即发送数据给客户端,多线程读不能有太大提升,一般不建议开启

后台线程

  • close_file 表示关闭相应文件描述符对应的文件(释放套接字、数据空间等)释放临时文件。
  • aof_fsync 表示 AOF 刷盘
  • lazy_free 表示惰性释放空间

主线程生产任务事件,因为每一个后台线程多有对应的事件队列,当有事件处理时,会发送到对应的后台线程队列,后台线程就是消费者,从队列取出任务事件并处理(如果对应线程休眠就先唤醒)

关注队列并发问题(互斥量)生产者投递任务之前先上锁,投递之后立即释放锁

消费者从队列取任务之前,先上锁,取到之后立即释放锁

都是先加锁

五种IO模型、IO多路复用

操作系统-网络系统下

Redis 到底是单线程还是多线程?

  • 如果仅仅是核心命令处理,就是单线程
    • 单线程,Redis是纯内存操作,性能瓶颈是网络和内存,所以多线程并不会带来巨大提升
    • 多线程会导致过多上下文切换(单核)
    • 多线程线程安全问题,复杂度高,性能会降低
  • 如果是整个Redis,就是多线程
    • 4.0引入多线程异步处理一些耗时较长的任务,异步删除unlink
    • 6.0核心网络模型引入多线程,进一步提高多核CPU利用率
      • IO影响性能,在命令解析和命令回复处理器引入多线程

过期删除

通过一个过期字典(hash表)保存数据过期时间,过期时间是毫秒精度的UNIX时间戳

利用两个Dict分别记录key-value和key-ttl对

三种策略:

  • 定时删除:设置ttl时,创建一个定时事件,时间到达时事件处理器自动执行key的删除操作

    • 保证key尽快删除,内存友好
    • 过期key较多时,对cpu不友好
  • 惰性删除:不主动删除,访问的时候检查是否过期,过期就删除

    • cpu友好
    • 过期key一直不访问,内存不友好
  • 定期删除 :每隔一段时间抽一批key执行过期删除,对内存友好,Redis内部维护定时任务,这个定时任务是在Redis主线程执行的

    • Slow:执行时间长,频率低(默认0.1s一次),定时任务serverCron(),Redis服务启动时执行,清理耗时不超过25ms,遍历抽取20个key判断过期,如果没到25ms同时过期key比例大于10%,就再抽20个key
    • Fast:执行时间短,执行频率高,每个事件循环前调用beforeSleep(),在启动后执行事件循环,每次循环执行,在循环时如果没到0.1秒就不执行Slow,两次间隔不低于2ms,清理耗时不超过1ms,遍历抽取20个key,如果没到1ms同时过期key大于10%就再次抽样

Redis采用定期删除+惰性删除 ,但还是可能存在过期key堆积在内存,解决方法是内存淘汰机制

内存淘汰

Redis提供6中淘汰策略:

  • volatile-lru:设置了TTL的key使用最近最少使用淘汰

  • ttl:找将过期的数据淘汰

  • random:随机设置了TTL的key

  • allkeys-lru:全体key,移除最少最近使用的key(最常用),当前时间-最后一次访问时间,越大淘汰优先级越高

    • 传统lru基于链表,最新操作的键移到表头,内存淘汰时,只删除尾部元素,但链表缓存数据空间开销大,大量数据访问时链表移动操作耗时
    • Redis实现一种近似lru算法,目的是节约内存,在Redis对象结构体加一个字段,记录该数据最后一次访问时间,内存淘汰随机采样时,随机取5个值,然后淘汰最近没用的,不用维护和操作链表
    • 但无法解决缓存污染问题,应用一次性读大量数据,就读一次,然后就留存到Redis缓存很长时间,用4.0lfy解决
  • allkeys-random:随机所有key

  • no-eviction:禁止淘汰,让写入数据报错。默认使用这个

4.0之后加入:

  • lfu:最少频率使用,统计每个key的访问频率,越小淘汰优先级越高,保存逻辑访问次数和最后一次访问时间,访问次数越多,计数器累加概率越小,计数器也会随时间衰减

    • 在Redis对象结构加字段lru,在lru时记录最后一次key访问时长,在lfu存key访问时间戳和访问频次,初始频次5,随时间衰减
    • 访问key时先衰减,时间差距大衰减就大,之后根据概率增加,访问频次越大,增加概率越小
  • allkey-lfu:当内存不足容纳新数据,移除最不经常使用数据

事务

什么是 Redis 事务?

Redis 事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断

很少使用,不满足原子性和持久性,而且浪费网络资源,不建议使用

如何使用 Redis 事务?

MULTI之后可以输入多个命令,Redis会将这些命令放到队列,调用EXEC后,再执行所有命令

DISCARD取消一个事务

WATCH监听指定Key,如果执行事务时,被监听的key被修改,整个事务就不会被执行,但如果是同一个session,就可以执行

Redis 事务支持原子性吗?

Redis 事务在运行错误的情况下,除了执行过程中出现错误的命令外,其他命令都能正常执行。并且,Redis 事务是不支持回滚(roll back)操作的。因此,Redis 事务其实是不满足原子性的。

Redis 事务支持持久性吗?

如果 Redis 没有使用 RDB 或 AOF,那么事务的持久化属性肯定得不到保证。

如果 Redis 使用了 RDB 模式,那么,在一个事务执行后,而下一次的 RDB 快照还未执行前,如果发生了实例宕机,数据丢失,这种情况下,事务修改的数据也是不能保证持久化的。

如果 Redis 采用了 AOF 模式,因为 AOF 模式的配置选项 no、everysec 和 always(基本满足,但性能太差,不适用) 都会存在数据丢失的情况。

所以,事务的持久性属性也还是得不到保证。

不管 Redis 采用什么持久化模式,事务的持久性属性是得不到保证的。

如何解决 Redis 事务的缺陷?

2.6开始支持lua脚本,批量执行多条Redis命令,提交到服务器一次性执行完成

一段Lua脚本可以视为一条命令,保证操作不会被其他插入打扰

但如果lua运行时出错,出错之后的命令不会执行,之前的命令不能回滚,无法实现类似关系型数据库执行失败可以回滚的那种原子性效果。因此, 严格来说的话,通过 Lua 脚本来批量执行 Redis 命令实际也是不完全满足原子性的。

性能优化

  • 批量操作减少网络传输:减少RTT、减少socket IO成本(上下文切换)成本,大数据导入

    • 原生批量操作(MGET)命令是原子操作,pipeline(一次网络传输将所有命令传到服务器)是非原子操作
    • 在集群环境都要自己维护key和哈希槽的关系
    • 一条lua脚本可以看作一条命令执行,可以看作是原子操作,执行过程中不会有其他脚本或命令同时执行,但不能实现类似数据库回滚的原子性,集群下无法保证lua脚本的原子操作,因为无法保证所有的Key都在同一个哈希槽里,可以给key设置相同{}
  • 大量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持久化虽然可以保证数据安全,但会带来很多额外开销,所以

    • 用来做缓存的Redis实例尽量不开启持久化
    • 分布式锁、库存等持久性要求高再开启
    • 建议关闭RDB(频率不高,频率低的话不断fork子进程),开启AOF
    • 利用脚本定期在slave做RDB
    • 配置禁止rewrite(AOF的文件重写)期间进行aof,避免因AOF造成主线程阻塞,但是这样在rewrite期间可能会有数据丢失
      • 主线程把记录写入AOF缓冲区后,会对比上次fsync刷盘时间,如果大于2秒会阻塞(会认为出问题了,直到刷盘结束再放行),小于2秒会通过
      • 如果此时磁盘IO频繁,就影响AOF的fsync阻塞,就导致主线程阻塞

缓存穿透、缓存击穿、缓存雪崩

缓存穿透

发生原因:大量请求key不合理,不在缓存中,也不在数据库中

解决方法

  1. 缓存无效key:适用于请求的key变化不频繁,将无效key缓存到redis并设置短一点过期时间

  2. 布隆过滤器:把所有可能的请求的值都存放在布隆过滤器,如果请求的值不在过滤器里就直接返回错误信息,如果说在但其实不一定在

    原理:一个元素加入过滤器时会先计算哈希值,再把数组对应下标设置1,判断元素时,会先计算哈希,之后判断每个位数组是否都为1,都1才说明可能在,存在不为1就一定不在

缓存击穿

发生原因:请求key是热点数据,存在于数据库但不在缓存(通常是缓存数据过期),就可能导致瞬时大量请求落在数据库

解决方法

  1. 设置热点数据永不过期或延长
  2. 互斥锁,保证只有一个线程更新缓存
  3. 逻辑过期,后台异步更新,重建缓存时还要获取互斥锁

缓存雪崩

发生原因:Redis宕机或数据库大量数据同一时间过期,大量请求直接访问数据库

解决方法

  • 如果是redis服务不可用:采用集群(主从)、限流(数据库只处理少部分请求),服务熔断(暂停缓存访问,返回错误)
  • 如果是大量数据同时过期:设置不同失效时间、永不失效、二级缓存

主从复制、集群

读写分离,从节点读,主节点写,主节点同步数据给从节点

开启主从关系:从节点执行replicaof或slaveof(5.0之前),有临时(redis-cli连接时执行slaveof)和永久(配置文件),加上主节点的ip和端口

数据同步原理

第一次主从同步是全量同步

  • 第一阶段 :slave执行replicaof命令建立连接,master判断如果是第一次,master就返回master数据版本信息,slave保存版本信息
    • 判断是不是第一次同步 :slave必须声明自己的replication id和offset,master判断id不一致一定是第一次,一致的话根据offset进行增量同步
      • Replication id:数据集标记,id一致说明是同一数据集,每个master都有唯一replid,slave会继承master的replid
      • offset:偏移量,随着记录在repl_baklog中数据增多主键增大。slave完成同步时也会记录当前同步的offset,如果slave的offset小于master的offset,就说明slave数据落后于master,需要更新
  • 第二阶段:master执行bgsave生成RDB并发送,slave清空本地数据,加载RDB文件,master记录RDB期间新的命令repl_baklog
  • 第三阶段:master发送repl_baklog中的命令,slave执行命令,后续命令都写入repl_baklog并发送

slave重启后同步,执行增量同步

第一阶段判断不是第一次同步后恢复continue,第二阶段master直接去repl_baklog获取offset后的数据,发送命令并执行

repl_baklog本质是数组,大小固定,环形结构,会覆盖之前记录,所以如果slave和master差太多(slave断开太久,导致未备份数据被覆盖),大于环的大小,就无法增量同步,就只能全量同步

优化

提高全量同步性能

  • 在master配置repl_diskless-sync yes启用无磁盘复制,避免全量同步时的磁盘IO(写RDB时不写入磁盘IO而直接写入网络发送)
  • Redis单节点内存占用不要太大,减少RDB导致的过多磁盘IO

减少全量同步

  • 适当提高repl_baklog大小,发现slave宕机时尽快实现故障恢复,尽可能避免全量同步

减少主节点压力

  • 主从从,用从给部分从进行同步,限制一个master上的slave节点数量

哨兵模式

哨兵实现主从集群的自动故障恢复,哨兵也是集群

  • 监控:监控每个节点状态
  • 故障转移:如果master故障,哨兵会将一个slave提升为master
  • 通知:哨兵充当Redis客户端的服务发现来源,当集群发生故障转移时,将最新信息推送给Redis客户端

服务状态监控

哨兵基于心跳机制检测服务状态,每隔1秒向集群每个实例发送ping命令

  • 主观下线:如果节点没在规定时间响应,就认为主观下线
  • 客观下线:若超过指定数量的哨兵都认为该实例主观下线,该实例就客观下线,最少设置哨兵的一半

选举新的master

选举依据

  • 首先判断slave和master断开时间长短,超过指定值就直接排除该slave
  • 然后判断slave节点的优先级,越小越高,0表示永不参与选举
  • 如果优先级一样,就判断offset,越大说明数据越新,优先级越高
  • 最后判断slave运行id(启动时的id),越小优先级越高

脑裂:脑裂的主要原因其实就是哨兵集群认为主节点已经出现故障了,重新选举其它从节点作为主节点,而原主节点其实是假故障,从而导致短暂的出现两个主节点,那么在主从切换期间客户端一旦给原主节点发送命令,就会造成数据丢失。

解决无法彻底解决,脑裂最本质的问题是主从集群内部没有共识算法来维护多个节点的强一致性,它不像Zookeeper那样,每次写入必须大多数节点成功后才算成功,当脑裂发生时,Zookeeper节点被孤立,此时无法写入大多数节点,写请求会直接失败,因此Zookeeper才能保证集群的强一致性。

  • 限制原主库接收请求:配置最少从节点个数1(客户端连接master写数据时,必须确保master有至少一个slave,否则直接拒绝)
  • 数据复制和同步的延迟不能超过5秒,否则拒绝写入
  • 这两个配置项必须同时满足,不然主节点拒绝写入。在假故障期间满足min-slaves-to-write和min-slaves-max-lag的要求,那么主节点就会被禁止写入,脑裂造成的数据丢失情况自然也就解决了。

故障转移

选中一个slave为新的master后

  • 哨兵先给备选的slave发送slaveof no one,让该节点称为master
  • 哨兵给其他slave发送slaveof 新ip 新端口,开始从新的master同步数据
  • 最后,哨兵将故障节点标记为slave,故障节点恢复后自动成为新的master的slave

Spring的RedisTemplate底层利用lettuce实现节点的感知和自动切换

yml配置文件配置master名称和nodes从节点

配置主从读写分离:加入Bean:LettuceClientConfigurationBuilderCustomizer,指定读取策略(4个,从主节点读和优先从主节点读,主不可用时从节点读,和从从节点读和优先从从节点读,从不可用从主节点读),指定REPLICA_PREFERRED,最后一中

分片

主从和哨兵解决高可用、高并发读的问题,还有海量数据存储和高并发写的问题没解决

使用分片集群解决这两个问题,特征:

  • 集群多个master,每个master保存不同数据
  • 每个master都可以有多个slave
  • master之间ping监测彼此健康状态
  • 客户端请求可以访问集群的任意节点,最终都会转发到正确节点

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切换给执行这个命令的节点,实现无感知的数据迁移

  1. slave告诉master拒绝任何客户端请求
  2. master返回当前数据offset给slave
  3. 等待数据offset与master一致,不一致赶快同步
  4. slave和master开始故障转移
  5. slave标记自己为master,广播故障转移结果
  6. master收到广播,开始处理客户端读请求

手动的Failover支持三种不同模式:

  • 缺省:默认,1-6步
  • force:省略对offset的一致性校验
  • takeover:直接执行第5步,忽略数据一致性、忽略master状态和其他master意见

RedisTemplate访问分片集群:底层同样基于lettuce实现分片集群的支持,配置分片集群地址和读写分离

Redission

优化setnx,分布式工具集合

redis集群是ap,zookeeper是cp

  • setnx问题:不可重入 (同一个线程不能多次获取同一个锁)、不可重试 (获取锁只尝试一次就返回)、超时释放(业务时间过长锁自动释放)、主从一致性(主从同步延迟,主节点宕机,锁失效,解决方法创建连锁,无主节点, 每个节点都获取到锁才算成功)
  • 使用时要引入依赖,创建客户端bean,用客户端.getLock(指定名称)返回可重入锁lock,lock.tryLock(获取锁最大等待时间(期间重试),自动释放时间,单位)返回布尔
  • 原理:先判断锁是否存在,不存在就直接设置哈希key,键是线程id,值是重入次数,设置有效期和重入次数1,如果存在就有给键是该线程id的重入次数+1,再设置有效期,释放锁时,先判断锁是否是自己持有(线程id是自己的),不是就直接返回,是自己的锁就先-1重入次数,再判断次数大于0就重置有效期(leasttime为-1时才看门狗,默认30秒,自动续期时会使用定时任务在1/3后拿出并发map的entry取出线程id,刷新有效期,之后递归调用自己,不断新建定时刷新任务,等于0就删除,同时发布消息通知
    • tryLock里,会先tryAcquire尝试获取锁,返回ttl,为null表示成功,失败就重试(订阅释放锁信号,如果超时就取消订阅返回false,否则就while(true)尝试获取信号量)
    • unLock里,会取消更新任务,entry里拿线程id,取消任务,取消entry
  • 主从一致性 :主节点获取到锁但没同步到其他节点,此时主节点宕机,新节点还是可以继续获取锁。两个线程同时持有一把锁
    • RedLock红锁:不能只在一个redis实例上创建锁,应该在多个redis实例上创建锁,超过节点一半

延迟队列

sorted set和定时任务

  1. 创建有序集合,存储任务,score是任务执行时间戳/延迟时间
  2. 延迟任务添加到该集合,设置执行时间戳
  3. 创建独立线程从有序集合检索到期任务
  4. 定期ZRANGEBYSCORE获取当前时间之前的任务,ZREM从有序集合删除过期任务,如果任务失败就重新添加任务并设置执行时间戳

计算机网络

网络模型、输入URL

TCP/IP 四层网络模型

  1. 应用层:只专注为用户提供应用功能,应用层是工作在操作系统中的用户态,传输层及以下工作在内核态(消息/报文)
  2. 传输层:为应用层提供网络支持,TCP比UDP多了流量控制、超时重传、拥塞控制等,数据包超过MSS(TCP最大报文段长度)就将数据包分块,TCP中把每个分块叫一个TCP段(段)
  3. **网络层:**负责将数据从一个设备传输到另一个设备,IP协议可以寻址(告诉我们下一个目的地的方向)和路由(根据下一个目的地选择路径),如果IP报文大小超过MTU就会再次分片(包)
  4. **网络接口层:**为网络层提供链路级别传输的服务,工作在网卡这个层次,在IP头部加上MAC,封装成数据帧(帧)

五层是把网络接口层分开的

OSI 七层模型

应用层:为应用程序提供服务

表示层:数据格式转化,数据加密

会话层:建立、管理维护会话

输入URL会发生什么?(DNS)

  1. 浏览器解析URL,生成发送给服务器的请求信息

  2. 查询服务器域名对应的IP地址

    域名解析工作流程

    1. 浏览器本地缓存,操作系统本地缓存,hosts文件,都没有就问本地DNS服务器
    2. 本地DNS服务器会先查缓存,没有就问根域名服务器,DNS服务器都会先查自己有没有这个域名的缓存
    3. 根域名返回顶级域名服务器地址让本地DNS去问
    4. 本地问顶级域名服务器,继续返回更下面的DNS服务器地址
    5. 权威DNS服务器将ip返回本地DNS
    6. 本地DNS将ip地址返回给客户端,客户端和目标建立连接
  3. 找到ip后,就可以将HTTP的传输工作交给操作系统的协议栈

    ip还包括ICMP协议和ARP协议:

    1. ICMP:用于告知网络包传输过程中产生的错误和各种控制信息
    2. ARP:用于根据IP地址查询对应的MAC地址
    3. RARP:已知MAC地址求IP地址
  4. HTTP基于TCP传输

    TCP报文头部:源端口、目标端口、序号、确认号、状态位、窗口大小(流量控制,双方都声明窗口(缓存大小),标识自己处理能力)、校验和、紧急指针、选项、数据

    三次握手:保证双发都有发送和接收的能力

​ 如果HTTP消息超过MSS,TCP就需要把HTTP数据拆为多个段

​ TCP协议有两个端口(浏览器监听的端口(通常随机生成),服务器监听的端口(HTTP默认80,HTTPS默认443))

  1. IP将数据封装为网络包发送,需要源ip(存在多个网卡时,要根据路由表选择)和目标ip,协议号

  2. MAC用于两点之间的传输,需要源MAC(网卡获取)和目标MAC地址(ARP协议在以太网广播,也有缓存)

  3. 网卡将数字信息转换为电信号,在网线上传输,控制网卡需要网卡驱动程序,网卡驱动程序获取网络包后,将其复制到网卡的缓存区,然后在开头加上报头和起始帧分界符 ,末尾加上用于检错的帧校验序列(FCS)

  4. 交换机将网络包转发到目的地,工作在MAC层,通过包尾的FCS校验错误,没问题就放到缓冲区(之后查询MAC地址表看自己有没有目标MAC地址,找不到就转发给除了源端口之外的所有端口),交换机直接接收所有包放到缓冲区,网卡判断不是发给自己的就丢弃,交换机端口没有MAC地址

  5. 路由器(基于IP,路由器的各个端口有MAC地址和IP):和交换机(基于以太网,没MAC地址)类似,所以路由器遇到不匹配的包会直接丢弃,查路由表,根据路由表网关列判断对方ip地址,为空表示已经到终点了,否则继续转发,之后ARP查询MAC地址

HTTP

HTTP状态码、报文结构

HTTP 常见的状态码有哪些?
  • 1xx:提示信息

  • 2xx:服务器成功处理客户端请求

    • 200 OK:成功,响应头有body
    • 204 No Content:成功,响应头无body
    • 206 Partial Content:应用于 HTTP 分块下载或断点续传,表示响应返回的 body 数据并不是资源的全部,而是其中的一部分,也是服务器处理成功的状态。
  • 3xx:客户端请求的资源发生变动,需要重定向

    • 301 Moved Permanently:永久重定向,请求资源不在,要用新URL
    • 302 Found:临时重定向,请求资源还在,要用新URL

    301 和 302 都会在响应头里使用字段 Location,指明后续要跳转的 URL,浏览器会自动重定向新的 URL。

    • 304 Not Modified:不跳转,资源未修改,重定向已存在的缓冲文件,告诉客户端可以继续使用缓存,用于缓存控制
  • 4xx:客户端发送的报文有误,服务器无法处理

    • 400 Bad Request:客户端请求的报文有错误,屁话
    • 403 Fobidden:服务器禁止访问资源,不是客户端请求出错
    • 404 Not Found:请求的资源在服务器上不存在
  • 5xx:客户端请求报文正确,但服务器处理时内部发生错误

    • 500 Internal Server Error:和400类似,服务器发生错误,屁话
    • 501 Not Implemented:客户端请求的功能不支持
    • 502 Bad Gateway:服务器作为网关,访问后端服务器发生错误
    • 503 Service Unavailable:服务器很忙,无法响应客户端
HTTP 报文结构

请求头和请求体,

字段

  • Host:客户端发请求时指定服务器域名
  • Content Length:服务器返回的数据长度
  • Connection:客户端要求服务器使用HTTP长连接(只要任意一端没有明确提出断开连接,就保持TCP连接)(HTTP1.1默认,但为了兼容老版本的HTTP,需要指定Connedtion为Keep-Alive)
  • Content Type:服务器返回数据的格式
  • Content-Encoding:表示服务器返回的数据用了什么压缩格式,客户端也可以在请求时用Accept-Encoding说明接受的压缩格式

GET与POST

区别

GET请求参数写在URL里,只允许ASCII字符,URL长度也有限制

POST时根据body对指定的资源进行处理

GET 和 POST 方法都是安全和幂等的吗?

安全指请求方法不会破坏服务器上的资源

幂等指多次执行相同操作,结果相同

  • GET是安全幂等的:只读操作,而且有缓存
  • POST不安全不幂等:修改资源

GET可以带body

POST可以写URL

HTTP缓存

强制缓存

只要浏览器判断缓存没过期,就之间使用浏览器本地缓存,浏览器说了算

返回状态码后面跟了 from disk cache就是使用强制缓存

强缓存利用HTTP响应头部实现,都表示资源在客户端缓存的有效期

  • Cache-Control, 是一个相对时间;优先级高,选项多,精细

    实现流程:

    1. 浏览器第一次请求服务器资源,返回资源同时返回Cache-Control过期时间大小
    2. 浏览器再次请求该资源时,会先看Cache-Control,没过期就用缓存,否则再发请求
    3. 服务器再次收到请求后,会再次更新返回的Cache-Control
  • Expires,是一个绝对时间;

协商缓存

与服务器协商之后,通过协商结果判断是否使用本地缓存,如响应码304

两种头部方式实现:

  1. 请求头部中的 If-Modified-Since 字段与响应头部中的 Last-Modified 字段实现
    • 响应的Last-Modified标识资源的最后修改时间
    • 请求的If-Modified-Since:资源过期后,发现响应有Last-Modified,再次发请求的时候带上Last-Modified时间,服务器接收之后发现有If-Modified-Since就判断如果资源最后修改时间更新,就返回最新资源,否则返回304,说明资源不用改
  2. 请求头部中的 If-None-Match 字段与响应头部中的 ETag 字段
    • 响应头部中 Etag:唯一标识响应资源;
    • 请求头的If-None-Match:资源过期时,发现响应有Etag,就再次向服务器发请求,将请求头If-None-Match设置为Etag的值,服务器收到后判断资源没变化就返回304,变化就返回新资源

第一种基于时间,第二种基于一个唯一表达式,第二个更准确判断文件内容是否被修改,避免由于时间篡改导致的不可靠问题

Etag优先级比Last-Modified高,第二种比第一种高,Etag没变化再看第一种

因为Etag可以解决更多的问题

  1. 没修改文件内容但文件最后修改时间改变,客户端认为文件改变,重新请求
  2. If-Modified-Since检查的粒度是秒级的,用Etag就不怕
  3. 有些服务器不能精确获取文件最后修改时间

协商缓存都需要配合强制缓存的Cache-Control使用,只有没命中强制缓存时,才能发起协商缓存

HTTP1.1

断点续传

http1.1中header的Range和contentRange,使用范围请求,通过指定HTTP请求报文首部字段Range来请求尚未收到的资源

  • Range:请求头指定第1个和最后1一个字节位置
  • Content-Range:响应头响应覆盖的范围和整个实体长度

比如范围请求只请求5000-10000字节的资源,Range:5000-10000,范围请求响应状态码是206, 如果服务器无法响应范围请求,则会返回状态码200 OK和完整的实体内容。

优点
  1. 简单:header+body,头部key-value
  2. 灵活和易于扩展:各类请求方法、URL、状态码、头字段可以自定义和补充,工作在应用层,下层可以随意变化,如
    1. HTTPS就是在HTTP和TCP之间增加SSL/TLS安全传输层
    2. HTTP/1.1和HTTP/2.0使用TCP协议,HTTP/3.0使用UDP协议
  3. 应用广泛和跨平台
缺点

无状态和明文传输都是双刃剑

  1. 无状态

    • 好处:不需要额外资源记录状态
    • 坏处:每次都要问一下身份
      • Cookie解决:通过在请求和响应报文写入Cookie信息控制客户端的状态
  2. 明文传输

  3. 不安全:用HTTPS解决

    1. 通信使用明文
    2. 不验证通信方身份
    3. 无法证明报文完整性
长连接、管道传输

HTTP基于TCP/IP,使用请求-应答通信模式

  1. 长连接:为了解决HTTP/1.0短连接问题,只要任意一端没有明确提出断开连接,就一致保持TCP连接状态,或者超过一定时间没有数据交互,服务器就主动断开这个连接
  2. 管道网络传输 (不是默认开启,浏览器基本不支持 ):在同一个TCP连接,客户端可以发起多个请求,不用等请求响应回来,可以立即发请求,但服务器必须按请求的顺序发送回响应
    • 如果服务器在处理前面的请求耗时较长,那么后续的请求就会被阻塞,称为 队头阻塞 ,所以HTTP/1.1管道解决了请求的队头阻塞 ,没有解决响应的队头阻塞
  3. 队头阻塞:响应队头阻塞
优化方案
  • 尽量避免发送 HTTP 请求

    • 缓存
  • 减少HTTP请求次数

    • 减少重定向次数:重定向由代理服务器完成
    • 合并请求:减少了重复发送的HTTP头部,如小图片合成大图片图片二进制用base64编码 ,以URL形式嵌入HTML,和HTML一块发送,客户端收到HTML后,解码出数据就是图片,但当大资源的一个小资源发生变化后,客户端必须重新下载完整的大资源
    • 延迟发送请求:请求网页时只获取用户当前看到的资源
  • 减少服务器的 HTTP 响应的数据大小

    • 压缩响应资源

HTTPS

和HTTP区别
  • HTTP是明文传输吗,HTTPS加密传输
  • HTTP 在 TCP 三次握手就建立连接,HTTPS 在 TCP 三次握手之后还要进行 SSL/TLS 的握手过程
  • HTTP默认80端口,HTTPS默认443端口
  • HTTPS 需要向CA申请证书,确保服务器身份可信
HTTPS 解决的问题,为什么安全

解决方法:

  • 信息加密:使用混合加密,防止信息被窃取
  • 校验机制:摘要算法,为数据生成唯一指纹,用于校验数据完整性
  • 身份证书:将服务器公钥放入数字证书中,解决冒充

具体:

  1. 混合加密 :HTTPS 采用对称加密 (过程全部使用对称加密的会话密钥加密数据)和非对称加密(通信建立前用非对称加密交换会话密钥,之后不用非对称加密)

    使用原因:

    • 对称加密只使用一个密钥,无法做到安全的密钥交换,速度快
    • 非对称加密使用两个密钥:公钥和私钥,公钥可以任意分发但私钥保密,解决密钥交换问题但速度慢
  2. 摘要算法+数字签名 :为了保证信息不被修改,对内容计算出一个指纹(摘要算法哈希函数),和内容一块发给对方,对方收到后根据内容也计算一个指纹,用于判断内容是否被更改。现在缺少客户端对收到消息是服务端的证明,所以使用非对称加密解决,两个密钥双向加解密

    • 公钥加密,私钥解密:保证内容传输安全,私钥角度 ,只有有私钥的人才能解密,一般不会这样,因为耗费性能

    • 私钥加密,公钥解密:保证内容不会被冒充,私钥角度 ,只有有私钥的人才发送公钥能解密的内容,是非对称加密的主要用途,来确认消息身份 ,如数字签名算法,私钥加密是对内容的哈希值加密 (加密后就是数字签名

      私钥是服务端保管,服务端向客户端颁发对应公钥,如果客户端收到的信息能被公钥解密,就说明该消息是服务器发送

  3. 数字证书

    此时缺少身份验证 ,因为公钥可能被伪造,所以可以将服务器的公钥注册到CA(服务器公钥+ca数字签名(ca用自己的私钥对其他信息的hash的加密)保存在ca机构,客户端拿到数字证书后,因为浏览器或操作系统内置ca的公钥,就去ca机构验证数字证书是否合法。

数组,不用额外数组空间,

干啥来着

建立连接过程

SSL/TLS基本流程:

  • 客户端向服务器要公钥
  • 双方协商生产会话密钥
  • 双方采用会话密钥进行加密通信

前两部是建立过程,也是TLS握手过程,握手阶段涉及四次通信,用不同密钥交换算法,握手流程也不一样,常用密钥交换算法有RSA和ECDHE

基于RSA的握手过程:

  1. 客户端请求

    客户端发起加密请求,发送TLS版本、客户端随机数、客户端支持的密码套件列表(如RSA加密算法)

  2. 服务端请求

    服务器收到请求,发出响应,发送确认TLS版本、服务端随机数、确认的密码套件、服务器数字证书

  3. 客户端回应

    客户端通过浏览器或操作系统的CA公钥,确认数字证书,没问题就取出服务器的公钥 ,用它加密报文,再向服务器发送一个被服务器公钥加密的随机数 、改变加密算法的通知(表示之后就使用会话密钥通信)、客户端握手结束通知(同时把之前所有内容的数据做个摘要,供服务端校验),服务器和客户端有了这三个随机数(Client Random、Server Random、pre-master key),接着就用双方协商的加密算法,各自生成本次通信的「会话秘钥」

  4. 服务器回应

    收到第三个随机数后,通过协商的加密算法,计算本次通信的会话密钥,然后向客户端发送加密算法改变的通知、服务器握手结束通知(同时把之前所有内容数据做个摘要,供客户端校验)

接下来,客户端和服务器进入加密通信,用会话密钥加密内容

但RSA存在HTTPS前向安全问题:服务端私钥泄露之后,TLS密文都会被破解,所以一般使用ECDHE密钥协商算法

证书校验流程

CA签发证书时

  1. CA把持有者的公钥、用途、颁发者、有效时间等信息打成一个包,然后对这些信息进行Hash运算,算出一个hash值
  2. CA用自己的私钥将Hash加密,生成数字签名
  3. 最后将数字签名添加在文件证书上,形成数字证书

客户端校验服务端证书时

  1. 客户端算出内容信息的Hash值H1
  2. 浏览器或操作系统内置CA公钥,收到证书后,可以使用CA公钥解密数字签名,得到Hash值H2
  3. 最后比较H1和H2,相同就表示可信赖的证书

但可能证书有层级,验证方式大概是由于用户信任最上层的证书,所以上层证书担保的下层证书可以被信任,一层一层向下,最后因为用户信任操作系统或浏览器,所以都信任,为了确保证书的绝对安全性

保证完整性

TLS在实现上分为握手协议记录协议

  • TLS握手协议就是TLS四次握手,负责协商加密算法和生成对称密钥,后续用该密钥加密
  • 记录协议负责保护应用程序数据并验证完整性和来源

记录协议主要负责HTTP数据的压缩、加密和数据的人则会那个,过程:

  1. 消息被分为多个较短片段,分别对每个片段压缩
  2. 被压缩的片段会加上消息认证码(MAC值,哈希算法生成),保证完整性,并进行数据认证,可以识别出篡改,为了防止重放攻击,在计算消息认证码时,加上了片段的编码
  3. 经过压缩的片段加上消息认证码会一起通过对称加密
  4. 经过加密的数据再加上数据类型、版本号、压缩后的长度组成的报头,就是最终的报文数据

记录协议完成后,最终报文就到TCP层传输

HTTPS 一定安全可靠吗?

一个场景:客户端向服务端发起HTTPS请求,被假基站转发到一个中间人服务器,于是客户端和中间人服务器完成TLS握手,中间人服务器和真正的服务端完成TLS握手

具体过程是中间人和服务端和客户端都维持了对称加密密钥,但前提在中间人和客户端交互时,中间人要冒充服务端,就要发送自己的公钥证书,但这个伪造证书会被客户端识别出来是非法的(不是受信任的CA颁发的J),但用户还是可以坚持用,选择信任了中间人

中间人作为客户端和服务端建立连接不会有问题,因为服务端不会校验客户端身份

中间人作为服务端和客户端建立连接,会有客户端信任服务端的问题,服务端必须持有对应域名的私钥,中间人要拿到私钥只能1. 去服务端拿私钥、2.去CA拿域名签发私钥、3.自己签发证书,同时要被浏览器信任

所以,HTTPS 协议本身到目前为止还是没有任何漏洞的,即使你成功进行中间人攻击,本质上是利用了客户端的漏洞(用户点击继续访问或者被恶意导入伪造的根证书),并不是 HTTPS 不够安全

可以使用HTTPS双向认证

优化方案

TLS握手目的是为了通过非对称加密握手协商或者交换出对称加密密钥,最长花费2RTT,后续传输的数据都使用对称加密密钥加密解密

性能损耗在TLS握手过程和握手后的对称加密报文传输(现在主流加密算法性能不错)

  • 硬件优化:HTTPS 协议是计算密集型,而不是 I/O 密集型,所以不能把钱花在网卡、硬盘等地方,应该花在 CPU 上。
  • 软件优化:升级linux内核、升级协议
  • 协议优化:密钥交换算法优化,ECDHE可以比RSA快一个RTT,第三次握手后,第四次握手前,发送加密数据
  • TLS升级:
  • 证书优化
    • 传输:用ECDSA证书而不是RSA证书,因为短
    • 验证:
  • 会话复用:复用密钥
    • Session ID:客户端和服务器首次建立TLS连接后,双方在内存缓存会话密钥(value),用Session ID标识(key)
      • 缺点是服务器必须保存每一个客户端的会话密钥,服务器内存压力大,因为负载均衡,所以再次连接不一定会命中上次的服务器
    • Session Ticket:服务器不缓存每个客户端的密钥,把缓存交给了客户端
      • 客户端和服务器首次建立连接时,服务器会加密会话密钥为Ticket发给客户端,交给客户端缓存Ticket,客户端再次连接服务器时,客户端会发送Ticket,服务器解密后就可以获取上一次对话密钥,然后验证有效期,没问题就恢复对话
    • Pre-shared Key

HTTP1.1、HTTP2.0、HTTP3.0

HTTP/1.1 相比 HTTP/1.0 提高了什么性能?

改进:

  • 长连接改善短连接的性能开销
  • 支持管道网络传输,解决发送队头阻塞,减少整体响应时间(但没使用

性能瓶颈:

  • Header未压缩,只能压缩body
  • 每次发送相同冗长首部
  • 响应队头阻塞(服务器响应慢)
  • 没有请求优先控制
  • 请求只能客户端开始,服务器被动响应
HTTP2.0 做了什么优化?

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请求都必须等待这个丢了的包被重传回来

HTTP/3 做了哪些优化?

1、2都有队头阻塞

  • 1.1的管道(默认不开启,浏览器基本不支持)解决请求队头阻塞,没解决响应队头阻塞
  • 2.0虽然多个请求复用一个TCP解决队头阻塞,但如果发生丢包,就会阻塞所有HTTP请求,属于TCP层队头阻塞

HTTP/3把HTTP下层的TCP协议换成UDP

UDP不管顺序、不管丢包,所以不会出现2的队头阻塞,虽然UDP是不可靠传输,但是基于UDP的QUIC协议可以实现类似TCP的可靠传输

QUIC特点:

  1. 无队头阻塞:也可以在同一条连接上并发Stream,Stream可以认为是一条HTTP请求

    QUIC保证传输可靠性:某个Stream发生丢包时,只会阻塞这个Stream,其他Stream不受影响,因此不存在队头阻塞,2.0如果某个Stream的包丢失,其他Stream也会受影响

    QUIC连接上的多个Stream没有依赖,都是独立的,某个Stream发生丢包时,只会影响该Stream

  2. 更快的连接建立 :对于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 的效果。

  3. 连接迁移:基于TCP的HTTP协议,是通过四元组确定一条TCP连接,但当网络从4g切换到wifi时,意味ip变化,就必须重新建立连接,同时建立连接很慢,成本高

    QUIC协议没有用四元组绑定连接,通过连接ID标记通信的两个端点,客户端和服务端可以选择一组ID标识自己,即使IP变化,只要保有上下文信息(如连接ID、TLS密钥),就可以复用连接,清除重连成本,连接迁移

所以, QUIC(基于UDP) 是一个在 UDP 之上的 TCP + TLS + HTTP/2 的多路复用的协议。很多网络设备无法识别QUIC包,就会当作UDP的包直接抛弃

既然有 HTTP 协议,为什么还要有 RPC?

TCP特点:面向连接、可靠、基于字节流

RPC是远程过程调用,是一种调用方式,大部分基于TCP,工作在应用层,如gRPC、thrift

比如HTTP调用本地方法,RPC直接调用远程服务器暴露的方法

电脑上的各种联网软件,作为客户端和服务端建立收发信息,可以使用自家的RPC协议C/S,但对于浏览器,不仅要能访问自家公司的服务器,还要访问其他公司的服务器,因此需要统一标准,HTTP用于统一B/S协议

HTTP 和 RPC 有什么区别?

  • 服务发现:建立连接需要ip和端口,找到服务对应的ip和端口的过程,就是服务发现

    • HTTP中,知道服务的域名,就可以通过DNS解析得到ip地址,默认80端口
    • RPC,一般有专门中间服务保存服务名和ip,如Consul或Etcd,甚至Redis,想要访问某个服务,就去这些中间服务获取ip和端口,dns也是服务发现的一种,也有基于DNS做服务发现的组件
  • 底层连接形式: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定时轮询:在前端代码定时发HTTP请求,伪服务器推,常见扫码登录,前端不知道有没有扫,不断去问后端有没有扫
  • 长轮询:将超时时间设置大一代女,时间之内只要服务器收到扫码请求, 就返回给客户端,超时就发下一次请求,这样就减少了HTTP请求的个数

扫码登录的简单场景可以用,但网络游戏一般有大量数据从服务器主动推送到客户端

使用WebSocket

全双工:同一时间内,双方都可以主动向对方发送数据

HTTP/1.1,同一时间里,客户端和服务器只能有一方主动发送数据,半双工,因为没必要全双工,不搞游戏

为了支持游戏,需要一个新的应用层协议基于TCP的WebSocket

建立WebSocket连接

本来用HTTP,打开页游后需要切换WebSocket

浏览器会在TCP三次握手建立连接之后,统一使用HTTP协议先进行一次通信

  • 如果此时是普通HTTP,就普通HTTP
  • 如果想建立WebSocket连接,就在HTTP请求带上特殊Header头,表明想升级协议,同时带上随机生成的base64编码发给服务器。如果服务器正好支持升级,就走WebSocket握手流程,根据base64编码用算法变成另一个字符串,发给浏览器,带上101状态码(协议转换),浏览器也用同样算法将base64转为字符串,如果字符串相同,就通过

数据包在WebSocket中叫帧

WebSocket的数据格式也是数据头(内含payload长度) + payload data 的形式。因为TCP协议本身是全双工,直接用纯裸TCP会有粘包问题,因为不知道边界,所以一般在消息头包含消息体长度

WebSocket完美继承TCP全双工,适用于服务器和客户端频繁交互的场景,游戏/聊天室/飞书网页协同办公

TCP

TCP介绍

TCP是面向连接的、可靠的、基于字节流的传输层通信协议

  • 面向连接:一对一连接,UDP可以一个主机同时向多个主机发送消息(一对多)

    • 用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括Socket、序列号和窗口大小称为连接

      建立一个TCP连接需要客户端和服务端达成三个信息的共识:

      • Socket:ip和端口号组成
      • 序列号:解决乱序
      • 窗口大小:流量控制
    • 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报文自动丢弃

TCP头格式

源端口目标端口序列号 (建立连接时计算机生成随机数为初始值,每发送一次,就累加一次大小, 解决包乱序的问题确认应答号 (指下一次期望收到的数据序列号,发送端收到这个确认应答之后,可以认为在这个序号之前的数据都已经被正常接收,解决丢包问题,控制位:

  • ACK:1标识确认应答字段有效,TCP规定除了最初建立的连接时的SYN包之外都必须是 1
  • RST:1标识TCP连接出现异常必须强制断开连接
  • SYN:1标识希望建立连接,并在序列号字段设定初始随机值
  • FIN:1标识不会有数据发送,希望断开连接。通信双方主机之间就可以相互交换FIN为1的TCP段

TCP 保活

此时,如果服务端一直不给客户端发送数据,就永远不知道客户端故障,此时服务端一直ESTABLISH,占用系统资源

所以TCP增加保活机制 :原理:

定义一个时间段,在这个时间段如果没有任何连接的活动,TCP保活就开始启用,每隔一个时间间隔,发送一个探测报文,数据很少,如果连续几个探测报文都没得到响应,则认为当前连接已经死亡,系统内核将错误信息通知上层,默认保活2小时(2小时内没连接活动就启用保活),检测间隔75秒,9次无响应就认为是不可达

如果保活发现对方正常响应,就重置保活时间,如果客户端宕机,TCP的保活发送到客户端,会产生一个RST,如果服务端宕机(不是进程崩溃,进程崩溃后操作系统在回收进程资源时,会发送FIN,但宕机就无法感知,所以需要保活探测对方是不是主机宕机),此时多次无响应会报告死亡

因为TCP保活检测时间长,可以在应用层实现一个心跳机制

MSS 和 MTU

  • MTU:网络包最大长度:1500字节
  • MSS:除去IP和TCP头部,一个网络包所能容纳的TCP数据的最大长度
    • 建立连接的时候会协商这个大小,协商交互双方能够接收的最大段长MSS值

如果只使用IP分片,当一个IP分片丢失时,整个IP报文的所有分片都要重传,但IP没有超时重传 ,由TCP负责超时重传,会重发整个TCP报文(头部+数据),也就是该IP分片对应的TCP报文,所以在IP层分片传输没有效率,TCP在建立连接时通常要协商双方的MSS值,TCP层发现数据超过MSS时,就会先分片,此时由它形成的IP包长度不会超过MTU,也就不用IP分片了

TCP分片之后,进行重发也是MSS为单位,而不用重传所有分片

TCP 保证可靠性

  • 连接管理:三次握手建立可靠连接
  • 校验和:发送数据的二进制求和取反,接收方以相同方式计算校验和比对,不同就丢弃
  • 序列号:防止数据丢失,避免数据重复,保证有序性
  • 确认应答:接受方收到报文返回ACK,携带确认序列号,告知发送方接收数据的情况,指定时间没收到确认应答就启动超时重传
  • 超时重传:数据包和确认包丢失都会超时重传,接收端收到重复数据会丢弃并回传ACK
  • 流量控制:根据接收方处理能力决定发送端发送速度
  • 拥塞控制:发送端维护一个拥塞窗口

UDP 和 TCP

区别和使用场景

UDP利用ip提供无连接的通信服务

  • 端口:告诉把报文发给哪个进程
  • 包长度:保存UDP总长度
  • 校验和:为了提供可靠UDP首部和数据,防止收到受损的UDP包

UDP和TCP区别:

  1. 连接:
    1. TCP面向连接,传输数据前先建立连接
    2. UDP不需要连接,直接传输数据,
  2. 服务对象:
    1. TCP是一对一
    2. UDP支持一对一、一对多、多对多
  3. 可靠性:
    1. TCP可靠交付数据,无差错、不丢失、不重复、按序到达
    2. UDP不保证可靠交付数据,但可以基于UDP实现可靠传输协议,如QUIC协议
  4. 拥塞控制、流量控制
    1. TCP有拥塞控制和流量控制,保证数据传输安全性
    2. UDP没有,即使网络拥堵,也不影响发送速率
  5. 首部开销:
    1. TCP首部较长而且可变
    2. UDP首部短而且不变
  6. 传输方式:
    1. TCP是流式传输,没边界,保证顺序和可靠
    2. UDP是一个一个包发送的,有边界,但可能丢包和乱序
  7. 分片不同:
    1. TCP如果大于MSS大小,就会在传输层分片,目标主机也会在传输层组装,如果丢失一个分片,只需要传输这个分片
    2. UDP如果大于MTU,就会在IP层分片,目标主机会在IP层组装数据

TCP 和 UDP 应用场景:

TCP:FTP文件传输、HTTP/HTTPS

UDP:包总量少的通信(如DNS、SNMP),视频音频、广播通信

UDP没有首部长度字段是因为UDP首部长度不会变化,不用记录

TCP没有包长度字段:

TCP数据长度=IP总长度 - IP首部长度 - TCP首部长度

UDP长度也可以这样算,但还是存在包长度字段,可能因为为了网络设备硬件设计方便,保证首部长度是4字节的整数倍,或者因为之前UDP不是基于IP协议

TCP 和 UDP 可以使用同一个端口吗?

可以

传输层端口的作用是为了区分同一个主机上不同应用程序的数据包

所以并不冲突

三次握手

三次握手过程
  1. 开始时,客户端和服务端都处于CLOSE 状态,然后服务端主动监听端口,处于LISTEN
  2. 客户端会随机初始化序号(client_isn),作为发送序号,同时SYN标志为1,接着把第一个SYN发给服务端,表示向服务端发起连接,不包含数据,之后客户端处于 SYN_SENT
  3. 服务端收到SYN后,初始化自己的序号(server_isn),作为发送序号,再将确认应答号填入client_isn + 1,把SYN和ACK标为1,把报文发给客户端,报文不包含应用层数据,之后服务端处于 SYN_RCVD
  4. 客户端收到报文后,向服务端发送最后一个应答报文,ACK1,确认应答号为server_isn + 1,这次报文可以携带客户到服务端的数据,之后客户端处于ESTABLISHED
  5. 服务端收到ACK后,也进入ESTABLISHED状态

第三次握手可以携带数据,前两次握手不能携带数据,三次握手后,都处于ESTABLISHED

为什么是3次握手?不是2次、4次

因为三次握手可以保证双方都具有接收和发送的能力,主要原因是三次握手可以防止历史连接的建立,还有是为了同步双方的序列号和避免资源浪费 :防止历史连接建立是因为如果客户端先发syn后直接宕机,此时syn被阻塞,重启客户端之后再次发syn,此时服务端先收到了之前阻塞的syn,两个syn的序列号不同,然后服务端返回对旧syn的确认,客户端收到之后发现这个确认号不是自己期望收到的,就会返回RST报文,服务端收到RST就断开连接,新syn到了之后就又会建立连接,如果是两次握手的话,服务端第一次收到旧syn就可以给客户端发送连接了,虽然客户端会发送RST,但服务端已经把数据发出去了,服务端肯定会建立这个历史连接然后浪费资源发送数据。同步双方的序列号是因为双方都是一来一回,都要得到应答回应,所以是三次握手;避免资源浪费就是避免2次连接服务端在旧连接基础上直接发送数据,浪费资源

四次连接是因为没有必要,相当于服务端返回syn和ack分两次

两次握手也可以根据上下文信息丢弃 syn 历史报文,我记着两次握手没有具体实现,应该可以这样

初始序列号作用
  • 防止历史报文被下一个相同四元组连接接收 (主要):如果每次初始化序列号一样,可能会接收混乱,例如:
    客户端和服务端先建立的TCP连接,客户端发送数据包被阻塞,然后超时重传,此时服务端重启,连接消失,所以在收到数据包时会RST,之后又建立相同四元组连接,被阻塞的数据包正好到达服务端,刚好序列号在接收窗口内,所以该数据包会被服务端正常接收,就混乱了
  • 安全性,防止黑客伪造相同序列号的TCP报文被对方接收

初始序列号 ISN 是时钟+对四元组的Hash算法

第一次握手丢失会发生什么?

客户端建立连接时先发SYN,然后进入SYN_SENT 状态,之后如果收不到服务端的SYN-ACK,就会触发超时重传,重传SYN(序列号也一样),Linux超时重传默认5次,每次超时时间是上一次二倍,总耗时1分钟,次数到最大次数之后,如果在最后超时时间还是没有SYN-ACK,就会在超时时间之后断开连接

第二次握手丢失会发生什么?

服务端收到客户端第一次握手后,会返回SYN-ACK给客户端,此时服务端进入SYN_RCVD状态

第二次握手的SYN-ACK:

  • ACK是对第一次握手的确认
  • SYN是服务端发起建立TCP连接

如果第二次握手丢失,客户端可能认为自己的SYN丢了,就触发超时重传,因为第二次握手包含服务端的SYN,所以当客户端收到后,需要给服务端发送ACK,服务端才认为SYN被客户端收到了,但第二次丢失后,对于服务端,会触发超时重传,重传SYN-ACK,和客户端策略一致

所以当第二次握手丢失,客户端和服务端都会重传

第三次握手丢失会发生什么?

客户端收到服务端的SYN-ACK后,会给服务端回一个ACK,此时客户端状态进入ESTABLISH

因为是对第二次SYN的ACK,所以当第三次握手丢失,服务端一直收不到ACK,就会触发服务端的超时重传

ACK不会有重传,ACK丢失,需要对方重传对应报文

SYN 攻击、如何避免

攻击者伪造不同IP 的SYN报文,服务端每收到一个SYN报文,就进入SYN_RCVD 状态,但服务端发出去的ACK+SYN无法得到ACK应答,时间长就会沾满服务端的半连接队列

TCP半连接和全连接

  • 半连接队列:SYN队列
  • 全连接队列:accept队列

服务端接收到客户端的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重传次数,让更快断开连接

SYN 报文丢弃情况、PAWS
  • 开启tcp_tw_recycle参数,并且在NAT环境下,造成SYN报文被丢弃

    • 同时开启recycle和timestamps选项,就开一种叫per-hostPAWS机制:作用是防止TCP包中的序列号发生绕回

    • PAWS,所有TCP包发送都会带上发送时的时间戳,PAWS要求双方都维护最近一次收到数据包的时间戳,每收到一个数据包就会和时间戳比较,如果发现收到的数据包中时间戳不是递增的,则表示该数据包是过期的,就会直接丢弃这个数据包

      **per-host:**对【对端IP做PAWS】检查,而不是对四元组做PAWS检查

  • TCP两个队列满了(半连接队列和全连接队列),造成SYN报文被丢弃

    • **半连接队列满了:**服务器syn攻击可能导致半连接队列满了,这时后面的syn包会被丢弃,但如果开启了syncookies功能,即使半连接队列满了,也不会丢弃syn包(服务端根据当前状态计算出一个值,放在syn+ack中,客户端返回ack时,取出该值验证,合法就认为建立成功,可以增大半连接队列(也要增大全连接队列,否则无效)、开启syncookies、减少syn+ack重传次数
    • **全连接队列满了:**如果accept队列过小或者应用程序调用accept不及时,就会造成accept队列满了,后续请求会被抛弃,可以增大全连接队列长度和检查为什么调用accept不及时
没 accept 建立连接

可以

accept不参与三次握手,只负责从TCP全连接队列取出一个已经建立连接的socket,用户层通过accept系统调用拿到了已经建立连接的socket,就可以对该socket进行读写操作

没 listen 建立连接

可以

客户端可以自己连接自己,也可以两个客户端同时向对方发出请求建立连接(TCP同时打开),这两个情况都没有服务端参与,没有listen,就TCP连接

已建立连接的TCP收到 SYN

已经建立TCP连接,客户端中途宕机,服务端一直处于Established状态,客户端恢复后,向服务端建立连接

TCP连接是由四元组唯一确定的

  1. **客户端的SYN报文里的端口号与历史连接不相同:**此时服务端会认为是一个新的连接,就三次握手建立新连接,此时旧连接处于Estaablished状态的服务端如果给客户端发送数据,因为客户端连接已经关闭,此时客户端内核返回RST报文,服务端收到后会释放连接。如果服务端一直没有发送数据包给客户端,在超过一段时间后,TCp保活机制就会启动,检测客户端没有存活后,服务端就会释放连接
  2. **客户端的SYN报文里的端口号与历史连接相同:**也就是处于Established状态的服务端收到了这个SYN报文(此时的SYn报文乱序,因为初始化序列号是一个随机数),服务端会回复一个携带了正确序列号和确认号(确认号就不是SYN的序列号+!,是宕机之前服务端发送的ACK)的ACK(Challenge ACK),客户端收到这个ACK之后,发现确认号不是期望收到的(期望收到的是确认号是自己随机SYN的序列号+1),就返回RST报文,服务端收到后会释放连接

要伪造一个能关闭 TCP 连接的 RST 报文,必须同时满足「四元组相同」和「序列号是对方期望的」这两个条件。

伪造使用工具

四次挥手、TIMEOUT

四次挥手
四次挥手过程

双方都可以主动断开连接

  • 开始都处于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,仅仅标识客户端不再发送数据,但是还能接收数据

    • 客户端shutdown()关闭写方向(如果关闭读方向,那么收到数据包就会直接回复RST)
  • 服务端收到客户端的FIN,先回一个ACK,而服务端可能还有数据处理和发送,等服务端不在发送数据,才发送FIN给客户端表示同意现在关闭连接

服务端需要等待完成数据的发送和处理,服务端的ACK一般是分开,所以是四次挥手,但在特定情况下可以三次挥手

四次挥手,可以变成三次吗?

是否发送第三次挥手控制权不在内核,在被动关闭方的应用程序,因为应用程序可能还要发送数据,所以由应用程序决定什么时候调用关闭连接函数,调用后内核会发送FIN报文,但FIN报文不一定必须是调用关闭连接的函数才发送,因为可能进程退出了,内核都会发送FIN

关闭

  • close:关闭发送和读取
  • shutdown:关闭一个方向

如果客户端是用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后客户端就会断开连接

TIMEWAIT
TIME_WAIT 等待 2MSL

MSL是报文最大生存时间

  • 确保当前连接的报文不会出现在下一次连接中
    • 报文从发送到被接收最多1MSL
    • 最坏情况,ack在1个MSL之后到达服务端,在到达前一瞬间,服务端重发FIN,此时该FIN需要1MSL才失效,所以需要2个MSL才能让双方滞留报文失效,让重传FIN失效
    • 如果ACK在第一个MSL内丢失,服务端的FIN会在第2个MSL到达
  • 尽量让服务端收到最后的ACK,允许丢失一次ACK
    • 如果ACK丢失,服务端重传FIN让客户端重传最后的ACK,所以客户端在发送ACK后会尝试等待一段时间接收可能发过来的重传FIN
    • RTO超时重传时间动态计算,因为报文从发出到接收最后1MSL(超过会被抛弃),所以RTO最多2MSL,假如RTO是2MSL,则TIMEWAIT也必须最少2MSL(发ACK等如果1MSL丢失,此时对于FIN会重传(FIN发送到1MSL,此时过了2MSL),第2MSL末尾会到达客户端)

如果重传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

TIME_WAIT 优化
  • 打开 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

TIME_WAIT 收到 SYN

这个要看SYN的序列号和时间戳是否合法,因为处于TIME_WAIT的连接收到SYN后,会判断SYN的序列号和时间戳是否合法

都开启TCP时间戳就会再判断时间戳是否合法,否则就只是判断序列号

  • 合法SYN:客户端SYN的序列号比服务端期望的下一个序列号大,并且SYN的时间戳比服务端最后收到报文的时间戳大

    TIME_WAIT连接收到合法SYN后,就会重用四元组连接,跳过2MSL转变为SYN_RECV状态,接着建立连接

  • 非法SYN:客户端SYN的序列号比服务端期望的下一个序列号小,或者SYN的时间戳比服务端最后收到报文的时间戳小

TIME_WAIT 收到RST

会不会断开,关键看 net.ipv4.tcp_rfc1337 这个内核参数(默认情况是为 0):

  • 如果这个参数设置为 0, 收到 RST 报文会提前结束 TIME_WAIT 状态,释放连接。
  • 如果这个参数设置为 1, 就会丢掉 RST 报文。
收到乱序的 FIN 包
  1. 客户端shutdown()关闭写方向(如果关闭读方向,那么收到数据包就会直接回复RST)发送FIN,客户端变为FIN_WAIT_1
  2. 服务端发送ACK,服务端变为CLOSE_WAIT
  3. 服务端发送数据包给客户端,但被延迟
  4. 客户端收到ACK后,客户端变为FIN_WAIT_2
  5. 服务端发送FIN,比延迟的数据包先到,但FIN其实是一个乱序的报文,因为FIN包和服务端之前的ACK之间有数据包,服务端进入LAST_ACK
  6. 客户端收到FIN报文,但发现乱序,就不会转换为TIME_WAIT状态,此时在FIN_WAIT_2状态,收到了乱序的FIN报文,就会被加入**乱序队列,**并不会进入TIME_WAIT状态,等到之前被网络延迟的数据包到达后,会判断乱序队列有没有数据,然后会检测乱序队列是否有可用的数据,如果能找到与当前报文序列号保持的顺序的报文,就看该报文有没有FIN标志,如果有FIN标志,就进入TIME_WAIT状态

重传机制

TCP实现可靠传输方式之一,通过序列号和确认应答号

针对TCP数据包丢失,用重传机制解决

  • 超时重传:发送数据时设定定时器,超过指定时间后没收到对方的ACK确认应答报文,就重发该数据

    TCP会在数据包丢失确认应答丢失时超时重传

    RTT是包的往返时间

    RTO表示超时重传时间,应该略大于报文往返RTT的值,因为报文往返RTT动态变化,所以RTO也应该动态变化

    • RTO较大时,重发慢,效率低
    • 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字段是窗口大小,是接收方告诉发送端自己还有多少缓冲区可以接收数据,于是发送端就根据这个接收端的处理能力发送数据,不会导致接收端处理不过来,所以,窗口大小是由接收方的窗口大小决定的

发送方发送的数据大小不能超过接收方的窗口大小,否则接收方就无法正常接收到数据

发送方滑动窗口

  • 已发送并收到ACK确认的数据
  • 已发送但未收到ACK确认的数据(发送窗口,也包含可用窗口)
  • 未发送但在接收方处理范围内的数据(可用窗口
  • 未发送但超过接收方处理范围的数据

数据发完之后,可用窗口就为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说明网络没问题

    在快速恢复之前,拥塞窗口变为一半,慢启动门限变为拥塞窗口

    步骤:

    • 拥塞窗口+3
    • 重传丢失数据包
    • 如果再收到重复ACK,拥塞窗口+1
    • 如果收到新数据的ACK,就将拥塞窗口设置为上面的慢启动门限,因为ack了新数据,说明重复的三个ack时的数据都收到了,恢复过程结束,可以恢复到之前的状态(即再次进入拥塞避免状态)

半连接队列和全连接队列

什么是 TCP 半连接队列和全连接队列?

在 TCP 三次握手的时候,Linux 内核会维护两个队列,分别是:

  • 半连接队列,也称 SYN 队列;哈希表:存放不完整连接,为了o(1)的取出复杂度
  • 全连接队列,也称 accept 队列;链表:只要是个连接就行

服务端收到sync之后,内核会将连接存储在半连接队列,返回客户端syn+ack,客户端返回ack,服务端收到ack之后,内核会将连接从半连接队列删除,创建新的连接加入accept队列,等待进程调用accept函数时取出连接、

全连接和半连接队列都有最大长度限制,超过后,内核会直接丢弃或返回RST包

**全连接溢出:**服务端处理大量并发请求时,如果全连接队列过小就容易溢出,后续请求会被丢弃(默认行为,也可以发送RST)

半连接溢出:对服务端一直发syn包,但是不进行第三次握手ack,就可以使服务端有大量处于SYN_RECV的TCP连接, SYN 洪泛、SYN 攻击、DDos 攻击。

策略:

  1. 如果半连接队列满了,并且没有开启 tcp_syncookies,则会丢弃;
  2. 若全连接队列满了,且没有重传 SYN+ACK 包的连接请求多于 1 个,则会丢弃;
  3. 如果没有开启 tcp_syncookies,并且 max_syn_backlog 减去 当前半连接队列长度小于 (max_syn_backlog >> 2),则会丢弃;

开启 tcp_syncookies 是缓解 SYN 攻击其中一个手段。开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接

syncookies的做法:服务端根据当前状态计算一个值,放在恢复的syn+ack里,客户端收到ack后,取出值验证,合法就认为建立连接成功

syncookies 参数主要有以下三个值:

  • 0 值,表示关闭该功能;
  • 1 值,表示仅当 SYN 半连接队列放不下时,再启用它;
  • 2 值,表示无条件开启功能;

那么在应对 SYN 攻击时,只需要设置为 1 即可:

如何防御SYN攻击?
  • 增大半连接队列;要想增大半连接队列,我们得知不能只单纯增大 tcp_max_syn_backlog 的值,还需一同增大 somaxconn 和 backlog,也就是增大全连接队列
  • 开启 tcp_syncookies 功能
  • 减少 SYN+ACK 重传次数:收到SYN攻击之后,服务端会有大量处于SYN_RECV的TCP连接,处于这个状态的TCP会重传SYN+ACK,重传达到次数上限后就会断开连接,所以可以减少重传次数加速断开连接

优化 TCP

三次握手的性能提升
  • 客户端优化 :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 功能需要客户端和服务端同时支持,才有效果。

四次挥手的性能提升

过程:

  • 主动关闭方(客户端)发送FIN,变成FIN_WAIT_1状态
  • 服务端收到FIN后,先返回ACK,服务端变成CLOSE_WAIT状态
  • 客户端接收到ACK之后状态变为FIN_WAIT_2,等到没有数据处理之后,服务端返回FIN,状态变为LASK_ACK
  • 客户端收到FIN之后,发送ACK,变为TIME_WAIT状态
  • 服务端接收到ACK后,状态变为CLOSE
  • 客户端在TIME_WAIT之后,状态变为CLOSE

优化

  • 主动方优化:如果进程收到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,则会关闭缓冲区的动态整功能,所以不建议在程序设置它俩,而是交给内核自动调整比较好。

有效配置这些参数后,既能够最大程度地保持并发性,也能让资源充裕时连接传输速度达到最大值。

粘包

粘包问题是不知道用户消息的边界,接收方需要通过边界划分有效的用户消息

解决的三种分包方式:

  • 固定长度的消息:灵活度不高
  • 特殊字符作为边界:如HTTP,设置回车符、换行符作为HTTP报文协议的边界,注意,如果内容有该特殊字符则需要进行转义
  • 自定义消息结构:包头固定大小,包头的一个字段说明数据有多大

TLS 和 TCP 能同时握手吗?

TLS1.2握手4次,2个RTT

TLS1.3用1个RTT,1.3还可以会话恢复,重连需要0-RTT

「HTTPS 中的 TLS 握手过程可以同时进行三次握手」,这个场景是可能存在到,但是在没有说任何前提条件,而说这句话就等于耍流氓。需要下面这两个条件同时满足才可以:

  • 客户端和服务端都开启了 TCP Fast Open 功能,且 TLS 版本是 1.3;
  • 客户端和服务端已经完成过一次通信;
    • 第一个HTTP3次握手客户端本地缓存 Fast Open 选项中的 Cookie
    • 后续通信可以在第一次握手的时候携带数据

TCP Keepalive 和 HTTP Keep-Alive

HTTP 协议采用的是「请求-应答」的模式,客户端发请求,服务端才响应,1.1默认开启

HTTP 的 Keep-Alive 也叫 HTTP 长连接,该功能是由「应用程序」实现的,可以使得用同一个 TCP 连接来发送和接收多个 HTTP 请求/应答,减少了 HTTP 短连接带来的多次 TCP 连接建立和释放的开销。

TCP 的 Keepalive 也叫 TCP 保活机制,该功能是由「内核」实现的,当客户端和服务端长达一定时间没有进行数据交互时,内核为了确保该连接是否还有效,就会发送探测报文,来检测对方是否还在线,然后来决定是否要关闭该连接。

TCP 协议有什么缺陷?

  • 升级困难:TCP协议在内核中实现,应用程序只能使用不能修改,想升级只能升级内核
  • 建立连接的延迟:虽然Fast Open可以,但2013年提出,使用要升级操作系统
    • TLS 是在应用层实现的握手,而 TCP 是在内核实现的握手,所以TLS无法对TCP头部加密,意味着TCP的序列号都是明文传输,存在安全问题,比如伪造RST报文,只要序列号在对方接收窗口内,就成功,所以TCP3次握手同步序列号,随机序列号增加安全性
  • 队头阻塞:TCP 层必须保证收到的字节数据是完整且有序的,否则应用层也无法从内核中读取到这部分数据
  • 网络迁移需要重新建立TCP连接:4G换wifi意味ip变了,就要重建连接

如何基于 UDP 协议实现可靠传输?

三次握手协商连接ID,后续传输只需要固定连接ID,从而实现连接迁移

日常传数据的时候使用Packet Number严格递增(为了解决TCP重传的歧义问题,TCP重传时序列号和原始报文一样),QUIC支持乱序确认(丢包重传后的号码也递增,就不会因为丢包重传将当前窗口阻塞在原地,解决队头阻塞)

用了 TCP 协议,数据一定不会丢吗?

  • 数据从发送端到接收端,任何一个地方都可能丢包,几乎可以说丢包不可避免
    • 建立连接时丢包:第一次握手的半连接队列,第三次半连接升级为全连接
    • 流量控制丢包:流控队列有长度,发送数据过快长度不够时会丢包
    • 网卡丢包:接收缓冲区等内核触发软中断接收,如果缓冲区过小发送数据又过快就可能溢出,一个网卡可以有多个接收缓冲区RingBuffer,或者网卡传输速度有上限
    • 接收缓冲区丢包:接收缓冲区满了之后接收窗口是0,如果这个有数据发过来就丢包
    • 两端之间的网络丢包:各种路由器和交换机,ping查看和目的机器有没有丢包,mtr查看和目的机器之间的每个节点的丢包情况
  • 大部分时候TCP重传保证消息可靠性
  • 如果服务异常,如接口延时高,总失败,可以用ping或mtr看是不是中间丢包
  • TCP只保证传输层消息可靠性,不保证应用层消息可靠性,如果要保证应用层可靠性就要应用层自己实现逻辑保证

TCP 序列号和确认号是如何变化的?

序列号=上一次发送的序列号+len(数据长度),特殊,如果上一次发送的报文是SYN或FIN,len=1

确认号=上一次收到的报文的序列号+len(数据长度),特殊,SYN或FIN,len=1

如果客户端发送的第三次握手ACK丢失了,处于SYN_RCVD状态服务端收到了客户端第一个TCP数据报文会发生什么?

因为发送的第一个数据报文的序列号和确认号和第三次握手的序列号和确认好一样,同时报文将ACK为1,所以服务端收到这个报文可以正常建立连接,然后正常接收这个数据包

IP

IP基础

IP是在主机之间通信用的,MAC是实现直连的两个设备之间的通信。IP负责在没直连的两个网络之间进行通信

IP分类:ABC分为网络号和主机号两个部分,主机号全1为广播,全0指某个网络

  • A(0.0.0.0-127.255.255.255):
  • B(128.0.0.0-191.255.255.255):
  • C(192.0.0.0-223.255.255.255):
  • D和E没有主机号,D用于多播(将包发给特定组所有主机),E预留

判断时第1位是0就是A类,不是就继续向下判断,第2位是0就是B类。。。

IP缺点:同一网络下没有地址层次,缺少地址灵活性。C类最大主机太少,B类最大主机太多。用CIDR无分类地址解决

CIDR无分类地址

没有地址分类的概念,32位ip被分为网络号和主机号

a.b.c.d/x,/x表示前x位是主机号,或使用子网掩码,和IP与运算

子网掩码还可以划分子网:将主机地址分为子网网络地址和子网主机地址

  • 未做子网划分的 ip 地址:网络地址+主机地址
  • 做子网划分后的 ip 地址:网络地址+(子网网络地址+子网主机地址)

C类地址从8位主机号取2位作为子网网络地址,可以划分四个子网

IP分片与重组

以太网的数据链路的最大传输单元MTU是1500字节,重组时由目标主机重组,路由器不重组

IPv6

128位,16位一组,连续0可以用::隔开,但最多只有1次两个冒号

  • 可自动状态,可以没有DHCP服务器也可以实现自动分配IP地址
  • 包首部长度固定40字节,去掉包头校验和,简化首部结构,减轻路由器负荷,提高传输性能
  • 有应对伪造IP地址的网络安全功能以及防止线路窃听的功能,提高安全性

DNS

域名解析

浏览器先查自己缓存,没有就找操作系统缓存,没有就检查本地域名解析文件hosts,没有就向DNS服务器查询

  1. 先发本地DNS服务器
  2. 没找到就问根域名服务器(不直接用于域名解析,指明一条道路)
  3. 顶级域名服务器
  4. 权威域名服务器

ARP

已知IP地址查找下一跳的MAC地址

RARP

已知MAC地址求IP地址

DHCP

DHCP获取动态IP地址:使用UDP广播通信

NAT

网络地址转NAT

ICMP

互联网控制报文协议,报告消息

ping的工作原理

ICMP 主要的功能包括:确认 IP 包是否成功送达目标地址、报告发送过程中 IP 包被废弃的原因和改善网络设置等。

IP 通信中如果某个 IP 包因为某种原因未能达到目标地址,那么这个具体的原因将由 ICMP 负责通知

断网了,还能 ping 通 127.0.0.1 吗?

可以

当发现目标IP是外网IP时,会从"真网卡"发出。

当发现目标IP是回环地址 时,就会选择本地网卡

断网的情况下,网卡已经不工作了

设计模式

单例模式

双重检验锁实现单例模式

java 复制代码
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(); 这段代码其实是分为三步执行:

  1. uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

适配器模式

字节流字符流适配器

主要用于接口互不兼容的类的协调工作,IO 流中的字符流和字节流的接口不同,它们之间可以协调工作就是基于适配器模式来做的,更准确点来说是对象适配器。通过适配器,我们可以将字节流对象适配成一个字符流对象,这样我们可以直接通过字节流对象来读取或者写入字符数据。

AOP适配器

AOP中,每个类型的通知都有对应的拦截器,通知要通过对应的适配器,是配成MethodInterceptor接口类型的对象,通过调用getInterceptor,适配成MethodBeforeAdviceInterceptor

装饰器模式

不改变原有对象,扩展功能

通过 BufferedInputStream(字节缓冲输入流)来增强 FileInputStream 的功能

通过组合替代继承扩展原始类功能,在继承关系复杂的场景实用

对于字节流来说, FilterInputStream (对应输入流)和FilterOutputStream(对应输出流)是装饰器模式的核心,分别用于增强 InputStreamOutputStream子类对象的功能。

我们常见的BufferedInputStream(字节缓冲输入流)是FilterInputStream 的子类,BufferedOutputStream(字节缓冲输出流)、DataOutputStream等等都是FilterOutputStream的子类。

适配器模式和装饰器模式有什么区别

装饰器模式 更侧重于动态地增强原始类的功能,装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口。并且,装饰器模式支持对原始类嵌套使用多个装饰器。

适配器模式 更侧重于让接口不兼容而不能交互的类可以一起工作,当我们调用适配器对应的方法时,适配器内部会调用适配者类或者和适配类相关的类的方法,这个过程透明的。就比如说 StreamDecoder (流解码器)和StreamEncoder(流编码器)就是分别基于 InputStreamOutputStream 来获取 FileChannel对象并调用对应的 read 方法和 write 方法进行字节数据的读取和写入。

适配器和适配者两者不需要继承相同的抽象类或者实现相同的接口。

观察者模式(发布订阅模式)

发布者发布消息时,参与订阅的订阅者会收到对应消息通知,原理是使用一个集合存储所有订阅类,发布消息的时候遍历这个集合,并调用集合中每个订阅者类的通知方法

Java实现发布订阅模式_java发布订阅模式-CSDN博客

代理模式

代理模式的主要作用是扩展目标对象的功能,比如说在目标对象的某个方法执行前后你可以增加一些自定义的操作。

  1. 静态代理: 静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。

    1. 实现步骤
      1. 定义一个接口及其实现类
      2. 创建一个代理类同样实现这个接口
      3. 将目标对象注入代理类,然后在代理类对应方法调用目标类的对应方法
  2. 动态代理:动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。

    1. jdk动态代理InvocationHandler接口和Proxy类)

      Proxy类中方法newProxyInstance(),用来生成一个代理对象

      java 复制代码
      public static Object newProxyInstance(ClassLoader loader,
                                            Class<?>[] interfaces, //被代理类实现的一些接口
                                            InvocationHandler h)   //实现了InvocationHandler接口的对象
          throws IllegalArgumentException
      {
          ......
      }

      要实现动态代理,需要实现InvocationHandler来自定义处理逻辑,动态代理对象调用方法时,会被转发到实现InvocationHandler接口类的invoke方法调用

      java 复制代码
      public interface InvocationHandler {
      
          /**
           * 当你使用代理对象调用方法的时候实际会调用到这个方法
           */
          public Object invoke(Object proxy, Method method, Object[] args)
              throws Throwable;
      }

      通过Proxy类的newProxyInstance()创建的代理对象在调用方法时,实际会调用到实现InvocationHandler接口类的invoke()方法

      步骤:

      1. 定义一个接口和实现类
      2. 自定义InvocationHandler并重写invoke方法,在invoke方法中会调用原生方法并自定义逻辑
      3. 通过 Proxy.newProxyInstance(ClassLoader loader,Class<?>[] interfaces,InvocationHandler h) 方法创建代理对象;
    2. cglib动态代理 (通过继承方式),Spring中的AOP模块,如果目标对象实现了接口,默认采用jdk动态代理,否则采用cglib动态代理,cglib中MethodInterceptor接口和Enhancer类是核心

      jdk动态代理的问题是只能代理实现了接口的类

      你需要自定义 MethodInterceptor 并重写 intercept 方法,intercept 用于拦截增强被代理类的方法。

      java 复制代码
      public interface MethodInterceptor
      extends Callback{
          // 拦截被代理类中的方法
          public Object intercept(Object obj 被代理的对象, java.lang.reflect.Method method 需要增强的方法, Object[] args,MethodProxy proxy 调用原始方法) throws Throwable;
      }

      你可以通过 Enhancer类来动态获取被代理类,代理类继承了目标类,当代理类调用方法的时候,会被方法拦截器拦截,实际调用的是 MethodInterceptor 中的 intercept 方法。

      步骤:

      1. 定义一个类
      2. 自定义 MethodInterceptor 并重写 intercept 方法,intercept 用于拦截增强被代理类的方法,和 JDK 动态代理中的 invoke 方法类似;
      3. 通过 Enhancer 类的 create()创建代理类的实例,然后调用方法;

      使用时需要添加依赖

JDK 动态代理和 CGLIB 动态代理对比

  1. jdk动态代理只能代理实现了接口的类或直接代理接口,Cglib可以代理未实现接口的类。cglib是通过生成一个被代理类的子类来拦截被代理类的方法调用,所以不能代理声明了final类型的类和方法
  2. 大部分jdk效率高

静态代理和动态代理对比

  1. 灵活性:动态代理更灵活,不必实现接口,可以直接代理类,并且不用针对每个目标类都创建代理类。静态代理中,接口一旦新加方法,目标对象和代理对象都要修改
  2. jvm层面:静态代理在编译时就将接口,实现类,代理类这些变成一个个实际的class文件,动态代理是在运行时动态生成类字节码加载到jvm中

排序

纠正:插入排序的最好时间复杂度为 O(n) 而不是 O(n^2) 。

希尔排序的平均时间复杂度为 O(nlogn)

冒泡排序

java 复制代码
/*
	稳定
	每次比较相邻的元素,外层循环控制排序的轮数,每一轮将最大元素移到数组末尾
	最佳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;
}

选择排序

java 复制代码
/*
	不稳定
	每次从未排序的部分选择最小的元素,放到已排序部分的末尾
	都是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;
}

插入排序

java 复制代码
/*
	稳定
	将未排序的元素逐个插入到已排序部分的正确位置,外循环控制排序轮数,从第二个元素开始插入到已排序的位置
	最佳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;
}

希尔排序

java 复制代码
/*
	不稳定
	也是一种插入排序,先将整个待排序的记录序列分割成若干子序列分别进行直接插入排序,基本有序后再对全体记录依次直接插入排序
	最佳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;
}

归并排序

java 复制代码
/*
	稳定
	分治,先使每个子序列有序,再使子序列间有序
	都是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.递归地把小于基准值元素的子序列和大于基准值元素的子序列进行上述操作即可。

java 复制代码
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。

java 复制代码
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输出返回到原数组即可。

java 复制代码
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.从非空桶里把排好序的数据拼接起来即可。

java 复制代码
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次即可。

java 复制代码
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;
                }
            }
        }
    }
}

LRU算法(lru)(链表+hashmap)

思路:

  1. 定义节点类(Node):每个节点包含键(key)、值(value)、前一个节点(prev)和后一个节点(next)的引用。

  2. 定义LRUCache类

a. 初始化(LRUCache) :创建一个双端链表作为缓存数据的存储结构,同时初始化一个HashMap用于快速查找缓存中的节点。headtail分别表示链表的头部和尾部节点。

b. 添加节点(addNode):将一个新节点添加到链表的头部。

c. 删除节点(removeNode):从链表中删除一个节点。

d. 移动到头部(moveToHead):将某个节点从当前位置移动到链表的头部,表示该节点最近被使用过。

e. 弹出尾部节点(popTail):从链表的尾部弹出一个节点,即最近最少使用的节点。

f. 获取数据(get):根据键从HashMap中查找节点。如果找到,则将该节点移动到链表的头部并返回其值;否则返回-1。

g. 插入或更新数据(put):根据键从HashMap中查找节点。如果找到,则更新节点的值并将其移动到链表的头部;否则创建一个新节点,将其添加到链表的头部,并在HashMap中建立键和节点的映射。如果此时缓存的大小超过了容量,则弹出链表的尾部节点并从HashMap中删除其映射。

java 复制代码
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 和消息队列的区别

都是分布式微服务系统中重要组件之一

  • 用途:RPC主要解决两个服务的远程通信问题,不需要了解底层网络的通信机制。RPC可以帮助我们调用远程计算机上某个服务的方法,就像调用本地方法一样。消息队列主要用来降低系统耦合性、实现任务异步、有效进行流量削峰
  • 通信方式:RPC双向直接网络通讯、消息队列是单向引入中间载体的网络通讯
  • 架构:消息队列需要存储消息,RPC不用
  • 请求处理的时效性:RPC发出的调用一般会立即被处理,存放在消息队列的消息不一定被立即处理

RPC 和消息队列本质上是网络通讯的两种不同的实现机制,两者的用途不同

分布式消息队列技术选型

  • Kafka:开源的分布式流式处理平台,全面的高性能消息队列。

    流式处理平台三个关键功能:

    1. 消息队列:发布和订阅消息流,类似消息队列
    2. 容错的持久方式存储记录消息流:消息持久化到磁盘,避免消息丢失
    3. 流式处理平台:消息发布的时候进行处理,Kafka提供了一个完整的流式处理类库

    Kafka 是一个分布式系统,由通过高性能 TCP 网络协议进行通信的服务器和客户端组成,可以部署在在本地和云环境中的裸机硬件、虚拟机和容器上

    Kafka2.8之前重度依赖Zookeeper做元数据管理和集群的高可用,2.8之后,引入基于Raft协议的KRaft模式,不再依赖Zookeeper

  • RocketMQ:阿里开源的一款云原生"消息、事件、流"实时数据处理平台,借鉴了 Kafka

  • RabbitMQ:Erlang 语言实现 AMQP,用于在分布式系统中存储转发消息。具体特点

    • 可靠性:保证消息可靠性,如持久化、传输确认和发布确认
    • 灵活路由:消息进入队列之前,通过交换器路由消息。内置交换器,也可以将多个交换器绑定在一起,也可以通过插件实现自己的交换器
    • 扩展性:多个RabbitMQ节点可以组成一个集群,也可以根据实际业务动态扩展集群节点
    • 高可用性:队列可以在集群中的机器上设置镜像,使得在部分节点出现问题时仍然可用
    • 支持多种协议:除了原生支持 AMQP 协议,还支持 STOMP、MQTT 等多种消息中间件协议。
    • 多语言客户端:几乎支持所有常用语言
    • 易用的管理界面:可用监控和管理消息、集群中的节点等
    • 插件机制
  • Pulsar:集消息、存储、轻量化函数式计算为一体,采用计算与存储分离架构设计,支持多租户、持久化存储、多机房跨区域数据复制,具有强一致性、高吞吐、低延时及高可扩展性等流数据存储特性,被看作是云原生时代实时消息流传输、存储和计算最佳解决方案。

  • ActiveMQ:被淘汰

对比方向

  • 吞吐量 :万级的 ActiveMQRabbitMQ 的吞吐量(ActiveMQ 的性能最差)要比十万级甚至是百万级的 RocketMQKafka 低一个数量级。
  • 可用性 :都可以实现高可用。ActiveMQRabbitMQ 都是基于主从架构实现高可用性。RocketMQ 基于分布式架构。 Kafka 也是分布式的,一个数据多个副本,少数机器宕机,不会丢失数据,不会导致不可用
  • 时效性RabbitMQ 基于 Erlang 开发,所以并发能力很强,性能极其好,延时很低,达到微秒级,其他几个都是 ms 级。
  • 功能支持:Pulsar 的功能更全面,支持多租户、多种消费模式和持久性模式等功能,是下一代云原生分布式消息流平台。
  • 消息丢失:ActiveMQ 和 RabbitMQ 丢失的可能性非常低, Kafka、RocketMQ 和 Pulsar 理论上可以做到 0 丢失。

总结

  • ActiveMQ不推荐
  • RabbitMQ在吞吐量虽然低一点,但并发能力很强,性能极其好,延时很低,微妙级,(十万/百万并发是首选)
  • RocketMQ 和 Pulsar 支持强一致性,对消息一致性要求比较高的场景可以使用。
  • Kafka提供超高吞吐量,ms级的延迟,极高的可用性和可靠性,分布式可用任意扩展,同时支撑较少的topic数量,保证超大的吞吐量。唯一劣势是消息可能重复消费,影响很小,所以在大数据领域使用多

RabbitMQ

基础

RabbitMQ 是使用 Erlang 编写的一个开源的消息队列,并发能力很强,性能极其好,延时很低,达到微秒级,其他几个都是 ms 级

特点

  • 可靠性: RabbitMQ 使用一些机制来保证可靠性, 如持久化、传输确认及发布确认等。
  • 灵活的路由 : 在消息进入队列之前,通过交换器来路由消息。对于典型的路由功能, RabbitMQ 己经提供了一些内置的交换器来实现。针对更复杂的路由功能,可以将多个 交换器绑定在一起, 也可以通过插件机制来实现自己的交换器。
  • 扩展性: 多个 RabbitMQ 节点可以组成一个集群,也可以根据实际业务情况动态地扩展 集群中节点。
  • 高可用性 : 队列可以在集群中的机器上设置镜像,使得在部分节点出现问题的情况下队 列仍然可用。
  • 多种协议: RabbitMQ 除了原生支持 AMQP 协议,还支持 STOMP, MQTT 等多种消息 中间件协议。
  • 多语言客户端 :RabbitMQ 几乎支持所有常用语言,比如 Java、 Python、 Ruby、 PHP、 C#、 JavaScript 等。
  • 管理界面 : RabbitMQ 提供了一个易用的用户界面,使得用户可以监控和管理消息、集 群中的节点等。
  • 插件机制 : RabbitMQ 提供了许多插件 , 以实现从多方面进行扩展,当然也可以编写自 己的插件

消费模型,组成

生产者与消费者模型,主要负责接收、存储和转发消息。

消息一般由2部分组成:消息头和消息体 ,消息体是payLoad ,不透明,消息头是由一系列的可选属性组成,包括routing-key (路由键)、priority (优先级)、delivery-mode(持久化标志)等,生产者把消息交给RabbitMQ后,RabbitMQ会根据消息头将消息发送给感兴趣的消费者

  • Producer和 Consumer

  • Exchange :交换器会将消息分配到对应的消息队列,如果路由不到,可能会返回给生产者,可能会直接丢弃,4种类型,不同类型对应不同路由策略:direct (默认)、fanouttopicheaders 。不同类型交换器转发消息的策略不同,生产者发送消息给交换器时,一般会指定一个RoutingKey (路由键),指定这个消息的路由规则,这个 RoutingKey 需要与交换器类型和绑定键(BindingKey)联合使用才能最终生效 。RabbitMQ 中通过 Binding(绑定)Exchange(交换器)Queue(消息队列) 关联起来,在绑定的时候一般会指定一个 BindingKey(绑定建) ,交换器和队列可以是多对多的关系,生产者将消息发送给交换器时,需要一个 RoutingKey,当 BindingKey 和 RoutingKey 相匹配时,消息会被路由到对应的队列中,在绑定多个队列到同一个交换器的时候,这些绑定允许使用相同的 BindingKey。BindingKey 并不是在所有的情况下都生效,它依赖于交换器类型,比如 fanout 类型的交换器就会无视,而是将消息路由到所有绑定到该交换器的队列中。

    • fanout:会把发送到该交换器的消息发送到所有绑定的 Queue种,广播消息,最快

    • direct :把消息路由到BindingKeyRountingKey 完全匹配的Queue,常用于处理优先级的任务,根据优先级把消息发送到对应的队列

    • topic :将消息路由到BindingKeyRountingKey 匹配的队列

      . 号分隔字符串,每一段独立的字符串称为一个单词,BindingKey和 RountingKey都是点号

      BindingKey可以存在 *和#,用于模糊匹配,星号匹配一个单词,井号匹配多个单词(可以是0个)

    • headers(不推荐):不依赖路由键的匹配规则路由消息,而是根据消息内容的headers属性进行匹配。在绑定队列和交换器时指定一组键值对,当发送消息到交换器时,RabbitMQ 会获取到该消息的 headers(也是一个键值对的形式),对比其中的键值对是否完全匹配队列和交换器绑定时指定的键值对,如果完全匹配则消息会路由到该队列,否则不会路由到该队列。headers 类型的交换器性能会很差,而且也不实用,基本上不会看到它的存在。

  • Queue :一个消息可投入一个或多个队列。多个消费者可以订阅同一个队列 ,这时队列中的消息会被平均分摊(Round-Robin,即轮询)给多个消费者进行处理,而不是每个消费者都收到所有的消息并处理,这样避免消息被重复消费。

  • Broker :可以将Broker 看作一台RabbitMQ服务器

AMQP

RabbitMQ 就是 AMQP 协议的 Erlang 的实现

AMQP 协议的三层

  • Module Layer:协议最高层,主要定义了一些客户端调用的命令,客户端可以用这些命令实现自己的业务逻辑。
  • Session Layer:中间层,主要负责客户端命令发送给服务器,再将服务端应答返回客户端,提供可靠性同步机制和错误处理。
  • TransportLayer:最底层,主要传输二进制数据流,提供帧的处理、信道服用、错误检测和数据表示等。

AMQP 模型的三大组件

  • 交换器 (Exchange):消息代理服务器中用于把消息路由到队列的组件。
  • 队列 (Queue):用来存储消息的数据结构,位于硬盘或内存中。
  • 绑定 (Binding):一套规则,告知交换器消息应该将消息投递给哪个队列。

消息堆积

生产者发送消息速度超过消费者消费消息速度,队列中消息堆积,直到上限,之后消息成为死信,可能被丢弃

解决

  • 增加消费者
  • 在消费者开启线程池加快处理消费
  • 扩大队列容积,声明队列时使用惰性队列

惰性队列

接收到消息后直接存入磁盘而不是内存,消费时才从磁盘加载,支持百万条消息存储

死信交换机、死信队列

DLX:死信交换器,死信邮箱。当消息在一个队列中变成死信消息之后,能够被重新发送到另一个交换器中,这个交换器就死信交换器,绑定死信交换器的队列就是死信队列

导致死信的原因:

  • 消息被拒且 requeue = false
  • 消息TTL过期
  • 队列满了,无法添加,最早消息可能成为死信

延迟队列

延迟队列指存储对应的延迟消息,消息发送之后,不想让消费者立刻拿到消息,而是等待特定时间后,消费者才拿到这个消息进行消费

RabbitMQ本身没有延迟队列,实现延迟消息,一般有两种方式:

  1. 通过 RabbitMQ 本身队列的特性来实现,需要使用 RabbitMQ 的死信交换机(Exchange)和消息的存活时间 TTL(Time To Live)。
  2. 在 RabbitMQ 3.5.7 及以上的版本提供了一个插件(rabbitmq-delayed-message-exchange)来实现延迟队列功能。同时,插件依赖 Erlang/OPT 18.0 及以上。

AMQP 协议以及 RabbitMQ 本身没有直接支持延迟队列的功能,但是可以通过 TTL 和 DLX 模拟出延迟队列的功能。

优先级队列

3.5.0有优先级队列实现,优先级高的队列会先被消费

可以通过x-max-priority参数来实现优先级队列。不过,当消费速度大于生产速度且 Broker 没有堆积的情况下,优先级显得没有意义。也就是消费速度过快的话优先级队列没有意义,如果消费速度慢,此时需要先消费优先级高的消息

工作模式

  • 简单模式
  • work 工作模式
  • pub/sub 发布订阅模式
  • Routing 路由模式
  • Topic 主题模式

消息传输

因为TCP连接的创建和销毁开销较大,且并发数受系统资源限制,会造成性能瓶颈,所以 RabbitMQ使用信道的方式来传输数据

信道(Channel)是生产者、消费者和RabbitMQ通信的渠道,是建立在TCP连接上的虚拟连接,且每条TCP连接上的信道数量没有限制,所以RabbitMQ可以在一条TCP连接上建立大量信道达到多个线程处理,这个TCP被多个线程共享,每个信道在RabbtiMQ都有唯一的ID,保证了信道私有性,每个信道对应一个线程使用

多个线程都使用一条TCP连接,每个线程对应TCP连接中的一条信道

消息的不丢失、可靠性

消息到MQ、MQ自己、MQ到消费者

  • 生产者到RabbitMQ :生产者确认,Confirm 机制,发布到交换机失败和交换机到队列失败有不同提示,失败后可以回调重发,定时重发,记录日志
  • RabbitMQ自身:开启持久化交换机、队列、消息、集群
  • RabbitMQ 到消费者:消费者确认,mq收到ack回执再删除消息,三种,manual手动ack、auto ,没异常自动ack,none,关闭ack,获取消息一定成功处理,开启消费者失败重试,多次重试失败后将消息投递到异常交换机

消息的重复消费、顺序性

  • 每条消息设置一个唯一标识id
  • 幂等方案,分布式锁

高可用

RabbitMQ基于主从(非分布式)做高可用

RabbitMQ有三种模式:单机模式、普通集群模式、镜像集群模式

  • 仲裁队列:3.8后,替代镜像队列,都是主从同步,基于Raft协议,强一直

  • 普通集群模式:共享交换机、队列元信息(可以有引用)、不包含队列的消息,访问集群某节点,如果队列不在该节点,就从数据所在节点传递到当前节点,队列所在节点宕机,队列中消息就丢失

  • 镜像集群模式:本质是主从模式,消息会同步备份,创建队列的节点叫该队列的主节点,备份到其他节点叫做该队列的镜像节点,一个队列的主节点可能是另一个队列的镜像节点,所有操作都是主节点完成,同步给镜像节点,主节点宕机后,镜像节点替代。可能丢失数据,可以使用仲裁队列

延时和过期失效

RabbitMQ可以设置过期时间(TTL)。如果消息在队列积压超过TTL就会被RabbitMQ清理掉,大量数据会直接丢失,可以在过了高峰之后,查出丢失的数据,

假设 1 万个订单积压在 mq 里面,没有处理,其中 1000 个订单都丢了,你只能手动写程序把那 1000 个订单给查出来,手动发到 mq 里去再补一次。

Kafka

概述、CAP

分布式流处理平台

  1. 消息队列:发布订阅消息流
  2. 容错的持久方式存储记录消息流:消息持久化磁盘,避免消息丢失
  3. **流式处理平台:**在消息发布的时候进行处理,提供一个完整的流式处理类库

官方说是CA,原因是,Kafka设计是运行在一个数据中心,网络分区问题基本不会发生,所以是CA系统。

但现实中即使在一个数据中心,还是会有分区问题

kafka可以通过一些配置满足AP或CP或平衡,比如配置写入数据时等待同步到所有节点才返回ACK(acks=all),这样满足CP,如果配置写入数据主节点提交了直接返回ACK,在给其他节点同步之前宕机后,消费者读不到这条消息,满足AP

可以配置ack=all同时配置容忍一个节点宕机时强一致性和整体可用性

除了上面的几个常用配置项,下面这个配置项也跟consistency和availability相关。这个配置项的作用是控制,在所有节点宕机之后,如果有一个节点之前不是在ISR列表里面,启动起来之后是否可以成为leader。当设置成默认值false时,表示不可以,因为这个节点的数据很可能不是最新的,如果它成为了主节点,那么就可能导致一些数据丢失,从而损失consistency,但是却可以保证availability。如果设置成true,则相反。这个配置项让用户可以基于自己的业务需要,在consistency和availability之间做一个选择。

javascript 复制代码
unclean.leader.election.enable=false

分区同步三个参数

  • replication.factor:默认3,即每个分区只有1个leader副本和2个follow副本
  • acks:必须要有多少个分区副本收到消息,生产者才认为该消息是写入成功的
    • 0:不需要响应
    • 1:只要leader副本接收到消息,就响应ack
    • all:ISR列表的副本全部收到消息才响应
  • min.insync.replicas:最小同步副本,消息至少被写入到多少个副本才算是 "真正写入",默认值为 1,如果同步副本的数量低于该配置值,则生产者会收到错误响应,从而确保消息不丢失,只是一个最低限制,即同步副本少于该配置值,则会抛异常

消息模型

发布订阅模型,解决早期队列模型将生产者发送多个消费者需要创建多个队列的问题

使用主题作为消息通信载体,如果只有一个订阅者,就相当于队列模型

每个broker包含topic和partition

  • topic:生产者发送消息到主题,消费者订阅主题消费消息
  • partition:一个topic可以有多个partition,同一topic下的partition可以在不同的broker上

高性能设计、为什么吞吐量高

它把所有的消息都变成一个批量的文件,并且进行合理的批量压缩,减少网络IO损耗,通过mmap提高I/O速度,写入数据的时候由于单个Partion是末尾添加所以速度最优;读取数据的时候配合sendfile直接暴力输出。

  • 消息分区:不受单台服务器限制,处理更多数据

  • 顺序读写:磁盘顺序读写效率高

    • kafka每个区都是文件,数据会插到文件末尾,每个消费者对每个topic都有一个offset表示读取到了第几条数据
  • 页缓存:磁盘数据缓存到内存

  • 零拷贝:减少上下文切换和数据拷贝

    • 内核有页缓存,从磁盘加载后放到页缓存,再复制到用户空间,再拷贝到内核socket缓冲区,再拷贝要硬件网卡,零拷贝可以从页缓存直接拷贝到网卡
  • 消息压缩:减少磁盘io和网络io

  • 分批发送:将消息打包批量发送、减少网络开销

高可用、分区备份机制

集群:每个broker是一台kafka实例

分区备份机制

kafka为分区引入多副本,分区的多个副本之间有一个leader,其他副本是follower,消息会被发送到leader副本,follower副本从leader副本拉取消息并同步,follower分为ISR副本,使用同步复制,其他副本使用异步复制

选举时优先从ISR选,因为同步,如果都不能就从其他follower

优势:各个分区可以分布在不同broker,提供很好的并发能力,多副本提高消息存储安全性

Zookeeper和Kafka

ZooKeeper 主要为 Kafka 提供元数据的管理的功能

  1. broker注册:zookeeper专门有进行Broker服务器列表的节点,broker启动时会去zookeeper注册, 在/brokers/ids下创建属于自己的节点,记录ip和端口
  2. topic注册 :同一个topic的消息会被分成多个分区分布到多个broker上,zookeeper维护分区和broker的对应关系,比如创建了一个名字为 my-topic 的主题并且它有2个分区,对应到 zookeeper 中会创建这些文件夹:/brokers/topics/my-topic/Partitions/0/brokers/topics/my-topic/Partitions/1
  3. 负载均衡:分区分布在不同broker提供并发能力,同一个topic下的分区,kafka会尽力将这些分区分布到不同的broker,消费时,zookeeper可以根据当前分区数量和消费者数量实现动态负载均衡

2.8之后不依赖Zookeeper,引入基于Raft协议的kRaft模式,3.3.1可以使用

消费顺序性

kafka保证分区中的消息有序,消息被追加到分区时会分配一个偏移量,kafka通过偏移量保证消息在分区的顺序

kafka发送消息时可以指定分区或者key

消息不丢失/可靠性

  • 生产者丢失消息 :生产者发送消息时丢失
    • 判断消息发送的结果,添加回调函数
    • 设置重试次数
  • kafka丢失消息leader副本的broker挂掉,新选leader时,原leader的数据还有没被follow同步的,消息丢失 。批量刷盘到Page cache后系统挂掉,数据丢失,Kafka没有提供同步刷盘的方式。同步刷盘在RocketMQ中有实现,实现原理是将异步刷盘的流程进行阻塞,减少刷盘间隔,减少刷盘数据量大小。时间越短,性能越差,可靠性越好(尽可能可靠)
    • 设置acks=all,默认1,代表消息被leader接收就算发送成功,all代表只有所有isr列表的副本全部收到消息才给生产者响应,0代表写入消息之前不会等待服务器响应,可能丢失
    • 设置replication.factor>=3,保证每个分区至少有3个副本
    • 设置min.insync.replicas>1,消息至少被写入2个副本才算发送成功,默认1
    • 设置unclean.leader.election.enable=false,leader故障时不会从follow选取和leader同步程度不够的选取新leader
  • 消费者丢失消息 :消费者拉取分区消息后自动提交偏移量,但还没消费,消费者就挂掉了,实际消费没消费但偏移量自动提交了
    • 手动关闭自动提交偏移量,每次在真正消费完消息再手动提交偏移量,但可能重复消费,如刚消费完,没提交偏移量就挂掉了,就还会再次消费
    • commitsync同步提交偏移量

消息重复消费

原因:消费后没提交偏移量

  • 消费者宕机重启,消息被消费但没提交
  • 自动提交之前,有新的消费者加入或移除,发生rebalance,再次消费时,消费者会根据提交的偏移量重复消费数据
  • 消息处理耗时,或者消费者拉取消息太多,会认为当前消费者死掉,触发rebalance

解决

  • 消费消息服务做幂等校验,如redis的set,mysql的主键
  • 关闭自动提交,手动提交
    • 处理完消息再提交:可能没提交就挂掉,可能重复提交
    • 拉取到消息就提交:消息可能丢失,允许消息延迟时使用这种,然后用定时任务在业务不忙的时候做数据兜底

Rebalance

同一个消费者组,分区的所有权改变机制,重新均衡消费者消费。

触发时机

  1. 消费者组成员变化
  2. 订阅的topic个数变化
  3. 订阅topic的分区数变化

过程:join和sync

  1. join加入组,所有成员向协调者发送加入组请求,之后协调者选择一个消费者担任leader,把组成员信息和订阅信息发给leader
  2. sync,分配哪个消费者消费哪些主题下的哪些分区,分配好后把信息发给协调者,协调者收到分配方案之后会把结果发送给各个消费者

影响

  • 可能重复消费:消费者退出时没提交偏移量,rebalance时分区重新分配其他消费者
  • 集群不稳定:rebalance扩散到整个消费者组所有消费者,一个消费者退出,整个消费者组会rebalance
  • 影响消费速度:频繁rebalance降低消费速度

如何避免:

  • 业务需要、分区增加和主题增加取消不可避免
  • 合理设置消费者参数
    • 没及时发送心跳而rebalance
    • 消费者消费超时被踢出而rebalance

重试机制

默认消费异常会进行重试,重试多次后会跳过当前消息,继续进行后续消息的消费,不会一直卡在当前消息,默认重试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 链表的头部,还要淘汰 LRU 链表末尾的页。

避免预读失效:将数据分为冷数据和热数据,分别进行LRU,让预读页在内存停留时间短

  • linux实现两个LRU链表:活跃LRU链表和非活跃LRU链表
  • mysql的innodb在LRU链表划分2个区域:young前和old后,预读页加入old的头部,页真正被访问时才插入young的头部,如果一直不访问,就从old移除,不影响young

避免缓存污染:提高进入活跃LRU链表的门槛

  • linux在内存页第二次访问时才升级到活跃链表
  • mysql的innodb在内存页第二次访问时才从old升级到young,和第一次超过1秒才升级,1秒内就不升级

进程生命周期,状态

资源分配的基本单位

运行中的程序,进程切换要记录当前进程运行的状态信息,下次切换回来的时候就可以恢复执行

进程状态7个:

  • 运行:等待事件时会阻塞,调度其他进程时会就绪(时间片用完),挂起直接到就绪挂起
  • 就绪:调度选择当前进程时会运行
  • 阻塞:事件完成会就绪,是等待某个事件的返回
  • 创建、结束
  • 挂起:描述进程没有占用实际物理内存空间
    • 阻塞挂起:进程在磁盘等待某个事件的出现,事件出现后转为就绪挂起
    • 就绪挂起:进程在磁盘只要进入内存就立刻执行
    • 挂起的原因包括:进程使用的内存空间不在物理内存、sleep让进程间歇性挂起、用户希望挂起

进程、线程间通信方式

进程间通信方式

  1. 管道:就是内核里面的一串缓存,半双工
  2. 消息队列:保存在内核中的消息链表,发送数据会分成一个个独立的数据单元消息体,存在用户态和内核态的数据拷贝开销
  3. 共享内存:虚拟地址映射到相同的物理内存,进程可以直接读写共享内存,不用复制或数据传输,但共享内存使用需要同步和互斥操作
  4. 信号量:保护共享资源,计数器,实现进程间的互斥和同步
  5. 信号:计数器,控制多个进程对共享资源的访问,它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。
  6. Socket:不同主机的进程通信

线程间通信方式

  1. 锁机制:互斥锁、条件变量、读写锁(允许多个线程同时读,写互斥)
  2. 信号量
  3. 信号

线程

CPU调度的基本单位,线程之间可以并发执行,各个线程可以共享资源,缺点是线程崩溃时,所属进程其他线程都会崩溃,除了java

线程上下文切换时

  • 如果不在同一个进程,就是进程的上下文切换
  • 如果在同一个进程,因为虚拟内存是共享的,所以切换时虚拟内存这些资源不变,只切换线程的私有数据,开销更小

线程三种实现方式

  • 用户线程:每个进程有私有TCB线程控制块
  • 内核线程:TCB在操作系统里
  • 轻量级线程LWP:在内核支持用户线程,和内核线程一对一

用户线程和内核线程是多对一、一对一、多对多

线程和进程比较

  • 进程是资源分配的基本单位,线程是CPU调度的基本单位
  • 进程拥有一个完整的资源,线程只独享必不可少的资源,如寄存器和栈
  • 线程也有就绪、阻塞、执行三种基本状态
  • 线程能减少并发执行的时间和空间开销

多线程互斥和同步

同步是并发线程可能需要互相等待互通消息,这种相互制约的等待叫同步,就是操作A应该在操作B之前执行等

互斥比如操作A和操作B不能在同一时刻执行

锁和信号量可以实现同步和互斥

  • 任何时刻只能有一个线程操作缓冲区,说明操作缓冲区是临界代码,需要互斥
  • 缓冲区空时,消费者必须等待生产者生成数据;缓冲区满时,生产者必须等待消费者取出数据。说明生产者和消费者需要同步

死锁

两个线程都在等待对方释放锁

四个必要条件

  • 互斥:多个线程不能同时使用同一个资源
  • 持有并等待:线程在等待其他资源时不会释放自己的资源
  • 不可剥夺:线程持有的资源在自己使用完之前不能被其他线程获取
  • 环路等待:死锁发生时,两个线程获取资源的顺序构成了环形链

jstack可以检查

预防死锁

  • 一次性申请所有资源

  • 进程只获得运行初期需要的资源,在运行过程中逐步释放分配已经使用完毕的资源,再去请求新的资源

  • 使用资源有序分配法破坏环路等待

避免死锁:使用前判断,只允许不会产生死锁的进程申请资源

  • 如果一个进程的请求会导致死锁,就不启动该进程
  • 如果一个进程的增加资源请求会导致死锁,就拒绝该申请

进程、线程调度算法

单核CPU:

  1. 先来先服务:每次从就绪队列拿线程。对长作业好,用于CPU繁忙
  2. 最短作业优先 :这个算法选择具有最短执行时间的进程优先执行,以最大程度地减少等待时间。然而,这个算法通常需要预知每个进程的执行时间,这在实际情况下很难实现。
  3. 高响应比优先调度:这个算法是基于优先级的调度算法,但它考虑了等待时间。它选择具有最高响应比(响应时间与服务时间之比)的进程,以优先执行等待时间较长的进程。
  4. 时间片轮转:每个进程被分配一个固定的时间片,它们依次执行。如果一个进程在其时间片内没有完成执行,它将被移到队列的末尾,下一个进程获得执行的机会。
  5. 优先级调度:每个进程被分配一个优先级值。调度器始终选择具有最高优先级的进程进行执行。这可以是在进程创建时分配的静态优先级,也可以是基于诸如CPU使用时间等因素的动态优先级。
  6. 多级反馈队列:这是一种混合调度算法,它结合了轮转法和优先级调度。进程根据其行为和性能被放入不同的队列,不同队列具有不同的优先级。进程可以在不同队列之间移动,具体取决于其执行历史。

内存页面置换算法

缺页中断:CPU访问的页面不在物理内存中,会产生一个缺页中断,请求操作系统把缺页调入物理内存

当出现缺页异常,需调入新页面而内存已满时,选择被置换的物理页面

  • 最佳页面置换算法:置换在未来最长时间不访问的页面
  • 先进先出置换算法:选择内存驻留时间长的页面
  • 最近最久未使用的置换算法:LRU,最长时间没访问的页面
  • 时钟页面置换算法:所有页面保存环形链表,不断转
  • 最不常用算法:LFU,选择访问次数最少的页面淘汰

文件系统

每个文件有索引节点 (文件元信息,文件唯一标识)和目录项(记录文件名字,索引指针和其他目录项的层级关联关系)

磁盘读写最小单位是扇区,多个扇区组成一个逻辑块,每次读写最小单位是逻辑块4kb,也就是一次性读取8个扇区

虚拟文件系统:用户层和文件系统层中间,对用户提供统一接口

文件IO

  • 缓冲与非缓冲IO :根据是否利用标准库缓冲
    • 缓冲IO:利用标准库的缓存实现文件的加速访问,标注库通过系统调用访问文件
    • 非缓冲IO:直接通过系统调用访问文件,不经过标准库缓存
  • 直接与非直接IO :根据是否利用操作系统缓存
    • 直接IO:不会发生内核缓存和用户程序之间数据复制,直接经过文件系统访问磁盘
    • 非直接IO:读操作时,数据从内核缓冲拷贝给用户程序,写操作时,数据从用户程序拷贝给内核缓存,再由内核决定什么时候写入数据到磁盘
      • 内核缓存写入磁盘:调用write后发现内核缓存过多,主动调用sync,内存紧张,缓存超时
  • 阻塞与非阻塞IO和同步与异步IO
    • 阻塞IO:read时线程被阻塞,等待内核数据准备好,把数据从内核缓冲区拷贝到用户缓冲区,read才返回
    • 非阻塞IO:read时数据没准备好,立即返回,应用程序不断轮询内核,直到数据准备好再拷贝
      • IO多路复用:用户可以在一个线程内同时处理多个socket的IO请求
      • 其实 阻塞非阻塞和多路复用都是同步调用,因为在read时内核将数据从内核拷贝到用户应用程序都是同步的

网络系统

DMA(直接内存访问)

进行性IO和内存数据传输时,数据搬运交给DMA控制器,CPU去处理其他事务

本来read读的时候cpu将数据从磁盘缓冲区拷贝到内核缓冲区,再把数据从内核缓冲区拷贝到用户缓冲区

使用DMA后由DMA把数据从磁盘缓冲区拷贝到内核缓冲区,发送中断给CPU,CPU再将数据从内核缓冲区拷贝到用户缓冲区

零拷贝

用户缓冲区没必要存在,用DMA传输

  • mmap+write:mmap替换read系统调用,DMA把磁盘数据拷贝到内核缓冲区,进程再调用write,操作系统直接将内核缓冲区数据拷贝到socket缓冲区,发生在内核,CPU搬运,最后把内核socket里的缓冲区的数据拷贝到网卡的缓冲区,由DMA搬运
  • sendfile:专门发送文件的系统调用函数sendfile,直接把内核缓冲区数据拷贝到socket缓冲区。
    • 网卡如果支持SGDMA就可以减少把内核缓冲区数据拷贝到socket缓冲区的过程:先通过DMA把磁盘数据拷贝到内核缓冲区,缓冲区描述符和数据长度传到socket缓冲区,就可以直接将内核缓冲区的数据拷贝到网卡的缓冲区

PageCache

磁盘高速缓存,缓存最近被访问的数据,有预读功能

所以,传输文件的时候,我们要根据文件的大小来使用不同的方式:

  • 传输大文件的时候,使用「异步 I/O + 直接 I/O」;
  • 传输小文件的时候,则使用「零拷贝技术」;

高性能网络模式:Reactor和Proactor

Reactor非阻塞同步网络模式,感知的是就绪可读写事件。封装IO多路复用,事件反应。IO多路复用监听事件,收到事件后,根据事件类型分配给某个进程/线程

Reactor 模式主要由 Reactor(数量可变) 和处理资源池(单/多,线程/进程)这两个核心部分组成:

  • Reactor 负责监听和分发事件,事件类型包含连接事件、读写事件;
  • 处理资源池负责处理事件

除了多Reactor单线程/进程,其他3个都有使用

单Reactor单进程/线程

  • C语言是单进程,java是单线程,Redis6.0之前是单Reactor单进程
  • 监听事件,收到事件后根据事件类型分发给Acceptor对象或Handler对象处理

单Reactor多线程/进程

Hander对象不再负责业务处理,只负责数据的接收和发送,子线程的Processor对象进行业务处理,处理完后发给Handler对象,充分利用多核

多Reactor多进程/线程

分为主线程和子线程

Proactor异步网络模式感知的是已完成的读写事件,在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据。

但linux下的异步是用户空间模拟的,Windows有真正的异步IO

一致性哈希

负载均衡算法,为了减少迁移的数据量

一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响。

为了解决不均匀分布的问题,引入虚拟节点,对真实节点做多个副本,将虚拟节点映射到哈希环上,提高均衡度

五种IO模型、IO多路复用

IO模型是不同的策略,比如用户读取时,内核检查有没有数据,此时内核可以直接返回或等一会,就取决于IO模型

两个阶段(内核获取数据从内核拷贝数据到用户缓冲区

  • 阻塞IO :用户读取时,等待数据就绪,再将内核数据拷贝到用户缓冲区,两阶段都阻塞
  • 非阻塞IO :用户读取时,没数据就立即返回结果,循环访问,第一个阶段非阻塞,第二个阶段阻塞。性能没提升,而且CPU空转,使用率增加
  • IO多路复用 :确保读的时候一定有数据,直接进入第二阶段,利用单个线程监听多个文件描述符(从0开始递增整数,关联linux文件,socket也是文件),在可读可写时得到通知,避免无效等待,充分利用CPU,阻塞等待数据,监听多种方式,两阶段阻塞
    • select和poll:只会通知用户进程有FD就绪,但不知道具体是哪个FD,需要用户进程逐个遍历
      • select:监听多个fd,是long型数组,长度32,bit位代替fd的状态,1代表就绪,遍历时有就绪的话就再遍历,返回就绪个数,最后用户空间遍历,找到就绪的读取
        • 需要将fd集合拷贝到内核空间,结束后再次拷贝回用户空间
        • 无法得知哪个fd就绪,需要遍历整个fd
        • 最多监听1024字节
      • poll:内核发现有事件就绪,就把就绪的类型放到实际发生的事件类型,未就绪就设置0,pollfd数组所有fd链表,每个pollfd有监听类型和实际发生类型,内核遍历fd判断是否就绪,就绪或超时后拷贝数组到用户空间,返回就绪fd数量,用户进程判断大于0就再遍历pollfd数组找到就绪id
        • 没监听数量上限,但fd越多,每次遍历消耗时间越久,性能反而下降
        • pollfd数组拷贝到内核空间,最后拷贝回去
        • 需要遍历整个pollfd才直到哪个fd就绪
    • epoll :通知用户进程FD就绪的同时,把已经就绪的FD写入用户空间
      • epoll_create创建epoll实例,红黑树记录监听的fd,链表记录就绪的fd,会在内核创建红黑树和链表,是eventpoll对象
      • epoll_ctl添加监听的fd,关联回调(eventpoll对象,监听的FD和事件类型和执行的操作)将一个FD添加到红黑树中,并设置回调,触发时就把对应的FD加入就绪链表
      • epoll_wait等待fd就绪 ,检查就绪链表如果不为空就返回就绪的数量,再传空数组events,最后把链表的元素拷贝到传到用户空间的events空数组
        • 得到通知,通知有两种模式,LT(数据可读时重复通知多次,直到数据处理完成,默认)和ET(数据可读时只通知一次)
        • LT还有一些问题,如循环性能就不高,而且因为拷贝fd的时候内核还有fd,此时其他监听了这个fd的进程都被唤醒,都能读,但没必要那么多被唤醒(惊群)
        • 拷贝FD的时候会先断开内核链表和FD的连接,LT在用户拷贝完FD后重新添加内核events的FD,ET回把内核链表FD干掉,也可以在ET时手动添加回去或者一次通知就读完(循环读,但不能用阻塞,读到没有一直阻塞)
        • ET最好结合非阻塞IO读取,更推荐
      • 红黑树保存监听的fd,理论无上限,而且增删改查效率高,性能不会随监听的fd变多而下降
      • 每个fd只执行一次添加到红黑树,以后每次wait不用传参数,不用重复拷贝fd
  • 信号驱动IO :和内核建立信号关联并设置回调,内核有FD就绪时发出信号通知用户,期间用户可以执行其他业务,不用阻塞等待,第一阶段非阻塞,第二阶段阻塞
    • 大量IO操作,信号多,函数不能及时处理导致信号队列溢出
    • 内核和用户空间频繁交互性能较低
  • 异步IO :通知内核我想读哪个fd,读到哪里去,内核数据就绪并拷贝完成后再通知用户进程,两阶段不阻塞
    • 高并发不停给内核安排任务,IO读写多,效率低,必须做好并发访问的限流,复杂

IO操作同步还是异步,关键看数据在内核空间和用户空间的拷贝过程,阶段二是同步还是异步

Linux命令

性能指标

  • 带宽:链路最大传输速率
  • 延时:请求包发送后到收到响应的延迟
  • 吞吐量:单位时间成功传输的数据量,吞吐受带宽限制
  • PPS:表示以网络包为单位的传输速率

网络配置

ifconfig或ip

socket信息

netstat或ss(推荐)

网络吞吐率和PPS

sar,-n可以查看网络统计信息,网口、TCP等

连通性和延时

ping

分析日志

ls -lh查看日志文件大小

不用cat,用less按需加载文件

Nginx

高性能HTTP和反向代理web服务器,linux的epoll模型

多进程单线程,提高并发率,多进程之间相互独立,一个worker进程挂了不影响其他worker进程,master进程管理worker进程,分发请求

不用多线程

  • 采用单线程来异步非阻塞处理请求,不会为每个请求分配cpu和内存资源,节省了大量资源,同时也减少了大量的CPU的上下文切换。所以才使得Nginx支持更高的并发。
  • 因为 Nginx 要保证高可用性,多线程之间会共享地址空间,当某一个第三方模块引发了一个段错误时,就会导致整个 Nginx 进程挂掉

反向代理

反向代理 :目标服务器与客户端之间的代理,代理服务器接收客户端请求并将其转发到后端的目标服务器上,它是服务器的代理,帮助服务器做负载均衡

正向代理 :客户端与目标服务器之间的代理,代理服务器代表客户端发送请求并获取响应,他是客户端的代理,帮客户端访问无法访问的服务器

静态映射

访问服务器静态资源,本地目录不在nginx根目录下,需要进行目录映射,location配置rewrite跳转

nginx -s reload 重新载入配置文件

负载均衡策略

  • 轮询
  • 加权轮询:weight越大优先级越高
  • IP哈希:根据ip哈希到同一台服务器
  • URL哈希:根据请求的URL哈希分配服务器
  • fair:按后端服务器的响应时间分配,响应时间短的优先
nginx 复制代码
# 配置上游服务器	默认轮询,加 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;
	}
}

Zookeeper

开源分布式协调服务框架

数据保存在内存,不适合保存大数据,适合读多写少,写会同步所有服务器状态

  • Data model数据模型:层次化多叉树

  • znode数据节点:stat状态(记录版本)和data内容

    • 持久节点
    • 临时节点:会话结束节点消失,只能做叶子节点
    • 持久顺序:持久而且有顺序
    • 临时顺序:临时而且有顺序
  • version版本:stat记录当前节点版本、当前子节点版本、当前节点的ACL版本

  • ACL权限控制:创建获取设置等权限,身份认证提供ip限制用户名密码认证

  • Watcher事件监听器:用户在节点上注册Watcher,在特定事件触发时,zookeeper将事件通知到对应客户端

  • Session会话:zookeeper和客户端的tcp长连接,可以心跳检测,创建会话之前会给客户端分配sessionId,全局唯一

应用场景

  • 命名服务:顺序节点生成全局唯一ID
  • 数据发布/订阅:Watcher机制实现数据发布/订阅,数据发布到Zookeeper被监听的节点,其他机器可以监听节点变化实现配置动态更新
  • 分布式锁:创建唯一节点获得分布式锁,当获得锁的一方执行完相关代码或者是挂掉之后就释放锁。

集群

ZAB协议保持数据一致性

  • 崩溃恢复:崩溃时进入恢复模式选举产生新leader,产生后而且过半机器与该leader同步后,退出恢复模式
  • 消息广播:过半follower完成和leader同步后,整个服务可以进入消息广播,新加入的服务器自觉数据恢复模式

没有使用主从模式,使用leader、follower、observer

  • leader:为客户端提供读写,负责投票的发起和决议,更新系统状态
  • follower:为客户端提供读,写转发给leader,参与投票
  • observer:为客户端提供读,写转发给leader,不投票,不参与过半写成功,3.3新增

leader选举

  1. 选举阶段:节点都处于选举状态,只要一个节点超过半数票数就可以当选准leader
  2. 发现阶段:follower和准leader通信,同步follower最近接收的事务提议
  3. 同步阶段:利用leader前一阶段的最新提议,同步集群所有副本,之后准leader成为真正leader
  4. 广播阶段:集群正式提供服务,leader可以广播消息

集群为什么是奇数台

3台最大允许宕机1台,4台最大允许宕机1台,所以奇数就可以

集群脑裂:多台机器在不同机房,机房间网络线路故障,网络不通,集群被割裂多个集群,子集群各自选leader,使用过半机制解决,不可能产生2个leader

分布式/微服务

区别

  • 对象不同:cookie针对每个网站,每个网站只能对应一个,保存在客户端,Session针对用户,只有客户端能访问,session在用户访问后自动消失
  • 存储数据大小:cookie不超过4k,session存储在服务器上存储任意数据
  • 生命周期不同:Cookie 可设置为长时间保持,比如我们经常使用的默认登录功能,Session 一般失效时间较短,客户端关闭(默认情况下)或者 Session 超时都会失效。
  • 存储位置不同:cookie保存在客户端,session保存在服务端
  • 数据类型不同:cookie的值只是字符串,session的值是Object
  • 安全性不同:cookie不安全

cookie 是不可跨域的:每个 cookie 都会绑定单一的域名,无法在别的域名下获取使用,一级域名和二级域名之间是允许共享使用的(靠的是 domain)。

前端清空吗

流程

  1. 用户第一次请求服务器的时候,服务器根据用户提交的相关信息,创建对应的 Session
  2. 请求返回时将此 Session 的唯一标识信息 SessionID 返回给浏览器
  3. 浏览器接收到服务器返回的 SessionID 信息后,会将此信息存入到 Cookie 中,同时 Cookie 记录此 SessionID 属于哪个域名
  4. 当用户第二次访问服务器的时候,请求会自动判断此域名下是否存在 Cookie 信息,如果存在自动将 Cookie 信息也发送给服务端,服务端会从 Cookie 中获取 SessionID,再根据 SessionID 查找对应的 Session 信息,如果没有找到说明用户没有登录或者登录失效,如果找到 Session 证明用户已经登录可执行后面操作。

token 和 JWT

Session 是一种记录服务器和客户端会话状态的机制,使服务端有状态化,可以记录会话信息,耗费服务器资源。而 Token 是令牌,访问资源接口(API)时所需要的资源凭证,token存用户信息需要去查询数据库验证,常用的cookie只能是按域名发送对应的cookie,不能实现跨域的功能,会遭受CSRF攻击、存储在客户端不安全。

session 是空间换时间,token 是时间换空间。

服务器不用存储Session,JWT 认证可以有效避免 CSRF 攻击,因为 JWT 一般是存在在 localStorage 中,使用 JWT 进行身份验证的过程中是不会涉及到 Cookie 的

组成

  • Header : 描述 JWT 的元数据,定义了生成签名的算法以及 Token 的类型。Header 被转换成 Base64 编码(用64个字符表示任意二进制数据
  • Payload : 用来存放实际需要传递的数据,默认不加密,转换成 Base64 编码
  • Signature(签名):服务器通过 Payload、Header 和一个服务端密钥(Secret)使用 Header 里面指定的签名算法(默认是 HMAC SHA256)生成。

客户端接收到 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

在多个应用系统中,只需要登录一次,就可以访问其他相互信任的应用系统。

同域下,sso服务登录后,将cookie域设置为顶级域,所有子域都可以访问顶级域cookie,同时session进行共享,sso系统登录后,再访问顶级域下的其他域,cookie会带到其他域的服务端,再通过共享session找到对应的session

不同域下,cookie不共享,单点登录

  1. 用户访问系统1时需要登录,但没有登录,跳转到CAS服务器(SSO系统)
  2. SSO系统也没登录,弹出用户登录页,SSO登录后,将登录状态写入SSO的session,浏览器写入SSO域下的cookie
  3. SSO系统登录后生成一个ST(Server Ticket),跳转到系统1,把ST传给系统1
  4. 系统1拿到ST后,向SSO发请求验证ST是否有效
  5. 通过验证系统1就把登录状态写入session并设置系统1域的cookie

之后再访问系统1时,就是登录的,此时用户访问系统2,系统2没登陆跳转到SSO,SSO登录了,所以直接生成ST返回系统2把发给系统2,系统2拿到ST请求SSO是否有效,成功就把登录状态写入session,并在系统2域下写入cookie

系统1和系统2在不同域,session不共享也没关系

分布式锁

不同主机访问共享资源,需要互斥防止彼此干扰

  • 数据库实现:表的唯一约束,成功插入数据代表获取到锁,删除就是释放锁
    • 优点:操作简单
    • 缺点:性能开销大
  • redis:key是唯一的,value是线程的编号,setnx
    • 优点:非阻塞,性能好
    • 缺点:运维成本高,操作不好容易死锁
  • zookeeper:每个客户端对方法加锁时,在zookeeper上与该方法对应的指定节点的目录下,生成一个唯一临时有序节点,判断如果是有序节点最小的一个就算获取到锁,释放锁时直接删除临时节点,避免服务宕机导致锁无法释放的死锁问题
    • 优点:集群,无单点问题,可重入,可避免锁无法释放
    • 缺点:有性能瓶颈,性能不如redis

CAP和BASE

CAP

分布式系统三个指标

  • Consistency一致性:用户访问分布式系统的任意节点,得到的数据一致
  • Availability可用性:用户访问集群任意健康节点,必须能得到响应,而不是超时或拒绝
  • Partition tolerance分区容错性 :因为网络故障或其他原因导致分布式系统的部分节点和其他节点时区连接,形成独立分区,在集群出现分区时,整个系统也要持续对外提供服务(分布式系统能够在网络分区(即节点之间无法相互通信)的情况下继续正常运行 )此时可能数据不一致(从节点分区独立,未同步,但可以先同步再提供服务保证一致性,但同步要等待,从节点此时不可用,就没可用性,所以没cap
    • 分布式系统肯定需要网络连接,分区p是必然的

BASE

对CAP的一种解决思路

  • Basically Available基本可用:分布式系统出现故障时,允许损失部分可用性,保证核心可用
  • Soft State软状态:在一定时间内,允许出现中间状态,比如临时的不一致状态
  • Eventually Consistent最终一致性:无法保证强一致性,但在软状态结束后,最终达到数据一致

Eureka:AP思想

Zookeeper:CP思想

分布式事务

解决分布式事务的思想和模型

  • 最终一致思想:各分支事务分别执行并提交,有不一致情况想办法回滚 AP
  • 强一致思想:各分支事务执行完业务不要提交,等待彼此结果同一提交或回滚 CP

Seata

Seate事务管理,XA、AT、TCC

  • TC事务协调者:维护全局和分支事务的状态,协调全局事务提交或回滚
  • TM事务管理器定义全局事务的范围,开启、提交、回滚全局事务
  • RM资源管理器:管理分支事务处理的资源,和TC交谈注册分支事务和报告分支事务的状态,驱动分支事务提交或回滚,每个微服务对应一个RM,属于一个分支事务,需要注册到TC

XA模式:强一致、性能差

  • RM一阶段:
    • 注册分支事务到TC
    • 执行分支业务sql但不提交
    • 报告执行状态给TC
  • TC二阶段:检测各分支事务执行状态
    • 都成功就通知所有RM提交事务
    • 有失败就通知所有RM回滚事务
  • RM二阶段:接收TC指令,提交或回滚事务

AT模式:分阶段提交,弥补XA资源锁定周期过长的缺陷,性能好

  • RM阶段一:
    • 注册分支事务
    • 记录undolog数据快照
    • 执行sql并提交
    • 报告事务状态
  • 提交时RM的工作阶段二:删除undolog
  • 回滚时RM的工作阶段二:根据undolog恢复数据

TCC模式:性能好,但需要人工编码

  • Try:资源的检测和预留
  • Confirm:完成资源操作业务,要求Try成功Confirm一定要成功
  • Cancel:预留资源释放,try的反向操作

MQ分布式事务

生成消息到mq,消费者从mq读消息执行本地事务,确保mq和mysql在同一个事务

在a服务写数据时,需要在同一个事务内发送消息到另一个事务,异步,性能好,但实时性差

分布式算法

Paxos算法

基于消息传递且具有高度容错特性的一致性算法,解决的问题就是分布式系统中如果就某个值达成一致。

Paxos 中主要有三个角色,分别为 Proposer提案者Acceptor表决者Learner学习者

2个阶段

  • prepare阶段:
    • Proposer提案者:提出提案,首先获取一个全局唯一提案编号,把提案编号发给所有表决者。
    • Acceptor表决者:accept提案后,记录提案编号,每个表决者保存已经被accept提案的最大编号的提案,只会accept编号大于本地最大提案的提案,批准的时候会返回给提案者自己最大的编号的提案。
  • accept阶段:
    • 如果一个提案者收到超过半数的批准,就给所有批准的表决者真正的提案。
    • 表决者收到提案后比较本地最大提案,大于等于最大提案编号才accept该提案(此时执行提案内容但不提交),随后返回情况给提案者。
    • 提案者收到超过半数accept,就向所有表决者发送提案的提交请求(此时也需要向未批准的acceptor发送提案内容和提案编号让它无条件执行和提交,对于前面已经批准过提案的表决者只发提案编号,让执行提交就好)。
    • 如果提案者没收到超过半数的accept,就递增提案编号,重新进入Prepa。

一致性Hash算法

分布式集群里,机器的添加删除或故障自动脱离,如果用常用hash,操作后原有数据可能找不到,违反单调性。

hash环解决单调性,用hash算法把一个key哈希到一个有2^32个桶的空间里,环上也添加对应缓存节点,对于数据的key哈希后顺时针找最近的缓存节点存储数据,缓存节点宕机删除后,原节点数据顺时针找最近节点存储,也可以增加节点

虚拟节点解决平衡性,一个节点宕机后,数据需要落在距离它最近的节点,会导致下一个节点压力增大,可能导致雪崩,整个服务挂掉,虚拟节点是实际节点在hash空间的复制品,一个实际节点对应多个虚拟节点,当节点宕机后,存储流量压力分散在多台节点上,解决雪崩问题。

哈希算法好坏的条件

  • 平衡性:哈希结果尽可能分布到所有缓冲。
  • 单调性:如果有一些内容通过哈希分配到相应缓冲,又有新的缓冲加入系统,哈希结果应该保证原有已分配的内容可以被映射到原有或者新的缓冲,而不会映射到旧的缓冲集合的其他缓冲区。
  • 分散性:尽量避免相同内容被不同终端映射到不同缓冲区。
  • 负载:尽量降低缓冲负载。

雪花算法

推特开源分布式id生成,划分命名空间分割64big位,long类型

  • 第1位:0
  • 第2位开始的41位是时间戳毫秒
  • 中间10位是机器数
  • 最后12位是自增序列

相当于在一毫秒一个数据中心的一台机器上可产生4096个有序的不重复的ID。强依赖机器时钟

SpringCloud/alibaba五大组件

  • Eureka:注册中心,albb用Nacos做注册中心和配置中心

    • 服务注册和发现:服务提供者把自己的信息注册到eureka,消费者向eureka拉取服务列表信息,服务提供者每30秒向eureka发送心跳,如果90秒没收到心跳,就从eureka剔除,nacos可以设置临时实例(心跳监测)和非临时实例(nacos主动询问),主动向消费者推送提供者变更信息
  • Ribbon:负载均衡,发出远程调用feign就会使用ribbon,决定选择哪一台服务器,有轮询、权重、随机、区域分类等

    • 客户端负载均衡,nginx是服务端负载均衡(客户端所有请求交给nginx,nginx实现负载均衡转发),ribbon是从注册中心获取服务列表缓存本地,在本地实现负载均衡,是客户端负载均衡。Feign集成了ribbon
  • Feign:远程调用

  • Hystrix:服务熔断,albb用sentinel

  • Zuul/Gateway:网关,albb用Gateway

服务雪崩

一个服务失败,导致整条链路的服务都失败,如服务d宕机,服务a不断向服务d请求,调用失败的连接没释放,连接满后服务a也宕机

降级熔断

服务熔断一般是某个服务(下游服务)故障引起,而服务降级一般是从整体负荷考虑

服务降级

原因:整体负荷超出整体负载承受能力

目的:保证重要或基本服务正常运行,非重要服务延迟使用或暂停使用

一般与feign接口整合编写降级逻辑

降级方式

  • 延迟服务
  • 页面跳转
  • 写降级:秒杀,只进行cache的更新,异步扣减库存到数据库,保证最终一致性
  • 读降级:多级缓存,后端服务有问题时降级为只读缓存

服务熔断

原因:当下游服务因访问压力过大而响应变慢或失败,上游服务为了保护系统整体的可用性,可以暂时切断对下游服务的调用。熔断该节点微服务的调用,快速返回"错误"的响应信息。

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(窗口数) 的请求, 如果当前窗口的请求计数总和超过了限制的数量的话就不再处理其他请求。

很显然, 当滑动窗口的格子划分的越多,滑动窗口的滚动就越平滑,限流的统计就会越精确。

漏桶算法(Sentine排队等待算法)

按照固定速率流出请求

发请求:给桶注水

处理请求:漏桶漏水

往桶中任意速率注水,固定速率流水。水超过桶流量就丢弃,因为桶容量不变,就保证了整体的速率

**实现方法:**准备一个队列保存请求,定期从队列拿请求执行

访问频率超过接口响应速率就拒绝请求,强行限制数据的传输速率

因为漏出速率固定,所以即使网络不阻塞,漏桶也不能接收大量突发请求

漏桶限制的是常量流出速率(即流出速率是一个固定常量值,比如都是1的速率流出,而不能一次是1,下次又是2),从而平滑突发流入速率;

令牌桶算法(Sentine预热限流算法)

按照固定速率加令牌

桶里装的令牌,请求在被处理之前需要拿到一个令牌,处理完请求之后丢弃令牌

如果桶满了,就不能继续往里面继续添加令牌了,如果一段时间没有请求到来,桶内就积累一些token,下一次的突发流量,只要token足够,也能一次处理

如果没有令牌就拒绝新请求

所以令牌桶的特点是允许突发流量

令牌桶限制的是平均流入速率(允许突发请求,只要有令牌就可以处理,支持一次拿3个令牌,4个令牌),并允许一定程度突发流量;

放库存吗

git 手动

  • git commit:保存目录下所有文件的快照,git还保存提交的历史
  • 分支 :指向某个提交记录而已
    • git branch 分支名:创建分支
    • git checkout 分支名:切换分支
    • git checkout -b 分支名:创建分支同时切换
  • 合并
    • git merge 目标分支:把目标分支和并到当前分支
    • git rebase 目标分支:把当前分支的工作移到目标分支下,实际上就是取出一系列提交记录,然后复制它们,最后在另一个地方逐个放下去,可以创造更线性的提交历史
  • HEAD :对当前所在分支的符号引用,通常指向分支名
    • git checkout 提交记录名:分离的HEAD
    • git checkout 引用名^:把HEAD指向分支名的上一个
    • git checkout 引用名~数字:把HEAD指向分支名的上几个
    • git branch -f 分支名 引用名^/~数字:让分支强制指向引用的上层级
  • 撤销
    • git reset 引用名^/~数字:把当前分支记录回退到引用名,原来指向的提交记录还在,但处于未加入暂存区
    • git revert 引用名^/~数字:把当前分支记录对于引用的撤销更改形成新的引用,此时可以推送更改
  • 远程仓库
    • git clone:在本地创建一个远程仓库的拷贝

实战篇Redis

开篇导读

  • 短信登录

这一块我们会使用redis共享session来实现

  • 商户查询缓存

通过本章节,我们会理解缓存击穿,缓存穿透,缓存雪崩等问题,让小伙伴的对于这些概念的理解不仅仅是停留在概念上,更是能在代码中看到对应的内容

  • 优惠卷秒杀

通过本章节,我们可以学会Redis的计数器功能, 结合Lua完成高性能的redis操作,同时学会Redis分布式锁的原理,包括Redis的三种消息队列

  • 附近的商户

我们利用Redis的GEOHash来完成对于地理坐标的操作

  • UV统计

主要是使用Redis来完成统计功能

  • 用户签到

使用Redis的BitMap数据统计功能

  • 好友关注

基于Set集合的关注、取消关注,共同关注等等功能,这一块知识咱们之前就讲过,这次我们在项目中来使用一下

  • 打人探店

基于List来完成点赞列表的操作,同时基于SortedSet来完成点赞的排行榜功能

以上这些内容咱们统统都会给小伙伴们讲解清楚,让大家充分理解如何使用Redis

1、短信登录

1.2 、基于Session实现登录流程

发送验证码:

用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号

如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户

短信验证码登录、注册:

用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息

校验登录状态:

用户在请求时候,会从cookie中携带者JsessionId到后台,后台通过JsessionId从session中拿到用户信息,如果没有session信息,则进行拦截,如果有session信息,则将用户信息保存到threadLocal中,并且放行

1.3 、实现发送短信验证码功能

页面流程

具体代码如下

贴心小提示:

具体逻辑上文已经分析,我们仅仅只需要按照提示的逻辑写出代码即可。

  • 发送验证码
java 复制代码
    @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();
    }
  • 登录
java 复制代码
    @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();
    }

1.4、实现登录拦截功能

温馨小贴士: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就不一样,所以可以通过这种方式来做到线程隔离

拦截器代码

Java 复制代码
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;
    }
}

让拦截器生效

java 复制代码
@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);
    }
}

1.5、隐藏用户敏感信息

我们通过浏览器观察到此时用户的全部信息都在,这样极为不靠谱,所以我们应当在返回用户信息之前,将用户的敏感信息进行隐藏,采用的核心思路就是书写一个UserDto对象,这个UserDto对象就没有敏感信息了,我们在返回前,将有用户敏感信息的User对象转化成没有敏感信息的UserDto对象,那么就能够避免这个尴尬的问题了

在登录方法处修改

java 复制代码
//7.保存用户信息到session中
session.setAttribute("user", BeanUtils.copyProperties(user,UserDTO.class));

在拦截器处:

java 复制代码
//5.存在,保存用户信息到Threadlocal
UserHolder.saveUser((UserDTO) user);

在UserHolder处:将user对象换成UserDTO

java 复制代码
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();
    }
}

1.6、session共享问题

核心思路分析:

每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了

但是这种方案具有两个大问题

1、每台服务器中都有完整的一份session数据,服务器压力过大。

2、session拷贝数据时,可能会出现延迟

所以咱们后来采用的方案都是基于redis来完成,我们把session换成redis,redis数据本身就是共享的,就可以避免session共享的问题了

1.7 Redis代替session的业务流程

1.7.1、设计key的结构

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

1.7.2、设计key的具体细节

所以我们可以使用String结构,就是一个简单的key,value键值对的方式,但是关于key的处理,session他是每个用户都有自己的session,但是redis的key是共享的,咱们就不能使用code了

在设计这个key的时候,我们之前讲过需要满足两点

1、key要具有唯一性

2、key要方便携带

如果我们采用phone:手机号这个的数据来存储当然是可以的,但是如果把这样的敏感数据存储到redis中并且从页面中带过来毕竟不太合适,所以我们在后台生成一个随机串token,然后让前端带来这个token就能完成我们的整体逻辑了

1.7.3、整体访问流程

当注册完成后,用户去登录会去校验用户提交的手机号和验证码,是否一致,如果一致,则根据手机号查询用户信息,不存在则新建,最后将用户数据保存到redis,并且生成token作为redis的key,当我们校验用户是否登录时,会去携带着token进行访问,从redis中取出token对应的value,判断是否存在这个数据,如果没有则拦截,如果存在则将其保存到threadLocal中,并且放行。

1.8 基于Redis实现短信登录

这里具体逻辑就不分析了,之前咱们已经重点分析过这个逻辑啦。

UserServiceImpl代码

java 复制代码
@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);
}

1.9 解决状态登录刷新问题

1.9.1 初始方案思路总结:

在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录token令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的

1.9.2 优化方案

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

1.9.3 代码

RefreshTokenInterceptor

java 复制代码
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

java 复制代码
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;
    }
}

2、商户查询缓存

2.1 什么是缓存?

前言 :什么是缓存?

举个例子:越野车,山地自行车,都拥有"避震器",防止 车体加速后因惯性,在酷似"U"字母的地形上飞跃,硬着陆导致的损害,像个弹簧一样;

同样,实际开发中,系统也需要"避震器",防止过高的数据访问猛冲系统,导致其操作线程无法及时处理信息而瘫痪;

这在实际开发中对企业讲,对产品口碑,用户评价都是致命的;所以企业非常重视缓存技术;

缓存(Cache),就是数据交换的缓冲区 ,俗称的缓存就是缓冲区内的数据,一般从数据库中获取,存储于本地代码(例如:

java 复制代码
例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())之间的关系是固定的,不能改变,因此不用担心赋值(=)导致缓存失效;

2.1.1 为什么要使用缓存

一句话:因为速度快,好用

缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力

实际开发过程中,企业的数据量,少则几十万,多则几千万,这么大数据量,如果没有缓存来作为"避震器",系统是几乎撑不住的,所以企业会大量运用到缓存技术;

但是缓存也会增加代码复杂度和运营的成本:

2.1.2 如何使用缓存

实际开发中,会构筑多级缓存来使系统运行速度进一步提升,例如:本地缓存与redis中的缓存并发使用

浏览器缓存:主要是存在于浏览器端的缓存

**应用层缓存:**可以分为tomcat本地缓存,比如之前提到的map,或者是使用redis作为缓存

**数据库缓存:**在数据库中有一片空间是 buffer pool,增改查数据都会先加载到mysql的缓存中

**CPU缓存:**当代计算机最大的问题是 cpu性能提升了,但内存读写速度没有跟上,所以为了适应当下的情况,增加了cpu的L1,L2,L3级的缓存

2.2 添加商户缓存

在我们查询商户信息时,我们是直接操作从数据库中去进行查询的,大致逻辑是这样,直接查询数据库那肯定慢咯,所以我们需要增加缓存

@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
    //这里是直接查询数据库
    return shopService.queryById(id);
}
2.2.1 、缓存模型和思路

标准的操作方式就是查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入redis。

2.1.2、代码如下

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

2.3 缓存更新策略

缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,所以redis会对部分数据进行更新,或者把他叫为淘汰更合适。

**内存淘汰:**redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)

**超时剔除:**当我们给redis设置了过期时间ttl之后,redis会将超时的数据进行删除,方便咱们继续使用缓存

**主动更新:**我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题

2.3.1 、数据库缓存不一致解决方案:

由于我们的缓存的数据源来自于数据库 ,而数据库的数据是会发生变化的 ,因此,如果当数据库中数据发生变化,而缓存却没有同步 ,此时就会有一致性问题存在,其后果是:

用户使用缓存中的过时数据,就会产生类似多线程数据安全问题,从而影响业务,产品口碑等;怎么解决呢?有如下几种方案

Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案

Read/Write Through Pattern : 由系统本身完成,数据库与缓存的问题交由系统本身去处理

Write Behind Caching Pattern :调用者只操作缓存,其他线程去异步处理数据库,实现最终一致

2.3.2 、数据库和缓存不一致采用什么方案

综合考虑使用方案一,但是方案一调用者如何处理呢?这里有几个问题

操作缓存和数据库时有三个问题需要考虑:

如果采用第一个方案,那么假设我们每次操作数据库后,都操作缓存,但是中间如果没有人查询,那么这个更新动作实际上只有最后一次生效,中间的更新动作意义并不大,我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来

  • 删除缓存还是更新缓存?

    • 更新缓存:每次更新数据库都更新缓存,无效写操作较多
    • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存
  • 如何保证缓存与数据库的操作的同时成功或失败?

    • 单体系统,将缓存与数据库操作放在一个事务
    • 分布式系统,利用TCC等分布式事务方案

应该具体操作缓存还是操作数据库,我们应当是先操作数据库,再删除缓存,原因在于,如果你选择第一种方案,在两个线程并发来访问时,假设线程1先来,他先把缓存删了,此时线程2过来,他查询缓存数据并不存在,此时他写入缓存,当他写入缓存后,线程1再执行更新动作时,实际上写入的就是旧的数据,新的数据被旧数据覆盖了。

  • 先操作缓存还是先操作数据库?
    • 先删除缓存,再操作数据库
    • 先操作数据库,再删除缓存

2.4 实现商铺和缓存与数据库双写一致

核心思路如下:

修改ShopController中的业务逻辑,满足下面的需求:

根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间

根据id修改店铺时,先修改数据库,再删除缓存

修改重点代码1 :修改ShopServiceImpl的queryById方法

设置redis缓存时添加过期时间

修改重点代码2

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

2.5 缓存穿透问题的解决思路

缓存穿透 :缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。

常见的解决方案有两种:

  • 缓存空对象
    • 优点:实现简单,维护方便
    • 缺点:
      • 额外的内存消耗
      • 可能造成短期的不一致
  • 布隆过滤
    • 优点:内存占用较少,没有多余key
    • 缺点:
      • 实现复杂
      • 存在误判可能

**缓存空对象思路分析:**当我们客户端访问不存在的数据时,先请求redis,但是此时redis中没有数据,此时会访问到数据库,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库,我们都知道数据库能够承载的并发不如redis这么高,如果大量的请求同时过来访问这种不存在的数据,这些请求就都会访问到数据库,简单的解决方案就是哪怕这个数据在数据库中也不存在,我们也把这个数据存入到redis中去,这样,下次用户过来访问这个不存在的数据,那么在redis中也能找到这个数据就不会进入到缓存了

**布隆过滤:**布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中,

假设布隆过滤器判断这个数据不存在,则直接返回

这种方式优点在于节约内存空间,存在误判,误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突

2.6 编码解决商品查询的缓存穿透问题:

核心思路如下:

在原来的逻辑中,我们如果发现这个数据在mysql中不存在,直接就返回404了,这样是会存在缓存穿透问题的

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

小总结:

缓存穿透产生的原因是什么?

  • 用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力

缓存穿透的解决方案有哪些?

  • 缓存null值
  • 布隆过滤
  • 增强id的复杂度,避免被猜测id规律
  • 做好数据的基础格式校验
  • 加强用户权限校验
  • 做好热点参数的限流

2.7 缓存雪崩问题及解决思路

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

解决方案:

  • 给不同的Key的TTL添加随机值
  • 利用Redis集群提高服务的可用性
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存

2.8 缓存击穿问题及解决思路

缓存击穿问题也叫热点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把重建数据构建完后,其他线程才能走返回正确的数据。

这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。

进行对比

**互斥锁方案:**由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响

逻辑过期方案: 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦

2.9 利用互斥锁解决缓存击穿问题

核心思路:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是进行查询之后,如果从缓存没有查询到数据,则进行互斥锁的获取,获取互斥锁后,判断是否获得到了锁,如果没有获得到,则休眠,过一会再进行尝试,直到获取到锁为止,才能进行查询

如果获取到了锁的线程,再去进行查询,查询后将数据写入redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行操作数据库的逻辑,防止缓存击穿

操作锁的代码:

核心思路就是利用redis的setnx方法来表示获取锁,该方法含义是redis中如果没有这个key,则插入成功,返回1,在stringRedisTemplate中返回true, 如果有这个key则插入失败,则返回0,在stringRedisTemplate返回false,我们可以通过true,或者是false,来表示是否有线程成功插入key,成功插入的key的线程我们认为他就是获得到锁的线程。

java 复制代码
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);
}

操作代码:

java 复制代码
 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;
    }

3.0 、利用逻辑过期解决缓存击穿问题

需求:修改根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题

思路分析:当用户开始查询redis时,判断是否命中,如果没有命中则直接返回空数据,不查询数据库,而一旦命中后,将value取出,判断value中的过期时间是否满足,如果没有过期,则直接返回redis中的数据,如果过期,则在开启独立线程后直接返回之前的数据,独立线程去重构数据,重构完成后释放互斥锁。

如果封装数据:因为现在redis中存储的数据的value需要带上过期时间,此时要么你去修改原来的实体类,要么你

步骤一、

新建一个实体类,我们采用第二个方案,这个方案,对原来代码没有侵入性。

@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

步骤二、

ShopServiceImpl 新增此方法,利用单元测试进行缓存预热

在测试类中

步骤三:正式代码

ShopServiceImpl

java 复制代码
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;
}

3.1、封装Redis工具类

基于StringRedisTemplate封装一个缓存工具类,满足下列需求:

  • 方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
  • 方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓

存击穿问题

  • 方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
  • 方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题

将逻辑进行封装

java 复制代码
@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 中

java 复制代码
@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);
    }

3、优惠卷秒杀

3.1 -全局唯一ID

每个店铺都可以发布优惠券:

当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:

  • id的规律性太明显
  • 受单表数据量的限制

场景分析:如果我们的id具有太明显的规则,用户或者说商业对手很容易猜测出来我们的一些敏感信息,比如商城在一天时间内,卖出了多少单,这明显不合适。

场景分析二:随着我们商城规模越来越大,mysql的单表的容量不宜超过500W,数据量过大之后,我们要进行拆库拆表,但拆分表了之后,他们从逻辑上讲他们是同一张表,所以他们的id是不能一样的, 于是乎我们需要保证id的唯一性。

全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:

为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:

ID的组成部分:符号位:1bit,永远为0

时间戳:31bit,以秒为单位,可以使用69年

序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID

3.2 -Redis实现全局唯一Id

java 复制代码
@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就不再阻塞,统计出来的时间也就是所有分线程执行完后的时间。

java 复制代码
@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));
}

3.3 添加优惠卷

每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购:

tb_voucher:优惠券的基本信息,优惠金额、使用规则等

tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息

平价卷由于优惠力度并不是很大,所以是可以任意领取

而代金券由于优惠力度大,所以像第二种卷,就得限制数量,从表结构上也能看出,特价卷除了具有优惠卷的基本信息以外,还具有库存,抢购时间,结束时间等等字段

**新增普通卷代码: **VoucherController

java 复制代码
@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {
    voucherService.save(voucher);
    return Result.ok(voucher.getId());
}

新增秒杀卷代码:

VoucherController

java 复制代码
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
    voucherService.addSeckillVoucher(voucher);
    return Result.ok(voucher.getId());
}

VoucherServiceImpl

java 复制代码
@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());
}

3.4 实现秒杀下单

下单核心思路:当我们点击抢购时,会触发右侧的请求,我们只需要编写对应的controller即可

秒杀下单应该思考的内容:

下单时需要判断两点:

  • 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
  • 库存是否充足,不足则无法下单

下单核心逻辑分析:

当用户开始进行下单,我们应当去查询优惠卷信息,查询到优惠卷信息,判断是否满足秒杀条件

比如时间是否充足,如果时间充足,则进一步判断库存是否足够,如果两者都满足,则扣减库存,创建订单,然后返回订单id,如果有一个条件不满足则直接结束。

VoucherOrderServiceImpl

java 复制代码
@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);

}

3.5 库存超卖问题分析

有关超卖问题分析:在我们原有代码中是这么写的

java 复制代码
 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 是为了在操作失败时,再次进行自旋操作,即把之前的逻辑再操作一次。

java 复制代码
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无法执行成功

3.6 乐观锁解决超卖问题

修改代码方案一、

VoucherOrderServiceImpl 在扣减库存时,改为:

java 复制代码
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 即可

java 复制代码
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的值进行递增,最后返回一个完整的值

3.6 优惠券秒杀-一人一单

需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单

现在的问题在于:

优惠卷是为了引流,但是目前的情况是,一个人可以无限制的抢这个优惠卷,所以我们应当增加一层逻辑,让一个用户只能下一个单,而不是让一个用户下多个单

具体操作逻辑如下:比如时间是否充足,如果时间充足,则进一步判断库存是否足够,然后再根据优惠卷id和用户id查询是否已经下过这个订单,如果下过这个订单,则不再下单,否则进行下单

VoucherOrderServiceImpl

初步代码:增加一人一单逻辑

java 复制代码
@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 锁

java 复制代码
@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()方法

java 复制代码
@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.的方式调用的,事务想要生效,还得利用代理来生效,所以这个地方,我们需要获得原始的事务对象, 来操作事务

3.7 集群环境下的并发问题

通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。

1、我们将服务启动两份,端口分别为8081和8082:

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

具体操作(略)

有关锁失效原因分析

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

4、分布式锁

4.1 、基本原理和实现方式对比

分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。

分布式锁的核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路

那么分布式锁他应该满足一些什么样的条件呢?

可见性:多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思

互斥:互斥是分布式锁的最基本的条件,使得程序串行执行

高可用:程序不易崩溃,时时刻刻都保证较高的可用性

高性能:由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能

安全性:安全也是程序中必不可少的一环

常见的分布式锁有三种

Mysql:mysql本身就带有锁机制,但是由于mysql性能本身一般,所以采用分布式锁的情况下,其实使用mysql作为分布式锁比较少见

Redis:redis作为分布式锁是非常常见的一种使用方式,现在企业级开发中基本都使用redis或者zookeeper作为分布式锁,利用setnx这个方法,如果插入key成功,则表示获得到了锁,如果有人插入成功,其他人插入失败则表示无法获得到锁,利用这套逻辑来实现分布式锁

Zookeeper:zookeeper也是企业级开发中较好的一个实现分布式锁的方案,由于本套视频并不讲解zookeeper的原理和分布式锁的实现,所以不过多阐述

4.2 、Redis分布式锁的实现核心思路

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

  • 获取锁:

    • 互斥:确保只能有一个线程获取锁
    • 非阻塞:尝试一次,成功返回true,失败返回false
  • 释放锁:

    • 手动释放
    • 超时释放:获取锁时添加一个超时时间

核心思路:

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

4.3 实现分布式锁版本一

  • 加锁逻辑

锁的基本接口

SimpleRedisLock

利用setnx方法进行加锁,同时增加过期时间,防止死锁,此方法可以保证加锁和增加过期时间具有原子性

java 复制代码
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

释放锁,防止删除别人的锁

java 复制代码
public void unlock() {
    //通过del删除锁
    stringRedisTemplate.delete(KEY_PREFIX + name);
}
  • 修改业务代码
java 复制代码
  @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();
        }
    }

4.4 Redis分布式锁误删情况说明

逻辑说明:

持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是误删别人锁的情况说明

解决方案:解决方案就是在每个线程释放锁的时候,去判断一下当前这把锁是否属于自己,如果属于自己,则不进行锁的删除,假设还是上边的情况,线程1卡顿,锁自动释放,线程2进入到锁的内部执行逻辑,此时线程1反应过来,然后删除锁,但是线程1,一看当前这把锁不是属于自己,于是不进行删除锁逻辑,当线程2走到删除锁逻辑时,如果没有卡过自动释放锁的时间点,则判断当前这把锁是属于自己的,于是删除这把锁。

4.5 解决Redis分布式锁误删问题

需求:修改之前的分布式锁实现,满足:在获取锁时存入线程标示(可以用UUID表示)

在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致

  • 如果一致则释放锁
  • 如果不一致则不释放锁

核心逻辑:在存入锁时,放入自己线程的标识,在删除锁时,判断当前这把锁的标识是不是自己存入的,如果是,则进行删除,如果不是,则不进行删除。

具体代码如下:加锁

java 复制代码
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);
}

释放锁

java 复制代码
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值并非是自己,所以不能释放锁,也就无法删除别人的锁,此时第二个线程能够正确释放锁,通过这个案例初步说明我们解决了锁误删的问题。

4.6 分布式锁的原子性问题

更为极端的误删逻辑说明:

线程1现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,而且已经走到了条件判断的过程中,比如他已经拿到了当前这把锁确实是属于他自己的,正准备删除锁,但是此时他的锁到期了,那么此时线程2进来,但是线程1他会接着往后执行,当他卡顿结束后,他直接就会执行删除锁那行代码,相当于条件判断并没有起到作用,这就是删锁时的原子性问题,之所以有这个问题,是因为线程1的拿锁,比锁,删锁,实际上并不是原子性的,我们要防止刚才的情况发生,

4.7 Lua脚本解决多条命令原子性问题

Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,它的基本语法大家可以参考网站:https://www.runoob.com/lua/lua-tutorial.html,这里重点介绍Redis提供的调用函数,我们可以使用lua去操作redis,又能保证他的原子性,这样就可以实现拿锁比锁删锁是一个原子性动作了,作为Java程序员这一块并不作一个简单要求,并不需要大家过于精通,只需要知道他有什么作用即可。

这里重点介绍Redis提供的调用函数,语法如下:

lua 复制代码
redis.call('命令名称', 'key', '其它参数', ...)

例如,我们要执行set name jack,则脚本是这样:

lua 复制代码
# 执行 set name jack
redis.call('set', 'name', 'jack')

例如,我们要先执行set name Rose,再执行get name,则脚本如下:

lua 复制代码
# 先执行 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脚本就会变成这样

lua 复制代码
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
  -- 一致,则删除锁
  return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0

4.8 利用Java代码调用Lua脚本改造分布式锁

lua脚本本身并不需要大家花费太多时间去研究,只需要知道如何调用,大致是什么意思即可,所以在笔记中并不会详细的去解释这些lua表达式的含义。

我们的RedisTemplate中,可以利用execute方法去执行lua脚本,参数对应关系就如下图股

Java代码

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的分布式锁实现思路:

  • 利用set nx ex获取锁,并设置过期时间,保存线程标示
  • 释放锁时先判断线程标示是否与自己一致,一致则删除锁
    • 特性:
      • 利用set nx满足互斥性
      • 利用set ex保证故障时锁依然能释放,避免死锁,提高安全性
      • 利用Redis集群保证高可用和高并发特性

笔者总结:我们一路走来,利用添加过期时间,防止死锁问题的发生,但是有了过期时间之后,可能出现误删别人锁的问题,这个问题我们开始是利用删之前 通过拿锁,比锁,删锁这个逻辑来解决的,也就是删之前判断一下当前这把锁是否是属于自己的,但是现在还有原子性问题,也就是我们没法保证拿锁比锁删锁是一个原子性的动作,最后通过lua表达式来解决这个问题

但是目前还剩下一个问题锁不住,什么是锁不住呢,你想一想,如果当过期时间到了之后,我们可以给他续期一下,比如续个30s,就好像是网吧上网, 网费到了之后,然后说,来,网管,再给我来10块的,是不是后边的问题都不会发生了,那么续期问题怎么解决呢,可以依赖于我们接下来要学习redission啦

测试逻辑:

第一个线程进来,得到了锁,手动删除锁,模拟锁超时了,其他线程会执行lua来抢锁,当第一天线程利用lua删除锁时,lua能保证他不能删除他的锁,第二个线程删除锁时,利用lua同样可以保证不会删除别人的锁,同时还能保证原子性。

5、分布式锁-redission

5.1 分布式锁-redission功能介绍

基于setnx实现的分布式锁存在下面的问题:

重入问题:重入问题是指 获得锁的线程可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,比如HashTable这样的代码中,他的方法都是使用synchronized修饰的,假如他在一个方法内,调用另一个方法,那么此时如果是不可重入的,不就死锁了吗?所以可重入锁他的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的。

不可重试:是指目前的分布式只能尝试一次,我们认为合理的情况是:当线程在获得锁失败后,他应该能再次尝试获得锁。

**超时释放:**我们在加锁时增加了过期时间,这样的我们可以防止死锁,但是如果卡顿的时间超长,虽然我们采用了lua表达式防止删锁的时候,误删别人的锁,但是毕竟没有锁住,有安全隐患

主从一致性: 如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题。

那么什么是Redission呢

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

Redission提供了分布式锁的多种多样的功能

5.2 分布式锁-Redission快速入门

引入依赖:

java 复制代码
<dependency>
	<groupId>org.redisson</groupId>
	<artifactId>redisson</artifactId>
	<version>3.13.6</version>
</dependency>

配置Redisson客户端:

java 复制代码
@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的分布式锁

java 复制代码
@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

java 复制代码
@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();
        }
 }

5.3 分布式锁-redission可重入锁原理

在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)的自旋抢锁。

lua 复制代码
"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]);"

5.4 分布式锁-redission锁重试和WatchDog机制

说明:由于课程中已经说明了有关tryLock的源码解析以及其看门狗原理,所以笔者在这里给大家分析lock()方法的源码解析,希望大家在学习过程中,能够掌握更多的知识

抢锁过程中,获得当前线程,通过tryAcquire进行抢锁,该抢锁逻辑和之前逻辑相同

1、先判断当前这把锁是否存在,如果不存在,插入一把锁,返回null

2、判断当前这把锁是否是属于当前线程,如果是,则返回null

所以如果返回是null,则代表着当前这哥们已经抢锁完毕,或者可重入完毕,但是如果以上两个条件都不满足,则进入到第三个条件,返回的是锁的失效时间,同学们可以自行往下翻一点点,你能发现有个while( true) 再次进行tryAcquire进行抢锁

java 复制代码
long threa
相关推荐
武子康1 分钟前
大数据-230 离线数仓 - ODS层的构建 Hive处理 UDF 与 SerDe 处理 与 当前总结
java·大数据·数据仓库·hive·hadoop·sql·hdfs
武子康3 分钟前
大数据-231 离线数仓 - DWS 层、ADS 层的创建 Hive 执行脚本
java·大数据·数据仓库·hive·hadoop·mysql
苏-言10 分钟前
Spring IOC实战指南:从零到一的构建过程
java·数据库·spring
界面开发小八哥17 分钟前
更高效的Java 23开发,IntelliJ IDEA助力全面升级
java·开发语言·ide·intellij-idea·开发工具
草莓base30 分钟前
【手写一个spring】spring源码的简单实现--容器启动
java·后端·spring
Allen Bright43 分钟前
maven概述
java·maven
编程重生之路1 小时前
Springboot启动异常 错误: 找不到或无法加载主类 xxx.Application异常
java·spring boot·后端
薯条不要番茄酱1 小时前
数据结构-8.Java. 七大排序算法(中篇)
java·开发语言·数据结构·后端·算法·排序算法·intellij-idea
努力进修1 小时前
“探索Java List的无限可能:从基础到高级应用“
java·开发语言·list
politeboy1 小时前
k8s启动springboot容器的时候,显示找不到application.yml文件
java·spring boot·kubernetes