网站开发需要会什么,python网页游戏开发,上海配资网站开发,wordpress 界面 阴影文章目录 自我介绍MySQL索引索引种类、B树聚簇索引、非聚簇索引联合索引、最左前缀匹配原则索引下推索引失效索引优化 日志、缓冲池redo log#xff08;重做日志#xff09;刷盘时机日志文件组 bin log#xff08;归档日志#xff09;记录格式写入机制 两阶段提交undo log树聚簇索引、非聚簇索引联合索引、最左前缀匹配原则索引下推索引失效索引优化 日志、缓冲池redo log重做日志刷盘时机日志文件组 bin log归档日志记录格式写入机制 两阶段提交undo log回滚日志Buffer Pool缓冲池 主从复制、分库分表**主从复制****分库分表** 事务、MVCCACID特性隔离级别MVCC隐藏字段ReadViewundo-log数据可见性算法RC 和 RR 隔离级别下 MVCC 的差异MVCC临建锁防止幻读 可重复读不完全解决幻读 InnoDB、插入缓存、预读四大特性MyISAM和InnoDB有什么区别 sql语句执行过程、mysql架构sql语句执行过程mysql基本架构概览 慢查询、执行计划mysql中的锁表级锁和行级锁对比记录锁、间隙锁、临建锁、插入意向锁行锁共享锁和排他锁意向锁表锁 数据存储、行格式表空间文件结构行格式行溢出磁盘IO过高优化 mysql、sql语句优化 框架SpringIOCBean 的作用域Bean 是线程安全的吗Bean 的生命周期循环依赖/循环引用 AOPSpring 框架的设计模式Spring 事务管理事务的方式事务传播行为工作原理事务失效 SpringMVC核心组件执行流程/工作原理 SpringBoot自动装配/自动配置 Mybatis执行流程分页插件延迟加载多级缓存一级缓存二级缓存 JVMjvm参数监控工具java内存区域运行时数据区程序计数器程序计数器为什么是私有的? java虚拟机栈虚拟机栈和本地方法栈为什么是私有的? 本地方法栈堆方法区运行时常量池字符串常量池JDK 1.7 为什么要将字符串常量池移动到堆中 直接内存 对象创建、布局、访问过程对象的创建对象的内存布局对象的访问定位 jvm垃圾回收内存分配和回收原则死亡对象判断方法、GCRoots四种引用类型**如何判断一个类是无用类**垃圾回收算法标记清除标记复制标记整理分代收集 垃圾收集器Serial收集器ParNew收集器Parallel Scavenge收集器Serial Old收集器Parallel Old收集器CMS收集器G1收集器ZGC收集器 STW类文件结构Class文件结构 类加载过程类的生命周期类加载过程加载验证准备解析初始化 类卸载 类加载器类加载器加载规则3个内置类加载器自定义类加载器双亲委派模型执行流程好处打破双亲委派、tomcat JUC四大锁、锁升级、锁降级、锁粗化、锁消除读锁为什么不能升级为写锁 共享锁和独占锁可中断锁和不可中断锁公平锁和非公平锁乐观锁和悲观锁乐观锁存在哪些问题 JMM、指令重排、并发三特性指令重排序volatile如何禁止指令重排序 JMM、happens-beforeJava 内存区域和 JMM 有何区别happens-before 原则happens-before 常见规则有哪些happens-before 和 JMM 什么关系 并发三个特性原子性volatile 可以保证原子性么 可见性volatile如何保证变量的可见性 有序性 线程池Executor框架工作原理/流程Runnable vs Callableexecute() vs submit()shutdown()VSshutdownNow()isTerminated() VS isShutdown() 常见内置线程池FixedThreadPoolSingleThreadExecutorCachedThreadPoolScheduledThreadPool 为什么不推荐使用内置线程池线程池常见参数线程池饱和策略拒绝策略 AQSAQS核心思想AQS 资源共享方式 常见同步工具类Semaphore(信号量)CountDownLatch(倒计时器CyclicBarrier(循环栅栏) Atomic原子类ThreadLocal数据结构Hash 冲突、过期清理set()扩容机制get()详解内存泄露 CompletableFutureCompletableFuture 类有什么用 并发和并行同步、异步、阻塞、非阻塞四种组合 线程间协作方式线程生命周期和状态线程上下文切换什么是线程死锁?如何避免死锁?sleep() 方法和 wait() 方法Sychronized概述构造方法可以用synchronized修饰吗synchronized底层原理 ReentrantLocksynchronized 和 ReentrantLock 有什么区别ReentrantReadWriteLockReentrantReadWriteLock 适合什么场景 StampedLockStampedLock 的性能为什么更好StampedLock 适合什么场景StampedLock 的底层原理 FutureFuture有什么用Callable 和 Future 有什么关系 集合ArrayList扩容机制 ArrayList和LinkedList区别HashMap添加和扩容扩容时先插入再扩容还是先扩容再插入HashMap 为什么线程不安全 ConcurrentHashMapConcurrentHashMap 1.7ConcurrentHashMap 1.8ConcurrentHashMap 和 Hashtable 的区别 LinkedHashMapCopyOnWriteArrayListBlockingQueueArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别PriorityQueueComparable 和 Comparator 的区别集合转换集合转 Map集合遍历集合去重集合转数组数组转集合 Java异常反射Unsafe深拷贝、浅拷贝、引用拷贝StringString为什么不可变字符串拼接Java 9 为何要将 String 的底层实现由 char[] 改成了 byte[] ?String#intern 方法有什么作用? 常用字符编码所占字节数新特性finalIO模型BIO、NIO、AIO值传递还是引用传递 Redis缓存一致性、旁路缓存旁路缓存读写穿透异步缓存写入 数据结构、跳表五大数据结构底层数据结构3种特殊数据结构跳表 RDBAOF工作基本流程AOF 重写AOF为什么是在执行完命令之后记录日志AOF校验机制了解吗RDB和AOF混合持久化如何选择 RDB 和 AOF 分布式锁setnx常见阻塞原因、大Key单线程、IO模型、性能Redis 为什么这么快、高性能单线程模型、Reactor 模式多线程后台线程五种IO模型、IO多路复用Redis 到底是单线程还是多线程 过期删除内存淘汰事务什么是 Redis 事务如何使用 Redis 事务Redis 事务支持原子性吗Redis 事务支持持久性吗如何解决 Redis 事务的缺陷 性能优化缓存穿透、缓存击穿、缓存雪崩缓存穿透缓存击穿缓存雪崩 主从复制、集群哨兵模式分片Redission延迟队列 计算机网络网络模型、输入URLTCP/IP 四层网络模型OSI 七层模型输入URL会发生什么DNS HTTPHTTP状态码、报文结构HTTP 常见的状态码有哪些HTTP 报文结构 GET与POST区别GET 和 POST 方法都是安全和幂等的吗 HTTP缓存强制缓存协商缓存 HTTP1.1断点续传优点缺点长连接、管道传输优化方案 HTTPS和HTTP区别HTTPS 解决的问题为什么安全建立连接过程证书校验流程保证完整性HTTPS 一定安全可靠吗优化方案 HTTP1.1、HTTP2.0、HTTP3.0HTTP/1.1 相比 HTTP/1.0 提高了什么性能HTTP2.0 做了什么优化HTTP/3 做了哪些优化 既然有 HTTP 协议为什么还要有 RPCHTTP 和 RPC 有什么区别WebSocket TCPTCP介绍TCP头格式TCP 保活MSS 和 MTUTCP 保证可靠性UDP 和 TCP区别和使用场景TCP 和 UDP 可以使用同一个端口吗 三次握手三次握手过程为什么是3次握手不是2次、4次初始序列号作用第一次握手丢失会发生什么第二次握手丢失会发生什么第三次握手丢失会发生什么SYN 攻击、如何避免SYN 报文丢弃情况、PAWS没 accept 建立连接没 listen 建立连接已建立连接的TCP收到 SYN 四次挥手、TIMEOUT四次挥手四次挥手过程为什么挥手需要四次四次挥手可以变成三次吗第一次挥手丢失会发生什么第二次挥手丢失会发生什么第三次挥手丢失会发生什么第四次挥手丢失会发生什么 TIMEWAITTIME_WAIT 等待 2MSLTIME_WAIT 优化TIME_WAIT 收到 SYNTIME_WAIT 收到RST 收到乱序的 FIN 包 重传机制滑动窗口接收窗口和发送窗口的大小是相等的吗滑动窗口是如何影响传输速度的 流量控制拥塞控制半连接队列和全连接队列什么是 TCP 半连接队列和全连接队列如何防御SYN攻击 优化 TCP三次握手的性能提升四次挥手的性能提升传输数据的性能提升如何确定最大传输速度怎样调整缓冲区大小 粘包TLS 和 TCP 能同时握手吗TCP Keepalive 和 HTTP Keep-AliveTCP 协议有什么缺陷如何基于 UDP 协议实现可靠传输用了 TCP 协议数据一定不会丢吗TCP 序列号和确认号是如何变化的如果客户端发送的第三次握手ACK丢失了处于SYN_RCVD状态服务端收到了客户端第一个TCP数据报文会发生什么 IPIP基础CIDR无分类地址IP分片与重组IPv6DNSARPRARPDHCPNATICMPping的工作原理断网了还能 ping 通 127.0.0.1 吗 设计模式单例模式双重检验锁实现单例模式 适配器模式装饰器模式适配器模式和装饰器模式有什么区别 观察者模式发布订阅模式代理模式JDK 动态代理和 CGLIB 动态代理对比静态代理和动态代理对比 排序冒泡排序选择排序插入排序希尔排序归并排序快速排序堆排序计数排序桶排序基数排序LRU算法(lru)(链表hashmap) 消息队列RPC 和消息队列的区别分布式消息队列技术选型RabbitMQ基础消费模型组成AMQP消息堆积惰性队列死信交换机、死信队列延迟队列优先级队列工作模式消息传输消息的不丢失、可靠性消息的重复消费、顺序性高可用延时和过期失效 Kafka概述、CAP分区同步三个参数消息模型高性能设计、为什么吞吐量高高可用、分区备份机制分区备份机制 Zookeeper和Kafka消费顺序性消息不丢失/可靠性消息重复消费Rebalance重试机制数据清理、文件存储机制 操作系统中断内存管理虚拟内存内存分段内存分页段页式内存管理内存分配过程预读失效和缓存污染 进程生命周期状态进程、线程间通信方式线程线程和进程比较多线程互斥和同步死锁进程、线程调度算法内存页面置换算法文件系统网络系统DMA直接内存访问零拷贝PageCache高性能网络模式Reactor和Proactor一致性哈希五种IO模型、IO多路复用 Linux命令性能指标网络配置socket信息网络吞吐率和PPS连通性和延时分析日志 Nginx反向代理静态映射负载均衡策略 Zookeeper应用场景集群 分布式/微服务Cookie 和 Sessiontoken 和 JWT单点登录SSO分布式锁CAP和BASE分布式事务SeataMQ分布式事务 分布式算法Paxos算法一致性Hash算法雪花算法 SpringCloud/alibaba五大组件服务雪崩降级熔断服务降级服务熔断 限流固定窗口计数器算法滑动窗口计数器算法漏桶算法Sentine排队等待算法令牌桶算法Sentine预热限流算法 git 手动实战篇Redis开篇导读1、短信登录1.2 、基于Session实现登录流程1.3 、实现发送短信验证码功能1.4、实现登录拦截功能1.5、隐藏用户敏感信息1.6、session共享问题1.7 Redis代替session的业务流程1.7.1、设计key的结构1.7.2、设计key的具体细节1.7.3、整体访问流程 1.8 基于Redis实现短信登录1.9 解决状态登录刷新问题1.9.1 初始方案思路总结1.9.2 优化方案1.9.3 代码 2、商户查询缓存2.1 什么是缓存?2.1.1 为什么要使用缓存2.1.2 如何使用缓存 2.2 添加商户缓存2.2.1 、缓存模型和思路2.1.2、代码如下 2.3 缓存更新策略2.3.1 、数据库缓存不一致解决方案2.3.2 、数据库和缓存不一致采用什么方案 2.4 实现商铺和缓存与数据库双写一致2.5 缓存穿透问题的解决思路2.6 编码解决商品查询的缓存穿透问题2.7 缓存雪崩问题及解决思路2.8 缓存击穿问题及解决思路2.9 利用互斥锁解决缓存击穿问题3.0 、利用逻辑过期解决缓存击穿问题3.1、封装Redis工具类 3、优惠卷秒杀3.1 -全局唯一ID3.2 -Redis实现全局唯一Id3.3 添加优惠卷3.4 实现秒杀下单3.5 库存超卖问题分析3.6 乐观锁解决超卖问题3.6 优惠券秒杀-一人一单3.7 集群环境下的并发问题 4、分布式锁4.1 、基本原理和实现方式对比4.2 、Redis分布式锁的实现核心思路4.3 实现分布式锁版本一4.4 Redis分布式锁误删情况说明4.5 解决Redis分布式锁误删问题4.6 分布式锁的原子性问题4.7 Lua脚本解决多条命令原子性问题4.8 利用Java代码调用Lua脚本改造分布式锁 5、分布式锁-redission5.1 分布式锁-redission功能介绍5.2 分布式锁-Redission快速入门5.3 分布式锁-redission可重入锁原理5.4 分布式锁-redission锁重试和WatchDog机制 自我介绍
面试官你好感谢贵公司给我一个面试的机会我叫薛君宝来自西安邮电大学计算机学院软件工程专业现在是一名大三在校生在大一期间我自学了c语言加入了我们学校的实验室选择java后端方向先后学习了java基础jdbcmysqlssm框架以及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前缀匹配的范围查询不会停止匹配所以在创建联合索引的时候可以将区分度高的字段放在最左边可以过滤更多数据 比如联合索引ab在a相同时b是有序的在a1 and b2的情况下是可以走到索引的而你执行a 1 and b 2时a字段能用到索引b字段用不到索引。因为a的值此时是一个范围不是固定的在这个范围内b值不是有序的因此b字段用不上索引。 索引下推
在非聚簇索引遍历过程中对索引中包含的字段先做判断过滤掉不符合条件的记录减少回表次数。
将过滤条件推到存储引擎层处理减少回表次数
比如select * from t where name like ‘a%’ and age1联合索引是name,age传统会找联合索引以a开头之后将所有以a开头的记录回表查询开启索引下推后会继续查找age1的索引记录之后再进行回表操作
索引失效 创建了联合索引但查询条件未遵守最左匹配原则 不一定会失效如果字段都是索引也会走全扫描二级索引树因为优化器认为成本低 在索引列上计算、函数、类型转换等操作 因为索引保存的是初始值不是函数计算后的值 以%开头的like 不一定会失效看字段如果字段只有主键和二级索引就不会走全表扫描而是走全扫描二级索引树优化器选择 查询条件使用or同时or的前后条件中有一个列没有索引涉及的索引都不会被使用到 因为or就是满足一个就可以因此只有一个条件列是索引列就没意义只要有条件列不是索引列就要进行全表扫描 发生隐式转换索引字段是字符串类型查询时输入参数是整型的话就走全表扫描。如果索引是整型输入参数是字符串就不会导致索引失效因为mysql会自动把字符串转为数字用函数
索引优化
选择合适的字段 不为NULL数据为NULL时数据库较难优化被频繁查询的字段被作为条件查询的字段高区分度的列男女低区分度频繁需要排序的字段被经常频繁用于连接的字段提高多表连接查询的效率 被频繁更新的字段应该慎重简建立索引 虽然查询快但维护成本较高如果一个字段不经常查询但经常被修改就不应该在这种字段上建立索引 限制每张表上的索引数量 单表不超过5个索引会增加查询效率同时会降低插入和更新的效率mysql优化器在选择如何优化查询时会对每一个可以用到的索引进行评估生成一个最好的执行计划如果同时有很多个索引都可以用于查询就会增加mysql优化器生成执行计划的时间同时降低查询性能 尽可能考虑建立联合索引而不是单列索引 每个索引都对应一颗B树如果表的字段过多索引过多数据变多时索引占用空间会很大修改索引耗费的时间会更多联合索引会节约磁盘空间修改数据的操作效率会提升 注意避免冗余索引 索引功能相同能命中ab就肯定可以命中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 pos 和 checkpoint 之间的还空着的部分可以用来写入新的 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记录行数据最终被修改成的样子缺点时每行数据的变化都会记录批量修改产生大量数据而在默认情况下只记录一个updatemixed根据不同情况使用上面两个
写入机制
事务执行时先把日志写到binlog cacheServer层的cache事务提交的时候再把binlog cache写到binlog文件
一个事务的binlog不能被拆开确保一次性写入所以系统会给每个线程分配一个块内存作为binlog cache
每个线程都向page cache里write再fsync到磁盘虽然每个线程都有自己的binlog cache但最终都写入同一个binlog文件
write和fsync的时机默认是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状态设置为commitwrite到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刷盘操作合并为一个减少磁盘IOprepare不变commit分为三个阶段
flush多个事务按顺序将binlog写入page cachesync对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的可重复读可以很大程度解决幻读有两种情况
快照读用MVCC保证不出现幻读可重复读下读取一致性数据当前读使用Next-key Lock加锁保证不出现幻读Next-key Lock是行锁和间隙锁的结合行锁只能锁住已经存在的行为了避免插入新行需要依赖间隙
MVCC
多版本并发控制多个并发事务同时读写数据的时候保证数据的一致性和隔离性通过在每行维护多个版本的数据实现当一个事务要修改数据时MVCC会为该事务创建一个数据快照而不是直接修改实际数据行
MVCC的实现依赖隐藏字段、Read View、undo log。在内部实现中InnoDB 通过数据行的事务id和 Read View 来判断数据的可见性如不可见则通过数据行的 回滚指针找到 undo log 中的历史版本。每个事务读到的数据版本可能是不一样的在同一个事务中用户只能看到该事务创建 Read View 之前已经提交的修改和该事务本身做的修改
隐藏字段
到底清楚不
每行数据有三个隐藏字段
DB_TRX_ID6字节表示最后一次插入或更新该行的事务 id。此外delete 操作在内部被视为更新只不过会在记录头 Record header 中的 deleted_flag 字段将其标记为已删除DB_ROLL_PTR7字节 回滚指针指向该行的 undo log 。如果该行未被更新则为空
ReadView
Read View主要用来做可见性判断里面保存了当前对本事务不可见的其他活跃事务
主要有以下字段
m_low_limit_id目前出现过的最大的事务 ID1大于等于这个 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 log 和 update undo log insert undo log指在 insert 操作中产生的 undo log。因为 insert 操作的记录只对事务本身可见对其他事务不可见故该 undo log 可以在事务提交后直接删除。 update undo logupdate 或 delete 操作中产生的 undo log。该 undo log可能需要提供 MVCC 机制因此不能在事务提交时就进行删除。提交时放入 undo log 链表等待 purge线程 进行最后的删除
不同事务或者相同事务的对同一记录行的修改会使该记录行的 undo log 成为一条链表链首就是最新的记录链尾就是最早的旧记录。
数据可见性算法
ReadView中主要就是有个列表来存储我们系统中当前活跃着的读写事务也就是begin了还未提交的事务。通过这个列表来判断记录的某个版本是否对当前事务可见。假设当前列表里的事务id为[80,100]。
a) 如果你要访问的记录版本的事务id为50比当前列表最小的id80小那说明这个事务在之前就提交了所以对当前活动的事务来说是可访问的。
b) 如果你要访问的记录版本的事务id为90,发现此事务在列表id最大值和最小值之间那就再判断一下是否在列表内如果在那就说明此事务还未提交所以版本不能被访问。如果不在那说明事务已经提交所以版本可以被访问。
c) 如果你要访问的记录版本的事务id为110那比事务列表最大id100都大那说明这个版本是在ReadView生成之后才发生的所以不能被访问。
这些记录都是去undo log 链里面找的先找最近记录如果最近这一条记录事务id不符合条件不可见的话再去找上一个版本再比较当前事务的id和这个版本事务id看能不能访问以此类推直到返回可见的版本或者结束。
RC 和 RR 隔离级别下 MVCC 的差异
在 RC 隔离级别下的 每次select 查询前都生成一个Read View (m_ids 列表)后续查询都利用这个Read View通过这个Read View就可以在undo log版本链找到事务开始时的数据所以每次查询的数据都一样在 RR 隔离级别下只在事务开始后 第一次select 数据前生成一个Read Viewm_ids 列表
s
MVCC临建锁防止幻读
RR级别通过MVCC和临建锁解决幻读
普通select会以MVCC快照读方式读取数据RR只会在事务开启的第一次查询生成read view所以其他事务的更新、插入对当前事务不可见实现可重复读和防止快照读下的幻读执行select...for update、插入、修改、删除等当前读当前读下读取的都是最新的数据如果其他事务插入新的数据并且刚好在事务查询范围里就会产生幻读使用临建锁防止幻读执行当前读时锁定读取到的记录的同时锁定间隙防止其他事务在查询范围插入数据。只要不插入就不会幻读
可重复读不完全解决幻读
可重复读时事务1第一次普通select生成Read View之后事务2新添加记录并提交然后事务1对那条记录进行更新此时这条记录的trx_id就变成事务1的事务id之后事务1就可以用普通select查询该条记录事务1先快照读事务2插入一条记录事务1再次当前读就会读到事务2插入的记录幻读 解决时在开启事务之后立刻执行当前读因为会对记录加临建锁避免其他事务在对应范围插入新纪录
InnoDB、插入缓存、预读
四大特性
插入缓存写缓存change buffer增删改操作有效是insert buffer只对insert有效的增强提升插入性能降低磁盘io 对于非唯一普通索引页唯一索引在插入时需要判断唯一要读取辅助索引页到缓冲池才能使用不在缓冲池对页进行写操作不会立刻加载到缓冲池仅仅记录缓冲变更等下次读取时再将数据合并恢复到缓冲池还有后台线程在数据库空闲时、缓冲池不够时、数据库正常关闭、redolog写满时都会触发刷新插入时先判断插入的索引页是否在缓冲池在就直接插入不在就先放入insert buffer再进行合并操作写回磁盘通常把多个插入合并到一个操作减少随机io比如Insert buffer有1992100合并之前需要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存储引擎负责数据的存储和读取支持InnodbMyISAMMemoryinnodb有日志模块redo logmysql5.5时是默认innodb存储引擎
慢查询、执行计划
定位慢查询日志记录所有执行时间超过默认10秒的sql要配置开启慢查询 slow_query_log 1查询命令show variables like long_query_time;
原因explain或者desc加上查询sql
执行计划是一条sql在经过mysql查询优化器后具体的执行方式 select_type查询的类型主要用于区分普通查询、联合查询、子查询等复杂的查询常见的值有 UNION在 UNION 语句中UNION 之后出现的 SELECT。DERIVED在 FROM 中出现的子查询将被标记为 DERIVED。 type查询执行的类型描述了查询是如何执行的。所有值的顺序从最优到最差排序为system const eq_ref ref fulltext ref_or_null index_merge unique_subquery index_subquery range index ALL possible_keys可能使用的索引没有就为NULL key实际用的索引NULL表示没用到索引 key_len实际使用索引的最大长度联合索引时可能是多个列的长度和 rows估算找到所需记录需要读取的行数越小越好 Extra额外信息 Using index表明查询使用了覆盖索引不用回表查询效率非常高。Using index condition表示查询优化器选择使用了索引下推这个特性。
mysql中的锁
表级锁和行级锁对比
表级锁和行级锁对比
表级锁 MySQL 中锁定粒度最大的一种锁全局锁除外是针对非索引字段加的锁对当前操作的整张表加锁MyISAM 和 InnoDB 引擎都支持表级锁。行级锁 MySQL 中锁定粒度最小的一种锁是 针对索引字段加的锁 只针对当前操作的行记录进行加锁。 行级锁能大大减少数据库操作的冲突。其加锁粒度最小并发度高但加锁的开销也最大加锁慢会出现死锁。行级锁和存储引擎有关是在存储引擎层面实现的。
记录锁、间隙锁、临建锁、插入意向锁行锁
InnoDB 行锁是通过对索引数据页上的记录加锁实现的MySQL InnoDB 支持三种行锁定方式
记录锁Record Lock也被称为记录锁属于单个行记录上的锁。 有共享锁S和排他锁X 间隙锁Gap Lock锁定一个范围不包括记录本身。锁前开后开 多个事务可以同时持有相同间隙范围的间隙锁不存在互斥关系 临键锁Next-Key LockRecord LockGap Lock锁定一个范围包含记录本身主要目的是为了解决幻读问题。记录锁只能锁住已经存在的记录为了避免插入新记录需要依赖间隙锁。是加锁基本单位锁前开后闭 用唯一索引等值查询时如果查询记录存在临建锁会退化为记录锁如果不存在临建锁会退化为间隙锁用唯一索引范围查询时会对每个扫描的索引加间隙锁但如果是大于等于就会退化为记录锁小于或小于等于就会退化为间隙锁用普通索引等值查询时会同时对主键索引和普通索引加锁但对主键索引加锁时只有满足查询条件的记录才对主键加锁 如果查询记录存在由于肯定存在索引值相同的记录所以是一个扫描的过程直到第一个不符合条件的二级索引就停止途中加的是临建锁第一个不符合条件的退化为间隙锁符合查询条件的加记录锁如果查询记录不存在扫描到第一条不符合条件的二级索引记录该索引的临建锁退化为间隙锁因为不存在满足条件的记录就不会对主键索引加锁 用普通索引范围查询时临建锁不会退化都是临建锁没加索引的查询当前读查询时没有用索引列作为查询条件或查询语句没走索引导致全表扫描那么每一条记录都会加临建锁相当于锁全表 避免方法设置安全更新update必须满足1用where并且条件必须有索引列2用limit3同时用where和limitwhere可以没索引列其中之一才能执行成功delete必须满足同时用where和limitwhere可以没索引列才能执行成功如果where带了索引但优化器还是选择全表可以用force index告诉优化器使用哪个索引 插入意向锁锁定一个点是特殊的间隙锁如事务1在准备插入的时候发现已经被事务2加了间隙锁插入就会阻塞此时事务1生成一个插入意向锁处于等待状态不意味事务1获取到锁只有正常状态才能获取到锁此时事务1发生阻塞直到事务2提交了事务。注意两个事务在同一时间不能一个拥有间隙锁另一个拥有对应间隙的插入意向锁
共享锁和排他锁
不论是表级锁还是行级锁都存在共享锁Share LockS 锁和排他锁Exclusive LockX 锁这两类
共享锁S 锁又称读锁事务在读取记录的时候获取共享锁允许多个事务同时获取锁兼容。排他锁X 锁又称写锁/独占锁事务在修改记录的时候获取排他锁不允许多个事务同时获取。如果一个记录已经被加了排他锁那其他事务不能再对这条事务加任何类型的锁锁不兼容。
意向锁表锁
快速判断是否可以对某个表使用表锁
意向锁是表级锁共有两种
意向共享锁Intention Shared LockIS 锁事务有意向对表中的某些记录加共享锁S 锁加共享锁前必须先取得该表的 IS 锁。意向排他锁Intention Exclusive LockIX 锁事务有意向对表中的某些记录加排他锁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给该字段加索引但只是因为查询简单优化2SELECT * FROM product WHERE ID (select id from product limit 10000, 1) limit 10优化3SELECT * 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 创建对象 构造函数 依赖注入 检查Aware相关接口并设置相关依赖传入bean名称、bean类加载器、bean工厂实例 BeanPostProcessor后置处理器在初始化方法之前处理 初始化方法 检查是不是InitializingBean来决定是否调用afterPropertiesSet方法检查是否配置有自定义的iit-method BeanPostProcessor后置处理器在初始化方法之后处理 销毁bean 如果bean实现了DisposableBean接口 就执行destroy()销毁bean的时候如果bean在配置文件包含destroy-method属性就执行对应方法
循环依赖/循环引用
两个或两个以上的bean互相持有对象最终形成闭环比如a依赖bb依赖a
三级缓存解决大部分循环依赖
一级缓存单例池缓存已经经历完整生命周期初始化完成的bean对象二级缓存缓存早期的bean对象生命周期没走完 避免多次调用对象工厂产生多例每次生成对象不同所以使用工厂生产好的对象直接放到二级缓存使用时都是同一个只使用一二级缓存可以解决一般对象的循环依赖但如果是代理对象就应该注入代理对象但spring都是在创建完bean之后才创建对应的代理代理在后置处理的初始化后完成aop代理不提前创建代理对象在出现循环依赖被其他对象注入时才生成代理对象放入二级缓存设计原则所以引入三级缓存 三级缓存缓存ObjectFactory主要用来创建代理对象代理对象再放到二级缓存
先实例化aa生成一个对象工厂放到三级缓存注入b的时候需要实例化b此时b生成一个对象工厂放到三级缓存b里需要注入a通过三级缓存里a的对象工厂创建代理对象一个也可以是指定的其他对象放入二级缓存再从二级缓存把a的代理对象注入到bb创建成功后放入单例池把b注入a后放入单例池
构造方法的循环依赖需要使用Lazy进行懒加载需要对象再进行bean对象的创建因为bean的生命周期中构造函数第一个执行框架不能解决构造函数的依赖注入
AOP
面向切面编程能将与业务无关却为业务逻辑调用相同的逻辑封装起来。减少重复代码
基于动态代理如果要代理的对象实现某个接口就用jdk动态代理其他使用cglib动态代理
AspectJ是AOP的框架
前置、后置、返回方法结束返回结果值之前、异常、环绕通知
Order自定义切面顺序
Spring 框架的设计模式
工厂设计模式 : Spring 使用工厂模式通过 BeanFactory或ApplicationContext 创建 bean 对象。 BeanFactory延迟注入使用bean的时候注入ApplicationContext容器启动的时候一次性创建所有bean扩展了BeanFactory 代理设计模式 : Spring AOP 功能的实现。单例设计模式 : Spring 中的 Bean 默认都是单例的。 ConcurrentHashMap实现给这个map添加的时候synchronized这个map 模板方法模式 : Spring 中 jdbcTemplate、hibernateTemplate 等以 Template 结尾的对数据库操作的类它们就使用到了模板模式。装饰者设计模式 : 我们的项目需要连接多个数据库而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。观察者模式: Spring 事件驱动模型就是观察者模式很经典的一个应用。适配器模式 : Spring AOP 的增强或通知(Advice)使用到了适配器模式、spring MVC 中也是用到了适配器模式适配Controller。 AOP中每个类型的通知都有对应的拦截器通知要通过对应的适配器是配成MethodInterceptor接口类型的对象通过调用getInterceptor适配成MethodBeforeAdviceInterceptorMVC中DispatcherServlet调用HandlerMapping解析请求对应的Handler解析到对应的HandlerController再由HandlerAdapter适配器处理Controller作为需要适配的类因为Controller太多不同Controller要通过不同方法对请求处理所以让适配器执行处理
Spring 事务
管理事务的方式
编程式事务在代码中硬编码(不推荐使用) : 通过 TransactionTemplate或者 TransactionManager 手动管理事务实际应用中很少使用但是对于你理解 Spring 事务管理原理有帮助。声明式事务在 XML 配置文件中配置或者直接基于注解推荐使用 : 实际是通过 AOP 实现基于Transactional 的全注解方式使用最多
事务传播行为
事务传播行为是为了解决业务层方法之间互相调用的事务问题。
四种正确的行为
REQUIRED默认如果存在事务就加入该事务如果当前没有事务就创建一个新事务REQUIRES_NEW创建一个新事务如果存在当前事务就把当前事务挂起NESTED如果当前存在事务就创建一个事务作为当前事务的嵌套事务运行如果当前没有事务就创建一个新事务MANDATORY如果当前存在事务就加入该事务如果当前没有事务就抛出异常
三种错误的行为事务可能不会回滚
SUPPORTS如果当前存在事务就加入该事务如果当前没有事务就以非事务的方式继续运行NOT_SUPPORTED以非事务方式运行如果当前存在事务就把当前事务挂起NEVER以非事务方式运行如果当前存在事务就抛出异常
工作原理
基于AOP动态代理一个方法添加Transactional注解之后spring会基于这个类生成一个代理对象会把这个代理对象作为bean使用这个代理对象的时候如果有事务处理就先把事务的自动提交关闭再执行具体的业务如果业务没有出现异常spring会提交事务如果出现异常会回滚
事务失效
异常捕获处理事务通知只有捕捉到目标抛出的异常才能进行回滚如果目标自己处理事务通知就无法回滚可以在catch后再次throw抛出检查异常比如读文件编译异常文件并不存在因为spring默认只回滚非检查异常可以配置rollbackFor属性为Exception.class非public方法导致事务失效spring为方法创建代理添加事务通知前提条件是方法是public的
SpringMVC
核心组件
DispatcherServlet核心的中央处理器负责接收请求、分发并给予客户端响应。HandlerMapping处理器映射器根据 URL 去匹配查找能处理的 Handler 并会将请求涉及到的拦截器和 Handler 一起封装。HandlerAdapter处理器适配器根据 HandlerMapping 找到的 Handler 适配执行对应的 HandlerHandler请求处理器处理实际请求的处理器。ViewResolver视图解析器根据 Handler 返回的逻辑视图 / 视图解析并渲染真正的视图并传递给 DispatcherServlet 响应客户端
执行流程/工作原理
客户端浏览器发送请求 DispatcherServlet前端控制器拦截请求。DispatcherServlet 根据请求信息调用 HandlerMapping处理器映射器 。HandlerMapping 根据 URL 去匹配查找能处理的 Handler Controller 控制器 并会将请求涉及到的拦截器和 Handler 一起封装。返回处理器执行链DispatcherServlet 调用 HandlerAdapter处理器适配器执行 Handler 。Handler 完成对用户请求的处理后会返回一个 ModelAndView 对象给DispatcherServletModel 是返回的数据对象View 是个逻辑上的 View。前端控制器调用 ViewResolver视图解析器 把逻辑视图转换为真正视图返回给前端控制器渲染视图把 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.factoriesstarter扩展包都有META-INF接着筛选需要装配的类通过ConditionalOnXXX所有条件都满足该类才生效比如一些类依赖其他bean就有这个条件根据条件决定是否加载bean Configuration允许在上下文中注册额外的 bean 或导入其他配置类ComponentScan扫描被Component (Service,Controller)注解的 bean注解默认会扫描启动类所在的包下所有的类 可以自定义不扫描某些 bean。
Mybatis
执行流程
构建会话工厂sqlSessionFactory全局一个生产sqlSession创建会话SqlSession项目与数据库的会话包含执行sql的所有方法每次操作一次会话Executor执行器真正操作数据库接口维护缓存MappedStatement对象读取存储mapper里的一个方法信息代表某一次数据库的操作输入参数映射输出结果映射
分页插件
原理分页插件的基本原理是使用 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参数
-XX:PrintGCDetails 打印基本 GC 信息
-Xmssize: 指定JVM的初始堆大小
-Xmxsize: 指定JVM的最大堆大小
-Xsssize: 指定每个线程的堆栈大小
-XX:UseG1GC: 启用G1垃圾收集器
-XX:PrintFlagsFinal命令来查看当前JVM的所有参数及其默认值。监控工具
这些命令在 JDK 安装目录下的 bin 目录下 jps (JVM Process Status: 类似 UNIX 的 ps 命令。用于查看所有 Java 进程的启动类、传入参数和 Java 虚拟机参数等信息 jstatJVM 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 中新加入的 NIONon-Blocking I/O也被称为 New I/O引入了一种基于**通道Channel**与**缓存区Buffer*的 I/O 方式它可以直接使用 Native 函数库直接分配堆外内存然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能因为*避免了在 Java 堆和 Native 堆之间来回复制数据。
直接内存是一种特殊的内存缓冲区并不在 Java 堆或方法区中分配的而是通过 JNI 是一种编程框架使得Java虚拟机中的Java程序可以调用本地应用/或库也可以被其他程序调用的方式在本地内存上分配的。
堆外内存就是把内存对象分配在堆新生代老年代永久代以外的内存这些内存直接受操作系统管理而不是虚拟机这样做的结果就是能够在一定程度上减少垃圾回收对应用程序造成的影响。
对象创建、布局、访问过程
对象的创建 类加载检查先去常量池定位这个类的符号引用检查这个符号引用的类是否已经被加载过、解析和初始化没有就先执行对应类加载 分配内存对象内存大小在类加载完成后就确定了。分配方式有指针碰撞和空闲列表选择方法由java堆是否规整决定java堆是否规整由所采用的垃圾收集器是否带有压缩整理功能决定 指针碰撞内存规整用过的内存和没用的内存中间有一个分界指针只用往没用的内存方向移动就好Serial,ParNew空闲列表内存不规整虚拟机维护一个空闲列表分配的时候根据列表找之后更新列表CMS java堆是否规整取决于GC收集器是标记-清除还是标记-整理也叫标记-压缩复制算法内存是规整的 内存分配的并发问题 CAS失败重试冲突失败就重试虚拟机采用CAS配失败重试保证更新操作的原子性TLAB为每一个线程在Eden区分配一块内存JVM给线程中的对象分配内存时先在TLAB中分配当对象大于TLAB中的剩余内存或TLAB内存耗尽时再用CAS失败重试 初始化零值不包括对象头保证对象的实例字段不赋初值就直接使用 设置对象头对象是哪个类的实例如何找到类的元数据信息对象哈希码对象GC分代年龄等存放在对象头中还会根据虚拟机当前运行状态不同是否启用偏向锁等会有不同的设置方式 执行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失效就-10就不能被使用实现简单但存在对象循环引用问题 可达性算法分析通过一系列GC Roots的对象为起点从这些节点开始向下搜索走过的路径称为引用链当一个对象到GC Roots没有任何引用链相连就证明对象不可用需要被回收。可以作为GC Roots的对象 虚拟机栈栈帧中的本地变量表中引用的对象 本地方法栈Native方法中引用的对象 方法区中类静态属性引用的对象 方法区中常量引用的对象 所有被同步锁持有的对象
即使不可达但也不一定被回收对象真正死亡至少经历两次标记可达性分析法中不可达的对象被第一次标记并且进行一次筛选筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法或 finalize 方法已经被虚拟机调用过时虚拟机将这两种情况视为没有必要执行。
被判定为需要执行的对象将会被放在一个队列中进行第二次标记除非这个对象与引用链上的任何一个对象建立关联否则就会被真的回收。
jdk9及以后各个类的finalize方法会被逐渐弃用移除
四种引用类型 强引用 不回收 软引用 空间够就不回收空间不够就回收可以用来实现内存敏感的高速缓存可以和ReferenceQueue联合使用如果软引用引用的对象被回收虚拟机就会把这个软引用加入到与之关联的队列 软引用可以加速 JVM 对垃圾内存的回收速度可以维护系统的运行安全防止内存溢出OutOfMemory等问题的产生。 弱引用 只要发生垃圾回收垃圾回收器线程扫描它所管辖的内存区域中如果发现只具有弱引用的对象就会直接回收但垃圾回收器优先级很低的线程发现会慢一点弱引用也可以加入ReferenceQueue中如果弱引用所引用的对象被垃圾回收Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。 虚引用 如果一个对象仅持有虚引用那么它就和没有任何引用一样在任何时候都可能被垃圾回收。 虚引用主要用来跟踪对象被垃圾回收的活动 虚引用与软引用和弱引用的一个区别在于 虚引用必须和引用队列ReferenceQueue联合使用。当垃圾回收器准备回收一个对象时如果发现它还有虚引用就会在回收对象的内存之前把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列那么就可以在所引用的对象的内存被回收之前采取必要的行动。
如何判断一个类是无用类
方法区主要回收无用的类无用类满足3条
该类所有实例已经被回收加载该类的ClassLoader已经被回收该类对应的Class对象没有在任何地方被引用无法在任何地方通过反射访问该类的方法
满足后仅仅可以被回收不是必然
垃圾回收算法
标记清除
首先标记不需要回收的对象标记完成后统一回收掉没有标记的对象
问题
效率标记和清除效率都不高空间会产生大量不连续内存碎片
标记复制
为了解决标记清除的效率和碎片问题将内存分为大小相同的两块每次使用其中的一块当这一块内存使用完成后将还存活的对象复制到另一块然后再把之前的空间全部清理掉每次内存回收一半
问题
可用内存变小可用内存缩小为原来的一半不适合老年代如果存活数量比较大复制性能会变差
标记整理
根据老年代特点的标记算法标记过程和标记清除一样但不是直接回收可回收对象而是将所有存活对象向一端移动然后直接清理另外一端因为多了整理所以效率不高适合老年代这种垃圾回收频率不高的场景
分代收集
根据对象存活周期的不同
一般将java堆分为新生代和老年代
在新生代可用选择标记复制算法每次收集都有大量对象死去只需要付出少量对象的复制成本就可用完成垃圾收集但老年代对象存活几率比较高而且没有额外空间进行分配担保所以使用标记清除或标记整理
垃圾收集器
垃圾收集器是内存回收的具体体现
默认收集器
JDK 8Parallel Scavenge新生代 Parallel Old老年代JDK 9 ~ JDK20: G1
Serial收集器
串行收集器单线程
新生代标记复制
老年代标记整理
没有线程交互的开销简单高效
应用Client 模式下的虚拟机
ParNew收集器
本质是Serial收集器的多线程版本除了使用多线程进行垃圾收集其他行为无区别
新生代标记复制
老年代标记整理
除了Serial只有它可用和CMS收集器配合工作
应用Server 模式下的虚拟机
并行和并发概念补充
并行Parallel指多条垃圾收集线程并行工作但此时用户线程仍然处于等待状态。并发Concurrent指用户线程与垃圾收集线程同时执行但不一定是并行可能会交替执行用户程序在继续运行而垃圾收集器运行在另一个 CPU 上。
Parallel Scavenge收集器
使用标记复制的多线程收集器看上去和ParNew一样
关注吞吐量高效率利用CPUCMS等垃圾收集器关注用户线程的停顿时间提高用户体验。吞吐量就是CPU中用于运行用户代码的时间和CPU总消耗时间的比值
Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量如果对于收集器运作不太了解手工优化存在困难的时候使用 Parallel Scavenge 收集器配合自适应调节策略把内存管理优化交给虚拟机去完成也是一个不错的选择。
新生代标记复制
老年代标记整理
JDK1.8 默认使用的是 Parallel Scavenge Parallel Old
Serial Old收集器
serial收集器的老年代版本单线程
用途
jdk1.5及以上与Parallel Scavenge 收集器搭配作为CMS收集器的后备方案
Parallel Old收集器
Parallel Scavenge收集器的老年代版本使用多线程和标记整理算法在注重吞吐量以及CPU资源的场合优先考虑Parallel Scavenge收集器和 Parallel Old收集器
CMS收集器
以获取最短回收停顿时间为目标注重用户体验
HotSpot第一款真正意义上的并发收集器第一次实现了让垃圾收集线程和用户线程基本上同时工作
标记清除算法
步骤
初始标记暂停所有其他线程记录直接和root相连的对象快并发标记同时开启 GC 和用户线程用一个闭包结构去记录可达对象。但在这个阶段结束这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。重新标记为了修正并发标记期间因为用户线程继续运行导致标记产生变动的那部分对象的标记记录比初始慢比并发快并发清除开启用户线程同时GC线程开始对未标记的区域做清扫
初始标记和重新标记会stw
优点并发收集低延迟
缺点对CPU资源敏感无法处理浮动垃圾标记清除产生大量碎片
G1收集器
面向服务器极高概率满足GC停顿时间同时具备高吞吐量性能特征
并行与并发G1充分利用CPU和多核环境的硬件优势使用多个CPU缩短stw时间。G1可用通过并发方式让java程序和gc同时执行分代收集不需要和其他收集器配合空间整合和CMS的标记清除不同G1整体看是标记整理局部看是标记复制可预测的停顿建立可预测的停顿时间模型明确在一个长度M毫秒的时间内消耗在垃圾回收上的时间不超过N毫秒
步骤
初始标记并发标记最终标记筛选回收
内存的回收是以region作为基本单位的
G1收集器在后台维护优先列表每次根据允许的收集时间优先回收价值最大的Region。Region划分内存空间以及有优先级的区域回收方式保证G1收集器在有限时间尽可能高的收集效率
jdk9成为默认垃圾收集器
ZGC收集器
和CMS中的ParNew和G1类似采用标记复制不过改进了算法stw更少java15可以使用
STW
整个虚拟机应用线程暂停工作
确保标记的时候不会有对象的引用被修改
类文件结构
Class文件结构
ClassFile {u4 magic; //Class 文件的标志u2 minor_version;//Class 的小版本号u2 major_version;//Class 的大版本号u2 constant_pool_count;//常量池的数量cp_info constant_pool[constant_pool_count-1];//常量池u2 access_flags;//Class 的访问标记u2 this_class;//当前类u2 super_class;//父类u2 interfaces_count;//接口数量u2 interfaces[interfaces_count];//一个类可以实现多个接口u2 fields_count;//Class 文件的字段属性数量field_info fields[fields_count];//一个类可以有多个字段u2 methods_count;//Class 文件的方法数量method_info methods[methods_count];//一个类可以有个多个方法u2 attributes_count;//此类的属性表中的属性数attribute_info attributes[attributes_count];//属性表集合
}类加载过程
类的生命周期
类从加载到虚拟机内存到卸载出内存生命周期7个阶段
加载验证准备解析初始化使用卸载
验证准备解析统称为连接
类加载过程
class文件需要加载到虚拟机后才能运行和使用加载class文件分为3步加载-连接-初始化连接又分为验证-准备-解析
加载
通过全类名获取该类的二进制字节流无要求zipjar网络动态代理等将字节流的静态存储结构转换为方法区的运行时数据结构在内存生成一个代表该类的Class对象作为方法区这些数据的访问入口
通过类加载器完成具体哪个类加载器由双亲委派模型决定但也可以打破
每个类都有一个引用指向加载它的类加载器但数组类不是通过ClassLoader加载的而是JVM在需要的时候自动创建的数组类通过getClassLoader()方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的。
加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的加载阶段尚未结束连接阶段可能就已经开始了。
验证
连接的第一步目的是确保Class文件的字节流包含的信息符合规范运行后不会危害虚拟机安全
这一步耗费资源较多也可以用参数关闭大部分类验证缩短加载时间4个阶段
文件格式验证Class 文件格式检查是否符合Class文件格式规范如是否魔数开头…元数据验证字节码语义检查类似这个类是否有父类是否继承了不能继承的类…字节码验证程序语义检查类似参数类型类型转换是否正确…符号引用验证类的正确性检查类似该类使用的其他类方法字段是否存在还有访问权限这一步发生在解析阶段在 JVM将符号引用转化为直接引用的时候。确保解析阶段正常执行
后三个验证不会再读取操作字节流
方法区是JVM运行时数据区的一块逻辑区域是各个线程共享的内存区域方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
准备
正式为类变量分配内存并设置初始值这些内存都在方法区分配
此时只分配类变量静态变量static修饰不包括实例变量。实例变量会在对象实例化时随着对象一块分配在堆概念上讲类变量使用的内存应当在方法区但jdk7之前使用永久代实现方法区时符合这个概念。但在jdk7及之后Hotspot把原来在永久代的字符串常量池静态变量等移动到堆此时类变量会随着Class对象存放在堆初始值是默认零值但如果是static final的变量特殊情况比如给 value 变量加上了 final 关键字public static final int value111 那么准备阶段 value 的值就被赋值为 111
解析
虚拟机讲常量池的符号引用替换为直接引用的过程解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程也就是得到类或者字段、方法在内存中的指针或者偏移量。
初始化
初始化阶段是执行初始化方法 clinit ()方法的过程是类加载的最后一步这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。
clinit是带锁线程安全
初始化阶段只有6种情况必须对类进行初始化
当遇到 new、getstatic、putstatic、invokestatic 这 4 条字节码指令时比如 new 一个类读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。 当 jvm 执行 new 指令时会初始化类。即当程序创建一个类的实例对象。当 jvm 执行 getstatic 指令时会初始化类。即程序访问类的静态变量(不是静态常量常量会被加载到运行时常量池)。当 jvm 执行 putstatic 指令时会初始化类。即程序给类的静态变量赋值。当 jvm 执行 invokestatic 指令时会初始化类。即程序调用类的静态方法。 使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forname(...), newInstance() 等等。如果类没初始化需要触发其初始化。初始化一个类如果其父类还未初始化则先触发该父类的初始化。当虚拟机启动时用户需要定义一个要执行的主类 (包含 main 方法的那个类)虚拟机会先初始化这个类。MethodHandle 和 VarHandle 可以看作是轻量级的反射调用机制而要想使用这 2 个调用 就必须先使用 findStaticVarHandle 来初始化要调用的类。「补充来自issue745open in new window」 当一个接口中定义了 JDK8 新加入的默认方法被 default 关键字修饰的接口方法时如果有这个接口的实现类发生了初始化那该接口要在其之前被初始化。
类卸载
3个要求
该类所有实例对象已回收该类没有被其他地方引用该类的类加载器实例被回收
由JVM自带的类加载器加载的类不会被卸载由自定义的类加载器加载的类可能被卸载
JDK 自带的 BootstrapClassLoader, ExtClassLoader, AppClassLoader 负责加载 JDK 提供的类所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的所以使用我们自定义加载器加载的类是可以被卸载掉的。
类加载器
类加载器是一个负责加载类的对象ClassLoader是一个抽象类。给定类的二进制名称类加载器会尝试定位和生成构成类定义的数据。典型策略是将名称转换为文件名然后从文件系统中读取该名称的类文件
主要作用加载java类的字节码.class文件到jvm中在内存中生成一个代表该类的class对象其实还可以加载其他东西文本图像等但只讨论加载类
类加载器加载规则
jvm启动并不会加载所有类而是根据需要动态加载用到的时候再加载
已经加载的类会放到classloader中类加载时会先判断类是否被加载过加载过就直接返回否则才加载相同二进制名称的类只会被加载一次
3个内置类加载器
jvm内置3个classloader
BootstrapClassLoader启动类加载器最顶层加载类c实现主要加载jdk核心类库, %JAVA_HOME%/lib目录下的 rt.jar基础类库、resources.jar、charsets.jar等 jar 包和类以及被 -Xbootclasspath参数指定的路径下的所有类。ExtensionClassLoader扩展类加载器主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。AppClassLoader(应用程序类加载器)面向我们用户的加载器负责加载当前应用 classpath 下的所有 jar 包和类。
java9时扩展类加载器改名为平台类加载器大部分都是平台类加载器加载的
我们可以对 Java 类的字节码 .class 文件进行加密加载时再利用自定义的类加载器对其解密。
自定义类加载器
BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader。如果我们要自定义自己的类加载器很明显需要继承 ClassLoader抽象类。
protected Class loadClass(String name, boolean resolve)加载指定二进制名称的类实现了双亲委派机制 。name 为类的二进制名称resove 如果为 true在加载时调用 resolveClass(Class? c) 方法解析该类。
protected Class findClass(String name)根据类的二进制名称来查找类默认实现是空方法。
官方建议 ClassLoader的子类重写 findClass(String name)方法而不是loadClass(String name, boolean resolve) 方法。
不想打破双亲委派模型就重写findClass()方法无法被父类加载器加载的类最终会通过这个方法被加载。但是如果想打破双亲委派模型则需要重写 loadClass() 方法。
双亲委派模型
ClassLoader类使用委托模型搜索类和资源
双亲委派模型要求除了启动类加载器之外其他类加载器都有自己的父加载器
ClassLoader实例会在亲自查找类之前将任务委托给其父类加载器
双亲委派模型是jdk官方推荐的也可以打破类加载器的父子关系一般不是以继承实现的而是组合
public abstract class ClassLoader {...// 组合private final ClassLoader parent;protected ClassLoader(ClassLoader parent) {this(checkCreateClassLoader(), parent);}...
}组合优于继承
执行流程
流程
类加载时先判断当前类是否被加载过加载过就会直接返回否则才尝试加载加载时首先不会自己区尝试加载这个类而是调用父加载器的loadClass所有请求都会传送到顶层启动类加载器只有父加载器无法加载时子加载器才会尝试自己加载自己的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 编译的时候通过一种叫做逃逸分析的技术可以检测到这些不必要的锁然后将其删除。
锁升级
偏向锁升级当一个线程访问同步块时首先会尝试获取偏向锁。如果当前对象没有被其他线程竞争过并且持有偏向锁的线程仍然存活那么当前线程可以直接获取偏向锁不会发生锁升级。轻量级锁自旋锁升级如果获取偏向锁失败表示当前对象存在竞争那么偏向锁会升级为轻量级锁。这时JVM会通过CAS操作将对象头中的锁标记改为指向线程栈中的锁记录Lock Record的指针并将对象的内容复制到锁记录中。如果轻量级锁获取失败即有多个线程竞争同一个对象的锁那么轻量级锁会升级为自旋锁。自旋锁不会使线程阻塞而是让线程执行忙等待尝试反复获取锁。这样可以避免线程切换带来的性能损失。重量级锁升级当自旋锁尝试获取锁的次数达到一定阈值或者等待时间超过一定限制时自旋锁会升级为重量级锁。重量级锁会使线程阻塞将竞争锁的线程放入等待队列等待锁释放后进行唤醒。
锁降级锁通常不会主动降级但重量级锁在释放时可以降级为轻量级锁但是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 内存模型)主要定义了共享内存中多线程程序读写操作的行为规范内存分为线程私有工作内存线程共享主内存线程之间交互需要主内存
指令重排序
指令重排序就是系统在执行代码的时候不一定按照编写代码的顺序依次执行
常见指令重排序
编译器优化重排编译器在不改变单线程程序语义的前提下重新安排语句的执行顺序。通过禁止特定类型的编译器重排序的方式禁止重排序指令并行重排现代处理器的指令级并行技术将多条指令重叠执行。如果不存在数据依赖性处理器可以改变语句对应机器指令的执行顺序。通过插入**内存屏障一种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 来启动线程比使用 Thread 的 start 方法更好除了更易管理效率更好用线程池实现节约开销外还有关键的一点有助于避免 this 逃逸问题。 this 逃逸是指在构造函数返回之前其他线程就持有该对象的引用调用尚未构造完全的对象的方法可能引发令人疑惑的错误。 三大部分 任务(Runnable /Callable) 执行任务都必须实现这两个接口之一 任务的执行(Executor) 核心接口 Executor以及继承自 Executor 接口的 ExecutorService 接口。ThreadPoolExecutor 和 ScheduledThreadPoolExecutor 这两个关键类实现了 ExecutorService ScheduledThreadPoolExecutor 实际上是继承了 ThreadPoolExecutor 并实现了 ScheduledExecutorService 而 ScheduledExecutorService 又实现了 ExecutorService 异步计算的结果(Future) Future 接口以及 Future 接口的实现类 FutureTask 类都可以代表异步计算的结果。 当我们把 Runnable接口 或 Callable 接口 的实现类提交给 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 执行。调用 submit() 方法时会返回一个 FutureTask 对象 主线程创建实现Runnable或Callable接口的任务对象把任务对象提交给ExecutorService执行ExecutorService.executeRunnable command或者ExecutorService.submitRunnable task如果执行submit就返回一个实现Future接口的对象主线程执行FutureTask.get()方法等待任务执行成功也可以取消任务执行
工作原理/流程
execute(任务)流程
如果当前运行的线程数小于核心线程数就会新建一个线程执行任务如果当前运行的线程数大于或等于核心线程数但是小于最大线程数就把该任务放入任务队列里如果任务队列满但是当前运行的线程数小于最大线程数就新建一个线程执行任务如果当前运行的线程数已经是最大线程数就会执行拒绝策略
在 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 对象可以判断任务是否执行成功并且可以通过 Future 的 get()方法来获取返回值get()方法会阻塞当前线程直到任务完成而使用 getlong timeoutTimeUnit 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_VALUE 的 LinkedBlockingQueue无界队列
运行流程
如果当前运行线程数小于核心线程数新任务会创建新线程如果运行线程数等于核心线程数新任务会加入 LinkedBlockingQueue线程池中的线程执行完任务后会循环从队列中取任务
SingleThreadExecutor
只有一个线程的线程池
SingleThreadExecutor 的 corePoolSize 和 maximumPoolSize 都被设置为 1其他参数和 FixedThreadPool 相同也使用LinkedBlockQueue
执行流程
运行线程数小于核心线程数创建新线程执行任务有一个运行的线程之后任务加入LinkedBlockingQueue线程执行完任务之后会反复从队列中获取任务
CachedThreadPool
根据需要创建新线程
核心线程数为0最大线程数int最大意味着如果主线程提交任务的速度高于线程处理任务的速度CachedThreadPool会不断创建新线程
执行流程
先提交任务到任务队列如果空闲线程还未销毁主线程就把任务交给空闲线程否则执行2初始线程为0或没有空闲线程时此时会创建新线程来执行任务
ScheduledThreadPool
给定的延迟后运行任务或定期执行任务基本不用
无界阻塞队列
ScheduledThreadPool 是通过 ScheduledThreadPoolExecutor 创建的使用的DelayedWorkQueue延迟阻塞队列作为线程池的任务队列。
延迟队列按照延迟时间长短对任务进行排序采用堆保证每次出队的任务都是当前队列中执行时间最靠前的添加元素满了之后会自动扩容原来的一般永远不阻塞所以最多只会创建核心线程数的线程
ScheduledThreadPoolExecutor 继承了 ThreadPoolExecutor所以创建 ScheduledThreadExecutor 本质也是创建一个 ThreadPoolExecutor 线程池
为什么不推荐使用内置线程池
FixedThreadPool 和 SingleThreadExecutor使用无界LinkedBlockingQueue可能堆积大量请求导致OOMCachedThreadPool同步队列SynchronousQueue允许创建线程数量无限可能创建大量线程导致OMMScheduledThreadPool 和 SingleThreadScheduledExecutor无界阻塞队列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(信号量)
synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源而Semaphore(信号量)可以用来**控制同时访问特定资源的线程数量。**其他线程都会阻塞常用于限流
当初始的资源个数为 1 的时候Semaphore 退化为排他锁。
Semaphore 有两种模式。
公平模式 调用 acquire() 方法的顺序就是获取许可证的顺序遵循 FIFO非公平模式 抢占式的。
Semaphore 通常用于那些资源有明确访问数量限制的场景比如限流仅限于单机模式实际项目中推荐使用 Redis Lua 来做限流。
原理
Semaphore 是共享锁的一种实现只有拿到许可证的线程才能执行它默认构造 AQS 的 state 值为 permits许可证的数量
acquire时线程尝试获取锁如果state0就可以获取成功尝试使用CAS修改-1CAS失败了会循环重新获取最新的值尝试获取如果获取失败就会创建一个Node节点加入阻塞队列挂起当前线程自旋判断state是否大于0释放许可证成功之后会唤醒同步队列中的一个线程被唤醒的线程会尝试获取锁失败就重新进入阻塞队列挂起线程
Sync 是 CountDownLatch 的内部类 , 继承了 AbstractQueuedSynchronizer 重写了其中的某些方法。并且Sync 对应的还有两个子类 NonfairSync对应非公平模式 和 FairSync对应公平模式。
CountDownLatch(倒计时器
允许 count 个线程阻塞在一个地方直至所有线程的任务都执行完毕。
CountDownLatch 是一次性的计数器只能初始化一次
CountDownLatch 是共享锁的一种实现它默认构造 AQS 的 state 值为 count
线程调用countDown()时其实是调用CAS操作减少statestate为0时表示所有线程都调用了countDown方法那么在CountDownLatch上等待的线程就会被唤醒并继续执行
调用await()等待加锁时如果state不为0证明任务还没有执行完毕await()就会一直阻塞即await()之后的语句不会被执行main线程被加入等待队列也就是在CLH队列中然后CountDownLatch会自旋CAS判断state0如果为0就会释放所有等待的线程执行await()之后的语句
典型用法
启动一个服务时主线程需要等待多个组件加载完毕之后继续执行将计数器设为n等到0的时候在CountDownLatch上await()的线程就会被唤醒实现多个线程执行任务的最大并行性强调多个线程在同一时刻同时开始执行。类似将多个线程放到起点同时开跑做法是初始化一个共享的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 它的key是ThreadLocal? k 继承自WeakReferencekey是ThreadLocal的弱引用
ThreadLocal只是一个key存储ThreadLocal是为了使用多个ThreadLocal时能找到自己想使用的ThreadLocal
如果entry是强引用key是ThreadLocal是一个static的ThreadLocal就一直不被gc则entry也不能gcvalue也不能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()
哈希计算后的槽位对应的Entry为空时直接设置数据槽位不为空key值和当前ThreadLocal的哈希值相同更新槽位不为空key值不同继续向后遍历遍历到Entry为null之前没有过期Entrykey为null将数据放入Entry为null遍历到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中可能有一些key为null的Entry数据被清理掉所以此时通过判断size threshold - threshold / 4 也就是size threshold * 3/4 来决定是否扩容。
扩容大小是之前的2倍然后重新计算哈希
get()详解
如果槽位有值但key值不同就继续向后迭代查找发现key为null时会触发一次探测式数据回收操作
内存泄露
ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用而 value 是强引用。所以如果 ThreadLocal 没有被外部强引用的情况下在垃圾回收的时候key 会被清理掉而 value 不会被清理掉。
ThreadLocalMap 中就会出现 key 为 null 的 Entryvalue 永远无法被 GC 回收这个时候就可能会产生内存泄露ThreadLocalMap在调用 set()、get()、remove() 方法的时候会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后最好手动调用remove()方法
CompletableFuture
CompletableFuture 同时实现了 Future 和 CompletionStage 接口
boolean cancel(boolean mayInterruptIfRunning)尝试取消执行任务。boolean isCancelled()判断任务是否被取消。boolean isDone()判断任务是否已经被执行完成。get()等待任务执行完成并获取运算结果。get(long timeout, TimeUnit unit)多了一个超时时间。
如果你不需要从回调函数中获取返回结果可以使用 thenAccept() 或者 thenRun()。这两个方法的区别在于 thenRun() 不能访问异步计算的结果。
你可以通过 handle() 方法来处理任务执行过程中可能出现的抛出异常的情况。
那 thenCompose() 和 thenCombine() 有什么区别呢
thenCompose() 可以链接两个 CompletableFuture 对象并将前一个任务的返回结果作为下一个任务的参数它们之间存在着先后顺序。thenCombine() 会在两个任务都执行完成后把两个任务的结果合并。两个任务是并行执行的它们之间并没有先后依赖顺序。
通过 CompletableFuture 的 allOf()这个静态方法来并行运行多个 CompletableFuture 。
allOf() 方法会等到所有的 CompletableFuture 都运行完成之后再返回
CompletableFuture 类有什么用
Future 在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get() 方法为阻塞调用。
Java 8 才被引入CompletableFuture 类可以解决Future 的这些缺陷。CompletableFuture 除了提供了更为好用和强大的 Future 特性之外还提供了函数式编程、异步任务编排组合可以将多个异步任务串联起来组成一个完整的链式调用等能力。
CompletableFuture 同时实现了 Future 和 CompletionStage 接口
并发和并行
并发两个及两个以上线程在同一时间段执行
并行两个及两个以上线程在同一时刻执行
关键在于是否同时执行
同步、异步、阻塞、非阻塞
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线程被阻塞。被终止或结束运行
这其中前三种都会发生线程切换
什么是线程死锁?如何避免死锁?
线程死锁是多个线程同时被阻塞一个或多个等待某个资源的释放导致线程无限期阻塞比如互相持有锁
产生死锁的四个必要条件
互斥条件该资源任意一个时刻只由一个线程占用。请求与保持条件一个线程因请求资源而阻塞时对已获得的资源保持不放。不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺只有自己使用完毕后才释放资源。循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
预防死锁
破坏请求与保持条件一次性申请所有的资源。破坏不剥夺条件占用部分资源的线程进一步申请其他资源时如果申请不到可以主动释放它占有的资源。破坏循环等待条件靠按序申请资源来预防。按某一顺序申请资源释放资源则反序释放。破坏循环等待条件。
避免死锁
避免死锁就是在资源分配时借助于算法比如银行家算法对资源分配进行计算评估使其进入安全状态线程按顺序分配资源。
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的线程waitsetJava 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程都需要操作系统帮忙完成而操作系统实现线程之间的切换时需要从用户态转换到内核态这个状态之间的转换需要相对比较长的时间时间成本相对较高。
java6之后synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销这些优化让 synchronized 锁的效率提升了很多。因此 synchronized 还是可以在实际项目中使用的像 JDK 源码、很多开源框架都大量使用了 synchronized 。
构造方法可以用synchronized修饰吗
不能
构造方法本身属于线程安全不存在同步的构造方法这一说
synchronized底层原理
修饰代码块时
synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令其中 monitorenter 指令指向同步代码块的开始位置monitorexit 指令则指明同步代码块的结束位置。
包含一个 monitorenter 指令以及两个 monitorexit 指令这是为了保证锁在同步代码块代码正常执行以及出现异常的这两种情况下都能被正确释放。
当执行 monitorenter 指令时线程试图获取锁也就是获取 对象监视器 monitor 的持有权。
在Hotspot中monitor基于c实现每个对象都内置了一个ObjectMonitor对象wait/notify等方法也依赖monitor对象所以只有在同步块或同步方法中才能调用wait/notify等方法否则会抛出java.lang.IllegalMonitorStateException的异常
在执行monitorenter时会尝试获取对象的锁如果锁的计数器为 0 则表示锁可以被获取获取后将锁计数器设为 1 也就是加 1。如果获取对象锁失败那当前线程就要阻塞等待直到锁被另外一个线程释放为止。
对象锁的的拥有者线程才可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后将锁计数器设为 0表明锁被释放其他线程可以尝试获取锁。
修饰方法时
没有 monitorenter 指令和 monitorexit 指令取得代之的确实是 ACC_SYNCHRONIZED 标识该标识指明了该方法是一个同步方法如果是实例方法JVM 会尝试获取实例对象的锁。如果是静态方法JVM 会尝试获取当前 class 的锁。
本质都是获取对象monitor
ReentrantLock
ReentrantLock实现了Lock接口是一个可重入的独占锁比synchronized更灵活强大
ReentrantLock有一个内部类Sync继承AQSAbstractQueueSynchronizer加锁和释放锁的大部分操作在Sync中实现Sync 有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。默认使用非公平锁
ReentrantLock 的底层就是由 AQS 来实现的
synchronized 和 ReentrantLock 有什么区别
二者都是可重入锁递归锁线程可以再次获取自己的锁Lock实现类和synchronized都是可重入的
synchronized依赖于jvm而ReentrantLock依赖于api
synchronized依赖于jvm实现ReetrantLock是jdk层面实现的比synchronized增加了一些高级功能 等待可中断可以中断等待锁的线程lock.lockInterruptibly() 来实现这个机制。也就是说正在等待的线程可以选择放弃等待改为处理其他事情。可实现公平锁可以指定公平还是非公平synchronized只是非公平可实现选择性通知锁绑定多个条件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 并不是直接实现 Lock或 ReadWriteLock接口而是基于 CLH 锁 独立实现的AQS 也是基于这玩意。
StampedLock 提供了三种模式的读写控制模式读锁、写锁和乐观读。
写锁独占锁一把锁只能被一个线程获得。当一个线程获取写锁后其他请求读锁和写锁的线程必须等待。类似于 ReentrantReadWriteLock 的写锁不过这里的写锁是不可重入的。读锁 悲观读共享锁没有线程获取写锁的情况下多个线程可以同时持有读锁。如果己经有线程持有写锁则其他线程请求获取该读锁会被阻塞。类似于 ReentrantReadWriteLock 的读锁不过这里的读锁是不可重入的。乐观读允许多个线程获取乐观读以及读锁。同时允许一个写线程获取写锁。
StampedLock 在获取锁的时候会返回一个 long 型的数据戳该数据戳用于稍后的锁释放参数如果返回的数据戳为 0 则表示锁获取失败。当前线程持有了锁再次获取锁还是会返回一个新的数据戳这也是StampedLock不可重入的原因。
StampedLock 的性能为什么更好
相比于传统读写锁多出来的乐观读是StampedLock比 ReadWriteLock 性能更好的关键原因。StampedLock 的乐观读允许一个写线程获取写锁所以不会导致所有写线程阻塞也就是当读多写少的时候写线程有机会获取写锁减少了线程饥饿的问题吞吐量大大提高。
StampedLock 适合什么场景
和 ReentrantReadWriteLock 一样StampedLock 同样适合读多写少的业务场景可以作为 ReentrantReadWriteLock的替代品性能更好。
不过需要注意的是StampedLock不可重入不支持条件变量 Conditon对中断操作支持也不友好使用不当容易导致 CPU 飙升。如果你需要用到 ReentrantLock 的一些高级性能就不太建议使用 StampedLock 了。
StampedLock 的底层原理
StampedLock 不是直接实现 Lock或 ReadWriteLock接口而是基于 CLH 锁 实现的AQS 也是基于这玩意CLH 锁是对自旋锁的一种改良是一种隐式的链表队列。StampedLock 通过 CLH 队列进行线程的管理通过同步状态值 state 来表示锁的状态和类型。
Future
Future有什么用
异步思想
当执行某一耗时任务时可以将这个耗时任务交给一个子线程异步执行再通过Future获取耗时任务的执行结果
Callable 和 Future 有什么关系
FutureTask 提供了 Future 接口的基本实现常用来封装 Callable 和 Runnable具有取消任务、查看任务是否执行完成以及获取任务执行结果的方法。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
如果定位的数组位置没有元素就直接插入有元素就和插入的key比较相同就直接覆盖不相同就判断p是否是一个树节点是就调用树的插入方法否则就遍历链表插入尾部
扩容resizeresize伴随一次重新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一个数据时
计算key的位置获取指定位置的segment如果指定位置的segment为空就初始化这个segment。初始化流程 检查计算得到的segment是否为nullnull就继续初始化用segment[0]的容量和负载因子创建一个HashEntry数组再次检查计算得到指定位置的segment是否为null用创建的HashEntry数组初始化这个Segment自旋判断计算得到指定位置的segment是否为null为null就用cas在这个位置赋值Segment直到赋值成功 Segment.put插入keyvalue下面是真正Put因为Segment继承了ReentrantLock所以Segment内部也可以方便获取锁tryLock()获取锁获取不到使用scanAndLockForPut方法不断自旋tryLock()获取锁次数大于指定次数时使用lock()阻塞获取锁顺便获取对应HashEntry继续获取计算put数据放入的index位置然后获取这个位置上的HashEntry遍历put新元素因为可能获取的 HashEntry是一个空元素或者一个链表 如果这个位置上的HashEntry不存在 如果当前容量大于扩容阈值小于最大容量进行扩容头插法插入 如果这个位置上的HashEntry存在 判断链表当前元素key和hash值是否和put的key的hash值一致一致就替换不一致就获取链表下一个节点直到发现相同值进行替换如果没有相同的 如果当前容量大于扩容阈值小于最大容量进行扩容直接头插法插入 如果插入的位置之前就存在替换之后返回旧值否则返回null
扩容rehash
Segment 中的链表长度超过阈值默认为 8时会触发该 Segment 的扩容。
扩容时首先获取段的锁获取成功其他扩容线程会阻塞再把旧段的元素分批迁移到新段此时其他线程对该段的写操作会被阻塞迁移完成后原始段会指向新段扩容锁释放。只会扩容到原来的2倍老数组的数据移动到新数组时位置要么不变要么变为 index oldSize参数里的 node 会在扩容之后使用链表头插法插入到指定位置。
get
计算key的存放位置遍历指定位置查找相同key的value值
ConcurrentHashMap 1.8
采用 Node CAS synchronized 来保证并发安全锁粒度更细synchronized 只锁定当前链表或红黑二叉树的首节点这样只要 hash 不冲突就不会产生并发就不会影响其他 Node 的读写效率大幅提升。
CAS是并发更新时修改数据
初始化 initTable
通过自旋和 CAS操作完成sizeCtl 值决定当前初始化状态小于0就说明另外线程正在进行初始化此时主动让出CPU使用权
-1正在初始化-N有N - 1个线程正在进行扩容0table没初始化就表示table初始化大小0table已经初始化就表示table扩容的阈值
put 根据key计算hashcode 判断是否需要进行初始化 根据当前key定位出的Node如果为空表示当前位置可以写入数据用CAS尝试写入失败就自旋保证成功 如果当前位置的 hashcode MOVED -1,则需要进行扩容。 多线程扩容用cas修改获得其他线程状态 如果都不满足就利用synchronized锁链表首节点或树头节点写入数据 如果数量大于 TREEIFY_THRESHOLD 则要执行树化方法在 treeifyBin 中会首先判断当前数组长度 ≥64 时才会将链表转换为红黑树。
get
根据hash值计算位置查找指定位置如果头节点就是要找的就直接返回它的value如果头节点hash值小于0说明正在扩容或者是红黑树进行查找如果是链表遍历查找
ConcurrentHashMap 和 Hashtable 的区别
主要体现在线程安全的实现
数据结构jDK1.7 的 ConcurrentHashMap 底层采用 分段的数组链表 实现JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样数组链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组链表 的形式实现线程安全的方式 1.7时的ConcurrentHashMap对桶数组进行了分割分段Segment分段锁每一把锁只锁容器其中一部分数据多线程访问容器不同数据段的数据就不存在锁竞争1.8ConcurrentHashMap直接用 Node数组链表红黑树并发控制使用synchronized和CAS整体看起来像优化过线程安全的HashMapTreeNode被TreeBin包装waiter属性维护当前使用这颗红黑树的线程防止其他线程的进入红黑树旋转时根节点可能被原来的子节点替换Hashtable同一把锁用synchronized保证线程安全效率低put时其他线程不能get
LinkedHashMap
继承HashMap在HashMap基础上维护一条双向链表
定义了排序模式 accessOrder默认false访问顺序为true访问一个元素之后会移到后面插入顺序false
支持遍历时按照插入顺序有序遍历顺序和插入顺序一致支持按照元素访问顺序排序适用于封装LRU缓存工具遍历效率和元素个数成正比HashMap和容量成正比LinkedHashMap迭代效率会高
LRU缓存
最近最少使用确保当存放的元素超过容器容量时将最近最少访问的元素移除
实现思路
继承 LinkedHashMap构造方法指定 accessOrder 为true访问一个元素之后会移到最后链表首元素就是最近最少被访问的元素重写 removeEldstEntry方法返回一个boolean告诉 LinkedHashMap是否需要移除链表首元素
源码
Node设计Entry增加before和after让节点具备双向链表的特性HashMap的TreeNode继承了LinkedHashMap的entry这是为了保证使用LinkedHashMap时树节点具备双向链表的特性
getaccessOrder为true时会在元素查找之后将访问的元素移动到链表的末尾
CopyOnWriteArrayList
t读取操作完全不加锁写入也不会阻塞读取操作只有写写会互斥读性能提升
线程安全的核心在于采用了 写时复制Copy-On-Write
写时复制如果多个调用者同时请求相同资源他们会同时获取相同的指针指向相同的资源直到某个调用者试图修改资源的内容时系统才会真正复制一份副本给调用者其他调用者不变。这样的优点是如果调用者没有修改资源就不会有副本被创建因此多个调用者只是读取操作时可以共享一份资源
当修改CopyOnWriteArrayList的内容时不会直接修改原数组而是先创建数组的副本对副本进行修改修改完成再将修改后的数组赋值回去
写时复制适合读多写少的场景
缺点
内存占用每次写操作都要复制一份原数据占用额外空间写操作开销写操作都需要复制一份原数据然后进行修改和替换写入频繁时开销大数据一致性问题修改操作需要等待复制完成可能导致一定数据一致性问题
插入默认插入尾部也可以指定位置
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() 方法的实现。
HashSet 的 contains() 方法底部依赖的 HashMap 的 containsKey() 方法时间复杂度接近于 O1
集合转数组
使用集合转数组的方法必须使用集合的 toArray(T[] array)传入的是类型完全一致、长度为 0 的空数组。
数组转集合
传入对象数组使用工具类 Arrays.asList() 把数组转换成集合时不能使用其修改集合相关的方法 它的 add/remove/clear 方法会抛出 UnsupportedOperationException 异常。如果aslist里参数是一个字符串数组所以可以修改字符串数组元素影响list
Arrays.asList() 方法返回的并不是 java.util.ArrayList 而是 java.util.Arrays 的一个内部类,这个内部类并没有实现集合的修改方法或者说并没有重写这些方法。保存时使用数组保存元素如果传入字符串数组会保存对应引用所以可以在外部修改字符串数组来影响listArrayList的toArray方法会复制整个数组
可以这样转为ArrayList
List list new ArrayList(Arrays.asList(a, b, c))推荐使用Stream也可以转基本类型数组
Integer [] myArray { 1, 2, 3 };
List myList Arrays.stream(myArray).collect(Collectors.toList());Java
异常
Exception和Error共同的父类是Throwable
不要在finally使用return当try和finally语句都有return时try语句的return会被忽略。因为try语句中的return返回值会先被暂存到一个本地变量中当执行finally中的return之后这个本地变量的值就变成了finally语句中的return返回值使用日志打印异常之后就不要再抛出异常
反射
获取Class对象四种方式
类名.classClass.forName()传入类的全路径对象.getClass()xxxClassLoader.loadClass()传入类的全路径 通过类加载器获取Class对象不会进行初始化静态代码块和静态对象不会得到执行
Unsafe
只有启动类加载器加载的类才可以调用Unsafe类中的方法
内存操作内存屏障 编译器和 CPU 会在保证程序输出结果一致的情况下会对代码进行重排序从指令优化角度提升性能。但指令重排可能导致CPU高速缓存和内存数据不一致内存屏障Memory Barrier就是通过阻止屏障两边的指令重排序从而避免编译器和硬件的不正确优化情况。loadFence方法为例它会禁止读操作重排序保证在这个屏障之前的所有读操作都已经完成并且将缓存数据设为无效重新从主存中进行加载。 对象操作数据操作CAS 操作线程调度Class 操作系统信息
深拷贝、浅拷贝、引用拷贝
浅拷贝堆上创建新对象但如果原对象内部属性是引用类型浅拷贝会复制内部对象的引用地址共用一个内部对象深拷贝完全复制整个对象引用拷贝两个不同的引用指向同一个对象
String
String为什么不可变
public final class String implements java.io.Serializable, ComparableString, CharSequence {private final char value[];//...
}保存字符串的数组被final修饰并且私有并且String没有提供/暴露修改这个字符串的方法String类被final修饰导致不能被继承避免了子类破坏String不可变
字符串拼接
“和”是专门为String类重载过的运算符仅有的两个
实际上通过StringBuilder的append()方法实现拼接完成调用toString()得到String对象 但如果在循环内使用号编译器不会复用StringBuilder会创建过多的StringBuilder对象但这个问题在jdk9得到解决号改为了用动态方法makeConcatWithConstants()实现不是大量创建StringBuilder 编译时Javac编译器会进行常量折叠 常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中这是 Javac 编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)只有编译器在程序编译期就可以确定值的常量才可以对于 String str3 str ing; 编译器会给你优化成 String str3 string;
Java 9 为何要将 String 的底层实现由 char[] 改成了 byte[] ?
Java 9 之后String、StringBuilder 与 StringBuffer 的实现改用 byte 数组存储字符串。
新版String支持两个编码方案Latin-1和UTF-16。如果不超过Latin-1表示范围就用Latin-1byte占一个字节相比于char节省一半内存
绝大多数字符串对象只包含Latin-1可表示的字符
String#intern 方法有什么作用?
将指定的字符串对象的引用保存在字符串常量池
常用字符编码所占字节数
utf8 :英文占 1 字节中文占 3 字节unicode任何字符都占 2 个字节gbk英文占 1 字节中文占 2 字节。
新特性 Interface方法可以用default或static修饰就可以有方法体实现类也不必重写此方法 default修饰的方法是普通实例方法可以用this调用可以被子类继承、重写。static修饰的方法使用上和一般类静态方法一样。但它不能被子类继承只能用Interface调用。java8中接口和抽象类 函数式接口有且只有一个抽象方法 Lambda表达式替代匿名内部类 Stream不存储数据可以检索和逻辑处理集合数据分为串行流和并行流一个Stream只能操作一次操作完就关闭了 Optional防止空指针异常让代码简洁 Date-Time APIjava.time类
final
原理
写final域会要求编译器在final域写之后构造函数返回前插入一个写写屏障 不会被重排序到构造函数外 读final域的重排序规则会要求编译器在读final域的操作前插入一个读读屏障 先读对象引用再读对象的final域
看处理器x86不会对写写和读读重排序
IO模型BIO、NIO、AIO
IO操作必须通过系统调用间接访问内核空间应用程序只是发起IO操作的调用具体IO执行是操作系统内核完成的
应用程序发起IO调用后有两个步骤
内核等待IO设备准备好数据内核将数据从内核空间拷贝到用户空间
linux下的5中IO模型同步阻塞 I/O、同步非阻塞 I/O、I/O 多路复用、信号驱动 I/O 和异步 I/O。
BIO同步阻塞IOread后会一致阻塞直到内核把数据拷贝到用户空间
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的客户端
旁路缓存缺陷
首次请求的数据一定不在cache解决方法提前将热点数据放入cache写操作频繁时导致cache的数据会被频繁删除影响命中率解决方法 cache和db强一致性场景更新db时更新cache但需要加锁保证同一时间只有一个请求更新缓存可以短暂允许db和cache数据不一致的场景更新db时更新cache更新完成给cache加一个短的过期时间保证即使数据不一致影响也不大
读写穿透
Read/Write Through Pattern读写穿透服务端把cache视为主要数据存储从中读取数据并写入数据cache服务负责将数据读取和写入db少见大概率因为经常使用的分布式缓存Redis没有提供cache将数据写入db的功能
写
先查cachecache不存在就直接更新dbcache存在就先更新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还没异步更新dbcache服务就挂掉了
非常少见消息队列中消息的异步写入磁盘、MySQL 的 Innodb Buffer Pool 机制都用到了这种策略。
写性能很高适合一些数据经常变化但对数据一致性要求没那么高的场景如浏览器、点赞量
数据结构、跳表
五大数据结构 StringSDS 二进制安全SDS是Redis自己构建的简单动态字符串不止保存文本还可以保存二进制并且获取字符串长度复杂度o(1)SDS的API是安全的不会造成缓冲区溢出编码intlong内、embstr小于44字节字符串实际是只读的修改的时候会先转为raw、raw基于SDS上限512mb 应用场景 需要存储常规数据缓存session、token、图片地址、序列化的对象SET、GET需要计数用户单位时间的请求数简单限流、页面单位时间的访问数SET、GET、INCR、DECR分布式锁SETNX KEY VALUE存在一些缺陷不建议 ListRedis3.2之前LinkedList/ZipList Redis3.2之后QuickList 双向链表支持反向查找和遍历 应用场景 信息流展示最新文章、最新动态LPUSH、LRANGE消息队列不建议可以用Stream还是不建议 HashHash Table、ZipList元素数量小于默认512个任意entry大小小于64字节 类似HashMap数组链表但做了很多优化 应用场景 对象数据存储用户、商品信息存储HSET、HMSET、HGET、HMGET字段频繁变动用Hash存储而不是json的String SetIntset都是整数而且个数小于512 hashtablevalue是null 无序集合类似HashSet可以很快实现集合的操作都是整数而且元素不多时使用IntSet 应用场景 需要存放的数据不能重复网站UV统计数据零太大就使用HyperLogLog、文章点赞SCARD需要获取多个数据源交集、并集、差集共同好友好友推荐SINTER、SINTERSTORE、SUNION、SUNIONSTORE、SDIFF、SDIFFSTORE需要随机获取数据源元素抽奖系统SPOP随机获取元素并移除适合不允许重复中奖的场景、SRANDMEMBER随机获取元素适合允许重复中奖的场景 ZsetZipList元素数量小于默认128同时每个元素小于默认64字节、SkipList Sorted Set类似Set但增加一个权重参数score可以根据score排序也可以根据score范围获取元素的列表 应用场景 需要随机获取元素根据某个权重进行排序排行榜、微信步数排行榜ZRANGE、ZREVRANGE、ZREVRANK需要存储的数据有优先级或重要程度优先级任务队列ZRANGE、ZREVRANGE、ZREVRANK
底层数据结构
动态字符串SDS会保存长度自动扩展 新字符串小于1M扩展后长度2倍1新字符串大于1M扩展后长度1M1内存预分配 IntSetset的一种实现基于整数数组长度可变、有序支持2、4、8字节编码 所有整数升序保存在数组如果加入的数字超出之前数字的编码大小范围会自动升级编码到合适大小按照新编码扩容数组倒序将数组元素拷贝到新位置最后将待添加的元素加入末尾底层采用二分查找查询 Dict键值对三部分组成哈希表、哈希节点、字典一个字典包含两个哈希表一个是空rehash时使用 哈希表是数组和单向链表新增会检查负载因子如果大于1并且每执行后台进程或者大于5都会触发扩容扩容到第一个大于等于used1的2^n删除时也检查负载因子小于0.1会收缩rehash是根据哈希表的每个key计算索引加入新的哈希表会先计算新的size如果是扩容新size是第一个大于等于used1的2n收缩新size是第一个大于等于used的2n 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字节41变5之后刚好254字节所以又变5字节又加4…连续多次空间扩展频繁申请内存销毁内核态切换新增和删除都可能导致连锁更新 QuickList双端链表每个节点都是ziplist限制每个ziplist的大小解决连续空间申请效率问题SkipList跳表 升序排列节点可能包含多个指针记录最高层级层级1-32之间的随机数越高跨度越大效率和红黑树基本一致但实现更简单RedisObjectRedis任意的键值都会被封装为一个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
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主线程bgsavefork一个子进程子进程执行不阻塞Redis主线程默认 此时主进程也可以处理命令写时复制技术fork子进程时复制页表指向同一个物理内存修改的时候才复制物理内存减少创建子进程的性能损耗主进程修改共享数据时就复制一份对应数据去修改不影响子进程
说Redis主线程是因为Redis启动之后通过单线程完成主要工作也可以叫Redis主进程
AOF
默认不开启Redis6.0之后默认开启
每执行一条会更改Redis中的数据的命令Redis就会将该命令写入AOF缓冲区中然后再写入AOF文件里此时还在系统内核缓存区未同步到磁盘最后再根据持久化方式fsync策略的配置决定什么时候将系统内核缓存区的数据同步到硬盘
工作基本流程
命令追加append所有写命令会追加到AOF缓冲区文件写入write将AOF缓冲区数据写入AOF文件磁盘需要调用write函数将数据写入系统内核缓冲区之后直接返回延迟写此时没有同步到磁盘同步磁盘操作依赖系统调度机制文件同步fsyncAOF缓冲区根据对应的持久化方式fsync策略向磁盘做同步操作需要调用fsync函数fsync针对单个文件强制进行磁盘同步并阻塞直到写入磁盘完成后返回强制刷新系统内核缓冲区同步到磁盘文件重写rewrite)aof文件变太大时需要定期对aof文件进行重写达到压缩目的重启加载load)redis重启后可以加载aof文件进行数据回复
fsync策略
appendfsync always主线程write写操作后后台线程立刻调用fsync函数同步aof文件磁盘fsync完成后线程返回writefsyncappendfsync everysec主线程write写操作后立刻返回后台线程每秒钟调用fsync函数同步一次aof文件**write fsyncfsync间隔1秒**appendfsync no主线程调用write写操作后立刻返回让操作系统决定何时调用linux一般30秒一次wirte但不fsyncfsync时机由操作系统决定
为了兼顾写入性能一般选择第二种即使出现崩溃用户最多损失1秒之内产生的数据
redis7.0开始使用 Multi Part AOF 机制将原来单个aof文件拆分为多个aof文件
AOF 重写
AOF变太大64MRedis在后台自动重写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的交并差集有遍历的需求可以使用 HSCAN、SSCAN、ZSCAN 代替。还有O(n)之上的命令如返回/移除排序set指定排名范围的所有元素O(logn m) **SAVE 创建 RDB 快照**save同步保存操作阻塞Redis主线程。bgsave会fork一个子进程子进程执行不阻塞Redis主线程默认 AOF 日志记录阻塞先执行命令再记录日志可能阻塞后续命令执行 AOF 刷盘阻塞根据fsync策略后台线程刷盘时需要等待直到写入成功如果磁盘压力太大刷盘会阻塞刷盘成功后主线程的write才会成功返回 AOF 重写阻塞将AOF缓冲区的新数据写到新文件的过程中会产生阻塞 大Key、BigKeystring 类型的 value 超过 10 kb复合类型的 value 包含的元素超过 5000 个 网络阻塞获取大Key网络流量较大 使用del删除大Key会阻塞工作线程没办法处理后续命令 延迟删除大key不会记录在慢日志中因为慢日志只记录一个命令真正操作内存数据的耗时如果Redis主动删除过期key是在命令真正执行之前执行的 创建子进程时复制父进程页表阻塞时间长 创建子进程后如果父进程修改共享数据的大key就会发生写时复制拷贝物理内存但大key占空间大复制就耗时阻塞父进程 客户端超时阻塞。Redis单线程执行命令操作大key耗时长阻塞Redis 查找大Key用 --bigkeys 查找大Key时选择在从节点执行主节点执行会阻塞
单线程、IO模型、性能
Redis 为什么这么快、高性能
Redis基于内存内存访问速度快并发10wmysql是1w基于Reactor模式开发一套高效事件处理模型主要是单线程事件循环和IO多路复用内置多种优化过后的数据结构实现性能高
单线程模型、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引入多线程异步处理一些耗时较长的任务异步删除unlink6.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个keyFast执行时间短执行频率高每个事件循环前调用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集中过期问题对于过期 keyRedis 采用的是 定期删除惰性/懒汉式删除 策略。尽量给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不合理不在缓存中也不在数据库中
解决方法 缓存无效key适用于请求的key变化不频繁将无效key缓存到redis并设置短一点过期时间 布隆过滤器把所有可能的请求的值都存放在布隆过滤器如果请求的值不在过滤器里就直接返回错误信息如果说在但其实不一定在 原理一个元素加入过滤器时会先计算哈希值再把数组对应下标设置1判断元素时会先计算哈希之后判断每个位数组是否都为1都1才说明可能在存在不为1就一定不在
缓存击穿
发生原因请求key是热点数据存在于数据库但不在缓存通常是缓存数据过期就可能导致瞬时大量请求落在数据库
解决方法
设置热点数据永不过期或延长互斥锁保证只有一个线程更新缓存逻辑过期后台异步更新重建缓存时还要获取互斥锁
缓存雪崩
发生原因Redis宕机或数据库大量数据同一时间过期大量请求直接访问数据库
解决方法
如果是redis服务不可用采用集群主从、限流数据库只处理少部分请求服务熔断暂停缓存访问返回错误如果是大量数据同时过期设置不同失效时间、永不失效、二级缓存
主从复制、集群
读写分离从节点读主节点写主节点同步数据给从节点
开启主从关系从节点执行replicaof或slaveof(5.0之前)有临时redis-cli连接时执行slaveof和永久配置文件加上主节点的ip和端口
数据同步原理
第一次主从同步是全量同步
第一阶段slave执行replicaof命令建立连接master判断如果是第一次master就返回master数据版本信息slave保存版本信息 判断是不是第一次同步slave必须声明自己的replication id和offsetmaster判断id不一致一定是第一次一致的话根据offset进行增量同步 Replication id数据集标记id一致说明是同一数据集每个master都有唯一replidslave会继承master的replidoffset偏移量随着记录在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从节点
配置主从读写分离加入BeanLettuceClientConfigurationBuilderCustomizer指定读取策略4个从主节点读和优先从主节点读主不可用时从节点读和从从节点读和优先从从节点读从不可用从主节点读指定REPLICA_PREFERRED最后一中
分片
主从和哨兵解决高可用、高并发读的问题还有海量数据存储和高并发写的问题没解决
使用分片集群解决这两个问题特征
集群多个master每个master保存不同数据每个master都可以有多个slavemaster之间ping监测彼此健康状态客户端请求可以访问集群的任意节点最终都会转发到正确节点
cluster-enabled yes开启集群
Redis6.2.4redis-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切换给执行这个命令的节点实现无感知的数据迁移
slave告诉master拒绝任何客户端请求master返回当前数据offset给slave等待数据offset与master一致不一致赶快同步slave和master开始故障转移slave标记自己为master广播故障转移结果master收到广播开始处理客户端读请求
手动的Failover支持三种不同模式
缺省默认1-6步force省略对offset的一致性校验takeover直接执行第5步忽略数据一致性、忽略master状态和其他master意见
RedisTemplate访问分片集群底层同样基于lettuce实现分片集群的支持配置分片集群地址和读写分离
Redission
优化setnx分布式工具集合
redis集群是apzookeeper是cp
setnx问题不可重入同一个线程不能多次获取同一个锁、不可重试获取锁只尝试一次就返回、超时释放业务时间过长锁自动释放、主从一致性主从同步延迟主节点宕机锁失效解决方法创建连锁无主节点 每个节点都获取到锁才算成功使用时要引入依赖创建客户端bean用客户端.getLock(指定名称)返回可重入锁locklock.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和定时任务
创建有序集合存储任务score是任务执行时间戳/延迟时间延迟任务添加到该集合设置执行时间戳创建独立线程从有序集合检索到期任务定期ZRANGEBYSCORE获取当前时间之前的任务ZREM从有序集合删除过期任务如果任务失败就重新添加任务并设置执行时间戳
计算机网络
网络模型、输入URL
TCP/IP 四层网络模型
应用层只专注为用户提供应用功能应用层是工作在操作系统中的用户态传输层及以下工作在内核态消息/报文传输层为应用层提供网络支持TCP比UDP多了流量控制、超时重传、拥塞控制等数据包超过MSSTCP最大报文段长度就将数据包分块TCP中把每个分块叫一个TCP段段**网络层**负责将数据从一个设备传输到另一个设备IP协议可以寻址告诉我们下一个目的地的方向和路由根据下一个目的地选择路径如果IP报文大小超过MTU就会再次分片包**网络接口层**为网络层提供链路级别传输的服务工作在网卡这个层次在IP头部加上MAC封装成数据帧帧
五层是把网络接口层分开的
OSI 七层模型
应用层为应用程序提供服务
表示层数据格式转化数据加密
会话层建立、管理维护会话
输入URL会发生什么DNS 浏览器解析URL生成发送给服务器的请求信息 查询服务器域名对应的IP地址 域名解析工作流程 浏览器本地缓存操作系统本地缓存hosts文件都没有就问本地DNS服务器本地DNS服务器会先查缓存没有就问根域名服务器DNS服务器都会先查自己有没有这个域名的缓存根域名返回顶级域名服务器地址让本地DNS去问本地问顶级域名服务器继续返回更下面的DNS服务器地址权威DNS服务器将ip返回本地DNS本地DNS将ip地址返回给客户端客户端和目标建立连接 找到ip后就可以将HTTP的传输工作交给操作系统的协议栈 ip还包括ICMP协议和ARP协议 ICMP用于告知网络包传输过程中产生的错误和各种控制信息ARP用于根据IP地址查询对应的MAC地址RARP已知MAC地址求IP地址 HTTP基于TCP传输 TCP报文头部源端口、目标端口、序号、确认号、状态位、窗口大小流量控制双方都声明窗口缓存大小标识自己处理能力、校验和、紧急指针、选项、数据 三次握手保证双发都有发送和接收的能力
如果HTTP消息超过MSSTCP就需要把HTTP数据拆为多个段
TCP协议有两个端口浏览器监听的端口通常随机生成服务器监听的端口HTTP默认80HTTPS默认443 IP将数据封装为网络包发送需要源ip存在多个网卡时要根据路由表选择和目标ip协议号 MAC用于两点之间的传输需要源MAC网卡获取和目标MAC地址ARP协议在以太网广播也有缓存 网卡将数字信息转换为电信号在网线上传输控制网卡需要网卡驱动程序网卡驱动程序获取网络包后将其复制到网卡的缓存区然后在开头加上报头和起始帧分界符末尾加上用于检错的帧校验序列FCS 交换机将网络包转发到目的地工作在MAC层通过包尾的FCS校验错误没问题就放到缓冲区之后查询MAC地址表看自己有没有目标MAC地址找不到就转发给除了源端口之外的所有端口交换机直接接收所有包放到缓冲区网卡判断不是发给自己的就丢弃交换机端口没有MAC地址 路由器基于IP路由器的各个端口有MAC地址和IP和交换机基于以太网没MAC地址类似所以路由器遇到不匹配的包会直接丢弃查路由表根据路由表网关列判断对方ip地址为空表示已经到终点了否则继续转发之后ARP查询MAC地址
HTTP
HTTP状态码、报文结构
HTTP 常见的状态码有哪些 1xx提示信息 2xx服务器成功处理客户端请求 200 OK成功响应头有body204 No Content成功响应头无body206 Partial Content应用于 HTTP 分块下载或断点续传表示响应返回的 body 数据并不是资源的全部而是其中的一部分也是服务器处理成功的状态。 3xx客户端请求的资源发生变动需要重定向 301 Moved Permanently永久重定向请求资源不在要用新URL302 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-AliveContent 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 是一个相对时间优先级高选项多精细 实现流程 浏览器第一次请求服务器资源返回资源同时返回Cache-Control过期时间大小浏览器再次请求该资源时会先看Cache-Control没过期就用缓存否则再发请求服务器再次收到请求后会再次更新返回的Cache-Control Expires是一个绝对时间
协商缓存
与服务器协商之后通过协商结果判断是否使用本地缓存如响应码304
两种头部方式实现
请求头部中的 If-Modified-Since 字段与响应头部中的 Last-Modified 字段实现 响应的Last-Modified标识资源的最后修改时间请求的If-Modified-Since资源过期后发现响应有Last-Modified再次发请求的时候带上Last-Modified时间服务器接收之后发现有If-Modified-Since就判断如果资源最后修改时间更新就返回最新资源否则返回304说明资源不用改 请求头部中的 If-None-Match 字段与响应头部中的 ETag 字段 响应头部中 Etag唯一标识响应资源请求头的If-None-Match资源过期时发现响应有Etag就再次向服务器发请求将请求头If-None-Match设置为Etag的值服务器收到后判断资源没变化就返回304变化就返回新资源
第一种基于时间第二种基于一个唯一表达式第二个更准确判断文件内容是否被修改避免由于时间篡改导致的不可靠问题
Etag优先级比Last-Modified高第二种比第一种高Etag没变化再看第一种
因为Etag可以解决更多的问题
没修改文件内容但文件最后修改时间改变客户端认为文件改变重新请求If-Modified-Since检查的粒度是秒级的用Etag就不怕有些服务器不能精确获取文件最后修改时间
协商缓存都需要配合强制缓存的Cache-Control使用只有没命中强制缓存时才能发起协商缓存
HTTP1.1
断点续传
http1.1中header的Range和contentRange使用范围请求通过指定HTTP请求报文首部字段Range来请求尚未收到的资源
Range请求头指定第1个和最后1一个字节位置Content-Range响应头响应覆盖的范围和整个实体长度
比如范围请求只请求5000-10000字节的资源Range5000-10000范围请求响应状态码是206 如果服务器无法响应范围请求则会返回状态码200 OK和完整的实体内容。
优点
简单headerbody头部key-value灵活和易于扩展各类请求方法、URL、状态码、头字段可以自定义和补充工作在应用层下层可以随意变化如 HTTPS就是在HTTP和TCP之间增加SSL/TLS安全传输层HTTP/1.1和HTTP/2.0使用TCP协议HTTP/3.0使用UDP协议 应用广泛和跨平台
缺点
无状态和明文传输都是双刃剑 无状态 好处不需要额外资源记录状态坏处每次都要问一下身份 Cookie解决通过在请求和响应报文写入Cookie信息控制客户端的状态 明文传输 不安全用HTTPS解决 通信使用明文不验证通信方身份无法证明报文完整性
长连接、管道传输
HTTP基于TCP/IP使用请求-应答通信模式
长连接为了解决HTTP/1.0短连接问题只要任意一端没有明确提出断开连接就一致保持TCP连接状态或者超过一定时间没有数据交互服务器就主动断开这个连接管道网络传输不是默认开启浏览器基本不支持在同一个TCP连接客户端可以发起多个请求不用等请求响应回来可以立即发请求但服务器必须按请求的顺序发送回响应 如果服务器在处理前面的请求耗时较长那么后续的请求就会被阻塞称为 队头阻塞所以HTTP/1.1管道解决了请求的队头阻塞没有解决响应的队头阻塞 队头阻塞响应队头阻塞
优化方案 尽量避免发送 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 解决的问题为什么安全
解决方法
信息加密使用混合加密防止信息被窃取校验机制摘要算法为数据生成唯一指纹用于校验数据完整性身份证书将服务器公钥放入数字证书中解决冒充
具体 混合加密HTTPS 采用对称加密过程全部使用对称加密的会话密钥加密数据和非对称加密通信建立前用非对称加密交换会话密钥之后不用非对称加密 使用原因 对称加密只使用一个密钥无法做到安全的密钥交换速度快非对称加密使用两个密钥公钥和私钥公钥可以任意分发但私钥保密解决密钥交换问题但速度慢 摘要算法数字签名为了保证信息不被修改对内容计算出一个指纹摘要算法哈希函数和内容一块发给对方对方收到后根据内容也计算一个指纹用于判断内容是否被更改。现在缺少客户端对收到消息是服务端的证明所以使用非对称加密解决两个密钥双向加解密 公钥加密私钥解密保证内容传输安全私钥角度只有有私钥的人才能解密一般不会这样因为耗费性能 私钥加密公钥解密保证内容不会被冒充私钥角度只有有私钥的人才发送公钥能解密的内容是非对称加密的主要用途来确认消息身份如数字签名算法私钥加密是对内容的哈希值加密加密后就是数字签名 私钥是服务端保管服务端向客户端颁发对应公钥如果客户端收到的信息能被公钥解密就说明该消息是服务器发送的 数字证书 此时缺少身份验证因为公钥可能被伪造所以可以将服务器的公钥注册到CA服务器公钥ca数字签名ca用自己的私钥对其他信息的hash的加密保存在ca机构客户端拿到数字证书后因为浏览器或操作系统内置ca的公钥就去ca机构验证数字证书是否合法。
数组不用额外数组空间
干啥来着
建立连接过程
SSL/TLS基本流程
客户端向服务器要公钥双方协商生产会话密钥双方采用会话密钥进行加密通信
前两部是建立过程也是TLS握手过程握手阶段涉及四次通信用不同密钥交换算法握手流程也不一样常用密钥交换算法有RSA和ECDHE
基于RSA的握手过程 客户端请求 客户端发起加密请求发送TLS版本、客户端随机数、客户端支持的密码套件列表如RSA加密算法 服务端请求 服务器收到请求发出响应发送确认TLS版本、服务端随机数、确认的密码套件、服务器数字证书 客户端回应 客户端通过浏览器或操作系统的CA公钥确认数字证书没问题就取出服务器的公钥用它加密报文再向服务器发送一个被服务器公钥加密的随机数、改变加密算法的通知表示之后就使用会话密钥通信、客户端握手结束通知同时把之前所有内容的数据做个摘要供服务端校验服务器和客户端有了这三个随机数Client Random、Server Random、pre-master key接着就用双方协商的加密算法各自生成本次通信的「会话秘钥」。 服务器回应 收到第三个随机数后通过协商的加密算法计算本次通信的会话密钥然后向客户端发送加密算法改变的通知、服务器握手结束通知同时把之前所有内容数据做个摘要供客户端校验
接下来客户端和服务器进入加密通信用会话密钥加密内容
但RSA存在HTTPS前向安全问题服务端私钥泄露之后TLS密文都会被破解所以一般使用ECDHE密钥协商算法
证书校验流程
CA签发证书时
CA把持有者的公钥、用途、颁发者、有效时间等信息打成一个包然后对这些信息进行Hash运算算出一个hash值CA用自己的私钥将Hash加密生成数字签名最后将数字签名添加在文件证书上形成数字证书
客户端校验服务端证书时
客户端算出内容信息的Hash值H1浏览器或操作系统内置CA公钥收到证书后可以使用CA公钥解密数字签名得到Hash值H2最后比较H1和H2相同就表示可信赖的证书
但可能证书有层级验证方式大概是由于用户信任最上层的证书所以上层证书担保的下层证书可以被信任一层一层向下最后因为用户信任操作系统或浏览器所以都信任为了确保证书的绝对安全性
保证完整性
TLS在实现上分为握手协议和记录协议
TLS握手协议就是TLS四次握手负责协商加密算法和生成对称密钥后续用该密钥加密记录协议负责保护应用程序数据并验证完整性和来源
记录协议主要负责HTTP数据的压缩、加密和数据的人则会那个过程
消息被分为多个较短片段分别对每个片段压缩被压缩的片段会加上消息认证码MAC值哈希算法生成保证完整性并进行数据认证可以识别出篡改为了防止重放攻击在计算消息认证码时加上了片段的编码经过压缩的片段加上消息认证码会一起通过对称加密经过加密的数据再加上数据类型、版本号、压缩后的长度组成的报头就是最终的报文数据
记录协议完成后最终报文就到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个或多个MessageMessage中对应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特点 无队头阻塞也可以在同一条连接上并发StreamStream可以认为是一条HTTP请求 QUIC保证传输可靠性某个Stream发生丢包时只会阻塞这个Stream其他Stream不受影响因此不存在队头阻塞2.0如果某个Stream的包丢失其他Stream也会受影响 QUIC连接上的多个Stream没有依赖都是独立的某个Stream发生丢包时只会影响该Stream 更快的连接建立对于1/2协议TCP属于内核实现的传输层TLS时openssl库实现的表示层合并时需要分批次握手先TCP再TLS。3在传输数据时虽然需要QUIC握手但只要一个RTT目的时确认双方的连接ID连接迁移就是基于连接ID实现的 但3的QUIC协议不是和TLS分层而是QUIC包含TLS它在自己帧里会携带TLS的记录同时QUIC使用TLS/1.3需要一个RTT就可以完成建立连接和密钥协商 甚至在第二次连接的时候应用数据包可以和 QUIC 握手信息连接信息 TLS 信息一起发送达到 0-RTT 的效果。 连接迁移基于TCP的HTTP协议是通过四元组确定一条TCP连接但当网络从4g切换到wifi时意味ip变化就必须重新建立连接同时建立连接很慢成本高 QUIC协议没有用四元组绑定连接通过连接ID标记通信的两个端点客户端和服务端可以选择一组ID标识自己即使IP变化只要保有上下文信息如连接ID、TLS密钥就可以复用连接清除重连成本连接迁移
所以 QUIC基于UDP 是一个在 UDP 之上的伪 TCP TLS HTTP/2 的多路复用的协议。很多网络设备无法识别QUIC包就会当作UDP的包直接抛弃
既然有 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连接需要客户端和服务端达成三个信息的共识 Socketip和端口号组成序列号解决乱序窗口大小流量控制 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头格式
源端口、目标端口序列号建立连接时计算机生成随机数为初始值每发送一次就累加一次大小 解决包乱序的问题、确认应答号指下一次期望收到的数据序列号发送端收到这个确认应答之后可以认为在这个序号之前的数据都已经被正常接收解决丢包问题控制位
ACK1标识确认应答字段有效TCP规定除了最初建立的连接时的SYN包之外都必须是 1RST1标识TCP连接出现异常必须强制断开连接SYN1标识希望建立连接并在序列号字段设定初始随机值FIN1标识不会有数据发送希望断开连接。通信双方主机之间就可以相互交换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区别
连接 TCP面向连接传输数据前先建立连接UDP不需要连接直接传输数据 服务对象 TCP是一对一UDP支持一对一、一对多、多对多 可靠性 TCP可靠交付数据无差错、不丢失、不重复、按序到达UDP不保证可靠交付数据但可以基于UDP实现可靠传输协议如QUIC协议 拥塞控制、流量控制 TCP有拥塞控制和流量控制保证数据传输安全性UDP没有即使网络拥堵也不影响发送速率 首部开销 TCP首部较长而且可变UDP首部短而且不变 传输方式 TCP是流式传输没边界保证顺序和可靠UDP是一个一个包发送的有边界但可能丢包和乱序 分片不同 TCP如果大于MSS大小就会在传输层分片目标主机也会在传输层组装如果丢失一个分片只需要传输这个分片UDP如果大于MTU就会在IP层分片目标主机会在IP层组装数据
TCP 和 UDP 应用场景
TCPFTP文件传输、HTTP/HTTPS
UDP包总量少的通信如DNS、SNMP视频音频、广播通信
UDP没有首部长度字段是因为UDP首部长度不会变化不用记录
TCP没有包长度字段
TCP数据长度IP总长度 - IP首部长度 - TCP首部长度
UDP长度也可以这样算但还是存在包长度字段可能因为为了网络设备硬件设计方便保证首部长度是4字节的整数倍或者因为之前UDP不是基于IP协议
TCP 和 UDP 可以使用同一个端口吗
可以
传输层端口的作用是为了区分同一个主机上不同应用程序的数据包
所以并不冲突
三次握手
三次握手过程
开始时客户端和服务端都处于CLOSE状态然后服务端主动监听端口处于LISTEN客户端会随机初始化序号client_isn作为发送序号同时SYN标志为1接着把第一个SYN发给服务端表示向服务端发起连接不包含数据之后客户端处于 SYN_SENT服务端收到SYN后初始化自己的序号server_isn作为发送序号再将确认应答号填入client_isn 1把SYN和ACK标为1把报文发给客户端报文不包含应用层数据之后服务端处于 SYN_RCVD客户端收到报文后向服务端发送最后一个应答报文ACK1确认应答号为server_isn 1这次报文可以携带客户到服务端的数据之后客户端处于ESTABLISHED服务端收到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状态但服务端发出去的ACKSYN无法得到ACK应答时间长就会沾满服务端的半连接队列
TCP半连接和全连接
半连接队列SYN队列全连接队列accept队列
服务端接收到客户端的SYN后就会创建一个半连接对象加入内核的SYN队列接着发送SYNACK给客户端等待客户端回应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 减少SYNACK重传次数大量处于SYN_REVC的TCP连接会一直重传SYNACK当重传超过次数达到上限后就会断开连接所以减少fwd重传次数让更快断开连接
SYN 报文丢弃情况、PAWS 开启tcp_tw_recycle参数并且在NAT环境下造成SYN报文被丢弃 同时开启recycle和timestamps选项就开一种叫per-host的 PAWS机制作用是防止TCP包中的序列号发生绕回 PAWS所有TCP包发送都会带上发送时的时间戳PAWS要求双方都维护最近一次收到数据包的时间戳每收到一个数据包就会和时间戳比较如果发现收到的数据包中时间戳不是递增的则表示该数据包是过期的就会直接丢弃这个数据包。 **per-host**对【对端IP做PAWS】检查而不是对四元组做PAWS检查 TCP两个队列满了半连接队列和全连接队列造成SYN报文被丢弃 **半连接队列满了**服务器syn攻击可能导致半连接队列满了这时后面的syn包会被丢弃但如果开启了syncookies功能即使半连接队列满了也不会丢弃syn包服务端根据当前状态计算出一个值放在synack中客户端返回ack时取出该值验证合法就认为建立成功可以增大半连接队列也要增大全连接队列否则无效、开启syncookies、减少synack重传次数**全连接队列满了**如果accept队列过小或者应用程序调用accept不及时就会造成accept队列满了后续请求会被抛弃可以增大全连接队列长度和检查为什么调用accept不及时
没 accept 建立连接
可以
accept不参与三次握手只负责从TCP全连接队列取出一个已经建立连接的socket用户层通过accept系统调用拿到了已经建立连接的socket就可以对该socket进行读写操作
没 listen 建立连接
可以
客户端可以自己连接自己也可以两个客户端同时向对方发出请求建立连接TCP同时打开这两个情况都没有服务端参与没有listen就TCP连接
已建立连接的TCP收到 SYN
已经建立TCP连接客户端中途宕机服务端一直处于Established状态客户端恢复后向服务端建立连接
TCP连接是由四元组唯一确定的
**客户端的SYN报文里的端口号与历史连接不相同**此时服务端会认为是一个新的连接就三次握手建立新连接此时旧连接处于Estaablished状态的服务端如果给客户端发送数据因为客户端连接已经关闭此时客户端内核返回RST报文服务端收到后会释放连接。如果服务端一直没有发送数据包给客户端在超过一段时间后TCp保活机制就会启动检测客户端没有存活后服务端就会释放连接**客户端的SYN报文里的端口号与历史连接相同**也就是处于Established状态的服务端收到了这个SYN报文此时的SYn报文乱序因为初始化序列号是一个随机数服务端会回复一个携带了正确序列号和确认号确认号就不是SYN的序列号!是宕机之前服务端发送的ACK的ACKChallenge 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后会尝试等待一段时间接收可能发过来的重传FINRTO超时重传时间动态计算因为报文从发出到接收最后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 包
客户端shutdown()关闭写方向如果关闭读方向那么收到数据包就会直接回复RST发送FIN客户端变为FIN_WAIT_1服务端发送ACK服务端变为CLOSE_WAIT服务端发送数据包给客户端但被延迟客户端收到ACK后客户端变为FIN_WAIT_2服务端发送FIN比延迟的数据包先到但FIN其实是一个乱序的报文因为FIN包和服务端之前的ACK之间有数据包服务端进入LAST_ACK客户端收到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 SACKD-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时发送方还是会发那一点字节 但TCPIP头有40个字节
解决方法让接收方不通告小窗口接收方策略窗口大小小于min(MSS、缓存空间/2就会向发送方通告窗口为0让发送方避免发送小数据使用 Nagle 算法延时处理发送方策略只有满足等到窗口大小大于MSS同时数据大小大于MSS或收到之前发送数据的ACK包才可以发送数据都不满足就不发数据
两方都满足才能避免糊涂窗口综合症Nagle默认打开如果需要小数据包交互的场景需要关闭Nagle算法
拥塞控制
流量控制只是避免发送方的数据填满接收方的缓存
在网络拥堵时如果继续发送大量数据包可能会导致数据包时延、丢失等此时TCP就会重传数据但重传会导致网络负担加重会导致更大的延迟以及更多的丢包此时就会恶性循环
拥塞控制避免发送方的数据填满整个网络
拥塞窗口为了在发送方调节发送数据的量是发送方维护的一个状态变量会根据网络的拥塞程度动态变化
发送窗口的为拥塞窗口和接收窗口的最小值
网络出现拥堵发送方没在规定时间收到ACK也就是超时重传拥塞窗口就减小反之就增大
拥塞控制4个算法 慢启动 TCP刚建立连接后首先是慢启动一点一点提高发送数据包的数量**当发送方每收到一个 ACK拥塞窗口 cwnd 的大小就会加 1。**发包指数性增长1248达到慢启动门限就会使用拥塞避免算法 拥塞避免 一般65535字节每收到一个ACK拥塞窗口就增长1变成线性增长8910 这时就会慢慢进入拥塞状态就会出现丢包现象就需要对丢失的包进行重传 触发重传就会进入拥塞发生 拥塞发生 重传分为超时重传和快速重传 超时重传会使用拥塞发生此时慢启动门限会变为拥塞窗口的一半拥塞窗口重置为1 快速重传的拥塞发生算法TCP认为不严重拥塞窗口变为一半慢启动门限变为拥塞窗口然后进入快速恢复算法 快速恢复 快速重传和快速恢复一般同时使用快速恢复是认为你还能收到3个重复ACK说明网络没问题 在快速恢复之前拥塞窗口变为一半慢启动门限变为拥塞窗口 步骤 拥塞窗口3重传丢失数据包如果再收到重复ACK拥塞窗口1如果收到新数据的ACK就将拥塞窗口设置为上面的慢启动门限因为ack了新数据说明重复的三个ack时的数据都收到了恢复过程结束可以恢复到之前的状态即再次进入拥塞避免状态
半连接队列和全连接队列
什么是 TCP 半连接队列和全连接队列
在 TCP 三次握手的时候Linux 内核会维护两个队列分别是
半连接队列也称 SYN 队列哈希表存放不完整连接为了o(1)的取出复杂度全连接队列也称 accept 队列链表只要是个连接就行
服务端收到sync之后内核会将连接存储在半连接队列返回客户端synack客户端返回ack服务端收到ack之后内核会将连接从半连接队列删除创建新的连接加入accept队列等待进程调用accept函数时取出连接、
全连接和半连接队列都有最大长度限制超过后内核会直接丢弃或返回RST包
**全连接溢出**服务端处理大量并发请求时如果全连接队列过小就容易溢出后续请求会被丢弃默认行为也可以发送RST
半连接溢出对服务端一直发syn包但是不进行第三次握手ack就可以使服务端有大量处于SYN_RECV的TCP连接 SYN 洪泛、SYN 攻击、DDos 攻击。
策略
如果半连接队列满了并且没有开启 tcp_syncookies则会丢弃若全连接队列满了且没有重传 SYNACK 包的连接请求多于 1 个则会丢弃如果没有开启 tcp_syncookies并且 max_syn_backlog 减去 当前半连接队列长度小于 (max_syn_backlog 2)则会丢弃
开启 tcp_syncookies 是缓解 SYN 攻击其中一个手段。开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接
syncookies的做法服务端根据当前状态计算一个值放在恢复的synack里客户端收到ack后取出值验证合法就认为建立连接成功
syncookies 参数主要有以下三个值
0 值表示关闭该功能1 值表示仅当 SYN 半连接队列放不下时再启用它2 值表示无条件开启功能
那么在应对 SYN 攻击时只需要设置为 1 即可
如何防御SYN攻击
增大半连接队列要想增大半连接队列我们得知不能只单纯增大 tcp_max_syn_backlog 的值还需一同增大 somaxconn 和 backlog也就是增大全连接队列开启 tcp_syncookies 功能减少 SYNACK 重传次数收到SYN攻击之后服务端会有大量处于SYN_RECV的TCP连接处于这个状态的TCP会重传SYNACK重传达到次数上限后就会断开连接所以可以减少重传次数加速断开连接
优化 TCP
三次握手的性能提升 客户端优化SYN 的全称就叫 Synchronize Sequence Numbers同步序列号等待服务端返回synack时会一直重传默认5次一共1分钟左右所以可以调整重传次数比如内网通信时可以适当降低让赶快出错 服务端优化主要谈半连接队列重发次数syncookies参数队列大小连接满之后的策略丢弃(应对突发流量还是RST 如何绕过三次握手三次握手建立连接的结果就是HTTP请求必须在一个RTT从客户端到服务端的一个往返时间后才能发送linux3.7之后提供了TCP Fast Open功能直接用Cookie建立连接同时发送数据首次客户端发送SYN包含Fast Open选项该选项Cookie为空服务端返回Cookie放到Cookie选项中客户端缓存Cookie之后就可以直接发送数据带上Cookie支持Fast Open的服务端会对Cookie进行校验有效就返回Synack随后服务端发送数据Cookie无效就会直接丢弃数据随后的Synack只确认序列号。这样服务端可以在握手完成过之前发送数据 客户端在请求并存储了 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 是带宽和时延的乘积所以它就叫「带宽时延积」缩写为 BDPBandwidth 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个RTT1.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或FINlen1
确认号上一次收到的报文的序列号len数据长度特殊SYN或FINlen1
如果客户端发送的第三次握手ACK丢失了处于SYN_RCVD状态服务端收到了客户端第一个TCP数据报文会发生什么
因为发送的第一个数据报文的序列号和确认号和第三次握手的序列号和确认好一样同时报文将ACK为1所以服务端收到这个报文可以正常建立连接然后正常接收这个数据包
IP
IP基础
IP是在主机之间通信用的MAC是实现直连的两个设备之间的通信。IP负责在没直连的两个网络之间进行通信
IP分类ABC分为网络号和主机号两个部分主机号全1为广播全0指某个网络
A0.0.0.0-127.255.255.255B128.0.0.0-191.255.255.255C192.0.0.0-223.255.255.255D和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服务器查询
先发本地DNS服务器没找到就问根域名服务器不直接用于域名解析指明一条道路顶级域名服务器权威域名服务器
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是回环地址时就会选择本地网卡
断网的情况下网卡已经不工作了
设计模式
单例模式
双重检验锁实现单例模式
public class Singleton {private volatile static Singleton uniqueInstance;private Singleton() {}public static Singleton getUniqueInstance() {//先判断对象是否已经实例过没有实例化过才进入加锁代码if (uniqueInstance null) {//类对象加锁synchronized (Singleton.class) {if (uniqueInstance null) {uniqueInstance new Singleton();}}}return uniqueInstance;}
}uniqueInstance new Singleton(); 这段代码其实是分为三步执行
为 uniqueInstance 分配内存空间初始化 uniqueInstance将 uniqueInstance 指向分配的内存地址
但是由于 JVM 具有指令重排的特性执行顺序有可能变成 1-3-2。指令重排在单线程环境下不会出现问题但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如线程 T1 执行了 1 和 3此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空因此返回 uniqueInstance但此时 uniqueInstance 还未被初始化。
适配器模式
字节流字符流适配器
主要用于接口互不兼容的类的协调工作IO 流中的字符流和字节流的接口不同它们之间可以协调工作就是基于适配器模式来做的更准确点来说是对象适配器。通过适配器我们可以将字节流对象适配成一个字符流对象这样我们可以直接通过字节流对象来读取或者写入字符数据。
AOP适配器
AOP中每个类型的通知都有对应的拦截器通知要通过对应的适配器是配成MethodInterceptor接口类型的对象通过调用getInterceptor适配成MethodBeforeAdviceInterceptor
装饰器模式
不改变原有对象扩展功能
通过 BufferedInputStream字节缓冲输入流来增强 FileInputStream 的功能
通过组合替代继承扩展原始类功能在继承关系复杂的场景实用
对于字节流来说 FilterInputStream 对应输入流和FilterOutputStream对应输出流是装饰器模式的核心分别用于增强 InputStream 和OutputStream子类对象的功能。
我们常见的BufferedInputStream(字节缓冲输入流)是FilterInputStream 的子类BufferedOutputStream字节缓冲输出流、DataOutputStream等等都是FilterOutputStream的子类。
适配器模式和装饰器模式有什么区别
装饰器模式 更侧重于动态地增强原始类的功能装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口。并且装饰器模式支持对原始类嵌套使用多个装饰器。
适配器模式 更侧重于让接口不兼容而不能交互的类可以一起工作当我们调用适配器对应的方法时适配器内部会调用适配者类或者和适配类相关的类的方法这个过程透明的。就比如说 StreamDecoder 流解码器和StreamEncoder流编码器就是分别基于 InputStream 和 OutputStream 来获取 FileChannel对象并调用对应的 read 方法和 write 方法进行字节数据的读取和写入。
适配器和适配者两者不需要继承相同的抽象类或者实现相同的接口。
观察者模式发布订阅模式
发布者发布消息时参与订阅的订阅者会收到对应消息通知原理是使用一个集合存储所有订阅类发布消息的时候遍历这个集合并调用集合中每个订阅者类的通知方法
Java实现发布订阅模式_java发布订阅模式-CSDN博客
代理模式
代理模式的主要作用是扩展目标对象的功能比如说在目标对象的某个方法执行前后你可以增加一些自定义的操作。 静态代理 静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。 实现步骤 定义一个接口及其实现类创建一个代理类同样实现这个接口将目标对象注入代理类然后在代理类对应方法调用目标类的对应方法 动态代理动态代理是在运行时动态生成类字节码并加载到 JVM 中的。 jdk动态代理InvocationHandler接口和Proxy类 Proxy类中方法newProxyInstance()用来生成一个代理对象 public static Object newProxyInstance(ClassLoader loader,Class?[] interfaces, //被代理类实现的一些接口InvocationHandler h) //实现了InvocationHandler接口的对象throws IllegalArgumentException
{......
}要实现动态代理需要实现InvocationHandler来自定义处理逻辑动态代理对象调用方法时会被转发到实现InvocationHandler接口类的invoke方法调用 public interface InvocationHandler {/*** 当你使用代理对象调用方法的时候实际会调用到这个方法*/public Object invoke(Object proxy, Method method, Object[] args)throws Throwable;
}通过Proxy类的newProxyInstance()创建的代理对象在调用方法时实际会调用到实现InvocationHandler接口类的invoke()方法 步骤 定义一个接口和实现类自定义InvocationHandler并重写invoke方法在invoke方法中会调用原生方法并自定义逻辑通过 Proxy.newProxyInstance(ClassLoader loader,Class?[] interfaces,InvocationHandler h) 方法创建代理对象 cglib动态代理通过继承方式Spring中的AOP模块如果目标对象实现了接口默认采用jdk动态代理否则采用cglib动态代理cglib中MethodInterceptor接口和Enhancer类是核心 jdk动态代理的问题是只能代理实现了接口的类 你需要自定义 MethodInterceptor 并重写 intercept 方法intercept 用于拦截增强被代理类的方法。 public interface MethodInterceptor
extends Callback{// 拦截被代理类中的方法public Object intercept(Object obj 被代理的对象, java.lang.reflect.Method method 需要增强的方法, Object[] args,MethodProxy proxy 调用原始方法) throws Throwable;
}你可以通过 Enhancer类来动态获取被代理类代理类继承了目标类当代理类调用方法的时候会被方法拦截器拦截实际调用的是 MethodInterceptor 中的 intercept 方法。 步骤 定义一个类自定义 MethodInterceptor 并重写 intercept 方法intercept 用于拦截增强被代理类的方法和 JDK 动态代理中的 invoke 方法类似通过 Enhancer 类的 create()创建代理类的实例然后调用方法 使用时需要添加依赖
JDK 动态代理和 CGLIB 动态代理对比
jdk动态代理只能代理实现了接口的类或直接代理接口Cglib可以代理未实现接口的类。cglib是通过生成一个被代理类的子类来拦截被代理类的方法调用所以不能代理声明了final类型的类和方法大部分jdk效率高
静态代理和动态代理对比
灵活性动态代理更灵活不必实现接口可以直接代理类并且不用针对每个目标类都创建代理类。静态代理中接口一旦新加方法目标对象和代理对象都要修改jvm层面静态代理在编译时就将接口实现类代理类这些变成一个个实际的class文件动态代理是在运行时动态生成类字节码加载到jvm中
排序 纠正插入排序的最好时间复杂度为 O(n) 而不是 O(n^2) 。
希尔排序的平均时间复杂度为 O(nlogn)
冒泡排序
/*稳定每次比较相邻的元素外层循环控制排序的轮数每一轮将最大元素移到数组末尾最佳o(n) 最差o(n^2) 平均o(n^2)o(1)
*/
public static int[] bubbleSort(int[] arr) {for (int i 1; i arr.length; i) {for (int j 0; j arr.length - i; j) {if (arr[j] arr[j 1]) {int tmp arr[j];arr[j] arr[j 1];arr[j 1] tmp;}}}return arr;
}选择排序
/*不稳定每次从未排序的部分选择最小的元素放到已排序部分的末尾都是o(n^2)o(1)
*/
public static int[] selectionSort(int[] arr) {for (int i 0; i arr.length - 1; i) {int minIndex i;for (int j i 1; j arr.length; j) {if (arr[j] arr[minIndex]) {minIndex j;}}if (minIndex ! i) {int tmp arr[i];arr[i] arr[minIndex];arr[minIndex] tmp;}}return arr;
}插入排序
/*稳定将未排序的元素逐个插入到已排序部分的正确位置外循环控制排序轮数从第二个元素开始插入到已排序的位置最佳o(n) 最差o(n^2) 平均o(n^2)o(1)*/
public static int[] insertionSort(int[] arr) {for (int i 1; i arr.length; i) {// 用在已排序部分int preIndex i - 1;int current arr[i];// 在已排序部分查找正确位置当前值更小就向前插入while (preIndex 0 current arr[preIndex]) {arr[preIndex 1] arr[preIndex];preIndex - 1;}// 将currentcharudaoarr[preIndex 1] current;}return arr;
}希尔排序
/*不稳定也是一种插入排序先将整个待排序的记录序列分割成若干子序列分别进行直接插入排序基本有序后再对全体记录依次直接插入排序最佳o(nlogn) 最差o(n^2) 平均o(nlogn)o(1)
*/
public static int[] shellSort(int[] arr) {int n arr.length;int gap n / 2;while (gap 0) {for (int i gap; i n; i) {int current arr[i];int preIndex i - gap;// 直接插入排序while (preIndex 0 arr[preIndex] current) {arr[preIndex gap] arr[preIndex];preIndex - gap;}arr[preIndex gap] current;}gap / 2;}return arr;
}归并排序
/*稳定分治先使每个子序列有序再使子序列间有序都是o(nlogn)o(n)
*/
public static int[] mergeSort(int[] arr) {// 边界一个元素直接返回if (arr.length 1) {return arr;}int middle arr.length / 2;int[] arr1 Arrays.copyOfRange(arr, 0, middle);int[] arr2 Arrays.copyOfRange(arr, middle, arr.length);return merge(mergeSort(arr1), mergeSort(arr2));
}
public static int[] merge(int[] arr1, int[] arr2) {int[] ans new int[arr1.length arr2.length];int idx 0, idx1 0, idx2 0;while (idx1 arr1.length idx2 arr2.length) {if (arr1[idx1] arr2[idx2]) {ans[idx] arr1[idx1];idx1 1;} else {ans[idx] arr2[idx2];idx2 1;}idx 1;}if (idx1 arr1.length) {while (idx1 arr1.length) {ans[idx] arr1[idx1];}} else {while (idx2 arr2.length) {ans[idx] arr2[idx2];}}return ans;
}快速排序
1.从序列中随机挑出一个元素做为基准pivot这里选择序列的最左边元素作为基准 2.重新排列序列将所有比基准值小的元素摆放在基准前面所有比基准值大的摆在基准的后面。该操作结束之后该基准就处于数列的中间位置。这个操作称为分区partition 3.递归地把小于基准值元素的子序列和大于基准值元素的子序列进行上述操作即可。
public class QuickSort {public static void quickSort(int[] arr) {sort(arr, 0, arr.length - 1);}private static void sort(int[] arr, int left, int right) {if (left right) {int pivotIdx partition(arr, left, right);sort(arr, 0, pivotIdx - 1);sort(arr, pivotIdx 1, right);}}private static int partition(int[] arr, int left, int right) {int idx left 1;for (int i idx; i right; i) {if (arr[left] arr[i]) {swap(arr, i, idx);}}swap(arr, left, idx - 1);return idx - 1;}private static void swap(int[] arr, int idx1, int idx2) {int tmp arr[idx1];arr[idx1] arr[idx2];arr[idx2] tmp;}
}
堆排序
1.将待排序列(R0, R1, ……, Rn)构建成最大堆最小堆 2.将堆顶元素R[0]与最后一个元素R[n]进行交换此时得到新的无序区(R0, R1, ……, Rn-1)和新的有序区(Rn),且满足R[0, 1, ……, n-1]R[n]R[n] 3.由于调整后的新堆可能违反堆的性质因此需要对当前无序区(R0, R1, ……, Rn-1)进行调整 4.重复步骤2~3直到有序区的元素个数为n。
public class HeapSort {private static int heapLen;public static void heapSort(int[] arr) {heapLen arr.length;for (int i heapLen - 1; i 0; i--) {heapify(arr, i);}for (int i heapLen - 1; i 0; i--) {swap(arr, 0, heapLen - 1);heapLen--;heapify(arr, 0);}}private static void heapify(int[] arr, int idx) {int left idx * 2 1, right idx * 2 2, largest idx;if (left heapLen arr[left] arr[largest]) {largest left;}if (right heapLen arr[right] arr[largest]) {largest right;}if (largest ! idx) {swap(arr, largest, idx);heapify(arr, largest);}}private static void swap(int[] arr, int idx1, int idx2) {int tmp arr[idx1];arr[idx1] arr[idx2];arr[idx2] tmp;}
}
计数排序
1.找出数组中的最大值maxVal和最小值minVal 2.创建一个计数数组countArr其长度是maxVal-minVal1元素默认值都为0 3.遍历原数组arr中的元素arr[i]以arr[i]-minVal作为countArr数组的索引以arr[i]的值在arr中元素出现次数作为countArr[a[i]-min]的值 4.遍历countArr数组只要该数组的某一下标的值不为0则循环将下标值minVal输出返回到原数组即可。
public class CountingSort {public static void countingSort(int[] arr) {int len arr.length;if (len 2) return;int minVal arr[0], maxVal arr[0];for (int i 1; i len; i) {if (arr[i] minVal) {minVal arr[i];} else if (arr[i] maxVal) {maxVal arr[i];}}int[] countArr new int[maxVal - minVal 1];for (int val : arr) {countArr[val - minVal];}for (int arrIdx 0, countIdx 0; countIdx countArr.length; countIdx) {while (countArr[countIdx]-- 0) {arr[arrIdx] minVal countIdx;}}}
}
桶排序
1.设置一个bucketSize该数值的选择对性能至关重要性能最好时每个桶都均匀放置所有数值反之最差表示每个桶最多能放置多少个数值 2.遍历输入数据并且把数据依次放到到对应的桶里去 对每个非空的桶进行排序可以使用其它排序方法这里递归使用桶排序 3.从非空桶里把排好序的数据拼接起来即可。
import java.util.ArrayList;
import java.util.List;public class BucketSort {private static ListInteger bucketSort(ListInteger 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;ListListInteger 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));}}ListInteger result new ArrayList();for (ListInteger val : bucket) {result.addAll(val);}return result;}
}
基数排序
1.取得数组中的最大数并取得位数即为迭代次数n例如数组中最大数为123则 n3 2.arr为原始数组从最低位或最高位开始根据每位的数字组成radix数组radix数组是个二维数组其中一维长度为10例如123在第一轮时存放在下标为3的radix数组中 3.将radix数组中的数据从0下标开始依次赋值给原数组 4.重复2~3步骤n次即可。
import java.util.ArrayList;
import java.util.List;//基数排序
public class RadixSort {public static void radixSort(int[] arr) {if (arr.length 2) return;int maxVal arr[0];//求出最大值for (int a : arr) {if (maxVal a) {maxVal a;}}int n 1;while (maxVal / 10 ! 0) {//求出最大值位数maxVal / 10;n;}for (int i 0; i n; i) {ListListInteger 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 (ListInteger list : radix) {for (int a : list) {arr[index] a;}}}}
}
LRU算法(lru)(链表hashmap)
思路 定义节点类Node每个节点包含键key、值value、前一个节点prev和后一个节点next的引用。 定义LRUCache类
a. 初始化LRUCache创建一个双端链表作为缓存数据的存储结构同时初始化一个HashMap用于快速查找缓存中的节点。head和tail分别表示链表的头部和尾部节点。
b. 添加节点addNode将一个新节点添加到链表的头部。
c. 删除节点removeNode从链表中删除一个节点。
d. 移动到头部moveToHead将某个节点从当前位置移动到链表的头部表示该节点最近被使用过。
e. 弹出尾部节点popTail从链表的尾部弹出一个节点即最近最少使用的节点。
f. 获取数据get根据键从HashMap中查找节点。如果找到则将该节点移动到链表的头部并返回其值否则返回-1。
g. 插入或更新数据put根据键从HashMap中查找节点。如果找到则更新节点的值并将其移动到链表的头部否则创建一个新节点将其添加到链表的头部并在HashMap中建立键和节点的映射。如果此时缓存的大小超过了容量则弹出链表的尾部节点并从HashMap中删除其映射。
import java.util.HashMap;class LRUCache {class Node {int key;int value;Node prev;Node next;}private void addNode(Node node) {node.prev head;node.next head.next;head.next.prev node;head.next node;}private void removeNode(Node node) {Node prev node.prev;Node next node.next;prev.next next;next.prev prev;}private void moveToHead(Node node) {removeNode(node);addNode(node);}private Node popTail() {Node res tail.prev;removeNode(res);return res;}private HashMapInteger, 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开源的分布式流式处理平台,全面的高性能消息队列。 流式处理平台三个关键功能 消息队列发布和订阅消息流类似消息队列容错的持久方式存储记录消息流消息持久化到磁盘避免消息丢失流式处理平台消息发布的时候进行处理Kafka提供了一个完整的流式处理类库 Kafka 是一个分布式系统由通过高性能 TCP 网络协议进行通信的服务器和客户端组成可以部署在在本地和云环境中的裸机硬件、虚拟机和容器上 Kafka2.8之前重度依赖Zookeeper做元数据管理和集群的高可用2.8之后引入基于Raft协议的KRaft模式不再依赖Zookeeper RocketMQ阿里开源的一款云原生“消息、事件、流”实时数据处理平台借鉴了 Kafka RabbitMQErlang 语言实现 AMQP用于在分布式系统中存储转发消息。具体特点 可靠性保证消息可靠性如持久化、传输确认和发布确认灵活路由消息进入队列之前通过交换器路由消息。内置交换器也可以将多个交换器绑定在一起也可以通过插件实现自己的交换器扩展性多个RabbitMQ节点可以组成一个集群也可以根据实际业务动态扩展集群节点高可用性队列可以在集群中的机器上设置镜像使得在部分节点出现问题时仍然可用支持多种协议除了原生支持 AMQP 协议还支持 STOMP、MQTT 等多种消息中间件协议。多语言客户端几乎支持所有常用语言易用的管理界面可用监控和管理消息、集群中的节点等插件机制 Pulsar集消息、存储、轻量化函数式计算为一体采用计算与存储分离架构设计支持多租户、持久化存储、多机房跨区域数据复制具有强一致性、高吞吐、低延时及高可扩展性等流数据存储特性被看作是云原生时代实时消息流传输、存储和计算最佳解决方案。 ActiveMQ被淘汰
对比方向
吞吐量万级的 ActiveMQ 和 RabbitMQ 的吞吐量ActiveMQ 的性能最差要比十万级甚至是百万级的 RocketMQ 和 Kafka 低一个数量级。可用性都可以实现高可用。ActiveMQ 和 RabbitMQ 都是基于主从架构实现高可用性。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默认、fanout、topic、headers。不同类型交换器转发消息的策略不同生产者发送消息给交换器时一般会指定一个RoutingKey路由键指定这个消息的路由规则这个 RoutingKey 需要与交换器类型和绑定键(BindingKey)联合使用才能最终生效。RabbitMQ 中通过 Binding(绑定) 将 Exchange(交换器) 与 Queue(消息队列) 关联起来在绑定的时候一般会指定一个 BindingKey(绑定建) 交换器和队列可以是多对多的关系生产者将消息发送给交换器时需要一个 RoutingKey,当 BindingKey 和 RoutingKey 相匹配时消息会被路由到对应的队列中在绑定多个队列到同一个交换器的时候这些绑定允许使用相同的 BindingKey。BindingKey 并不是在所有的情况下都生效它依赖于交换器类型比如 fanout 类型的交换器就会无视而是将消息路由到所有绑定到该交换器的队列中。 fanout会把发送到该交换器的消息发送到所有绑定的 Queue种广播消息最快 direct把消息路由到BindingKey和 RountingKey完全匹配的Queue常用于处理优先级的任务根据优先级把消息发送到对应的队列 topic将消息路由到BindingKey和 RountingKey 匹配的队列 . 号分隔字符串每一段独立的字符串称为一个单词BindingKey和 RountingKey都是点号 BindingKey可以存在 *和#用于模糊匹配星号匹配一个单词井号匹配多个单词可以是0个 headers不推荐不依赖路由键的匹配规则路由消息而是根据消息内容的headers属性进行匹配。在绑定队列和交换器时指定一组键值对当发送消息到交换器时RabbitMQ 会获取到该消息的 headers也是一个键值对的形式)对比其中的键值对是否完全匹配队列和交换器绑定时指定的键值对如果完全匹配则消息会路由到该队列否则不会路由到该队列。headers 类型的交换器性能会很差而且也不实用基本上不会看到它的存在。 Queue一个消息可投入一个或多个队列。多个消费者可以订阅同一个队列这时队列中的消息会被平均分摊Round-Robin即轮询给多个消费者进行处理而不是每个消费者都收到所有的消息并处理这样避免消息被重复消费。 Broker可以将Broker看作一台RabbitMQ服务器
AMQP
RabbitMQ 就是 AMQP 协议的 Erlang 的实现
AMQP 协议的三层
Module Layer:协议最高层主要定义了一些客户端调用的命令客户端可以用这些命令实现自己的业务逻辑。Session Layer:中间层主要负责客户端命令发送给服务器再将服务端应答返回客户端提供可靠性同步机制和错误处理。TransportLayer:最底层主要传输二进制数据流提供帧的处理、信道服用、错误检测和数据表示等。
AMQP 模型的三大组件
交换器 (Exchange)消息代理服务器中用于把消息路由到队列的组件。队列 (Queue)用来存储消息的数据结构位于硬盘或内存中。绑定 (Binding)一套规则告知交换器消息应该将消息投递给哪个队列。
消息堆积
生产者发送消息速度超过消费者消费消息速度队列中消息堆积直到上限之后消息成为死信可能被丢弃
解决
增加消费者在消费者开启线程池加快处理消费扩大队列容积声明队列时使用惰性队列
惰性队列
接收到消息后直接存入磁盘而不是内存消费时才从磁盘加载支持百万条消息存储
死信交换机、死信队列
DLX死信交换器死信邮箱。当消息在一个队列中变成死信消息之后能够被重新发送到另一个交换器中这个交换器就死信交换器绑定死信交换器的队列就是死信队列
导致死信的原因
消息被拒且 requeue false消息TTL过期队列满了无法添加最早消息可能成为死信
延迟队列
延迟队列指存储对应的延迟消息消息发送之后不想让消费者立刻拿到消息而是等待特定时间后消费者才拿到这个消息进行消费
RabbitMQ本身没有延迟队列实现延迟消息一般有两种方式
通过 RabbitMQ 本身队列的特性来实现需要使用 RabbitMQ 的死信交换机Exchange和消息的存活时间 TTLTime To Live。在 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没异常自动acknone关闭ack获取消息一定成功处理开启消费者失败重试多次重试失败后将消息投递到异常交换机
消息的重复消费、顺序性
每条消息设置一个唯一标识id幂等方案分布式锁
高可用
RabbitMQ基于主从非分布式做高可用
RabbitMQ有三种模式单机模式、普通集群模式、镜像集群模式 仲裁队列3.8后替代镜像队列都是主从同步基于Raft协议强一直 普通集群模式共享交换机、队列元信息可以有引用、不包含队列的消息访问集群某节点如果队列不在该节点就从数据所在节点传递到当前节点队列所在节点宕机队列中消息就丢失 镜像集群模式本质是主从模式消息会同步备份创建队列的节点叫该队列的主节点备份到其他节点叫做该队列的镜像节点一个队列的主节点可能是另一个队列的镜像节点所有操作都是主节点完成同步给镜像节点主节点宕机后镜像节点替代。可能丢失数据可以使用仲裁队列
延时和过期失效
RabbitMQ可以设置过期时间TTL。如果消息在队列积压超过TTL就会被RabbitMQ清理掉大量数据会直接丢失可以在过了高峰之后查出丢失的数据
假设 1 万个订单积压在 mq 里面没有处理其中 1000 个订单都丢了你只能手动写程序把那 1000 个订单给查出来手动发到 mq 里去再补一次。
Kafka
概述、CAP
分布式流处理平台
消息队列发布订阅消息流容错的持久方式存储记录消息流消息持久化磁盘避免消息丢失**流式处理平台**在消息发布的时候进行处理提供一个完整的流式处理类库
官方说是CA原因是Kafka设计是运行在一个数据中心网络分区问题基本不会发生所以是CA系统。
但现实中即使在一个数据中心还是会有分区问题
kafka可以通过一些配置满足AP或CP或平衡比如配置写入数据时等待同步到所有节点才返回ACKacksall这样满足CP如果配置写入数据主节点提交了直接返回ACK在给其他节点同步之前宕机后消费者读不到这条消息满足AP
可以配置ackall同时配置容忍一个节点宕机时强一致性和整体可用性
除了上面的几个常用配置项下面这个配置项也跟consistency和availability相关。这个配置项的作用是控制在所有节点宕机之后如果有一个节点之前不是在ISR列表里面启动起来之后是否可以成为leader。当设置成默认值false时表示不可以因为这个节点的数据很可能不是最新的如果它成为了主节点那么就可能导致一些数据丢失从而损失consistency但是却可以保证availability。如果设置成true则相反。这个配置项让用户可以基于自己的业务需要在consistency和availability之间做一个选择。
unclean.leader.election.enablefalse分区同步三个参数
replication.factor默认3即每个分区只有1个leader副本和2个follow副本acks必须要有多少个分区副本收到消息生产者才认为该消息是写入成功的 0不需要响应1只要leader副本接收到消息就响应ackallISR列表的副本全部收到消息才响应 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 提供元数据的管理的功能
broker注册zookeeper专门有进行Broker服务器列表的节点broker启动时会去zookeeper注册 在/brokers/ids下创建属于自己的节点记录ip和端口topic注册同一个topic的消息会被分成多个分区分布到多个broker上zookeeper维护分区和broker的对应关系比如创建了一个名字为 my-topic 的主题并且它有2个分区对应到 zookeeper 中会创建这些文件夹/brokers/topics/my-topic/Partitions/0、/brokers/topics/my-topic/Partitions/1负载均衡分区分布在不同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中有实现实现原理是将异步刷盘的流程进行阻塞减少刷盘间隔减少刷盘数据量大小。时间越短性能越差可靠性越好尽可能可靠 设置acksall默认1代表消息被leader接收就算发送成功all代表只有所有isr列表的副本全部收到消息才给生产者响应0代表写入消息之前不会等待服务器响应可能丢失设置replication.factor3保证每个分区至少有3个副本设置min.insync.replicas1消息至少被写入2个副本才算发送成功默认1设置unclean.leader.election.enablefalseleader故障时不会从follow选取和leader同步程度不够的选取新leader 消费者丢失消息消费者拉取分区消息后自动提交偏移量但还没消费消费者就挂掉了实际消费没消费但偏移量自动提交了 手动关闭自动提交偏移量每次在真正消费完消息再手动提交偏移量但可能重复消费如刚消费完没提交偏移量就挂掉了就还会再次消费commitsync同步提交偏移量
消息重复消费
原因消费后没提交偏移量
消费者宕机重启消息被消费但没提交自动提交之前有新的消费者加入或移除发生rebalance再次消费时消费者会根据提交的偏移量重复消费数据消息处理耗时或者消费者拉取消息太多会认为当前消费者死掉触发rebalance
解决
消费消息服务做幂等校验如redis的setmysql的主键关闭自动提交手动提交 处理完消息再提交可能没提交就挂掉可能重复提交拉取到消息就提交消息可能丢失允许消息延迟时使用这种然后用定时任务在业务不忙的时候做数据兜底
Rebalance
同一个消费者组分区的所有权改变机制重新均衡消费者消费。
触发时机
消费者组成员变化订阅的topic个数变化订阅topic的分区数变化
过程join和sync
join加入组所有成员向协调者发送加入组请求之后协调者选择一个消费者担任leader把组成员信息和订阅信息发给leadersync分配哪个消费者消费哪些主题下的哪些分区分配好后把信息发给协调者协调者收到分配方案之后会把结果发送给各个消费者
影响
可能重复消费消费者退出时没提交偏移量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让进程间歇性挂起、用户希望挂起
进程、线程间通信方式
进程间通信方式
管道就是内核里面的一串缓存半双工消息队列保存在内核中的消息链表发送数据会分成一个个独立的数据单元消息体存在用户态和内核态的数据拷贝开销共享内存虚拟地址映射到相同的物理内存进程可以直接读写共享内存不用复制或数据传输但共享内存使用需要同步和互斥操作信号量保护共享资源计数器实现进程间的互斥和同步信号计数器控制多个进程对共享资源的访问它常作为一种锁机制防止某进程正在访问共享资源时其他进程也访问该资源。Socket不同主机的进程通信
线程间通信方式
锁机制互斥锁、条件变量、读写锁允许多个线程同时读写互斥信号量信号
线程
CPU调度的基本单位线程之间可以并发执行各个线程可以共享资源缺点是线程崩溃时所属进程其他线程都会崩溃除了java
线程上下文切换时
如果不在同一个进程就是进程的上下文切换如果在同一个进程因为虚拟内存是共享的所以切换时虚拟内存这些资源不变只切换线程的私有数据开销更小
线程三种实现方式
用户线程每个进程有私有TCB线程控制块内核线程TCB在操作系统里轻量级线程LWP在内核支持用户线程和内核线程一对一
用户线程和内核线程是多对一、一对一、多对多
线程和进程比较
进程是资源分配的基本单位线程是CPU调度的基本单位进程拥有一个完整的资源线程只独享必不可少的资源如寄存器和栈线程也有就绪、阻塞、执行三种基本状态线程能减少并发执行的时间和空间开销
多线程互斥和同步
同步是并发线程可能需要互相等待互通消息这种相互制约的等待叫同步就是操作A应该在操作B之前执行等
互斥比如操作A和操作B不能在同一时刻执行
锁和信号量可以实现同步和互斥
任何时刻只能有一个线程操作缓冲区说明操作缓冲区是临界代码需要互斥缓冲区空时消费者必须等待生产者生成数据缓冲区满时生产者必须等待消费者取出数据。说明生产者和消费者需要同步。
死锁
两个线程都在等待对方释放锁
四个必要条件
互斥多个线程不能同时使用同一个资源持有并等待线程在等待其他资源时不会释放自己的资源不可剥夺线程持有的资源在自己使用完之前不能被其他线程获取环路等待死锁发生时两个线程获取资源的顺序构成了环形链
jstack可以检查
预防死锁 一次性申请所有资源 进程只获得运行初期需要的资源在运行过程中逐步释放分配已经使用完毕的资源再去请求新的资源 使用资源有序分配法破坏环路等待
避免死锁使用前判断只允许不会产生死锁的进程申请资源
如果一个进程的请求会导致死锁就不启动该进程如果一个进程的增加资源请求会导致死锁就拒绝该申请
进程、线程调度算法
单核CPU
先来先服务每次从就绪队列拿线程。对长作业好用于CPU繁忙最短作业优先这个算法选择具有最短执行时间的进程优先执行以最大程度地减少等待时间。然而这个算法通常需要预知每个进程的执行时间这在实际情况下很难实现。高响应比优先调度这个算法是基于优先级的调度算法但它考虑了等待时间。它选择具有最高响应比响应时间与服务时间之比的进程以优先执行等待时间较长的进程。时间片轮转每个进程被分配一个固定的时间片它们依次执行。如果一个进程在其时间片内没有完成执行它将被移到队列的末尾下一个进程获得执行的机会。优先级调度每个进程被分配一个优先级值。调度器始终选择具有最高优先级的进程进行执行。这可以是在进程创建时分配的静态优先级也可以是基于诸如CPU使用时间等因素的动态优先级。多级反馈队列这是一种混合调度算法它结合了轮转法和优先级调度。进程根据其行为和性能被放入不同的队列不同队列具有不同的优先级。进程可以在不同队列之间移动具体取决于其执行历史。
内存页面置换算法
缺页中断CPU访问的页面不在物理内存中会产生一个缺页中断请求操作系统把缺页调入物理内存
当出现缺页异常需调入新页面而内存已满时选择被置换的物理页面
最佳页面置换算法置换在未来最长时间不访问的页面先进先出置换算法选择内存驻留时间长的页面最近最久未使用的置换算法LRU最长时间没访问的页面时钟页面置换算法所有页面保存环形链表不断转最不常用算法LFU选择访问次数最少的页面淘汰
文件系统
每个文件有索引节点文件元信息文件唯一标识和目录项记录文件名字索引指针和其他目录项的层级关联关系
磁盘读写最小单位是扇区多个扇区组成一个逻辑块每次读写最小单位是逻辑块4kb也就是一次性读取8个扇区
虚拟文件系统用户层和文件系统层中间对用户提供统一接口
文件IO
缓冲与非缓冲IO根据是否利用标准库缓冲 缓冲IO利用标准库的缓存实现文件的加速访问标注库通过系统调用访问文件非缓冲IO直接通过系统调用访问文件不经过标准库缓存 直接与非直接IO根据是否利用操作系统缓存 直接IO不会发生内核缓存和用户程序之间数据复制直接经过文件系统访问磁盘非直接IO读操作时数据从内核缓冲拷贝给用户程序写操作时数据从用户程序拷贝给内核缓存再由内核决定什么时候写入数据到磁盘 内核缓存写入磁盘调用write后发现内核缓存过多主动调用sync内存紧张缓存超时 阻塞与非阻塞IO和同步与异步IO 阻塞IOread时线程被阻塞等待内核数据准备好把数据从内核缓冲区拷贝到用户缓冲区read才返回非阻塞IOread时数据没准备好立即返回应用程序不断轮询内核直到数据准备好再拷贝 IO多路复用用户可以在一个线程内同时处理多个socket的IO请求其实 阻塞非阻塞和多路复用都是同步调用因为在read时内核将数据从内核拷贝到用户应用程序都是同步的
网络系统
DMA直接内存访问
进行性IO和内存数据传输时数据搬运交给DMA控制器CPU去处理其他事务
本来read读的时候cpu将数据从磁盘缓冲区拷贝到内核缓冲区再把数据从内核缓冲区拷贝到用户缓冲区
使用DMA后由DMA把数据从磁盘缓冲区拷贝到内核缓冲区发送中断给CPUCPU再将数据从内核缓冲区拷贝到用户缓冲区
零拷贝
用户缓冲区没必要存在用DMA传输
mmapwritemmap替换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型数组长度32bit位代替fd的状态1代表就绪遍历时有就绪的话就再遍历返回就绪个数最后用户空间遍历找到就绪的读取 需要将fd集合拷贝到内核空间结束后再次拷贝回用户空间无法得知哪个fd就绪需要遍历整个fd最多监听1024字节 poll内核发现有事件就绪就把就绪的类型放到实际发生的事件类型未就绪就设置0pollfd数组所有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的FDET回把内核链表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按后端服务器的响应时间分配响应时间短的优先
# 配置上游服务器 默认轮询加 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选举
选举阶段节点都处于选举状态只要一个节点超过半数票数就可以当选准leader发现阶段follower和准leader通信同步follower最近接收的事务提议同步阶段利用leader前一阶段的最新提议同步集群所有副本之后准leader成为真正leader广播阶段集群正式提供服务leader可以广播消息
集群为什么是奇数台
3台最大允许宕机1台4台最大允许宕机1台所以奇数就可以
集群脑裂多台机器在不同机房机房间网络线路故障网络不通集群被割裂多个集群子集群各自选leader使用过半机制解决不可能产生2个leader
分布式/微服务
Cookie 和 Session
区别
对象不同cookie针对每个网站每个网站只能对应一个保存在客户端Session针对用户只有客户端能访问session在用户访问后自动消失存储数据大小cookie不超过4ksession存储在服务器上存储任意数据生命周期不同Cookie 可设置为长时间保持比如我们经常使用的默认登录功能Session 一般失效时间较短客户端关闭默认情况下或者 Session 超时都会失效。存储位置不同cookie保存在客户端session保存在服务端数据类型不同cookie的值只是字符串session的值是Object安全性不同cookie不安全
cookie 是不可跨域的每个 cookie 都会绑定单一的域名无法在别的域名下获取使用一级域名和二级域名之间是允许共享使用的靠的是 domain。
前端清空吗
流程
用户第一次请求服务器的时候服务器根据用户提交的相关信息创建对应的 Session请求返回时将此 Session 的唯一标识信息 SessionID 返回给浏览器浏览器接收到服务器返回的 SessionID 信息后会将此信息存入到 Cookie 中同时 Cookie 记录此 SessionID 属于哪个域名当用户第二次访问服务器的时候请求会自动判断此域名下是否存在 Cookie 信息如果存在自动将 Cookie 信息也发送给服务端服务端会从 Cookie 中获取 SessionID再根据 SessionID 查找对应的 Session 信息如果没有找到说明用户没有登录或者登录失效如果找到 Session 证明用户已经登录可执行后面操作。
token 和 JWT
Session 是一种记录服务器和客户端会话状态的机制使服务端有状态化可以记录会话信息耗费服务器资源。而 Token 是令牌访问资源接口API时所需要的资源凭证token存用户信息需要去查询数据库验证常用的cookie只能是按域名发送对应的cookie不能实现跨域的功能会遭受CSRF攻击、存储在客户端不安全。
session 是空间换时间token 是时间换空间。
服务器不用存储SessionJWT 认证可以有效避免 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时需要登录但没有登录跳转到CAS服务器SSO系统SSO系统也没登录弹出用户登录页SSO登录后将登录状态写入SSO的session浏览器写入SSO域下的cookieSSO系统登录后生成一个STServer Ticket跳转到系统1把ST传给系统1系统1拿到ST后向SSO发请求验证ST是否有效通过验证系统1就把登录状态写入session并设置系统1域的cookie
之后再访问系统1时就是登录的此时用户访问系统2系统2没登陆跳转到SSOSSO登录了所以直接生成ST返回系统2把发给系统2系统2拿到ST请求SSO是否有效成功就把登录状态写入session并在系统2域下写入cookie
系统1和系统2在不同域session不共享也没关系
分布式锁
不同主机访问共享资源需要互斥防止彼此干扰
数据库实现表的唯一约束成功插入数据代表获取到锁删除就是释放锁 优点操作简单缺点性能开销大 rediskey是唯一的value是线程的编号setnx 优点非阻塞性能好缺点运维成本高操作不好容易死锁 zookeeper每个客户端对方法加锁时在zookeeper上与该方法对应的指定节点的目录下生成一个唯一临时有序节点判断如果是有序节点最小的一个就算获取到锁释放锁时直接删除临时节点避免服务宕机导致锁无法释放的死锁问题 优点集群无单点问题可重入可避免锁无法释放缺点有性能瓶颈性能不如redis
CAP和BASE
CAP
分布式系统三个指标
Consistency一致性用户访问分布式系统的任意节点得到的数据一致Availability可用性用户访问集群任意健康节点必须能得到响应而不是超时或拒绝Partition tolerance分区容错性因为网络故障或其他原因导致分布式系统的部分节点和其他节点时区连接形成独立分区在集群出现分区时整个系统也要持续对外提供服务分布式系统能够在网络分区即节点之间无法相互通信的情况下继续正常运行此时可能数据不一致从节点分区独立未同步但可以先同步再提供服务保证一致性但同步要等待从节点此时不可用就没可用性所以没cap 分布式系统肯定需要网络连接分区p是必然的
BASE
对CAP的一种解决思路
Basically Available基本可用分布式系统出现故障时允许损失部分可用性保证核心可用Soft State软状态在一定时间内允许出现中间状态比如临时的不一致状态Eventually Consistent最终一致性无法保证强一致性但在软状态结束后最终达到数据一致
EurekaAP思想
ZookeeperCP思想
分布式事务
解决分布式事务的思想和模型
最终一致思想各分支事务分别执行并提交有不一致情况想办法回滚 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是服务端负载均衡客户端所有请求交给nginxnginx实现负载均衡转发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 提交记录名分离的HEADgit 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 、实现发送短信验证码功能
页面流程 具体代码如下
贴心小提示
具体逻辑上文已经分析我们仅仅只需要按照提示的逻辑写出代码即可。
发送验证码 Overridepublic Result sendCode(String phone, HttpSession session) {// 1.校验手机号if (RegexUtils.isPhoneInvalid(phone)) {// 2.如果不符合返回错误信息return Result.fail(手机号格式错误);}// 3.符合生成验证码String code RandomUtil.randomNumbers(6);// 4.保存验证码到 sessionsession.setAttribute(code,code);// 5.发送验证码log.debug(发送短信验证码成功验证码{}, code);// 返回okreturn Result.ok();}登录 Overridepublic 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后线程会找到用户想要访问的工程然后用这个线程转发到工程中的controllerservicedao中并且访问对应的DB在用户执行完请求后再统一返回再找到tomcat端的socket再将数据写回到用户端的socket完成请求和响应
通过以上讲解我们可以得知 每个用户其实对应都是去找tomcat线程池中的一个线程来完成工作的 使用完成后再进行回收既然每个请求都是独立的所以在每个用户去访问我们的工程时我们可以使用threadlocal来做到线程隔离每个线程操作自己的一份数据。
温馨小贴士关于threadlocal
如果小伙伴们看过threadLocal的源码你会发现在threadLocal中无论是他的put方法和他的get方法 都是先从获得当前用户的线程然后从线程中取出线程的成员变量map只要线程不一样map就不一样所以可以通过这种方式来做到线程隔离 拦截器代码
public class LoginInterceptor implements HandlerInterceptor {Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//1.获取sessionHttpSession session request.getSession();//2.获取session中的用户Object user session.getAttribute(user);//3.判断用户是否存在if(user null){//4.不存在拦截返回401状态码response.setStatus(401);return false;}//5.存在保存用户信息到ThreadlocalUserHolder.saveUser((User)user);//6.放行return true;}
}让拦截器生效
Configuration
public class MvcConfig implements WebMvcConfigurer {Resourceprivate StringRedisTemplate stringRedisTemplate;Overridepublic 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对象那么就能够避免这个尴尬的问题了
在登录方法处修改
//7.保存用户信息到session中
session.setAttribute(user, BeanUtils.copyProperties(user,UserDTO.class));在拦截器处
//5.存在保存用户信息到Threadlocal
UserHolder.saveUser((UserDTO) user);在UserHolder处将user对象换成UserDTO
public class UserHolder {private static final ThreadLocalUserDTO 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换成redisredis数据本身就是共享的就可以避免session共享的问题了 1.7 Redis代替session的业务流程
1.7.1、设计key的结构
首先我们要思考一下利用redis来存储数据那么到底使用哪种结构呢由于存入的数据比较简单我们可以考虑使用String或者是使用哈希如下图如果使用String同学们注意他的value用多占用一点空间如果使用哈希则他的value中只会存储他数据本身如果不是特别在意内存其实使用String就可以啦。 1.7.2、设计key的具体细节
所以我们可以使用String结构就是一个简单的keyvalue键值对的方式但是关于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代码
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);MapString, 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.返回tokenreturn Result.ok(token);
}1.9 解决状态登录刷新问题
1.9.1 初始方案思路总结
在这个方案中他确实可以使用对应路径的拦截同时刷新登录token令牌的存活时间但是现在这个拦截器他只是拦截需要被拦截的路径假设当前用户访问了一些不需要拦截的路径那么这个拦截器就不会生效所以此时令牌刷新的动作实际上就不会执行所以这个方案他是存在问题的 1.9.2 优化方案
既然之前的拦截器无法对不需要拦截的路径生效那么我们可以添加一个拦截器在第一个拦截器中拦截所有的路径把第二个拦截器做的事情放入到第一个拦截器中同时刷新令牌因为第一个拦截器有了threadLocal的数据所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可完成整体刷新功能。 1.9.3 代码
RefreshTokenInterceptor
public class RefreshTokenInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate stringRedisTemplate;}Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1.获取请求头中的tokenString token request.getHeader(authorization);if (StrUtil.isBlank(token)) {return true;}// 2.基于TOKEN获取redis中的用户String key LOGIN_USER_KEY token;MapObject, Object userMap stringRedisTemplate.opsForHash().entries(key);// 3.判断用户是否存在if (userMap.isEmpty()) {return true;}// 5.将查询到的hash数据转为UserDTOUserDTO userDTO BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);// 6.存在保存用户信息到 ThreadLocalUserHolder.saveUser(userDTO);// 7.刷新token有效期stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);// 8.放行return true;}Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {// 移除用户UserHolder.removeUser();}
}
LoginInterceptor
public class LoginInterceptor implements HandlerInterceptor {Overridepublic 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),就是数据交换的缓冲区,俗称的缓存就是缓冲区内的数据,一般从数据库中获取,存储于本地代码(例如:
例1:Static final ConcurrentHashMapK,V map new ConcurrentHashMap(); 本地用于高并发例2:static final CacheK,V USER_CACHE CacheBuilder.newBuilder().build(); 用于redis等缓存例3:Static final MapK,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的L1L2L3级的缓存 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的线程我们认为他就是获得到锁的线程。
private boolean tryLock(String key) {Boolean flag stringRedisTemplate.opsForValue().setIfAbsent(key, 1, 10, TimeUnit.SECONDS);return BooleanUtil.isTrue(flag);
}private void unlock(String key) {stringRedisTemplate.delete(key);
}操作代码 public Shop queryWithMutex(Long id) {String key CACHE_SHOP_KEY id;// 1、从redis中查询商铺缓存String shopJson stringRedisTemplate.opsForValue().get(key);// 2、判断是否存在if (StrUtil.isNotBlank(shopJson)) {// 存在,直接返回return JSONUtil.toBean(shopJson, Shop.class);}//判断命中的值是否是空值if (shopJson ! null) {//返回一个错误信息return null;}// 4.实现缓存重构//4.1 获取互斥锁String lockKey lock:shop: id;Shop shop null;try {boolean isLock tryLock(lockKey);// 4.2 判断否获取成功if(!isLock){//4.3 失败则休眠重试Thread.sleep(50);return queryWithMutex(id);}//4.4 成功根据id查询数据库shop getById(id);// 5.不存在返回错误if(shop null){//将空值写入redisstringRedisTemplate.opsForValue().set(key,,CACHE_NULL_TTL,TimeUnit.MINUTES);//返回错误信息return null;}//6.写入redisstringRedisTemplate.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
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查询缓存并反序列化为指定类型需要利用逻辑过期解决缓存击穿问题
将逻辑进行封装
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)));// 写入RedisstringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));}public R,ID R queryWithPassThrough(String keyPrefix, ID id, ClassR type, FunctionID, 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) {// 将空值写入redisstringRedisTemplate.opsForValue().set(key, , CACHE_NULL_TTL, TimeUnit.MINUTES);// 返回错误信息return null;}// 6.存在写入redisthis.set(key, r, time, unit);return r;}public R, ID R queryWithLogicalExpire(String keyPrefix, ID id, ClassR type, FunctionID, 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, ClassR type, FunctionID, 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) {// 将空值写入redisstringRedisTemplate.opsForValue().set(key, , CACHE_NULL_TTL, TimeUnit.MINUTES);// 返回错误信息return null;}// 6.存在写入redisthis.set(key, r, time, unit);} catch (InterruptedException e) {throw new RuntimeException(e);}finally {// 7.释放锁unlock(lockKey);}// 8.返回return r;}private boolean tryLock(String key) {Boolean flag stringRedisTemplate.opsForValue().setIfAbsent(key, 1, 10, TimeUnit.SECONDS);return BooleanUtil.isTrue(flag);}private void unlock(String key) {stringRedisTemplate.delete(key);}
}在ShopServiceImpl 中
Resource
private CacheClient cacheClient;Overridepublic 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
Component
public class RedisIdWorker {/*** 开始时间戳*/private static final long BEGIN_TIMESTAMP 1640995200L;/*** 序列号的位数*/private static final int COUNT_BITS 32;private StringRedisTemplate stringRedisTemplate;public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate stringRedisTemplate;}public long nextId(String keyPrefix) {// 1.生成时间戳LocalDateTime now LocalDateTime.now();long nowSecond now.toEpochSecond(ZoneOffset.UTC);long timestamp nowSecond - BEGIN_TIMESTAMP;// 2.生成序列号// 2.1.获取当前日期精确到天String date now.format(DateTimeFormatter.ofPattern(yyyy:MM:dd));// 2.2.自增长long count stringRedisTemplate.opsForValue().increment(icr: keyPrefix : date);// 3.拼接并返回return timestamp COUNT_BITS | count;}
}测试类
知识小贴士关于countdownlatch
countdownlatch名为信号枪主要的作用是同步协调在多线程的等待于唤醒问题
我们如果没有CountDownLatch 那么由于程序是异步的当异步程序没有执行完时主线程就已经执行完了然后我们期望的是分线程全部走完之后主线程再走所以我们此时需要使用到CountDownLatch
CountDownLatch 中有两个最重要的方法
1、countDown
2、await
await 方法 是阻塞方法我们担心分线程没有执行完时main线程就先执行所以使用await可以让main线程阻塞那么什么时候main线程不再阻塞呢当CountDownLatch 内部维护的 变量变为0时就不再阻塞直接放行那么什么时候CountDownLatch 维护的变量变为0 呢我们只需要调用一次countDown 内部变量就减少1我们让分线程和变量绑定 执行完一个分线程就减少一个变量当分线程全部走完CountDownLatch 维护的变量就是0此时await就不再阻塞统计出来的时间也就是所有分线程执行完后的时间。
Test
void testIdWorker() throws InterruptedException {CountDownLatch latch new CountDownLatch(300);Runnable task () - {for (int i 0; i 100; i) {long id redisIdWorker.nextId(order);System.out.println(id id);}latch.countDown();};long begin System.currentTimeMillis();for (int i 0; i 300; i) {es.submit(task);}latch.await();long end System.currentTimeMillis();System.out.println(time (end - begin));
}3.3 添加优惠卷
每个店铺都可以发布优惠券分为平价券和特价券。平价券可以任意购买而特价券需要秒杀抢购 tb_voucher优惠券的基本信息优惠金额、使用规则等 tb_seckill_voucher优惠券的库存、开始抢购时间结束抢购时间。特价优惠券才需要填写这些信息
平价卷由于优惠力度并不是很大所以是可以任意领取
而代金券由于优惠力度大所以像第二种卷就得限制数量从表结构上也能看出特价卷除了具有优惠卷的基本信息以外还具有库存抢购时间结束时间等等字段
**新增普通卷代码 **VoucherController
PostMapping
public Result addVoucher(RequestBody Voucher voucher) {voucherService.save(voucher);return Result.ok(voucher.getId());
}新增秒杀卷代码
VoucherController
PostMapping(seckill)
public Result addSeckillVoucher(RequestBody Voucher voucher) {voucherService.addSeckillVoucher(voucher);return Result.ok(voucher.getId());
}VoucherServiceImpl
Override
Transactional
public void addSeckillVoucher(Voucher voucher) {// 保存优惠券save(voucher);// 保存秒杀信息SeckillVoucher seckillVoucher new SeckillVoucher();seckillVoucher.setVoucherId(voucher.getId());seckillVoucher.setStock(voucher.getStock());seckillVoucher.setBeginTime(voucher.getBeginTime());seckillVoucher.setEndTime(voucher.getEndTime());seckillVoucherService.save(seckillVoucher);// 保存秒杀库存到Redis中stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY voucher.getId(), voucher.getStock().toString());
}3.4 实现秒杀下单
下单核心思路当我们点击抢购时会触发右侧的请求我们只需要编写对应的controller即可 秒杀下单应该思考的内容
下单时需要判断两点
秒杀是否开始或结束如果尚未开始或已经结束则无法下单库存是否充足不足则无法下单
下单核心逻辑分析
当用户开始进行下单我们应当去查询优惠卷信息查询到优惠卷信息判断是否满足秒杀条件
比如时间是否充足如果时间充足则进一步判断库存是否足够如果两者都满足则扣减库存创建订单然后返回订单id如果有一个条件不满足则直接结束。 VoucherOrderServiceImpl
Override
public Result seckillVoucher(Long voucherId) {// 1.查询优惠券SeckillVoucher voucher seckillVoucherService.getById(voucherId);// 2.判断秒杀是否开始if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {// 尚未开始return Result.fail(秒杀尚未开始);}// 3.判断秒杀是否已经结束if (voucher.getEndTime().isBefore(LocalDateTime.now())) {// 尚未开始return Result.fail(秒杀已经结束);}// 4.判断库存是否充足if (voucher.getStock() 1) {// 库存不足return Result.fail(库存不足);}//5扣减库存boolean success seckillVoucherService.update().setSql(stock stock -1).eq(voucher_id, voucherId).update();if (!success) {//扣减库存return Result.fail(库存不足);}//6.创建订单VoucherOrder voucherOrder new VoucherOrder();// 6.1.订单idlong orderId redisIdWorker.nextId(order);voucherOrder.setId(orderId);// 6.2.用户idLong userId UserHolder.getUser().getId();voucherOrder.setUserId(userId);// 6.3.代金券idvoucherOrder.setVoucherId(voucherId);save(voucherOrder);return Result.ok(orderId);}3.5 库存超卖问题分析
有关超卖问题分析在我们原有代码中是这么写的 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中的var1var2 是预估值如果预估值 内存值则代表中间没有被人修改过此时就将新值去替换 内存值
其中do while 是为了在操作失败时再次进行自旋操作即把之前的逻辑再操作一次。
int var5;
do {var5 this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 var4));return var5;课程中的使用方式
课程中的使用方式是没有像cas一样带自旋的操作也没有对version的版本号1 他的操作逻辑是在操作时对版本号进行1 操作然后要求version 如果是1 的情况下才能操作那么第一个线程在操作后数据库中的version变成了2但是他自己满足version1 所以没有问题此时线程2执行线程2 最后也需要加上条件version 1 但是现在由于线程1已经操作过了所以线程2操作时就不满足version1 的条件了所以线程2无法执行成功 3.6 乐观锁解决超卖问题
修改代码方案一、
VoucherOrderServiceImpl 在扣减库存时改为
boolean success seckillVoucherService.update().setSql(stock stock -1) //set stock stock -1.eq(voucher_id, voucherId).eq(stock,voucher.getStock()).update(); //where id and stock ?以上逻辑的核心含义是只要我扣减库存时的库存和之前我查询到的库存是一样的就意味着没有人在中间修改过库存那么此时就是安全的但是以上这种方式通过测试发现会有很多失败的情况失败的原因在于在使用乐观锁过程中假设100个线程同时都拿到了100的库存然后大家一起去进行扣减但是100个人中只有1个人能扣减成功其他的人在处理时他们在扣减时库存已经被修改过了所以此时其他线程都会失败
修改代码方案二、
之前的方式要修改前后都保持一致但是这样我们分析过成功的概率太低所以我们的乐观锁需要变一下改成stock大于0 即可
boolean success seckillVoucherService.update().setSql(stock stock -1).eq(voucher_id, voucherId).update().gt(stock,0); //where id ? and stock 0知识小扩展
针对cas中的自旋压力过大我们可以使用Longaddr这个类去解决
Java8 提供的一个对AtomicLong改进后的一个类LongAdder
大量线程并发更新一个原子性的时候天然的问题就是自旋会导致并发性问题当然这也比我们直接使用syn来的好
所以利用这么一个类LongAdder来进行优化
如果获取某个值则会对cell和base的值进行递增最后返回一个完整的值 3.6 优惠券秒杀-一人一单
需求修改秒杀业务要求同一个优惠券一个用户只能下一单
现在的问题在于
优惠卷是为了引流但是目前的情况是一个人可以无限制的抢这个优惠卷所以我们应当增加一层逻辑让一个用户只能下一个单而不是让一个用户下多个单
具体操作逻辑如下比如时间是否充足如果时间充足则进一步判断库存是否足够然后再根据优惠卷id和用户id查询是否已经下过这个订单如果下过这个订单则不再下单否则进行下单 VoucherOrderServiceImpl
初步代码增加一人一单逻辑
Override
public Result seckillVoucher(Long voucherId) {// 1.查询优惠券SeckillVoucher voucher seckillVoucherService.getById(voucherId);// 2.判断秒杀是否开始if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {// 尚未开始return Result.fail(秒杀尚未开始);}// 3.判断秒杀是否已经结束if (voucher.getEndTime().isBefore(LocalDateTime.now())) {// 尚未开始return Result.fail(秒杀已经结束);}// 4.判断库存是否充足if (voucher.getStock() 1) {// 库存不足return Result.fail(库存不足);}// 5.一人一单逻辑// 5.1.用户idLong 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.订单idlong orderId redisIdWorker.nextId(order);voucherOrder.setId(orderId);voucherOrder.setUserId(userId);// 7.3.代金券idvoucherOrder.setVoucherId(voucherId);save(voucherOrder);return Result.ok(orderId);}**存在问题**现在的问题还是和之前一样并发过来查询数据库都不存在订单所以我们还是需要加锁但是乐观锁比较适合更新数据而现在是插入数据所以我们需要使用悲观锁操作
**注意**在这里提到了非常多的问题我们需要慢慢的来思考首先我们的初始方案是封装了一个createVoucherOrder方法同时为了确保他线程安全在方法上添加了一把synchronized 锁
Transactional
public synchronized Result createVoucherOrder(Long voucherId) {Long userId UserHolder.getUser().getId();// 5.1.查询订单int count query().eq(user_id, userId).eq(voucher_id, voucherId).count();// 5.2.判断是否存在if (count 0) {// 用户已经购买过了return Result.fail(用户已经购买过一次);}// 6.扣减库存boolean success seckillVoucherService.update().setSql(stock stock - 1) // set stock stock - 1.eq(voucher_id, voucherId).gt(stock, 0) // where id ? and stock 0.update();if (!success) {// 扣减失败return Result.fail(库存不足);}// 7.创建订单VoucherOrder voucherOrder new VoucherOrder();// 7.1.订单idlong orderId redisIdWorker.nextId(order);voucherOrder.setId(orderId);// 7.2.用户idvoucherOrder.setUserId(userId);// 7.3.代金券idvoucherOrder.setVoucherId(voucherId);save(voucherOrder);// 7.返回订单idreturn Result.ok(orderId);
}但是这样添加锁锁的粒度太粗了在使用锁过程中控制锁粒度 是一个非常重要的事情因为如果锁的粒度太大会导致每个线程进来都会锁住所以我们需要去控制锁的粒度以下这段代码需要修改为 intern() 这个方法是从常量池中拿到数据如果我们直接使用userId.toString() 他拿到的对象实际上是不同的对象new出来的对象我们使用锁必须保证锁必须是同一把所以我们需要使用intern()方法
Transactional
public Result createVoucherOrder(Long voucherId) {Long userId UserHolder.getUser().getId();synchronized(userId.toString().intern()){// 5.1.查询订单int count query().eq(user_id, userId).eq(voucher_id, voucherId).count();// 5.2.判断是否存在if (count 0) {// 用户已经购买过了return Result.fail(用户已经购买过一次);}// 6.扣减库存boolean success seckillVoucherService.update().setSql(stock stock - 1) // set stock stock - 1.eq(voucher_id, voucherId).gt(stock, 0) // where id ? and stock 0.update();if (!success) {// 扣减失败return Result.fail(库存不足);}// 7.创建订单VoucherOrder voucherOrder new VoucherOrder();// 7.1.订单idlong orderId redisIdWorker.nextId(order);voucherOrder.setId(orderId);// 7.2.用户idvoucherOrder.setUserId(userId);// 7.3.代金券idvoucherOrder.setVoucherId(voucherId);save(voucherOrder);// 7.返回订单idreturn 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 、基本原理和实现方式对比
分布式锁满足分布式系统或集群模式下多进程可见并且互斥的锁。
分布式锁的核心思想就是让大家都使用同一把锁只要大家使用的是同一把锁那么我们就能锁住线程不让线程进行让程序串行执行这就是分布式锁的核心思路 那么分布式锁他应该满足一些什么样的条件呢
可见性多个线程都能看到相同的结果注意这个地方说的可见性并不是并发编程中指的内存可见性只是说多个进程之间都能感知到变化的意思
互斥互斥是分布式锁的最基本的条件使得程序串行执行
高可用程序不易崩溃时时刻刻都保证较高的可用性
高性能由于加锁本身就让性能降低所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能
安全性安全也是程序中必不可少的一环 常见的分布式锁有三种
Mysqlmysql本身就带有锁机制但是由于mysql性能本身一般所以采用分布式锁的情况下其实使用mysql作为分布式锁比较少见
Redisredis作为分布式锁是非常常见的一种使用方式现在企业级开发中基本都使用redis或者zookeeper作为分布式锁利用setnx这个方法如果插入key成功则表示获得到了锁如果有人插入成功其他人插入失败则表示无法获得到锁利用这套逻辑来实现分布式锁
Zookeeperzookeeper也是企业级开发中较好的一个实现分布式锁的方案由于本套视频并不讲解zookeeper的原理和分布式锁的实现所以不过多阐述 4.2 、Redis分布式锁的实现核心思路
实现分布式锁时需要实现的两个基本方法 获取锁 互斥确保只能有一个线程获取锁非阻塞尝试一次成功返回true失败返回false 释放锁 手动释放超时释放获取锁时添加一个超时时间
核心思路
我们利用redis 的setNx 方法当有多个线程进入时我们就利用该方法第一个线程进入时redis 中就有这个key 了返回了1如果结果是1则表示他抢到了锁那么他去执行业务然后再删除锁退出锁逻辑没有抢到锁的哥们等待一定时间后重试即可 4.3 实现分布式锁版本一
加锁逻辑
锁的基本接口 SimpleRedisLock
利用setnx方法进行加锁同时增加过期时间防止死锁此方法可以保证加锁和增加过期时间具有原子性
private static final String KEY_PREFIXlock:
Override
public boolean tryLock(long timeoutSec) {// 获取线程标示String threadId Thread.currentThread().getId()// 获取锁Boolean success stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX name, threadId , timeoutSec, TimeUnit.SECONDS);return Boolean.TRUE.equals(success);
}释放锁逻辑
SimpleRedisLock
释放锁防止删除别人的锁
public void unlock() {//通过del删除锁stringRedisTemplate.delete(KEY_PREFIX name);
}修改业务代码 Overridepublic 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表示 在释放锁时先获取锁中的线程标示判断是否与当前线程标示一致
如果一致则释放锁如果不一致则不释放锁
核心逻辑在存入锁时放入自己线程的标识在删除锁时判断当前这把锁的标识是不是自己存入的如果是则进行删除如果不是则不进行删除。 具体代码如下加锁
private static final String ID_PREFIX UUID.randomUUID().toString(true) -;
Override
public boolean tryLock(long timeoutSec) {// 获取线程标示String threadId ID_PREFIX Thread.currentThread().getId();// 获取锁Boolean success stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX name, threadId, timeoutSec, TimeUnit.SECONDS);return Boolean.TRUE.equals(success);
}释放锁
public void unlock() {// 获取线程标示String threadId ID_PREFIX Thread.currentThread().getId();// 获取锁中的标示String id stringRedisTemplate.opsForValue().get(KEY_PREFIX name);// 判断标示是否一致if(threadId.equals(id)) {// 释放锁stringRedisTemplate.delete(KEY_PREFIX name);}
}有关代码实操说明
在我们修改完此处代码后我们重启工程然后启动两个线程第一个线程持有锁后手动释放锁第二个线程 此时进入到锁内部再放行第一个线程此时第一个线程由于锁的value值并非是自己所以不能释放锁也就无法删除别人的锁此时第二个线程能够正确释放锁通过这个案例初步说明我们解决了锁误删的问题。
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提供的调用函数语法如下
redis.call(命令名称, key, 其它参数, ...)例如我们要执行set name jack则脚本是这样
# 执行 set name jack
redis.call(set, name, jack)例如我们要先执行set name Rose再执行get name则脚本如下
# 先执行 set name jack
redis.call(set, name, Rose)
# 再执行 get name
local name redis.call(get, name)
# 返回
return name写好脚本以后需要用Redis命令来调用脚本调用脚本的常见命令如下 例如我们要执行 redis.call(‘set’, ‘name’, ‘jack’) 这个脚本语法如下 如果脚本中的key、value不想写死可以作为参数传递。key类型参数会放入KEYS数组其它参数会放入ARGV数组在脚本中可以从KEYS和ARGV数组获取这些参数 接下来我们来回一下我们释放锁的逻辑
释放锁的业务流程是这样的
1、获取锁中的线程标示
2、判断是否与指定的标示当前线程标示一致
3、如果一致则释放锁删除
4、如果不一致则什么都不做
如果用Lua脚本来表示则是这样的
最终我们操作redis的拿锁比锁删锁的lua脚本就会变成这样
-- 这里的 KEYS[1] 就是锁的key这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示判断是否与当前线程标示一致
if (redis.call(GET, KEYS[1]) ARGV[1]) then-- 一致则删除锁return redis.call(DEL, KEYS[1])
end
-- 不一致则直接返回
return 04.8 利用Java代码调用Lua脚本改造分布式锁
lua脚本本身并不需要大家花费太多时间去研究只需要知道如何调用大致是什么意思即可所以在笔记中并不会详细的去解释这些lua表达式的含义。
我们的RedisTemplate中可以利用execute方法去执行lua脚本参数对应关系就如下图股 Java代码
private static final DefaultRedisScriptLong 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快速入门
引入依赖
dependencygroupIdorg.redisson/groupIdartifactIdredisson/artifactIdversion3.13.6/version
/dependency配置Redisson客户端
Configuration
public class RedissonConfig {Beanpublic RedissonClient redissonClient(){// 配置Config config new Config();config.useSingleServer().setAddress(redis://192.168.150.101:6379).setPassword(123321);// 创建RedissonClient对象return Redisson.create(config);}
}
如何使用Redission的分布式锁
Resource
private RedissionClient redissonClient;Test
void testRedisson() throws Exception{//获取锁(可重入)指定锁的名称RLock lock redissonClient.getLock(anyLock);//尝试获取锁参数分别是获取锁的最大等待时间(期间会重试)锁自动释放时间时间单位boolean isLock lock.tryLock(1,10,TimeUnit.SECONDS);//判断获取锁成功if(isLock){try{System.out.println(执行业务); }finally{//释放锁lock.unlock();}}}在 VoucherOrderServiceImpl
注入RedissonClient
Resource
private RedissonClient redissonClient;Override
public Result seckillVoucher(Long voucherId) {// 1.查询优惠券SeckillVoucher voucher seckillVoucherService.getById(voucherId);// 2.判断秒杀是否开始if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {// 尚未开始return Result.fail(秒杀尚未开始);}// 3.判断秒杀是否已经结束if (voucher.getEndTime().isBefore(LocalDateTime.now())) {// 尚未开始return Result.fail(秒杀已经结束);}// 4.判断库存是否充足if (voucher.getStock() 1) {// 库存不足return Result.fail(库存不足);}Long userId UserHolder.getUser().getId();//创建锁对象 这个代码不用了因为我们现在要使用分布式锁//SimpleRedisLock lock new SimpleRedisLock(order: userId, stringRedisTemplate);RLock lock redissonClient.getLock(lock:order: userId);//获取锁对象boolean isLock lock.tryLock();//加锁失败if (!isLock) {return Result.fail(不允许重复下单);}try {//获取代理对象(事务)IVoucherOrderService proxy (IVoucherOrderService) AopContext.currentProxy();return proxy.createVoucherOrder(voucherId);} finally {//释放锁lock.unlock();}}5.3 分布式锁-redission可重入锁原理
在Lock锁中他是借助于底层的一个voaltile的一个state变量来记录重入的状态的比如当前没有人持有这把锁那么state0假如有人持有这把锁那么state1如果持有这把锁的人再次持有这把锁那么state就会1 如果是对于synchronized而言他在c语言代码中会有一个count原理和state类似也是重入一次就加一释放一次就-1 直到减少成0 时表示当前这把锁没有被人持有。
在redission中我们的也支持支持可重入锁
在分布式锁中他采用hash结构用来存储锁其中大key表示表示这把锁是否存在用小key表示当前这把锁被哪个线程持有所以接下来我们一起分析一下当前的这个lua表达式
这个地方一共有3个参数
KEYS[1] 锁名称
ARGV[1] 锁失效时间
ARGV[2] id “:” threadId; 锁的小key
exists: 判断数据是否存在 name是lock是否存在,如果0就表示当前这把锁不存在
redis.call(‘hset’, KEYS[1], ARGV[2], 1);此时他就开始往redis里边去写数据 写成一个hash结构
Lock{
id “:” threadId : 1
}
如果当前这把锁存在则第一个条件不满足再判断
redis.call(‘hexists’, KEYS[1], ARGV[2]) 1
此时需要通过大key小key判断当前这把锁是否是属于自己的如果是自己的则进行
redis.call(‘hincrby’, KEYS[1], ARGV[2], 1)
将当前这个锁的value进行1 redis.call(‘pexpire’, KEYS[1], ARGV[1]); 然后再对其设置过期时间如果以上两个条件都不满足则表示当前这把锁抢锁失败最后返回pttl即为当前这把锁的失效时间
如果小伙帮们看了前边的源码 你会发现他会去判断当前这个方法的返回值是否为null如果是null则对应则前两个if对应的条件退出抢锁逻辑如果返回的不是null即走了第三个分支在源码处会进行while(true)的自旋抢锁。
if (redis.call(exists, KEYS[1]) 0) then redis.call(hset, KEYS[1], ARGV[2], 1); redis.call(pexpire, KEYS[1], ARGV[1]); return nil; end; if (redis.call(hexists, KEYS[1], ARGV[2]) 1) then redis.call(hincrby, KEYS[1], ARGV[2], 1); redis.call(pexpire, KEYS[1], ARGV[1]); return nil; end; return redis.call(pttl, KEYS[1]);5.4 分布式锁-redission锁重试和WatchDog机制
说明由于课程中已经说明了有关tryLock的源码解析以及其看门狗原理所以笔者在这里给大家分析lock()方法的源码解析希望大家在学习过程中能够掌握更多的知识
抢锁过程中获得当前线程通过tryAcquire进行抢锁该抢锁逻辑和之前逻辑相同
1、先判断当前这把锁是否存在如果不存在插入一把锁返回null
2、判断当前这把锁是否是属于当前线程如果是则返回null
所以如果返回是null则代表着当前这哥们已经抢锁完毕或者可重入完毕但是如果以上两个条件都不满足则进入到第三个条件返回的是锁的失效时间同学们可以自行往下翻一点点你能发现有个while( true) 再次进行tryAcquire进行抢锁
long threa