面试

Redis-项目

更新策略

内存淘汰:什么也不用管 让redis自己维护 超出一定空间就会删除缓存 然后下次查询时更新缓存
超时剔除:给KEY加上TTL过期时长 到期后redis就会删除
主动更新:自己编写业务逻辑 在修改数据库时更新缓存
主动更新一般采用先写数据库再删除缓存
最佳实践就是采用主动更新策略然后用超时剔除兜底

缓存问题

缓存穿透
就是请求的数据在缓存中和数据库中不存在 请求会直接打到数据库上 如果请求量过大 就会对数据库造成较大压力
解决策略

  • 返回空值 最简单 但是会占用过多内存 存在数据不一致问题
  • 布隆过滤 实现起来麻烦 而且会存在误判的可能性
  • 增加ID复杂度 避免ID被猜测
  • 做好参数限流
  • 做好响应权限和基础校验格式

缓存雪崩
就是大量缓存KEY失效或者redis服务宕机造成请求都打到数据库压力过大
解决策略

  • 对key有效时长增加随机TTL
  • 做好redis集群服务
  • 做好服务降级策略 如果大量请求打过来 先快速响应失败一部分请求 减缓压力
  • 给服务添加多级缓存

缓存击穿
就是热点KEY问题 某些高并发且重建服务比较复杂的KEY失效 导致压力过大
解决策略

  • 互斥锁 setnx命令来实现
  • 逻辑过期

互斥锁就是查询如果失效 会获取互斥锁 然后开始重建热点key 在这期间其他试图获取互斥锁就会失效
逻辑过期就是不设置TTL 用逻辑时间来表示是否过期 如果过期需要重建服务 也获取互斥锁 然后新开一个线程进行重建热点key 然后直接返回过期数据 在重建热点key期间 如果有其他线程进来 也会获取互斥锁失败 然后直接返回过期数据 这就是跟互斥锁来重建不同的
第一种方法重建过程中其他线程来访问需要等待 如果时间过长就会造成阻塞 实现简单 可以保证一致性 但是性能较低 甚至可能有死锁风险
第二种直接用过期数据来返回 更快 性能更好 但是无法保证一致性问题 还会有额外的内存消耗

超卖问题

多线程引发超卖 导致库存为负数 解决超卖问题就需要加锁
一般用乐观锁解决超卖问题 分为两种

  • 版本号法 给数据库设置一个新的属性版本号 然后查询的时候查库存和版本号 进行修改的时候使version + 1并加上where条件 除了where id = ?之外加一个version = ?

  • CAS法 就是对版本号进行更新
    比如库存stock 直接就是where条件判断stock等不等于查询的时候即可 也可以更加快捷一下 直接判断stock>0即可 这样只需要保证stock大于0 直接扣减库存肯定不会出问题

分布式锁

分布式锁在redis中实现是setnx实现 然后由于还需要设置超时时间 所以汇聚到一条命令里set key value nx ex 10 成功返回true 失败返回false 但是由于stringRedisTemplate是用的Boolean封装 而普通的是boolean 所以需要拆箱 拆箱过程中就有可能因业务阻塞引起误删锁问题
因此为了解决误删问题 引入了lua脚本
但是同样用纯redis实现分布式锁还是会有问题

  • 不可重入 锁只能获取一次 如果同一个线程获取多个锁 就不行
  • 不可重试 只能获取一次 获取失败直接返回结果
  • 主从一致问题
  • 超时释放 如果业务执行时间较长 有可能会释放锁 导致安全问题

因此引入Redisson

分布式锁-Redisson

Redisson解决了由纯redis引入分布式锁的问题

  • 可重入性 底层采用哈希结构来记录当前线程获取锁的次数 每次锁多获取一次 就重置TTL 然后释放的时候判断锁次数是不是0 如果是0 就释放 如果不是0 就-1 然后重置TTL
  • 可重试 Redisson利用订阅发布功能 获取失败并不会返回失败 而是会等待 等其他线程释放锁之后发布释放信号 然后订阅锁开始获取 达到重试功能
  • 主从一致性 Redisson解决该问题用了联锁 所有的节点都会获取一遍锁 只有当所有节点都获取之后 才算获取锁成功
  • 超时释放 Redisson采用看门狗机制来进行续约 每隔一段时间都会重置超时时间

Redis消息队列

Redis消息队列分三种实现方式

  • List 该方式支持持久化 但是不支持消息确认和消息回溯
  • PubSub 不支持持久化以及消息确认和消息回溯
  • Stream 支持持久化 消息确认和消息回溯

Stream实现的消息队列获取消息后会先存入Steam中 然后等待消费者获取确认 如果消费者获取了没有确认 那么这个消息就会存入一个pending-list中 然后从这个list中取消息处理 处理完成之后会发送XACK来确认消息被消费

秒杀业务优化

主线程通过lua脚本核算资格并检验是否重复下单后并发送到消息队列然后直接创建订单返回 然后通过后置处理器在容器创建之初就通过线程池异步开启线程来从steam消息队列中获取消息 然后执行创建订单确认消费 如果没有消费就从pending-list获取未被消费的消息 继续执行 并通过Redisson获取分布式锁锁保证一人一单 然后创建订单并用数据库CAS来预防超卖问题 由于存在自调用 防止切面导致事务失效 所以在主线程中获取了代理对象 然后在子线程处理创建订单通过代理对象处理 使事务成功

Redis-原理

数据结构

字符串SDS

  • 获取字符串时间复杂度O(1)
  • 支持动态扩容
  • 二进制安全 因为记录了总字节数 所以遍历直接根据总字节数遍历 即可 不用管有没有'0'
  • 减少内存分配 直接动态扩容即可 不用再申请另外的内存空间

IntSet

IntSet基于数组实现 有长度可变 有序的特征 采用统一的编码方式 便于根据字节数快速查找 如果存储数据超过当前编码方式大小之后 就会自动进行编码升级

  • 确定所超过的数据最适合的编码方式
  • 然后将原数组倒序扩容到正确位置
  • 插入新数据
  • 更改头中的编码方式

有序底层是基于二分查找来实现的 在二分查找过程中 如果找到相同数据 会直接返回原数组 来确保数据唯一

  • IntSet中数据唯一
  • 具有编码方式升级的功能 节省内存空间
  • 底层采用二分法查找实现

Dict

Dict是数组结合指针实现 就是先创建Dict头 然后确定size总大小 并且还会创建两个DictHashTable 一个是存储数据 另一个就是做rehash会用到 然后会根据key算出hash值 再让求到的hash值跟sizemask与运算得到所在数组的具体位置 这个数组就会指向每一个Dict

在Dict中有一个负载因子(used/size) 当负载因子超过1且没有执行bgsave或超过5会扩容 每次删除key检查负载因子小于0.1就会收缩 无论收缩还是扩容 由hash值计算的索引都会失效 所以都会进行rehash 然后由于rehash要进行大量读写 所以一般是渐进性rehash
rehash流程

  • 先计算新的size 根据used来算
  • 然后根据size申请新的空间
  • 每次增删改查就判断rehashidx 如果大于1 就把dict[0]下的一个索引下的数据都rehash到dict[1] 直到所有索引全部rehash完成
  • 然后把dict[1]给到dict[0] 原来的dict[0]释放
  • 把rehashidx = -1 代表rehash结束

ZipList

可以看成时一种特殊的双端链表 只不过没有指针 是由一段连续的内存空间实现 会动态分配内存 且两端压入/弹出都是O(1) 每一个entry中会记录前一个和自身的占用字节 然后来实现计算

  • 连续空间的双端链表
  • 没有指针 靠记录上一个和自身的字节数来寻址 内存占用低
  • 数据量过大会导致链表过长 影响查询性能
  • 删除较多较大数据会出现连锁更新问题

QuickList

解决ZipList的问题 引入了QucikList QuickList是一个双端链表 不过每一个节点都是ZipList

  • 节点为ZipList的双端链表
  • 节点ZipList解决了传统链表资源占用问题
  • 解决了因ZipList过大而申请效率的问题
  • 中间节点可以进一步压缩 更节省空间

SkipList

跳表是双端链表 并且包含多个指针 每个指针跨度不同 而且是升序排列

  • 是一个双端链表 每个节点都有score和ele score来排序 score一样就按ele排序 数据在ele中存储
  • 不同的指针层级最大为32层 越大跨度越高
  • 查询效率和红黑树一致 但是实现简单

RedisObject

String
String底层用SDS实现 如果是小于LONG_MAX的整数 会取消SDS 直接存储在ptr指针里
List
采用QuickList实现
Set
由于元素唯一无序 因此使用Dict实现 value为空 只在key中存入数据 不过如果Set中都是整数且元素不超过默认最大数量时 会采用IntSet实现
ZSet
元素有序唯一并且还要根据key找value 所以底层使用Dict和SkipList实现 SkipList确保有序Dict确保唯一 但是占用内存很大 所以当数据量过小时会采用ZipList实现 手动维护排序逻辑
Hash
默认采用ZipList实现 两个Entry一个是key一个是value 如果数据量过大就会采用Dict实现

IO模型

影响读写的最大因素就是等待内核响应和把数据写到缓冲区 所以就会产生多种IO模型
阻塞IO
发送recvform命令 然后阻塞等待内核响应 相应之后开始拷贝 拷贝过程中也是阻塞等待

非阻塞IO
也是发送recvform命令 但是内核会直接返回结果 如果没有数据就返回异常信息 然后用户会反复发起请求 直到有结果响应为止 响应之后拷贝数据同样是阻塞的 虽然第一阶段是非阻塞的 但是由于一直在发请求 造成CPU忙等 导致CPU使用率提高

IO多路复用

IO多路复用会发送select命令来监听FD(文件描述符) 包含socket 然后监听多个FD 如果内核态数据准备就绪会返回响应告知用户态 此时用户态发送recvform命令来拷贝数据 虽然第二阶段仍然是阻塞拷贝 第一阶段虽然也是等待 但是由于监听多个FD 只要有一个准备好了就可以开始准备数据 因此是一种有效等待
实现监听FD的方式常见有三种 select poll和epoll
select和poll这两种监听FD 当FD准备就绪之后会把所有的FD全部拷贝到用户空间 让用户去遍历获得已就绪的FD 然后遍历完再把整个FD拷贝给内核态
而epoll会建立一个红黑树来添加FD 并且每个FD都有一个回调函数 如果FD就绪 就会把已就绪的FD放到就绪数组中 然后直接把数组中已就绪的FD拷贝到用户空间即可
epoll时间通知方式分为LT和ET 默认是LT 就是重复通知多次直到数据处理完 ET就是只通知一次 不管处理没处理完
LT会导致惊群问题 只需要两个线程 但是由于一直在通知 导致所有的线程都被唤醒 最佳实践是ET只通知一次 然后采取非阻塞IO一直读 直到读完

Redis网络模型
Redis网络模型就是采取基于epoll实现的IO多路复用 先创建epoll实例监听不同的FD也就是socket 然后监听到就绪之后做事件派发 把socket派发给不同的处理器 分为连接应答处理器 命令请求处理其 命令回复处理器 在Redis6.0之后 命令回复处理器改为了多线程 然后命令请求解析也变成了多线程 但是核心的命令执行还是单线程

内存回收策略

分为惰性删除和周期删除

  • 惰性删除
    key过期后不会立即删除 而是会等到下一次访问这个key时 检查这个key的过期时间 如果过期了 再删除
    问题就是如果有的key频率很低 过期了一直不会删除
  • 周期删除
    分为两种模式 FAST和SLOW SLOW模式默认频率时10hz 每次耗时不超过25ms FAST模式两次间隔不能低于2ms 耗时不能超过1ms

一般都是二者结合组成内存回收策略

内存淘汰策略

其实就是分为两大类四种 一类是对有TTL的操作 一类是对全体的key操作 四种分别是一个随机KEY 一个算出最小TTL 一个是LRU 一个LFU 默认是不淘汰任何key

Redis持久化

分为RDB和AOF两种
RDB
是Redis数据备份文件 也叫数据快照 即使把Redis中的所有数据备份到磁盘 然后Redis如果故障 就可以通过读取磁盘的文件 达成快速数据恢复
RDB一般是通过bgsave命令来开启一个子线程去备份数据 子线程会复制一个页表 根据页表的映射关系向磁盘中存储数据快照
AOF
是追加文件 Redis每一个命令都会记录下来 命令日志文件 可以通过配置开启并设置记录频率 一般是一秒记录一次 最多丢失一秒数据 还可以通过bgrewriteof来对命令进行重写 就是以最少的命令来完成数据的记录
一般项目中都是二者结合使用

Redis集群服务

主从集群

主从集群提高Redis的并发读 分为一个主节点 多个从节点 主节点可读可写 从节点只能读
同步原理
分为全量同步和增量同步 区别就是判断节点的replId是否一致 如果不一致代表是第一次连接 就执行bgsave命令把生成的RDB文件发给从节点 让从节点的replId和所有的数据一样 如果判断replId一致 代表从节点是断网重连 因此需要做增量同步 在主节点中有一个循环数组repl_backlog 主节点的offset减去从节点的offset 多出来的就是需要做增量同步的一部分 把这些缺少的发给从节点即可完成增量同步 之后主节点每次写数据 都会传给从节点实现实时同步

哨兵集群

通过哨兵集群来检测主从集群的服务状态 分为三个作用

  • 监控 哨兵会定时通过心跳检测主从集群的每个节点服务状态
  • 自动故障切换 如果其中一个哨兵发现主节点故障 会认为该主节点主观下线 如果超出半数以上的哨兵都认为主节点故障 就是客观下线 就会在从节点中选出一个作为主节点 一般规则就是看从节点的offset哪个最大
  • 通知 当更换主节点时 哨兵会把更换的主节点信息推送给redis客户端 也会告诉其他从节点新的主节点信息 如果之前主节点恢复 也会变为从节点

哨兵脑裂问题
是由于网络原因导致哨兵检测不到主节点 因此会在从节点选举出新的主节点 但是主节点并没有故障 还是跟客户端连接 不停读写数据 因此造成多个主节点 并且数据不同步 网路恢复后 会将之前的主节点变成从节点 然后进行数据同步导致丢失数据 就是脑裂现象 解决该问题就是可以通过修改配置来解决 比如至少有一个从节点才能同步数据 第二个可以设置数据复制和同步的延迟时间 减少数据丢失

分片集群

主从哨兵解决了高并发读和高可用的问题 但是还存在高并发写和大量存储的问题 就是分片集群
分片集群分为多个主节点 每个主节点可以有多个从节点 然后每个主节点间会通过心跳检测 省去了哨兵 然后通过散列插槽来进行分片集群的存储问题 会根据key做hash运算 然后对16384取余 得出结果就是在分片集群中的位置 这样保证可以根据key拿到信息 解决高并发写以及大量存储数据的问题

Mysql

慢查询

如何定位慢查询

慢查询表象就是查询时间过长 接口压测响应时间超过1s
开源工具
Skywalking来定位慢查询
Mysql自带慢日志
通过在Mysql中开启慢日志查询开关 然后配置最大时长 如果sql语句超过该时长 就会记录到log日志文件中

分析sql

在查询语句之前加上explain或者desc 就能得到sql查询语句的信息

key:命中的索引 key_len:索引大小
通过key和key_len来查看是否命中索引 Extra是优化建议 看看sql需不需要做回表 type是sql连接类型 性能由好到坏分为NULL system const eq_ref ref range index all
const:主键查询
eq_ref:主键索引查询或唯一索引查询
ref:索引查询
range:范围查询
index:索引树扫描
all:全盘扫描

  • sql执行很慢 如何分析
    采用mysql自带的分析工具EXPLAIN
  • 根据key和key_len查看是否命中了索引
  • 根据type类型分析是否需要进一步优化 是否存在全盘扫描或索引树
  • 根据extra判断是否出现了回表 如果是就添加索引或修改返回字段

索引

什么是索引

索引就是帮助mysql高效获取数据的有序数据结构 在数据之外 数据库系统维护者满足特定算法的数据结构(B+树) 这些数据结构以某种方式指向数据 就可以在mysql中实现高级查找算法 这种数据结构就是索引

索引的底层数据结构

mysql底层使用的是B+树 其他的二叉搜索树存在最坏情况 红黑树也是二叉树 如果数据量很大 层级就会很高 同样查找会慢

B树 B树是多叉路平衡查找树 balance 有多个分支 每个数据之间通过指针指向下一层级的数据 下面就是5阶的B树 存四个数据 五个指针

B+树是对B树的优化 只有叶子节点有数据 其他节点都是指针存储 mysql中的存储引擎InnoDB就是用B+树实现的索引结构

优势
由于其他节点不带数据 遍历的时候不会把额外数据查上 所以磁盘读写代价B+树更低
查询效率B+树更稳定
B+树叶子节点通过双端链表连接 更便于做扫库和区间查询

  • 什么是索引
    索引是帮助mysql高效查找数据的一种有序数据结构
    索引能够提高检索效率 不需要全盘扫描 降低数据库IO成本
    通过索引对数据排序 降低排序成本 减少CPU损耗

  • 索引底层的数据结构
    Mysql的InnoDB引擎采用的是B+树的数据结构来存储索引
    阶数更短 层级更少
    磁盘读写代价B+树更低 非叶子节点只存储指针 叶子节点存储数据
    叶子节点用双端链表连接 便于扫库和区间查询

聚簇索引 非聚簇索引 回表查询

聚簇索引
只有一个 以主键创建索引 保存的数据是整行的数据
非聚簇索引 也叫二级索引
可以有多个 保存的数据是当前行的主键 如果没有主键 就会用唯一索引 如果没有唯一索引 InnoDB就会创建一个rowID

回表查询
比如查询语句select * from user where name = 'Arm' 因为name创建了索引 所以会先走二级索引 找到name对应的数据 是主键 然后回去再根据主键走聚簇索引 拿到主键对应的整行数据 就完成了查询

  • 什么是聚簇索引 什么是非聚簇索引
    聚簇索引只有一个 就是把数据和索引放在一块 B+树的叶子节点保存了整行的信息 一般都是主键
    非聚簇索引可以有多个 数据和索引分开存储 B+树叶子节点保存了主键 无主键保存唯一索引 无唯一索引保存rowID
  • 什么是回表查询
    就是通过二级查询拿到对应的主键 然后再根据主键走聚簇索引拿到整行的数据

覆盖索引 超大分页优化

覆盖索引
查询用到了索引 然后返回的信息在索引的查询中能够全部得到
简而言之就是不涉及回表查询的索引查询就是覆盖索引

  • 什么是覆盖索引
    覆盖索引就是查询用到了索引并且返回的列中能够在索引查询中一次性全部得到

  • 超大分页怎么处理
    mysql数据量过大时 分页深度越深 limit做分页查询 需要对数据进行排序 耗时过长
    解决思路是覆盖索引加子查询 就是通过id查询也就是根据覆盖索引把数据分页排好序 然后去跟原来表进行关联查询 就可以提升性能

  • 索引创建原则有哪些
    单表数据量大 且查询比较频繁的表
    常作为查询 排序 分页的字段需要创建索引
    尽量联合索引 避免回表查询
    控制索引数量 越多维护成本越大
    字段内容区分度高
    内容长使用前缀索引
    索引不能使用null值 用not null约束

索引失效

  1. 违反最左前缀法则
    如果索引了多列 就是联合索引好几个字段 使用索引查询需要从最左前列开始 才会命中索引 如果跳过索引 后续就会失效不命中
    也就是需要按照顺序来用索引 否则就会失效
  2. 范围查询右边的列
    如果查询的时候 在中间的索引用来范围查询 那么该索引右边的列 就都不会命中
  3. 索引列上进行运算操作 索引失效
  4. 字符串不加单引号 mysql会进行类型转换 从而导致 索引失效
  5. 以%开头的like模糊查询 索引失效 如果%放在末尾 正常命中
  • 什么情况下索引会失效
    使用联合索引时违反了最左前缀法则
    范围查询时 右边的列索引会失效
    索引的列不能进行运算
    以%开头的模糊查询 索引会失效
    字符串不加单引号 mysql会进行类型转换 索引会失效

sql优化

表的设计优化
参考了阿里的开发手册
设计合适的数值 int bigint 根据实际情况选择
设计合适的字符串类型 char和varchar char定长效率高 varchar可变效率低
sql语句优化
select指明字段名称 避免直接用* 预防回表查询
sql语句避免索引失效的情况
用union all代替union union会多一次过滤的操作 效率低
避免在where字句中对字段进行表达式操作
join优化能用innerjoin就不用left join right join 因为内连接会对两个表进行优化 以小表为驱动
主从复制 读写分离
如果有大量操作都是读 为了避免写操作影响读操作的情况 就可以搭建主从复制 来进行读写分离

  • 对sql优化的经验
    表的设计优化
    索引优化 索引创建原则
    sql优化
    主从复制 读写分离
    分库分表

事务

事务特性

原子性(Atomicity) 要么都成功 要么都失败
隔离性(Consistency) 不能被其他事务干扰
一致性(Isolation) 数据要一致
持久性(Durability) 进行落盘操作 就是持久化
转账案例结合说明

并发事务问题 隔离级别

并发事务问题
脏读:一个事务读到了另一个事务还没提交的数据

不可重复读:一个事务先后读取同一条记录 读取的数据不同
幻读:在解决了不可重复读的问题上 一个事务先查询数据 发现没有数据 插入数据时 发现数据已经存在 再查询 还是查询不到 就是幻读 其实是另一个事务插入了数据

隔离级别
未提交读 不能解决任何问题
读已提交 可以解决脏读 但是不能解决后两种
可重复读 解决了脏读 不可重复度 mysql默认使用 无法解决幻读
串行化 解决所有 但是不推荐 因为一个事务必须提交才能让另一个事务访问 并发效率不好

redo log和undo log

mysql中有两个结构
缓冲池:内存中的一个区域 存放着磁盘中经常操作的数据 可以减少磁盘IO
数据页:InnoDB引擎中管理的最小单元 存放着数据 默认一页16kb
mysql操作时 会先操作缓冲池 如果缓冲池中没有数据 就加载磁盘并缓冲到内存的缓冲池中 然后操作完再从内存刷新到磁盘中 但是如果还没刷新 mysql宕机了 就会破坏持久化 就要用到redo log
redo log
重做日志 用来记录事务提交时数据页的修改 来实现事务的持久性
日志文件由两部分组成 重做日志缓冲(redo log buffer)和重做日志文件(redo log file)前者存在内存 后者存在磁盘 当事务提交之后会把数据同步到该日志文件中 如果发生错误导致数据丢失 就根据文件来恢复数据

undo log
回滚日志 用来记录数据被修改前的信息 作用包含回滚MVCC redo log是物理日志 undo log是逻辑日志
比如如果删除一条信息 undo log就记录新增一条信息
如果更新一条信息 undo log就记录更新之前的信息
undo log 可以用来确保事务的一致性和原子性

  • undo log 和redo log 的区别
    redo log记录的数据页的物理变化 服务宕机是可以用来同步数据
    undo log记录的是逻辑日志 事务回滚时可以逆操作来恢复数据
    redo log保证了事务的持久性 undo log 保证了事务的原子性和一致性

MVCC

事务的隔离性如何实现
通过排他锁和MVCC
排他锁:一个事务获取了数据行的排他锁 其他事务就不能再获取改行的其他锁
MVCC:多版本并发控制
MVCC
Multi-Version-Concurrency-Control 多版本并发控制 维护一个数据的多个版本 使读写操作没有冲突
MVCC的具体实现 依赖数据库的隐式字段 undo log日志 readView

  • 隐藏字段

    DB_TRX_ID:最近修改事务ID 记录插入这条记录或最后一次修改这条记录的事务ID
    DB_ROLL_PTR:回滚指针 指向这条记录的上一个版本 配合undo log 指向上一个版本
    DB_ROW_ID:隐藏主键 如果没有主键 会生成该隐藏主键

  • undo log
    回滚日志 在insert update delete产生便于数据回滚的日志
    如果是insert 只在回滚时需要 事务提交之后就会删除
    如果是update delete不只回滚需要 MVCC版本访问也需要 不会立即删除
    undo log版本链

    不同事务操作数据时会生成一条记录版本链表 头部是最新的旧数据 尾部是最早的旧数据

  • readView
    当前读
    读取数据的最新版本 读取时保证其他并发事务不能修改 读取时会加锁 只要提交就会读到最新数据
    快照读
    简单的select 不加锁 读取的有可能是历史版本 非阻塞读 根据隔离级别不一样 读到的数据也不一样
    Read Committed 每一个select都会生成一个快照
    Repeatable Read 开启事务后的第一个select才会生成快照
    ReadView包含四个核心字段

主要就是看事务ID和活跃的事务ID比对 比如在事务5第一次查询的时候 事务二的数据就可以被访问 因为事务2的ID小于活跃事务ID的最小值
不同的隔离级别 生成ReadView的时机不同
Read Committed 每一次快照读都生成ReadView
Repeatable Read 仅事务中第一次快照读生成ReadView 后续都复用该ReadView

事务的隔离性如何实现的?
通过锁和MVCC来实现
其中MVCC是多版本并发控制 指维护一个数据的多个版本 使读写操作没有冲突 底层由三个部分来实现

  • 隐藏字段
    trx_id 当前事务ID 记录每一次操作的事务ID 自增
    roll_pointer 回滚指针 指向上一个版本的事务地址
  • undo log
    回滚日志:存储老版本数据
    版本链:多个事务并行操作某一行数据 记录该数据的历史版本 通过roll_pointer形成一个链表
  • ReadView
    解决事务查询选择的版本问题
    根据ReadView匹配规则和一些事务ID来判断可以访问哪个版本的数据
    不同隔离级别的快照读是不一样的 最终访问结果也不一样
    Read Committed 每一次执行快照读都会生成ReadView
    Repeatable Read 仅在事务第一次执行快照读生成ReadView 后续复用

主从同步原理

Mysql主从复制同步的核心就是二进制日志
二进制日志(BINLOG) 记录了所有的DDL(数据定义语言 create drop)语句和DML(数据操纵语言 如insert update)语句 不包括数据查询语句

就是主库在事务提交时 会把数据变更记录在二进制文件(BINLOG)中
从库读取主库的二进制文件 写到从库的中继文件(Relay log)中
从库根据中继文件来实现数据同步

分库分表

当数据量过大时 就需要用到分库分表可以分担访问压力 解决存储压力
拆分策略分为垂直拆分水平拆分 垂直拆分 然后两种都分为分库和分表
垂直分库

根据业务不同将不同表拆分到不同数据库中
在高并发下 可以提高磁盘IO和数据量连接数
垂直分表

实现数据冷热分离
减少IO过渡争抢 两表互不影响
水平分库

水平分库由于多个库加起来才是完整的数据 所以需要做路由
路由规则分为根据ID取模和按范围路由
特点就是解决了单库大数量 高并发的性能问题
提高系统的稳定性和可用性
水平分表
水平分表和水平分库差不多 水平分 做路由
特点就是优化单一表数据量过大产生的性能问题
避免因IO争抢而导致的锁表问题

分库之后的问题
分布式事务
跨节点关联查询
跨节点分页 做排序
主键避重

解决思路就是加一个中间件比如mycat

你们项目使用过分库分表吗

  • 业务介绍
    根据简历上的项目 来介绍
    达到什么样的量级
  • 拆分策略
    水平分库 把一个库的数据拆成多个库 解决了高并发以及海量数据存储问题 要用到mycat解决路由
    水平分表 解决了单表存储和性能问题 用到mycat解决路由
    垂直分库 根据业务拆分 高并发下提高磁盘IO和网络连接数
    垂直分表 冷热数据分离 多表互不影响

框架

Spring

单例Bean是线程安全的吗
不是线程安全的 Spring框架中有个@Scope注解 默认是单例Singleton 因为一般在注入的时候注入的都是无状态的对象 是线程安全的 如果在注入的对象定义了修改的变量 就不是线程安全的 可以用多例模式或者加锁来解决

AOP

AOP称为面向切面编程 用于将那些与业务无太大关系 且公共性又高的行为和逻辑抽取并封装为一个公共模块 这个模块称为切面(Aspect) 减少代码耦合 便于维护
常见使用场景:记录操作日志 缓存处理 Spring内置的事务处理
案例就是通过自定义注解填充公共字段
什么是AOP
面向切面编程 用于将那些与业务无关但是公共性又比较高的行为和逻辑抽取为一个公共模块 可以降低代码耦合度 提高系统可维护性
使用AOP
记录操作日志 缓存 Spring内置的事务处理 案例:填充公共字段
通过环绕通知+切点表达式 根据连接点JointPoint通过反射拿到对应的信息 类 方法 各种参数 然后来完成一系列逻辑
Spring中的事务是如何实现的
就是定义了Transactional注解 然后切点就是这个注解 通过环绕通知来控制事务的开启 提交 回滚

事务失效的场景

异常捕获处理
就是发生异常然后自己通过try catch捕获了异常 事务就会失效 解决思路就是再把异常抛出去让Spring感知到就可以了
抛出检查异常
Spring默认的事务回滚是发生检查异常才会回滚 非检查异常不会 解决思路就是配置rollbackfor属性@Transactional(rollbackfor = Exception.class)这样只要是异常 就会发生正常回滚
非Public方法
Spring创建代理 做事务通知前提条件都是public方法 如果不是public就会导致事务失效
自调用

Bean的生命周期

Bean的生命周期
首先通过BeanDefinition来获取Bean的定义信息 然后调用构造函数实例化Bean 再走Bean的依赖注入 包括Autowired 然后处理Aware接口 去进一步修改Bean的属性 接着调用初始化之气那的后置处理器 然后开始初始化 一个通过spring自带的afterPropertiesSet来设置属性 还可以通过@postConstruct来自定以init初始化方法 然后初始化完成会调用初始化之后后置处理器来完成对Bean的进一步增强常见的就是AOP 通过动态代理基于反射来进一步增强Bean的行为 到这里Bean就创建完成可以使用了 后续容器关闭会调用销毁方法来销毁Bean 如果加了@preDestroy会在销毁之前进行一系列行为

Spring循环依赖

Spring解决循环依赖是通过三级缓存来解决
一级缓存 singletonObjects 单例池 缓存已经走完完整生命周期 初始化完成的Bean
二级缓存 earlySingletonObject 早期单例池 缓存生命周期没走完的Bean对象
三级缓存 singletonFactories 缓存的是对象工厂 用来创建对象的

二级缓存可以解决一部分循环引用问题 但是如果对象A是由代理对象来代理 就无法解决 所以需要三级缓存来解决代理对象产生的循环引用

三级缓存就可以解决大部分循环依赖问题了 不能解决的比如由构造方法造成的循环依赖 解决思路就是通过@Lazy懒加载来解决循环依赖

Spring的循环依赖
循环依赖就是两个以上的Bean相互引用对方造成的现象 比如A依赖B B依赖A
Spring允许循环依赖的存在 由三级缓存来解决循环依赖
一级缓存是 singletonObject 单例池 存储的都是走完完整生命周期的Bean
二级缓存是 earlySingletonObject 早期单例池 也叫半成品区 存储的未走完生命周期的Bean
三级缓存是 ObjectFactory 对象工厂 来创建某个对象的
要来创建A 先实例化A 然后A生成了一个对象工厂的对象放在三级缓存中 然后需要注入B B开始实例化 也生成一个对象工厂的对象放在三级缓存中 B需要注入A 就从三级缓存中取出A的对象工厂对象 由对象工厂对象来创建A对象或者A的代理对象放在二级缓存里 然后从二级缓存里把A的对象或者代理对象注入给B B实例化完就把B放到了一级缓存 也就是单例池中 完成实例化 此时A也可以完成实例化了 就把B注入给A 然后实例完的A也放到一级缓存中 解决循环依赖
为什么需要三级缓存? 解决由代理对象引起的循环依赖
三级缓存里二级缓存的作用? 保证单例 让对象工厂创建的对象只需要创建一次即可 不用多次创建避免多个实例 之后只需要从二级缓存中取Bean即可
构造方法引起的循环依赖? 使用@Lazy懒加载来解决循环依赖

SpringMVC

请求进来之后首先会到达DispatcherServlet前端控制器 是调度中心 然后前端控制器把请求给到处理器映射器HandlerMapping 处理器映射器就可以根据请求找到请求对应的方法 并返回给前端控制器处理器执行链 为什么返回执行链是因为除了请求对应的方法还可能由一系列拦截器 返回完执行器链就会由前端控制器给到处理器适配器HandlerAdaptor 由处理器适配器去调度处理器Handler来处理请求 然后处理器Handler响应数据给到处理器适配器 处理器适配器处理完相应的参数跟返回值之后就返回给前端控制器ModelAndView 此时的视图只是逻辑视图 并不是真正的视图 然后前端控制器把ModelAndView给到视图解析器(ViewResolver)视图解析器就会把逻辑视图解析为真正的视图返回给前端控制器View对象 然后前端控制器渲染View对象 返回请求

前面流程都一样 都是请求给到DispatcherServlet然后给到处理器映射器拿到对应执行器链 然后给到处理器适配器 处理器适配器处理参数调度处理器处理方法 处理器执行完返回结果 然后处理器适配器根据@ResponseBody注解来调用HttpConvertMessage来将响应结果转换成json数据 最后给到前端控制器响应给前端

SpringBoot自动配置原理

在SpringBoot项目中的启动类上有一个注解@SpringBootApplication 这个注解封装了三个注解 分别是@ComponentScan扫描包 扫描当前包及其子类 @SpringBootConfiguration 表明当前类是一个配置类 @EnableAutoConfiguration 这个注解就是自动装配的核心注解
这个注解通过@Import注解导入对应的配置选择器 内部就是读取了该项目和所引用jar包的classpath下的META-INFO下的spring.factories文件中所配置类的全类名 这些配置类定义的Bean会基于条件注解所指定的条件来决定是否把对应组件注入到容器中
条件注解会像ConditionalOnClass判断是否由对应的Class文件 判断后再决定是否注入

Spring常见注解

Spring常见注解

SpringMVC常见注解

SpringBoot常见注解

MyBatis

MyBatis执行流程

首先就是读取MyBatis配置文件 mabats-config.xml 其中包含了运行环境和映射文件 然后就开始创建SqlSessionFactory会话工厂 由会话工厂创建SqlSession对象 该对象中包含了所有执行Sql的方法 接着就是操作数据库的接口 Executor执行器 除了操作数据库接口还负责查询缓存的维护 Executor执行方法中有一个MapperStatement类型的参数 封装了映射信息 最后输入参数映射 把JAVA数据类型转换为数据库类型 然后响应结果再转换为JAVA数据类型 即可完成MyBatis执行流程

MyBatis延迟加载

MyBatis是否支持延迟加载
MyBatis支持延迟加载 但是默认是不开启的 延迟加载意思就是在需要用到数据时才加载数据 不需要数据时就不会加载 可以在ResultMap中开启局部的延迟加载或者在MyBatis配置文件中 可以配置延迟加载LazyLoadingEnable=true来开启全局延迟加载
延迟加载底层原理
使用CGLIB创建目标对象的代理对象 然后通过invoke 方法来查看目标方法是不是null值 执行sql查询 获取数据之后 再通过set设置属性值 完成数据的加载

MyBatis多级缓存

一级缓存: 基于PerpetualCache的HashMap本地缓存 存储作用域时Session级别 当Session进行flush或者close的时候 缓存会清空 默认开启一级缓存
二级缓存: 二级缓存也是基于PerpetualCache的HashMap本地缓存 不过作用域是namespace和mapper 不会依赖于SqlSession 在mybatis配置文件中开启二级缓存 然后在mapper里加上标签让二级缓存生效
mybatis只要进行了删除修改新增操作 就会清空某一个作用域内的缓存 二级缓存需要实现Serializable接口 只有会话提交关闭后 一级缓存的数据才会转移到二级缓存中

SpringCloud

网关校验用户信息及向下游传递

就是在网关里用过滤器拿到请求头中的token 解析出来用户ID 然后通过exchange.mutate方法向下游传递 网关做完了 然后就是从网关传递中拿到用户ID 在公共模块里用拦截器拦截请求 拿到请求头中的用户ID 然后存到ThreadLocal中 这样每一个微服务自己就有用户ID了 后续就是在公共模块中通过MVCConfig配置 需要条件注解来注册组件 因为网关没有引入MVC 然后再META-INFO下自动装配
网关传递完之后有的是通过OpenFeign远程调用的 不经过网关 所以需要在OpenFeign中定义拦截器 拿到远程调用的用户ID 然后向下游传递

雪崩问题

就是由于调用链路中某个服务故障 然后导致调用这个服务也开始故障 如果并发量高 占用过多tomcat资源 就会导致其他服务也故障 从而引起服务雪崩
原因
服务之间相互调用出现故障或宕机
没有做好服务故障的异常处理
调用链中级联失败导致集群宕机
解决思路
请求限流 也叫流量整合
线程隔离
服务熔断 统计异常比例 达到或超出就会走fallback逻辑 也叫服务降级

分布式事务

使用Seata来实现分布式事务的管理 Seata中有三个部分 分别是TC(事务协调者) RM(资源管理器) TM(事务管理者)
TC就是维护全局和分支事务的状态 TM是定义全局服务的范围 RM是管理分支事务
XA模式
一阶段执行sql但是不提交 只报告状态 占用整个DB锁 等所有事务都执行完才确定提交/回滚 保证强一致性 性能低
AT模式
一阶段执行sql并提交 但是会做一个undolog数据快照 然后二阶段如果所有分支事务成功 就删除快照 如果失败需要回滚 就根据快照回滚 但是会产生脏写问题
脏写问题
就是一阶段获取DB锁执行提交 还没到二阶段 然后又来个进程 继续执行了一遍一阶段 导致多次执行sql 因此Seata设计了全局锁 由Seata管理 就是在执行完sql之后提交之前获取全局锁 然后一直到二阶段删除快照/回滚之后才释放全局锁 在此期间 其他线程无法获取 无法进行写 因此解决了脏写问题 和XA不同的是全局锁是行锁 只操作当前行 XA是整个DB锁 还有个问题如果分支事务没有被Seata管理 那么就无法获取全局锁
TCC模式
和AT模式类似 一阶段执行完直接提交 但是二阶段不用执行快照 而是人为编写数据恢复逻辑 之后数据回滚就走人为逻辑来回滚 不用全局锁和释放了数据库性能更好 但是有代码侵入 最终一致 需要做幂等处理

最大努力通知

就是人为实现事务 去不断通知消息 等事务执行完返回ACK确认 从而达到事务状态 也是最终一致性

MQ

可靠性

发送者可靠性
通过配置开启发送者确认机制 发送成功会返回ACK确认 失败返回NACK
MQ可靠性
做持久化 交换机持久化 队列持久化 消息持久化 3.12版本之后MQ都是用的Lazy Queue 直接把消息存到磁盘中 会缓存一部分到内存里

  • 处理消息堆积
    MQ中有大量消息怎么办
    提高消费者数量 在消费者中开启线程池多线程处理消息 使用惰性队列
    消费者可靠性
    通过消费者确认机制 消费完消息返回ACK确认 NACK失败 MQ再次投递 REJECT拒绝 MQ删除 SpringAMQP开启auto
    不开启失败重连 就会一直投递 耗费CPU 开启失败重连机制 消息投递失败会重新发消息 最多不超过三次 如果三次还是失败 需要对消息处理 一般是指定一个交换机 指定一个队列 叫错误队列 把失败三次的消息发送到错误队列里

幂等处理

就是消息处理一次和多次效果是一样的 避免并发错误
唯一消息ID
通过设置消息转换器来让MQ带上唯一消息ID 然后消息进来的时候先从数据库/redis查ID 如果有代表已经消费过 不管 如果没有 代表消息第一次来 就开始消费消息 然后最后把消息ID放到数据库/redis 保证幂等性
业务逻辑
结合业务逻辑和需求 对非幂等业务加上业务判断确保幂等性
支付服务和交易服务 当用户支付完成mq发消息通知交易服务把订单改为已支付 但是网络原因没有发送成功 此时用户退款了 把订单改成了退款 mq重试消息发来了把订单改为已支付 因此交易服务可以做逻辑判断来实现幂等 就是更改订单之前查看订单状态是否是未支付 如果是 就修改 不是就不修改 保证幂等性
不同微服务之间确保一致性
比如交易服务和支付服务 交易服务完成后会基于MQ去异步通知支付服务来完成订单同步
然后确保消息的可靠性 做了生产者确认机制 消费者确认机制 失败重试机制 并且失败三次会将是失败消息发送给错误队列 以便后续做调整 还做了MQ的消息持久化来避免因宕机导致的消息丢失
最后做了业务幂等来保证消息重复投递导致数据异常

延迟消息

死信交换机
死信分为三种情况
消息处理失败
投递队列消息满了
消息过期
如果这三个死信所在的队列通过配置dead-letter-exchange只当了一个交换机 这个就是死信交换机
通过死信交换机来做了延迟消息的处理 完成下单三十分钟之内支付 不支付就会发送延迟消息更改订单状态

MQ高可用

高可用就要搭建集群 MQ集群分为普通集群 镜像集群 仲裁队列
普通集群
各个节点中交换机相同 队列不同 但是会在各个节点保存其他节点的队列引用 当消费者选择的节点是其他节点的队列时 就会通过引用的队列发送给其他节点 但是如果宕机 消息就会丢失

镜像集群
类似于主从集群 不同节点之间共享信息 创建队列的节点称为主节点 备份到其他节点称为镜像节点
每一个节点既有可能是主节点 又有可能是镜像节点
主节点操作完成就会同步给镜像节点
但是还是存在丢失数据的可能性

仲裁队列
是在3,8版本之后引入的功能 仲裁队列和镜像集群几乎相同 但是主从同步是基于Raft实现的 具有强一致性 消息难以丢失

Nacos和Eureka

Nacos和Eureka都支持心跳检测 但是Eureka30s检测一次 而nacos5s检测一次 并且nacos还支持服务端主动提供心跳状态 仅限于永久实例
临时示例心跳不正常会被剔除 永久实例不会
nacos服务变更会主动推送 消息推送更及时 Eureka只会30s拉取一次服务
nacos集群采用AP模式 但是也支持CP Eureka只支持AP

负载均衡策略

随机 轮询 集群优先

服务保护

监控接口以及各种状态

SkyWalking

线程隔离

线程池隔离
每一个服务会申请一个线程池来管控 隔离性较好 但是管控资源会很麻烦 性能一般但是隔离性好
信号量隔离
不需要浪费其他资源 直接在服务调用时根据计数器来实现隔离作用 隔离性一般 但是性能好

滑动窗口算法

把时间划分为多个窗口 每个窗口时间跨度默认是1s
然后每个时间跨度内会分成默认两个区间 每个区间都有独立的计数器
当请求到达时 会根据当前时间减去时间跨度的最近的下一个小区间 以此为准 查看两个小区间内的所有请求超没超上限
区间分的越多就越准确 但是性能就越低

漏桶算法

用队列实现 请求进来了就入桶 然后一个个往外漏出去 如果桶满了请求就会丢弃 可以达到整流QPS的作用

令牌桶算法

内部就是基于计数器加一减一来实现 计数器随着时间区间自增 下一个时间区间就重置 然后请求进来就减一 实现简单 成本低 但是有可能会造成QPS忽高忽低的情况 就是在1s末来了十个请求全部消耗完 2s又十个 所以在这一秒内就是QPS20 因此不使用忽高忽低的情况 适用于热点参数限流

Sentinel限流和GateWay限流

GateWay限流实现简单 就是通过Redis实现的令牌桶算法来进行限流
而Sentinel内部就比较复杂
默认采用滑动窗口算法来限流 服务熔断也是采用的滑动窗口算法
限流之后可以快速失败或者排队等待 排队等待是用的漏桶算法
热点参数限流用的是令牌桶算法

任务调度

使用了xxl-job来实现任务调度
xxl-job路由策略有哪些
平时用的比较多的就是轮询 故障转移 分片广播
xxl-job执行失败怎么解决
路由策略选择故障转移 使用健康的实例来执行任务
设置重试次数
查看日志+邮件告警来通知负责人解决
如果有大数据量的任务需要同时处理 怎么解决
路由策略选择分片广播 部署集群 让多个实例一块执行
在任务执行代码中可以获取分片总数和当前分片 按照取模的方法分摊到各个实例执行

集合

ArrayList

数据结构 数组

数组是一个用连续得内存空间存储相同数据类型得线性数据结构
创建数组时会在栈内存中存储数据名 然后指向堆内存存储的数据首地址 用索引进行寻址

寻址公式:a[i] = baseAddress + i * dataTypeSize
baseAddress是起始地址 dataTypeSize是元素类型得大小 int就是4个字节
然后通过索引i来实现快速定位数据
为什么数组索引从0开始不从1开始呢
在根据数组索引获取数据过程中 是根据寻址公式来进行获取得 寻址公式就是首地址 + 索引 * 数据类型大小
如果变成1 就对于CPU多了一个减法操作
查找得时间复杂度
如果根据索引查找 就是O(1) 如果是未排序得 就是O(n) 如果排序了用二分查找 就是O(logn)
插入删除复杂度
插入删除需要操作整个数组 所以平均时间是O(n)

ArrayList源码分析

扩容

ArrayList底层实现原理
底层是用动态得数组实现的
ArrayList初始容量为0 当第一次往里添加数据时会初始化容量为10
在扩容时会变为原来容量的1.5倍 每次扩容都要拷贝数组
在添加数据的时候要确保已使用长度+1之后可以存下下一个数据 然后计算数组的容量 如果超过了当前数组长度 就调用grow方法扩容 变成原来的1.5倍 确保新增的数据有地方存储之后 就将新元素添加到位于size的位置上 添加成功返回布尔值
如何实现数组和List之间的转换
数组转List:调用JDK的java.utils.Arrays工具类的asList方法 并且转换List之后 如果修改数组的内容 List会受影响 因为底层使用的Arrays类中的一个内部类ArrayList来构造的集合 在这个集的构造器中 把我们传入的这个集合进行了包装 最后都是指向的同一个额内存地址
List转数组:调用List的toArray方法 无参toArray方法返回Object数组 传入初始化长度的数组对象 返回该对象数组 List用toArray转数组后 如果修改了List内容 数组不会影响 当调用了toArray以后 底层是进行了数组的拷贝 跟原来元素没关系了 是一个新的内存空间

LinkedList

单向链表
链表中每一个元素都是一个结点
物理存储单元上非连续非顺序的存储结构
每个结点包含两个部分 一个是数据 一个是后继指针next
双端链表
有两个指针 一个后继指针 一个前驱指针

ArrayList和LinkedList区别

  • 底层数据结构
    ArrayList底层是动态数组的结构实现 LinkedList底层是通过双端链表的结构实现
  • 效率
    ArrayList可以根据索引查询 复杂度是O(1)LinkedList不支持索引查询
    查找未知索引ArrayList需要遍历 是O(n)
    ArrayList删除和新增对头节点是O(1) 对其他节点都是O(n)
    LinkedList删除和新增对头尾节点和已知节点都是O(1) 其他节点需要遍历 都是O(n)
  • 占用空间
    ArrayList占用空间是连续的 且只需要存储数据
    LinkedList占用空间不连续 而且还要多存储指针空间 占用空间大
  • 线程安全
    二者都不是线程安全的
    可以在方法内使用 局部变量是线程安全的
    可以通过Collections对二者进行包装加锁 消耗一部分效率 实现线程安全

数据结构

红黑树

也叫平衡二叉树 是一种自平衡的二叉搜索树
红黑树性质

  • 节点分成红黑二色
  • 根节点是黑色
  • 叶子节点是黑色
  • 红色节点的子节点是黑色
  • 从任一节点到黑色节点的所有路径都包含相同数目的黑色节点

红黑树性质为了保证稳定 如果不符合性质 就要发生旋转 然后来保证平衡

散列表

散列表就是哈希表 根据键找值的数据结构 由数组演化而来
将key映射为数组下标的函数就是散列函数 表示为hashValue = hash(key)
有三个基本要求 得到的hashValue必须大于0 相同的key必须得到相同的hashValue 不同的key必须得到不同的hashValue
第三个难以实现 因此会出现哈希冲突(散列冲突 哈希碰撞)
用拉链法解决哈希冲突 就是发生哈希冲突时 在后面加一个链表 拉链数量大于8 会变成红黑树

HashMap实现原理

底层使用hash表数据结构 即数组加链表或红黑树
当往HashMap中put元素时 会根据Key做一个hash运算计算当前对象的元素所在数组的下标
然后进行存储 如果发生哈希冲突 就把key-value放入链表或者红黑树中 一般是链表 如果数组长度大于64且链表长度大于8会转为红黑树
获取时 找到hash值对应的下标 判断key是否相同 相同则取出数据

jdk1.7和1.8中hashMap有什么区别?
在1.7之前的拉链法并没有红黑树的实现 发生哈希冲突都是以链表形式存储
1.8的拉链法就有了红黑树处理链表过长导致效率变慢的因素 如果数组长度大于64且链表大于8就会转为红黑树 如果红黑树节点小于6个就会还原成链表

HashMap的put具体流程

HashMap是懒加载 在创建对象时并没有初始化数组
在无参构造函数中 设置了默认的加载因子为0.75

HashMap的put方法具体流程?

  1. 判断键值对数组table是否为空或为null 如果是 就执行resize进行扩容(初始化)
  2. 根据key计算hash值找到数组索引
  3. 判断table[i] == null是否成立 成立直接插入
  4. 如果不成立
    4.1 判断table[i]首个元素是否和key一样 如果一样 就覆盖
    4.2 判断table[i]是不是红黑树 如果是红黑树 就走红黑树的逻辑 插入键值对
    4.3 遍历table[i] 用尾插法插入数据 然后判断链表长度是否大于8 大于8就把链表转换为红黑树
  5. 插入成功后 判断++size是否超过了最大容量threshold(数组长度 * 0.75) 如果超过 就进行扩容

HashMap扩容机制

HashMap的扩容机制是什么?
添加元素或者初始化时需要调用resize方法扩容 第一次初始化长度为16 以后每次扩容都是达到了扩容阈值(数组长度 * 0.75)
每次扩容的时候 都是扩容之前容量的两倍
扩容之后会新建一个数组 然后把旧数组的数据挪动到新数组中
如果是没有hash冲突的节点 直接使用e.hash & (newCap - 1)取模重新计算索引位置
如果是红黑树 就用红黑树的添加逻辑
如果是链表 就需要遍历链表 判断e.hash & oldCap是否为0 如果是 就直接拷贝即可 如果不是 就把之前索引加上增加的数组大小这个位置上

HashMap寻址方法
首先进行hash运算
然后调用hash方法 也就是扰动算法 可以尽可能避免哈希冲突
最后用数组长度-1与运算代替取模

为什么数组长度一定要是2的倍数
计算索引效率更高 因为2的倍数可以用与运算代替取模
扩容时重新计算索引效率更高 hash & oldCap ==0的元素留在原来位置 否则新位置 = 旧位置 + oldCap

HashMap在1.7下多线程死循环问题
1.7在扩容时 因为采用头插法 所以进行数据迁移就有可能导致死循环
比如有两个线程 线程一读到数据时正准备扩容 线程二介入开始进行扩容 因为是头插法 链表顺序会反过来 之前顺序比如AB 扩容后就是BA 线程二结束
线程一继续执行就会发生死循环
线程一再进行头插法时 由于另一个线程的原因 B的next指向了A 所以导致了 B指向A A指向B 形成循环
到JDK8时就解决了该问题 采用了尾插法 避免死循环

并发编程篇

线程基础

线程和进程的区别

进程就是一个应用程序 进程包括多个线程 一个线程就是一个指令流
二者对比
进程是正在运行程序的实例 进程中包含了线程 每个线程执行不同的任务
不同的进程使用不同的内存空间 在当前进程下的所有线程共享内存空间
线程更轻量 线程上下文切换一般要比进程上下文切换成本低

并行与并发

二者有什么区别
在多核CPU下 并发是同一时间应对多件事的能力 多个线程轮流使用一个或者多个CPU
并行是同一时间动手做多件事情的能力 一个四核CPU可以同时执行4个线程

创建方式

  • 继承Thread类
  • 实现Runnable接口
  • 实现Callable接口
  • 线程池创建线程

Runnable和Callable有什么区别
Runnable没有返回值
Callable有返回值 通过泛型来定义返回值的类型 可以通过FutureTask来配合拿到异步线程的结果
Callable的call方法可以抛异常 而Runnable的run方法不能抛异常

启动线程的时候start方法和run方法有什么区别
run方法还是主线程在运行 可以调用多次
而start方法才是开启了一个新线程来执行的 只能调用一次

线程的状态以及切换

线程包含哪些状态
新建状态(NEW) 可运行(RUNNING) 阻塞(BLOCKED) 等待(WAITING) 时间等待(TIMED_WALTING) 终止(TERMINATED)
各个状态如何切换

  • 创建线程时是新建状态
  • 调用start方法变成可执行状态
  • 线程获取到了CPU的执行权 执行结束是终止状态
  • 可执行状态中 如果没有获取到CPU的执行权 就会切换其他状态
    • 如果没有获取锁 就会进入阻塞状态 获取之后再切换回可执行
    • 如果线程调用了wait方法就会进入等待状态 其他线程调用notify就会切换成可执行状态
    • 如果线程调用sleep方法就会进入计时等待状态 到时间后就会切换成可执行状态

按顺序执行线程

创建三个线程如何按顺序执行
通过join方法 比如t1线程执行 然后在t2线程中加入t1.join确保t1结束之后才执行t2 然后在t3中加入t2.join 确保t2结束之后才执行t3 从而确保按顺序执行
notify和notifyAll有什么区别
notify只唤醒随机一个线程
notifyAll唤醒所有线程

wait和sleep方法的异同

共同点
二者都是让当前线程暂时放弃CPU的使用权 进入阻塞状态
不同点

  1. 方法归属不同
    wait方法是Object的成员方法
    sleep是Thread的静态方法
  2. 醒来时机不同
    wait可以被notify唤醒 如果wait不唤醒就会一直等待下去
    他们都可以被打断唤醒
  3. 锁特性不同
    wait方法调用前回获取wait对象的锁 sleep不用
    wait方法执行后回释放对象锁 允许其他线程获得锁
    sleep如果在synchronized代码块中执行 不会释放锁

停止线程

  1. 使用退出标志停止 使线程正常退出 也就是run方法执行完后线程终止
  2. 使用stop方法强行终止 方法已废弃
  3. 使用interrupt中断线程
    打断阻塞的线程(sleep wait join) 会抛出InterruptedException异常
    打断正常的线程 可以根据打断状态来标记是否退出线程

线程安全

synchronized原理

Synchronized是对象锁 采用互斥的方式在同一时刻最多只能有一个线程持有锁 其他线程再想获取就会阻塞

monitor监视器 分为三个部分
Owner 存储当前获取锁的线程 只能有一个
EntryList 存储的没有抢到锁的线程 处于blocking阻塞状态
WaitSet 关联了调用wait方法的线程 处于waiting状态

Synchronized原理

  • synchronized对象锁采用互斥的方式让同一个时刻最多只能有同一个线程持有锁
  • 底层是由monitor实现的 是jvm级别的对象 线程获取锁就是让对象关联monitor
  • monitor中有三个属性 owner entryList waitSet
  • owner是关联的当前获取锁的线程 只能有一个 entryList关联的是处于阻塞的线程 waitSet关联的是处于waiting状态的线程

synchronized进阶

monitor属于重量级锁 因为是jvm提供的 所以需要内核态和用户态的切换 进程的上下文切换 性能较低 成本较高
在jdk1.6之后 引入了轻量级锁和偏向锁 适用于没有竞争的场景 比如锁重入 来减少损耗
对象的内存结构
在HotSpot虚拟机中 对象在内存存储分为三个部分 对象头 实例数据 和对齐填充

每个对象都可以关联一个Monitor对象 如果给对象上了重量级锁synchronized之后 该对象头的Mark Word就被设置成指向Monitor对象的指针

偏向锁
轻量级锁在没有竞争时 每次重入仍然需要CAS操作 因此偏向锁对重入进行了优化 第一次使用CAS将线程ID设置到对象的Mark Word头 之后发现只要是同一个ID 就是重入 不用重新CAS 以后只要不发生竞争 这个对象就归线程所有

你了解过锁升级吗?
java中的synchronized有偏向锁 轻量级锁 重量级锁三种形式 分别对应锁只被一个线程持有 不同线程交替持有 多线程竞争锁三种情况

谈一谈JMM(Java内存模型)

  • JMM是Java Memory Model Java内存模型 定义了共享内存中多线程读写操作的规范 通过这些规则来规范对内存读写操作的正确性
  • JMM把内存分为两块 一个是私有线程的工作内存 一个是所有线程共享的内存 叫主内存
  • 线程跟线程之间是相互隔离的 同步数据是通过主内存来交互的

CAS

CAS叫Compare And Swap 比较再交换 是一种乐观锁思想 在无锁状态下保证线程操作共享数据的原子性

在JMM中线程交换数据 会将主内存的数据和自己的旧数据进行比对 如果一样就同步数据 如果不一样就开启自旋
自选就是进行CAS失败后 会重新从主内存中获取一份数据 然后再执行自身线程逻辑 再对主内存进行CAS判断 如果一样就同步数据 不一样继续自旋 这个操作也叫自旋锁
CAS底层是调用的unsafe类中操作系统实现的CAS指令

CAS你知道吗

  • CAS全程Compare And Swap 比较再交换 是一种乐观锁的思想 在无锁状态下可以保证数据操作的原子性
  • CAS用到的地方比如AQS框架 AtomicXXX类等
  • 操作共享变量的时候使用的自旋锁 效率上更高一些
  • CAS底层是调用的Unsafe类的方法 是操作系统提供的

乐观锁和悲观锁的区别

  • CAS就是基于乐观锁的思想 认为没有线程竞争 哪怕有了导致数据不一致 再重试获取就行
  • synchroinzed是基于悲观锁的思想 认为线程竞争一直存在 上了锁谁也无法获取 改完了释放锁才可以获取

Volatile

一个共享变量(成员变量 静态成员变量)被Volatile修饰后 就有了两层含义

  1. 保证线程间的可见性
    用volatile修饰共享变量 能防止编译器优化发生 让一个线程对变量的修改对另一个线程可见
  2. 禁止进行指令重排序
    volatile修饰的变量会在读写时加入不同的屏障 防止其他操作越过屏障 从而达到阻止重排序的效果

AQS

AbstractQueuedSynchronizer 抽象队列同步器 时构建锁或者其他同步组件的基础框架

AQS常见的实现类
ReentrantLock 阻塞式锁
Semaphore 信号量
CountDownLatch 倒计时锁
基本工作机制
AQS内部有一个volatile修饰的state 0是无锁 1是有锁 线程进来就会修改state的值 然后占有锁 后续线程进来抢不到锁会在一个先进先出的双向队列里等待 直到state为0 就会取出头部的线程 让他占有锁

什么是AQS

  • 是多线程中的队列同步器 是一种锁机制 作为一个基础框架使用的 像ReentrantLock Semaphore就是基于AQS实现的
  • AQS内部维护了一个先进先出的双端队列 存储的是排队的线程
  • AQS内部还有一个volatile修饰的state属性 0就是无锁 1就是有锁
  • 对state修改时用到了cas操作来保证原子性

ReentrantLock

就是可重入锁

  • 可中断
  • 可以设置超时时间
  • 可以设置公平锁
  • 支持多个条件变量
  • 支持重入锁

主要是利用CAS+AQS队列来实现 支持公平锁和非公平锁两者实现类似
构造方法支持传入boolean参数 默认是非公平锁 传入true就是公平锁 false就是非公平锁 公平锁效率没有非公平锁高 多个线程访问 公平锁表现出较低的吞吐量

ReentrantLock实现原理

  • ReentrantLock表示可重入锁 调用方法获取锁之后再次调用lock 是不会阻塞的
  • ReentrantLock利用CAS+AQS队列来实现
  • 支持公平锁和非公平锁 提供的构造器中无参默认是非公平锁 也可以传参设置公平锁

synchronized和lock区别

  • 语法层面
    synchronized是关键字 源码在jvm中 用C++实现
    Lock是接口 源码由JDK提供 用java语言实现
    使用synchronized时 退出同步代码块会自动释放锁 而使用lock时 需要用unlock来释放锁
  • 功能层面
    二者都属于悲观锁 都具备互斥 同步 可重入功能
    Lock提供了很多synchronized不具备的功能 比如公平锁 可打断 可超时 多条件变量(await方法)
    Lock有适合不同场景的实现 如ReentrantLock ReentrantReadWriteLock(读写锁)
  • 性能层面
    没有竞争时 synchronized做了很多优化 偏向锁 轻量级锁 性能好
    竞争激烈时 Lock的实现会有更好的性能

死锁条件

死锁:一个线程获取多把锁 就容易发生死锁 比如一个线程先占有A锁 然后在代码块里获取B锁 另一个线程先占有B锁 在代码块里获取A锁 两个线程start就会死锁
如何进行死锁诊断
出现死锁现象 可以使用jdk的工具jps(输出JVM中运行的进程状态信息)和jstack(查看java进程内的线程堆栈信息)

还可以使用可视化工具jconsole和VisualVM(故障处理工具)
二者都在jdk中 VisualVM高版本不会集成了 需要自己下载 并且还可以解决OOM问题

ConcurrentHashMap

线程安全的hashMap
1.7采用分段的数组加链表

加锁必然导致性能的下降
1.8就跟HashMap底层用到的一样了 就是数组加链表/红黑树实现 然后通过CAS和synchronized来确保线程安全

聊一下ConcurrentHashMap

  1. 底层数据结构
  • JDK1.7采用分段的数组+链表实现
  • JDK1.8采用的数据结构跟HashMap的结构一样 都是数组+链表/红黑树
  1. 加锁的方式
  • JDK1.7是用的Segment分段锁 底层使用的是ReentrantLock
  • JDK1.8采用CAS添加新节点 采用synchronized锁住链表或者红黑树首节点 相对Segement分段锁粒度更细 性能更好

导致并发出现的根本原因?

  • 原子性 synchronized lock
  • 内存可见性 volatile
  • 有序性 volatile

线程池

线程池原理及参数

核心参数

  • corePoolSize 核心线程数
  • maximumPoolSize 最大线程数目 = (核心线程 + 救急线程)
  • keepAliveTime 生存时间 - 救急线程的生存时间
  • unit 生存时间 - 救急线程的生存时间的单位
  • workQueue 阻塞队列 没有线程可用时 就会把任务加入到这个队列 队列满就会创建救急线程
  • threadFactory 线程工厂 来给线程命名和设置守护线程
  • handler 拒绝策略 线程繁忙加上队列也满了 就会出发拒绝策略
    1. AbortPolice 直接抛出异常 默认策略
    2. CallerRunsPolicy 由调用者所在的线程来执行任务
    3. DiscardOldestPolicy 丢弃阻塞队列中最靠前的任务 并执行当前任务
    4. DiscardPolicy 直接丢弃任务

执行原理

常见的阻塞队列

workQueue 没有空闲核心线程时 会将任务放到阻塞队列中 队列满就会创建救急线程执行任务
1.ArrayBlockingQueue 基于数组结构的有界阻塞队列 FIFO
2.LinkedBlockingQueue 基于链表结构的有界阻塞队列 FIFO
3.DelayedWorkQueue 是一个优先队列 可以保证每次出队的任务都是当前队列中执行时间最靠前的
4.SynchronousQueue 不存储元素的阻塞队列 每次插入操作必须等待一个移除操作

LinkedBlockingQueue和ArrayBlockingQueue的异同
LinkedBlockingQueue是默认无界的 支持有界 底层是链表 懒加载的 只有创建节点时才会添加数据 头尾各有一把锁 因此出队入队互不影响 性能好
ArrayBlockingQueue是强制有界的 底层是数组 提前初始化Node数组 只有一把锁 因此相互影响操作 性能不好

确定核心线程数

高并发 执行任务时间短->就是CPU核数 + 1 减少线程上下文切换
并发不高 任务执行时间长 如果是IO密集型任务 就是CPU核数 * 2 + 1 如果是计算密集型任务 就是CPU核数 + 1
并发高 任务执行时间长 就需要考虑整体架构的设计 查看是否可以做到缓存 增加服务器

线程池的种类

线程池的种类有哪些

  • newFixedThreadPool:创建一个定长线程 可控制线程最大并发数 超出线程会在队列中等待
  • newSingleThreadExecutor:创建一个单线程化的线程池 只会用唯一的工作线程来执行任务 保证所有的任务按照指定顺序FIFO执行
  • newCachedThreadPool 创建一个可缓存线程池 都是临时线程 可以灵活回收空闲线程
  • newScheduledThreadPool:可以执行延迟任务的线程池 支持定时及周期性任务执行

为什么不建议使用Executors去创建线程池
因为FixedThreadPool和SingleThreadPool创建的阻塞队列长度为Integer的最大值 可能堆积大量请求 导致OOM
CachedThreadPool创建的线程数量也是Integer的最大值 创建大量线程 同样导致OOM
建议使用ThreadPoolExecutor的方式去创建

使用场景

线程池使用场景

还有一个是当多个接口没有关联并且并行执行时 就可以通过线程池来同步提交任务 然后缩短耗时

控制某个方法允许并发访问线程的数量
信号量隔离 在多线程中 可以使用工具类Semaphore 通过信号量设置来及性能隔离
创建Semaphore对象 然后给定一个容量
acquire请求一个容量 信号量-1
release释放一个容量 信号量+1
从而达到控制并发线程
也可以通过线程隔离 参考sentinel

谈一谈ThreadLocal

  1. ThreadLocal是一个线程内部存储类 让多个线程只操作自己内部的值 从而实现线程数据隔离 避免争用引发的线程安全问题
  2. ThreadLocal实现了线程内的资源共享
  3. 每个线程内有一个ThreadLocalMap类型的成员变量 用来存储资源对象
  • 调用set方法 就是以ThreadLocal自己为key 资源对象为value 放入当前线程的ThreadLocalMap集合中
  • 调用get方法 以ThreadLocal自己为key 在当前线程中查找关联的资源值
  • 调用remove方法 就是以ThreadLocal自己作为key 移除当前线程关联的资源值
  1. ThreadLocal内存泄漏问题
    ThreadLocalMap中的key是弱引用 值是强引用 key会被GC释放内存 关联value的内存不会被释放 因此导致内存泄漏 解决方法就是主动remove释放key value

JVM

Java Virtual Machine Java二进制字节码的运行环境
好处:一次编写 到处运行和自动内存管理 垃圾回收机制

JVM组成

程序计数器

程序计数器(PC Register)
什么是程序计数器?
线程私有的 内部保存的字节码的行号 用来记录正在执行的字节码的地址

JAVA堆

Java堆是一个线程共享的区域 用来保存对象实例,数组等 当堆中没有内存空间可分配时 就会抛出OutOfMemoryError异常 即OOM

Java8的元空间就是方法区 在Java7中 方法区是保存在堆中的 叫做永久代 但是保存在堆中如果过小OOM 过大浪费 所以为了避免OOM 直接在java8中放到了内存中
你能介绍一下Java堆吗

  • 就是线程共享的区域 主要用来保存对象实例 数组等 内存不够就抛出OOM异常 OutOfMemoryError
  • 组成:老年代 + 年轻代
    • 年轻代被划分为两部分 Eden区存放刚创建的对象数组 和两个幸存者Survivor区
    • 老年代主要保存生命周期长的对象 一般是一些老的对象
  • JDK1.7和JDK1.8的区别
    • 1.7中堆中有个永久代 存储的是类信息 静态变量 常量 编译后的代码
    • 1.8移除了永久代 把这块空间存储到了本地内存的元空间中 防止内存溢出OOM

虚拟机栈

Java Virtual Machine Stacks(java虚拟机栈)

  • 每个线程运行时需要的内存 就是虚拟机栈 先进后出
  • 每个栈由多个栈帧(frame)组成 对应每次发昂发调用时所占用的内存 包括参数,局部变量,返回地址
  • 每个线程只能有一个活动栈 就是当前正在执行的方法

垃圾回收是否设计到栈内存?
不涉及 垃圾回收是指堆内存 栈内存回收是把栈帧弹出之后就会释放
栈内存分配越大越好吗?
不是 栈内存默认1024K 如果分配大 就会导致线程数变少
方法内的局部变量是否线程安全
如果方法内部的局部变量没有逃离方法的作用范围 那就是线程安全的
如果方法内部的局部变量引用了对象 并逃出了作用范围 比如返回值 传参 那就不是线程安全的
栈内存溢出情况
栈帧过多导致溢出 递归
栈帧过大导致溢出
堆和栈的区别是什么

  • 栈内存一般存储的是局部变量和方法调用 堆内存是存储java对象和数组 堆会GC垃圾回收 栈不会
  • 栈内存是线程私有的 堆是线程共享的
  • 两者溢出的异常不同
    • 栈内存不足是StackOverFlowError 栈溢出
    • 堆内存不足是OutOfMemoryError 内存不足

方法区

解释一下方法区?

  • 方法区(Method Area) 是线程共享的内存空间
  • 主要存储类的信息以及运行时常量池
  • 虚拟机启动的时候创建 关闭的时候释放
  • 如果方法区内存无法满足所需大小 就会抛OutOfMemoryError:MetaSpace异常

常量池
类似于一张表 虚拟机指令根据这张常量表可以找到要执行的类名 方法名 参数类型 字面量信息
运行时常量池
常量池是.class文件中的 当该类被加载 它的常量池信息就会放入运行时常量池 并把里面的符号地址变为真实地址

直接内存

直接内存:不属于JVM的内存结构 不由JVM管理 是虚拟机的系统内存 常见于NIO操作 用于数据缓冲区 吞吐量大 分配回收成本较高 读写性能好

常规IO操作

常规IO有两个缓冲区 系统缓冲区和Java缓冲区 因为java无法直接操作系统缓冲区 所以需要从系统缓冲区复制一份给java缓冲区 造成了不必要的复制 性能不好

NIO操作

直接内存的存在就不用去管数据的多份复制 java代码和系统都可以访问 减少复制次数 提高性能
你听过直接内存吗
并不属于JVM中的内存结构 不由JVM管理 是虚拟机的系统内存
常见于NIO操作 用于数据缓冲区 分配回收成本较高 但是读写性能好 不受JVM内存回收管理

类加载器

类加载器与双亲委派

什么是类加载器
JVM只会运行二进制文件 类加载器就是将字节码文件加载到JVM中 从而让java程序能够启动起来
类加载器有哪些
顺序是自上而下的

  • 启动类加载器(BootStrapClassLoader) 加载JAVA_HOME/jre/lib下的jar包
  • 扩展类加载器(ExtClassLoader)加载JAVA_HOME/jre/lib/ext下的jar包
  • 启动类加载器(AppClassLoader)加载classPath下的类
  • 自定义类加载器(CustomizeClassLoader)自定义加载规则

什么是双亲委派模型
加载一个类 先委托上一级的加载器进行加载 如果上级加载器也有上级 就继续向上委托 如果该类委托上级没有被加载 子加载器尝试加载该类 如果可以加载 就向下派发
为什么JVM采用双亲委派模型

  • 通过双亲委派机制可以避免某一个类重复被加载 当父类已经加载后则无需重复加载 保证唯一性
  • 为了安全 保证类库API不会被修改

类装载

类从加载到虚拟机开始 直到卸载 生命周期包括了:加载 验证 准备 解析 初始化 使用 和卸载 验证准备解析三部分统称为连接(linking)

加载
通过类的全名 获取类的二进制数据流
解析二进制数据流作为方法区的数据结构
创建类实例 表示该类型 作为方法区这个类的各种数据的访问入口

验证
验证类是否符合JVM规范 安全性检查
文件格式验证 元数据验证 字节码验证都是格式检查 检查格式是否错误 语法是否错误 字节发是否合规
符号引用验证:Class文件在常量池中会通过字符串来记录自己将要使用的其他类或者方法 检查他们是否存在
准备
static变量 只会分配空间 设置默认值 赋值在初始化阶段完成
static final修饰的基本类型或字符串变量 会分配空间 并且赋值
static final的引用类型 分配空间 赋值在初始化阶段完成
解析
把符号引用转换为直接引用
初始化
对类的静态变量 静态代码块进行初始化操作
如果初始化一个类 其父类没被初始化 那就优先初始化其父类
如果包含多个静态变量和静态代码块 则自上而下顺序依次执行
使用
JVM从入口方法开始执行用户的代码

说一下类加载的执行过程

  • 加载:查找和导入class文件
  • 验证:保证加载类的准确性
  • 准备:为类变量分配内存并设置初始值
  • 解析:把类中的符号引用转换为直接引用
  • 初始化:对类的静态变量 静态代码块执行初始化操作
  • 使用:JVM从入口方法开始执行用户代码
  • 销毁:用户代码执行完毕后 jVM开始销毁创建的Class对象

垃圾回收

什么时候垃圾器回收

如果一个或者多个对象没有任何的引用指向它了 就是垃圾 如果定位了垃圾 就会被垃圾器回收 定位垃圾的方法有两种 引用计数器和可达性分析算法
引用计数法
一个对象被引用了一次 就会在对象头上递增一次引用次数 如果引用次数为0 就代表这个对象可回收
但是当出现了循环引用的时候 引用计数法就会失效 引发内存泄漏

可达性分析算法
现在虚拟机都是通过可达性分析算法来确定哪些是垃圾
通过根节点GC Roots开始扫描堆中的对象 以GC Roots为起点的对象都是正常对象 如果以GC Roots为起点扫描不到 那就是垃圾 代表可以回收
哪些对象可以作为GC Root

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(Native方法)引用的对象

垃圾回收算法

分为标记清除算法 复制算法 标记整理算法
标记清除算法
分为两个阶段 标记和清除
1.根据可达性算法把的出的垃圾进行标记
2.对这些标记为可回收的内容进行回收

优点:标记和清除速度较快
缺点:内存不连续 碎片化严重
标记整理算法

跟标记清除差不多 就是多了一步整理空间的流程 没有碎片化的同时导致了性能下降
复制算法

优点:在垃圾较多的时候效率较高 清理后内存无碎片
缺点:内存使用率低 因为有两片内存空间只使用了一半

分代回收

说一下分代回收

  • 堆的区域划分
    在java8中 堆分成了新生代和老年代 新生代占了1/3 老年代占了2/3 新生代中又分了三部分 Eden区中存放的都是刚创建的对象 幸存者区分为from和to 比例是8:1:1

  • 分代回收策略

    • 新创建的对象 都会到Eden区
    • Eden区内存不足 就会用可达性分析算法标记Eden和From存活的对象
    • 将存货对象采用复制算法复制到to中 复制完毕后释放Eden区和from区
    • 一段时间后Eden区又不足 继续标记Eden区和to区的存活对象 复制到from区
    • 当幸存者区对象经过多次回收(最多15次) 晋升到老年代 如果幸存者区内存不足或者对象过大也会提前晋升

MinorGC MixedGC FullGC区别是什么

  • MinorGC发生在新生代的垃圾回收 暂停时间短(STW)
  • MixedGC 新生代和老年代部分区域的垃圾回收 G1收集器特有的
  • FullGC 新生代+老年代完整垃圾回收 暂停时间长(STW) 应尽力避免

STW:StopTheWord 暂停所有应用程序线程 等待垃圾回收完成

垃圾回收器

垃圾回收器包括串行垃圾回收器 并行垃圾回收器 CMS(并发)垃圾回收器 G1垃圾回收器
串行垃圾回收器
Serial和Serial Old串行垃圾回收器 单线程进行垃圾回收 堆内存较小 适合个人电脑

  • Serial作用新生代 采用复制算法
  • Serial Old作用老年代 采用标记整理算法

垃圾回收时 只有一个线程在工作 并且Java中所有线程都要暂停(STW) 等待垃圾回收完成

并行垃圾回收器
Parallel和Parallel Old是一个并行垃圾回收器 JDK8默认采用此垃圾回收器

  • Parallel作用新生代 采用复制算法
  • Parallel Old作用老年代 采用标记整理算法

垃圾回收时 多个线程共同进行垃圾回收 性能要好 但是还是会暂停所有线程 等待垃圾回收线程完成

CMS并发垃圾回收器
全程Concurrent Mark Sweep 是并发的使用标记清除算法的垃圾回收器 该回收器针对老年代垃圾回收 停顿时间短 最大特点就是垃圾回收时 不影响其他线程
<img src="https://img2024.cnblogs.com/blog/3633503/202509/3633503-20250914170807753-297961930.png)

初始标记只标记GC Root的引用 只标记一代 并发标记才开始彻底标记 然后重新标记是为了防止运行时会有新增或删除引用 虽然也有暂停线程 但是时间短

G1垃圾回收器

谈一下G1垃圾回收器

  • 应用于新生代和老年代 JDK9之后默认使用G1
  • 划分成多个区域 每个区域都可以充当eden survivor old humongous 其中humongous专门为大对象准备
  • 采用复制算法
  • 响应时间和吞吐量兼顾
  • 分为新生代回收 并发标记 混合收集
    https://www.bilibili.com/video/BV1yT411H7YK?t=180.8&p=128
  • 如果并发失败(回收速度赶不上创建对象速度)就会出发FullGC

强引用 软引用 弱引用 虚引用

  • 强引用:new对象 只有所有的GC Roots不通过强引用引用该对象 才会被垃圾回收器回收
  • 软引用:配合SoftReference使用 仅有软引用引用该对象时 第一次垃圾回收不会回收该对象 如果第一次之后内存仍不足 就会回收该软引用对象
  • 弱引用:配合WeakReference仅有弱引用引用该对象时 无论内存是否充足 都会回收弱引用对象
  • 虚引用:配合引用队列使用 被引用对象回收时 会将虚引用入队 由Reference Handler线程调用虚引用相关方法释放直接内存

JVM实践

JVM在哪调优

  • war包部署在tomcat中设置
    修改TOMCAT_HOME/bin/catalina.sh文件

  • jar包部署设置

JVM调优参数

JVM调优 主要是更改年轻代 老年代 元空间内存大小和垃圾回收器类型

  • 设置堆空间大小
    -Xms:设置堆初始化大小 -Xmx:设置堆最大大小 最大大小默认是物理内存1/4 初始大小是1/16
    堆太小 导致年轻代和老年代频繁回收 产生STW 暂停用户线程
    堆太大 如果发生FullGC会扫描整个堆空间 暂停用户线程过长
  • 虚拟机栈的设置
    每个线程会默认开启1M的内存 用于存放栈帧 调用参数 局部变量等 一般256K 通常减少每个线程的堆栈 可以产生更多的线程
    -Xss128k
  • Eden区和两个Survivor区大小比例
  • 年轻代晋升老年代阈值
  • 设置垃圾回收收集器
    jdk8默认使用并发垃圾回收器 可以通过设置参数改成使用G1垃圾回收器
    -XX:+UseG1 GC

JVM调优工具

  • 命令工具
    • jps 进程状态信息
    • jstack 查看java进程内线程的堆栈信息
    • jmap 查看堆转信息
    • jhat 堆转储快照分析工具
    • jstat JVM统计监测工具
  • 可视化工具
    • jconsole 对JVM内存 线程 类的监控
    • VisualVM 监控线程 内存情况

内存泄漏

一般来说 都是堆的内存泄露问的多
解决思路:

  • 获取堆内存快照dump
    使用jmap命令拿到运行中程序的dump文件 如果文件没启动或者已经退出 jmap就不合适 因为jmap只适合运行中的 就可以通过配置VM参数 设置发生OOM时生成dump文件 从而可以进一步分析堆中的情况

  • 使用VisualVM分析dump文件

  • 通过查看堆信息的情况 定位内存溢出问题

CPU飙高

CPU飙高的排查与思路
使用top命令查看占用CPU的情况 发现哪一个进程占用CPU较高
通过ps命令查看进程中的线程信息
使用jstack命令查看进程中哪些线程出现了问题 最终定位问题

企业场景

设计模式

工厂设计模式

工厂模式最大的优点就是解耦 不需要跟具体的类打交道 只需要把想要的类交给工厂来创建
简单工厂
就是通过一个工厂来创建 虽然解耦 但是耦合还是存在
工厂方法模式
创建一个工厂接口规定规则 然后通过工厂实现类来明确所要创建的类 彻底解耦

优点:
用户只知道具体工厂名称即可创建所要的类 无需知道类的创建过程
无需对原工厂进行任何修改 满足开闭原则
缺点:
每增加一个产品就要增加一个具体产品类和一个对应的具体工厂类 增加了系统的复杂度
抽象工厂模式
就是遇到华为和小米的这种不同品牌 且都有手机电脑的来看 一个工厂方法是不够的 抽象工厂就是再加一层 先分品牌 再分手机还是电脑 所以就有了抽象工厂模式

优点:保证客户端只使用同一个产品组的对象
缺点:需要新增产品时 所有工厂类都要修改

策略模式

优点:策略类可以自由切换 易于扩展
缺点:客户端必须知道所有的客户类 策略模式会产生很多的策略类

什么是策略模式
策略模式定义了一系列算法 并将眉哥哥算法封装起来 使他们可以相互替换 且算法的变化不会影响使用算法的客户
一个系统需要动态的在几种算法中选择一种时 可以将每个算法封装到策略类中
案例(工厂方法 + 策略)
介绍业务(满减 满300九折 500八折 1000七折)
提供多种策略 都让Spring容器管理
提供一个工厂 准备策略对象 根据参数提供对象

大致思路就是在yml文件中配置参数 yaml中key是前端传的不同的策略 值是策略的对象名
所有策略的对象是交给了spring容器管理 然后在工厂方法中定义map 通过实现Aware接口(ApplicationContextAware)在容器中获取策略对象 然后放到map中 工厂就可以根据策略类型 得到策略对象 然后在service中就可以注入工厂方法 实现开闭自由 解耦合

责任链设计模式

优点:
降低耦合 增强可扩展性 责任分担
缺点:
责任链较长 要涉及多个对象 性能较低
增加了客户端的复杂性

常见技术场景

单点登录

单点登录 Single Sign On(SSO) 只登陆一次 就可以访问所有信任的应用系统
使用JWT
用户发起登录请求 返回给前端一个token 前端把token存到请求头中
用户访问其他服务 就携带了token 由网关进行验证 无效就返回401(认证失败) 跳到登陆页面
校验成功 再由网关路由到其他服务
传递给下游?OpenFeign?

权限认证?

后台管理系统更注重权限控制 最常见的就是RBAC模型
(Role-Based Access Control)
具体实现
五张表(用户表 角色表 权限表 用户角色中间表 角色权限中间表)用户角色多对多 所以需要一张中间表 角色权限多对多 需要一张中间表
七张表(用户表 角色表 权限表 菜单表 用户角色中间表 角色权限中间表 权限菜单中间表)

权限认证如何实现
后台管理的相关经验
RBAC的五张表(用户表 角色表 权限表)
SpringSecurity

数据安全性

对称加密:
文件解密和加密使用同一把密钥
优点:加密速度快 效率高
缺点:不安全
非对称加密
公开密钥加密 私有密钥解密
优点:安全性更高
缺点:加密解密速度慢
上传数据的安全性如何控制
使用非对称加密 给前端一个公钥 把数据加密后传到后端 后端解密后处理数据
文件大用对称加密 不要存敏感信息
文件小 要求安全性 就非对称加密

日志采集

采集日志是为了定位问题
方式有哪些
常规采集:按天保存一个日志文件
ELK:ElasticSearch Logstash Kibana

项目中日志是这么采集的?
搭建了ELK日志采集系统
ElasticSearch:全文搜索数据引擎 可以对数据进行存储 分析 搜索
Logstash:数据收集引擎 可以动态收集数据 可以对数据进行过滤分析 主要是收集日志
Kibana:数据可视化分析平台 来对ES的数据进行分析 查询 图表化展示

查看日志的命令有哪些
Linux:
实时监控日志的变化:tail -f xx.log
按照行号查询:tail -n 100 xx.log 尾部 head -n 100 xx.log 头部
查询日志中含debug:cat -n xx.log | grep 'debug' 出来的是行号 结合上面
按照日期查询:

日志太多 处理方式:

生产问题排查

先分析日志 查看系统日志或日志文件 定位问题
运用远程debug

快速定位系统瓶颈

压测(性能测试)
监控工具 链路追踪工具
线上诊断工具 Arthas(阿尔萨斯)

posted @ 2025-09-03 20:21  big4mart  阅读(17)  评论(0)    收藏  举报